Skip to content

Commit 1cdc24b

Browse files
committed
feat(slideshow): rework keyboard & focus navigation
1 parent fb4145c commit 1cdc24b

File tree

11 files changed

+243
-266
lines changed

11 files changed

+243
-266
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Changed
1111

1212
- `SlideShow`: use `inert` on hidden slides (on top of `aria-hidden` and `tabindex="-1"`) for greater semantic.
13+
- `SlideShow`: reworked keyboard & focus management
14+
- Pagination items do not use roving tabindex anymore
15+
- Roles `tab` & `tabpanel` are not used anymore
16+
- Switching slides moves the focus
17+
- On the slide if it contains multiple focusable element
18+
- On the focusable element if the slide contains exactly one focusable element
19+
- On the pagination item otherwise
1320

1421
## [3.9.3][] - 2024-10-09
1522

packages/lumx-react/src/components/image-lightbox/internal/ImageSlideshow.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const ImageSlideshow: React.FC<ImageSlideshowProps> = ({
3131
activeIndex,
3232
slideshowId,
3333
setSlideshow,
34+
slideshow,
3435
slideshowSlidesId,
3536
slidesCount,
3637
onNextClick,
@@ -61,6 +62,7 @@ export const ImageSlideshow: React.FC<ImageSlideshowProps> = ({
6162
onNextClick={onNextClick}
6263
onPreviousClick={onPreviousClick}
6364
onPaginationClick={onPaginationClick}
65+
parentRef={slideshow}
6466
{...slideshowControlsProps}
6567
paginationItemProps={(index: number) => {
6668
const props = slideshowControlsProps?.paginationItemProps?.(index) || {};

packages/lumx-react/src/components/slideshow/Slides.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import classNames from 'classnames';
66
import { FULL_WIDTH_PERCENT } from '@lumx/react/components/slideshow/constants';
77
import { Comp, GenericProps, HasTheme } from '@lumx/react/utils/type';
88
import { getRootClassName, handleBasicClasses } from '@lumx/react/utils/className';
9+
import { useMergeRefs } from '@lumx/react/utils/mergeRefs';
910
import { buildSlideShowGroupId, SlideshowItemGroup } from './SlideshowItemGroup';
1011

1112
export interface SlidesProps extends GenericProps, HasTheme {
@@ -32,7 +33,7 @@ export interface SlidesProps extends GenericProps, HasTheme {
3233
/**
3334
* Accessible label to set on a slide group.
3435
* Receives the group position starting from 1 and the total number of groups.
35-
* */
36+
*/
3637
slideGroupLabel?: (groupPosition: number, groupTotal: number) => string;
3738
}
3839

@@ -70,7 +71,6 @@ export const Slides: Comp<SlidesProps, HTMLDivElement> = forwardRef((props, ref)
7071
slideGroupLabel,
7172
...forwardedProps
7273
} = props;
73-
const wrapperRef = React.useRef<HTMLDivElement>(null);
7474
const startIndexVisible = activeIndex;
7575
const endIndexVisible = startIndexVisible + 1;
7676

@@ -82,10 +82,12 @@ export const Slides: Comp<SlidesProps, HTMLDivElement> = forwardRef((props, ref)
8282
return groupBy && groupBy > 1 ? chunk(childrenArray, groupBy) : childrenArray;
8383
}, [children, groupBy]);
8484

85+
const slidesRef = React.useRef<HTMLDivElement>(null);
86+
8587
return (
8688
<section
8789
id={id}
88-
ref={ref}
90+
ref={useMergeRefs(slidesRef, ref)}
8991
{...forwardedProps}
9092
className={classNames(className, handleBasicClasses({ prefix: CLASSNAME, theme }), {
9193
[`${CLASSNAME}--fill-height`]: fillHeight,
@@ -100,14 +102,14 @@ export const Slides: Comp<SlidesProps, HTMLDivElement> = forwardRef((props, ref)
100102
onMouseLeave={toggleAutoPlay}
101103
aria-live={isAutoPlaying ? 'off' : 'polite'}
102104
>
103-
<div ref={wrapperRef} className={`${CLASSNAME}__wrapper`} style={wrapperStyle}>
105+
<div className={`${CLASSNAME}__wrapper`} style={wrapperStyle}>
104106
{groups.map((group, index) => (
105107
<SlideshowItemGroup
106108
key={index}
107109
id={slidesId && buildSlideShowGroupId(slidesId, index)}
108-
role={hasControls ? 'tabpanel' : 'group'}
109110
label={slideGroupLabel ? slideGroupLabel(index + 1, groups.length) : undefined}
110111
isDisplayed={index >= startIndexVisible && index < endIndexVisible}
112+
slidesRef={slidesRef}
111113
>
112114
{group}
113115
</SlideshowItemGroup>

packages/lumx-react/src/components/slideshow/Slideshow.stories.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
/* eslint-disable jsx-a11y/anchor-is-valid */
12
import React from 'react';
23
import range from 'lodash/range';
3-
import { AspectRatio, Button, FlexBox, ImageBlock, Slideshow, SlideshowItem, Orientation } from '@lumx/react';
4+
import { AspectRatio, Button, FlexBox, ImageBlock, Slideshow, SlideshowItem, Orientation, Link } from '@lumx/react';
45
import { IMAGES, LANDSCAPE_IMAGES } from '@lumx/react/stories/controls/image';
56

67
export default {
@@ -65,6 +66,16 @@ export const ResponsiveSlideShowSwipe = () => {
6566
}}
6667
slideGroupLabel={(currentGroup, totalGroup) => `${currentGroup} of ${totalGroup}`}
6768
>
69+
<SlideshowItem>
70+
<FlexBox
71+
style={{ border: '1px solid grey', maxWidth: 300, height: 300 }}
72+
hAlign="center"
73+
vAlign="center"
74+
>
75+
<Link href="#">A link</Link>
76+
<Button>A button</Button>
77+
</FlexBox>
78+
</SlideshowItem>
6879
{slides.map((slide) => (
6980
<SlideshowItem key={`${slide}`}>
7081
<FlexBox

packages/lumx-react/src/components/slideshow/Slideshow.tsx

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import React, { forwardRef } from 'react';
22

33
import { SlideshowControls, SlideshowControlsProps, Theme, Slides, SlidesProps } from '@lumx/react';
4-
import { DEFAULT_OPTIONS } from '@lumx/react/hooks/useSlideshowControls';
54
import { Comp, GenericProps } from '@lumx/react/utils/type';
65
import { useFocusWithin } from '@lumx/react/hooks/useFocusWithin';
76
import { mergeRefs } from '@lumx/react/utils/mergeRefs';
8-
import { buildSlideShowGroupId } from './SlideshowItemGroup';
7+
import { DEFAULT_OPTIONS } from './useSlideshowControls';
98

109
/**
1110
* Defines the props of the component.
@@ -14,7 +13,7 @@ export interface SlideshowProps
1413
extends GenericProps,
1514
Pick<SlidesProps, 'autoPlay' | 'slidesId' | 'id' | 'theme' | 'fillHeight' | 'groupBy' | 'slideGroupLabel'> {
1615
/** current slide active */
17-
activeIndex?: SlidesProps['activeIndex'];
16+
activeIndex?: number;
1817
/** Interval between each slide when automatic rotation is enabled. */
1918
interval?: number;
2019
/** Props to pass to the slideshow controls (minus those already set by the Slideshow props). */
@@ -134,27 +133,14 @@ export const Slideshow: Comp<SlideshowProps, HTMLDivElement> = forwardRef((props
134133
parentRef={slideshow}
135134
theme={theme}
136135
isAutoPlaying={isAutoPlaying}
137-
nextButtonProps={{
138-
'aria-controls': slideshowSlidesId,
139-
...slideshowControlsProps.nextButtonProps,
140-
}}
141-
previousButtonProps={{
142-
'aria-controls': slideshowSlidesId,
143-
...slideshowControlsProps.previousButtonProps,
144-
}}
145136
playButtonProps={
146137
autoPlay
147138
? {
148-
'aria-controls': slideshowSlidesId,
149139
onClick: toggleForcePause,
150140
...slideshowControlsProps.playButtonProps,
151141
}
152142
: undefined
153143
}
154-
paginationItemProps={(index) => ({
155-
'aria-controls': buildSlideShowGroupId(slideshowSlidesId, index),
156-
...slideshowControlsProps.paginationItemProps?.(index),
157-
})}
158144
/>
159145
</div>
160146
) : undefined

packages/lumx-react/src/components/slideshow/SlideshowControls.stories.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,10 @@ export const ControllingSlideshow = ({ images = Object.values(LANDSCAPE_IMAGES),
7878
parentRef={slideshow}
7979
theme={theme}
8080
isAutoPlaying={isAutoPlaying}
81-
nextButtonProps={{ label: 'Next', 'aria-controls': slideshowSlidesId }}
82-
previousButtonProps={{ label: 'Previous', 'aria-controls': slideshowSlidesId }}
81+
nextButtonProps={{ label: 'Next' }}
82+
previousButtonProps={{ label: 'Previous' }}
8383
playButtonProps={{
8484
label: 'Play/Pause',
85-
'aria-controls': slideshowSlidesId,
8685
onClick: toggleForcePause,
8786
}}
8887
paginationItemLabel={(index) => `Slide ${index}`}

packages/lumx-react/src/components/slideshow/SlideshowControls.tsx

Lines changed: 63 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
1-
import React, { forwardRef, RefObject, useCallback, useMemo } from 'react';
1+
import React, { forwardRef, RefObject, useCallback, useState } from 'react';
22

33
import classNames from 'classnames';
44
import range from 'lodash/range';
55

66
import { mdiChevronLeft, mdiChevronRight, mdiPlayCircleOutline, mdiPauseCircleOutline } from '@lumx/icons';
7-
import { Emphasis, IconButton, IconButtonProps, Theme } from '@lumx/react';
7+
import { Emphasis, IconButton, IconButtonProps, Slides, Theme } from '@lumx/react';
88
import { Comp, GenericProps, HasTheme } from '@lumx/react/utils/type';
99
import { getRootClassName, handleBasicClasses } from '@lumx/react/utils/className';
1010
import { WINDOW } from '@lumx/react/constants';
11-
import { useSlideshowControls, DEFAULT_OPTIONS } from '@lumx/react/hooks/useSlideshowControls';
12-
import { useRovingTabIndex } from '@lumx/react/hooks/useRovingTabIndex';
11+
import { useKeyNavigate } from '@lumx/react/components/slideshow/useKeyNavigate';
12+
import { useMergeRefs } from '@lumx/react/utils/mergeRefs';
1313

14+
import { buildSlideShowGroupId } from '@lumx/react/components/slideshow/SlideshowItemGroup';
15+
import { DEFAULT_OPTIONS, useSlideshowControls } from './useSlideshowControls';
1416
import { useSwipeNavigate } from './useSwipeNavigate';
1517
import { PAGINATION_ITEM_SIZE, PAGINATION_ITEMS_MAX } from './constants';
1618
import { usePaginationVisibleRange } from './usePaginationVisibleRange';
@@ -34,11 +36,11 @@ export interface SlideshowControlsProps extends GenericProps, HasTheme {
3436
/** Number of slides. */
3537
slidesCount: number;
3638
/** On next button click callback. */
37-
onNextClick?(loopback?: boolean): void;
39+
onNextClick?(loopBack?: boolean): void;
3840
/** On pagination change callback. */
3941
onPaginationClick?(index: number): void;
4042
/** On previous button click callback. */
41-
onPreviousClick?(loopback?: boolean): void;
43+
onPreviousClick?(loopBack?: boolean): void;
4244
/** whether the slideshow is currently playing */
4345
isAutoPlaying?: boolean;
4446
/**
@@ -100,7 +102,7 @@ const InternalSlideshowControls: Comp<SlideshowControlsProps, HTMLDivElement> =
100102
...forwardedProps
101103
} = props;
102104

103-
let parent;
105+
let parent: HTMLElement | null | undefined;
104106
if (WINDOW) {
105107
// Checking window object to avoid errors in SSR.
106108
parent = parentRef instanceof HTMLElement ? parentRef : parentRef?.current;
@@ -109,33 +111,30 @@ const InternalSlideshowControls: Comp<SlideshowControlsProps, HTMLDivElement> =
109111
// Listen to touch swipe navigate left & right.
110112
useSwipeNavigate(
111113
parent,
112-
// Go next without loopback.
114+
// Go next without loop back.
113115
useCallback(() => onNextClick?.(false), [onNextClick]),
114-
// Go previous without loopback.
116+
// Go previous without loop back.
115117
useCallback(() => onPreviousClick?.(false), [onPreviousClick]),
116118
);
117119

118-
/**
119-
* Add roving tab index pattern to pagination items and activate slide on focus.
120-
*/
121-
useRovingTabIndex({
122-
parentRef: paginationRef,
123-
elementSelector: 'button',
124-
keepTabIndex: true,
125-
onElementFocus: (element) => {
126-
element.click();
127-
},
128-
});
120+
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
121+
const onButtonFocus = useCallback((index: number) => () => setFocusedIndex(index), [setFocusedIndex]);
122+
const onFocusOut = useCallback(() => setFocusedIndex(null), [setFocusedIndex]);
129123

130124
// Pagination "bullet" range.
131-
const visibleRange = usePaginationVisibleRange(activeIndex as number, slidesCount);
125+
const visibleRange = usePaginationVisibleRange(focusedIndex ?? (activeIndex as number), slidesCount);
132126

133127
// Inline style of wrapper element.
134128
const wrapperStyle = { transform: `translateX(-${PAGINATION_ITEM_SIZE * visibleRange.min}px)` };
135129

130+
const controlsRef = React.useRef<HTMLDivElement>(null);
131+
useKeyNavigate(controlsRef.current, onNextClick, onPreviousClick);
132+
133+
const slideshowSlidesId = React.useMemo(() => parent?.querySelector(`.${Slides.className}__slides`)?.id, [parent]);
134+
136135
return (
137136
<div
138-
ref={ref}
137+
ref={useMergeRefs(ref, controlsRef)}
139138
{...forwardedProps}
140139
className={classNames(className, handleBasicClasses({ prefix: CLASSNAME, theme }), {
141140
[`${CLASSNAME}--has-infinite-pagination`]: slidesCount > PAGINATION_ITEMS_MAX,
@@ -148,64 +147,53 @@ const InternalSlideshowControls: Comp<SlideshowControlsProps, HTMLDivElement> =
148147
color={theme === Theme.dark ? 'light' : 'dark'}
149148
emphasis={Emphasis.low}
150149
onClick={onPreviousClick}
150+
aria-controls={slideshowSlidesId}
151151
/>
152+
152153
<div ref={paginationRef} className={`${CLASSNAME}__pagination`}>
153154
<div
154155
className={`${CLASSNAME}__pagination-items`}
155156
style={wrapperStyle}
156-
role="tablist"
157157
{...paginationProps}
158+
onBlur={onFocusOut}
158159
>
159-
{useMemo(
160-
() =>
161-
range(slidesCount).map((index) => {
162-
const isOnEdge =
163-
index !== 0 &&
164-
index !== slidesCount - 1 &&
165-
(index === visibleRange.min || index === visibleRange.max);
166-
const isActive = activeIndex === index;
167-
const isOutRange = index < visibleRange.min || index > visibleRange.max;
168-
const {
169-
className: itemClassName = undefined,
170-
label = undefined,
171-
...itemProps
172-
} = paginationItemProps ? paginationItemProps(index) : {};
173-
174-
const ariaLabel =
175-
label || paginationItemLabel?.(index) || `${index + 1} / ${slidesCount}`;
176-
177-
return (
178-
<button
179-
className={classNames(
180-
handleBasicClasses({
181-
prefix: `${CLASSNAME}__pagination-item`,
182-
isActive,
183-
isOnEdge,
184-
isOutRange,
185-
}),
186-
itemClassName,
187-
)}
188-
key={index}
189-
type="button"
190-
tabIndex={isActive ? undefined : -1}
191-
role="tab"
192-
aria-selected={isActive}
193-
onClick={() => onPaginationClick?.(index)}
194-
aria-label={ariaLabel}
195-
{...itemProps}
196-
/>
197-
);
198-
}),
199-
[
200-
slidesCount,
201-
visibleRange.min,
202-
visibleRange.max,
203-
activeIndex,
204-
paginationItemProps,
205-
paginationItemLabel,
206-
onPaginationClick,
207-
],
208-
)}
160+
{range(slidesCount).map((index) => {
161+
const isOnEdge =
162+
index !== 0 &&
163+
index !== slidesCount - 1 &&
164+
(index === visibleRange.min || index === visibleRange.max);
165+
const isActive = activeIndex === index;
166+
const isOutRange = index < visibleRange.min || index > visibleRange.max;
167+
const {
168+
className: itemClassName = undefined,
169+
label = undefined,
170+
...itemProps
171+
} = paginationItemProps ? paginationItemProps(index) : {};
172+
173+
const ariaLabel = label || paginationItemLabel?.(index) || `${index + 1} / ${slidesCount}`;
174+
175+
return (
176+
<button
177+
className={classNames(
178+
handleBasicClasses({
179+
prefix: `${CLASSNAME}__pagination-item`,
180+
isActive,
181+
isOnEdge,
182+
isOutRange,
183+
}),
184+
itemClassName,
185+
)}
186+
key={index}
187+
type="button"
188+
aria-current={isActive || undefined}
189+
aria-controls={buildSlideShowGroupId(slideshowSlidesId, index)}
190+
onClick={() => onPaginationClick?.(index)}
191+
onFocus={onButtonFocus(index)}
192+
aria-label={ariaLabel}
193+
{...itemProps}
194+
/>
195+
);
196+
})}
209197
</div>
210198
</div>
211199

@@ -216,6 +204,7 @@ const InternalSlideshowControls: Comp<SlideshowControlsProps, HTMLDivElement> =
216204
className={`${CLASSNAME}__play`}
217205
color={theme === Theme.dark ? 'light' : 'dark'}
218206
emphasis={Emphasis.low}
207+
aria-controls={slideshowSlidesId}
219208
/>
220209
) : null}
221210

@@ -226,6 +215,7 @@ const InternalSlideshowControls: Comp<SlideshowControlsProps, HTMLDivElement> =
226215
color={theme === Theme.dark ? 'light' : 'dark'}
227216
emphasis={Emphasis.low}
228217
onClick={onNextClick}
218+
aria-controls={slideshowSlidesId}
229219
/>
230220
</div>
231221
);

0 commit comments

Comments
 (0)