Skip to content

Commit

Permalink
feat(slideshow): rework keyboard & focus navigation
Browse files Browse the repository at this point in the history
  • Loading branch information
gcornut committed Oct 23, 2024
1 parent fb4145c commit b35c5aa
Show file tree
Hide file tree
Showing 12 changed files with 267 additions and 268 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const ImageSlideshow: React.FC<ImageSlideshowProps> = ({
activeIndex,
slideshowId,
setSlideshow,
slideshow,
slideshowSlidesId,
slidesCount,
onNextClick,
Expand Down Expand Up @@ -61,6 +62,7 @@ export const ImageSlideshow: React.FC<ImageSlideshowProps> = ({
onNextClick={onNextClick}
onPreviousClick={onPreviousClick}
onPaginationClick={onPaginationClick}
parentRef={slideshow}
{...slideshowControlsProps}
paginationItemProps={(index: number) => {
const props = slideshowControlsProps?.paginationItemProps?.(index) || {};
Expand Down
22 changes: 15 additions & 7 deletions packages/lumx-react/src/components/slideshow/Slides.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } 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 {
Expand All @@ -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;
}

Expand Down Expand Up @@ -70,7 +72,6 @@ export const Slides: Comp<SlidesProps, HTMLDivElement> = forwardRef((props, ref)
slideGroupLabel,
...forwardedProps
} = props;
const wrapperRef = React.useRef<HTMLDivElement>(null);
const startIndexVisible = activeIndex;
const endIndexVisible = startIndexVisible + 1;

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

const slidesRef = React.useRef<HTMLDivElement>(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(NEXT_SLIDE_EVENT)), [slide]);
useKeyNavigate(slide, onNextSlide, onPrevSlide);

return (
<section
id={id}
ref={ref}
ref={useMergeRefs(slidesRef, ref)}
{...forwardedProps}
className={classNames(className, handleBasicClasses({ prefix: CLASSNAME, theme }), {
[`${CLASSNAME}--fill-height`]: fillHeight,
Expand All @@ -100,14 +108,14 @@ export const Slides: Comp<SlidesProps, HTMLDivElement> = forwardRef((props, ref)
onMouseLeave={toggleAutoPlay}
aria-live={isAutoPlaying ? 'off' : 'polite'}
>
<div ref={wrapperRef} className={`${CLASSNAME}__wrapper`} style={wrapperStyle}>
<div className={`${CLASSNAME}__wrapper`} style={wrapperStyle}>
{groups.map((group, index) => (
<SlideshowItemGroup
key={index}
id={slidesId && buildSlideShowGroupId(slidesId, index)}
role={hasControls ? 'tabpanel' : 'group'}
label={slideGroupLabel ? slideGroupLabel(index + 1, groups.length) : undefined}
label={slideGroupLabel?.(index + 1, groups.length)}
isDisplayed={index >= startIndexVisible && index < endIndexVisible}
slidesRef={slidesRef}
>
{group}
</SlideshowItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -65,6 +66,16 @@ export const ResponsiveSlideShowSwipe = () => {
}}
slideGroupLabel={(currentGroup, totalGroup) => `${currentGroup} of ${totalGroup}`}
>
<SlideshowItem>
<FlexBox
style={{ border: '1px solid grey', maxWidth: 300, height: 300 }}
hAlign="center"
vAlign="center"
>
<Link href="#">A link</Link>
<Button>A button</Button>
</FlexBox>
</SlideshowItem>
{slides.map((slide) => (
<SlideshowItem key={`${slide}`}>
<FlexBox
Expand Down
18 changes: 2 additions & 16 deletions packages/lumx-react/src/components/slideshow/Slideshow.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import React, { forwardRef } from 'react';

import { SlideshowControls, SlideshowControlsProps, Theme, Slides, SlidesProps } from '@lumx/react';
import { DEFAULT_OPTIONS } from '@lumx/react/hooks/useSlideshowControls';
import { Comp, GenericProps } from '@lumx/react/utils/type';
import { useFocusWithin } from '@lumx/react/hooks/useFocusWithin';
import { mergeRefs } from '@lumx/react/utils/mergeRefs';
import { buildSlideShowGroupId } from './SlideshowItemGroup';
import { DEFAULT_OPTIONS } from './useSlideshowControls';

/**
* Defines the props of the component.
Expand All @@ -14,7 +13,7 @@ export interface SlideshowProps
extends GenericProps,
Pick<SlidesProps, 'autoPlay' | 'slidesId' | 'id' | 'theme' | 'fillHeight' | 'groupBy' | 'slideGroupLabel'> {
/** 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). */
Expand Down Expand Up @@ -134,27 +133,14 @@ export const Slideshow: Comp<SlideshowProps, HTMLDivElement> = 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),
})}
/>
</div>
) : undefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}`}
Expand Down
136 changes: 63 additions & 73 deletions packages/lumx-react/src/components/slideshow/SlideshowControls.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
/**
Expand Down Expand Up @@ -100,7 +102,7 @@ const InternalSlideshowControls: Comp<SlideshowControlsProps, HTMLDivElement> =
...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;
Expand All @@ -109,33 +111,30 @@ const InternalSlideshowControls: Comp<SlideshowControlsProps, HTMLDivElement> =
// 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<number | null>(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<HTMLDivElement>(null);
useKeyNavigate(controlsRef.current, onNextClick, onPreviousClick);

const slideshowSlidesId = React.useMemo(() => parent?.querySelector(`.${Slides.className}__slides`)?.id, [parent]);

return (
<div
ref={ref}
ref={useMergeRefs(ref, controlsRef)}
{...forwardedProps}
className={classNames(className, handleBasicClasses({ prefix: CLASSNAME, theme }), {
[`${CLASSNAME}--has-infinite-pagination`]: slidesCount > PAGINATION_ITEMS_MAX,
Expand All @@ -148,64 +147,53 @@ const InternalSlideshowControls: Comp<SlideshowControlsProps, HTMLDivElement> =
color={theme === Theme.dark ? 'light' : 'dark'}
emphasis={Emphasis.low}
onClick={onPreviousClick}
aria-controls={slideshowSlidesId}
/>

<div ref={paginationRef} className={`${CLASSNAME}__pagination`}>
<div
className={`${CLASSNAME}__pagination-items`}
style={wrapperStyle}
role="tablist"
{...paginationProps}
onBlur={onFocusOut}
>
{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 (
<button
className={classNames(
handleBasicClasses({
prefix: `${CLASSNAME}__pagination-item`,
isActive,
isOnEdge,
isOutRange,
}),
itemClassName,
)}
key={index}
type="button"
tabIndex={isActive ? undefined : -1}
role="tab"
aria-selected={isActive}
onClick={() => onPaginationClick?.(index)}
aria-label={ariaLabel}
{...itemProps}
/>
);
}),
[
slidesCount,
visibleRange.min,
visibleRange.max,
activeIndex,
paginationItemProps,
paginationItemLabel,
onPaginationClick,
],
)}
{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 (
<button
className={classNames(
handleBasicClasses({
prefix: `${CLASSNAME}__pagination-item`,
isActive,
isOnEdge,
isOutRange,
}),
itemClassName,
)}
key={index}
type="button"
aria-current={isActive || undefined}
aria-controls={buildSlideShowGroupId(slideshowSlidesId, index)}
onClick={() => onPaginationClick?.(index)}
onFocus={onButtonFocus(index)}
aria-label={ariaLabel}
{...itemProps}
/>
);
})}
</div>
</div>

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

Expand All @@ -226,6 +215,7 @@ const InternalSlideshowControls: Comp<SlideshowControlsProps, HTMLDivElement> =
color={theme === Theme.dark ? 'light' : 'dark'}
emphasis={Emphasis.low}
onClick={onNextClick}
aria-controls={slideshowSlidesId}
/>
</div>
);
Expand Down
Loading

0 comments on commit b35c5aa

Please sign in to comment.