From fb4145cd1efde7983e884bc608c490b4e121d74c Mon Sep 17 00:00:00 2001 From: Guillaume Cornut Date: Thu, 10 Oct 2024 13:15:34 +0200 Subject: [PATCH 1/2] feat(slideshow): use `inert` on hidden slides --- CHANGELOG.md | 4 ++++ .../src/components/slideshow/useSlideFocusManagement.tsx | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 085a13a88c..8c65df294d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- `SlideShow`: use `inert` on hidden slides (on top of `aria-hidden` and `tabindex="-1"`) for greater semantic. + ## [3.9.3][] - 2024-10-09 ### Fixed diff --git a/packages/lumx-react/src/components/slideshow/useSlideFocusManagement.tsx b/packages/lumx-react/src/components/slideshow/useSlideFocusManagement.tsx index 50b9802211..2aef4dea7a 100644 --- a/packages/lumx-react/src/components/slideshow/useSlideFocusManagement.tsx +++ b/packages/lumx-react/src/components/slideshow/useSlideFocusManagement.tsx @@ -29,9 +29,10 @@ export const useSlideFocusManagement = ({ isSlideDisplayed, slideRef }: UseSlide * Display given slide to screen readers and, if focus was blocked, restore focus on elements. */ const enableSlide = () => { - // Hide from screen readers + element.removeAttribute('inert'); element.setAttribute('aria-hidden', 'false'); // Find elements we have blocked focus on + // (won't be necessary once "inert" gets sufficient browser support) element.querySelectorAll(`.${BLOCKED_FOCUS_CLASSNAME}`).forEach((focusableElement) => { focusableElement.removeAttribute('tabindex'); focusableElement.classList.remove(BLOCKED_FOCUS_CLASSNAME); @@ -42,6 +43,7 @@ export const useSlideFocusManagement = ({ isSlideDisplayed, slideRef }: UseSlide * Hide given slide from screen readers and block focus on all focusable elements within. */ const blockSlide = () => { + element.setAttribute('inert', ''); element.setAttribute('aria-hidden', 'true'); getFocusableElements(element).forEach((focusableElement) => { focusableElement.setAttribute('tabindex', '-1'); @@ -72,6 +74,7 @@ export const useSlideFocusManagement = ({ isSlideDisplayed, slideRef }: UseSlide }; // Create an observer instance linked to the callback function + // (won't be necessary once "inert" gets sufficient browser support) const observer = new MutationObserver(callback); if (element) { From a22afaba17b0e15778a039cb8207a2b3b1fcd111 Mon Sep 17 00:00:00 2001 From: Guillaume Cornut Date: Mon, 21 Oct 2024 16:07:07 +0200 Subject: [PATCH 2/2] feat(slideshow): rework keyboard & focus navigation --- CHANGELOG.md | 7 + .../internal/ImageSlideshow.tsx | 2 + .../src/components/slideshow/Slides.tsx | 22 ++- .../slideshow/Slideshow.stories.tsx | 13 +- .../src/components/slideshow/Slideshow.tsx | 18 +-- .../slideshow/SlideshowControls.stories.tsx | 5 +- .../slideshow/SlideshowControls.tsx | 136 ++++++++--------- .../components/slideshow/SlideshowItem.tsx | 11 +- .../slideshow/SlideshowItemGroup.tsx | 49 ++---- .../src/components/slideshow/constants.ts | 4 + .../slideshow/useSlideFocusManagement.tsx | 139 ++++++++++-------- .../slideshow}/useSlideshowControls.ts | 129 ++++++++-------- 12 files changed, 267 insertions(+), 268 deletions(-) rename packages/lumx-react/src/{hooks => components/slideshow}/useSlideshowControls.ts (65%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c65df294d..8db1a23bbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - `SlideShow`: use `inert` on hidden slides (on top of `aria-hidden` and `tabindex="-1"`) for greater semantic. +- `SlideShow`: reworked keyboard & focus management + - Pagination items do not use roving tabindex anymore + - Roles `tab` & `tabpanel` are not used anymore + - Switching slides moves the focus + - On the slide if it contains multiple focusable element + - On the focusable element if the slide contains exactly one focusable element + - On the pagination item otherwise ## [3.9.3][] - 2024-10-09 diff --git a/packages/lumx-react/src/components/image-lightbox/internal/ImageSlideshow.tsx b/packages/lumx-react/src/components/image-lightbox/internal/ImageSlideshow.tsx index 27534ffaee..93f3d52ca6 100644 --- a/packages/lumx-react/src/components/image-lightbox/internal/ImageSlideshow.tsx +++ b/packages/lumx-react/src/components/image-lightbox/internal/ImageSlideshow.tsx @@ -31,6 +31,7 @@ export const ImageSlideshow: React.FC = ({ activeIndex, slideshowId, setSlideshow, + slideshow, slideshowSlidesId, slidesCount, onNextClick, @@ -61,6 +62,7 @@ export const ImageSlideshow: React.FC = ({ onNextClick={onNextClick} onPreviousClick={onPreviousClick} onPaginationClick={onPaginationClick} + parentRef={slideshow} {...slideshowControlsProps} paginationItemProps={(index: number) => { const props = slideshowControlsProps?.paginationItemProps?.(index) || {}; diff --git a/packages/lumx-react/src/components/slideshow/Slides.tsx b/packages/lumx-react/src/components/slideshow/Slides.tsx index 0b9ccf87a8..bf14db91e6 100644 --- a/packages/lumx-react/src/components/slideshow/Slides.tsx +++ b/packages/lumx-react/src/components/slideshow/Slides.tsx @@ -3,9 +3,11 @@ import chunk from 'lodash/chunk'; import classNames from 'classnames'; -import { FULL_WIDTH_PERCENT } from '@lumx/react/components/slideshow/constants'; +import { FULL_WIDTH_PERCENT, NEXT_SLIDE_EVENT, PREV_SLIDE_EVENT } from '@lumx/react/components/slideshow/constants'; import { Comp, GenericProps, HasTheme } from '@lumx/react/utils/type'; import { getRootClassName, handleBasicClasses } from '@lumx/react/utils/className'; +import { useMergeRefs } from '@lumx/react/utils/mergeRefs'; +import { useKeyNavigate } from '@lumx/react/components/slideshow/useKeyNavigate'; import { buildSlideShowGroupId, SlideshowItemGroup } from './SlideshowItemGroup'; export interface SlidesProps extends GenericProps, HasTheme { @@ -32,7 +34,7 @@ export interface SlidesProps extends GenericProps, HasTheme { /** * Accessible label to set on a slide group. * Receives the group position starting from 1 and the total number of groups. - * */ + */ slideGroupLabel?: (groupPosition: number, groupTotal: number) => string; } @@ -70,7 +72,6 @@ export const Slides: Comp = forwardRef((props, ref) slideGroupLabel, ...forwardedProps } = props; - const wrapperRef = React.useRef(null); const startIndexVisible = activeIndex; const endIndexVisible = startIndexVisible + 1; @@ -82,10 +83,17 @@ export const Slides: Comp = forwardRef((props, ref) return groupBy && groupBy > 1 ? chunk(childrenArray, groupBy) : childrenArray; }, [children, groupBy]); + const slidesRef = React.useRef(null); + + const slide = slidesRef.current; + const onNextSlide = React.useCallback(() => slide?.dispatchEvent(new CustomEvent(NEXT_SLIDE_EVENT)), [slide]); + const onPrevSlide = React.useCallback(() => slide?.dispatchEvent(new CustomEvent(PREV_SLIDE_EVENT)), [slide]); + useKeyNavigate(slide, onNextSlide, onPrevSlide); + return (
= forwardRef((props, ref) onMouseLeave={toggleAutoPlay} aria-live={isAutoPlaying ? 'off' : 'polite'} > -
+
{groups.map((group, index) => ( = startIndexVisible && index < endIndexVisible} + slidesRef={slidesRef} > {group} diff --git a/packages/lumx-react/src/components/slideshow/Slideshow.stories.tsx b/packages/lumx-react/src/components/slideshow/Slideshow.stories.tsx index 2f6630070c..f9fbcc0447 100644 --- a/packages/lumx-react/src/components/slideshow/Slideshow.stories.tsx +++ b/packages/lumx-react/src/components/slideshow/Slideshow.stories.tsx @@ -1,6 +1,7 @@ +/* eslint-disable jsx-a11y/anchor-is-valid */ import React from 'react'; import range from 'lodash/range'; -import { AspectRatio, Button, FlexBox, ImageBlock, Slideshow, SlideshowItem, Orientation } from '@lumx/react'; +import { AspectRatio, Button, FlexBox, ImageBlock, Slideshow, SlideshowItem, Orientation, Link } from '@lumx/react'; import { IMAGES, LANDSCAPE_IMAGES } from '@lumx/react/stories/controls/image'; export default { @@ -65,6 +66,16 @@ export const ResponsiveSlideShowSwipe = () => { }} slideGroupLabel={(currentGroup, totalGroup) => `${currentGroup} of ${totalGroup}`} > + + + A link + + + {slides.map((slide) => ( { /** current slide active */ - activeIndex?: SlidesProps['activeIndex']; + activeIndex?: number; /** Interval between each slide when automatic rotation is enabled. */ interval?: number; /** Props to pass to the slideshow controls (minus those already set by the Slideshow props). */ @@ -134,27 +133,14 @@ export const Slideshow: Comp = forwardRef((props parentRef={slideshow} theme={theme} isAutoPlaying={isAutoPlaying} - nextButtonProps={{ - 'aria-controls': slideshowSlidesId, - ...slideshowControlsProps.nextButtonProps, - }} - previousButtonProps={{ - 'aria-controls': slideshowSlidesId, - ...slideshowControlsProps.previousButtonProps, - }} playButtonProps={ autoPlay ? { - 'aria-controls': slideshowSlidesId, onClick: toggleForcePause, ...slideshowControlsProps.playButtonProps, } : undefined } - paginationItemProps={(index) => ({ - 'aria-controls': buildSlideShowGroupId(slideshowSlidesId, index), - ...slideshowControlsProps.paginationItemProps?.(index), - })} />
) : undefined diff --git a/packages/lumx-react/src/components/slideshow/SlideshowControls.stories.tsx b/packages/lumx-react/src/components/slideshow/SlideshowControls.stories.tsx index 8e497dad9b..d0588a8922 100644 --- a/packages/lumx-react/src/components/slideshow/SlideshowControls.stories.tsx +++ b/packages/lumx-react/src/components/slideshow/SlideshowControls.stories.tsx @@ -78,11 +78,10 @@ export const ControllingSlideshow = ({ images = Object.values(LANDSCAPE_IMAGES), parentRef={slideshow} theme={theme} isAutoPlaying={isAutoPlaying} - nextButtonProps={{ label: 'Next', 'aria-controls': slideshowSlidesId }} - previousButtonProps={{ label: 'Previous', 'aria-controls': slideshowSlidesId }} + nextButtonProps={{ label: 'Next' }} + previousButtonProps={{ label: 'Previous' }} playButtonProps={{ label: 'Play/Pause', - 'aria-controls': slideshowSlidesId, onClick: toggleForcePause, }} paginationItemLabel={(index) => `Slide ${index}`} diff --git a/packages/lumx-react/src/components/slideshow/SlideshowControls.tsx b/packages/lumx-react/src/components/slideshow/SlideshowControls.tsx index 9360c36b99..851754323c 100644 --- a/packages/lumx-react/src/components/slideshow/SlideshowControls.tsx +++ b/packages/lumx-react/src/components/slideshow/SlideshowControls.tsx @@ -1,16 +1,18 @@ -import React, { forwardRef, RefObject, useCallback, useMemo } from 'react'; +import React, { forwardRef, RefObject, useCallback, useState } from 'react'; import classNames from 'classnames'; import range from 'lodash/range'; import { mdiChevronLeft, mdiChevronRight, mdiPlayCircleOutline, mdiPauseCircleOutline } from '@lumx/icons'; -import { Emphasis, IconButton, IconButtonProps, Theme } from '@lumx/react'; +import { Emphasis, IconButton, IconButtonProps, Slides, Theme } from '@lumx/react'; import { Comp, GenericProps, HasTheme } from '@lumx/react/utils/type'; import { getRootClassName, handleBasicClasses } from '@lumx/react/utils/className'; import { WINDOW } from '@lumx/react/constants'; -import { useSlideshowControls, DEFAULT_OPTIONS } from '@lumx/react/hooks/useSlideshowControls'; -import { useRovingTabIndex } from '@lumx/react/hooks/useRovingTabIndex'; +import { useKeyNavigate } from '@lumx/react/components/slideshow/useKeyNavigate'; +import { useMergeRefs } from '@lumx/react/utils/mergeRefs'; +import { buildSlideShowGroupId } from '@lumx/react/components/slideshow/SlideshowItemGroup'; +import { DEFAULT_OPTIONS, useSlideshowControls } from './useSlideshowControls'; import { useSwipeNavigate } from './useSwipeNavigate'; import { PAGINATION_ITEM_SIZE, PAGINATION_ITEMS_MAX } from './constants'; import { usePaginationVisibleRange } from './usePaginationVisibleRange'; @@ -34,11 +36,11 @@ export interface SlideshowControlsProps extends GenericProps, HasTheme { /** Number of slides. */ slidesCount: number; /** On next button click callback. */ - onNextClick?(loopback?: boolean): void; + onNextClick?(loopBack?: boolean): void; /** On pagination change callback. */ onPaginationClick?(index: number): void; /** On previous button click callback. */ - onPreviousClick?(loopback?: boolean): void; + onPreviousClick?(loopBack?: boolean): void; /** whether the slideshow is currently playing */ isAutoPlaying?: boolean; /** @@ -100,7 +102,7 @@ const InternalSlideshowControls: Comp = ...forwardedProps } = props; - let parent; + let parent: HTMLElement | null | undefined; if (WINDOW) { // Checking window object to avoid errors in SSR. parent = parentRef instanceof HTMLElement ? parentRef : parentRef?.current; @@ -109,33 +111,30 @@ const InternalSlideshowControls: Comp = // Listen to touch swipe navigate left & right. useSwipeNavigate( parent, - // Go next without loopback. + // Go next without loop back. useCallback(() => onNextClick?.(false), [onNextClick]), - // Go previous without loopback. + // Go previous without loop back. useCallback(() => onPreviousClick?.(false), [onPreviousClick]), ); - /** - * Add roving tab index pattern to pagination items and activate slide on focus. - */ - useRovingTabIndex({ - parentRef: paginationRef, - elementSelector: 'button', - keepTabIndex: true, - onElementFocus: (element) => { - element.click(); - }, - }); + const [focusedIndex, setFocusedIndex] = useState(null); + const onButtonFocus = useCallback((index: number) => () => setFocusedIndex(index), [setFocusedIndex]); + const onFocusOut = useCallback(() => setFocusedIndex(null), [setFocusedIndex]); // Pagination "bullet" range. - const visibleRange = usePaginationVisibleRange(activeIndex as number, slidesCount); + const visibleRange = usePaginationVisibleRange(focusedIndex ?? (activeIndex as number), slidesCount); // Inline style of wrapper element. const wrapperStyle = { transform: `translateX(-${PAGINATION_ITEM_SIZE * visibleRange.min}px)` }; + const controlsRef = React.useRef(null); + useKeyNavigate(controlsRef.current, onNextClick, onPreviousClick); + + const slideshowSlidesId = React.useMemo(() => parent?.querySelector(`.${Slides.className}__slides`)?.id, [parent]); + return (
PAGINATION_ITEMS_MAX, @@ -148,64 +147,53 @@ const InternalSlideshowControls: Comp = color={theme === Theme.dark ? 'light' : 'dark'} emphasis={Emphasis.low} onClick={onPreviousClick} + aria-controls={slideshowSlidesId} /> +
- {useMemo( - () => - range(slidesCount).map((index) => { - const isOnEdge = - index !== 0 && - index !== slidesCount - 1 && - (index === visibleRange.min || index === visibleRange.max); - const isActive = activeIndex === index; - const isOutRange = index < visibleRange.min || index > visibleRange.max; - const { - className: itemClassName = undefined, - label = undefined, - ...itemProps - } = paginationItemProps ? paginationItemProps(index) : {}; - - const ariaLabel = - label || paginationItemLabel?.(index) || `${index + 1} / ${slidesCount}`; - - return ( -
@@ -216,6 +204,7 @@ const InternalSlideshowControls: Comp = className={`${CLASSNAME}__play`} color={theme === Theme.dark ? 'light' : 'dark'} emphasis={Emphasis.low} + aria-controls={slideshowSlidesId} /> ) : null} @@ -226,6 +215,7 @@ const InternalSlideshowControls: Comp = color={theme === Theme.dark ? 'light' : 'dark'} emphasis={Emphasis.low} onClick={onNextClick} + aria-controls={slideshowSlidesId} />
); diff --git a/packages/lumx-react/src/components/slideshow/SlideshowItem.tsx b/packages/lumx-react/src/components/slideshow/SlideshowItem.tsx index 2b7c52afa6..1a613114f9 100644 --- a/packages/lumx-react/src/components/slideshow/SlideshowItem.tsx +++ b/packages/lumx-react/src/components/slideshow/SlideshowItem.tsx @@ -33,16 +33,7 @@ const CLASSNAME = getRootClassName(COMPONENT_NAME); export const SlideshowItem: Comp = forwardRef((props, ref) => { const { className, children, ...forwardedProps } = props; return ( -
+
{children}
); diff --git a/packages/lumx-react/src/components/slideshow/SlideshowItemGroup.tsx b/packages/lumx-react/src/components/slideshow/SlideshowItemGroup.tsx index 2841b364ed..9267edb46e 100644 --- a/packages/lumx-react/src/components/slideshow/SlideshowItemGroup.tsx +++ b/packages/lumx-react/src/components/slideshow/SlideshowItemGroup.tsx @@ -1,19 +1,17 @@ -import React, { forwardRef } from 'react'; +import React from 'react'; -import classNames from 'classnames'; -import { mergeRefs } from '@lumx/react/utils/mergeRefs'; +import { getRootClassName } from '@lumx/react/utils/className'; -import { Comp, GenericProps } from '@lumx/react/utils/type'; -import { getRootClassName, handleBasicClasses } from '@lumx/react/utils/className'; import { useSlideFocusManagement } from './useSlideFocusManagement'; /** * Defines the props of the component. */ -export interface SlideshowItemGroupProps extends GenericProps { - role?: 'tabpanel' | 'group'; +export interface SlideshowItemGroupProps { + id?: string; label?: string; isDisplayed?: boolean; + slidesRef?: React.RefObject; } /** @@ -26,39 +24,24 @@ const COMPONENT_NAME = 'SlideshowItemGroup'; */ export const CLASSNAME = getRootClassName(COMPONENT_NAME); -export const buildSlideShowGroupId = (slidesId: string, index: number) => `${slidesId}-slide-${index}`; +export const buildSlideShowGroupId = (slidesId: string | undefined, index: number) => + slidesId && `${slidesId}-slide-${index}`; /** - * SlideshowItemGroup component. - * - * @param props Component props. - * @param ref Component ref. - * @return React element. + * Internal slideshow item group component. */ -export const SlideshowItemGroup: Comp = forwardRef((props, ref) => { - const { className, children, role = 'group', label, isDisplayed, ...forwardedProps } = props; - const groupRef = React.useRef(null); +export const SlideshowItemGroup: React.FC = (props) => { + const { id, children, label, isDisplayed, slidesRef } = props; - useSlideFocusManagement({ isSlideDisplayed: isDisplayed, slideRef: groupRef }); + const groupRef = useSlideFocusManagement({ + isSlideDisplayed: isDisplayed, + slidesRef, + }); return ( -
+
{children}
); -}); - +}; SlideshowItemGroup.displayName = COMPONENT_NAME; -SlideshowItemGroup.className = CLASSNAME; diff --git a/packages/lumx-react/src/components/slideshow/constants.ts b/packages/lumx-react/src/components/slideshow/constants.ts index 49434a627e..2cfa4ee38a 100644 --- a/packages/lumx-react/src/components/slideshow/constants.ts +++ b/packages/lumx-react/src/components/slideshow/constants.ts @@ -22,3 +22,7 @@ export const PAGINATION_ITEMS_MAX = 5; * Size of a pagination item. Used to translate wrapper. */ export const PAGINATION_ITEM_SIZE = 12; + + +export const NEXT_SLIDE_EVENT = 'lumx-next-slide-event'; +export const PREV_SLIDE_EVENT = 'lumx-prev-slide-event'; diff --git a/packages/lumx-react/src/components/slideshow/useSlideFocusManagement.tsx b/packages/lumx-react/src/components/slideshow/useSlideFocusManagement.tsx index 2aef4dea7a..ec547b5b2d 100644 --- a/packages/lumx-react/src/components/slideshow/useSlideFocusManagement.tsx +++ b/packages/lumx-react/src/components/slideshow/useSlideFocusManagement.tsx @@ -3,93 +3,102 @@ import { getFocusableElements } from '@lumx/react/utils/focus/getFocusableElemen export interface UseSlideFocusManagementProps { isSlideDisplayed?: boolean; - slideRef: React.RefObject; + slidesRef?: React.RefObject; } /** - * Classname set on elements whose focus was blocked. + * Data attribute set on elements whose focus was blocked. * This is to easily find elements that have been tempered with, * and not elements whose focus was already initially blocked. - * */ -const BLOCKED_FOCUS_CLASSNAME = 'focus-blocked'; + */ +const BLOCKED_FOCUS = 'data-focus-blocked'; /** * Manage how slides must behave when visible or not. * When not visible, they should be hidden from screen readers and not focusable. */ -export const useSlideFocusManagement = ({ isSlideDisplayed, slideRef }: UseSlideFocusManagementProps) => { - useEffect(() => { - const element = slideRef?.current; +export const useSlideFocusManagement = ({ isSlideDisplayed, slidesRef }: UseSlideFocusManagementProps) => { + const [slide, setSlide] = React.useState(null); - if (!element) { + const [focusableElementSet, setFocusableElementSet] = React.useState>(); + React.useEffect(() => { + if (!slide) { return undefined; } - - /** - * Display given slide to screen readers and, if focus was blocked, restore focus on elements. - */ - const enableSlide = () => { - element.removeAttribute('inert'); - element.setAttribute('aria-hidden', 'false'); - // Find elements we have blocked focus on - // (won't be necessary once "inert" gets sufficient browser support) - element.querySelectorAll(`.${BLOCKED_FOCUS_CLASSNAME}`).forEach((focusableElement) => { - focusableElement.removeAttribute('tabindex'); - focusableElement.classList.remove(BLOCKED_FOCUS_CLASSNAME); - }); - }; - - /** - * Hide given slide from screen readers and block focus on all focusable elements within. - */ - const blockSlide = () => { - element.setAttribute('inert', ''); - element.setAttribute('aria-hidden', 'true'); - getFocusableElements(element).forEach((focusableElement) => { - focusableElement.setAttribute('tabindex', '-1'); - focusableElement.classList.add(BLOCKED_FOCUS_CLASSNAME); + // Update the slide's focusable element list (including the blocked elements) + const updateFocusableElements = () => + setFocusableElementSet((set = new Set()) => { + // TODO: remove when `inert` gets sufficient browser support + const focusedBlocked = Array.from(slide.querySelectorAll(`[${BLOCKED_FOCUS}]`)) as HTMLElement[]; + for (const element of focusedBlocked) { + set.add(element); + } + for (const element of getFocusableElements(slide)) { + set.add(element); + } + return set; }); - }; - const handleDisplay = () => { - if (!element) { - return; + // Observe changes in the content of the slide + const observer = new MutationObserver((mutationsList) => { + if (mutationsList.some((mutation) => mutation.type === 'childList')) { + updateFocusableElements(); } - if (isSlideDisplayed) { - enableSlide(); - } else { - blockSlide(); + }); + + updateFocusableElements(); + + observer.observe(slide, { attributes: true, childList: true, subtree: true }); + return observer.disconnect(); + }, [slide]); + + useEffect(() => { + if (!slide || !focusableElementSet) { + return; + } + const focusableElements = Array.from(focusableElementSet); + + if (!isSlideDisplayed) { + /* Block slide */ + slide.setAttribute('inert', ''); + slide.setAttribute('aria-hidden', 'true'); + + // TODO: remove when `inert` gets sufficient browser support + for (const focusableElement of focusableElements) { + focusableElement.setAttribute('tabindex', '-1'); + focusableElement.setAttribute(BLOCKED_FOCUS, ''); } - }; - - // Callback function to execute when mutations are observed - const callback: MutationCallback = (mutationsList) => { - if (element) { - for (const mutation of mutationsList) { - if (mutation.type === 'childList') { - handleDisplay(); - } - } + } else { + /* Un-block slide */ + slide.removeAttribute('inert'); + slide.removeAttribute('aria-hidden'); + + // TODO: remove when `inert` gets sufficient browser support + for (const focusableElement of focusableElements) { + focusableElement.removeAttribute('tabindex'); + focusableElement.removeAttribute(BLOCKED_FOCUS); } - }; - // Create an observer instance linked to the callback function - // (won't be necessary once "inert" gets sufficient browser support) - const observer = new MutationObserver(callback); + // Change focus on slide displayed + const isUserActivated = slidesRef?.current?.dataset.lumxUserActivated === 'true'; + if (isUserActivated) { + let elementToFocus: HTMLElement | undefined = slide; - if (element) { - handleDisplay(); + // We have exactly one focusable element => focus it + if (focusableElementSet.size === 1) { + // eslint-disable-next-line prefer-destructuring + elementToFocus = focusableElements[0]; + } - /** If slide is hidden, start observing for elements to block focus */ - if (!isSlideDisplayed) { - observer.observe(element, { attributes: true, childList: true, subtree: true }); + // We have not focusable element => focus the pagination item + if (focusableElementSet.size === 0) { + elementToFocus = document.querySelector(`[aria-controls="${slide?.id}"]`) as HTMLElement; + } + + elementToFocus?.focus({ preventScroll: true }); } } + }, [focusableElementSet, isSlideDisplayed, slide, slidesRef]); - return () => { - if (!isSlideDisplayed) { - observer.disconnect(); - } - }; - }, [isSlideDisplayed, slideRef]); + return setSlide; }; diff --git a/packages/lumx-react/src/hooks/useSlideshowControls.ts b/packages/lumx-react/src/components/slideshow/useSlideshowControls.ts similarity index 65% rename from packages/lumx-react/src/hooks/useSlideshowControls.ts rename to packages/lumx-react/src/components/slideshow/useSlideshowControls.ts index 3192419bb2..2228fcd3af 100644 --- a/packages/lumx-react/src/hooks/useSlideshowControls.ts +++ b/packages/lumx-react/src/components/slideshow/useSlideshowControls.ts @@ -1,9 +1,10 @@ -import { useState, useCallback, useEffect } from 'react'; - +import { SetStateAction, useCallback, useEffect, useState } from 'react'; +import { clamp } from '@lumx/react'; import { useInterval } from '@lumx/react/hooks/useInterval'; -import { AUTOPLAY_DEFAULT_INTERVAL } from '@lumx/react/components/slideshow/constants'; import { useId } from '@lumx/react/hooks/useId'; +import { AUTOPLAY_DEFAULT_INTERVAL, NEXT_SLIDE_EVENT, PREV_SLIDE_EVENT } from './constants'; + export interface UseSlideshowControlsOptions { /** default active index to be displayed */ defaultActiveIndex?: number; @@ -43,9 +44,9 @@ export interface UseSlideshowControls { /** id to be used for the wrapper that contains the slides */ slideshowSlidesId: string; /** callback that triggers the previous slide while using the slideshow controls */ - onPreviousClick: (loopback: boolean) => void; + onPreviousClick: (loopBack: boolean) => void; /** callback that triggers the next slide while using the slideshow controls */ - onNextClick: (loopback: boolean) => void; + onNextClick: (loopBack: boolean) => void; /** callback that triggers a specific page while using the slideshow controls */ onPaginationClick: (index: number) => void; /** whether the slideshow is autoplaying or not */ @@ -54,16 +55,18 @@ export interface UseSlideshowControls { isForcePaused: boolean; /** callback to change whether the slideshow is autoplaying or not */ toggleAutoPlay: () => void; - /** calback to change whether the slideshow should be force paused or not */ + /** callback to change whether the slideshow should be force paused or not */ toggleForcePause: () => void; /** current active slide index */ activeIndex: number; /** set the current index as the active one */ setActiveIndex: (index: number) => void; - /** callback that stops the auto play */ + /** callback that stops the autoplay */ stopAutoPlay: () => void; - /** callback that starts the auto play */ + /** callback that starts the autoplay */ startAutoPlay: () => void; + /** True if the last slide change is user activated */ + isUserActivated?: boolean; } export const DEFAULT_OPTIONS: Partial = { @@ -90,40 +93,34 @@ export const useSlideshowControls = ({ // Number of slides when using groupBy prop. const slidesCount = Math.ceil(itemsCount / Math.min(groupBy as number, itemsCount)); - // Change current index to display next slide. - const goToNextSlide = useCallback( - (loopback = true) => { - setCurrentIndex((index) => { - if (loopback && index === slidesCount - 1) { - // Loopback to the start. - return 0; - } - if (index < slidesCount - 1) { - // Next slide. - return index + 1; - } - return index; - }); + // Set current active index (& if is user activated) + const setActiveIndex = useCallback( + (setStateAction: SetStateAction, isUser?: boolean) => { + // Store on element a boolean value when the slide change was not from a user action. + const elementDataset = element?.dataset as any; + if (elementDataset) { + if (isUser) elementDataset.lumxUserActivated = true; + else delete elementDataset.lumxUserActivated; + } + + setCurrentIndex(setStateAction); }, - [slidesCount, setCurrentIndex], + [element], ); - // Change current index to display previous slide. - const goToPreviousSlide = useCallback( - (loopback = true) => { - setCurrentIndex((index) => { - if (loopback && index === 0) { - // Loopback to the end. - return slidesCount - 1; - } - if (index > 0) { - // Previous slide. - return index - 1; + // Change slide given delta (-1/+1) with or without loop back. + const goTo = useCallback( + (delta: -1 | 1 = 1, loopBack = true, isUser?: boolean) => { + setActiveIndex((index) => { + if (loopBack) { + const newIndex = (index + delta) % slidesCount; + if (newIndex < 0) return slidesCount + newIndex; + return newIndex; } - return index; - }); + return clamp(index + delta, 0, slidesCount - 1); + }, isUser); }, - [slidesCount, setCurrentIndex], + [slidesCount, setActiveIndex], ); // Auto play @@ -132,22 +129,22 @@ export const useSlideshowControls = ({ const isSlideshowAutoPlaying = isForcePaused ? false : isAutoPlaying; // Start - useInterval(goToNextSlide, isSlideshowAutoPlaying && slidesCount > 1 ? (interval as number) : null); + useInterval(goTo, isSlideshowAutoPlaying && slidesCount > 1 ? (interval as number) : null); - // Reset current index if it become invalid. + // Reset current index if it becomes invalid. useEffect(() => { if (currentIndex > slidesCount - 1) { - setCurrentIndex(defaultActiveIndex as number); + setActiveIndex(defaultActiveIndex as number); } - }, [currentIndex, slidesCount, defaultActiveIndex]); + }, [currentIndex, slidesCount, defaultActiveIndex, setActiveIndex]); - const startAutoPlay = () => { + const startAutoPlay = useCallback(() => { setIsAutoPlaying(Boolean(autoPlay)); - }; + }, [autoPlay]); - const stopAutoPlay = () => { + const stopAutoPlay = useCallback(() => { setIsAutoPlaying(false); - }; + }, []); // Handle click on a bullet to go to a specific slide. const onPaginationClick = useCallback( @@ -156,36 +153,48 @@ export const useSlideshowControls = ({ setIsForcePaused(true); if (index >= 0 && index < slidesCount) { - setCurrentIndex(index); + setActiveIndex(index, true); } }, - [slidesCount, setCurrentIndex], + [stopAutoPlay, slidesCount, setActiveIndex], ); // Handle click or keyboard event to go to next slide. const onNextClick = useCallback( - (loopback = true) => { + (loopBack = true) => { stopAutoPlay(); setIsForcePaused(true); - goToNextSlide(loopback); + goTo(1, loopBack, true); }, - [goToNextSlide], + [goTo, stopAutoPlay], ); // Handle click or keyboard event to go to previous slide. const onPreviousClick = useCallback( - (loopback = true) => { + (loopBack = true) => { stopAutoPlay(); setIsForcePaused(true); - goToPreviousSlide(loopback); + goTo(-1, loopBack, true); }, - [goToPreviousSlide], + [goTo, stopAutoPlay], ); + // Listen to custom next/prev slide events + useEffect(() => { + if (!element) return undefined; + + element.addEventListener(NEXT_SLIDE_EVENT, onNextClick); + element.addEventListener(PREV_SLIDE_EVENT, onPreviousClick); + return () => { + element.removeEventListener(NEXT_SLIDE_EVENT, onNextClick); + element.removeEventListener(PREV_SLIDE_EVENT, onPreviousClick); + }; + }, [element, onNextClick, onPreviousClick]); + // If the activeIndex props changes, update the current slide useEffect(() => { - setCurrentIndex(activeIndex as number); - }, [activeIndex]); + setActiveIndex(activeIndex as number); + }, [activeIndex, setActiveIndex]); // If the slide changes, with autoplay for example, trigger "onChange" useEffect(() => { @@ -199,15 +208,15 @@ export const useSlideshowControls = ({ const generatedSlidesId = useId(); const slideshowSlidesId = slidesId || generatedSlidesId; - const toggleAutoPlay = () => { + const toggleAutoPlay = useCallback(() => { if (isSlideshowAutoPlaying) { stopAutoPlay(); } else { startAutoPlay(); } - }; + }, [isSlideshowAutoPlaying, startAutoPlay, stopAutoPlay]); - const toggleForcePause = () => { + const toggleForcePause = useCallback(() => { const shouldBePaused = !isForcePaused; setIsForcePaused(shouldBePaused); @@ -217,7 +226,7 @@ export const useSlideshowControls = ({ } else { stopAutoPlay(); } - }; + }, [isForcePaused, startAutoPlay, stopAutoPlay]); // Start index and end index of visible slides. const startIndexVisible = currentIndex * (groupBy as number); @@ -237,7 +246,7 @@ export const useSlideshowControls = ({ toggleAutoPlay, activeIndex: currentIndex, slidesCount, - setActiveIndex: setCurrentIndex, + setActiveIndex, startAutoPlay, stopAutoPlay, isForcePaused,