Skip to content

Commit 38e84fa

Browse files
committed
feat: a11y improvements
* streamline ARIA roles, role descriptions and labels * add localization for ARIA labels and role descriptions * set empty string as the default image slide `alt` attribute
1 parent e575095 commit 38e84fa

File tree

6 files changed

+59
-13
lines changed

6 files changed

+59
-13
lines changed

dev/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export default function App() {
1212
<main className="centered">
1313
<Lightbox slides={slides} index={index} setIndex={setIndex} />
1414

15-
<button type="button" className="button" onClick={() => setIndex(0)}>
15+
<button type="button" className="button" onClick={() => setIndex(0)} aria-haspopup="dialog">
1616
Open Lightbox
1717
</button>
1818
</main>

src/components/Carousel.tsx

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,39 @@ import { useEffect, useRef } from "react";
33
import ImageSlide from "./ImageSlide";
44
import { useZoom, useZoomInternal } from "./Zoom";
55
import { useLightboxContext } from "./LightboxContext";
6-
import { cssClass, isImageSlide, round } from "../utils";
6+
import { cssClass, isImageSlide, round, translateLabel, translateSlideCounter } from "../utils";
77
import { RenderSlideProps, SlideImage } from "../types";
88

9-
function CarouselSlide({ slide, rect, current }: Pick<RenderSlideProps, "slide" | "rect" | "current">) {
9+
function CarouselSlide({
10+
slide,
11+
rect,
12+
current,
13+
slideIndex,
14+
}: Pick<RenderSlideProps, "slide" | "rect" | "current" | "slideIndex">) {
1015
const ref = useRef<HTMLDivElement | null>(null);
1116

1217
const { zoom, offsetX, offsetY } = useZoom();
13-
const { styles, render: { slide: renderSlide, slideHeader, slideFooter } = {} } = useLightboxContext();
18+
const {
19+
slides,
20+
styles,
21+
labels,
22+
render: { slide: renderSlide, slideHeader, slideFooter } = {},
23+
} = useLightboxContext();
1424

1525
useEffect(() => {
1626
if (!current && ref.current?.contains(document.activeElement)) {
1727
ref.current.closest<HTMLElement>('[tabindex="-1"]')?.focus();
1828
}
1929
}, [current]);
2030

21-
const context = { slide, rect, current, zoom: round(current ? zoom : 1, 3) };
31+
const context = { slide, rect, current, slideIndex, zoom: round(current ? zoom : 1, 3) };
2232

2333
return (
2434
<div
2535
ref={ref}
2636
role="group"
27-
aria-roledescription="slide"
37+
aria-label={translateSlideCounter(labels, slideIndex + 1, slides.length)}
38+
aria-roledescription={translateLabel(labels, "Slide")}
2839
className={cssClass("slide")}
2940
hidden={!current}
3041
style={{
@@ -44,12 +55,20 @@ function CarouselSlide({ slide, rect, current }: Pick<RenderSlideProps, "slide"
4455
}
4556

4657
export default function Carousel() {
47-
const { slides, index, styles, carousel: { preload = 2 } = {} } = useLightboxContext();
58+
const { slides, index, styles, labels, carousel: { preload = 2 } = {} } = useLightboxContext();
4859
const { setCarouselRef } = useZoomInternal();
4960
const { rect } = useZoom();
5061

5162
return (
52-
<div ref={setCarouselRef} style={styles?.carousel} className={cssClass("carousel")}>
63+
<div
64+
ref={setCarouselRef}
65+
style={styles?.carousel}
66+
className={cssClass("carousel")}
67+
role="region"
68+
aria-live="polite"
69+
aria-label={translateLabel(labels, "Photo gallery")}
70+
aria-roledescription={translateLabel(labels, "Carousel")}
71+
>
5372
{rect &&
5473
Array.from({ length: 2 * preload + 1 }).map((_, i) => {
5574
const slideIndex = index - preload + i;
@@ -63,6 +82,7 @@ export default function Carousel() {
6382
key={slide.key ?? [`${slideIndex}`, isImageSlide(slide) && slide.src].filter(Boolean).join("|")}
6483
rect={rect}
6584
slide={slide}
85+
slideIndex={slideIndex}
6686
current={slideIndex === index}
6787
/>
6888
);

src/components/ImageSlide.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export default function ImageSlide({ slide, rect, zoom }: ImageSlideProps) {
5858
width={width}
5959
height={height}
6060
src={slide.src}
61-
alt={slide.alt}
61+
alt={slide.alt ?? ""}
6262
{...imageProps}
6363
/>
6464
);

src/components/Portal.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { createPortal } from "react-dom";
44
import useSensors from "./useSensors";
55
import { useController } from "./Controller";
66
import { useLightboxContext } from "./LightboxContext";
7-
import { clsx, cssClass, cssVar, getChildren } from "../utils";
7+
import { clsx, cssClass, cssVar, getChildren, translateLabel } from "../utils";
88
import { Callback } from "../types";
99

1010
function setAttribute(element: Element, attribute: string, value: string) {
@@ -22,7 +22,7 @@ function setAttribute(element: Element, attribute: string, value: string) {
2222
}
2323

2424
export default function Portal({ children }: PropsWithChildren) {
25-
const { styles, className } = useLightboxContext();
25+
const { labels, styles, className } = useLightboxContext();
2626

2727
const cleanup = useRef<Callback[]>([]);
2828

@@ -120,8 +120,7 @@ export default function Portal({ children }: PropsWithChildren) {
120120
<div
121121
aria-modal
122122
role="dialog"
123-
aria-live="polite"
124-
aria-roledescription="lightbox"
123+
aria-label={translateLabel(labels, "Lightbox")}
125124
tabIndex={-1}
126125
ref={handleRef}
127126
style={styles?.portal}

src/types.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,28 @@ export interface ImageSource {
7474

7575
/** Custom UI labels / translations */
7676
export interface Labels {
77+
/** `Previous` button title */
7778
Previous?: string;
79+
/** `Next` button title */
7880
Next?: string;
81+
/** `Close` button title */
7982
Close?: string;
83+
/** Lightbox ARIA label */
84+
Lightbox?: string;
85+
/** Carousel ARIA role description */
86+
Carousel?: string;
87+
/** Slide ARIA role description */
88+
Slide?: string;
89+
/** Carousel ARIA label */
90+
"Photo gallery"?: string;
91+
/**
92+
* Slide ARIA label
93+
*
94+
* The value is a template string supporting the following placeholders:
95+
* - {index} - current slide index
96+
* - {total} - total number of slides
97+
*/
98+
"{index} of {total}"?: string;
8099
}
81100

82101
/** Label key */
@@ -110,6 +129,8 @@ export interface RenderSlideProps {
110129
zoom: number;
111130
/** if `true`, the slide is the current slide in the viewport */
112131
current: boolean;
132+
/** slide index */
133+
slideIndex: number;
113134
}
114135

115136
/** Toolbar settings */

src/utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ export function translateLabel(labels: Labels | undefined, label: Label) {
3131
return labels?.[label] ?? label;
3232
}
3333

34+
export function translateSlideCounter(labels: Labels | undefined, index: number, total: number) {
35+
return translateLabel(labels, "{index} of {total}")
36+
.replace(/\{index}/g, `${index}`)
37+
.replace(/\{total}/g, `${total}`);
38+
}
39+
3440
export function makeUseContext<T>(context: Context<T | null>) {
3541
return () => {
3642
const ctx = useContext(context);

0 commit comments

Comments
 (0)