From d2e76e44878eac09930387c9d4324ea5e2d86d07 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 17:21:44 -0800 Subject: [PATCH 01/21] fix(Lightbox): a single photo's caption sits on the caption's backing A single photo's caption had no backing, so its white text over the backdrop measured 4.00:1 over a white page, under FR17's 4.5:1. It now sits on the backing that a gallery's caption band uses, reaching below its last line as far as above its first: 17.22:1 over a white page. The record drops the gap. --- .changeset/lightbox-single-caption-backing.md | 7 ++ .../components/Lightbox/Lightbox.spec.md | 3 - packages/core/src/Lightbox/Lightbox.tsx | 9 ++- .../LightboxZoom.interaction.browser.spec.ts | 66 +++++++++++++++++++ 4 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 .changeset/lightbox-single-caption-backing.md diff --git a/.changeset/lightbox-single-caption-backing.md b/.changeset/lightbox-single-caption-backing.md new file mode 100644 index 0000000000000..53b464ecaa871 --- /dev/null +++ b/.changeset/lightbox-single-caption-backing.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] A single photo's caption in `Lightbox` now sits on the same backing as a gallery's caption band, so it keeps 4.5:1 contrast over any page behind the viewer. + +@vjeux diff --git a/docs/specs/components/Lightbox/Lightbox.spec.md b/docs/specs/components/Lightbox/Lightbox.spec.md index c2e22fb651ead..3cd277a594e0d 100644 --- a/docs/specs/components/Lightbox/Lightbox.spec.md +++ b/docs/specs/components/Lightbox/Lightbox.spec.md @@ -88,9 +88,6 @@ the requirement it misses and the person it affects. browser that places controls elsewhere pages from them. - **Tab containment (FR4).** Tab from the viewer's last control can move focus to the browser's own interface. -- **Single photo's caption contrast (FR17).** A single photo's caption has no - backing. Its white text over the backdrop measures 4.00:1 over a white - page, under FR17's 4.5:1, so a low-vision reader can lose it there. - **Zoom reset key (AR3).** `0` does not return a zoomed photo to its fitted size. - **Zoom announcement (AR4).** A zoom change announces "zoomed in" or "zoomed diff --git a/packages/core/src/Lightbox/Lightbox.tsx b/packages/core/src/Lightbox/Lightbox.tsx index c94e5776a7011..77e7524639c1d 100644 --- a/packages/core/src/Lightbox/Lightbox.tsx +++ b/packages/core/src/Lightbox/Lightbox.tsx @@ -499,6 +499,13 @@ const styles = stylex.create({ maxWidth: '600px', flexShrink: 0, }, + // A single photo's caption sits on the caption's backing too, so it keeps + // 4.5:1 over any page behind the viewer. The backing reaches below its + // last line as far as above its first. + captionSingle: { + paddingBlockEnd: spacingVars['--spacing-2'], + backgroundColor: CAPTION_BACKING, + }, closeButton: { position: 'absolute', top: spacingVars['--spacing-3'], @@ -1858,7 +1865,7 @@ export function Lightbox({ id={captionId} as="div" type="body" - xstyle={styles.caption}> + xstyle={[styles.caption, styles.captionSingle]}> {currentItem.caption} )} diff --git a/packages/core/src/Lightbox/__tests__/LightboxZoom.interaction.browser.spec.ts b/packages/core/src/Lightbox/__tests__/LightboxZoom.interaction.browser.spec.ts index 608f5485ed2f9..4dffca0f7fe34 100644 --- a/packages/core/src/Lightbox/__tests__/LightboxZoom.interaction.browser.spec.ts +++ b/packages/core/src/Lightbox/__tests__/LightboxZoom.interaction.browser.spec.ts @@ -556,6 +556,72 @@ for (const [photo, fill] of [ }); } +for (const [story, trigger] of [ + ['core-lightbox--with-caption', 'Open with caption'], + ['core-lightbox--zoom-with-caption', 'Open zoom with caption'], +] as const) { + test(`a single photo's caption keeps 4.5:1 on its backing over a white page and photo (${story})`, async ({ + page, + }) => { + await page.route('https://picsum.photos/**', async route => + route.fulfill({ + contentType: 'image/svg+xml', + body: '', + }), + ); + await page.goto(`${storybook()}/iframe.html?id=${story}&viewMode=story`); + await page.locator('#storybook-root > *').first().waitFor(); + // The page behind the viewer is white, the lightest it can be. + await page.addStyleTag({ + content: + 'html, body, #storybook-root { background: #ffffff !important; }', + }); + await frames(page, 2); + const driver = await touchDriver(page); + const opener = await page + .getByRole('button', {name: trigger}) + .boundingBox(); + if (opener == null) { + throw new Error('no trigger'); + } + await driver.tap({ + x: opener.x + opener.width / 2, + y: opener.y + opener.height / 2, + }); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await expect + .poll(async () => + dialog + .locator('img') + .first() + .evaluate(img => (img as HTMLImageElement).naturalWidth), + ) + .toBe(1200); + // The caption is the viewer's description. + const captionId = await dialog.getAttribute('aria-describedby'); + const caption = page.locator(`[id="${captionId}"]`); + await expect(caption).toBeVisible(); + await frames(page, 2); + // The caption's colour against the lightest pixel in its top padding, + // above its first line: what the text sits on, with no glyph there. + const {color, points} = await caption.evaluate(element => { + const style = getComputedStyle(element); + const box = element.getBoundingClientRect(); + const y = box.top + parseFloat(style.paddingTop) / 2; + return { + color: style.color, + points: [0.25, 0.5, 0.75].map(f => ({x: box.left + box.width * f, y})), + }; + }); + const shot = PNG.sync.read(await page.screenshot()); + const scale = shot.width / (page.viewportSize()?.width ?? shot.width); + expect( + contrast(parseRgb(color), lightestAt(shot, scale, points)), + ).toBeGreaterThanOrEqual(4.5); + }); +} + test('the stage fills the phone screen and fits the photo', async ({page}) => { const {state} = await openViewer(page); const s = await state(); From d39851504262e9f0ad023230f6946b51c3d6a39e Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 17:21:44 -0800 Subject: [PATCH 02/21] fix(Lightbox): a single photo's caption shows as a gallery's A single photo laid out its caption on its own: under the photo, centred with it, on a plate as wide as its text. Its caption now shows as a gallery page's does. The photo is the one page of the viewer's track, and its caption sits in the band at the bottom of the viewer, on the same backing, two lines tall with "more" for a longer one. A photo with no caption has no band. Only paging stays a gallery's: a single photo shows no Previous, Next or counter and has no pager, and a drag past a zoomed photo's edge springs back. A zoomable photo's box now takes the stage's cursor, not the reset's button cursor, so a gallery shows the zoom-in cursor over its fitted photo too. The zoom core's fit callback, which only the single photo's caption used, goes. --- .changeset/lightbox-single-caption-backing.md | 2 +- apps/storybook/stories/Lightbox.stories.tsx | 29 +- .../components/Lightbox/Lightbox.spec.md | 4 +- packages/core/src/Lightbox/Lightbox.doc.mjs | 2 +- packages/core/src/Lightbox/Lightbox.test.tsx | 140 +++-- packages/core/src/Lightbox/Lightbox.tsx | 515 +++++++----------- .../LightboxFinePointer.a11y.browser.spec.ts | 47 +- .../src/Lightbox/LightboxZoomResize.test.tsx | 15 +- ...xSingleCaption.interaction.browser.spec.ts | 226 ++++++++ ...htboxZoomModes.interaction.browser.spec.ts | 18 +- packages/core/src/Lightbox/useZoomCore.ts | 48 +- 11 files changed, 609 insertions(+), 437 deletions(-) create mode 100644 packages/core/src/Lightbox/__tests__/LightboxSingleCaption.interaction.browser.spec.ts diff --git a/.changeset/lightbox-single-caption-backing.md b/.changeset/lightbox-single-caption-backing.md index 53b464ecaa871..f4f321442d3ab 100644 --- a/.changeset/lightbox-single-caption-backing.md +++ b/.changeset/lightbox-single-caption-backing.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[fix] A single photo's caption in `Lightbox` now sits on the same backing as a gallery's caption band, so it keeps 4.5:1 contrast over any page behind the viewer. +[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 fbe1636571619..8bd4faf6e4c4b 100644 --- a/apps/storybook/stories/Lightbox.stories.tsx +++ b/apps/storybook/stories/Lightbox.stories.tsx @@ -104,7 +104,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); @@ -214,6 +214,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/components/Lightbox/Lightbox.spec.md b/docs/specs/components/Lightbox/Lightbox.spec.md index 3cd277a594e0d..3d47e988d8918 100644 --- a/docs/specs/components/Lightbox/Lightbox.spec.md +++ b/docs/specs/components/Lightbox/Lightbox.spec.md @@ -140,6 +140,7 @@ FR21). Consumer props and usage remain documented in `Lightbox.doc.mjs`. | 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" button 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, and "more"/"less" is a ghost `Button`, not hand-styled elements. | Current source, docs, and tests | settled | +| FR1b | A single photo and a set of photos 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 behavior for single photo is different than multiple for the caption 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 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 | @@ -332,6 +333,7 @@ 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; 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` | @@ -340,7 +342,7 @@ a decision that they must remain unthemeable. | FR11 | Fine-pointer browser tests | Double-click, click on the photo, drag, ctrl-wheel, trackpad scroll, mouse wheel notch | A double-click that does not zoom 2× about the point, a click that hides the controls, a fine-pointer close that animates, or a mouse wheel's notch that pans a zoomed photo fails. | `audit:Lightbox/touch` | | FR6, AR1 | Touch test double-tapping a photo shaped like the screen and a landscape photo | 9:16 photo on a phone viewport, 3:2 photo | A double tap that zooms less than 2×, or not about the tapped point, fails. | `audit:Lightbox/touch` | | 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 | 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, a mid-grey and a black 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 a ghost `Button`, two start edges, or a caption under 4.5:1 fails. | `audit:Lightbox/theming` | +| FR17 | 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 a ghost `Button`, 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/src/Lightbox/Lightbox.doc.mjs b/packages/core/src/Lightbox/Lightbox.doc.mjs index fb92f3b5c9a0d..bdbc6c7dcc171 100644 --- a/packages/core/src/Lightbox/Lightbox.doc.mjs +++ b/packages/core/src/Lightbox/Lightbox.doc.mjs @@ -32,7 +32,7 @@ const anatomy = [ name: 'Caption', required: false, description: - 'Caller-provided caption displayed below the active media. In a gallery it shows at most two lines; a longer caption adds a "more" button that opens it in full over the photo.', + '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 adds a "more" button 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 599f2edf83eac..9c0d02e56dfe1 100644 --- a/packages/core/src/Lightbox/Lightbox.test.tsx +++ b/packages/core/src/Lightbox/Lightbox.test.tsx @@ -1454,6 +1454,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={[]} />, @@ -1462,7 +1502,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(); }); @@ -1745,9 +1796,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}); @@ -1835,12 +1889,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); }); }); @@ -1940,24 +1994,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. @@ -1985,7 +2021,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( @@ -2011,17 +2047,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)); }, ); @@ -2064,7 +2096,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); @@ -2087,10 +2121,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); diff --git a/packages/core/src/Lightbox/Lightbox.tsx b/packages/core/src/Lightbox/Lightbox.tsx index 77e7524639c1d..761da329f3df7 100644 --- a/packages/core/src/Lightbox/Lightbox.tsx +++ b/packages/core/src/Lightbox/Lightbox.tsx @@ -214,28 +214,18 @@ 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', - maxWidth: '100%', - maxHeight: '100%', - overflow: 'hidden', - }, - // 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, + maxWidth: '100%', + maxHeight: '100%', + overflow: 'hidden', }, // The zoom button's box clips, so its focus ring sits inside its edge. zoomButtonFocusInset: { @@ -248,42 +238,32 @@ const styles = stylex.create({ display: 'flex', alignItems: 'center', justifyContent: 'center', + alignSelf: 'stretch', + flexGrow: 1, + flexShrink: 1, + flexBasis: 0, + position: 'relative', overflow: 'hidden', 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 + // 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', }, + // 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', - }, - // 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, - }, - imageWrapperGallery: { - alignSelf: 'stretch', - flexGrow: 1, - flexShrink: 1, - flexBasis: 0, - position: 'relative', + cursor: { + default: 'inherit', + ':is(:disabled,[aria-disabled="true"])': 'default', + }, }, // 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 @@ -499,13 +479,6 @@ const styles = stylex.create({ maxWidth: '600px', flexShrink: 0, }, - // A single photo's caption sits on the caption's backing too, so it keeps - // 4.5:1 over any page behind the viewer. The backing reaches below its - // last line as far as above its first. - captionSingle: { - paddingBlockEnd: spacingVars['--spacing-2'], - backgroundColor: CAPTION_BACKING, - }, closeButton: { position: 'absolute', top: spacingVars['--spacing-3'], @@ -675,6 +648,7 @@ export function Lightbox({ 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); @@ -704,6 +678,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, current in every respect (its page, the counter, the // pager, the announcement): an index past either end shows the nearest @@ -870,16 +846,13 @@ 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(); @@ -1048,16 +1021,15 @@ export function Lightbox({ 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 ( @@ -1076,16 +1048,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( @@ -1094,11 +1058,10 @@ export function Lightbox({ ); const middle = boxRect.left + box.clientWidth / 2; const shown = media.getBoundingClientRect(); - // A gallery's caption row can sit apart from the photo. There the media is - // the photo's rows and the caption's own box. So the band between them, - // and the row beside the caption, are outside it. - const captionBox = - page === viewer ? null : caption?.getBoundingClientRect(); + // The caption band can sit apart from the photo. There the media is the + // photo's rows and the caption's own box. So the band between them, and + // the row beside the caption, are outside it. + const captionBox = caption?.getBoundingClientRect(); const isOutside = ({x, y}: {x: number; y: number}) => { const isOnShownPhoto = x >= shown.left && @@ -1135,9 +1098,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 || @@ -1146,52 +1107,18 @@ export function Lightbox({ handleClose(); } }, - [handleClose, hasZoom, isVideo, isGallery, isClickOutsideMedia], + [handleClose, isClickOutsideMedia], ); const isZoomTarget = hasZoom && !isVideo; 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(() => { - const caption = captionRef.current; - if (caption == null) { - return; - } - const stageElement = imageWrapperRef.current; - const photo = stageElement?.querySelector('img'); - // The photo's laid-out height, not its zoomed box. - const below = - isZoomTarget && - isShownAtFitRef.current && - stageElement != null && - photo != null - ? (stageElement.clientHeight - photo.offsetHeight) / 2 - : 0; - caption.style.translate = below > 0 ? `0 ${-below}px` : ''; - }, [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 @@ -1202,10 +1129,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) => { @@ -1216,11 +1139,11 @@ export function Lightbox({ ); }; const stage = useZoomStage(zoom, { - stageRef: isGallery ? pageMediaRef : imageWrapperRef, - surfaceRef: isGallery ? trackRef : imageWrapperRef, + stageRef: pageMediaRef, + surfaceRef: trackRef, isEnabled: isOpen && isZoomTarget, - // 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]') @@ -1240,7 +1163,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]), }); @@ -1313,7 +1236,9 @@ export function Lightbox({ 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, @@ -1381,7 +1306,7 @@ export function Lightbox({ 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`). @@ -1402,7 +1327,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. @@ -1550,7 +1475,6 @@ export function Lightbox({ ) : ( {isCurrent ); // 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 hasCaptionBand = - isGallery && mediaArray.some(item => isRenderable(item.caption)); + const pageIndexes = [index - 1, index, index + 1].filter( + i => i >= 0 && i < mediaArray.length, + ); + const hasCaptionBand = mediaArray.some(item => isRenderable(item.caption)); // The current caption's toggle shows while two lines clamp it, or open. const hasCaptionToggle = isCaptionClamped || isCaptionOpen; // Its place ends the caption's last line, unless the open caption scrolls. const hasCaptionTogglePlace = hasCaptionToggle && !isCaptionScrolling; - // The keyboard's zoom button is the photo's wrapper. In a gallery it is the - // current page's photo box, so the caption band stays outside it. - const isWrapperZoomButton = isZoomTarget && !isGallery; + // 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, @@ -1654,27 +1576,13 @@ export function Lightbox({ isMirrored: true, })} - {/* Media + caption group (centered together) */} -
+ {/* The stage: a track of pages, each a photo and its caption */} +
- {isGallery ? ( -
- {pageIndexes.map(i => ( +
+ {pageIndexes.map(i => ( +
+ {renderMedia(mediaArray[i], i === index)} +
+ {hasCaptionBand && (
- {renderMedia(mediaArray[i], i === index)} -
- {hasCaptionBand && ( -
- {isRenderable(mediaArray[i].caption) && ( - // The page's one caption: clamped in the - // band, or open over the photo's bottom. -
-
- +
+ +
+
+
+ {i === index && ( + // Mounted with the caption, and hidden + // until the caption needs it. So a + // measurement only shows or hides it, + // and adds no element. +
+ styles.captionToggle, + !hasCaptionToggle && + styles.captionToggleHidden, + ]} + /> + )}
- )} -
- )} -
- ))} -
- ) : ( - renderMedia(currentItem, true) - )} +
+ )} +
+ )} +
+ ))} +
- - {!isGallery && isRenderable(currentItem.caption) && ( - - {currentItem.caption} - - )} {/* Gallery nav: next — see "prev" above; stays mounted and disabled at diff --git a/packages/core/src/Lightbox/LightboxFinePointer.a11y.browser.spec.ts b/packages/core/src/Lightbox/LightboxFinePointer.a11y.browser.spec.ts index 6b755503235a3..38e9eabc0caae 100644 --- a/packages/core/src/Lightbox/LightboxFinePointer.a11y.browser.spec.ts +++ b/packages/core/src/Lightbox/LightboxFinePointer.a11y.browser.spec.ts @@ -123,12 +123,15 @@ test("the zoom target's focus ring sits inside the stage, which a clipping group await page.keyboard.press('Shift+Tab'); await page.keyboard.press('Tab'); await expect(stage).toBeFocused(); + // The zoom target is the photo's box on the stage's one page. The stage + // clips it, as a gallery's. const ring = await stage.evaluate(el => { const style = getComputedStyle(el); return { width: parseFloat(style.outlineWidth), offset: parseFloat(style.outlineOffset), - clips: getComputedStyle(el.parentElement as Element).overflow, + clips: getComputedStyle(el.closest('[data-lightbox-viewport]') as Element) + .overflow, }; }); expect(ring.clips).toBe('hidden'); @@ -137,10 +140,10 @@ test("the zoom target's focus ring sits inside the stage, which a clipping group expect(ring.offset).toBeLessThanOrEqual(-ring.width); }); -test('a caption sits under the fitted photo, as it does without zoom', async ({ +test("a caption sits in its band under the photo's box, zoomed or not, as in a gallery", async ({ page, }) => { - // A 3:1 panorama leaves the filling stage most of its height free. + // A 3:1 panorama leaves the photo's box most of its height free. await page.route('https://picsum.photos/**', async route => route.fulfill({ contentType: 'image/svg+xml', @@ -156,22 +159,29 @@ test('a caption sits under the fitted photo, as it does without zoom', async ({ await expect .poll(async () => img.evaluate(el => (el as HTMLImageElement).naturalWidth)) .toBe(3000); - const caption = dialog.getByText('A winding path through the forest'); - await expect - .poll(async () => { - const photo = await boxOf(img); - const below = await boxOf(caption); - return Math.round(below.y - (photo.y + photo.height)); - }) - .toBe(0); - // Zoomed, the photo fills the stage, and the caption goes back below it. + const caption = dialog.locator('[data-lightbox-caption]'); const stage = dialog.getByRole('button', {name: 'Zoom'}); + // The band starts where the photo's box ends, and the fitted photo centres + // in that box, clear of the band. + const band = async () => + caption.evaluate(el => { + const r = (el.parentElement as HTMLElement).getBoundingClientRect(); + return {top: r.top, height: r.height}; + }); const stageBox = await boxOf(stage); + const fitted = await band(); + expect(Math.abs(fitted.top - (stageBox.y + stageBox.height))).toBeLessThan( + 0.5, + ); + const photo = await boxOf(img); + expect(photo.y + photo.height).toBeLessThanOrEqual(fitted.top + 0.5); + // Zoomed, the photo fills its box, and the band and caption stay put. + const captionBox = await boxOf(caption); await page.mouse.dblclick(centre(stageBox).x, centre(stageBox).y); await expect(stage).toHaveAttribute('aria-pressed', 'true'); - await expect - .poll(async () => (await boxOf(caption)).y - (stageBox.y + stageBox.height)) - .toBeGreaterThanOrEqual(-0.5); + await settled(page); + expect(await band()).toEqual(fitted); + expect(await boxOf(caption)).toEqual(captionBox); }); /** Opens the gallery story (zoom on) at its first photo with the mouse. */ @@ -243,11 +253,16 @@ test('the zoom target takes its touches as on a phone where the primary pointer expect(await stage.evaluate(el => getComputedStyle(el).touchAction)).toBe( 'none', ); - // The last is the media surface, which has no box. + // As in a gallery, the photo's box claims them and the track, the zoom + // stage's surface, leaves only the page's pinch-zoom. The last is the media + // surface, which has no box. expect(await touchActionsAbove(img)).toEqual([ 'auto', 'none', 'auto', + 'pinch-zoom', + 'auto', + 'auto', 'auto', 'auto', ]); diff --git a/packages/core/src/Lightbox/LightboxZoomResize.test.tsx b/packages/core/src/Lightbox/LightboxZoomResize.test.tsx index ad063fceb3923..e2b2776267f0f 100644 --- a/packages/core/src/Lightbox/LightboxZoomResize.test.tsx +++ b/packages/core/src/Lightbox/LightboxZoomResize.test.tsx @@ -4,9 +4,9 @@ * @file LightboxZoomResize.test.tsx * @input Lightbox with `hasZoom`, mounted closed with no media, given its * photos, then opened; the shared resize observer watched - * @output Pins that the zoom follows a resize of its stage however the viewer - * first rendered. A stage that mounts after the viewer is subscribed once - * it exists + * @output Pins that the zoom follows a resize of its stage's track however + * the viewer first rendered. A track that mounts after the viewer is + * subscribed once it exists * @position Unit test for useZoomCore.ts's resize subscription */ @@ -51,12 +51,15 @@ describe('Lightbox zoom and resizes', () => { rerender( {}} media={photos} hasZoom />, ); - // The zoom target is the stage: a rotation while zoomed keeps the same - // photo point centred only if its resizes reach the zoom. + // The zoom target is the stage, on its page of the track. A rotation + // while zoomed keeps the same photo point centred only if the track's + // resizes reach the zoom. const stage = screen.getByRole('button', {name: 'Zoom'}); + const track = stage.closest('[data-lightbox-page]')?.parentElement; const observed = vi .mocked(observeResize) .mock.calls.map(([element]) => element); - expect(observed).toContain(stage); + expect(track).toBeInstanceOf(HTMLElement); + expect(observed).toContain(track); }); }); diff --git a/packages/core/src/Lightbox/__tests__/LightboxSingleCaption.interaction.browser.spec.ts b/packages/core/src/Lightbox/__tests__/LightboxSingleCaption.interaction.browser.spec.ts new file mode 100644 index 0000000000000..5f8492c03f525 --- /dev/null +++ b/packages/core/src/Lightbox/__tests__/LightboxSingleCaption.interaction.browser.spec.ts @@ -0,0 +1,226 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file LightboxSingleCaption.interaction.browser.spec.ts + * @input The built Lightbox stories with a single photo and with a gallery, + * on the phone project and at a desktop size + * @output Proofs that a single photo presents its caption as a gallery's + * page does. The band has the same shape and backing, the text the same + * type, and the photo the same box. A long caption ends in "more", and a + * photo with no caption has no band. A single photo shows no paging + * controls + * @position Interaction tests for Lightbox's caption (browser), beside + * LightboxPaging.interaction.browser.spec.ts + */ + +import {expect, test, type Page} from '@astryxdesign/a11y-spec/playwright'; +import { + frames, + openStory, + withStorybook, +} from '@astryxdesign/interaction-harness'; + +const storybook = withStorybook(test); + +/** A plain 3:2 photo, served for every story photo. */ +const PHOTO_SVG = + ''; + +/** Opens a story's Lightbox and waits for its photos to load. */ +async function openViewer(page: Page, story: string, trigger: string) { + await page.route('https://picsum.photos/**', async route => + route.fulfill({contentType: 'image/svg+xml', body: PHOTO_SVG}), + ); + await openStory(page, storybook(), story); + await page.getByRole('button', {name: trigger}).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await expect + .poll(async () => + dialog.evaluate(el => + [...el.querySelectorAll('[data-current] img')].every( + img => (img as HTMLImageElement).naturalWidth === 1200, + ), + ), + ) + .toBe(true); + await frames(page, 2); + return dialog; +} + +/** + * The current page's photo box, photo and caption band, in what a gallery + * and a single photo share: no content-dependent size. + */ +async function captionShape(page: Page) { + return page.getByRole('dialog').evaluate(el => { + const viewer = el.querySelector('[data-lightbox-viewport]'); + const current = viewer?.querySelector('[data-current]'); + const media = current?.querySelector('[data-lightbox-page-media]'); + const photo = media?.querySelector('img'); + const band = media?.nextElementSibling; + const caption = band?.querySelector('[data-lightbox-caption]'); + const text = caption?.querySelector('.astryx-text'); + if ( + viewer == null || + media == null || + photo == null || + band == null || + text == null + ) { + return null; + } + const round = (n: number) => Math.round(n * 2) / 2; + const v = viewer.getBoundingClientRect(); + const m = media.getBoundingClientRect(); + const p = photo.getBoundingClientRect(); + const b = band.getBoundingClientRect(); + const bandStyle = getComputedStyle(band); + const textStyle = getComputedStyle(text); + return { + band: { + height: round(b.height), + left: round(b.left - v.left), + right: round(v.right - b.right), + bottom: round(v.bottom - b.bottom), + backgroundColor: bandStyle.backgroundColor, + paddingBottom: bandStyle.paddingBottom, + }, + // The photo's row ends where the band starts. + photoRow: {height: round(m.height), gapToBand: round(b.top - m.bottom)}, + photo: { + top: round(p.top - v.top), + left: round(p.left - v.left), + width: round(p.width), + height: round(p.height), + }, + text: { + fontSize: textStyle.fontSize, + lineHeight: textStyle.lineHeight, + fontWeight: textStyle.fontWeight, + color: textStyle.color, + textAlign: textStyle.textAlign, + maxWidth: textStyle.maxWidth, + paddingTop: textStyle.paddingTop, + paddingInline: `${textStyle.paddingLeft} ${textStyle.paddingRight}`, + }, + }; + }); +} + +for (const size of ['phone', 'desktop'] as const) { + test.describe(`on a ${size}`, () => { + if (size === 'desktop') { + test.use({ + viewport: {width: 1280, height: 800}, + deviceScaleFactor: 1, + isMobile: false, + hasTouch: false, + }); + } + + test('a single photo presents its caption as a gallery page does: the same band, text and photo box', async ({ + page, + }) => { + await openViewer( + page, + 'core-lightbox--gallery-with-captions', + 'Open gallery with captions', + ); + const gallery = await captionShape(page); + expect(gallery).not.toBeNull(); + await openViewer( + page, + 'core-lightbox--with-caption', + 'Open with caption', + ); + expect(await captionShape(page)).toEqual(gallery); + await openViewer( + page, + 'core-lightbox--zoom-with-caption', + 'Open zoom with caption', + ); + expect(await captionShape(page)).toEqual(gallery); + }); + + test('a single photo’s long caption ends in "more", which opens it in full and "less" closes, as in a gallery', async ({ + page, + }) => { + const dialog = await openViewer( + page, + 'core-lightbox--single-with-long-caption', + 'Open single photo with a long caption', + ); + const more = dialog.getByRole('button', {name: 'more'}); + await expect(more).toBeVisible(); + await expect(more).toHaveAttribute('aria-expanded', 'false'); + const lines = async () => + dialog.evaluate(el => { + const text = el.querySelector( + '[data-current] [data-lightbox-caption] .astryx-text', + ); + if (text == null) { + return Number.NaN; + } + const style = getComputedStyle(text); + const height = + text.clientHeight - + parseFloat(style.paddingTop) - + parseFloat(style.paddingBottom); + return Math.round(height / parseFloat(style.lineHeight)); + }); + expect(await lines()).toBe(2); + await more.click(); + const less = dialog.getByRole('button', {name: 'less'}); + await expect(less).toHaveAttribute('aria-expanded', 'true'); + await expect.poll(lines).toBeGreaterThan(2); + await less.click(); + await expect(more).toHaveAttribute('aria-expanded', 'false'); + await expect.poll(lines).toBe(2); + }); + + test('a single photo with no caption has no band: its photo box fills the viewer, as a gallery’s with no caption', async ({ + page, + }) => { + const dialog = await openViewer( + page, + 'core-lightbox--with-zoom', + 'Open with zoom', + ); + const fill = await dialog.evaluate(el => { + const viewer = el.querySelector('[data-lightbox-viewport]'); + const media = viewer?.querySelector( + '[data-current] [data-lightbox-page-media]', + ); + if (viewer == null || media == null) { + return null; + } + return { + captions: el.querySelectorAll('[data-lightbox-caption]').length, + band: media.nextElementSibling != null, + heightGap: Math.round( + viewer.getBoundingClientRect().height - + media.getBoundingClientRect().height, + ), + }; + }); + expect(fill).toEqual({captions: 0, band: false, heightGap: 0}); + }); + + test('a single photo shows no paging controls: no Previous, Next or counter', async ({ + page, + }) => { + const dialog = await openViewer( + page, + 'core-lightbox--with-caption', + 'Open with caption', + ); + await expect(dialog.getByRole('button', {name: 'Previous'})).toHaveCount( + 0, + ); + await expect(dialog.getByRole('button', {name: 'Next'})).toHaveCount(0); + await expect(dialog.getByText('1 / 1')).toHaveCount(0); + await expect(dialog.locator('[data-lightbox-page]')).toHaveCount(1); + }); + }); +} diff --git a/packages/core/src/Lightbox/__tests__/LightboxZoomModes.interaction.browser.spec.ts b/packages/core/src/Lightbox/__tests__/LightboxZoomModes.interaction.browser.spec.ts index 4e6fa8ca762a3..26b71f05fbaaf 100644 --- a/packages/core/src/Lightbox/__tests__/LightboxZoomModes.interaction.browser.spec.ts +++ b/packages/core/src/Lightbox/__tests__/LightboxZoomModes.interaction.browser.spec.ts @@ -169,18 +169,28 @@ test('with a fine pointer (a desktop) the stage fills the viewer, as under a coa const img = dialog.getByRole('img', {name: 'Forest path'}); await decoded(img); await frames(page, 2); - // The stage and its group stretch to fill the viewer for every pointer. A - // click or a drag beside the photo is the stage's, as a finger's is. + // The stage and its group stretch to fill the viewer for every pointer. + // The zoom button is the stage's one page, the photo's box, and with no + // caption it fills the stage. A click or a drag beside the photo is the + // stage's, as a finger's is. const layout = await stage.evaluate(el => { - const own = getComputedStyle(el); - const group = getComputedStyle(el.parentElement as HTMLElement); + const viewport = el.closest('[data-lightbox-viewport]') as HTMLElement; + const own = getComputedStyle(viewport); + const group = getComputedStyle(viewport.parentElement as HTMLElement); + const v = viewport.getBoundingClientRect(); + const b = el.getBoundingClientRect(); return { stage: [own.alignSelf, own.flexGrow], group: [group.alignSelf, group.flexGrow], + button: [ + Math.round(b.width - v.width), + Math.round(b.height - v.height), + ], }; }); expect(layout.stage).toEqual(['stretch', '1']); expect(layout.group).toEqual(['stretch', '1']); + expect(layout.button).toEqual([0, 0]); // The photo fits inside the viewer, as before. const imgBox = await img.boundingBox(); if (imgBox == null) { diff --git a/packages/core/src/Lightbox/useZoomCore.ts b/packages/core/src/Lightbox/useZoomCore.ts index 6c8d5a5e41982..9889434a46518 100644 --- a/packages/core/src/Lightbox/useZoomCore.ts +++ b/packages/core/src/Lightbox/useZoomCore.ts @@ -5,8 +5,8 @@ /** * @file useZoomCore.ts * @input A stage element ref, which clips and centres the photo, and the - * element the gestures attach to: the stage, or a gallery's track. Also - * whether zoom is on, the current item's key and the zoom and fit callbacks + * element the gestures attach to, the viewer's track of pages. Also whether + * zoom is on, the current item's key and the zoom callback * @output Exports `useZoomCore`: Lightbox's zoom stage for every pointer. It * writes the photo's transform through the compositor animator and settles * every zoom, the keyboard's zoom and pan included, on `springs.settle`. Its @@ -65,21 +65,6 @@ export interface UseZoomCoreOptions { itemKey: unknown; /** The photo became zoomed or returned to the fitted size. */ onZoomedChange: (isZoomed: boolean) => void; - /** - * The photo left or came back to its fitted size on screen. It reads false - * from a gesture's first move or a settle's start, and true once a settle - * back lands. - */ - onFitChange?: (isAtFit: boolean) => void; -} - -/** Whether a shown transform is the fitted size, to within a pixel. */ -function isAtFit(transform: ZoomTransform): boolean { - return ( - Math.abs(transform.scale - 1) < 1e-3 && - Math.abs(transform.x) < 0.5 && - Math.abs(transform.y) < 0.5 - ); } /** What Lightbox calls: the photo's ref and the keyboard's zoom and pan. */ @@ -255,21 +240,12 @@ export function useZoomCore(options: UseZoomCoreOptions): ZoomCore { [animator], ); - const atFitRef = useRef(true); - const setAtFit = useCallback((next: boolean) => { - if (atFitRef.current !== next) { - atFitRef.current = next; - optionsRef.current.onFitChange?.(next); - } - }, []); - const write = useCallback( (transform: ZoomTransform) => { targetRef.current = transform; animator.set({x: transform.x, y: transform.y, scale: transform.scale}); - setAtFit(isAtFit(transform)); }, - [animator, setAtFit], + [animator], ); const setTarget = useCallback((transform: ZoomTransform) => { @@ -328,12 +304,8 @@ export function useZoomCore(options: UseZoomCoreOptions): ZoomCore { from.y === target.y && from.scale === target.scale ) { - setAtFit(isAtFit(target)); return; } - // The photo is off its fitted size while it moves, back at it only - // once a settle there lands. - setAtFit(false); // Registers the reduced-motion cross-fade once; later calls do nothing. registerReducedMotionCrossFade(); // Every zoom settles on `springs.settle`, read where the stage is. A @@ -344,7 +316,7 @@ export function useZoomCore(options: UseZoomCoreOptions): ZoomCore { const span = target.scale - from.scale; const rate = Number.isFinite(scaleVelocity) && span !== 0 ? scaleVelocity / span : 0; - const handle = animator.animate( + animator.animate( {x: target.x, y: target.y, scale: target.scale}, { spring: resolveMotion(stageElement, springs.settle), @@ -356,15 +328,8 @@ export function useZoomCore(options: UseZoomCoreOptions): ZoomCore { }, {touchRelease}, ); - if (isAtFit(target)) { - void handle.finished.then(landed => { - if (landed && targetRef.current === target) { - setAtFit(true); - } - }); - } }, - [animator, current, setAtFit, setZoomed], + [animator, current, setZoomed], ); // Another item: back to the fitted size before it paints. Lightbox resets @@ -375,8 +340,7 @@ export function useZoomCore(options: UseZoomCoreOptions): ZoomCore { targetRef.current = IDENTITY; zoomedRef.current = false; geometryRef.current = null; - setAtFit(true); - }, [animator, options.itemKey, setAtFit]); + }, [animator, options.itemKey]); // A rotation or resize keeps the same photo point centred at the same // on-screen size (the shared resize observer). It watches the surface: in From b64c3d4b9a8b27b140aebe83955b361996bc29ef Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 17:21:44 -0800 Subject: [PATCH 03/21] feat(Lightbox): a gallery's caption sits in a two-line band Each page of a Lightbox gallery laid out its own photo and caption, so a neighbour with a longer caption arrived with a smaller photo. Every page now has a caption band two caption lines tall under its photo, whatever the caption, so the photo area is the same on every page and paging never resizes it. A page with no caption keeps the band's space, so its photo sits where every other page's does, but it shows no backing there. A longer caption shows two lines, and its second line ends in a "more" button, so the caption keeps the band's whole width. The toggle is one line tall, so its fill stays on that line. The button opens the whole caption over the bottom of the photo, on the scrim the controls use, and its "less" closes it. An open caption too long for its panel scrolls, and it keeps the toggle's width clear at the end of every line. Paging closes it too. The whole caption is always the viewer's accessible description, clamped or open. The viewer lays out no hidden copies of other captions. Each caption renders the same elements in every state: without a toggle, the toggle's place in its last line has no size. The viewer renders inside MediaTheme, dark, so its controls, caption and counter take the theme's dark surface tokens and its rules for them, with no Lightbox-owned colors beyond the controls' and the caption's backing. The caption is body Text, both of its lines start at one edge, and "more" and "less" are a ghost Button. The band clears the screen's bottom edge and its safe area. Focus the viewer moves follows the last input, so a tap that opens it paints no ring. The caption sits on its own backing, the controls' backing colour at 90 %, so it keeps 4.5:1 over any photo or backdrop behind it. With zoom, the controls' icons and the Counter's text keep the on-dark colour, whatever a theme does to buttons or Text. An open caption is the clamped caption itself, unclamped over the photo's bottom, so the caption renders once. In a gallery the keyboard's zoom button is the current page's photo box, so the caption band and its button sit outside it, and Enter or Space on "more" toggles the caption. Paging from a focused "more", "less" or photo moves focus to the new page's photo, or to Close without zoom, so the arrow keys keep paging. "Less" keeps its focus. It folds in the follow-ups that sat on it as separate commits: a caption that grows past two lines gets "more"; the GalleryKeepsIndex story keeps its description; the caption is measured after layout, not in the commit; the caption toggle takes its width from its delivery; and a caption delivery reads first and commits once. --- .changeset/lightbox-gallery-caption-row.md | 9 + .changeset/lightbox-gallery-paging.md | 2 +- apps/storybook/stories/Lightbox.stories.tsx | 13 +- .../specs/AST-038-layer-text-boundary/spec.md | 2 +- .../components/Lightbox/Lightbox.spec.md | 9 +- packages/core/locales/en.json | 8 + packages/core/src/Lightbox/Lightbox.doc.mjs | 3 +- packages/core/src/Lightbox/Lightbox.test.tsx | 363 +++++- packages/core/src/Lightbox/Lightbox.tsx | 1011 +++++++++++++---- .../LightboxFinePointer.a11y.browser.spec.ts | 4 + .../LightboxKeyboard.a11y.browser.spec.ts | 101 ++ .../LightboxViewport.a11y.browser.spec.ts | 392 ++++++- ...oxEveryPointer.interaction.browser.spec.ts | 43 +- ...LightboxPaging.interaction.browser.spec.ts | 867 +++++++++++++- ...boxPagingModes.interaction.browser.spec.ts | 49 +- .../LightboxZoom.interaction.browser.spec.ts | 148 ++- packages/core/src/Text/useTruncation.test.tsx | 115 +- packages/core/src/Text/useTruncation.ts | 80 +- 18 files changed, 2939 insertions(+), 280 deletions(-) create mode 100644 .changeset/lightbox-gallery-caption-row.md diff --git a/.changeset/lightbox-gallery-caption-row.md b/.changeset/lightbox-gallery-caption-row.md new file mode 100644 index 0000000000000..2b9983edb79d2 --- /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 a "more" button, 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 a ghost `Button`, 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/apps/storybook/stories/Lightbox.stories.tsx b/apps/storybook/stories/Lightbox.stories.tsx index 0080f9daabdcc..fbe1636571619 100644 --- a/apps/storybook/stories/Lightbox.stories.tsx +++ b/apps/storybook/stories/Lightbox.stories.tsx @@ -152,8 +152,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: () => { @@ -170,9 +171,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'}, ]} 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 0227e9719ca52..c2e22fb651ead 100644 --- a/docs/specs/components/Lightbox/Lightbox.spec.md +++ b/docs/specs/components/Lightbox/Lightbox.spec.md @@ -88,6 +88,9 @@ the requirement it misses and the person it affects. browser that places controls elsewhere pages from them. - **Tab containment (FR4).** Tab from the viewer's last control can move focus to the browser's own interface. +- **Single photo's caption contrast (FR17).** A single photo's caption has no + backing. Its white text over the backdrop measures 4.00:1 over a white + page, under FR17's 4.5:1, so a low-vision reader can lose it there. - **Zoom reset key (AR3).** `0` does not return a zoomed photo to its fitted size. - **Zoom announcement (AR4).** A zoom change announces "zoomed in" or "zoomed @@ -139,7 +142,7 @@ FR21). Consumer props and usage remain documented in `Lightbox.doc.mjs`. | 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 | +| 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" button 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, and "more"/"less" is a ghost `Button`, not hand-styled elements. | 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 | @@ -155,6 +158,7 @@ FR21). Consumer props and usage remain documented in `Lightbox.doc.mjs`. | 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 | 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 | ### Allowed variation @@ -330,7 +334,7 @@ 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` | | 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` | @@ -339,6 +343,7 @@ a decision that they must remain unthemeable. | FR11 | Fine-pointer browser tests | Double-click, click on the photo, drag, ctrl-wheel, trackpad scroll, mouse wheel notch | A double-click that does not zoom 2× about the point, a click that hides the controls, a fine-pointer close that animates, or a mouse wheel's notch that pans a zoomed photo fails. | `audit:Lightbox/touch` | | FR6, AR1 | Touch test double-tapping a photo shaped like the screen and a landscape photo | 9:16 photo on a phone viewport, 3:2 photo | A double tap that zooms less than 2×, or not about the tapped point, fails. | `audit:Lightbox/touch` | | 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 | 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, a mid-grey and a black 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 a ghost `Button`, 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 33463386faec5..90b28c8705431 100644 --- a/packages/core/locales/en.json +++ b/packages/core/locales/en.json @@ -1599,6 +1599,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 gallery caption that two lines cut short in a Lightbox. 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 gallery's full caption is open 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/Lightbox/Lightbox.doc.mjs b/packages/core/src/Lightbox/Lightbox.doc.mjs index d65e12d9b3dd1..fb92f3b5c9a0d 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 gallery it shows at most two lines; a longer caption adds a "more" button 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 6a7c6299c5bd3..599f2edf83eac 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 {__resetLiveRegionsForTest} from '../hooks/useAnnounce'; @@ -625,6 +634,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( @@ -1292,9 +1504,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); }); @@ -2026,6 +2241,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'}); @@ -2117,3 +2363,114 @@ 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(',')})`; + const captionShapes = () => + [ + ...document.querySelectorAll('[data-lightbox-caption] .astryx-text'), + ].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 0cefcc060028e..c94e5776a7011 100644 --- a/packages/core/src/Lightbox/Lightbox.tsx +++ b/packages/core/src/Lightbox/Lightbox.tsx @@ -26,6 +26,7 @@ import { useCallback, useEffect, + useId, useMemo, useRef, useState, @@ -34,6 +35,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 { @@ -50,6 +52,9 @@ import {useScrollLock} from '../hooks/useScrollLock'; import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; import {useAttachment} from '../hooks/useAttachment'; import {observeResize} from '../utils/sharedResizeObserver'; +import {observeContent} from '../utils/sharedMutationObserver'; +import {watchHitArea, writeHitInsets} from '../utils/hitArea'; +import {usePressTracker} from '../gestures/usePressTracker'; import {mergeProps, rtlStyles} from '../utils'; import type {BaseProps} from '../BaseProps'; import {themeProps} from '../utils/themeProps'; @@ -80,7 +85,19 @@ 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 {useInteractionModalityTracking} from '../utils/interactionModality'; +import {MediaTheme} from '../theme/MediaTheme'; +import {Text} from '../Text/Text'; +import {useHeldTruncation} from '../Text/useTruncation'; +import {Button} from '../Button/Button'; registerEnglishFallback(english); /** @@ -161,6 +178,10 @@ 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)`; + const styles = stylex.create({ dialog: { position: 'fixed', @@ -216,8 +237,8 @@ const styles = stylex.create({ 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']})`, @@ -247,14 +268,8 @@ const styles = stylex.create({ 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, }, // 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. @@ -270,42 +285,179 @@ const styles = stylex.create({ flexBasis: 0, position: 'relative', }, - // 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', overflow: 'hidden', - 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. + // A zero-width float as tall as every shown line but the last. It pushes + // the toggle's place down to the last line. Without a toggle it has no + // height. + captionToggleSpacer: { + float: { + default: 'right', + ':is([dir="rtl"] *)': 'left', + }, + inlineSize: 0, + }, + captionToggleSpacerHeight: (height: number) => ({ + blockSize: height, + }), + // 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 size, so it moves no line. + captionTogglePlace: { + float: { + default: 'right', + ':is([dir="rtl"] *)': 'left', + }, + clear: { + default: 'right', + ':is([dir="rtl"] *)': 'left', + }, + }, + captionTogglePlaceShown: (width: number) => ({ + inlineSize: width, + blockSize: '1lh', + marginInlineStart: spacingVars['--spacing-1'], + }), + // The caption's Button over its place: the caption's last line, inside the + // Text's inline padding. It is one caption line tall, so its fill stays on + // its own line. Under a coarse pointer, the shared hit area writes how far + // the toggle may grow toward 44 px on each side, short of any neighbour. + // This layer takes taps out to there, except upward into the line above. + // Its sides are physical, as the page's rects. + // SYNC: `caption`'s paddingInline. + // SYNC: /packages/core/src/utils/hitArea.ts (HIT_INSET_PROPERTIES) + captionToggle: { + position: 'absolute', + insetInlineEnd: spacingVars['--spacing-3'], + insetBlockEnd: 0, + blockSize: '1lh', + paddingBlock: 0, + '::after': { + content: '""', + position: 'absolute', + inset: + '0 calc(var(--astryx-hit-inset-right, 0px) * -1) calc(var(--astryx-hit-inset-bottom, 0px) * -1) calc(var(--astryx-hit-inset-left, 0px) * -1)', + }, + }, + // 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: { @@ -338,11 +490,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'], @@ -375,14 +525,8 @@ 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, }, - controlButton: { - color: colorVars['--color-on-dark'], - }, // With zoom a photo can lie under the controls. Lightbox's own translucent // backing keeps them readable over any photo: 3:1 for the buttons, 4.5:1 // for the counter's text. A theme restyles the buttons, not the backing. @@ -391,14 +535,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({ @@ -424,6 +573,31 @@ const KEYBOARD_PAN_OFFSETS: Record = { /** The height of a video's native controls, px: no browser reports it. */ const VIDEO_CONTROLS_BAND = 48; +/** + * Calls `onDelivery` with each entry of the shared ResizeObserver for + * `element`. It skips the call that registers it, which runs in React's + * commit, where a read forces a layout before the browser's own. The first + * delivery comes after the browser lays `element` out. Where no + * ResizeObserver exists (jsdom), it returns null and the caller measures at + * once. + */ +function observeAfterLayout( + element: Element, + onDelivery: (entry: ResizeObserverEntry) => void, +): (() => void) | null { + if (typeof ResizeObserver === 'undefined') { + return null; + } + let isRegistered = false; + const stop = observeResize(element, entry => { + if (isRegistered) { + onDelivery(entry); + } + }); + isRegistered = true; + return stop; +} + /** * A fullscreen overlay for viewing images at full resolution. * @@ -484,7 +658,10 @@ 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); @@ -612,10 +789,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]); @@ -688,7 +872,174 @@ export function Lightbox({ // clears the flag, and a finger's only on the stage. const didDragRef = useRef(false); const mediaGroupRef = useRef(null); - const captionRef = 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, + ); + // How far below the caption's first line its last shown line starts. + const [captionLastLineTop, setCaptionLastLineTop] = useState(0); + // Whether the open caption scrolls inside its panel. + const [isCaptionScrolling, setIsCaptionScrolling] = useState(false); + const measureCaptionLines = useCallback(() => { + const caption = bandCaptionRef.current; + if (caption != null) { + const view = caption.ownerDocument.defaultView ?? window; + const style = view.getComputedStyle(caption); + const lines = + caption.clientHeight - + parseFloat(style.paddingTop) - + parseFloat(style.paddingBottom); + const line = parseFloat(style.lineHeight); + setCaptionLastLineTop( + Number.isFinite(line) ? Math.max(0, lines - line) : 0, + ); + } + 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's update changes attributes + // and styles only, and adds no element. Another observer that reacts to new + // elements then observes nothing mid-delivery. The observer watches the + // caption's box, two levels above its text. A delivery can show the toggle, + // and the toggle's observers sit deeper, so they get their deliveries in the + // same frame. + useAttachment( + bandCaptionRef, + element => { + const box = element.closest('[data-lightbox-caption]') ?? element; + const register = (): (() => void) | undefined => { + const stop = observeAfterLayout(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. + const stopContent = observeContent(element, () => { + stopResize?.(); + 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); + useAttachment( + captionToggleRef, + toggle => watchHitArea(toggle, insets => writeHitInsets(toggle, insets)), + null, + ); + usePressTracker(captionToggleRef); + // 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 = observeAfterLayout(toggle, entry => { + const width = 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 @@ -736,14 +1087,34 @@ 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) && - !( + // A gallery's caption row can sit apart from the photo. There the media is + // the photo's rows and the caption's own box. So the band between them, + // and the row beside the caption, are outside it. + const captionBox = + page === viewer ? null : caption?.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; + return !isOnPhotoRows && !isOnCaption; + }; const press = pressPointRef.current; return ( isOutside({x: e.clientX, y: e.clientY}) || @@ -783,35 +1154,21 @@ export function Lightbox({ // 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, - ); + const caption = captionRef.current; + if (caption == null) { + return; } + const stageElement = imageWrapperRef.current; + const photo = stageElement?.querySelector('img'); + // The photo's laid-out height, not its zoomed box. + const below = + isZoomTarget && + isShownAtFitRef.current && + stageElement != null && + photo != null + ? (stageElement.clientHeight - photo.offsetHeight) / 2 + : 0; + caption.style.translate = below > 0 ? `0 ${-below}px` : ''; }, [isZoomTarget]); useAttachment( imageWrapperRef, @@ -925,6 +1282,28 @@ 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, @@ -948,6 +1327,7 @@ export function Lightbox({ shouldBeginPan: direction => !stage.isZoomed() || stage.isAtEdge(direction), onIndexChange: (next, reason) => { if (reason === 'drag') { + noteFocusBeforePaging(); setIndex(next); } }, @@ -966,25 +1346,32 @@ 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 @@ -1018,15 +1405,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. @@ -1070,9 +1459,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(); @@ -1096,12 +1489,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 = ( @@ -1117,11 +1512,12 @@ export function Lightbox({ ]} /> } + ref={buttonRef} label={label} variant="ghost" isDisabled={isDisabled} onClick={onClick} - xstyle={[!hasZoom && place, styles.controlButton]} + xstyle={!hasZoom && place} /> ); return hasZoom ? ( @@ -1147,7 +1543,7 @@ export function Lightbox({ ) : ( {isCurrent i >= 0 && i < mediaArray.length) : []; + const hasCaptionBand = + isGallery && mediaArray.some(item => isRenderable(item.caption)); + // The current caption's toggle shows while two lines clamp it, or open. + const hasCaptionToggle = isCaptionClamped || isCaptionOpen; + // Its place ends the caption's last line, unless the open caption scrolls. + const hasCaptionTogglePlace = hasCaptionToggle && !isCaptionScrolling; + // The keyboard's zoom button is the photo's wrapper. In a gallery it is the + // current page's photo box, so the caption band stays outside it. + const isWrapperZoomButton = isZoomTarget && !isGallery; + const zoomButtonProps = { + role: 'button', + tabIndex: 0, + 'aria-pressed': isZoomed, + 'aria-label': t('@astryx.lightbox.zoom'), + onKeyDown: handleImageKeyDown, + } as const; return ( -
{ - pointerTypeRef.current = e.pointerType; - pressPointRef.current = {x: e.clientX, y: e.clientY}; - const isOnStage = - e.target instanceof Node && - imageWrapperRef.current?.contains(e.target) === true; - if (e.pointerType !== 'touch' || isOnStage) { - didDragRef.current = false; - } - }}> - {control(styles.closeButton, { - icon: 'close', - label: t('@astryx.lightbox.close'), - onClick: handleClose, - })} + {/* A media surface: the controls and the caption take the dark + surface tokens and the theme's rules for them. */} + +
{ + pointerTypeRef.current = e.pointerType; + pressPointRef.current = {x: e.clientX, y: e.clientY}; + const isOnStage = + e.target instanceof Node && + imageWrapperRef.current?.contains(e.target) === true; + if (e.pointerType !== 'touch' || isOnStage) { + didDragRef.current = false; + } + }}> + {control(styles.closeButton, { + icon: 'close', + label: t('@astryx.lightbox.close'), + onClick: handleClose, + buttonRef: closeButtonRef, + })} - {/* Gallery nav: prev — stays mounted and is disabled at the start of + {/* Gallery nav: prev — stays mounted and is disabled at the start of the range so pressing/arrowing to the boundary doesn't unmount the focused control and drop focus to . */} - {isGallery && - control([styles.navButton, styles.navPrev], { - icon: 'chevronLeft', - label: t('@astryx.lightbox.previous'), - isDisabled: !canPrev, - onClick: goToPrev, - isMirrored: true, - })} + {isGallery && + control([styles.navButton, styles.navPrev], { + icon: 'chevronLeft', + label: t('@astryx.lightbox.previous'), + isDisabled: !canPrev, + onClick: goToPrev, + isMirrored: true, + })} - {/* Media + caption group (centered together) */} -
+ {/* Media + caption group (centered together) */}
- e.currentTarget.removeAttribute(OVER_PHOTO_ATTRIBUTE) - : undefined - } - onDoubleClick={ - isZoomTarget - ? e => { - // A finger's double tap is the stage's own. - if (pointerTypeRef.current !== 'touch') { - stage.doubleClickAt(e.clientX, e.clientY); + styles.mediaGroup, + isZoomTarget && !isGallery && styles.mediaGroupZoom, + isGallery && styles.mediaGroupGallery, + )}> +
+ e.currentTarget.removeAttribute( + OVER_PHOTO_ATTRIBUTE, + ) + : undefined + } + onDoubleClick={ + isZoomTarget + ? e => { + // A finger's double tap is the stage's own. + if (pointerTypeRef.current !== 'touch') { + stage.doubleClickAt(e.clientX, e.clientY); + } } - } - : undefined - }> - {isGallery ? ( -
- {pageIndexes.map(i => ( -
+ : undefined + }> + {isGallery ? ( +
+ {pageIndexes.map(i => (
- {renderMedia(mediaArray[i], i === index)} -
- {isRenderable(mediaArray[i].caption) && (
- {mediaArray[i].caption} + ref={i === index ? pageMediaRef : undefined} + data-lightbox-page-media="" + {...(isZoomTarget && i === index + ? zoomButtonProps + : undefined)} + {...stylex.props( + styles.pageMedia, + isZoomTarget && styles.pageMediaZoomTarget, + isZoomTarget && + i === index && [ + focusOutlineStyles.focusVisible, + styles.zoomButtonFocusInset, + ], + )}> + {renderMedia(mediaArray[i], i === index)}
- )} -
- ))} -
- ) : ( - renderMedia(currentItem, true) + {hasCaptionBand && ( +
+ {isRenderable(mediaArray[i].caption) && ( + // The page's one caption: clamped in the + // band, or open over the photo's bottom. +
+
+ +
+
+
+ {i === index && ( + // Mounted with the caption, and hidden + // until the caption needs it. So a + // measurement only shows or hides it, + // and adds no element. +
+
+ )} +
+ )} +
+ ))} +
+ ) : ( + renderMedia(currentItem, true) + )} +
+ + {!isGallery && isRenderable(currentItem.caption) && ( + + {currentItem.caption} + )}
- {!isGallery && isRenderable(currentItem.caption) && ( -
- {currentItem.caption} -
- )} -
- - {/* Gallery nav: next — see "prev" above; stays mounted and disabled at + {/* Gallery nav: next — see "prev" above; stays mounted and disabled at the end of the range instead of unmounting. */} - {isGallery && - control([styles.navButton, styles.navNext], { - icon: 'chevronRight', - label: t('@astryx.lightbox.next'), - isDisabled: !canNext, - onClick: goToNext, - isMirrored: true, - })} + {isGallery && + control([styles.navButton, styles.navNext], { + icon: 'chevronRight', + label: t('@astryx.lightbox.next'), + isDisabled: !canNext, + onClick: goToNext, + isMirrored: true, + })} - {/* Gallery counter */} - {isGallery && mediaArray.length > 1 && ( -
- {index + 1} / {mediaArray.length} -
- )} -
+ {/* Gallery counter */} + {isGallery && mediaArray.length > 1 && ( + + {index + 1} / {mediaArray.length} + + )} + +
diff --git a/packages/core/src/Lightbox/LightboxFinePointer.a11y.browser.spec.ts b/packages/core/src/Lightbox/LightboxFinePointer.a11y.browser.spec.ts index e0d0e36f1cee2..6b755503235a3 100644 --- a/packages/core/src/Lightbox/LightboxFinePointer.a11y.browser.spec.ts +++ b/packages/core/src/Lightbox/LightboxFinePointer.a11y.browser.spec.ts @@ -243,11 +243,13 @@ test('the zoom target takes its touches as on a phone where the primary pointer expect(await stage.evaluate(el => getComputedStyle(el).touchAction)).toBe( 'none', ); + // The last is the media surface, which has no box. expect(await touchActionsAbove(img)).toEqual([ 'auto', 'none', 'auto', 'auto', + 'auto', ]); }); @@ -259,6 +261,7 @@ test('a gallery leaves its sideways touches to the page where the primary pointe // page's pinch-zoom. The drag paging is the touch presentation's, so the // pager's viewport claims no pan. const {current} = await openGallery(page); + // The last is the media surface, which has no box. expect(await touchActionsAbove(current)).toEqual([ 'auto', 'none', @@ -267,6 +270,7 @@ test('a gallery leaves its sideways touches to the page where the primary pointe 'auto', 'auto', 'auto', + 'auto', ]); }); diff --git a/packages/core/src/Lightbox/LightboxKeyboard.a11y.browser.spec.ts b/packages/core/src/Lightbox/LightboxKeyboard.a11y.browser.spec.ts index d179b26e6f473..5259b249179bf 100644 --- a/packages/core/src/Lightbox/LightboxKeyboard.a11y.browser.spec.ts +++ b/packages/core/src/Lightbox/LightboxKeyboard.a11y.browser.spec.ts @@ -137,3 +137,104 @@ test('Tab and Shift+Tab wrap inside an open single image, zoomed or not', async await page.keyboard.press('+'); await expectTabWrapsInside(page); }); + +test('an arrow key pressed on "more" pages, and focus stays in the viewer for the next arrow', async ({ + page, +}) => { + await page.route(/picsum\.photos/, async route => route.abort()); + await page.goto( + `${storybook.origin}/iframe.html?id=core-lightbox--gallery-with-captions&viewMode=story`, + ); + await page.getByRole('button', {name: 'Open gallery with captions'}).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toContainText('1 / 4'); + // The first caption runs past two lines, so its band shows "more". + const more = dialog.getByRole('button', {name: 'more'}); + await more.focus(); + await expect(more).toBeFocused(); + await page.keyboard.press('ArrowRight'); + await expect(dialog).toContainText('2 / 4'); + // Paging took the toggle away; focus moved to a part of the viewer. + const isFocusInViewer = await dialog.evaluate( + element => + element.ownerDocument.activeElement !== element.ownerDocument.body && + element.contains(element.ownerDocument.activeElement), + ); + expect(isFocusInViewer).toBe(true); + await page.keyboard.press('ArrowRight'); + await expect(dialog).toContainText('3 / 4'); +}); + +// Opens the zoomable gallery whose first caption runs past two lines. +async function openCaptionGallery(page: Page) { + await page.route(/picsum\.photos/, async route => route.abort()); + await page.goto( + `${storybook.origin}/iframe.html?id=core-lightbox--gallery-with-captions&viewMode=story`, + ); + await page.getByRole('button', {name: 'Open gallery with captions'}).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toContainText('1 / 4'); + await expect( + dialog.getByRole('button', {name: 'more', exact: true}), + ).toBeVisible(); + return dialog; +} + +test("no button sits inside the zoomable gallery's zoom button", async ({ + page, +}) => { + const dialog = await openCaptionGallery(page); + await expect(dialog.getByRole('button', {name: 'Zoom'})).toHaveCount(1); + expect( + await dialog + .locator('[role="button"] button, [role="button"] [role="button"]') + .count(), + ).toBe(0); +}); + +test('Enter and Space on "more" toggle the caption in a zoomable gallery, and the photo keeps its size', async ({ + page, +}) => { + const dialog = await openCaptionGallery(page); + const zoom = dialog.getByRole('button', {name: 'Zoom'}); + await expect(zoom).toHaveAttribute('aria-pressed', 'false'); + await dialog.getByRole('button', {name: 'more', exact: true}).focus(); + await page.keyboard.press('Enter'); + const less = dialog.getByRole('button', {name: 'less', exact: true}); + await expect(less).toHaveAttribute('aria-expanded', 'true'); + await expect(zoom).toHaveAttribute('aria-pressed', 'false'); + await page.keyboard.press('Space'); + await expect( + dialog.getByRole('button', {name: 'more', exact: true}), + ).toHaveAttribute('aria-expanded', 'false'); + await expect(zoom).toHaveAttribute('aria-pressed', 'false'); +}); + +test('"less" keeps focus on the caption toggle, and the next arrow still pages', async ({ + page, +}) => { + const dialog = await openCaptionGallery(page); + const more = dialog.getByRole('button', {name: 'more', exact: true}); + await more.focus(); + await more.click(); + const less = dialog.getByRole('button', {name: 'less', exact: true}); + await expect(less).toBeFocused(); + await less.click(); + await expect(more).toBeFocused(); + await page.keyboard.press('ArrowRight'); + await expect(dialog).toContainText('2 / 4'); +}); + +test('the arrows page from a focused photo, and focus moves to the next photo', async ({ + page, +}) => { + const dialog = await openCaptionGallery(page); + const zoom = dialog.getByRole('button', {name: 'Zoom'}); + await zoom.focus(); + await expect(zoom).toBeFocused(); + await page.keyboard.press('ArrowRight'); + await expect(dialog).toContainText('2 / 4'); + await expect(dialog.getByRole('button', {name: 'Zoom'})).toBeFocused(); + await page.keyboard.press('ArrowRight'); + await expect(dialog).toContainText('3 / 4'); +}); diff --git a/packages/core/src/Lightbox/LightboxViewport.a11y.browser.spec.ts b/packages/core/src/Lightbox/LightboxViewport.a11y.browser.spec.ts index 82dc722f56380..2d4082af3df27 100644 --- a/packages/core/src/Lightbox/LightboxViewport.a11y.browser.spec.ts +++ b/packages/core/src/Lightbox/LightboxViewport.a11y.browser.spec.ts @@ -10,7 +10,12 @@ * @position Cross-browser proof for Lightbox's full-screen sizing */ -import {expect, test} from '@astryxdesign/a11y-spec/playwright'; +import { + expect, + test, + type Locator, + type Page, +} from '@astryxdesign/a11y-spec/playwright'; import { DEFAULT_STORYBOOK_DIR, serveStorybook, @@ -72,3 +77,388 @@ test('the viewer is as tall as the dynamic viewport, so a toolbar never covers i expect(declared.supported).toBe(true); expect(declared.heights).toContain('100dvh'); }); + +/** A 3:2 photo for every item, so no size waits on the network. */ +const PHOTO_SVG = + ''; + +/** Opens "Gallery with captions": long, medium, no and short captions. */ +async function openCaptionGallery( + page: Page, + viewport: {width: number; height: number}, +): Promise { + await page.setViewportSize(viewport); + await page.route(/picsum\.photos/, async route => + route.fulfill({contentType: 'image/svg+xml', body: PHOTO_SVG}), + ); + await page.goto( + `${storybook.origin}/iframe.html?id=core-lightbox--gallery-with-captions&viewMode=story`, + ); + await page.getByRole('button', {name: 'Open gallery with captions'}).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + return dialog; +} + +interface PageFacts { + mediaTop: number; + mediaHeight: number; + bandHeight: number; + bandBackground: string; + hasCaption: boolean; +} + +/** The current page's photo box and caption band. */ +async function readPage(dialog: Locator): Promise { + return dialog.evaluate(element => { + const current = element.querySelector('[data-current]') as HTMLElement; + const media = current.querySelector( + '[data-lightbox-page-media]', + ) as HTMLElement; + const band = media.nextElementSibling as HTMLElement; + const mediaBox = media.getBoundingClientRect(); + return { + mediaTop: mediaBox.top, + mediaHeight: mediaBox.height, + bandHeight: band.getBoundingClientRect().height, + bandBackground: getComputedStyle(band).backgroundColor, + hasCaption: band.querySelector('[data-lightbox-caption]') != null, + }; + }); +} + +/** The clamped caption's text, its last shown line and its toggle. */ +async function readToggle(dialog: Locator) { + return dialog.evaluate(element => { + const caption = element.querySelector( + '[data-current] [data-lightbox-caption]', + ) as HTMLElement; + const text = caption.querySelector('.astryx-text') as HTMLElement; + const toggle = caption.querySelector('button') as HTMLElement; + const textBox = text.getBoundingClientRect(); + const toggleBox = toggle.getBoundingClientRect(); + const style = getComputedStyle(text); + const lineHeight = parseFloat(style.lineHeight); + // The rendered words of the text's last shown line, from its text + // nodes only. + const words: DOMRect[] = []; + const walker = document.createTreeWalker(text, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node != null; node = walker.nextNode()) { + const range = document.createRange(); + range.selectNodeContents(node); + words.push(...range.getClientRects()); + } + const lastLine = words.filter( + rect => + rect.width > 0 && + rect.bottom <= textBox.bottom + 0.5 && + rect.top >= textBox.bottom - lineHeight - 0.5, + ); + return { + textLeft: textBox.left, + textRight: textBox.right, + textWidth: textBox.width, + textBottom: textBox.bottom, + paddingEnd: parseFloat(style.paddingRight), + captionWidth: caption.getBoundingClientRect().width, + toggleLeft: toggleBox.left, + toggleRight: toggleBox.right, + toggleMiddle: (toggleBox.top + toggleBox.bottom) / 2, + toggleWidth: toggleBox.width, + lineHeight, + lastLineWords: lastLine.length, + lastLineEnd: Math.max(...lastLine.map(rect => rect.right)), + isClamped: text.scrollHeight > text.clientHeight + 1, + }; + }); +} + +function expectToggleEndsLastLine( + facts: Awaited>, +): void { + const detail = JSON.stringify(facts); + // The caption, its last line and its toggle are there. + expect(facts.textWidth, detail).toBeGreaterThan(100); + expect(facts.toggleWidth, detail).toBeGreaterThan(0); + expect(facts.lastLineWords, detail).toBeGreaterThan(0); + expect(facts.isClamped, detail).toBe(true); + // The text runs the caption's whole width: no column beside it. + expect(facts.textWidth, detail).toBeGreaterThanOrEqual( + facts.captionWidth - 0.5, + ); + // The toggle ends the last line, inside the text's padding. + expect(facts.toggleRight, detail).toBeLessThanOrEqual( + facts.textRight - facts.paddingEnd + 0.5, + ); + expect(facts.lastLineEnd, detail).toBeLessThanOrEqual(facts.toggleLeft + 0.5); + expect( + Math.abs(facts.toggleMiddle - (facts.textBottom - facts.lineHeight / 2)), + detail, + ).toBeLessThanOrEqual(1); +} + +for (const viewport of [ + {width: 402, height: 874}, + {width: 1440, height: 900}, +]) { + const size = `${viewport.width}×${viewport.height}`; + + test(`a gallery page without a caption shows no caption backing, and every page's photo keeps its top (${size})`, async ({ + page, + }) => { + const dialog = await openCaptionGallery(page, viewport); + const pages: PageFacts[] = []; + for (let shown = 1; shown <= 4; shown++) { + if (shown > 1) { + await dialog.getByRole('button', {name: 'Next'}).click(); + } + await expect(dialog.getByText(`${shown} / 4`)).toBeVisible(); + pages.push(await readPage(dialog)); + } + const detail = JSON.stringify(pages); + // Every page has its photo box and its band. + for (const facts of pages) { + expect(facts.mediaHeight, detail).toBeGreaterThan(100); + expect(facts.bandHeight, detail).toBeGreaterThan(20); + } + expect( + pages.map(facts => facts.hasCaption), + detail, + ).toEqual([true, true, false, true]); + for (const facts of pages) { + expect( + Math.abs(facts.mediaTop - pages[0].mediaTop), + detail, + ).toBeLessThanOrEqual(0.5); + } + expect(pages[2].bandBackground, detail).toBe('rgba(0, 0, 0, 0)'); + expect(pages[0].bandBackground, detail).not.toBe('rgba(0, 0, 0, 0)'); + }); + + test(`a clamped caption's "more" ends its second line, with no column of its own (${size})`, async ({ + page, + }) => { + const dialog = await openCaptionGallery(page, viewport); + await expect(dialog.getByRole('button', {name: 'more'})).toBeVisible(); + const facts = await readToggle(dialog); + expectToggleEndsLastLine(facts); + }); +} + +test(`the first frame that shows the clamped caption at open already ends it in "more"`, async ({ + page, +}) => { + await page.setViewportSize({width: 402, height: 874}); + await page.route(/picsum\.photos/, async route => + route.fulfill({contentType: 'image/svg+xml', body: PHOTO_SVG}), + ); + // A frame's resize observers deliver after its animation-frame callbacks + // and before it paints, and a delivery may commit an update. So each + // delivery reads the frame again, after its observer's own callback. + await page.addInitScript(() => { + const Native = window.ResizeObserver; + window.ResizeObserver = class extends Native { + constructor(callback: ResizeObserverCallback) { + super((entries, observer) => { + callback(entries, observer); + (window as unknown as {__afterResize?: () => void}).__afterResize?.(); + }); + } + }; + }); + await page.goto( + `${storybook.origin}/iframe.html?id=core-lightbox--gallery-with-captions&viewMode=story`, + ); + // Each frame from here on: whether the open viewer shows the current + // caption, and whether its "more" is there, as the frame paints it. + await page.evaluate(() => { + const frames: {isShown: boolean; hasMore: boolean}[] = []; + (window as unknown as {__captionFrames: typeof frames}).__captionFrames = + frames; + const sample = () => { + const caption = document.querySelector( + 'dialog[open] [data-current] [data-lightbox-caption]', + ); + const text = caption?.querySelector('.astryx-text'); + const isShown = text != null && text.getBoundingClientRect().height > 0; + // The toggle stays mounted, hidden until the caption is clamped. + const toggle = caption?.querySelector('button'); + const hasMore = toggle != null && toggle.getClientRects().length > 0; + return {isShown, hasMore}; + }; + const read = () => { + frames.push(sample()); + if (frames.length < 600 && !frames.some(frame => frame.hasMore)) { + requestAnimationFrame(read); + } + }; + (window as unknown as {__afterResize: () => void}).__afterResize = () => { + if (frames.length > 0) { + frames[frames.length - 1] = sample(); + } + }; + requestAnimationFrame(read); + }); + await page.getByRole('button', {name: 'Open gallery with captions'}).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog.getByRole('button', {name: 'more'})).toBeVisible(); + const frames = await page.evaluate( + () => + ( + window as unknown as { + __captionFrames: {isShown: boolean; hasMore: boolean}[]; + } + ).__captionFrames, + ); + const firstShown = frames.find(frame => frame.isShown); + expect(firstShown, JSON.stringify(frames.slice(-6))).toEqual({ + isShown: true, + hasMore: true, + }); +}); + +test('the caption opens, closes and pages with no ResizeObserver loop error', async ({ + page, +}) => { + // Storybook's preview observes the document body again for each element a + // commit adds. An element added in a resize delivery joins that loop late, + // and the browser reports the loop as an error. + await page.addInitScript(() => { + const errors: string[] = []; + (window as unknown as {__pageErrors: string[]}).__pageErrors = errors; + window.addEventListener('error', event => { + errors.push(event.message); + }); + }); + const dialog = await openCaptionGallery(page, {width: 402, height: 874}); + await expect(dialog.getByRole('button', {name: 'more'})).toBeVisible(); + for (let round = 0; round < 2; round++) { + await dialog.getByRole('button', {name: 'more'}).click(); + await expect(dialog.getByRole('button', {name: 'less'})).toBeVisible(); + await dialog.getByRole('button', {name: 'less'}).click(); + await expect(dialog.getByRole('button', {name: 'more'})).toBeVisible(); + } + await dialog.getByRole('button', {name: 'Next'}).click(); + await expect(dialog.getByText('2 / 4')).toBeVisible(); + await dialog.getByRole('button', {name: 'Previous'}).click(); + await expect(dialog.getByText('1 / 4')).toBeVisible(); + // An arrow key pages from the focused "more", which opens the caption. + await dialog.getByRole('button', {name: 'more'}).click(); + await expect(dialog.getByRole('button', {name: 'less'})).toBeVisible(); + await page.keyboard.press('ArrowRight'); + await expect(dialog.getByText('2 / 4')).toBeVisible(); + await page.keyboard.press('ArrowLeft'); + await expect(dialog.getByText('1 / 4')).toBeVisible(); + await expect(dialog.getByRole('button', {name: 'more'})).toBeVisible(); + // Two frames, so the last frame's deliveries have run. + await page.evaluate( + async () => + new Promise(resolve => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + }), + ); + const errors = await page.evaluate( + () => (window as unknown as {__pageErrors: string[]}).__pageErrors, + ); + expect(errors).toEqual([]); +}); + +test(`a caption whose text grows while its box keeps two lines gets "more" at the end of its second line`, async ({ + page, +}) => { + const dialog = await openCaptionGallery(page, {width: 402, height: 874}); + await dialog.getByRole('button', {name: 'Next'}).click(); + await expect(dialog.getByText('2 / 4')).toBeVisible(); + // The second page's caption fits its two lines, with no toggle. + await expect(dialog.getByRole('button', {name: 'more'})).toHaveCount(0); + const lines = await dialog.evaluate(element => { + const text = element.querySelector( + '[data-current] [data-lightbox-caption] .astryx-text', + ) as HTMLElement; + const walker = document.createTreeWalker(text, NodeFilter.SHOW_TEXT); + let last: Text | null = null; + for (let node = walker.nextNode(); node != null; node = walker.nextNode()) { + last = node as Text; + } + const before = text.getBoundingClientRect().height; + // New text, past two lines, in a box that keeps its height. + last?.appendData( + ' The trail climbs past the old mill, crosses the river on stepping stones, and winds back through the beeches to the car park at the top of the hill.', + ); + return {before, after: text.getBoundingClientRect().height}; + }); + expect(lines.after, JSON.stringify(lines)).toBeCloseTo(lines.before, 0); + await expect(dialog.getByRole('button', {name: 'more'})).toBeVisible(); + await expect(async () => { + expectToggleEndsLastLine(await readToggle(dialog)); + }).toPass({timeout: 3_000}); +}); + +test(`a clamped caption's "more" still ends its second line after the viewport narrows (402 → 360)`, async ({ + page, +}) => { + const dialog = await openCaptionGallery(page, {width: 402, height: 874}); + await expect(dialog.getByRole('button', {name: 'more'})).toBeVisible(); + expectToggleEndsLastLine(await readToggle(dialog)); + await page.setViewportSize({width: 360, height: 874}); + await expect(async () => { + const facts = await readToggle(dialog); + expect(facts.textWidth, JSON.stringify(facts)).toBeLessThan(361); + expectToggleEndsLastLine(facts); + }).toPass({timeout: 3_000}); +}); + +for (const width of [360, 390]) { + test(`an open caption scrolled to its end keeps every line beside "less" clear of it (${width}×150)`, async ({ + page, + }) => { + // A short viewport: the open caption runs past its panel and scrolls. + const dialog = await openCaptionGallery(page, {width, height: 150}); + await dialog.getByRole('button', {name: 'more'}).click(); + await expect(dialog.getByRole('button', {name: 'less'})).toBeVisible(); + const facts = await dialog.evaluate(element => { + const caption = element.querySelector( + '[data-current] [data-lightbox-caption]', + ) as HTMLElement; + const text = caption.querySelector('.astryx-text') as HTMLElement; + text.scrollTop = text.scrollHeight; + const textBox = text.getBoundingClientRect(); + const toggle = ( + caption.querySelector('button') as HTMLElement + ).getBoundingClientRect(); + const words: DOMRect[] = []; + const walker = document.createTreeWalker(text, NodeFilter.SHOW_TEXT); + for ( + let node = walker.nextNode(); + node != null; + node = walker.nextNode() + ) { + const range = document.createRange(); + range.selectNodeContents(node); + words.push(...range.getClientRects()); + } + // The shown words level with the toggle. + const beside = words.filter( + rect => + rect.width > 0 && + rect.bottom > textBox.top + 0.5 && + rect.top < textBox.bottom - 0.5 && + rect.bottom > toggle.top + 0.5 && + rect.top < toggle.bottom - 0.5, + ); + return { + overflow: text.scrollHeight - text.clientHeight, + isAtEnd: + Math.abs(text.scrollTop + text.clientHeight - text.scrollHeight) <= 1, + besideCount: beside.length, + besideEnd: Math.max(...beside.map(rect => rect.right)), + toggleLeft: toggle.left, + }; + }); + const detail = JSON.stringify(facts); + expect(facts.overflow, detail).toBeGreaterThan(20); + expect(facts.isAtEnd, detail).toBe(true); + expect(facts.besideCount, detail).toBeGreaterThan(0); + expect(facts.besideEnd, detail).toBeLessThanOrEqual(facts.toggleLeft + 0.5); + }); +} diff --git a/packages/core/src/Lightbox/__tests__/LightboxEveryPointer.interaction.browser.spec.ts b/packages/core/src/Lightbox/__tests__/LightboxEveryPointer.interaction.browser.spec.ts index 552f709b31175..5b4ab001b86f1 100644 --- a/packages/core/src/Lightbox/__tests__/LightboxEveryPointer.interaction.browser.spec.ts +++ b/packages/core/src/Lightbox/__tests__/LightboxEveryPointer.interaction.browser.spec.ts @@ -33,11 +33,13 @@ import { const storybook = withStorybook(test); -type Story = 'with-zoom' | 'gallery-with-zoom' | 'default'; +type Story = + 'with-zoom' | 'gallery-with-zoom' | 'gallery-with-captions' | 'default'; const TRIGGER: Record = { 'with-zoom': 'Open with zoom', 'gallery-with-zoom': 'Open gallery with zoom', + 'gallery-with-captions': 'Open gallery with captions', default: 'Open lightbox', }; @@ -89,10 +91,9 @@ async function openDesktop( await page.getByRole('button', {name: TRIGGER[story]}).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible(); - const img = - story === 'gallery-with-zoom' - ? dialog.locator('[data-current] img') - : dialog.getByRole('img').first(); + const img = story.startsWith('gallery') + ? dialog.locator('[data-current] img') + : dialog.getByRole('img').first(); await expect .poll(async () => img.evaluate(el => (el as HTMLImageElement).naturalWidth)) .toBe(3000); @@ -165,6 +166,38 @@ async function timedPointer(page: Page, pointerType: 'mouse' | 'pen') { }; } +test('a click on "more" opens a long caption in full, and the viewer stays open', async ({ + browser, +}) => { + const {context, page, dialog} = await openDesktop( + browser, + 'gallery-with-captions', + ); + try { + await dialog.getByRole('button', {name: 'more'}).click(); + const less = dialog.getByRole('button', {name: 'less'}); + await expect(less).toHaveAttribute('aria-expanded', 'true'); + const id = await less.getAttribute('aria-controls'); + await expect(page.locator(`[id="${id}"]`)).toBeVisible(); + await less.click(); + await expect(dialog.getByRole('button', {name: 'more'})).toHaveAttribute( + 'aria-expanded', + 'false', + ); + // Back in the band: the same caption, without the open caption's backing. + await expect + .poll(async () => + page + .locator(`[id="${id}"]`) + .evaluate(caption => getComputedStyle(caption).position), + ) + .toBe('static'); + await expect(dialog).toBeVisible(); + } finally { + await context.close(); + } +}); + test('a mouse drag sideways leaves a gallery on its photo, as on main', async ({ browser, }) => { diff --git a/packages/core/src/Lightbox/__tests__/LightboxPaging.interaction.browser.spec.ts b/packages/core/src/Lightbox/__tests__/LightboxPaging.interaction.browser.spec.ts index 403abf5d52983..f9c2614e21b8d 100644 --- a/packages/core/src/Lightbox/__tests__/LightboxPaging.interaction.browser.spec.ts +++ b/packages/core/src/Lightbox/__tests__/LightboxPaging.interaction.browser.spec.ts @@ -26,6 +26,7 @@ import { BusyWindowTooLateError, castStoryboard, CONTROL_RGB, + countBetween, drag, frames, markElement, @@ -38,6 +39,7 @@ import { type TouchDriver, type TouchScript, touchStoryboard, + traceRendering, trackMarker, waitForAnimation, waitForRest, @@ -120,7 +122,9 @@ async function openGallery( await driver.tap({x: t.x + t.width / 2, y: t.y + t.height / 2}); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible(); - const viewport = dialog.getByRole('button', {name: 'Zoom'}); + // The pager's viewport. In a gallery the keyboard's zoom button is the + // current page's photo box inside it. + const viewport = dialog.locator('[data-lightbox-viewport]'); const track = viewport.locator(':scope > div').first(); await expect .poll(async () => @@ -240,6 +244,383 @@ test("a drag past half a photo pages to the next one, Photos' 40 pt gap between await expect(g.dialog).toHaveAccessibleName('Mountain lake'); }); +test('every page centres its photo on the same box, with or without a caption', async ({ + page, +}) => { + // The story's first photo has a caption and the second none. The pages + // share one caption row, so the photos line up mid-swipe and the next + // photo rests where the first one did. + const g = await openGallery(page); + const centres = async () => + g.track.evaluate(el => + ([...el.children] as HTMLElement[]).map(p => { + const img = p.querySelector('img'); + const r = img?.getBoundingClientRect(); + return { + current: p.hasAttribute('data-current'), + y: r == null ? Number.NaN : r.top + r.height / 2, + }; + }), + ); + const before = await centres(); + expect(before.length).toBeGreaterThan(1); + const first = before.find(c => c.current)?.y ?? Number.NaN; + for (const c of before) { + expect(Math.abs(c.y - first)).toBeLessThan(1); + } + const mid = {x: g.box.x + g.box.width * 0.8, y: g.box.y + g.box.height / 2}; + let spread = Number.NaN; + await g.driver.play(slowDrag(mid, -g.box.width * 0.6), { + onEvent: async step => { + if (step.type === 'touchMove' && Number.isNaN(spread)) { + const ys = (await centres()).map(c => c.y); + spread = Math.max(...ys) - Math.min(...ys); + } + }, + }); + expect(spread).toBeLessThan(1); + await waitForRest(g.track); + expect(await g.counter()).toBe('2 / 4'); + const after = (await centres()).find(c => c.current)?.y ?? Number.NaN; + expect(Math.abs(after - first)).toBeLessThan(1); +}); + +test('every page keeps a two-line caption band, so the photo keeps its box', async ({ + page, +}) => { + // The story's captions run long, about two lines, none and short. Each + // page's band under its photo is two caption lines tall, so the photo box + // neither grows nor moves from page to page. + const g = await openGallery(page, { + story: 'core-lightbox--gallery-with-captions', + trigger: 'Open gallery with captions', + }); + const shown = async () => + g.track.evaluate(el => { + const current = el.querySelector(':scope > [data-current]'); + const media = current?.querySelector('[data-lightbox-page-media]'); + const band = media?.nextElementSibling; + if (media == null || band == null) { + return null; + } + const r = media.getBoundingClientRect(); + // The band's lines, above the padding that clears the screen's edge. + const pad = parseFloat(getComputedStyle(band).paddingBottom); + return {top: r.top, height: r.height, band: band.clientHeight - pad}; + }); + // Two lines of the caption's text, under its top padding. + const twoLines = await g.track.evaluate(el => { + const caption = el.querySelector( + ':scope > [data-current] [data-lightbox-caption]', + ); + const text = caption?.querySelector('.astryx-text') ?? caption; + if (text == null) { + throw new Error('no caption'); + } + const style = getComputedStyle(text); + return 2 * parseFloat(style.lineHeight) + parseFloat(style.paddingTop); + }); + const more = g.dialog.getByRole('button', {name: 'more'}); + const first = await shown(); + expect(first).not.toBeNull(); + expect(Math.abs((first?.band ?? 0) - twoLines)).toBeLessThan(1); + await expect(more).toHaveCount(1); + const mid = {x: g.box.x + g.box.width * 0.8, y: g.box.y + g.box.height / 2}; + for (const counter of ['2 / 4', '3 / 4', '4 / 4']) { + await g.driver.play(slowDrag(mid, -g.box.width * 0.6)); + await waitForRest(g.track); + expect(await g.counter()).toBe(counter); + const now = await shown(); + expect(Math.abs((now?.band ?? 0) - twoLines)).toBeLessThan(1); + expect(Math.abs((now?.top ?? Number.NaN) - (first?.top ?? 0))).toBeLessThan( + 0.5, + ); + expect( + Math.abs((now?.height ?? Number.NaN) - (first?.height ?? 0)), + ).toBeLessThan(0.5); + } + // The last caption fits its band, so it has no "more". + await expect(more).toHaveCount(0); +}); + +test('the caption is body Text on the media surface: its lines start at one edge, and "more" is a ghost Button that ends the last', async ({ + page, +}) => { + const g = await openGallery(page, { + story: 'core-lightbox--gallery-with-captions', + trigger: 'Open gallery with captions', + }); + const caption = await g.track.evaluate(el => { + const text = el.querySelector( + ':scope > [data-current] [data-lightbox-caption] .astryx-text', + ); + if (text == null) { + return null; + } + // Each line's start: the leftmost fragment on each line, top to bottom, + // from the caption's text nodes only. + const fragments: DOMRect[] = []; + const walker = document.createTreeWalker(text, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node != null; node = walker.nextNode()) { + const range = document.createRange(); + range.selectNodeContents(node); + fragments.push(...range.getClientRects()); + } + const starts = new Map(); + for (const r of fragments) { + if (r.width > 1) { + const top = Math.round(r.top); + starts.set(top, Math.min(starts.get(top) ?? Infinity, r.left)); + } + } + const lines = [...starts.entries()] + .sort(([a], [b]) => a - b) + .map(([, left]) => left); + const box = text.getBoundingClientRect(); + return { + lines, + right: box.right - parseFloat(getComputedStyle(text).paddingRight), + bottom: box.bottom, + lineHeight: parseFloat(getComputedStyle(text).lineHeight), + isText: text.classList.contains('astryx-text'), + type: text.getAttribute('data-type'), + isOnMedia: text.closest('[data-astryx-media="dark"]') != null, + }; + }); + expect(caption).not.toBeNull(); + expect(caption?.isText).toBe(true); + expect(caption?.type).toBe('body'); + expect(caption?.isOnMedia).toBe(true); + // Present first: two lines or more, then the shared start edge. + expect(caption?.lines.length ?? 0).toBeGreaterThanOrEqual(2); + const [first = Number.NaN, second = Number.NaN] = caption?.lines ?? []; + expect(Math.abs(second - first)).toBeLessThan(1); + const more = g.dialog.getByRole('button', {name: 'more'}); + await expect(more).toHaveClass(/astryx-button/); + await expect(more).toHaveAttribute('data-variant', 'ghost'); + const m = await more.boundingBox(); + expect(m).not.toBeNull(); + // It ends the last line: inside the text's end, centred on that line. + expect((m?.x ?? 0) + (m?.width ?? 0)).toBeLessThanOrEqual( + (caption?.right ?? 0) + 0.5, + ); + expect( + Math.abs( + (m?.y ?? 0) + + (m?.height ?? 0) / 2 - + ((caption?.bottom ?? 0) - (caption?.lineHeight ?? 0) / 2), + ), + ).toBeLessThan(1); +}); + +test("the caption band clears the screen's bottom edge", async ({page}) => { + const g = await openGallery(page, { + story: 'core-lightbox--gallery-with-captions', + trigger: 'Open gallery with captions', + }); + const placed = await g.track.evaluate(el => { + const band = el.querySelector( + ':scope > [data-current] [data-lightbox-page-media]', + )?.nextElementSibling; + const dialog = el.closest('dialog'); + if (band == null || dialog == null) { + return null; + } + return { + pad: parseFloat(getComputedStyle(band).paddingBottom), + gap: + dialog.getBoundingClientRect().bottom - + band.getBoundingClientRect().bottom, + }; + }); + expect(placed).not.toBeNull(); + // At least the theme's 12 px spacing under the lines, inside the viewer. + expect(placed?.pad ?? 0).toBeGreaterThanOrEqual(12); + expect(placed?.gap ?? -1).toBeGreaterThanOrEqual(0); +}); + +test("a tap that opens the viewer paints no ring on its first control; a key's opening does", async ({ + page, +}) => { + const g = await openGallery(page); + const focused = async () => + page.evaluate(() => { + const active = document.activeElement; + return { + isInViewer: active?.closest('dialog') != null, + hasRing: active?.matches(':focus-visible') ?? false, + }; + }); + expect(await focused()).toEqual({isInViewer: true, hasRing: false}); + await page.keyboard.press('Escape'); + await expect(g.dialog).toBeHidden(); + const trigger = page.getByRole('button', {name: 'Open gallery with zoom'}); + await trigger.focus(); + await page.keyboard.press('Enter'); + await expect(g.dialog).toBeVisible(); + expect(await focused()).toEqual({isInViewer: true, hasRing: true}); +}); + +test('"more" opens a long caption in full over the photo, on a scrim, and "less" closes it', async ({ + page, +}) => { + const g = await openGallery(page, { + story: 'core-lightbox--gallery-with-captions', + trigger: 'Open gallery with captions', + }); + // The viewer's description is the whole caption, clamped or not. + await expect(g.dialog).toHaveAccessibleDescription( + /^A winding path through the forest.*the last hill home\.$/, + ); + const clamp = await g.track.evaluate(el => { + const text = el.querySelector( + ':scope > [data-current] [data-lightbox-caption] .astryx-text', + ); + return text == null + ? null + : {scroll: text.scrollHeight, client: text.clientHeight}; + }); + expect(clamp?.scroll ?? 0).toBeGreaterThan((clamp?.client ?? 0) + 1); + const more = g.dialog.getByRole('button', {name: 'more'}); + const m = await more.boundingBox(); + if (m == null) { + throw new Error('no more button'); + } + // A finger's tap just below the label still lands on the toggle, which + // takes taps out toward 44 px, short of the line above. + await g.driver.tap({x: m.x + m.width / 2, y: m.y + m.height + 6}); + const less = g.dialog.getByRole('button', {name: 'less'}); + await expect(less).toHaveAttribute('aria-expanded', 'true'); + const id = await less.getAttribute('aria-controls'); + const full = page.locator(`[id="${id}"]`); + await expect(full).toBeVisible(); + const placed = await full.evaluate(panel => { + const media = panel + .closest('[data-current]') + ?.querySelector('[data-lightbox-page-media]'); + const text = panel.querySelector('.astryx-text'); + const r = panel.getBoundingClientRect(); + const dialog = panel.closest('dialog'); + const described = dialog?.getAttribute('aria-describedby'); + return { + top: r.top, + photoBottom: media?.getBoundingClientRect().bottom ?? Number.NaN, + fits: text != null && text.scrollHeight <= text.clientHeight + 1, + backing: getComputedStyle(panel).backgroundColor, + text: text?.textContent ?? '', + // The caption renders once: one caption on the page, and the + // description is that caption. + captions: + panel + .closest('[data-current]') + ?.querySelectorAll('[data-lightbox-caption]').length ?? 0, + isDescription: + described != null && + text?.id === described && + dialog?.querySelectorAll(`[id="${described}"]`).length === 1, + }; + }); + // Over the photo's bottom, all of it shown, on a backing. + expect(placed.top).toBeLessThan(placed.photoBottom); + expect(placed.fits).toBe(true); + expect(placed.backing).not.toBe('rgba(0, 0, 0, 0)'); + expect(placed.text).toMatch(/the last hill home\.$/); + expect(placed.captions).toBe(1); + expect(placed.isDescription).toBe(true); + await expect(g.dialog).toBeVisible(); + expect(await g.counter()).toBe('1 / 4'); + const l = await less.boundingBox(); + if (l == null) { + throw new Error('no less button'); + } + await g.driver.tap({x: l.x + l.width / 2, y: l.y + l.height + 6}); + await expect(more).toHaveAttribute('aria-expanded', 'false'); + // Back in the band: clamped again, with no backing. + await expect + .poll(async () => + full.evaluate(panel => getComputedStyle(panel).backgroundColor), + ) + .toBe('rgba(0, 0, 0, 0)'); + const reclamped = await full.evaluate(panel => { + const text = panel.querySelector('.astryx-text'); + return text != null && text.scrollHeight > text.clientHeight + 1; + }); + expect(reclamped).toBe(true); + await expect(g.dialog).toBeVisible(); +}); + +test('a swipe that pages from a focused "more" keeps focus in the viewer', async ({ + page, +}) => { + const g = await openGallery(page, { + story: 'core-lightbox--gallery-with-captions', + trigger: 'Open gallery with captions', + }); + // A tap leaves focus on a button in Chromium and on Android. + const more = g.dialog.getByRole('button', {name: 'more'}); + await more.focus(); + await expect(more).toBeFocused(); + const mid = {x: g.box.x + g.box.width * 0.8, y: g.box.y + g.box.height / 2}; + await g.driver.play(slowDrag(mid, -g.box.width * 0.6)); + await waitForRest(g.track); + expect(await g.counter()).toBe('2 / 4'); + const isFocusInViewer = await g.dialog.evaluate( + element => + element.ownerDocument.activeElement !== element.ownerDocument.body && + element.contains(element.ownerDocument.activeElement), + ); + expect(isFocusInViewer).toBe(true); +}); + +test('a finger scrolls an open caption too long for its panel, and the gallery stays put', async ({ + page, + browserName, +}) => { + test.skip( + browserName === 'webkit', + 'needs native scrolling from injected touches (Mobile Safari on the simulator)', + ); + const g = await openGallery(page, { + story: 'core-lightbox--gallery-with-captions', + trigger: 'Open gallery with captions', + }); + // A short viewport, so the long caption overflows its panel (60 % of the + // viewport's height) at the body type's size. + await page.setViewportSize({width: 402, height: 200}); + await frames(page, 2); + const more = g.dialog.getByRole('button', {name: 'more'}); + const m = await more.boundingBox(); + if (m == null) { + throw new Error('no more button'); + } + await g.driver.tap({x: m.x + m.width / 2, y: m.y + m.height / 2}); + const less = g.dialog.getByRole('button', {name: 'less'}); + await expect(less).toHaveAttribute('aria-expanded', 'true'); + const full = page.locator( + `[id="${await less.getAttribute('aria-controls')}"]`, + ); + // The open caption's text scrolls; the toggle stays at its end. + const text = full.locator('.astryx-text').first(); + const overflow = await text.evaluate( + element => element.scrollHeight - element.clientHeight, + ); + expect(overflow).toBeGreaterThan(20); + const r = await text.boundingBox(); + if (r == null) { + throw new Error('no caption text'); + } + const from = {x: r.x + r.width / 2, y: r.y + r.height * 0.7}; + await g.driver.play( + drag(from, {x: from.x, y: from.y - 80}, {durationMs: 600, restMs: 150}), + ); + await expect + .poll(async () => text.evaluate(element => element.scrollTop)) + .toBeGreaterThan(10); + expect(await g.counter()).toBe('1 / 4'); + await expect(full).toBeVisible(); + await expect(g.dialog).toBeVisible(); +}); + test('a zoomed photo keeps the drag until its edge; the next drag from the edge pages', async ({ page, }) => { @@ -1075,3 +1456,487 @@ test("two pages on, a rotation keeps a zoomed photo's centre point", async ({ expect(Math.abs(after.u - before.u)).toBeLessThan(0.02); expect(Math.abs(after.v - before.v)).toBeLessThan(0.02); }); + +/** + * The layouts inside the task of one input: from the document's capture + * listener to its bubble listener. React commits a discrete input in a + * microtask after its root's listener, so that commit falls inside. A layout + * there is one that script forced, by reading geometry the browser had not laid + * out yet. + */ +async function taskLayouts( + page: Page, + type: 'click' | 'keydown', + act: () => Promise, +): Promise<{layouts: number; events: string[]}> { + await page.evaluate(eventType => { + const at = () => performance.timeOrigin + performance.now(); + const marks = {from: 0, to: 0}; + (window as unknown as {__task: typeof marks}).__task = marks; + document.addEventListener( + eventType, + () => { + marks.from = at(); + }, + {capture: true, once: true}, + ); + document.addEventListener( + eventType, + () => { + marks.to = at(); + }, + {once: true}, + ); + }, type); + const trace = await traceRendering(page, async () => { + await act(); + await page.waitForTimeout(100); + }); + const task = await page.evaluate( + () => (window as unknown as {__task: {from: number; to: number}}).__task, + ); + expect(task.to).toBeGreaterThan(task.from); + return { + layouts: countBetween(trace, task.from, task.to).Layout ?? 0, + events: trace.events + .filter(e => e.t >= task.from && e.t <= task.to) + .map(e => `${e.name}@${(e.t - task.from).toFixed(1)}`), + }; +} + +test('opening a gallery on a clamped caption lays out no more than opening one without captions', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'rendering traces are Chromium-only'); + await page.route('https://picsum.photos/**', async route => { + const id = /\/id\/(\d+)\//.exec(route.request().url())?.[1] ?? '10'; + await route.fulfill({ + contentType: 'image/svg+xml', + body: photoSvg(COLORS[id] ?? '#888'), + }); + }); + // Opens the story's gallery with a click. In the captioned story, the first + // page's caption is clamped, with its "more". + const measure = async (story: string, trigger: string) => { + await openStory(page, storybook(), story); + const open = await taskLayouts(page, 'click', async () => + page.getByRole('button', {name: trigger}).click(), + ); + await expect(page.getByRole('dialog')).toBeVisible(); + return open; + }; + const captioned = await measure( + 'core-lightbox--gallery-with-captions', + 'Open gallery with captions', + ); + await expect( + page.getByRole('dialog').getByRole('button', {name: 'more'}), + ).toBeVisible(); + const plain = await measure( + 'core-lightbox--gallery-with-zoom', + 'Open gallery with zoom', + ); + // The caption's sizes are read after the browser lays the page out, so + // its band and its "more" force no layout of their own. + expect( + captioned.layouts, + JSON.stringify({captioned, plain}), + ).toBeLessThanOrEqual(plain.layouts); +}); + +/** + * Logs every geometry read of an element inside a caption in the task of the + * next input of `type`. The task runs from the document's capture listener to + * its bubble listener, React's commit included. The log is + * `window.__captionReads`. + */ +async function logCaptionReadsInTask(page: Page, type: 'click' | 'keydown') { + await page.evaluate(eventType => { + const reads: string[] = []; + (window as unknown as {__captionReads: string[]}).__captionReads = reads; + let isInTask = false; + document.addEventListener( + eventType, + () => { + isInTask = true; + }, + {capture: true, once: true}, + ); + document.addEventListener( + eventType, + () => { + isInTask = false; + }, + {once: true}, + ); + const inCaption = (node: unknown) => { + const element = + node instanceof Element + ? node + : node instanceof Node + ? node.parentElement + : null; + return element?.closest('[data-lightbox-caption]') != null; + }; + const note = (name: string, node: unknown) => { + if (isInTask && inCaption(node)) { + reads.push(name); + } + }; + const getters: [object, string][] = [ + [HTMLElement.prototype, 'offsetWidth'], + [HTMLElement.prototype, 'offsetHeight'], + [Element.prototype, 'clientWidth'], + [Element.prototype, 'clientHeight'], + [Element.prototype, 'scrollWidth'], + [Element.prototype, 'scrollHeight'], + ]; + for (const [proto, name] of getters) { + const own = Object.getOwnPropertyDescriptor(proto, name); + if (own?.get == null) { + continue; + } + const get = own.get as (this: Element) => number; + Object.defineProperty(proto, name, { + configurable: true, + get(this: Element): number { + note(name, this); + return get.call(this); + }, + }); + } + const rect = Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect = function (this: Element) { + note('getBoundingClientRect', this); + return rect.call(this); + }; + const rangeRect = Range.prototype.getBoundingClientRect; + Range.prototype.getBoundingClientRect = function (this: Range) { + note('Range.getBoundingClientRect', this.commonAncestorContainer); + return rangeRect.call(this); + }; + const computed = window.getComputedStyle; + window.getComputedStyle = function ( + this: Window, + element: Element, + pseudo?: string | null, + ) { + note('getComputedStyle', element); + return computed.call(this, element, pseudo); + }; + }, type); + return async () => + page.evaluate( + () => (window as unknown as {__captionReads: string[]}).__captionReads, + ); +} + +for (const viewport of [ + {width: 402, height: 874}, + {width: 1440, height: 900}, +]) { + test(`the caption is measured after layout, never in the task that opens the gallery or pages it (${viewport.width}×${viewport.height})`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + // A delivery that resizes what its observer watches leaves that one for + // the next frame, and the browser reports the loop as an error. + await page.addInitScript(() => { + const errors: string[] = []; + (window as unknown as {__pageErrors: string[]}).__pageErrors = errors; + window.addEventListener('error', event => { + errors.push(event.message); + }); + }); + await page.route('https://picsum.photos/**', async route => { + const id = /\/id\/(\d+)\//.exec(route.request().url())?.[1] ?? '10'; + await route.fulfill({ + contentType: 'image/svg+xml', + body: photoSvg(COLORS[id] ?? '#888'), + }); + }); + await openStory(page, storybook(), 'core-lightbox--gallery-with-captions'); + // The first page's caption is clamped, with its "more". The second + // page's fits its two lines. + const openReads = await logCaptionReadsInTask(page, 'click'); + await page + .getByRole('button', {name: 'Open gallery with captions'}) + .click(); + const dialog = page.getByRole('dialog'); + await expect(dialog.getByRole('button', {name: 'more'})).toBeVisible(); + const opening = await openReads(); + await dialog.getByRole('button', {name: 'Next'}).click(); + await expect(dialog.getByText('2 / 4')).toBeVisible(); + await waitForRest(dialog.locator('[data-lightbox-viewport] > div').first()); + await frames(page, 2); + const backReads = await logCaptionReadsInTask(page, 'keydown'); + await page.keyboard.press('ArrowLeft'); + await expect(dialog.getByText('1 / 4')).toBeVisible(); + await expect(dialog.getByRole('button', {name: 'more'})).toBeVisible(); + const back = await backReads(); + await frames(page, 2); + const errors = await page.evaluate( + () => (window as unknown as {__pageErrors: string[]}).__pageErrors, + ); + expect({opening, back, errors}).toEqual({ + opening: [], + back: [], + errors: [], + }); + }); +} + +test("the caption toggle's width comes from its own delivery, never from a read of the toggle", async ({ + page, +}) => { + // A read of the toggle in a resize delivery can come after another + // observer's update in the same delivery, which forces a layout. The + // delivery's own size was measured in the layout before it. + await page.addInitScript(() => { + const reads: string[] = []; + (window as unknown as {__toggleReads: string[]}).__toggleReads = reads; + let isDelivering = false; + const Native = window.ResizeObserver; + window.ResizeObserver = class extends Native { + constructor(callback: ResizeObserverCallback) { + super((entries, observer) => { + isDelivering = true; + try { + callback(entries, observer); + } finally { + isDelivering = false; + } + }); + } + }; + const own = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'offsetWidth', + ); + const get = own?.get as ((this: HTMLElement) => number) | undefined; + if (get == null) { + return; + } + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + get(this: HTMLElement): number { + if ( + isDelivering && + this.tagName === 'BUTTON' && + this.closest('[data-lightbox-caption]') != null + ) { + reads.push('offsetWidth'); + } + return get.call(this); + }, + }); + }); + await page.route('https://picsum.photos/**', async route => { + const id = /\/id\/(\d+)\//.exec(route.request().url())?.[1] ?? '10'; + await route.fulfill({ + contentType: 'image/svg+xml', + body: photoSvg(COLORS[id] ?? '#888'), + }); + }); + await openStory(page, storybook(), 'core-lightbox--gallery-with-captions'); + await page.getByRole('button', {name: 'Open gallery with captions'}).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog.getByRole('button', {name: 'more'})).toBeVisible(); + await dialog.getByRole('button', {name: 'Next'}).click(); + await expect(dialog.getByText('2 / 4')).toBeVisible(); + await waitForRest(dialog.locator('[data-lightbox-viewport] > div').first()); + await frames(page, 2); + // Back onto the clamped first caption, whose toggle mounts hidden and is + // shown by the band's delivery. + await page.keyboard.press('ArrowLeft'); + await expect(dialog.getByText('1 / 4')).toBeVisible(); + await expect(dialog.getByRole('button', {name: 'more'})).toBeVisible(); + await frames(page, 2); + const reads = await page.evaluate( + () => (window as unknown as {__toggleReads: string[]}).__toggleReads, + ); + expect(reads).toEqual([]); +}); + +test('no caption read follows a write in the same resize delivery, from opening through "more", "less" and paging', async ({ + page, +}) => { + // A read after a write in one delivery forces a layout before the + // browser's own. A delivery lasts until the microtasks it queued have run. + // A transform or an opacity changes no layout, so it counts as no write. + // Two limits: a plain style property set by assignment is no write here, + // and a microtask that a queued microtask queues runs after the window. + await page.addInitScript(() => { + const reads: string[] = []; + (window as unknown as {__lateReads: string[]}).__lateReads = reads; + let isDelivering = false; + let hasWritten = false; + const Native = window.ResizeObserver; + window.ResizeObserver = class extends Native { + constructor(callback: ResizeObserverCallback) { + super((entries, observer) => { + isDelivering = true; + hasWritten = false; + try { + callback(entries, observer); + } finally { + // Microtasks the callback queued run before this one. + queueMicrotask(() => { + isDelivering = false; + hasWritten = false; + }); + } + }); + } + }; + const noteWrite = () => { + if (isDelivering) { + hasWritten = true; + } + }; + const setters: [object, string][] = [ + [Element.prototype, 'className'], + [HTMLElement.prototype, 'hidden'], + [Node.prototype, 'nodeValue'], + [Node.prototype, 'textContent'], + [CharacterData.prototype, 'data'], + ]; + for (const [proto, name] of setters) { + const own = Object.getOwnPropertyDescriptor(proto, name); + if (own?.set == null) { + continue; + } + const set = own.set; + Object.defineProperty(proto, name, { + ...own, + set(this: unknown, value: unknown) { + noteWrite(); + set.call(this, value); + }, + }); + } + const methods: [Record, string][] = [ + [Element.prototype as unknown as Record, 'setAttribute'], + [ + Element.prototype as unknown as Record, + 'removeAttribute', + ], + [ + CSSStyleDeclaration.prototype as unknown as Record, + 'setProperty', + ], + [ + CSSStyleDeclaration.prototype as unknown as Record, + 'removeProperty', + ], + ]; + const compositorOnly = new Set([ + 'translate', + 'scale', + 'rotate', + 'transform', + 'opacity', + ]); + for (const [proto, name] of methods) { + const method = proto[name] as (...args: unknown[]) => string | undefined; + proto[name] = function ( + this: unknown, + ...args: unknown[] + ): string | undefined { + if ( + !(this instanceof CSSStyleDeclaration) || + !compositorOnly.has(String(args[0])) + ) { + noteWrite(); + } + return method.apply(this, args); + }; + } + const inCaption = (node: unknown) => { + const element = + node instanceof Element + ? node + : node instanceof Node + ? node.parentElement + : null; + return element?.closest('[data-lightbox-caption]') != null; + }; + const note = (name: string, node: unknown) => { + if (isDelivering && hasWritten && inCaption(node)) { + reads.push(name); + } + }; + const getters: [object, string][] = [ + [HTMLElement.prototype, 'offsetWidth'], + [HTMLElement.prototype, 'offsetHeight'], + [Element.prototype, 'clientWidth'], + [Element.prototype, 'clientHeight'], + [Element.prototype, 'scrollWidth'], + [Element.prototype, 'scrollHeight'], + ]; + for (const [proto, name] of getters) { + const own = Object.getOwnPropertyDescriptor(proto, name); + if (own?.get == null) { + continue; + } + const get = own.get as (this: Element) => number; + Object.defineProperty(proto, name, { + configurable: true, + get(this: Element): number { + note(name, this); + return get.call(this); + }, + }); + } + const rect = Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect = function (this: Element) { + note('getBoundingClientRect', this); + return rect.call(this); + }; + const rangeRect = Range.prototype.getBoundingClientRect; + Range.prototype.getBoundingClientRect = function (this: Range) { + note('Range.getBoundingClientRect', this.commonAncestorContainer); + return rangeRect.call(this); + }; + const computed = window.getComputedStyle; + window.getComputedStyle = function ( + this: Window, + element: Element, + pseudo?: string | null, + ) { + note('getComputedStyle', element); + return computed.call(this, element, pseudo); + }; + }); + await page.route('https://picsum.photos/**', async route => { + const id = /\/id\/(\d+)\//.exec(route.request().url())?.[1] ?? '10'; + await route.fulfill({ + contentType: 'image/svg+xml', + body: photoSvg(COLORS[id] ?? '#888'), + }); + }); + await openStory(page, storybook(), 'core-lightbox--gallery-with-captions'); + await page.getByRole('button', {name: 'Open gallery with captions'}).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog.getByRole('button', {name: 'more'})).toBeVisible(); + await frames(page, 2); + await dialog.getByRole('button', {name: 'more'}).click(); + await expect(dialog.getByRole('button', {name: 'less'})).toBeVisible(); + await frames(page, 2); + await dialog.getByRole('button', {name: 'less'}).click(); + await expect(dialog.getByRole('button', {name: 'more'})).toBeVisible(); + await frames(page, 2); + await dialog.getByRole('button', {name: 'Next'}).click(); + await expect(dialog.getByText('2 / 4')).toBeVisible(); + await waitForRest(dialog.locator('[data-lightbox-viewport] > div').first()); + await frames(page, 2); + await page.keyboard.press('ArrowLeft'); + await expect(dialog.getByText('1 / 4')).toBeVisible(); + await expect(dialog.getByRole('button', {name: 'more'})).toBeVisible(); + await frames(page, 2); + const reads = await page.evaluate( + () => (window as unknown as {__lateReads: string[]}).__lateReads, + ); + expect(reads).toEqual([]); +}); diff --git a/packages/core/src/Lightbox/__tests__/LightboxPagingModes.interaction.browser.spec.ts b/packages/core/src/Lightbox/__tests__/LightboxPagingModes.interaction.browser.spec.ts index f9ac40d40b14f..637f032fe8269 100644 --- a/packages/core/src/Lightbox/__tests__/LightboxPagingModes.interaction.browser.spec.ts +++ b/packages/core/src/Lightbox/__tests__/LightboxPagingModes.interaction.browser.spec.ts @@ -119,7 +119,9 @@ async function openGallery( } const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible(); - const viewport = dialog.getByRole('button', {name: 'Zoom'}); + // The pager's viewport. In a gallery the keyboard's zoom button is the + // current page's photo box inside it. + const viewport = dialog.locator('[data-lightbox-viewport]'); const track = viewport.locator(':scope > div').first(); await expect .poll(async () => @@ -665,11 +667,11 @@ test('without zoom, a mouse click beside a photo narrower than its caption leave } }); -test('with a mouse a click in the slot a moved-up caption leaves closes the gallery', async ({ +test('with a mouse a click on the caption row under a photo with none closes the gallery', async ({ browser, }) => { - // With zoom a caption moves up under its fitted photo. The strip it leaves - // at the page's bottom edge is the backdrop, as without zoom. + // Every page has a two-line caption band. Under the third photo, which has + // no caption, that band is the backdrop. const context = await browser.newContext({ viewport: {width: 1280, height: 1100}, hasTouch: false, @@ -685,28 +687,39 @@ test('with a mouse a click in the slot a moved-up caption leaves closes the gall story: 'core-lightbox--gallery-with-captions', trigger: 'Open gallery with captions', }); - const slot = await g.dialog.locator('[data-current]').evaluate(current => { + for (const counter of ['2 / 4', '3 / 4']) { + await g.dialog.getByRole('button', {name: 'Next'}).click(); + await expect(g.dialog.getByText(counter)).toBeVisible(); + } + await expect(g.dialog.locator('[data-current]')).toHaveCount(1); + await expect + .poll(async () => + g.dialog.locator('[data-current] [data-lightbox-caption]').count(), + ) + .toBe(0); + const band = await g.dialog.locator('[data-current]').evaluate(current => { const r = current.getBoundingClientRect(); - const caption = current - .querySelector('[data-lightbox-caption]') + const media = current + .querySelector('[data-lightbox-page-media]') ?.getBoundingClientRect(); return { x: r.left + r.width / 2, - y: r.bottom - 4, - captionBottom: caption?.bottom ?? Number.NaN, + y: ((media?.bottom ?? Number.NaN) + r.bottom) / 2, + rowHeight: r.bottom - (media?.bottom ?? Number.NaN), }; }); - // The caption sits above the strip, so the point is the page's own. - expect(slot.captionBottom).toBeLessThan(slot.y); + // The band keeps its two lines' height, and the point is in it. + expect(band.rowHeight).toBeGreaterThan(20); expect( - await page.evaluate( - ({x, y}) => - document.elementFromPoint(x, y)?.hasAttribute('data-lightbox-page') ?? - false, - slot, - ), + await page.evaluate(({x, y}) => { + const el = document.elementFromPoint(x, y); + return ( + el?.closest('[data-lightbox-page][data-current]') != null && + el.closest('[data-lightbox-page-media]') == null + ); + }, band), ).toBe(true); - await page.mouse.click(slot.x, slot.y); + await page.mouse.click(band.x, band.y); await expect(g.dialog).toBeHidden(); } finally { await context.close(); diff --git a/packages/core/src/Lightbox/__tests__/LightboxZoom.interaction.browser.spec.ts b/packages/core/src/Lightbox/__tests__/LightboxZoom.interaction.browser.spec.ts index c8d2abb5ec8f0..608f5485ed2f9 100644 --- a/packages/core/src/Lightbox/__tests__/LightboxZoom.interaction.browser.spec.ts +++ b/packages/core/src/Lightbox/__tests__/LightboxZoom.interaction.browser.spec.ts @@ -321,8 +321,9 @@ for (const theme of SHIPPED_THEMES) { await tapOn(dialog.getByRole('button', {name: 'Next'})); await expect(dialog.getByText('2 / 4')).toBeVisible(); const img = dialog.getByRole('img', {name: 'Mountain lake'}); - // The stage, and whatever moved inside it to show the second photo, rests. - await waitForRest(dialog.getByRole('button', {name: 'Zoom'})); + // The viewport, and whatever moved inside it to show the second photo, + // rests. + await waitForRest(dialog.locator('[data-lightbox-viewport]')); const rect = await img.boundingBox(); if (rect == null) { throw new Error('no photo'); @@ -412,6 +413,149 @@ for (const theme of SHIPPED_THEMES) { }); } +test("with zoom, a theme's button and Text colours reach neither the controls' icons nor the counter's text", async ({ + page, +}) => { + await page.goto( + `${storybook()}/iframe.html?id=core-lightbox--gallery-with-zoom&viewMode=story`, + ); + await page.locator('#storybook-root > *').first().waitFor(); + // Rules shaped as a theme writes them: the component's class and its + // reflected prop, more specific than a component's own styles. + await page.addStyleTag({ + content: + '.astryx-button[data-variant="ghost"] { color: rgb(17, 17, 17); } .astryx-text[data-color="primary"] { color: rgb(17, 17, 17); }', + }); + const driver = await touchDriver(page); + const trigger = await page + .getByRole('button', {name: 'Open gallery with zoom'}) + .boundingBox(); + if (trigger == null) { + throw new Error('no trigger'); + } + await driver.tap({ + x: trigger.x + trigger.width / 2, + y: trigger.y + trigger.height / 2, + }); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + // The on-dark colour as this theme resolves it, inside the viewer. + const onDark = await dialog.evaluate(element => { + const probe = element.ownerDocument.createElement('span'); + probe.style.color = 'var(--color-on-dark)'; + element.append(probe); + const color = getComputedStyle(probe).color; + probe.remove(); + return color; + }); + for (const name of ['Close', 'Previous', 'Next']) { + const icon = await dialog + .getByRole('button', {name}) + .evaluate( + element => + getComputedStyle(element.querySelector('svg') ?? element).color, + ); + expect(icon, name).toBe(onDark); + } + const counter = await dialog + .getByText('1 / 4') + .evaluate(element => getComputedStyle(element).color); + expect(counter).toBe(onDark); +}); + +for (const [photo, fill] of [ + ['mid-grey', '#7f7f7f'], + ['white', '#ffffff'], +] as const) { + test(`the caption keeps 4.5:1 on its backing, in its band and open, over a ${photo} photo`, async ({ + page, + }) => { + await page.route('https://picsum.photos/**', async route => + route.fulfill({ + contentType: 'image/svg+xml', + // A tall photo fills the stage, so an open caption lies over it. + body: ``, + }), + ); + await page.goto( + `${storybook()}/iframe.html?id=core-lightbox--gallery-with-captions&viewMode=story`, + ); + await page.locator('#storybook-root > *').first().waitFor(); + await frames(page, 2); + const driver = await touchDriver(page); + const tapOn = async (locator: Locator) => { + const b = await locator.boundingBox(); + if (b == null) { + throw new Error('nothing to tap'); + } + await driver.tap({x: b.x + b.width / 2, y: b.y + b.height / 2}); + }; + await tapOn(page.getByRole('button', {name: 'Open gallery with captions'})); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + // The current photo has loaded, so it has its box. + await expect + .poll(async () => + dialog + .locator('[data-current] img') + .first() + .evaluate(img => (img as HTMLImageElement).naturalWidth), + ) + .toBe(2000); + const caption = dialog + .locator('[data-current] [data-lightbox-caption] .astryx-text') + .first(); + // The caption's colour against the lightest pixel in its top padding, + // above its first line: what the text sits on, with no glyph there. + const ratio = async () => { + await frames(page, 2); + const {color, points, isOverPhoto} = await caption.evaluate(element => { + const style = getComputedStyle(element); + const box = element.getBoundingClientRect(); + const y = box.top + parseFloat(style.paddingTop) / 2; + const spots = [0.25, 0.5, 0.75].map(f => ({ + x: box.left + box.width * f, + y, + })); + const photo = element + .closest('[data-current]') + ?.querySelector('img') + ?.getBoundingClientRect(); + return { + color: style.color, + points: spots, + isOverPhoto: + photo != null && + spots.every( + ({x, y: top}) => + top >= photo.top && + top <= photo.bottom && + x >= photo.left && + x <= photo.right, + ), + }; + }); + const shot = PNG.sync.read(await page.screenshot()); + const scale = shot.width / (page.viewportSize()?.width ?? shot.width); + return { + value: contrast(parseRgb(color), lightestAt(shot, scale, points)), + isOverPhoto, + }; + }; + const inBand = await ratio(); + expect(inBand.value, 'in the band').toBeGreaterThanOrEqual(4.5); + await tapOn(dialog.getByRole('button', {name: 'more'})); + await expect(dialog.getByRole('button', {name: 'less'})).toHaveAttribute( + 'aria-expanded', + 'true', + ); + // Open, its first lines lie over the photo's bottom. + const open = await ratio(); + expect(open.isOverPhoto).toBe(true); + expect(open.value, 'open').toBeGreaterThanOrEqual(4.5); + }); +} + test('the stage fills the phone screen and fits the photo', async ({page}) => { const {state} = await openViewer(page); const s = await state(); diff --git a/packages/core/src/Text/useTruncation.test.tsx b/packages/core/src/Text/useTruncation.test.tsx index 8d1415f60ec1e..b7c33e57fba1a 100644 --- a/packages/core/src/Text/useTruncation.test.tsx +++ b/packages/core/src/Text/useTruncation.test.tsx @@ -7,8 +7,19 @@ import {act, render, screen, waitFor} from '@testing-library/react'; import {afterEach, describe, it, expect, vi} from 'vitest'; -import {useCallback} from 'react'; -import {useTruncation} from './useTruncation'; +import {useCallback, useEffect} from 'react'; +import type * as layoutEffects from '../hooks/useIsomorphicLayoutEffect'; +import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; +import {useHeldTruncation, useTruncation} from './useTruncation'; + +// The shipped layout effect, observable: a test counts who runs one. +vi.mock('../hooks/useIsomorphicLayoutEffect', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useIsomorphicLayoutEffect: vi.fn(actual.useIsomorphicLayoutEffect), + }; +}); function Probe({text}: {text: string}) { const truncation = useTruncation({maxLines: 1}); @@ -164,6 +175,106 @@ describe('useTruncation', () => { }); }); + describe('useHeldTruncation', () => { + // The caller measures after each commit, as Lightbox does after layout. + function HeldProbe({ + isHeld, + height, + measures = true, + }: { + isHeld: boolean; + height: number; + measures?: boolean; + }) { + const truncation = useHeldTruncation({maxLines: 2, isHeld}); + const {measure} = truncation; + useEffect(() => { + if (measures) { + measure(); + } + }); + return ( + <> + + text + + + {String(truncation.isTruncated)} + + + ); + } + + // jsdom lays nothing out: the text's content height is its scrollHeight, + // which each case sets through the element's data-height. + function stubHeights() { + const scrollHeight = vi + .spyOn(HTMLElement.prototype, 'scrollHeight', 'get') + .mockImplementation(function (this: HTMLElement) { + return Number(this.dataset.height ?? 0); + }); + const offsetHeight = vi + .spyOn(HTMLElement.prototype, 'offsetHeight', 'get') + .mockImplementation(function (this: HTMLElement) { + return this.dataset.height == null ? 0 : 48; + }); + return () => { + scrollHeight.mockRestore(); + offsetHeight.mockRestore(); + }; + } + + // Text and Heading run plain useTruncation in every instance, so only the + // held variant may add a step to each render. + it('adds a layout effect only to the held variant, never to useTruncation', () => { + const layoutEffect = vi.mocked(useIsomorphicLayoutEffect); + layoutEffect.mockClear(); + render(); + expect(layoutEffect).not.toHaveBeenCalled(); + render(); + expect(layoutEffect).toHaveBeenCalled(); + }); + + it('reads nothing by itself: its caller measures', () => { + const restore = stubHeights(); + const reads = vi.mocked( + Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollHeight')! + .get!, + ); + try { + reads.mockClear(); + render(); + expect(reads).not.toHaveBeenCalled(); + expect(screen.getByTestId('truncated')).toHaveTextContent('false'); + } finally { + restore(); + } + }); + + it('keeps its last answer while held, and its caller measures it again on release', async () => { + const restore = stubHeights(); + try { + const {rerender} = render(); + await waitFor(() => + expect(screen.getByTestId('truncated')).toHaveTextContent('true'), + ); + // Held, the text shows every line: no measurement changes the answer. + rerender(); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByTestId('truncated')).toHaveTextContent('true'); + // Released over text that now fits, it measures again. + rerender(); + await waitFor(() => + expect(screen.getByTestId('truncated')).toHaveTextContent('false'), + ); + } finally { + restore(); + } + }); + }); + describe('content observation', () => { afterEach(() => { vi.unstubAllGlobals(); diff --git a/packages/core/src/Text/useTruncation.ts b/packages/core/src/Text/useTruncation.ts index 03109ac7c3c93..62445f5bf4597 100644 --- a/packages/core/src/Text/useTruncation.ts +++ b/packages/core/src/Text/useTruncation.ts @@ -5,7 +5,9 @@ /** * @file useTruncation.ts * @input Uses React hooks, sharedResizeObserver, sharedMutationObserver - * @output Exports useTruncation hook for detecting text overflow + * @output Exports useTruncation hook for detecting text overflow, and + * useHeldTruncation, internal to core: the same check with a hold, which + * its caller measures * @position Hook; consumed by Text.tsx, Heading.tsx * * SYNC: When modified, update: @@ -13,6 +15,7 @@ */ import {useCallback, useRef, useState, type RefCallback} from 'react'; +import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; import {observeResize} from '../utils/sharedResizeObserver'; import {observeContent} from '../utils/sharedMutationObserver'; @@ -24,6 +27,15 @@ export interface UseTruncationOptions { maxLines: number; } +interface HeldTruncationOptions extends UseTruncationOptions { + /** + * While true, the hook keeps its last result and measures nothing. A caller + * that shows the text unclamped for a while, such as an expanded caption, + * keeps the clamped answer. Releasing it measures again. + */ + isHeld: boolean; +} + export interface UseTruncationReturn { /** * Ref callback to attach to the text element @@ -69,7 +81,47 @@ export interface UseTruncationReturn { export function useTruncation( options: UseTruncationOptions, ): UseTruncationReturn { - const {maxLines} = options; + const {ref, isTruncated, fullText} = useTruncationMeasure( + options.maxLines, + NEVER_HELD, + true, + ); + return {ref, isTruncated, fullText}; +} + +/** + * useTruncation with a hold, internal to core. Lightbox holds its caption's + * clamped answer while the caption is open. This variant observes nothing and + * reads nothing by itself. Its caller observes the text and calls `measure` + * after the browser lays the text out. + */ +export function useHeldTruncation( + options: HeldTruncationOptions, +): UseTruncationReturn & {measure: () => void} { + const {maxLines, isHeld} = options; + const isHeldRef = useRef(isHeld); + const truncation = useTruncationMeasure(maxLines, isHeldRef, false); + // Syncs the hold with the caller's flag. The held answer stands until the + // caller's next measurement after the release, so it never flickers. + useIsomorphicLayoutEffect(() => { + isHeldRef.current = isHeld; + }, [isHeld]); + return truncation; +} + +/** A hold that never holds: plain useTruncation's. */ +const NEVER_HELD: {readonly current: boolean} = {current: false}; + +/** + * The measurement both hooks share. While `isHeldRef` holds, it measures + * nothing. `measure` checks the current element again. Without `observes`, it + * leaves observing and measuring the element to its caller. + */ +function useTruncationMeasure( + maxLines: number, + isHeldRef: {readonly current: boolean}, + observes: boolean, +): UseTruncationReturn & {measure: () => void} { const [isTruncated, setIsTruncated] = useState(false); const [fullText, setFullText] = useState(''); const elementRef = useRef(null); @@ -78,6 +130,9 @@ export function useTruncation( const checkTruncation = useCallback( (element: HTMLElement) => { + if (isHeldRef.current) { + return; + } if (maxLines === 0) { setIsTruncated(false); return; @@ -108,7 +163,7 @@ export function useTruncation( setIsTruncated(contentHeight > element.offsetHeight); } }, - [maxLines], + [maxLines, isHeldRef], ); const ref: RefCallback = useCallback( @@ -121,6 +176,15 @@ export function useTruncation( unobserveContentRef.current = null; elementRef.current = element; + if (!observes) { + // A replaced text shows no truncation until its caller measures the + // next one. So a stale answer never sizes the next text. + if (element == null) { + setIsTruncated(false); + setFullText(''); + } + return; + } if (element && maxLines > 0) { // Observe via shared singleton (if available). The first measurement @@ -161,12 +225,20 @@ export function useTruncation( setFullText(''); } }, - [maxLines, checkTruncation], + [maxLines, checkTruncation, observes], ); + const measure = useCallback(() => { + const element = elementRef.current; + if (element != null && maxLines > 0) { + checkTruncation(element); + } + }, [maxLines, checkTruncation]); + return { ref, isTruncated, fullText, + measure, }; } From 50875b6ed54b3fc350023dc812a133027cf9d636 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 17:21:44 -0800 Subject: [PATCH 04/21] docs(Lightbox): caption notes and records cover a single photo The translator notes for "more" and "less" described only a gallery's caption, and the record's "Captioned media" state named the old centred group. A single photo now shows its caption in the same band, so both say so. FR1b's exception now names the touch pager, which takes a gallery's edge drag. --- docs/specs/components/Lightbox/Lightbox.spec.md | 4 ++-- packages/core/locales/en.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/specs/components/Lightbox/Lightbox.spec.md b/docs/specs/components/Lightbox/Lightbox.spec.md index 3d47e988d8918..9d6ac44b31633 100644 --- a/docs/specs/components/Lightbox/Lightbox.spec.md +++ b/docs/specs/components/Lightbox/Lightbox.spec.md @@ -140,7 +140,7 @@ FR21). Consumer props and usage remain documented in `Lightbox.doc.mjs`. | 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" button 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, and "more"/"less" is a ghost `Button`, not hand-styled elements. | Current source, docs, and tests | settled | -| FR1b | A single photo and a set of photos 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 behavior for single photo is different than multiple for the caption 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 pager takes it (FR7). | Owner direction, Nov 6 2026 | settled | +| FR1b | A single photo and a set of photos 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 behavior for single photo is different than multiple for the caption 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 | @@ -177,7 +177,7 @@ FR21). Consumer props and usage remain documented in `Lightbox.doc.mjs`. | 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 photo 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. | diff --git a/packages/core/locales/en.json b/packages/core/locales/en.json index 90b28c8705431..311dc0177eac7 100644 --- a/packages/core/locales/en.json +++ b/packages/core/locales/en.json @@ -1601,11 +1601,11 @@ }, "@astryx.lightbox.captionMore": { "defaultMessage": "more", - "description": "Visible label of the button after a gallery caption that two lines cut short in a Lightbox. It opens the full caption over the photo. Pairs with `lightbox.captionLess`." + "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 gallery's full caption is open over the photo. It closes the full caption. Pairs with `lightbox.captionMore`." + "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", From 232f499fce539d97dd09e349f75bece6af56fb57 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 17:21:44 -0800 Subject: [PATCH 05/21] test(Layer): the one-gutter check lists Lightbox's full-screen viewer Lightbox is a full-screen viewer, not an anchor-mode layer. Its caption band keeps the device's bottom safe-area inset under the caption, as Drawer's and BottomSheet's panels keep theirs. So the check that only the layer module defines an anchored layer's gutter lists it with the other surfaces that read the device's insets themselves. --- packages/core/src/Layer/layerViewportInset.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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', From b565bdc2282f82fb90957d0615f16ddfbb95d94e Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 17:21:44 -0800 Subject: [PATCH 06/21] docs(Lightbox): FR1b covers a single captioned video too The spec owners set FR1b's scope: a single captioned item, a photo or a video, shows its caption exactly as a gallery caption does, in the band. FR1b's first sentence and the "Captioned media" state now say so. The code already shows a single video's caption in the band. --- docs/specs/components/Lightbox/Lightbox.spec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/specs/components/Lightbox/Lightbox.spec.md b/docs/specs/components/Lightbox/Lightbox.spec.md index 9d6ac44b31633..1e2da50665365 100644 --- a/docs/specs/components/Lightbox/Lightbox.spec.md +++ b/docs/specs/components/Lightbox/Lightbox.spec.md @@ -140,7 +140,7 @@ FR21). Consumer props and usage remain documented in `Lightbox.doc.mjs`. | 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" button 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, and "more"/"less" is a ghost `Button`, not hand-styled elements. | Current source, docs, and tests | settled | -| FR1b | A single photo and a set of photos 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 behavior for single photo is different than multiple for the caption 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 | +| 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 behavior for single photo is different than multiple for the caption 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 | @@ -177,7 +177,7 @@ FR21). Consumer props and usage remain documented in `Lightbox.doc.mjs`. | 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 shows in the band under the active Media's page, for one photo or a gallery. | 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. | From 801fa11f47c531b555224ce59016dbae8989f5d0 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 22:44:27 -0800 Subject: [PATCH 07/21] docs(Lightbox): FR1b quotes only the owner's exact words The owner's message reads differently from the sentence FR1b quoted. FR1b now keeps only her exact fragment, "they really should be consistent", and says the rest in plain prose. --- docs/specs/components/Lightbox/Lightbox.spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/components/Lightbox/Lightbox.spec.md b/docs/specs/components/Lightbox/Lightbox.spec.md index 1ec2fe10a49ff..c9c6769c5f647 100644 --- a/docs/specs/components/Lightbox/Lightbox.spec.md +++ b/docs/specs/components/Lightbox/Lightbox.spec.md @@ -143,7 +143,7 @@ FR21). Consumer props and usage remain documented in `Lightbox.doc.mjs`. | 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" button 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, and "more"/"less" is a ghost `Button`, not hand-styled elements. | 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 behavior for single photo is different than multiple for the caption 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 | +| 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 | From cd2e283b8219711ee88ffe485e72ff27293bbbbc Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sun, 8 Nov 2026 06:54:40 -0800 Subject: [PATCH 08/21] fix(Lightbox): a drag to close fades a gallery's caption band Main's drag to close fades the controls and the caption with its travel. In a gallery the caption's backing is its band, which stayed opaque over the fading backdrop. The band now fades in the caption's place, once. --- packages/core/src/Lightbox/Lightbox.tsx | 6 ++- ...ightboxDismiss.interaction.browser.spec.ts | 37 ++++++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/core/src/Lightbox/Lightbox.tsx b/packages/core/src/Lightbox/Lightbox.tsx index ef47dcfb46663..40a85cc665023 100644 --- a/packages/core/src/Lightbox/Lightbox.tsx +++ b/packages/core/src/Lightbox/Lightbox.tsx @@ -1225,13 +1225,14 @@ export function Lightbox({ if (dialog == null || container == null) { return {backdrop: null, chrome: []}; } + // A caption in a band fades as the band, which carries its backing. const chrome: HTMLElement[] = []; for (const element of container.querySelectorAll( - '[data-lightbox-chrome], [data-lightbox-page]:not([data-current]), [data-lightbox-caption]', + '[data-lightbox-chrome], [data-lightbox-page]:not([data-current]), [data-lightbox-caption-band], [data-lightbox-caption]', )) { if ( element.parentElement?.closest( - '[data-lightbox-page]:not([data-current])', + '[data-lightbox-page]:not([data-current]), [data-lightbox-caption-band]', ) == null ) { chrome.push(element); @@ -1931,6 +1932,7 @@ export function Lightbox({ {hasCaptionBand && (
{ + // The band carries the caption's backing. It fades in the caption's place, + // once, so no dark strip stays over the fading backdrop. + const v = await openViewer(page, 'captions'); + const caption = v.dialog.locator('[data-current] [data-lightbox-caption]'); + const band = caption.locator('xpath=..'); + let compared = false; + await v.driver.play(pullDown(v.centre, 100), { + onEvent: async step => { + if ( + compared || + step.type !== 'touchMove' || + step.changed[0].y - v.centre.y < 70 + ) { + return; + } + compared = true; + await frames(page, 1); + const controls = await v.closeAlpha(); + expect(controls).toBeLessThan(0.95); + const opacity = async (locator: Locator) => + locator.evaluate(el => Number(getComputedStyle(el).opacity)); + expect(await opacity(band)).toBeCloseTo(controls, 3); + expect(await opacity(caption)).toBe(1); + }, + }); + expect(compared).toBe(true); +}); + test('a gallery photo dismisses with a drag down', async ({page}) => { const v = await openViewer(page, 'gallery'); await v.driver.play(pullDown(v.centre, 120)); From 5c56c40cb37d0c856acee1804227cb9af75bafea Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sun, 8 Nov 2026 07:11:26 -0800 Subject: [PATCH 09/21] feat(Lightbox): a caption's "more" and "less" are an inline link The caption's toggle was a ghost Button with a size of its own. It is now Link's button form: a native button in the caption's own font and line height, underlined, at the end of the caption's last line. It keeps aria-expanded and aria-controls, and the shared hit area still grows its tap toward 44 px under a finger. Lightbox's record, docs and changeset say so. --- .changeset/lightbox-gallery-caption-row.md | 4 +- .../components/Lightbox/Lightbox.spec.md | 44 +++++++++---------- packages/core/src/Lightbox/Lightbox.doc.mjs | 2 +- packages/core/src/Lightbox/Lightbox.test.tsx | 7 +-- packages/core/src/Lightbox/Lightbox.tsx | 41 ++++++++--------- ...LightboxPaging.interaction.browser.spec.ts | 23 ++++++++-- 6 files changed, 66 insertions(+), 55 deletions(-) diff --git a/.changeset/lightbox-gallery-caption-row.md b/.changeset/lightbox-gallery-caption-row.md index 2b9983edb79d2..c20429a24bd8e 100644 --- a/.changeset/lightbox-gallery-caption-row.md +++ b/.changeset/lightbox-gallery-caption-row.md @@ -2,8 +2,8 @@ '@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 a "more" button, which opens the whole caption over the photo. The whole caption is always the viewer's accessible description. +[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 a ghost `Button`, 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. +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/docs/specs/components/Lightbox/Lightbox.spec.md b/docs/specs/components/Lightbox/Lightbox.spec.md index 1670e86c55373..294b74b8fd9d1 100644 --- a/docs/specs/components/Lightbox/Lightbox.spec.md +++ b/docs/specs/components/Lightbox/Lightbox.spec.md @@ -146,27 +146,27 @@ 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. 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" button 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, and "more"/"less" is a ghost `Button`, not hand-styled elements. | 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, 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). A drag down that closes the viewer is a gesture-driven exit, so it plays the measured springs 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 whole-number `index`, controlled or default, the viewer opens on the nearest valid item: below 0 the first, past the last the last. | 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 | +| 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 | +| 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, 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). A drag down that closes the viewer is a gesture-driven exit, so it plays the measured springs 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 whole-number `index`, controlled or default, the viewer opens on the nearest valid item: below 0 the first, past the last the last. | 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 @@ -366,7 +366,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, controlled and default | An index past either end 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, a mid-grey and a black 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 a ghost `Button`, two start edges, or a caption under 4.5:1 fails. | `audit:Lightbox/theming` | +| 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, a mid-grey and a black 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/src/Lightbox/Lightbox.doc.mjs b/packages/core/src/Lightbox/Lightbox.doc.mjs index 1c315b81e27b2..9b7e459f18262 100644 --- a/packages/core/src/Lightbox/Lightbox.doc.mjs +++ b/packages/core/src/Lightbox/Lightbox.doc.mjs @@ -32,7 +32,7 @@ const anatomy = [ name: 'Caption', required: false, description: - 'Caller-provided caption displayed below the active media. In a gallery it shows at most two lines; a longer caption adds a "more" button that opens it in full over the photo.', + 'Caller-provided caption displayed below the active media. In a gallery it shows at most two lines; 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 37a64d9e1544e..413d6f7945a7a 100644 --- a/packages/core/src/Lightbox/Lightbox.test.tsx +++ b/packages/core/src/Lightbox/Lightbox.test.tsx @@ -2674,10 +2674,11 @@ describe("Lightbox gallery caption's toggle", () => { ]; 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'), - ].map(shapeOf); + [...document.querySelectorAll('[data-lightbox-caption] .astryx-text')] + .filter(text => text.closest('button') == null) + .map(shapeOf); const fits = render(
diff --git a/packages/core/src/Lightbox/__tests__/LightboxPaging.interaction.browser.spec.ts b/packages/core/src/Lightbox/__tests__/LightboxPaging.interaction.browser.spec.ts index f9c2614e21b8d..c778d458b5d3c 100644 --- a/packages/core/src/Lightbox/__tests__/LightboxPaging.interaction.browser.spec.ts +++ b/packages/core/src/Lightbox/__tests__/LightboxPaging.interaction.browser.spec.ts @@ -343,7 +343,7 @@ test('every page keeps a two-line caption band, so the photo keeps its box', asy await expect(more).toHaveCount(0); }); -test('the caption is body Text on the media surface: its lines start at one edge, and "more" is a ghost Button that ends the last', async ({ +test('the caption is body Text on the media surface: its lines start at one edge, and "more" is a link in its type that ends the last line', async ({ page, }) => { const g = await openGallery(page, { @@ -382,6 +382,7 @@ test('the caption is body Text on the media surface: its lines start at one edge right: box.right - parseFloat(getComputedStyle(text).paddingRight), bottom: box.bottom, lineHeight: parseFloat(getComputedStyle(text).lineHeight), + fontSize: getComputedStyle(text).fontSize, isText: text.classList.contains('astryx-text'), type: text.getAttribute('data-type'), isOnMedia: text.closest('[data-astryx-media="dark"]') != null, @@ -396,14 +397,28 @@ test('the caption is body Text on the media surface: its lines start at one edge const [first = Number.NaN, second = Number.NaN] = caption?.lines ?? []; expect(Math.abs(second - first)).toBeLessThan(1); const more = g.dialog.getByRole('button', {name: 'more'}); - await expect(more).toHaveClass(/astryx-button/); - await expect(more).toHaveAttribute('data-variant', 'ghost'); + // Link's button form: a native button in the caption's own type. + await expect(more).toHaveClass(/astryx-link/); + await expect(more).toHaveAttribute('type', 'button'); + const moreType = await more.evaluate(el => ({ + fontSize: getComputedStyle(el).fontSize, + lineHeight: parseFloat(getComputedStyle(el).lineHeight), + underline: getComputedStyle(el).textDecorationLine, + })); + // Underlined, so it reads as a link beside the caption's own text. + expect(moreType.underline).toBe('underline'); + expect(moreType.fontSize).toBe(caption?.fontSize); + expect(moreType.lineHeight).toBeCloseTo(caption?.lineHeight ?? 0, 1); const m = await more.boundingBox(); expect(m).not.toBeNull(); - // It ends the last line: inside the text's end, centred on that line. + // It ends the last line: inside the text's end, one line tall, centred on + // that line. expect((m?.x ?? 0) + (m?.width ?? 0)).toBeLessThanOrEqual( (caption?.right ?? 0) + 0.5, ); + expect(Math.abs((m?.height ?? 0) - (caption?.lineHeight ?? 0))).toBeLessThan( + 1, + ); expect( Math.abs( (m?.y ?? 0) + From 926524af73b0a0636da867b07fe4dc43399bfcf5 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sun, 8 Nov 2026 10:01:34 -0800 Subject: [PATCH 10/21] fix(Lightbox): the caption's toggle takes its touch target from Core The caption's "more" and "less" grew their touch target through their own copy of the shared hit area: a watch and a band of their own. They now use Core's touch hit area and its layer, as other controls do, with a growth that never reaches up into the caption's line above. Lightbox's record marks FR19's single-photo gap, and the contrast case adds the black photo its verification row names. --- .../components/Lightbox/Lightbox.spec.md | 2 +- packages/core/src/Lightbox/Lightbox.tsx | 62 ++++++++++++------- .../LightboxViewport.a11y.browser.spec.ts | 56 +++++++++++++++++ .../LightboxZoom.interaction.browser.spec.ts | 1 + 4 files changed, 97 insertions(+), 24 deletions(-) diff --git a/docs/specs/components/Lightbox/Lightbox.spec.md b/docs/specs/components/Lightbox/Lightbox.spec.md index 294b74b8fd9d1..3abe31b926787 100644 --- a/docs/specs/components/Lightbox/Lightbox.spec.md +++ b/docs/specs/components/Lightbox/Lightbox.spec.md @@ -166,7 +166,7 @@ FR21). Consumer props and usage remain documented in `Lightbox.doc.mjs`. | 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 whole-number `index`, controlled or default, the viewer opens on the nearest valid item: below 0 the first, past the last the last. | 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 | +| 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; implementation gap | ### Motion diff --git a/packages/core/src/Lightbox/Lightbox.tsx b/packages/core/src/Lightbox/Lightbox.tsx index c412147c40440..26f8224e7a96e 100644 --- a/packages/core/src/Lightbox/Lightbox.tsx +++ b/packages/core/src/Lightbox/Lightbox.tsx @@ -55,7 +55,9 @@ import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; import {useAttachment} from '../hooks/useAttachment'; import {observeResize} from '../utils/sharedResizeObserver'; import {observeContent} from '../utils/sharedMutationObserver'; -import {watchHitArea, writeHitInsets} from '../utils/hitArea'; +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'; @@ -188,6 +190,24 @@ const OVER_PHOTO_ATTRIBUTE = 'data-lightbox-over-photo'; // 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', @@ -439,22 +459,12 @@ const styles = stylex.create({ }), // 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. Under a coarse pointer, the shared - // hit area writes how far the toggle may grow toward 44 px on each side, - // short of any neighbour. This layer takes taps out to there, except upward - // into the line above. Its sides are physical, as the page's rects. + // it sits on that line as its text does. // SYNC: `caption`'s paddingInline. - // SYNC: /packages/core/src/utils/hitArea.ts (HIT_INSET_PROPERTIES) captionToggle: { position: 'absolute', insetInlineEnd: spacingVars['--spacing-3'], insetBlockEnd: 0, - '::after': { - content: '""', - position: 'absolute', - inset: - '0 calc(var(--astryx-hit-inset-right, 0px) * -1) calc(var(--astryx-hit-inset-bottom, 0px) * -1) calc(var(--astryx-hit-inset-left, 0px) * -1)', - }, }, // The toggle's own display wins over `hidden`'s, so this hides it. captionToggleHidden: { @@ -1082,10 +1092,15 @@ export function Lightbox({ const captionToggleRef = useRef(null); // Close, where focus goes off a paged-away toggle when the stage takes none. const closeButtonRef = useRef(null); - useAttachment( + // 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, - toggle => watchHitArea(toggle, insets => writeHitInsets(toggle, insets)), - null, + captionToggleHitAreaRef, ); usePressTracker(captionToggleRef); // Syncs the toggle's place in the caption's last line with its width. The @@ -1741,8 +1756,6 @@ export function Lightbox({ : []; const hasCaptionBand = isGallery && mediaArray.some(item => isRenderable(item.caption)); - // The current caption's toggle shows while two lines clamp it, or open. - const hasCaptionToggle = isCaptionClamped || isCaptionOpen; // Its place ends the caption's last line, unless the open caption scrolls. const hasCaptionTogglePlace = hasCaptionToggle && !isCaptionScrolling; // The keyboard's zoom button is the photo's wrapper. In a gallery it is the @@ -2011,7 +2024,7 @@ export function Lightbox({ // measurement only shows or hides it, // and adds no element. + xstyle={ + [ + styles.captionToggle, + touchHitAreaStyles.layer, + !hasCaptionToggle && + styles.captionToggleHidden, + ] as StyleXStyles + }> {isCaptionOpen ? t('@astryx.lightbox.captionLess') : t('@astryx.lightbox.captionMore')} diff --git a/packages/core/src/Lightbox/LightboxViewport.a11y.browser.spec.ts b/packages/core/src/Lightbox/LightboxViewport.a11y.browser.spec.ts index 2d4082af3df27..8aa6203212d47 100644 --- a/packages/core/src/Lightbox/LightboxViewport.a11y.browser.spec.ts +++ b/packages/core/src/Lightbox/LightboxViewport.a11y.browser.spec.ts @@ -245,6 +245,62 @@ for (const viewport of [ }); } +test.describe('under a finger', () => { + test.use({hasTouch: true}); + + test(`"more" takes a finger's taps toward 44 px below it and beside it, never from the line above`, async ({ + page, + }) => { + const dialog = await openCaptionGallery(page, {width: 402, height: 874}); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(true); + const more = dialog.getByRole('button', {name: 'more'}); + await expect(more).toBeVisible(); + // The shared hit area writes the toggle's insets once it measures it. + await expect + .poll(async () => + more.evaluate( + element => + parseFloat( + element.style.getPropertyValue('--astryx-hit-inset-bottom'), + ) || 0, + ), + ) + .toBeGreaterThan(0); + const facts = await more.evaluate(element => { + const box = element.getBoundingClientRect(); + const inset = (side: string) => + parseFloat( + element.style.getPropertyValue(`--astryx-hit-inset-${side}`) || '0', + ); + const middle = box.left + box.width / 2; + const takes = (x: number, y: number) => { + const hit = element.ownerDocument.elementFromPoint(x, y); + return hit != null && element.contains(hit); + }; + const right = inset('right'); + const bottom = inset('bottom'); + return { + top: inset('top'), + right, + bottom, + above: takes(middle, box.top - 4), + below: takes(middle, box.bottom + bottom / 2), + beside: takes(box.right + right / 2, box.top + box.height / 2), + }; + }); + // The line above is the caption's own text: a tap there is not "more". + expect(facts.above).toBe(false); + expect(facts.top).toBe(0); + // Below and beside, a tap within the grown target is "more". + expect(facts.bottom).toBeGreaterThan(0); + expect(facts.right).toBeGreaterThan(0); + expect(facts.below).toBe(true); + expect(facts.beside).toBe(true); + }); +}); + test(`the first frame that shows the clamped caption at open already ends it in "more"`, async ({ page, }) => { diff --git a/packages/core/src/Lightbox/__tests__/LightboxZoom.interaction.browser.spec.ts b/packages/core/src/Lightbox/__tests__/LightboxZoom.interaction.browser.spec.ts index 4b5c61ce4291c..f18d849e26fc0 100644 --- a/packages/core/src/Lightbox/__tests__/LightboxZoom.interaction.browser.spec.ts +++ b/packages/core/src/Lightbox/__tests__/LightboxZoom.interaction.browser.spec.ts @@ -466,6 +466,7 @@ test("with zoom, a theme's button and Text colours reach neither the controls' i for (const [photo, fill] of [ ['mid-grey', '#7f7f7f'], ['white', '#ffffff'], + ['black', '#000000'], ] as const) { test(`the caption keeps 4.5:1 on its backing, in its band and open, over a ${photo} photo`, async ({ page, From f53cbe584c903d906b8cfe9ea1c7ea742b6d0f39 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 9 Nov 2026 10:31:21 -0800 Subject: [PATCH 11/21] fix(Lightbox): "more" and "less" take the caption's colour The caption toggle used the link's accent colour. A theme build whose media surface keeps a dark accent drew it near-black on the dark band, so "more" could not be seen. The toggle now inherits the caption's colour, and the underline sets it apart. --- packages/core/src/Lightbox/Lightbox.tsx | 9 ++-- .../LightboxViewport.a11y.browser.spec.ts | 50 ++++++++++++++++++- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/packages/core/src/Lightbox/Lightbox.tsx b/packages/core/src/Lightbox/Lightbox.tsx index 32dd8f9ffcefb..826b6c38f60f3 100644 --- a/packages/core/src/Lightbox/Lightbox.tsx +++ b/packages/core/src/Lightbox/Lightbox.tsx @@ -2027,9 +2027,12 @@ export function Lightbox({ ref={captionToggleRefs} hidden={!hasCaptionToggle} type="inherit" - // On the media surface a link's - // colour is close to the caption's. - // The underline sets it apart. + // The toggle takes the caption's + // own colour. A theme's accent on + // the media surface can match the + // band. The underline sets the + // toggle apart. + color="inherit" hasUnderline aria-expanded={isCaptionOpen} aria-controls={fullCaptionId} diff --git a/packages/core/src/Lightbox/LightboxViewport.a11y.browser.spec.ts b/packages/core/src/Lightbox/LightboxViewport.a11y.browser.spec.ts index 8aa6203212d47..06e7c15e9d1f9 100644 --- a/packages/core/src/Lightbox/LightboxViewport.a11y.browser.spec.ts +++ b/packages/core/src/Lightbox/LightboxViewport.a11y.browser.spec.ts @@ -86,13 +86,15 @@ const PHOTO_SVG = async function openCaptionGallery( page: Page, viewport: {width: number; height: number}, + theme?: string, ): Promise { await page.setViewportSize(viewport); await page.route(/picsum\.photos/, async route => route.fulfill({contentType: 'image/svg+xml', body: PHOTO_SVG}), ); + // Storybook's theme global applies from the story's first render. await page.goto( - `${storybook.origin}/iframe.html?id=core-lightbox--gallery-with-captions&viewMode=story`, + `${storybook.origin}/iframe.html?id=core-lightbox--gallery-with-captions&viewMode=story${theme == null ? '' : `&globals=astryxTheme:${theme}`}`, ); await page.getByRole('button', {name: 'Open gallery with captions'}).click(); const dialog = page.getByRole('dialog'); @@ -301,6 +303,52 @@ test.describe('under a finger', () => { }); }); +/** Every shipped theme. */ +const SHIPPED_THEMES = [ + 'butter', + 'chocolate', + 'gothic', + 'matcha', + 'neutral', + 'stone', + 'y2k', +] as const; + +for (const theme of SHIPPED_THEMES) { + test(`"more" and "less" read in the caption's own colour on its band (${theme})`, async ({ + page, + }) => { + const dialog = await openCaptionGallery( + page, + {width: 402, height: 874}, + theme, + ); + for (const name of ['more', 'less']) { + const toggle = dialog.getByRole('button', {name}); + await expect(toggle).toBeVisible(); + // The caption's text is the first child of the box the toggle sits + // in. Where the caption reads on its band, the toggle reads too. + const read = await toggle.evaluate(element => { + const caption = element.parentElement?.firstElementChild; + return { + hasCaption: caption != null, + isToggle: caption === element, + captionText: caption?.textContent?.trim() ?? '', + toggle: getComputedStyle(element).color, + caption: caption == null ? '' : getComputedStyle(caption).color, + }; + }); + expect(read.hasCaption, `"${name}" (${theme}): a caption`).toBe(true); + expect(read.isToggle, `"${name}" (${theme}): not the toggle`).toBe(false); + expect(read.captionText, `"${name}" (${theme}): caption text`).not.toBe( + '', + ); + expect(read.toggle, `"${name}" (${theme})`).toBe(read.caption); + await toggle.click(); + } + }); +} + test(`the first frame that shows the clamped caption at open already ends it in "more"`, async ({ page, }) => { From 48232ac02a3a87b77a7eda6e04f8500ef1a844c6 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 9 Nov 2026 11:35:52 -0800 Subject: [PATCH 12/21] fix(Lightbox): the caption measures through one shared observer path Lightbox kept its own copy of the shared ResizeObserver's skip of the registration call, and the copy differed from useTruncation's. For an element another subscriber already observed, it waited for a resize that might never come. One export, observeResizeAfterLayout, now holds the skip and the microtask measure, and both call it. --- packages/core/src/Lightbox/Lightbox.tsx | 41 +++------- packages/core/src/Text/useTruncation.ts | 38 +++------ .../src/utils/sharedResizeObserver.test.ts | 77 +++++++++++++++++++ .../core/src/utils/sharedResizeObserver.ts | 48 +++++++++++- 4 files changed, 144 insertions(+), 60 deletions(-) diff --git a/packages/core/src/Lightbox/Lightbox.tsx b/packages/core/src/Lightbox/Lightbox.tsx index 3f76625afa849..982f7f092d800 100644 --- a/packages/core/src/Lightbox/Lightbox.tsx +++ b/packages/core/src/Lightbox/Lightbox.tsx @@ -53,7 +53,10 @@ 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 { + observeResize, + observeResizeAfterLayout, +} from '../utils/sharedResizeObserver'; import {observeContent} from '../utils/sharedMutationObserver'; import {MIN_TOUCH_TARGET} from '../utils/hitArea'; import {useTouchHitArea, type TouchHitAreaOptions} from '../utils/touchHitArea'; @@ -629,31 +632,6 @@ const KEYBOARD_PAN_OFFSETS: Record = { /** The height of a video's native controls, px: no browser reports it. */ const VIDEO_CONTROLS_BAND = 48; -/** - * Calls `onDelivery` with each entry of the shared ResizeObserver for - * `element`. It skips the call that registers it, which runs in React's - * commit, where a read forces a layout before the browser's own. The first - * delivery comes after the browser lays `element` out. Where no - * ResizeObserver exists (jsdom), it returns null and the caller measures at - * once. - */ -function observeAfterLayout( - element: Element, - onDelivery: (entry: ResizeObserverEntry) => void, -): (() => void) | null { - if (typeof ResizeObserver === 'undefined') { - return null; - } - let isRegistered = false; - const stop = observeResize(element, entry => { - if (isRegistered) { - onDelivery(entry); - } - }); - isRegistered = true; - return stop; -} - /** * A fullscreen overlay for viewing images and videos at full resolution. * @@ -1072,7 +1050,7 @@ export function Lightbox({ element => { const box = element.closest('[data-lightbox-caption]') ?? element; const register = (): (() => void) | undefined => { - const stop = observeAfterLayout(box, () => { + const stop = observeResizeAfterLayout(box, () => { noteCaptionDelivery(true, null); }); if (stop == null) { @@ -1120,8 +1098,13 @@ export function Lightbox({ useAttachment( captionToggleRef, toggle => { - const stop = observeAfterLayout(toggle, entry => { - const width = entry.borderBoxSize?.[0]?.inlineSize ?? 0; + 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)); } diff --git a/packages/core/src/Text/useTruncation.ts b/packages/core/src/Text/useTruncation.ts index c9bb88135b025..e10e33c779760 100644 --- a/packages/core/src/Text/useTruncation.ts +++ b/packages/core/src/Text/useTruncation.ts @@ -16,7 +16,7 @@ import {useCallback, useRef, useState, type RefCallback} from 'react'; import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; -import {isResizeObserved, observeResize} from '../utils/sharedResizeObserver'; +import {observeResizeAfterLayout} from '../utils/sharedResizeObserver'; import {observeContent} from '../utils/sharedMutationObserver'; export interface UseTruncationOptions { @@ -187,36 +187,16 @@ function useTruncationMeasure( } if (element && maxLines > 0) { - // Observe via the shared singleton, if available. The call that - // observeResize makes on registration runs inside React's commit, so - // it measures nothing. The observer's first notification measures a - // new element. The browser sends it after its own layout, so mounting - // text forces no layout pass. An element another subscriber already - // observes gets no new first notification, so a microtask after the - // commit measures it. The truncated state only decides whether to - // compose the tooltip, so nothing paints differently. - if (typeof ResizeObserver !== 'undefined') { - let registered = false; - const wasObserved = isResizeObserved(element); - const unobserve = observeResize(element, () => { - if (registered) { - checkTruncation(element); - } - }); - registered = true; - unobserveRef.current = unobserve; - // Skipped when this registration has ended by then (measuring - // turned off, or the element replaced, in the same task). - if (wasObserved) { - queueMicrotask(() => { - if (unobserveRef.current === unobserve) { - checkTruncation(element); - } - }); - } - } else { + // Measured after the browser's layout, so mounting text forces no + // layout pass. The truncated state only decides whether to compose + // the tooltip, so nothing paints differently. + const unobserve = observeResizeAfterLayout(element, () => { + checkTruncation(element); + }); + if (unobserve == null) { checkTruncation(element); } + unobserveRef.current = unobserve; // New text in a box that keeps its size fires no resize, so the // captured text and truncated state would otherwise stay stale. unobserveContentRef.current = observeContent(element, () => { diff --git a/packages/core/src/utils/sharedResizeObserver.test.ts b/packages/core/src/utils/sharedResizeObserver.test.ts index f30206612e162..b786c4cc6a7be 100644 --- a/packages/core/src/utils/sharedResizeObserver.test.ts +++ b/packages/core/src/utils/sharedResizeObserver.test.ts @@ -280,4 +280,81 @@ describe('sharedResizeObserver', () => { expect(cb1).not.toHaveBeenCalled(); expect(cb2).not.toHaveBeenCalled(); }); + + describe('observeResizeAfterLayout', () => { + it('skips the call observeResize makes on registration', async () => { + const {observeResizeAfterLayout} = await import('./sharedResizeObserver'); + const el = document.createElement('div'); + const cb = vi.fn(); + + const unsubscribe = observeResizeAfterLayout(el, cb); + await Promise.resolve(); + + expect(unsubscribe).not.toBeNull(); + expect(mockObserve).toHaveBeenCalledWith(el); + expect(cb).not.toHaveBeenCalled(); + unsubscribe?.(); + }); + + it("passes on the observer's notifications", async () => { + const {observeResizeAfterLayout} = await import('./sharedResizeObserver'); + const el = document.createElement('div'); + const cb = vi.fn(); + const unsubscribe = observeResizeAfterLayout(el, cb); + const entry = {target: el} as unknown as ResizeObserverEntry; + + capturedCallback([entry], {} as ResizeObserver); + + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith(entry); + unsubscribe?.(); + }); + + it('measures an element another subscriber already observes in a microtask, with no entry', async () => { + const {observeResize, observeResizeAfterLayout} = + await import('./sharedResizeObserver'); + const el = document.createElement('div'); + const unsubscribePeer = observeResize(el, vi.fn()); + const cb = vi.fn(); + + const unsubscribe = observeResizeAfterLayout(el, cb); + + // Not where it registers: the microtask after it. + expect(cb).not.toHaveBeenCalled(); + await Promise.resolve(); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith(null); + unsubscribe?.(); + unsubscribePeer(); + }); + + it('a registration that ends first is not measured in the microtask', async () => { + const {observeResize, observeResizeAfterLayout} = + await import('./sharedResizeObserver'); + const el = document.createElement('div'); + const unsubscribePeer = observeResize(el, vi.fn()); + const cb = vi.fn(); + + const unsubscribe = observeResizeAfterLayout(el, cb); + unsubscribe?.(); + await Promise.resolve(); + + expect(cb).not.toHaveBeenCalled(); + unsubscribePeer(); + }); + + it('returns null where no ResizeObserver exists', async () => { + const {observeResizeAfterLayout} = await import('./sharedResizeObserver'); + const saved = global.ResizeObserver; + // @ts-expect-error -- jsdom has no ResizeObserver + delete global.ResizeObserver; + try { + expect( + observeResizeAfterLayout(document.createElement('div'), vi.fn()), + ).toBeNull(); + } finally { + global.ResizeObserver = saved; + } + }); + }); }); diff --git a/packages/core/src/utils/sharedResizeObserver.ts b/packages/core/src/utils/sharedResizeObserver.ts index f3dcf279dcd61..a86684be9898f 100644 --- a/packages/core/src/utils/sharedResizeObserver.ts +++ b/packages/core/src/utils/sharedResizeObserver.ts @@ -5,8 +5,9 @@ /** * @file sharedResizeObserver.ts * @input ResizeObserver API - * @output Exports observeResize / unobserveResize for shared observation, and - * the internal isResizeObserved + * @output Exports observeResize / unobserveResize for shared observation, + * observeResizeAfterLayout for a first measurement after layout, and the + * internal isResizeObserved * @position Utility; consumed by useTruncation, useOverflow, and any component * that needs resize observation without creating per-instance observers * @@ -18,6 +19,7 @@ * SYNC: When modified, update: * - /packages/core/src/utils/index.ts (exports) * - /packages/core/src/Text/useTruncation.ts (primary consumer) + * - /packages/core/src/Lightbox/Lightbox.tsx (observeResizeAfterLayout) */ type ResizeCallback = (entry: ResizeObserverEntry) => void; @@ -107,6 +109,48 @@ export function observeResize( }; } +/** + * Observe `element` as `observeResize` does, without the call it makes on + * registration. That call runs where the caller registers, such as React's + * commit, where a read forces a layout before the browser's own. The + * browser's first notification comes after it lays `element` out. + * + * An element another subscriber already observes gets no new first + * notification. So `onResize` runs in a microtask instead, with no entry, and + * the caller measures there. It does not run once the registration has ended. + * + * Returns null where no ResizeObserver exists (jsdom). The caller measures at + * once there. + */ +export function observeResizeAfterLayout( + element: Element, + onResize: (entry: ResizeObserverEntry | null) => void, +): (() => void) | null { + if (typeof ResizeObserver === 'undefined') { + return null; + } + const wasObserved = isResizeObserved(element); + let isRegistered = false; + let isActive = true; + const unsubscribe = observeResize(element, entry => { + if (isRegistered) { + onResize(entry); + } + }); + isRegistered = true; + if (wasObserved) { + queueMicrotask(() => { + if (isActive) { + onResize(null); + } + }); + } + return () => { + isActive = false; + unsubscribe(); + }; +} + /** * Stop observing an element. * From 0412ac8e3a7f54668a762b32be23717f9f39d7a5 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 9 Nov 2026 12:49:43 -0800 Subject: [PATCH 13/21] fix(Lightbox): the caption's toggle sits on a whole line box The toggle's place in the caption's last line came from the caption's whole-pixel client height. With a line box a fraction of a pixel tall, that place laid the open caption out again inside the resize delivery that measured it, and the browser reported a ResizeObserver loop. The place is now a whole number of line boxes. --- packages/core/src/Lightbox/Lightbox.tsx | 9 +++-- .../LightboxViewport.a11y.browser.spec.ts | 40 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/core/src/Lightbox/Lightbox.tsx b/packages/core/src/Lightbox/Lightbox.tsx index 982f7f092d800..a22b43f5e3b41 100644 --- a/packages/core/src/Lightbox/Lightbox.tsx +++ b/packages/core/src/Lightbox/Lightbox.tsx @@ -980,9 +980,12 @@ export function Lightbox({ parseFloat(style.paddingTop) - parseFloat(style.paddingBottom); const line = parseFloat(style.lineHeight); - setCaptionLastLineTop( - Number.isFinite(line) ? Math.max(0, lines - line) : 0, - ); + // A whole number of line boxes. The client height rounds to whole + // pixels, and a spacer read from it would lay the caption out again a + // fraction of a pixel shorter, inside the delivery that measured it. + const lineCount = + Number.isFinite(line) && line > 0 ? Math.round(lines / line) : 0; + setCaptionLastLineTop(lineCount > 1 ? (lineCount - 1) * line : 0); } setIsCaptionScrolling( isCaptionOpen && diff --git a/packages/core/src/Lightbox/LightboxViewport.a11y.browser.spec.ts b/packages/core/src/Lightbox/LightboxViewport.a11y.browser.spec.ts index 06e7c15e9d1f9..1f05b0a7f56c6 100644 --- a/packages/core/src/Lightbox/LightboxViewport.a11y.browser.spec.ts +++ b/packages/core/src/Lightbox/LightboxViewport.a11y.browser.spec.ts @@ -467,6 +467,46 @@ test('the caption opens, closes and pages with no ResizeObserver loop error', as expect(errors).toEqual([]); }); +// Some fonts set a line box a fraction of a pixel tall, where the caption's +// client height rounds to whole pixels. These two round it up and down. +for (const lineBox of ['20.125px', '19.984375px']) { + test(`a caption whose line box is a fraction of a pixel opens and closes with no ResizeObserver loop error (${lineBox})`, async ({ + page, + }) => { + await page.addInitScript(height => { + const errors: string[] = []; + (window as unknown as {__pageErrors: string[]}).__pageErrors = errors; + window.addEventListener('error', event => { + errors.push(event.message); + }); + document.addEventListener('DOMContentLoaded', () => { + const style = document.createElement('style'); + style.textContent = `[data-lightbox-caption] * { line-height: ${height} !important; }`; + document.head.appendChild(style); + }); + }, lineBox); + const dialog = await openCaptionGallery(page, {width: 402, height: 874}); + await expect(dialog.getByRole('button', {name: 'more'})).toBeVisible(); + for (let round = 0; round < 2; round++) { + await dialog.getByRole('button', {name: 'more'}).click(); + await expect(dialog.getByRole('button', {name: 'less'})).toBeVisible(); + await dialog.getByRole('button', {name: 'less'}).click(); + await expect(dialog.getByRole('button', {name: 'more'})).toBeVisible(); + } + // Two frames, so the last frame's deliveries have run. + await page.evaluate( + async () => + new Promise(resolve => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + }), + ); + const errors = await page.evaluate( + () => (window as unknown as {__pageErrors: string[]}).__pageErrors, + ); + expect(errors).toEqual([]); + }); +} + test(`a caption whose text grows while its box keeps two lines gets "more" at the end of its second line`, async ({ page, }) => { From f27d557877bb519ccfac2f71daffab8e75cd6c7f Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 9 Nov 2026 15:22:43 -0800 Subject: [PATCH 14/21] fix(Lightbox): the toggle's place is as tall as the caption's lines The toggle's place sat under a spacer whose height came from the line height. Layout rounds line boxes to its own grid: Chromium rounds each line box, and WebKit rounds their sum. Where the line height falls off that grid, the place reached past the last line, and the open caption grew inside the resize delivery that measured it. The place now holds line boxes of its own and shows as many as the caption does, so it ends exactly where the last line ends. Only its last line keeps the text away. --- packages/core/src/Lightbox/Lightbox.tsx | 78 +++++++++--------- .../LightboxViewport.a11y.browser.spec.ts | 81 ++++++++++--------- 2 files changed, 84 insertions(+), 75 deletions(-) diff --git a/packages/core/src/Lightbox/Lightbox.tsx b/packages/core/src/Lightbox/Lightbox.tsx index a22b43f5e3b41..7e1c589f2b5a0 100644 --- a/packages/core/src/Lightbox/Lightbox.tsx +++ b/packages/core/src/Lightbox/Lightbox.tsx @@ -430,36 +430,37 @@ const styles = stylex.create({ }, // 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. - // A zero-width float as tall as every shown line but the last. It pushes - // the toggle's place down to the last line. Without a toggle it has no - // height. - captionToggleSpacer: { - float: { - default: 'right', - ':is([dir="rtl"] *)': 'left', - }, - inlineSize: 0, - }, - captionToggleSpacerHeight: (height: number) => ({ - blockSize: height, - }), // 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 size, so it moves no line. + // a toggle it has no box, so it moves no line. captionTogglePlace: { float: { default: 'right', ':is([dir="rtl"] *)': 'left', }, - clear: { - 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. + '::before': { + content: 'attr(data-lines)', }, }, - captionTogglePlaceShown: (width: number) => ({ + // 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, - blockSize: '1lh', 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 @@ -632,6 +633,16 @@ const KEYBOARD_PAN_OFFSETS: Record = { /** The height of a video's native controls, px: no browser reports it. */ const VIDEO_CONTROLS_BAND = 48; +/** + * The toggle's place generates these lines of a no-break space, and shows + * as many as the caption does. They never change, so a measurement changes + * no node. 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. + */ +const CAPTION_PLACE_LINES = Array.from({length: 240}, () => '\u00a0').join( + '\n', +); /** * A fullscreen overlay for viewing images and videos at full resolution. * @@ -966,8 +977,8 @@ export function Lightbox({ bandCaptionRef, captionTruncation.ref, ); - // How far below the caption's first line its last shown line starts. - const [captionLastLineTop, setCaptionLastLineTop] = useState(0); + // How many lines the caption shows. + const [captionShownLines, setCaptionShownLines] = useState(0); // Whether the open caption scrolls inside its panel. const [isCaptionScrolling, setIsCaptionScrolling] = useState(false); const measureCaptionLines = useCallback(() => { @@ -980,12 +991,11 @@ export function Lightbox({ parseFloat(style.paddingTop) - parseFloat(style.paddingBottom); const line = parseFloat(style.lineHeight); - // A whole number of line boxes. The client height rounds to whole - // pixels, and a spacer read from it would lay the caption out again a - // fraction of a pixel shorter, inside the delivery that measured it. - const lineCount = - Number.isFinite(line) && line > 0 ? Math.round(lines / line) : 0; - setCaptionLastLineTop(lineCount > 1 ? (lineCount - 1) * line : 0); + // The client height rounds to whole pixels, so this rounds to whole + // lines. + setCaptionShownLines( + Number.isFinite(line) && line > 0 ? Math.round(lines / line) : 0, + ); } setIsCaptionScrolling( isCaptionOpen && @@ -1994,27 +2004,19 @@ export function Lightbox({ ]}>
-
diff --git a/packages/core/src/Lightbox/LightboxViewport.a11y.browser.spec.ts b/packages/core/src/Lightbox/LightboxViewport.a11y.browser.spec.ts index 1f05b0a7f56c6..818a9cd2778fd 100644 --- a/packages/core/src/Lightbox/LightboxViewport.a11y.browser.spec.ts +++ b/packages/core/src/Lightbox/LightboxViewport.a11y.browser.spec.ts @@ -467,44 +467,51 @@ test('the caption opens, closes and pages with no ResizeObserver loop error', as expect(errors).toEqual([]); }); -// Some fonts set a line box a fraction of a pixel tall, where the caption's -// client height rounds to whole pixels. These two round it up and down. -for (const lineBox of ['20.125px', '19.984375px']) { - test(`a caption whose line box is a fraction of a pixel opens and closes with no ResizeObserver loop error (${lineBox})`, async ({ - page, - }) => { - await page.addInitScript(height => { - const errors: string[] = []; - (window as unknown as {__pageErrors: string[]}).__pageErrors = errors; - window.addEventListener('error', event => { - errors.push(event.message); - }); - document.addEventListener('DOMContentLoaded', () => { - const style = document.createElement('style'); - style.textContent = `[data-lightbox-caption] * { line-height: ${height} !important; }`; - document.head.appendChild(style); - }); - }, lineBox); - const dialog = await openCaptionGallery(page, {width: 402, height: 874}); - await expect(dialog.getByRole('button', {name: 'more'})).toBeVisible(); - for (let round = 0; round < 2; round++) { - await dialog.getByRole('button', {name: 'more'}).click(); - await expect(dialog.getByRole('button', {name: 'less'})).toBeVisible(); - await dialog.getByRole('button', {name: 'less'}).click(); +// Some fonts and themes set a line box a fraction of a pixel tall. The +// caption's client height rounds it to whole pixels: the first two round up +// and down. Layout also rounds a line height off its grid, as the last two. +// At the wider viewport, "more" first wraps the caption onto an extra line. +for (const lineBox of ['20.125px', '19.984375px', '19.6px', '22.4px']) { + for (const viewport of [ + {width: 402, height: 874}, + {width: 1440, height: 900}, + ]) { + test(`a caption whose line box is a fraction of a pixel opens and closes with no ResizeObserver loop error (${lineBox}, ${viewport.width} px)`, async ({ + page, + }) => { + await page.addInitScript(height => { + const errors: string[] = []; + (window as unknown as {__pageErrors: string[]}).__pageErrors = errors; + window.addEventListener('error', event => { + errors.push(event.message); + }); + document.addEventListener('DOMContentLoaded', () => { + const style = document.createElement('style'); + style.textContent = `[data-lightbox-caption] * { line-height: ${height} !important; }`; + document.head.appendChild(style); + }); + }, lineBox); + const dialog = await openCaptionGallery(page, viewport); await expect(dialog.getByRole('button', {name: 'more'})).toBeVisible(); - } - // Two frames, so the last frame's deliveries have run. - await page.evaluate( - async () => - new Promise(resolve => { - requestAnimationFrame(() => requestAnimationFrame(resolve)); - }), - ); - const errors = await page.evaluate( - () => (window as unknown as {__pageErrors: string[]}).__pageErrors, - ); - expect(errors).toEqual([]); - }); + for (let round = 0; round < 2; round++) { + await dialog.getByRole('button', {name: 'more'}).click(); + await expect(dialog.getByRole('button', {name: 'less'})).toBeVisible(); + await dialog.getByRole('button', {name: 'less'}).click(); + await expect(dialog.getByRole('button', {name: 'more'})).toBeVisible(); + } + // Two frames, so the last frame's deliveries have run. + await page.evaluate( + async () => + new Promise(resolve => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + }), + ); + const errors = await page.evaluate( + () => (window as unknown as {__pageErrors: string[]}).__pageErrors, + ); + expect(errors).toEqual([]); + }); + } } test(`a caption whose text grows while its box keeps two lines gets "more" at the end of its second line`, async ({ From 558fa3401f5bd0df6c6464c857e58e2da0dc3b3d Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 9 Nov 2026 16:46:44 -0800 Subject: [PATCH 15/21] fix(Lightbox): an open caption's toggle place shrinks when it widens An open caption scrolls, so its height also holds the toggle's place, and the place's line count could grow but never shrink. Widening the viewport with the caption open left "less" alone on an empty row. When the open caption's width changes, the count now comes from the text's own lines. A smaller count lands in the next frame, outside the resize delivery. --- packages/core/src/Lightbox/Lightbox.tsx | 71 ++++++++++++++++--- .../LightboxViewport.a11y.browser.spec.ts | 37 ++++++++++ 2 files changed, 100 insertions(+), 8 deletions(-) diff --git a/packages/core/src/Lightbox/Lightbox.tsx b/packages/core/src/Lightbox/Lightbox.tsx index 7e1c589f2b5a0..409daae4137c2 100644 --- a/packages/core/src/Lightbox/Lightbox.tsx +++ b/packages/core/src/Lightbox/Lightbox.tsx @@ -977,25 +977,75 @@ export function Lightbox({ bandCaptionRef, captionTruncation.ref, ); - // How many lines the caption shows. const [captionShownLines, setCaptionShownLines] = useState(0); + // 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 lines = - caption.clientHeight - - parseFloat(style.paddingTop) - - parseFloat(style.paddingBottom); 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. - setCaptionShownLines( - Number.isFinite(line) && line > 0 ? Math.round(lines / line) : 0, - ); + const lineCount = hasLine ? linesOf(caption.clientHeight) : 0; + setCaptionShownLines(lineCount); + // An open caption scrolls, so its height also holds the toggle's place. + // So its count never falls by itself. A new width can need fewer lines. + // The flow's height counts only the text's own 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 && @@ -2003,6 +2053,11 @@ export function Lightbox({ ), ]}>