From e463256058e5e4b4063697ee223cb6742e8fe42b Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 2 Nov 2026 22:48:05 -0800 Subject: [PATCH 01/13] feat(core): UIScrollView zoom constants and the zoom stage geometry The UIKit constants table gains zoomMedia, measured on iOS 26.2 in a UIKit lab app and in Photos: the give past the zoom limits, the eased settle and its length law, and Photos' double tap and maximum zoom. The settle's three lengths are internal motion roles. component:Lightbox names them in a new Motion roles table, and the generated motion role table carries them. Lightbox/zoomStage.ts is the pure geometry of a zooming scroll view in a photo viewer, checked against those numbers. A double tap zooms to the larger of filling the stage and 2x, about the tapped point, as component:Lightbox FR6 asks. Nothing renders the stage yet: Lightbox's touch zoom plays it in the next change. --- .changeset/uikit-zoom-media.md | 7 + .../motion-roles.md | 3 + .../components/Lightbox/Lightbox.spec.md | 48 +- packages/core/src/Lightbox/zoomStage.test.ts | 402 +++++++++++++++++ packages/core/src/Lightbox/zoomStage.ts | 413 ++++++++++++++++++ packages/core/src/motion/MEASUREMENTS.md | 15 + packages/core/src/motion/README.md | 1 + packages/core/src/motion/uikit/index.ts | 12 +- packages/core/src/motion/uikit/zoomMedia.ts | 127 ++++++ 9 files changed, 1005 insertions(+), 23 deletions(-) create mode 100644 .changeset/uikit-zoom-media.md create mode 100644 packages/core/src/Lightbox/zoomStage.test.ts create mode 100644 packages/core/src/Lightbox/zoomStage.ts create mode 100644 packages/core/src/motion/uikit/zoomMedia.ts diff --git a/.changeset/uikit-zoom-media.md b/.changeset/uikit-zoom-media.md new file mode 100644 index 0000000000000..c51eb7d7a9b1a --- /dev/null +++ b/.changeset/uikit-zoom-media.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the UIKit constants table gains UIScrollView zooming for a photo viewer, measured on iOS 26.2. It holds the give past the zoom limits, the settle's eased curve and length law, and Photos' aspect-fill double tap and maximum zoom. The zoom stage geometry that Lightbox's touch zoom is built on comes with it. Neither is exported from the package entry point, and no component reads them yet. + +@vjeux diff --git a/docs/specs/AST-094-touch-presentations/motion-roles.md b/docs/specs/AST-094-touch-presentations/motion-roles.md index 509282902bcf8..bd96c52dc1dfc 100644 --- a/docs/specs/AST-094-touch-presentations/motion-roles.md +++ b/docs/specs/AST-094-touch-presentations/motion-roles.md @@ -41,3 +41,6 @@ Every motion role a registered table lists (`spec:AST-094` FR4, DEC-5): its kind | `timing.curves.move` | curve | cubic-bezier(0.42, 0, 0.58, 1) | `spec:AST-094` FR4 | | `timing.curves.viewChange` | curve | cubic-bezier(0.25, 0.1, 0.25, 1) | `spec:AST-094` FR4 | | `timing.stateChangeMs` | duration | 250 ms | `spec:AST-094` FR4 | +| `zoomMedia.settle.baseMs` | duration | 100 ms | `component:Lightbox` | +| `zoomMedia.settle.lnRatioMs` | duration | 300 ms | `component:Lightbox` | +| `zoomMedia.settle.maxMs` | duration | 400 ms | `component:Lightbox` | diff --git a/docs/specs/components/Lightbox/Lightbox.spec.md b/docs/specs/components/Lightbox/Lightbox.spec.md index 2241ad74b31ed..3ee33ad3fbd4d 100644 --- a/docs/specs/components/Lightbox/Lightbox.spec.md +++ b/docs/specs/components/Lightbox/Lightbox.spec.md @@ -131,24 +131,36 @@ FR21). Consumer props and usage remain documented in `Lightbox.doc.mjs`. ## Behavioral and layout contract -| ID | Candidate invariant | Basis | Draft review state | -| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | --------------------------- | -| FR1 | The viewer contains one active Media item and a Close button; galleries additionally contain Previous and Next buttons plus a Counter, and Caption renders only when supplied. | Current source, docs, and tests | settled | -| FR2 | Viewer overlay carries the current `lightbox` target. Media, Caption, and Counter carry no Lightbox public target. | Current source and public docs | settled | -| FR3 | Close, Previous, and Next button surfaces use Button's `button` target through IconButton rather than Lightbox-owned button targets. | Current source and target docs | settled | -| FR4 | The open viewer is a focus region through Core's shared focus-region module (`component:Dialog/DEC-2`): Tab from its last control moves focus to its first and Shift+Tab from its first moves focus to its last, so focus never leaves the viewer for the browser's own interface. | `component:Dialog/DEC-2` | settled; implementation gap | -| FR5 | Under a coarse primary pointer, after hydration, Lightbox presents FR6–FR10 with no opt-in. Every spring a gesture drives in them (the zoom settle, the page settle, the dismiss and the pinch close) is a named motion role read through the motion role table, never a literal, and starts at the gesture's velocity; motion that a tap, a double tap, a key or the program starts (the controls fade and a double tap's zoom transition) plays the theme's duration and easing tokens (`spec:AST-094` FR45). 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` FR36–FR40 | 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 wheel to pan a zoomed photo; 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, on the theme's duration and easing tokens (`spec:AST-094` FR45), and returns into it when it closes, on the theme's exit duration and easing (`spec:AST-094` FR7): 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 the theme's exit duration and easing, `--duration-exit` and `--ease-exit` (`spec:AST-090` FR14; `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 | +| ID | Candidate invariant | Basis | Draft review state | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | --------------------------- | +| FR1 | The viewer contains one active Media item and a Close button; galleries additionally contain Previous and Next buttons plus a Counter, and Caption renders only when supplied. | Current source, docs, and tests | settled | +| FR2 | Viewer overlay carries the current `lightbox` target. Media, Caption, and Counter carry no Lightbox public target. | Current source and public docs | settled | +| FR3 | Close, Previous, and Next button surfaces use Button's `button` target through IconButton rather than Lightbox-owned button targets. | Current source and target docs | settled | +| FR4 | The open viewer is a focus region through Core's shared focus-region module (`component:Dialog/DEC-2`): Tab from its last control moves focus to its first and Shift+Tab from its first moves focus to its last, so focus never leaves the viewer for the browser's own interface. | `component:Dialog/DEC-2` | settled; implementation gap | +| FR5 | Under a coarse primary pointer, after hydration, Lightbox presents FR6–FR10 with no opt-in. Every spring a gesture drives in them (the page settle, the dismiss and the pinch close) is a named motion role read through the motion role table, never a literal, and starts at the gesture's velocity; motion that a tap, a double tap, a key or the program starts (the controls fade and a double tap's zoom transition) plays the theme's duration and easing tokens (`spec:AST-094` FR45). The zoom settle plays `timing.curves.move` for `zoomMedia.settle.baseMs` plus `zoomMedia.settle.lnRatioMs` times \|ln(zoom ratio)\|, at most `zoomMedia.settle.maxMs`, per `spec:AST-094` FR45; iOS measures this settle keyed by zoom ratio. 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` FR36–FR40 | 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 wheel to pan a zoomed photo; 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, on the theme's duration and easing tokens (`spec:AST-094` FR45), and returns into it when it closes, on the theme's exit duration and easing (`spec:AST-094` FR7): 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 the theme's exit duration and easing, `--duration-exit` and `--ease-exit` (`spec:AST-090` FR14; `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 | + +### Motion roles + +Under a coarse primary pointer the zoom settle plays these motion roles, which +this record names and the generated motion role table carries (`spec:AST-094` +FR4, DEC-5). They are internal, so no theme overrides them. + +| Role | Default | Use | +| ---------------------------- | ------- | ------------------------------------------------------ | +| `zoomMedia.settle.baseMs` | 100 ms | The zoom settle's length before its zoom-ratio term | +| `zoomMedia.settle.lnRatioMs` | 300 ms | The length added for each unit of the zoom ratio's log | +| `zoomMedia.settle.maxMs` | 400 ms | The zoom settle's longest length | ### Allowed variation diff --git a/packages/core/src/Lightbox/zoomStage.test.ts b/packages/core/src/Lightbox/zoomStage.test.ts new file mode 100644 index 0000000000000..42986c688939e --- /dev/null +++ b/packages/core/src/Lightbox/zoomStage.test.ts @@ -0,0 +1,402 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file zoomStage.test.ts + * @input vitest, the zoom stage geometry and the UIKit zoom constants + * @output Checks the zoom stage against UIKit's measured zooming and + * Lightbox's double tap. It covers the overshoot, the settle, the maximum + * zoom, the point kept in place, resting places, a drag's rubber band and a + * resize + * @position Testing; zoomStage.ts without a browser + */ + +import {describe, expect, it} from 'vitest'; +import {cubicBezier} from '../motion/bezier'; +import {solveAnimatedPageScroll} from '../motion/paging'; +import {rubberBand} from '../motion/rubberBand'; +import {motionRole, motionRoleOf} from '../motion/roles'; +import { + reducedMotionCrossFade, + registerReducedMotionCrossFade, +} from '../motion/uikit/timing'; +import {registerZoomMediaTable, zoomMedia} from '../motion/uikit/zoomMedia'; +import { + IDENTITY, + ZOOM_STEP, + doubleTapScaleFor, + doubleTapTarget, + draggedTransform, + isZoomed, + maxScaleFor, + overshootScale, + pannedBy, + pinchScaleFor, + rawForDragged, + releaseTarget, + restRange, + restingTransform, + settleCurves, + settleSeconds, + transformForResize, + transformKeepingPoint, + type ZoomGeometry, + type ZoomTransform, +} from './zoomStage'; + +/** + * The lab's stage: a 402 × 874 screen showing a 3000 × 2000 photo fitted + * 402 × 268 at its vertical centre, on a 3× screen. + */ +function landscape(overrides: Partial = {}): ZoomGeometry { + const width = 402; + const height = 874; + const fit = {x: 0, y: (874 - 268) / 2, width: 402, height: 268}; + const doubleTapScale = doubleTapScaleFor(width, height, 402, 268); + return { + width, + height, + fit, + minScale: 1, + maxScale: maxScaleFor(3000, 402, 3, doubleTapScale), + doubleTapScale, + ...overrides, + }; +} + +/** Where the photo point (u, v) of the fitted box shows on the stage. */ +function shown( + geometry: ZoomGeometry, + transform: ZoomTransform, + u: number, + v: number, +) { + return { + x: geometry.fit.x + transform.x + u * transform.scale, + y: geometry.fit.y + transform.y + v * transform.scale, + }; +} + +describe('the overshoot past the zoom limits (UIKit lab, iOS 26.2)', () => { + const geometry = landscape({minScale: 1, maxScale: 4}); + + it('follows the pinch 1:1 inside the limits', () => { + for (const scale of [1, 1.5, 2.25, 4]) { + expect(overshootScale(geometry, scale)).toBe(scale); + } + }); + + it('gives way past the maximum as measured', () => { + // Pinch 4.5 / 5 / 5.5 / 6 past a maximum of 4. + const measured = [ + [4.5, 4.444], + [5, 4.8], + [5.5, 5.091], + [6, 5.333], + ]; + for (const [pinch, zoom] of measured) { + expect(overshootScale(geometry, pinch)).toBeCloseTo(zoom, 3); + } + }); + + it('gives way past the minimum as measured', () => { + const measured = [ + [0.85, 0.87], + [0.7, 0.769], + [0.474, 0.655], + [0.323, 0.596], + ]; + for (const [pinch, zoom] of measured) { + expect(overshootScale(geometry, pinch)).toBeCloseTo(zoom, 3); + } + }); + + it('never reaches twice the maximum or half the minimum', () => { + expect(overshootScale(geometry, 1e6)).toBeLessThan(8); + expect(overshootScale(geometry, 1e-6)).toBeGreaterThan(0.5); + }); + + it('inverts, so a pinch can continue from a photo already past a limit', () => { + for (const pinch of [0.3, 0.6, 0.95, 1, 2, 4, 4.4, 7, 20]) { + expect( + pinchScaleFor(geometry, overshootScale(geometry, pinch)), + ).toBeCloseTo(pinch, 9); + } + }); +}); + +describe('the settle (UIScrollView zoom animation)', () => { + it('lasts 0.1 s + 0.3 s × |ln(zoom ratio)|, at most 0.4 s, as measured', () => { + expect(settleSeconds(5.333, 4)).toBeCloseTo(0.186, 3); + expect(settleSeconds(4.8, 4)).toBeCloseTo(0.155, 3); + expect(settleSeconds(0.596, 1)).toBeCloseTo(0.255, 3); + expect(settleSeconds(1, 2.5)).toBeCloseTo(0.375, 3); + expect(settleSeconds(2.5, 1)).toBeCloseTo(0.375, 3); + expect(settleSeconds(1, 4)).toBe(0.4); + // A move at the same zoom (a keyboard step). + expect(settleSeconds(3, 3)).toBeCloseTo(0.1, 9); + }); + + it('plays its three lengths as motion roles, named by the zoom table', () => { + registerZoomMediaTable(); + expect(motionRole('zoomMedia.settle.baseMs')?.value).toBe(100); + expect(motionRole('zoomMedia.settle.lnRatioMs')?.value).toBe(300); + expect(motionRole('zoomMedia.settle.maxMs')?.value).toBe(400); + }); + + it('runs along the curve it is given', () => { + const from: ZoomTransform = {x: 0, y: 0, scale: 1}; + const to: ZoomTransform = {x: -400, y: -100, scale: 3}; + // A linear curve: a quarter of the time is a quarter of the way + // (easeInEaseOut would be about 13 %). + const curves = settleCurves(from, to, [0, 0, 1, 1]); + const t = curves.duration / 4; + expect(curves.x.at(t)).toBeCloseTo(-100, 3); + expect(curves.scale.at(t)).toBeCloseTo(1.5, 3); + }); + + it('plays each axis as the eased move an animated page scroll plays', () => { + const from: ZoomTransform = {x: 0, y: 0, scale: 1}; + const to: ZoomTransform = {x: -400, y: -100, scale: 3}; + const curves = settleCurves(from, to); + const page = solveAnimatedPageScroll({ + from: 0, + to: -400, + durationMs: curves.duration * 1000, + curve: zoomMedia.settle.curve, + }); + for (const p of [0, 0.25, 0.5, 0.75, 1]) { + const t = p * curves.duration; + expect(curves.x.at(t)).toBe(page.at(t)); + expect(curves.x.velocity(t)).toBe(page.velocity(t)); + } + }); + + it('plays the timing.curves.move role: its curve is that leaf, not a copy', () => { + registerReducedMotionCrossFade(); + expect(zoomMedia.settle.curve).toBe(reducedMotionCrossFade.curve); + expect(motionRoleOf(zoomMedia.settle.curve)?.id).toBe('timing.curves.move'); + }); + + it('moves zoom and offset together on easeInEaseOut', () => { + const from: ZoomTransform = {x: 0, y: 0, scale: 1}; + const to: ZoomTransform = {x: -400, y: -100, scale: 3}; + const curves = settleCurves(from, to); + const ease = cubicBezier([0.42, 0, 0.58, 1]); + expect(zoomMedia.settle.curve).toEqual([0.42, 0, 0.58, 1]); + expect(curves.duration).toBe(0.4); + for (const p of [0, 0.1, 0.25, 0.5, 0.75, 1]) { + const t = p * curves.duration; + expect(curves.scale.at(t)).toBeCloseTo(1 + 2 * ease(p), 9); + expect(curves.x.at(t)).toBeCloseTo(-400 * ease(p), 9); + expect(curves.y.at(t)).toBeCloseTo(-100 * ease(p), 9); + } + // Ease in and out: at rest at both ends, fastest in the middle. + expect(curves.x.velocity(0)).toBeCloseTo(0, 0); + expect(curves.x.velocity(curves.duration)).toBeCloseTo(0, 0); + expect(Math.abs(curves.x.velocity(curves.duration / 2))).toBeGreaterThan( + 1000, + ); + }); +}); + +describe('the double tap: the larger of filling the stage and 2×, about the tapped point', () => { + it('zooms until the photo fills the screen when that is more than 2×', () => { + // Photos' aspect fill: landscape 402 × 268 on an 874 pt screen → 3.261. + expect(doubleTapScaleFor(402, 874, 402, 268)).toBeCloseTo(3.261, 3); + }); + + it('zooms 2× when filling the screen takes less', () => { + // A portrait 402 × 603 fills the screen at 1.449×, below the zoom step. + expect(ZOOM_STEP).toBe(2); + expect(doubleTapScaleFor(402, 874, 402, 603)).toBe(2); + expect(doubleTapScaleFor(400, 300, 400, 300)).toBe(2); + }); + + it('keeps a centre tap at the centre, and the photo fills the screen', () => { + const geometry = landscape(); + const target = doubleTapTarget(geometry, IDENTITY, {x: 201, y: 437}); + expect(target.scale).toBeCloseTo(874 / 268, 9); + const centre = shown(geometry, target, 201, 134); + expect(centre.x).toBeCloseTo(201, 9); + expect(centre.y).toBeCloseTo(437, 9); + expect(geometry.fit.y + target.y).toBeCloseTo(0, 9); + }); + + it('keeps an off-centre tap where it lands', () => { + const geometry = landscape(); + // A third of the way across the photo: that point stays under the tap. + const target = doubleTapTarget(geometry, IDENTITY, {x: 134, y: 437}); + expect(shown(geometry, target, 134, 134).x).toBeCloseTo(134, 9); + }); + + it('stops at the edge for a tap near it', () => { + // A portrait photo 2000 × 3000, fitted 402 × 603, zooms 2×. + const fit = {x: 0, y: (874 - 603) / 2, width: 402, height: 603}; + const geometry = landscape({ + fit, + doubleTapScale: doubleTapScaleFor(402, 874, 402, 603), + }); + const target = doubleTapTarget(geometry, IDENTITY, {x: 201, y: 700}); + expect(target.scale).toBe(2); + // The photo's bottom edge rests on the screen's bottom edge. + expect(fit.y + target.y + fit.height * 2).toBeCloseTo(874, 9); + }); + + it('returns to the fitted size when zoomed', () => { + const geometry = landscape(); + const zoomed = doubleTapTarget(geometry, IDENTITY, {x: 201, y: 437}); + expect(isZoomed(geometry, zoomed)).toBe(true); + expect(doubleTapTarget(geometry, zoomed, {x: 50, y: 50})).toEqual(IDENTITY); + }); +}); + +describe("Photos' maximum zoom (iOS 26.2)", () => { + it('allows 6 times the zoom that shows one image pixel per device pixel', () => { + // Measured: 14.93 for 3000 × 2000 and 9.95 for 2000 × 3000, fitted 402 pt + // wide at 3×. + expect(maxScaleFor(3000, 402, 3, 3.261)).toBeCloseTo(14.93, 2); + expect(maxScaleFor(2000, 402, 3, 2)).toBeCloseTo(9.95, 2); + // Never below the double tap's zoom. + expect(maxScaleFor(200, 402, 3, 3.261)).toBe(3.261); + }); +}); + +describe('the point under the fingers', () => { + it('stays under the centroid as it moves and the zoom changes', () => { + const geometry = landscape(); + const start: ZoomTransform = {x: -300, y: -100, scale: 2.5}; + const anchor = {x: 150, y: 400}; + const u = (anchor.x - geometry.fit.x - start.x) / start.scale; + const v = (anchor.y - geometry.fit.y - start.y) / start.scale; + const centroid = {x: 190, y: 360}; + const next = transformKeepingPoint(geometry, start, anchor, centroid, 4.2); + const point = shown(geometry, next, u, v); + expect(point.x).toBeCloseTo(centroid.x, 9); + expect(point.y).toBeCloseTo(centroid.y, 9); + expect(next.scale).toBe(4.2); + }); + + it('settles a release past the maximum about the last centroid', () => { + const geometry = landscape({maxScale: 4}); + const centroid = {x: 220, y: 430}; + // Released at 5.333 with the content point under the centroid at u, v. + const released: ZoomTransform = {x: -700, y: -500, scale: 5.333}; + const target = releaseTarget(geometry, released, centroid); + expect(target.scale).toBe(4); + const u = (centroid.x - geometry.fit.x - released.x) / released.scale; + const v = (centroid.y - geometry.fit.y - released.y) / released.scale; + const point = shown(geometry, target, u, v); + expect(point.x).toBeCloseTo(centroid.x, 9); + expect(point.y).toBeCloseTo(centroid.y, 9); + }); + + it('keeps a release inside the limits where it is', () => { + const geometry = landscape(); + const released: ZoomTransform = {x: -100, y: -50, scale: 2}; + expect(releaseTarget(geometry, released, {x: 0, y: 0})).toBe(released); + }); + + it('settles a release past the minimum to the fitted size, centred', () => { + const geometry = landscape(); + const released: ZoomTransform = {x: 40, y: 30, scale: 0.6}; + expect(releaseTarget(geometry, released, {x: 180, y: 420})).toEqual( + IDENTITY, + ); + }); +}); + +describe('the resting place', () => { + it('centres the photo on an axis where it is smaller than the stage', () => { + const geometry = landscape(); + // 2× is 804 × 536: wider than the screen, shorter than it. + const range = restRange(geometry, 2); + expect(range.x).toEqual({min: 402 - 804, max: 0}); + expect(range.y.min).toBe(range.y.max); + expect(geometry.fit.y + range.y.min + 536 / 2).toBeCloseTo(874 / 2, 9); + }); + + it('keeps a zoomed photo with no gap at either edge', () => { + const geometry = landscape(); + expect(restingTransform(geometry, {x: 50, y: 0, scale: 2}).x).toBe(0); + expect(restingTransform(geometry, {x: -900, y: 0, scale: 2}).x).toBe( + 402 - 804, + ); + }); + + it('pans by keyboard steps inside those bounds', () => { + const geometry = landscape(); + const from: ZoomTransform = { + x: -10, + y: restRange(geometry, 2).y.min, + scale: 2, + }; + expect(pannedBy(geometry, from, 50, 0).x).toBe(0); + expect(pannedBy(geometry, from, -50, 0).x).toBe(-60); + expect(pannedBy(geometry, from, 0, 50).y).toBe(from.y); + }); +}); + +describe('the rubber band of a drag', () => { + const geometry = landscape(); + + it('follows the finger inside the bounds', () => { + const raw: ZoomTransform = {x: -200, y: 0, scale: 2}; + expect(draggedTransform(geometry, raw).x).toBe(-200); + }); + + it("gives way past an edge with UIScrollView's rubber band over the stage width", () => { + const raw: ZoomTransform = {x: 120, y: 0, scale: 2}; + expect(draggedTransform(geometry, raw).x).toBeCloseTo( + rubberBand(120, 402, 0.55), + 9, + ); + const far: ZoomTransform = {x: 402 - 804 - 300, y: 0, scale: 2}; + expect(draggedTransform(geometry, far).x).toBeCloseTo( + 402 - 804 + rubberBand(-300, 402, 0.55), + 9, + ); + }); + + it('does not move the photo on an axis where it fits', () => { + const raw: ZoomTransform = {x: -200, y: 500, scale: 2}; + expect(draggedTransform(geometry, raw).y).toBe( + restRange(geometry, 2).y.min, + ); + }); + + it('inverts, so a finger landing on a banded photo picks it up where it shows', () => { + const raw: ZoomTransform = {x: 150, y: 0, scale: 2}; + const banded = draggedTransform(geometry, raw); + expect(rawForDragged(geometry, banded).x).toBeCloseTo(150, 9); + }); +}); + +describe('a resize while zoomed (rotation)', () => { + it('keeps the photo point at the centre and its size on screen', () => { + const before = landscape(); + const transform: ZoomTransform = {x: -500, y: -150, scale: 3}; + const u = (201 - before.fit.x - transform.x) / transform.scale / 402; + const v = (437 - before.fit.y - transform.y) / transform.scale / 268; + // Landscape orientation: 874 × 402, the photo fitted 603 × 402. + const after: ZoomGeometry = { + width: 874, + height: 402, + fit: {x: (874 - 603) / 2, y: 0, width: 603, height: 402}, + minScale: 1, + maxScale: maxScaleFor(3000, 603, 3, 874 / 603), + doubleTapScale: 874 / 603, + }; + const next = transformForResize(before, after, transform); + // The same on-screen size: 402 × 3 = 1206 px wide either way. + expect(603 * next.scale).toBeCloseTo(402 * 3, 9); + const centre = shown(after, next, u * 603, v * 402); + expect(centre.x).toBeCloseTo(437, 6); + expect(centre.y).toBeCloseTo(201, 6); + }); + + it('stays at the fitted size when not zoomed', () => { + const before = landscape(); + const after = landscape({width: 500}); + expect(transformForResize(before, after, IDENTITY)).toEqual(IDENTITY); + }); +}); diff --git a/packages/core/src/Lightbox/zoomStage.ts b/packages/core/src/Lightbox/zoomStage.ts new file mode 100644 index 0000000000000..b7a4881607db0 --- /dev/null +++ b/packages/core/src/Lightbox/zoomStage.ts @@ -0,0 +1,413 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file zoomStage.ts + * @input The stage's size, the photo's fitted box and pixel size, a + * transform (offset from the fitted box, and scale), and finger positions + * @output The zoom stage's pure geometry: zoom limits, resting places, a + * drag's rubber band and a pinch's overshoot. Also the transform that keeps + * a point in place, and the eased settle curves + * @position Pure math for Lightbox's touch zoom. No DOM and no React. + * + * The model is a zooming UIScrollView in a photo viewer. Its constants come + * from motion/uikit/zoomMedia.ts and motion/uikit/scroll.ts. + * SYNC: When modified, update zoomStage.test.ts. + */ + +import {rubberBandClamp, rubberBandUnclamp} from '../motion/rubberBand'; +import {solveAnimatedPageScroll} from '../motion/paging'; +import {resolveMotion} from '../motion/resolveMotion'; +import {registerZoomMediaTable, zoomMedia} from '../motion/uikit/zoomMedia'; +import type {MotionCurve} from '../motion/animator'; + +export interface StagePoint { + readonly x: number; + readonly y: number; +} + +/** + * Scales the fitted box about its top-left corner and moves it by (x, y). A + * photo point at (u, v) in the fitted box shows at (fit.x + x + u·scale, + * fit.y + y + v·scale). + */ +export interface ZoomTransform { + /** Offset of the photo's box from its fitted place, px. */ + readonly x: number; + readonly y: number; + /** Scale relative to the fitted size. */ + readonly scale: number; +} + +export interface ZoomGeometry { + /** The stage's size, px. */ + readonly width: number; + readonly height: number; + /** The photo's box at the fitted size, in stage coordinates. */ + readonly fit: { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + }; + readonly minScale: number; + readonly maxScale: number; + /** The zoom a double tap goes to from the fitted size. */ + readonly doubleTapScale: number; +} + +export const IDENTITY: ZoomTransform = {x: 0, y: 0, scale: 1}; + +/** Within this of the fitted size the photo counts as not zoomed. */ +export const ZOOMED_EPSILON = 0.001; + +/** Lightbox's zoom step: a double tap zooms at least this many times. */ +export const ZOOM_STEP = 2; + +/** + * The zoom a double tap goes to from the fitted size. It is the larger of + * filling the stage and `ZOOM_STEP`, for a stage of `width` × `height` and a + * photo fitted at `fitWidth` × `fitHeight`. Photos zooms to the fill alone, + * so Lightbox departs from it on a photo that fills at less than `ZOOM_STEP`. + */ +export function doubleTapScaleFor( + width: number, + height: number, + fitWidth: number, + fitHeight: number, +): number { + if (!(fitWidth > 0) || !(fitHeight > 0)) { + return ZOOM_STEP; + } + return Math.max(ZOOM_STEP, width / fitWidth, height / fitHeight); +} + +/** + * The largest zoom for a photo `naturalWidth` image pixels wide, fitted + * `fitWidth` CSS px wide on a screen of `devicePixelRatio`. It is Photos' + * multiple of the zoom that shows one image pixel per device pixel. It is + * never less than `doubleTapScale`, so a double tap lands inside the limits. + */ +export function maxScaleFor( + naturalWidth: number, + fitWidth: number, + devicePixelRatio: number, + doubleTapScale: number, +): number { + const {maxPixelZoomMultiple} = zoomMedia.viewer; + if (!(fitWidth > 0) || !(naturalWidth > 0) || !(devicePixelRatio > 0)) { + return doubleTapScale; + } + const pixelScale = naturalWidth / (fitWidth * devicePixelRatio); + return Math.max(doubleTapScale, maxPixelZoomMultiple * pixelScale); +} + +/** Whether `transform` shows the photo larger than fitted. */ +export function isZoomed( + geometry: ZoomGeometry, + transform: ZoomTransform, +): boolean { + return transform.scale > geometry.minScale + ZOOMED_EPSILON; +} + +interface AxisRange { + readonly min: number; + readonly max: number; +} + +function axisRange( + stageSize: number, + fitStart: number, + fitSize: number, + scale: number, +): AxisRange { + const size = fitSize * scale; + if (size <= stageSize) { + const centred = (stageSize - size) / 2 - fitStart; + return {min: centred, max: centred}; + } + // `0 - fitStart`, not `-fitStart`: no negative zero in the style. + return {min: stageSize - size - fitStart, max: 0 - fitStart}; +} + +/** + * Where the photo may rest at `scale`: the offset range on each axis. On an + * axis where the scaled photo is smaller than the stage, it rests centred, as + * in every UIKit photo viewer. Where it is larger, it rests with no gap at + * either edge. + */ +export function restRange( + geometry: ZoomGeometry, + scale: number, +): {readonly x: AxisRange; readonly y: AxisRange} { + const {fit} = geometry; + return { + x: axisRange(geometry.width, fit.x, fit.width, scale), + y: axisRange(geometry.height, fit.y, fit.height, scale), + }; +} + +/** The resting transform nearest to `transform`, at its scale. */ +export function restingTransform( + geometry: ZoomGeometry, + transform: ZoomTransform, +): ZoomTransform { + const range = restRange(geometry, transform.scale); + return { + x: Math.min(range.x.max, Math.max(range.x.min, transform.x)), + y: Math.min(range.y.max, Math.max(range.y.min, transform.y)), + scale: transform.scale, + }; +} + +/** + * The scale a pinch shows for its own scale `scale`: 1:1 inside the + * limits, giving way past them (`zoomMedia.overshoot.limitFactor`). + */ +export function overshootScale(geometry: ZoomGeometry, scale: number): number { + const f = zoomMedia.overshoot.limitFactor; + const {minScale: m, maxScale: M} = geometry; + if (scale > M) { + return M * (f - ((f - 1) * M) / scale); + } + if (scale < m) { + return m / (f - ((f - 1) * scale) / m); + } + return scale; +} + +/** + * The pinch's own scale that shows `shown`: the inverse of + * `overshootScale`, to continue a pinch from a photo already past a limit. + */ +export function pinchScaleFor(geometry: ZoomGeometry, shown: number): number { + const f = zoomMedia.overshoot.limitFactor; + const {minScale: m, maxScale: M} = geometry; + if (shown > M) { + // shown = M·(f − (f − 1)·M ÷ s) ⇒ s = (f − 1)·M² ÷ (f·M − shown) + const gap = f * M - shown; + return gap > 0 ? ((f - 1) * M * M) / gap : Number.POSITIVE_INFINITY; + } + if (shown < m) { + // shown = m ÷ (f − (f − 1)·s ÷ m) ⇒ s = (f − m ÷ shown)·m ÷ (f − 1) + return Math.max(0, ((f - m / shown) * m) / (f - 1)); + } + return shown; +} + +/** + * The transform at `scale` that shows the photo point under `anchor` (as + * placed by `from`) under `point`. A pinch keeps the point under its first + * centroid under the current centroid. A double tap and a settle past a limit + * keep the point where it is (`point` = `anchor`). + */ +export function transformKeepingPoint( + geometry: ZoomGeometry, + from: ZoomTransform, + anchor: StagePoint, + point: StagePoint, + scale: number, +): ZoomTransform { + const {fit} = geometry; + // The fitted-box point under `anchor`. + const u = (anchor.x - fit.x - from.x) / from.scale; + const v = (anchor.y - fit.y - from.y) / from.scale; + return { + x: point.x - fit.x - u * scale, + y: point.y - fit.y - v * scale, + scale, + }; +} + +/** + * Where a drag shows the photo: `raw` is where the finger's travel would put + * it with no edges. Past an edge it gives way with UIScrollView's rubber band + * over the stage's size on that axis. On an axis where the photo is smaller + * than the stage, it stays centred, as a scroll view whose content fits does + * not bounce. + */ +export function draggedTransform( + geometry: ZoomGeometry, + raw: ZoomTransform, +): ZoomTransform { + const range = restRange(geometry, raw.scale); + return { + x: + range.x.min === range.x.max + ? range.x.min + : rubberBandClamp(raw.x, range.x.min, range.x.max, geometry.width), + y: + range.y.min === range.y.max + ? range.y.min + : rubberBandClamp(raw.y, range.y.min, range.y.max, geometry.height), + scale: raw.scale, + }; +} + +/** The finger-travel position behind a shown one: `draggedTransform` undone. */ +export function rawForDragged( + geometry: ZoomGeometry, + shown: ZoomTransform, +): ZoomTransform { + const range = restRange(geometry, shown.scale); + return { + x: + range.x.min === range.x.max + ? shown.x + : rubberBandUnclamp(shown.x, range.x.min, range.x.max, geometry.width), + y: + range.y.min === range.y.max + ? shown.y + : rubberBandUnclamp(shown.y, range.y.min, range.y.max, geometry.height), + scale: shown.scale, + }; +} + +/** + * The resting transform a double tap at `point` goes to from `from`. From + * the fitted size it zooms to `geometry.doubleTapScale` about the tapped + * point, kept inside the stage. Photos brings the tapped point to the centre + * instead. When zoomed, it goes back to the fitted size. + */ +export function doubleTapTarget( + geometry: ZoomGeometry, + from: ZoomTransform, + point: StagePoint, +): ZoomTransform { + if (isZoomed(geometry, from)) { + return restingTransform(geometry, {...IDENTITY, scale: geometry.minScale}); + } + const scale = Math.min( + geometry.maxScale, + Math.max(geometry.minScale, geometry.doubleTapScale), + ); + return restingTransform( + geometry, + transformKeepingPoint(geometry, from, point, point, scale), + ); +} + +/** + * The resting transform a release goes to. Inside the limits it is `shown` + * itself, since a drag's coast settles any edge. Past a limit it is the limit, + * with the point under the last centroid kept in place and the photo kept + * inside the stage. + */ +export function releaseTarget( + geometry: ZoomGeometry, + shown: ZoomTransform, + centroid: StagePoint, +): ZoomTransform { + const limit = Math.min( + geometry.maxScale, + Math.max(geometry.minScale, shown.scale), + ); + if (limit === shown.scale) { + return shown; + } + return restingTransform( + geometry, + transformKeepingPoint(geometry, shown, centroid, centroid, limit), + ); +} + +/** + * The settle's length, s. It is a base plus a term in the log of the zoom + * ratio, at most a cap: the zoom table's three motion roles. + */ +export function settleSeconds(fromScale: number, toScale: number): number { + registerZoomMediaTable(); + const {settle} = zoomMedia; + const baseMs = resolveMotion(null, settle, 'baseMs'); + if (!(fromScale > 0) || !(toScale > 0)) { + return baseMs / 1000; + } + const lnRatioMs = resolveMotion(null, settle, 'lnRatioMs'); + const maxMs = resolveMotion(null, settle, 'maxMs'); + const ratio = Math.abs(Math.log(toScale / fromScale)); + return Math.min(maxMs, baseMs + lnRatioMs * ratio) / 1000; +} + +/** + * UIScrollView's zoom settle as curves for the animator. The offset and the + * scale move together on one eased curve, from `from` to `to`, over + * `settleSeconds`. The curve is `zoomMedia.settle.curve` by default. Each + * axis is the eased move an animated page scroll plays. + */ +export function settleCurves( + from: ZoomTransform, + to: ZoomTransform, + curve: readonly [number, number, number, number] = resolveMotion( + null, + zoomMedia.settle.curve, + ), +): { + readonly duration: number; + readonly x: MotionCurve; + readonly y: MotionCurve; + readonly scale: MotionCurve; +} { + const duration = settleSeconds(from.scale, to.scale); + const along = (a: number, b: number): MotionCurve => + solveAnimatedPageScroll({ + from: a, + to: b, + durationMs: duration * 1000, + curve, + }); + return { + duration, + x: along(from.x, to.x), + y: along(from.y, to.y), + scale: along(from.scale, to.scale), + }; +} + +/** The resting transform after panning by (dx, dy): a keyboard step. */ +export function pannedBy( + geometry: ZoomGeometry, + from: ZoomTransform, + dx: number, + dy: number, +): ZoomTransform { + return restingTransform(geometry, { + x: from.x + dx, + y: from.y + dy, + scale: from.scale, + }); +} + +/** + * The transform that shows the same photo after the stage resizes, as on a + * rotation. The photo point at the stage's centre stays at its centre. The + * photo keeps its size on screen, so the zoom relative to the fitted size + * changes with the fitted size. The result is within the new limits, at rest. + */ +export function transformForResize( + before: ZoomGeometry, + after: ZoomGeometry, + transform: ZoomTransform, +): ZoomTransform { + if (!isZoomed(before, transform)) { + return {...IDENTITY, scale: after.minScale}; + } + const ratio = after.fit.width > 0 ? before.fit.width / after.fit.width : 1; + const scale = Math.min( + after.maxScale, + Math.max(after.minScale, transform.scale * ratio), + ); + // The photo point (in fitted-box px of `before`) at the old centre, then + // the same point in the new fitted box. + const u = + (before.width / 2 - before.fit.x - transform.x) / + transform.scale / + before.fit.width; + const v = + (before.height / 2 - before.fit.y - transform.y) / + transform.scale / + before.fit.height; + return restingTransform(after, { + x: after.width / 2 - after.fit.x - u * after.fit.width * scale, + y: after.height / 2 - after.fit.y - v * after.fit.height * scale, + scale, + }); +} diff --git a/packages/core/src/motion/MEASUREMENTS.md b/packages/core/src/motion/MEASUREMENTS.md index aaa6aafd51f35..2b6a9f521617b 100644 --- a/packages/core/src/motion/MEASUREMENTS.md +++ b/packages/core/src/motion/MEASUREMENTS.md @@ -314,3 +314,18 @@ scroll view coasts with 0.25 × the pan's velocity plus 0.75 × its value one event earlier (four HID releases; `velocity.scrollReleaseWeight`, which a pan's `scrollVelocity()` reads). On UIKit, Reduce Motion with Prefer Cross-Fade changes nothing on a picker; the Wheel follows design:reduced-motion instead. + +## Zoom media (UIScrollView zooming, Photos) + +Values Lightbox's zoom stage reads (`uikit/zoomMedia.ts`). Done (UIKit lab +and Photos, iOS 26.2 simulator, Oct 23 2026; tagged `measured:`): the +overshoot past the limits, the settle curve and its duration law, the absence +of scale momentum and of a two-finger-tap action, the content point under the +centroid staying put, Photos' aspect-fill double tap and its maximum zoom on +3000 × 2000 and 2000 × 3000 photos. M-ZM2 confirms that maximum on two other +sizes. A zoomed pan flicked into an edge coasts and bounces like any scroll +view (`scroll.*`). + +| ID | What | App and gesture | Extract | Method | +| ----- | ------------ | -------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------ | +| M-ZM2 | Maximum zoom | Photos: pinch a 4032 × 3024 and a 1200 × 800 photo as far as it goes | the zoom it settles on against the photo's pixel size and the fitted size | B, C | diff --git a/packages/core/src/motion/README.md b/packages/core/src/motion/README.md index fa0341f398e59..e296191c961b0 100644 --- a/packages/core/src/motion/README.md +++ b/packages/core/src/motion/README.md @@ -27,6 +27,7 @@ roles through `resolveMotion`. | `uikit/segmented.ts` | UISegmentedControl: the selection indicator's spring; under Reduce Motion the animator's cross-fade replaces it. | | `uikit/switch.ts` | UISwitch on iOS 26.2: geometry, the lift, settle, travel and tracking springs, the flip distance, the track colour. | | `uikit/datePicker.ts` | UIDatePicker: the rules it adds to its wheels. | +| `uikit/zoomMedia.ts` | UIScrollView zooming for a photo viewer: overshoot past the limits, the settle curve and duration, double-tap and maximum zoom. | | `roles.ts` | The internal motion roles: the springs, durations and curves `spec:AST-094` FR4 names, registered by the shared tables, and a role's CSS form read back. | | `roleValue.ts` | A role's CSS form, written: a spring's duration and bounce, a duration in ms, a curve's `cubic-bezier()`. | | `resolveMotion.ts` | A role's value: its table value, as the per-role tables stay internal (`spec:AST-094` DEC-15). | diff --git a/packages/core/src/motion/uikit/index.ts b/packages/core/src/motion/uikit/index.ts index f9bbff0054a1f..dae49ac3c7a4f 100644 --- a/packages/core/src/motion/uikit/index.ts +++ b/packages/core/src/motion/uikit/index.ts @@ -3,11 +3,12 @@ /** * @file index.ts * @input The domain files of the UIKit constants table - * @output Re-exports `springs` and one constant per spring (`SHEET_PRESENT_SPRING`, - * …), `scroll`, `timing`, `navigation`, `picker`, `progressBar`, - * `segmentedControl`, `datePicker`, `slider` and `switchControl`. It also - * re-exports the gesture thresholds: one `UIKIT_*` group per recognizer, - * their union `UIKIT_GESTURES`, and the haptic tables + * @output Re-exports `springs` and one constant per spring + * (`SHEET_PRESENT_SPRING`, …), and the `scroll`, `timing`, `navigation`, + * `picker`, `progressBar`, `segmentedControl`, `datePicker`, `slider`, + * `switchControl` and `zoomMedia` domains. It also re-exports the gesture + * thresholds: one `UIKIT_*` group per recognizer, their union + * `UIKIT_GESTURES`, and the haptic tables * @position Entry of the UIKit constants table (motion/uikit). Internal to * core: not exported from the package entry point * @@ -84,3 +85,4 @@ export { } from './springs'; export {switchControl} from './switch'; export {registerTimingTable, timing} from './timing'; +export {zoomMedia} from './zoomMedia'; diff --git a/packages/core/src/motion/uikit/zoomMedia.ts b/packages/core/src/motion/uikit/zoomMedia.ts new file mode 100644 index 0000000000000..4a6745233294a --- /dev/null +++ b/packages/core/src/motion/uikit/zoomMedia.ts @@ -0,0 +1,127 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file zoomMedia.ts + * @input Nothing; data only + * @output Exports `zoomMedia`: UIScrollView zooming in a photo viewer. That + * is the give past the zoom limits, the settle's curve and duration law, + * and Photos' double tap and maximum zoom + * @position A domain of the UIKit constants table (motion/uikit). Lightbox's + * zoom stage reads it. Its three settle lengths are internal motion roles + * + * SYNC: When modified, update /packages/core/src/motion/MEASUREMENTS.md + */ + +import {registerMotionTable} from '../roles'; +import {timing} from './timing'; + +/** + * UIScrollView zooming as a photo viewer uses it, with Photos' double tap + * and maximum zoom. Re-exported from motion/uikit, inside core. + * + * Measured on the iOS 26.2 simulator (iPhone 17 Pro, 402 × 874 pt at 3×). + * The lab was a UIKit host app with a full-screen UIScrollView zooming one + * image view (minimum zoom = fit, maximum 4, bouncesZoom on). Photos was + * measured on 3000 × 2000 and 2000 × 3000 photos. Real HID touches drove + * both, and every `CAAnimation`, zoomScale and contentOffset was logged. + * + * Findings with no number: inside the limits, the zoom equals the pinch's + * scale 1:1. The content point under the fingers' centroid stays under it + * (within 1 pt), past the limits too. A pinch released inside the limits + * keeps no scale momentum. A two-finger tap does nothing. The double tap + * fires on the second lift, with no wait. + * + * Every leaf carries one or more `@source`. WebKit and GUESS values also + * carry the `@measure` task that will confirm or replace them, and + * uikit.test.ts enforces both. + */ +export const zoomMedia = { + overshoot: { + /** + * Past a zoom limit the zoom gives way. Past the maximum M it reads + * M·(f − (f − 1)·M ÷ s). Past the minimum m it reads + * m ÷ (f − (f − 1)·s ÷ m), where s is the pinch's own scale. With f = 2, + * the zoom leaves the limit at the pinch's pace and approaches f times the + * limit (2M, m ÷ 2). Pinches of 4.5, 5, 5.5 and 6 past a maximum of 4 read + * 4.444, 4.8, 5.091 and 5.333. Pinches of 0.85, 0.7, 0.474 and 0.323 past + * a minimum of 1 read 0.870, 0.769, 0.655 and 0.596. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, zoomScale against the pinch recognizer's scale per + * event, Oct 23 2026 + */ + limitFactor: 2, + }, + settle: { + /** + * The curve of the return from past a limit, and of `zoom(to:animated:)` + * and `setZoomScale(_:animated:)`. UIKit runs one CABasicAnimation, + * easeInEaseOut, on the image view's transform and position and the scroll + * view's bounds. This is the `timing.curves.move` leaf itself, so the + * settle plays that role. Zoom and offset move together on it. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, CAAnimation read (timing function control points), + * Oct 23 2026 + */ + curve: timing.curves.move, + /** + * Its length, ms, is this plus `lnRatioMs` × |ln(zoom ratio)|. So + * 5.333 → 4 lasts 186 ms, 4.8 → 4 lasts 155 ms, and 0.596 → 1 lasts + * 255 ms. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, CAAnimation durations of releases past the limits and + * of zoom(to:animated:), Oct 23 2026 + */ + baseMs: 100, + /** + * See `baseMs`. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, CAAnimation durations of releases past the limits and + * of zoom(to:animated:), Oct 23 2026 + */ + lnRatioMs: 300, + /** + * `zoom(to:animated:)` never takes longer than this, ms. 1 ↔ 2.5 takes + * 375 ms, and a rect 4 times smaller takes 400 ms. Photos' double tap + * from 1 to 3.261 shows about 420 ms of motion. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, CAAnimation durations of zoom(to:animated:) and + * setZoomScale(_:animated:), Oct 23 2026 + */ + maxMs: 400, + }, + viewer: { + /** + * In Photos, a double tap at the fitted size zooms until the photo fills + * the stage (aspect fill). A 402 × 268 landscape photo on the 874 pt tall + * screen zooms 3.261×, and a 402 × 603 portrait 1.449×. Photos brings the + * tapped point to the centre, kept inside the stage. A double tap while + * zoomed returns to the fitted size. Both run `settle`. + * @source measured: Photos, iOS 26.2 simulator, iPhone 17 Pro, + * zoomScale and contentOffset per frame after double taps at the + * centre and near the edge of 3000 × 2000 and 2000 × 3000 photos, + * Oct 23 2026 + */ + doubleTapZoom: 'aspectFill', + /** + * The maximum zoom is this many times the zoom at which one image pixel + * covers one device pixel. That gives 14.93 for a 3000 × 2000 photo fitted + * 402 pt wide at 3×, and 9.95 for 2000 × 3000. + * @source measured: Photos, iOS 26.2 simulator, iPhone 17 Pro, the + * zoomScale a pinch settles on past the maximum, 3000 × 2000 and + * 2000 × 3000 photos, Oct 23 2026 + */ + maxPixelZoomMultiple: 6, + }, +} as const; + +/** + * Registers the zoom settle's three lengths as internal motion roles. + * Nothing registers on import: the zoom stage calls it before its first + * settle, and a second call does nothing. The settle's curve is the + * `timing.curves.move` role. + */ +export function registerZoomMediaTable(): void { + registerMotionTable('zoomMedia', zoomMedia, { + roles: ['settle.baseMs', 'settle.lnRatioMs', 'settle.maxMs'], + }); +} From 14f14d7af3ae88d1015a7fe0acee4767c618e52c Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 27 Oct 2026 23:48:21 -0700 Subject: [PATCH 02/13] feat(Lightbox): touch zoom like the iOS Photos viewer Lightbox's zoom moves to the gesture recognizers and the compositor animator. A pinch zooms about the fingers with the pan riding along, and it gives past the zoom limits and eases back about the last centroid. A finger's double tap zooms to the larger of filling the viewer and 2x, about the tapped point, over the theme's move easing and medium duration. A mouse's or a pen's double-click zooms 2x about the clicked point. In the touch presentation a zoomed pan has UIScrollView's rubber band, coast and edge bounce. A mouse's or a pen's drag pans 1:1 and stops at the edges and at the release, as before. The zoom takes every pointer: a ctrl-wheel or a trackpad pinch zooms, and a trackpad's scroll pans a zoomed photo; a mouse wheel's notches never pan. The photo's stage fills the viewer and takes the pinch; the controls and caption keep the page's pinch-zoom. Since a zoomed photo can now lie under Close, Previous, Next and the counter, a zoomable Lightbox sets them on its own translucent backing and keeps their icons and text on the on-dark colour, so they keep 3:1, and 4.5:1 for the counter's text, over any photo in every theme. The keyboard keeps its 2x zoom about the centre over the released 200 ms ease-out, and keyboard panning stops at the photo's edges. The double tap reads its tokens through the shared theme-timing reader in motion/themeTiming.ts. A theme that leaves --duration-medium unset gets Core's default duration there, not a zoom that lands at once. --- .changeset/lightbox-double-click-point.md | 7 + .changeset/lightbox-every-pointer.md | 7 + .changeset/lightbox-touch-zoom.md | 9 + .changeset/uikit-zoom-media.md | 2 +- apps/storybook/stories/Lightbox.stories.tsx | 42 + .../components/Lightbox/Lightbox.spec.md | 82 +- packages/core/src/Lightbox/Lightbox.doc.mjs | 7 +- packages/core/src/Lightbox/Lightbox.test.tsx | 767 +++++++++- packages/core/src/Lightbox/Lightbox.tsx | 588 +++++--- .../LightboxFinePointer.a11y.browser.spec.ts | 574 ++++++++ .../src/Lightbox/LightboxZoomResize.test.tsx | 62 + ...oxEveryPointer.interaction.browser.spec.ts | 291 ++++ .../LightboxZoom.interaction.browser.spec.ts | 1299 +++++++++++++++++ ...htboxZoomModes.interaction.browser.spec.ts | 608 ++++++++ packages/core/src/Lightbox/useZoomCore.ts | 486 ++++++ packages/core/src/Lightbox/useZoomStage.ts | 502 +++++++ packages/core/src/Lightbox/zoomStage.test.ts | 72 +- packages/core/src/Lightbox/zoomStage.ts | 67 +- .../src/hooks/useScrollLockImports.test.ts | 3 +- packages/core/src/motion/themeTiming.test.ts | 136 ++ packages/core/src/motion/themeTiming.ts | 87 ++ 21 files changed, 5428 insertions(+), 270 deletions(-) create mode 100644 .changeset/lightbox-double-click-point.md create mode 100644 .changeset/lightbox-every-pointer.md create mode 100644 .changeset/lightbox-touch-zoom.md create mode 100644 packages/core/src/Lightbox/LightboxFinePointer.a11y.browser.spec.ts create mode 100644 packages/core/src/Lightbox/LightboxZoomResize.test.tsx create mode 100644 packages/core/src/Lightbox/__tests__/LightboxEveryPointer.interaction.browser.spec.ts create mode 100644 packages/core/src/Lightbox/__tests__/LightboxZoom.interaction.browser.spec.ts create mode 100644 packages/core/src/Lightbox/__tests__/LightboxZoomModes.interaction.browser.spec.ts create mode 100644 packages/core/src/Lightbox/useZoomCore.ts create mode 100644 packages/core/src/Lightbox/useZoomStage.ts create mode 100644 packages/core/src/motion/themeTiming.test.ts create mode 100644 packages/core/src/motion/themeTiming.ts diff --git a/.changeset/lightbox-double-click-point.md b/.changeset/lightbox-double-click-point.md new file mode 100644 index 0000000000000..e490269ff42f8 --- /dev/null +++ b/.changeset/lightbox-double-click-point.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] Lightbox: a double-click now zooms 2× about the clicked point instead of about the centre, and the next goes back; the keyboard keeps its 2× zoom about the centre. + +@vjeux diff --git a/.changeset/lightbox-every-pointer.md b/.changeset/lightbox-every-pointer.md new file mode 100644 index 0000000000000..f345d5effa7ce --- /dev/null +++ b/.changeset/lightbox-every-pointer.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] Lightbox: a ctrl-wheel or a trackpad pinch now zooms a photo, and a trackpad's scroll pans a zoomed photo. + +@vjeux diff --git a/.changeset/lightbox-touch-zoom.md b/.changeset/lightbox-touch-zoom.md new file mode 100644 index 0000000000000..b49f15b6fab16 --- /dev/null +++ b/.changeset/lightbox-touch-zoom.md @@ -0,0 +1,9 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] Lightbox's zoom now works under a finger the way the iOS Photos viewer does, on a phone, a tablet or a touchscreen laptop. Pinch the photo to zoom around your fingers, and move them to pan while you zoom. Past the zoom limits the photo gives way, then eases back when you let go. Double-tap zooms until the photo fills the screen, at least 2×, and keeps the tapped point under your finger; double-tap again to go back. On a phone or a tablet, a zoomed photo pans with momentum, stretches past its edges and settles back. The keyboard keeps the zoom it had: 2× about the centre, over the same 200 ms ease-out, and keyboard panning now stops at the photo's edges instead of carrying it out of view. + +The photo's stage fills the viewer. A pinch on the photo no longer zooms the page, and a pinch on the controls or caption still does. With `hasZoom` on, a tap on the dark area beside the photo closes the viewer after the double-tap wait instead of at once; a click still closes it at once. The zoom animations run on the compositor, so they stay smooth while the page is busy. With `hasZoom` off, nothing changes. With `hasZoom`, Close, Previous, Next and the counter sit on a dark translucent backing, so they stay readable over a zoomed photo. + +@vjeux diff --git a/.changeset/uikit-zoom-media.md b/.changeset/uikit-zoom-media.md index c51eb7d7a9b1a..3896f0733b298 100644 --- a/.changeset/uikit-zoom-media.md +++ b/.changeset/uikit-zoom-media.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: the UIKit constants table gains UIScrollView zooming for a photo viewer, measured on iOS 26.2. It holds the give past the zoom limits, the settle's eased curve and length law, and Photos' aspect-fill double tap and maximum zoom. The zoom stage geometry that Lightbox's touch zoom is built on comes with it. Neither is exported from the package entry point, and no component reads them yet. +[chore] Internal: the UIKit constants table gains UIScrollView zooming for a photo viewer, measured on iOS 26.2. It holds the give past the zoom limits, the settle's eased curve and length law, and Photos' aspect-fill double tap and maximum zoom. The zoom stage geometry that Lightbox's touch zoom is built on comes with it. Neither is exported from the package entry point, and Lightbox's touch zoom reads them. @vjeux diff --git a/apps/storybook/stories/Lightbox.stories.tsx b/apps/storybook/stories/Lightbox.stories.tsx index e3a6b88af81f4..840c9bdd7fb59 100644 --- a/apps/storybook/stories/Lightbox.stories.tsx +++ b/apps/storybook/stories/Lightbox.stories.tsx @@ -104,6 +104,48 @@ export const WithZoom: Story = { }, }; +/** Zoom on a photo with a caption, which sits under the fitted photo. */ +export const ZoomWithCaption: Story = { + render: () => { + const [isOpen, setIsOpen] = useState(false); + return ( + <> + + + + ); + }, +}; + +export const GalleryWithZoom: Story = { + render: () => { + const [isOpen, setIsOpen] = useState(false); + const [index, setIndex] = useState(0); + 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 3ee33ad3fbd4d..a4e868e36dba0 100644 --- a/docs/specs/components/Lightbox/Lightbox.spec.md +++ b/docs/specs/components/Lightbox/Lightbox.spec.md @@ -51,7 +51,8 @@ same actions. presentation (FR5–FR10) and the touch exit (FR16) are the default (`spec:AST-094` FR13). Under a fine pointer a mouse or pen drag pages and closes the viewer, a ctrl-wheel or trackpad pinch over the photo zooms the - photo instead of the page, a wheel pans a zoomed photo, and a double-click + photo instead of the page, a trackpad's scroll pans a zoomed photo, and a + double-click zooms 2× about the clicked point instead of the centre (FR11). Preserved: a click on the photo keeps the controls, the viewer opens at once under every pointer and closes at once under a fine pointer, keyboard zoom stays 2× about @@ -78,15 +79,11 @@ Consumer migration instructions belong in consumer docs and release notes. The shipped Lightbox does not yet meet every requirement below. Each gap names the requirement it misses and the person it affects. -- **Touch presentation (FR5–FR10, FR12).** On a touch screen a pinch zooms the - page instead of the photo, a double tap zooms 2× about the centre, and there - is no swipe paging, drag or pinch to close, tap to hide the controls, or - thumbnail transition. +- **Touch presentation (FR7–FR10, FR12).** On a touch screen there is no swipe + paging, drag or pinch to close, tap to hide the controls, or thumbnail + transition. - **Tab containment (FR4).** Tab from the viewer's last control can move focus to the browser's own interface. -- **Single-pointer pan (AR2, FR11).** A double-click zooms 2× about the centre, - not about the clicked point, so a single-pointer user can reach a zoomed - photo's edge only by dragging. - **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 @@ -95,10 +92,12 @@ the requirement it misses and the person it affects. are sent from above the modal Lightbox renders, so they reach the page body and a screen-reader user does not hear them while the viewer is open (`spec:AST-086` FR14). -- **Lightbox-owned pan listeners (FR14).** The pan drag registers Lightbox's - own `window` pointer listeners instead of using the shared touch modules. -- **Fine-pointer gestures (FR11).** A mouse drag on a fitted photo does - nothing, a ctrl-wheel zooms the page, and a wheel does not pan a zoomed photo. +- **Fine-pointer gestures (FR11).** FR11's drag paging (FR7) and drag to + close (FR8) are not built for a mouse, a pen or a trackpad: a drag on a + fitted photo does nothing. +- **Double-click listener (FR14).** A mouse's or a pen's double-click reaches + the zoom through the stage's own `dblclick` listener, since only the browser + knows the system's double-click speed. - **Touch exit (FR16).** On a touch screen the viewer disappears at once when it closes. @@ -143,7 +142,7 @@ FR21). Consumer props and usage remain documented in `Lightbox.doc.mjs`. | 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` FR36–FR40 | 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 wheel to pan a zoomed photo; 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 | +| 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, on the theme's duration and easing tokens (`spec:AST-094` FR45), and returns into it when it closes, on the theme's exit duration and easing (`spec:AST-094` FR7): 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 | @@ -162,6 +161,14 @@ FR4, DEC-5). They are internal, so no theme overrides them. | `zoomMedia.settle.lnRatioMs` | 300 ms | The length added for each unit of the zoom ratio's log | | `zoomMedia.settle.maxMs` | 400 ms | The zoom settle's longest length | +A finger's double-tap zoom is tap-started motion, so it plays the theme's move +easing and medium duration (`--duration-medium`, `--ease-move`), per +`spec:AST-094` FR45. Its measured value stays a reference: + +| Motion | iOS measures | Storybook theme's nearest duration token | +| ------------------------------- | ------------ | ---------------------------------------- | +| A finger's double-tap zoom (2×) | about 308 ms | `--duration-medium`, 300 ms | + ### Allowed variation - **AV1 — Media mode.** Media may be an image or video; zoom, pan, drag down to @@ -249,6 +256,14 @@ under `spec:AST-020` FR1; reduced motion cites `design:reduced-motion`. never covers it. - **AR8 — The page keeps its own zoom (WCAG 2.2 1.4.4).** A pinch on the controls or the caption zooms the page, so their text stays resizable (FR6). +- **AR9 — The controls keep their contrast over a zoomed photo (WCAG 2.2 + 1.4.3, 1.4.11).** With `hasZoom`, a photo can lie under Close, Previous, + Next, and the Counter, so each sits on a translucent backing: the buttons + keep 3:1 and the Counter's text 4.5:1 over any photo content beneath them. + In a zoomable Lightbox, Close, Previous, Next and the Counter sit on + Lightbox's own backing, and their icons and text use the on-dark colour, so + a theme's restyling of these controls can't remove their contrast over a + photo. Without `hasZoom`, the controls have no backing. ## Design relationships @@ -326,26 +341,27 @@ a decision that they must remain unthemeable. ## Verification map -| 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` | -| 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` | -| AR3 | `Lightbox.test.tsx` "Lightbox unmounting while open" suite | Viewer rendered only while open and removed by the page, with the opener still on the page; a page control that removes it keeps its focus; StrictMode | Focus left on the page body after an open viewer unmounts fails. | `audit:Lightbox/accessibility` | -| FR5–FR10 | Interaction tests on synthetic touch streams with explicit timestamps; Reduce Motion; a busy main thread | Below and past each commit point; a release moving back; a drag on a video's frame and on its scrubber; a tap beside the photo with the controls hidden | A literal timing, a second decision during a settle, a gesture that pages while pinching, a drag on a video's frame that does not page, a drag on its controls that pages, or a tap beside the photo that closes while the controls are hidden fails. | `audit:Lightbox/touch` | -| FR11 | Fine-pointer browser tests | Double-click, click on the photo, drag, ctrl-wheel, wheel | A double-click that does not zoom 2× about the point, a click that hides the controls, or a fine-pointer close that animates 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` | -| 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` | -| AR1, AR2 | Browser tests that complete every gesture's action with one pointer and no drag | Zoom, page, close, reach a zoomed photo's edge | An action reachable only by a multipoint, path, or drag gesture fails. | `audit:Lightbox/accessibility` | -| AR4 | Accessibility-tree and announcement tests | Open, page by button, key, and swipe, zoom by every input | A missing dialog name or `alt`, a repeated or missing position announcement, a message not exposed inside the open viewer, or a zoom without its level fails. | `audit:Lightbox/accessibility` | -| AR5, AR7 | Browser tests with the controls hidden | Tab, assistive-technology focus, a key press, keyboard zoom | A hidden control that is unfocusable, absent from the tree, or still hidden when focused fails. | `audit:Lightbox/accessibility` | -| AR6 | Reduced-motion tests under a coarse and a fine pointer | Transition, page settle, dismiss, pinch close, controls fade | Travel, scaling, or a cross-fade over 200 ms under reduced motion fails. | `audit:Lightbox/accessibility` | -| AR8 | Touch test pinching on the controls and on the caption | Single image, gallery | A pinch on the controls or caption that does not zoom the page fails. | `audit:Lightbox/accessibility` | -| Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and current target | Missing, extra, prefixed, stale, or multiply assigned mappings fail repository validation. | `audit:Lightbox/theming` | +| 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` | +| 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` | +| AR3 | `Lightbox.test.tsx` "Lightbox unmounting while open" suite | Viewer rendered only while open and removed by the page, with the opener still on the page; a page control that removes it keeps its focus; StrictMode | Focus left on the page body after an open viewer unmounts fails. | `audit:Lightbox/accessibility` | +| FR5–FR10 | Interaction tests on synthetic touch streams with explicit timestamps; Reduce Motion; a busy main thread | Below and past each commit point; a release moving back; a drag on a video's frame and on its scrubber; a tap beside the photo with the controls hidden | A literal timing, a second decision during a settle, a gesture that pages while pinching, a drag on a video's frame that does not page, a drag on its controls that pages, or a tap beside the photo that closes while the controls are hidden fails. | `audit:Lightbox/touch` | +| 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` | +| 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` | +| AR1, AR2 | Browser tests that complete every gesture's action with one pointer and no drag | Zoom, page, close, reach a zoomed photo's edge | An action reachable only by a multipoint, path, or drag gesture fails. | `audit:Lightbox/accessibility` | +| AR4 | Accessibility-tree and announcement tests | Open, page by button, key, and swipe, zoom by every input | A missing dialog name or `alt`, a repeated or missing position announcement, a message not exposed inside the open viewer, or a zoom without its level fails. | `audit:Lightbox/accessibility` | +| AR5, AR7 | Browser tests with the controls hidden | Tab, assistive-technology focus, a key press, keyboard zoom | A hidden control that is unfocusable, absent from the tree, or still hidden when focused fails. | `audit:Lightbox/accessibility` | +| AR6 | Reduced-motion tests under a coarse and a fine pointer | Transition, page settle, dismiss, pinch close, controls fade | Travel, scaling, or a cross-fade over 200 ms under reduced motion fails. | `audit:Lightbox/accessibility` | +| AR8 | Touch test pinching on the controls and on the caption | Single image, gallery | A pinch on the controls or caption that does not zoom the page fails. | `audit:Lightbox/accessibility` | +| AR9 | Touch test zooming a white photo under the controls and measuring their contrast in every shipped theme, butter's recoloured buttons and gothic's hovered Next included | Gallery with zoom: Close, Previous, Next, and the Counter, in each shipped theme | A control under 3:1, or the Counter's text under 4.5:1, over the zoomed light photo fails. | `audit:Lightbox/accessibility` | +| Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and current target | Missing, extra, prefixed, stale, or multiply assigned mappings fail repository validation. | `audit:Lightbox/theming` | ## Decision log diff --git a/packages/core/src/Lightbox/Lightbox.doc.mjs b/packages/core/src/Lightbox/Lightbox.doc.mjs index 44e9cc55bcd82..65d06ec9af390 100644 --- a/packages/core/src/Lightbox/Lightbox.doc.mjs +++ b/packages/core/src/Lightbox/Lightbox.doc.mjs @@ -98,7 +98,7 @@ export const docs = { name: 'hasZoom', type: 'boolean', description: - 'Enable zoom on double-click, or Enter/Space/+/- via keyboard (images only). When zoomed, drag or use arrow keys to pan.', + 'Enable zoom (images only): pinch (or ctrl-wheel), or double-tap or double-click to zoom in at that point and back; Enter/Space/+/- zoom 2× about the centre and back. When zoomed, drag or scroll a trackpad to pan, or use arrow keys; on a phone or a tablet, the pan has momentum.', default: 'false', }, { @@ -211,7 +211,8 @@ export const docsZh = { { name: 'hasZoom', type: 'boolean', - description: '启用双击缩放(仅图片)。缩放后可拖动平移。', + description: + '启用缩放(仅图片):双指捏合(或按住 Ctrl 滚动滚轮),或双点按、双击在该处放大并还原;Enter/Space/+/- 键以中心放大 2 倍并还原。缩放后可拖动或在触控板上滚动平移,或用方向键平移;在手机或平板上,平移带惯性。', default: 'false', }, { @@ -296,7 +297,7 @@ export const docsDense = { index: 'Current index in gallery mode.', onIndexChange: 'Callback when gallery index changes.', hasZoom: - 'Enable zoom (double-click, Enter/Space, or +/-) and pan (drag or arrow keys) for images.', + 'Enable zoom (pinch or ctrl-wheel, double-tap or double-click, Enter/Space, or +/-) and pan (drag, trackpad scroll, or arrow keys) for images.', xstyle: 'StyleX styles for layout customization.', }, }; diff --git a/packages/core/src/Lightbox/Lightbox.test.tsx b/packages/core/src/Lightbox/Lightbox.test.tsx index c4b5d7e35df8c..75e88c05a7caa 100644 --- a/packages/core/src/Lightbox/Lightbox.test.tsx +++ b/packages/core/src/Lightbox/Lightbox.test.tsx @@ -1,7 +1,7 @@ // Copyright (c) Meta Platforms, Inc. and affiliates. import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; -import {render, screen, fireEvent, waitFor} from '@testing-library/react'; +import {act, render, screen, fireEvent, waitFor} from '@testing-library/react'; import {Profiler, StrictMode, useState} from 'react'; import {Lightbox} from './Lightbox'; import {__resetLiveRegionsForTest} from '../hooks/useAnnounce'; @@ -29,6 +29,74 @@ function politeRegion(): HTMLElement | null { return document.querySelector('[data-astryx-live-region="polite"]'); } +/** + * jsdom has no layout, so give the zoom stage what a browser measures. The + * stage is 400 × 600, with a landscape photo fitted 400 × 200 at its vertical + * centre. Filling the stage zooms that photo 3×. Returns the undo. + */ +function stubStageLayout(): () => void { + const undo: (() => void)[] = []; + const define = ( + proto: object, + key: string, + get: (element: HTMLElement) => number, + ) => { + const original = Object.getOwnPropertyDescriptor(proto, key); + Object.defineProperty(proto, key, { + configurable: true, + get(this: HTMLElement) { + return get(this); + }, + }); + undo.push(() => { + if (original) { + Object.defineProperty(proto, key, original); + } else { + Reflect.deleteProperty(proto, key); + } + }); + }; + const isPhoto = (element: Element) => element instanceof HTMLImageElement; + const isStage = (element: Element) => + element.querySelector(':scope > img') != null; + define(HTMLElement.prototype, 'offsetWidth', element => + isPhoto(element) ? 400 : 0, + ); + define(HTMLElement.prototype, 'offsetHeight', element => + isPhoto(element) ? 200 : 0, + ); + define(Element.prototype, 'clientWidth', element => + isStage(element) ? 400 : 0, + ); + define(Element.prototype, 'clientHeight', element => + isStage(element) ? 600 : 0, + ); + return () => undo.reverse().forEach(restore => restore()); +} + +/** A phone: the primary pointer is coarse, so the touch layer is on. Returns the undo. */ +function stubCoarsePointer(): () => void { + const original = window.matchMedia; + window.matchMedia = (query: string) => ({ + matches: query === '(pointer: coarse)', + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + }); + return () => { + window.matchMedia = original; + }; +} + +/** The photo's inline `translate` and `scale`, as the zoom stage wrote them. */ +function photoTransform(img: HTMLElement): {translate: string; scale: string} { + return {translate: img.style.translate, scale: img.style.scale}; +} + // Both position messages, supplied by the test. Overriding the pair means an // assertion also proves which of the two keys the component reached for. const POSITION_MESSAGES = { @@ -526,6 +594,13 @@ describe('Lightbox', () => { {src: '/b.jpg', alt: 'Image B'}, {src: '/c.jpg', alt: 'Image C'}, ]; + let restoreLayout: () => void = () => {}; + beforeEach(() => { + restoreLayout = stubStageLayout(); + }); + afterEach(() => { + restoreLayout(); + }); function zoomTarget(): HTMLElement { return screen.getByRole('button', {name: 'Zoom'}); @@ -611,24 +686,53 @@ describe('Lightbox', () => { const dialog = document.querySelector('dialog')!; fireEvent.keyDown(zoomTarget(), {key: 'Enter'}); const img = screen.getByAltText('Image B'); - expect(img.getAttribute('style') ?? '').toContain('translate(0px, 0px)'); + // Lightbox's released zoom: 2× about the stage's centre, so 800 × 400 + // with its centre on the stage's centre. Filling the stage would take + // 3× for this photo. + expect(photoTransform(img)).toEqual({ + translate: '-200px -100px', + scale: '2 2', + }); // ArrowRight reveals content to the right (image shifts left) and must // not fall through to gallery navigation. fireEvent.keyDown(dialog, {key: 'ArrowRight'}); expect(onIndexChange).not.toHaveBeenCalled(); - expect(img.getAttribute('style') ?? '').toContain( - 'translate(-25px, 0px)', - ); + expect(photoTransform(img).translate).toBe('-250px -100px'); + // At 400 px the photo is shorter than the 600 px stage, so it stays + // centred vertically. fireEvent.keyDown(dialog, {key: 'ArrowDown'}); - expect(img.getAttribute('style') ?? '').toContain( - 'translate(-25px, -25px)', - ); + expect(photoTransform(img).translate).toBe('-250px -100px'); fireEvent.keyDown(dialog, {key: 'ArrowLeft'}); fireEvent.keyDown(dialog, {key: 'ArrowUp'}); - expect(img.getAttribute('style') ?? '').toContain('translate(0px, 0px)'); + expect(photoTransform(img).translate).toBe('-200px -100px'); expect(onIndexChange).not.toHaveBeenCalled(); }); + it('stops panning at the edges of the photo', () => { + render( + {}} + media={{src: '/photo.jpg', alt: 'Photo'}} + hasZoom + />, + ); + const dialog = document.querySelector('dialog')!; + fireEvent.keyDown(dialog, {key: '+'}); + const img = screen.getByAltText('Photo'); + for (let i = 0; i < 12; i++) { + fireEvent.keyDown(dialog, {key: 'ArrowLeft'}); + } + // The photo's left edge rests on the stage's left edge, no gap: on main + // the arrows could carry the photo out of view. + expect(photoTransform(img).translate).toBe('0px -100px'); + for (let i = 0; i < 24; i++) { + fireEvent.keyDown(dialog, {key: 'ArrowRight'}); + } + // 800 px wide on a 400 px stage: its right edge on the stage's. + expect(photoTransform(img).translate).toBe('-400px -100px'); + }); + it('navigates the gallery with arrows when not zoomed, even with hasZoom', () => { const onIndexChange = vi.fn(); render( @@ -664,9 +768,7 @@ describe('Lightbox', () => { const img = screen.getByAltText('Image B'); // ArrowRight still reveals what is to the right of the photo. fireEvent.keyDown(dialog, {key: 'ArrowRight'}); - expect(img.getAttribute('style') ?? '').toContain( - 'translate(-25px, 0px)', - ); + expect(photoTransform(img).translate).toBe('-250px -100px'); expect(onIndexChange).not.toHaveBeenCalled(); }); @@ -746,6 +848,37 @@ describe('Lightbox', () => { }); describe('backdrop dismiss', () => { + it('closes on a click on the strip a raised caption leaves below the stage', () => { + const onOpenChange = vi.fn(); + render( + , + ); + // 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}); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('without zoom, leaves a click beside a narrower caption alone', () => { + const onOpenChange = vi.fn(); + render( + , + ); + // The media group hugs its media and caption, as before. + fireEvent.click(screen.getByText('A caption').parentElement!); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + it('calls onOpenChange(false) when the dark area around the media is clicked', () => { const onOpenChange = vi.fn(); render( @@ -779,7 +912,610 @@ describe('Lightbox', () => { }); }); +describe('Lightbox gestures for a mouse and a pen (spec:AST-094 FR43)', () => { + // Gestures attach for every pointer. A mouse's or a pen's double-click + // zooms 2×, and a zoomed photo pans once the pan begins. + let restoreLayout: () => void = () => {}; + beforeEach(() => { + restoreLayout = stubStageLayout(); + }); + afterEach(() => { + restoreLayout(); + }); + + /** A pointer event as a browser sends it, at `time` (ms). */ + function pointer( + type: 'pointerdown' | 'pointermove' | 'pointerup', + target: Element, + pointerType: 'mouse' | 'pen', + x: number, + y: number, + time: number, + ): void { + const event = new Event(type, {bubbles: true, cancelable: true}); + const fields: Record = { + pointerType, + pointerId: pointerType === 'pen' ? 7 : 1, + isPrimary: true, + button: 0, + buttons: type === 'pointerup' ? 0 : 1, + clientX: x, + clientY: y, + timeStamp: time, + }; + for (const [key, value] of Object.entries(fields)) { + Object.defineProperty(event, key, {value}); + } + act(() => { + target.dispatchEvent(event); + }); + } + + /** Two clicks `gapMs` apart, then the browser's dblclick. */ + function doubleClick( + target: Element, + pointerType: 'mouse' | 'pen', + x: number, + y: number, + gapMs = 80, + ): void { + const t0 = performance.now(); + pointer('pointerdown', target, pointerType, x, y, t0); + pointer('pointerup', target, pointerType, x, y, t0 + 40); + pointer('pointerdown', target, pointerType, x, y, t0 + 40 + gapMs); + pointer('pointerup', target, pointerType, x, y, t0 + 80 + gapMs); + act(() => { + target.dispatchEvent( + new MouseEvent('dblclick', {bubbles: true, clientX: x, clientY: y}), + ); + }); + } + + /** A finger's double tap, on a phone (a coarse primary pointer). */ + function fingerDoubleTap(target: Element, x: number, y: number): void { + const t0 = performance.now(); + const at = (type: string, down: boolean, time: number) => { + const event = new Event(type, {bubbles: true, cancelable: true}); + const entry = {identifier: 1, clientX: x, clientY: y, target}; + Object.defineProperty(event, 'touches', {value: down ? [entry] : []}); + Object.defineProperty(event, 'changedTouches', {value: [entry]}); + Object.defineProperty(event, 'timeStamp', {value: time}); + act(() => { + target.dispatchEvent(event); + }); + }; + at('touchstart', true, t0); + at('touchend', false, t0 + 40); + at('touchstart', true, t0 + 120); + at('touchend', false, t0 + 160); + } + + function renderPhoto() { + const view = render( + {}} + media={{src: '/photo.jpg', alt: 'Photo'}} + hasZoom + />, + ); + return { + view, + stage: screen.getByRole('button', {name: 'Zoom'}), + img: screen.getByAltText('Photo'), + }; + } + + it('zooms a mouse double-click 2× about the clicked point, and back', async () => { + const {stage, img} = renderPhoto(); + // Filling the viewer would take 3×. The clicked photo point stays under + // the pointer, and the photo stays centred on the axis it fits. + doubleClick(stage, 'mouse', 20, 220); + expect(photoTransform(img)).toEqual({ + translate: '-20px -100px', + scale: '2 2', + }); + expect(stage).toHaveAttribute('aria-pressed', 'true'); + // The next double-click reads where the photo shows: let the zoom land. + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 400)); + }); + doubleClick(stage, 'mouse', 20, 220); + expect(stage).toHaveAttribute('aria-pressed', 'false'); + }); + + it('zooms a slow double-click too, as the browser reports it', () => { + const {stage, img} = renderPhoto(); + // 600 ms between the clicks: past UIKit's 350 ms tap interval, inside a + // system's slower double-click setting. + doubleClick(stage, 'mouse', 20, 220, 600); + expect(photoTransform(img)).toEqual({ + translate: '-20px -100px', + scale: '2 2', + }); + }); + + it("zooms a finger's double tap to fill the viewer, about the tapped point", () => { + const restoreCoarse = stubCoarsePointer(); + try { + const {stage, img} = renderPhoto(); + fingerDoubleTap(stage, 20, 220); + expect(photoTransform(img)).toEqual({ + translate: '-40px -200px', + scale: '3 3', + }); + } finally { + restoreCoarse(); + } + }); + + it('zooms a pen double-click 2× too', () => { + const {stage, img} = renderPhoto(); + doubleClick(stage, 'pen', 200, 300); + expect(photoTransform(img).scale).toBe('2 2'); + expect(stage).toHaveAttribute('aria-pressed', 'true'); + }); + + it('pans a zoomed photo under a mouse drag from its first move, with the grab cursor', async () => { + const {stage, img} = renderPhoto(); + fireEvent.keyDown(document.querySelector('dialog')!, {key: '+'}); + // A drag starts where the photo shows: let the 200 ms zoom land first. + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 400)); + }); + const from = photoTransform(img).translate; + const t0 = performance.now(); + pointer('pointerdown', stage, 'mouse', 200, 300, t0); + const [fromX, fromY] = from.split(' ').map(parseFloat); + const moved = () => { + const [x, y] = photoTransform(img).translate.split(' ').map(parseFloat); + return {x: x - fromX, y: y - fromY}; + }; + // A finger's 10 px begin threshold does not apply: 2 px already pans. + pointer('pointermove', stage, 'mouse', 198, 300, t0 + 16); + expect(moved().x).toBeCloseTo(-2, 5); + expect(moved().y).toBeCloseTo(0, 5); + expect(stage).toHaveAttribute('data-zoom-dragging'); + pointer('pointermove', stage, 'mouse', 170, 300, t0 + 32); + expect(moved().x).toBeCloseTo(-30, 5); + pointer('pointerup', stage, 'mouse', 170, 300, t0 + 48); + expect(stage).not.toHaveAttribute('data-zoom-dragging'); + }); + + it('leaves the viewer open on a caption click after a zoomed drag from above the photo', async () => { + const onOpenChange = vi.fn(); + render( + , + ); + const dialog = document.querySelector('dialog')!; + const stage = screen.getByRole('button', {name: 'Zoom'}); + fireEvent.keyDown(dialog, {key: '+'}); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 400)); + }); + // A zoomed drag from above the fitted photo: its own click is ignored. + const t0 = performance.now(); + pointer('pointerdown', stage, 'mouse', 200, 50, t0); + pointer('pointermove', stage, 'mouse', 180, 50, t0 + 16); + pointer('pointerup', stage, 'mouse', 180, 50, t0 + 32); + fireEvent.click(stage, {clientX: 180, clientY: 50, detail: 1}); + fireEvent.keyDown(dialog, {key: '-'}); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 400)); + }); + // Each caption click reads its own press, not the drag's. + const caption = screen.getByText('A caption'); + for (const at of [t0 + 1000, t0 + 2000]) { + pointer('pointerdown', caption, 'mouse', 200, 300, at); + pointer('pointerup', caption, 'mouse', 200, 300, at + 40); + fireEvent.click(caption, {clientX: 200, clientY: 300, detail: 1}); + } + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('starts no pan for a mouse drag on a fitted photo, so its click above the photo closes', () => { + const onOpenChange = vi.fn(); + render( + , + ); + const stage = screen.getByRole('button', {name: 'Zoom'}); + // From the photo's middle to above it, far past a finger's threshold. + const t0 = performance.now(); + pointer('pointerdown', stage, 'mouse', 200, 300, t0); + pointer('pointermove', stage, 'mouse', 200, 250, t0 + 16); + pointer('pointermove', stage, 'mouse', 200, 50, t0 + 32); + pointer('pointerup', stage, 'mouse', 200, 50, t0 + 48); + fireEvent.click(stage, {clientX: 200, clientY: 50, detail: 1}); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('leaves the viewer open on a mouse click on the zoomed photo above its fitted box', async () => { + const onOpenChange = vi.fn(); + render( + , + ); + const dialog = document.querySelector('dialog')!; + const stage = screen.getByRole('button', {name: 'Zoom'}); + fireEvent.keyDown(dialog, {key: '+'}); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 400)); + }); + // The zoomed photo spreads over the stage, past its fitted box. + const img = screen.getByAltText('Photo'); + img.getBoundingClientRect = () => ({ + x: -200, + y: -100, + left: -200, + top: -100, + right: 600, + bottom: 300, + width: 800, + height: 400, + toJSON: () => ({}), + }); + const t0 = performance.now(); + pointer('pointerdown', stage, 'mouse', 200, 50, t0); + pointer('pointerup', stage, 'mouse', 200, 50, t0 + 40); + fireEvent.click(stage, {clientX: 200, clientY: 50, detail: 1}); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('closes on the first click above the photo after a zoomed drag, as main does', async () => { + const onOpenChange = vi.fn(); + render( + , + ); + const dialog = document.querySelector('dialog')!; + const stage = screen.getByRole('button', {name: 'Zoom'}); + fireEvent.keyDown(dialog, {key: '+'}); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 400)); + }); + // A zoomed drag, whose own click the stage keeps. + const t0 = performance.now(); + pointer('pointerdown', stage, 'mouse', 200, 300, t0); + pointer('pointermove', stage, 'mouse', 180, 300, t0 + 16); + 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}); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); + +describe('Lightbox double-click under the touch layer', () => { + let restoreLayout: () => void = () => {}; + const originalMatchMedia = window.matchMedia; + beforeEach(() => { + restoreLayout = stubStageLayout(); + window.matchMedia = (query: string) => ({ + matches: query === '(pointer: coarse)', + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + }); + }); + afterEach(() => { + restoreLayout(); + window.matchMedia = originalMatchMedia; + }); + + it("leaves a finger's double-click to the touch layer's recognizers", () => { + render( + {}} + media={{src: '/photo.jpg', alt: 'Photo'}} + hasZoom + />, + ); + const stage = screen.getByRole('button', {name: 'Zoom'}); + const down = new Event('pointerdown', {bubbles: true, cancelable: true}); + Object.defineProperty(down, 'pointerType', {value: 'touch'}); + Object.defineProperty(down, 'button', {value: 0}); + act(() => { + stage.dispatchEvent(down); + stage.dispatchEvent( + new MouseEvent('dblclick', {bubbles: true, clientX: 200, clientY: 300}), + ); + }); + expect(stage).toHaveAttribute('aria-pressed', 'false'); + }); +}); + +describe('Lightbox touch zoom (synthetic touches)', () => { + let restoreLayout: () => void = () => {}; + const originalMatchMedia = window.matchMedia; + beforeEach(() => { + restoreLayout = stubStageLayout(); + // A phone: the primary pointer is coarse, so the touch layer is on. + window.matchMedia = (query: string) => ({ + matches: query === '(pointer: coarse)', + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + }); + }); + afterEach(() => { + restoreLayout(); + window.matchMedia = originalMatchMedia; + }); + + interface Contact { + id: number; + x: number; + y: number; + } + + /** A touch event as a browser sends it: who is down, and who changed. */ + function touch( + type: 'touchstart' | 'touchmove' | 'touchend', + target: Element, + down: Contact[], + changed: Contact[], + time: number, + ): void { + const event = new Event(type, {bubbles: true, cancelable: true}); + const entry = (contact: Contact) => ({ + identifier: contact.id, + clientX: contact.x, + clientY: contact.y, + target, + }); + Object.defineProperty(event, 'touches', {value: down.map(entry)}); + Object.defineProperty(event, 'changedTouches', {value: changed.map(entry)}); + Object.defineProperty(event, 'timeStamp', {value: time}); + act(() => { + target.dispatchEvent(event); + }); + } + + /** 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. + const first = vi.fn(); + const second = vi.fn(); + const photo = {src: '/photo.jpg', alt: 'Photo'}; + const view = render( + , + ); + view.rerender( + , + ); + const stage = screen.getByRole('button', {name: 'Zoom'}); + // Above the photo, which the stage centres at its fitted size. + const finger: Contact = {id: 6, x: 200, y: 50}; + const t0 = performance.now(); + touch('touchstart', stage, [finger], [finger], t0); + touch('touchend', stage, [], [finger], t0 + 40); + // A single tap waits out a double tap's interval. + await waitFor(() => expect(second).toHaveBeenCalledWith(false)); + expect(first).not.toHaveBeenCalled(); + }); + + it.each([ + ['a fitted photo', false], + ['a zoomed photo', true], + ])( + 'ignores a finger’s first tap on the strip after a finger drag on %s, as before', + async (_, isZoomedFirst) => { + const onOpenChange = vi.fn(); + render( + , + ); + const stage = screen.getByRole('button', {name: 'Zoom'}); + if (isZoomedFirst) { + fireEvent.keyDown(document.querySelector('dialog')!, {key: '+'}); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 400)); + }); + } + // A finger drag on the photo, which fires no click. + let finger: Contact = {id: 5, x: 200, y: 300}; + touch('touchstart', stage, [finger], [finger], 1000); + for (let i = 1; i <= 6; i++) { + finger = {...finger, x: finger.x - 10}; + 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}); + }; + // The first tap on the strip below the stage only clears the flag. + tapStrip(); + expect(onOpenChange).not.toHaveBeenCalled(); + tapStrip(); + expect(onOpenChange).toHaveBeenCalledWith(false); + }, + ); + + it('keeps panning with the finger left down after a pinch, without a jump', () => { + render( + {}} + media={{src: '/photo.jpg', alt: 'Photo'}} + hasZoom + />, + ); + const stage = screen.getByRole('button', {name: 'Zoom'}); + const img = screen.getByAltText('Photo'); + const x = () => parseFloat(photoTransform(img).translate || '0'); + // Two fingers land on the photo, spread (100 → 140 px) and drift left + // together (the midpoint 200 → 180). + let a: Contact = {id: 1, x: 150, y: 300}; + let b: Contact = {id: 2, x: 250, y: 300}; + touch('touchstart', stage, [a], [a], 0); + touch('touchstart', stage, [a, b], [b], 0); + for (let i = 1; i <= 10; i++) { + a = {...a, x: 150 - 4 * i}; + b = {...b, x: 250}; + touch('touchmove', stage, [a, b], [a, b], i * 16); + } + const scaled = parseFloat(photoTransform(img).scale); + expect(scaled).toBeGreaterThan(1.2); + // The right finger lifts: nothing moves. + const beforeLift = x(); + touch('touchend', stage, [a], [b], 11 * 16); + expect(x()).toBeCloseTo(beforeLift, 6); + // The left finger drags on 40 px: the photo follows it 1:1, zoom kept. + for (let i = 1; i <= 8; i++) { + a = {...a, x: a.x - 5}; + touch('touchmove', stage, [a], [a], (11 + i) * 16); + } + expect(x()).toBeCloseTo(beforeLift - 40, 6); + expect(parseFloat(photoTransform(img).scale)).toBeCloseTo(scaled, 6); + touch('touchend', stage, [], [a], 20 * 16); + }); + + it("takes a caption off the photo from a pinch's first move", () => { + render( + {}} + media={{src: '/photo.jpg', alt: 'Photo', caption: 'A caption'}} + hasZoom + />, + ); + 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'); + let a: Contact = {id: 1, x: 150, y: 300}; + let b: Contact = {id: 2, x: 250, y: 300}; + touch('touchstart', stage, [a], [a], 0); + touch('touchstart', stage, [a, b], [b], 0); + for (let i = 1; i <= 4; i++) { + a = {...a, x: 150 - 6 * i}; + 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. + expect( + parseFloat(photoTransform(screen.getByAltText('Photo')).scale), + ).toBeGreaterThan(1); + expect(caption.style.translate).toBe(''); + touch('touchend', stage, [a], [b], 5 * 16); + touch('touchend', stage, [], [a], 6 * 16); + }); + + it('frees the pan once a pinch that outlived its photo ends', async () => { + const lightbox = (src: string) => ( + {}} + media={{src, alt: 'Photo'}} + hasZoom + /> + ); + const view = render(lightbox('/first.jpg')); + const stage = screen.getByRole('button', {name: 'Zoom'}); + // Two fingers pinch the first photo, and another photo arrives while + // they are down. Then they lift. + let a: Contact = {id: 1, x: 150, y: 300}; + const b: Contact = {id: 2, x: 250, y: 300}; + touch('touchstart', stage, [a], [a], 0); + touch('touchstart', stage, [a, b], [b], 0); + for (let i = 1; i <= 6; i++) { + a = {...a, x: 150 - 4 * i}; + touch('touchmove', stage, [a, b], [a, b], i * 16); + } + view.rerender(lightbox('/second.jpg')); + touch('touchend', stage, [a], [b], 7 * 16); + touch('touchend', stage, [], [a], 8 * 16); + // A double tap zooms the new photo, and a drag then pans it 1:1. + const tapAt = {id: 3, x: 200, y: 300}; + touch('touchstart', stage, [tapAt], [tapAt], 400); + touch('touchend', stage, [], [tapAt], 440); + touch('touchstart', stage, [tapAt], [tapAt], 520); + touch('touchend', stage, [], [tapAt], 560); + const img = screen.getByAltText('Photo'); + expect(parseFloat(photoTransform(img).scale)).toBeGreaterThan(1.5); + // The drag reads where the photo shows: let the zoom land. + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 400)); + }); + const x = () => parseFloat(photoTransform(img).translate || '0'); + const before = x(); + let finger: Contact = {id: 4, x: 200, y: 300}; + touch('touchstart', stage, [finger], [finger], 900); + for (let i = 1; i <= 8; i++) { + finger = {...finger, x: finger.x - 5}; + touch('touchmove', stage, [finger], [finger], 900 + i * 16); + } + expect(x()).toBeLessThan(before - 20); + touch('touchend', stage, [], [finger], 900 + 9 * 16); + }); +}); + describe('Lightbox zoom reset timing', () => { + let restoreLayout: () => void = () => {}; + beforeEach(() => { + restoreLayout = stubStageLayout(); + }); + afterEach(() => { + restoreLayout(); + }); + it('shows the next image unzoomed in the same commit that changes it', () => { const media = [ {src: '/a.jpg', alt: 'Image A'}, @@ -814,6 +1550,13 @@ describe('Lightbox zoom reset timing', () => { 'aria-pressed', 'false', ); + // The photo element is reused for the next image, and it is back at the + // fitted size before that image paints. An animator at rest at the + // identity leaves the element's own style. + expect(photoTransform(screen.getByAltText('Image B'))).toEqual({ + translate: '', + scale: '', + }); }); }); diff --git a/packages/core/src/Lightbox/Lightbox.tsx b/packages/core/src/Lightbox/Lightbox.tsx index 3a8a39ec3143d..e1cba0aa2088e 100644 --- a/packages/core/src/Lightbox/Lightbox.tsx +++ b/packages/core/src/Lightbox/Lightbox.tsx @@ -5,13 +5,17 @@ /** * @file Lightbox.tsx * @input Uses React, native dialog, StyleX, IconButton, theme tokens, layerTextReset, layerStructureReset, - * isRtlElement (gallery arrow keys follow the rendered direction) + * and isRtlElement (gallery arrow keys follow the rendered direction). The + * zoom uses useZoomCore and useZoomStage * @output Exports Lightbox component, LightboxProps, LightboxMedia * @position Core implementation; consumed by index.ts * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/Lightbox/Lightbox.doc.mjs (props table, features, implementation notes) * - /packages/core/src/Lightbox/Lightbox.test.tsx (tests for new/changed behavior) + * - /packages/core/src/Lightbox/useZoomCore.ts (the zoom stage for every pointer) + * - /packages/core/src/Lightbox/useZoomStage.ts (the gestures, for every pointer) + * - /packages/core/src/Lightbox/__tests__/LightboxZoom.interaction.browser.spec.ts (touch zoom proofs) * - /packages/core/src/Lightbox/index.ts (exports if types change) * - /packages/core/src/Layer/useLayerDismissal.ts (dismissal stack) * - /apps/storybook/stories/Lightbox.stories.tsx (storybook stories) @@ -25,15 +29,26 @@ import { useRef, useState, type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, + type ComponentProps, type ReactNode, } from 'react'; import * as stylex from '@stylexjs/stylex'; -import {colorVars, spacingVars, typeScaleVars} from '../theme/tokens.stylex'; +import type {StyleXStyles} from '@stylexjs/stylex'; +import { + colorVars, + focusVars, + radiusVars, + spacingVars, + typeScaleVars, +} from '../theme/tokens.stylex'; import {Icon} from '../Icon'; import {IconButton} from '../IconButton'; import {useAnnounce} from '../hooks/useAnnounce'; import {useScrollLock} from '../hooks/useScrollLock'; import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; +import {useAttachment} from '../hooks/useAttachment'; +import {observeResize} from '../utils/sharedResizeObserver'; import {mergeProps, rtlStyles} from '../utils'; import type {BaseProps} from '../BaseProps'; import {themeProps} from '../utils/themeProps'; @@ -49,6 +64,9 @@ import {useLayerDismissal} from '../Layer/useLayerDismissal'; import {useMergedRefs} from '../hooks/useMergedRefs'; import {isRtlElement} from '../hooks/isRtlElement'; +import {useZoomCore} from './useZoomCore'; +import {useZoomStage} from './useZoomStage'; +import {useTouchPresentation} from '../utils/touchPresentation'; import { registerEnglishFallback, useComponentTranslator, @@ -112,8 +130,12 @@ export interface LightboxProps extends BaseProps { */ onIndexChange?: (index: number) => void; /** - * Enable zoom on double-click, or Enter/Space/`+`/`-` via keyboard - * (images only). When zoomed, drag or use arrow keys to pan. + * Enable zoom (images only). A pinch or a ctrl-wheel zooms about the + * fingers or the pointer. A double tap fills the viewer and a double-click + * zooms 2×, about that point, and the next goes back. Enter, Space, `+` and + * `-` zoom 2× about the centre and back. When zoomed, drag or scroll a + * trackpad to pan, or use the arrow keys. On a phone or a tablet, the pan + * has momentum. The gestures work for every pointer. * @default false */ hasZoom?: boolean; @@ -128,6 +150,9 @@ export interface LightboxProps extends BaseProps { // Styles // ============================================================================= +/** Set on the stage while a mouse or a pen hovers over the fitted photo. */ +const OVER_PHOTO_ATTRIBUTE = 'data-lightbox-over-photo'; + const styles = stylex.create({ dialog: { position: 'fixed', @@ -168,6 +193,28 @@ const styles = stylex.create({ 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, + }, + // The filling stage's group clips, so its focus ring sits inside its edge. + imageWrapperFocusInset: { + outlineOffset: { + default: '0', + ':focus-visible': `calc(-1 * ${focusVars['--focus-outline-width']})`, + }, + }, imageWrapper: { display: 'flex', alignItems: 'center', @@ -177,22 +224,27 @@ const styles = stylex.create({ 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', + }, + // 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: { cursor: { - default: 'zoom-in', - '@media (hover: hover)': 'zoom-in', + default: 'default', + ':is([data-lightbox-over-photo])': 'zoom-in', ':is(:disabled,[aria-disabled="true"])': 'default', }, }, imageWrapperZoomed: { cursor: { default: 'grab', - ':is(:disabled,[aria-disabled="true"])': 'default', - }, - }, - imageWrapperDragging: { - cursor: { - default: 'grabbing', + // ZOOM_DRAGGING_ATTRIBUTE, set by the stage while a drag moves the photo. + ':is([data-zoom-dragging])': 'grabbing', ':is(:disabled,[aria-disabled="true"])': 'default', }, }, @@ -201,15 +253,9 @@ const styles = stylex.create({ maxHeight: '100%', objectFit: 'contain', pointerEvents: 'none', - transitionProperty: 'transform', - transitionDuration: { - default: '200ms', - '@media (prefers-reduced-motion: reduce)': '0ms', - }, - transitionTimingFunction: 'ease-out', - }, - imageDragging: { - transitionProperty: 'none', + // The zoom stage scales the photo about its top-left corner and moves it + // with the individual `scale` and `translate` properties. + transformOrigin: '0 0', }, video: { maxWidth: '100%', @@ -237,11 +283,10 @@ const styles = stylex.create({ navButton: { position: 'absolute', top: '50%', - // The individual `translate` property, not `transform`: this style now - // lands on the Button root, and a `transform` here replaces the Button's - // own transform rules — measured: the `scale(0.98)` press feedback stops - // firing. `translate` composes with them, reproducing exactly what the - // removed wrapper element did (wrapper translated, button scaled). + // The individual `translate` property, not `transform`. This style lands + // on the Button root, or on its backing with zoom. A `transform` on the + // Button root would replace its own transform rules: measured, the + // `scale(0.98)` press feedback stops firing. `translate` composes with them. translate: '0 -50%', zIndex: 1, }, @@ -263,12 +308,22 @@ const styles = stylex.create({ controlButton: { color: colorVars['--color-on-dark'], }, -}); - -const dynamicStyles = stylex.create({ - imageTransform: (transform: string) => ({ - transform, - }), + // 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. + controlBacking: { + display: 'inline-flex', + 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'], + }, }); /** @@ -288,13 +343,18 @@ const KEYBOARD_PAN_OFFSETS: Record = { /** * A fullscreen overlay for viewing images at full resolution. * - * Supports single image and gallery modes. In gallery mode, provides - * prev/next navigation via buttons and arrow keys; the arrow keys follow - * the writing direction (in RTL, ArrowLeft goes to the next item, like the - * Next button on the left). Optionally supports - * zoom (double-click, Enter/Space on the image, or `+`/`-` to toggle 2x) - * and pan (drag or arrow keys when zoomed; arrows navigate the gallery - * when not zoomed). + * Supports single image and gallery modes. In gallery mode, it provides + * prev/next navigation via buttons and arrow keys. The arrow keys follow the + * writing direction: in RTL, ArrowLeft goes to the next item, like the Next + * button on the left. + * + * Optionally supports zoom like a photo viewer. A pinch or a ctrl-wheel zooms + * about the fingers or the pointer, gives past the limits and eases back. A + * double tap or a double-click zooms in at that point and back. Enter or + * Space on the image, and `+` or `-` from the keyboard, zoom too. When zoomed, + * a drag or a trackpad's scroll pans. On a phone or a tablet, the pan has + * momentum and a rubber band at the edges. The arrow keys pan a zoomed photo + * and navigate the gallery otherwise. * * Uses the native `` element with `showModal()` for focus * trapping and top-layer placement. Dismiss via Escape, close button, @@ -360,11 +420,10 @@ export function Lightbox({ [isControlled, onIndexChange, setUncontrolledIndex], ); - // Zoom/pan state - const [zoom, setZoom] = useState(1); - const [pan, setPan] = useState({x: 0, y: 0}); - const [isDragging, setIsDragging] = useState(false); - const dragStartRef = useRef({x: 0, y: 0, panX: 0, panY: 0}); + // Whether the photo is zoomed: a discrete state for aria-pressed and the + // cursor. The zoom and pan themselves live outside React (useZoomCore, + // useZoomStage). + const [isZoomed, setIsZoomed] = useState(false); // Resolve current media item const mediaArray = useMemo( @@ -385,7 +444,7 @@ export function Lightbox({ useScrollLock(isOpen); // A new media item starts unzoomed. Adjusted during render, so the new item - // never paints at the previous item's zoom and pan. + // never paints as zoomed; the stage resets the photo before it paints. const currentSrc = currentItem?.src; const [viewedItem, setViewedItem] = useState({index, src: currentSrc}); // Object.is, matching the dependency comparison of the effect this replaces: @@ -395,8 +454,7 @@ export function Lightbox({ !Object.is(viewedItem.src, currentSrc) ) { setViewedItem({index, src: currentSrc}); - setZoom(1); - setPan({x: 0, y: 0}); + setIsZoomed(false); } // Announce gallery navigation to screen readers. Moving between images only @@ -500,23 +558,89 @@ export function Lightbox({ [handleClose, shouldDismissOnCloseRequest], ); - // Backdrop click. The layout container fills the whole transparent dialog, - // so clicks on the visual backdrop (the dark area around the media) land on - // the container, never on the dialog element itself — treat both as the - // backdrop. A pan drag that ends over the backdrop still fires a click on - // the common ancestor; ignore it so releasing a drag doesn't dismiss. + // The last press's pointer type. A finger's double tap is the stage's own. + // A mouse's or a pen's is the browser's `dblclick`. + const pointerTypeRef = useRef(''); + // Without zoom, a click on the dark area lands on the container or the + // dialog, and both close. With zoom, the stage covers that area. A finger's + // tap beside the photo closes on the stage's own tap, and so does its tap on + // the media group's strip below the 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. const didDragRef = useRef(false); + const mediaGroupRef = useRef(null); + const captionRef = useRef(null); + const pressPointRef = useRef<{x: number; y: number} | null>(null); + // Whether a mouse's or a pen's click on the zoom target's media closes the + // viewer. It does when its press or its release lands outside the media. + // 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. + const isClickOutsideMedia = useCallback((e: ReactMouseEvent) => { + const stageElement = imageWrapperRef.current; + const photo = stageElement?.querySelector('img'); + const group = mediaGroupRef.current; + if ( + e.detail === 0 || + pointerTypeRef.current === 'touch' || + stageElement == null || + photo == null || + group == null || + !(e.target instanceof Node) || + !group.contains(e.target) + ) { + return false; + } + const space = (stageElement.clientHeight - photo.offsetHeight) / 2; + const box = stageElement.getBoundingClientRect(); + const top = box.top + space; + let bottom = top + photo.offsetHeight; + const caption = captionRef.current; + 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 width = Math.min( + stageElement.clientWidth, + Math.max(photo.naturalWidth, caption?.offsetWidth ?? 0), + ); + const middle = box.left + stageElement.clientWidth / 2; + const shown = photo.getBoundingClientRect(); + const isOutside = ({x, y}: {x: number; y: number}) => + (y < top || y > bottom || Math.abs(x - middle) > width / 2) && + !( + x >= shown.left && + x <= shown.right && + y >= shown.top && + y <= shown.bottom + ); + const press = pressPointRef.current; + return ( + isOutside({x: e.clientX, y: e.clientY}) || + (press != null && isOutside(press)) + ); + }, []); const handleBackdropClick = useCallback( (e: ReactMouseEvent) => { if (didDragRef.current) { didDragRef.current = false; return; } - if (e.target === e.currentTarget || e.target === containerRef.current) { + if ( + e.target === e.currentTarget || + e.target === containerRef.current || + (hasZoom && + !isVideo && + (pointerTypeRef.current === 'touch' + ? e.target === mediaGroupRef.current + : isClickOutsideMedia(e))) + ) { handleClose(); } }, - [handleClose], + [handleClose, hasZoom, isVideo, isClickOutsideMedia], ); // Gallery navigation @@ -532,29 +656,87 @@ export function Lightbox({ } }, [canNext, index, setIndex]); - // Zoom: double-click, Enter/Space on the image, or +/- keys toggle 1x ↔ 2x. - // Zoom changes are silent to assistive tech (only the transform changes), so - // mirror them in the polite live region, including a hint that arrow keys - // pan while zoomed. - const applyZoom = useCallback( - (next: number) => { - if (!hasZoom || isVideo || next === zoom) { - return; - } - setZoom(next); - setPan({x: 0, y: 0}); + 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: imageWrapperRef, + itemKey: viewedItem, + // Zoom changes are silent to assistive tech, since only the transform + // changes. Announce them in the polite live region. + onZoomedChange: next => { + setIsZoomed(next); announce( - next > 1 - ? t('@astryx.lightbox.zoomedIn') - : t('@astryx.lightbox.zoomedOut'), + next ? t('@astryx.lightbox.zoomedIn') : t('@astryx.lightbox.zoomedOut'), ); }, - [hasZoom, isVideo, zoom, announce, t], - ); - - const handleDoubleClick = useCallback(() => { - applyZoom(zoom === 1 ? 2 : 1); - }, [applyZoom, zoom]); + onFitChange: isAtFit => { + isShownAtFitRef.current = isAtFit; + placeCaption(); + }, + }); + const {isTouch} = useTouchPresentation(); + const stage = useZoomStage(zoom, { + stageRef: imageWrapperRef, + isEnabled: isOpen && isZoomTarget, + onTapOutside: handleClose, + onDragStart, + isTouchPresentation: isTouch, + }); + // Whether a mouse or a pen hovers over the fitted photo, for its cursor. The + // stage fills the viewer, so the photo's box is read, not the event target. + // The attribute is written on the stage, so hovering renders nothing. + const markPointerOverPhoto = (e: ReactPointerEvent) => { + if (e.pointerType === 'touch' || e.buttons !== 0) { + return; + } + const r = e.currentTarget.querySelector('img')?.getBoundingClientRect(); + e.currentTarget.toggleAttribute( + OVER_PHOTO_ATTRIBUTE, + r != null && + e.clientX >= r.left && + e.clientX <= r.right && + e.clientY >= r.top && + e.clientY <= r.bottom, + ); + }; // Keyboard navigation. While zoomed, arrows pan the image (matching common // lightbox conventions); when not zoomed they navigate the gallery. @@ -566,21 +748,21 @@ export function Lightbox({ // its right. const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - if (hasZoom && !isVideo) { + if (isZoomTarget) { if (e.key === '+' || e.key === '=') { e.preventDefault(); - applyZoom(2); + stage.zoomIn(); return; } if (e.key === '-') { e.preventDefault(); - applyZoom(1); + stage.zoomOut(); return; } - if (zoom > 1 && KEYBOARD_PAN_OFFSETS[e.key] !== undefined) { + if (stage.isZoomed() && KEYBOARD_PAN_OFFSETS[e.key] !== undefined) { e.preventDefault(); const [dx, dy] = KEYBOARD_PAN_OFFSETS[e.key]; - setPan(prev => ({x: prev.x + dx, y: prev.y + dy})); + stage.panBy(dx, dy); return; } } @@ -595,7 +777,7 @@ export function Lightbox({ } } }, - [hasZoom, isVideo, zoom, applyZoom, goToPrev, goToNext], + [isZoomTarget, stage, goToPrev, goToNext], ); // Enter/Space on the focused image wrapper (role="button") toggles zoom. @@ -603,68 +785,62 @@ export function Lightbox({ (e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - handleDoubleClick(); - } - }, - [handleDoubleClick], - ); - - // Pan: mouse drag when zoomed - const handlePointerDown = useCallback( - (e: ReactMouseEvent) => { - if (zoom <= 1 || !hasZoom) { - return; + stage.toggle(); } - setIsDragging(true); - didDragRef.current = false; - dragStartRef.current = { - x: e.clientX, - y: e.clientY, - panX: pan.x, - panY: pan.y, - }; }, - [zoom, hasZoom, pan], + [stage], ); - useEffect(() => { - if (!isDragging) { - return; - } - - const handlePointerMove = (e: PointerEvent) => { - didDragRef.current = true; - const dx = e.clientX - dragStartRef.current.x; - const dy = e.clientY - dragStartRef.current.y; - setPan({ - x: dragStartRef.current.panX + dx, - y: dragStartRef.current.panY + dy, - }); - }; - - const handlePointerUp = () => { - setIsDragging(false); - }; - - window.addEventListener('pointermove', handlePointerMove); - window.addEventListener('pointerup', handlePointerUp); - return () => { - window.removeEventListener('pointermove', handlePointerMove); - window.removeEventListener('pointerup', handlePointerUp); - }; - }, [isDragging]); - - const isZoomed = zoom > 1; - const isZoomTarget = hasZoom && !isVideo; - const imageTransform = - zoom === 1 - ? null - : `scale(${zoom}) translate(${pan.x / zoom}px, ${pan.y / zoom}px)`; - if (!currentItem) { return null; } + // A control of the viewer. With zoom it sits on Lightbox's own backing, + // which takes the control's place. Its icon keeps the on-dark colour. So a + // theme's restyling of the button can't remove its contrast. + const control = ( + place: StyleXStyles, + { + icon, + label, + isDisabled, + onClick, + isMirrored = false, + }: { + icon: ComponentProps['icon']; + label: string; + isDisabled?: boolean; + onClick: () => void; + isMirrored?: boolean; + }, + ) => { + const button = ( + + } + label={label} + variant="ghost" + isDisabled={isDisabled} + onClick={onClick} + xstyle={[!hasZoom && place, styles.controlButton]} + /> + ); + return hasZoom ? ( + {button} + ) : ( + button + ); + }; + return ( -
- {/* Close button */} - } - label={t('@astryx.lightbox.close')} - variant="ghost" - onClick={handleClose} - xstyle={[styles.closeButton, styles.controlButton]} - /> +
{ + 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, + })} {/* 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 && ( - - } - label={t('@astryx.lightbox.previous')} - variant="ghost" - isDisabled={!canPrev} - onClick={goToPrev} - xstyle={[ - styles.navButton, - styles.navPrev, - styles.controlButton, - ]} - /> - )} + {isGallery && + control([styles.navButton, styles.navPrev], { + icon: 'chevronLeft', + label: t('@astryx.lightbox.previous'), + isDisabled: !canPrev, + onClick: goToPrev, + isMirrored: true, + })} {/* Media + caption group (centered together) */} -
+
+ onPointerMove={ + isZoomTarget ? markPointerOverPhoto : undefined + } + onPointerLeave={ + isZoomTarget + ? e => + 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 + }> {isVideo ? (
{isRenderable(currentItem.caption) && ( -
+
{currentItem.caption}
)} @@ -791,31 +984,22 @@ export function Lightbox({ {/* Gallery nav: next — see "prev" above; stays mounted and disabled at the end of the range instead of unmounting. */} - {isGallery && ( - - } - label={t('@astryx.lightbox.next')} - variant="ghost" - isDisabled={!canNext} - onClick={goToNext} - xstyle={[ - styles.navButton, - styles.navNext, - styles.controlButton, - ]} - /> - )} + {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}
)} diff --git a/packages/core/src/Lightbox/LightboxFinePointer.a11y.browser.spec.ts b/packages/core/src/Lightbox/LightboxFinePointer.a11y.browser.spec.ts new file mode 100644 index 0000000000000..bb9420ee93592 --- /dev/null +++ b/packages/core/src/Lightbox/LightboxFinePointer.a11y.browser.spec.ts @@ -0,0 +1,574 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file LightboxFinePointer.a11y.browser.spec.ts + * @input The built Lightbox "with zoom" story, a desktop browser (a fine + * primary pointer) and real mouse input; in Chromium, pen input + * @output Browser evidence that the zoom's gestures attach for every + * pointer where the primary pointer is fine too. The zoom target takes its + * touches as on a phone. A mouse's double-click zooms 2× about the clicked + * point, and the next zooms back; a pen's double-click zooms 2× too. The + * keyboard's zoom plays the released 200 ms ease-out, a pen drag pans a + * zoomed photo, and a mouse drag stops at the photo's edges + * @position Cross-browser proof for Lightbox's mouse, pen and keyboard zoom + * where the primary pointer is fine + */ + +import {expect, test, type Page} from '@astryxdesign/a11y-spec/playwright'; +import { + DEFAULT_STORYBOOK_DIR, + serveStorybook, + type StaticServer, +} from '@astryxdesign/a11y-spec/storybook'; + +let storybook: StaticServer; +test.beforeAll(async () => { + storybook = await serveStorybook( + process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR, + ); +}); +test.afterAll(async () => { + await storybook?.close(); +}); + +const PHOTO = {width: 3000, height: 2000}; + +/** A landscape test photo: a checker with a red disc at its centre. */ +function photoSvg(width: number, height: number): string { + return ``; +} + +interface Box { + x: number; + y: number; + width: number; + height: number; +} + +/** Opens the story's viewer with the mouse and waits for the photo. */ +async function openViewer(page: Page) { + await page.route('https://picsum.photos/**', async route => + route.fulfill({ + contentType: 'image/svg+xml', + body: photoSvg(PHOTO.width, PHOTO.height), + }), + ); + await page.goto( + `${storybook.origin}/iframe.html?id=core-lightbox--with-zoom&viewMode=story`, + ); + await page.getByRole('button', {name: 'Open with zoom'}).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + const stage = dialog.getByRole('button', {name: 'Zoom'}); + const img = dialog.getByRole('img', {name: 'Forest path'}); + await expect + .poll(async () => img.evaluate(el => (el as HTMLImageElement).naturalWidth)) + .toBe(PHOTO.width); + await settled(page); + const fitted = await boxOf(img); + const stageBox = await boxOf(stage); + return {dialog, stage, img, fitted, stageBox}; +} + +async function boxOf(locator: { + evaluate: (fn: (el: Element) => R) => Promise; +}): Promise { + return locator.evaluate(el => { + const r = el.getBoundingClientRect(); + return {x: r.x, y: r.y, width: r.width, height: r.height}; + }); +} + +/** Waits until the photo's box holds still for six frames (at most 3 s). */ +async function settled(page: Page): Promise { + await page.evaluate( + async () => + new Promise(resolve => { + const img = document.querySelector('dialog img'); + const started = performance.now(); + let last = ''; + let still = 0; + const tick = (now: number) => { + const r = img?.getBoundingClientRect(); + const key = r ? `${r.x},${r.y},${r.width},${r.height}` : ''; + still = key === last ? still + 1 : 0; + last = key; + if (still >= 6 || now - started > 3000) { + resolve(); + } else { + requestAnimationFrame(tick); + } + }; + requestAnimationFrame(tick); + }), + ); +} + +function centre(box: Box) { + return {x: box.x + box.width / 2, y: box.y + box.height / 2}; +} + +test("the zoom target's focus ring sits inside the stage, which a clipping group holds", async ({ + page, +}) => { + const {stage} = await openViewer(page); + await stage.focus(); + await page.keyboard.press('Shift+Tab'); + await page.keyboard.press('Tab'); + await expect(stage).toBeFocused(); + 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, + }; + }); + expect(ring.clips).toBe('hidden'); + expect(ring.width).toBeGreaterThan(0); + // The whole ring is drawn inside the stage's box. + expect(ring.offset).toBeLessThanOrEqual(-ring.width); +}); + +test('a caption sits under the fitted photo, as it does without zoom', async ({ + page, +}) => { + // A 3:1 panorama leaves the filling stage most of its height free. + await page.route('https://picsum.photos/**', async route => + route.fulfill({ + contentType: 'image/svg+xml', + body: photoSvg(3000, 1000), + }), + ); + await page.goto( + `${storybook.origin}/iframe.html?id=core-lightbox--zoom-with-caption&viewMode=story`, + ); + await page.getByRole('button', {name: 'Open zoom with caption'}).click(); + const dialog = page.getByRole('dialog'); + const img = dialog.getByRole('img', {name: 'Forest path'}); + 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 stage = dialog.getByRole('button', {name: 'Zoom'}); + const stageBox = await boxOf(stage); + 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); +}); + +test('the zoom target takes its touches as on a phone where the primary pointer is fine too', async ({ + page, +}) => { + const {stage} = await openViewer(page); + // Gestures attach for every pointer. A touchscreen laptop's finger pinches + // and pans the photo as on a phone, so the zoom target claims its touches, + // as under a coarse pointer. + expect(await stage.evaluate(el => getComputedStyle(el).touchAction)).toBe( + 'none', + ); +}); + +test('a mouse double-click zooms 2× about the clicked point, and the next zooms back', async ({ + page, +}) => { + const {stage, img, fitted, stageBox} = await openViewer(page); + // A quarter of the way in from the photo's top-left corner. + const click = { + x: fitted.x + fitted.width / 4, + y: fitted.y + fitted.height / 4, + }; + await page.mouse.dblclick(click.x, click.y); + await expect(stage).toHaveAttribute('aria-pressed', 'true'); + await settled(page); + const zoomed = await boxOf(img); + expect(zoomed.width / fitted.width).toBeCloseTo(2, 2); + // The clicked photo point stays under the pointer on each axis where the + // zoomed photo overflows the stage. At least one axis does. + const overflowsX = zoomed.width > stageBox.width + 1; + const overflowsY = zoomed.height > stageBox.height + 1; + expect(overflowsX || overflowsY).toBe(true); + if (overflowsX) { + expect(zoomed.x + (click.x - fitted.x) * 2).toBeCloseTo(click.x, 0); + } + if (overflowsY) { + expect(zoomed.y + (click.y - fitted.y) * 2).toBeCloseTo(click.y, 0); + } + await page.mouse.dblclick(centre(stageBox).x, centre(stageBox).y); + await expect(stage).toHaveAttribute('aria-pressed', 'false'); + await settled(page); + const back = await boxOf(img); + expect(back.width).toBeCloseTo(fitted.width, 1); + expect(back.x).toBeCloseTo(fitted.x, 1); +}); + +/** + * Samples the photo's width every frame from the first `startEvent` on, + * while `act` zooms it 2×, and checks Lightbox's released 200 ms ease-out. + */ +async function expectReleasedZoom( + page: Page, + fitted: Box, + startEvent: 'keydown' | 'dblclick', + act: () => Promise, +): Promise { + // The sampler is in place before the input, through an awaited install. A + // sampler the page gets later would wait for an input that already came. + await page.evaluate(type => { + (window as unknown as {__samples: Promise<[number, number][]>}).__samples = + new Promise<[number, number][]>(resolve => { + const img = document.querySelector('dialog img'); + const samples: [number, number][] = []; + let start: number | null = null; + window.addEventListener( + type, + () => { + start = performance.now(); + }, + {capture: true, once: true}, + ); + const tick = (now: number) => { + if (start != null && img != null) { + samples.push([now - start, img.getBoundingClientRect().width]); + } + if (start == null || now - start < 800) { + requestAnimationFrame(tick); + } else { + resolve(samples); + } + }; + requestAnimationFrame(tick); + }); + }, startEvent); + await act(); + const samples = await page.evaluate( + async () => + (window as unknown as {__samples: Promise<[number, number][]>}).__samples, + ); + const to = fitted.width * 2; + const progress = (width: number) => + (width - fitted.width) / (to - fitted.width); + // Timed from the last frame before the photo moved. An engine can start + // the animation a frame or more after the input, more on a busy runner. + // The start-up is not the curve. + const first = samples.findIndex(([, width]) => progress(width) > 0.001); + expect(first).toBeGreaterThanOrEqual(0); + const origin = first > 0 ? samples[first - 1][0] : 0; + // Progress 100 ms in, interpolated between the frames around it. CSS + // `ease-out` over 200 ms is past 0.6 there (0.69 at its middle). + // UIScrollView's eased settle over its ~0.31 s is near 0.25 there. + const after = samples.findIndex(([t]) => t - origin >= 100); + expect(after).toBeGreaterThan(0); + const [t0, w0] = samples[after - 1]; + const [t1, w1] = samples[after]; + const at100 = progress(w0 + ((w1 - w0) * (origin + 100 - t0)) / (t1 - t0)); + expect(at100).toBeGreaterThan(0.5); + // It ends within the 200 ms and a frame or two. + const doneAt = samples.find(([, width]) => progress(width) > 0.995)?.[0]; + expect(doneAt).toBeDefined(); + expect((doneAt ?? Number.POSITIVE_INFINITY) - origin).toBeLessThan(250); +} + +test('the keyboard’s zoom plays the released 200 ms ease-out', async ({ + page, +}) => { + const {stage, fitted} = await openViewer(page); + // The keyboard keeps Lightbox's released zoom: Enter zooms 2× about the + // centre. + await expectReleasedZoom(page, fitted, 'keydown', async () => { + await stage.focus(); + await page.keyboard.press('Enter'); + }); +}); + +test('a mouse double-click plays the released 200 ms ease-out too', async ({ + page, +}) => { + const {stageBox, fitted} = await openViewer(page); + const middle = centre(stageBox); + await expectReleasedZoom(page, fitted, 'dblclick', async () => { + await page.mouse.dblclick(middle.x, middle.y); + }); +}); + +test('a pen double-click zooms 2× too', async ({page, browserName}) => { + test.skip( + browserName !== 'chromium', + 'Pen input is dispatched through the Chrome DevTools Protocol.', + ); + const {stage, img, fitted, stageBox} = await openViewer(page); + const cdp = await page.context().newCDPSession(page); + const {x, y} = centre(stageBox); + await cdp.send('Input.dispatchMouseEvent', { + type: 'mouseMoved', + x, + y, + pointerType: 'pen', + }); + for (const clickCount of [1, 2]) { + for (const type of ['mousePressed', 'mouseReleased'] as const) { + await cdp.send('Input.dispatchMouseEvent', { + type, + x, + y, + button: 'left', + clickCount, + pointerType: 'pen', + }); + } + } + await expect(stage).toHaveAttribute('aria-pressed', 'true'); + await settled(page); + expect((await boxOf(img)).width / fitted.width).toBeCloseTo(2, 2); +}); + +test('a pen drag pans a zoomed photo through the stage’s pan', async ({ + page, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + 'Pen input is dispatched through the Chrome DevTools Protocol.', + ); + const {stage, img, stageBox} = await openViewer(page); + const middle = centre(stageBox); + // 2x about the centre, with room to pan either way. + await stage.focus(); + await page.keyboard.press('+'); + await expect(stage).toHaveAttribute('aria-pressed', 'true'); + await settled(page); + const before = await boxOf(img); + const cdp = await page.context().newCDPSession(page); + const pen = async ( + type: 'mouseMoved' | 'mousePressed' | 'mouseReleased', + x: number, + pressed: boolean, + ) => + cdp.send('Input.dispatchMouseEvent', { + type, + x, + y: middle.y, + pointerType: 'pen', + button: type === 'mouseMoved' && !pressed ? 'none' : 'left', + buttons: pressed ? 1 : 0, + clickCount: type === 'mouseMoved' ? 0 : 1, + }); + await pen('mouseMoved', middle.x, false); + await pen('mousePressed', middle.x, true); + for (let i = 1; i <= 8; i++) { + await pen('mouseMoved', middle.x - i * 10, true); + } + await pen('mouseReleased', middle.x - 80, false); + await settled(page); + const after = await boxOf(img); + // The stage's pan takes the pen and follows it from the first move. + expect(after.x - before.x).toBeLessThanOrEqual(-70); +}); + +test('a mouse drag stops at the photo’s edges instead of carrying it out of view', async ({ + page, +}) => { + const {stage, img, stageBox} = await openViewer(page); + const middle = centre(stageBox); + await page.mouse.dblclick(middle.x, middle.y); + await expect(stage).toHaveAttribute('aria-pressed', 'true'); + await settled(page); + await page.mouse.move(middle.x, middle.y); + await page.mouse.down(); + await page.mouse.move(middle.x + 1500, middle.y + 900, {steps: 12}); + await page.mouse.up(); + await settled(page); + const dragged = await boxOf(img); + // The photo's left edge stops on the stage's left edge. It still covers the + // stage's width, since 2× a fitted photo is wider than the stage. + expect(dragged.x).toBeLessThanOrEqual(stageBox.x + 0.5); + expect(dragged.x + dragged.width).toBeGreaterThanOrEqual( + stageBox.x + stageBox.width - 0.5, + ); + // Vertically it stays where a zoomed photo may rest: inside the stage + // where it is shorter, covering it where taller. + if (dragged.height <= stageBox.height) { + expect(dragged.y).toBeGreaterThanOrEqual(stageBox.y - 0.5); + expect(dragged.y + dragged.height).toBeLessThanOrEqual( + stageBox.y + stageBox.height + 0.5, + ); + } else { + expect(dragged.y).toBeLessThanOrEqual(stageBox.y + 0.5); + } +}); + +/** Clicks `point` with the mouse; true when the viewer is still open. */ +async function staysOpenAfterClick( + page: Page, + point: {x: number; y: number}, +): Promise { + await page.mouse.click(point.x, point.y); + await page.waitForTimeout(400); + return page.evaluate(() => document.querySelector('dialog')?.open === true); +} + +test('a mouse click beside the fitted photo, level with it, leaves the viewer open, as on main', async ({ + page, +}) => { + const {fitted, stageBox} = await openViewer(page); + // The fitted photo leaves dark stage on both sides. Main's media spans the + // viewer's width there, so a click beside the photo does not close. + expect(fitted.x - stageBox.x).toBeGreaterThan(40); + const level = fitted.y + fitted.height / 2; + expect(await staysOpenAfterClick(page, {x: stageBox.x + 20, y: level})).toBe( + true, + ); + expect( + await staysOpenAfterClick(page, { + x: fitted.x + fitted.width + 20, + y: level, + }), + ).toBe(true); +}); + +test('with zoom on, a mouse click on the photo leaves the viewer open and one below it closes, as on main', async ({ + page, +}) => { + await page.setViewportSize({width: 1280, height: 1000}); + const {fitted} = await openViewer(page); + expect(await staysOpenAfterClick(page, centre(fitted))).toBe(true); + const bottom = fitted.y + fitted.height; + expect(1000 - bottom).toBeGreaterThan(40); + expect( + await staysOpenAfterClick(page, { + x: fitted.x + fitted.width / 2, + y: (bottom + 1000) / 2, + }), + ).toBe(false); +}); + +test('after a 2× double-click, a mouse click on the zoomed photo above the fitted box leaves the viewer open', async ({ + page, +}) => { + await page.setViewportSize({width: 1280, height: 1000}); + const {stage, img, fitted, stageBox} = await openViewer(page); + expect(fitted.y - stageBox.y).toBeGreaterThan(40); + await page.mouse.dblclick(centre(fitted).x, centre(fitted).y); + await expect(stage).toHaveAttribute('aria-pressed', 'true'); + await settled(page); + // 30 px from the top: above the fitted box, on the zoomed photo. + const point = {x: centre(fitted).x, y: stageBox.y + 30}; + const zoomed = await boxOf(img); + expect(point.y).toBeLessThan(fitted.y); + expect(point.y).toBeGreaterThan(zoomed.y); + expect(await staysOpenAfterClick(page, point)).toBe(true); +}); + +test('a mouse press on the fitted photo released above it closes the viewer, as main’s click does', async ({ + page, +}) => { + await page.setViewportSize({width: 1280, height: 1000}); + const {fitted} = await openViewer(page); + expect(fitted.y).toBeGreaterThan(40); + const middle = centre(fitted); + await page.mouse.move(middle.x, middle.y); + await page.mouse.down(); + await page.mouse.move(middle.x, fitted.y / 2, {steps: 5}); + await page.mouse.up(); + await page.waitForTimeout(400); + expect( + await page.evaluate(() => document.querySelector('dialog')?.open === true), + ).toBe(false); +}); + +test('without zoom, a mouse click beside the photo leaves the viewer open and one above it closes, as on main', async ({ + page, +}) => { + await page.route('https://picsum.photos/**', async route => + route.fulfill({ + contentType: 'image/svg+xml', + body: photoSvg(PHOTO.width, PHOTO.height), + }), + ); + const open = async () => { + await page.goto( + `${storybook.origin}/iframe.html?id=core-lightbox--default&viewMode=story`, + ); + await page.getByRole('button', {name: 'Open lightbox'}).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + const img = page.getByRole('dialog').getByRole('img').first(); + await expect + .poll(async () => + img.evaluate(el => (el as HTMLImageElement).naturalWidth), + ) + .toBe(PHOTO.width); + await settled(page); + return boxOf(img); + }; + const fitted = await open(); + expect(fitted.x).toBeGreaterThan(40); + expect( + await staysOpenAfterClick(page, { + x: fitted.x / 2, + y: fitted.y + fitted.height / 2, + }), + ).toBe(true); + await page.setViewportSize({width: 1280, height: 1100}); + const tall = await open(); + expect(tall.y).toBeGreaterThan(40); + expect( + await staysOpenAfterClick(page, { + x: tall.x + tall.width / 2, + y: tall.y / 2, + }), + ).toBe(false); +}); + +test('in a gallery with zoom, a mouse click beside a small photo closes past its caption’s width and stays open within it, as on main', async ({ + page, +}) => { + // A photo narrower than the viewer: the media is as wide as its caption, + // which is wider than the photo here. + await page.setViewportSize({width: 1280, height: 1100}); + await page.route('https://picsum.photos/**', async route => + route.fulfill({contentType: 'image/svg+xml', body: photoSvg(200, 1200)}), + ); + await page.goto( + `${storybook.origin}/iframe.html?id=core-lightbox--gallery-with-zoom&viewMode=story`, + ); + await page.getByRole('button', {name: 'Open gallery with zoom'}).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + const img = dialog.getByRole('img').first(); + await expect + .poll(async () => img.evaluate(el => (el as HTMLImageElement).naturalWidth)) + .toBe(200); + await settled(page); + const photo = await boxOf(img); + // The caption follows the stage in the media group. + const caption = await dialog + .getByRole('button', {name: 'Zoom'}) + .evaluate(stage => { + const r = stage.nextElementSibling?.getBoundingClientRect(); + return r == null ? null : {x: r.x, width: r.width}; + }); + if (caption == null) { + throw new Error('no caption'); + } + const level = photo.y + photo.height / 2; + // Within the caption's width, beside the photo. + expect(caption.x).toBeLessThan(photo.x - 10); + expect( + await staysOpenAfterClick(page, {x: (caption.x + photo.x) / 2, y: level}), + ).toBe(true); + // Past the caption's width. + expect(caption.x).toBeGreaterThan(60); + expect(await staysOpenAfterClick(page, {x: caption.x - 30, y: level})).toBe( + false, + ); +}); diff --git a/packages/core/src/Lightbox/LightboxZoomResize.test.tsx b/packages/core/src/Lightbox/LightboxZoomResize.test.tsx new file mode 100644 index 0000000000000..ad063fceb3923 --- /dev/null +++ b/packages/core/src/Lightbox/LightboxZoomResize.test.tsx @@ -0,0 +1,62 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @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 + * @position Unit test for useZoomCore.ts's resize subscription + */ + +import {render, screen} from '@testing-library/react'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {observeResize} from '../utils/sharedResizeObserver'; +import type * as SharedResizeObserver from '../utils/sharedResizeObserver'; +import {Lightbox} from './Lightbox'; + +vi.mock('../utils/sharedResizeObserver', async importOriginal => { + const actual = await importOriginal(); + return {...actual, observeResize: vi.fn(actual.observeResize)}; +}); + +// The native dialog for jsdom, as Lightbox.test.tsx stubs it. +beforeEach(() => { + HTMLDialogElement.prototype.showModal = vi.fn(function ( + this: HTMLDialogElement, + ) { + this.setAttribute('open', ''); + }); + HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) { + this.removeAttribute('open'); + }); +}); + +describe('Lightbox zoom and resizes', () => { + it('subscribes its stage to resizes when it opens on photos that arrived after it mounted', () => { + const photos = [{src: '/photo.jpg', alt: 'Photo'}]; + // Mounted closed while its photos load, as a gallery page does. + const {rerender} = render( + {}} media={[]} hasZoom />, + ); + rerender( + {}} + media={photos} + hasZoom + />, + ); + 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. + const stage = screen.getByRole('button', {name: 'Zoom'}); + const observed = vi + .mocked(observeResize) + .mock.calls.map(([element]) => element); + expect(observed).toContain(stage); + }); +}); diff --git a/packages/core/src/Lightbox/__tests__/LightboxEveryPointer.interaction.browser.spec.ts b/packages/core/src/Lightbox/__tests__/LightboxEveryPointer.interaction.browser.spec.ts new file mode 100644 index 0000000000000..0b322ba7a4907 --- /dev/null +++ b/packages/core/src/Lightbox/__tests__/LightboxEveryPointer.interaction.browser.spec.ts @@ -0,0 +1,291 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file LightboxEveryPointer.interaction.browser.spec.ts + * @input The built "With zoom" and "Default" Lightbox stories, with a + * 3000 × 2000 test photo. A desktop context (1280 × 800, a fine primary + * pointer) has a mouse and a trackpad's wheel. Chromium also has timed + * mouse events through the Chrome DevTools Protocol + * @output Proofs that Lightbox's gestures work for every pointer. A ctrl-wheel + * zooms, and a trackpad's scroll pans a zoomed photo and stops at its edge. + * A mouse wheel's notch leaves it in place, and a mouse drag's release + * stops it + * @position Interaction tests for Lightbox (browser), beside + * LightboxZoomModes.interaction.browser.spec.ts + */ + +import { + expect, + test, + type Browser, + type Locator, + type Page, +} from '@astryxdesign/a11y-spec/playwright'; +import { + frames, + openStory, + waitForRest, + withStorybook, +} from '@astryxdesign/interaction-harness'; + +const storybook = withStorybook(test); + +type Story = 'with-zoom' | 'default'; + +const TRIGGER: Record = { + 'with-zoom': 'Open with zoom', + default: 'Open lightbox', +}; + +function photoSvg(width: number, height: number): string { + return ``; +} + +interface Point { + x: number; + y: number; +} + +/** Opens a story's viewer in a desktop context: a mouse, no touch. */ +async function openDesktop( + browser: Browser, + story: Story, + {reducedMotion = false}: {reducedMotion?: boolean} = {}, +) { + const context = await browser.newContext({ + viewport: {width: 1280, height: 800}, + hasTouch: false, + isMobile: false, + reducedMotion: reducedMotion ? 'reduce' : 'no-preference', + }); + const page = await context.newPage(); + // A fine primary pointer in this context (the interaction config can report + // a coarse one without touch). + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + await page.route('https://picsum.photos/**', async route => + route.fulfill({contentType: 'image/svg+xml', body: photoSvg(3000, 2000)}), + ); + await openStory(page, storybook(), `core-lightbox--${story}`); + await page.getByRole('button', {name: TRIGGER[story]}).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + const img = dialog.getByRole('img').first(); + await expect + .poll(async () => img.evaluate(el => (el as HTMLImageElement).naturalWidth)) + .toBe(3000); + await frames(page, 2); + const box = await boxOf(img); + // A wheel delta as the page receives it, in CSS pixels. Chromium's input + // divides Playwright's by the device pixel ratio, and WebKit's does not. A + // mouse wheel's notch is a whole 100 px only as the page sees it. + const scale = + page.context().browser()?.browserType().name() === 'chromium' + ? await page.evaluate(() => window.devicePixelRatio) + : 1; + return { + wheel: async (dx: number, dy: number) => + page.mouse.wheel(dx * scale, dy * scale), + context, + page, + dialog, + img, + centre: {x: box.x + box.width / 2, y: box.y + box.height / 2}, + }; +} + +async function boxOf(locator: Locator) { + const box = await locator.boundingBox(); + if (box == null) { + throw new Error('no box'); + } + return box; +} + +/** + * Mouse or pen events with exact timestamps through the Chrome DevTools + * Protocol: a release's velocity is the move's, whatever the runner's pace. + */ +async function timedPointer(page: Page, pointerType: 'mouse' | 'pen') { + const cdp = await page.context().newCDPSession(page); + let clock = await page.evaluate( + () => performance.timeOrigin + performance.now(), + ); + return async ( + type: 'mousePressed' | 'mouseMoved' | 'mouseReleased', + at: Point, + afterMs: number, + ) => { + clock += afterMs; + await cdp.send('Input.dispatchMouseEvent', { + type, + x: at.x, + y: at.y, + button: 'left', + buttons: type === 'mouseReleased' ? 0 : 1, + clickCount: type === 'mouseMoved' ? 0 : 1, + pointerType, + timestamp: clock / 1000, + }); + }; +} + +test('a ctrl-wheel zooms the photo', async ({browser}) => { + const {wheel, context, page, dialog, img, centre} = await openDesktop( + browser, + 'with-zoom', + ); + try { + const fitted = await boxOf(img); + await page.mouse.move(centre.x, centre.y); + await page.keyboard.down('Control'); + for (let i = 0; i < 3; i++) { + await wheel(0, -20); + await frames(page, 1); + } + await page.keyboard.up('Control'); + await expect(dialog.getByRole('button', {name: 'Zoom'})).toHaveAttribute( + 'aria-pressed', + 'true', + ); + await waitForRest(img); + expect((await boxOf(img)).width).toBeGreaterThan(fitted.width * 1.05); + } finally { + await context.close(); + } +}); + +const WHEEL_DRIVERS = + "Chromium's driver sends each wheel as a mouse wheel's notch; WebKit's sends a trackpad's precise deltas."; + +test("a trackpad's scroll pans a zoomed photo", async ({ + browser, + browserName, +}) => { + test.skip(browserName !== 'webkit', WHEEL_DRIVERS); + const {wheel, context, page, dialog, img, centre} = await openDesktop( + browser, + 'with-zoom', + ); + try { + await dialog.getByRole('button', {name: 'Zoom'}).focus(); + await page.keyboard.press('+'); + await waitForRest(img); + const zoomed = await boxOf(img); + await page.mouse.move(centre.x, centre.y); + // A trackpad's stream of small deltas. One 120 px delta over a 360 + // legacy delta reads as a mouse wheel's notch. + for (const dy of [12, 12, 12, 12, 12, 12, 11, 10, 9, 8]) { + await wheel(0, dy); + } + await expect + .poll(async () => (await boxOf(img)).y, {timeout: 3000}) + .toBeLessThan(zoomed.y - 20); + await expect(dialog).toBeVisible(); + } finally { + await context.close(); + } +}); + +test("a trackpad's scroll stops a zoomed photo at its edge, with no give, as a mouse drag does", async ({ + browser, + browserName, +}) => { + test.skip(browserName !== 'webkit', WHEEL_DRIVERS); + const {wheel, context, page, dialog, img, centre} = await openDesktop( + browser, + 'with-zoom', + ); + try { + await dialog.getByRole('button', {name: 'Zoom'}).focus(); + await page.keyboard.press('+'); + await waitForRest(img); + // The gap between the photo's bottom edge and its stage's. + const gap = async () => + img.evaluate(el => { + const stage = (el.parentElement as HTMLElement).getBoundingClientRect(); + return stage.bottom - el.getBoundingClientRect().bottom; + }); + // Zoomed 2×, the photo reaches well past the stage's bottom edge. + expect(await gap()).toBeLessThan(-100); + await page.mouse.move(centre.x, centre.y); + // A trackpad's stream that runs far past the photo's bottom edge. + const gaps: number[] = []; + for (let i = 0; i < 60; i += 1) { + await wheel(0, 12); + gaps.push(await gap()); + } + // The give past the edge belongs to the touch presentation. The photo's + // edge stops at the stage's and stays there. + expect(Math.max(...gaps)).toBeLessThan(0.5); + expect(gaps.at(-1)).toBeGreaterThan(-0.5); + await waitForRest(img); + expect(Math.abs(await gap())).toBeLessThan(0.5); + } finally { + await context.close(); + } +}); + +test("a mouse wheel's notch leaves a zoomed photo in place", async ({ + browser, + browserName, +}) => { + test.skip(browserName !== 'chromium', WHEEL_DRIVERS); + const {wheel, context, page, dialog, img, centre} = await openDesktop( + browser, + 'with-zoom', + ); + try { + await dialog.getByRole('button', {name: 'Zoom'}).focus(); + await page.keyboard.press('+'); + await waitForRest(img); + const zoomed = await boxOf(img); + await page.mouse.move(centre.x, centre.y); + await wheel(0, 120); + // The host ends a notch's stream 100 ms after its last event. Fifteen + // frames pass that at up to 120 Hz. + await frames(page, 15); + await waitForRest(img); + const after = await boxOf(img); + expect(Math.abs(after.x - zoomed.x)).toBeLessThan(1); + expect(Math.abs(after.y - zoomed.y)).toBeLessThan(1); + await expect(dialog).toBeVisible(); + } finally { + await context.close(); + } +}); + +test('a zoomed photo stops at a mouse drag’s release, as on main', async ({ + browser, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + 'Timed mouse events are dispatched through the Chrome DevTools Protocol.', + ); + const {context, page, dialog, img, centre} = await openDesktop( + browser, + 'with-zoom', + ); + try { + await dialog.getByRole('button', {name: 'Zoom'}).focus(); + await page.keyboard.press('+'); + await waitForRest(img); + const mouse = await timedPointer(page, 'mouse'); + await mouse('mouseMoved', centre, 0); + await mouse('mousePressed', centre, 16); + for (let i = 1; i <= 6; i++) { + await mouse('mouseMoved', {x: centre.x - 20 * i, y: centre.y}, 16); + } + await mouse('mouseReleased', {x: centre.x - 120, y: centre.y}, 4); + const atRelease = (await boxOf(img)).x; + await frames(page, 30); + await waitForRest(img); + // The coast belongs to the touch presentation: the photo stays where the + // release left it. + expect(Math.abs((await boxOf(img)).x - atRelease)).toBeLessThan(1); + } 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 new file mode 100644 index 0000000000000..7ef36bc39bd46 --- /dev/null +++ b/packages/core/src/Lightbox/__tests__/LightboxZoom.interaction.browser.spec.ts @@ -0,0 +1,1299 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file LightboxZoom.interaction.browser.spec.ts + * @input The built "With zoom" Lightbox story on the phone project, with a + * 3000 × 2000 test photo, real touch input and the interaction harness + * @output Proofs for Lightbox's zoom stage against UIKit's measured zooming, + * under a finger in Chromium and WebKit. They cover the pinch, the give past + * the limits, the double tap and the zoomed pan. They also cover a tap + * beside the photo, a settle through a blocked main thread, the sharp + * redraw and storyboards + * @position Interaction tests for Lightbox (browser); the shape follows + * BottomSheet/__tests__/BottomSheetDetent.interaction.browser.spec.ts + */ + +import { + expect, + test, + type Locator, + type Page, +} from '@astryxdesign/a11y-spec/playwright'; +// @ts-expect-error -- pngjs ships no declarations; runtime support is pinned. +import {PNG} from 'pngjs'; +import { + CONTROL_RGB, + animationStoryboard, + blockMainThread, + busyWindowProof, + castStoryboard, + compareCurve, + countBetween, + cubicBezier, + curveOf, + drag, + flick, + frames, + freezeTimers, + holdAnimations, + installLongTaskLog, + installTimerGate, + markElement, + openStory, + PARTIAL_LIFT_SKIP, + pinch, + releaseAnimations, + sampleCurve, + startBusyControl, + startScreencast, + timeline, + touchDriver, + touchStoryboard, + traceRendering, + trackMarker, + waitForAnimation, + waitForRest, + withStorybook, + writeEvidence, + type TouchDriver, + type TouchFrame, + type TouchScript, +} from '@astryxdesign/interaction-harness'; + +const storybook = withStorybook(test); + +// UIKit's measured zoom (motion/uikit/zoomMedia.ts), restated so a change to +// the table has to be made here too. +const EASE_IN_OUT = cubicBezier(0.42, 0, 0.58, 1); +/** The theme's duration token that a finger's double tap zooms over. */ +const TAP_ZOOM_DURATION = '--duration-medium'; +/** iOS's measured length of a finger's 2× double-tap zoom, ms. */ +const IOS_DOUBLE_TAP_MS = 308; +/** The theme's duration tokens, shortest to longest. */ +const DURATION_TOKENS = [ + '--duration-fast-min', + '--duration-fast', + '--duration-fast-max', + '--duration-medium-min', + '--duration-medium', + '--duration-medium-max', + '--duration-slow-min', + '--duration-slow', + '--duration-slow-max', +]; +const settleMs = (from: number, to: number) => + Math.min(400, 100 + 300 * Math.abs(Math.log(to / from))); + +/** + * A test photo: a checkerboard of 50 px squares, whose sharp edges judge the + * redraw, with a red disc at its centre. It is SVG, so it has an intrinsic + * size and draws its edges at any zoom. The squares are drawn one by one, + * since a `` goes through a tile image whose seams some engines blur. + */ +function photoSvg(width: number, height: number): string { + let squares = ''; + for (let y = 0; y < height; y += 50) { + for (let x = 0; x < width; x += 50) { + if ((x + y) % 100 === 0) { + squares += ``; + } + } + } + return `${squares}`; +} + +interface Box { + x: number; + y: number; + width: number; + height: number; +} + +interface PhotoState { + /** The stage, in page coordinates. */ + stage: Box; + /** The photo's fitted box, in stage coordinates. */ + fit: Box; + /** The zoom stage's transform: offset from the fitted box, and scale. */ + x: number; + y: number; + scale: number; +} + +interface OpenViewer { + driver: TouchDriver; + dialog: Locator; + stage: Locator; + photo: Locator; + state: () => Promise; +} + +// A plain evaluate (no auto-wait): it must work mid-animation. +async function readState( + photo: Locator, + options: {touches?: boolean} = {}, +): Promise { + return photo.evaluate((element, withTouches) => { + const img = element as HTMLImageElement; + const stage = img.parentElement as HTMLElement; + const rect = stage.getBoundingClientRect(); + const style = getComputedStyle(img); + const [tx = '0px', ty = '0px'] = + style.translate === 'none' ? [] : style.translate.split(' '); + const scale = style.scale === 'none' ? 1 : parseFloat(style.scale); + return { + stage: { + x: rect.left + stage.clientLeft, + y: rect.top + stage.clientTop, + width: stage.clientWidth, + height: stage.clientHeight, + }, + fit: { + x: img.offsetLeft, + y: img.offsetTop, + width: img.offsetWidth, + height: img.offsetHeight, + }, + x: parseFloat(tx), + y: parseFloat(ty), + scale, + // The touches the page last received (a capture listener the proof + // installs), read with the photo in the same evaluate. + ...(withTouches + ? { + touches: + (window as unknown as {__touches?: {x: number; y: number}[]}) + .__touches ?? [], + } + : {}), + }; + }, options.touches === true); +} + +/** The fitted-photo point shown under the page point `at`. */ +function photoPointAt(state: PhotoState, at: {x: number; y: number}) { + return { + u: (at.x - state.stage.x - state.fit.x - state.x) / state.scale, + v: (at.y - state.stage.y - state.fit.y - state.y) / state.scale, + }; +} + +/** Where the fitted-photo point (u, v) shows, in page coordinates. */ +function pagePointOf(state: PhotoState, u: number, v: number) { + return { + x: state.stage.x + state.fit.x + state.x + u * state.scale, + y: state.stage.y + state.fit.y + state.y + v * state.scale, + }; +} + +async function openViewer( + page: Page, + photo: {width: number; height: number} = {width: 3000, height: 2000}, +): Promise { + await installLongTaskLog(page); + await page.route('https://picsum.photos/**', async route => + route.fulfill({ + contentType: 'image/svg+xml', + body: photoSvg(photo.width, photo.height), + }), + ); + await openStory(page, storybook(), 'core-lightbox--with-zoom'); + const driver = await touchDriver(page); + const trigger = page.getByRole('button', {name: 'Open with zoom'}); + const box = await trigger.boundingBox(); + if (box == null) { + throw new Error('no trigger'); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + const dialog = page.getByRole('dialog', {name: 'Forest path'}); + await expect(dialog).toBeVisible(); + const stage = dialog.getByRole('button', {name: 'Zoom'}); + const img = dialog.getByRole('img', {name: 'Forest path'}); + await expect + .poll(async () => img.evaluate(el => (el as HTMLImageElement).naturalWidth)) + .toBe(photo.width); + // Also the page's first contact with the curve recorder, so an animation a + // test's first action starts is recorded however soon it ends. + await waitForRest(img); + await frames(page, 2); + return { + driver, + dialog, + stage, + photo: img, + state: async () => readState(img), + }; +} + +function doubleTap(at: {x: number; y: number}): TouchScript { + return [ + {t: 0, fingers: [{id: 0, ...at}]}, + {t: 50, fingers: []}, + {t: 170, fingers: [{id: 0, ...at}]}, + {t: 220, fingers: []}, + ]; +} + +const pageScale = async (page: Page) => + page.evaluate(() => window.visualViewport?.scale ?? 1); + +interface Rgb { + r: number; + g: number; + b: number; +} + +/** WCAG 2's relative luminance of an sRGB colour. */ +function luminance({r, g, b}: Rgb): number { + const [lr, lg, lb] = [r, g, b].map(channel => { + const value = channel / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb; +} + +/** WCAG 2's contrast ratio of two colours. */ +function contrast(first: Rgb, second: Rgb): number { + const [light, dark] = [luminance(first), luminance(second)].sort( + (a, b) => b - a, + ); + return (light + 0.05) / (dark + 0.05); +} + +/** The colour a `color` value such as `rgb(255, 255, 255)` names. */ +function parseRgb(value: string): Rgb { + const [r, g, b] = (value.match(/[\d.]+/g) ?? []).map(Number); + return {r, g, b}; +} + +/** The darkest of a screenshot's pixels at `points` (CSS px). */ +function lightestAt( + png: {width: number; data: Uint8Array}, + scale: number, + points: {x: number; y: number}[], +): Rgb { + let lightest: Rgb = {r: 0, g: 0, b: 0}; + for (const {x, y} of points) { + const i = (Math.round(y * scale) * png.width + Math.round(x * scale)) * 4; + const pixel = {r: png.data[i], g: png.data[i + 1], b: png.data[i + 2]}; + if (luminance(pixel) > luminance(lightest)) { + lightest = pixel; + } + } + return lightest; +} + +/** Every shipped theme; the probe theme is a test fixture, not a design. */ +const SHIPPED_THEMES = [ + 'butter', + 'chocolate', + 'gothic', + 'matcha', + 'neutral', + 'stone', + 'y2k', +] as const; + +for (const theme of SHIPPED_THEMES) { + test(`over a zoomed light photo, Close, Previous, Next and the counter keep their contrast (${theme})`, async ({ + page, + }) => { + await page.route('https://picsum.photos/**', async route => + route.fulfill({ + contentType: 'image/svg+xml', + body: '', + }), + ); + // Storybook's theme global applies from the story's first render. + await page.goto( + `${storybook()}/iframe.html?id=core-lightbox--gallery-with-zoom&viewMode=story&globals=astryxTheme:${theme}`, + ); + 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 zoom'})); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + // The second photo, so both Previous and Next show. The tap leaves Next + // hovered, as a phone does, where a theme may restyle its background. + 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'})); + const rect = await img.boundingBox(); + if (rect == null) { + throw new Error('no photo'); + } + await driver.play( + doubleTap({x: rect.x + rect.width / 2, y: rect.y + rect.height / 2}), + ); + await expect + .poll(async () => + img.evaluate(el => parseFloat(getComputedStyle(el).scale) || 1), + ) + .toBeGreaterThan(1.5); + await waitForRest(img); + const zoomed = await img.boundingBox(); + const controls = [ + {name: 'Close', locator: dialog.getByRole('button', {name: 'Close'})}, + { + name: 'Previous', + locator: dialog.getByRole('button', {name: 'Previous'}), + }, + {name: 'Next', locator: dialog.getByRole('button', {name: 'Next'})}, + ]; + const counter = dialog.getByText('2 / 4'); + const boxes = await Promise.all( + [...controls.map(c => c.locator), counter].map(async l => + l.boundingBox(), + ), + ); + // Present, painted and over the photo: the zoomed photo covers every box. + for (const box of boxes) { + expect(box).not.toBeNull(); + expect(zoomed).not.toBeNull(); + if (box == null || zoomed == null) { + return; + } + expect(box.width * box.height).toBeGreaterThan(0); + expect(zoomed.x).toBeLessThanOrEqual(box.x); + expect(zoomed.y).toBeLessThanOrEqual(box.y); + expect(zoomed.x + zoomed.width).toBeGreaterThanOrEqual(box.x + box.width); + expect(zoomed.y + zoomed.height).toBeGreaterThanOrEqual( + box.y + box.height, + ); + } + const shot = PNG.sync.read(await page.screenshot()); + const scale = shot.width / (page.viewportSize()?.width ?? shot.width); + const ratios: Record = {}; + for (const [i, {name, locator}] of controls.entries()) { + const box = boxes[i]; + if (box == null) { + return; + } + // The icon's own colour: a theme may restyle the button around it. + const fg = parseRgb( + await locator.evaluate( + el => getComputedStyle(el.querySelector('svg') ?? el).color, + ), + ); + // A ring between the icon and the button's edge: what the icon sits on. + const centre = {x: box.x + box.width / 2, y: box.y + box.height / 2}; + const radius = Math.min(box.width, box.height) * 0.34; + const ring = Array.from({length: 8}, (_, k) => ({ + x: centre.x + radius * Math.cos((k * Math.PI) / 4), + y: centre.y + radius * Math.sin((k * Math.PI) / 4), + })); + ratios[name] = contrast(fg, lightestAt(shot, scale, ring)); + } + const counterBox = boxes[controls.length]; + if (counterBox == null) { + return; + } + const counterFg = parseRgb( + await counter.evaluate(el => getComputedStyle(el).color), + ); + // Just inside the counter's box, beside its text. + const beside = [ + {x: counterBox.x + 2, y: counterBox.y + counterBox.height / 2}, + { + x: counterBox.x + counterBox.width - 2, + y: counterBox.y + counterBox.height / 2, + }, + ]; + ratios.counter = contrast(counterFg, lightestAt(shot, scale, beside)); + for (const {name} of controls) { + expect(ratios[name], name).toBeGreaterThanOrEqual(3); + } + expect(ratios.counter, 'counter').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(); + const viewport = page.viewportSize() ?? {width: 0, height: 0}; + expect(s.stage.width).toBe(viewport.width); + expect(s.stage.height).toBe(viewport.height); + expect(s.fit.width).toBe(viewport.width); + expect(s.fit.height).toBeCloseTo((viewport.width * 2000) / 3000, 0); + expect(s.fit.y).toBeCloseTo((viewport.height - s.fit.height) / 2, 0); + expect([s.x, s.y, s.scale]).toEqual([0, 0, 1]); +}); + +test('a pinch keeps the photo point under the fingers, and the page does not zoom', async ({ + page, +}) => { + const {driver, state, photo} = await openViewer(page); + const before = await state(); + const centre = {x: 150, y: before.stage.y + before.stage.height / 2 + 30}; + // The photo point under the centroid when the pinch begins (past its 8 px + // threshold the fingers have already moved a little). + let anchor: {u: number; v: number} | null = null; + // Spread and move: the centroid travels 40 px right and 20 px up. + const spread = pinch({ + center: centre, + fromDistance: 80, + toDistance: 260, + durationMs: 400, + }); + const moved: TouchScript = spread.map(frame => { + const p = Math.min(1, frame.t / 400); + return { + t: frame.t, + fingers: frame.fingers.map(f => ({ + ...f, + x: f.x + 40 * p, + y: f.y - 20 * p, + })), + }; + }); + const errors: number[] = []; + const scales: number[] = []; + let moves = 0; + // The centroid of the touches the page received (the engine may round a + // scripted position), which is what the stage follows. + await page.evaluate(() => { + document.addEventListener( + 'touchmove', + event => { + (window as unknown as {__touches: {x: number; y: number}[]}).__touches = + [...event.touches].map(touch => ({ + x: touch.clientX, + y: touch.clientY, + })); + }, + {capture: true, passive: true}, + ); + }); + await driver.play(moved, { + onEvent: async step => { + if (step.type !== 'touchMove' || step.fingers.length < 2) { + return; + } + moves++; + // The photo and the touches the page received, in one evaluate. The + // page takes a touch with its frame, after the dispatch returns. So two + // reads could straddle one on a busy page: the photo from one move and + // the fingers from the next. An anchor taken from such a pair stays off + // by that move for the whole pinch. + const {touches: received = [], ...s} = await readState(photo, { + touches: true, + }); + const centroid = + received.length === 2 + ? { + x: (received[0].x + received[1].x) / 2, + y: (received[0].y + received[1].y) / 2, + } + : { + x: (step.fingers[0].x + step.fingers[1].x) / 2, + y: (step.fingers[0].y + step.fingers[1].y) / 2, + }; + if (anchor == null) { + if (s.scale !== 1) { + anchor = photoPointAt(s, centroid); + } + return; + } + const shown = pagePointOf(s, anchor.u, anchor.v); + errors.push(Math.hypot(shown.x - centroid.x, shown.y - centroid.y)); + scales.push(s.scale); + }, + }); + expect(moves).toBeGreaterThan(10); + expect(errors.length).toBeGreaterThan(5); + // UIKit keeps the point within 1 pt; one event of lag is allowed here. + expect(Math.max(...errors), JSON.stringify(errors)).toBeLessThan(2); + // The zoom grew with the fingers and kept no momentum after the lift. + expect(scales.every((s, i) => i === 0 || s >= scales[i - 1] - 0.001)).toBe( + true, + ); + await frames(page, 3); + const lifted = await state(); + await frames(page, 18); + const later = await state(); + expect(later.scale).toBeCloseTo(lifted.scale, 3); + expect(later.scale).toBeGreaterThan(2.5); + expect(await pageScale(page)).toBe(1); +}); + +test('a pinch on the controls still zooms the page', async ({ + page, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + 'native pinch-zoom from scripted touches is Chromium-only', + ); + const {driver, dialog} = await openViewer(page); + const close = dialog.getByRole('button', {name: 'Close'}); + const box = await close.boundingBox(); + if (box == null) { + throw new Error('no close button'); + } + await driver.play( + pinch({ + center: {x: box.x + box.width / 2, y: box.y + box.height / 2}, + fromDistance: 30, + toDistance: 200, + durationMs: 400, + }), + ); + await expect.poll(async () => pageScale(page)).toBeGreaterThan(1.5); +}); + +test('past the minimum the zoom gives way, then returns on the eased curve', async ({ + page, +}, testInfo) => { + const {driver, photo, state} = await openViewer(page); + const before = await state(); + const centre = {x: 201, y: before.stage.y + before.stage.height / 2}; + let shown = 1; + let pinchScale = 1; + await driver.play( + pinch({ + center: centre, + fromDistance: 300, + toDistance: 100, + durationMs: 300, + restMs: 100, + }), + { + onEvent: async step => { + if (step.type === 'touchMove' && step.fingers.length === 2) { + shown = (await state()).scale; + pinchScale = Math.abs(step.fingers[1].x - step.fingers[0].x); + } + }, + }, + ); + // The recognizer's scale is relative to the span when the pinch began + // (past its 8 px hysteresis); UIKit's law maps it to the shown zoom. + const recognizerScale = pinchScale / (300 - 8); + expect(shown).toBeCloseTo(1 / (2 - recognizerScale), 2); + const [settle] = await waitForAnimation(photo); + const held = await holdAnimations(page, photo); + const duration = Math.max(...held.map(a => a.curveEndMs)); + const samples = curveOf( + await sampleCurve(photo, timeline(duration)), + 'scaleX', + ); + await releaseAnimations(page); + const from = samples[0].value; + const expectedMs = settleMs(from, 1); + const expected = (t: number) => + from + (1 - from) * EASE_IN_OUT(Math.min(1, t / expectedMs)); + const result = compareCurve(samples, expected, 0.003); + await writeEvidence( + 'curves/lightbox-zoom-past-minimum', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + {settle, held, samples, result}, + null, + 1, + ), + }, + testInfo, + ); + expect(from).toBeCloseTo(shown, 2); + expect(duration).toBeGreaterThanOrEqual(expectedMs - 1); + expect(result.maxError, JSON.stringify(result.worst)).toBeLessThanOrEqual( + 0.003, + ); + await waitForRest(photo); + const rest = await state(); + expect([rest.x, rest.y, rest.scale]).toEqual([0, 0, 1]); +}); + +test("past the maximum the zoom gives way by UIKit's law and settles about the last centroid", async ({ + page, +}) => { + // A 600 × 400 photo on a 3× phone has a low maximum, so one pinch passes + // it. Six times the zoom that shows its pixels 1:1 is 2.985, above the + // fill zoom. + const {driver, photo, state} = await openViewer(page, { + width: 600, + height: 400, + }); + const before = await state(); + const dpr = await page.evaluate(() => window.devicePixelRatio); + const max = Math.max( + before.stage.height / before.fit.height, + (6 * 600) / (before.fit.width * dpr), + ); + const centre = {x: 220, y: before.stage.y + before.stage.height / 2}; + let last = before; + let span = 0; + await driver.play( + pinch({ + center: centre, + fromDistance: 70, + toDistance: 330, + durationMs: 400, + restMs: 100, + }), + { + onEvent: async step => { + if (step.type === 'touchMove' && step.fingers.length === 2) { + last = await state(); + span = Math.abs(step.fingers[1].x - step.fingers[0].x); + } + }, + }, + ); + const recognizerScale = span / (70 + 8); + expect(recognizerScale).toBeGreaterThan(max); + expect(last.scale).toBeCloseTo(max * (2 - max / recognizerScale), 2); + // The photo point under the last centroid stays there through the settle. + const anchor = photoPointAt(last, centre); + await waitForAnimation(photo); + await waitForRest(photo); + const rest = await state(); + expect(rest.scale).toBeCloseTo(max, 3); + const shown = pagePointOf(rest, anchor.u, anchor.v); + // Unless the stage's edge stopped it (it keeps the photo inside). + const clampedX = + rest.x === 0 || + Math.abs(rest.x - (rest.stage.width - rest.fit.width * rest.scale)) < 0.5; + if (!clampedX) { + expect(shown.x).toBeCloseTo(centre.x, 0); + } + expect(await pageScale(page)).toBe(1); +}); + +test('a double tap zooms over the Storybook theme’s duration token nearest iOS’s 308 ms', async ({ + page, +}) => { + const {stage} = await openViewer(page); + const tokens = await stage.evaluate((element, names) => { + const style = getComputedStyle(element); + return names.map(name => { + const value = style.getPropertyValue(name).trim(); + const ms = value.endsWith('ms') + ? parseFloat(value) + : parseFloat(value) * 1000; + return {name, ms}; + }); + }, DURATION_TOKENS); + expect(tokens.every(token => Number.isFinite(token.ms))).toBe(true); + const nearest = tokens.reduce((best, token) => + Math.abs(token.ms - IOS_DOUBLE_TAP_MS) < + Math.abs(best.ms - IOS_DOUBLE_TAP_MS) + ? token + : best, + ); + // The Storybook theme's medium duration, 300 ms, is the nearest to iOS's + // measured 308 ms. + expect(nearest).toEqual({name: TAP_ZOOM_DURATION, ms: 300}); +}); + +test('a double tap zooms until the photo fills the screen, about the tapped point', async ({ + page, +}, testInfo) => { + const {driver, photo, stage, state} = await openViewer(page); + const before = await state(); + const at = {x: 120, y: before.stage.y + before.stage.height / 2}; + const tapped = photoPointAt(before, at); + await driver.play(doubleTap(at)); + const [zoom] = await waitForAnimation(photo); + const held = await holdAnimations(page, photo); + const duration = Math.max(...held.map(a => a.curveEndMs)); + const samples = curveOf( + await sampleCurve(photo, timeline(duration)), + 'scaleX', + ); + await releaseAnimations(page); + const fill = before.stage.height / before.fit.height; + // A tap starts the zoom, so it plays the theme's move easing and medium + // duration: its duration token on its `--ease-move`. + const theme = await stage.evaluate((element, token) => { + const style = getComputedStyle(element); + return { + duration: style.getPropertyValue(token).trim(), + ease: style.getPropertyValue('--ease-move').trim(), + }; + }, TAP_ZOOM_DURATION); + expect(theme.duration).toMatch(/^\d+(\.\d+)?ms$/); + const points = /^cubic-bezier\(([^)]*)\)$/ + .exec(theme.ease)?.[1] + ?.split(',') + .map(Number); + if (points?.length !== 4 || points.some(Number.isNaN)) { + throw new Error(`no cubic-bezier move easing: ${theme.ease}`); + } + const expectedMs = parseFloat(theme.duration); + const ease = cubicBezier(points[0], points[1], points[2], points[3]); + const expected = (t: number) => + 1 + (fill - 1) * ease(Math.min(1, t / expectedMs)); + const result = compareCurve(samples, expected, 0.005); + await writeEvidence( + 'curves/lightbox-double-tap-zoom', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + {zoom, held, samples, result}, + null, + 1, + ), + }, + testInfo, + ); + expect(result.maxError, JSON.stringify(result.worst)).toBeLessThanOrEqual( + 0.005, + ); + await waitForRest(photo); + const zoomed = await state(); + expect(zoomed.scale).toBeCloseTo(fill, 3); + // The tapped photo point stays under the finger across the screen. Up and + // down, the photo exactly fills the screen. + const shown = pagePointOf(zoomed, tapped.u, tapped.v); + expect(shown.x).toBeCloseTo(at.x, 0); + expect(zoomed.fit.y + zoomed.y).toBeCloseTo(0, 0); + await expect(stage).toHaveAttribute('aria-pressed', 'true'); + // Again: back to the fitted size. + await driver.play(doubleTap({x: 300, y: at.y})); + await waitForAnimation(photo); + await waitForRest(photo); + const fitted = await state(); + expect([fitted.x, fitted.y, fitted.scale]).toEqual([0, 0, 1]); + await expect(stage).toHaveAttribute('aria-pressed', 'false'); +}); + +test("a double tap near the photo's top edge stops at the screen's top edge", async ({ + page, +}) => { + const {driver, photo, state} = await openViewer(page); + const before = await state(); + // Kept in place, the tapped point would leave a gap above the photo. + await driver.play( + doubleTap({ + x: before.stage.x + before.stage.width / 2, + y: before.stage.y + before.fit.y + 10, + }), + ); + await waitForAnimation(photo); + await waitForRest(photo); + const zoomed = await state(); + expect(zoomed.fit.y + zoomed.y).toBeCloseTo(0, 1); +}); + +test('a zoomed pan tracks the finger, rubber-bands past the edge and returns', async ({ + page, +}) => { + const {driver, photo, state} = await openViewer(page); + const before = await state(); + const mid = {x: 201, y: before.stage.y + before.stage.height / 2}; + await driver.play(doubleTap(mid)); + await waitForAnimation(photo); + await waitForRest(photo); + const zoomed = await state(); + // Room to the left edge: the photo's left edge is this far off screen. + const room = -(zoomed.fit.x + zoomed.x); + const overshoot = 120; + const xs: number[] = []; + await driver.play( + drag( + mid, + {x: mid.x + room + overshoot + 10, y: mid.y}, + {durationMs: 500, restMs: 150}, + ), + { + onEvent: async step => { + if (step.type === 'touchMove') { + xs.push((await state()).x); + } + }, + }, + ); + // 1:1 until the edge (after the 10 px hysteresis), then UIScrollView's + // rubber band over the stage width: (1 − 1 ÷ (x·0.55 ÷ d + 1))·d. + const d = zoomed.stage.width; + const banded = (1 - 1 / ((overshoot * 0.55) / d + 1)) * d; + expect(xs.at(-1)).toBeCloseTo(zoomed.x + room + banded, 0); + await waitForRest(photo); + const back = await state(); + expect(back.fit.x + back.x).toBeCloseTo(0, 1); + expect(back.scale).toBeCloseTo(zoomed.scale, 6); +}); + +test('a zoomed flick coasts and stops at the far edge', async ({page}) => { + const {driver, photo, state} = await openViewer(page); + const before = await state(); + const mid = {x: 201, y: before.stage.y + before.stage.height / 2}; + await driver.play(doubleTap(mid)); + await waitForAnimation(photo); + await waitForRest(photo); + // Bring the photo within 160 px of its far edge with a slow drag, held + // still before the lift so it does not coast. The flick's own 120 px leave + // 40 for the coast, which it reaches at any release velocity the run + // measures. Under load, Linux WebKit delivers a flick's moves in bursts, + // and its release velocity came out as low as ~350 px/s. + const near = await state(); + const toEdge = + near.fit.x + near.x + near.fit.width * near.scale - near.stage.width; + const from = {x: near.stage.width - 20, y: mid.y}; + await driver.play( + drag( + from, + {x: from.x - (toEdge - 160), y: from.y}, + {durationMs: 600, restMs: 150}, + ), + ); + await waitForRest(photo); + const zoomed = await state(); + const script = flick(mid, { + velocity: {x: -1500, y: 0}, + durationMs: 80, + hz: 120, + }); + let released = zoomed; + await driver.play(script, { + onEvent: async step => { + if (step.type === 'touchMove') { + released = await state(); + } + }, + }); + await frames(page, 6); + const coasting = await state(); + // The photo kept moving after the lift. + expect(coasting.x).toBeLessThan(released.x - 5); + await waitForRest(photo, {timeout: 8_000}); + const rest = await state(); + // At the right edge: the photo's right edge on the stage's. + expect(rest.fit.x + rest.x + rest.fit.width * rest.scale).toBeCloseTo( + rest.stage.width, + 0, + ); +}); + +test('a finger landing on a coasting photo stops it where it shows', async ({ + page, +}) => { + const {driver, photo, state} = await openViewer(page); + const before = await state(); + const mid = {x: 201, y: before.stage.y + before.stage.height / 2}; + await driver.play(doubleTap(mid)); + await waitForAnimation(photo); + await waitForRest(photo); + await driver.play( + flick(mid, {velocity: {x: -900, y: 0}, durationMs: 80, hz: 120}), + ); + // The finger lands once the coast has carried the photo on past the + // release, then rests 250 ms and lifts without moving. + const released = (await state()).x; + await expect + .poll(async () => (await state()).x, {timeout: 2000}) + .toBeLessThan(released - 10); + const reads: PhotoState[] = []; + await driver.play( + [ + {t: 0, fingers: [{id: 0, x: 120, y: mid.y}]}, + {t: 250, fingers: []}, + ], + { + onEvent: async () => { + reads.push(await state()); + }, + }, + ); + // Read at the touch-down and at the lift. + expect(reads).toHaveLength(2); + const [{x: caughtX}, {x: heldX, stage, fit, scale}] = reads; + // The coast stopped at once, where it showed, short of the edge. + expect(heldX).toBeCloseTo(caughtX, 1); + expect(heldX).toBeGreaterThan(stage.width - fit.width * scale - fit.x + 20); + // Inside the stage, the lift with no drag leaves it there. + await frames(page, 15); + expect((await state()).x).toBeCloseTo(heldX, 1); +}); + +test('a finger left down after a pinch keeps panning without a jump', async ({ + page, +}) => { + const {driver, photo, state} = await openViewer(page); + test.skip(!driver.partialLifts, PARTIAL_LIFT_SKIP); + const before = await state(); + const mid = {x: 201, y: before.stage.y + before.stage.height / 2}; + await driver.play(doubleTap(mid)); + await waitForAnimation(photo); + await waitForRest(photo); + // Two fingers spread and drift left together, then the right one lifts + // and the left one drags on 60 px. + const script: TouchFrame[] = []; + for (let i = 0; i <= 12; i++) { + const p = i / 12; + script.push({ + t: i * 16, + fingers: [ + {id: 0, x: 170 - 30 * p, y: mid.y}, + {id: 1, x: 250 - 10 * p, y: mid.y}, + ], + }); + } + script.push({t: 13 * 16, fingers: [{id: 0, x: 140, y: mid.y}]}); + for (let i = 1; i <= 12; i++) { + script.push({ + t: (13 + i) * 16, + fingers: [{id: 0, x: 140 - 5 * i, y: mid.y}], + }); + } + script.push({t: 26 * 16 + 150, fingers: []}); + const xs: {type: string; x: number}[] = []; + await driver.play(script, { + onEvent: async step => { + xs.push({type: step.type, x: (await state()).x}); + }, + }); + const lift = xs.findIndex(e => e.type === 'touchEnd'); + const lastMove = xs.map(e => e.type).lastIndexOf('touchMove'); + expect(lift).toBeGreaterThan(0); + // No jump when the second finger lifts, then 1:1 with the finger left. + expect(Math.abs(xs[lift].x - xs[lift - 1].x)).toBeLessThan(1); + expect(xs[lift].x - xs[lastMove].x).toBeCloseTo(60, 0); +}); + +test('a tap beside the photo closes the viewer; a tap on it does not', async ({ + page, +}) => { + const {driver, dialog, state} = await openViewer(page); + const s = await state(); + await driver.play([ + {t: 0, fingers: [{id: 0, x: 201, y: s.stage.y + s.stage.height / 2}]}, + {t: 50, fingers: []}, + ]); + // A single tap waits out the double tap's 300 ms. Forty frames pass that + // at up to 120 Hz. + await frames(page, 40); + await expect(dialog).toBeVisible(); + await driver.play([ + {t: 0, fingers: [{id: 0, x: 201, y: s.stage.y + 60}]}, + {t: 50, fingers: []}, + ]); + await expect(dialog).toBeHidden(); +}); + +test('the double-tap zoom keeps advancing every frame while the main thread is blocked', async ({ + page, +}, testInfo) => { + const {driver, photo, state} = await openViewer(page); + const before = await state(); + await markElement(photo, {mode: 'outline'}); + // A compositor-only control animation in the same window: what the + // compositor presented while the main thread spun, on this machine. + await startBusyControl(page); + const cast = await startScreencast(page); + // The page itself waits for the zoom and blocks one frame into it. The + // theme's move easing covers most of its travel early, and a round trip + // from the test would start the block too late. + const blocking = blockMainThread(page, 500, {during: photo}); + await driver.play( + doubleTap({x: 201, y: before.stage.y + before.stage.height / 2}), + ); + const block = await blocking; + // A few frames after the block, so the screencast holds its end. + await frames(page, 6); + const castFrames = await cast.stop(); + const tracked = await trackMarker(page, castFrames); + const control = await trackMarker(page, castFrames, {rgb: CONTROL_RGB}); + // The theme's move easing slows hard at its end, so its last few px span + // several frames. WebKit's 2 px cast step shows that as a plateau, so + // judging stops two cast steps from the end. + const proof = busyWindowProof(tracked, block, { + axis: 'height', + settledWithinSteps: 2, + control, + }); + await writeEvidence( + 'busy-main-thread/lightbox-double-tap-zoom', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + { + block, + proof, + frames: tracked.map(f => ({ + t: +(f.t - block.from).toFixed(1), + y: f.box?.y ?? null, + h: f.box?.height ?? null, + })), + control: control.map(f => ({ + t: +(f.t - block.from).toFixed(1), + x: f.box?.x ?? null, + })), + }, + null, + 1, + ), + }, + testInfo, + ); + const film = await castStoryboard(page, castFrames, { + name: 'lightbox-double-tap-zoom-busy-main-thread', + zero: block.from, + from: block.from - 40, + to: proof.judged.to + 40, + columns: 8, + cellScale: 0.35, + data: t => ({h: tracked.find(f => f.t === t)?.box?.height ?? -1}), + }); + await film.write(testInfo, {block, judged: proof.judged}); + // Judged against the control. A two-core runner's software compositor and + // the screencast share the core the block spins on. So every compositor + // animation presents fewer frames there, the control's too. The photo keeps + // pace with the control, and a main-thread animation would not. + expect(proof.verdict.failures).toEqual([]); + expect(proof.monotonic).toBe(true); +}); + +test('the zoom settles and the pinch do no Layout or Paint per frame', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'rendering traces are Chromium-only'); + const {driver, photo, state} = await openViewer(page); + const before = await state(); + let zoom = {from: 0, to: 0}; + let pinchWindow = {from: 0, to: 0}; + const trace = await traceRendering(page, async () => { + await driver.play( + doubleTap({x: 201, y: before.stage.y + before.stage.height / 2}), + ); + [zoom] = await waitForAnimation(photo); + await waitForRest(photo); + const start = Date.now(); + await driver.play( + pinch({ + center: {x: 201, y: before.stage.y + before.stage.height / 2}, + fromDistance: 100, + toDistance: 180, + durationMs: 400, + restMs: 100, + }), + ); + pinchWindow = {from: start, to: Date.now()}; + }); + const during = countBetween(trace, zoom.from + 50, zoom.to - 50); + expect(during.Layout ?? 0, JSON.stringify(during)).toBe(0); + expect(during.Paint ?? 0, JSON.stringify(during)).toBe(0); + // While the fingers move: no layout (a style write per event, composited). + const moving = countBetween( + trace, + pinchWindow.from + 100, + pinchWindow.to - 150, + ); + expect(moving.Layout ?? 0, JSON.stringify(moving)).toBe(0); + expect(trace.longTasks).toEqual([]); +}); + +test('the zoomed photo is redrawn sharp', async ({page}, testInfo) => { + // A 5:1 panorama: the double tap fills the screen at about 8.9×. A photo + // left at its fitted raster would smear each edge over ~9 pixels. + const {driver, photo, state} = await openViewer(page, { + width: 3000, + height: 600, + }); + const before = await state(); + await driver.play( + doubleTap({x: 201, y: before.stage.y + before.stage.height / 2}), + ); + await waitForAnimation(photo); + await waitForRest(photo); + await frames(page, 10); + // Rows of device pixels across the checkerboard: count the pixels caught + // between black and white at each edge. A photo scaled up from its fitted + // raster smears each edge over several pixels. A redraw at the new zoom + // keeps it to one or two, from the edge's sub-pixel position and the + // engine's filtering. + const zoomed = await state(); + const y = Math.round(zoomed.stage.y + zoomed.stage.height * 0.3); + // The whole viewport, decoded in a scratch page like the harness's marker + // tracking (a clipped capture of the top layer is not the presented frame). + const shot = await page.screenshot({scale: 'device'}); + await writeEvidence( + 'sharpness/lightbox-zoomed', + {[`${testInfo.project.name}.png`]: shot}, + testInfo, + ); + const dpr = await page.evaluate(() => window.devicePixelRatio); + const scratch = await page.context().newPage(); + // One checkerboard period of rows, from a third of the way down. The row + // that crosses the most edges lies inside a row of squares, not on the + // boundary between two. + const rows = await scratch.evaluate( + async ({base64, from, count}) => { + const blob = await ( + await fetch(`data:image/png;base64,${base64}`) + ).blob(); + const bitmap = await createImageBitmap(blob); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const context = canvas.getContext('2d'); + if (context == null) { + throw new Error('no 2d context'); + } + context.drawImage(bitmap, 0, 0); + const out: number[][] = []; + for (let row = from; row < from + count; row++) { + const data = context.getImageData(0, row, bitmap.width, 1).data; + const values: number[] = []; + for (let i = 0; i < data.length; i += 4) { + values.push((data[i] + data[i + 1] + data[i + 2]) / 3); + } + out.push(values); + } + return out; + }, + { + base64: shot.toString('base64'), + from: Math.round(y * dpr), + count: Math.ceil(((100 * zoomed.fit.width) / 3000) * zoomed.scale * dpr), + }, + ); + await scratch.close(); + const measureRow = (gray: number[]) => { + let edges = 0; + let smeared = 0; + let run = 0; + let side: 'dark' | 'light' | null = null; + for (const value of gray) { + if (value > 40 && value < 215) { + run++; + continue; + } + const next = value <= 40 ? 'dark' : 'light'; + if (side != null && next !== side) { + edges++; + smeared = Math.max(smeared, run); + } + side = next; + run = 0; + } + return {edges, smeared}; + }; + const {edges, smeared} = rows + .map(measureRow) + .reduce((best, next) => (next.edges > best.edges ? next : best)); + await writeEvidence( + 'sharpness/lightbox-zoomed', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + { + scale: zoomed.scale, + edges, + smeared, + y, + }, + null, + 1, + ), + }, + testInfo, + ); + expect(zoomed.scale).toBeGreaterThan(8); + expect(edges).toBeGreaterThanOrEqual(4); + expect( + smeared, + `widest edge ${smeared} device px at zoom ${zoomed.scale}`, + ).toBeLessThanOrEqual(2); +}); + +test( + 'storyboard: pinch to zoom and the return from past the minimum', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {driver, photo, state} = await openViewer(page); + await installTimerGate(page); + const viewport = page.viewportSize() ?? {width: 402, height: 714}; + const clip = {x: 0, y: 0, width: viewport.width, height: viewport.height}; + const s = await state(); + const centre = {x: 180, y: s.stage.y + s.stage.height / 2}; + const measure = async () => { + const now = await state(); + return { + scale: +now.scale.toFixed(3), + x: +now.x.toFixed(1), + y: +now.y.toFixed(1), + }; + }; + const spread = await touchStoryboard( + page, + driver, + pinch({ + center: centre, + fromDistance: 80, + toDistance: 260, + durationMs: 300, + hz: 30, + restMs: 100, + }), + {name: 'lightbox-pinch-zoom', clip, measure, columns: 5, cellScale: 0.4}, + ); + const squeeze = await touchStoryboard( + page, + driver, + pinch({ + center: centre, + fromDistance: 300, + toDistance: 70, + durationMs: 300, + hz: 30, + restMs: 100, + }), + { + name: 'lightbox-pinch-past-minimum', + clip, + measure, + columns: 5, + cellScale: 0.4, + }, + ); + await waitForAnimation(photo); + await freezeTimers(page); + const {storyboard: settle} = await animationStoryboard(page, { + name: 'lightbox-return-from-past-minimum', + clip, + scope: photo, + measure, + columns: 6, + cellScale: 0.35, + }); + await spread.write(testInfo); + await squeeze.write(testInfo); + await settle.write(testInfo); + }, +); + +test( + 'storyboard: double tap to fill and back', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {driver, photo, state} = await openViewer(page); + await installTimerGate(page); + const viewport = page.viewportSize() ?? {width: 402, height: 714}; + const clip = {x: 0, y: 0, width: viewport.width, height: viewport.height}; + const s = await state(); + const measure = async () => { + const now = await state(); + return {scale: +now.scale.toFixed(3), x: +now.x.toFixed(1)}; + }; + await driver.play(doubleTap({x: 120, y: s.stage.y + s.stage.height / 2})); + await waitForAnimation(photo); + await freezeTimers(page); + const {storyboard: zoomIn} = await animationStoryboard(page, { + name: 'lightbox-double-tap-zoom-in', + clip, + scope: photo, + measure, + columns: 6, + cellScale: 0.35, + }); + await zoomIn.write(testInfo); + }, +); diff --git a/packages/core/src/Lightbox/__tests__/LightboxZoomModes.interaction.browser.spec.ts b/packages/core/src/Lightbox/__tests__/LightboxZoomModes.interaction.browser.spec.ts new file mode 100644 index 0000000000000..22bf5d7f3038c --- /dev/null +++ b/packages/core/src/Lightbox/__tests__/LightboxZoomModes.interaction.browser.spec.ts @@ -0,0 +1,608 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file LightboxZoomModes.interaction.browser.spec.ts + * @input The built "With zoom" Lightbox story with a 3000 × 2000 test photo. + * It uses the phone project's touch input, a desktop context with a mouse, + * and the interaction harness + * @output Proofs for the zoom stage beyond the finger's model: a fine + * pointer, Reduce Motion, a mouse drag past the edge, and zoom off. Each + * test's name says what it proves + * @position Interaction tests for Lightbox (browser), beside + * LightboxZoom.interaction.browser.spec.ts + */ + +import { + expect, + test, + type Browser, + type Locator, + type Page, +} from '@astryxdesign/a11y-spec/playwright'; +import { + curveOf, + drag, + flick, + frames, + holdAnimations, + openStory, + pinch, + releaseAnimations, + sampleCurve, + timeline, + touchDriver, + waitForAnimation, + waitForRest, + withStorybook, + type TouchScript, +} from '@astryxdesign/interaction-harness'; + +const storybook = withStorybook(test); + +/** Where the photo shows: its transform, size and stage. */ +async function readTransform(img: Locator) { + return img.evaluate(el => { + const style = getComputedStyle(el); + const [x = '0', y = '0'] = + style.translate === 'none' ? [] : style.translate.split(' '); + const parent = el.parentElement as HTMLElement; + const stage = parent.getBoundingClientRect(); + return { + x: parseFloat(x), + y: parseFloat(y), + scale: style.scale === 'none' ? 1 : parseFloat(style.scale), + width: (el as HTMLElement).offsetWidth, + left: (el as HTMLElement).offsetLeft, + stage: { + x: stage.x + parent.clientLeft, + y: stage.y + parent.clientTop, + width: parent.clientWidth, + height: parent.clientHeight, + }, + }; + }); +} + +/** The story on a desktop: a fine pointer (a mouse), no touch. */ +async function openDesktop(browser: Browser) { + const context = await browser.newContext({ + viewport: {width: 1280, height: 800}, + hasTouch: false, + isMobile: false, + }); + const page = await context.newPage(); + // A fine primary pointer in this context (the interaction config can report + // a coarse one without touch). + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + await routePhoto(page); + await openStory(page, storybook(), 'core-lightbox--with-zoom'); + await page.getByRole('button', {name: 'Open with zoom'}).click(); + const dialog = page.getByRole('dialog', {name: 'Forest path'}); + await expect(dialog).toBeVisible(); + const img = dialog.getByRole('img', {name: 'Forest path'}); + await decoded(img); + // Also the page's first contact with the curve recorder, so an animation a + // test's first action starts is recorded however soon it ends. + await waitForRest(img); + await frames(page, 2); + return {context, page, img, read: async () => readTransform(img)}; +} + +async function openPhone(page: Page, size = {width: 3000, height: 2000}) { + await page.route('https://picsum.photos/**', async route => + route.fulfill({ + contentType: 'image/svg+xml', + body: photoSvg(size.width, size.height), + }), + ); + await openStory(page, storybook(), 'core-lightbox--with-zoom'); + const driver = await touchDriver(page); + const trigger = page.getByRole('button', {name: 'Open with zoom'}); + const box = await trigger.boundingBox(); + if (box == null) { + throw new Error('no trigger'); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + const dialog = page.getByRole('dialog', {name: 'Forest path'}); + await expect(dialog).toBeVisible(); + const img = dialog.getByRole('img', {name: 'Forest path'}); + await expect + .poll(async () => img.evaluate(el => (el as HTMLImageElement).naturalWidth)) + .toBe(size.width); + await waitForRest(img); + await frames(page, 2); + const read = async () => readTransform(img); + return {driver, img, read}; +} + +function photoSvg(width: number, height: number): string { + return ``; +} + +async function routePhoto(page: Page) { + await page.route('https://picsum.photos/**', async route => + route.fulfill({contentType: 'image/svg+xml', body: photoSvg(3000, 2000)}), + ); +} + +async function decoded(img: Locator) { + await expect + .poll(async () => img.evaluate(el => (el as HTMLImageElement).naturalWidth)) + .toBe(3000); +} + +function doubleTap(at: {x: number; y: number}): TouchScript { + return [ + {t: 0, fingers: [{id: 0, ...at}]}, + {t: 50, fingers: []}, + {t: 170, fingers: [{id: 0, ...at}]}, + {t: 220, fingers: []}, + ]; +} + +test('with a fine pointer (a desktop) the stage fills the viewer, as under a coarse pointer: its gestures take the whole viewer', async ({ + browser, +}) => { + const context = await browser.newContext({ + viewport: {width: 1280, height: 800}, + hasTouch: false, + isMobile: false, + }); + try { + const page = await context.newPage(); + await routePhoto(page); + await openStory(page, storybook(), 'core-lightbox--with-zoom'); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + await page.getByRole('button', {name: 'Open with zoom'}).click(); + const dialog = page.getByRole('dialog', {name: 'Forest path'}); + await expect(dialog).toBeVisible(); + const stage = dialog.getByRole('button', {name: 'Zoom'}); + 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. + const layout = await stage.evaluate(el => { + const own = getComputedStyle(el); + const group = getComputedStyle(el.parentElement as HTMLElement); + return { + stage: [own.alignSelf, own.flexGrow], + group: [group.alignSelf, group.flexGrow], + }; + }); + expect(layout.stage).toEqual(['stretch', '1']); + expect(layout.group).toEqual(['stretch', '1']); + // The photo fits inside the viewer, as before. + const imgBox = await img.boundingBox(); + if (imgBox == null) { + throw new Error('no photo'); + } + expect(imgBox.width).toBeLessThanOrEqual(1280); + expect(imgBox.height).toBeLessThanOrEqual(800); + expect(imgBox.width / imgBox.height).toBeCloseTo(1.5, 2); + } finally { + await context.close(); + } +}); + +test('under Reduce Motion a double tap cross-fades to its zoom instead of travelling', async ({ + page, +}) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + await routePhoto(page); + await openStory(page, storybook(), 'core-lightbox--with-zoom'); + const driver = await touchDriver(page); + const trigger = page.getByRole('button', {name: 'Open with zoom'}); + const box = await trigger.boundingBox(); + if (box == null) { + throw new Error('no trigger'); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + const dialog = page.getByRole('dialog', {name: 'Forest path'}); + await expect(dialog).toBeVisible(); + const img = dialog.getByRole('img', {name: 'Forest path'}); + await decoded(img); + await waitForRest(img); + await frames(page, 2); + const rect = await img.boundingBox(); + if (rect == null) { + throw new Error('no photo'); + } + await driver.play( + doubleTap({x: rect.x + rect.width / 2, y: rect.y + rect.height / 2}), + ); + await waitForAnimation(img); + const held = await holdAnimations(page, img); + const duration = Math.max(...held.map(a => a.curveEndMs)); + const samples = await sampleCurve(img, timeline(duration)); + await releaseAnimations(page); + // The photo fades out and back in; the new scale lands while it is + // transparent (design:reduced-motion DR1: no travel). DR2: on a touch + // screen, a fade of 200 ms or less. + const opacity = curveOf(samples, 'opacity').map(s => s.value); + expect(Math.min(...opacity)).toBeLessThan(0.1); + expect(duration).toBeLessThanOrEqual(205); + // The landing is a timed write between the fade out and the fade back in, + // with no animation running then. So wait for the photo to be opaque again. + await expect + .poll(async () => + img.evaluate(el => parseFloat(getComputedStyle(el).opacity)), + ) + .toBe(1); + expect( + await img.evaluate(el => parseFloat(getComputedStyle(el).scale)), + ).toBeGreaterThan(1.5); +}); + +test('with a mouse a zoomed drag follows the pointer and stops at the photo’s edge, with no give', async ({ + browser, +}) => { + const {context, page, img, read} = await openDesktop(browser); + try { + const before = await read(); + const y = before.stage.y + before.stage.height / 2; + const left = before.stage.x + 100; + // 2x about the centre from the keyboard (a double-click zooms 2× about + // the clicked point), with room to drag past an edge. + await page + .getByRole('dialog') + .getByRole('button', {name: 'Zoom', exact: true}) + .focus(); + await page.keyboard.press('+'); + // On a loaded machine the keyboard's 200 ms zoom can start and end with + // no page frame between, where the recorder sees nothing. So the test + // waits for the zoomed scale itself. + await expect.poll(async () => (await read()).scale).toBeGreaterThan(1.5); + await waitForRest(img); + const zoomed = await read(); + expect(zoomed.scale).toBeGreaterThan(1.5); + // The photo's left edge at the stage's: x is minus the fitted photo's + // offset in the stage (0 where the stage hugs it). + const edge = -(zoomed.stage.width - zoomed.width) / 2; + // Right by 600 px: far past the photo's left edge. + await page.mouse.move(left, y); + await page.mouse.down(); + const xs: number[] = []; + for (let i = 1; i <= 20; i += 1) { + await page.mouse.move(left + i * 30, y); + xs.push((await read()).x); + } + await page.mouse.up(); + // The give past the edge belongs to the touch presentation. The photo + // follows the pointer to its edge and stays there. + expect(xs.at(-1)).toBeCloseTo(edge, 1); + expect(Math.max(...xs)).toBeLessThan(edge + 0.5); + await waitForRest(img); + expect((await read()).x).toBeCloseTo(edge, 1); + } finally { + await context.close(); + } +}); + +test('with a mouse the zoom-in cursor shows over the fitted photo, not over the dark area beside it, as on main', async ({ + browser, +}) => { + const {context, page, img, read} = await openDesktop(browser); + try { + const {stage} = await read(); + const photo = await img.boundingBox(); + if (photo == null) { + throw new Error('no photo'); + } + // The fitted photo leaves dark stage on its left, where a click closes. + expect(photo.x - stage.x).toBeGreaterThan(20); + const cursorAt = async (x: number, y: number) => { + await page.mouse.move(x, y); + return img.evaluate( + el => getComputedStyle(el.parentElement as HTMLElement).cursor, + ); + }; + const y = photo.y + photo.height / 2; + expect(await cursorAt(stage.x + 20, y)).toBe('default'); + expect(await cursorAt(photo.x + photo.width / 2, y)).toBe('zoom-in'); + expect(await cursorAt(stage.x + 20, y)).toBe('default'); + } finally { + await context.close(); + } +}); + +test('with a mouse a zoomed drag follows the pointer’s whole travel from the press, with no lasting offset', async ({ + browser, +}) => { + const {context, page, img, read} = await openDesktop(browser); + try { + // 2x about the centre from the keyboard, with room to drag both ways. + await page + .getByRole('dialog') + .getByRole('button', {name: 'Zoom', exact: true}) + .focus(); + await page.keyboard.press('+'); + // On a loaded machine the keyboard's 200 ms zoom can start and end with + // no page frame between, where the recorder sees nothing. So the test + // waits for the zoomed scale itself. + await expect.poll(async () => (await read()).scale).toBeGreaterThan(1.5); + await waitForRest(img); + const zoomed = await read(); + expect(zoomed.scale).toBeGreaterThan(1.5); + const press = { + x: zoomed.stage.x + zoomed.stage.width / 2, + y: zoomed.stage.y + zoomed.stage.height / 2, + }; + await page.mouse.move(press.x, press.y); + await page.mouse.down(); + // Small steps from the press. Main's drag moved the photo by the + // pointer's whole travel from the press. + for (let i = 1; i <= 10; i += 1) { + await page.mouse.move(press.x + i * 4, press.y + i * 3); + if (i === 1) { + // The first 5 px move already pans: a finger's 10 px threshold + // does not apply to a mouse. + const first = await read(); + expect(first.x - zoomed.x).toBeCloseTo(4, 0); + expect(first.y - zoomed.y).toBeCloseTo(3, 0); + } + } + const moved = await read(); + await page.mouse.up(); + expect(moved.x - zoomed.x).toBeCloseTo(40, 0); + expect(moved.y - zoomed.y).toBeCloseTo(30, 0); + } finally { + await context.close(); + } +}); + +test('under Reduce Motion, + then - pressed mid-fade leave the photo at full opacity, also after reopening', async ({ + page, +}) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + const {img} = await openPhone(page); + const dialog = page.getByRole('dialog', {name: 'Forest path'}); + const opacity = async () => + img.evaluate(el => parseFloat(getComputedStyle(el).opacity)); + await dialog.getByRole('button', {name: 'Zoom'}).focus(); + // The second key lands mid-dip of the first one's cross-fade. + await page.keyboard.press('+'); + await expect.poll(opacity, {timeout: 2000}).toBeLessThan(0.9); + await page.keyboard.press('-'); + await waitForRest(img); + expect(await opacity()).toBeCloseTo(1, 3); + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); + await page.getByRole('button', {name: 'Open with zoom'}).click(); + await expect(dialog).toBeVisible(); + await waitForRest(img); + await frames(page, 2); + expect(await opacity()).toBeCloseTo(1, 3); +}); + +test('with zoom off, a tap on the dark area around the photo closes the viewer on a touch screen', async ({ + page, +}) => { + await routePhoto(page); + await openStory(page, storybook(), 'core-lightbox--default'); + const driver = await touchDriver(page); + const trigger = page.getByRole('button', {name: 'Open lightbox'}); + const box = await trigger.boundingBox(); + if (box == null) { + throw new Error('no trigger'); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + const img = dialog.getByRole('img'); + await decoded(img); + await frames(page, 2); + const photo = await img.boundingBox(); + const viewport = page.viewportSize(); + if (photo == null || viewport == null) { + throw new Error('no photo'); + } + // Below the photo: the dark area, not a stage stretched over it. + await driver.tap({ + x: photo.x + photo.width / 2, + y: Math.min(photo.y + photo.height + 80, viewport.height - 20), + }); + await expect(dialog).toBeHidden(); +}); + +test('under Reduce Motion a keyboard zoom with a fine pointer completes at once', async ({ + browser, +}) => { + // design:reduced-motion DR3: on any pointer but a coarse one, at once. + const context = await browser.newContext({ + viewport: {width: 1280, height: 800}, + hasTouch: false, + isMobile: false, + reducedMotion: 'reduce', + }); + try { + const page = await context.newPage(); + // A fine primary pointer in this context (the interaction config can report + // a coarse one without touch). + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + await routePhoto(page); + await openStory(page, storybook(), 'core-lightbox--with-zoom'); + await page.getByRole('button', {name: 'Open with zoom'}).click(); + const dialog = page.getByRole('dialog', {name: 'Forest path'}); + await expect(dialog).toBeVisible(); + const img = dialog.getByRole('img', {name: 'Forest path'}); + await decoded(img); + await frames(page, 2); + await dialog.getByRole('button', {name: 'Zoom'}).focus(); + await page.keyboard.press('+'); + await frames(page, 1); + const now = await img.evaluate(el => ({ + running: el.getAnimations().filter(a => a.playState === 'running').length, + scale: parseFloat(getComputedStyle(el).scale), + opacity: parseFloat(getComputedStyle(el).opacity), + })); + expect(now.running).toBe(0); + expect(now.scale).toBeGreaterThan(1.5); + expect(now.opacity).toBe(1); + } finally { + await context.close(); + } +}); + +test('under Reduce Motion a pinch released past the maximum settles in place, with no travel', async ({ + page, +}) => { + // design:reduced-motion DR1–DR2: what a release starts fades; it never + // moves or scales on its way back. + await page.emulateMedia({reducedMotion: 'reduce'}); + // A small photo: its maximum zoom (under 4.5x at any pixel ratio) is + // well within the pinch's 10x. + const {driver, img, read} = await openPhone(page, {width: 300, height: 200}); + const s = await read(); + const centre = { + x: s.stage.x + s.stage.width / 2, + y: s.stage.y + s.stage.height / 2, + }; + let released = 1; + await driver.play( + pinch({ + center: centre, + fromDistance: 60, + toDistance: 600, + durationMs: 400, + restMs: 80, + }), + { + onEvent: async step => { + if (step.type === 'touchMove') { + released = (await read()).scale; + } + }, + }, + ); + await waitForAnimation(img); + const held = await holdAnimations(page, img); + const duration = Math.max(...held.map(a => a.curveEndMs)); + const opacity = curveOf( + await sampleCurve(img, timeline(duration)), + 'opacity', + ).map(x => x.value); + await releaseAnimations(page); + // It fades out and back in while the scale lands at the maximum, + // transparent: no travel back. + expect(duration).toBeLessThanOrEqual(205); + expect(Math.min(...opacity)).toBeLessThan(0.1); + // The scale lands in a timed write while the photo is transparent: wait + // for it, then for the photo to be opaque again. + await expect.poll(async () => (await read()).scale).toBeLessThan(released); + await expect + .poll(async () => + img.evaluate(el => parseFloat(getComputedStyle(el).opacity)), + ) + .toBe(1); + expect((await read()).scale).toBeGreaterThan(1.5); +}); + +/** The photo's box and its stage's, in page px. */ +async function edges(img: Locator) { + return img.evaluate(el => { + const photo = el.getBoundingClientRect(); + const stage = (el.parentElement as HTMLElement).getBoundingClientRect(); + return { + photo: {left: photo.left, right: photo.right}, + stage: {left: stage.left, right: stage.right}, + }; + }); +} + +test('under Reduce Motion a zoomed flick coasts on and settles at the edge, as on iOS', async ({ + page, +}) => { + // Momentum that continues a throw is the person's own movement + // (design:reduced-motion DR2, DEC-2). So it coasts and settles at an edge, + // with no cross-fade. + await page.emulateMedia({reducedMotion: 'reduce'}); + const {driver, img, read} = await openPhone(page); + const s = await read(); + const mid = { + x: s.stage.x + s.stage.width / 2, + y: s.stage.y + s.stage.height / 2, + }; + await driver.play(doubleTap(mid)); + await waitForRest(img); + await frames(page, 2); + let lifted = Number.NaN; + await driver.play(flick(mid, {velocity: {x: -1500, y: 0}, durationMs: 80}), { + onEvent: async step => { + if (step.type === 'touchEnd') { + lifted = (await read()).x; + } + }, + }); + const opacities = await img.evaluate( + async el => + new Promise(resolve => { + const seen: number[] = []; + const start = performance.now(); + const tick = (now: number) => { + seen.push(parseFloat(getComputedStyle(el).opacity)); + if (now - start < 600) { + requestAnimationFrame(tick); + } else { + resolve(seen); + } + }; + requestAnimationFrame(tick); + }), + ); + await waitForRest(img); + // It coasted on past where the finger lifted. + expect((await read()).x).toBeLessThan(lifted - 20); + // No cross-fade. + expect(Math.min(...opacities)).toBe(1); + // It rests with no gap at the stage's edges. + const {photo, stage} = await edges(img); + expect(photo.left).toBeLessThanOrEqual(stage.left + 0.5); + expect(photo.right).toBeGreaterThanOrEqual(stage.right - 0.5); +}); + +test('under Reduce Motion a zoomed photo pulled past its edge settles back to it', async ({ + page, +}) => { + // The coast's settle at an edge (DR2): released still past the edge, the + // photo returns to it instead of staying out. + await page.emulateMedia({reducedMotion: 'reduce'}); + const {driver, img, read} = await openPhone(page); + const s = await read(); + const mid = { + x: s.stage.x + s.stage.width / 2, + y: s.stage.y + s.stage.height / 2, + }; + await driver.play(doubleTap(mid)); + await waitForRest(img); + await frames(page, 2); + // One slow drag right across the stage: the photo, centred and zoomed, + // moves until its left edge reaches the stage's, then gives past it. + const zoomed = await edges(img); + let pastAtLift = Number.NaN; + await driver.play( + drag( + {x: zoomed.stage.left + 6, y: mid.y}, + {x: zoomed.stage.right - 6, y: mid.y}, + {durationMs: 600, restMs: 150}, + ), + { + onEvent: async step => { + if (step.type === 'touchEnd') { + const now = await edges(img); + pastAtLift = now.photo.left - now.stage.left; + } + }, + }, + ); + expect(pastAtLift).toBeGreaterThan(10); + await waitForRest(img); + const {photo, stage} = await edges(img); + expect(photo.left).toBeLessThanOrEqual(stage.left + 0.5); +}); diff --git a/packages/core/src/Lightbox/useZoomCore.ts b/packages/core/src/Lightbox/useZoomCore.ts new file mode 100644 index 0000000000000..97c296346c339 --- /dev/null +++ b/packages/core/src/Lightbox/useZoomCore.ts @@ -0,0 +1,486 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file useZoomCore.ts + * @input A stage element ref (clipping and centring the photo), the current + * item's key, and the zoom and fit callbacks + * @output Exports `useZoomCore`: Lightbox's zoom stage for every pointer. It + * writes the photo's transform through the compositor animator and plays + * the eased settle and the keyboard's zoom and pan + * @position Lightbox's shared zoom stage, internal. useZoomStage.ts drives + * its pointer paths; the geometry is in zoomStage.ts. + * + * SYNC: When modified, update useZoomStage.ts, zoomStage.ts, Lightbox.tsx + * and __tests__/LightboxZoomModes.interaction.browser.spec.ts. + */ + +import {useCallback, useMemo, useRef, useState, type RefObject} from 'react'; +import type {Point} from '../gestures/types'; +import {useAttachment} from '../hooks/useAttachment'; +import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; +import { + createMotionAnimator, + type Animator, + type MotionCurve, +} from '../motion/animator'; +import {mapProgress, mapRange} from '../motion/progress'; +import {resolveMotion} from '../motion/resolveMotion'; +import {readThemeTiming, type ThemeTimingTokens} from '../motion/themeTiming'; +import {registerReducedMotionCrossFade} from '../motion/uikit/timing'; +import {zoomMedia} from '../motion/uikit/zoomMedia'; +import {observeResize} from '../utils/sharedResizeObserver'; +import { + IDENTITY, + RELEASED_ZOOM, + doubleClickTarget, + doubleTapScaleFor, + isZoomed, + maxScaleFor, + pannedBy, + releasedZoomTarget, + restingTransform, + settleCurves, + transformForResize, + type StagePoint, + type ZoomGeometry, + type ZoomTransform, +} from './zoomStage'; + +/** Set on the stage while a finger or the mouse drags a zoomed photo. */ +export const ZOOM_DRAGGING_ATTRIBUTE = 'data-zoom-dragging'; + +export interface UseZoomCoreOptions { + /** The stage: the photo's clipping parent, which centres it. */ + stageRef: RefObject; + /** Changes when another item shows; the photo returns to the fitted size. */ + 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. */ +export interface ZoomStage { + /** Callback ref for the photo element the stage moves and scales. */ + readonly contentRef: (element: HTMLElement | null) => void; + /** Whether the photo is zoomed now. */ + isZoomed(): boolean; + /** Keyboard: zoom in 2x about the stage's centre, or out when zoomed. */ + toggle(): void; + zoomIn(): void; + zoomOut(): void; + /** Keyboard: move a zoomed photo by (dx, dy) px, kept inside the stage. */ + panBy(dx: number, dy: number): void; + /** + * A mouse's or a pen's double-click at a client point: 2× about it, or + * back when zoomed. Lightbox calls it on the browser's `dblclick`, which + * keeps the system's double-click speed. + */ + doubleClickAt(clientX: number, clientY: number): void; +} + +/** The core as the gestures (useZoomStage.ts) drive it. */ +export interface ZoomCore { + /** The API Lightbox calls (one object for the component's lifetime). */ + readonly api: ZoomStage; + readonly animator: Animator; + /** The last measured geometry (null until a step measures it). */ + readonly geometryRef: {current: ZoomGeometry | null}; + /** Where the photo is going: the last written value or the settle's end. */ + readonly targetRef: {current: ZoomTransform}; + readonly zoomedRef: {current: boolean}; + readonly optionsRef: {current: UseZoomCoreOptions}; + /** Reads the stage and the photo (layout): when a step begins. */ + measure(): ZoomGeometry | null; + /** Client px to stage px, from the last measure. */ + toStage(point: Point): StagePoint; + /** Where the photo shows now (its presentation value). */ + current(): ZoomTransform; + /** Shows the photo at `transform` at once. */ + write(transform: ZoomTransform): void; + /** Where the photo is going, when an animation the core did not start ends there. */ + setTarget(transform: ZoomTransform): void; + setZoomed(next: boolean): void; + /** + * UIScrollView's eased settle (a release past a limit, a zoom to a rect). + * With `released` it plays Lightbox's released 200 ms ease-out instead, + * the keyboard's (see RELEASED_ZOOM). With `tapped` it plays the theme's + * move easing and medium duration, as a finger's double tap does. Under + * Reduce Motion the animator's default applies. That is a fade in place on + * a touch screen or for a finger's release (`touchRelease`), and at once + * otherwise. + */ + settleTo( + geometry: ZoomGeometry, + target: ZoomTransform, + options?: {touchRelease?: boolean; released?: boolean; tapped?: boolean}, + ): void; +} + +/** A tap-started zoom plays the theme's move easing over its medium duration. */ +const TAP_ZOOM_TIMING: ThemeTimingTokens = { + durations: ['--duration-medium'], + easing: '--ease-move', +}; + +/** + * The zoom a tap starts. Each channel follows the same eased progress, so the + * tapped point stays in place all the way. + */ +function tapCurves( + from: ZoomTransform, + to: ZoomTransform, + progress: MotionCurve, +): { + readonly x: MotionCurve; + readonly y: MotionCurve; + readonly scale: MotionCurve; +} { + const along = (a: number, b: number) => + mapProgress(progress, mapRange([0, 1], [a, b], {clamp: false})); + return { + x: along(from.x, to.x), + y: along(from.y, to.y), + scale: along(from.scale, to.scale), + }; +} + +/** + * Lightbox's zoom stage. Every zoom step runs on the compositor as baked + * keyframes, with no React render and no layout read after a step begins. A + * keyboard step continues from where the photo is going, so presses during a + * settle add up. React hears only discrete changes (`onZoomedChange`). + */ +export function useZoomCore(options: UseZoomCoreOptions): ZoomCore { + // One animator for the component's lifetime; the photo's ref attaches it. + const [animator] = useState(() => createMotionAnimator(null)); + const optionsRef = useRef(options); + useIsomorphicLayoutEffect(() => { + optionsRef.current = options; + }); + const contentElementRef = useRef(null); + const geometryRef = useRef(null); + /** Client px of the stage's top-left corner when last measured. */ + const originRef = useRef({x: 0, y: 0}); + const zoomedRef = useRef(false); + const targetRef = useRef(IDENTITY); + + const contentRef = useCallback( + (element: HTMLElement | null) => { + contentElementRef.current = element; + animator.attach(element); + }, + [animator], + ); + + // Layout reads happen here only: when a gesture or a keyboard zoom begins. + // Sizes are read unrounded (offset sizes round to whole pixels, an error + // a deep zoom would multiply): the stage's box and the photo's used size. + // The stage centres the photo, so its fitted box follows from the two. + const measure = useCallback((): ZoomGeometry | null => { + const stage = optionsRef.current.stageRef.current; + const content = contentElementRef.current; + const view = stage?.ownerDocument.defaultView; + if (stage == null || content == null || view == null) { + return null; + } + const rect = stage.getBoundingClientRect(); + // The stage has no border or scrollbar: its box is its content box. + const width = rect.width > 0 ? rect.width : stage.clientWidth; + const height = rect.height > 0 ? rect.height : stage.clientHeight; + const used = view.getComputedStyle(content); + const fitWidth = parseFloat(used.width) || content.offsetWidth; + const fitHeight = parseFloat(used.height) || content.offsetHeight; + if (!(width > 0 && height > 0 && fitWidth > 0 && fitHeight > 0)) { + return null; + } + originRef.current = { + x: rect.left + stage.clientLeft, + y: rect.top + stage.clientTop, + }; + // By tag, not `instanceof`: a viewer rendered into another window has + // that window's element classes. + const naturalWidth = + content.tagName === 'IMG' + ? (content as HTMLImageElement).naturalWidth + : 0; + const natural = naturalWidth > 0 ? naturalWidth : fitWidth; + const doubleTapScale = doubleTapScaleFor( + width, + height, + fitWidth, + fitHeight, + ); + const geometry: ZoomGeometry = { + width, + height, + fit: { + x: (width - fitWidth) / 2, + y: (height - fitHeight) / 2, + width: fitWidth, + height: fitHeight, + }, + minScale: 1, + maxScale: maxScaleFor( + natural, + fitWidth, + view.devicePixelRatio || 1, + doubleTapScale, + ), + doubleTapScale, + }; + geometryRef.current = geometry; + return geometry; + }, []); + + const toStage = useCallback( + (point: Point): StagePoint => ({ + x: point.x - originRef.current.x, + y: point.y - originRef.current.y, + }), + [], + ); + + const current = useCallback( + (): ZoomTransform => ({ + x: animator.get('x'), + y: animator.get('y'), + scale: animator.get('scale'), + }), + [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], + ); + + const setTarget = useCallback((transform: ZoomTransform) => { + targetRef.current = transform; + }, []); + + const setZoomed = useCallback((next: boolean) => { + if (zoomedRef.current !== next) { + zoomedRef.current = next; + optionsRef.current.onZoomedChange(next); + } + }, []); + + const settleTo = useCallback( + ( + geometry: ZoomGeometry, + target: ZoomTransform, + { + touchRelease = false, + released = false, + tapped = false, + }: {touchRelease?: boolean; released?: boolean; tapped?: boolean} = {}, + ) => { + const from = current(); + animator.stop(); + // A Reduce Motion cross-fade stopped mid-dip would leave the photo dim, + // since the next dip returns to the frozen opacity. So every settle + // starts from full opacity, and ends there. + if (animator.get('opacity') !== 1) { + animator.set({opacity: 1}); + } + targetRef.current = target; + setZoomed(isZoomed(geometry, target)); + if ( + from.x === target.x && + 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(); + // A pinch's settle plays the `timing.curves.move` role, and the mouse's + // and the keyboard's zoom plays the released ease-out. A tap starts no + // gesture, so its zoom plays the theme's move easing and medium + // duration, read where the stage that took the tap is. + const stageElement = optionsRef.current.stageRef.current; + const curves = released + ? settleCurves(from, target, RELEASED_ZOOM.curve, RELEASED_ZOOM.seconds) + : tapped && stageElement != null + ? tapCurves( + from, + target, + readThemeTiming(stageElement, TAP_ZOOM_TIMING), + ) + : settleCurves( + from, + target, + resolveMotion(stageElement, zoomMedia.settle.curve), + ); + const handle = animator.play( + {x: curves.x, y: curves.y, scale: curves.scale}, + {touchRelease}, + ); + if (isAtFit(target)) { + void handle.finished.then(landed => { + if (landed && targetRef.current === target) { + setAtFit(true); + } + }); + } + }, + [animator, current, setAtFit, setZoomed], + ); + + // Another item: back to the fitted size before it paints. Lightbox resets + // its own zoomed state during that render. + useIsomorphicLayoutEffect(() => { + animator.stop(); + animator.set({x: 0, y: 0, scale: 1, opacity: 1}); + targetRef.current = IDENTITY; + zoomedRef.current = false; + geometryRef.current = null; + setAtFit(true); + }, [animator, options.itemKey, setAtFit]); + + // A rotation or resize keeps the same photo point centred at the same + // on-screen size (the shared resize observer). + const onResize = useCallback(() => { + const before = geometryRef.current; + if (before == null || !zoomedRef.current) { + return; + } + const after = measure(); + if (after == null) { + return; + } + animator.stop(); + const next = transformForResize(before, after, targetRef.current); + write(next); + setZoomed(isZoomed(after, next)); + }, [animator, measure, setZoomed, write]); + // The stage mounts with the viewer's first media, which can come after the + // viewer, as in a gallery mounted closed while its photos load. + // `useAttachment` follows the element after every commit. + const onResizeRef = useRef(onResize); + useIsomorphicLayoutEffect(() => { + onResizeRef.current = onResize; + }); + useAttachment( + options.stageRef, + stage => observeResize(stage, () => onResizeRef.current()), + null, + ); + + // The keyboard works on where the photo is going, like UIKit's model + // values: a press during a settle continues from that settle's end. + const zoomIn = useCallback(() => { + const geometry = measure(); + if (geometry == null || zoomedRef.current) { + return; + } + settleTo(geometry, releasedZoomTarget(geometry, targetRef.current), { + released: true, + }); + }, [measure, settleTo]); + + const zoomOut = useCallback(() => { + const geometry = measure(); + if (geometry == null || !zoomedRef.current) { + return; + } + settleTo( + geometry, + restingTransform(geometry, {...IDENTITY, scale: geometry.minScale}), + {released: true}, + ); + }, [measure, settleTo]); + + const api = useMemo( + (): ZoomStage => ({ + contentRef, + isZoomed: () => zoomedRef.current, + toggle: () => (zoomedRef.current ? zoomOut() : zoomIn()), + zoomIn, + zoomOut, + panBy: (dx: number, dy: number) => { + const geometry = measure(); + if (geometry == null || !zoomedRef.current) { + return; + } + settleTo(geometry, pannedBy(geometry, targetRef.current, dx, dy), { + released: true, + }); + }, + doubleClickAt: (clientX: number, clientY: number) => { + const geometry = measure(); + if (geometry == null) { + return; + } + const point = toStage({x: clientX, y: clientY}); + settleTo(geometry, doubleClickTarget(geometry, current(), point), { + released: true, + }); + }, + }), + [contentRef, current, measure, settleTo, toStage, zoomIn, zoomOut], + ); + + // One object for the component's lifetime: every member is stable. + return useMemo( + () => ({ + api, + animator, + geometryRef, + targetRef, + zoomedRef, + optionsRef, + measure, + toStage, + current, + write, + setTarget, + setZoomed, + settleTo, + }), + [ + api, + animator, + measure, + toStage, + current, + write, + setTarget, + setZoomed, + settleTo, + ], + ); +} diff --git a/packages/core/src/Lightbox/useZoomStage.ts b/packages/core/src/Lightbox/useZoomStage.ts new file mode 100644 index 0000000000000..98475d8b9d2fb --- /dev/null +++ b/packages/core/src/Lightbox/useZoomStage.ts @@ -0,0 +1,502 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file useZoomStage.ts + * @input The zoom core (useZoomCore.ts), the stage element ref, whether the + * gestures and the touch presentation are on, and tap and drag callbacks + * @output Exports `useZoomStage`: Lightbox's gestures, those of a zooming + * UIScrollView for every pointer, on top of the core, whose API it returns + * @position Lightbox's gesture module, internal, with one import site + * (Lightbox.tsx). It builds on the gesture recognizers and the core's + * animator; the geometry is in zoomStage.ts. + * + * SYNC: When modified, update useZoomCore.ts, zoomStage.ts, Lightbox.tsx and + * __tests__/LightboxZoom.interaction.browser.spec.ts. + */ + +import {useRef, useState, type RefObject} from 'react'; +import {LongPressGestureRecognizer} from '../gestures/LongPressGestureRecognizer'; +import {PanGestureRecognizer} from '../gestures/PanGestureRecognizer'; +import {PinchGestureRecognizer} from '../gestures/PinchGestureRecognizer'; +import {TapGestureRecognizer} from '../gestures/TapGestureRecognizer'; +import type {Point} from '../gestures/types'; +import {useGestureRecognizer} from '../gestures/useGestureRecognizer'; +import {UIKIT_PAN} from '../motion/uikit/gestures'; +import {coast as coastCurves} from '../motion/coast'; +import {decayRestDistance} from '../motion/decay'; +import { + doubleTapTarget, + draggedTransform, + isZoomed, + overshootScale, + pinchScaleFor, + rawForDragged, + releaseTarget, + restRange, + transformKeepingPoint, + type StagePoint, + type ZoomGeometry, + type ZoomTransform, +} from './zoomStage'; +import { + ZOOM_DRAGGING_ATTRIBUTE, + type ZoomCore, + type ZoomStage, +} from './useZoomCore'; + +export {ZOOM_DRAGGING_ATTRIBUTE, type ZoomStage}; + +export interface UseZoomStageOptions { + /** The stage: the photo's clipping parent, which centres it. */ + stageRef: RefObject; + /** The gestures are on (zoom is on), for every pointer. */ + isEnabled: boolean; + /** A finger's single tap landed outside the photo. */ + onTapOutside?: () => void; + /** A pan began (a release over the backdrop must not close). */ + onDragStart?: () => void; + /** + * The touch presentation is on. A pan's give past the edges and its coast + * belong to it. Otherwise a drag or a trackpad's scroll pans 1:1, and it + * stops at the edges and at the release. + */ + isTouchPresentation?: boolean; +} + +interface PinchSession { + readonly start: ZoomTransform; + /** The pinch's own scale the start corresponds to (past a limit, more). */ + readonly startPinchScale: number; + readonly anchor: StagePoint; + centroid: StagePoint; +} + +interface PanSession { + /** The pointer-travel transform when the session started. */ + readonly raw: ZoomTransform; + readonly translation: Point; + /** It gives past the edges and coasts at the release. */ + readonly hasGive: boolean; +} + +/** Every pointer: a mouse, a pen and a finger. */ +const EVERY_POINTER = ['touch', 'pen', 'mouse'] as const; +/** Fingers only: a pen does not pinch. A ctrl-wheel pinches too. */ +const PINCH_POINTERS = ['touch'] as const; +/** Fingers only: a mouse's or a pen's double-click is the browser's. */ +const DOUBLE_TAP_POINTERS = ['touch'] as const; +/** A mouse's or a pen's click beside the photo is the browser's `click`. */ +const TAP_POINTERS = ['touch'] as const; + +/** Sets the zoom pan's begin threshold for the press that just landed. */ +function setBeginThreshold(pan: PanGestureRecognizer, px: number): void { + pan.hysteresis = px; +} + +/** + * Lightbox's zoom gestures. They take every pointer: a mouse, a pen and a + * finger alike. The pinch is the fingers', and a ctrl-wheel pinches too. The + * pan also takes a trackpad's scroll; a mouse wheel's notches never pan. The + * keyboard is the core's. + * + * Tracking writes the photo's `translate` and `scale` once per input event, + * with no React render and no layout read after a gesture begins. Every + * release and double tap runs on the compositor as baked keyframes. A press + * on a moving photo takes it from its presentation value. + * + * The recognizers on the stage, as UIScrollView arranges its own: + * - pinch, with the pan, so two fingers zoom and move at once. A finger left + * down after the pinch keeps panning. + * - pan: one or two fingers, a mouse's or a pen's drag, or a trackpad's + * scroll. It moves a zoomed photo. In the touch presentation, its release + * carries a scroll view's velocity. + * - double tap, a finger's: it fills the viewer, at least 2×, and keeps the + * point in place. When zoomed it goes back to the fitted size. A mouse's or + * a pen's double-click is the browser's `dblclick` (the core's + * `doubleClickAt`). + * - single tap, which waits for a finger's double tap to fail. A tap outside + * the photo is reported, and Lightbox closes on it. A mouse's or a pen's + * click ends it at once. + * - a zero-duration press beside them all, UIKit's touch-down idiom. A press + * on a moving photo stops it where it shows, and a lift with no drag lets + * it settle. + * + * The stage's `touch-action` is the caller's (none while zoom is on), since a + * disabled recognizer would still claim it. + * + * It follows UIKit's measured behavior (motion/uikit/zoomMedia.ts). Inside the + * limits the zoom follows the pinch 1:1. The content point under the centroid + * stays under it, past the limits too. No scale momentum follows the lift. + * Past a limit, the return is one eased animation anchored at the last + * centroid. In the touch presentation, a zoomed pan coasts and bounces like a + * scroll view. A two-finger tap does nothing. + */ +export function useZoomStage( + core: ZoomCore, + options: UseZoomStageOptions, +): ZoomStage { + const { + animator, + geometryRef, + targetRef, + measure, + toStage, + current, + write, + setTarget, + setZoomed, + settleTo, + } = core; + const pinchRef = useRef(null); + const panRef = useRef(null); + /** A touch stopped a moving photo; a pan or pinch has since taken over. */ + const caughtRef = useRef(false); + const draggedSinceCatchRef = useRef(false); + /** A pan gives past the edges and coasts only in the touch presentation. */ + const hasGive = () => options.isTouchPresentation === true; + + /** + * A zoomed pan's release: UIScrollView's coast, bouncing at the edges. + */ + const coast = (geometry: ZoomGeometry, velocity: Point) => { + const from = current(); + // Under Reduce Motion too, a coast keeps its momentum and its settle at + // an edge. That is the person's own movement, and iOS keeps decelerating + // scrolls under Reduce Motion. The animator's policy leaves `decay` alone. + const range = restRange(geometry, from.scale); + const vx = range.x.min === range.x.max ? 0 : velocity.x; + const vy = range.y.min === range.y.max ? 0 : velocity.y; + const rest = (value: number, v: number, {min, max}: typeof range.x) => + Math.min(max, Math.max(min, value + decayRestDistance(v))); + setTarget({ + x: rest(from.x, vx, range.x), + y: rest(from.y, vy, range.y), + scale: from.scale, + }); + setZoomed(isZoomed(geometry, from)); + animator.decay( + coastCurves(animator, {x: vx, y: vy}, {bounds: {x: range.x, y: range.y}}), + ); + }; + + // Whether a finger began each gesture, read from its first touch as it + // begins. Under reduced motion a finger's release cross-fades. A mouse's, a + // trackpad's or a pen's release completes at once where the primary pointer + // is fine. + const fingerRef = useRef({pinch: false, catch: false}); + const [recognizers] = useState(() => { + const pinch = new PinchGestureRecognizer({ + name: 'Lightbox.zoomPinch', + allowedPointerTypes: PINCH_POINTERS, + }); + // A zoomed photo is a scroll view: its coast reads the pan's scroll-view + // release (`scrollVelocity()`), so it coasts as far as UIKit's. + const pan = new PanGestureRecognizer({ + name: 'Lightbox.zoomPan', + maximumNumberOfTouches: 2, + allowedPointerTypes: EVERY_POINTER, + allowedScrollTypes: ['continuous'], + // A mouse, a pen or a trackpad pans only a zoomed photo. On a fitted + // photo it starts no pan, so its press and release stay a click. + shouldBegin: recognizer => { + if (recognizer.trackedTouches[0]?.pointerType === 'touch') { + return true; + } + const geometry = geometryRef.current; + return geometry != null && isZoomed(geometry, current()); + }, + }); + // A finger's double tap. A mouse's or a pen's double-click is the + // browser's `dblclick`, which keeps the system's double-click speed. + const doubleTap = new TapGestureRecognizer({ + name: 'lightbox-zoom-double-tap', + numberOfTapsRequired: 2, + allowedPointerTypes: DOUBLE_TAP_POINTERS, + }); + const tap = new TapGestureRecognizer({ + name: 'lightbox-zoom-tap', + allowedPointerTypes: TAP_POINTERS, + }); + const touchDown = new LongPressGestureRecognizer({ + name: 'lightbox-zoom-touch-down', + minimumPressDuration: 0, + allowableMovement: Number.POSITIVE_INFINITY, + cancelsTouchesInView: false, + delaysTouchesEnded: false, + allowedPointerTypes: EVERY_POINTER, + }); + pinch.recognizeSimultaneouslyWith(pan); + tap.requireToFail(doubleTap); + for (const other of [pinch, pan, doubleTap, tap]) { + touchDown.recognizeSimultaneouslyWith(other); + } + return {pinch, pan, doubleTap, tap, touchDown}; + }); + + const recognizerOptions = {isEnabled: options.isEnabled, manageStyle: false}; + + useGestureRecognizer( + options.stageRef, + () => recognizers.pinch, + pinch => { + if (pinch.state === 'began') { + fingerRef.current.pinch = + pinch.trackedTouches[0]?.pointerType === 'touch'; + const geometry = measure(); + if (geometry == null) { + return; + } + draggedSinceCatchRef.current = true; + animator.stop(); + const start = current(); + const anchor = toStage(pinch.focalPoint()); + pinchRef.current = { + start, + startPinchScale: pinchScaleFor(geometry, start.scale), + anchor, + centroid: anchor, + }; + } + const session = pinchRef.current; + const geometry = geometryRef.current; + const isMoving = pinch.state === 'began' || pinch.state === 'changed'; + if (!isMoving) { + // The session ends with the pinch, even one that outlived its photo: + // a session left behind would block every later pan. + pinchRef.current = null; + } + if (session == null || geometry == null) { + return; + } + if (isMoving) { + session.centroid = toStage(pinch.focalPoint()); + const shown = overshootScale( + geometry, + session.startPinchScale * pinch.scale, + ); + write( + transformKeepingPoint( + geometry, + session.start, + session.anchor, + session.centroid, + shown, + ), + ); + return; + } + // Ended (a finger lifted) or cancelled (the browser took the touches). + const shown = current(); + const target = releaseTarget(geometry, shown, session.centroid); + if (target !== shown) { + // Past a limit: UIKit's eased return, anchored at the last centroid. + // A finger still down rests until it lands again. + panRef.current = null; + settleTo(geometry, target, { + touchRelease: fingerRef.current.pinch, + }); + return; + } + const pan = recognizers.pan; + if (pan.state === 'began' || pan.state === 'changed') { + // The finger left down keeps panning from here, without a jump. + const give = hasGive(); + panRef.current = { + raw: rawForDragged(geometry, shown), + translation: give ? pan.translation() : pan.totalTranslation(), + hasGive: give, + }; + setZoomed(isZoomed(geometry, shown)); + return; + } + coast(geometry, {x: 0, y: 0}); + }, + recognizerOptions, + ); + + useGestureRecognizer( + options.stageRef, + () => recognizers.pan, + pan => { + const stage = options.stageRef.current; + if (pan.state === 'began') { + const geometry = measure(); + if (geometry == null) { + return; + } + draggedSinceCatchRef.current = true; + animator.stop(); + options.onDragStart?.(); + const shown = current(); + if (isZoomed(geometry, shown)) { + stage?.setAttribute(ZOOM_DRAGGING_ATTRIBUTE, ''); + } + // The translation already leaves out the begin threshold's travel, + // and what the finger moved past it shows at once, as in UIKit. + // Without give, the photo follows the pointer's whole travel from the + // press, so it catches up the threshold's travel at once. + panRef.current = { + raw: rawForDragged(geometry, shown), + translation: {x: 0, y: 0}, + hasGive: hasGive(), + }; + } + if (pan.state === 'began' || pan.state === 'changed') { + const session = panRef.current; + const geometry = geometryRef.current; + if (pinchRef.current != null || session == null || geometry == null) { + return; + } + const translation = session.hasGive + ? pan.translation() + : pan.totalTranslation(); + const raw = { + x: session.raw.x + translation.x - session.translation.x, + y: session.raw.y + translation.y - session.translation.y, + scale: session.raw.scale, + }; + if (session.hasGive) { + write(draggedTransform(geometry, raw)); + return; + } + const range = restRange(geometry, raw.scale); + write({ + x: Math.min(range.x.max, Math.max(range.x.min, raw.x)), + y: Math.min(range.y.max, Math.max(range.y.min, raw.y)), + scale: raw.scale, + }); + return; + } + // Ended or cancelled. + stage?.removeAttribute(ZOOM_DRAGGING_ATTRIBUTE); + const session = panRef.current; + panRef.current = null; + const geometry = geometryRef.current; + if (pinchRef.current != null || session == null || geometry == null) { + return; + } + // The release carries the shared filter's velocity at the lift: a scroll + // view's, at rest after a silent hold. + coast( + geometry, + pan.state === 'ended' && session.hasGive + ? pan.scrollVelocity() + : {x: 0, y: 0}, + ); + }, + recognizerOptions, + ); + + useGestureRecognizer( + options.stageRef, + () => recognizers.touchDown, + touchDown => { + if (touchDown.state === 'began') { + const isFinger = touchDown.trackedTouches[0]?.pointerType === 'touch'; + fingerRef.current.catch = isFinger; + // A mouse's or a pen's drag on a zoomed photo pans from its first + // move. A finger keeps UIKit's begin threshold, which tells a tap from + // a pan. + const geometry = geometryRef.current; + setBeginThreshold( + recognizers.pan, + !isFinger && geometry != null && isZoomed(geometry, current()) + ? 0 + : UIKIT_PAN.hysteresis.value, + ); + draggedSinceCatchRef.current = false; + const shown = current(); + const target = targetRef.current; + const moving = + Math.abs(shown.x - target.x) > 0.01 || + Math.abs(shown.y - target.y) > 0.01 || + Math.abs(shown.scale - target.scale) > 0.0001; + caughtRef.current = moving; + if (moving) { + // Stopped where it shows: the presentation value, from the curve. + animator.stop(); + setTarget(shown); + } + return; + } + if (touchDown.state === 'changed') { + return; + } + // Lifted, or taken. With no drag since the catch, the photo settles from + // where it was stopped. It goes back inside the stage, or back to a zoom + // limit it passed. + const caught = caughtRef.current; + caughtRef.current = false; + const geometry = geometryRef.current ?? measure(); + if (!caught || draggedSinceCatchRef.current || geometry == null) { + return; + } + const shown = current(); + const limit = releaseTarget(geometry, shown, { + x: geometry.width / 2, + y: geometry.height / 2, + }); + if (limit !== shown) { + settleTo(geometry, limit, { + touchRelease: fingerRef.current.catch, + }); + return; + } + coast(geometry, {x: 0, y: 0}); + }, + recognizerOptions, + ); + + useGestureRecognizer( + options.stageRef, + () => recognizers.doubleTap, + doubleTap => { + if (doubleTap.state !== 'ended') { + return; + } + const geometry = measure(); + if (geometry == null) { + return; + } + // A finger's double tap fills the viewer, at least 2×, and keeps the + // tapped point in place. A tap starts it, so it plays the theme's move + // easing and medium duration. + const point = toStage(doubleTap.location()); + settleTo(geometry, doubleTapTarget(geometry, current(), point), { + tapped: true, + }); + }, + recognizerOptions, + ); + + useGestureRecognizer( + options.stageRef, + () => recognizers.tap, + tap => { + if (tap.state !== 'ended') { + return; + } + const geometry = measure(); + if (geometry == null) { + return; + } + const point = toStage(tap.location()); + const {x, y, scale} = current(); + const left = geometry.fit.x + x; + const top = geometry.fit.y + y; + const inside = + point.x >= left && + point.x <= left + geometry.fit.width * scale && + point.y >= top && + point.y <= top + geometry.fit.height * scale; + if (!inside) { + options.onTapOutside?.(); + } + }, + recognizerOptions, + ); + + return core.api; +} diff --git a/packages/core/src/Lightbox/zoomStage.test.ts b/packages/core/src/Lightbox/zoomStage.test.ts index 42986c688939e..a7bf128232829 100644 --- a/packages/core/src/Lightbox/zoomStage.test.ts +++ b/packages/core/src/Lightbox/zoomStage.test.ts @@ -4,9 +4,9 @@ * @file zoomStage.test.ts * @input vitest, the zoom stage geometry and the UIKit zoom constants * @output Checks the zoom stage against UIKit's measured zooming and - * Lightbox's double tap. It covers the overshoot, the settle, the maximum - * zoom, the point kept in place, resting places, a drag's rubber band and a - * resize + * Lightbox's own zoom. It covers the overshoot, the settle, the maximum + * zoom, both double taps, resting places, a drag's rubber band, a resize + * and the keyboard's zoom * @position Testing; zoomStage.ts without a browser */ @@ -22,7 +22,9 @@ import { import {registerZoomMediaTable, zoomMedia} from '../motion/uikit/zoomMedia'; import { IDENTITY, + RELEASED_ZOOM, ZOOM_STEP, + doubleClickTarget, doubleTapScaleFor, doubleTapTarget, draggedTransform, @@ -33,6 +35,7 @@ import { pinchScaleFor, rawForDragged, releaseTarget, + releasedZoomTarget, restRange, restingTransform, settleCurves, @@ -400,3 +403,66 @@ describe('a resize while zoomed (rotation)', () => { expect(transformForResize(before, after, IDENTITY)).toEqual(IDENTITY); }); }); + +describe('the double-click: 2× about the clicked point', () => { + const g = landscape(); + it('zooms 2× and keeps the clicked photo point under the pointer', () => { + // Aspect fill would zoom this photo about 3.3×. + expect(g.doubleTapScale).toBeGreaterThan(3); + const fitted = {...IDENTITY, scale: g.minScale}; + const target = doubleClickTarget(g, fitted, {x: 134, y: 437}); + expect(target.scale).toBe(ZOOM_STEP); + expect(shown(g, target, 134, 134).x).toBeCloseTo(134, 9); + }); + it('returns to the fitted size when zoomed', () => { + const fitted = {...IDENTITY, scale: g.minScale}; + const zoomed = doubleClickTarget(g, fitted, {x: 134, y: 437}); + expect(doubleClickTarget(g, zoomed, {x: 50, y: 50})).toEqual( + restingTransform(g, fitted), + ); + }); +}); + +describe('the released zoom the keyboard keeps', () => { + const g = landscape(); + const centre = {x: g.width / 2, y: g.height / 2}; + it('zooms 2x about the centre, whatever aspect fill would give', () => { + // Aspect fill would zoom this photo about 3.3x. + expect(g.doubleTapScale).toBeGreaterThan(3); + const target = releasedZoomTarget(g, {...IDENTITY, scale: g.minScale}); + expect(target.scale).toBe(2); + // The stage's centre shows the photo's centre, as before. + const middle = shown(g, target, g.fit.width / 2, g.fit.height / 2); + expect(middle.x).toBeCloseTo(centre.x, 9); + expect(middle.y).toBeCloseTo(centre.y, 9); + }); + it('returns to the fitted size when zoomed', () => { + const zoomed = releasedZoomTarget(g, {...IDENTITY, scale: g.minScale}); + expect(releasedZoomTarget(g, zoomed)).toEqual( + restingTransform(g, {...IDENTITY, scale: g.minScale}), + ); + }); + it('stays inside the limits', () => { + const small = landscape({maxScale: 1.5}); + expect( + releasedZoomTarget(small, {...IDENTITY, scale: small.minScale}).scale, + ).toBe(1.5); + }); + it("plays CSS's 200 ms ease-out, not the settle", () => { + const from = {...IDENTITY, scale: 1}; + const to = releasedZoomTarget(g, from); + const curves = settleCurves( + from, + to, + RELEASED_ZOOM.curve, + RELEASED_ZOOM.seconds, + ); + expect(curves.duration).toBe(0.2); + expect(settleSeconds(from.scale, to.scale)).not.toBe(0.2); + const ease = cubicBezier([0, 0, 0.58, 1]); + for (const t of [0.05, 0.1, 0.15]) { + expect(curves.scale.at(t)).toBeCloseTo(1 + ease(t / 0.2), 9); + } + expect(curves.scale.at(0.2)).toBe(2); + }); +}); diff --git a/packages/core/src/Lightbox/zoomStage.ts b/packages/core/src/Lightbox/zoomStage.ts index b7a4881607db0..7b0e97523ca6e 100644 --- a/packages/core/src/Lightbox/zoomStage.ts +++ b/packages/core/src/Lightbox/zoomStage.ts @@ -3,15 +3,15 @@ /** * @file zoomStage.ts * @input The stage's size, the photo's fitted box and pixel size, a - * transform (offset from the fitted box, and scale), and finger positions + * transform (offset from the fitted box, and scale), and pointer positions * @output The zoom stage's pure geometry: zoom limits, resting places, a * drag's rubber band and a pinch's overshoot. Also the transform that keeps - * a point in place, and the eased settle curves - * @position Pure math for Lightbox's touch zoom. No DOM and no React. + * a point in place, the eased settle curves and the keyboard's zoom + * @position Pure math under useZoomCore and useZoomStage; no DOM, no React. * - * The model is a zooming UIScrollView in a photo viewer. Its constants come - * from motion/uikit/zoomMedia.ts and motion/uikit/scroll.ts. - * SYNC: When modified, update zoomStage.test.ts. + * The model is a zooming UIScrollView; its constants come from motion/uikit. + * SYNC: When modified, update zoomStage.test.ts, useZoomCore.ts and + * useZoomStage.ts. */ import {rubberBandClamp, rubberBandUnclamp} from '../motion/rubberBand'; @@ -60,7 +60,10 @@ export const IDENTITY: ZoomTransform = {x: 0, y: 0, scale: 1}; /** Within this of the fitted size the photo counts as not zoomed. */ export const ZOOMED_EPSILON = 0.001; -/** Lightbox's zoom step: a double tap zooms at least this many times. */ +/** + * Lightbox's zoom step. A double tap zooms at least this many times, and a + * double-click and the keyboard zoom this many times. + */ export const ZOOM_STEP = 2; /** @@ -160,8 +163,8 @@ export function restingTransform( } /** - * The scale a pinch shows for its own scale `scale`: 1:1 inside the - * limits, giving way past them (`zoomMedia.overshoot.limitFactor`). + * The scale a pinch shows for its own scale `scale`. It is 1:1 inside the + * limits and gives way past them (`zoomMedia.overshoot.limitFactor`). */ export function overshootScale(geometry: ZoomGeometry, scale: number): number { const f = zoomMedia.overshoot.limitFactor; @@ -219,7 +222,7 @@ export function transformKeepingPoint( } /** - * Where a drag shows the photo: `raw` is where the finger's travel would put + * Where a drag shows the photo: `raw` is where the pointer's travel would put * it with no edges. Past an edge it gives way with UIScrollView's rubber band * over the stage's size on that axis. On an axis where the photo is smaller * than the stage, it stays centred, as a scroll view whose content fits does @@ -243,7 +246,7 @@ export function draggedTransform( }; } -/** The finger-travel position behind a shown one: `draggedTransform` undone. */ +/** The pointer-travel position behind a shown one: `draggedTransform` undone. */ export function rawForDragged( geometry: ZoomGeometry, shown: ZoomTransform, @@ -330,8 +333,9 @@ export function settleSeconds(fromScale: number, toScale: number): number { /** * UIScrollView's zoom settle as curves for the animator. The offset and the * scale move together on one eased curve, from `from` to `to`, over - * `settleSeconds`. The curve is `zoomMedia.settle.curve` by default. Each - * axis is the eased move an animated page scroll plays. + * `duration` (`settleSeconds` by default). The curve is + * `zoomMedia.settle.curve` by default. Each axis is the eased move an + * animated page scroll plays. */ export function settleCurves( from: ZoomTransform, @@ -340,13 +344,13 @@ export function settleCurves( null, zoomMedia.settle.curve, ), + duration: number = settleSeconds(from.scale, to.scale), ): { readonly duration: number; readonly x: MotionCurve; readonly y: MotionCurve; readonly scale: MotionCurve; } { - const duration = settleSeconds(from.scale, to.scale); const along = (a: number, b: number): MotionCurve => solveAnimatedPageScroll({ from: a, @@ -362,6 +366,41 @@ export function settleCurves( }; } +/** + * Lightbox's released zoom, which the keyboard keeps. Enter, Space or `+` + * zooms `ZOOM_STEP` times about the stage's centre, or back to the fitted + * size when zoomed. Every such change, a keyboard pan included, plays the + * released 200 ms CSS `ease-out`. + */ +export const RELEASED_ZOOM = { + seconds: 0.2, + curve: [0, 0, 0.58, 1] as readonly [number, number, number, number], +}; + +/** Where a zoom key goes from `from` (see RELEASED_ZOOM). */ +export function releasedZoomTarget( + geometry: ZoomGeometry, + from: ZoomTransform, +): ZoomTransform { + return doubleClickTarget(geometry, from, { + x: geometry.width / 2, + y: geometry.height / 2, + }); +} + +/** + * Where a double-click at `point` goes from `from`. It zooms `ZOOM_STEP` + * times about the clicked point, kept inside the stage, or goes back to the + * fitted size when zoomed. + */ +export function doubleClickTarget( + geometry: ZoomGeometry, + from: ZoomTransform, + point: StagePoint, +): ZoomTransform { + return doubleTapTarget({...geometry, doubleTapScale: ZOOM_STEP}, from, point); +} + /** The resting transform after panning by (dx, dy): a keyboard step. */ export function pannedBy( geometry: ZoomGeometry, diff --git a/packages/core/src/hooks/useScrollLockImports.test.ts b/packages/core/src/hooks/useScrollLockImports.test.ts index d9fe6553b6386..cae22c9ca869e 100644 --- a/packages/core/src/hooks/useScrollLockImports.test.ts +++ b/packages/core/src/hooks/useScrollLockImports.test.ts @@ -91,13 +91,12 @@ describe('useScrollLock imports', () => { // The lock itself, and the overlays that take it: whatever the lock pulls // in ships with each of them. // AlertDialog is left out: its action group's press tracking brings the - // host itself. + // host itself. So is Lightbox: its zoom's gestures bring the host itself. it.each([ 'hooks/useScrollLock.ts', 'Dialog/Dialog.tsx', 'BottomSheet/BottomSheet.tsx', 'Drawer/Drawer.tsx', - 'Lightbox/Lightbox.tsx', ])('%s reaches neither the gesture host nor its arena', entry => { const reached = reachableFrom(entry); const gestures = [...reached].filter(path => path.startsWith('gestures/')); diff --git a/packages/core/src/motion/themeTiming.test.ts b/packages/core/src/motion/themeTiming.test.ts new file mode 100644 index 0000000000000..980638f5d80aa --- /dev/null +++ b/packages/core/src/motion/themeTiming.test.ts @@ -0,0 +1,136 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file themeTiming.test.ts + * @input vitest (jsdom), readThemeTiming and easedProgress, the theme's + * duration and easing defaults + * @output Checks how a motion reads its theme tokens. Covers a set value, an + * explicit zero, an empty or unparseable value, the fallback chain, the + * progress velocity and easedProgress's curve. + * @position Testing; validates themeTiming.ts + */ + +import {afterEach, describe, expect, it} from 'vitest'; +import {cubicBezier} from './bezier'; +import {parseCssEasing} from './roles'; +import { + easedProgress, + readThemeTiming, + type ThemeTimingTokens, +} from './themeTiming'; +import {easeDefaults} from '../theme/tokens.stylex'; + +const EXIT: ThemeTimingTokens = { + durations: ['--duration-exit', '--duration-fast'], + easing: '--ease-exit', +}; + +const exitEase = cubicBezier(parseCssEasing(easeDefaults['--ease-exit'])!); + +function element(tokens: Record = {}): HTMLElement { + const node = document.createElement('div'); + for (const [name, value] of Object.entries(tokens)) { + node.style.setProperty(name, value); + } + document.body.appendChild(node); + return node; +} + +describe('readThemeTiming', () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + it('plays the last token\u2019s default duration and the easing default when no token is set', () => { + const timing = readThemeTiming(element(), EXIT); + expect(timing.duration).toBeCloseTo(0.175, 6); + expect(timing.at(0.0875)).toBeCloseTo(exitEase(0.5), 6); + expect(timing.at(0.175)).toBe(1); + }); + + it('plays the first set token of the chain, in ms or s, with its easing', () => { + const exit = readThemeTiming( + element({ + '--duration-exit': '0.3s', + '--duration-fast': '100ms', + '--ease-exit': 'linear', + }), + EXIT, + ); + expect(exit.duration).toBeCloseTo(0.3, 6); + expect(exit.at(0.15)).toBeCloseTo(0.5, 6); + const fast = readThemeTiming(element({'--duration-fast': '250ms'}), EXIT); + expect(fast.duration).toBeCloseTo(0.25, 6); + }); + + it('gives the progress velocity per second, and none once the motion ends', () => { + const timing = readThemeTiming( + element({'--duration-exit': '0.3s', '--ease-exit': 'linear'}), + EXIT, + ); + expect(timing.velocity(0)).toBeCloseTo(1 / 0.3, 4); + expect(timing.velocity(0.15)).toBeCloseTo(1 / 0.3, 4); + expect(timing.velocity(0.3)).toBe(0); + const eased = readThemeTiming(element(), EXIT); + const t = 0.05; + const h = 1e-6; + expect(eased.velocity(t)).toBeCloseTo( + (eased.at(t + h) - eased.at(t - h)) / (2 * h), + 3, + ); + }); + + it('an explicit zero duration means no motion', () => { + const timing = readThemeTiming( + element({'--duration-exit': '0ms', '--duration-fast': '250ms'}), + EXIT, + ); + expect(timing.duration).toBe(0); + expect(timing.at(0)).toBe(1); + expect(timing.velocity(0)).toBe(0); + }); + + it('an empty duration falls back down the chain', () => { + const timing = readThemeTiming( + element({'--duration-exit': ' ', '--duration-fast': '250ms'}), + EXIT, + ); + expect(timing.duration).toBeCloseTo(0.25, 6); + }); + + it('an unparseable duration falls back down the chain, and never means no motion', () => { + const next = readThemeTiming( + element({'--duration-exit': 'fast', '--duration-fast': '250ms'}), + EXIT, + ); + expect(next.duration).toBeCloseTo(0.25, 6); + const last = readThemeTiming( + element({'--duration-exit': '-1ms', '--duration-fast': 'calc(1s)'}), + EXIT, + ); + expect(last.duration).toBeCloseTo(0.175, 6); + }); + + it('an unparseable easing gets its default', () => { + const timing = readThemeTiming( + element({'--duration-exit': '200ms', '--ease-exit': 'steps(4)'}), + EXIT, + ); + expect(timing.at(0.1)).toBeCloseTo(exitEase(0.5), 6); + }); +}); + +describe('easedProgress', () => { + it('eases from 0 to 1 over its length, and a zero length means no motion', () => { + const curve = easedProgress(0.2, x => x * x); + expect(curve.duration).toBe(0.2); + expect(curve.at(0)).toBe(0); + expect(curve.at(0.1)).toBeCloseTo(0.25, 6); + expect(curve.at(0.2)).toBe(1); + expect(curve.velocity(0.1)).toBeCloseTo(5, 3); + expect(curve.velocity(0.2)).toBe(0); + const none = easedProgress(0, x => x); + expect(none.at(0)).toBe(1); + expect(none.velocity(0)).toBe(0); + }); +}); diff --git a/packages/core/src/motion/themeTiming.ts b/packages/core/src/motion/themeTiming.ts new file mode 100644 index 0000000000000..971b2bb2d8bcd --- /dev/null +++ b/packages/core/src/motion/themeTiming.ts @@ -0,0 +1,87 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file themeTiming.ts + * @input An element and the theme tokens that time one motion + * @output readThemeTiming: the theme's eased progress from 0 to 1 for those + * tokens where the element is, as one timed MotionCurve. easedProgress: + * that curve for a length and an easing already read. + * @position Motion foundation, internal to core. Motion that a tap, a key or + * the program starts reads its duration and easing from the theme with it. + * + * SYNC: When modified, update /packages/core/src/motion/themeTiming.test.ts + */ + +import type {MotionCurve} from './animator'; +import {cubicBezier} from './bezier'; +import {parseCssEasing, parseMotionRoleValue} from './roles'; +import { + durationDefaults, + easeDefaults, + type DurationVarName, + type EaseVarName, +} from '../theme/tokens.stylex'; + +/** + * The theme tokens that time one motion. The duration tokens are a fallback + * chain, and the last one always has a default. + */ +export interface ThemeTimingTokens { + readonly durations: readonly [...string[], DurationVarName]; + readonly easing: EaseVarName; +} + +/** + * The eased progress from 0 to 1 over `seconds`, as a MotionCurve whose + * velocity is in progress per second. A zero length means no motion. + */ +export function easedProgress( + seconds: number, + ease: (x: number) => number, +): MotionCurve { + const at = (t: number) => + seconds <= 0 || t >= seconds ? 1 : ease(Math.max(0, t) / seconds); + // A central difference, one step inside the curve at either end. + const step = Math.min(1e-3, seconds / 100); + return { + duration: seconds, + at, + velocity: t => { + if (seconds <= 0 || t >= seconds) { + return 0; + } + const before = Math.max(0, t - step); + const after = Math.min(seconds, t + step); + return (at(after) - at(before)) / (after - before); + }, + }; +} + +/** + * The eased progress on the tokens, from 0 to 1, as a MotionCurve whose + * velocity is in progress per second. It reads the tokens where the element + * is, so a theme scope and the theme's tempo apply. An explicit zero duration + * means no motion. An unset, empty or unparseable duration falls back to the + * next token in the chain. The last token falls back to its default, so a bad + * value never means no motion. An unset or unparseable easing gets its + * default. + */ +export function readThemeTiming( + element: Element, + {durations, easing}: ThemeTimingTokens, +): MotionCurve { + const style = element.ownerDocument.defaultView?.getComputedStyle(element); + const read = (name: string) => style?.getPropertyValue(name) ?? ''; + let durationMs: number | null = null; + for (const name of durations) { + durationMs = parseMotionRoleValue('duration', read(name)); + if (durationMs != null) { + break; + } + } + const last = durations[durations.length - 1] as DurationVarName; + durationMs ??= parseMotionRoleValue('duration', durationDefaults[last]) ?? 0; + const points = parseCssEasing(read(easing)) ?? + parseCssEasing(easeDefaults[easing]) ?? [0, 0, 1, 1]; + return easedProgress(durationMs / 1000, cubicBezier(points)); +} From dc3a360b9a07ab407cccc8c5fb2c70b72fec8e9e Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 27 Oct 2026 23:50:51 -0700 Subject: [PATCH 03/13] feat(Lightbox): swipe between gallery photos like Photos A Lightbox gallery moved only through its two arrow buttons. A finger's drag or flick now pages between items with UIKit's paging release: one page at most, with Photos' measured 40 pt gap, settled on the compositor. The drag paging is part of the touch presentation, which a coarse primary pointer brings (spec:AST-094 FR13); under a fine pointer the buttons and the arrow keys page, as before. A zoomed photo keeps the drag until its edge, as nested scroll views do, and a pinch never pages. A video pages from its frame. Its native controls are taken as a 48 px band at its bottom edge, since browsers expose no control geometry, so a drag that starts there stays the video's (component:Lightbox FR7). Only the current page takes focus and is in the accessibility tree. Next, Previous and any other change of `index` slide the row under the touch presentation; under a fine pointer the row moves at once, so a button the owner refuses shows nothing. A drag the owner refuses springs back. Under Reduce Motion they fade. On a zoomable gallery the photo rows take the stage's touches while the track allows the page's pinch-zoom, so a pinch on a caption still zooms the page. The pan and the pinch read where their touches began from the shared recognizers, and without zoom a tap beside a gallery's photo closes the viewer through a shared tap recognizer. --- .changeset/lightbox-every-pointer.md | 2 +- .changeset/lightbox-gallery-paging.md | 11 + apps/storybook/stories/Lightbox.stories.tsx | 91 +++ .../components/Lightbox/Lightbox.spec.md | 15 +- packages/core/src/Lightbox/Lightbox.doc.mjs | 15 +- packages/core/src/Lightbox/Lightbox.test.tsx | 406 +++++++++- packages/core/src/Lightbox/Lightbox.tsx | 519 ++++++++++--- .../LightboxFinePointer.a11y.browser.spec.ts | 233 +++++- ...oxEveryPointer.interaction.browser.spec.ts | 113 ++- ...LightboxPaging.interaction.browser.spec.ts | 689 +++++++++++++++++ ...boxPagingModes.interaction.browser.spec.ts | 717 ++++++++++++++++++ .../LightboxZoom.interaction.browser.spec.ts | 3 + packages/core/src/Lightbox/useZoomCore.ts | 96 ++- packages/core/src/Lightbox/useZoomStage.ts | 162 ++-- packages/core/src/hooks/usePager.test.ts | 80 ++ packages/core/src/hooks/usePager.ts | 23 +- packages/core/src/motion/MEASUREMENTS.md | 5 +- packages/core/src/motion/uikit/zoomMedia.ts | 10 +- 18 files changed, 2984 insertions(+), 206 deletions(-) create mode 100644 .changeset/lightbox-gallery-paging.md create mode 100644 packages/core/src/Lightbox/__tests__/LightboxPaging.interaction.browser.spec.ts create mode 100644 packages/core/src/Lightbox/__tests__/LightboxPagingModes.interaction.browser.spec.ts diff --git a/.changeset/lightbox-every-pointer.md b/.changeset/lightbox-every-pointer.md index f345d5effa7ce..36087efe82164 100644 --- a/.changeset/lightbox-every-pointer.md +++ b/.changeset/lightbox-every-pointer.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[feat] Lightbox: a ctrl-wheel or a trackpad pinch now zooms a photo, and a trackpad's scroll pans a zoomed photo. +[feat] Lightbox: with `hasZoom`, a ctrl-wheel or a trackpad pinch now zooms a photo, and a trackpad's scroll pans a zoomed photo. @vjeux diff --git a/.changeset/lightbox-gallery-paging.md b/.changeset/lightbox-gallery-paging.md new file mode 100644 index 0000000000000..5fc684ceced15 --- /dev/null +++ b/.changeset/lightbox-gallery-paging.md @@ -0,0 +1,11 @@ +--- +'@astryxdesign/core': patch +--- + +[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. + +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. + +@vjeux diff --git a/apps/storybook/stories/Lightbox.stories.tsx b/apps/storybook/stories/Lightbox.stories.tsx index 840c9bdd7fb59..0080f9daabdcc 100644 --- a/apps/storybook/stories/Lightbox.stories.tsx +++ b/apps/storybook/stories/Lightbox.stories.tsx @@ -126,6 +126,11 @@ export const ZoomWithCaption: Story = { }, }; +/** + * A gallery a phone can page through. Drag or flick between photos, with + * Photos' 40 pt gap. Pinch or double-tap a photo to zoom, and drag a zoomed + * photo to its edge before the next drag pages. + */ export const GalleryWithZoom: Story = { render: () => { const [isOpen, setIsOpen] = useState(false); @@ -146,6 +151,64 @@ export const GalleryWithZoom: Story = { }, }; +/** + * A gallery whose captions differ in length: the photo keeps one box + * whichever pages, and so whichever captions, are rendered. + */ +export const GalleryWithCaptions: Story = { + render: () => { + const [isOpen, setIsOpen] = useState(false); + return ( + <> + + + + ); + }, +}; + +/** + * A controlled gallery whose parent keeps its index, paging only with its own + * buttons, say while a change is confirmed. A swipe returns to the photo the + * parent shows. + */ +export const GalleryKeepsIndex: Story = { + render: () => { + const [isOpen, setIsOpen] = useState(false); + return ( + <> + + {}} + hasZoom + /> + + ); + }, +}; + export const WithCaption: Story = { render: () => { const [isOpen, setIsOpen] = useState(false); @@ -167,6 +230,34 @@ export const WithCaption: Story = { }, }; +/** + * A gallery with a video between two photos. A drag on the video's frame + * pages, and a drag that starts on its controls stays the video's. + */ +export const GalleryWithVideo: Story = { + render: () => { + const [isOpen, setIsOpen] = useState(false); + return ( + <> + + + + ); + }, +}; + export const Video: 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 a4e868e36dba0..2925e3c5d29f8 100644 --- a/docs/specs/components/Lightbox/Lightbox.spec.md +++ b/docs/specs/components/Lightbox/Lightbox.spec.md @@ -79,9 +79,13 @@ Consumer migration instructions belong in consumer docs and release notes. The shipped Lightbox does not yet meet every requirement below. Each gap names the requirement it misses and the person it affects. -- **Touch presentation (FR7–FR10, FR12).** On a touch screen there is no swipe - paging, drag or pinch to close, tap to hide the controls, or thumbnail - transition. +- **Touch presentation (FR8–FR10, FR12).** On a touch screen there is no drag + or pinch to close, tap to hide the controls, or thumbnail transition. +- **Paging motion roles (FR5).** The page settle plays the pager's own UIKit + values, which the motion role table does not list as roles. +- **Video controls (FR7).** A video's native controls are taken as a 48 px + band at its bottom edge, since browsers expose no control geometry; a + 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. - **Zoom reset key (AR3).** `0` does not return a zoomed photo to its fitted @@ -98,6 +102,11 @@ the requirement it misses and the person it affects. - **Double-click listener (FR14).** A mouse's or a pen's double-click reaches the zoom through the stage's own `dblclick` listener, since only the browser knows the system's double-click speed. +- **Press tracker (FR14).** The viewer's own `pointerdown` handler records + each press's pointer type and point, which the browser's `dblclick` and + `click` do not carry, and clears the flag a drag leaves for its click. The + zoom stage's own capture `pointerdown` listener sets the pan's start + threshold for each press. - **Touch exit (FR16).** On a touch screen the viewer disappears at once when it closes. diff --git a/packages/core/src/Lightbox/Lightbox.doc.mjs b/packages/core/src/Lightbox/Lightbox.doc.mjs index 65d06ec9af390..d65e12d9b3dd1 100644 --- a/packages/core/src/Lightbox/Lightbox.doc.mjs +++ b/packages/core/src/Lightbox/Lightbox.doc.mjs @@ -80,7 +80,7 @@ export const docs = { // the description instead (#1645). type: 'LightboxMedia | LightboxMedia[]', description: - "Media to display. Pass a single object for one item, or an array for gallery mode with prev/next navigation. Each item is {src: string, alt: string, caption?: ReactNode, type?: 'image' | 'video'}; type defaults to 'image', and zoom/pan is disabled for 'video'.", + "Media to display. Pass a single object for one item, or an array for gallery mode with prev/next navigation (on a phone or tablet, a swipe moves between items too). Each item is {src: string, alt: string, caption?: ReactNode, type?: 'image' | 'video'}; type defaults to 'image', and zoom/pan is disabled for 'video'.", required: true, }, { @@ -92,7 +92,7 @@ export const docs = { name: 'onIndexChange', type: '(index: number) => void', description: - 'Callback when the gallery index changes via prev/next navigation.', + 'Callback when the gallery index changes: prev/next, the arrow keys, or a swipe.', }, { name: 'hasZoom', @@ -128,7 +128,7 @@ export const docs = { usage: { anatomy, description: - 'A fullscreen overlay for viewing images and videos at full resolution. Supports single-item and gallery modes with prev/next navigation, optional zoom and pan for images, and native video controls.', + 'A fullscreen overlay for viewing images and videos at full resolution. Supports single-item and gallery modes with prev/next navigation and swiping, optional zoom and pan for images, and native video controls.', bestPractices: [ { guidance: true, @@ -195,7 +195,7 @@ export const docsZh = { name: 'media', type: 'LightboxMedia | LightboxMedia[]', description: - "要显示的媒体。传入单个对象或数组(用于画廊模式的上一张/下一张导航)。每项为 {src, alt, caption?, type?: 'image' | 'video'};type 默认为 'image','video' 禁用缩放/平移。", + "要显示的媒体。传入单个对象或数组(用于画廊模式的上一张/下一张导航;在手机或平板上,滑动也可在各项之间切换)。每项为 {src, alt, caption?, type?: 'image' | 'video'};type 默认为 'image','video' 禁用缩放/平移。", required: true, }, { @@ -228,7 +228,7 @@ export const docsZh = { usage: { anatomy, description: - 'A fullscreen overlay for viewing images and videos at full resolution. Supports single-item and gallery modes with prev/next navigation, optional zoom and pan for images, and native video controls.', + 'A fullscreen overlay for viewing images and videos at full resolution. Supports single-item and gallery modes with prev/next navigation and swiping, optional zoom and pan for images, and native video controls.', bestPractices: [ { guidance: true, @@ -266,7 +266,7 @@ export const docsDense = { usage: { anatomy, description: - 'A fullscreen overlay for viewing images and videos at full resolution. Supports single-item and gallery modes with prev/next navigation, optional zoom and pan for images, and native video controls.', + 'A fullscreen overlay for viewing images and videos at full resolution. Supports single-item and gallery modes with prev/next navigation and swiping, optional zoom and pan for images, and native video controls.', bestPractices: [ {guidance: true, description: 'Always provide alt text for every image.'}, { @@ -295,7 +295,8 @@ export const docsDense = { onOpenChange: 'Callback when open state changes.', media: 'Single media object or array for gallery mode.', index: 'Current index in gallery mode.', - onIndexChange: 'Callback when gallery index changes.', + onIndexChange: + 'Callback when gallery index changes (buttons, keys or a swipe).', hasZoom: 'Enable zoom (pinch or ctrl-wheel, double-tap or double-click, Enter/Space, or +/-) and pan (drag, trackpad scroll, or arrow keys) for images.', xstyle: 'StyleX styles for layout customization.', diff --git a/packages/core/src/Lightbox/Lightbox.test.tsx b/packages/core/src/Lightbox/Lightbox.test.tsx index 75e88c05a7caa..0ad4f866afffa 100644 --- a/packages/core/src/Lightbox/Lightbox.test.tsx +++ b/packages/core/src/Lightbox/Lightbox.test.tsx @@ -57,8 +57,12 @@ function stubStageLayout(): () => void { }); }; const isPhoto = (element: Element) => element instanceof HTMLImageElement; + // The zoom stage: a single item's photo box, or a gallery page's. const isStage = (element: Element) => - element.querySelector(':scope > img') != null; + element.hasAttribute('data-lightbox-page-media') || + (element.getAttribute('role') === 'button' && + element.getAttribute('aria-label') === 'Zoom' && + element.querySelector('[data-lightbox-page]') == null); define(HTMLElement.prototype, 'offsetWidth', element => isPhoto(element) ? 400 : 0, ); @@ -108,6 +112,115 @@ const POSITION_MESSAGES = { }, }; +/** A mouse press on `target` from `from` to `to`, as a browser sends it. */ +/** A client rectangle at a place, for a test that sets an element's box. */ +function rectAt(left: number, top: number, width: number, height: number) { + return { + x: left, + y: top, + left, + top, + right: left + width, + bottom: top + height, + width, + height, + toJSON: () => ({}), + }; +} + +/** + * Lays out a gallery's current page for a mouse's click: its photo's box, + * natural width and caption, and the viewer's width. jsdom lays out nothing. + */ +function layOutPage( + page: Element, + { + photo, + natural, + caption, + viewer, + }: { + photo: ReturnType; + natural: number; + caption?: ReturnType; + viewer: number; + }, +): void { + const define = (element: Element, values: Record) => { + for (const [key, value] of Object.entries(values)) { + Object.defineProperty(element, key, {configurable: true, value}); + } + }; + const img = page.querySelector('img')!; + define(img, { + getBoundingClientRect: () => photo, + offsetHeight: photo.height, + naturalWidth: natural, + }); + // The photo's own box hugs it without zoom. + define(img.parentElement!, { + getBoundingClientRect: () => photo, + clientHeight: photo.height, + clientWidth: photo.width, + }); + const text = page.querySelector('[data-lightbox-caption]'); + if (caption != null && text != null) { + define(text, { + getBoundingClientRect: () => caption, + offsetWidth: caption.width, + }); + } + define(page.closest('[data-lightbox-page]')!.parentElement!.parentElement!, { + clientWidth: viewer, + }); +} + +function mousePress( + target: Element, + from: {x: number; y: number}, + to: {x: number; y: number} = from, +): void { + const send = ( + type: string, + {x, y}: {x: number; y: number}, + buttons: number, + ) => { + const event = new Event(type, {bubbles: true, cancelable: true}); + for (const [key, value] of Object.entries({ + pointerType: 'mouse', + pointerId: 1, + isPrimary: true, + button: 0, + buttons, + clientX: x, + clientY: y, + timeStamp: performance.now(), + })) { + Object.defineProperty(event, key, {value}); + } + act(() => { + target.dispatchEvent(event); + }); + }; + send('pointerdown', from, 1); + if (to.x !== from.x || to.y !== from.y) { + send('pointermove', to, 1); + } + send('pointerup', to, 0); + // Then the click a mouse's press and release make. + act(() => { + target.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + cancelable: true, + detail: 1, + clientX: to.x, + clientY: to.y, + }), + ); + }); +} + describe('Lightbox', () => { it('renders as a dialog element', () => { render( @@ -277,6 +390,290 @@ describe('Lightbox', () => { expect(screen.getByAltText('Image B')).toBeInTheDocument(); }); + it('renders the current item and its neighbours as pages, each with its own caption', () => { + render( + {}} + media={media} + index={1} + />, + ); + const pages = [ + ...document.querySelectorAll('[data-lightbox-page]'), + ]; + expect(pages).toHaveLength(3); + const current = pages.filter(page => page.hasAttribute('data-current')); + expect(current).toHaveLength(1); + expect(current[0]).toContainElement(screen.getByAltText('Image B')); + expect(current[0]).toHaveTextContent('Second'); + // The neighbours are off screen: hidden from assistive technology. + for (const page of pages.filter(p => p !== current[0])) { + const img = page.querySelector('img'); + expect(img).toHaveAttribute('aria-hidden', 'true'); + expect(img).toHaveAttribute('alt', ''); + } + expect(pages[0]).toHaveTextContent('First'); + }); + + it('closes on a click beside the photo on a page', () => { + const onOpenChange = vi.fn(); + render( + , + ); + const current = document.querySelector('[data-current]')!; + mousePress(current.querySelector('[data-lightbox-page-media]')!, { + x: 20, + y: 20, + }); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('closes on a mouse press beside the photo that moved past the tap limit', () => { + const onOpenChange = vi.fn(); + render( + , + ); + const pageMedia = document.querySelector( + '[data-current] [data-lightbox-page-media]', + )!; + // 60 px straight down: past the tap's 45 px limit. A mouse's click + // closes it, as on the backdrop, however far the press moved. + mousePress(pageMedia, {x: 100, y: 100}, {x: 100, y: 160}); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('leaves the viewer open when the keyboard activates a caption link after a mouse press', () => { + const onOpenChange = vi.fn(); + render( + More}, + {src: '/b.jpg', alt: 'B'}, + ]} + index={0} + />, + ); + const link = screen.getByRole('link', {name: 'More'}); + // A mouse press on the stage, then Enter on the caption's link: the + // browser clicks the link with no pointer (detail 0, at 0, 0). + const down = new Event('pointerdown', {bubbles: true, cancelable: true}); + Object.defineProperty(down, 'pointerType', {value: 'mouse'}); + act(() => { + link.dispatchEvent(down); + }); + act(() => { + link.dispatchEvent( + new MouseEvent('click', {bubbles: true, cancelable: true, detail: 0}), + ); + }); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('plays a video again when paging back to it, with hasAutoPlay', () => { + const play = vi + .spyOn(HTMLMediaElement.prototype, 'play') + .mockImplementation(async () => {}); + try { + const withVideo = [ + {src: '/a.jpg', alt: 'Image A'}, + {src: '/clip.webm', alt: 'Clip', type: 'video' as const}, + ]; + const {rerender} = render( + {}} + media={withVideo} + index={0} + hasAutoPlay + />, + ); + expect(play).not.toHaveBeenCalled(); + rerender( + {}} + media={withVideo} + index={1} + hasAutoPlay + />, + ); + expect(play).toHaveBeenCalledTimes(1); + expect(play.mock.contexts[0]).toBe( + document.querySelector('[data-current] video'), + ); + } finally { + play.mockRestore(); + } + }); + + it('shows the last item as the current one for an index past the end', () => { + render( + {}} + media={media} + index={media.length + 2} + />, + ); + const photo = screen.getByAltText('Image C'); + expect(photo).not.toHaveAttribute('aria-hidden'); + const page = photo.closest('[data-lightbox-page]')!; + expect(page).toHaveAttribute('data-current'); + expect(page).not.toHaveAttribute('inert'); + expect( + screen.getByText(`${media.length} / ${media.length}`), + ).toBeTruthy(); + }); + + it('pauses a playing video once the viewer moves to another item', () => { + const items = [ + {src: '/clip.mp4', alt: 'A clip', type: 'video' as const}, + {src: '/b.jpg', alt: 'Image B'}, + ]; + const {rerender} = render( + {}} + media={items} + index={0} + />, + ); + const video = document.querySelector('video')!; + // Started by someone: playing, with its sound. + const pause = vi.fn(); + video.pause = pause; + Object.defineProperty(video, 'paused', { + configurable: true, + get: () => pause.mock.calls.length > 0, + }); + rerender( + {}} + media={items} + index={1} + />, + ); + // Still mounted as the pager's neighbour, and stopped. + expect(document.querySelector('video')).toBe(video); + expect(pause).toHaveBeenCalledTimes(1); + }); + + it('stays open on a click on the photo, with zoom off', () => { + const onOpenChange = vi.fn(); + render( + , + ); + const current = document.querySelector('[data-current]')!; + const box = current.querySelector( + '[data-lightbox-page-media]', + )!; + // The photo takes no pointer events: a press on it lands on its box. + layOutPage(current, { + photo: rectAt(100, 100, 400, 200), + natural: 400, + viewer: 1000, + }); + mousePress(box, {x: 300, y: 200}); + expect(onOpenChange).not.toHaveBeenCalled(); + // Beside the photo, past its natural width, the click is the backdrop's. + mousePress(box, {x: 20, y: 200}); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('closes on a mouse press beside the photo that is released on it', () => { + const onOpenChange = vi.fn(); + render( + , + ); + const current = document.querySelector('[data-current]')!; + layOutPage(current, { + photo: rectAt(100, 100, 400, 200), + natural: 400, + viewer: 1000, + }); + // A press that starts on the backdrop closes, wherever it ends. + mousePress( + current.querySelector('[data-lightbox-page-media]')!, + {x: 20, y: 200}, + {x: 300, y: 200}, + ); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("stays open on a mouse click beside a narrow photo, within its caption's width", () => { + const onOpenChange = vi.fn(); + render( + , + ); + const current = document.querySelector('[data-current]')!; + const box = current.querySelector('[data-lightbox-page-media]')!; + layOutPage(current, { + photo: rectAt(250, 100, 100, 300), + natural: 100, + caption: rectAt(100, 410, 400, 30), + viewer: 1000, + }); + // The media is as wide as the caption here, so this point is on it. + mousePress(box, {x: 150, y: 250}); + expect(onOpenChange).not.toHaveBeenCalled(); + // Past the caption's width, the click is the backdrop's. + mousePress(box, {x: 50, y: 250}); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('stays open on a mouse click beside a large photo, level with it, since its media spans the viewer', () => { + const onOpenChange = vi.fn(); + render( + , + ); + const current = document.querySelector('[data-current]')!; + // A photo wider than the viewer, shown at 400 px. + layOutPage(current, { + photo: rectAt(300, 100, 400, 200), + natural: 3000, + viewer: 1000, + }); + mousePress(current.querySelector('[data-lightbox-page-media]')!, { + x: 990, + y: 200, + }); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + it('shows gallery counter', () => { render( { ['a fitted photo', false], ['a zoomed photo', true], ])( - 'ignores a finger’s first tap on the strip after a finger drag on %s, as before', + 'closes on a finger’s tap on the strip after a finger drag on %s, once the drag’s flag is clear', async (_, isZoomedFirst) => { const onOpenChange = vi.fn(); render( @@ -1379,9 +1776,10 @@ describe('Lightbox touch zoom (synthetic touches)', () => { fingerDown(group, 200, 590); fireEvent.click(group, {clientX: 200, clientY: 590, detail: 1}); }; - // The first tap on the strip below the stage only clears the flag. + // 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).not.toHaveBeenCalled(); + expect(onOpenChange).toHaveBeenCalledTimes(isZoomedFirst ? 0 : 1); tapStrip(); expect(onOpenChange).toHaveBeenCalledWith(false); }, diff --git a/packages/core/src/Lightbox/Lightbox.tsx b/packages/core/src/Lightbox/Lightbox.tsx index e1cba0aa2088e..b39432a3be634 100644 --- a/packages/core/src/Lightbox/Lightbox.tsx +++ b/packages/core/src/Lightbox/Lightbox.tsx @@ -6,7 +6,7 @@ * @file Lightbox.tsx * @input Uses React, native dialog, StyleX, IconButton, theme tokens, layerTextReset, layerStructureReset, * and isRtlElement (gallery arrow keys follow the rendered direction). The - * zoom uses useZoomCore and useZoomStage + * zoom uses useZoomCore and useZoomStage, and a gallery pages with usePager * @output Exports Lightbox component, LightboxProps, LightboxMedia * @position Core implementation; consumed by index.ts * @@ -16,6 +16,7 @@ * - /packages/core/src/Lightbox/useZoomCore.ts (the zoom stage for every pointer) * - /packages/core/src/Lightbox/useZoomStage.ts (the gestures, for every pointer) * - /packages/core/src/Lightbox/__tests__/LightboxZoom.interaction.browser.spec.ts (touch zoom proofs) + * - /packages/core/src/Lightbox/__tests__/LightboxPaging.interaction.browser.spec.ts (gallery paging proofs) * - /packages/core/src/Lightbox/index.ts (exports if types change) * - /packages/core/src/Layer/useLayerDismissal.ts (dismissal stack) * - /apps/storybook/stories/Lightbox.stories.tsx (storybook stories) @@ -65,6 +66,11 @@ import {useLayerDismissal} from '../Layer/useLayerDismissal'; import {useMergedRefs} from '../hooks/useMergedRefs'; import {isRtlElement} from '../hooks/isRtlElement'; import {useZoomCore} from './useZoomCore'; +import {usePager} from '../hooks/usePager'; +import {TapGestureRecognizer} from '../gestures/TapGestureRecognizer'; +import type {GestureTouch} from '../gestures/types'; +import {useGestureRecognizer} from '../gestures/useGestureRecognizer'; +import {zoomMedia} from '../motion/uikit/zoomMedia'; import {useZoomStage} from './useZoomStage'; import {useTouchPresentation} from '../utils/touchPresentation'; import { @@ -126,7 +132,8 @@ export interface LightboxProps extends BaseProps { */ defaultIndex?: number; /** - * Callback when the gallery index changes via prev/next navigation. + * Callback when the gallery index changes: the previous and next buttons, + * the arrow keys or a swipe. */ onIndexChange?: (index: number) => void; /** @@ -231,6 +238,73 @@ const styles = stylex.create({ imageWrapperZoomTarget: { touchAction: 'none', }, + // In a gallery the photo rows take the stage's touches, for every pointer + // (as imageWrapperZoomTarget). A descendant cannot allow what an ancestor's + // `touch-action` rules out. So the track allows only the page's pinch-zoom, + // and a pinch on a caption still zooms the page. The pager's pan manages the + // stage's own `touch-action`. + trackZoomTarget: { + touchAction: 'pinch-zoom', + }, + // A zoomable page's photo box fills the page above its caption, as a + // single item's stage does. + pageMediaZoomTarget: { + touchAction: 'none', + alignSelf: 'stretch', + flexGrow: 1, + flexShrink: 1, + flexBasis: 0, + }, + // 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', + }, + // The pages stack in the track's one cell, each offset by its relative + // inset. + track: { + position: 'absolute', + inset: 0, + display: 'grid', + gridTemplateColumns: '100%', + gridTemplateRows: '100%', + }, + // 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. + page: { + position: 'relative', + gridArea: '1 / 1', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + minHeight: 0, + minWidth: 0, + willChange: 'transform', + }, + // A page's photo box: the zoom stage of the current page. + pageMedia: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + minHeight: 0, + minWidth: 0, + maxWidth: '100%', + }, // 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: { @@ -326,6 +400,12 @@ const styles = stylex.create({ }, }); +const pageStyles = stylex.create({ + position: (index: number) => ({ + insetInlineStart: `calc(${index} * (100% + ${zoomMedia.viewer.pageGap}px))`, + }), +}); + /** * Pan distance (px) per arrow-key press while zoomed. Offsets move the * viewport in the arrow's direction — pressing ArrowRight reveals content to @@ -340,6 +420,9 @@ const KEYBOARD_PAN_OFFSETS: Record = { ArrowDown: [0, -KEYBOARD_PAN_STEP], }; +/** The height of a video's native controls, px: no browser reports it. */ +const VIDEO_CONTROLS_BAND = 48; + /** * A fullscreen overlay for viewing images at full resolution. * @@ -403,12 +486,15 @@ export function Lightbox({ const mergedDialogRef = useMergedRefs(ref, dialogRef); const containerRef = useRef(null); const imageWrapperRef = useRef(null); + const trackRef = useRef(null); + // A gallery's zoom stage: the current page's photo box. + const pageMediaRef = useRef(null); const triggerElementRef = useRef(null); // Index state (controlled + uncontrolled) const isControlled = controlledIndex !== undefined; const [uncontrolledIndex, setUncontrolledIndex] = useState(defaultIndex); - const index = isControlled ? controlledIndex : uncontrolledIndex; + const requestedIndex = isControlled ? controlledIndex : uncontrolledIndex; const setIndex = useCallback( (value: number) => { @@ -431,10 +517,11 @@ export function Lightbox({ [media], ); const isGallery = mediaArray.length > 1; - const currentItem = - mediaArray.length > 0 - ? mediaArray[Math.min(index, mediaArray.length - 1)] - : null; + // The item shown, current in every respect (its page, the counter, the + // pager, the announcement): an index past either end shows the nearest + // item. A NaN index stays NaN (the viewed-item check below). + const index = Math.max(0, Math.min(requestedIndex, mediaArray.length - 1)); + const currentItem = mediaArray.length > 0 ? mediaArray[index] : null; const currentType = currentItem?.type ?? 'image'; const isVideo = currentType === 'video'; const canPrev = isGallery && index > 0; @@ -475,7 +562,7 @@ export function Lightbox({ if (!indexChanged || !isOpen || !wasOpen) { return; } - const item = mediaArray[Math.min(index, mediaArray.length - 1)]; + const item = mediaArray[index]; const position = {index: index + 1, total: mediaArray.length}; announce( item?.alt @@ -484,6 +571,30 @@ export function Lightbox({ ); }, [index, isOpen, announce, mediaArray, t]); + // A video left on another page stops: the pager keeps the neighbours + // mounted, and a playing one would go on, with its sound, off screen. With + // `hasAutoPlay`, the video paged to plays again, as a newly shown one does. + useEffect(() => { + const container = containerRef.current; + if (container == null) { + return; + } + for (const video of container.querySelectorAll( + '[data-lightbox-page]:not([data-current]) video', + )) { + if (!video.paused) { + video.pause(); + } + } + const current = container.querySelector( + '[data-lightbox-page][data-current] video', + ); + if (hasAutoPlay && current != null && current.paused) { + // A browser can refuse playback without a user's gesture. + void current.play()?.catch(() => {}); + } + }, [index, hasAutoPlay]); + // Open/close dialog. The dialog renders only with an item, so a viewer // opened before its first item arrives opens when that item does. const hasItem = currentItem != null; @@ -558,56 +669,69 @@ export function Lightbox({ [handleClose, shouldDismissOnCloseRequest], ); - // The last press's pointer type. A finger's double tap is the stage's own. - // A mouse's or a pen's is the browser's `dblclick`. + // The last press's pointer type, which the browser's `dblclick` does not + // carry. A finger's double tap is the stage's own. A mouse's or a pen's is + // the browser's `dblclick`. 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. With zoom, the stage covers that area. A finger's - // tap beside the photo closes on the stage's own tap, and so does its tap on - // the media group's strip below the 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. + // 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. const didDragRef = useRef(false); const mediaGroupRef = useRef(null); const captionRef = useRef(null); - const pressPointRef = useRef<{x: number; y: number} | null>(null); - // Whether a mouse's or a pen's click on the zoom target's media closes the - // viewer. It does when its press or its release lands outside the media. - // 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. + // 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. const isClickOutsideMedia = useCallback((e: ReactMouseEvent) => { - const stageElement = imageWrapperRef.current; - const photo = stageElement?.querySelector('img'); + const viewer = imageWrapperRef.current; + const page = viewer?.querySelector('[data-current]') ?? viewer; + const media = page?.querySelector('img') ?? page?.querySelector('video'); + // The photo's own box, which centres it: the stage, or a page's. + const box = media?.parentElement; const group = mediaGroupRef.current; if ( e.detail === 0 || pointerTypeRef.current === 'touch' || - stageElement == null || - photo == null || + viewer == null || + page == null || + media == null || + box == null || group == null || !(e.target instanceof Node) || !group.contains(e.target) ) { return false; } - const space = (stageElement.clientHeight - photo.offsetHeight) / 2; - const box = stageElement.getBoundingClientRect(); - const top = box.top + space; - let bottom = top + photo.offsetHeight; - const caption = captionRef.current; + 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 natural = + media instanceof HTMLImageElement ? media.naturalWidth : media.videoWidth; const width = Math.min( - stageElement.clientWidth, - Math.max(photo.naturalWidth, caption?.offsetWidth ?? 0), + viewer.clientWidth, + Math.max(natural, caption?.offsetWidth ?? 0), ); - const middle = box.left + stageElement.clientWidth / 2; - const shown = photo.getBoundingClientRect(); + 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) && !( @@ -628,34 +752,21 @@ export function Lightbox({ didDragRef.current = false; return; } + const isMediaClose = + pointerTypeRef.current === 'touch' + ? hasZoom && !isVideo && e.target === mediaGroupRef.current + : (isGallery || (hasZoom && !isVideo)) && isClickOutsideMedia(e); if ( e.target === e.currentTarget || e.target === containerRef.current || - (hasZoom && - !isVideo && - (pointerTypeRef.current === 'touch' - ? e.target === mediaGroupRef.current - : isClickOutsideMedia(e))) + isMediaClose ) { handleClose(); } }, - [handleClose, hasZoom, isVideo, isClickOutsideMedia], + [handleClose, hasZoom, isVideo, isGallery, isClickOutsideMedia], ); - // Gallery navigation - const goToPrev = useCallback(() => { - if (canPrev) { - setIndex(index - 1); - } - }, [canPrev, index, setIndex]); - - const goToNext = useCallback(() => { - if (canNext) { - setIndex(index + 1); - } - }, [canNext, index, setIndex]); - const isZoomTarget = hasZoom && !isVideo; const onDragStart = () => { didDragRef.current = true; @@ -668,21 +779,35 @@ export function Lightbox({ // 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; + // 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 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, @@ -697,7 +822,9 @@ export function Lightbox({ // 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: imageWrapperRef, + stageRef: isGallery ? pageMediaRef : imageWrapperRef, + surfaceRef: isGallery ? trackRef : imageWrapperRef, + isEnabled: isOpen && isZoomTarget, itemKey: viewedItem, // Zoom changes are silent to assistive tech, since only the transform // changes. Announce them in the polite live region. @@ -713,12 +840,39 @@ export function Lightbox({ }, }); const {isTouch} = useTouchPresentation(); + const startsOnCaption = (touch: GestureTouch | undefined) => { + const target = touch?.target; + return ( + target instanceof Element && + target.closest('[data-lightbox-caption]') != null + ); + }; const stage = useZoomStage(zoom, { - stageRef: imageWrapperRef, + stageRef: isGallery ? pageMediaRef : imageWrapperRef, + surfaceRef: isGallery ? trackRef : imageWrapperRef, isEnabled: isOpen && isZoomTarget, - onTapOutside: handleClose, + // 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. + onTapOutside: ({x, y}) => { + const caption = trackRef.current + ?.querySelector('[data-current] [data-lightbox-caption]') + ?.getBoundingClientRect(); + if ( + caption != null && + x >= caption.left && + x <= caption.right && + y >= caption.top && + y <= caption.bottom + ) { + return; + } + handleClose(); + }, onDragStart, isTouchPresentation: isTouch, + // A pinch whose first finger lands on a gallery's caption is the page's. + shouldBeginPinch: recognizer => + !startsOnCaption(recognizer.trackedTouches[0]), }); // Whether a mouse or a pen hovers over the fitted photo, for its cursor. The // stage fills the viewer, so the photo's box is read, not the event target. @@ -727,7 +881,10 @@ export function Lightbox({ if (e.pointerType === 'touch' || e.buttons !== 0) { return; } - const r = e.currentTarget.querySelector('img')?.getBoundingClientRect(); + const photo = + e.currentTarget.querySelector('[data-current] img') ?? + e.currentTarget.querySelector('img'); + const r = photo?.getBoundingClientRect(); e.currentTarget.toggleAttribute( OVER_PHOTO_ATTRIBUTE, r != null && @@ -737,6 +894,128 @@ export function Lightbox({ e.clientY <= r.bottom, ); }; + // A gallery pages like Photos, on a UIKit paging scroll view's release. A + // drag or a flick moves one page at most, with Photos' 40 pt gap. A zoomed + // photo keeps the drag until its edge, then the pager takes the next one. + // As in Photos, the photo left behind returns to the fitted size once the + // pager rests. + // A controlled parent can refuse a swipe by keeping its index. The pager + // then goes back to the photo the parent shows. + // A video's native controls are taken as a band at its bottom edge, since + // browsers expose no control geometry. A drag that starts there stays the + // video's, as a scrubber's or a volume slider's drag. + const startsOnVideoControls = (touch: GestureTouch | undefined) => { + const video = trackRef.current?.querySelector( + '[data-current] video[controls]', + ); + if (touch == null || video == null) { + return false; + } + const box = video.getBoundingClientRect(); + return ( + touch.startX >= box.left && + touch.startX <= box.right && + touch.startY <= box.bottom && + touch.startY >= box.bottom - VIDEO_CONTROLS_BAND + ); + }; + usePager({ + viewportRef: imageWrapperRef, + trackRef, + count: mediaArray.length, + gap: zoomMedia.viewer.pageGap, + index, + // The drag paging is part of the touch presentation. Under a fine pointer + // the buttons and the arrow keys page, and the page keeps its scroll. + isPanEnabled: isOpen && isGallery && isTouch, + // Under the touch presentation a change of `index` slides the pager, and + // a refused drag springs back. Under a fine pointer it moves the pager at + // once, so a refused button shows nothing. + isControlledChangeAnimated: isTouch, + // In the touch presentation every pointer pages. A drag that starts on a + // video's controls stays the video's. + panOptions: { + allowedPointerTypes: ['touch', 'pen', 'mouse'], + maximumNumberOfTouches: 1, + shouldBegin: pan => !startsOnVideoControls(pan.trackedTouches[0]), + }, + shouldBeginPan: direction => !stage.isZoomed() || stage.isAtEdge(direction), + onIndexChange: (next, reason) => { + if (reason === 'drag') { + setIndex(next); + } + }, + onSettle: () => { + for (const img of trackRef.current?.querySelectorAll( + '[data-lightbox-page]:not([data-current]) img', + ) ?? []) { + img.style.translate = ''; + img.style.scale = ''; + } + }, + }); + + // 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. + 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); + 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)) + ); + }; + // Without zoom, a gallery's 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`). + // With zoom, the stage's own tap closes it. + useGestureRecognizer( + imageWrapperRef, + () => + new TapGestureRecognizer({ + name: 'lightbox-backdrop-tap', + allowedPointerTypes: ['touch'], + }), + tap => { + if (tap.state !== 'ended') { + return; + } + const {x, y} = tap.location(); + if (!isOnCurrentMedia(x, y)) { + handleClose(); + } + }, + {isEnabled: isOpen && isGallery && !isZoomTarget, manageStyle: false}, + ); + + // Gallery navigation. The buttons and the arrow keys ask for the new item. + // Under the touch presentation the pager then slides there, as a paging + // scroll view's `setContentOffset` does. Under a fine pointer it moves at + // once. + + const goToPrev = useCallback(() => { + if (canPrev) { + setIndex(index - 1); + } + }, [canPrev, index, setIndex]); + + const goToNext = useCallback(() => { + if (canNext) { + setIndex(index + 1); + } + }, [canNext, index, setIndex]); // Keyboard navigation. While zoomed, arrows pan the image (matching common // lightbox conventions); when not zoomed they navigate the gallery. @@ -841,6 +1120,35 @@ export function Lightbox({ ); }; + const renderMedia = ( + {src, alt, type: kind = 'image'}: LightboxMedia, + isCurrent: boolean, + ) => + kind === 'video' ? ( +