diff --git a/dev/App.tsx b/dev/App.tsx index 4b65b32..37f94b4 100644 --- a/dev/App.tsx +++ b/dev/App.tsx @@ -30,7 +30,7 @@ export default function App() { plugins={[Captions, Counter, Download, Share, Fullscreen, Slideshow, Thumbnails, Video, Zoom]} /> - diff --git a/dev/slides.ts b/dev/slides.ts index 15d0403..5a72d55 100644 --- a/dev/slides.ts +++ b/dev/slides.ts @@ -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" }, diff --git a/src/Lightbox.tsx b/src/Lightbox.tsx index 6236e82..123a279 100644 --- a/src/Lightbox.tsx +++ b/src/Lightbox.tsx @@ -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, @@ -98,7 +104,9 @@ export function Lightbox({ index={parseInt(index || defaultIndex)} > - {renderNode(createNode(RootModule, config), props)} + + {renderNode(createNode(RootModule, config), props)} + diff --git a/src/components/IconButton.tsx b/src/components/IconButton.tsx index be34f20..b37df39 100644 --- a/src/components/IconButton.tsx +++ b/src/components/IconButton.tsx @@ -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"; diff --git a/src/components/ImageSlide.tsx b/src/components/ImageSlide.tsx index cfcdaf3..3557a57 100644 --- a/src/components/ImageSlide.tsx +++ b/src/components/ImageSlide.tsx @@ -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} diff --git a/src/components/LightboxRoot.tsx b/src/components/LightboxRoot.tsx index 973d89f..2371800 100644 --- a/src/components/LightboxRoot.tsx +++ b/src/components/LightboxRoot.tsx @@ -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>(function LightboxRoot( - { className, children, ...rest }, + { className, children, onFocus, onBlur, ...rest }, ref, ) { const nodeRef = React.useRef(null); + const { trackFocusWithin } = useA11yContext(); return ( -
+
{children}
diff --git a/src/contexts/A11yContext.tsx b/src/contexts/A11yContext.tsx new file mode 100644 index 0000000..e1d7ca7 --- /dev/null +++ b/src/contexts/A11yContext.tsx @@ -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(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 {children}; +} diff --git a/src/contexts/index.ts b/src/contexts/index.ts index 71ce013..4c90bf0 100644 --- a/src/contexts/index.ts +++ b/src/contexts/index.ts @@ -1,3 +1,4 @@ +export * from "./A11yContext.js"; export * from "./DocumentContext.js"; export * from "./Events.js"; export * from "./LightboxProps.js"; diff --git a/src/modules/Carousel.tsx b/src/modules/Carousel.tsx index c021093..cb13345 100644 --- a/src/modules/Carousel.tsx +++ b/src/modules/Carousel.tsx @@ -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) { @@ -36,13 +38,14 @@ type CarouselSlideProps = { function CarouselSlide({ slide, offset }: CarouselSlideProps) { const containerRef = React.useRef(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(); @@ -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()}
@@ -103,9 +107,10 @@ function Placeholder() { return
; } -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); @@ -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 ? : , diff --git a/src/modules/Controller/Controller.tsx b/src/modules/Controller/Controller.tsx index 2239010..a8cb937 100644 --- a/src/modules/Controller/Controller.tsx +++ b/src/modules/Controller/Controller.tsx @@ -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} > diff --git a/src/modules/Portal.tsx b/src/modules/Portal.tsx index f63f4e9..0e4e75f 100644 --- a/src/modules/Portal.tsx +++ b/src/modules/Portal.tsx @@ -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"; @@ -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); @@ -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` } diff --git a/src/plugins/captions/Description.tsx b/src/plugins/captions/Description.tsx index 497c42f..63a0fb3 100644 --- a/src/plugins/captions/Description.tsx +++ b/src/plugins/captions/Description.tsx @@ -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"; @@ -9,7 +9,7 @@ export type DescriptionProps = Pick; export function Description({ description }: DescriptionProps) { const { descriptionTextAlign, descriptionMaxLines } = useCaptionsProps(); - const { styles } = useLightboxProps(); + const { styles, labels } = useLightboxProps(); const { visible } = useCaptions(); if (!visible) return null; @@ -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 ? [
] : []), line]) diff --git a/src/plugins/captions/Title.tsx b/src/plugins/captions/Title.tsx index b6a03dc..b285fd0 100644 --- a/src/plugins/captions/Title.tsx +++ b/src/plugins/captions/Title.tsx @@ -15,6 +15,8 @@ export function Title({ title }: TitleProps) { return (
diff --git a/src/plugins/captions/index.ts b/src/plugins/captions/index.ts index eb39733..2bf94eb 100644 --- a/src/plugins/captions/index.ts +++ b/src/plugins/captions/index.ts @@ -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; } diff --git a/src/plugins/counter/Counter.tsx b/src/plugins/counter/Counter.tsx index 2bc77cd..2679c77 100644 --- a/src/plugins/counter/Counter.tsx +++ b/src/plugins/counter/Counter.tsx @@ -26,7 +26,13 @@ export function CounterComponent({ counter }: ComponentProps) { if (slides.length === 0) return null; return ( -
+
{currentIndex + 1} {separator} {slides.length}
); diff --git a/src/plugins/download/index.ts b/src/plugins/download/index.ts index 92bbabe..39144fe 100644 --- a/src/plugins/download/index.ts +++ b/src/plugins/download/index.ts @@ -36,6 +36,7 @@ declare module "../../types.js" { } interface Labels { + /** `Download` button title */ Download?: string; } diff --git a/src/plugins/fullscreen/index.ts b/src/plugins/fullscreen/index.ts index 234258b..1a85141 100644 --- a/src/plugins/fullscreen/index.ts +++ b/src/plugins/fullscreen/index.ts @@ -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; } diff --git a/src/plugins/share/index.ts b/src/plugins/share/index.ts index 884e641..7ca127b 100644 --- a/src/plugins/share/index.ts +++ b/src/plugins/share/index.ts @@ -35,6 +35,7 @@ declare module "../../types.js" { } interface Labels { + /** `Share` button title */ Share?: string; } diff --git a/src/plugins/slideshow/SlideshowContext.tsx b/src/plugins/slideshow/SlideshowContext.tsx index f33f158..5575c0c 100644 --- a/src/plugins/slideshow/SlideshowContext.tsx +++ b/src/plugins/slideshow/SlideshowContext.tsx @@ -14,6 +14,7 @@ import { SLIDE_STATUS_PLAYING, SlideshowRef, SlideStatus, + useA11yContext, useController, useEventCallback, useEvents, @@ -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(() => { diff --git a/src/plugins/slideshow/index.ts b/src/plugins/slideshow/index.ts index 131734e..e6c31f1 100644 --- a/src/plugins/slideshow/index.ts +++ b/src/plugins/slideshow/index.ts @@ -26,7 +26,9 @@ declare module "../../types.js" { } interface Labels { + /** `Play` button title */ Play?: string; + /** `Pause` button title */ Pause?: string; } diff --git a/src/plugins/thumbnails/Thumbnail.tsx b/src/plugins/thumbnails/Thumbnail.tsx index 9b05960..dae3709 100644 --- a/src/plugins/thumbnails/Thumbnail.tsx +++ b/src/plugins/thumbnails/Thumbnail.tsx @@ -12,9 +12,11 @@ import { makeComposePrefix, RenderThumbnailProps, Slide, + translateSlideCounter, useDocumentContext, useEventCallback, useLightboxProps, + useLightboxState, } from "../../index.js"; import { cssThumbnailPrefix } from "./utils.js"; import { useThumbnailsProps } from "./props.js"; @@ -75,20 +77,22 @@ export type FadeSettings = { export type ThumbnailProps = { slide: Slide | null; + index: number; onClick: () => void; - active: boolean; fadeIn?: FadeSettings; fadeOut?: FadeSettings; placeholder: boolean; onLoseFocus: () => void; }; -export function Thumbnail({ slide, onClick, active, fadeIn, fadeOut, placeholder, onLoseFocus }: ThumbnailProps) { +export function Thumbnail({ slide, index, onClick, fadeIn, fadeOut, placeholder, onLoseFocus }: ThumbnailProps) { const ref = React.useRef(null); - const { render, styles } = useLightboxProps(); + const { render, styles, labels } = useLightboxProps(); + const { slides, globalIndex } = useLightboxState(); const { getOwnerDocument } = useDocumentContext(); const { width, height, imageFit } = useThumbnailsProps(); const rect = { width, height }; + const active = index === globalIndex; const onLoseFocusCallback = useEventCallback(onLoseFocus); @@ -126,6 +130,8 @@ export function Thumbnail({ slide, onClick, active, fadeIn, fadeOut, placeholder ...styles.thumbnail, }} onClick={onClick} + aria-current={active ? true : undefined} + aria-label={translateSlideCounter(labels, slides, index)} > {slide && renderThumbnail({ slide, render, rect, imageFit })} diff --git a/src/plugins/thumbnails/ThumbnailsTrack.tsx b/src/plugins/thumbnails/ThumbnailsTrack.tsx index f2ef5db..c2da34d 100644 --- a/src/plugins/thumbnails/ThumbnailsTrack.tsx +++ b/src/plugins/thumbnails/ThumbnailsTrack.tsx @@ -14,6 +14,7 @@ import { getSlideKey, hasSlides, Slide, + translateLabel, useAnimation, useEventCallback, useEvents, @@ -59,7 +60,7 @@ export function ThumbnailsTrack({ visible, containerRef }: ThumbnailsTrackProps) const isRTL = useRTL(); const { publish, subscribe } = useEvents(); - const { carousel, styles } = useLightboxProps(); + const { carousel, styles, labels } = useLightboxProps(); const { slides, globalIndex, animation } = useLightboxState(); const { registerSensors, subscribeSensors } = useSensors(); @@ -161,6 +162,7 @@ export function ThumbnailsTrack({ visible, containerRef }: ThumbnailsTrackProps) ref={track} style={styles.thumbnailsTrack} className={clsx(cssClass(cssPrefix("track")), cssClass(CLASS_FLEX_CENTER))} + aria-label={translateLabel(labels, "Thumbnails")} tabIndex={-1} {...registerSensors} > @@ -193,8 +195,8 @@ export function ThumbnailsTrack({ visible, containerRef }: ThumbnailsTrackProps) return ( composePrefix(base, prefix); } -export function label(labels: Labels | undefined, defaultLabel: Label) { +export function translateLabel(labels: Labels | undefined, defaultLabel: Label) { return labels?.[defaultLabel] ?? defaultLabel; } +/** @deprecated - use `translateLabel` instead */ +export function label(labels: Labels | undefined, defaultLabel: Label) { + return translateLabel(labels, defaultLabel); +} + +export function translateSlideCounter(labels: Labels | undefined, slides: Slide[], index: number) { + return translateLabel(labels, "{index} of {total}") + .replace(/\{index}/g, `${getSlideIndex(index, slides.length) + 1}`) + .replace(/\{total}/g, `${slides.length}`); +} + export function cleanup(...cleaners: (() => void)[]) { return () => { cleaners.forEach((cleaner) => { diff --git a/test/unit/core/utils.spec.ts b/test/unit/core/utils.spec.ts index 047e57d..da403fe 100644 --- a/test/unit/core/utils.spec.ts +++ b/test/unit/core/utils.spec.ts @@ -2,7 +2,7 @@ import * as React from "react"; import { render, screen } from "@testing-library/react"; import { vi } from "vitest"; -import { cleanup, clsx, cssClass, cssVar, label, makeUseContext } from "../../../src/utils.js"; +import { cleanup, clsx, cssClass, cssVar, makeUseContext, translateLabel } from "../../../src/utils.js"; import { Labels } from "../../../src/types.js"; describe("utils", () => { @@ -28,24 +28,24 @@ describe("utils", () => { }); }); - describe("label", () => { + describe("translateLabel", () => { const labels: Labels = { Previous: "previous", Next: "next", }; it("can be called with no labels", () => { - expect(label(undefined, "Previous")).toBe("Previous"); + expect(translateLabel(undefined, "Previous")).toBe("Previous"); }); it("handles absent translation correctly", () => { // @ts-expect-error - expected error - expect(label(labels, "Other")).toBe("Other"); + expect(translateLabel(labels, "Other")).toBe("Other"); }); it("translates labels correctly", () => { - expect(label(labels, "Previous")).toBe("previous"); - expect(label(labels, "Next")).toBe("next"); + expect(translateLabel(labels, "Previous")).toBe("previous"); + expect(translateLabel(labels, "Next")).toBe("next"); }); });