Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
0100b6e
fix(a11y): identify the slide description
ubermanu Jun 5, 2025
5d1e5ad
fix(a11y): allow translation of roles descriptions
ubermanu Jun 5, 2025
9d0cdd4
fix(a11y): add slide label
ubermanu Jun 5, 2025
7ce34c0
fix(a11y): remove aria-live=polite on modal
ubermanu Jun 5, 2025
11bb01a
fix(a11y): always set ARIA attributes on carousel
ubermanu Jun 5, 2025
c29ca27
fix(a11y): announce the dialog role
ubermanu Jun 5, 2025
e57354d
fix(a11y): add focus trap on dialog
ubermanu Jun 5, 2025
dde8733
chore: add focus-trap-react to external deps
ubermanu Jun 5, 2025
57ea627
fix: tests
ubermanu Jun 5, 2025
a2ecd29
fix(a11y): hide counter for assistive technologies
Jun 6, 2025
64b3450
fix(a11y): remove focus trap
ubermanu Jun 6, 2025
3623f4b
fix(a11y): announce that the button opens a dialog
ubermanu Jun 6, 2025
8f7df8b
fix(a11y): use correct slide index in ariaLabel
ubermanu Jun 6, 2025
154d66d
fix(a11y): set title as heading
ubermanu Jun 6, 2025
ac7d92c
fix(a11y): update thumbnails
ubermanu Jun 6, 2025
5bdc8f9
fix(a11y): update slide counter label, for a more natural version
ubermanu Jun 6, 2025
dd152c3
fix(a11y): attach gallery controls + attrs on the carousel
ubermanu Jun 6, 2025
7a1cef1
fix(a11y): focus the gallery when mounted in a dialog
ubermanu Jun 6, 2025
973f34e
fix(a11y): restore controller as the sensors mount point
ubermanu Jun 6, 2025
dbf7b1e
fix(a11y): disable aria-live when playback is enabled and focus is in…
ubermanu Jun 10, 2025
47e163c
fix(a11y): add empty alt attribute if not defined
ubermanu Jun 10, 2025
5e19923
fix(a11y): disable aria-live when playing and focus is outside the ca…
ubermanu Jun 10, 2025
20baf2a
fix(a11y): remove slide title from thumbnail label
ubermanu Jun 26, 2025
fc01c3e
fix(a11y): move focus back to the container
ubermanu Jun 26, 2025
5a84ed3
revert(a11y): disable aria-live when playing and focus is outside the…
igordanchenko Jun 26, 2025
0e7657f
revert(a11y): disable aria-live when playback is enabled and focus is…
igordanchenko Jun 26, 2025
4f614da
fix(a11y): disable aria-live when playback is enabled and focus is in…
igordanchenko Jun 26, 2025
fb690c2
refactor: rename label to translateLabel
igordanchenko Jun 26, 2025
daeac04
refactor: code cleanup
igordanchenko Jun 26, 2025
c1dea5b
refactor: streamline slide counter label format
igordanchenko Jun 26, 2025
b15d2ae
fix(a11y): change slide role to group
igordanchenko Jun 26, 2025
0ecfca6
feat: add alternative texts to some images in dev mode
ubermanu Jun 30, 2025
ace2136
docs: add labels localization documentation
igordanchenko Jul 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dev/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export default function App() {
plugins={[Captions, Counter, Download, Share, Fullscreen, Slideshow, Thumbnails, Video, Zoom]}
/>

<button type="button" className="button" onClick={() => setOpen(true)}>
<button type="button" className="button" aria-haspopup="dialog" onClick={() => setOpen(true)}>
Open Lightbox
</button>
</>
Expand Down
18 changes: 15 additions & 3 deletions dev/slides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,21 @@ function imageLink(asset: string, size: number) {
}

export const slides = [
{ asset: "image01.0800ee93.3840x5760" },
{ asset: "image02.645bc7e4.3840x5070" },
{ asset: "image03.13c5eeb7.3840x5120" },
{
asset: "image01.0800ee93.3840x5760",
alt: "A small black and tan dog wearing oversized black sunglasses, posing confidently against a yellow background",
description: "Puppy in sunglasses",
},
{
asset: "image02.645bc7e4.3840x5070",
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",
description: "Miami beach",
},
{
asset: "image03.13c5eeb7.3840x5120",
alt: "A bright pink inflatable flamingo floating in clear turquoise ocean water under a sunny, cloudless sky",
description: "Flamingo",
},
{ asset: "image04.2d71a97f.3840x2546" },
{ asset: "image05.c6ce32ab.3840x5760" },
{ asset: "image06.74d5e191.3840x2553" },
Expand Down
12 changes: 10 additions & 2 deletions src/Lightbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import { AnimationSettings, ComponentProps, LightboxExternalProps, Node } from "
import { parseInt } from "./utils.js";
import { LightboxDefaultProps } from "./props.js";
import { createNode, withPlugins } from "./config.js";
import { EventsProvider, LightboxPropsProvider, LightboxStateProvider, TimeoutsProvider } from "./contexts/index.js";
import {
A11yContextProvider,
EventsProvider,
LightboxPropsProvider,
LightboxStateProvider,
TimeoutsProvider,
} from "./contexts/index.js";
import {
CarouselModule,
ControllerModule,
Expand Down Expand Up @@ -98,7 +104,9 @@ export function Lightbox({
index={parseInt(index || defaultIndex)}
>
<TimeoutsProvider>
<EventsProvider>{renderNode(createNode(RootModule, config), props)}</EventsProvider>
<EventsProvider>
<A11yContextProvider>{renderNode(createNode(RootModule, config), props)}</A11yContextProvider>
</EventsProvider>
</TimeoutsProvider>
</LightboxStateProvider>
</LightboxPropsProvider>
Expand Down
2 changes: 1 addition & 1 deletion src/components/IconButton.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from "react";

import { clsx, cssClass, label as translateLabel } from "../utils.js";
import { clsx, cssClass, translateLabel } from "../utils.js";
import { useLightboxProps } from "../contexts/index.js";
import { ELEMENT_BUTTON, ELEMENT_ICON } from "../consts.js";
import { Label } from "../types.js";
Expand Down
2 changes: 1 addition & 1 deletion src/components/ImageSlide.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ export function ImageSlide({
)}
style={{ ...defaultStyle, ...style, ...imagePropsStyle }}
{...restImageProps}
alt={image.alt}
alt={image.alt ?? ""}
sizes={sizes}
srcSet={srcSet}
src={image.src}
Expand Down
12 changes: 9 additions & 3 deletions src/components/LightboxRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,23 @@ import * as React from "react";

import { clsx, cssClass } from "../utils.js";
import { useForkRef } from "../hooks/index.js";
import { DocumentContextProvider } from "../contexts/index.js";
import { DocumentContextProvider, useA11yContext } from "../contexts/index.js";

const LightboxRoot = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(function LightboxRoot(
{ className, children, ...rest },
{ className, children, onFocus, onBlur, ...rest },
ref,
) {
const nodeRef = React.useRef<HTMLDivElement>(null);
const { trackFocusWithin } = useA11yContext();

return (
<DocumentContextProvider nodeRef={nodeRef}>
<div ref={useForkRef(ref, nodeRef)} className={clsx(cssClass("root"), className)} {...rest}>
<div
ref={useForkRef(ref, nodeRef)}
className={clsx(cssClass("root"), className)}
{...trackFocusWithin(onFocus, onBlur)}
{...rest}
>
{children}
</div>
</DocumentContextProvider>
Expand Down
46 changes: 46 additions & 0 deletions src/contexts/A11yContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import * as React from "react";

import { makeUseContext } from "../utils.js";

export type A11yContextType = {
focusWithin: boolean;
trackFocusWithin: (
onFocus?: React.FocusEventHandler,
onBlur?: React.FocusEventHandler,
) => {
onFocus: React.FocusEventHandler;
onBlur: React.FocusEventHandler;
};
autoPlaying: boolean;
setAutoPlaying: (value: boolean) => void;
};

export const A11yContext = React.createContext<A11yContextType | null>(null);

export const useA11yContext = makeUseContext("useA11yContext", "A11yContext", A11yContext);

export function A11yContextProvider({ children }: React.PropsWithChildren) {
const [focusWithin, setFocusWithin] = React.useState(false);
const [autoPlaying, setAutoPlaying] = React.useState(false);

const context = React.useMemo(() => {
const trackFocusWithin: A11yContextType["trackFocusWithin"] = (onFocus, onBlur) => {
const trackAndDelegate = (focusWithinValue: boolean) => (event: React.FocusEvent) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
setFocusWithin(focusWithinValue);
}

(focusWithinValue ? onFocus : onBlur)?.(event);
};

return {
onFocus: trackAndDelegate(true),
onBlur: trackAndDelegate(false),
};
};

return { focusWithin, trackFocusWithin, autoPlaying, setAutoPlaying };
}, [focusWithin, autoPlaying]);

return <A11yContext.Provider value={context}>{children}</A11yContext.Provider>;
}
1 change: 1 addition & 0 deletions src/contexts/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./A11yContext.js";
export * from "./DocumentContext.js";
export * from "./Events.js";
export * from "./LightboxProps.js";
Expand Down
19 changes: 14 additions & 5 deletions src/modules/Carousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ import {
isImageSlide,
makeInertWhen,
parseLengthPercentage,
translateLabel,
translateSlideCounter,
} from "../utils.js";
import { ImageSlide } from "../components/index.js";
import { useController } from "./Controller/index.js";
import { useDocumentContext, useLightboxProps, useLightboxState } from "../contexts/index.js";
import { useA11yContext, useDocumentContext, useLightboxProps, useLightboxState } from "../contexts/index.js";
import { CLASS_FLEX_CENTER, CLASS_SLIDE, MODULE_CAROUSEL } from "../consts.js";

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

const { currentIndex } = useLightboxState();
const { currentIndex, slides } = useLightboxState();
const { slideRect, focus } = useController();
const {
render,
carousel: { imageFit, imageProps },
on: { click: onClick },
styles: { slide: style },
labels,
} = useLightboxProps();
const { getOwnerDocument } = useDocumentContext();

Expand Down Expand Up @@ -90,8 +93,9 @@ function CarouselSlide({ slide, offset }: CarouselSlideProps) {
)}
{...makeInertWhen(offscreen)}
style={style}
role="region"
aria-roledescription="slide"
role="group"
aria-roledescription={translateLabel(labels, "Slide")}
aria-label={translateSlideCounter(labels, slides, currentIndex + offset)}
>
{renderSlide()}
</div>
Expand All @@ -103,9 +107,10 @@ function Placeholder() {
return <div className={cssClass(CLASS_SLIDE)} style={style} />;
}

export function Carousel({ carousel }: ComponentProps) {
export function Carousel({ carousel, labels }: ComponentProps) {
const { slides, currentIndex, globalIndex } = useLightboxState();
const { setCarouselRef } = useController();
const { autoPlaying, focusWithin } = useA11yContext();

const spacingValue = parseLengthPercentage(carousel.spacing);
const paddingValue = parseLengthPercentage(carousel.padding);
Expand Down Expand Up @@ -142,6 +147,10 @@ export function Carousel({ carousel }: ComponentProps) {
[`${cssVar(cssPrefix("padding_px"))}`]: paddingValue.pixel || 0,
[`${cssVar(cssPrefix("padding_percent"))}`]: paddingValue.percent || 0,
}}
role="region"
aria-live={autoPlaying && !focusWithin ? "off" : "polite"}
aria-roledescription={translateLabel(labels, "Carousel")}
aria-label={translateLabel(labels, "Photo gallery")}
>
{items.map(({ key, slide, offset }) =>
slide ? <CarouselSlide key={key} slide={slide} offset={offset} /> : <Placeholder key={key} />,
Expand Down
1 change: 0 additions & 1 deletion src/modules/Controller/Controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,6 @@ export function Controller({ children, ...props }: ComponentProps) {
...(controller.touchAction !== "none" ? { [cssVar("controller_touch_action")]: controller.touchAction } : null),
...styles.container,
}}
{...(controller.aria ? { role: "region", "aria-live": "polite", "aria-roledescription": "carousel" } : null)}
tabIndex={-1}
{...registerSensors}
>
Expand Down
7 changes: 3 additions & 4 deletions src/modules/Portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { createPortal } from "react-dom";
import { ComponentProps } from "../types.js";
import { LightboxDefaultProps } from "../props.js";
import { createModule } from "../config.js";
import { clsx, composePrefix, cssClass, cssVar, reflow } from "../utils.js";
import { clsx, composePrefix, cssClass, cssVar, reflow, translateLabel } from "../utils.js";
import { useEventCallback, useMotionPreference } from "../hooks/index.js";
import { useEvents, useTimeouts } from "../contexts/index.js";
import { LightboxRoot } from "../components/index.js";
Expand All @@ -28,7 +28,7 @@ function setAttribute(element: Element, attribute: string, value: string) {
};
}

export function Portal({ children, animation, styles, className, on, portal, close }: ComponentProps) {
export function Portal({ children, animation, styles, className, on, portal, close, labels }: ComponentProps) {
const [mounted, setMounted] = React.useState(false);
const [visible, setVisible] = React.useState(false);

Expand Down Expand Up @@ -119,8 +119,7 @@ export function Portal({ children, animation, styles, className, on, portal, clo
)}
aria-modal
role="dialog"
aria-live="polite"
aria-roledescription="lightbox"
aria-label={translateLabel(labels, "Lightbox")}
style={{
...(animation.fade !== LightboxDefaultProps.animation.fade
? { [cssVar("fade_animation_duration")]: `${animationDuration}ms` }
Expand Down
6 changes: 4 additions & 2 deletions src/plugins/captions/Description.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from "react";

import { clsx, cssVar, Slide, useLightboxProps } from "../../index.js";
import { clsx, cssVar, Slide, translateLabel, useLightboxProps } from "../../index.js";
import { cssPrefix } from "./utils.js";
import { defaultCaptionsProps, useCaptionsProps } from "./props.js";
import { useCaptions } from "./CaptionsContext.js";
Expand All @@ -9,7 +9,7 @@ export type DescriptionProps = Pick<Slide, "description">;

export function Description({ description }: DescriptionProps) {
const { descriptionTextAlign, descriptionMaxLines } = useCaptionsProps();
const { styles } = useLightboxProps();
const { styles, labels } = useLightboxProps();
const { visible } = useCaptions();

if (!visible) return null;
Expand All @@ -32,6 +32,8 @@ export function Description({ description }: DescriptionProps) {
: null),
...styles.captionsDescription,
}}
role="paragraph"
aria-roledescription={translateLabel(labels, "Caption")}
>
{typeof description === "string"
? description.split("\n").flatMap((line, index) => [...(index > 0 ? [<br key={index} />] : []), line])
Expand Down
2 changes: 2 additions & 0 deletions src/plugins/captions/Title.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export function Title({ title }: TitleProps) {

return (
<div
role="heading"
aria-level={2}
style={styles.captionsTitleContainer}
className={clsx(cssPrefix("captions_container"), cssPrefix("title_container"))}
>
Expand Down
4 changes: 4 additions & 0 deletions src/plugins/captions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ declare module "../../types.js" {
}

interface Labels {
/** Slide description ARIA role description */
Caption?: string;
/** `Show captions` button title */
"Show captions"?: string;
/** `Hide captions` button title */
"Hide captions"?: string;
}

Expand Down
8 changes: 7 additions & 1 deletion src/plugins/counter/Counter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@ export function CounterComponent({ counter }: ComponentProps) {
if (slides.length === 0) return null;

return (
<div className={clsx(cssClass("counter"), className || legacyClassName)} {...legacyRest} {...rest}>
<div
className={clsx(cssClass("counter"), className || legacyClassName)}
{...legacyRest}
{...rest}
// slides already provide the "k of N" label, so there is no need to override the `aria-hidden` attribute here
aria-hidden
>
{currentIndex + 1} {separator} {slides.length}
</div>
);
Expand Down
1 change: 1 addition & 0 deletions src/plugins/download/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ declare module "../../types.js" {
}

interface Labels {
/** `Download` button title */
Download?: string;
}

Expand Down
2 changes: 2 additions & 0 deletions src/plugins/fullscreen/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ declare module "../../types.js" {

interface Labels {
// TODO v4: change Fullscreen to lowercase
/** `Enter Fullscreen` button title */
"Enter Fullscreen"?: string;
/** `Exit Fullscreen` button title */
"Exit Fullscreen"?: string;
}

Expand Down
1 change: 1 addition & 0 deletions src/plugins/share/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ declare module "../../types.js" {
}

interface Labels {
/** `Share` button title */
Share?: string;
}

Expand Down
5 changes: 5 additions & 0 deletions src/plugins/slideshow/SlideshowContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
SLIDE_STATUS_PLAYING,
SlideshowRef,
SlideStatus,
useA11yContext,
useController,
useEventCallback,
useEvents,
Expand All @@ -40,6 +41,10 @@ export function SlideshowContextProvider({ slideshow, carousel: { finite }, on,
const { subscribe } = useEvents();
const { next } = useController();

const { setAutoPlaying } = useA11yContext();

React.useEffect(() => setAutoPlaying(playing), [playing, setAutoPlaying]);

const disabled = slides.length === 0 || (finite && currentIndex === slides.length - 1);

const play = React.useCallback(() => {
Expand Down
2 changes: 2 additions & 0 deletions src/plugins/slideshow/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ declare module "../../types.js" {
}

interface Labels {
/** `Play` button title */
Play?: string;
/** `Pause` button title */
Pause?: string;
}

Expand Down
Loading