Skip to content

Commit f6a7808

Browse files
ubermanuNicolas ZERRigordanchenko
authored
feat: a11y improvements (#373)
* add ARIA role descriptions * add ARIA roles to slide title and description * add localization for ARIA labels and role descriptions * set empty string as the default image slide `alt` attribute * disable `aria-live` during slideshow playback when focus is outside the carousel --------- Co-authored-by: Nicolas ZERR <nicolaszerr@stratis.fr> Co-authored-by: Igor Danchenko <64441155+igordanchenko@users.noreply.github.com>
1 parent 887cace commit f6a7808

27 files changed

Lines changed: 188 additions & 38 deletions

File tree

dev/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export default function App() {
3030
plugins={[Captions, Counter, Download, Share, Fullscreen, Slideshow, Thumbnails, Video, Zoom]}
3131
/>
3232

33-
<button type="button" className="button" onClick={() => setOpen(true)}>
33+
<button type="button" className="button" aria-haspopup="dialog" onClick={() => setOpen(true)}>
3434
Open Lightbox
3535
</button>
3636
</>

dev/slides.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,21 @@ function imageLink(asset: string, size: number) {
55
}
66

77
export const slides = [
8-
{ asset: "image01.0800ee93.3840x5760" },
9-
{ asset: "image02.645bc7e4.3840x5070" },
10-
{ asset: "image03.13c5eeb7.3840x5120" },
8+
{
9+
asset: "image01.0800ee93.3840x5760",
10+
alt: "A small black and tan dog wearing oversized black sunglasses, posing confidently against a yellow background",
11+
description: "Puppy in sunglasses",
12+
},
13+
{
14+
asset: "image02.645bc7e4.3840x5070",
15+
alt: "A vibrant pastel lifeguard tower on Miami Beach, painted in shades of pink, purple, and orange, with a green flag and a plane flying overhead",
16+
description: "Miami beach",
17+
},
18+
{
19+
asset: "image03.13c5eeb7.3840x5120",
20+
alt: "A bright pink inflatable flamingo floating in clear turquoise ocean water under a sunny, cloudless sky",
21+
description: "Flamingo",
22+
},
1123
{ asset: "image04.2d71a97f.3840x2546" },
1224
{ asset: "image05.c6ce32ab.3840x5760" },
1325
{ asset: "image06.74d5e191.3840x2553" },

src/Lightbox.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import { AnimationSettings, ComponentProps, LightboxExternalProps, Node } from "
44
import { parseInt } from "./utils.js";
55
import { LightboxDefaultProps } from "./props.js";
66
import { createNode, withPlugins } from "./config.js";
7-
import { EventsProvider, LightboxPropsProvider, LightboxStateProvider, TimeoutsProvider } from "./contexts/index.js";
7+
import {
8+
A11yContextProvider,
9+
EventsProvider,
10+
LightboxPropsProvider,
11+
LightboxStateProvider,
12+
TimeoutsProvider,
13+
} from "./contexts/index.js";
814
import {
915
CarouselModule,
1016
ControllerModule,
@@ -98,7 +104,9 @@ export function Lightbox({
98104
index={parseInt(index || defaultIndex)}
99105
>
100106
<TimeoutsProvider>
101-
<EventsProvider>{renderNode(createNode(RootModule, config), props)}</EventsProvider>
107+
<EventsProvider>
108+
<A11yContextProvider>{renderNode(createNode(RootModule, config), props)}</A11yContextProvider>
109+
</EventsProvider>
102110
</TimeoutsProvider>
103111
</LightboxStateProvider>
104112
</LightboxPropsProvider>

src/components/IconButton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from "react";
22

3-
import { clsx, cssClass, label as translateLabel } from "../utils.js";
3+
import { clsx, cssClass, translateLabel } from "../utils.js";
44
import { useLightboxProps } from "../contexts/index.js";
55
import { ELEMENT_BUTTON, ELEMENT_ICON } from "../consts.js";
66
import { Label } from "../types.js";

src/components/ImageSlide.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ export function ImageSlide({
150150
)}
151151
style={{ ...defaultStyle, ...style, ...imagePropsStyle }}
152152
{...restImageProps}
153-
alt={image.alt}
153+
alt={image.alt ?? ""}
154154
sizes={sizes}
155155
srcSet={srcSet}
156156
src={image.src}

src/components/LightboxRoot.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,23 @@ import * as React from "react";
22

33
import { clsx, cssClass } from "../utils.js";
44
import { useForkRef } from "../hooks/index.js";
5-
import { DocumentContextProvider } from "../contexts/index.js";
5+
import { DocumentContextProvider, useA11yContext } from "../contexts/index.js";
66

77
const LightboxRoot = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(function LightboxRoot(
8-
{ className, children, ...rest },
8+
{ className, children, onFocus, onBlur, ...rest },
99
ref,
1010
) {
1111
const nodeRef = React.useRef<HTMLDivElement>(null);
12+
const { trackFocusWithin } = useA11yContext();
1213

1314
return (
1415
<DocumentContextProvider nodeRef={nodeRef}>
15-
<div ref={useForkRef(ref, nodeRef)} className={clsx(cssClass("root"), className)} {...rest}>
16+
<div
17+
ref={useForkRef(ref, nodeRef)}
18+
className={clsx(cssClass("root"), className)}
19+
{...trackFocusWithin(onFocus, onBlur)}
20+
{...rest}
21+
>
1622
{children}
1723
</div>
1824
</DocumentContextProvider>

src/contexts/A11yContext.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import * as React from "react";
2+
3+
import { makeUseContext } from "../utils.js";
4+
5+
export type A11yContextType = {
6+
focusWithin: boolean;
7+
trackFocusWithin: (
8+
onFocus?: React.FocusEventHandler,
9+
onBlur?: React.FocusEventHandler,
10+
) => {
11+
onFocus: React.FocusEventHandler;
12+
onBlur: React.FocusEventHandler;
13+
};
14+
autoPlaying: boolean;
15+
setAutoPlaying: (value: boolean) => void;
16+
};
17+
18+
export const A11yContext = React.createContext<A11yContextType | null>(null);
19+
20+
export const useA11yContext = makeUseContext("useA11yContext", "A11yContext", A11yContext);
21+
22+
export function A11yContextProvider({ children }: React.PropsWithChildren) {
23+
const [focusWithin, setFocusWithin] = React.useState(false);
24+
const [autoPlaying, setAutoPlaying] = React.useState(false);
25+
26+
const context = React.useMemo(() => {
27+
const trackFocusWithin: A11yContextType["trackFocusWithin"] = (onFocus, onBlur) => {
28+
const trackAndDelegate = (focusWithinValue: boolean) => (event: React.FocusEvent) => {
29+
if (!event.currentTarget.contains(event.relatedTarget)) {
30+
setFocusWithin(focusWithinValue);
31+
}
32+
33+
(focusWithinValue ? onFocus : onBlur)?.(event);
34+
};
35+
36+
return {
37+
onFocus: trackAndDelegate(true),
38+
onBlur: trackAndDelegate(false),
39+
};
40+
};
41+
42+
return { focusWithin, trackFocusWithin, autoPlaying, setAutoPlaying };
43+
}, [focusWithin, autoPlaying]);
44+
45+
return <A11yContext.Provider value={context}>{children}</A11yContext.Provider>;
46+
}

src/contexts/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from "./A11yContext.js";
12
export * from "./DocumentContext.js";
23
export * from "./Events.js";
34
export * from "./LightboxProps.js";

src/modules/Carousel.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ import {
1414
isImageSlide,
1515
makeInertWhen,
1616
parseLengthPercentage,
17+
translateLabel,
18+
translateSlideCounter,
1719
} from "../utils.js";
1820
import { ImageSlide } from "../components/index.js";
1921
import { useController } from "./Controller/index.js";
20-
import { useDocumentContext, useLightboxProps, useLightboxState } from "../contexts/index.js";
22+
import { useA11yContext, useDocumentContext, useLightboxProps, useLightboxState } from "../contexts/index.js";
2123
import { CLASS_FLEX_CENTER, CLASS_SLIDE, MODULE_CAROUSEL } from "../consts.js";
2224

2325
function cssPrefix(value?: string) {
@@ -36,13 +38,14 @@ type CarouselSlideProps = {
3638
function CarouselSlide({ slide, offset }: CarouselSlideProps) {
3739
const containerRef = React.useRef<HTMLDivElement>(null);
3840

39-
const { currentIndex } = useLightboxState();
41+
const { currentIndex, slides } = useLightboxState();
4042
const { slideRect, focus } = useController();
4143
const {
4244
render,
4345
carousel: { imageFit, imageProps },
4446
on: { click: onClick },
4547
styles: { slide: style },
48+
labels,
4649
} = useLightboxProps();
4750
const { getOwnerDocument } = useDocumentContext();
4851

@@ -90,8 +93,9 @@ function CarouselSlide({ slide, offset }: CarouselSlideProps) {
9093
)}
9194
{...makeInertWhen(offscreen)}
9295
style={style}
93-
role="region"
94-
aria-roledescription="slide"
96+
role="group"
97+
aria-roledescription={translateLabel(labels, "Slide")}
98+
aria-label={translateSlideCounter(labels, slides, currentIndex + offset)}
9599
>
96100
{renderSlide()}
97101
</div>
@@ -103,9 +107,10 @@ function Placeholder() {
103107
return <div className={cssClass(CLASS_SLIDE)} style={style} />;
104108
}
105109

106-
export function Carousel({ carousel }: ComponentProps) {
110+
export function Carousel({ carousel, labels }: ComponentProps) {
107111
const { slides, currentIndex, globalIndex } = useLightboxState();
108112
const { setCarouselRef } = useController();
113+
const { autoPlaying, focusWithin } = useA11yContext();
109114

110115
const spacingValue = parseLengthPercentage(carousel.spacing);
111116
const paddingValue = parseLengthPercentage(carousel.padding);
@@ -142,6 +147,10 @@ export function Carousel({ carousel }: ComponentProps) {
142147
[`${cssVar(cssPrefix("padding_px"))}`]: paddingValue.pixel || 0,
143148
[`${cssVar(cssPrefix("padding_percent"))}`]: paddingValue.percent || 0,
144149
}}
150+
role="region"
151+
aria-live={autoPlaying && !focusWithin ? "off" : "polite"}
152+
aria-roledescription={translateLabel(labels, "Carousel")}
153+
aria-label={translateLabel(labels, "Photo gallery")}
145154
>
146155
{items.map(({ key, slide, offset }) =>
147156
slide ? <CarouselSlide key={key} slide={slide} offset={offset} /> : <Placeholder key={key} />,

src/modules/Controller/Controller.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,6 @@ export function Controller({ children, ...props }: ComponentProps) {
414414
...(controller.touchAction !== "none" ? { [cssVar("controller_touch_action")]: controller.touchAction } : null),
415415
...styles.container,
416416
}}
417-
{...(controller.aria ? { role: "region", "aria-live": "polite", "aria-roledescription": "carousel" } : null)}
418417
tabIndex={-1}
419418
{...registerSensors}
420419
>

0 commit comments

Comments
 (0)