diff --git a/.changeset/lightbox-gallery-caption-row.md b/.changeset/lightbox-gallery-caption-row.md
new file mode 100644
index 0000000000000..c20429a24bd8e
--- /dev/null
+++ b/.changeset/lightbox-gallery-caption-row.md
@@ -0,0 +1,9 @@
+---
+'@astryxdesign/core': patch
+---
+
+[feat] A Lightbox gallery's caption sits in a band two lines tall under every photo, so the photo keeps one size and place from item to item. A photo with no caption keeps the band's space without its backing. A longer caption shows two lines, the second ending in an inline "more" link, which opens the whole caption over the photo. The whole caption is always the viewer's accessible description.
+
+The viewer is a media surface: its controls, caption and counter take the theme's dark surface tokens through `MediaTheme`. The caption is body `Text` whose lines start at one edge, "more" and "less" are `Link`'s button form in the caption's own type, and the band clears the screen's bottom edge and its safe area. A gallery's caption sits on a backing that keeps it at 4.5:1 over any photo. A tap that opens the viewer paints no focus ring on its first control, and paging from a focused "more" or "less" keeps focus in the viewer.
+
+@vjeux
diff --git a/.changeset/lightbox-gallery-paging.md b/.changeset/lightbox-gallery-paging.md
index 5fc684ceced15..623e6f6085cd6 100644
--- a/.changeset/lightbox-gallery-paging.md
+++ b/.changeset/lightbox-gallery-paging.md
@@ -4,7 +4,7 @@
[feat] A Lightbox gallery now pages the way the iOS Photos viewer does. On a phone or tablet, drag or flick between items: the row follows your finger, and the release lands on the next or previous item, one at most. A gap of 40 px separates the items while they move. A zoomed photo pans with your finger until it reaches its edge; a new drag from that edge moves to the next item. A photo you leave zoomed returns to its fitted size once the gallery rests. Under a finger, the previous and next buttons and the arrow keys slide to the item instead of swapping it in place.
-Each item's caption moves with its item, right under its photo. `onIndexChange` also reports a swipe.
+Each item's caption moves with its item, in a caption band the gallery's photos share. `onIndexChange` also reports a swipe.
Lightbox now loads its touch code with it, as Toast and Slider do. Its import grows from 51.4 KB to 99.6 KB gzip with the zoom and paging beneath, mostly shared touch modules that a page loads once; Lightbox's own code adds 14.1 KB minified.
diff --git a/.changeset/lightbox-single-caption-backing.md b/.changeset/lightbox-single-caption-backing.md
new file mode 100644
index 0000000000000..f4f321442d3ab
--- /dev/null
+++ b/.changeset/lightbox-single-caption-backing.md
@@ -0,0 +1,7 @@
+---
+'@astryxdesign/core': patch
+---
+
+[fix] A single photo's caption in a `Lightbox` now shows as a gallery's does. It sits in the band at the bottom of the viewer, on the caption's backing, which keeps 4.5:1 over any page behind it. The photo centres above it. A caption longer than two lines ends in "more", which opens it in full over the photo. A single photo with no caption shows no band.
+
+@vjeux
diff --git a/apps/storybook/stories/Lightbox.stories.tsx b/apps/storybook/stories/Lightbox.stories.tsx
index 59297bca0a938..cf85218db250d 100644
--- a/apps/storybook/stories/Lightbox.stories.tsx
+++ b/apps/storybook/stories/Lightbox.stories.tsx
@@ -128,7 +128,7 @@ export const WithZoom: Story = {
},
};
-/** Zoom on a photo with a caption, which sits under the fitted photo. */
+/** Zoom on a photo with a caption, in the caption band a gallery's pages use. */
export const ZoomWithCaption: Story = {
render: () => {
const [isOpen, setIsOpen] = useState(false);
@@ -176,8 +176,9 @@ export const GalleryWithZoom: Story = {
};
/**
- * A gallery whose captions differ in length: the photo keeps one box
- * whichever pages, and so whichever captions, are rendered.
+ * A gallery whose captions differ in length. The first runs long, so two
+ * lines cut it short and "more" shows it all. The next runs about two lines,
+ * one has none, and the last is short. The photo keeps one box on every page.
*/
export const GalleryWithCaptions: Story = {
render: () => {
@@ -194,9 +195,13 @@ export const GalleryWithCaptions: Story = {
{
...GALLERY_MEDIA[0],
caption:
- 'A winding path through the forest, past the old mill and down to the river, where the trail turns back toward the village',
+ 'A winding path through the forest, past the old mill and down to the river, where the trail turns back toward the village. In autumn the beeches along it turn copper, and on a clear morning you can see the church spire from the bridge before the path climbs the last hill home.',
+ },
+ {
+ ...GALLERY_MEDIA[1],
+ caption:
+ 'Morning fog lifting off the lake, seen from the old stone bridge',
},
- GALLERY_MEDIA[1],
GALLERY_MEDIA[3],
{...GALLERY_MEDIA[2], caption: 'Golden hour'},
]}
@@ -233,6 +238,33 @@ export const GalleryKeepsIndex: Story = {
},
};
+/**
+ * A single photo whose caption runs long. As on a gallery's page, two lines
+ * cut it short and "more" shows it all.
+ */
+export const SingleWithLongCaption: Story = {
+ render: () => {
+ const [isOpen, setIsOpen] = useState(false);
+ return (
+ <>
+
+
+ >
+ );
+ },
+};
+
export const WithCaption: Story = {
render: () => {
const [isOpen, setIsOpen] = useState(false);
diff --git a/docs/specs/AST-038-layer-text-boundary/spec.md b/docs/specs/AST-038-layer-text-boundary/spec.md
index 7af573ab03b97..2f9858cbe5ecb 100644
--- a/docs/specs/AST-038-layer-text-boundary/spec.md
+++ b/docs/specs/AST-038-layer-text-boundary/spec.md
@@ -298,7 +298,7 @@ and ownership guards MUST remain component-owned when that equivalence is absent
| [useLayer](../../../packages/core/src/Layer/useLayer.tsx) base typography | Existing body family/size/leading are the one equivalent baseline moved into the private shared reset by the text-only implementation. No token/value or precedence change for those three declarations is intended. |
| [Tooltip](../../../packages/core/src/Tooltip/useTooltip.tsx) painted surface | Explicit body type, inverted colors, and content `wordBreak: break-word` are deliberate. Keep them; moving inner declarations outward can change supported overrides even where values match. |
| [Toast](../../../packages/core/src/Toast/Toast.tsx) | Uses body **defaults** for size/leading, surface inversion, and content `overflowWrap: anywhere`. This is not equivalent to replacing them with inherited live body variables. Keep them. |
-| [Lightbox](../../../packages/core/src/Lightbox/Lightbox.tsx), menu rows, keyboard hints, chart/radial tooltips | Centered large captions, menu label/supporting type and start alignment, smaller nowrap keyboard hints, and compact chart wrapping remain intentional local presentation. Do not delete them as redundant resets. |
+| [Lightbox](../../../packages/core/src/Lightbox/Lightbox.tsx), menu rows, keyboard hints, chart/radial tooltips | Lightbox's caption renders through Text under MediaTheme (`component:Lightbox`). Menu label/supporting type and start alignment, smaller nowrap keyboard hints, and compact chart wrapping remain intentional local presentation. Do not delete them as redundant resets. |
| [useLayer portal writing context](../../../packages/core/src/Layer/useLayer.tsx), [padding reset](../../../packages/core/src/Layout/padding.stylex.ts) | Corrective portals preserve lost direction/writing mode without snapshotting tokens. The existing padding boundary resets its own structural variables. Preserve both; neither is a generic group or text barrier. |
| Button/InputGroup end-cap rules and Collapsible/BottomSheetSwitcher context guards | Already solve distinct membership/ownership problems. Preserve them; the text boundary does not replace sibling classification, nested presentation isolation, or sheet registration scope. |
| `all: unset` on Item/TreeListItem/Collapsible/Stepper/Token/Thumbnail/ChatComposer controls | Local UA/control resets, not layer-root inheritance fixes. No new `all`/`unset`/`revert` layer reset is proposed. ToastViewport's `inset: unset` is geometry; menuWidth's `unset`/`revert` strings are caller-supplied width keywords routed to inline-size rather than `min()`, not a general inheritance reset. |
diff --git a/docs/specs/components/Lightbox/Lightbox.spec.md b/docs/specs/components/Lightbox/Lightbox.spec.md
index 0666e8a718b51..2e2e7f52229f5 100644
--- a/docs/specs/components/Lightbox/Lightbox.spec.md
+++ b/docs/specs/components/Lightbox/Lightbox.spec.md
@@ -94,6 +94,8 @@ the requirement it misses and the person it affects.
- **Fine-pointer gestures (FR11).** FR11's drag paging (FR7) and drag to
close (FR8) are not built for a mouse, a pen or a trackpad: a drag on a
fitted photo does nothing.
+ On a desktop, a click in the viewer outside the photo and the caption band closes it, as a click outside the photo did before.
+ With zoom, a double-click on a caption zooms the photo, and a mouse does not select the caption's text, for one photo or a gallery, as a gallery's caption did before.
- **Double-click listener (FR14).** A mouse's or a pen's double-click reaches
the zoom through the stage's own `dblclick` listener, since only the browser
knows the system's double-click speed.
@@ -132,26 +134,28 @@ FR21). Consumer props and usage remain documented in `Lightbox.doc.mjs`.
## Behavioral and layout contract
-| ID | Candidate invariant | Basis | Draft review state |
-| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | --------------------------- |
-| FR1 | The viewer contains one active Media item and a Close button; galleries additionally contain Previous and Next buttons plus a Counter, and Caption renders only when supplied. | Current source, docs, and tests | settled |
-| FR2 | Viewer overlay carries the current `lightbox` target. Media, Caption, and Counter carry no Lightbox public target. | Current source and public docs | settled |
-| FR3 | Close, Previous, and Next button surfaces use Button's `button` target through IconButton rather than Lightbox-owned button targets. | Current source and target docs | settled |
-| FR4 | The open viewer is a focus region through Core's shared focus-region module (`component:Dialog/DEC-2`): Tab from its last control moves focus to its first and Shift+Tab from its first moves focus to its last, so focus never leaves the viewer for the browser's own interface. | `component:Dialog/DEC-2` | settled; implementation gap |
-| FR5 | Under a coarse primary pointer, after hydration, Lightbox presents FR6–FR10 with no opt-in. Lightbox's main interaction is a gesture, so every move in them (the zoom settle, a double tap's zoom, the page settle, the dismissal, a cancelled drag's return and the pinch close) is a spring on a named motion role read through the motion role table, never a literal, and a release starts at the gesture's velocity; the controls fade is companion motion on the pace of the main spring (`spec:AST-094` FR45). The dismissal plays Photos' measured springs, on the roles the Motion section lists (`spec:AST-094` FR4). Under reduced motion they follow AR6. | `spec:AST-094` FR4, FR13 | settled; implementation gap |
-| FR6 | With `hasZoom`, on an image, a pinch zooms about the fingers and pans while it zooms; past the minimum or maximum the photo resists and eases back without overshoot. A double tap zooms to the larger of filling the viewer and 2×, about the tapped point; a second double tap returns to the fitted size. A zoomed photo pans with momentum and rubber-band edges. A pinch on the controls or the caption still zooms the page. | `spec:AST-094` FR3, FR10 | settled; implementation gap |
-| FR7 | In a gallery, a horizontal drag moves the row of items, which sit apart by the measured gap. The release lands on at most one neighbouring item by `spec:AST-094` FR3's paging rule (DEC-25). Paging applies over a video item's frame as over a photo; a drag that starts on the video's own controls, such as its scrubber or volume, stays the video's. A zoomed photo pans to its edge before a new drag pages. A pinch never pages. Previous, Next, and the arrow keys keep paging. A controlled `index` the caller refuses springs back. `onIndexChange` reports a swipe. | `spec:AST-094` FR3, FR14, FR36–FR39, DEC-25 | settled; implementation gap |
-| FR8 | On a fitted image, a downward drag moves the photo with the finger, shrinks it, and fades the backdrop. The release decides once: a projected travel past the commit point closes the viewer and reports `onOpenChange(false)` at the lift; a release moving back springs back. Video is unchanged. | `spec:AST-094` FR4, FR36–FR40, FR45 | settled; implementation gap |
-| FR9 | With `hasZoom`, a pinch in from the fitted size is a release decision: below the measured fraction of the fitted size the release closes the viewer; opening the fingers again by the measured amount before the lift is moving back and cancels. A pinch that starts zoomed in never closes the viewer. | `spec:AST-094` FR36, FR37 | settled; implementation gap |
-| FR10 | With `hasZoom`, under a coarse pointer only, a tap on the photo fades the controls and caption and darkens the backdrop. While they are hidden, the next tap on the photo, a tap beside the photo, or a cancelled drag brings them back, and that tap beside the photo does not close the viewer (ORD3). AR5 governs keyboard and assistive-technology access while they are hidden. | `architecture:public-component-api` INV16 | settled; implementation gap |
-| FR11 | A mouse, a pen, and a trackpad also use FR7's drag paging, FR8's drag to close, a ctrl-wheel or trackpad pinch over the photo for FR6's zoom and FR9's close, and a trackpad's scroll to pan a zoomed photo; a mouse wheel's notches leave it in place (`spec:AST-094` FR43); over the photo these replace the browser's page zoom and scroll. A double-click zooms 2× about the clicked point. A click on the photo keeps the controls, and the viewer opens and closes at once. | `spec:AST-094` FR43; `spec:AST-017` FR59 | settled; implementation gap |
-| FR12 | Inside `experimental` only, under a coarse pointer, the photo grows out of its source when the viewer opens, and returns into it when it closes, on springs, `springs.settle` unless the motion table names another role, since Lightbox's main interaction is a gesture (`spec:AST-094` FR7, FR45): the element `experimental.sourceFor(index)` returns, else the opener when it shows a picture. A close reports `onOpenChange(false)` at the request; the viewer stays only until the photo lands. Pointer input during the entrance is held; keys and assistive technology stay live. Without `experimental`, opening is unchanged and closing plays FR16. | `spec:AST-017` FR21; `spec:AST-094` FR35 | settled; implementation gap |
-| FR13 | `hasZoom={false}` turns off FR6, FR9, and FR10. FR7 and FR8 add to the buttons, keys, and Escape and remove none of them, so Lightbox takes no `presentation` prop. | `architecture:public-component-api` INV16 | settled |
-| FR14 | Gestures, paging, motion, and reduced motion run through Core's one shared set of internal touch modules; Lightbox bundles no copy of a module's primitive and adds no gesture listener outside them. | `architecture:react-component-runtime` INV13 | settled; implementation gap |
-| FR15 | Lightbox supports a Picture-in-Picture window as its only second document; there it ranks Escape and platform close among that window's members. | `family:overlay-dismissal` FR8 | settled |
-| FR16 | Under a coarse primary pointer, a close that is not a drag down (FR8), a pinch in (FR9), or a return into a source (FR12) plays the touch exit: Close, Escape, a platform close, and a tap beside the photo while the controls are shown (ORD3). The photo shrinks a little and fades out in place with the backdrop and controls, as the native viewer's zoom transition does without a source, on a spring from rest, `springs.settle` unless the motion table names another role, with the backdrop and controls fading on its pace (`spec:AST-094` FR7, FR45). `onOpenChange(false)` reports at the request, and the viewer takes no input while the exit plays. Under a fine pointer the viewer closes at once (FR11). | `spec:AST-094` FR7 | settled; implementation gap |
-| FR17 | For any `index`, controlled or default, the viewer opens on the nearest valid item: below 0 the first, past the last the last. A fraction rounds to the nearest item, and NaN opens the first. | Current source and tests | settled |
-| FR18 | In a gallery, a video on a neighbouring page stays mounted and paused while another item shows. Paging back to it shows it where it stopped, as the platform photo viewer does: paused, or playing on from there with `hasAutoPlay`. | Current source and tests | settled |
+| ID | Candidate invariant | Basis | Draft review state |
+| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | --------------------------- |
+| FR1 | The viewer contains one active Media item and a Close button; galleries additionally contain Previous and Next buttons plus a Counter, and Caption renders only when supplied. In a gallery with any caption, every page keeps a band two caption lines tall under its Media, with the caption's backing only where the page has a caption. A longer caption's second line ends in a "more" action that opens it in full over the bottom of the Media, on a scrim. The full caption is always the viewer's accessible description. The caption band uses Astryx parts: the caption text is `Text` with the body type, not a hand-styled element. The caption's more/less toggle is an inline link-styled action that flows with the caption text (component:Link's button form), with aria-expanded and aria-controls. | Current source, docs, and tests | settled |
+| FR1b | A single captioned item, a photo or a video, and a gallery present the caption the same way: the same plate, width, text treatment, inline "more", spacing and photo position, and no plate when there is no caption. (Owner direction, Nov 6 2026 (Cindy): the single-photo caption behaved differently from the gallery's, and "they really should be consistent".) Exceptions: a single photo has no Previous or Next button, no Counter and no paging, since there is no other item; and a drag past a zoomed single photo's edge rubber-bands and returns (FR6), where a gallery's touch pager takes it (FR7). | Owner direction, Nov 6 2026 | settled |
+| FR2 | Viewer overlay carries the current `lightbox` target. Media, Caption, and Counter carry no Lightbox public target. | Current source and public docs | settled |
+| FR3 | Close, Previous, and Next button surfaces use Button's `button` target through IconButton rather than Lightbox-owned button targets. | Current source and target docs | settled |
+| FR4 | The open viewer is a focus region through Core's shared focus-region module (`component:Dialog/DEC-2`): Tab from its last control moves focus to its first and Shift+Tab from its first moves focus to its last, so focus never leaves the viewer for the browser's own interface. | `component:Dialog/DEC-2` | settled; implementation gap |
+| FR5 | Under a coarse primary pointer, after hydration, Lightbox presents FR6–FR10 with no opt-in. Lightbox's main interaction is a gesture, so every move in them (the zoom settle, a double tap's zoom, the page settle, the dismissal, a cancelled drag's return and the pinch close) is a spring on a named motion role read through the motion role table, never a literal, and a release starts at the gesture's velocity; the controls fade is companion motion on the pace of the main spring (`spec:AST-094` FR45). The dismissal plays Photos' measured springs, on the roles the Motion section lists (`spec:AST-094` FR4). Under reduced motion they follow AR6. | `spec:AST-094` FR4, FR13 | settled; implementation gap |
+| FR6 | With `hasZoom`, on an image, a pinch zooms about the fingers and pans while it zooms; past the minimum or maximum the photo resists and eases back without overshoot. A double tap zooms to the larger of filling the viewer and 2×, about the tapped point; a second double tap returns to the fitted size. A zoomed photo pans with momentum and rubber-band edges. A pinch on the controls or the caption still zooms the page. | `spec:AST-094` FR3, FR10 | settled; implementation gap |
+| FR7 | In a gallery, a horizontal drag moves the row of items, which sit apart by the measured gap. The release lands on at most one neighbouring item by `spec:AST-094` FR3's paging rule (DEC-25). Paging applies over a video item's frame as over a photo; a drag that starts on the video's own controls, such as its scrubber or volume, stays the video's. A zoomed photo pans to its edge before a new drag pages. A pinch never pages. Previous, Next, and the arrow keys keep paging. A controlled `index` the caller refuses springs back. `onIndexChange` reports a swipe. | `spec:AST-094` FR3, FR14, FR36–FR39, DEC-25 | settled; implementation gap |
+| FR8 | On a fitted image, a downward drag moves the photo with the finger, shrinks it, and fades the backdrop. The release decides once: a projected travel past the commit point closes the viewer and reports `onOpenChange(false)` at the lift; a release moving back springs back. Video is unchanged. | `spec:AST-094` FR4, FR36–FR40, FR45 | settled; implementation gap |
+| FR9 | With `hasZoom`, a pinch in from the fitted size is a release decision: below the measured fraction of the fitted size the release closes the viewer; opening the fingers again by the measured amount before the lift is moving back and cancels. A pinch that starts zoomed in never closes the viewer. | `spec:AST-094` FR36, FR37 | settled; implementation gap |
+| FR10 | With `hasZoom`, under a coarse pointer only, a tap on the photo fades the controls and caption and darkens the backdrop. While they are hidden, the next tap on the photo, a tap beside the photo, or a cancelled drag brings them back, and that tap beside the photo does not close the viewer (ORD3). AR5 governs keyboard and assistive-technology access while they are hidden. | `architecture:public-component-api` INV16 | settled; implementation gap |
+| FR11 | A mouse, a pen, and a trackpad also use FR7's drag paging, FR8's drag to close, a ctrl-wheel or trackpad pinch over the photo for FR6's zoom and FR9's close, and a trackpad's scroll to pan a zoomed photo; a mouse wheel's notches leave it in place (`spec:AST-094` FR43); over the photo these replace the browser's page zoom and scroll. A double-click zooms 2× about the clicked point. A click on the photo keeps the controls, and the viewer opens and closes at once. | `spec:AST-094` FR43; `spec:AST-017` FR59 | settled; implementation gap |
+| FR12 | Inside `experimental` only, under a coarse pointer, the photo grows out of its source when the viewer opens, and returns into it when it closes, on springs, `springs.settle` unless the motion table names another role, since Lightbox's main interaction is a gesture (`spec:AST-094` FR7, FR45): the element `experimental.sourceFor(index)` returns, else the opener when it shows a picture. A close reports `onOpenChange(false)` at the request; the viewer stays only until the photo lands. Pointer input during the entrance is held; keys and assistive technology stay live. Without `experimental`, opening is unchanged and closing plays FR16. | `spec:AST-017` FR21; `spec:AST-094` FR35 | settled; implementation gap |
+| FR13 | `hasZoom={false}` turns off FR6, FR9, and FR10. FR7 and FR8 add to the buttons, keys, and Escape and remove none of them, so Lightbox takes no `presentation` prop. | `architecture:public-component-api` INV16 | settled |
+| FR14 | Gestures, paging, motion, and reduced motion run through Core's one shared set of internal touch modules; Lightbox bundles no copy of a module's primitive and adds no gesture listener outside them. | `architecture:react-component-runtime` INV13 | settled; implementation gap |
+| FR15 | Lightbox supports a Picture-in-Picture window as its only second document; there it ranks Escape and platform close among that window's members. | `family:overlay-dismissal` FR8 | settled |
+| FR16 | Under a coarse primary pointer, a close that is not a drag down (FR8), a pinch in (FR9), or a return into a source (FR12) plays the touch exit: Close, Escape, a platform close, and a tap beside the photo while the controls are shown (ORD3). The photo shrinks a little and fades out in place with the backdrop and controls, as the native viewer's zoom transition does without a source, on a spring from rest, `springs.settle` unless the motion table names another role, with the backdrop and controls fading on its pace (`spec:AST-094` FR7, FR45). `onOpenChange(false)` reports at the request, and the viewer takes no input while the exit plays. Under a fine pointer the viewer closes at once (FR11). | `spec:AST-094` FR7 | settled; implementation gap |
+| FR17 | For any `index`, controlled or default, the viewer opens on the nearest valid item: below 0 the first, past the last the last. A fraction rounds to the nearest item, and NaN opens the first. | Current source and tests | settled |
+| FR18 | In a gallery, a video on a neighbouring page stays mounted and paused while another item shows. Paging back to it shows it where it stopped, as the platform photo viewer does: paused, or playing on from there with `hasAutoPlay`. | Current source and tests | settled |
+| FR19 | The viewer is a media surface: it renders inside `MediaTheme` (dark), so its controls and caption take the theme's dark surface tokens and the active theme's component rules (`architecture:theme-application` INV8), with no Lightbox-owned colors beyond AR9's backing, the caption's backing and the on-dark color. | Specs decision, Nov 5 2026 | settled |
### Motion
@@ -185,7 +189,7 @@ return included, plays `springs.settle`.
| Single image | Viewer overlay, Media, and Close button render. | Caption and zoom may be absent or present. |
| Gallery boundary | Both navigation buttons and Counter remain rendered. | Previous or Next is disabled at its boundary. |
| Video | Media renders native video controls without image zoom interaction. | Autoplay follows the existing consumer prop. |
-| Captioned media | Caption follows the active Media inside the centered media group. | Caption content remains caller-provided. |
+| Captioned media | Caption shows in the band under the active Media's page, for one item or a gallery. | Caption content remains caller-provided. |
| Zoomed image | A double tap or double-click, `-`, or `0` returns to the fitted size. | The zoom level and pan position. |
| Controls hidden | Controls stay focusable and in the accessibility tree; focus or a key brings them back. | Backdrop shade while hidden. |
@@ -224,7 +228,9 @@ under `spec:AST-020` FR1; reduced motion cites `design:reduced-motion`.
in, `-` zooms out, and `0` returns to the fitted size; while zoomed, the arrow
keys pan the photo. Focus stays inside the viewer (FR4), and on close returns
to the element that opened the viewer when that element is still connected and
- can receive focus (`family:overlay-dismissal` AV1).
+ can receive focus (`family:overlay-dismissal` AV1). Paging away from a
+ focused "more" or "less" moves focus to the new page's photo, or to Close
+ when the photo has no zoom.
- **AR4 — Screen-reader semantics and announcements (WCAG 2.2 1.1.1, 4.1.2,
4.1.3).** The viewer is a modal dialog named by the active item's `alt`, or a
localized "Media viewer" when it has none. Every item requires `alt`, which
@@ -340,7 +346,8 @@ a decision that they must remain unthemeable.
| Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
-| FR1 | `Lightbox.test.tsx` render, gallery, caption, media, and zoom suites | Single image, gallery edges, caption, video | Removing a documented part fails existing role, content, media, or navigation assertions. | `audit:Lightbox/anatomy` |
+| FR1 | `Lightbox.test.tsx` render, gallery, caption, media, and zoom suites; the `LightboxViewport` caption band cases | Single image, gallery edges, caption, video | Removing a documented part fails existing role, content, media, or navigation assertions. | `audit:Lightbox/anatomy` |
+| FR1b | `Lightbox.test.tsx` single photo cases; `LightboxSingleCaption` interaction specs, Chromium and WebKit, on a phone and a desktop | A single photo with a caption, with zoom, with a long caption and with none, against a gallery page | A single photo whose band, caption text or photo box differs from a gallery page's, a long single caption without "more", a band without a caption, or a Previous, Next or Counter on a single photo fails. | `audit:Lightbox/anatomy` |
| FR2 | Source inspection and `themingTargets.test.ts` | Viewer overlay, Media, Caption, and Counter | Removing the root target or documenting an unshipped child target fails evidence or inventory. | `audit:Lightbox/theming` |
| FR3 | Source inspection plus Button and IconButton public composition | Close and gallery navigation controls | Re-owning a control surface requires the delegated owner and this map to change. | `audit:Lightbox/theming` |
| FR4, AR3 | Browser keyboard test: Tab and Shift+Tab at both ends, Escape, arrows in LTR and RTL, `+`, `-`, `0`, close focus | Single image, gallery, zoomed image, removed opener | Focus leaving the viewer, a key without its action, or focus not returning to a connected opener fails. | `audit:Lightbox/accessibility` |
@@ -352,6 +359,7 @@ a decision that they must remain unthemeable.
| FR16 | Touch tests closing by Close, Escape, platform close, and a tap beside the photo with the controls shown; Reduce Motion | With and without `experimental`; controls shown and hidden | A touch close without the exit, a literal timing, input reaching the viewer during the exit, or a tap beside the photo that closes while the controls are hidden fails. | `audit:Lightbox/touch` |
| FR17 | `Lightbox.test.tsx` index cases | An index below 0 and past the end, NaN, and an in-range fraction (1.5, 0.4, 1.2), controlled and default | An index that opens an empty viewer, or any item but the nearest, fails. | `audit:Lightbox/anatomy` |
| FR18 | `Lightbox.test.tsx` video paging cases (pause, keep, autoplay) | A playing video paged away from and back to | A neighbouring video that unmounts, keeps playing, or starts again from its beginning fails. | `audit:Lightbox/anatomy` |
+| FR19 | Unit: `Lightbox.test.tsx` media surface cases; browser: `LightboxPaging` and `LightboxZoom` interaction specs, Chromium and WebKit | Gallery with captions, with and without zoom; a single photo's caption; over a white and a mid-grey photo (body 14.7:1 over white) | A viewer outside `MediaTheme`, a Lightbox-set text color, a caption or counter that is not `Text`, a toggle that is not Link's button form, two start edges, or a caption under 4.5:1 fails. | `audit:Lightbox/theming` |
| FR12 | Opener and `sourceFor` tests, with and without `experimental` | Image opener, text-button opener, mid-entrance close | Any change to opening or closing without `experimental` fails. | `audit:Lightbox/touch` |
| FR13 | `hasZoom={false}` tests under a coarse pointer | Gallery, single image | A zoom, a pinch close, or a tap that hides the controls with `hasZoom={false}` fails. | `audit:Lightbox/touch` |
| FR14 | Source boundary check and bundle analysis | A page with Lightbox and another touch component | A second copy of a touch module or a Lightbox-owned gesture listener fails. | `audit:Lightbox/touch` |
diff --git a/packages/core/locales/en.json b/packages/core/locales/en.json
index 120404f345842..54b9b96e98485 100644
--- a/packages/core/locales/en.json
+++ b/packages/core/locales/en.json
@@ -1687,6 +1687,14 @@
"defaultMessage": "Zoomed out",
"description": "Polite screen-reader announcement after zooming back out of a Lightbox image. Pairs with `lightbox.zoomedIn`."
},
+ "@astryx.lightbox.captionMore": {
+ "defaultMessage": "more",
+ "description": "Visible label of the button after a Lightbox caption that two lines cut short, for a single photo or a gallery. It opens the full caption over the photo. Pairs with `lightbox.captionLess`."
+ },
+ "@astryx.lightbox.captionLess": {
+ "defaultMessage": "less",
+ "description": "Visible label of the same button while a Lightbox caption is open in full over the photo. It closes the full caption. Pairs with `lightbox.captionMore`."
+ },
"@astryx.metadataList.showMore": {
"defaultMessage": "Show more",
"description": "Button label under a MetadataList that reveals items hidden beyond the configured maximum. Toggles with `metadataList.showLess` — keep the pair parallel in your language."
diff --git a/packages/core/src/Layer/layerViewportInset.test.ts b/packages/core/src/Layer/layerViewportInset.test.ts
index 11cb1dbbd48aa..67f93999a09a5 100644
--- a/packages/core/src/Layer/layerViewportInset.test.ts
+++ b/packages/core/src/Layer/layerViewportInset.test.ts
@@ -18,10 +18,12 @@ const EDGES = ['block-start', 'block-end', 'inline-start', 'inline-end'];
// Surfaces that keep the same gutter by convention but are not anchor-mode
// layers (spec:AST-059 non-goals), plus the module itself. AppShell reads the
// device's insets for the page shell itself (its header and the end of its
-// content), not as a layer gutter.
+// content), not as a layer gutter. Lightbox's caption band reads the bottom
+// inset of its full-screen viewer.
const GUTTER_OWNERS = new Set([
'Layer/layerViewportInset.stylex.ts',
'AppShell/AppShell.tsx',
+ 'Lightbox/Lightbox.tsx',
'Dialog/Dialog.tsx',
'Toast/ToastViewport.tsx',
'BottomSheet/BottomSheetPanel.tsx',
diff --git a/packages/core/src/Lightbox/Lightbox.doc.mjs b/packages/core/src/Lightbox/Lightbox.doc.mjs
index 6a908e341898d..27a5512e36f8f 100644
--- a/packages/core/src/Lightbox/Lightbox.doc.mjs
+++ b/packages/core/src/Lightbox/Lightbox.doc.mjs
@@ -31,7 +31,8 @@ const anatomy = [
{
name: 'Caption',
required: false,
- description: 'Caller-provided caption displayed below the active media.',
+ description:
+ 'Caller-provided caption displayed below the active media, in a band at the bottom of the viewer. It shows at most two lines, for a single photo as for a gallery; a longer caption ends in an inline "more" link that opens it in full over the photo.',
},
{
name: 'Counter',
diff --git a/packages/core/src/Lightbox/Lightbox.test.tsx b/packages/core/src/Lightbox/Lightbox.test.tsx
index 7a0c431f7a16c..bcf72c1fbebed 100644
--- a/packages/core/src/Lightbox/Lightbox.test.tsx
+++ b/packages/core/src/Lightbox/Lightbox.test.tsx
@@ -1,8 +1,17 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.
-import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
+import {
+ describe,
+ it,
+ expect,
+ vi,
+ beforeEach,
+ afterEach,
+ type MockInstance,
+} from 'vitest';
import {act, render, screen, fireEvent, waitFor} from '@testing-library/react';
import {Profiler, StrictMode, useState} from 'react';
+import {createPortal} from 'react-dom';
import {Lightbox} from './Lightbox';
import {getFocusableElements} from '../hooks/useFocusTrap';
import {getGestureHost} from '../gestures/GestureHost';
@@ -707,6 +716,209 @@ describe('Lightbox', () => {
expect(onOpenChange).toHaveBeenCalledWith(false);
});
+ it('closes on a mouse click in the band between a photo and its caption row', () => {
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+ const current = document.querySelector('[data-current]')!;
+ const box = current.querySelector('[data-lightbox-page-media]')!;
+ layOutPage(current, {
+ photo: rectAt(100, 100, 400, 200),
+ natural: 400,
+ caption: rectAt(250, 600, 100, 30),
+ viewer: 1000,
+ });
+ // Below the photo and above its caption, the click is the backdrop's.
+ mousePress(box, {x: 300, y: 450});
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it("shows a 0 caption and none for true, and lays out no other page's caption", () => {
+ render(
+ {}}
+ index={3}
+ media={[
+ {src: '/a.jpg', alt: 'A', caption: 'First'},
+ {src: '/b.jpg', alt: 'B'},
+ {src: '/c.jpg', alt: 'C', caption: true},
+ {src: '/d.jpg', alt: 'D', caption: 0},
+ ]}
+ />,
+ );
+ // The first page is not rendered, so nothing lays out its caption.
+ expect(screen.queryByText('First')).toBeNull();
+ expect(
+ document.querySelector('[data-current] [data-lightbox-caption]'),
+ ).toHaveTextContent('0');
+ });
+
+ it("describes the viewer with the current item's full caption, however long", () => {
+ const long =
+ 'A winding path through the forest, past the old mill and down to the river, where the trail turns back toward the village';
+ render(
+ {}}
+ index={0}
+ media={[
+ {src: '/a.jpg', alt: 'A', caption: long},
+ {src: '/b.jpg', alt: 'B', caption: 'Short'},
+ ]}
+ />,
+ );
+ expect(screen.getByRole('dialog')).toHaveAccessibleDescription(long);
+ });
+
+ it('renders as a media surface: the content under MediaTheme dark, the caption and counter as body Text', () => {
+ render(
+ {}}
+ index={0}
+ hasZoom
+ media={[
+ {src: '/a.jpg', alt: 'A', caption: 'Golden hour'},
+ {src: '/b.jpg', alt: 'B'},
+ ]}
+ />,
+ );
+ const dialog = screen.getByRole('dialog');
+ const surface = dialog.querySelector('[data-astryx-media="dark"]');
+ // Inside the dialog, so the backdrop keeps the page's overlay.
+ expect(surface).not.toBeNull();
+ expect(dialog).not.toHaveAttribute('data-astryx-media');
+ const close = screen.getByRole('button', {name: /close/i});
+ expect(surface).toContainElement(close);
+ const caption = dialog.querySelector(
+ '[data-current] [data-lightbox-caption] .astryx-text',
+ );
+ expect(caption).toHaveAttribute('data-type', 'body');
+ expect(caption).toHaveTextContent('Golden hour');
+ const counter = screen.getByText('1 / 2');
+ expect(counter.closest('.astryx-text')).toHaveAttribute(
+ 'data-type',
+ 'body',
+ );
+ expect(surface).toContainElement(counter);
+ });
+
+ it("renders a single photo's caption as body Text", () => {
+ render(
+ {}}
+ media={{src: '/a.jpg', alt: 'A', caption: 'Golden hour'}}
+ />,
+ );
+ expect(
+ screen.getByText('Golden hour').closest('.astryx-text'),
+ ).toHaveAttribute('data-type', 'body');
+ });
+
+ describe('the ring on the control the opening focuses', () => {
+ const media = [
+ {src: '/a.jpg', alt: 'A'},
+ {src: '/b.jpg', alt: 'B'},
+ ];
+ // As a browser does, showModal() focuses the dialog's first control.
+ const openAndFocusFirstControl = vi.fn(function (
+ this: HTMLDialogElement,
+ ) {
+ this.setAttribute('open', '');
+ this.querySelector('button')?.focus();
+ });
+ // The options of the last focus() call on `element`.
+ const lastFocusOptions = (
+ focus: MockInstance<(options?: FocusOptions) => void>,
+ element: Element,
+ ) => {
+ const call = focus.mock.contexts.lastIndexOf(element);
+ expect(call).toBeGreaterThanOrEqual(0);
+ return focus.mock.calls[call]?.[0];
+ };
+
+ it("a tap's opening paints no ring on the focused control; a key's paints one", () => {
+ HTMLDialogElement.prototype.showModal = openAndFocusFirstControl;
+ const focus = vi.spyOn(HTMLElement.prototype, 'focus');
+ const view = (isOpen: boolean) => (
+ {}}
+ index={0}
+ media={media}
+ />
+ );
+ const {rerender} = render(view(false));
+ fireEvent.pointerDown(document.body);
+ rerender(view(true));
+ const control = document.activeElement!;
+ expect(control.closest('dialog')).not.toBeNull();
+ expect(lastFocusOptions(focus, control)).toEqual({focusVisible: false});
+ rerender(view(false));
+ fireEvent.keyDown(document.body, {key: 'Enter'});
+ focus.mockClear();
+ rerender(view(true));
+ expect(document.activeElement).toBe(control);
+ expect(lastFocusOptions(focus, control)).toBeUndefined();
+ focus.mockRestore();
+ });
+
+ it("a tap in a Picture-in-Picture window's document paints no ring there either", () => {
+ const frame = document.createElement('iframe');
+ document.body.append(frame);
+ const frameDocument = frame.contentDocument!;
+ const frameWindow = frameDocument.defaultView!;
+ // jsdom gives each document its own HTMLDialogElement prototype.
+ frameWindow.HTMLDialogElement.prototype.showModal =
+ openAndFocusFirstControl;
+ frameWindow.HTMLDialogElement.prototype.close =
+ HTMLDialogElement.prototype.close;
+ const focus = vi.spyOn(frameWindow.HTMLElement.prototype, 'focus');
+ const view = (isOpen: boolean) =>
+ createPortal(
+ {}}
+ index={0}
+ media={media}
+ />,
+ frameDocument.body,
+ );
+ const {rerender, unmount} = render(<>{view(false)}>);
+ // The window's own last input is a tap, whatever the page's was.
+ fireEvent.keyDown(document.body, {key: 'Enter'});
+ fireEvent.pointerDown(frameDocument.body);
+ rerender(<>{view(true)}>);
+ const control = frameDocument.activeElement!;
+ expect(control.closest('dialog')).not.toBeNull();
+ expect(lastFocusOptions(focus, control)).toEqual({focusVisible: false});
+ focus.mockRestore();
+ unmount();
+ frame.remove();
+ });
+ });
+
+ it('describes a single photo with its caption', () => {
+ render(
+ {}}
+ media={{src: '/a.jpg', alt: 'A', caption: 'Golden hour'}}
+ />,
+ );
+ expect(screen.getByRole('dialog')).toHaveAccessibleDescription(
+ 'Golden hour',
+ );
+ });
+
it("stays open on a mouse click beside a narrow photo, within its caption's width", () => {
const onOpenChange = vi.fn();
render(
@@ -1394,6 +1606,46 @@ describe('Lightbox', () => {
});
});
+ describe('a single photo', () => {
+ it("renders as a gallery's page: its photo's box, then its caption in the band below", () => {
+ render(
+ {}}
+ media={{src: '/photo.jpg', alt: 'Photo', caption: 'A caption'}}
+ />,
+ );
+ const pages = document.querySelectorAll('[data-lightbox-page]');
+ expect(pages).toHaveLength(1);
+ const page = pages[0];
+ expect(page).toHaveAttribute('data-current');
+ const media = page.querySelector('[data-lightbox-page-media]')!;
+ expect(media).toContainElement(screen.getByAltText('Photo'));
+ const caption = page.querySelector(
+ '[data-lightbox-caption]',
+ )!;
+ expect(caption).toHaveTextContent('A caption');
+ expect(media.nextElementSibling).toContainElement(caption);
+ // Nothing to page to: no Previous, Next or counter.
+ expect(screen.queryByRole('button', {name: 'Previous'})).toBeNull();
+ expect(screen.queryByRole('button', {name: 'Next'})).toBeNull();
+ expect(screen.queryByText('1 / 1')).toBeNull();
+ });
+
+ it('with no caption has no band, as a gallery with none', () => {
+ render(
+ {}}
+ media={{src: '/photo.jpg', alt: 'Photo'}}
+ />,
+ );
+ const page = document.querySelector('[data-lightbox-page]')!;
+ expect(page.children).toHaveLength(1);
+ expect(page.querySelector('[data-lightbox-caption]')).toBeNull();
+ });
+ });
+
it('does not crash with an empty media array', () => {
const {container} = render(
{}} media={[]} />,
@@ -1402,7 +1654,7 @@ describe('Lightbox', () => {
});
describe('backdrop dismiss', () => {
- it('closes on a click on the strip a raised caption leaves below the stage', () => {
+ it("closes on a mouse click above a single photo, as on a gallery's page", () => {
const onOpenChange = vi.fn();
render(
{
hasZoom
/>,
);
- // The media group: the stage, then the caption's place below it. A
- // mouse's click there is below the raised caption.
- const group = screen.getByText('A caption').parentElement!;
- fireEvent.click(group, {detail: 1, clientY: 590});
+ // The stage centres the 200 px photo in its 600 px box.
+ const current = document.querySelector('[data-current]')!;
+ mousePress(current.querySelector('[data-lightbox-page-media]')!, {
+ x: 200,
+ y: 20,
+ });
expect(onOpenChange).toHaveBeenCalledWith(false);
});
- it('without zoom, leaves a click beside a narrower caption alone', () => {
+ it("without zoom, leaves a mouse click on a single photo's caption alone", () => {
const onOpenChange = vi.fn();
render(
{
media={{src: '/photo.jpg', alt: 'Photo', caption: 'A caption'}}
/>,
);
- // The media group hugs its media and caption, as before.
- fireEvent.click(screen.getByText('A caption').parentElement!);
+ const current = document.querySelector('[data-current]')!;
+ layOutPage(current, {
+ photo: rectAt(100, 200, 200, 200),
+ natural: 200,
+ caption: rectAt(50, 560, 300, 40),
+ viewer: 400,
+ });
+ mousePress(current.querySelector('[data-lightbox-caption]')!, {
+ x: 200,
+ y: 580,
+ });
expect(onOpenChange).not.toHaveBeenCalled();
});
@@ -1444,9 +1707,12 @@ describe('Lightbox', () => {
);
// The container fills the whole dialog, so a click on the visual
// backdrop (the dark area around the media) lands on it — not on the
- // dialog element itself.
+ // dialog element itself. It sits in the media surface, which has no
+ // box of its own.
const dialog = document.querySelector('dialog')!;
- const container = dialog.firstElementChild!;
+ const container = dialog.querySelector(
+ '[data-astryx-media="dark"]',
+ )!.firstElementChild!;
fireEvent.click(container);
expect(onOpenChange).toHaveBeenCalledWith(false);
});
@@ -1682,9 +1948,12 @@ describe('Lightbox gestures for a mouse and a pen (spec:AST-094 FR43)', () => {
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 400));
});
- // Each caption click reads its own press, not the drag's.
+ // Each caption click reads its own press, not the drag's. The caption
+ // sits in its band, on the stage's surface, so its presses are timed as a
+ // browser times them: now.
const caption = screen.getByText('A caption');
- for (const at of [t0 + 1000, t0 + 2000]) {
+ for (let i = 0; i < 2; i++) {
+ const at = performance.now();
pointer('pointerdown', caption, 'mouse', 200, 300, at);
pointer('pointerup', caption, 'mouse', 200, 300, at + 40);
fireEvent.click(caption, {clientX: 200, clientY: 300, detail: 1});
@@ -1772,12 +2041,12 @@ describe('Lightbox gestures for a mouse and a pen (spec:AST-094 FR43)', () => {
pointer('pointerup', stage, 'mouse', 180, 300, t0 + 32);
fireEvent.click(stage, {clientX: 180, clientY: 300, detail: 1});
expect(onOpenChange).not.toHaveBeenCalled();
- // A new press on the dark area above starts a new click, which closes.
- const container =
- screen.getByText('A caption').parentElement!.parentElement!;
- pointer('pointerdown', container, 'mouse', 200, 10, t0 + 1000);
- pointer('pointerup', container, 'mouse', 200, 10, t0 + 1040);
- fireEvent.click(container, {clientX: 200, clientY: 10, detail: 1});
+ // A new press on the dark area above, on the stage, starts a new click,
+ // which closes. It is timed as a browser times it: now.
+ const later = performance.now();
+ pointer('pointerdown', stage, 'mouse', 200, 10, later);
+ pointer('pointerup', stage, 'mouse', 200, 10, later + 40);
+ fireEvent.click(stage, {clientX: 200, clientY: 10, detail: 1});
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
@@ -1841,6 +2110,7 @@ describe('Lightbox recognizers attach for every pointer (spec:AST-094 FR43)', ()
}
it('attaches no drag to dismiss where the primary pointer is fine', () => {
+ // Only the backdrop's tap beside the photo, as on a gallery's page.
expect(
lightboxRecognizersAttached(() => {
render(
@@ -1851,7 +2121,7 @@ describe('Lightbox recognizers attach for every pointer (spec:AST-094 FR43)', ()
/>,
);
}),
- ).toEqual([]);
+ ).toEqual(['lightbox-backdrop-tap']);
});
it('attaches the drag to dismiss under the touch layer', () => {
@@ -1935,24 +2205,6 @@ describe('Lightbox touch zoom (synthetic touches)', () => {
});
}
- /** A finger's pointerdown, which a browser sends before its touchstart. */
- function fingerDown(target: Element, x: number, y: number): void {
- const event = new Event('pointerdown', {bubbles: true, cancelable: true});
- const fields = {pointerType: 'touch', pointerId: 2, isPrimary: true};
- for (const [key, value] of Object.entries({
- ...fields,
- button: 0,
- buttons: 1,
- clientX: x,
- clientY: y,
- })) {
- Object.defineProperty(event, key, {value});
- }
- act(() => {
- target.dispatchEvent(event);
- });
- }
-
it('reports a tap beside the photo to the newest onOpenChange', async () => {
// The stage's gestures read the props of the latest render, so a parent's
// new callback is the one a tap beside the photo reaches.
@@ -1980,7 +2232,7 @@ describe('Lightbox touch zoom (synthetic touches)', () => {
['a fitted photo', false],
['a zoomed photo', true],
])(
- 'closes on a finger’s tap on the strip after a finger drag on %s, once the drag’s flag is clear',
+ 'closes on a finger’s tap beside the photo after a finger drag on %s',
async (_, isZoomedFirst) => {
const onOpenChange = vi.fn();
render(
@@ -2006,17 +2258,13 @@ describe('Lightbox touch zoom (synthetic touches)', () => {
touch('touchmove', stage, [finger], [finger], 1000 + i * 16);
}
touch('touchend', stage, [], [finger], 1000 + 7 * 16);
- const group = screen.getByText('A caption').parentElement!;
- const tapStrip = () => {
- fingerDown(group, 200, 590);
- fireEvent.click(group, {clientX: 200, clientY: 590, detail: 1});
- };
- // A zoomed drag's flag takes the first tap. A fitted photo's drag starts
- // no zoom pan, since paging owns it, so it leaves no flag.
- tapStrip();
- expect(onOpenChange).toHaveBeenCalledTimes(isZoomedFirst ? 0 : 1);
- tapStrip();
- expect(onOpenChange).toHaveBeenCalledWith(false);
+ // Above the photo, on the stage: its own tap closes the viewer, as on a
+ // gallery's page. The drag leaves nothing in its way.
+ const tap: Contact = {id: 7, x: 200, y: 50};
+ touch('touchstart', stage, [tap], [tap], 2000);
+ touch('touchend', stage, [], [tap], 2040);
+ // A single tap waits out a double tap's interval.
+ await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false));
},
);
@@ -2059,7 +2307,7 @@ describe('Lightbox touch zoom (synthetic touches)', () => {
touch('touchend', stage, [], [a], 20 * 16);
});
- it("takes a caption off the photo from a pinch's first move", () => {
+ it('keeps a single photo’s caption in its band, outside the photo’s box, through a pinch', () => {
render(
{
/>,
);
const stage = screen.getByRole('button', {name: 'Zoom'});
- const caption = screen.getByText('A caption');
- // Fitted, the caption is raised by the stage's free space below the
- // photo: (600 - 200) / 2.
- expect(caption.style.translate).toBe('0 -200px');
+ const caption = stage.parentElement!.querySelector(
+ '[data-lightbox-caption]',
+ )!;
+ // The band is the page's row below the photo's box, as on a gallery's.
+ expect(stage).not.toContainElement(caption);
+ expect(caption.style.translate).toBe('');
let a: Contact = {id: 1, x: 150, y: 300};
let b: Contact = {id: 2, x: 250, y: 300};
touch('touchstart', stage, [a], [a], 0);
@@ -2082,10 +2332,12 @@ describe('Lightbox touch zoom (synthetic touches)', () => {
b = {...b, x: 250 + 6 * i};
touch('touchmove', stage, [a, b], [a, b], i * 16);
}
- // Both fingers are still down, and the photo spreads over the stage.
+ // Both fingers are still down, and the photo spreads over the stage. The
+ // caption stays in its band, unmoved.
expect(
parseFloat(photoTransform(screen.getByAltText('Photo')).scale),
).toBeGreaterThan(1);
+ expect(stage).not.toContainElement(caption);
expect(caption.style.translate).toBe('');
touch('touchend', stage, [a], [b], 5 * 16);
touch('touchend', stage, [], [a], 6 * 16);
@@ -2207,7 +2459,7 @@ describe('Lightbox touch zoom (synthetic touches)', () => {
expect(onOpenChange).not.toHaveBeenCalled();
});
- it('ignores the first tap on the strip after a drag to close springs back, as before', async () => {
+ it('closes on a finger’s tap beside the photo after a drag to close springs back', async () => {
const onOpenChange = vi.fn();
render(
{
touch('touchend', stage, [], [finger], clock + 7 * 16);
await new Promise(resolve => setTimeout(resolve, 50));
expect(onOpenChange).not.toHaveBeenCalled();
- const group = screen.getByText('A caption').parentElement!;
- const tapStrip = () => {
- fingerDown(group, 200, 590);
- fireEvent.click(group, {clientX: 200, clientY: 590, detail: 1});
- };
- // The first tap on the strip below the stage only clears the drag's flag.
- tapStrip();
- expect(onOpenChange).not.toHaveBeenCalled();
- tapStrip();
- expect(onOpenChange).toHaveBeenCalledWith(false);
+ // Above the photo, on the stage: its own tap closes the viewer, as on a
+ // gallery's page. The drag leaves nothing in its way.
+ const tap: Contact = {id: 7, x: 200, y: 50};
+ touch('touchstart', stage, [tap], [tap], clock + 1000);
+ touch('touchend', stage, [], [tap], clock + 1040);
+ // A single tap waits out a double tap's interval.
+ await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false));
});
});
@@ -2370,6 +2619,37 @@ describe('Lightbox unmounting while open', () => {
);
}
+ it('returns focus to the opener without scrolling the page when the viewer closes', () => {
+ function Viewer() {
+ const [isOpen, setIsOpen] = useState(false);
+ return (
+ <>
+
+
+ >
+ );
+ }
+ render();
+ const opener = screen.getByRole('button', {name: 'View photos'});
+ opener.focus();
+ fireEvent.click(opener);
+ const focus = vi.spyOn(opener, 'focus');
+
+ fireEvent.click(screen.getByLabelText('Close'));
+
+ expect(document.activeElement).toBe(opener);
+ expect(focus).toHaveBeenCalledWith(
+ expect.objectContaining({preventScroll: true}),
+ );
+ });
+
it('returns focus to the opener when the viewer unmounts', async () => {
render();
const opener = screen.getByRole('button', {name: 'View photos'});
@@ -2461,3 +2741,115 @@ describe('Lightbox focus region (Dialog DEC-2)', () => {
expectTabWrapsInside(document.querySelector('dialog') as HTMLElement);
});
});
+
+describe("Lightbox gallery caption's toggle", () => {
+ it('follows the caption in reading order, and its place in the last line is hidden from assistive technology', async () => {
+ const caption =
+ 'A winding path through the forest, past the old mill and down to the river';
+ // Two lines clamp the caption: its text runs past them.
+ const inCaption = (element: Element) =>
+ element.closest('[data-lightbox-caption]') != null &&
+ element.classList.contains('astryx-text');
+ const scrollHeight = vi
+ .spyOn(HTMLElement.prototype, 'scrollHeight', 'get')
+ .mockImplementation(function (this: HTMLElement) {
+ return inCaption(this) ? 120 : 0;
+ });
+ const clientHeight = vi
+ .spyOn(HTMLElement.prototype, 'clientHeight', 'get')
+ .mockImplementation(function (this: HTMLElement) {
+ return inCaption(this) ? 48 : 0;
+ });
+ try {
+ render(
+ {}}
+ index={0}
+ media={[
+ {src: '/a.jpg', alt: 'A', caption},
+ {src: '/b.jpg', alt: 'B'},
+ ]}
+ />,
+ );
+ const toggle = await screen.findByRole('button', {name: 'more'});
+ const text = document.querySelector(
+ '[data-current] [data-lightbox-caption] .astryx-text',
+ ) as HTMLElement;
+ expect(text).toHaveTextContent(caption);
+ expect(text.textContent).toBe(caption);
+ expect(
+ text.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ const place = text.querySelector('[aria-hidden="true"]');
+ expect(place).not.toBeNull();
+ expect(place?.textContent).toBe('');
+ expect(getFocusableElements(text)).toEqual([]);
+ } finally {
+ scrollHeight.mockRestore();
+ clientHeight.mockRestore();
+ }
+ });
+
+ it('renders the same caption elements with or without its toggle, clamped or open', async () => {
+ const long =
+ 'A winding path through the forest, past the old mill and down to the river';
+ const media = [
+ {src: '/a.jpg', alt: 'A', caption: long},
+ {src: '/b.jpg', alt: 'B', caption: 'Golden hour'},
+ ];
+ const shapeOf = (element: Element): string =>
+ `${element.tagName}(${[...element.children].map(shapeOf).join(',')})`;
+ // The caption's own Text, not the toggle's label inside it.
+ const captionShapes = () =>
+ [...document.querySelectorAll('[data-lightbox-caption] .astryx-text')]
+ .filter(text => text.closest('button') == null)
+ .map(shapeOf);
+ const fits = render(
+ {}}
+ index={0}
+ media={media}
+ />,
+ );
+ const withoutToggle = captionShapes();
+ fits.unmount();
+ // Two lines clamp the first caption: its text runs past them.
+ const inLongCaption = (element: Element) =>
+ element.classList.contains('astryx-text') && element.textContent === long;
+ const scrollHeight = vi
+ .spyOn(HTMLElement.prototype, 'scrollHeight', 'get')
+ .mockImplementation(function (this: HTMLElement) {
+ return inLongCaption(this) ? 120 : 0;
+ });
+ const clientHeight = vi
+ .spyOn(HTMLElement.prototype, 'clientHeight', 'get')
+ .mockImplementation(function (this: HTMLElement) {
+ return inLongCaption(this) ? 48 : 0;
+ });
+ try {
+ render(
+ {}}
+ index={0}
+ media={media}
+ />,
+ );
+ fireEvent.click(await screen.findByRole('button', {name: 'more'}));
+ await screen.findByRole('button', {name: 'less'});
+ const open = captionShapes();
+ fireEvent.click(screen.getByRole('button', {name: 'less'}));
+ await screen.findByRole('button', {name: 'more'});
+ const clamped = captionShapes();
+ expect(withoutToggle).toHaveLength(2);
+ expect(withoutToggle[1]).toBe(withoutToggle[0]);
+ expect(clamped).toEqual(withoutToggle);
+ expect(open).toEqual(withoutToggle);
+ } finally {
+ scrollHeight.mockRestore();
+ clientHeight.mockRestore();
+ }
+ });
+});
diff --git a/packages/core/src/Lightbox/Lightbox.tsx b/packages/core/src/Lightbox/Lightbox.tsx
index aa83fd2f271b0..7855ad887678d 100644
--- a/packages/core/src/Lightbox/Lightbox.tsx
+++ b/packages/core/src/Lightbox/Lightbox.tsx
@@ -27,6 +27,7 @@
import {
useCallback,
useEffect,
+ useId,
useMemo,
useReducer,
useRef,
@@ -36,6 +37,7 @@ import {
type ComponentProps,
type ReactNode,
} from 'react';
+import {flushSync} from 'react-dom';
import * as stylex from '@stylexjs/stylex';
import type {StyleXStyles} from '@stylexjs/stylex';
import {
@@ -51,7 +53,12 @@ import {announceInModal} from '../hooks/useAnnounce';
import {useScrollLock} from '../hooks/useScrollLock';
import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect';
import {useAttachment} from '../hooks/useAttachment';
-import {observeResize} from '../utils/sharedResizeObserver';
+import {observeResizeAfterLayout} from '../utils/sharedResizeObserver';
+import {observeContent} from '../utils/sharedMutationObserver';
+import {MIN_TOUCH_TARGET} from '../utils/hitArea';
+import {useTouchHitArea, type TouchHitAreaOptions} from '../utils/touchHitArea';
+import {touchHitAreaStyles} from '../internal/touchHitArea.stylex';
+import {usePressTracker} from '../gestures/usePressTracker';
import {mergeProps, rtlStyles} from '../utils';
import type {BaseProps} from '../BaseProps';
import {themeProps} from '../utils/themeProps';
@@ -84,9 +91,21 @@ import {
} from '../i18n/useComponentTranslator';
import english from '../i18n/generated-english/Lightbox/Lightbox.generated';
import {isRenderable} from '../utils/isRenderable';
-import {prepareFocusReturnOnRemoval} from '../utils/focusReturn';
+import {
+ focusForLastInput,
+ moveFocusLeftBehind,
+ noteFocusWithin,
+ prepareFocusReturnOnRemoval,
+ returnFocusForLastInput,
+ type FocusWithin,
+} from '../utils/focusReturn';
import {animatorFor, type Animator} from '../motion/animator';
import {dismissBackdropAlpha, dismissChromeAlpha} from './zoomStage';
+import {useInteractionModalityTracking} from '../utils/interactionModality';
+import {MediaTheme} from '../theme/MediaTheme';
+import {Text} from '../Text/Text';
+import {useHeldTruncation} from '../Text/useTruncation';
+import {Link} from '../Link/Link';
registerEnglishFallback(english);
/**
@@ -168,6 +187,28 @@ export interface LightboxProps extends BaseProps {
/** Set on the stage while a mouse or a pen hovers over the fitted photo. */
const OVER_PHOTO_ATTRIBUTE = 'data-lightbox-over-photo';
+// The caption's backing: the controls' backing colour, darker, so the caption
+// keeps 4.5:1 over any photo or backdrop behind it (WCAG 2.2 1.4.3).
+const CAPTION_BACKING = `color-mix(in srgb, ${colorVars['--color-on-light']} 90%, transparent)`;
+
+/**
+ * The caption toggle's touch target grows toward 44 px down and to each
+ * side, never up: the line above is the caption's own text.
+ */
+const CAPTION_TOGGLE_HIT_AREA: TouchHitAreaOptions = {
+ growth: target => {
+ const x = Math.max(
+ 0,
+ (MIN_TOUCH_TARGET - (target.right - target.left)) / 2,
+ );
+ const y = Math.max(
+ 0,
+ (MIN_TOUCH_TARGET - (target.bottom - target.top)) / 2,
+ );
+ return {top: 0, right: x, bottom: y, left: x};
+ },
+};
+
const styles = stylex.create({
dialog: {
position: 'fixed',
@@ -204,33 +245,23 @@ const styles = stylex.create({
height: '100%',
position: 'relative',
},
+ // The stage is the pager's viewport, a single photo's too. It fills the
+ // viewer on every presentation, so its pages have a size of their own.
mediaGroup: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
+ alignSelf: 'stretch',
+ flexGrow: 1,
+ minHeight: 0,
maxWidth: '100%',
maxHeight: '100%',
// ZOOM_DISMISSING_ATTRIBUTE on the stage inside: a photo dragged to
// dismiss draws outside the box that hugs it.
overflow: {default: 'hidden', ':has([data-zoom-dismissing])': 'visible'},
},
- // A zoomable photo's stage fills the viewer for every pointer, as a photo
- // viewer's zooming scroll view does. A zoomed photo uses the whole screen,
- // and a pinch can start anywhere around it. Only a zoom target does this:
- // around any other item, a tap on the dark area still closes the viewer.
- mediaGroupZoom: {
- alignSelf: 'stretch',
- flexGrow: 1,
- minHeight: 0,
- },
- imageWrapperFill: {
- alignSelf: 'stretch',
- flexGrow: 1,
- flexShrink: 1,
- flexBasis: 0,
- },
- // The filling stage's group clips, so its focus ring sits inside its edge.
- imageWrapperFocusInset: {
+ // The zoom button's box clips, so its focus ring sits inside its edge.
+ zoomButtonFocusInset: {
outlineOffset: {
default: '0',
':focus-visible': `calc(-1 * ${focusVars['--focus-outline-width']})`,
@@ -240,101 +271,209 @@ const styles = stylex.create({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
- // ZOOM_DISMISSING_ATTRIBUTE, on this stage or a gallery page's: a photo
- // dragged to dismiss draws outside it, above its caption and below the
- // controls. The drag fades the caption, which makes it a stacking context
- // painted after the stage.
- overflow: {
- default: 'hidden',
- ':is([data-zoom-dismissing])': 'visible',
- ':has([data-zoom-dismissing])': 'visible',
- },
- zIndex: {default: null, ':is([data-zoom-dismissing])': 1},
- cursor: 'default',
- userSelect: 'none',
- minHeight: 0,
- },
- // The stage's own pinch, pan and double tap take every touch on it,
- // whatever the primary pointer. Only the stage does: the controls and
- // caption leave pinch-zoom to the page. It fills the viewer (mediaGroupZoom,
- // imageWrapperFill).
- imageWrapperZoomTarget: {
- touchAction: 'none',
- },
- // In a gallery the photo rows take the stage's touches, for every pointer
- // (as imageWrapperZoomTarget). A descendant cannot allow what an ancestor's
- // `touch-action` rules out. So the track allows only the page's pinch-zoom,
- // and a pinch on a caption still zooms the page. The pager's pan manages the
- // stage's own `touch-action`.
- trackZoomTarget: {
- touchAction: 'pinch-zoom',
- },
- // A zoomable page's photo box fills the page above its caption, as a
- // single item's stage does.
- pageMediaZoomTarget: {
- touchAction: 'none',
alignSelf: 'stretch',
flexGrow: 1,
flexShrink: 1,
flexBasis: 0,
+ position: 'relative',
+ // ZOOM_DISMISSING_ATTRIBUTE on the current page's photo box: a photo
+ // dragged to dismiss draws outside the stage.
+ overflow: {default: 'hidden', ':has([data-zoom-dismissing])': 'visible'},
+ cursor: 'default',
+ userSelect: 'none',
+ minHeight: 0,
},
// In the touch presentation, a photo without zoom leaves pinch-zoom to the
// page but takes one-finger drags. A drag down dismisses it.
imageWrapperDismissTarget: {
touchAction: 'pinch-zoom',
},
- // A gallery's stage is the pager's viewport: it fills the viewer on every
- // presentation, so its pages have a size of their own.
- mediaGroupGallery: {
- alignSelf: 'stretch',
- flexGrow: 1,
- minHeight: 0,
+ // The photo rows take the stage's own pinch, pan and double tap, for every
+ // pointer. A descendant cannot allow what an ancestor's `touch-action` rules
+ // out. So the track allows only the page's pinch-zoom, and a pinch on the
+ // controls or a caption still zooms the page. The pager's pan manages the
+ // stage's own `touch-action`.
+ trackZoomTarget: {
+ touchAction: 'pinch-zoom',
},
- imageWrapperGallery: {
- alignSelf: 'stretch',
- flexGrow: 1,
- flexShrink: 1,
- flexBasis: 0,
- position: 'relative',
+ // The photo box is the zoom button. It takes the stage's cursor, not the
+ // reset's button cursor, so the zoom-in cursor shows only over the photo.
+ pageMediaZoomTarget: {
+ touchAction: 'none',
+ cursor: {
+ default: 'inherit',
+ ':is(:disabled,[aria-disabled="true"])': 'default',
+ },
},
- // The pages stack in the track's one cell, each offset by its relative
- // inset.
+ // Two rows that every page shares through subgrid: the photo's and the
+ // caption band's. The band has one height on every page (`captionBand`). So
+ // every photo centres on the same box, with or without a caption. The next
+ // photo slides in level with the current one, and paging never resizes it.
track: {
position: 'absolute',
inset: 0,
display: 'grid',
gridTemplateColumns: '100%',
- gridTemplateRows: '100%',
+ gridTemplateRows: 'minmax(0, 1fr) auto',
},
- // One page per item, the stage's size, at index × (width + gap). A page
- // lays out its photo and caption as a single item does: the caption right
- // under the photo, and the pair centred. Each page has its own layer.
- // Through a busy main thread, Mobile Safari moves the track but cannot
- // paint what it had not painted. So a page that slides in must be painted
- // whole before it moves.
+ // One page per item, the stage's size, at index × (width + gap). The pages
+ // stack in the track's one column, offset by their relative inset. Each has
+ // its own layer. Through a busy main thread, Mobile Safari moves the track
+ // but cannot paint what it had not painted. So a page that slides in must be
+ // painted whole before it moves.
page: {
position: 'relative',
- gridArea: '1 / 1',
- display: 'flex',
- flexDirection: 'column',
- alignItems: 'center',
- justifyContent: 'center',
+ gridColumn: '1',
+ gridRow: '1 / span 2',
+ display: 'grid',
+ gridTemplateColumns: '100%',
+ gridTemplateRows: 'subgrid',
minHeight: 0,
- minWidth: 0,
willChange: 'transform',
},
- // A page's photo box: the zoom stage of the current page.
+ // A page's photo box: the zoom stage of the current page, in the photo
+ // row. Its caption sits in the caption band below it, so the viewer's size
+ // never changes with the item.
pageMedia: {
+ gridRow: '1',
+ minHeight: 0,
+ minWidth: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
- // As the single item's stage, while a photo is dragged to dismiss.
+ // ZOOM_DISMISSING_ATTRIBUTE: a photo dragged to dismiss draws outside its
+ // box, above its caption band and below the controls. The drag fades the
+ // band, which makes it a stacking context painted after the box.
overflow: {default: 'hidden', ':is([data-zoom-dismissing])': 'visible'},
zIndex: {default: null, ':is([data-zoom-dismissing])': 1},
- minHeight: 0,
+ },
+ // A gallery's caption band, under every page's photo. It is two caption
+ // lines tall whatever the caption, so the photo area stays the same. Its
+ // lines are the caption's own type, on the caption's backing. Its padding
+ // clears the screen's bottom edge and the safe area there.
+ captionBand: {
+ gridRow: '2',
+ position: 'relative',
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'flex-start',
+ minWidth: 0,
+ boxSizing: 'content-box',
+ // The caption Text's type, so `2lh` is two caption lines.
+ // SYNC: the caption Text's `type` in this file.
+ fontSize: typeScaleVars['--text-body-size'],
+ lineHeight: typeScaleVars['--text-body-leading'],
+ blockSize: `calc(${spacingVars['--spacing-2']} + 2lh)`,
+ paddingBlockEnd: `calc(${spacingVars['--spacing-3']} + env(safe-area-inset-bottom, 0px))`,
+ backgroundColor: CAPTION_BACKING,
+ },
+ // The band under an open caption, which brings its own backing.
+ captionBandUnderOpen: {
+ backgroundColor: 'transparent',
+ },
+ // A page with no caption keeps the band's space, so its photo sits where
+ // every other page's does. It shows no backing there.
+ captionBandBare: {
+ backgroundColor: 'transparent',
+ },
+ captionInBand: {
+ display: 'flex',
+ minWidth: 0,
+ maxWidth: '100%',
+ },
+ // The caption's Text and its toggle. The toggle is out of the flow, so the
+ // Text takes the whole width.
+ captionBody: {
+ position: 'relative',
+ display: 'flex',
+ alignItems: 'flex-start',
minWidth: 0,
maxWidth: '100%',
},
+ // Lets the clamped Text shrink inside the band.
+ captionClamped: {
+ flexShrink: 1,
+ minWidth: 0,
+ },
+ // The caption's lines in one block, so the clamp counts them and the floats
+ // in it wrap them. Every state renders it, with or without a toggle.
+ captionFlow: {
+ display: 'block',
+ },
+ // Physical floats: StyleX keys `inline-end` on the page's direction. So a
+ // right-to-left caption flips them, as the toggle follows its own.
+ // The toggle's place at the end of the caption's last line. The text wraps
+ // around it, so the clamp's ellipsis comes just before the toggle. Without
+ // a toggle it has no box, so it moves no line.
+ captionTogglePlace: {
+ float: {
+ default: 'right',
+ ':is([dir="rtl"] *)': 'left',
+ },
+ display: 'none',
+ overflow: 'hidden',
+ whiteSpace: 'pre',
+ userSelect: 'none',
+ // The clamp draws an ellipsis on its last line. The place shows nothing.
+ visibility: 'hidden',
+ // The lines are generated, so the caption's text and its ranges hold
+ // none of them. Each line is a no-break space, and the place shows as
+ // many as the caption does. An open caption scrolls once it passes 60 %
+ // of the viewport, and then the place is not shown. 240 lines cover that
+ // at a 10px line in a window 4,000px tall.
+ '::before': {
+ content: `"${'\\A0\\A'.repeat(239)}\\A0"`,
+ },
+ },
+ // The place shows one line box for each line the caption shows. Layout
+ // rounds line boxes to its own grid, and each engine rounds them its own
+ // way. So the place ends exactly where the caption's last line ends. Only
+ // its last line keeps the text away.
+ captionTogglePlaceShown: (width: number, lines: number) => ({
+ display: '-webkit-box',
+ WebkitBoxOrient: 'vertical',
+ WebkitLineClamp: lines,
+ inlineSize: width,
+ marginInlineStart: spacingVars['--spacing-1'],
+ shapeOutside: 'inset(calc(100% - 1lh + 0.5px) 0 0 0)',
+ }),
+ // The caption's link over its place: the caption's last line, inside the
+ // Text's inline padding. It takes the caption's font and line height, so
+ // it sits on that line as its text does.
+ // SYNC: `caption`'s paddingInline.
+ captionToggle: {
+ position: 'absolute',
+ insetInlineEnd: spacingVars['--spacing-3'],
+ insetBlockEnd: 0,
+ },
+ // The toggle's own display wins over `hidden`'s, so this hides it.
+ captionToggleHidden: {
+ display: 'none',
+ },
+ // An open caption that scrolls keeps its toggle's width clear at every
+ // line's end. The toggle stays in view as the lines move.
+ captionTextBesideToggle: (width: number) => ({
+ paddingInlineEnd: `calc(${spacingVars['--spacing-3']} + ${width}px + ${spacingVars['--spacing-1']})`,
+ }),
+ // The open caption: the same element, out of the band and over the
+ // photo's bottom, on the controls' scrim. Its last line and the toggle stay
+ // where they were, and it grows upward.
+ captionOpen: {
+ position: 'absolute',
+ insetInline: 0,
+ insetBlockEnd: 0,
+ justifyContent: 'center',
+ maxWidth: 'none',
+ paddingBlockEnd: `calc(${spacingVars['--spacing-3']} + env(safe-area-inset-bottom, 0px))`,
+ backgroundColor: CAPTION_BACKING,
+ },
+ // The open caption's text, up to 60 % of the viewport tall. A finger
+ // scrolls a longer one, and the toggle stays in view beside it.
+ captionTextOpen: {
+ maxBlockSize: `calc(60dvh - ${spacingVars['--spacing-3']} - env(safe-area-inset-bottom, 0px))`,
+ overflowY: 'auto',
+ touchAction: 'pan-y',
+ },
// A fitted photo's zoom-in cursor shows only over the photo, where the
// stage sets OVER_PHOTO_ATTRIBUTE. Beside it, a click closes the viewer.
imageWrapperZoomable: {
@@ -367,11 +506,9 @@ const styles = stylex.create({
objectFit: 'contain',
outline: 'none',
},
+ // The caption's box. Its type and color are the caption Text's, and both of
+ // its lines start at one edge.
caption: {
- color: colorVars['--color-on-dark'],
- fontSize: typeScaleVars['--text-large-size'],
- lineHeight: typeScaleVars['--text-large-leading'],
- textAlign: 'center',
paddingBlockStart: spacingVars['--spacing-2'],
paddingBlockEnd: 0,
paddingInline: spacingVars['--spacing-3'],
@@ -406,21 +543,17 @@ const styles = stylex.create({
position: 'absolute',
top: spacingVars['--spacing-3'],
insetInlineStart: spacingVars['--spacing-3'],
- color: colorVars['--color-on-dark'],
- fontSize: typeScaleVars['--text-body-size'],
- lineHeight: typeScaleVars['--text-body-leading'],
zIndex: 1,
pointerEvents: 'auto',
},
controlButton: {
- color: colorVars['--color-on-dark'],
pointerEvents: 'auto',
},
// The controls sit in layers over the viewer that pass clicks through. A
// drag to dismiss fades the layers, which carry no transition of their own,
// since a Button's opacity transition would trail the finger.
// They sit above the stage, even a photo dragged to dismiss. While a fade
- // writes their opacity, each is a stacking context. A gallery's positioned
+ // writes their opacity, each is a stacking context. The track's positioned
// pages, later in tree order, would otherwise cover them and take the taps
// meant for the controls.
chromeLayer: {
@@ -437,14 +570,19 @@ const styles = stylex.create({
backgroundColor: `color-mix(in srgb, ${colorVars['--color-on-light']} 75%, transparent)`,
borderRadius: radiusVars['--radius-full'],
},
- controlIcon: {
- color: colorVars['--color-on-dark'],
- },
counterBacking: {
backgroundColor: `color-mix(in srgb, ${colorVars['--color-on-light']} 75%, transparent)`,
borderRadius: radiusVars['--radius-full'],
paddingInline: spacingVars['--spacing-2'],
},
+ // With zoom, the icons and the Counter's text keep the on-dark colour on
+ // the backing, whatever a theme does to the buttons or to Text.
+ controlIcon: {
+ color: colorVars['--color-on-dark'],
+ },
+ counterOnDark: {
+ color: colorVars['--color-on-dark'],
+ },
});
const pageStyles = stylex.create({
@@ -531,13 +669,17 @@ export function Lightbox({
}: LightboxProps) {
const t = useComponentTranslator();
const dialogRef = useRef(null);
- const mergedDialogRef = useMergedRefs(ref, dialogRef);
+ // The last input in the viewer's own document, a Picture-in-Picture window's
+ // too, decides whether focus the viewer moves shows a ring.
+ const {ref: modalityRef} = useInteractionModalityTracking();
+ const mergedDialogRef = useMergedRefs(ref, dialogRef, modalityRef);
// Native showModal() lets Tab leave for the browser's own UI, so the shared
// module wraps it.
useFocusRegionContainment(dialogRef, isOpen);
const containerRef = useRef(null);
const imageWrapperRef = useRef(null);
const trackRef = useRef(null);
+ const noPagerTrackRef = useRef(null);
// A gallery's zoom stage: the current page's photo box.
const pageMediaRef = useRef(null);
const triggerElementRef = useRef(null);
@@ -567,6 +709,8 @@ export function Lightbox({
() => (Array.isArray(media) ? media : [media]),
[media],
);
+ // Every item is a page of the track, a single photo too, so its photo and
+ // caption sit as a gallery page's do. Only paging needs more than one item.
const isGallery = mediaArray.length > 1;
// The item shown is current in every respect: its page, the counter, the
// pager and the announcement.
@@ -691,10 +835,17 @@ export function Lightbox({
if (isOpen && !dialog.open) {
triggerElementRef.current = document.activeElement;
dialog.showModal();
+ // The browser focuses the viewer's first control as it opens. Its ring
+ // follows the last input, so a tap or a click shows none.
+ const view = dialog.ownerDocument.defaultView ?? window;
+ const focused = dialog.ownerDocument.activeElement;
+ if (focused instanceof view.HTMLElement && dialog.contains(focused)) {
+ focusForLastInput(focused);
+ }
} else if (!isOpen && dialog.open) {
dialog.close();
if (triggerElementRef.current instanceof HTMLElement) {
- triggerElementRef.current.focus();
+ returnFocusForLastInput(triggerElementRef.current);
}
}
}, [isOpen, hasItem]);
@@ -758,27 +909,293 @@ export function Lightbox({
const pointerTypeRef = useRef('');
// Where the last press on the media started, which a click does not carry.
const pressPointRef = useRef<{x: number; y: number} | null>(null);
- // Without zoom, a click on the dark area lands on the container or the
- // dialog, and both close. A finger's tap on a gallery's stage, or on the
- // zoom stage beside the photo, closes on the stage's own tap. So does its
- // tap on the media group's strip below a single photo's stage. A mouse's or
- // a pen's click closes only outside the media (`isClickOutsideMedia`). A
- // zoomed drag sets `didDragRef`, so its click is ignored. The next press
- // clears the flag, and a finger's only on the stage.
+ // The stage fills the viewer. A finger's tap on it beside the media closes
+ // on a tap of its own: the zoom stage's, or the backdrop tap without zoom.
+ // A mouse's or a pen's click closes only outside the media
+ // (`isClickOutsideMedia`). A zoomed drag sets `didDragRef`, so its click is
+ // ignored. The next press clears the flag, and a finger's only on the stage.
const didDragRef = useRef(false);
const mediaGroupRef = useRef(null);
- const captionRef = useRef(null);
+ // The current item's caption, which is the viewer's accessible description.
+ const captionId = useId();
+ const fullCaptionId = useId();
+ // The item whose full caption is open over its photo. Paging closes it.
+ const [openCaptionIndex, setOpenCaptionIndex] = useState(null);
+ if (openCaptionIndex != null && openCaptionIndex !== index) {
+ setOpenCaptionIndex(null);
+ }
+ const isCaptionOpen = openCaptionIndex === index;
+ // The toggle's width, which its place in the caption's last line takes.
+ const [captionToggleWidth, setCaptionToggleWidth] = useState(0);
+ // The current page's caption in its band, and whether two lines clamp it.
+ // Text's truncation check also follows new text in a box that keeps its
+ // size. While the caption is open it holds its closed answer, so the toggle
+ // stays mounted, with its focus, as the caption closes.
+ const bandCaptionRef = useRef(null);
+ const captionTruncation = useHeldTruncation({
+ maxLines: 2,
+ isHeld: isCaptionOpen,
+ });
+ const isCaptionClamped = captionTruncation.isTruncated;
+ const measureCaptionClamp = captionTruncation.measure;
+ const bandCaptionMeasureRef = useMergedRefs(
+ bandCaptionRef,
+ captionTruncation.ref,
+ );
+ const [captionShownLines, setCaptionShownLines] = useState(0);
+ // Whether the current caption is open, for the content observer's callback.
+ const isCaptionOpenRef = useRef(false);
+ // The current caption's lines, without its toggle's place.
+ const bandCaptionFlowRef = useRef(null);
+ // The caption's width at its last measure, and the cancel of a smaller
+ // count that waits for the next frame.
+ const captionWidthRef = useRef(0);
+ const cancelCaptionShrinkRef = useRef<(() => void) | null>(null);
+ // Whether the open caption scrolls inside its panel.
+ const [isCaptionScrolling, setIsCaptionScrolling] = useState(false);
+ const measureCaptionLines = useCallback(() => {
+ // A measure in the same frame cancels a waiting smaller count, so it
+ // counts the text's lines again.
+ const wasShrinking = cancelCaptionShrinkRef.current != null;
+ cancelCaptionShrinkRef.current?.();
+ cancelCaptionShrinkRef.current = null;
+ const caption = bandCaptionRef.current;
+ if (caption != null) {
+ const view = caption.ownerDocument.defaultView ?? window;
+ const style = view.getComputedStyle(caption);
+ const line = parseFloat(style.lineHeight);
+ const hasLine = Number.isFinite(line) && line > 0;
+ const linesOf = (height: number) =>
+ Math.round(
+ (height -
+ parseFloat(style.paddingTop) -
+ parseFloat(style.paddingBottom)) /
+ line,
+ );
+ // The client height rounds to whole pixels, so this rounds to whole
+ // lines.
+ const lineCount = hasLine ? linesOf(caption.clientHeight) : 0;
+ setCaptionShownLines(lineCount);
+ // The open caption is a scroll container, so its height includes the
+ // toggle's place, and that height never falls by itself. A new width
+ // can need fewer lines. The flow's height counts only the text's lines.
+ const flow = bandCaptionFlowRef.current;
+ const width = caption.clientWidth;
+ if (
+ isCaptionOpen &&
+ hasLine &&
+ flow != null &&
+ (width !== captionWidthRef.current || wasShrinking)
+ ) {
+ const textLines = Math.round(
+ flow.getBoundingClientRect().height / line,
+ );
+ if (textLines < lineCount) {
+ // Fewer lines shrink the caption. Inside this delivery that is a
+ // resize loop, so they wait for the next frame. A toggle that no
+ // longer fits beside the last line then takes a line of its own.
+ const frame = view.requestAnimationFrame(() => {
+ cancelCaptionShrinkRef.current = null;
+ if (bandCaptionRef.current !== caption) {
+ return;
+ }
+ // eslint-disable-next-line @eslint-react/dom-no-flush-sync -- the smaller count must lay out before the caption is read again in this frame
+ flushSync(() => {
+ setCaptionShownLines(textLines);
+ });
+ setCaptionShownLines(
+ Math.max(textLines, linesOf(caption.clientHeight)),
+ );
+ });
+ cancelCaptionShrinkRef.current = () => {
+ view.cancelAnimationFrame(frame);
+ };
+ }
+ }
+ captionWidthRef.current = width;
+ }
+ setIsCaptionScrolling(
+ isCaptionOpen &&
+ caption != null &&
+ caption.scrollHeight > caption.clientHeight + 1,
+ );
+ }, [isCaptionOpen]);
+ // The caption's reads and its toggle's width wait for one microtask after
+ // the observer's callback. There the caption is read before any write, and
+ // one commit lands before the frame paints. A read after a write in that
+ // delivery would force a layout before the browser's own.
+ const measureCaptionRef = useRef(() => {});
+ // Syncs the measurement with this render's clamp and lines measures.
+ useIsomorphicLayoutEffect(() => {
+ measureCaptionRef.current = () => {
+ measureCaptionClamp();
+ measureCaptionLines();
+ };
+ });
+ const captionDeliveryRef = useRef<{
+ isCaptionDirty: boolean;
+ toggleWidth: number | null;
+ } | null>(null);
+ const noteCaptionDelivery = useCallback(
+ (isCaptionDirty: boolean, toggleWidth: number | null) => {
+ let due = captionDeliveryRef.current;
+ if (due == null) {
+ due = {isCaptionDirty: false, toggleWidth: null};
+ captionDeliveryRef.current = due;
+ queueMicrotask(() => {
+ const pending = captionDeliveryRef.current;
+ captionDeliveryRef.current = null;
+ if (pending == null) {
+ return;
+ }
+ // eslint-disable-next-line @eslint-react/dom-no-flush-sync -- a delivery comes after layout and before paint; the caption's "more" and the toggle's place commit in that frame
+ flushSync(() => {
+ if (pending.isCaptionDirty) {
+ measureCaptionRef.current();
+ }
+ if (pending.toggleWidth != null) {
+ setCaptionToggleWidth(pending.toggleWidth);
+ }
+ });
+ });
+ }
+ due.isCaptionDirty ||= isCaptionDirty;
+ due.toggleWidth = toggleWidth ?? due.toggleWidth;
+ },
+ [],
+ );
+ // Syncs the clamp, the toggle's place and the open caption's scroll with
+ // the caption's laid-out size and its text. A resize, a font load and the
+ // caption's own open resize it. New text can keep its size, and paging
+ // attaches the next page's caption. The dialog's opening attaches it again.
+ // Each registration gets one delivery after the browser lays the caption
+ // out, and the commit reads nothing. A delivery changes attributes and
+ // styles only and adds no element, so no observer that reacts to new
+ // elements runs mid-delivery. The toggle's observers sit deeper, so they
+ // deliver in the same frame.
+ useAttachment(
+ bandCaptionRef,
+ element => {
+ const box = element.closest('[data-lightbox-caption]') ?? element;
+ const register = (): (() => void) | undefined => {
+ const stop = observeResizeAfterLayout(box, () => {
+ noteCaptionDelivery(true, null);
+ });
+ if (stop == null) {
+ measureCaptionRef.current();
+ return undefined;
+ }
+ return stop;
+ };
+ let stopResize = register();
+ // New text in a box that keeps its size fires no resize. A new
+ // registration gets its own delivery after the next layout. An open
+ // caption first counts its new text's lines, as an opening does.
+ const stopContent = observeContent(element, () => {
+ stopResize?.();
+ if (isCaptionOpenRef.current) {
+ // eslint-disable-next-line @eslint-react/dom-no-flush-sync -- the caption lays out without the toggle's place before its lines are counted
+ flushSync(() => {
+ setCaptionShownLines(0);
+ });
+ // eslint-disable-next-line @eslint-react/dom-no-flush-sync -- the new count must lay out before the new registration's delivery reads the caption
+ flushSync(() => {
+ countOpenCaption();
+ });
+ }
+ stopResize = register();
+ });
+ return () => {
+ stopResize?.();
+ stopContent();
+ };
+ },
+ isOpen,
+ );
+ // The caption's toggle. Under a coarse pointer it takes taps out toward
+ // 44 px. A finger's tap on it is its own, as UIKit gives a control's tap
+ // precedence over the single tap of the photo around it.
+ const captionToggleRef = useRef(null);
+ // Close, where focus goes off a paged-away toggle when the stage takes none.
+ const closeButtonRef = useRef(null);
+ // The current caption's toggle shows while two lines clamp it, or open.
+ const hasCaptionToggle = isCaptionClamped || isCaptionOpen;
+ const captionToggleHitAreaRef = useTouchHitArea(
+ hasCaptionToggle,
+ CAPTION_TOGGLE_HIT_AREA,
+ );
+ const captionToggleRefs = useMergedRefs(
+ captionToggleRef,
+ captionToggleHitAreaRef,
+ );
+ usePressTracker(captionToggleRef);
+ // Counts an open caption's lines while the toggle's place is not shown, so
+ // the count is the text's own lines. The place also takes the toggle's width
+ // for its current label. A toggle that does not fit beside the last line
+ // then takes a line of its own, before the resize delivery reads the
+ // caption.
+ const countOpenCaption = useCallback(() => {
+ const caption = bandCaptionRef.current;
+ const flow = bandCaptionFlowRef.current;
+ if (caption == null || flow == null) {
+ return;
+ }
+ const view = caption.ownerDocument.defaultView ?? window;
+ const line = parseFloat(view.getComputedStyle(caption).lineHeight);
+ if (!Number.isFinite(line) || line <= 0) {
+ return;
+ }
+ // The layout width, as the observer's own fallback reads it: a scaled
+ // ancestor does not change it.
+ const toggleWidth = captionToggleRef.current?.offsetWidth ?? 0;
+ if (toggleWidth > 0) {
+ setCaptionToggleWidth(toggleWidth);
+ }
+ setCaptionShownLines(
+ Math.round(flow.getBoundingClientRect().height / line),
+ );
+ }, []);
+ // Syncs the content observer's callback with this render's open caption.
+ useIsomorphicLayoutEffect(() => {
+ isCaptionOpenRef.current = isCaptionOpen;
+ }, [isCaptionOpen]);
+ // Syncs the toggle's place in the caption's last line with its width. The
+ // width is the delivery's own size, measured in the layout the delivery
+ // follows, so nothing reads the toggle there. A hidden toggle's size is
+ // zero, so the last width stays until a shown toggle's delivery brings
+ // another.
+ useAttachment(
+ captionToggleRef,
+ toggle => {
+ const stop = observeResizeAfterLayout(toggle, entry => {
+ // No entry: another subscriber already observes the toggle, so it
+ // is measured here.
+ const width =
+ entry == null
+ ? toggle.offsetWidth
+ : (entry.borderBoxSize?.[0]?.inlineSize ?? 0);
+ if (width > 0) {
+ noteCaptionDelivery(false, Math.round(width));
+ }
+ });
+ if (stop == null) {
+ setCaptionToggleWidth(toggle.offsetWidth);
+ return () => {};
+ }
+ return stop;
+ },
+ null,
+ );
// Whether a mouse's or a pen's click on the media closes the viewer. It
- // does when its press or its release lands outside the media: a single
- // item's, or a gallery's current page's. The media runs from the fitted
- // photo's top to the caption's bottom. It is as wide as the photo's
- // natural width or the caption, up to the viewer's. While zoomed, the
- // photo's shown box is media too.
+ // does when its press or its release lands outside the current page's
+ // media. That is the fitted photo's rows, as wide as the photo's natural
+ // width or the caption, up to the viewer's, and the caption's own box.
+ // While zoomed, the photo's shown box is media too.
const isClickOutsideMedia = useCallback((e: ReactMouseEvent) => {
const viewer = imageWrapperRef.current;
- const page = viewer?.querySelector('[data-current]') ?? viewer;
+ const page = viewer?.querySelector('[data-current]');
const media = page?.querySelector('img') ?? page?.querySelector('video');
- // The photo's own box, which centres it: the stage, or a page's.
+ // The page's photo box, which centres the photo.
const box = media?.parentElement;
const group = mediaGroupRef.current;
if (
@@ -797,16 +1214,8 @@ export function Lightbox({
const space = (box.clientHeight - media.offsetHeight) / 2;
const boxRect = box.getBoundingClientRect();
const top = boxRect.top + space;
- let bottom = top + media.offsetHeight;
- const caption =
- page === viewer
- ? captionRef.current
- : page.querySelector('[data-lightbox-caption]');
- if (caption != null) {
- // The caption's place under the fitted photo, also while zoomed.
- const lift = caption.style.translate === '' ? space : 0;
- bottom = Math.max(bottom, caption.getBoundingClientRect().bottom - lift);
- }
+ const bottom = top + media.offsetHeight;
+ const caption = page.querySelector('[data-lightbox-caption]');
const natural =
media instanceof HTMLImageElement ? media.naturalWidth : media.videoWidth;
const width = Math.min(
@@ -815,14 +1224,41 @@ export function Lightbox({
);
const middle = boxRect.left + box.clientWidth / 2;
const shown = media.getBoundingClientRect();
- const isOutside = ({x, y}: {x: number; y: number}) =>
- (y < top || y > bottom || Math.abs(x - middle) > width / 2) &&
- !(
+ // The caption band can sit apart from the photo. There the media is the
+ // photo's rows, the band and the caption's own box, which an open caption
+ // lifts over the photo. So the space between the photo and the band is
+ // outside it.
+ const captionBox = caption?.getBoundingClientRect();
+ const bandBox = caption?.parentElement?.getBoundingClientRect();
+ const isOutside = ({x, y}: {x: number; y: number}) => {
+ const isOnShownPhoto =
x >= shown.left &&
x <= shown.right &&
y >= shown.top &&
- y <= shown.bottom
- );
+ y <= shown.bottom;
+ if (isOnShownPhoto) {
+ return false;
+ }
+ if (captionBox == null) {
+ return y < top || y > bottom || Math.abs(x - middle) > width / 2;
+ }
+ const isOnPhotoRows =
+ y >= top &&
+ y <= top + media.offsetHeight &&
+ Math.abs(x - middle) <= width / 2;
+ const isOnCaption =
+ y >= captionBox.top &&
+ y <= captionBox.bottom &&
+ x >= captionBox.left &&
+ x <= captionBox.right;
+ const isOnBand =
+ bandBox != null &&
+ y >= bandBox.top &&
+ y <= bandBox.bottom &&
+ x >= bandBox.left &&
+ x <= bandBox.right;
+ return !isOnPhotoRows && !isOnCaption && !isOnBand;
+ };
const press = pressPointRef.current;
return (
isOutside({x: e.clientX, y: e.clientY}) ||
@@ -836,9 +1272,7 @@ export function Lightbox({
return;
}
const isMediaClose =
- pointerTypeRef.current === 'touch'
- ? hasZoom && !isVideo && e.target === mediaGroupRef.current
- : (isGallery || (hasZoom && !isVideo)) && isClickOutsideMedia(e);
+ pointerTypeRef.current !== 'touch' && isClickOutsideMedia(e);
if (
e.target === e.currentTarget ||
e.target === containerRef.current ||
@@ -847,11 +1281,14 @@ export function Lightbox({
handleClose();
}
},
- [handleClose, hasZoom, isVideo, isGallery, isClickOutsideMedia],
+ [handleClose, isClickOutsideMedia],
);
const isZoomTarget = hasZoom && !isVideo;
+ // The current page's caption band. The drag to close fades it with the
+ // viewer's controls.
+ const currentCaptionBandRef = useRef(null);
const dismissFades = useCallback((): {
backdrop: Animator | null;
chrome: Animator[];
@@ -861,14 +1298,17 @@ export function Lightbox({
if (dialog == null || container == null) {
return {backdrop: null, chrome: []};
}
- const chrome: HTMLElement[] = [];
+ // A caption in a band fades as the band, which carries its backing.
+ const band = currentCaptionBandRef.current;
+ const chrome: HTMLElement[] = band == null ? [] : [band];
for (const element of container.querySelectorAll(
'[data-lightbox-chrome], [data-lightbox-page]:not([data-current]), [data-lightbox-caption]',
)) {
if (
element.parentElement?.closest(
'[data-lightbox-page]:not([data-current])',
- ) == null
+ ) == null &&
+ band?.contains(element) !== true
) {
chrome.push(element);
}
@@ -878,63 +1318,14 @@ export function Lightbox({
chrome: chrome.map(element => animatorFor(element)),
};
}, []);
-
const onDragStart = () => {
didDragRef.current = true;
};
- // With zoom the stage fills the viewer, so a caption would sit at the
- // viewer's bottom. It moves up by the stage's space below the fitted
- // photo, so it sits under the photo as it does without zoom. The stage
- // already leaves the caption its height. A photo shown larger than it fits
- // fills the stage, from a gesture's first move until a settle back lands.
- // Meanwhile the caption goes back to its own place below the stage.
- const isShownAtFitRef = useRef(true);
- const placeCaption = useCallback(() => {
- // The stage's space below its fitted photo: the photo's laid-out height,
- // not its zoomed box.
- const place = (
- caption: HTMLElement | null,
- stageElement: HTMLElement | null,
- isAtFit: boolean,
- ) => {
- const photo = stageElement?.querySelector('img');
- if (caption == null) {
- return;
- }
- const below =
- isZoomTarget && isAtFit && stageElement != null && photo != null
- ? (stageElement.clientHeight - photo.offsetHeight) / 2
- : 0;
- caption.style.translate = below > 0 ? `0 ${-below}px` : '';
- };
- place(captionRef.current, imageWrapperRef.current, isShownAtFitRef.current);
- // A gallery's pages, each with its own caption under its own photo. Only
- // the current one can be zoomed.
- for (const page of trackRef.current?.querySelectorAll(
- ':scope > [data-lightbox-page]',
- ) ?? []) {
- place(
- page.querySelector(':scope > [data-lightbox-caption]'),
- page.querySelector(':scope > [data-lightbox-page-media]'),
- !page.hasAttribute('data-current') || isShownAtFitRef.current,
- );
- }
- }, [isZoomTarget]);
- useAttachment(
- imageWrapperRef,
- element => observeResize(element, placeCaption),
- placeCaption,
- );
- // Places the caption for the new item's layout before it paints.
- useIsomorphicLayoutEffect(() => {
- placeCaption();
- }, [placeCaption, viewedItem]);
-
// The zoom stage takes the keyboard's zoom and pan and every pointer's
// gestures. Past a limit, a pinch or a pan gives way and returns.
const zoom = useZoomCore({
- stageRef: isGallery ? pageMediaRef : imageWrapperRef,
- surfaceRef: isGallery ? trackRef : imageWrapperRef,
+ stageRef: pageMediaRef,
+ surfaceRef: trackRef,
isEnabled: isOpen && isZoomTarget,
itemKey: viewedItem,
// Zoom changes are silent to assistive tech, since only the transform
@@ -945,10 +1336,6 @@ export function Lightbox({
next ? t('@astryx.lightbox.zoomedIn') : t('@astryx.lightbox.zoomedOut'),
);
},
- onFitChange: isAtFit => {
- isShownAtFitRef.current = isAtFit;
- placeCaption();
- },
});
const {isTouch} = useTouchPresentation();
const startsOnCaption = (touch: GestureTouch | undefined) => {
@@ -959,12 +1346,12 @@ export function Lightbox({
);
};
const stage = useZoomStage(zoom, {
- stageRef: isGallery ? pageMediaRef : imageWrapperRef,
- surfaceRef: isGallery ? trackRef : imageWrapperRef,
+ stageRef: pageMediaRef,
+ surfaceRef: trackRef,
isEnabled: isOpen && isZoomTarget,
itemKey: viewedItem,
- // A gallery's stage shares its surface with the page around it. A tap
- // there closes, as on the backdrop, unless it lands on the caption.
+ // The stage shares its surface with the page around it. A tap there
+ // closes, as on the backdrop, unless it lands on the caption.
onTapOutside: ({x, y}) => {
const caption = trackRef.current
?.querySelector('[data-current] [data-lightbox-caption]')
@@ -984,7 +1371,7 @@ export function Lightbox({
isTouchPresentation: isTouch,
// Only a gallery's touch pager takes a drag past a zoomed photo's edge.
hasOuterPager: isGallery && isTouch,
- // A pinch whose first finger lands on a gallery's caption is the page's.
+ // A pinch whose first finger lands on a caption is the page's.
shouldBeginPinch: recognizer =>
!startsOnCaption(recognizer.trackedTouches[0]),
dismiss: {
@@ -1010,10 +1397,7 @@ export function Lightbox({
// on a cancel the shared settle spring, read where the stage is.
const spring = dismisses
? zoomMedia.dismiss.dismissSizeSpring
- : resolveMotion(
- (isGallery ? pageMediaRef : imageWrapperRef).current,
- springs.settle,
- );
+ : resolveMotion(pageMediaRef.current, springs.settle);
const opacity = dismisses ? 0 : 1;
const move = (backdropMovesRef.current += 1);
setBackdropMoving(true);
@@ -1117,9 +1501,33 @@ export function Lightbox({
touch.startY >= box.bottom - VIDEO_CONTROLS_BAND
);
};
+
+ // Paging turns the page that holds focus inert, which drops focus inside
+ // it, such as on its caption toggle or its photo. Such focus moves to the
+ // new page's photo, or to Close without zoom, so the arrow keys keep paging.
+ const pagingFocusRef = useRef(null);
+ const noteFocusBeforePaging = useCallback(() => {
+ pagingFocusRef.current = noteFocusWithin(
+ imageWrapperRef.current?.querySelector('[data-current]'),
+ );
+ }, []);
+ // Effect: syncs DOM focus with the new page, since the browser drops focus
+ // from the page that turned inert.
+ useIsomorphicLayoutEffect(() => {
+ const note = pagingFocusRef.current;
+ pagingFocusRef.current = null;
+ moveFocusLeftBehind(
+ note,
+ isZoomTarget ? pageMediaRef.current : closeButtonRef.current,
+ note?.container,
+ );
+ }, [index, isZoomTarget]);
+
usePager({
viewportRef: imageWrapperRef,
- trackRef,
+ // A single photo has no pager, since it has nothing to page to. Its track
+ // still holds its one page and takes the zoom's gestures.
+ trackRef: isGallery ? trackRef : noPagerTrackRef,
count: mediaArray.length,
gap: zoomMedia.viewer.pageGap,
index,
@@ -1140,6 +1548,7 @@ export function Lightbox({
shouldBeginPan: direction => !stage.isZoomed() || stage.isAtEdge(direction),
onIndexChange: (next, reason) => {
if (reason === 'drag') {
+ noteFocusBeforePaging();
setIndex(next);
}
},
@@ -1158,28 +1567,35 @@ export function Lightbox({
},
});
- // Whether a point is on the current page's media group: its photo or video
- // and its caption, as one box. The photo takes no pointer events, so a
- // press on it lands on its box. An element with no size yet (a photo still
- // loading) adds nothing to the box.
+ // Whether a point is on the current page's media. That is inside its
+ // caption's own box, or in its photo's or video's rows across the width of
+ // both. So a click beside a narrow photo, within its caption's width, stays
+ // on the media. The band between the photo and the caption row does not.
+ // The photo takes no pointer events, so a press on it lands on its box. An
+ // element with no size yet (a photo still loading) counts for nothing.
const isOnCurrentMedia = (x: number, y: number) => {
const page = imageWrapperRef.current?.querySelector('[data-current]');
- const boxes = [
- page?.querySelector('img'),
- page?.querySelector('video'),
- page?.querySelector('[data-lightbox-caption]'),
- ]
- .map(element => element?.getBoundingClientRect())
- .filter((r): r is DOMRect => r != null && r.width > 0 && r.height > 0);
+ const sized = (element: Element | null | undefined) => {
+ const r = element?.getBoundingClientRect();
+ return r != null && r.width > 0 && r.height > 0 ? r : null;
+ };
+ const media =
+ sized(page?.querySelector('img')) ?? sized(page?.querySelector('video'));
+ const caption = sized(page?.querySelector('[data-lightbox-caption]'));
+ const boxes = [media, caption].filter((r): r is DOMRect => r != null);
+ const inRows = (r: DOMRect | null) =>
+ r != null && y >= r.top && y <= r.bottom;
return (
- boxes.length > 0 &&
- x >= Math.min(...boxes.map(r => r.left)) &&
- x <= Math.max(...boxes.map(r => r.right)) &&
- y >= Math.min(...boxes.map(r => r.top)) &&
- y <= Math.max(...boxes.map(r => r.bottom))
+ (inRows(caption) &&
+ caption != null &&
+ x >= caption.left &&
+ x <= caption.right) ||
+ (inRows(media) &&
+ x >= Math.min(...boxes.map(r => r.left)) &&
+ x <= Math.max(...boxes.map(r => r.right)))
);
};
- // Without zoom, a gallery's stage fills the viewer. So a finger's tap on it
+ // Without zoom, the stage fills the viewer. So a finger's tap on it
// beside the media closes the viewer, as a click on the backdrop does. A
// press that moves past the tap's movement limit is no tap. A mouse's or a
// pen's click closes it only outside the media (`isClickOutsideMedia`).
@@ -1200,7 +1616,7 @@ export function Lightbox({
handleClose();
}
},
- {isEnabled: isOpen && isGallery && !isZoomTarget, manageStyle: false},
+ {isEnabled: isOpen && !isZoomTarget, manageStyle: false},
);
// Gallery navigation. The buttons and the arrow keys ask for the new item.
@@ -1210,15 +1626,17 @@ export function Lightbox({
const goToPrev = useCallback(() => {
if (canPrev) {
+ noteFocusBeforePaging();
setIndex(index - 1);
}
- }, [canPrev, index, setIndex]);
+ }, [canPrev, index, noteFocusBeforePaging, setIndex]);
const goToNext = useCallback(() => {
if (canNext) {
+ noteFocusBeforePaging();
setIndex(index + 1);
}
- }, [canNext, index, setIndex]);
+ }, [canNext, index, noteFocusBeforePaging, setIndex]);
// Keyboard navigation. While zoomed, arrows pan the image (matching common
// lightbox conventions); when not zoomed they navigate the gallery.
@@ -1268,9 +1686,13 @@ export function Lightbox({
[isZoomTarget, stage, goToPrev, goToNext],
);
- // Enter/Space on the focused image wrapper (role="button") toggles zoom.
+ // Enter/Space on the focused zoom button toggles zoom. Keys from a child
+ // stay the child's.
const handleImageKeyDown = useCallback(
(e: React.KeyboardEvent) => {
+ if (e.target !== e.currentTarget) {
+ return;
+ }
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
stage.toggle();
@@ -1294,12 +1716,14 @@ export function Lightbox({
isDisabled,
onClick,
isMirrored = false,
+ buttonRef,
}: {
icon: ComponentProps['icon'];
label: string;
isDisabled?: boolean;
onClick: () => void;
isMirrored?: boolean;
+ buttonRef?: React.Ref;
},
) => {
const button = (
@@ -1315,6 +1739,7 @@ export function Lightbox({
]}
/>
}
+ ref={buttonRef}
label={label}
variant="ghost"
isDisabled={isDisabled}
@@ -1345,7 +1770,6 @@ export function Lightbox({
) : (
);
// The current item and its neighbours; the pager shows one at rest.
- const pageIndexes = isGallery
- ? [index - 1, index, index + 1].filter(i => i >= 0 && i < mediaArray.length)
- : [];
+ const pageIndexes = [index - 1, index, index + 1].filter(
+ i => i >= 0 && i < mediaArray.length,
+ );
+ const hasCaptionBand = mediaArray.some(item => isRenderable(item.caption));
+ // Its place ends the caption's last line, unless the open caption scrolls.
+ const hasCaptionTogglePlace = hasCaptionToggle && !isCaptionScrolling;
+ // The keyboard's zoom button is the current page's photo box, so the
+ // caption band stays outside it.
+ const zoomButtonProps = {
+ role: 'button',
+ tabIndex: 0,
+ 'aria-pressed': isZoomed,
+ 'aria-label': t('@astryx.lightbox.zoom'),
+ onKeyDown: handleImageKeyDown,
+ } as const;
return (