From 3dd581e9cf96efc699363fc686e340c14b884132 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 3 Nov 2026 06:35:52 -0800 Subject: [PATCH 001/118] feat(core): one progress module for the motion foundation progressCurve, mapProgress, mapRange and presentedProgressVelocity in motion/progress.ts, internal, one velocity unit (progress per second). Rebuilt on main from the pull request's own change, without the open dependency chain it no longer needs. Co-authored-by: Cindy Zhang --- .changeset/motion-progress.md | 7 + packages/core/src/motion/README.md | 1 + packages/core/src/motion/progress.test.ts | 172 ++++++++++++++++++++++ packages/core/src/motion/progress.ts | 155 +++++++++++++++++++ 4 files changed, 335 insertions(+) create mode 100644 .changeset/motion-progress.md create mode 100644 packages/core/src/motion/progress.test.ts create mode 100644 packages/core/src/motion/progress.ts diff --git a/.changeset/motion-progress.md b/.changeset/motion-progress.md new file mode 100644 index 0000000000000..d7d9d896ba1ff --- /dev/null +++ b/.changeset/motion-progress.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: one progress module for the motion foundation. `progressCurve` gives a spring's progress from 0 to 1 as a closed-form curve, `mapProgress` maps any curve from another (a companion's dim following a sheet, a clamped range), `mapRange` is the clamped linear map they share, and `presentedProgressVelocity` reads the velocity an interrupted motion continues from. One velocity unit throughout: progress per second. Nothing public changes; no component imports it yet. + +@vjeux diff --git a/packages/core/src/motion/README.md b/packages/core/src/motion/README.md index 05e5a15d629a1..19ef169b0e573 100644 --- a/packages/core/src/motion/README.md +++ b/packages/core/src/motion/README.md @@ -38,6 +38,7 @@ roles through `resolveMotion`. | `resolveSnapPoint.ts` | Release policy: the snap point a release lands on (flick, back-out cancel, step limit), on `selectSnapPoint`. Internal. | | `bezier.ts` | Cubic-bézier timing functions (Core Animation's named curves). | | `animator.ts` | Compositor animator: springs, coasts and any closed-form curve compiled to Web Animations, read back analytically. | +| `progress.ts` | A spring's progress, one curve mapped from another (companions, clamped ranges), the velocity that continues a motion. | | `presentation.ts` | Where each moving element is drawn relative to where the engine hit-tests it, reported by the animator while translate runs; read by the gesture host at touch-down. | | `reducedMotionPlan.ts` | The animator's Reduce Motion planner: what one call becomes, a cross-fade whose travel lands while transparent or at once. | | `__tests__/animator.interaction.browser.spec.ts` | Browser proofs on the interaction harness (Chromium and WebKit): busy main thread, end state through a block, no Layout / Paint, seeked curves, a dialog's backdrop. | diff --git a/packages/core/src/motion/progress.test.ts b/packages/core/src/motion/progress.test.ts new file mode 100644 index 0000000000000..4de3fa57df8de --- /dev/null +++ b/packages/core/src/motion/progress.test.ts @@ -0,0 +1,172 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import {describe, expect, it} from 'vitest'; +import type {MotionProperty} from './animator'; +import { + mapProgress, + mapRange, + presentedProgressVelocity, + progressCurve, + type ProgressChannel, +} from './progress'; +import {solveSpring, toSpring} from './spring'; + +const SPRING = {response: 0.4, dampingRatio: 0.85}; + +/** An animator stand-in that presents fixed velocities. */ +const presenting = (velocities: Partial>) => ({ + getVelocity: (property: MotionProperty) => velocities[property] ?? 0, +}); + +describe('progressCurve', () => { + it('follows the spring from 0 to 1 and lands exactly on 1 once settled', () => { + const curve = progressCurve(SPRING, {velocity: 2}); + const spring = solveSpring(toSpring(SPRING), {from: 0, to: 1, velocity: 2}); + expect(curve.at(0)).toBe(0); + expect(curve.velocity(0)).toBeCloseTo(2, 9); + for (const t of [0.05, 0.1, 0.2, 0.3]) { + expect(curve.at(t)).toBeCloseTo(spring.value(t), 12); + expect(curve.velocity(t)).toBeCloseTo(spring.velocity(t), 12); + } + expect(curve.duration).toBeGreaterThan(0.3); + expect(curve.at(curve.duration)).toBe(1); + expect(curve.velocity(curve.duration)).toBe(0); + }); +}); + +describe('mapProgress', () => { + it('maps the value through f and the velocity by the chain rule', () => { + const progress = progressCurve(SPRING); + const f = (p: number) => 100 + 300 * p * p; + const mapped = mapProgress(progress, f); + expect(mapped.duration).toBe(progress.duration); + for (const t of [0.02, 0.1, 0.25]) { + const p = progress.at(t); + expect(mapped.at(t)).toBeCloseTo(f(p), 9); + expect(mapped.velocity(t)).toBeCloseTo(600 * p * progress.velocity(t), 4); + } + expect(mapped.at(progress.duration)).toBe(400); + }); +}); + +describe('mapRange', () => { + it('maps linearly inside the input range, exactly at its ends', () => { + const dim = mapRange([600, 200], [0, 0.4]); + expect(dim(600)).toBe(0); + expect(dim(200)).toBe(0.4); + expect(dim(400)).toBeCloseTo(0.2, 12); + expect(dim(500)).toBeCloseTo(0.1, 12); + }); + + it('holds the output’s ends outside the input range, or runs on when not clamped', () => { + const dim = mapRange([600, 200], [0, 0.4]); + expect(dim(800)).toBe(0); + expect(dim(0)).toBe(0.4); + const open = mapRange([600, 200], [0, 0.4], {clamp: false}); + expect(open(800)).toBeCloseTo(-0.2, 12); + expect(open(0)).toBeCloseTo(0.6, 12); + }); + + it('steps at the input when its range is empty', () => { + const step = mapRange([10, 10], [0, 1]); + expect(step(9)).toBe(0); + expect(step(10)).toBe(1); + expect(step(11)).toBe(1); + }); +}); + +describe('mapProgress with a driving channel', () => { + // A sheet's y falling from 700 px to 100 px at a steady 1000 px/s. + const sheetY = { + duration: 0.6, + at: (t: number) => 700 - 1000 * t, + velocity: () => -1000, + }; + + it('follows any curve, not only a progress: the scrim’s dim along the sheet’s travel', () => { + const dim = mapProgress(sheetY, mapRange([600, 200], [0, 0.4])); + expect(dim.duration).toBe(0.6); + expect(dim.at(0.3)).toBeCloseTo(0.4 * (200 / 400), 12); + // Inside the range: 0.4 per 400 px at 1000 px/s, rising. + expect(dim.velocity(0.3)).toBeCloseTo(1, 4); + }); + + it('is still, velocity 0, outside the range on both sides', () => { + const dim = mapProgress(sheetY, mapRange([600, 200], [0, 0.4])); + // y = 650 (not yet in range) and y = 150 (past it). + for (const t of [0.05, 0.55]) { + expect(Math.abs(dim.velocity(t))).toBe(0); + } + expect(dim.at(0.05)).toBe(0); + expect(dim.at(0.55)).toBe(0.4); + }); +}); + +describe('presentedProgressVelocity', () => { + const line = ( + from: Partial>, + to: Partial>, + weights: Partial> = {}, + ): ProgressChannel[] => + (Object.keys(to) as MotionProperty[]).map(property => ({ + property, + at: p => from[property]! + (to[property]! - from[property]!) * p, + weight: weights[property], + })); + + it('is the presented velocity over the path\u2019s tangent when they are parallel', () => { + // A path of 200 px on x and 100 px on y, presented at 400 and 200 px/s: + // two progress units per second. + const channels = line({x: 0, y: 0}, {x: 200, y: 100}); + expect( + presentedProgressVelocity(presenting({x: 400, y: 200}), channels), + ).toBeCloseTo(2, 6); + }); + + it('keeps only the part along the path (v\u00b7t / t\u00b7t)', () => { + // Moving across the path counts for nothing; against it, negatively. + const channels = line({x: 0, y: 0}, {x: 100, y: 0}); + expect( + presentedProgressVelocity(presenting({x: 0, y: 500}), channels), + ).toBeCloseTo(0, 9); + expect( + presentedProgressVelocity(presenting({x: -50, y: 500}), channels), + ).toBeCloseTo(-0.5, 6); + }); + + it('weighs each channel into one unit, so a scale counts in px', () => { + // A 300 px wide box growing from scale 0.5 to 1 while moving 50 px: in px + // the scale moves 150 px against 50 px of travel. + const channels = line( + {x: 0, scaleX: 0.5}, + {x: 50, scaleX: 1}, + {scaleX: 300}, + ); + // Presented: 100 px/s on x and 1 scale/s (300 px/s): (100·50 + 300·150) / + // (50² + 150²) = 2 progress units per second. + expect( + presentedProgressVelocity(presenting({x: 100, scaleX: 1}), channels), + ).toBeCloseTo(2, 6); + }); + + it('is 0 on a path that moves none of its channels, or from rest', () => { + expect( + presentedProgressVelocity(presenting({x: 100}), line({x: 10}, {x: 10})), + ).toBe(0); + expect( + presentedProgressVelocity(presenting({}), line({x: 0}, {x: 10})), + ).toBe(0); + }); + + it('reads every channel at the one time it is given', () => { + const times: (number | undefined)[] = []; + const animator = { + getVelocity: (_property: MotionProperty, time?: number) => { + times.push(time); + return 0; + }, + }; + presentedProgressVelocity(animator, line({x: 0, y: 0}, {x: 1, y: 1}), 1234); + expect(times).toEqual([1234, 1234]); + }); +}); diff --git a/packages/core/src/motion/progress.ts b/packages/core/src/motion/progress.ts new file mode 100644 index 0000000000000..8b97de255de90 --- /dev/null +++ b/packages/core/src/motion/progress.ts @@ -0,0 +1,155 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file progress.ts + * @input A spring, a normalized progress, an animator's presented velocities + * @output Exports `progressCurve` (a spring's progress from 0 to 1 as a + * MotionCurve), `mapProgress` (one curve as a function of another), + * `mapRange` (the clamped linear map most companions use) and + * `presentedProgressVelocity` (the progress velocity that continues what + * an animator shows) + * @position Shared by the motion building blocks that move several channels + * along one path and by companion motion (a + * scrim's dim following a sheet): one velocity unit, one curve mapping and + * one way to continue an interrupted motion + * + * A path is described by a normalized progress: 0 where it starts, 1 where + * it rests, beyond either end on a spring's overshoot. Its velocity is in + * progress per second; each channel's own velocity stays in the channel's + * units per second. + * + * An interrupted motion continues from what is on screen: the + * block reads the presented values and velocities from the animator at one + * `now` (`get`, `getVelocity`), restarts a path from those values, and starts + * its progress with the presented velocity projected on the path's tangent + * at its start, v·t / (t·t), where t = d(values)/dp. Channels are weighted + * into one unit first (px per unit of scale, say), so a scale and a + * translation count alike. + * + * A companion follows a driving curve through `mapProgress`, most often + * with `mapRange`: `mapProgress(sheetY, mapRange([closedY, openY], [0, dim]))` + * dims a scrim as a sheet rises. Played on another animator, it stays in + * lockstep only when it starts with its driver: pass the driver's start, + * `scrim.play({opacity}, {at: start})` with `start = await handle.started` + * (or one shared `at` for both calls); two calls from rest otherwise each + * start at their own commit. + * + * SYNC: When modified, update /packages/core/src/motion/progress.test.ts and + * /packages/core/src/motion/README.md. + */ + +import type {Animator, MotionCurve, MotionProperty} from './animator'; +import { + SETTLING_EPSILON, + solveSpring, + toSpring, + type SpringDescription, +} from './spring'; + +/** The step of the finite differences taken on a progress. */ +const STEP = 1e-4; + +/** + * The progress of `spring` from 0 to 1, starting with `velocity` (progress + * per second): exactly 1 once it settles within `epsilon`. + */ +export function progressCurve( + spring: SpringDescription, + { + velocity = 0, + epsilon = SETTLING_EPSILON, + }: {velocity?: number; epsilon?: number} = {}, +): MotionCurve { + const curve = solveSpring(toSpring(spring), {from: 0, to: 1, velocity}); + const duration = curve.settleTime(epsilon); + return { + duration, + at: t => (t >= duration ? 1 : curve.value(t)), + velocity: t => (t >= duration ? 0 : curve.velocity(t)), + }; +} + +/** + * The curve of `f(driver(t))`, its velocity by the chain rule: a channel + * following a path as its progress moves, or a companion following any + * driving curve (a sheet's `y` mapped onto its scrim's opacity). The driver + * is any MotionCurve, not only a progress from 0 to 1. A companion played on + * another animator starts with its driver (`{at: start}`; see the file + * header). + */ +export function mapProgress( + driver: MotionCurve, + f: (value: number) => number, +): MotionCurve { + return { + duration: driver.duration, + at: t => f(driver.at(t)), + velocity: t => { + const value = driver.at(t); + return ( + ((f(value + STEP) - f(value - STEP)) / (2 * STEP)) * driver.velocity(t) + ); + }, + }; +} + +/** + * The linear map from `input` to `output`, held at `output`'s ends outside + * `input` unless `clamp` is false: what most companions are (a scrim's dim + * along a sheet's travel, a parallax, a dismissal's fade). Either range may + * run backward (`[openY, closedY]`). + */ +export function mapRange( + input: readonly [number, number], + output: readonly [number, number], + {clamp = true}: {clamp?: boolean} = {}, +): (x: number) => number { + const [a, b] = input; + const [c, d] = output; + return x => { + let t = a === b ? (x < a ? 0 : 1) : (x - a) / (b - a); + if (clamp) { + t = Math.min(1, Math.max(0, t)); + } + // Exact at both ends. + return t === 0 ? c : t === 1 ? d : c + (d - c) * t; + }; +} + +/** + * One channel of a path: an animator property and its value at each + * progress. + */ +export interface ProgressChannel { + readonly property: MotionProperty; + /** The property's value at progress `p`. */ + at(p: number): number; + /** + * What one unit of the property weighs in the projection, in a unit the + * channels share (px per unit of scale for a box `weight` px wide). 1 by + * default. + */ + readonly weight?: number; +} + +/** + * The progress velocity, per second, that continues what `animator` + * presents at `now` along a path that starts there: the presented velocity + * projected on the path's tangent at progress 0, v·t / (t·t). 0 when the + * path does not move any of its channels. + */ +export function presentedProgressVelocity( + animator: Pick, + channels: ReadonlyArray, + now?: number, +): number { + let along = 0; + let squared = 0; + for (const channel of channels) { + const weight = channel.weight ?? 1; + const tangent = ((channel.at(STEP) - channel.at(0)) / STEP) * weight; + along += animator.getVelocity(channel.property, now) * weight * tangent; + squared += tangent * tangent; + } + return squared > 1e-12 ? along / squared : 0; +} From 3cfb3b108112669d2863cf34cfe4c50052b7d3a4 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 3 Nov 2026 07:21:50 -0800 Subject: [PATCH 002/118] refactor(animatedLayout): take the settle spring's progress from progressCurve CodeBunny on #9755: springProgress solved the same spring as progressCurve. It now reads progressCurve (remaining = 1 - progress), so one implementation remains. Same curve, same settle epsilon (0.001) and duration. --- packages/core/src/motion/animatedLayout.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index 9d744f917fcd9..43c5ba9b34971 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -89,7 +89,8 @@ import { wrapText, } from './transientCopy'; import {isSpring} from './roleValue'; -import {solveSpring, toSpring, type SpringDescription} from './spring'; +import {progressCurve} from './progress'; +import type {SpringDescription} from './spring'; import {registerSpringsTable, SETTLE_SPRING} from './uikit/springs'; import {reducedMotionCrossFade} from './uikit/timing'; @@ -142,11 +143,11 @@ function settleSpring(): SpringDescription { } function springProgress(description: SpringDescription): SpringProgress { - const curve = solveSpring(toSpring(description), {from: 1, to: 0}); - const durationMs = Math.max(1, curve.settleTime(0.001) * 1000); + const curve = progressCurve(description, {epsilon: 0.001}); + const durationMs = Math.max(1, curve.duration * 1000); return { durationMs, - remaining: t => (t >= durationMs ? 0 : curve.value(t / 1000)), + remaining: t => (t >= durationMs ? 0 : 1 - curve.at(t / 1000)), }; } From 273da1e9d4fe603e81d4b68c513c6e5e26e68453 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 3 Nov 2026 07:42:10 -0800 Subject: [PATCH 003/118] feat(core): the rect morph's paths, the rect a grow-out presentation shows at each progress lerpPath, edgeGrowPath, each path's restartFrom and morphCurves in motion/morphPath.ts, internal. Pure geometry over a progress curve: the caller chooses the timing. Rebuilt on the progress module from the pull request's own change, without the open chain it no longer needs. Co-authored-by: Cindy Zhang --- .changeset/motion-morph-path.md | 7 + packages/core/src/motion/README.md | 1 + packages/core/src/motion/morphPath.test.ts | 437 +++++++++++++++++++++ packages/core/src/motion/morphPath.ts | 263 +++++++++++++ 4 files changed, 708 insertions(+) create mode 100644 .changeset/motion-morph-path.md create mode 100644 packages/core/src/motion/morphPath.test.ts create mode 100644 packages/core/src/motion/morphPath.ts diff --git a/.changeset/motion-morph-path.md b/.changeset/motion-morph-path.md new file mode 100644 index 0000000000000..0d0e272fecc67 --- /dev/null +++ b/.changeset/motion-morph-path.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the rect morph's paths, the geometry touch presentations share when they grow out of a source rect and shrink back into it. `lerpPath` (a straight line between two rects) and `edgeGrowPath` (UIKit's popover, growing out of a point on its edge) name the rect an element shows at each progress, and say how an interrupted motion continues in their own law; `morphCurves` turns a path into compositor curves (`translate`, `scale` and a companion `opacity`). Not exported from the package entry point; no component uses them yet. + +@vjeux diff --git a/packages/core/src/motion/README.md b/packages/core/src/motion/README.md index 19ef169b0e573..e0c672a1b1326 100644 --- a/packages/core/src/motion/README.md +++ b/packages/core/src/motion/README.md @@ -38,6 +38,7 @@ roles through `resolveMotion`. | `resolveSnapPoint.ts` | Release policy: the snap point a release lands on (flick, back-out cancel, step limit), on `selectSnapPoint`. Internal. | | `bezier.ts` | Cubic-bézier timing functions (Core Animation's named curves). | | `animator.ts` | Compositor animator: springs, coasts and any closed-form curve compiled to Web Animations, read back analytically. | +| `morphPath.ts` | The rect an element shows at each progress of a grow-out presentation: a straight line, a popover’s edge growth, and the curves that put it there. | | `progress.ts` | A spring's progress, one curve mapped from another (companions, clamped ranges), the velocity that continues a motion. | | `presentation.ts` | Where each moving element is drawn relative to where the engine hit-tests it, reported by the animator while translate runs; read by the gesture host at touch-down. | | `reducedMotionPlan.ts` | The animator's Reduce Motion planner: what one call becomes, a cross-fade whose travel lands while transparent or at once. | diff --git a/packages/core/src/motion/morphPath.test.ts b/packages/core/src/motion/morphPath.test.ts new file mode 100644 index 0000000000000..2f1f7e630da21 --- /dev/null +++ b/packages/core/src/motion/morphPath.test.ts @@ -0,0 +1,437 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file morphPath.test.ts + * @input vitest, the morph paths and the progress helpers, and the models of + * the presentations that move onto the morph, as their pull requests have + * them + * @output Checks that the morph's curves put an element on its path's rect + * every frame, and that each presentation's model (an anchored menu, a + * popover, a lifted preview, a zoom, a sub-menu's parent card) is the same + * rect at every progress, within 0.5 px + * @position Testing; the rect morph's player has its own tests + */ + +import {describe, expect, it} from 'vitest'; +import type {MotionCurve, MotionProperty} from './animator'; +import { + edgeGrowPath, + lerpPath, + morphCurves, + type MorphPath, + type MorphRect, +} from './morphPath'; +import {progressCurve} from './progress'; + +const SPRING = {response: 0.4, dampingRatio: 1}; +const BOUNCY = {response: 0.35, dampingRatio: 0.8}; + +/** Where translate and scale about the top-left corner put `box`. */ +function rectOf( + box: MorphRect, + values: {x: number; y: number; scaleX: number; scaleY: number}, +): MorphRect { + return { + x: box.x + values.x, + y: box.y + values.y, + width: box.width * values.scaleX, + height: box.height * values.scaleY, + }; +} + +function expectRect(actual: MorphRect, expected: MorphRect, tolerance: number) { + const gap = Math.max( + Math.abs(actual.x - expected.x), + Math.abs(actual.y - expected.y), + Math.abs(actual.width - expected.width), + Math.abs(actual.height - expected.height), + ); + expect(gap, JSON.stringify({actual, expected})).toBeLessThanOrEqual( + tolerance, + ); +} + +/** The rect `curves` show at `t` s for an element laid out in `box`. */ +function curvesRect( + box: MorphRect, + curves: Partial>, + t: number, +): MorphRect { + return rectOf(box, { + x: curves.x!.at(t), + y: curves.y!.at(t), + scaleX: curves.scaleX!.at(t), + scaleY: curves.scaleY!.at(t), + }); +} + +/** Every 1/120 s of `progress`, and its end. */ +function frames(progress: MotionCurve): number[] { + const times: number[] = []; + for (let t = 0; t < progress.duration; t += 1 / 120) { + times.push(t); + } + times.push(progress.duration); + return times; +} + +const SQUARE: MorphRect = {x: 179, y: 160, width: 22, height: 22}; + +const BOX: MorphRect = {x: 40, y: 204, width: 320, height: 253}; + +describe('morphCurves', () => { + it('puts the element on a straight path\u2019s rect every frame, each axis scaled to it', () => { + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + const progress = progressCurve(BOUNCY); + const curves = morphCurves(path, progress); + let overshoot = false; + for (const t of frames(progress)) { + const p = progress.at(t); + overshoot ||= p > 1; + expectRect(curvesRect(BOX, curves, t), path.rectAt(p), 0.01); + expect(curves.opacity!.at(t)).toBeCloseTo(p, 9); + } + // Past 1 on the spring's overshoot, the rect runs past rest too. + expect(overshoot).toBe(true); + }); + + it('keeps the element\u2019s aspect under a uniform scale', () => { + const thumb = {x: 12, y: 300, width: 100, height: 75}; + const detail = {x: 0, y: 120, width: 390, height: 292.5}; + const path = lerpPath(thumb, detail, {scale: 'uniform'}); + const progress = progressCurve(SPRING); + const curves = morphCurves(path, progress); + for (const t of frames(progress)) { + expect(curves.scaleY!.at(t)).toBe(curves.scaleX!.at(t)); + expectRect( + curvesRect(detail, curves, t), + path.rectAt(progress.at(t)), + 0.01, + ); + } + expect(curves.opacity).toBeUndefined(); + }); + + it('moves the companion opacity on a progress of its own when given one', () => { + const path = lerpPath(SQUARE, BOX, {opacity: [1, 0]}); + const progress = progressCurve(SPRING); + const fade = progressCurve({response: 0.2, dampingRatio: 1}); + const curves = morphCurves(path, progress, fade); + for (const t of [0.01, 0.05, 0.1, 0.2]) { + expect(curves.opacity!.at(t)).toBeCloseTo(1 - fade.at(t), 9); + } + }); + + it('ends exactly on identity when the path ends on its box', () => { + const progress = progressCurve(BOUNCY); + for (const path of [ + lerpPath(SQUARE, BOX), + edgeGrowPath({ + box: BOX, + origin: {x: 160, y: 0}, + axis: 'y', + collapsedScale: 0.01, + collapsedLength: 100 / 253, + }), + ]) { + const curves = morphCurves(path, progress); + const end = progress.duration; + expect([ + curves.x!.at(end), + curves.y!.at(end), + curves.scaleX!.at(end), + curves.scaleY!.at(end), + ]).toEqual([0, 0, 1, 1]); + } + }); +}); + +// The models of the presentations that move onto the morph, as their pull +// requests have them; each migration checks the morph against its live code. + +/** Popover (#8187, popoverSurfaceMotion.ts): popoverCollapsedState, popoverFrame. */ +const POPOVER = {collapsedScale: 0.01, collapsedLengthPx: 100}; +function popoverModel(surface: MorphRect, source: MorphRect) { + const gaps = { + below: surface.y - (source.y + source.height), + above: source.y - (surface.y + surface.height), + right: surface.x - (source.x + source.width), + left: source.x - (surface.x + surface.width), + }; + let side: keyof typeof gaps = 'below'; + for (const candidate of ['above', 'right', 'left'] as const) { + if (gaps[candidate] > gaps[side]) { + side = candidate; + } + } + const clamp = (v: number, min: number, max: number) => + Math.min(Math.max(v, min), max); + const centerX = clamp( + source.x + source.width / 2 - surface.x, + 0, + surface.width, + ); + const centerY = clamp( + source.y + source.height / 2 - surface.y, + 0, + surface.height, + ); + const vertical = side === 'below' || side === 'above'; + const fullLength = vertical ? surface.height : surface.width; + const axis: 'x' | 'y' = vertical ? 'y' : 'x'; + const state = { + originX: vertical ? centerX : side === 'right' ? 0 : surface.width, + originY: vertical ? (side === 'below' ? 0 : surface.height) : centerY, + axis, + scale: POPOVER.collapsedScale, + length: Math.min(1, POPOVER.collapsedLengthPx / fullLength), + }; + // A frame of (scale, length, opacity), scaled about the origin. + const frameRect = (scale: number, length: number): MorphRect => { + const along = scale * length; + const scaleX = state.axis === 'x' ? along : scale; + const scaleY = state.axis === 'y' ? along : scale; + return { + x: surface.x + state.originX * (1 - scaleX), + y: surface.y + state.originY * (1 - scaleY), + width: surface.width * scaleX, + height: surface.height * scaleY, + }; + }; + return { + state, + rectAt: (p: number) => + frameRect( + state.scale + (1 - state.scale) * p, + state.length + (1 - state.length) * p, + ), + frameRect, + }; +} + +/** DropdownMenu (#8080, menuSurfaceMotion.ts): menuCollapsedState, scaled per axis about its origin. */ +function menuModel(surface: MorphRect, anchor: {x: number; y: number}) { + const collapsedSize = 22; + const scaleX0 = collapsedSize / surface.width; + const scaleY0 = collapsedSize / surface.height; + const originX = + (anchor.x - surface.x - (surface.width / 2) * scaleX0) / (1 - scaleX0); + const originY = + (anchor.y - surface.y - (surface.height / 2) * scaleY0) / (1 - scaleY0); + return (p: number): MorphRect => { + const scaleX = scaleX0 + (1 - scaleX0) * p; + const scaleY = scaleY0 + (1 - scaleY0) * p; + return { + x: surface.x + originX * (1 - scaleX), + y: surface.y + originY * (1 - scaleY), + width: surface.width * scaleX, + height: surface.height * scaleY, + }; + }; +} + +/** ContextMenu (#8201, ContextMenuLift.tsx): the preview laid out on its source, x, y and scale sprung together. */ +function liftModel(source: MorphRect, pressScale: number, lifted: MorphRect) { + const from = { + x: (source.width * (1 - pressScale)) / 2, + y: (source.height * (1 - pressScale)) / 2, + scale: pressScale, + }; + const to = { + x: lifted.x - source.x, + y: lifted.y - source.y, + scale: lifted.width / source.width, + }; + return (p: number): MorphRect => { + const scale = from.scale + (to.scale - from.scale) * p; + return { + x: source.x + from.x + (to.x - from.x) * p, + y: source.y + from.y + (to.y - from.y) * p, + width: source.width * scale, + height: source.height * scale, + }; + }; +} + +/** Lightbox (#8231, zoomTransition.ts): zoomFramesAt's detail, centred, and its fade gain. */ +function zoomModel(source: MorphRect, detail: MorphRect) { + const aspect = detail.height / detail.width; + const startHeight = source.width * aspect; + const start = { + x: source.x, + y: source.y + source.height / 2 - startHeight / 2, + width: source.width, + height: startHeight, + }; + return { + rectAt: (p: number): MorphRect => { + const lerp = (a: number, b: number) => a + (b - a) * p; + const width = lerp(start.width, detail.width); + const anchor = lerp( + start.y + start.height / 2, + detail.y + detail.height / 2, + ); + const height = width * aspect; + return { + x: lerp(start.x, detail.x), + y: anchor - height / 2, + width, + height, + }; + }, + opacityAt: (p: number) => Math.min(1, Math.max(0, 1.56 * p)), + }; +} + +/** DropdownMenu sub-menu (#8220, menuDrillInMotion.ts): the parent card, scaled about its centre and dropped. */ +function cardModel(box: MorphRect) { + const parentScale = 0.97; + const dropPx = 3; + return (p: number): MorphRect => { + const scale = 1 + (parentScale - 1) * p; + return { + x: box.x + (box.width * (1 - scale)) / 2, + y: box.y + (box.height * (1 - scale)) / 2 + dropPx * p, + width: box.width * scale, + height: box.height * scale, + }; + }; +} + +/** Progresses 0 to 1.1 (a spring's overshoot) and a little below 0. */ +const PROGRESSES = Array.from({length: 24}, (_, i) => -0.05 + i * 0.05); + +describe('the presentations that move onto the morph are the same rect at every progress', () => { + it('an anchored menu (#8080): a straight line from the collapsed square on its control', () => { + const anchor = {x: 190, y: 171}; + const model = menuModel(BOX, anchor); + const path = lerpPath( + {x: anchor.x - 11, y: anchor.y - 11, width: 22, height: 22}, + BOX, + {opacity: [0, 1]}, + ); + for (const p of PROGRESSES) { + expectRect(path.rectAt(p), model(p), 0.5); + expect(path.opacityAt!(p)).toBeCloseTo(p, 9); + } + }); + + it('a popover (#8187): UIKit\u2019s edge growth, on each side of its source', () => { + const source = {x: 141, y: 160, width: 120, height: 44}; + const surfaces: Record = { + below: {x: 41, y: 204, width: 320, height: 253}, + above: {x: 41, y: -93, width: 320, height: 253}, + right: {x: 261, y: 100, width: 200, height: 160}, + left: {x: -59, y: 100, width: 200, height: 160}, + }; + for (const surface of Object.values(surfaces)) { + const model = popoverModel(surface, source); + const path = edgeGrowPath({ + box: surface, + origin: {x: model.state.originX, y: model.state.originY}, + axis: model.state.axis, + collapsedScale: model.state.scale, + collapsedLength: model.state.length, + }); + for (const p of PROGRESSES) { + expectRect(path.rectAt(p), model.rectAt(p), 0.5); + expect(path.opacityAt!(p)).toBeCloseTo(p, 9); + } + } + }); + + it('a popover (#8187) continues an interrupted motion in its own law: scale and length from the frame on screen', () => { + const surface = {x: 41, y: 204, width: 320, height: 253}; + const model = popoverModel(surface, { + x: 141, + y: 160, + width: 120, + height: 44, + }); + const path = edgeGrowPath({ + box: surface, + origin: {x: model.state.originX, y: model.state.originY}, + axis: model.state.axis, + collapsedScale: model.state.scale, + collapsedLength: model.state.length, + }); + // Mid-dismissal: scale 0.4, length 0.6 (readState), opacity 0.5. + const onScreen = model.frameRect(0.4, 0.6); + const back = path.restartFrom!(onScreen, 0.5, 'rest'); + const away = path.restartFrom!(onScreen, 0.5, 'source'); + for (const p of [0, 0.25, 0.5, 0.75, 1, 1.05]) { + // #8187's surfaceCurves: each of scale, length and opacity on a line. + expectRect( + back.rectAt(p), + model.frameRect(0.4 + 0.6 * p, 0.6 + 0.4 * p), + 0.5, + ); + expect(back.opacityAt!(p)).toBeCloseTo(0.5 + 0.5 * p, 9); + expectRect( + away.rectAt(p), + model.frameRect( + 0.4 + (model.state.scale - 0.4) * p, + 0.6 + (model.state.length - 0.6) * p, + ), + 0.5, + ); + expect(away.opacityAt!(p)).toBeCloseTo(0.5 - 0.5 * p, 9); + } + }); + + it('a lifted preview (#8201): its source box, scaled about its centre by the press, to its lifted box', () => { + const source = {x: 60, y: 300, width: 270, height: 64}; + const pressScale = 0.96; + const lifted = {x: 50.5, y: 297.75, width: 289, height: 68.5}; + const model = liftModel(source, pressScale, lifted); + const pressed = { + x: source.x + (source.width * (1 - pressScale)) / 2, + y: source.y + (source.height * (1 - pressScale)) / 2, + width: source.width * pressScale, + height: source.height * pressScale, + }; + const path = lerpPath(pressed, lifted, {box: source, scale: 'uniform'}); + const progress = progressCurve(SPRING); + const curves = morphCurves(path, progress); + for (const p of PROGRESSES) { + expectRect(path.rectAt(p), model(p), 0.5); + } + for (const t of frames(progress)) { + expectRect(curvesRect(source, curves, t), model(progress.at(t)), 0.5); + } + }); + + it('a zoom (#8231): the detail on its portal\u2019s frame, with its fade gain', () => { + const source = {x: 130, y: 400, width: 120, height: 120}; + const detail = {x: 0, y: 160, width: 390, height: 292.5}; + const model = zoomModel(source, detail); + const path: MorphPath = { + box: detail, + scale: 'uniform', + rectAt: model.rectAt, + opacityAt: model.opacityAt, + }; + const progress = progressCurve(SPRING); + const curves = morphCurves(path, progress); + for (const t of frames(progress)) { + const p = progress.at(t); + // zoomElementCurves: x and y from the box, scale by the width. + const frame = model.rectAt(p); + expect(curves.x!.at(t)).toBeCloseTo(frame.x - detail.x, 6); + expect(curves.y!.at(t)).toBeCloseTo(frame.y - detail.y, 6); + expect(curves.scaleX!.at(t)).toBeCloseTo(frame.width / detail.width, 9); + expect(curves.opacity!.at(t)).toBeCloseTo(model.opacityAt(p), 9); + expectRect(curvesRect(detail, curves, t), frame, 0.5); + } + }); + + it('a sub-menu\u2019s parent card (#8220): scaled about its centre and dropped', () => { + const box = {x: 40, y: 204, width: 250, height: 300}; + const model = cardModel(box); + const card = model(1); + const path = lerpPath(box, card, {box, scale: 'uniform'}); + for (const p of PROGRESSES) { + expectRect(path.rectAt(p), model(p), 0.5); + } + }); +}); diff --git a/packages/core/src/motion/morphPath.ts b/packages/core/src/motion/morphPath.ts new file mode 100644 index 0000000000000..622f2720b4215 --- /dev/null +++ b/packages/core/src/motion/morphPath.ts @@ -0,0 +1,263 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file morphPath.ts + * @input The progress helpers (progress.ts) and the animator's curve types + * @output Exports `lerpPath` and `edgeGrowPath` (the rect an element shows at + * each progress), `morphCurves` (the curves that put it there) and + * `travelAt` (the transform for one progress, for the rect morph's player) + * @position Motion foundation building block, internal to core: the geometry + * of "grow out of a source rect, shrink back into it" that touch + * presentations share (an anchored menu, a popover, a context menu's + * lifted preview, a photo's zoom, a sub-menu's parent card); a rect morph + * plays it + * + * A path names the rect an element shows at each progress: 0 on its source, + * 1 at rest, beyond either end on a spring's overshoot. The element stays + * laid out in the path's `box`; `translate` and `scale` about + * `transform-origin: 0 0` put it on the path's rect every frame, scaling each + * axis to the rect (`'axes'`) or both by the rect's width (`'uniform'`, which + * keeps the element's aspect), and a companion opacity follows a progress of + * its own or the same one. Only `translate`, `scale` and `opacity` move, so + * the motion runs on the compositor with no layout or paint per frame. + * + * Two laws cover the presentations: `lerpPath`, a straight line from one rect + * to another (position and size move together), and `edgeGrowPath`, UIKit's + * popover, which grows out of a point on its edge facing the source with its + * length along the axis away from the source growing with its scale. A path + * also says how an interrupted motion continues in its own law + * (`restartFrom`), so a popover turning around keeps its edge growth. + * + * SYNC: When modified, update: + * - /packages/core/src/motion/morphPath.test.ts + * - /packages/core/src/motion/README.md + */ + +import type {MotionCurve, MotionProperty} from './animator'; +import {mapProgress} from './progress'; + +/** + * A box in viewport px. + */ +export interface MorphRect { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +/** + * How the element is scaled onto a path's rect: each axis to the rect + * (`'axes'`), or both by the rect's width (`'uniform'`, keeping the + * element's aspect). + */ +export type MorphScale = 'axes' | 'uniform'; + +/** + * The end of a path a restarted path heads for. + */ +export type MorphEnd = 'source' | 'rest'; + +/** + * The rect an element shows at each progress of a presentation: 0 on its + * source, 1 at rest. + */ +export interface MorphPath { + /** The element's box as laid out: its rect with no transform. */ + readonly box: MorphRect; + /** @default 'axes' */ + readonly scale?: MorphScale; + /** The rect at progress `p`. */ + rectAt(p: number): MorphRect; + /** The companion opacity at progress `p`; opacity is left alone without it. */ + opacityAt?(p: number): number; + /** + * The path from `rect`, shown at `opacity`, to this path's `end`, in its + * own law: how an interrupted motion continues. A straight line to that + * end without it. + */ + restartFrom?(rect: MorphRect, opacity: number, end: MorphEnd): MorphPath; +} + +// Exact at both ends, so a path that ends on its box ends on identity. +const lerp = (a: number, b: number, p: number) => + p === 0 ? a : p === 1 ? b : a + (b - a) * p; + +/** + * A straight line from `from` (progress 0) to `to` (progress 1): position and + * size move together, as an anchored menu grows out of a square on its + * control, a lifted preview grows to its box, a sub-menu's parent card + * shrinks back. + */ +export function lerpPath( + from: MorphRect, + to: MorphRect, + { + box = to, + scale = 'axes', + opacity, + }: { + /** The element's box as laid out; `to` by default. */ + box?: MorphRect; + scale?: MorphScale; + /** The companion opacity at progress 0 and 1; none by default. */ + opacity?: readonly [number, number]; + } = {}, +): MorphPath { + return { + box, + scale, + rectAt: p => ({ + x: lerp(from.x, to.x, p), + y: lerp(from.y, to.y, p), + width: lerp(from.width, to.width, p), + height: lerp(from.height, to.height, p), + }), + ...(opacity != null + ? {opacityAt: (p: number) => lerp(opacity[0], opacity[1], p)} + : null), + }; +} + +interface GrowState { + /** The scale across the axis. */ + scale: number; + /** The share of the full length along the axis, before `scale`. */ + length: number; + opacity: number; +} + +const GROWN: GrowState = {scale: 1, length: 1, opacity: 1}; + +function growPath( + box: MorphRect, + origin: {x: number; y: number}, + axis: 'x' | 'y', + collapsed: GrowState, + from: GrowState, + to: GrowState, +): MorphPath { + const stateAt = (p: number): GrowState => ({ + scale: lerp(from.scale, to.scale, p), + length: lerp(from.length, to.length, p), + opacity: lerp(from.opacity, to.opacity, p), + }); + return { + box, + scale: 'axes', + rectAt(p) { + const state = stateAt(p); + const along = state.scale * state.length; + const scaleX = axis === 'x' ? along : state.scale; + const scaleY = axis === 'y' ? along : state.scale; + return { + x: box.x + origin.x * (1 - scaleX), + y: box.y + origin.y * (1 - scaleY), + width: box.width * scaleX, + height: box.height * scaleY, + }; + }, + opacityAt: p => stateAt(p).opacity, + restartFrom(rect, opacity, end) { + const scaleX = box.width > 0 ? rect.width / box.width : 1; + const scaleY = box.height > 0 ? rect.height / box.height : 1; + const across = axis === 'x' ? scaleY : scaleX; + const along = axis === 'x' ? scaleX : scaleY; + return growPath( + box, + origin, + axis, + collapsed, + {scale: across, length: across > 0 ? along / across : 1, opacity}, + end === 'rest' ? GROWN : collapsed, + ); + }, + }; +} + +/** + * UIKit's popover (UIPopoverPresentationController, iOS 26.2): it grows out of + * `origin`, the point of its edge facing the source, from a uniform + * `collapsedScale` and `collapsedLength` of its own length along `axis` (the + * axis pointing away from the source). At progress p its scale is + * s = s₀ + (1 − s₀)·p across the axis and s·(L₀ + (1 − L₀)·p) along it, + * about `origin`, and its opacity p, so it widens before it lengthens. An + * interrupted motion continues from the scale and length on screen. + */ +export function edgeGrowPath({ + box, + origin, + axis, + collapsedScale, + collapsedLength, +}: { + /** The element's box as laid out, at rest. */ + box: MorphRect; + /** The point it grows out of, px from the box's top-left corner. */ + origin: {x: number; y: number}; + /** The axis pointing away from the source: `y` above or below it. */ + axis: 'x' | 'y'; + /** The uniform scale it grows out of. */ + collapsedScale: number; + /** The share of its own length along `axis` it grows out of. */ + collapsedLength: number; +}): MorphPath { + const collapsed = { + scale: collapsedScale, + length: collapsedLength, + opacity: 0, + }; + return growPath(box, origin, axis, collapsed, collapsed, GROWN); +} + +/** The transform channels a path drives. */ +export type TravelProperty = 'x' | 'y' | 'scaleX' | 'scaleY'; + +/** + * The transform that puts an element laid out in `path.box` on its rect. + * + * @internal Read by the rect morph's player for its start + * values and its velocity projection; not exported from the package. + */ +export function travelAt( + path: MorphPath, + p: number, +): Record { + const rect = path.rectAt(p); + const {box} = path; + const scaleX = box.width > 0 ? rect.width / box.width : 1; + const scaleY = + path.scale === 'uniform' + ? scaleX + : box.height > 0 + ? rect.height / box.height + : 1; + return {x: rect.x - box.x, y: rect.y - box.y, scaleX, scaleY}; +} + +/** + * Curves for `Animator.play` that put an element laid out in `path.box`, with + * `transform-origin: 0 0`, on the path's rect as `progress` moves, and its + * opacity on `path.opacityAt` as `fade` moves (`progress` by default). + */ +export function morphCurves( + path: MorphPath, + progress: MotionCurve, + fade: MotionCurve = progress, +): Partial> { + const travel = (property: TravelProperty) => + mapProgress(progress, p => travelAt(path, p)[property]); + const curves: Partial> = { + x: travel('x'), + y: travel('y'), + scaleX: travel('scaleX'), + scaleY: travel('scaleY'), + }; + const {opacityAt} = path; + if (opacityAt != null) { + curves.opacity = mapProgress(fade, p => opacityAt(p)); + } + return curves; +} From 131ca142ce0e251357ca615c2b40dca5794f321c Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 3 Nov 2026 08:23:51 -0800 Subject: [PATCH 004/118] feat(core): one rect morph for touch presentations that grow out of a source rect createRectMorph in motion/rectMorph.ts, internal: present along a path and dismiss back onto a copy or the element, each continuing the other from the rect on screen. Rebuilt on main from the pull request's own change and ported to main's engine (createMotionAnimator). Timing follows the owner's rule for touch motion: present and dismiss take a motion, either a theme duration and easing (a tap, a key, the program) or a spring (a gesture's release, from its velocity). timedProgressCurve joins the progress helpers. architecture:react-component-runtime INV12 records the implementation gap: the size-change glide moves onto the morph next. Co-authored-by: Cindy Zhang --- .changeset/motion-rect-morph.md | 7 + docs/architecture/react-component-runtime.md | 4 +- packages/core/src/motion/README.md | 1 + .../core/src/motion/__tests__/motionBundle.ts | 2 + .../rectMorph.interaction.browser.spec.ts | 444 +++++++++++ packages/core/src/motion/progress.test.ts | 20 + packages/core/src/motion/progress.ts | 31 +- packages/core/src/motion/rectMorph.test.ts | 695 ++++++++++++++++++ packages/core/src/motion/rectMorph.ts | 501 +++++++++++++ 9 files changed, 1703 insertions(+), 2 deletions(-) create mode 100644 .changeset/motion-rect-morph.md create mode 100644 packages/core/src/motion/__tests__/rectMorph.interaction.browser.spec.ts create mode 100644 packages/core/src/motion/rectMorph.test.ts create mode 100644 packages/core/src/motion/rectMorph.ts diff --git a/.changeset/motion-rect-morph.md b/.changeset/motion-rect-morph.md new file mode 100644 index 0000000000000..0192c46f8c2cd --- /dev/null +++ b/.changeset/motion-rect-morph.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the rect morph, one way for a touch presentation to grow out of a source rect and shrink back into it. `createRectMorph` presents an element along a path (`lerpPath`, `edgeGrowPath`) and dismisses it back on a copy or on itself, each continuing the other from the rect on screen. A motion a tap or a key starts plays a theme duration and easing; a gesture's release plays a spring from its velocity. Reduced Motion is left to the animator. `timedProgressCurve` joins the progress helpers. Not exported from the package entry point; no component uses it yet. + +@vjeux diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 56838fde56f98..37e9e4bc3fbcc 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -198,7 +198,9 @@ those contracts. change and moves shifted content with a compositor-only translate played through the shared rectangle morph (`architecture:layer-runtime` INV15; `spec:AST-094` FR5) once Core has that morph; until then, the module plays - its own compositor translate. It never animates height. Its walk visits only + its own compositor translate. (Implementation gap: Core has the shared + rectangle morph, but the size-change glide still plays its own compositor + translate; it moves onto the morph next.) It never animates height. Its walk visits only the changed element's ancestors up to the area and the element siblings on either side at each of their levels, through `parentElement`, `nextElementSibling` and `previousElementSibling`, descending only into an diff --git a/packages/core/src/motion/README.md b/packages/core/src/motion/README.md index e0c672a1b1326..89ae755675ad3 100644 --- a/packages/core/src/motion/README.md +++ b/packages/core/src/motion/README.md @@ -39,6 +39,7 @@ roles through `resolveMotion`. | `bezier.ts` | Cubic-bézier timing functions (Core Animation's named curves). | | `animator.ts` | Compositor animator: springs, coasts and any closed-form curve compiled to Web Animations, read back analytically. | | `morphPath.ts` | The rect an element shows at each progress of a grow-out presentation: a straight line, a popover’s edge growth, and the curves that put it there. | +| `rectMorph.ts` | Grow out of a source rect and shrink back into it along a path: present and dismiss, each continuing the other from the rect on screen. | | `progress.ts` | A spring's progress, one curve mapped from another (companions, clamped ranges), the velocity that continues a motion. | | `presentation.ts` | Where each moving element is drawn relative to where the engine hit-tests it, reported by the animator while translate runs; read by the gesture host at touch-down. | | `reducedMotionPlan.ts` | The animator's Reduce Motion planner: what one call becomes, a cross-fade whose travel lands while transparent or at once. | diff --git a/packages/core/src/motion/__tests__/motionBundle.ts b/packages/core/src/motion/__tests__/motionBundle.ts index 874fb5e2a8d38..0b50a74857f91 100644 --- a/packages/core/src/motion/__tests__/motionBundle.ts +++ b/packages/core/src/motion/__tests__/motionBundle.ts @@ -9,6 +9,8 @@ */ export {createMotionAnimator} from '../animator'; +export {progressCurve} from '../progress'; +export {createRectMorph, edgeGrowPath, lerpPath} from '../rectMorph'; export {solveSpring, toSpring} from '../spring'; export {registerSpringsTable, springs} from '../uikit/springs'; export {motionRoleOf} from '../roles'; diff --git a/packages/core/src/motion/__tests__/rectMorph.interaction.browser.spec.ts b/packages/core/src/motion/__tests__/rectMorph.interaction.browser.spec.ts new file mode 100644 index 0000000000000..f4305542366ce --- /dev/null +++ b/packages/core/src/motion/__tests__/rectMorph.interaction.browser.spec.ts @@ -0,0 +1,444 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file rectMorph.interaction.browser.spec.ts + * @input The rect morph and the motion source bundled with esbuild onto a + * blank fixture page; the interaction harness's busy-main-thread, + * screencast and trace instruments; Chromium and WebKit on the phone + * profile + * @output Proof that the engine shows the morph's path: seeked frame by + * frame, a present and a dismissal put the element's box on screen on the + * path's rect within 0.5 px (a straight line with a fade, a popover's edge + * growth); the morph keeps moving through a 1 s main-thread block; no + * Layout or Paint runs while it animates (Chromium trace) + * @position Browser proof for rectMorph.ts, run by `pnpm test:interaction`; + * jsdom runs no animations + */ + +import {fileURLToPath} from 'node:url'; +import { + blockMainThread, + busyWindowProof, + countBetween, + markElement, + startScreencast, + traceRendering, + trackMarker, +} from '@astryxdesign/interaction-harness'; +import {expect, test, type Page} from '@playwright/test'; +import {build} from 'esbuild'; +// The page's `Motion` global (the bundle) is declared beside the animator's +// proofs, in animator.interaction.browser.spec.ts. + +let script = ''; + +test.beforeAll(async () => { + const result = await build({ + entryPoints: [fileURLToPath(new URL('./motionBundle.ts', import.meta.url))], + bundle: true, + write: false, + format: 'iife', + globalName: 'Motion', + target: 'es2022', + }); + script = result.outputFiles[0].text; +}); + +/** The surface at rest, and the 22 px square on its control it grows from. */ +const BOX = {x: 40, y: 204, width: 320, height: 253}; +const SQUARE = {x: 179, y: 160, width: 22, height: 22}; +/** UIKit's popover numbers (#8187): 1 % scale, 100 px of length. */ +const POPOVER = {origin: {x: 160, y: 0}, collapsedScale: 0.01}; +const SPRING = {response: 0.45, dampingRatio: 0.8}; +/** A slow spring for the block: well over a px per frame throughout. */ +const SLOW = {response: 2, dampingRatio: 1}; +const MARKER_INSET = 8; + +// A real navigation (not setContent): the page keeps its own window. +async function open(page: Page) { + const html = ` + +
+`; + await page.route('http://motion.test/', async route => + route.fulfill({contentType: 'text/html', body: html}), + ); + await page.goto('http://motion.test/'); +} + +type PathKind = 'line' | 'edge'; + +/** + * Plays a present (or, after it, a dismissal on a copy) of `kind`, pauses + * the engine, seeks it every 1/60 s, and returns the worst gap between the + * box on screen and the path's rect, px, and between the opacity on screen + * and the path's. + */ +async function seekedGap( + page: Page, + kind: PathKind, + phase: 'present' | 'dismiss', +) { + return page.evaluate( + async ({kind, phase, box, square, popover, spring}) => { + const surface = document.getElementById('surface') as HTMLElement; + const morph = Motion.createRectMorph(surface); + const path = + kind === 'line' + ? Motion.lerpPath(square, box, {opacity: [0, 1]}) + : Motion.edgeGrowPath({ + box, + origin: popover.origin, + axis: 'y', + collapsedScale: popover.collapsedScale, + collapsedLength: 100 / box.height, + }); + let target = surface; + let handle = morph.present(path, {spring: spring}); + if (phase === 'dismiss') { + await handle.finished; + target = surface.cloneNode(true) as HTMLElement; + target.id = 'copy'; + surface.after(target); + handle = morph.dismiss(path, {spring: spring}, {target}); + } + await handle.started; + const animations = target.getAnimations(); + const progress = Motion.progressCurve(spring); + for (const animation of animations) { + animation.pause(); + } + let worstRect = 0; + let worstOpacity = 0; + const step = 1000 / 60; + for (let t = 0; t <= progress.duration * 1000 + step; t += step) { + for (const animation of animations) { + animation.currentTime = t; + } + const p = progress.at(t / 1000); + // The dismissal runs the path backward, rest to source. + const at = phase === 'present' ? p : 1 - p; + // Past the source on an overshoot the scale crosses 0 (the copy is + // transparent there): the box on screen is the flipped rect. + const raw = path.rectAt(at); + const expected = { + x: Math.min(raw.x, raw.x + raw.width), + y: Math.min(raw.y, raw.y + raw.height), + width: Math.abs(raw.width), + height: Math.abs(raw.height), + }; + const rect = target.getBoundingClientRect(); + worstRect = Math.max( + worstRect, + Math.abs(rect.x - expected.x), + Math.abs(rect.y - expected.y), + Math.abs(rect.width - expected.width), + Math.abs(rect.height - expected.height), + ); + const opacity = Number.parseFloat(getComputedStyle(target).opacity); + const expectedOpacity = Math.min( + 1, + Math.max(0, path.opacityAt?.(at) ?? 1), + ); + worstOpacity = Math.max( + worstOpacity, + Math.abs(opacity - expectedOpacity), + ); + } + return { + animations: animations.length, + worstRect, + worstOpacity, + origin: getComputedStyle(target).transformOrigin, + }; + }, + {kind, phase, box: BOX, square: SQUARE, popover: POPOVER, spring: SPRING}, + ); +} + +for (const kind of ['line', 'edge'] as const) { + for (const phase of ['present', 'dismiss'] as const) { + test(`the engine shows the path\u2019s rect at every frame: ${kind === 'line' ? 'a straight line with a fade' : 'a popover\u2019s edge growth'}, ${phase}`, async ({ + page, + }) => { + await open(page); + const result = await seekedGap(page, kind, phase); + test.info().annotations.push({ + type: 'seeked gap', + description: JSON.stringify(result), + }); + // translate, scale and opacity, one animation each. + expect(result.animations).toBe(3); + expect(result.origin).toBe('0px 0px'); + expect(result.worstRect).toBeLessThanOrEqual(0.5); + expect(result.worstOpacity).toBeLessThanOrEqual(0.005); + }); + } +} + +// FR6 is verified in a browser too: an interrupted motion continues from the +// box on screen. Two readings: reads on either side of the switch in one +// task, and the composited frames across it. + +// One task, two reads either side of the switch: nothing is presented in +// between, so the second read is the new motion's first value. The morph +// reads the document timeline here, the clock the compositor presents on, +// so both reads see one instant whatever the frame timing; on its default +// clock the new motion's start would sit up to the time since the last frame +// ahead of the first read. +async function readAcrossSwitch(page: Page, restart: boolean) { + return page.evaluate( + async ({box, square, spring, restart}) => { + const element = document.getElementById('surface') as HTMLElement; + const morph = Motion.createRectMorph(element, { + now: () => Number(document.timeline.currentTime), + }); + const path = Motion.lerpPath(square, box); + const read = () => { + const r = element.getBoundingClientRect(); + return {x: r.x, y: r.y, width: r.width, height: r.height}; + }; + await morph.present(path, {spring: spring}).started; + // Partway up the present, with the box well away from both ends. + await new Promise(resolve => setTimeout(resolve, 160)); + // Read and switch inside a frame callback: there the timeline's time is + // the frame's, the time the browser samples the running animation at. + // In a timer task WebKit can hand out a timeline time a frame stale + // while the box is sampled fresher, which a loaded page shows as a + // step of a few px. + const frame = async () => + new Promise(resolve => requestAnimationFrame(resolve)); + await frame(); + const previous = read(); + const previousAt = Number(document.timeline.currentTime); + await frame(); + const before = read(); + const at = Number(document.timeline.currentTime); + if (restart) { + // What a morph that did not continue would do: start the path over. + morph.reset(); + morph.present(path, {spring: spring}); + } else { + // On the element itself: no copy, so the switch alone moves the box. + morph.dismiss(path, {spring: spring}, {target: element}); + } + const after = read(); + const change = (a: typeof before, b: typeof before) => + Math.max( + Math.abs(a.x - b.x), + Math.abs(a.y - b.y), + Math.abs(a.width - b.width), + Math.abs(a.height - b.height), + ); + return { + before, + after, + step: change(after, before), + // The reads share a clock that WebKit counts in whole milliseconds, + // so they can only agree to the box's travel in one of them, + // measured over the frame before. + clockTick: change(before, previous) / Math.max(1, at - previousAt), + }; + }, + {box: BOX, square: SQUARE, spring: SLOW, restart}, + ); +} + +// The composited frames across a switch, on the morph's default clock. The +// box rises 240 px along a path with no fade, so the marker is tracked in +// every frame; the dismissal (or the restart) takes over 600 ms in, near the +// middle of the travel. The screencast starts once the present has started: +// before that the box sits at rest, and its first step onto the path is not +// the switch. +type Switching = { + morph: ReturnType; + path: ReturnType; + from: {x: number; y: number; width: number; height: number}; +}; +async function trackAcrossSwitch(page: Page, restart: boolean) { + const surface = page.locator('#surface'); + await markElement(surface, {inset: MARKER_INSET}); + await page.evaluate( + async ({box, spring}) => { + const element = document.getElementById('surface') as HTMLElement; + const morph = Motion.createRectMorph(element); + const from = { + x: box.x, + y: box.y + 240, + width: box.width * 0.8, + height: box.height * 0.8, + }; + const path = Motion.lerpPath(from, box); + await morph.present(path, {spring: spring}).started; + (window as unknown as {switching: Switching}).switching = { + morph, + path, + from, + }; + }, + {box: BOX, spring: SLOW}, + ); + const cast = await startScreencast(page); + const geometry = await page.evaluate( + async ({box, spring, restart}) => { + const element = document.getElementById('surface') as HTMLElement; + const {morph, path, from} = (window as unknown as {switching: Switching}) + .switching; + await new Promise(resolve => setTimeout(resolve, 600)); + const at = element.getBoundingClientRect().y; + if (restart) { + morph.reset(); + morph.present(path, {spring: spring}); + } else { + morph.dismiss(path, {spring: spring}, {target: element}); + } + await new Promise(resolve => setTimeout(resolve, 600)); + // The smallest move a discontinuity would make from here: back to the + // path's start (a restart), or to the box at rest (a frame presented + // without the morph's transform). + return {jump: Math.min(Math.abs(at - from.y), Math.abs(at - box.y))}; + }, + {box: BOX, spring: SLOW, restart}, + ); + await page.waitForTimeout(100); + const seen = (await trackMarker(page, await cast.stop())).filter( + frame => frame.box != null, + ); + // A loaded page can open the screencast on the last frame composited before + // the present's first, with the box at rest; its step onto the path is not + // the switch. The present starts 240 px below rest, so the frames from the + // first one off the rest position on are the present's. + const onPath = seen.findIndex( + frame => (frame.box?.y ?? 0) > BOX.y + MARKER_INSET + 1, + ); + const tracked = onPath < 0 ? seen : seen.slice(onPath); + let rising = 0; + let falling = 0; + let worstStep = 0; + for (let i = 1; i < tracked.length; i++) { + const dy = tracked[i].box!.y - tracked[i - 1].box!.y; + if (dy < -0.5) { + rising++; + } + if (dy > 0.5) { + falling++; + } + worstStep = Math.max(worstStep, Math.abs(dy)); + } + return {tracked: tracked.length, rising, falling, worstStep, ...geometry}; +} + +const SUBPIXEL = 0.5; + +test('a dismissal mid-present starts from the box: reads either side of the switch, in one task', async ({ + page, +}) => { + await open(page); + const reading = await readAcrossSwitch(page, false); + test + .info() + .annotations.push({type: 'switch', description: JSON.stringify(reading)}); + expect(reading.step).toBeLessThanOrEqual(SUBPIXEL + reading.clockTick); +}); + +test('on screen, the box keeps moving through the switch without a jump', async ({ + page, +}) => { + await open(page); + const reading = await trackAcrossSwitch(page, false); + test + .info() + .annotations.push({type: 'track', description: JSON.stringify(reading)}); + // Frames on both sides of the switch: the box was tracked rising under the + // present and falling under the dismissal. + expect(reading.rising).toBeGreaterThanOrEqual(5); + expect(reading.falling).toBeGreaterThanOrEqual(5); + // A frame's own travel is a few px; a discontinuity moves the box by at + // least `jump`. Half of it separates the two. + expect(reading.worstStep).toBeLessThan(reading.jump / 2); +}); + +test('a restart fails both readings, so they have teeth', async ({page}) => { + await open(page); + const read = await readAcrossSwitch(page, true); + await open(page); + const track = await trackAcrossSwitch(page, true); + test.info().annotations.push({ + type: 'restart', + description: JSON.stringify({read, track}), + }); + expect(read.step).toBeGreaterThan(SUBPIXEL + read.clockTick); + expect(track.worstStep).toBeGreaterThanOrEqual(track.jump / 2); +}); + +test('a present keeps growing every frame through a 1 s main-thread block', async ({ + page, +}) => { + await open(page); + const surface = page.locator('#surface'); + await markElement(surface, {inset: MARKER_INSET}); + const cast = await startScreencast(page); + await page.evaluate( + async ({box, spring}) => { + const element = document.getElementById('surface') as HTMLElement; + const morph = Motion.createRectMorph(element); + // From 80 % of its size, 240 px lower (on screen the whole way): the + // marker in the box's corner rises the whole way, over a px a frame. + const from = { + x: box.x, + y: box.y + 240, + width: box.width * 0.8, + height: box.height * 0.8, + }; + await morph.present(Motion.lerpPath(from, box), {spring: spring}).started; + }, + {box: BOX, spring: SLOW}, + ); + const busy = await blockMainThread(page, 1000, { + during: surface, + minProgressMs: 40, + }); + await page.waitForTimeout(200); + const tracked = await trackMarker(page, await cast.stop()); + const proof = busyWindowProof(tracked, busy, {axis: 'y', settledWithin: 2}); + test + .info() + .annotations.push({type: 'busy window', description: proof.summary}); + expect(busy.to - busy.from).toBeGreaterThanOrEqual(1000); + expect(proof.verdict.failures, proof.summary).toEqual([]); + expect(proof.monotonic).toBe(true); +}); + +test('no Layout or Paint runs while it animates', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'tracing is a Chromium protocol'); + await open(page); + let start = 0; + const trace = await traceRendering(page, async () => { + start = await page.evaluate( + ({box, square, spring}) => { + const element = document.getElementById('surface') as HTMLElement; + const morph = Motion.createRectMorph(element); + morph.present(Motion.lerpPath(square, box, {opacity: [0, 1]}), { + spring: spring, + }); + return performance.timeOrigin + performance.now(); + }, + {box: BOX, square: SQUARE, spring: SLOW}, + ); + await page.waitForTimeout(1900); + }); + // After the frame that starts the animation. + const counts = countBetween(trace, start + 50, trace.window.to); + test.info().annotations.push({ + type: 'rendering work while animating', + description: JSON.stringify(counts), + }); + expect(counts.Layout ?? 0).toBe(0); + expect(counts.Paint ?? 0).toBe(0); +}); diff --git a/packages/core/src/motion/progress.test.ts b/packages/core/src/motion/progress.test.ts index 4de3fa57df8de..a56ce3a5b74bf 100644 --- a/packages/core/src/motion/progress.test.ts +++ b/packages/core/src/motion/progress.test.ts @@ -7,6 +7,7 @@ import { mapRange, presentedProgressVelocity, progressCurve, + timedProgressCurve, type ProgressChannel, } from './progress'; import {solveSpring, toSpring} from './spring'; @@ -170,3 +171,22 @@ describe('presentedProgressVelocity', () => { expect(times).toEqual([1234, 1234]); }); }); + +describe('timedProgressCurve', () => { + it('runs from 0 to 1 over the duration on the easing, then holds 1', () => { + const curve = timedProgressCurve(300, [0.25, 0.1, 0.25, 1]); + expect(curve.duration).toBeCloseTo(0.3, 9); + expect(curve.at(0)).toBeCloseTo(0, 9); + expect(curve.at(0.15)).toBeGreaterThan(0.5); + expect(curve.at(0.3)).toBe(1); + expect(curve.at(1)).toBe(1); + expect(curve.velocity(0.3)).toBe(0); + expect(curve.velocity(0.1)).toBeGreaterThan(0); + }); + + it('lands at once for a zero duration', () => { + const curve = timedProgressCurve(0, [0, 0, 1, 1]); + expect(curve.duration).toBe(0); + expect(curve.at(0)).toBe(1); + }); +}); diff --git a/packages/core/src/motion/progress.ts b/packages/core/src/motion/progress.ts index 8b97de255de90..11aec62e51d24 100644 --- a/packages/core/src/motion/progress.ts +++ b/packages/core/src/motion/progress.ts @@ -4,7 +4,8 @@ * @file progress.ts * @input A spring, a normalized progress, an animator's presented velocities * @output Exports `progressCurve` (a spring's progress from 0 to 1 as a - * MotionCurve), `mapProgress` (one curve as a function of another), + * MotionCurve), `timedProgressCurve` (the same over a duration and + * easing), `mapProgress` (one curve as a function of another), * `mapRange` (the clamped linear map most companions use) and * `presentedProgressVelocity` (the progress velocity that continues what * an animator shows) @@ -39,6 +40,7 @@ */ import type {Animator, MotionCurve, MotionProperty} from './animator'; +import {cubicBezier, type BezierPoints} from './bezier'; import { SETTLING_EPSILON, solveSpring, @@ -69,6 +71,33 @@ export function progressCurve( }; } +/** + * A progress from 0 to 1 over `durationMs`, eased by the cubic-bézier + * `easing`: the curve a timed motion (a theme duration and easing) plays. + * Its velocity is in progress per second. + */ +export function timedProgressCurve( + durationMs: number, + easing: BezierPoints, +): MotionCurve { + const duration = Math.max(0, durationMs) / 1000; + const ease = cubicBezier(easing); + const at = (t: number) => + duration === 0 || t >= duration ? 1 : ease(Math.max(0, t) / duration); + return { + duration, + at, + velocity: t => { + if (duration === 0 || t >= duration) { + return 0; + } + const t0 = Math.max(0, t - STEP); + const t1 = Math.min(duration, t + STEP); + return (at(t1) - at(t0)) / (t1 - t0); + }, + }; +} + /** * The curve of `f(driver(t))`, its velocity by the chain rule: a channel * following a path as its progress moves, or a companion following any diff --git a/packages/core/src/motion/rectMorph.test.ts b/packages/core/src/motion/rectMorph.test.ts new file mode 100644 index 0000000000000..15b836b616039 --- /dev/null +++ b/packages/core/src/motion/rectMorph.test.ts @@ -0,0 +1,695 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file rectMorph.test.ts + * @input vitest (jsdom), the rect morph, a recording stand-in for + * Element.animate (jsdom runs no animations), and the models of the + * presentations that move onto the morph, as their pull requests have them + * @output Checks that present and dismiss continue each other from the rect + * on screen with its velocity; the dismissal's target; the inline state + * left behind; Reduced Motion through the animator (the paths and the + * presentations' models are checked in morphPath.test.ts) + * @position Testing; the compositor side is proven in + * __tests__/rectMorph.interaction.browser.spec.ts + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {createMotionAnimator} from './animator'; +import {progressCurve, timedProgressCurve} from './progress'; +import { + createRectMorph, + edgeGrowPath, + lerpPath, + type MorphRect, +} from './rectMorph'; +import {timing} from './uikit/timing'; +import {registerSpringsTable, springs} from './uikit/springs'; +import {resolveMotion} from './resolveMotion'; + +interface Recorded { + element: Element; + keyframes: Keyframe[]; + options: KeyframeAnimationOptions; + cancelled: boolean; + startTime: number | null; + readonly playState: 'idle' | 'running'; + ready: Promise; + cancel(): void; +} + +let clock = 1000; +let recorded: Recorded[] = []; +// The animator's frames: a call reads the browser's start for its curves in +// the frame after it, never in the task that makes them, so a test plays that +// frame before it reads where the element is. +let frameQueue: (() => void)[] = []; +const requestFrame = (callback: () => void) => { + frameQueue.push(callback); +}; +async function playFrame() { + const frames = frameQueue; + frameQueue = []; + for (const callback of frames) { + callback(); + } + await Promise.resolve(); + await Promise.resolve(); +} + +beforeEach(() => { + clock = 1000; + recorded = []; + frameQueue = []; + vi.useFakeTimers(); + HTMLElement.prototype.animate = function (keyframes, options) { + const animation: Recorded = { + element: this, + keyframes: keyframes as Keyframe[], + options: options as KeyframeAnimationOptions, + cancelled: false, + // The browser starts it at once: its first frame is committed at the + // call, so the animator's model starts the curve then. + startTime: clock, + get playState() { + return this.cancelled ? 'idle' : 'running'; + }, + ready: new Promise(() => {}), + cancel() { + this.cancelled = true; + }, + }; + recorded.push(animation); + return animation as unknown as Animation; + }; +}); + +afterEach(() => { + Reflect.deleteProperty(HTMLElement.prototype, 'animate'); + vi.useRealTimers(); + vi.unstubAllGlobals(); + document.body.replaceChildren(); +}); + +/** + * The person's settings, through `matchMedia` as the animator reads them: no + * option overrides them. + */ +function stubMedia(reduced: boolean, coarse: boolean) { + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: query.includes('prefers-reduced-motion') + ? reduced + : query.includes('pointer: coarse') + ? coarse + : false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })); +} + +const SPRING = {response: 0.4, dampingRatio: 1}; +const BOUNCY = {response: 0.35, dampingRatio: 0.8}; + +function expectRect(actual: MorphRect, expected: MorphRect, tolerance: number) { + const gap = Math.max( + Math.abs(actual.x - expected.x), + Math.abs(actual.y - expected.y), + Math.abs(actual.width - expected.width), + Math.abs(actual.height - expected.height), + ); + expect(gap, JSON.stringify({actual, expected})).toBeLessThanOrEqual( + tolerance, + ); +} + +const SQUARE: MorphRect = {x: 179, y: 160, width: 22, height: 22}; +const BOX: MorphRect = {x: 40, y: 204, width: 320, height: 253}; + +// The popover's model, as #8187 has it, for the present that turns around +// on its edge growth. +/** Popover (#8187, popoverSurfaceMotion.ts): popoverCollapsedState, popoverFrame. */ +const POPOVER = {collapsedScale: 0.01, collapsedLengthPx: 100}; +function popoverModel(surface: MorphRect, source: MorphRect) { + const gaps = { + below: surface.y - (source.y + source.height), + above: source.y - (surface.y + surface.height), + right: surface.x - (source.x + source.width), + left: source.x - (surface.x + surface.width), + }; + let side: keyof typeof gaps = 'below'; + for (const candidate of ['above', 'right', 'left'] as const) { + if (gaps[candidate] > gaps[side]) { + side = candidate; + } + } + const clamp = (v: number, min: number, max: number) => + Math.min(Math.max(v, min), max); + const centerX = clamp( + source.x + source.width / 2 - surface.x, + 0, + surface.width, + ); + const centerY = clamp( + source.y + source.height / 2 - surface.y, + 0, + surface.height, + ); + const vertical = side === 'below' || side === 'above'; + const fullLength = vertical ? surface.height : surface.width; + const axis: 'x' | 'y' = vertical ? 'y' : 'x'; + const state = { + originX: vertical ? centerX : side === 'right' ? 0 : surface.width, + originY: vertical ? (side === 'below' ? 0 : surface.height) : centerY, + axis, + scale: POPOVER.collapsedScale, + length: Math.min(1, POPOVER.collapsedLengthPx / fullLength), + }; + // A frame of (scale, length, opacity), scaled about the origin. + const frameRect = (scale: number, length: number): MorphRect => { + const along = scale * length; + const scaleX = state.axis === 'x' ? along : scale; + const scaleY = state.axis === 'y' ? along : scale; + return { + x: surface.x + state.originX * (1 - scaleX), + y: surface.y + state.originY * (1 - scaleY), + width: surface.width * scaleX, + height: surface.height * scaleY, + }; + }; + return { + state, + rectAt: (p: number) => + frameRect( + state.scale + (1 - state.scale) * p, + state.length + (1 - state.length) * p, + ), + frameRect, + }; +} + +describe('createRectMorph', () => { + const setup = (options: {reducedMotion?: boolean; coarse?: boolean} = {}) => { + const element = document.createElement('div'); + document.body.appendChild(element); + stubMedia(options.reducedMotion ?? false, options.coarse ?? true); + const morph = createRectMorph(element, {now: () => clock, requestFrame}); + return {element, morph}; + }; + const copyOf = (element: HTMLElement) => { + const copy = element.cloneNode(true) as HTMLElement; + copy.setAttribute('data-copy', ''); + element.after(copy); + return copy; + }; + const live = (target: Element, property: string) => + recorded.filter( + animation => + !animation.cancelled && + animation.element === target && + animation.keyframes.some(frame => property in frame), + ); + const settle = async (ms: number) => { + await playFrame(); + clock += ms; + await vi.advanceTimersByTimeAsync(ms); + }; + + it('plays the table value, since a motion role is internal (DEC-11)', () => { + // A registered table's leaf is a motion role, but a role resolves to its + // table value: touch motion stays internal and no `--motion-*` variable + // retunes it. Writing one on the scope therefore changes nothing. + const {element, morph} = setup(); + const scope = document.createElement('div'); + scope.setAttribute('data-astryx-theme', 'brisk'); + document.body.appendChild(scope); + scope.appendChild(element); + registerSpringsTable(); + vi.spyOn(window, 'getComputedStyle').mockImplementation( + (node: Element) => + ({ + getPropertyValue: (name: string) => + node === scope && name === '--motion-springs-menu-open' + ? '0.9s 0.25' + : '', + }) as CSSStyleDeclaration, + ); + expect(resolveMotion(element, springs.menuOpen)).toBe(springs.menuOpen); + + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + morph.present(path, {spring: springs.menuOpen}); + const [presenting] = live(element, 'scale'); + const withVariable = Number(presenting.options.duration); + vi.restoreAllMocks(); + + const plain = setup(); + plain.morph.present(lerpPath(SQUARE, BOX, {opacity: [0, 1]}), { + spring: springs.menuOpen, + }); + const [plainPresenting] = live(plain.element, 'scale'); + expect(withVariable).toBeCloseTo( + Number(plainPresenting.options.duration), + 6, + ); + }); + + it('presents the element from its source to rest on the spring', async () => { + const {element, morph} = setup(); + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + morph.present(path, {spring: BOUNCY}); + await playFrame(); + expect(element.style.getPropertyValue('transform-origin')).toBe('0px 0px'); + expect(live(element, 'scale').length).toBe(1); + expect(live(element, 'translate').length).toBe(1); + const progress = progressCurve(BOUNCY); + const start = clock; + for (const t of [0, 0.03, 0.08, 0.15, 0.3]) { + clock = start + t * 1000; + expectRect(morph.presentedRect()!, path.rectAt(progress.at(t)), 0.01); + } + }); + + it('presents on a theme timing: its duration and easing as given, from rest', async () => { + const {element, morph} = setup(); + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + const easing = [0.2, 0, 0, 1] as const; + // A tap has no velocity: a timed present starts from rest even when + // one is given. + morph.present(path, {duration: 300, easing}, {velocity: 6}); + await playFrame(); + expect(live(element, 'scale').length).toBe(1); + const progress = timedProgressCurve(300, easing); + const start = clock; + for (const t of [0, 0.05, 0.12, 0.2, 0.29]) { + clock = start + t * 1000; + expectRect(morph.presentedRect()!, path.rectAt(progress.at(t)), 0.01); + } + }); + + it('plays on an animator it is handed, and leaves it to its owner when disposed', async () => { + // A zoom stage already drives the photo with its own animator (pinch, + // pan, settle): the morph plays on it, so the two share the channels. + const element = document.createElement('div'); + document.body.appendChild(element); + stubMedia(false, true); + const stage = createMotionAnimator(element, { + now: () => clock, + requestFrame, + }); + const morph = createRectMorph(element, { + animator: stage, + now: () => clock, + requestFrame, + }); + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + morph.present(path, {spring: BOUNCY}); + await playFrame(); + expect(live(element, 'scale')).toHaveLength(1); + clock += 50; + expect(stage.get('scaleX', clock)).toBeCloseTo( + morph.presentedRect()!.width / BOX.width, + 9, + ); + expect(stage.get('scaleX', clock)).not.toBeCloseTo(1, 3); + morph.dispose(); + // Still the stage's: it moves the element after the morph is gone. + stage.set({x: 12}); + expect(stage.get('x')).toBe(12); + }); + + it('holds the element at its first values until two frames present it, when asked for the presented start', async () => { + const {element, morph} = setup(); + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + morph.present(path, {spring: BOUNCY}, {start: 'presented'}); + expect(live(element, 'scale')).toHaveLength(0); + expectRect(morph.presentedRect()!, path.rectAt(0), 0.01); + await playFrame(); + expect(live(element, 'scale')).toHaveLength(0); + await playFrame(); + expect(live(element, 'scale')).toHaveLength(1); + }); + + it('leaves nothing of its own inline once the present settles', async () => { + const {element, morph} = setup(); + morph.present(lerpPath(SQUARE, BOX, {opacity: [0, 1]}), {spring: SPRING}); + await settle(progressCurve(SPRING).duration * 1000 + 50); + for (const property of [ + 'translate', + 'scale', + 'transform-origin', + 'opacity', + ]) { + expect(element.style.getPropertyValue(property), property).toBe(''); + } + expectRect(morph.presentedRect()!, BOX, 1e-9); + }); + + it('leaves a component\u2019s own opacity alone when its path has none', async () => { + const {element, morph} = setup(); + element.style.setProperty('opacity', '0.5'); + morph.present(lerpPath(SQUARE, BOX), {spring: SPRING}); + await settle(progressCurve(SPRING).duration * 1000 + 50); + expect(element.style.getPropertyValue('opacity')).toBe('0.5'); + expect(live(element, 'opacity').length).toBe(0); + }); + + it('dismisses on its target from the rect on screen, leaves the element as laid out, then releases the target', async () => { + const {element, morph} = setup(); + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + morph.present(path, {spring: SPRING}); + await settle(progressCurve(SPRING).duration * 1000 + 50); + const copy = copyOf(element); + const dismissing = morph.dismiss(path, {spring: BOUNCY}, {target: copy}); + expect(copy.style.getPropertyValue('transform-origin')).toBe('0px 0px'); + expect(live(copy, 'scale').length).toBe(1); + for (const property of [ + 'translate', + 'scale', + 'transform-origin', + 'opacity', + ]) { + expect(element.style.getPropertyValue(property), property).toBe(''); + } + // It runs from rest back to the square, fading out. + await playFrame(); + const progress = progressCurve(BOUNCY); + const back = lerpPath(BOX, SQUARE, {opacity: [1, 0]}); + const start = clock; + for (const t of [0, 0.05, 0.1, 0.2]) { + clock = start + t * 1000; + expectRect(morph.presentedRect()!, back.rectAt(progress.at(t)), 0.01); + } + clock = start; + await settle(progress.duration * 1000 + 50); + expect(await dismissing.finished).toBe(true); + expect(copy.isConnected).toBe(false); + }); + + it('lets the element go of its compositing layer as a dismissal on a copy starts, so no style write reaches it during the dismissal', async () => { + const {element, morph} = setup(); + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + morph.present(path, {spring: SPRING}); + // Dismissed about 100 ms after the present's curve ended, while the + // animator still keeps the element promoted (its promotion linger). + await settle(progressCurve(SPRING).duration * 1000 + 100); + const copy = copyOf(element); + morph.dismiss(path, {spring: BOUNCY}, {target: copy}); + // The dismissal's own task. + await Promise.resolve(); + await Promise.resolve(); + const writes: string[] = []; + const observer = new MutationObserver(records => { + for (const record of records) { + writes.push(record.oldValue ?? ''); + } + }); + observer.observe(element, { + attributes: true, + attributeFilter: ['style'], + attributeOldValue: true, + }); + // The dismissal's whole run, and any linger past it. + for (let ms = 0; ms < 1200; ms += 50) { + await settle(50); + } + writes.push(...observer.takeRecords().map(record => record.oldValue ?? '')); + observer.disconnect(); + expect(writes).toEqual([]); + expect(element.style.getPropertyValue('will-change')).toBe(''); + }); + + it('presents the element again after a dismissal on a copy', async () => { + const {element, morph} = setup(); + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + morph.present(path, {spring: SPRING}); + await settle(progressCurve(SPRING).duration * 1000 + 100); + morph.dismiss(path, {spring: BOUNCY}, {target: copyOf(element)}); + await settle(progressCurve(BOUNCY).duration * 1000 + 50); + const presenting = morph.present(path, {spring: SPRING}); + expect(live(element, 'scale').length).toBe(1); + await settle(progressCurve(SPRING).duration * 1000 + 50); + expect(await presenting.finished).toBe(true); + expect(element.style.getPropertyValue('scale')).toBe(''); + }); + + it('hands its target to the caller\u2019s release, and dismisses the element itself when it is the target', async () => { + const {element, morph} = setup(); + const path = lerpPath(SQUARE, BOX); + morph.present(path, {spring: SPRING}); + await settle(1000); + const released: HTMLElement[] = []; + const copy = copyOf(element); + morph.dismiss( + path, + {spring: SPRING}, + { + target: copy, + release: target => released.push(target), + }, + ); + await settle(1000); + expect(released).toEqual([copy]); + expect(copy.isConnected).toBe(true); + + morph.present(path, {spring: SPRING}); + await settle(1000); + morph.dismiss(path, {spring: SPRING}, {target: element}); + expect(live(element, 'scale').length).toBe(1); + await settle(1000); + expect(element.isConnected).toBe(true); + expectRect(morph.presentedRect()!, SQUARE, 0.01); + }); + + it('a second dismissal on the copy in flight keeps the copy and continues from its rect', async () => { + // A dismissal continues the dismissal in flight, so a caller that hands + // the same copy again (a retargeted shrink) keeps it: its release runs + // once, when the dismissal that is still running ends. + const {element, morph} = setup(); + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + morph.present(path, {spring: SPRING}); + await settle(progressCurve(SPRING).duration * 1000 + 50); + const released: HTMLElement[] = []; + const release = (target: HTMLElement) => { + released.push(target); + target.remove(); + }; + const copy = copyOf(element); + morph.dismiss(path, {spring: BOUNCY}, {target: copy, release}); + clock += 60; + await vi.advanceTimersByTimeAsync(60); + const onScreen = morph.presentedRect()!; + + const again = morph.dismiss( + path, + {spring: SPRING}, + {target: copy, release}, + ); + expect(copy.isConnected).toBe(true); + expect(released).toEqual([]); + expect(live(copy, 'scale').length).toBe(1); + expectRect(morph.presentedRect()!, onScreen, 0.01); + + await settle(progressCurve(SPRING).duration * 1000 + 50); + expect(await again.finished).toBe(true); + expect(released).toEqual([copy]); + expect(copy.isConnected).toBe(false); + }); + + it('a dismissal during the present starts from the rect on screen, with its velocity', async () => { + const {element, morph} = setup(); + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + morph.present(path, {spring: SPRING}); + await playFrame(); + const progress = progressCurve(SPRING); + clock += 60; + await vi.advanceTimersByTimeAsync(60); + const p = progress.at(0.06); + const dp = progress.velocity(0.06); + const onScreen = morph.presentedRect()!; + expectRect(onScreen, path.rectAt(p), 0.01); + const copy = copyOf(element); + morph.dismiss(path, {spring: BOUNCY}, {target: copy}); + // The same rect, now on the copy, and the same velocity: the dismissal's + // path is the rest of the line back to the square, so the growth's + // velocity carries on along it. + expectRect(morph.presentedRect()!, onScreen, 0.01); + await playFrame(); + const before = morph.presentedRect()!; + clock += 0.01; + const after = morph.presentedRect()!; + const expectedWidthSpeed = (BOX.width - SQUARE.width) * dp; // px/s + expect((after.width - before.width) / 0.01e-3).toBeCloseTo( + expectedWidthSpeed, + -1, + ); + }); + + it('a present during the dismissal starts from the copy\u2019s rect with its velocity and takes the copy away', async () => { + const {element, morph} = setup(); + const surface = {x: 41, y: 204, width: 320, height: 253}; + const model = popoverModel(surface, { + x: 141, + y: 160, + width: 120, + height: 44, + }); + const path = edgeGrowPath({ + box: surface, + origin: {x: model.state.originX, y: model.state.originY}, + axis: model.state.axis, + collapsedScale: model.state.scale, + collapsedLength: model.state.length, + }); + morph.present(path, {spring: SPRING}); + await settle(1000); + const copy = copyOf(element); + morph.dismiss(path, {spring: SPRING}, {target: copy}); + await playFrame(); + clock += 80; + await vi.advanceTimersByTimeAsync(80); + const onScreen = morph.presentedRect()!; + const before = {...onScreen}; + clock += 1; + const moving = morph.presentedRect()!; + clock -= 1; + morph.present(path, {spring: SPRING}); + expect(copy.isConnected).toBe(false); + expectRect(morph.presentedRect()!, onScreen, 0.01); + await playFrame(); + // It keeps shrinking for a moment (the dismissal's velocity), then grows. + clock += 1; + const next = morph.presentedRect()!; + expect(Math.sign(next.width - before.width)).toBe( + Math.sign(moving.width - before.width), + ); + await settle(1000); + expectRect(morph.presentedRect()!, surface, 1e-6); + }); + + it('takes a release velocity as given (progress per second)', async () => { + const {morph} = setup(); + const path = lerpPath(SQUARE, BOX); + morph.present(path, {spring: SPRING}, {velocity: 6}); + await playFrame(); + const before = morph.presentedRect()!; + clock += 0.01; + const after = morph.presentedRect()!; + expect((after.x - before.x) / 0.01e-3).toBeCloseTo( + (BOX.x - SQUARE.x) * 6, + -1, + ); + }); + + it('reset after a settled present writes nothing back inline', async () => { + const {element, morph} = setup(); + morph.present(lerpPath(SQUARE, BOX, {opacity: [0, 1]}), {spring: SPRING}); + await settle(progressCurve(SPRING).duration * 1000 + 50); + morph.reset(); + for (const property of [ + 'translate', + 'scale', + 'transform-origin', + 'opacity', + ]) { + expect(element.style.getPropertyValue(property), property).toBe(''); + } + }); + + it('reset ends every motion, takes the target away and leaves the element as laid out', () => { + const {element, morph} = setup(); + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + morph.present(path, {spring: SPRING}); + const copy = copyOf(element); + morph.dismiss(path, {spring: SPRING}, {target: copy}); + morph.reset(); + expect(copy.isConnected).toBe(false); + for (const property of [ + 'translate', + 'scale', + 'transform-origin', + 'opacity', + ]) { + expect(element.style.getPropertyValue(property), property).toBe(''); + } + expect(morph.presentedRect()).toBeNull(); + }); + + describe('under Reduced Motion (the animator\u2019s, design:reduced-motion)', () => { + /** When `animation`'s opacity reaches `target` for good, ms after its start. */ + const fadeEndMs = (animation: Recorded, target: number) => { + const duration = Number(animation.options.duration); + let end = 0; + for (const frame of animation.keyframes) { + if (Math.abs(Number(frame.opacity) - target) > 1e-3) { + end = (frame.offset as number) * duration; + } + } + const index = animation.keyframes.findIndex( + frame => (frame.offset as number) * duration > end, + ); + return (animation.keyframes[index].offset as number) * duration; + }; + + it('on a coarse pointer, a present lands at rest and fades in, a dismissal fades out where it stands, in 200 ms', async () => { + const {element, morph} = setup({reducedMotion: true, coarse: true}); + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + morph.present(path, {spring: SPRING}); + const [fadeIn] = live(element, 'opacity'); + expect(fadeEndMs(fadeIn, 1)).toBeLessThanOrEqual( + timing.reducedMotionCrossFadeMs + 1, + ); + // Nothing grows: it stands at rest while it fades. + expectRect(morph.presentedRect()!, BOX, 0.01); + await settle(1000); + + const copy = copyOf(element); + morph.dismiss(path, {spring: SPRING}, {target: copy}); + const [fadeOut] = live(copy, 'opacity'); + const fadeMs = fadeEndMs(fadeOut, 0); + expect(fadeMs).toBeLessThanOrEqual(timing.reducedMotionCrossFadeMs + 1); + const start = clock; + for (const t of [0, 50, 100, 150]) { + clock = start + t; + expectRect(morph.presentedRect()!, BOX, 0.01); + } + }); + + it('lands a path with no opacity at rest, instead of starting on its source rect', () => { + // With no opacity of its own the path gives the cross-fade nothing to + // read, so the morph says which way it goes: a present lands at rest + // (design:reduced-motion), it does not dip to the source and grow. + const {element, morph} = setup({reducedMotion: true, coarse: true}); + const path = lerpPath(SQUARE, BOX); + morph.present(path, {spring: SPRING}); + expectRect(morph.presentedRect()!, BOX, 0.01); + expect(live(element, 'scale').length).toBe(0); + expect(live(element, 'translate').length).toBe(0); + }); + + it('keeps the animator’s dip for an element that only moves, when the caller says so', () => { + // A sub-menu's parent card stays visible and only moves: treated as an + // entrance it would cut out and fade in, so its caller asks for a move, + // and the animator dips it: out, the travel lands, back in. + const {element, morph} = setup({reducedMotion: true, coarse: true}); + const path = lerpPath(SQUARE, BOX); + morph.present(path, {spring: SPRING}, {crossfadeAs: 'move'}); + const [dip] = live(element, 'opacity'); + expect(dip).toBeDefined(); + expect(Number(dip.keyframes[0].opacity)).toBeCloseTo(1, 6); + expect( + Math.min(...dip.keyframes.map(frame => Number(frame.opacity))), + ).toBeLessThan(0.5); + }); + + it('on any other pointer, it completes at once', () => { + const {element, morph} = setup({reducedMotion: true, coarse: false}); + morph.present(lerpPath(SQUARE, BOX, {opacity: [0, 1]}), {spring: SPRING}); + expect(live(element, 'scale').length).toBe(0); + expect(live(element, 'opacity').length).toBe(0); + expectRect(morph.presentedRect()!, BOX, 0.01); + }); + }); +}); diff --git a/packages/core/src/motion/rectMorph.ts b/packages/core/src/motion/rectMorph.ts new file mode 100644 index 0000000000000..af8464004a3f6 --- /dev/null +++ b/packages/core/src/motion/rectMorph.ts @@ -0,0 +1,501 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file rectMorph.ts + * @input An element laid out at rest in its box, a path it grows along + * (morphPath.ts), its timing (a duration and easing, or a spring), the + * compositor animator and the progress helpers (progress.ts) + * @output Exports `createRectMorph` (present and dismiss, each interrupting + * the other), and re-exports the paths it plays (`lerpPath`, + * `edgeGrowPath`, `morphCurves`) from morphPath.ts + * @position Motion foundation building block, internal to core: the one + * "grow out of a source rect, shrink back into it" that touch + * presentations share: an anchored menu, a popover, a context menu's + * lifted preview, a photo's zoom, a sub-menu's parent card + * + * A path (morphPath.ts) names the rect an element shows at each progress, + * and its curves put the element there with `translate` and `scale` about + * `transform-origin: 0 0` and a companion `opacity`, on the compositor. + * `createRectMorph` plays a path on its element + * (`present`) and back to the source on a target the caller gives + * (`dismiss`): a copy placed where the element stands, since the browser + * hides a closing popover at once, or the element itself. Each interrupts + * the other: the new motion starts from the rect the animator presents at + * that moment, on the path restarted there in its own law (`restartFrom`), + * with the presented velocity projected on it (progress.ts). Once a present settles nothing of the morph's is left inline: iOS + * Safari will not scroll a finger through a popover that keeps a `scale`, + * even at 1. + * + * A present or a dismissal takes its timing as a motion: the theme's + * duration and easing for a motion that a tap, a key or the program starts, + * or a spring for a gesture's release, which starts at the gesture's + * velocity. Only a spring carries a velocity into the motion. + * + * Reduced Motion is the animator's: on a coarse + * pointer a present lands at rest and fades in, a dismissal fades out where + * it stands, opacity only, in 200 ms; on any other pointer the change + * completes at once. + * + * SYNC: When modified, update: + * - /packages/core/src/motion/rectMorph.test.ts + * - /packages/core/src/motion/README.md + * - /packages/core/src/motion/morphPath.ts (the paths it plays) + */ + +import { + createMotionAnimator, + type AnimateOptions, + type Animator, + type AnimatorOptions, + type MotionHandle, + type MotionValues, +} from './animator'; +import { + presentedProgressVelocity, + progressCurve, + timedProgressCurve, + type ProgressChannel, +} from './progress'; +import { + lerpPath, + morphCurves, + travelAt, + type MorphEnd, + type MorphPath, + type MorphRect, + type TravelProperty, +} from './morphPath'; +import type {MotionCurvePoints} from './roles'; +import type {SpringDescription} from './spring'; +import {resolveMotion} from './resolveMotion'; + +// The paths the morph plays, where its callers have always imported them. +export {edgeGrowPath, lerpPath, morphCurves} from './morphPath'; +export type {MorphEnd, MorphPath, MorphRect, MorphScale} from './morphPath'; + +/** The path from what is on screen to `path`'s `end`. */ +function restart( + path: MorphPath, + rect: MorphRect, + opacity: number, + end: MorphEnd, +): MorphPath { + if (path.restartFrom != null) { + return path.restartFrom(rect, opacity, end); + } + const p = end === 'rest' ? 1 : 0; + const {opacityAt} = path; + return lerpPath(rect, path.rectAt(p), { + box: path.box, + scale: path.scale, + ...(opacityAt != null ? {opacity: [opacity, opacityAt(p)] as const} : null), + }); +} + +/** The rect an animator shows for an element laid out in `box`, at `now`. */ +function presentedRectOf( + animator: Animator, + box: MorphRect, + now: number, +): MorphRect { + return { + x: box.x + animator.get('x', now), + y: box.y + animator.get('y', now), + width: box.width * animator.get('scaleX', now), + height: box.height * animator.get('scaleY', now), + }; +} + +/** The travel channels of `path`, weighted in px for the projection. */ +function channelsOf(path: MorphPath): ProgressChannel[] { + const at = (property: TravelProperty) => (p: number) => + travelAt(path, p)[property]; + return [ + {property: 'x', at: at('x')}, + {property: 'y', at: at('y')}, + {property: 'scaleX', at: at('scaleX'), weight: path.box.width}, + {property: 'scaleY', at: at('scaleY'), weight: path.box.height}, + ]; +} + +function startValues(path: MorphPath, opacity?: number): MotionValues { + const values: MotionValues = {...travelAt(path, 0)}; + if (path.opacityAt != null) { + values.opacity = opacity ?? path.opacityAt(0); + } + return values; +} + +/** The morph's own inline properties, left on an element only while it moves. */ +const INLINE_PROPERTIES = ['translate', 'scale', 'transform-origin'] as const; + +/** + * How a present or a dismissal is timed. A motion that a tap, a key or the + * program starts plays the theme's duration (ms) and easing. A gesture's + * release plays a spring, which starts at the gesture's velocity + * (`RectMorphPlayOptions.velocity`). + */ +export type RectMorphMotion = + | {readonly duration: number; readonly easing: MotionCurvePoints} + | {readonly spring: SpringDescription}; + +/** + * How a present or a dismissal plays. + */ +export interface RectMorphPlayOptions { + /** + * Progress per second a spring starts with (a finger's release). By + * default the velocity of what is on screen, projected on the path. A + * timed motion starts from rest. + */ + velocity?: number; + /** + * A timing of its own for the companion opacity (a zoom's closing fade); + * the frame's timing by default. + */ + fade?: RectMorphMotion; + /** For the animator's Reduced Motion: a touch drag's release starts it. */ + touchRelease?: AnimateOptions['touchRelease']; + /** + * For the animator's Reduced Motion: what a cross-fade treats it as. A path + * with no opacity of its own is an entrance on a present and an exit on a + * dismissal by default; `'move'` keeps the animator's own dip for an + * element that stays visible and only moves (a sub-menu's parent card), so + * it fades out, lands and fades back in instead of cutting out. + */ + crossfadeAs?: AnimateOptions['crossfadeAs'] | 'move'; + /** + * When the curves start: `'presented'` holds the element at its first + * values until two frames have presented it, so a surface that appears + * late plays the whole curve (the animator's `start`). + */ + start?: AnimateOptions['start']; +} + +/** + * Where a dismissal plays. + */ +export interface RectMorphDismissOptions extends RectMorphPlayOptions { + /** + * The element the dismissal plays on: a copy of the element laid out in the + * path's box (a closing popover is hidden at once), or the element itself. + */ + target: HTMLElement; + /** + * Takes `target` away once the dismissal ends or a present interrupts it: + * `target.remove()` by default, nothing when it is the element. + */ + release?: (target: HTMLElement) => void; +} + +/** + * The present and the dismissal of one element. + */ +export interface RectMorph { + /** + * Plays `path` on the element from its source to rest on `motion`. While a + * dismissal or a present plays, it starts from the rect on screen instead, + * restarted on `path` toward rest with the presented velocity. + */ + present( + path: MorphPath, + motion: RectMorphMotion, + options?: RectMorphPlayOptions, + ): MotionHandle; + /** + * Plays `path` back to its source on `options.target` on `motion`, from the + * rect the element presents now (or the dismissal in flight) with its + * velocity. The element is left as laid out unless it is the target. + */ + dismiss( + path: MorphPath, + motion: RectMorphMotion, + options: RectMorphDismissOptions, + ): MotionHandle; + /** The rect on screen now (the dismissal's while one plays); null before any. */ + presentedRect(): MorphRect | null; + /** + * Ends every motion, takes a dismissal's target away and leaves the element + * as laid out, with none of the morph's inline properties. + */ + reset(): void; + /** `reset`, then releases the element's animator unless the caller handed it. */ + dispose(): void; +} + +function defaultNow(): number { + return typeof performance === 'undefined' ? Date.now() : performance.now(); +} + +/** + * The rect morph of `element`. + * + * @example + * ``` + * const morph = createRectMorph(menu); + * const path = lerpPath(square, box, {opacity: [0, 1]}); + * // A tap opened the menu: the theme's entrance duration and easing. + * morph.present(path, {duration: enterMs, easing: enterEasing}); + * // …a finger flicked it closed, with a copy placed where the menu stands: + * morph.dismiss(path, {spring: springs.menuClose}, {target: copy, velocity}); + * ``` + * + * An element that already has an animator (a zoom stage's pinch and pan) + * hands it in `animator`: the morph plays on it, so the two share the + * element's channels, and `dispose` leaves it to its owner. + */ +export function createRectMorph( + element: HTMLElement, + { + animator: given, + ...options + }: Pick & {animator?: Animator} = {}, +): RectMorph { + const now = options.now ?? defaultNow; + const animator = given ?? createMotionAnimator(element, options); + /** The box the element's transform is relative to, once it has one. */ + let box: MorphRect | null = null; + let presenting: MotionHandle | null = null; + /** Whether a path has animated the element's opacity. */ + let ownsOpacity = false; + let exiting: { + target: HTMLElement; + animator: Animator; + box: MorphRect; + release: (target: HTMLElement) => void; + } | null = null; + + const clearInline = () => { + for (const property of INLINE_PROPERTIES) { + element.style.removeProperty(property); + } + }; + + // Leaves the element as laid out: identity, and none of the morph's inline + // properties (an opacity only when a path animated it). At rest it needs no + // compositing layer, so the values go in without promoting it. + const restoreElement = () => { + animator.set( + ownsOpacity + ? {x: 0, y: 0, scaleX: 1, scaleY: 1, opacity: 1} + : {x: 0, y: 0, scaleX: 1, scaleY: 1}, + {promote: false}, + ); + clearInline(); + if (ownsOpacity) { + element.style.removeProperty('opacity'); + ownsOpacity = false; + } + box = null; + }; + + const endExit = () => { + if (exiting == null) { + return; + } + const {target, animator: closing, release} = exiting; + exiting = null; + if (closing !== animator) { + closing.dispose(); + } + release(target); + }; + + // Once a present settles on identity, nothing of the morph's stays inline. + const retire = () => { + const at = now(); + const identity = + Math.abs(animator.get('x', at)) < 1e-6 && + Math.abs(animator.get('y', at)) < 1e-6 && + Math.abs(animator.get('scaleX', at) - 1) < 1e-9 && + Math.abs(animator.get('scaleY', at) - 1) < 1e-9; + if (identity) { + clearInline(); + } + if (ownsOpacity && Math.abs(animator.get('opacity', at) - 1) < 1e-9) { + element.style.removeProperty('opacity'); + ownsOpacity = false; + } + }; + + // A path with no opacity of its own gives the animator's Reduced Motion + // cross-fade nothing to read, and it would start the motion on the source + // rect and dip. Such a path says which way it goes, so the cross-fade + // lands at rest on a present and fades out in place on a dismissal; a caller's own `crossfadeAs` still wins, and + // `'move'` keeps the animator's dip for an element that only moves. + const playOptions = ( + play: RectMorphPlayOptions, + path: MorphPath, + direction: 'entrance' | 'exit' = 'entrance', + ): AnimateOptions => ({ + ...(play.touchRelease != null ? {touchRelease: play.touchRelease} : null), + ...(play.start != null ? {start: play.start} : null), + ...(play.crossfadeAs === 'move' + ? null + : play.crossfadeAs != null + ? {crossfadeAs: play.crossfadeAs} + : path.opacityAt == null + ? {crossfadeAs: direction} + : null), + }); + + // A spring goes through `resolveMotion` on the element that plays it: a + // touch motion role resolves to its table value and reads no style, and a + // caller's bare spring comes back as given. A timed motion is the caller's + // duration and easing, already resolved from the theme. + const progressFor = ( + on: Element, + motion: RectMorphMotion, + velocity: number, + ) => + 'spring' in motion + ? progressCurve(resolveMotion(on, motion.spring), {velocity}) + : timedProgressCurve(motion.duration, motion.easing); + + const curvesFor = ( + on: Element, + path: MorphPath, + motion: RectMorphMotion, + play: RectMorphPlayOptions, + velocity: number, + ) => { + const progress = progressFor(on, motion, velocity); + const fade = + play.fade != null ? progressFor(on, play.fade, velocity) : progress; + return morphCurves(path, progress, fade); + }; + + const present: RectMorph['present'] = (path, motion, play = {}) => { + const at = now(); + const from = + exiting ?? (presenting != null && box != null ? {animator, box} : null); + let start = path; + let velocity = play.velocity ?? 0; + let opacity: number | undefined; + if (from != null) { + opacity = from.animator.get('opacity', at); + start = restart( + path, + presentedRectOf(from.animator, from.box, at), + opacity, + 'rest', + ); + velocity = + play.velocity ?? + presentedProgressVelocity(from.animator, channelsOf(start), at); + } + endExit(); + box = start.box; + ownsOpacity ||= start.opacityAt != null; + // A dismissal on a copy let the element go (dismiss, below). + animator.attach(element); + element.style.setProperty('transform-origin', '0px 0px'); + animator.set(startValues(start, opacity)); + const handle = animator.play( + curvesFor(element, start, motion, play, velocity), + playOptions(play, start), + ); + presenting = handle; + void handle.finished.then(done => { + if (done && presenting === handle) { + presenting = null; + retire(); + } + }); + return handle; + }; + + const dismiss: RectMorph['dismiss'] = (path, motion, play) => { + const {target} = play; + const at = now(); + const from = exiting ?? {animator, box: box ?? path.box}; + const opacity = from.animator.get('opacity', at); + const back = restart( + path, + presentedRectOf(from.animator, from.box, at), + opacity, + 'source', + ); + const velocity = + play.velocity ?? + presentedProgressVelocity(from.animator, channelsOf(back), at); + // The same copy handed again continues on it: it keeps its animator, and + // its release runs once, when the dismissal still running ends. Any other + // target ends the dismissal in flight first. + const continuing = + exiting != null && exiting.target === target ? exiting : null; + if (continuing != null) { + exiting = null; + } else { + endExit(); + } + presenting = null; + const onElement = target === element; + if (onElement) { + // A dismissal on a copy may have let the element go (below). + animator.attach(element); + box = back.box; + ownsOpacity ||= back.opacityAt != null; + } else { + // The element is hidden as it closes: leave it as laid out, and let go + // of the layer its presentation promoted, in this task. Kept, the + // promotion would end its linger after the element's last motion: a + // style write on an element that no longer paints, landing inside the + // dismissal's frames. The next present attaches the element again. + restoreElement(); + animator.attach(null); + } + const closing = onElement + ? animator + : (continuing?.animator ?? createMotionAnimator(target, options)); + target.style.setProperty('transform-origin', '0px 0px'); + // The copy starts as the element shows now, whatever its clone kept. + closing.set(startValues(back, opacity)); + const handle = closing.play( + curvesFor(target, back, motion, play, velocity), + playOptions(play, back, 'exit'), + ); + const current = { + target, + animator: closing, + box: back.box, + release: + play.release ?? + (onElement ? () => {} : (copy: HTMLElement) => copy.remove()), + }; + exiting = current; + void handle.finished.then(() => { + if (exiting === current) { + endExit(); + } + }); + return handle; + }; + + // `set` ends whatever still moves; nothing is frozen inline on the way. + const reset = () => { + endExit(); + presenting = null; + restoreElement(); + }; + + return { + present, + dismiss, + presentedRect() { + if (exiting != null) { + return presentedRectOf(exiting.animator, exiting.box, now()); + } + return box == null ? null : presentedRectOf(animator, box, now()); + }, + reset, + dispose() { + reset(); + if (given == null) { + animator.dispose(); + } + }, + }; +} From f19df271f4898e43f092b0df86bafc5dccb89c04 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 3 Nov 2026 08:58:46 -0800 Subject: [PATCH 005/118] fix(rectMorph): a handed-in animator stays on the element through a copy dismissal CodeBunny on #9769: a dismissal onto a copy detached the animator, and a handed-in one was never attached again, so its owner's writes stopped reaching the element. Only the morph's own animator lets go now. Co-authored-by: Cindy Zhang --- packages/core/src/motion/rectMorph.test.ts | 28 ++++++++++++++++++++++ packages/core/src/motion/rectMorph.ts | 7 ++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/core/src/motion/rectMorph.test.ts b/packages/core/src/motion/rectMorph.test.ts index 15b836b616039..b78f7bb569c82 100644 --- a/packages/core/src/motion/rectMorph.test.ts +++ b/packages/core/src/motion/rectMorph.test.ts @@ -320,6 +320,34 @@ describe('createRectMorph', () => { expect(stage.get('x')).toBe(12); }); + it('leaves a handed-in animator on the element through a dismissal onto a copy', async () => { + const element = document.createElement('div'); + document.body.appendChild(element); + stubMedia(false, true); + const stage = createMotionAnimator(element, { + now: () => clock, + requestFrame, + }); + const morph = createRectMorph(element, { + animator: stage, + now: () => clock, + requestFrame, + }); + const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); + morph.present(path, {spring: SPRING}); + await playFrame(); + const copy = element.cloneNode(true) as HTMLElement; + element.after(copy); + morph.dismiss(path, {spring: SPRING}, {target: copy}); + await playFrame(); + clock += 2000; + await vi.advanceTimersByTimeAsync(2000); + // The owner's own write still reaches the element. + stage.set({x: 12}); + expect(element.style.getPropertyValue('translate')).toContain('12px'); + morph.dispose(); + }); + it('holds the element at its first values until two frames present it, when asked for the presented start', async () => { const {element, morph} = setup(); const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); diff --git a/packages/core/src/motion/rectMorph.ts b/packages/core/src/motion/rectMorph.ts index af8464004a3f6..b7370b4bfbfd7 100644 --- a/packages/core/src/motion/rectMorph.ts +++ b/packages/core/src/motion/rectMorph.ts @@ -443,9 +443,12 @@ export function createRectMorph( // of the layer its presentation promoted, in this task. Kept, the // promotion would end its linger after the element's last motion: a // style write on an element that no longer paints, landing inside the - // dismissal's frames. The next present attaches the element again. + // dismissal's frames. The next present attaches the element again. A + // handed-in animator stays on the element: its owner still drives it. restoreElement(); - animator.attach(null); + if (given == null) { + animator.attach(null); + } } const closing = onElement ? animator From e3a43d0d3fade1adc4cf7cf3c2d4c670a543f06f Mon Sep 17 00:00:00 2001 From: Cindy Zhang <2781099+cixzhang@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:39:01 -0800 Subject: [PATCH 006/118] fix(motion): one timed curve keeps a retargeted velocity, for the pager and the morph CodeBunny on #9769: solveAnimatedPageScroll already played this curve and keeps the velocity of the move it retargets. timedCurve in progress.ts is now that one curve; the pager and the morph's timed motion call it, so a timed present that interrupts a dismissal keeps its velocity. The INV12 note states the system only. File header and comments trimmed. Co-authored-by: Cindy Zhang --- docs/architecture/react-component-runtime.md | 2 +- packages/core/src/motion/paging.ts | 38 ++--------- packages/core/src/motion/progress.test.ts | 9 +++ packages/core/src/motion/progress.ts | 70 +++++++++++++++----- packages/core/src/motion/rectMorph.test.ts | 8 +-- packages/core/src/motion/rectMorph.ts | 48 ++++---------- 6 files changed, 87 insertions(+), 88 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 37e9e4bc3fbcc..2064edfb7ab6a 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -200,7 +200,7 @@ those contracts. `spec:AST-094` FR5) once Core has that morph; until then, the module plays its own compositor translate. (Implementation gap: Core has the shared rectangle morph, but the size-change glide still plays its own compositor - translate; it moves onto the morph next.) It never animates height. Its walk visits only + translate.) It never animates height. Its walk visits only the changed element's ancestors up to the area and the element siblings on either side at each of their levels, through `parentElement`, `nextElementSibling` and `previousElementSibling`, descending only into an diff --git a/packages/core/src/motion/paging.ts b/packages/core/src/motion/paging.ts index 2cd978c7c42af..19b6ccf14c2a5 100644 --- a/packages/core/src/motion/paging.ts +++ b/packages/core/src/motion/paging.ts @@ -14,7 +14,8 @@ */ import type {MotionCurve} from './animator'; -import {cubicBezier, type BezierPoints} from './bezier'; +import type {BezierPoints} from './bezier'; +import {timedCurve} from './progress'; import {paging} from './uikit/paging'; /** @@ -113,10 +114,9 @@ export function solvePaging({ * of the table. * * `velocity` (px/s) is the velocity of a move that this one retargets. The new - * curve starts at it and still ends on `to`, at rest and on time. A new target - * keeps the current velocity, where UIKit drops it. The carry is a Hermite - * term, v × duration × u(1 − u)², less the curve's own starting slope. So a - * move from rest is the curve itself. + * curve starts at it and still ends on `to`, at rest and on time + * (`timedCurve`): a new target keeps the current velocity, where UIKit drops + * it. */ export function solveAnimatedPageScroll({ from, @@ -131,31 +131,5 @@ export function solveAnimatedPageScroll({ curve?: BezierPoints; velocity?: number; }): MotionCurve { - const duration = durationMs / 1000; - const distance = to - from; - const ease = cubicBezier(curve); - const progress = (u: number) => ease(Math.min(1, Math.max(0, u))); - // The carry, in px: the retargeted velocity over the whole duration, less - // what the curve's own starting slope already gives. - const startSlope = progress(1e-4) / 1e-4; - const carry = - velocity == null ? 0 : velocity * duration - distance * startSlope; - const at = (t: number) => { - const u = Math.min(1, Math.max(0, t / duration)); - return from + distance * progress(u) + carry * u * (1 - u) * (1 - u); - }; - return { - duration, - at, - velocity: t => { - if (t <= 0) { - return velocity ?? 0; - } - if (t >= duration) { - return 0; - } - const h = Math.min(1e-4, t / 2, (duration - t) / 2); - return (at(t + h) - at(t - h)) / (2 * h); - }, - }; + return timedCurve({from, to, durationMs, easing: curve, velocity}); } diff --git a/packages/core/src/motion/progress.test.ts b/packages/core/src/motion/progress.test.ts index a56ce3a5b74bf..1d3535ac1ca66 100644 --- a/packages/core/src/motion/progress.test.ts +++ b/packages/core/src/motion/progress.test.ts @@ -184,6 +184,15 @@ describe('timedProgressCurve', () => { expect(curve.velocity(0.1)).toBeGreaterThan(0); }); + it('starts at a retargeted velocity and still lands on time, at rest', () => { + const curve = timedProgressCurve(300, [0.25, 0.1, 0.25, 1], {velocity: 6}); + expect(curve.velocity(0)).toBe(6); + const h = 1e-4; + expect((curve.at(h) - curve.at(0)) / h).toBeCloseTo(6, 1); + expect(curve.at(0.3)).toBeCloseTo(1, 9); + expect(curve.velocity(0.3)).toBe(0); + }); + it('lands at once for a zero duration', () => { const curve = timedProgressCurve(0, [0, 0, 1, 1]); expect(curve.duration).toBe(0); diff --git a/packages/core/src/motion/progress.ts b/packages/core/src/motion/progress.ts index 11aec62e51d24..bd2cfdb68a938 100644 --- a/packages/core/src/motion/progress.ts +++ b/packages/core/src/motion/progress.ts @@ -4,8 +4,8 @@ * @file progress.ts * @input A spring, a normalized progress, an animator's presented velocities * @output Exports `progressCurve` (a spring's progress from 0 to 1 as a - * MotionCurve), `timedProgressCurve` (the same over a duration and - * easing), `mapProgress` (one curve as a function of another), + * MotionCurve), `timedCurve` and `timedProgressCurve` (a duration and + * easing, keeping a retargeted velocity), `mapProgress` (one curve as a function of another), * `mapRange` (the clamped linear map most companions use) and * `presentedProgressVelocity` (the progress velocity that continues what * an animator shows) @@ -72,32 +72,72 @@ export function progressCurve( } /** - * A progress from 0 to 1 over `durationMs`, eased by the cubic-bézier - * `easing`: the curve a timed motion (a theme duration and easing) plays. - * Its velocity is in progress per second. + * A timed motion from `from` to `to` over `durationMs`, eased by the + * cubic-bézier `easing`: the curve a theme duration and easing play. + * + * `velocity` (units per second) is the velocity of a motion this one + * retargets. The curve starts at it and still ends on `to`, at rest and on + * time, so a new target keeps the current velocity. The carry is a Hermite + * term, v × duration × u(1 − u)², less the curve's own starting slope, so a + * motion from rest is the eased curve itself. */ -export function timedProgressCurve( - durationMs: number, - easing: BezierPoints, -): MotionCurve { +export function timedCurve({ + from, + to, + durationMs, + easing, + velocity, +}: { + from: number; + to: number; + durationMs: number; + easing: BezierPoints; + velocity?: number; +}): MotionCurve { const duration = Math.max(0, durationMs) / 1000; + if (duration === 0) { + return {duration, at: () => to, velocity: () => 0}; + } + const distance = to - from; const ease = cubicBezier(easing); - const at = (t: number) => - duration === 0 || t >= duration ? 1 : ease(Math.max(0, t) / duration); + const progress = (u: number) => ease(Math.min(1, Math.max(0, u))); + // The carry: the retargeted velocity over the whole duration, less what + // the curve's own starting slope already gives. + const startSlope = progress(1e-4) / 1e-4; + const carry = + velocity == null ? 0 : velocity * duration - distance * startSlope; + const at = (t: number) => { + const u = Math.min(1, Math.max(0, t / duration)); + return from + distance * progress(u) + carry * u * (1 - u) * (1 - u); + }; return { duration, at, velocity: t => { - if (duration === 0 || t >= duration) { + if (t <= 0) { + return velocity ?? 0; + } + if (t >= duration) { return 0; } - const t0 = Math.max(0, t - STEP); - const t1 = Math.min(duration, t + STEP); - return (at(t1) - at(t0)) / (t1 - t0); + const h = Math.min(1e-4, t / 2, (duration - t) / 2); + return (at(t + h) - at(t - h)) / (2 * h); }, }; } +/** + * `timedCurve` as a progress from 0 to 1, its velocity in progress per + * second: the timed counterpart of `progressCurve`. + */ +export function timedProgressCurve( + durationMs: number, + easing: BezierPoints, + {velocity}: {velocity?: number} = {}, +): MotionCurve { + return timedCurve({from: 0, to: 1, durationMs, easing, velocity}); +} + /** * The curve of `f(driver(t))`, its velocity by the chain rule: a channel * following a path as its progress moves, or a companion following any diff --git a/packages/core/src/motion/rectMorph.test.ts b/packages/core/src/motion/rectMorph.test.ts index b78f7bb569c82..26137a9ce3b93 100644 --- a/packages/core/src/motion/rectMorph.test.ts +++ b/packages/core/src/motion/rectMorph.test.ts @@ -272,16 +272,16 @@ describe('createRectMorph', () => { } }); - it('presents on a theme timing: its duration and easing as given, from rest', async () => { + it('presents on a theme timing: its duration and easing as given, from the velocity it is given', async () => { const {element, morph} = setup(); const path = lerpPath(SQUARE, BOX, {opacity: [0, 1]}); const easing = [0.2, 0, 0, 1] as const; - // A tap has no velocity: a timed present starts from rest even when - // one is given. + // A timed present keeps the velocity it is given (a new target keeps the + // current velocity) and still lands on time. morph.present(path, {duration: 300, easing}, {velocity: 6}); await playFrame(); expect(live(element, 'scale').length).toBe(1); - const progress = timedProgressCurve(300, easing); + const progress = timedProgressCurve(300, easing, {velocity: 6}); const start = clock; for (const t of [0, 0.05, 0.12, 0.2, 0.29]) { clock = start + t * 1000; diff --git a/packages/core/src/motion/rectMorph.ts b/packages/core/src/motion/rectMorph.ts index b7370b4bfbfd7..b53d97efb6c3f 100644 --- a/packages/core/src/motion/rectMorph.ts +++ b/packages/core/src/motion/rectMorph.ts @@ -15,29 +15,6 @@ * presentations share: an anchored menu, a popover, a context menu's * lifted preview, a photo's zoom, a sub-menu's parent card * - * A path (morphPath.ts) names the rect an element shows at each progress, - * and its curves put the element there with `translate` and `scale` about - * `transform-origin: 0 0` and a companion `opacity`, on the compositor. - * `createRectMorph` plays a path on its element - * (`present`) and back to the source on a target the caller gives - * (`dismiss`): a copy placed where the element stands, since the browser - * hides a closing popover at once, or the element itself. Each interrupts - * the other: the new motion starts from the rect the animator presents at - * that moment, on the path restarted there in its own law (`restartFrom`), - * with the presented velocity projected on it (progress.ts). Once a present settles nothing of the morph's is left inline: iOS - * Safari will not scroll a finger through a popover that keeps a `scale`, - * even at 1. - * - * A present or a dismissal takes its timing as a motion: the theme's - * duration and easing for a motion that a tap, a key or the program starts, - * or a spring for a gesture's release, which starts at the gesture's - * velocity. Only a spring carries a velocity into the motion. - * - * Reduced Motion is the animator's: on a coarse - * pointer a present lands at rest and fades in, a dismissal fades out where - * it stands, opacity only, in 200 ms; on any other pointer the change - * completes at once. - * * SYNC: When modified, update: * - /packages/core/src/motion/rectMorph.test.ts * - /packages/core/src/motion/README.md @@ -71,7 +48,6 @@ import type {MotionCurvePoints} from './roles'; import type {SpringDescription} from './spring'; import {resolveMotion} from './resolveMotion'; -// The paths the morph plays, where its callers have always imported them. export {edgeGrowPath, lerpPath, morphCurves} from './morphPath'; export type {MorphEnd, MorphPath, MorphRect, MorphScale} from './morphPath'; @@ -134,8 +110,9 @@ const INLINE_PROPERTIES = ['translate', 'scale', 'transform-origin'] as const; /** * How a present or a dismissal is timed. A motion that a tap, a key or the * program starts plays the theme's duration (ms) and easing. A gesture's - * release plays a spring, which starts at the gesture's velocity - * (`RectMorphPlayOptions.velocity`). + * release plays a spring. Either starts at the velocity it is given + * (`RectMorphPlayOptions.velocity`), so a new target keeps the current + * velocity. */ export type RectMorphMotion = | {readonly duration: number; readonly easing: MotionCurvePoints} @@ -146,9 +123,9 @@ export type RectMorphMotion = */ export interface RectMorphPlayOptions { /** - * Progress per second a spring starts with (a finger's release). By - * default the velocity of what is on screen, projected on the path. A - * timed motion starts from rest. + * Progress per second to start with (a finger's release). By default the + * velocity of what is on screen, projected on the path, so a motion that + * interrupts another keeps its velocity. */ velocity?: number; /** @@ -303,7 +280,8 @@ export function createRectMorph( release(target); }; - // Once a present settles on identity, nothing of the morph's stays inline. + // iOS Safari does not scroll a finger through an element that keeps a + // `scale`, even at 1. Clear it once the present settles. const retire = () => { const at = now(); const identity = @@ -320,11 +298,9 @@ export function createRectMorph( } }; - // A path with no opacity of its own gives the animator's Reduced Motion - // cross-fade nothing to read, and it would start the motion on the source - // rect and dip. Such a path says which way it goes, so the cross-fade - // lands at rest on a present and fades out in place on a dismissal; a caller's own `crossfadeAs` still wins, and - // `'move'` keeps the animator's dip for an element that only moves. + // A path with no opacity gives the Reduced Motion cross-fade nothing to + // read. Treat it as an entrance on a present and an exit on a dismissal. A + // caller's `crossfadeAs` wins, and `'move'` keeps the animator's dip. const playOptions = ( play: RectMorphPlayOptions, path: MorphPath, @@ -352,7 +328,7 @@ export function createRectMorph( ) => 'spring' in motion ? progressCurve(resolveMotion(on, motion.spring), {velocity}) - : timedProgressCurve(motion.duration, motion.easing); + : timedProgressCurve(motion.duration, motion.easing, {velocity}); const curvesFor = ( on: Element, From c99210aa453fef362051d82a59894263ad31886e Mon Sep 17 00:00:00 2001 From: Cindy Zhang <2781099+cixzhang@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:06:47 -0800 Subject: [PATCH 007/118] fix(rectMorph): a timed motion from rest plays its easing as given CodeBunny on #9769: with nothing in flight the morph handed timedCurve a velocity of 0, which it treats as a retarget and uses to cancel the easing's starting slope. Only a motion in flight hands its velocity on now; tests check a present from rest and a dismissal from a settled present against the easing itself. Header and comments shortened. Co-authored-by: Cindy Zhang --- packages/core/src/motion/rectMorph.test.ts | 31 ++++++++++++++++++ packages/core/src/motion/rectMorph.ts | 37 +++++++++++----------- 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/packages/core/src/motion/rectMorph.test.ts b/packages/core/src/motion/rectMorph.test.ts index 26137a9ce3b93..4a26cf964db2f 100644 --- a/packages/core/src/motion/rectMorph.test.ts +++ b/packages/core/src/motion/rectMorph.test.ts @@ -15,6 +15,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {createMotionAnimator} from './animator'; +import {cubicBezier} from './bezier'; import {progressCurve, timedProgressCurve} from './progress'; import { createRectMorph, @@ -289,6 +290,36 @@ describe('createRectMorph', () => { } }); + it('plays a timed present from rest on its easing as given', async () => { + const {morph} = setup(); + const path = lerpPath(SQUARE, BOX); + const easing = [0, 0, 0.58, 1] as const; + const ease = cubicBezier(easing); + morph.present(path, {duration: 250, easing}); + await playFrame(); + const start = clock; + for (const t of [0.02, 0.05, 0.1, 0.2]) { + clock = start + t * 1000; + expectRect(morph.presentedRect()!, path.rectAt(ease(t / 0.25)), 0.01); + } + }); + + it('plays a timed dismissal from a settled present on its easing as given', async () => { + const {element, morph} = setup(); + const path = lerpPath(SQUARE, BOX); + morph.present(path, {spring: SPRING}); + await settle(2000); + const easing = [0, 0, 0.58, 1] as const; + const ease = cubicBezier(easing); + morph.dismiss(path, {duration: 250, easing}, {target: copyOf(element)}); + await playFrame(); + const start = clock; + for (const t of [0.02, 0.05, 0.1, 0.2]) { + clock = start + t * 1000; + expectRect(morph.presentedRect()!, path.rectAt(1 - ease(t / 0.25)), 0.01); + } + }); + it('plays on an animator it is handed, and leaves it to its owner when disposed', async () => { // A zoom stage already drives the photo with its own animator (pinch, // pan, settle): the morph plays on it, so the two share the channels. diff --git a/packages/core/src/motion/rectMorph.ts b/packages/core/src/motion/rectMorph.ts index b53d97efb6c3f..d7564fb169638 100644 --- a/packages/core/src/motion/rectMorph.ts +++ b/packages/core/src/motion/rectMorph.ts @@ -10,10 +10,8 @@ * @output Exports `createRectMorph` (present and dismiss, each interrupting * the other), and re-exports the paths it plays (`lerpPath`, * `edgeGrowPath`, `morphCurves`) from morphPath.ts - * @position Motion foundation building block, internal to core: the one - * "grow out of a source rect, shrink back into it" that touch - * presentations share: an anchored menu, a popover, a context menu's - * lifted preview, a photo's zoom, a sub-menu's parent card + * @position Motion building block, internal to core: grow out of a source + * rect and shrink back into it * * SYNC: When modified, update: * - /packages/core/src/motion/rectMorph.test.ts @@ -250,9 +248,8 @@ export function createRectMorph( } }; - // Leaves the element as laid out: identity, and none of the morph's inline - // properties (an opacity only when a path animated it). At rest it needs no - // compositing layer, so the values go in without promoting it. + // At rest the element needs no compositing layer, so set the values without + // promoting it. const restoreElement = () => { animator.set( ownsOpacity @@ -317,14 +314,14 @@ export function createRectMorph( : null), }); - // A spring goes through `resolveMotion` on the element that plays it: a - // touch motion role resolves to its table value and reads no style, and a - // caller's bare spring comes back as given. A timed motion is the caller's - // duration and easing, already resolved from the theme. + // A spring goes through `resolveMotion`. A motion role resolves to its + // table value, and a bare spring comes back as given. `velocity` is + // undefined when nothing was moving: a timed motion then plays its easing + // as given. const progressFor = ( on: Element, motion: RectMorphMotion, - velocity: number, + velocity: number | undefined, ) => 'spring' in motion ? progressCurve(resolveMotion(on, motion.spring), {velocity}) @@ -335,7 +332,7 @@ export function createRectMorph( path: MorphPath, motion: RectMorphMotion, play: RectMorphPlayOptions, - velocity: number, + velocity: number | undefined, ) => { const progress = progressFor(on, motion, velocity); const fade = @@ -348,7 +345,7 @@ export function createRectMorph( const from = exiting ?? (presenting != null && box != null ? {animator, box} : null); let start = path; - let velocity = play.velocity ?? 0; + let velocity = play.velocity; let opacity: number | undefined; if (from != null) { opacity = from.animator.get('opacity', at); @@ -394,9 +391,14 @@ export function createRectMorph( opacity, 'source', ); + // A settled present is not moving: only a motion in flight hands its + // velocity on. + const inFlight = exiting != null || presenting != null; const velocity = play.velocity ?? - presentedProgressVelocity(from.animator, channelsOf(back), at); + (inFlight + ? presentedProgressVelocity(from.animator, channelsOf(back), at) + : undefined); // The same copy handed again continues on it: it keeps its animator, and // its release runs once, when the dismissal still running ends. Any other // target ends the dismissal in flight first. @@ -416,9 +418,8 @@ export function createRectMorph( ownsOpacity ||= back.opacityAt != null; } else { // The element is hidden as it closes: leave it as laid out, and let go - // of the layer its presentation promoted, in this task. Kept, the - // promotion would end its linger after the element's last motion: a - // style write on an element that no longer paints, landing inside the + // of the layer its presentation promoted, in this task. Otherwise the + // promotion's linger ends later, with a style write inside the // dismissal's frames. The next present attaches the element again. A // handed-in animator stays on the element: its owner still drives it. restoreElement(); From 3272828782bdec02835551a2c12ba7fddaf64ca6 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Thu, 5 Nov 2026 11:20:20 -0800 Subject: [PATCH 008/118] docs(rectMorph): the timing follows the component's motion record The type's doc, the example and the changeset name no rule of their own about which input plays which timing: each play takes the timing the adopting component's motion record names (AST-094 FR45). Co-authored-by: Christopher Chedeau --- .changeset/motion-rect-morph.md | 2 +- packages/core/src/motion/rectMorph.ts | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.changeset/motion-rect-morph.md b/.changeset/motion-rect-morph.md index 0192c46f8c2cd..e7003e85f8e4b 100644 --- a/.changeset/motion-rect-morph.md +++ b/.changeset/motion-rect-morph.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: the rect morph, one way for a touch presentation to grow out of a source rect and shrink back into it. `createRectMorph` presents an element along a path (`lerpPath`, `edgeGrowPath`) and dismisses it back on a copy or on itself, each continuing the other from the rect on screen. A motion a tap or a key starts plays a theme duration and easing; a gesture's release plays a spring from its velocity. Reduced Motion is left to the animator. `timedProgressCurve` joins the progress helpers. Not exported from the package entry point; no component uses it yet. +[chore] Internal: the rect morph, one way for a touch presentation to grow out of a source rect and shrink back into it. `createRectMorph` presents an element along a path (`lerpPath`, `edgeGrowPath`) and dismisses it back on a copy or on itself, each continuing the other from the rect on screen. Each play takes the timing the component's motion record names (AST-094 FR45): a duration and easing, or a spring, starting from the velocity it is given. Reduced Motion is left to the animator. `timedProgressCurve` joins the progress helpers. Not exported from the package entry point; no component uses it yet. @vjeux diff --git a/packages/core/src/motion/rectMorph.ts b/packages/core/src/motion/rectMorph.ts index d7564fb169638..b673af8d5b8ad 100644 --- a/packages/core/src/motion/rectMorph.ts +++ b/packages/core/src/motion/rectMorph.ts @@ -106,11 +106,11 @@ function startValues(path: MorphPath, opacity?: number): MotionValues { const INLINE_PROPERTIES = ['translate', 'scale', 'transform-origin'] as const; /** - * How a present or a dismissal is timed. A motion that a tap, a key or the - * program starts plays the theme's duration (ms) and easing. A gesture's - * release plays a spring. Either starts at the velocity it is given - * (`RectMorphPlayOptions.velocity`), so a new target keeps the current - * velocity. + * How a present or a dismissal is timed: a duration (ms) and easing, or a + * spring. The timing follows the component's motion record (`spec:AST-094` + * FR45); the morph has no rule of its own about which input plays which. + * Either starts at the velocity it is given (`RectMorphPlayOptions.velocity`), + * so a new target keeps the current velocity. */ export type RectMorphMotion = | {readonly duration: number; readonly easing: MotionCurvePoints} @@ -211,10 +211,10 @@ function defaultNow(): number { * ``` * const morph = createRectMorph(menu); * const path = lerpPath(square, box, {opacity: [0, 1]}); - * // A tap opened the menu: the theme's entrance duration and easing. - * morph.present(path, {duration: enterMs, easing: enterEasing}); - * // …a finger flicked it closed, with a copy placed where the menu stands: - * morph.dismiss(path, {spring: springs.menuClose}, {target: copy, velocity}); + * // Each play takes the timing the component's motion record names. + * morph.present(path, presentMotion); + * // A dismissal onto a copy placed where the menu stands keeps its velocity. + * morph.dismiss(path, dismissMotion, {target: copy, velocity}); * ``` * * An element that already has an animator (a zoom stage's pinch and pan) From 2bcc49a4622f3a024a2975c8bc487c473a2eb553 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 02:26:34 -0800 Subject: [PATCH 009/118] feat(ContextMenu): open from a held finger on iOS 26's timeline A finger held on the trigger opens the menu on UIKit's timeline: the hold commits at 400 ms and the menu shows at 735 ms. One outside-press watch closes the menu for every pointer, and a touch closes it as it begins to pan. It carries this pull request's own diff from 6d7fceb72a, replayed on main after #9814's squash, and folds these 12 commits (the history of 6d7fceb72a has each one): - chore(motion): ContextMenu's touch timeline and press curve, measured on iOS 26.2 - test(ContextMenu): a held finger opens the menu on iOS 26's timeline, and a touch outside closes it as it pans - feat(ContextMenu): open from a held finger on iOS 26's timeline, with no page lock - docs(ContextMenu): FR11 cites AST-094 FR45 for the hold's measured moments - test(ContextMenu): the long-press a11y contract follows the touch model - docs(ContextMenu): the touch changeset and docs name their condition, not every touch device - test(Layer): the outside-press watch closes a layer at the press for a mouse or a pen - feat(Layer): the outside-press watch closes a layer at the press for a mouse or a pen - refactor(ContextMenu): one outside-press watch closes the menu for every pointer, and its mousedown Effect goes - docs(ContextMenu): split long file-header sentences - test(Indicator): the ContextMenu box-hold case pins the popover menu - test(ContextMenu): the long-press spec note keeps to 20 words --- .changeset/contextmenu-touch-timeline.md | 7 + .changeset/outside-press-pan-watch.md | 2 +- .../storybook/stories/ContextMenu.stories.tsx | 69 ++ .../ContextMenu/ContextMenu.spec.md | 95 +-- .../core/src/ContextMenu/ContextMenu.doc.mjs | 9 +- .../core/src/ContextMenu/ContextMenu.test.tsx | 796 ++++++++++++++++-- packages/core/src/ContextMenu/ContextMenu.tsx | 237 ++++-- .../core/src/ContextMenu/ContextMenuTouch.ts | 17 + .../src/ContextMenu/ContextMenuTouchLayer.tsx | 99 +++ ...ContextMenuLongPress.a11y.chromium.spec.ts | 130 ++- ...tMenuLongPress.interaction.browser.spec.ts | 182 ++++ .../src/ContextMenu/contextMenuPress.test.ts | 214 +++++ .../core/src/ContextMenu/contextMenuPress.ts | 198 +++++ packages/core/src/Indicator/boxHold.test.tsx | 5 +- packages/core/src/Layer/outsidePress.test.ts | 112 ++- packages/core/src/Layer/outsidePress.ts | 49 +- packages/core/src/motion/README.md | 1 + packages/core/src/motion/uikit/contextMenu.ts | 94 +++ 18 files changed, 2054 insertions(+), 262 deletions(-) create mode 100644 .changeset/contextmenu-touch-timeline.md create mode 100644 packages/core/src/ContextMenu/ContextMenuTouch.ts create mode 100644 packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx create mode 100644 packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts create mode 100644 packages/core/src/ContextMenu/contextMenuPress.test.ts create mode 100644 packages/core/src/ContextMenu/contextMenuPress.ts create mode 100644 packages/core/src/motion/uikit/contextMenu.ts diff --git a/.changeset/contextmenu-touch-timeline.md b/.changeset/contextmenu-touch-timeline.md new file mode 100644 index 0000000000000..b8c1a1e95a6c7 --- /dev/null +++ b/.changeset/contextmenu-touch-timeline.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] ContextMenu opens from a held finger on iOS 26's measured timeline where the primary pointer is coarse. The hold keeps UIKit's measured moments. At 400 ms the press takes the finger: its lift no longer sends a click, and the page cannot scroll under it. The menu opens at 735 ms if the finger is still down, or as soon as it lifts after 400 ms. A finger that lifts before 400 ms, or drifts more than 11.5 px on either axis before the menu opens, gets no menu. The page stays free to scroll while the menu is up: a touch outside the menu closes it as it begins to pan, or at its lift. A touch open lights no row: the menu takes the focus, and a row lights only under the finger, as in UIKit (keyboard and pointer opens still focus the first item). A second finger cancels the press before 400 ms; after it, the menu still opens. A fine pointer (a mouse) keeps the right-click menu, and a finger there keeps today's 500 ms long press. Pass `presentation="popover"` to keep today's menu: the menu at the pointer, opened by a finger held for 500 ms. + +@vjeux diff --git a/.changeset/outside-press-pan-watch.md b/.changeset/outside-press-pan-watch.md index e71ae7e4c072a..3cb9f8e136f72 100644 --- a/.changeset/outside-press-pan-watch.md +++ b/.changeset/outside-press-pan-watch.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: the layer's outside-press watch can follow chosen pointer types, close as a press that starts outside begins to pan (on either axis, at core's pan threshold) or when the browser takes it, and judge a press inside a closed shadow root, which the document sees as its host. No component passes these options yet, so every watch keeps today's behavior. Not exported from the package entry point. +[chore] Internal: the layer's outside-press watch can follow chosen pointer types, close as a press that starts outside begins to pan (on either axis, at core's pan threshold) or when the browser takes it, and judge a press inside a closed shadow root, which the document sees as its host. ContextMenu's touch model uses them; every other watch keeps today's behavior. Not exported from the package entry point. @vjeux diff --git a/apps/storybook/stories/ContextMenu.stories.tsx b/apps/storybook/stories/ContextMenu.stories.tsx index 891b4ca1c4aa1..7d8dff9642034 100644 --- a/apps/storybook/stories/ContextMenu.stories.tsx +++ b/apps/storybook/stories/ContextMenu.stories.tsx @@ -647,3 +647,72 @@ export const BottomSheetSubmenu: Story = { } }, }; + +const touchStyles = stylex.create({ + card: { + width: '200px', + height: '120px', + display: 'grid', + placeItems: 'center', + borderRadius: '12px', + backgroundColor: '#e5e7eb', + color: '#374151', + userSelect: 'none', + }, +}); + +export const TouchLongPress: Story = { + name: 'Touch / long press', + parameters: { + docs: { + description: { + story: + 'Where the primary pointer is coarse (a touch screen), hold a finger on the card. As on iOS, a haptic plays at 400 ms where the device can play one, and the menu opens at 735 ms, or as soon as the finger lifts after 400 ms. A finger that lifts before 400 ms, or drifts more than 11.5 px on either axis before the menu opens, gets no menu. While the menu is up, the page still scrolls: a touch outside the menu closes it as it begins to pan.', + }, + }, + }, + render: () => ( + {}}, + {label: 'Share', icon: ShareIcon, onClick: () => {}}, + {label: 'Save to Files', icon: ArrowDownTrayIcon, onClick: () => {}}, + {type: 'divider'}, + { + label: 'Delete', + icon: TrashIcon, + variant: 'destructive', + onClick: () => {}, + }, + ]}> +
Hold for actions
+
+ ), +}; + +export const TouchLongPressPopover: Story = { + name: 'Touch / long press, presentation="popover"', + parameters: { + docs: { + description: { + story: + 'With `presentation="popover"`, ContextMenu keeps today’s menu on every device: a finger held for 500 ms opens it at the finger, with no growth and no iOS timeline. A finger on a device whose primary pointer is fine (a mouse or a trackpad) gets the same long press.', + }, + }, + }, + render: () => ( + {}}, + {label: 'Share', icon: ShareIcon, onClick: () => {}}, + {label: 'Save to Files', icon: ArrowDownTrayIcon, onClick: () => {}}, + ]}> +
Hold for actions
+
+ ), +}; diff --git a/docs/specs/components/ContextMenu/ContextMenu.spec.md b/docs/specs/components/ContextMenu/ContextMenu.spec.md index 9fa75032743cb..dac8d53543eaf 100644 --- a/docs/specs/components/ContextMenu/ContextMenu.spec.md +++ b/docs/specs/components/ContextMenu/ContextMenu.spec.md @@ -36,15 +36,15 @@ system_specs: [spec:AST-094, spec:AST-107] ## Contract at a glance -| Area | Contract | -| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Public contract | None changes. ContextMenu takes caller `children` as the Trigger area, data-driven `items` or compound `menuContent`, `label`, `presentation`, `isDisabled`, and `triggerAs`. | -| Behavior | Right-click, Shift+F10 or the Menu key, and a touch long press open a menu at the cursor (or the trigger's bottom-left from the keyboard), or a bottom sheet; rows follow DropdownMenu's rows and press model. | -| End-user impact | People reach a region's actions by mouse, keyboard, or a held finger; a sub-menu drills in on a phone. A touch-opened menu does not yet grow from the finger or lift its trigger, and a scroll that starts outside leaves it open; mouse and keyboard opens keep today's look. | -| Builder impact | Builders wrap any region, including an inline reference in prose (`triggerAs="span"`). | -| Compatibility/readiness | Records shipped behavior; no API change; a disabled row gains `tabindex="-1"`; the released behavior under a finger moves as `spec:AST-094` FR25 lists, with `presentation="popover"` keeping it. The touch grow, pan dismissal, and trigger lift (FR5) are listed under Current-state impact. | -| Review checks | Reject a trigger that gains a role or `aria-haspopup`, a menu that bypasses the shared Escape stack, a `context-menu` target moved off the painted surface, or a second open-state owner. | -| Governing rules | `spec:AST-107` FR8; `spec:AST-094` FR26–FR35; `module:DropdownMenu/useMenuPress`; `family:overlay-dismissal`; `architecture:component-theming-surface`; `architecture:layer-runtime`. | +| Area | Contract | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Public contract | None changes. ContextMenu takes caller `children` as the Trigger area, data-driven `items` or compound `menuContent`, `label`, `presentation`, `isDisabled`, and `triggerAs`. | +| Behavior | Right-click, Shift+F10 or the Menu key, and a touch long press open a menu at the cursor (or the trigger's bottom-left from the keyboard), or a bottom sheet; rows follow DropdownMenu's rows and press model. | +| End-user impact | People reach a region's actions by mouse, keyboard, or a held finger; a sub-menu drills in on a phone. A touch-opened menu does not yet grow from the finger or lift its trigger; mouse and keyboard opens keep today's look. | +| Builder impact | Builders wrap any region, including an inline reference in prose (`triggerAs="span"`). | +| Compatibility/readiness | Records shipped behavior; no API change; a disabled row gains `tabindex="-1"`; the released behavior under a finger moves as `spec:AST-094` FR25 lists, with `presentation="popover"` keeping it. The touch grow and trigger lift (FR5) are listed under Current-state impact. | +| Review checks | Reject a trigger that gains a role or `aria-haspopup`, a menu that bypasses the shared Escape stack, a `context-menu` target moved off the painted surface, or a second open-state owner. | +| Governing rules | `spec:AST-107` FR8; `spec:AST-094` FR26–FR35; `module:DropdownMenu/useMenuPress`; `family:overlay-dismissal`; `architecture:component-theming-surface`; `architecture:layer-runtime`. | This table is a review projection; the body below is authoritative. @@ -100,19 +100,20 @@ Consumer props, content modes, and presentation policy remain documented in ## Behavioral and layout contract -| ID | Invariant | Basis | -| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| FR1 | Every render contains a caller-provided Trigger area; with `isDisabled` no invocation opens a menu and the native context menu is left alone. Pointer presentation opens a Pointer menu surface at the current local cursor anchor; touch presentation opens a Touch sheet frame containing a Touch menu surface and, for data mode, a Touch action list and Touch action rows. | Current source, docs, and tests | -| FR2 | The `context-menu` target stays on the painted Pointer menu surface and alternative Touch menu surface, not on the Trigger area or cursor anchor. | `architecture:component-theming-surface`; current source | -| FR3 | Pointer action rows retain DropdownMenu ownership. The touch frame retains BottomSheet ownership, while data-driven touch lists and rows retain List ownership. | Current source and owner docs | -| FR4 | Compound `menuContent` remains a caller-supplied pointer-menu interior and may also render inside the touch frame; the data-driven touch path instead converts the same item data to List and ListItem presentation. | Current source and tests | -| FR5 | The Pointer menu surface carries `data-astryx-menu-press` and follows `module:DropdownMenu/useMenuPress` FR1–FR7: the row under the release acts, the highlight follows a held pointer, a mouse released outside closes and a finger leaves the menu open, the stray click never acts, and `touch-action` follows overflow. The long press that opened the menu does not close it as it lifts; a tap outside closes it, and a mouse press outside closes it at the press. Under a finger the menu MUST close as a pan that starts outside it begins (`spec:AST-094` FR32), MUST hand the held finger that opened it to the menu as the opening press, and MUST treat a pen's press outside as FR32 does (FR33). The lift and the grow apply only to a menu opened by touch (a touch-initiated open on a coarse pointer); a menu opened by mouse or keyboard keeps its released appearance and motion, with no lift, no tint and no grow. A touch-opened menu MUST grow out of the finger's point and shrink back into it (`spec:AST-094` FR31) as its own surface, by transform and opacity only (`spec:AST-094` FR5), with no second element such as a glass surface growing behind it, and on a block trigger MUST lift the trigger above the page with the menu beside it (FR33; DD1, DEC-2). The long-press presentation is a gesture component, so the trigger's lift plays `springs.contextMenuLift`, the menu's grow `springs.contextMenuMorph`, and the trigger's return, part of the menu's exit, `springs.contextMenuDismiss` (`spec:AST-094` FR4, FR7, FR45). Implementation gap: it keeps the layer's own entrance and exit, a scroll that starts outside leaves it open, the opening finger is not handed to the menu so it cannot slide to a row, no trigger lift or page tint exists, and a pen press outside closes it at the press. Right-click, Shift+F10, long-press invocation, and keyboard navigation are as FR1 and FR8 state. | `module:DropdownMenu/useMenuPress`; `spec:AST-094` FR26–FR33 | -| FR6 | With `triggerAs="span"` the Trigger area is an inline element that owns a box, so the cursor anchor measures from the trigger's own rect on every invocation path. Right-click, Shift+F10, long-press, and `context-menu` target placement all use that box. | DEC-1 | -| FR7 | Sub-menu rows follow `component:DropdownMenu` FR11 inside the pointer menu: ContextMenu mounts the same drill-in view stack, named after its `label`, so a sub-menu drills in on a phone here too. A sub-menu with a header or footer shows its rows alone here (`component:DropdownMenu` FR20). | `component:DropdownMenu/DEC-4` | -| FR8 | In the pointer menu ArrowDown on the last row wraps to the first and ArrowUp on the first to the last; PageDown and PageUp page as in `component:DropdownMenu` FR12; a held key's auto-repeat does not activate; typeahead matches the row's label element alone. A disabled row is reached by these keys and by typeahead and stays inert, as `component:DropdownMenu` FR17 states. Implementation gap: the arrow keys and typeahead skip disabled rows today (Current-state impact). | Current source and tests | -| FR9 | While the Pointer menu surface is open, it participates in `family:overlay-dismissal` through the shared stack and provides descendant depth. One Escape press affects only the top-most present nested surface; ContextMenu closes only when it is that surface. | `family:overlay-dismissal` FR1–FR6; `ContextMenu.test.tsx` nested-layer case | -| FR10 | ContextMenu MUST be built on the public headless context menu (`spec:AST-107` FR8), with its invocation, open state, and cleanup the factory's and no behavior of its own beside the factory's. Implementation gap: no headless context menu ships; ContextMenu owns its invocation, open state, outside-press listener, and cleanup itself. | `spec:AST-107` FR8 | -| FR11 | Tab or Shift+Tab on a row of the pointer menu, its own or an open sub-menu flyout's at any depth, closes every open level and the menu and calls `onOpenChange(false)` once, as `component:DropdownMenu` FR23 states for its menu. | WAI-ARIA Menu Button pattern; current tests | +| ID | Invariant | Basis | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| FR1 | Every render contains a caller-provided Trigger area; with `isDisabled` no invocation opens a menu and the native context menu is left alone. Pointer presentation opens a Pointer menu surface at the current local cursor anchor; touch presentation opens a Touch sheet frame containing a Touch menu surface and, for data mode, a Touch action list and Touch action rows. | Current source, docs, and tests | +| FR2 | The `context-menu` target stays on the painted Pointer menu surface and alternative Touch menu surface, not on the Trigger area or cursor anchor. | `architecture:component-theming-surface`; current source | +| FR3 | Pointer action rows retain DropdownMenu ownership. The touch frame retains BottomSheet ownership, while data-driven touch lists and rows retain List ownership. | Current source and owner docs | +| FR4 | Compound `menuContent` remains a caller-supplied pointer-menu interior and may also render inside the touch frame; the data-driven touch path instead converts the same item data to List and ListItem presentation. | Current source and tests | +| FR5 | The Pointer menu surface carries `data-astryx-menu-press` and follows `module:DropdownMenu/useMenuPress` FR1–FR7: the row under the release acts, the highlight follows a held pointer, a mouse released outside closes and a finger leaves the menu open, the stray click never acts, and `touch-action` follows overflow. The long press that opened the menu does not close it as it lifts; a tap outside closes it, and a mouse press outside closes it at the press. Under a finger the menu MUST close as a pan that starts outside it begins (`spec:AST-094` FR32), MUST hand the held finger that opened it to the menu as the opening press, and MUST treat a pen's press outside as FR32 does (FR33). The lift and the grow apply only to a menu opened by touch (a touch-initiated open on a coarse pointer); a menu opened by mouse or keyboard keeps its released appearance and motion, with no lift, no tint and no grow. A touch-opened menu MUST grow out of the finger's point and shrink back into it (`spec:AST-094` FR31) as its own surface, by transform and opacity only (`spec:AST-094` FR5), with no second element such as a glass surface growing behind it, and on a block trigger MUST lift the trigger above the page with the menu beside it (FR33; DD1, DEC-2). The long-press presentation is a gesture component, so the trigger's lift plays `springs.contextMenuLift`, the menu's grow `springs.contextMenuMorph`, and the trigger's return, part of the menu's exit, `springs.contextMenuDismiss` (`spec:AST-094` FR4, FR7, FR45). Implementation gap: it keeps the layer's own entrance and exit, the opening finger is not handed to the menu so it cannot slide to a row, no trigger lift or page tint exists, and a pen press outside closes it at the press. Right-click, Shift+F10, long-press invocation, and keyboard navigation are as FR1 and FR8 state. | `module:DropdownMenu/useMenuPress`; `spec:AST-094` FR26–FR33 | +| FR6 | With `triggerAs="span"` the Trigger area is an inline element that owns a box, so the cursor anchor measures from the trigger's own rect on every invocation path. Right-click, Shift+F10, long-press, and `context-menu` target placement all use that box. | DEC-1 | +| FR7 | Sub-menu rows follow `component:DropdownMenu` FR11 inside the pointer menu: ContextMenu mounts the same drill-in view stack, named after its `label`, so a sub-menu drills in on a phone here too. A sub-menu with a header or footer shows its rows alone here (`component:DropdownMenu` FR20). | `component:DropdownMenu/DEC-4` | +| FR8 | In the pointer menu ArrowDown on the last row wraps to the first and ArrowUp on the first to the last; PageDown and PageUp page as in `component:DropdownMenu` FR12; a held key's auto-repeat does not activate; typeahead matches the row's label element alone. A disabled row is reached by these keys and by typeahead and stays inert, as `component:DropdownMenu` FR17 states. Implementation gap: the arrow keys and typeahead skip disabled rows today (Current-state impact). | Current source and tests | +| FR9 | While the Pointer menu surface is open, it participates in `family:overlay-dismissal` through the shared stack and provides descendant depth. One Escape press affects only the top-most present nested surface; ContextMenu closes only when it is that surface. | `family:overlay-dismissal` FR1–FR6; `ContextMenu.test.tsx` nested-layer case | +| FR10 | ContextMenu MUST be built on the public headless context menu (`spec:AST-107` FR8), with its invocation, open state, and cleanup the factory's and no behavior of its own beside the factory's. Implementation gap: no headless context menu ships; ContextMenu owns its invocation, open state, outside-press listener, and cleanup itself. | `spec:AST-107` FR8 | +| FR11 | Tab or Shift+Tab on a row of the pointer menu, its own or an open sub-menu flyout's at any depth, closes every open level and the menu and calls `onOpenChange(false)` once, as `component:DropdownMenu` FR23 states for its menu. | WAI-ARIA Menu Button pattern; current tests | +| FR12 | Under a finger, the hold commits at 400 ms and the menu displays at 735 ms, as UIKit's do. They are measured input, not motion, and stay internal (`spec:AST-094` FR4, FR45). | Measured: UIContextMenuInteraction on the iOS 26.2 simulator commits at 400 ms and displays its menu at 735 ms (`motion/uikit/contextMenu.ts`); `spec:AST-094` FR4, FR45 | ### Allowed variation @@ -244,24 +245,25 @@ anchor is positioning infrastructure rather than consumer anatomy. ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -| FR1, FR4 | `ContextMenu.test.tsx` rendering, invocation, bottom-sheet, adaptive, compound, and disabled cases ("renders menu with role=\"menu\"", "opens menu on right-click", "opens from a keyboard-invoked contextmenu (Shift+F10 / Menu key)", "opens on touch long-press", "opens the real menu content in a BottomSheet when requested", "uses the BottomSheet for adaptive presentation on compact touch", "renders menuContent as menu items", "does not open when isDisabled is true") | Right-click, keyboard, long press; pointer and sheet; data and compound; disabled | A missing invocation path, menu role, sheet, or compound interior, or a disabled trigger that opens, fails. Evidence gap: no test asserts that a disabled trigger leaves the native context menu alone. | `audit:ContextMenu/anatomy` | -| FR2 | `themingTargets.test.ts` | Rendered targets | A rendered class absent from the documented targets fails. Evidence gap: no test asserts `context-menu` on either painted surface or its absence from the trigger. | `audit:ContextMenu/theming` | -| FR3 | `ContextMenu.test.tsx` destructive-variant, section, divider, selectable, and alias cases | Shared rows through ContextMenu | A row that loses DropdownMenu's semantics or variant fails. Evidence gap: no test asserts the BottomSheet and List delegation beyond rendering. | `audit:ContextMenu/theming` | -| FR5 | `ContextMenu.test.tsx` "acts on the row under a finger release, once, and swallows the stray click" and outside-dismissal cases; `ContextMenuLongPress.a11y.chromium.spec.ts` | Finger release; long press lift; tap, mouse, pen, and scroll outside | A row acting on the press row, a stray click that acts, a long press that closes its own menu, or a tap or mouse press outside that leaves it open fails. Evidence gap: no test asserts the FR31 grow, the opening-finger handoff, or the FR33 lift; the scroll-outside and pen cases assert the current gaps. | `audit:ContextMenu/behavior` | -| FR5, DD1 touch scope | Browser tests on the touch story, the menu opened by a finger under a coarse pointer and by a mouse and by Shift+F10 under a fine pointer | Touch-opened, mouse-opened and keyboard-opened menus on a block trigger | A mouse- or keyboard-opened menu that lifts its trigger, tints the page or grows, or a touch-opened menu that blurs the page, grows a second surface, or animates anything but transform and opacity, fails. Evidence gap: the lift and the grow are unbuilt, so no test asserts either. | `audit:ContextMenu/behavior` | -| FR5 Reduce Motion | Browser tests on the touch story with reduced motion | A touch-opened menu opening and closing under Reduce Motion | A lift, grow or shrink that moves rather than playing an opacity-only cross-fade of 200 ms or less (`spec:AST-094` FR8) fails. Evidence gap: no test exists. | `audit:ContextMenu/behavior` | -| FR5 motion roles | Role registry tests over `spec:AST-094` FR4 | `springs.contextMenuLift`, `springs.contextMenuDismiss` and `springs.contextMenuMorph` at tempo 1 and `'standard'` | A lift, return or grow that plays a curve or a literal instead of its spring role, or a role off its FR4 table value, fails. | `audit:ContextMenu/behavior` | -| DEC-2 | Element-count browser test on the touch story (`spec:AST-017` FR60) | At rest and lifted, fine and coarse pointer | A lift element at rest, a copy of the trigger, or an open count beyond the layer, the shadow, the anchor and the placeholder fails. Evidence gap: the lift is unbuilt, so no test exists. | `audit:ContextMenu/anatomy` | -| FR6, AR1, ORD1 | `ContextMenu.test.tsx` "inline trigger (triggerAs)" and cursor-anchor cases; "does not put aria-haspopup on the role-less trigger wrapper (menus-15)"; `ContextMenu.triggerProps.test.tsx`; `ContextMenuCursorAnchor.a11y.browser.spec.ts` | `span` in a paragraph; default `div`; pointer and keyboard anchors | A block trigger in prose, a trigger with a role or `aria-haspopup`, or an anchor outside the trigger fails. | `audit:ContextMenu/behavior` | -| FR7 | `ContextMenu.test.tsx` "drills into nested data items inside the BottomSheet", "renders a submenu from a nested items array and keyboard-reaches an item after it", and "shows a sub-menu with a header or footer as its rows alone, and says why in development" | Sheet drill-in; pointer sub-menu; regions under ContextMenu | A missing sub-menu, an unreachable row after it, or a sub-menu dialog under ContextMenu fails. Evidence gap: no test here asserts the pointer drill-in on a phone. | `audit:ContextMenu/behavior` | -| FR8 | `ContextMenu.test.tsx` "wraps arrow navigation at the ends", typeahead cases, and disabled-row keyboard cases (`component:DropdownMenu` FR17) | Wrap; typeahead; disabled rows | A clamped arrow, a missing typeahead match, a disabled row the keys or typeahead skip, or a disabled row that acts or closes the menu fails. Evidence gap: no test asserts Page keys or auto-repeat. | `audit:ContextMenu/behavior` | -| FR9 | `ContextMenu.test.tsx` "lets a nested layer consume Escape before the context menu", "closes on Escape even when opened without auto-focus", and "ignores Escape during IME composition" | Nested layer; Escape; composition | A context menu that closes with its nested child, or on a composition Escape, fails. | `audit:ContextMenu/behavior` | -| FR10, PR1 | `ContextMenu.test.tsx` invocation and handler-composition cases ("composes the consumer context-menu handler with menu opening", "keeps component-owned opening when a consumer prevents default", "composes consumer touch handlers with long-press detection") | Right click, long press, keyboard | A context request that does not open the menu, or a dropped consumer handler, fails. Evidence gap: no test asserts listener cleanup on unmount; nothing tests a headless factory, which does not ship (the FR10 gap). | `audit:ContextMenu/behavior` | -| FR11 | `ContextMenu.test.tsx` "Tab from a flyout leaves the whole menu" case | a sub-menu row | A Tab that leaves the menu or a flyout open, or a second `onOpenChange(false)`, fails. | `audit:ContextMenu/accessibility` | -| Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and target | Missing, extra, prefixed, stale, or unclassified mappings fail repository validation. | `audit:ContextMenu/theming` | -| RTL relation | RTL audit curated D2 target (`apps/storybook/rtl-audit/targets.json`) on the `Presentation / BottomSheet submenu` story | BottomSheet opened into a submenu, LTR and RTL | The sheet header's back button not at the inline start of its heading in both directions fails D2. | `audit:ContextMenu/i18n-rtl` | +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| FR1, FR4 | `ContextMenu.test.tsx` rendering, invocation, bottom-sheet, adaptive, compound, and disabled cases ("renders menu with role=\"menu\"", "opens menu on right-click", "opens from a keyboard-invoked contextmenu (Shift+F10 / Menu key)", "opens for a finger still down at 735 ms (iOS 26.2), not a millisecond before", "opens the real menu content in a BottomSheet when requested", "uses the BottomSheet for adaptive presentation on compact touch", "renders menuContent as menu items", "does not open when isDisabled is true") | Right-click, keyboard, long press; pointer and sheet; data and compound; disabled | A missing invocation path, menu role, sheet, or compound interior, or a disabled trigger that opens, fails. Evidence gap: no test asserts that a disabled trigger leaves the native context menu alone. | `audit:ContextMenu/anatomy` | +| FR2 | `themingTargets.test.ts` | Rendered targets | A rendered class absent from the documented targets fails. Evidence gap: no test asserts `context-menu` on either painted surface or its absence from the trigger. | `audit:ContextMenu/theming` | +| FR3 | `ContextMenu.test.tsx` destructive-variant, section, divider, selectable, and alias cases | Shared rows through ContextMenu | A row that loses DropdownMenu's semantics or variant fails. Evidence gap: no test asserts the BottomSheet and List delegation beyond rendering. | `audit:ContextMenu/theming` | +| FR5 | `ContextMenu.test.tsx` "acts on the row under a finger release, once, and swallows the stray click" and outside-dismissal cases; `outsidePress.test.ts` pan cases; `ContextMenuLongPress.interaction.browser.spec.ts` "a new finger that pans outside the open menu closes it, and the page scrolls"; `ContextMenuLongPress.a11y.chromium.spec.ts` | Finger release; long press lift; tap, mouse, pen, and pan outside | A row acting on the press row, a stray click that acts, a long press that closes its own menu, a tap or mouse press outside that leaves it open, or a pan outside that leaves it open fails. Evidence gap: no test asserts the FR31 grow, the opening-finger handoff, or the FR33 lift. | `audit:ContextMenu/behavior` | +| FR5, DD1 touch scope | Browser tests on the touch story, the menu opened by a finger under a coarse pointer and by a mouse and by Shift+F10 under a fine pointer | Touch-opened, mouse-opened and keyboard-opened menus on a block trigger | A mouse- or keyboard-opened menu that lifts its trigger, tints the page or grows, or a touch-opened menu that blurs the page, grows a second surface, or animates anything but transform and opacity, fails. Evidence gap: the lift and the grow are unbuilt, so no test asserts either. | `audit:ContextMenu/behavior` | +| FR5 Reduce Motion | Browser tests on the touch story with reduced motion | A touch-opened menu opening and closing under Reduce Motion | A lift, grow or shrink that moves rather than playing an opacity-only cross-fade of 200 ms or less (`spec:AST-094` FR8) fails. Evidence gap: no test exists. | `audit:ContextMenu/behavior` | +| FR5 motion roles | Role registry tests over `spec:AST-094` FR4 | `springs.contextMenuLift`, `springs.contextMenuDismiss` and `springs.contextMenuMorph` at tempo 1 and `'standard'` | A lift, return or grow that plays a curve or a literal instead of its spring role, or a role off its FR4 table value, fails. | `audit:ContextMenu/behavior` | +| DEC-2 | Element-count browser test on the touch story (`spec:AST-017` FR60) | At rest and lifted, fine and coarse pointer | A lift element at rest, a copy of the trigger, or an open count beyond the layer, the shadow, the anchor and the placeholder fails. Evidence gap: the lift is unbuilt, so no test exists. | `audit:ContextMenu/anatomy` | +| FR6, AR1, ORD1 | `ContextMenu.test.tsx` "inline trigger (triggerAs)" and cursor-anchor cases; "does not put aria-haspopup on the role-less trigger wrapper (menus-15)"; `ContextMenu.triggerProps.test.tsx`; `ContextMenuCursorAnchor.a11y.browser.spec.ts` | `span` in a paragraph; default `div`; pointer and keyboard anchors | A block trigger in prose, a trigger with a role or `aria-haspopup`, or an anchor outside the trigger fails. | `audit:ContextMenu/behavior` | +| FR7 | `ContextMenu.test.tsx` "drills into nested data items inside the BottomSheet", "renders a submenu from a nested items array and keyboard-reaches an item after it", and "shows a sub-menu with a header or footer as its rows alone, and says why in development" | Sheet drill-in; pointer sub-menu; regions under ContextMenu | A missing sub-menu, an unreachable row after it, or a sub-menu dialog under ContextMenu fails. Evidence gap: no test here asserts the pointer drill-in on a phone. | `audit:ContextMenu/behavior` | +| FR8 | `ContextMenu.test.tsx` "wraps arrow navigation at the ends", typeahead cases, and disabled-row keyboard cases (`component:DropdownMenu` FR17) | Wrap; typeahead; disabled rows | A clamped arrow, a missing typeahead match, a disabled row the keys or typeahead skip, or a disabled row that acts or closes the menu fails. Evidence gap: no test asserts Page keys or auto-repeat. | `audit:ContextMenu/behavior` | +| FR9 | `ContextMenu.test.tsx` "lets a nested layer consume Escape before the context menu", "closes on Escape even when opened without auto-focus", and "ignores Escape during IME composition" | Nested layer; Escape; composition | A context menu that closes with its nested child, or on a composition Escape, fails. | `audit:ContextMenu/behavior` | +| FR10, PR1 | `ContextMenu.test.tsx` invocation and handler-composition cases ("composes the consumer context-menu handler with menu opening", "keeps component-owned opening when a consumer prevents default", "passes touch events through to the consumer’s own handlers", and the cases where a consumer `onTouchStart` or `onTouchEnd` runs beside the hold) | Right click, long press, keyboard | A context request that does not open the menu, or a dropped consumer handler, fails. Evidence gap: no test asserts listener cleanup on unmount; nothing tests a headless factory, which does not ship (the FR10 gap). | `audit:ContextMenu/behavior` | +| FR11 | `ContextMenu.test.tsx` "Tab from a flyout leaves the whole menu" case | a sub-menu row | A Tab that leaves the menu or a flyout open, or a second `onOpenChange(false)`, fails. | `audit:ContextMenu/accessibility` | +| FR12 | `ContextMenu.test.tsx` "opens for a finger still down at 735 ms (iOS 26.2), not a millisecond before" and "opens at the lift for a finger lifted after the commit (400 ms), as UIKit does"; `contextMenuPress.test.ts` | A held finger: the commit and the menu's display | A commit or a display off UIKit's moments fails. | `audit:ContextMenu/behavior` | +| Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and target | Missing, extra, prefixed, stale, or unclassified mappings fail repository validation. | `audit:ContextMenu/theming` | +| RTL relation | RTL audit curated D2 target (`apps/storybook/rtl-audit/targets.json`) on the `Presentation / BottomSheet submenu` story | BottomSheet opened into a submenu, LTR and RTL | The sheet header's back button not at the inline start of its heading in both directions fails D2. | `audit:ContextMenu/i18n-rtl` | ## Current-state impact @@ -271,13 +273,12 @@ anchor is positioning infrastructure rather than consumer anatomy. - **No headless factory (FR10).** No headless context menu ships, so ContextMenu owns its invocation, open state, and outside-press listener itself, against `spec:AST-107` FR8. -- **Touch grow, pan dismissal, handoff, and lift (FR5).** A touch-opened - menu keeps the layer's own entrance and exit instead of growing from the - finger, a scroll that starts outside leaves it open, the held finger that - opened it cannot slide to a row, and a block trigger is not lifted over a - tinted page; a pen press outside closes it at the press. All are against - `spec:AST-094` FR31, FR32, and FR33. A menu opened by mouse or keyboard - already keeps its released appearance and motion. +- **Touch grow, handoff, and lift (FR5).** A touch-opened menu keeps the + layer's own entrance and exit instead of growing from the finger, the held + finger that opened it cannot slide to a row, and a block trigger is not + lifted over a tinted page; a pen press outside closes it at the press. All + are against `spec:AST-094` FR31, FR32, and FR33. A menu opened by mouse or + keyboard already keeps its released appearance and motion. ## Decision log diff --git a/packages/core/src/ContextMenu/ContextMenu.doc.mjs b/packages/core/src/ContextMenu/ContextMenu.doc.mjs index f8ee2c5659074..4a5b1745c7bbe 100644 --- a/packages/core/src/ContextMenu/ContextMenu.doc.mjs +++ b/packages/core/src/ContextMenu/ContextMenu.doc.mjs @@ -132,8 +132,7 @@ export const docs = { name: 'presentation', type: "'popover' | 'bottom-sheet' | 'adaptive'", description: - 'Presentation policy. `popover` opens at the pointer position, `bottom-sheet` always uses an action sheet, and `adaptive` uses the BottomSheet on a compact touch screen: a coarse primary pointer, and a viewport narrower than the theme `md` breakpoint (768px by default) or shorter than 500px.', - default: "'popover'", + 'Presentation policy. Omitted, the menu opens at the pointer position, and under a finger a held finger opens it on iOS timing (the touch model). `popover` opens at the pointer position on every device, and a held finger opens it at 500 ms (the long press), as before; so does a finger where the primary pointer is fine. `bottom-sheet` always uses an action sheet, and `adaptive` uses the BottomSheet on a compact touch screen: a coarse primary pointer, and a viewport narrower than the theme `md` breakpoint (768px by default) or shorter than 500px.', }, { name: 'isDisabled', @@ -160,7 +159,7 @@ export const docs = { usage: { anatomy, description: - 'A right-click context menu that appears at the cursor position. Use to provide contextual actions for specific elements or regions without cluttering the UI with visible buttons.', + 'A right-click context menu that appears at the cursor position. Use to provide contextual actions for specific elements or regions without cluttering the UI with visible buttons. Under a finger, where the primary pointer is coarse, a held finger opens it with iOS timing: the menu opens at 735 ms, or as soon as the finger lifts after 400 ms.', bestPractices: [ { guidance: true, @@ -224,7 +223,7 @@ export const docs = { export const docsZh = { usage: { description: - '右键点击时在光标位置出现的上下文菜单。用于为特定元素或区域提供上下文操作,而不使 UI 杂乱。', + '右键点击时在光标位置出现的上下文菜单。用于为特定元素或区域提供上下文操作,而不使 UI 杂乱。在主指针为粗指针(触摸屏)时,按住手指即可打开,时序与 iOS 相同:735 毫秒时菜单打开;若手指在 400 毫秒后抬起,菜单在抬起时立即打开。', bestPractices: [ {guidance: true, description: '保持菜单项简洁和面向操作。'}, {guidance: true, description: '有很多项时使用分组和分隔线。'}, @@ -260,7 +259,7 @@ export const docsDense = { usage: { anatomy, description: - 'A right-click context menu that appears at the cursor position. Use to provide contextual actions for specific elements or regions.', + 'A right-click context menu that appears at the cursor position. Use to provide contextual actions for specific elements or regions. Under a finger (a coarse primary pointer): a held finger opens it at 735 ms, or on a lift after 400 ms.', bestPractices: [ {guidance: true, description: 'Keep items concise and action-oriented.'}, { diff --git a/packages/core/src/ContextMenu/ContextMenu.test.tsx b/packages/core/src/ContextMenu/ContextMenu.test.tsx index d68f7a78dcc1d..3b3957152001d 100644 --- a/packages/core/src/ContextMenu/ContextMenu.test.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.test.tsx @@ -10,7 +10,15 @@ import {useRef, useState} from 'react'; import {createPortal} from 'react-dom'; -import {describe, it, expect, vi, beforeAll, beforeEach} from 'vitest'; +import { + describe, + it, + expect, + vi, + afterEach, + beforeAll, + beforeEach, +} from 'vitest'; import { render, screen, @@ -37,6 +45,9 @@ import {Divider} from '../Divider'; import {useLayerDismissal} from '../Layer/useLayerDismissal'; import {warmMenuBottomSheet} from '../DropdownMenu/__tests__/warmMenuBottomSheet'; import {COMPACT_TOUCH_PRESENTATION_QUERY} from '../hooks/useAdaptivePresentation'; +import {COMPACT_QUERY, TOUCH_QUERY} from '../utils/touchPresentation'; +import {HAPTIC_VIBRATION_PATTERNS} from '../motion/uikit'; +import {contextMenu} from '../motion/uikit/contextMenu'; // The touch sheet is loaded lazily; resolve its modules once up front, so // ContextMenu's own lazy sheet renders within the cases' waitFor. @@ -180,6 +191,17 @@ describe('ContextMenu', () => { ).toBeInTheDocument(); }); + it('suppresses native text selection on the long-press trigger', () => { + const source = readFileSync( + 'packages/core/src/ContextMenu/ContextMenu.tsx', + 'utf8', + ); + + expect(source).toMatch( + /trigger:[\s\S]*?WebkitTouchCallout: 'none',[\s\S]*?WebkitUserSelect: 'none',[\s\S]*?userSelect: 'none'/, + ); + }); + it('does not put aria-haspopup on the role-less trigger wrapper (menus-15)', () => { render( @@ -374,7 +396,11 @@ describe('ContextMenu', () => { vi.stubGlobal( 'matchMedia', vi.fn().mockImplementation((query: string) => ({ - matches: query === COMPACT_TOUCH_PRESENTATION_QUERY, + // A compact touch screen: a coarse primary pointer, a narrow viewport. + matches: + query === COMPACT_TOUCH_PRESENTATION_QUERY || + query === TOUCH_QUERY || + query === COMPACT_QUERY, media: query, onchange: null, addEventListener: vi.fn(), @@ -414,6 +440,8 @@ describe('ContextMenu', () => { it.each(['open', 'closed', 'nested'] as const)( 'judges presses once inside a %s shadow root', mode => { + // Under the touch model, so a touch on the host closes the menu. + stubPointer(true); const host = document.createElement('div'); document.body.append(host); let root = host.attachShadow({mode: mode === 'open' ? 'open' : 'closed'}); @@ -431,16 +459,33 @@ describe('ContextMenu', () => { , {container}, ); - fireEvent.contextMenu(container.querySelector('div div')!); + const trigger = container.querySelector('div div')!; + fireEvent.contextMenu(trigger); // At the document a press on the menu is retargeted to a shadow host. const item = container.querySelector('[role="menuitem"]')!; fireEvent.mouseDown(item, {composed: true}); expect(HTMLElement.prototype.hidePopover).not.toHaveBeenCalled(); - // A press on the host itself is outside the menu. + // A touch on the menu is inside too, through its lift. + const onItem = {pointerType: 'touch', pointerId: 1, composed: true}; + fireEvent.pointerDown(item, onItem); + fireEvent.pointerUp(item, onItem); + expect(HTMLElement.prototype.hidePopover).not.toHaveBeenCalled(); + // A mouse press on the host itself is outside the menu. Its + // pointerdown comes first, as a browser sends it. + fireEvent.pointerDown(host, {pointerType: 'mouse', pointerId: 3}); fireEvent.mouseDown(host); expect(HTMLElement.prototype.hidePopover).toHaveBeenCalled(); + // A touch on the host closes the menu at its lift. + vi.mocked(HTMLElement.prototype.hidePopover).mockClear(); + fireEvent.contextMenu(trigger); + const onHost = {pointerType: 'touch', pointerId: 2}; + fireEvent.pointerDown(host, onHost); + expect(HTMLElement.prototype.hidePopover).not.toHaveBeenCalled(); + fireEvent.pointerUp(host, onHost); + expect(HTMLElement.prototype.hidePopover).toHaveBeenCalled(); } finally { host.remove(); + vi.unstubAllGlobals(); } }, ); @@ -665,114 +710,598 @@ describe('ContextMenu', () => { expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(2); }); }); +}); - it('opens on touch long-press', () => { - vi.useFakeTimers(); - try { - render( - -
Long-press me
-
, - ); - const trigger = screen.getByTestId('ctx'); - fireEvent.touchStart(trigger, { - touches: [{clientX: 20, clientY: 20}], - }); - // Not open until the long-press threshold elapses. - expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); - act(() => { - vi.advanceTimersByTime(500); +/** + * A finger on the real gesture host: touch events with explicit contacts, + * stamped on the (faked) `performance.now()` clock the host reads. + */ +interface Contact { + id: number; + x: number; + y: number; +} + +function dispatchTouch( + type: 'touchstart' | 'touchmove' | 'touchend' | 'touchcancel', + target: EventTarget, + changed: Contact[], + all: Contact[], +): Event { + const event = new Event(type, {bubbles: true, cancelable: true}); + const toTouch = (contact: Contact) => ({ + identifier: contact.id, + clientX: contact.x, + clientY: contact.y, + target, + }); + Object.defineProperty(event, 'changedTouches', {value: changed.map(toTouch)}); + Object.defineProperty(event, 'touches', {value: all.map(toTouch)}); + Object.defineProperty(event, 'timeStamp', {value: performance.now()}); + target.dispatchEvent(event); + return event; +} + +function finger(target: EventTarget, id = 1, x = 20, y = 20) { + const contact = {id, x, y}; + return { + down: () => dispatchTouch('touchstart', target, [contact], [contact]), + moveTo: (nx: number, ny: number) => { + contact.x = nx; + contact.y = ny; + return dispatchTouch('touchmove', target, [contact], [contact]); + }, + up: () => dispatchTouch('touchend', target, [contact], []), + }; +} + +const advance = (ms: number) => + act(() => { + vi.advanceTimersByTime(ms); + }); + +/** The primary pointer answers `matches` to `(pointer: coarse)`. */ +function stubPointer(isCoarse: boolean) { + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + matches: isCoarse && query === '(pointer: coarse)', + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); +} + +/** + * A finger on iOS in jsdom: a coarse pointer, on an engine that supports + * `-webkit-touch-callout` (WebKit on iOS). Undone by vi.unstubAllGlobals(). + */ +function stubIosTouch() { + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + matches: query === '(pointer: coarse)', + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); + vi.stubGlobal('CSS', { + ...(globalThis as {CSS?: object}).CSS, + supports: (condition: string) => + condition.includes('-webkit-touch-callout'), + }); +} + +/** A finger on Android: a coarse pointer, no WebKit touch callout. */ +function stubAndroidTouch() { + stubPointer(true); +} + +// The gesture host lives as long as the document and remembers the latest +// event time. Each install of fake timers restarts `performance.now()` near 0. +// So every case moves the faked clock past the ones before it. +let clockEpoch = 0; + +// One touch model on every touch device (#8316): iOS and Android alike. +describe.each([ + ['iOS', stubIosTouch], + ['Android', stubAndroidTouch], +] as const)( + 'ContextMenu under a finger on %s: UIKit’s long-press timeline', + (_platform, stubTouch) => { + beforeEach(() => { + stubTouch(); + vi.useFakeTimers({ + toFake: [ + 'setTimeout', + 'clearTimeout', + 'setInterval', + 'clearInterval', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'performance', + 'Date', + ], }); - expect(HTMLElement.prototype.showPopover).toHaveBeenCalled(); - } finally { + clockEpoch += 100_000; + vi.advanceTimersByTime(clockEpoch); + }); + afterEach(() => { vi.useRealTimers(); - } - }); + vi.unstubAllGlobals(); + }); - it('composes consumer touch handlers with long-press detection', () => { - vi.useFakeTimers(); - try { - const onTouchStart = vi.fn(); + function renderMenu( + props: { + isDisabled?: boolean; + presentation?: 'popover' | 'bottom-sheet'; + triggerAs?: 'div' | 'span'; + onTouchStart?: (event: React.TouchEvent) => void; + onTouchEnd?: (event: React.TouchEvent) => void; + } = {}, + ) { render( - +
Long-press me
, ); + return screen.getByTestId('ctx'); + } - fireEvent.touchStart(screen.getByTestId('ctx'), { - touches: [{clientX: 20, clientY: 20}], - }); - expect(onTouchStart).toHaveBeenCalledOnce(); + it('opens for a finger still down at 735 ms (iOS 26.2), not a millisecond before', () => { + const trigger = renderMenu(); + const press = finger(trigger); + press.down(); + advance(contextMenu.press.menuDisplayMs - 1); + expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + advance(1); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + press.up(); + }); + it('lights no row when a touch opens it: focus goes to the menu itself, not its first item', () => { + const trigger = renderMenu(); + const press = finger(trigger); + press.down(); + advance(contextMenu.press.menuDisplayMs); + // The focus moves on the next frame. + advance(20); + expect(screen.getByRole('menu', {hidden: true})).toHaveFocus(); + expect( + screen.getByRole('menuitem', {name: 'Item 1', hidden: true}), + ).not.toHaveFocus(); + press.up(); + }); + + it('opens at the lift for a finger lifted after the commit (400 ms), as UIKit does', () => { + const trigger = renderMenu(); + const press = finger(trigger); + press.down(); + advance(529); + expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + // A first open mounts the menu's layer, which shows it as it attaches. act(() => { - vi.advanceTimersByTime(500); + press.up(); }); - expect(HTMLElement.prototype.showPopover).toHaveBeenCalledOnce(); - } finally { - vi.useRealTimers(); - } - }); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + }); - it('clears a pending long-press when a consumer prevents default on touch end', () => { - vi.useFakeTimers(); - try { - render( + it('opens nothing for a drag past the tolerance after the commit, before the menu', () => { + const trigger = renderMenu(); + const press = finger(trigger, 1, 20, 20); + press.down(); + advance(500); + press.moveTo(20, 50); + advance(500); + press.up(); + advance(100); + expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + }); + + it('keeps the menu open as the finger lifts: the lift sends no click or compatibility mouse events', () => { + const trigger = renderMenu(); + const press = finger(trigger); + press.down(); + advance(contextMenu.press.menuDisplayMs); + const lift = press.up(); + // A cancelled touchend is the one signal that stops the browser's click + // and its compatibility mousedown, which would read as a press outside. + expect(lift.defaultPrevented).toBe(true); + expect(screen.getByRole('menu', {hidden: true})).toBeInTheDocument(); + expect(HTMLElement.prototype.hidePopover).not.toHaveBeenCalled(); + }); + + it('still opens at 735 ms beside a consumer onTouchStart, which runs (#7167)', () => { + const onTouchStart = vi.fn(); + const trigger = renderMenu({onTouchStart}); + const press = finger(trigger); + press.down(); + expect(onTouchStart).toHaveBeenCalledTimes(1); + advance(contextMenu.press.menuDisplayMs - 1); + expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + advance(1); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + press.up(); + }); + + it('opens nothing for an early lift whose consumer onTouchEnd prevents default (#7167)', () => { + const onTouchEnd = vi.fn((event: React.TouchEvent) => { + event.preventDefault(); + }); + const trigger = renderMenu({onTouchEnd}); + const press = finger(trigger); + press.down(); + advance(200); + press.up(); + expect(onTouchEnd).toHaveBeenCalledTimes(1); + advance(1000); + expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + }); + + it('moves the recognizer to the new trigger when triggerAs swaps it: a hold on the new node opens the menu', () => { + const {rerender} = render( + +
Long-press me
+
, + ); + rerender( event.preventDefault()}> -
Long-press me
+ triggerAs="span"> + Long-press me
, ); + const press = finger(screen.getByTestId('ctx')); + press.down(); + advance(contextMenu.press.menuDisplayMs); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + press.up(); + }); - const trigger = screen.getByTestId('ctx'); - fireEvent.touchStart(trigger, { - touches: [{clientX: 20, clientY: 20}], + it('opens nothing for a press lifted before the commit, and leaves its click alone', () => { + const trigger = renderMenu(); + const press = finger(trigger); + press.down(); + advance(250); + const lift = press.up(); + expect(lift.defaultPrevented).toBe(false); + advance(1000); + expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + }); + + it('cancels when the finger drifts past the tolerance on one axis (11.5 px)', () => { + const trigger = renderMenu(); + const press = finger(trigger, 1, 20, 20); + press.down(); + advance(50); + press.moveTo(20, 31.6); + advance(1000); + expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + press.up(); + }); + + it('still opens after a drift inside the tolerance', () => { + const trigger = renderMenu(); + const press = finger(trigger, 1, 20, 20); + press.down(); + advance(50); + press.moveTo(31, 31); // 15.6 px away, 11 px on each axis + advance(contextMenu.press.menuDisplayMs); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + press.up(); + }); + + it('cancels when a second finger lands before the commit', () => { + const trigger = renderMenu(); + const first = {id: 1, x: 20, y: 20}; + const second = {id: 2, x: 60, y: 20}; + dispatchTouch('touchstart', trigger, [first], [first]); + advance(100); + dispatchTouch('touchstart', trigger, [second], [first, second]); + advance(1000); + expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + dispatchTouch('touchend', trigger, [first, second], []); + }); + + it('lets the long press decide while a finger is down: the browser’s own long-press menu opens nothing', () => { + const trigger = renderMenu(); + const press = finger(trigger); + press.down(); + advance(380); + // Android Chrome fires contextmenu for a held finger on its own clock. + const native = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 20, }); - fireEvent.touchEnd(trigger); + trigger.dispatchEvent(native); + expect(native.defaultPrevented).toBe(true); + expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + advance(contextMenu.press.menuDisplayMs - 380); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + press.up(); + }); + + it('plays the click haptic at the commit where the platform vibrates, and none for an early lift', () => { + const vibrate = vi.fn(() => true); + Object.defineProperty(navigator, 'vibrate', { + value: vibrate, + configurable: true, + }); + try { + const trigger = renderMenu(); + const early = finger(trigger); + early.down(); + advance(250); + early.up(); + advance(500); + expect(vibrate).not.toHaveBeenCalled(); + + const held = finger(trigger); + held.down(); + advance(contextMenu.press.commitMs - 1); + expect(vibrate).not.toHaveBeenCalled(); + advance(1); + // At the commit, before the menu shows. + expect(vibrate).toHaveBeenCalledTimes(1); + expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + expect(vibrate).toHaveBeenCalledWith([ + ...HAPTIC_VIBRATION_PATTERNS[contextMenu.commitHaptic].value, + ]); + held.up(); + } finally { + // jsdom has no vibration API of its own. + delete (navigator as {vibrate?: unknown}).vibrate; + } + }); + it('leaves the page free to scroll while a menu a finger opened is up; a touch outside closes it as it begins to pan', () => { + const trigger = renderMenu(); + const press = finger(trigger); + press.down(); + advance(contextMenu.press.commitMs); + // Lifted after the commit: the menu opens at the lift. act(() => { - vi.advanceTimersByTime(500); + press.up(); + }); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + expect(document.body.style.position).toBe(''); + const outside = {pointerType: 'touch', pointerId: 9, clientX: 200}; + act(() => { + fireEvent.pointerDown(document.body, {...outside, clientY: 200}); + fireEvent.pointerMove(document.body, {...outside, clientY: 195}); + }); + expect(HTMLElement.prototype.hidePopover).not.toHaveBeenCalled(); + act(() => { + fireEvent.pointerMove(document.body, {...outside, clientY: 180}); }); + expect(HTMLElement.prototype.hidePopover).toHaveBeenCalled(); + }); + + it('leaves the page alone for a menu a right-click opened', () => { + const trigger = renderMenu(); + fireEvent.contextMenu(trigger, {clientX: 20, clientY: 20}); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + expect(document.body.style.position).toBe(''); + }); + + it('passes touch events through to the consumer’s own handlers', () => { + const onTouchStart = vi.fn(); + const onTouchEnd = vi.fn(); + const trigger = renderMenu({onTouchStart, onTouchEnd}); + const press = finger(trigger); + press.down(); + press.up(); + expect(onTouchStart).toHaveBeenCalledTimes(1); + expect(onTouchEnd).toHaveBeenCalledTimes(1); + }); + + it('keeps today’s menu with presentation="popover": a held finger opens it at 500 ms through today’s long press, with no growth and no scroll lock', () => { + const trigger = renderMenu({presentation: 'popover'}); + const shows = () => + vi.mocked(HTMLElement.prototype.showPopover).mock.calls.length; + expect(trigger).not.toHaveAttribute('data-astryx-pressable'); + const press = finger(trigger); + press.down(); + advance(499); + expect(shows()).toBe(0); + advance(1); + expect(shows()).toBe(1); + // Today’s menu: the first row takes the focus, nothing grows, the page + // is not locked. + expect(trigger.style.getPropertyValue('scale')).toBe(''); + expect(document.body.style.overflow).not.toBe('hidden'); + press.up(); + // The browser’s own long-press menu (Android Chrome) still opens it. + fireEvent.contextMenu(trigger, {clientX: 20, clientY: 20, detail: 1}); + expect(shows()).toBeGreaterThanOrEqual(1); + }); + + it('with presentation="popover", a drift past 10 px or a lift before 500 ms opens nothing (today’s long press)', () => { + const trigger = renderMenu({presentation: 'popover'}); + const shows = () => + vi.mocked(HTMLElement.prototype.showPopover).mock.calls.length; + let press = finger(trigger); + press.down(); + advance(300); + press.up(); + advance(400); + expect(shows()).toBe(0); + press = finger(trigger); + press.down(); + press.moveTo(31, 20); + advance(600); + expect(shows()).toBe(0); + press.up(); + }); + + it('opens nothing on touch when disabled', () => { + const trigger = renderMenu({isDisabled: true}); + const press = finger(trigger); + press.down(); + advance(1000); expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } + press.up(); + }); + }, +); + +describe('ContextMenu under a fine pointer: the touch model costs nothing', () => { + beforeEach(() => { + stubPointer(false); + vi.useFakeTimers({ + toFake: [ + 'setTimeout', + 'clearTimeout', + 'setInterval', + 'clearInterval', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'performance', + 'Date', + ], + }); + clockEpoch += 100_000; + vi.advanceTimersByTime(clockEpoch); + }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); }); - it('suppresses native text selection on the long-press trigger', () => { - const source = readFileSync( - 'packages/core/src/ContextMenu/ContextMenu.tsx', - 'utf8', + it('opens today’s menu for a finger held 500 ms (today’s long press), with no touch model: no growth, no scroll lock, the first row focused', () => { + // A fine pointer in this context, before any check. + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + const onTouchStart = vi.fn(); + render( + +
Right-click me
+
, ); + const trigger = screen.getByTestId('ctx'); + expect(trigger).not.toHaveAttribute('data-astryx-pressable'); + const press = finger(trigger); + press.down(); + expect(onTouchStart).toHaveBeenCalledTimes(1); + advance(499); + expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + advance(1); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + expect(trigger.style.getPropertyValue('scale')).toBe(''); + expect(document.body.style.overflow).not.toBe('hidden'); + advance(20); + expect( + screen.getByRole('menuitem', {name: 'Item 1', hidden: true}), + ).toHaveFocus(); + press.up(); + }); - expect(source).toMatch( - /trigger:[\s\S]*?WebkitTouchCallout: 'none',[\s\S]*?WebkitUserSelect: 'none',[\s\S]*?userSelect: 'none'/, + it('does not hand the finger that opened the menu on to it: a slide onto a row and a lift pick nothing', () => { + const onClick = vi.fn(); + render( + +
Right-click me
+
, ); + const trigger = screen.getByTestId('ctx'); + const press = finger(trigger); + press.down(); + advance(500); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + const row = screen.getByRole('menuitem', {name: 'Item 1', hidden: true}); + const box = row.getBoundingClientRect(); + press.moveTo(box.left + 4, box.top + 4); + press.up(); + advance(50); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('opens the cursor menu on a right-click, as before', () => { + render( + +
Right-click me
+
, + ); + const trigger = screen.getByTestId('ctx'); + fireEvent.contextMenu(trigger, {clientX: 20, clientY: 20, detail: 1}); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + advance(20); + expect( + screen.getByRole('menuitem', {name: 'Item 1', hidden: true}), + ).toHaveFocus(); }); +}); - it('cancels the long-press when the finger moves past the threshold', () => { - vi.useFakeTimers(); - try { - render( +describe('ContextMenu: the primary pointer alone picks a finger’s rules (spec:AST-094 FR13, FR33)', () => { + beforeEach(() => { + vi.useFakeTimers({ + toFake: [ + 'setTimeout', + 'clearTimeout', + 'setInterval', + 'clearInterval', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'performance', + 'Date', + ], + }); + clockEpoch += 100_000; + vi.advanceTimersByTime(clockEpoch); + }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('opens for the same held finger at 735 ms where the primary pointer is coarse, and at 500 ms where it is fine', () => { + const shows = () => + vi.mocked(HTMLElement.prototype.showPopover).mock.calls.length; + const renderTrigger = () => { + const view = render( -
Long-press me
+
Hold me
, ); - const trigger = screen.getByTestId('ctx'); - fireEvent.touchStart(trigger, {touches: [{clientX: 20, clientY: 20}]}); - // Move past MOVE_CANCEL_PX (10px) — treated as a scroll, not a press. - fireEvent.touchMove(trigger, {touches: [{clientX: 20, clientY: 40}]}); - act(() => { - vi.advanceTimersByTime(500); - }); - expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } + return {trigger: screen.getByTestId('ctx'), unmount: view.unmount}; + }; + // Coarse: the touch model's timeline, no menu at 500 ms. + stubPointer(true); + const coarse = renderTrigger(); + let press = finger(coarse.trigger); + press.down(); + advance(600); + expect(shows()).toBe(0); + advance(contextMenu.press.menuDisplayMs - 600); + expect(shows()).toBe(1); + press.up(); + coarse.unmount(); + vi.mocked(HTMLElement.prototype.showPopover).mockClear(); + // Fine: today's long press, the same finger opens at 500 ms. + stubPointer(false); + const fine = renderTrigger(); + press = finger(fine.trigger); + press.down(); + advance(499); + expect(shows()).toBe(0); + advance(1); + expect(shows()).toBe(1); + press.up(); }); }); @@ -1632,6 +2161,16 @@ describe('ContextMenu inline trigger (triggerAs)', () => { describe('ContextMenu outside dismissal', () => { const finger = (pointerId: number) => ({pointerType: 'touch', pointerId}); + // Under the touch model, a coarse primary pointer. Undone at the end. + function underTouchModel(run: () => void) { + stubPointer(true); + try { + run(); + } finally { + vi.unstubAllGlobals(); + } + } + // Holds a finger on the trigger until the long-press opens the menu. Only // the long-press delay is faked. function longPress(trigger: HTMLElement, pointerId: number) { @@ -1724,7 +2263,25 @@ describe('ContextMenu outside dismissal', () => { expect(onOpenChange).not.toHaveBeenCalledWith(false); }); - it('a tap outside closes the menu through its compatibility mousedown; a scroll that starts outside leaves it open', () => { + it('under the touch model, a tap outside closes the menu at its lift, and a scroll that starts outside closes it as the browser takes the finger', () => { + underTouchModel(() => { + const onOpenChange = vi.fn<(isOpen: boolean) => void>(); + const {trigger, outside} = renderMenu(onOpenChange); + fireEvent.contextMenu(trigger, {clientX: 50, clientY: 15, detail: 1}); + // The browser takes this finger for a scroll: no compatibility events. + fireEvent.pointerDown(outside, finger(3)); + expect(onOpenChange).toHaveBeenLastCalledWith(true); + fireEvent.pointerCancel(outside, finger(3)); + expect(onOpenChange).toHaveBeenLastCalledWith(false); + fireEvent.contextMenu(trigger, {clientX: 50, clientY: 15, detail: 1}); + fireEvent.pointerDown(outside, finger(4)); + expect(onOpenChange).toHaveBeenLastCalledWith(true); + lift(outside, 4); + expect(onOpenChange).toHaveBeenLastCalledWith(false); + }); + }); + + it('outside the touch model, a tap outside closes the menu through its compatibility mousedown, and a scroll that starts outside leaves it open', () => { const onOpenChange = vi.fn<(isOpen: boolean) => void>(); const {trigger, outside} = renderMenu(onOpenChange); fireEvent.contextMenu(trigger, {clientX: 50, clientY: 15, detail: 1}); @@ -1737,6 +2294,37 @@ describe('ContextMenu outside dismissal', () => { expect(onOpenChange).toHaveBeenLastCalledWith(false); }); + it('with presentation="popover", a finger keeps the released outside dismissal: a scroll or a drag outside leaves the menu open', () => { + underTouchModel(() => { + const onOpenChange = vi.fn<(isOpen: boolean) => void>(); + render( + <> + +
Long-press me
+
+ + , + ); + const outside = screen.getByRole('button', {name: 'Outside'}); + fireEvent.contextMenu(screen.getByTestId('ctx'), { + clientX: 50, + clientY: 15, + detail: 1, + }); + fireEvent.pointerDown(outside, {...finger(3), clientX: 0, clientY: 0}); + fireEvent.pointerCancel(outside, finger(3)); + fireEvent.pointerDown(outside, {...finger(4), clientX: 0, clientY: 0}); + fireEvent.pointerMove(outside, {...finger(4), clientX: 0, clientY: 40}); + fireEvent.pointerUp(outside, finger(4)); + expect(onOpenChange).toHaveBeenLastCalledWith(true); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + }); + }); + it('a mouse press or a pen press outside closes the menu at the press', () => { for (const pointerType of ['mouse', 'pen']) { const onOpenChange = vi.fn<(isOpen: boolean) => void>(); @@ -1749,7 +2337,7 @@ describe('ContextMenu outside dismissal', () => { } }); - it('presses that send no mousedown leave the menu open, as before: a cancelled pointerdown, a finger drag, a second finger', () => { + it('a mouse press that sends no mousedown leaves the menu open, as before', () => { const onOpenChange = vi.fn<(isOpen: boolean) => void>(); const {trigger, outside} = renderMenu(onOpenChange); fireEvent.contextMenu(trigger, {clientX: 50, clientY: 15, detail: 1}); @@ -1757,18 +2345,48 @@ describe('ContextMenu outside dismissal', () => { // resize handle): the browser sends no mousedown. fireEvent.pointerDown(outside, {pointerType: 'mouse', pointerId: 1}); fireEvent.pointerUp(outside, {pointerType: 'mouse', pointerId: 1}); - // A finger drag on a surface that does not pan (a Slider), lifted. - fireEvent.pointerDown(outside, finger(5)); - fireEvent.pointerMove(outside, finger(5)); - fireEvent.pointerUp(outside, finger(5)); - // A second finger's tap. - fireEvent.pointerDown(outside, finger(6)); - fireEvent.pointerDown(outside, finger(7)); - fireEvent.pointerUp(outside, finger(7)); - fireEvent.pointerCancel(outside, finger(6)); expect(onOpenChange).toHaveBeenLastCalledWith(true); expect(onOpenChange).not.toHaveBeenCalledWith(false); }); + + it('under the touch model, a finger outside closes the menu as it pans past the pan threshold, or at its lift; a second finger waits for the first one', () => { + underTouchModel(() => { + const onOpenChange = vi.fn<(isOpen: boolean) => void>(); + const {trigger, outside} = renderMenu(onOpenChange); + const open = () => + fireEvent.contextMenu(trigger, {clientX: 50, clientY: 15, detail: 1}); + // A drag on a surface that does not pan (a Slider): it closes the menu + // as it travels past the pan threshold, before the lift. + open(); + fireEvent.pointerDown(outside, {...finger(5), clientX: 0, clientY: 0}); + fireEvent.pointerMove(outside, {...finger(5), clientX: 8, clientY: 0}); + expect(onOpenChange).toHaveBeenLastCalledWith(true); + fireEvent.pointerMove(outside, {...finger(5), clientX: 30, clientY: 0}); + expect(onOpenChange).toHaveBeenLastCalledWith(false); + fireEvent.pointerUp(outside, finger(5)); + // A second finger's tap does not end the first finger's touch. + open(); + fireEvent.pointerDown(outside, finger(6)); + fireEvent.pointerDown(outside, finger(7)); + fireEvent.pointerUp(outside, finger(7)); + expect(onOpenChange).toHaveBeenLastCalledWith(true); + fireEvent.pointerUp(outside, finger(6)); + expect(onOpenChange).toHaveBeenLastCalledWith(false); + }); + }); + + it('under the touch model, a touch on the menu itself leaves it open', () => { + underTouchModel(() => { + const onOpenChange = vi.fn<(isOpen: boolean) => void>(); + const {trigger} = renderMenu(onOpenChange); + fireEvent.contextMenu(trigger, {clientX: 50, clientY: 15, detail: 1}); + const menu = screen.getByRole('menu', {hidden: true}); + fireEvent.pointerDown(menu, {...finger(8), clientX: 0, clientY: 0}); + fireEvent.pointerMove(menu, {...finger(8), clientX: 0, clientY: 40}); + fireEvent.pointerUp(menu, finger(8)); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + }); + }); }); describe('ContextMenu: Tab from a flyout leaves the whole menu (APG menu button)', () => { diff --git a/packages/core/src/ContextMenu/ContextMenu.tsx b/packages/core/src/ContextMenu/ContextMenu.tsx index b3b1536ec9e11..d59a02ab30a90 100644 --- a/packages/core/src/ContextMenu/ContextMenu.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.tsx @@ -4,7 +4,8 @@ /** * @file ContextMenu.tsx * @input Uses React, StyleX, useLayer, shared layer dismissal, BottomSheet, - * useListFocus, and the shared menu-presentation resolver + * useListFocus, the presentation resolver (usePresentation), useLongPress, + * and the touch layer (ContextMenuTouchLayer) * @output Exports ContextMenu with cursor-popover and touch-sheet presentations * @position Core implementation; consumed by index.ts * @@ -35,6 +36,8 @@ * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/ContextMenu/ContextMenu.doc.mjs * - /packages/core/src/ContextMenu/ContextMenu.test.tsx + * - /packages/core/src/ContextMenu/ContextMenuTouch.ts (the touch layer's one import site) + * - /packages/core/src/ContextMenu/contextMenuPress.ts (touch timeline) * - /packages/core/src/ContextMenu/index.ts * - /apps/storybook/stories/ContextMenu.stories.tsx * - /packages/cli/assets/templates/blocks/components/ContextMenu/ (showcase blocks) @@ -42,7 +45,6 @@ import React, { useCallback, - useEffect, useId, lazy, useMemo, @@ -59,7 +61,7 @@ import {useLayerInternal} from '../Layer/useLayer'; import {LayerDepthProvider} from '../Layer/LayerDepthContext'; import {useLayerDismissal} from '../Layer/useLayerDismissal'; import {currentGesture} from '../Layer/gestureCounter'; -import {noteOutsideDismissal} from '../Layer/outsidePress'; +import {useOutsidePress} from '../Layer/outsidePress'; import {MenuBottomSheetActionList} from '../DropdownMenu/MenuBottomSheetActionList'; import {menuBottomSheetStyles} from '../DropdownMenu/menuBottomSheet.stylex'; import {renderDropdownItems} from '../DropdownMenu/renderDropdownItems'; @@ -77,9 +79,15 @@ import { import {useMenuOverflow} from '../DropdownMenu/useMenuOverflow'; import {useMenuDrillIn} from '../DropdownMenu/useMenuDrillIn'; import {useInternalListFocus} from '../hooks/useListFocus'; +import {useLongPress} from '../hooks/useLongPress'; import {useMenuPress} from '../hooks/useMenuPress'; import {useTypeahead} from '../hooks/useTypeahead'; -import {useLongPress} from '../hooks/useLongPress'; +import {shadowHostOf} from '../utils/flatTree'; +import {usePresentation} from '../utils/presentation'; +import { + ContextMenuTouchLayer, + type ContextMenuTouchHandle, +} from './ContextMenuTouchLayer'; import {layerAnimations} from '../Layer/layerAnimations.stylex'; import {layerViewportInset} from '../Layer/layerViewportInset.stylex'; import { @@ -102,10 +110,7 @@ import type { } from '../DropdownMenu/DropdownMenu'; import {useMergedRefs} from '../hooks/useMergedRefs'; -import { - useAdaptivePresentation, - type AdaptivePresentation, -} from '../hooks/useAdaptivePresentation'; +import type {AdaptivePresentation} from '../hooks/useAdaptivePresentation'; import { registerEnglishFallback, useComponentTranslator, @@ -120,11 +125,12 @@ const LazyMenuBottomSheet = lazy(async () => })), ); const styles = stylex.create({ - // Trigger wrapper: suppress the iOS long-press callout/selection so the - // long-press opens our context menu instead of the native text/callout UI. - // `position: relative` establishes the containing block for the absolutely - // positioned cursor anchor below, so the anchor point tracks the trigger - // (and scrolls with it) instead of the page. + // Trigger wrapper. `position: relative` makes the containing block for the + // absolutely positioned cursor anchor below. So the anchor point follows + // the trigger, and scrolls with it, instead of the page. The iOS callout and + // text selection stay off. A long press or a right-click then opens this + // menu instead of selecting the trigger's text. The + // gesture host adds its own lock for a held press. trigger: { position: 'relative', WebkitTouchCallout: 'none', @@ -167,6 +173,11 @@ const styles = stylex.create({ transitionTimingFunction: easeVars['--ease-standard'], userSelect: 'none', WebkitTouchCallout: 'none', + // A touch open focuses the menu itself, so no row is lit until the finger + // moves onto one, as with DropdownMenu's held finger. The menu is an + // internal focus target, not a control, so it shows no browser ring. + // Keyboard focus moves to an item instead. + outline: 'none', }, // Scroll ownership by the browser's own signal; see DropdownMenu. touchPinchZoom: { @@ -284,11 +295,13 @@ interface ContextMenuBaseProps extends BaseProps { onOpenChange?: (isOpen: boolean) => void; /** * Presentation policy for the menu. - * - `popover`: open beside the pointer position. + * - omitted: the menu at the pointer. Under a finger, a held finger opens it + * on iOS's timeline (the touch model). + * - `popover`: the menu at the pointer on every device, as before. A held + * finger opens it at 500 ms through today's long press. * - `bottom-sheet`: open as an action sheet. * - `adaptive`: use a BottomSheet on compact coarse-pointer viewports and * the cursor-positioned popover elsewhere. - * @default 'popover' */ presentation?: AdaptivePresentation; 'data-testid'?: string; @@ -342,7 +355,7 @@ export function ContextMenu({ label: labelFromProps, isDisabled = false, onOpenChange, - presentation = 'popover', + presentation, triggerAs = 'div', ref, className, @@ -360,8 +373,12 @@ export function ContextMenu({ const t = useComponentTranslator(); const label = labelFromProps ?? t('@astryx.contextMenu.label'); const backLabel = t('@astryx.dropdownMenu.back'); - const resolvedPresentation = useAdaptivePresentation(presentation); - const usesBottomSheet = resolvedPresentation === 'bottom-sheet'; + // The surface, and whether the touch model applies: under a finger, unless + // the caller pins `presentation="popover"`. + const {surface, touchModes} = usePresentation(presentation, { + whenOmitted: 'touch', + }); + const usesBottomSheet = surface === 'bottom-sheet'; // Separate content props (union discriminant) from DOM pass-through attrs. // The union means exactly one of items/menuContent exists in rest; destructure // both so triggerProps contains only DOM-safe attributes. @@ -395,15 +412,20 @@ export function ContextMenu({ const [isOpen, setIsOpen] = useState(false); // An open from the trigger moves focus into the menu once it shows: a frame - // after the show, which for a first open comes as its layer attaches. - const focusOnShowRef = useRef(false); + // after the show, which for a first open comes as its layer attaches. A + // touch open focuses the menu itself; other opens focus its first row. + const focusOnShowRef = useRef<'first' | 'menu' | null>(null); const focusFirstRef = useRef<() => void>(() => {}); + const focusMenuRef = useRef<() => void>(() => {}); const [submenuPath, setSubmenuPath] = useState([]); const currentSubmenu = submenuPath.at(-1); const currentItems = currentSubmenu?.items ?? items; const currentTitle = currentSubmenu?.label ?? label; const sheetLabel = typeof currentTitle === 'string' ? currentTitle : label; + // The mounted touch layer, which says whether it times a finger. + const touchHandleRef = useRef(null); + const updateOpenState = useCallback( (nextIsOpen: boolean) => { if (!nextIsOpen) { @@ -415,12 +437,17 @@ export function ContextMenu({ [onOpenChange], ); + const outsidePress = useOutsidePress(); + // Starts the outside-press watch as the menu shows. It is set below, where + // the menu's list and its close exist. + const watchOutsideRef = useRef<() => void>(() => {}); const layer = useLayerInternal({ mode: 'context', // Mounted at the first open and kept (see the file header). lazyMount: true, keepsMountAfterShow: true, onHide: useCallback(() => { + outsidePress.stop(); setIsOpen(false); onOpenChange?.(false); // Restore focus to the element that was focused before opening. @@ -429,13 +456,19 @@ export function ContextMenu({ if (toRestore && document.contains(toRestore)) { toRestore.focus(); } - }, [onOpenChange]), + }, [onOpenChange, outsidePress]), onShow: useCallback(() => { setIsOpen(true); onOpenChange?.(true); - if (focusOnShowRef.current) { - focusOnShowRef.current = false; - requestAnimationFrame(() => focusFirstRef.current()); + watchOutsideRef.current(); + const focusTarget = focusOnShowRef.current; + if (focusTarget != null) { + focusOnShowRef.current = null; + requestAnimationFrame(() => + focusTarget === 'menu' + ? focusMenuRef.current() + : focusFirstRef.current(), + ); } }, [onOpenChange]), lightDismiss: false, @@ -489,6 +522,7 @@ export function ContextMenu({ focusFollowsLastInput: true, }); focusFirstRef.current = focusFirst; + focusMenuRef.current = () => listRef.current?.focus({preventScroll: true}); // Join the shared stack so an Escape press dismisses exactly one top-most // surface. BottomSheet owns its own dismissal path. @@ -511,44 +545,36 @@ export function ContextMenu({ ), }); - // Dismiss on any click outside the menu. We use popover="manual" (not - // "auto") because the native light-dismiss treats the mouseup from the - // opening right-click as a dismiss event. Handling it ourselves via - // mousedown avoids that race. A touch's compatibility mousedown for the - // press that opened the menu (sent mid-hold by iOS, or as a long press - // lifts by Chromium) belongs to that press's gesture, so it is not read as - // a press outside; any later press is a new gesture and closes as before. - useEffect(() => { - if (!isOpen || usesBottomSheet) { + // The menu is a manual popover: native light dismiss would read the mouseup + // of the opening right-click as a press outside. So one watch closes it. A + // mouse or a pen closes it at the press. Under the touch model, a touch + // closes it as it begins to pan, or at its lift, and the page stays free to + // scroll. Otherwise a touch closes it at its compatibility mousedown. The + // press that opened the menu keeps it open. + watchOutsideRef.current = () => { + const trigger = triggerRef.current; + if (trigger == null) { return; } - // Inside a shadow root, even a closed or nested one, the document sees a - // press on the menu as a press on an outer shadow host, so the menu's own - // tree judges its presses first and the document skips those. - const tree = listRef.current?.getRootNode(); - const shadow = tree instanceof ShadowRoot ? tree : null; - const judged = new WeakSet(); - const handleClickOutside = (e: Event) => { - const menu = listRef.current; - if (judged.has(e)) { - return; - } - judged.add(e); - if (currentGesture() === openingGestureRef.current) { - return; - } - if (menu && !menu.contains(e.target as Node)) { - noteOutsideDismissal(menu.ownerDocument); - closeMenu(); - } - }; - document.addEventListener('mousedown', handleClickOutside); - shadow?.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - shadow?.removeEventListener('mousedown', handleClickOutside); - }; - }, [isOpen, closeMenu, listRef, usesBottomSheet]); + // The menu mounts after its first show, so each press reads it then. It + // renders in the trigger's own root, which may be another window's. + const root = trigger.getRootNode(); + outsidePress.start( + trigger, + path => listRef.current != null && path.includes(listRef.current), + () => { + if (currentGesture() !== openingGestureRef.current) { + closeMenu(); + } + }, + { + pointerTypes: touchModes ? ['touch'] : [], + closesOnPan: touchModes, + pressTypes: touchModes ? ['mouse', 'pen'] : ['mouse', 'pen', 'touch'], + root: shadowHostOf(root) != null ? root : null, + }, + ); + }; const listKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -603,7 +629,15 @@ export function ContextMenu({ // what makes the menu context-relative: it scrolls with the content and the // browser auto-flips it against the viewport edges via CSS anchor positioning. const openAtLocalPoint = useCallback( - (localX: number, localY: number, focusEl: HTMLElement | null) => { + ( + localX: number, + localY: number, + focusEl: HTMLElement | null, + // A touch open focuses the menu itself: UIKit lights no row until the + // finger moves onto one. Pointer and keyboard opens focus the first + // item (APG menu pattern); arrow keys reach the items from the menu. + focusTarget: 'first' | 'menu' = 'first', + ) => { const rect = triggerRef.current?.getBoundingClientRect(); const x = rect ? keepAnchorInside(localX, rect.width) : localX; const y = rect ? keepAnchorInside(localY, rect.height) : localY; @@ -625,7 +659,7 @@ export function ContextMenu({ // A first open mounts the layer, and the show runs from the popover // element's callback ref as it attaches, in the same commit, before // the browser paints; a later one shows at once. - focusOnShowRef.current = true; + focusOnShowRef.current = focusTarget; layer.show(); } }, @@ -639,6 +673,22 @@ export function ContextMenu({ [], ); + // A client point in the trigger's own coordinates, where the cursor anchor + // lives. It stays inside the trigger's box. + const toTriggerLocal = useCallback( + (point: {x: number; y: number}): {x: number; y: number} => { + const rect = triggerRef.current?.getBoundingClientRect(); + if (rect == null) { + return point; + } + return { + x: Math.min(Math.max(point.x - rect.left, 0), rect.width), + y: Math.min(Math.max(point.y - rect.top, 0), rect.height), + }; + }, + [], + ); + const handleContextMenu = useCallback( (e: React.MouseEvent) => { if (isDisabled) { @@ -670,19 +720,33 @@ export function ContextMenu({ [isDisabled, openAtLocalPoint, getAnchorBaseRect], ); - // Touch long-press invocation (menus-8). iOS Safari never synthesizes a - // `contextmenu` event on long-press, so a context menu is otherwise - // unreachable on touch. Open the menu at the touch point once the press is - // held long enough (see useLongPress for timer/move-cancel/cleanup logic). - // The hook also returns the imperative start/moveTo/cancel API the menu press - // model drives; only its four touch handlers belong on the trigger element. + // Touch invocation. Under the touch model, the touch layer runs UIKit's long + // press and asks for the menu here, at the client point where the finger + // landed. + const openFromFinger = useCallback( + (point: {x: number; y: number}) => { + const local = toTriggerLocal(point); + // The finger is still down: its lift belongs to the press that opened + // the menu, so it does not close it. + openingGestureRef.current = currentGesture(); + openAtLocalPoint(local.x, local.y, triggerRef.current, 'menu'); + }, + [openAtLocalPoint, toTriggerLocal], + ); + + // Today's long press for a finger outside the touch model: under a fine + // primary pointer, or with `presentation="popover"`, the way back. Safari on + // iOS never sends a `contextmenu` event for a long press. So the menu opens + // at the touch point once the finger is held for 500 ms. The useLongPress + // hook owns the timer, the move cancel and the cleanup. Under the touch + // model, the touch layer's recognizer owns the finger instead. const { onTouchStart: handleLongPressTouchStart, onTouchMove: handleLongPressTouchMove, onTouchEnd: handleLongPressTouchEnd, onTouchCancel: handleLongPressTouchCancel, } = useLongPress({ - disabled: isDisabled, + disabled: isDisabled || touchModes, onLongPress: useCallback( (point: {x: number; y: number}) => { const rect = getAnchorBaseRect(); @@ -698,17 +762,6 @@ export function ContextMenu({ [openAtLocalPoint, getAnchorBaseRect], ), }); - - // ContextMenu owns invocation and long-press cleanup. Consumer handlers are - // notified too, but preventing their default does not cancel component-owned - // behavior that the public API does not promise as cancelable. - const handleTriggerContextMenu = useCallback( - (event: React.MouseEvent) => { - handleContextMenu(event); - onContextMenuProp?.(event); - }, - [handleContextMenu, onContextMenuProp], - ); const handleTriggerTouchStart = useCallback( (event: React.TouchEvent) => { handleLongPressTouchStart(event); @@ -738,6 +791,23 @@ export function ContextMenu({ [handleLongPressTouchCancel, onTouchCancelProp], ); + // ContextMenu owns invocation. Consumer handlers are notified too, but + // preventing their default does not cancel component-owned behavior that + // the public API does not promise as cancelable. + const handleTriggerContextMenu = useCallback( + (event: React.MouseEvent) => { + if (touchHandleRef.current?.isTracking() === true) { + // The browser's own long-press menu (Android Chrome) for a finger the + // recognizer is timing: the recognizer decides, not this event. + event.preventDefault(); + } else { + handleContextMenu(event); + } + onContextMenuProp?.(event); + }, + [handleContextMenu, onContextMenuProp], + ); + const popoverXstyle = menuWidth ? styles.popoverCustomWidth(menuWidth) : styles.popover; @@ -903,6 +973,13 @@ export function ContextMenu({ })} /> + {touchModes && !isDisabled ? ( + + ) : null} {usesBottomSheet ? ( diff --git a/packages/core/src/ContextMenu/ContextMenuTouch.ts b/packages/core/src/ContextMenu/ContextMenuTouch.ts new file mode 100644 index 0000000000000..f8f22f0b96c95 --- /dev/null +++ b/packages/core/src/ContextMenu/ContextMenuTouch.ts @@ -0,0 +1,17 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file ContextMenuTouch.ts + * @input ContextMenu's touch modules + * @output ContextMenu's touch layer, its one import site: the press + * recognizer (contextMenuPress.ts) + * @position ContextMenuTouchLayer.tsx, which ContextMenu renders under the + * touch model, imports its touch behaviour from here only; the mouse and + * keyboard paths stay in ContextMenu.tsx + * + * SYNC: When modified, update /packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx + */ + +export {ContextMenuPressRecognizer} from './contextMenuPress'; diff --git a/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx b/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx new file mode 100644 index 0000000000000..9650067be3297 --- /dev/null +++ b/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx @@ -0,0 +1,99 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file ContextMenuTouchLayer.tsx + * @input ContextMenu's touch layer (ContextMenuTouch.ts), the gesture + * foundation and the UIKit context-menu constants + * @output ContextMenuTouchLayer: renders nothing; runs the trigger's press + * recognizer while ContextMenu is under the touch model + * @position Rendered by ContextMenu while the touch model applies + * + * SYNC: When modified, update /packages/core/src/ContextMenu/ContextMenu.tsx + */ + +import {useImperativeHandle, useRef, type Ref, type RefObject} from 'react'; +import {playHaptic} from '../gestures/haptics'; +import {useGestureRecognizer} from '../gestures/useGestureRecognizer'; +import {contextMenu} from '../motion/uikit/contextMenu'; +import {ContextMenuPressRecognizer} from './ContextMenuTouch'; + +/** What ContextMenu reaches of a mounted touch layer. */ +export interface ContextMenuTouchHandle { + /** Whether the recognizer is timing a finger on the trigger. */ + isTracking(): boolean; +} + +export interface ContextMenuTouchLayerProps { + triggerRef: RefObject; + /** Receives the layer's handle as it mounts, and null as it unmounts. */ + ref?: Ref; + /** Open the menu at a client point (where the finger landed). */ + onOpen: (point: {x: number; y: number}) => void; +} + +/** + * UIContextMenuInteraction's long press. iOS Safari never fires `contextmenu` + * for a held finger. So the trigger runs its own recognizer on UIKit's + * timeline, `contextMenu.press`. At the commit, the haptic plays and the + * recognizer claims the finger. The page stops scrolling under it, and its + * lift sends no click and no compatibility mouse events. The menu opens where + * the finger landed: at `menuDisplayMs` if it is still down, or at the lift. + * A lift before the commit, a second finger or a scroll gets no menu. So does + * a drift past the tolerance before the menu shows. + */ +export function ContextMenuTouchLayer({ + triggerRef, + ref, + onOpen, +}: ContextMenuTouchLayerProps): null { + const onOpenRef = useRef(onOpen); + onOpenRef.current = onOpen; + const hasPressOpenedRef = useRef(false); + + // The gesture hook follows the element behind the ref, so a `triggerAs` + // swap moves the recognizer to the new node. + const recognizer = useGestureRecognizer( + triggerRef, + () => new ContextMenuPressRecognizer(), + press => { + const open = () => { + hasPressOpenedRef.current = true; + onOpenRef.current(press.press ?? press.location()); + }; + switch (press.state) { + case 'began': + hasPressOpenedRef.current = false; + // The click haptic, where a vibration motor is the only haptic. + // iOS Safari can play none here: no tap lands at the commit. + playHaptic(contextMenu.commitHaptic); + break; + case 'changed': + if (press.isMenuDue && !hasPressOpenedRef.current) { + open(); + } + break; + case 'ended': + if (!hasPressOpenedRef.current) { + open(); + } + break; + case 'possible': + case 'failed': + case 'cancelled': + break; + } + }, + ); + + useImperativeHandle( + ref, + () => ({ + isTracking: () => recognizer.numberOfTouches > 0, + }), + [recognizer], + ); + + return null; +} diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.a11y.chromium.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.a11y.chromium.spec.ts index d914ca03605f0..e3ceb579c81a6 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.a11y.chromium.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.a11y.chromium.spec.ts @@ -2,10 +2,12 @@ /** * @file ContextMenuLongPress.a11y.chromium.spec.ts - * @input The `Core/ContextMenu` Default story in a built Storybook and CDP - * touch input. + * @input The `Core/ContextMenu` Default and popover-presentation touch stories + * in a built Storybook, and CDP touch input. * @output Real-engine proof that the long press that opens a context menu * does not close it as the finger lifts, and that a tap outside still does. + * Under the touch model the lift sends no click. Under today's long press + * (`presentation="popover"`) its click arrives and acts on nothing. * @position Run with `pnpm test:a11y-contract`; jsdom cannot produce the * compatibility mouse events a real browser sends after a touch. */ @@ -35,6 +37,8 @@ test.afterAll(async () => { }); const STORY = 'core-contextmenu--default'; +// A finger keeps today's long press under `presentation="popover"`. +const POPOVER_STORY = 'core-contextmenu--touch-long-press-popover'; async function touchCdp(page: Page): Promise { const cdp = await page.context().newCDPSession(page); @@ -45,61 +49,88 @@ async function touchCdp(page: Page): Promise { return cdp; } -async function triggerCenter(page: Page): Promise<{x: number; y: number}> { - const box = await page.getByText('Right-click this area').boundingBox(); +async function triggerCenter( + page: Page, + label = 'Right-click this area', +): Promise<{x: number; y: number}> { + const box = await page.getByText(label).boundingBox(); if (box == null) { throw new Error('the trigger area has no rendered bounds'); } return {x: box.x + box.width / 2, y: box.y + box.height / 2}; } -/** - * Resolves once the browser has delivered the `click` that follows a touch - * (the last of its compatibility mouse events) and two frames have run, so - * anything that event closes has closed. - */ -async function armCompatClick(page: Page): Promise { +interface InputEntry { + type: 'click' | 'touchstart'; + /** For a `touchstart`: whether a menu was open as it arrived. */ + isMenuOpen?: boolean; +} + +/** Records every `click` and `touchstart` the page gets from now on, in order. */ +async function recordInput(page: Page): Promise { await page.evaluate(() => { - const w = window as unknown as {__compatClick?: Promise}; - w.__compatClick = new Promise(resolve => { - document.addEventListener( - 'click', - () => - requestAnimationFrame(() => requestAnimationFrame(() => resolve())), - {once: true, capture: true}, - ); - }); + const entries: {type: string; isMenuOpen?: boolean}[] = []; + (window as unknown as {__input?: typeof entries}).__input = entries; + document.addEventListener( + 'click', + () => { + entries.push({type: 'click'}); + }, + {capture: true}, + ); + document.addEventListener( + 'touchstart', + () => { + entries.push({ + type: 'touchstart', + // A closed menu can stay mounted, hidden. + isMenuOpen: + document.querySelector('[role="menu"]')?.checkVisibility() ?? false, + }); + }, + {capture: true}, + ); }); } -async function compatClickDelivered(page: Page): Promise { - await page.evaluate( - async () => - (window as unknown as {__compatClick?: Promise}).__compatClick, +async function recordedInput(page: Page): Promise { + return page.evaluate( + () => (window as unknown as {__input?: InputEntry[]}).__input ?? [], ); } -test('the long press that opens the menu leaves it open as the finger lifts', async ({ - page, -}) => { - await page.goto(`${storybook.origin}/iframe.html?id=${STORY}&viewMode=story`); +const nextFrame = async (page: Page) => + page.evaluate( + async () => + new Promise(resolve => requestAnimationFrame(() => resolve())), + ); + +/** + * Holds a finger on the trigger until the menu opens, lifts it, waits one + * frame, then taps outside. Returns the input recorded from the lift on: the + * outside tap's `touchstart` is the later event that bounds the lift's click. + */ +async function holdLiftThenTapOutside( + page: Page, + story: string, + label?: string, +): Promise { + await page.goto(`${storybook.origin}/iframe.html?id=${story}&viewMode=story`); + // Touch emulation makes the primary pointer coarse. const cdp = await touchCdp(page); - const center = await triggerCenter(page); + const center = await triggerCenter(page, label); await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{x: center.x, y: center.y, id: 1}], }); const menu = page.getByRole('menu'); await expect(menu).toBeVisible({timeout: 2000}); - await armCompatClick(page); + await recordInput(page); await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [], }); - await compatClickDelivered(page); - await expect(menu).toBeVisible(); - - // A tap outside still closes it. + await nextFrame(page); await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{x: 5, y: 5, id: 2}], @@ -108,7 +139,40 @@ test('the long press that opens the menu leaves it open as the finger lifts', as type: 'touchEnd', touchPoints: [], }); + // A tap outside still closes it. await expect(menu).toBeHidden(); + return recordedInput(page); +} + +/** The clicks before the outside tap, and whether the menu was open at it. */ +function beforeOutsideTap(entries: InputEntry[]) { + const tap = entries.findIndex(entry => entry.type === 'touchstart'); + expect(tap).toBeGreaterThanOrEqual(0); + return { + clicks: entries.slice(0, tap).filter(entry => entry.type === 'click') + .length, + isMenuOpen: entries[tap].isMenuOpen, + }; +} + +test('under the touch model, the long press that opens the menu leaves it open as the finger lifts, and the lift sends no click', async ({ + page, +}) => { + const entries = await holdLiftThenTapOutside(page, STORY); + expect(beforeOutsideTap(entries)).toEqual({clicks: 0, isMenuOpen: true}); +}); + +// The positive control: the same steps see the click that follows today's +// long press, so the check above can fail. +test('with presentation="popover", the click that follows the long press arrives before the outside tap and leaves the menu open', async ({ + page, +}) => { + const entries = await holdLiftThenTapOutside( + page, + POPOVER_STORY, + 'Hold for actions', + ); + expect(beforeOutsideTap(entries)).toEqual({clicks: 1, isMenuOpen: true}); }); test('a mouse press outside closes a right-click menu', async ({page}) => { diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts new file mode 100644 index 0000000000000..a8aa2d26a29a3 --- /dev/null +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -0,0 +1,182 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file ContextMenuLongPress.interaction.browser.spec.ts + * @input The built "Touch / long press" ContextMenu story, real touch input, + * the interaction harness, the context-menu constants and UIKit's frames + * @output Interaction and animation proofs for a context menu that a held + * finger opens on UIKit's timeline. They cover the menu's moments, the + * press feedback against UIKit's frames and a blocked main thread. They + * also cover the page under the finger, a pan outside and a storyboard of + * the press + * @position Interaction specs of packages/core/src/ContextMenu; Chromium's + * finger stands for Android's engine, and WebKit's for iOS + */ + +import { + expect, + test, + type Locator, + type Page, +} from '@astryxdesign/a11y-spec/playwright'; +import { + drag, + installInputLog, + openStory, + readInputLog, + tap, + touchDriver, + withStorybook, + type TouchDriver, +} from '@astryxdesign/interaction-harness'; +import {contextMenu} from '../../motion/uikit/contextMenu'; + +const storybook = withStorybook(test); +const {menuDisplayMs} = contextMenu.press; +// Core's press machine believes a press 150 ms after it lands +// (`utils/pressGesture.ts`). That module loads a StyleX stylesheet, which +// this Node-side spec cannot, so the onset is restated here. + +interface TouchStory { + driver: TouchDriver; + trigger: Locator; + center: {x: number; y: number}; +} + +/** The touch story under the engine's own finger (a coarse pointer). */ +async function openTouchStory( + page: Page, + story = 'core-contextmenu--touch-long-press', +): Promise { + await installInputLog(page); + // Note when the menu is asked to show, on the page clock (no per-frame + // work: rendering proofs share this page). + await page.addInitScript(() => { + const w = window as unknown as {__menuShownAt?: number | null}; + w.__menuShownAt = null; + const show = HTMLElement.prototype.showPopover; + HTMLElement.prototype.showPopover = function ( + this: HTMLElement, + ...args: Parameters + ) { + // Only a popover that holds the menu (Storybook shows its own). + if ( + this.matches('[role="menu"]') || + this.querySelector('[role="menu"]') != null + ) { + w.__menuShownAt ??= performance.now(); + } + return show.apply(this, args); + }; + }); + await openStory(page, storybook(), story); + const driver = await touchDriver(page); + const trigger = page.getByTestId('touch-context-menu'); + const box = await trigger.boundingBox(); + if (box == null) { + throw new Error('no trigger'); + } + return { + driver, + trigger, + center: {x: box.x + box.width / 2, y: box.y + box.height / 2}, + }; +} + +const menuShownAt = async (page: Page) => + page.evaluate( + () => + (window as unknown as {__menuShownAt?: number | null}).__menuShownAt ?? + null, + ); + +/** The page-clock timestamp of the last `type` event. */ +async function lastInput( + page: Page, + type: 'touchstart' | 'touchend', +): Promise { + const log = await readInputLog(page); + const record = log.filter(entry => entry.type === type).at(-1); + if (record == null) { + throw new Error(`no ${type} reached the page`); + } + return record.timeStamp; +} + +const touchStartedAt = async (page: Page) => lastInput(page, 'touchstart'); + +/** A finger held still for `ms`, then lifted. */ +const hold = (at: {x: number; y: number}, ms: number) => tap(at, {holdMs: ms}); + +test('a finger still down gets the menu at 735 ms (iOS 26.2), not before', async ({ + page, +}) => { + const {driver, center} = await openTouchStory(page); + await driver.play(hold(center, menuDisplayMs + 200)); + const start = await touchStartedAt(page); + const shown = await menuShownAt(page); + expect(shown, 'the menu never showed').not.toBeNull(); + const after = (shown as number) - start; + // The timer runs at its deadline, give or take a task. + expect(after).toBeGreaterThanOrEqual(menuDisplayMs); + expect(after).toBeLessThan(menuDisplayMs + 20); + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeVisible(); +}); + +/** The story with somewhere to scroll below it, and its trigger's center. */ +async function openScrollableStory(page: Page) { + const story = await openTouchStory(page); + await page.evaluate(() => { + document.body.style.paddingBottom = '3000px'; + }); + const box = await story.trigger.boundingBox(); + if (box == null) { + throw new Error('no trigger'); + } + const center = {x: box.x + box.width / 2, y: box.y + box.height / 2}; + // Where the trigger's container sits: the trigger itself is scaled by the + // press feedback. + const top = async () => + story.trigger.evaluate( + el => (el.parentElement ?? el).getBoundingClientRect().y, + ); + return {...story, center, top}; +} + +test('a new finger that pans outside the open menu closes it, and the page scrolls', async ({ + page, +}) => { + const {driver, center, top} = await openScrollableStory(page); + await driver.play(hold(center, menuDisplayMs + 50)); + const menu = page.getByRole('menu', {name: 'Photo actions'}); + await expect(menu).toBeVisible(); + const before = await top(); + // The menu opens at the finger. So the new finger lands beside the menu's + // box, at the first point below or above it with room for the pan. + const box = await menu.boundingBox(); + const viewport = page.viewportSize(); + if (box == null || viewport == null) { + throw new Error('the menu or the viewport has no box'); + } + const outside = [ + {x: center.x, y: Math.round(box.y + box.height + 40)}, + {x: center.x, y: Math.round(box.y - 40)}, + ].find(point => point.y - 130 > 0 && point.y + 10 < viewport.height); + if (outside == null) { + throw new Error('no point beside the menu has room for the pan'); + } + expect( + await page.evaluate( + ({x, y}) => document.elementFromPoint(x, y)?.closest('[role="menu"]'), + outside, + ), + ).toBeNull(); + await driver.play( + drag(outside, {x: outside.x, y: outside.y - 120}, {durationMs: 300}), + ); + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeHidden(); + // WebKit scrolls nothing from an injected touch (README: engine limits). + if (driver.drivesNativeGestures) { + expect(await top()).toBeLessThan(before); + } +}); diff --git a/packages/core/src/ContextMenu/contextMenuPress.test.ts b/packages/core/src/ContextMenu/contextMenuPress.test.ts new file mode 100644 index 0000000000000..fdc7db94eb237 --- /dev/null +++ b/packages/core/src/ContextMenu/contextMenuPress.test.ts @@ -0,0 +1,214 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file contextMenuPress.test.ts + * @input vitest, the gesture arena driven by synthetic touches at explicit + * timestamps (the gesture tests' harness), ContextMenuPressRecognizer + * @output The context menu's touch timeline as a state machine. The press + * commits at UIKit's moment and not a millisecond before, at that moment + * even when the timer runs late. It fails on travel past the long-press + * tolerance, a lift, a second finger or the browser taking the touch. The + * commit claims the finger and drops its click. A press that fails leaves + * the click alone + * @position Unit tests of packages/core/src/ContextMenu/contextMenuPress.ts + */ + +import {describe, expect, it} from 'vitest'; +import {createHarness} from '../gestures/__tests__/harness'; +import {contextMenu} from '../motion/uikit/contextMenu'; +import {ContextMenuPressRecognizer} from './contextMenuPress'; + +const COMMIT = contextMenu.press.commitMs; +const DISPLAY = contextMenu.press.menuDisplayMs; + +function setup( + options: ConstructorParameters[0] = {}, +) { + const harness = createHarness(); + const press = new ContextMenuPressRecognizer({name: 'press', ...options}); + const states: string[] = []; + press.addTransitionListener((recognizer, _from, to) => { + states.push(`${to}@${recognizer.clockTime}`); + }); + return {harness, press, states}; +} + +describe('ContextMenuPressRecognizer', () => { + it('commits at UIKit’s moment (iOS 26.2: 400 ms, first 60 Hz tick 417 ms), not a millisecond before', () => { + const {harness, press, states} = setup(); + harness.finger(1).down(100, 200, 1000, {recognizers: [press]}); + harness.advance(1000 + COMMIT - 1); + expect(press.state).toBe('possible'); + harness.advance(1000 + COMMIT); + expect(press.state).toBe('began'); + expect(states).toEqual([`began@${1000 + COMMIT}`]); + expect(COMMIT).toBe(400); + }); + + it('commits at the deadline even when the main thread runs the timer late', () => { + const {harness, press, states} = setup(); + harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + // Nothing ran until 900 ms (a blocked main thread): the press still + // began at 400, and the menu was due at 735. + harness.advance(900); + expect(states).toEqual([`began@${COMMIT}`, `changed@${DISPLAY}`]); + expect(press.beganAt).toBe(COMMIT); + }); + + it('claims the finger at the commit and drops the lift’s click', () => { + const {harness, press} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + expect(harness.claims).toEqual([]); + harness.advance(COMMIT); + expect(harness.claims).toEqual([1]); + expect(finger.up(COMMIT + 300)).toBe('suppress'); + expect(press.state).toBe('possible'); + expect(press.press).toBeNull(); + }); + + it('makes the menu due at 735 ms while the finger stays (changed, isMenuDue)', () => { + const {harness, press, states} = setup(); + harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(DISPLAY - 1); + expect(press.isMenuDue).toBe(false); + harness.advance(DISPLAY); + expect(press.isMenuDue).toBe(true); + expect(states).toEqual([`began@${COMMIT}`, `changed@${DISPLAY}`]); + expect(DISPLAY).toBe(735); + }); + + it('ends at a lift after the commit, before the menu is due (UIKit shows the menu at the lift)', () => { + const {harness, press, states} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(COMMIT); + expect(finger.up(529)).toBe('suppress'); + harness.advance(DISPLAY + 100); + expect(states).toEqual([`began@${COMMIT}`, 'ended@529', 'possible@529']); + }); + + it('is cancelled by a drift past the tolerance after the commit, before the menu', () => { + const {harness, press, states} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(COMMIT); + finger.move(0, 5, 480); // within: still committed + finger.move(0, 30, 500); // a 30 px drag at 0.5 s + expect(states).toEqual([`began@${COMMIT}`, 'changed@480', 'cancelled@500']); + harness.advance(DISPLAY + 100); + expect(press.isMenuDue).toBe(false); + }); + + it('lets the finger move freely once the menu is due', () => { + const {harness, press, states} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(DISPLAY); + finger.move(0, 80, DISPLAY + 50); + finger.up(DISPLAY + 100); + expect(states).toEqual([ + `began@${COMMIT}`, + `changed@${DISPLAY}`, + `changed@${DISPLAY + 50}`, + `ended@${DISPLAY + 100}`, + `possible@${DISPLAY + 100}`, + ]); + }); + + it.each([ + [7, 7], + [8, 8], + [10, 0], + [11, 11], + [0, -11.5], + ])('still commits after a drift of (%d, %d) pt, as UIKit does', (dx, dy) => { + const {harness, press} = setup(); + harness + .finger(1) + .down(100, 100, 0, {recognizers: [press]}) + .move(100 + dx, 100 + dy, 100); + harness.advance(COMMIT); + expect(press.state).toBe('began'); + }); + + it.each([ + [12, 0], + [0, 12], + [-11.6, 0], + [0, -11.6], + ])( + 'fails on a drift of (%d, %d) pt, past the tolerance on one axis', + (dx, dy) => { + const {harness, press, states} = setup(); + harness + .finger(1) + .down(100, 100, 0, {recognizers: [press]}) + .move(100 + dx, 100 + dy, 250); + expect(press.state).toBe('failed'); + harness.advance(COMMIT); + expect(states).toEqual(['failed@250']); + }, + ); + + it('fails when the finger lifts before the commit, and lets that tap’s click through', () => { + const {harness, press, states} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + expect(finger.up(250)).toBe('allow'); + harness.advance(COMMIT); + expect(states).toEqual(['failed@250', 'possible@250']); + }); + + it('fails on a second finger before the commit', () => { + const {harness, press} = setup(); + harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.finger(2).down(50, 0, 120, {recognizers: [press]}); + expect(press.state).toBe('failed'); + }); + + it('ignores a second finger after the commit', () => { + const {harness, press} = setup(); + harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(COMMIT); + harness.finger(2).down(50, 0, COMMIT + 20, {recognizers: [press]}); + expect(press.state).toBe('began'); + }); + + it('fails when the browser takes the finger to scroll', () => { + const {harness, press, states} = setup(); + harness + .finger(1) + .down(0, 0, 0, {recognizers: [press]}) + .takenByBrowser(90); + // The browser owns the finger now: the recognizer lets go of it at once. + expect(states).toEqual(['failed@90', 'possible@90']); + }); + + it('keeps the press while it fails, so the feedback knows which one ended', () => { + const {harness, press} = setup(); + let pressAtFailure: unknown = 'unset'; + press.addTransitionListener((recognizer, _from, to) => { + if (to === 'failed') { + pressAtFailure = recognizer.press; + } + }); + harness + .finger(1) + .down(10, 20, 5, {recognizers: [press]}) + .up(200); + expect(pressAtFailure).toEqual({time: 5, x: 10, y: 20}); + expect(press.press).toBeNull(); + }); + + it('takes a finger or a pen, not a mouse (a mouse opens the menu with its secondary button)', () => { + const mouse = setup(); + mouse.harness + .finger(1) + .down(0, 0, 0, {recognizers: [mouse.press], pointerType: 'mouse'}); + mouse.harness.advance(COMMIT); + expect(mouse.press.state).toBe('possible'); + + const pen = setup(); + pen.harness + .finger(1) + .down(0, 0, 0, {recognizers: [pen.press], pointerType: 'pen'}); + pen.harness.advance(COMMIT); + expect(pen.press.state).toBe('began'); + }); +}); diff --git a/packages/core/src/ContextMenu/contextMenuPress.ts b/packages/core/src/ContextMenu/contextMenuPress.ts new file mode 100644 index 0000000000000..dfc25022eeb40 --- /dev/null +++ b/packages/core/src/ContextMenu/contextMenuPress.ts @@ -0,0 +1,198 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file contextMenuPress.ts + * @input Touches from the gesture arena and the context-menu constants + * @output ContextMenuPressRecognizer: the long press that opens a context menu + * on UIContextMenuInteraction's timeline under a finger, and the press it + * reports + * @position ContextMenu's touch invocation, on the gesture foundation; + * ContextMenu.tsx attaches it to the trigger + * + * SYNC: When modified, update /packages/core/src/ContextMenu/ContextMenu.tsx + * and /packages/core/src/ContextMenu/contextMenuPress.test.ts + */ + +import {isActive} from '../gestures/GestureRecognizer'; +import { + LongPressGestureRecognizer, + type LongPressGestureRecognizerOptions, +} from '../gestures/LongPressGestureRecognizer'; +import type {GestureEventContext, GestureTouch} from '../gestures/types'; +import {contextMenu} from '../motion/uikit/contextMenu'; + +const {commitMs, menuDisplayMs, allowableMovement} = contextMenu.press; + +/** + * The foundation's own press timer, which this recognizer never lets fire: + * the hold below commits instead. It is longer than any hold, and under the + * window timer's 2^31 - 1 ms limit. + */ +const NEVER_MS = 2 ** 30; + +/** A finger that may become a context menu: when and where it landed. */ +export interface ContextMenuPress { + /** On the event clock (`performance.now()`, an event's `timeStamp`), ms. */ + readonly time: number; + /** Client CSS px. */ + readonly x: number; + readonly y: number; +} + +export type ContextMenuPressRecognizerOptions = Omit< + LongPressGestureRecognizerOptions, + | 'allowableMovement' + | 'minimumPressDuration' + | 'numberOfTapsRequired' + | 'numberOfTouchesRequired' +>; + +/** + * A context menu is not UIKit's plain long press (500 ms, 10 pt of travel). + * Its timeline is `contextMenu.press`, and these are the recognizer's states: + * - `possible`: the finger landed. A lift, a second finger, or a browser + * scroll fails it. A drift past `allowableMovement` on either + * axis fails it too. + * - `began` at `commitMs`: the click haptic. The recognizer claims the touch. + * The page stops scrolling under it, and the lift sends no click and no + * compatibility mouse events. The menu does not show yet. A drift past + * `allowableMovement` still ends it with no menu (`cancelled`). A lift shows + * the menu at once (`ended` without `isMenuDue`). + * - `changed` with `isMenuDue` at `menuDisplayMs`: the finger is still down, + * and the menu shows. From then on, the finger moves freely. + * + * The hold runs on the arena's clock and timers, so the commit lands at its + * deadline even when the main thread is busy. The foundation's long press + * gives the claim and failure rules and the trigger's style. It never begins + * on its own timer, only at the hold's commit. A press that fails or is + * cancelled reports it through `addTransitionListener`, while `press` still + * names it. + */ +export class ContextMenuPressRecognizer extends LongPressGestureRecognizer { + /** The finger in play, from its landing until the gesture resets. */ + press: ContextMenuPress | null = null; + /** + * The finger stayed until `menuDisplayMs`: the menu is due, and the finger + * moves freely from here. + */ + isMenuDue = false; + /** The finger the hold follows. */ + private pressTouchId: number | null = null; + /** Where the finger landed, while its hold runs. The drift counts from here. */ + private holdFrom: {x: number; y: number} | null = null; + /** When the finger landed, on the arena's clock. The deadlines count from it. */ + private holdStart = 0; + + constructor(options: ContextMenuPressRecognizerOptions = {}) { + super({ + ...options, + // A finger or a pen; a mouse opens the menu with its secondary button. + allowedPointerTypes: options.allowedPointerTypes ?? ['touch', 'pen'], + minimumPressDuration: NEVER_MS, + // The hold's own tolerance is the drift rule (touchesMoved below). + allowableMovement: Infinity, + }); + } + + /** + * The event clock at this moment: the timestamp of the input (or the + * deadline of the timer) being processed. Read it from a listener to + * place what the transition starts at the time it happened. + */ + get clockTime(): number { + return this.now(); + } + + protected override touchesBegan( + touches: ReadonlyArray, + event: GestureEventContext, + ): void { + super.touchesBegan(touches, event); + if ( + this.press == null && + this.state === 'possible' && + this.numberOfTouches === this.numberOfTouchesRequired + ) { + const [touch] = touches; + this.press = {time: event.time, x: touch.x, y: touch.y}; + this.pressTouchId = touch.id; + // The hold's deadlines count from the touch itself. This replaces the + // foundation's own press timer, which never fires. + this.holdFrom = {x: touch.x, y: touch.y}; + this.holdStart = event.time; + this.setTimer(event.time + commitMs, () => this.commit()); + } + } + + protected override touchesMoved(touches: ReadonlyArray): void { + const touch = touches.find(each => each.id === this.pressTouchId); + const hasDrifted = touch != null && !this.followHold(touch); + if (hasDrifted && this.state === 'possible') { + this.setState('failed'); + return; + } + if (hasDrifted && isActive(this.state) && !this.isMenuDue) { + this.setState('cancelled'); + return; + } + super.touchesMoved(touches); + } + + /** + * Follows the finger during its hold. A drift past `allowableMovement` on + * either axis ends the hold. Returns whether the hold still runs. + */ + private followHold(touch: GestureTouch): boolean { + const from = this.holdFrom; + if (from == null) { + return false; + } + if ( + Math.abs(touch.x - from.x) > allowableMovement || + Math.abs(touch.y - from.y) > allowableMovement + ) { + this.endHold(); + return false; + } + return true; + } + + private endHold(): void { + this.clearTimer(); + this.holdFrom = null; + } + + /** The hold's commit claims the finger, then waits for the menu's moment. */ + private commit(): void { + if (this.holdFrom == null) { + return; + } + if ( + this.state === 'possible' && + this.numberOfTouches === this.numberOfTouchesRequired + ) { + this.beganAt = this.now(); + this.setState('began'); + } + // A transition listener can end the hold. + if (this.holdFrom != null) { + this.setTimer(this.holdStart + menuDisplayMs, () => this.showMenu()); + } + } + + /** The finger is still down at the menu's moment: the menu is due. */ + private showMenu(): void { + if (this.holdFrom != null && isActive(this.state)) { + this.isMenuDue = true; + this.setState('changed'); + } + } + + protected override reset(): void { + super.reset(); + this.endHold(); + this.pressTouchId = null; + this.press = null; + this.isMenuDue = false; + } +} diff --git a/packages/core/src/Indicator/boxHold.test.tsx b/packages/core/src/Indicator/boxHold.test.tsx index 05bd93d001570..538b601bd7c8a 100644 --- a/packages/core/src/Indicator/boxHold.test.tsx +++ b/packages/core/src/Indicator/boxHold.test.tsx @@ -203,7 +203,10 @@ describe('a finger held on a control’s box', () => { const onChange = vi.fn(); const onOpenChange = vi.fn(); render( - + diff --git a/packages/core/src/Layer/outsidePress.test.ts b/packages/core/src/Layer/outsidePress.test.ts index ca08558aa9853..95beabf96d5a9 100644 --- a/packages/core/src/Layer/outsidePress.test.ts +++ b/packages/core/src/Layer/outsidePress.test.ts @@ -4,8 +4,9 @@ * @file outsidePress.test.ts * @input isFocusInsideOwnedLayer, isPressInsideOwnedLayer and * watchOutsidePress - * @output The shared focus-leave test, and the outside-press watch that - * closes as a finger outside begins to pan, at a cancel or at a lift + * @output The shared focus-leave test, and the outside-press watch. The + * watch closes as a finger outside begins to pan, at a cancel or at a lift. + * For a mouse or a pen it closes at the press */ import {fireEvent} from '@testing-library/react'; @@ -336,3 +337,110 @@ describe('watchOutsidePress, closing on a pan', () => { stop(); }); }); + +describe('watchOutsidePress, closing at the press', () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + const press = (pointerType: string, pointerId = 1) => ({ + pointerType, + pointerId, + composed: true, + }); + + function setup(root?: Node, pressTypes = ['mouse', 'pen']) { + const owner = document.createElement('div'); + const layer = document.createElement('div'); + const item = document.createElement('button'); + layer.append(item); + const outside = document.createElement('button'); + document.body.append(owner, outside); + (root ?? document.body).appendChild(layer); + const onOutside = vi.fn(); + const stop = watchOutsidePress( + owner, + path => path.includes(layer), + onOutside, + {closesOnPan: true, pointerTypes: ['touch'], pressTypes, root}, + ); + return {item, outside, onOutside, stop}; + } + + it('closes at the mousedown of a mouse or a pen press outside, and not again at its lift', () => { + const {outside, onOutside, stop} = setup(); + fireEvent.pointerDown(outside, press('mouse')); + fireEvent.mouseDown(outside); + expect(onOutside).toHaveBeenCalledOnce(); + fireEvent.pointerUp(outside, press('mouse')); + fireEvent.mouseUp(outside); + expect(onOutside).toHaveBeenCalledOnce(); + fireEvent.pointerDown(outside, press('pen', 2)); + fireEvent.mouseDown(outside); + expect(onOutside).toHaveBeenCalledTimes(2); + stop(); + }); + + it('keeps the layer open for a mouse press inside it, in a closed shadow root too', () => { + const host = document.createElement('div'); + document.body.append(host); + const shadow = host.attachShadow({mode: 'closed'}); + const {item, onOutside, stop} = setup(shadow); + fireEvent.pointerDown(item, press('mouse')); + fireEvent.mouseDown(item, {composed: true}); + expect(onOutside).not.toHaveBeenCalled(); + fireEvent.pointerDown(host, press('mouse')); + fireEvent.mouseDown(host, {composed: true}); + expect(onOutside).toHaveBeenCalledOnce(); + stop(); + }); + + it('judges a mousedown that no pointerdown came before as a mouse press', () => { + const {outside, onOutside, stop} = setup(); + fireEvent.mouseDown(outside); + expect(onOutside).toHaveBeenCalledOnce(); + stop(); + }); + + it('leaves a press whose handler stops its mousedown before the document unjudged', () => { + const {outside, onOutside, stop} = setup(); + outside.addEventListener('mousedown', event => event.stopPropagation()); + fireEvent.pointerDown(outside, press('mouse')); + fireEvent.mouseDown(outside); + expect(onOutside).not.toHaveBeenCalled(); + stop(); + }); + + it('leaves a touch to its own judgment, and its compatibility mousedown alone', () => { + const {outside, onOutside, stop} = setup(); + fireEvent.pointerDown(outside, press('touch')); + fireEvent.pointerUp(outside, press('touch')); + expect(onOutside).toHaveBeenCalledOnce(); + fireEvent.mouseDown(outside); + expect(onOutside).toHaveBeenCalledOnce(); + stop(); + }); + + it('judges a touch at its compatibility mousedown when touch is a press type', () => { + const {outside, onOutside, stop} = setup(undefined, [ + 'mouse', + 'pen', + 'touch', + ]); + fireEvent.pointerDown(outside, press('touch')); + fireEvent.pointerUp(outside, press('touch')); + expect(onOutside).not.toHaveBeenCalled(); + fireEvent.mouseDown(outside); + expect(onOutside).toHaveBeenCalledOnce(); + stop(); + }); + + it('counts a layer it closes at the press as an outside dismissal', () => { + const {outside, stop} = setup(); + const before = outsideDismissalCount(document); + fireEvent.pointerDown(outside, press('mouse')); + fireEvent.mouseDown(outside); + expect(outsideDismissalCount(document)).toBe(before + 1); + stop(); + }); +}); diff --git a/packages/core/src/Layer/outsidePress.ts b/packages/core/src/Layer/outsidePress.ts index 4c34767d91411..55229c34cdd20 100644 --- a/packages/core/src/Layer/outsidePress.ts +++ b/packages/core/src/Layer/outsidePress.ts @@ -5,7 +5,8 @@ /** * @file outsidePress.ts * @input Pointer presses on a document and on the shadow roots around a - * given root; with closesOnPan, pan moves from core's input stream + * given root. With closesOnPan, pan moves from core's input stream. With + * pressTypes, the presses' bubbling mousedown * @output watchOutsidePress, and useOutsidePress, one watch per component. * noteOutsideDismissal and outsideDismissalCount keep a per-document count * of layers that an outside press or focus move closed. The inside tests @@ -35,6 +36,14 @@ export interface OutsidePressOptions { * document judges presses too, and an inner root's judgment stands. */ readonly root?: Node | null; + /** + * The pointer types judged at the press rather than at the lift. A press + * outside by one of them closes the layer at its bubbling `mousedown`, as a + * manual popover's own dismissal does. A press's type is that of the last + * `pointerdown` before its `mousedown`. With none, or one that names no + * type, it is a mouse. The watch does not follow these types at the lift. + */ + readonly pressTypes?: ReadonlyArray; } // A modal host's backdrop press reads this. A press that closed a layer above @@ -60,21 +69,29 @@ export function outsideDismissalCount(doc: Document): number { * @param isInside - Whether a press with this composed path is inside. * @param onOutside - Called once for each press that starts and ends outside, * as native light dismiss does. With `closesOnPan`, also when that press - * pans or is cancelled. + * pans or is cancelled. For a press type, at the press. * @returns Stops watching. */ export function watchOutsidePress( owner: Element, isInside: (path: ReadonlyArray) => boolean, onOutside: () => void, - {pointerTypes, closesOnPan = false, root = null}: OutsidePressOptions = {}, + { + pointerTypes, + closesOnPan = false, + root = null, + pressTypes = [], + }: OutsidePressOptions = {}, ): () => void { const doc = owner.ownerDocument; const shadows = root == null ? [] : shadowRootsOutward(root); let startedOutside = false; let panStart: {id: number; x: number; y: number} | null = null; + // The type of the pointer whose press the next mousedown belongs to. + let pressType: string | null = null; const follows = (event: PointerEvent) => - pointerTypes == null || pointerTypes.includes(event.pointerType); + !pressTypes.includes(event.pointerType) && + (pointerTypes == null || pointerTypes.includes(event.pointerType)); const close = () => { startedOutside = false; panStart = null; @@ -86,6 +103,7 @@ export function watchOutsidePress( // An inner root's listener runs after the outer ones', so its judgment // stands. const handleDown = (event: PointerEvent) => { + pressType = event.pointerType; if (!follows(event)) { return; } @@ -164,10 +182,30 @@ export function watchOutsidePress( } }; + // A press type's press is judged at its bubbling mousedown, so a handler + // that stops it keeps the layer open. An inner root sees it first, with the + // real target, and the outer ones skip it. + let judgedPress: Event | null = null; + const handlePress = (event: Event) => { + if (event === judgedPress) { + return; + } + judgedPress = event; + const type = pressType || 'mouse'; + pressType = null; + if (pressTypes.includes(type) && !isInside(event.composedPath())) { + close(); + } + }; + const judges: EventTarget[] = [ doc, ...shadows.map(({root: shadow}) => shadow), ]; + const pressJudges = pressTypes.length > 0 ? judges : []; + for (const target of pressJudges) { + target.addEventListener('mousedown', handlePress); + } for (const target of judges) { target.addEventListener('pointerdown', handleDown as EventListener, true); } @@ -190,6 +228,9 @@ export function watchOutsidePress( true, ); } + for (const target of pressJudges) { + target.removeEventListener('mousedown', handlePress); + } stopMoves?.(); doc.removeEventListener('pointerup', handleUp, true); for (const {shadow, host, handleRootUp, handleHostUp} of liftJudges) { diff --git a/packages/core/src/motion/README.md b/packages/core/src/motion/README.md index 5d7cec1464883..c739deaa6c701 100644 --- a/packages/core/src/motion/README.md +++ b/packages/core/src/motion/README.md @@ -18,6 +18,7 @@ roles through `resolveMotion`. | `uikit/springs.ts` | Interaction springs (navigation, sheets, alerts, menus, controls, keyboard, banners) and the SwiftUI / UIKit presets. | | `uikit/scroll.ts` | UIScrollView: deceleration rates, rubber band, bounce-back spring, `delaysContentTouches`, scroll to top. | | `uikit/timing.ts` | Non-spring clocks and curves: press highlight, Core Animation's named curves, keyboard, Reduced Motion cross-fade. | +| `uikit/contextMenu.ts` | UIContextMenuInteraction: touch timeline (feedback, commit, menu), drift tolerance, commit haptic. | | `uikit/paging.ts` | A paging UIScrollView: the integrator that settles a release on its page, the page rule, the catch, `setContentOffset(animated:)`. | | `uikit/alert.ts` | UIAlertController: how a finger is tracked across the actions (highlight onset and bounds). | | `uikit/navigation.ts` | UINavigationController: push velocity, parallax, dimming, transition clock, interactive pop finish rule. | diff --git a/packages/core/src/motion/uikit/contextMenu.ts b/packages/core/src/motion/uikit/contextMenu.ts new file mode 100644 index 0000000000000..a6c168b433270 --- /dev/null +++ b/packages/core/src/motion/uikit/contextMenu.ts @@ -0,0 +1,94 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file contextMenu.ts + * @input Measurements of UIContextMenuInteraction on iOS 26.2 + * @output `contextMenu`: UIContextMenuInteraction's touch timeline, drift + * tolerance and commit haptic + * @position The context-menu domain of the UIKit constants table. Only + * ContextMenu's own modules import it. The table's barrel does not + * re-export it. + * + * SYNC: When modified, update /packages/core/src/motion/MEASUREMENTS.md and + * /packages/core/src/ContextMenu/contextMenuPress.ts + */ + +/** + * UIContextMenuInteraction's touch timeline on iOS 26.2. A context menu is not + * a plain long press. A finger that rests on the view starts a press feedback + * after 150 ms. A copy of the view grows uniformly, and its size follows a + * linear ramp through a spring. At 400 ms the press commits. The click haptic + * plays, and the touch can no longer become a tap. The menu displays at 735 ms + * if the finger is still down, or at the lift after the commit. A finger that + * lifts before the commit gets no menu, and the copy springs back. A finger + * that drifts too far before the menu displays gets the same. + * + * UIKit runs this on display-link ticks. So a 60 Hz screen shows each moment + * on the first frame after it: the feedback at 151 ms, the commit at 417 ms. + * The times here are the moments themselves, from the `touchstart` timestamp. + * UIKit counts from the main thread's receipt of the touch, about a frame + * after the hardware timestamp that a browser stamps. A browser's compositor + * shows a value about a frame after its time, so the two offsets cancel. + * Scales are fractions of the view's own size. The lift and dismissal springs + * are `springs.contextMenuLift` and `springs.contextMenuDismiss`. + * + * Every leaf has one or more `@source`. A GUESS value also has the `@measure` + * task that confirms or replaces it. uikit.test.ts enforces both. + */ +export const contextMenu = { + press: { + /** + * When the long press commits. The click haptic plays, and from then on + * the touch belongs to the menu, not to a tap. The menu itself waits for + * `menuDisplayMs` or the lift. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, recognizer state log, Oct 23 2026: the + * clickPresentationExclusion and clickPresentationFailure recognizers + * ended on the first 60 Hz tick after 400 ms (at 416.7 ms; the tick + * before, at 399.5 ms, had not), the haptic played at 420 ms, and the + * platter's size ramp changes slope at 400 ms + * @measure M-CM6 + */ + commitMs: 400, + /** + * How far the finger may drift on either axis, from where it landed, + * until the menu displays; past it the press ends with no menu. Not the + * long press's `UIKIT_GESTURES.longPress.allowableMovement` (a distance): + * a diagonal drift of 15.6 pt still opens the menu. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, drifts at 100, 250 and 500 ms, Oct 23 2026: (7, 7), + * (8, 8), (10, 0) and (11, 11) pt open the menu; (12, 0) and (0, 12) + * cancel it, before and after the onset alike, and a 30 pt drag after + * the commit cancels it too + * @source GUESS — the value, midway in the measured bracket (11, 12) + * @measure M-CM3 + */ + allowableMovement: 11.5, + /** + * When the menu displays for a finger that is still down: the delegate's + * willDisplay, with the backdrop and the menu. Until then, only the press + * copy shows, and it grows on the second ramp. A finger lifted after the + * commit gets the menu at the lift. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, delegate log and per-frame view tree, Oct 23 2026: + * 735–738 ms after the touch over five warm presses at random display + * phase (734 ms on the first press of a session); a press lifted at + * 0.5 s displayed at the lift (529 ms) + */ + menuDisplayMs: 735, + }, + /** + * The click haptic at the commit, as a vibration where that is the only + * haptic (Android browsers). UIKit plays a private pattern + * (`_UIClickPresentationFeedbackGenerator`) at the commit; iOS Safari can + * play nothing at that moment, because no tap lands then. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, UIFeedbackGenerator hook, Oct 23 2026: the click + * presentation feedback plays 3 ms after the commit, nothing plays for a + * press lifted before it (the moment) + * @source GUESS — a medium impact stands in for the private pattern (the + * kind) + * @measure M-CM5 + */ + commitHaptic: 'impactMedium', +} as const; From 812d3a40fd4e0753ac3be53669aebf8bd8ecab21 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 05:13:36 -0800 Subject: [PATCH 010/118] test(ContextMenu): the hold's menu commits in its deciding task (red) The hold's timer only sets ContextMenu's state, so React mounts and shows the menu in its next scheduled task. A WebKit CI run showed it at 758 ms, past the 735 ms proof's 20 ms. The proof now also wants the menu to show inside the timer task that decides it, and that task at the deadline counted from the touch. The page's stamps record the first show only. --- ...tMenuLongPress.interaction.browser.spec.ts | 53 ++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index a8aa2d26a29a3..973617c03cceb 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -52,8 +52,37 @@ async function openTouchStory( // Note when the menu is asked to show, on the page clock (no per-frame // work: rendering proofs share this page). await page.addInitScript(() => { - const w = window as unknown as {__menuShownAt?: number | null}; + const w = window as unknown as { + __menuShownAt?: number | null; + __menuShownTimerAt?: number | null; + }; w.__menuShownAt = null; + w.__menuShownTimerAt = null; + // When the timer task that runs now started. The hold's moments come from + // the gesture arena's timers. The menu commits in the task that decides + // it, so its show runs inside that timer task. + let timerAt: number | null = null; + const setTimer = window.setTimeout; + window.setTimeout = (( + handler: TimerHandler, + timeout?: number, + ...args: unknown[] + ) => + setTimer( + (...callArgs: unknown[]) => { + const outer = timerAt; + timerAt = performance.now(); + try { + if (typeof handler === 'function') { + handler(...callArgs); + } + } finally { + timerAt = outer; + } + }, + timeout, + ...args, + )) as typeof window.setTimeout; const show = HTMLElement.prototype.showPopover; HTMLElement.prototype.showPopover = function ( this: HTMLElement, @@ -64,7 +93,11 @@ async function openTouchStory( this.matches('[role="menu"]') || this.querySelector('[role="menu"]') != null ) { - w.__menuShownAt ??= performance.now(); + // Only the first show counts, so a later one cannot stand in for it. + if (w.__menuShownAt == null) { + w.__menuShownAt = performance.now(); + w.__menuShownTimerAt = timerAt; + } } return show.apply(this, args); }; @@ -83,6 +116,14 @@ async function openTouchStory( }; } +/** When the timer task that showed the menu started, or null outside one. */ +const menuShownTimerAt = async (page: Page) => + page.evaluate( + () => + (window as unknown as {__menuShownTimerAt?: number | null}) + .__menuShownTimerAt ?? null, + ); + const menuShownAt = async (page: Page) => page.evaluate( () => @@ -115,7 +156,15 @@ test('a finger still down gets the menu at 735 ms (iOS 26.2), not before', async await driver.play(hold(center, menuDisplayMs + 200)); const start = await touchStartedAt(page); const shown = await menuShownAt(page); + const decided = await menuShownTimerAt(page); expect(shown, 'the menu never showed').not.toBeNull(); + // The menu commits in the timer task that decides it. That task runs at + // the deadline, which counts from the touch's own timestamp. + expect( + decided, + 'the menu showed after the task that decided it', + ).not.toBeNull(); + expect((decided as number) - start).toBeGreaterThanOrEqual(menuDisplayMs - 1); const after = (shown as number) - start; // The timer runs at its deadline, give or take a task. expect(after).toBeGreaterThanOrEqual(menuDisplayMs); From 0776b4071b32c57040daa18b08f0097bf7789a17 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 05:13:37 -0800 Subject: [PATCH 011/118] fix(ContextMenu): the hold commits its menu in the task that decides it The hold's timer opened the menu with a plain state update, which waits for React's next scheduled task: the menu showed 9 ms after the deadline in Chromium and 17 ms in the WebKit CI container. UIKit shows the menu at its moment. The open now commits with flushSync, in the timer task, as a lift's open commits in the lift's dispatch. --- packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx b/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx index 9650067be3297..834178947c100 100644 --- a/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx +++ b/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx @@ -14,6 +14,7 @@ */ import {useImperativeHandle, useRef, type Ref, type RefObject} from 'react'; +import {flushSync} from 'react-dom'; import {playHaptic} from '../gestures/haptics'; import {useGestureRecognizer} from '../gestures/useGestureRecognizer'; import {contextMenu} from '../motion/uikit/contextMenu'; @@ -60,7 +61,10 @@ export function ContextMenuTouchLayer({ press => { const open = () => { hasPressOpenedRef.current = true; - onOpenRef.current(press.press ?? press.location()); + // eslint-disable-next-line @eslint-react/dom-no-flush-sync -- the menu shows in the task that decides it, as UIKit shows it at its moment, not a scheduled task later + flushSync(() => { + onOpenRef.current(press.press ?? press.location()); + }); }; switch (press.state) { case 'began': From e8f5ca1898daf706c39b645a0895759ee8fadb8b Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 05:14:22 -0800 Subject: [PATCH 012/118] test(ContextMenu): the menu mounts, closed, at the hold's commit (red) The menu's first show mounts its layer, a render of 6 ms in Chromium and 17 to 21 ms in the WebKit CI container inside the task that shows the menu at 735 ms. UIKit prepares a context menu as the interaction begins and only presents it at its moment. The proofs want the menu mounted, closed and out of the accessibility tree, at the commit; the same element shown at 735 ms or at the lift; and nothing left mounted by a move past the tolerance or a cancel. --- .../core/src/ContextMenu/ContextMenu.test.tsx | 69 +++++++++++++++++++ ...tMenuLongPress.interaction.browser.spec.ts | 19 +++++ 2 files changed, 88 insertions(+) diff --git a/packages/core/src/ContextMenu/ContextMenu.test.tsx b/packages/core/src/ContextMenu/ContextMenu.test.tsx index 3b3957152001d..2a2284db9cd6f 100644 --- a/packages/core/src/ContextMenu/ContextMenu.test.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.test.tsx @@ -869,6 +869,75 @@ describe.each([ press.up(); }); + it('mounts the menu, closed, at the commit, and shows that element at 735 ms', () => { + const trigger = renderMenu(); + const press = finger(trigger); + press.down(); + advance(contextMenu.press.commitMs - 1); + expect(screen.queryByRole('menu', {hidden: true})).toBeNull(); + advance(1); + const menu = screen.getByRole('menu', {hidden: true}); + expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + advance(contextMenu.press.menuDisplayMs - contextMenu.press.commitMs); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + expect(screen.getByRole('menu', {hidden: true})).toBe(menu); + press.up(); + }); + + it('shows the menu mounted at the commit at the lift that comes before 735 ms', () => { + const trigger = renderMenu(); + const press = finger(trigger); + press.down(); + advance(529); + const menu = screen.getByRole('menu', {hidden: true}); + act(() => { + press.up(); + }); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + expect(screen.getByRole('menu', {hidden: true})).toBe(menu); + }); + + it('mounts no menu for a press lifted before the commit', () => { + const trigger = renderMenu(); + const press = finger(trigger); + press.down(); + advance(250); + press.up(); + advance(1000); + expect(screen.queryByRole('menu', {hidden: true})).toBeNull(); + }); + + it.each([ + [ + 'a move past the tolerance', + (trigger: HTMLElement) => { + const moved = {id: 1, x: 20, y: 50}; + dispatchTouch('touchmove', trigger, [moved], [moved]); + }, + ], + [ + 'a touch the browser cancels', + (trigger: HTMLElement) => { + dispatchTouch('touchcancel', trigger, [{id: 1, x: 20, y: 20}], []); + }, + ], + ])( + 'unmounts the menu mounted at the commit after %s, before 735 ms', + (_, end) => { + const trigger = renderMenu(); + const press = finger(trigger); + press.down(); + advance(contextMenu.press.commitMs + 50); + expect(screen.getByRole('menu', {hidden: true})).toBeInTheDocument(); + act(() => { + end(trigger); + }); + advance(contextMenu.press.menuDisplayMs); + expect(screen.queryByRole('menu', {hidden: true})).toBeNull(); + expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + }, + ); + it('lights no row when a touch opens it: focus goes to the menu itself, not its first item', () => { const trigger = renderMenu(); const press = finger(trigger); diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index 973617c03cceb..efba3d49c46db 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -172,6 +172,25 @@ test('a finger still down gets the menu at 735 ms (iOS 26.2), not before', async await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeVisible(); }); +test('the menu mounts at the commit, closed and out of the accessibility tree, and shows at 735 ms', async ({ + page, +}) => { + const {driver, center} = await openTouchStory(page); + const playing = driver.play(hold(center, menuDisplayMs + 200)); + const prepared = page + .locator('[popover]') + .filter({has: page.locator('[role="menu"]')}); + // Mounted at the commit (400 ms), well before the menu's moment (735 ms). + await expect(prepared).toHaveCount(1, {timeout: 700}); + expect(await menuShownAt(page), 'the menu showed before 735 ms').toBeNull(); + expect(await prepared.evaluate(el => el.matches(':popover-open'))).toBe( + false, + ); + await expect(page.getByRole('menu', {name: 'Photo actions'})).toHaveCount(0); + await playing; + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeVisible(); +}); + /** The story with somewhere to scroll below it, and its trigger's center. */ async function openScrollableStory(page: Page) { const story = await openTouchStory(page); From 0fbd309fdf2f8f974c10697f040b46784461fc27 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 05:14:49 -0800 Subject: [PATCH 013/118] fix(ContextMenu): the hold mounts the menu at its commit, closed A lazy context layer gains prepare(), which mounts its content without showing it, and releasePrepared(), which unmounts that content unless the layer has shown since. Both are internal to core's layer hook. At the hold's commit, ContextMenu prepares its layer, so the show at 735 ms or at the lift only reveals it. A press that ends with no menu releases it. component:ContextMenu PR1 names the commit-time mount. --- .../ContextMenu/ContextMenu.spec.md | 4 +- packages/core/src/ContextMenu/ContextMenu.tsx | 14 ++++- .../src/ContextMenu/ContextMenuTouchLayer.tsx | 28 ++++++++- packages/core/src/Layer/useLayer.test.tsx | 60 +++++++++++++++++++ packages/core/src/Layer/useLayer.tsx | 60 ++++++++++++++++++- 5 files changed, 161 insertions(+), 5 deletions(-) diff --git a/docs/specs/components/ContextMenu/ContextMenu.spec.md b/docs/specs/components/ContextMenu/ContextMenu.spec.md index dac8d53543eaf..29691feaa9afc 100644 --- a/docs/specs/components/ContextMenu/ContextMenu.spec.md +++ b/docs/specs/components/ContextMenu/ContextMenu.spec.md @@ -152,7 +152,9 @@ Consumer props, content modes, and presentation policy remain documented in mounted. A menu never opened mounts no pointer menu surface, only its layer's inert marker: the surface mounts at the first open and stays mounted after it, so a page with many context menus (a table with one per cell) carries no - closed menu surfaces. + closed menu surfaces. A finger's hold mounts the surface, closed, at its + commit, so the menu at 735 ms only shows it; a hold that ends with no menu + unmounts it again. ## Accessibility contract diff --git a/packages/core/src/ContextMenu/ContextMenu.tsx b/packages/core/src/ContextMenu/ContextMenu.tsx index d59a02ab30a90..dafc716443ee3 100644 --- a/packages/core/src/ContextMenu/ContextMenu.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.tsx @@ -28,7 +28,9 @@ * context menu that was never opened adds no popover element to the page (a * table with a menu on every cell would otherwise carry one per cell). Once * mounted it stays, so every later open and close, its exit animation - * included, runs as before. + * included, runs as before. A finger's hold mounts it, closed, at the + * commit, so the show at 735 ms only reveals it. A hold that ends with no + * menu unmounts it again. * The trigger wrapper is a `div` by default; `triggerAs` makes it an inline * `span`, so a reference inside prose can own a context menu without * breaking the text flow. @@ -666,6 +668,14 @@ export function ContextMenu({ [layer, updateOpenState, usesBottomSheet], ); + // The hold's commit: the menu shows at its moment or at the lift, so its + // layer mounts now, closed, and the show only reveals it. + const prepareMenu = useCallback(() => { + if (!usesBottomSheet) { + layer.prepare(); + } + }, [layer, usesBottomSheet]); + // The box the cursor anchor's offsets are measured from: the trigger, which // is its containing block. const getAnchorBaseRect = useCallback( @@ -978,6 +988,8 @@ export function ContextMenu({ ref={touchHandleRef} triggerRef={triggerRef} onOpen={openFromFinger} + onCommit={prepareMenu} + onCancel={layer.releasePrepared} /> ) : null} diff --git a/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx b/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx index 834178947c100..a42432403acf2 100644 --- a/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx +++ b/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx @@ -32,6 +32,13 @@ export interface ContextMenuTouchLayerProps { ref?: Ref; /** Open the menu at a client point (where the finger landed). */ onOpen: (point: {x: number; y: number}) => void; + /** + * The hold committed: the menu shows at its moment or at the lift, so its + * content can mount now, hidden. + */ + onCommit?: () => void; + /** The press ended with no menu: what mounted at the commit can go. */ + onCancel?: () => void; } /** @@ -48,16 +55,32 @@ export function ContextMenuTouchLayer({ triggerRef, ref, onOpen, + onCommit, + onCancel, }: ContextMenuTouchLayerProps): null { const onOpenRef = useRef(onOpen); onOpenRef.current = onOpen; + const onCommitRef = useRef(onCommit); + onCommitRef.current = onCommit; + const onCancelRef = useRef(onCancel); + onCancelRef.current = onCancel; const hasPressOpenedRef = useRef(false); // The gesture hook follows the element behind the ref, so a `triggerAs` // swap moves the recognizer to the new node. const recognizer = useGestureRecognizer( triggerRef, - () => new ContextMenuPressRecognizer(), + () => { + const recognizer = new ContextMenuPressRecognizer(); + // Its target-action listeners never hear `failed`, so a press that ends + // with no menu releases what the commit mounted from here. + recognizer.addTransitionListener((_press, _from, to) => { + if (to === 'failed' || to === 'cancelled') { + onCancelRef.current?.(); + } + }); + return recognizer; + }, press => { const open = () => { hasPressOpenedRef.current = true; @@ -72,6 +95,9 @@ export function ContextMenuTouchLayer({ // The click haptic, where a vibration motor is the only haptic. // iOS Safari can play none here: no tap lands at the commit. playHaptic(contextMenu.commitHaptic); + // The menu shows at its moment or at the lift. Mounted now, its + // content costs that show nothing. + onCommitRef.current?.(); break; case 'changed': if (press.isMenuDue && !hasPressOpenedRef.current) { diff --git a/packages/core/src/Layer/useLayer.test.tsx b/packages/core/src/Layer/useLayer.test.tsx index f6c280516458a..37e0238403433 100644 --- a/packages/core/src/Layer/useLayer.test.tsx +++ b/packages/core/src/Layer/useLayer.test.tsx @@ -208,6 +208,12 @@ function KeptLazyContextHarness({ + + {unsafe ? (

{renderedLayer}

@@ -1024,6 +1030,60 @@ describe('useLayer', () => { expect(layer).toHaveAttribute('data-open', 'true'); }); + it('prepare() mounts it closed, and the next show opens that element at once', async () => { + const showSpy = mockPopover(); + const {container, getByRole} = render(); + + fireEvent.click(getByRole('button', {name: 'Prepare'})); + await waitFor(() => { + expect(container.querySelector('[popover]')).not.toBeNull(); + }); + const layer = container.querySelector('[popover]'); + expect(layer).not.toHaveAttribute('data-open'); + expect(showSpy).not.toHaveBeenCalled(); + + fireEvent.click(getByRole('button', {name: 'Trigger'})); + // Mounted: shown within the call, not after a commit. + expect(showSpy).toHaveBeenCalledOnce(); + expect(container.querySelector('[popover]')).toBe(layer); + expect(layer).toHaveAttribute('data-open', 'true'); + }); + + it('releasePrepared() unmounts a prepared layer that never showed', async () => { + const showSpy = mockPopover(); + const {container, getByRole} = render(); + + fireEvent.click(getByRole('button', {name: 'Prepare'})); + await waitFor(() => { + expect(container.querySelector('[popover]')).not.toBeNull(); + }); + fireEvent.click(getByRole('button', {name: 'Release'})); + + await waitFor(() => { + expect(container.querySelector('[popover]')).toBeNull(); + }); + expect(container.querySelector('template')).not.toBeNull(); + expect(showSpy).not.toHaveBeenCalled(); + }); + + it('releasePrepared() keeps a prepared layer that has shown since', async () => { + mockPopover(); + const {container, getByRole} = render(); + + fireEvent.click(getByRole('button', {name: 'Prepare'})); + await waitFor(() => { + expect(container.querySelector('[popover]')).not.toBeNull(); + }); + const layer = container.querySelector('[popover]'); + fireEvent.click(getByRole('button', {name: 'Trigger'})); + fireEvent.click(getByRole('button', {name: 'Close'})); + fireEvent.click(getByRole('button', {name: 'Release'})); + + await act(async () => {}); + expect(container.querySelector('[popover]')).toBe(layer); + expect(layer?.isConnected).toBe(true); + }); + it('without the option, a lazy layer still unmounts on hide', async () => { mockPopover(); const {container, getByRole} = render( diff --git a/packages/core/src/Layer/useLayer.tsx b/packages/core/src/Layer/useLayer.tsx index 75b8e877b7cf6..1bfc7ca3d80f4 100644 --- a/packages/core/src/Layer/useLayer.tsx +++ b/packages/core/src/Layer/useLayer.tsx @@ -456,6 +456,23 @@ interface InternalLayerFields { wasJustDismissed: () => boolean; } +/** Context-mode members a core component may use through `useLayerInternal`. */ +interface InternalContextLayerFields { + /** + * With `lazyMount`, mounts the layer's content without showing it. The + * popover stays closed, so it shows no pixel and is out of the accessibility + * tree until `show()`, which then only shows it. A caller that knows a show + * is coming spares that show the first mount's render. Does nothing for a + * layer that is mounted already. + */ + prepare: () => void; + /** + * Unmounts what `prepare()` mounted, unless the layer has shown since. A + * layer that has shown keeps its mount as its options say. + */ + releasePrepared: () => void; +} + /** * Context-mode options a core component may pass through `useLayerInternal`; * not part of the public `useLayer` options. @@ -485,7 +502,9 @@ interface InternalContextLayerOptions extends ContextLayerOptions { onCloseStart?: () => void; } -type InternalContextLayerReturn = ContextLayerReturn & InternalLayerFields; +type InternalContextLayerReturn = ContextLayerReturn & + InternalLayerFields & + InternalContextLayerFields; type InternalFixedLayerReturn = FixedLayerReturn & InternalLayerFields; function toCssLength(value: number | string): string { @@ -1427,6 +1446,30 @@ function useLayerImplementation( [mountStore], ); + // The mount came from `prepare()`, and the layer has not shown since. + const isMountPreparedRef = useRef(false); + + const prepare = useCallback(() => { + if (mode !== 'context' || !lazyMount || contextMountRef.current != null) { + return; + } + isMountPreparedRef.current = true; + requestContextMount(); + }, [mode, lazyMount, requestContextMount]); + + const releasePrepared = useCallback(() => { + if ( + !isMountPreparedRef.current || + pendingShowRef.current || + isOpenRef.current + ) { + return; + } + isMountPreparedRef.current = false; + contextMountRef.current = null; + mountStore.set(null); + }, [mountStore]); + const clearContextMount = useCallback(() => { if (mode !== 'context' || !lazyMount || keepsMountAfterShow) { return; @@ -1441,6 +1484,7 @@ function useLayerImplementation( if (wasJustDismissed()) { return; } + isMountPreparedRef.current = false; // A context popover left over until React commits a previous hide must not // be reopened. The synchronous mount ref is the source of truth. const candidate = popoverRef.current; @@ -2111,6 +2155,8 @@ function useLayerImplementation( hide, isOpen, wasJustDismissed, + prepare, + releasePrepared, id, render: renderContext, }), @@ -2121,6 +2167,8 @@ function useLayerImplementation( hide, isOpen, wasJustDismissed, + prepare, + releasePrepared, id, renderContext, ], @@ -2148,7 +2196,15 @@ export function useLayer( ): ContextLayerReturn | FixedLayerReturn { const internalLayer = useLayerImplementation(options); return useMemo(() => { - const {wasJustDismissed: _, ...layer} = internalLayer; + const { + wasJustDismissed: _, + prepare: _prepare, + releasePrepared: _releasePrepared, + ...layer + } = internalLayer as ( + InternalContextLayerReturn | InternalFixedLayerReturn + ) & + Partial; return layer; }, [internalLayer]); } From 32e75c73cc6aa98b8b4db2a2187ac6bd56b5d708 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 05:20:00 -0800 Subject: [PATCH 014/118] test(ContextMenu): a mouse press closes the menu in its own document On main, ContextMenu's mouse and pen dismissal listens on the page's document. A menu rendered into a same-origin iframe or a Document Picture-in-Picture window then closes on a press in the page and stays open on one in its own window. The shared outside-press watch listens in the trigger's own document, as `family:overlay-dismissal` FR8 has every member work in the document it renders in. With main's ContextMenu.tsx, this case fails. --- .../core/src/ContextMenu/ContextMenu.test.tsx | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/packages/core/src/ContextMenu/ContextMenu.test.tsx b/packages/core/src/ContextMenu/ContextMenu.test.tsx index 2a2284db9cd6f..17213536c4441 100644 --- a/packages/core/src/ContextMenu/ContextMenu.test.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.test.tsx @@ -27,6 +27,7 @@ import { waitFor, cleanup, createEvent, + within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import {readFileSync} from 'node:fs'; @@ -2456,6 +2457,67 @@ describe('ContextMenu outside dismissal', () => { expect(onOpenChange).not.toHaveBeenCalledWith(false); }); }); + + describe('in a second document', () => { + // An app can render a ContextMenu into a same-origin iframe or a Document + // Picture-in-Picture window through a portal. Every press for it then + // happens in that document. + let frame: HTMLIFrameElement; + let secondDocument: Document; + + beforeEach(() => { + frame = document.createElement('iframe'); + document.body.appendChild(frame); + secondDocument = frame.contentDocument!; + // The Popover API stand-in above, on the second window's own prototype. + const {prototype} = secondDocument.defaultView!.HTMLElement; + prototype.showPopover = HTMLElement.prototype.showPopover; + prototype.hidePopover = HTMLElement.prototype.hidePopover; + prototype.matches = HTMLElement.prototype.matches; + }); + + afterEach(() => { + // Unmount before the frame goes, or React removes nodes from a + // document that no longer exists. + cleanup(); + frame.remove(); + }); + + it('a mouse press outside the menu closes it in the document it renders in; a press in the page does not', () => { + const onOpenChange = vi.fn<(isOpen: boolean) => void>(); + render( + createPortal( + <> + +
Right-click me
+
+ + , + secondDocument.body, + ), + ); + const view = within(secondDocument.body); + fireEvent.contextMenu(view.getByTestId('ctx'), { + clientX: 50, + clientY: 15, + detail: 1, + }); + expect(onOpenChange).toHaveBeenLastCalledWith(true); + fireEvent.pointerDown(document.body, { + pointerType: 'mouse', + pointerId: 1, + }); + fireEvent.mouseDown(document.body); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + const outside = view.getByRole('button', {name: 'Outside'}); + fireEvent.pointerDown(outside, {pointerType: 'mouse', pointerId: 1}); + fireEvent.mouseDown(outside); + expect(onOpenChange).toHaveBeenLastCalledWith(false); + }); + }); }); describe('ContextMenu: Tab from a flyout leaves the whole menu (APG menu button)', () => { From df6fd04576e9fd141d4eb913f90bc4707249715d Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 10:15:20 -0800 Subject: [PATCH 015/118] docs(ContextMenu): PR1 names the outside-press watch and the touch query PR1 listed one mousedown listener and the adaptive query. While the menu is open, the outside-press watch adds pointer listeners to the trigger's document and shadow roots, plus a mousedown listener where a mouse or a pen closes it at the press. With presentation omitted, ContextMenu subscribes to the coarse-pointer query while mounted. --- .../components/ContextMenu/ContextMenu.spec.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/specs/components/ContextMenu/ContextMenu.spec.md b/docs/specs/components/ContextMenu/ContextMenu.spec.md index 29691feaa9afc..e1a79e4a0779e 100644 --- a/docs/specs/components/ContextMenu/ContextMenu.spec.md +++ b/docs/specs/components/ContextMenu/ContextMenu.spec.md @@ -144,12 +144,13 @@ Consumer props, content modes, and presentation policy remain documented in ### Performance and resources -- **PR1 — Bounded resources.** While open, ContextMenu adds a document (and - shadow-root) mousedown listener for outside dismissal and removes it on - close, and the - pointer menu observes its size and content to decide whether it scrolls. With - `presentation="adaptive"` it subscribes to the compact-touch query while - mounted. A menu never opened mounts no pointer menu surface, only its layer's +- **PR1 — Bounded resources.** While open, ContextMenu's outside-press watch + adds pointer listeners to the trigger's document (and shadow roots), plus a + mousedown listener where a mouse or a pen closes it at the press, and + removes them on close. The pointer menu observes its size and content to + decide whether it scrolls. With `presentation` omitted it subscribes to the + coarse-pointer query while mounted, and with `presentation="adaptive"` to + the compact-touch query. A menu never opened mounts no pointer menu surface, only its layer's inert marker: the surface mounts at the first open and stays mounted after it, so a page with many context menus (a table with one per cell) carries no closed menu surfaces. A finger's hold mounts the surface, closed, at its From 1cedda2d73d250a12bb71b35141eba86d5111143 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 10:41:35 -0800 Subject: [PATCH 016/118] feat(ContextMenu): a held finger grows the trigger on the lift spring Under a finger, a block trigger grows from the onset, 150 ms after the finger lands, toward 1.0735 times its size on springs.contextMenuLift, and returns on springs.contextMenuDismiss. It carries this pull request's own diff from 63c7a6c009, replayed on main after #9814's squash, and folds these 27 commits (the history of 63c7a6c009 has each one): - test(ContextMenu): a held finger grows the trigger on the theme's motion tokens, and the timeline's other proofs - feat(ContextMenu): a held finger grows the trigger on the theme's motion tokens - test(ContextMenu): a trigger gets its size back when its press feedback goes while the menu it opened is up - fix(ContextMenu): the press feedback attaches through useAttachment, and a departing feedback gives the trigger its size back - refactor(ContextMenu): the touch layer's recognizer carries its transition listener, and the press recognizer drops onPressStart - docs(ContextMenu): FR11's growth cites AST-094 FR45 - test(ContextMenu): the trigger returns on the theme's exit duration and easing when the menu closes - fix(ContextMenu): the trigger returns on the theme's exit duration and easing when the menu closes - docs(ContextMenu): FR11 keeps the measured basis for the hold's moments, and the tests that verify them - docs(ContextMenu): the block-trigger guidance says the trigger grows while the menu is up - test(ContextMenu): the growth starts 150 ms after the finger lands, wherever the press machine marks the press - fix(ContextMenu): the growth starts 150 ms after the finger lands, wherever the press machine marks the press - test(ContextMenu): the trigger's growth plays springs.contextMenuLift, and its returns springs.contextMenuDismiss - fix(ContextMenu): the trigger's growth plays springs.contextMenuLift, and its returns springs.contextMenuDismiss - docs(ContextMenu): FR11 names the block trigger, and the tests, story and comments say the trigger springs back - refactor(ContextMenu): the press feedback stops returning the pressable marker, and two comments say what the code does - test(ContextMenu): a right-click in a scaled container gives the anchor no scale of its own - fix(ContextMenu): the anchor cancels only the press feedback's own growth - fix(ContextMenu): the press feedback imports only its two springs - test(ContextMenu): a drift the recognizer still follows grows at onset - fix(ContextMenu): keep the growth's onset while the recognizer follows - test(ContextMenu): a finger grows at its own onset, marked or not - fix(ContextMenu): a finger arms the growth from its own landing - docs(ContextMenu): the growth starts 150 ms after the finger lands - refactor(ContextMenu): the recognizer times a finger's onset - refactor(ContextMenu): drop a comment that restates followsFingerRef - refactor(ContextMenu): the onset ref comment keeps only its constraint --- .changeset/contextmenu-touch-timeline.md | 2 +- .../storybook/stories/ContextMenu.stories.tsx | 2 +- .../ContextMenu/ContextMenu.spec.md | 66 +- .../core/src/ContextMenu/ContextMenu.doc.mjs | 21 +- .../core/src/ContextMenu/ContextMenu.test.tsx | 234 +++--- packages/core/src/ContextMenu/ContextMenu.tsx | 81 ++- .../core/src/ContextMenu/ContextMenuTouch.ts | 4 +- .../ContextMenuTouchGrowth.test.tsx | 262 +++++++ .../src/ContextMenu/ContextMenuTouchLayer.tsx | 61 +- .../ContextMenuTouchLayerProps.test.tsx | 62 ++ ...tMenuLongPress.interaction.browser.spec.ts | 688 +++++++++++++++++- .../src/ContextMenu/contextMenuPress.test.ts | 13 +- .../core/src/ContextMenu/contextMenuPress.ts | 6 + .../contextMenuPressFeedback.test.tsx | 332 +++++++++ .../ContextMenu/contextMenuPressFeedback.ts | 317 ++++++++ packages/core/src/motion/README.md | 2 +- packages/core/src/motion/uikit/contextMenu.ts | 27 +- .../core/src/utils/pressableCoverage.test.ts | 16 +- 18 files changed, 2033 insertions(+), 163 deletions(-) create mode 100644 packages/core/src/ContextMenu/ContextMenuTouchGrowth.test.tsx create mode 100644 packages/core/src/ContextMenu/ContextMenuTouchLayerProps.test.tsx create mode 100644 packages/core/src/ContextMenu/contextMenuPressFeedback.test.tsx create mode 100644 packages/core/src/ContextMenu/contextMenuPressFeedback.ts diff --git a/.changeset/contextmenu-touch-timeline.md b/.changeset/contextmenu-touch-timeline.md index b8c1a1e95a6c7..df1a62392af22 100644 --- a/.changeset/contextmenu-touch-timeline.md +++ b/.changeset/contextmenu-touch-timeline.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[feat] ContextMenu opens from a held finger on iOS 26's measured timeline where the primary pointer is coarse. The hold keeps UIKit's measured moments. At 400 ms the press takes the finger: its lift no longer sends a click, and the page cannot scroll under it. The menu opens at 735 ms if the finger is still down, or as soon as it lifts after 400 ms. A finger that lifts before 400 ms, or drifts more than 11.5 px on either axis before the menu opens, gets no menu. The page stays free to scroll while the menu is up: a touch outside the menu closes it as it begins to pan, or at its lift. A touch open lights no row: the menu takes the focus, and a row lights only under the finger, as in UIKit (keyboard and pointer opens still focus the first item). A second finger cancels the press before 400 ms; after it, the menu still opens. A fine pointer (a mouse) keeps the right-click menu, and a finger there keeps today's 500 ms long press. Pass `presentation="popover"` to keep today's menu: the menu at the pointer, opened by a finger held for 500 ms. +[feat] ContextMenu opens from a held finger on iOS 26's measured timeline where the primary pointer is coarse. A block trigger whose menu opens at the finger starts to grow 150 ms after the finger lands, toward its held size on UIKit's lift spring, played on the compositor, and the hold keeps UIKit's measured moments. At 400 ms the press takes the finger: its lift no longer sends a click, and the page cannot scroll under it. The menu opens at 735 ms if the finger is still down, or as soon as it lifts after 400 ms. A finger that lifts before 400 ms, or drifts more than 11.5 px on either axis before the menu opens, gets no menu, and the trigger springs back to its size. The page stays free to scroll while the menu is up: a touch outside the menu closes it as it begins to pan, or at its lift. The trigger returns to its size when the menu closes. A touch open lights no row: the menu takes the focus, and a row lights only under the finger, as in UIKit (keyboard and pointer opens still focus the first item). A second finger cancels the press before 400 ms; after it, the menu still opens. A fine pointer (a mouse) keeps the right-click menu, and a finger there keeps today's 500 ms long press. Pass `presentation="popover"` to keep today's menu: the menu at the pointer, opened by a finger held for 500 ms. @vjeux diff --git a/apps/storybook/stories/ContextMenu.stories.tsx b/apps/storybook/stories/ContextMenu.stories.tsx index 7d8dff9642034..0c25ea1cea0b4 100644 --- a/apps/storybook/stories/ContextMenu.stories.tsx +++ b/apps/storybook/stories/ContextMenu.stories.tsx @@ -667,7 +667,7 @@ export const TouchLongPress: Story = { docs: { description: { story: - 'Where the primary pointer is coarse (a touch screen), hold a finger on the card. As on iOS, a haptic plays at 400 ms where the device can play one, and the menu opens at 735 ms, or as soon as the finger lifts after 400 ms. A finger that lifts before 400 ms, or drifts more than 11.5 px on either axis before the menu opens, gets no menu. While the menu is up, the page still scrolls: a touch outside the menu closes it as it begins to pan.', + 'Where the primary pointer is coarse (a touch screen), hold a finger on the card. As on iOS, it starts to grow after 150 ms, a haptic plays at 400 ms where the device can play one, and the menu opens at 735 ms, or as soon as the finger lifts after 400 ms. A finger that lifts before 400 ms, or drifts more than 11.5 px on either axis before the menu opens, gets no menu, and the card springs back to its size. While the menu is up, the page still scrolls: a touch outside the menu closes it as it begins to pan. The card returns to its size when the menu closes.', }, }, }, diff --git a/docs/specs/components/ContextMenu/ContextMenu.spec.md b/docs/specs/components/ContextMenu/ContextMenu.spec.md index e1a79e4a0779e..5b9cc4172ffde 100644 --- a/docs/specs/components/ContextMenu/ContextMenu.spec.md +++ b/docs/specs/components/ContextMenu/ContextMenu.spec.md @@ -100,20 +100,20 @@ Consumer props, content modes, and presentation policy remain documented in ## Behavioral and layout contract -| ID | Invariant | Basis | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| FR1 | Every render contains a caller-provided Trigger area; with `isDisabled` no invocation opens a menu and the native context menu is left alone. Pointer presentation opens a Pointer menu surface at the current local cursor anchor; touch presentation opens a Touch sheet frame containing a Touch menu surface and, for data mode, a Touch action list and Touch action rows. | Current source, docs, and tests | -| FR2 | The `context-menu` target stays on the painted Pointer menu surface and alternative Touch menu surface, not on the Trigger area or cursor anchor. | `architecture:component-theming-surface`; current source | -| FR3 | Pointer action rows retain DropdownMenu ownership. The touch frame retains BottomSheet ownership, while data-driven touch lists and rows retain List ownership. | Current source and owner docs | -| FR4 | Compound `menuContent` remains a caller-supplied pointer-menu interior and may also render inside the touch frame; the data-driven touch path instead converts the same item data to List and ListItem presentation. | Current source and tests | -| FR5 | The Pointer menu surface carries `data-astryx-menu-press` and follows `module:DropdownMenu/useMenuPress` FR1–FR7: the row under the release acts, the highlight follows a held pointer, a mouse released outside closes and a finger leaves the menu open, the stray click never acts, and `touch-action` follows overflow. The long press that opened the menu does not close it as it lifts; a tap outside closes it, and a mouse press outside closes it at the press. Under a finger the menu MUST close as a pan that starts outside it begins (`spec:AST-094` FR32), MUST hand the held finger that opened it to the menu as the opening press, and MUST treat a pen's press outside as FR32 does (FR33). The lift and the grow apply only to a menu opened by touch (a touch-initiated open on a coarse pointer); a menu opened by mouse or keyboard keeps its released appearance and motion, with no lift, no tint and no grow. A touch-opened menu MUST grow out of the finger's point and shrink back into it (`spec:AST-094` FR31) as its own surface, by transform and opacity only (`spec:AST-094` FR5), with no second element such as a glass surface growing behind it, and on a block trigger MUST lift the trigger above the page with the menu beside it (FR33; DD1, DEC-2). The long-press presentation is a gesture component, so the trigger's lift plays `springs.contextMenuLift`, the menu's grow `springs.contextMenuMorph`, and the trigger's return, part of the menu's exit, `springs.contextMenuDismiss` (`spec:AST-094` FR4, FR7, FR45). Implementation gap: it keeps the layer's own entrance and exit, the opening finger is not handed to the menu so it cannot slide to a row, no trigger lift or page tint exists, and a pen press outside closes it at the press. Right-click, Shift+F10, long-press invocation, and keyboard navigation are as FR1 and FR8 state. | `module:DropdownMenu/useMenuPress`; `spec:AST-094` FR26–FR33 | -| FR6 | With `triggerAs="span"` the Trigger area is an inline element that owns a box, so the cursor anchor measures from the trigger's own rect on every invocation path. Right-click, Shift+F10, long-press, and `context-menu` target placement all use that box. | DEC-1 | -| FR7 | Sub-menu rows follow `component:DropdownMenu` FR11 inside the pointer menu: ContextMenu mounts the same drill-in view stack, named after its `label`, so a sub-menu drills in on a phone here too. A sub-menu with a header or footer shows its rows alone here (`component:DropdownMenu` FR20). | `component:DropdownMenu/DEC-4` | -| FR8 | In the pointer menu ArrowDown on the last row wraps to the first and ArrowUp on the first to the last; PageDown and PageUp page as in `component:DropdownMenu` FR12; a held key's auto-repeat does not activate; typeahead matches the row's label element alone. A disabled row is reached by these keys and by typeahead and stays inert, as `component:DropdownMenu` FR17 states. Implementation gap: the arrow keys and typeahead skip disabled rows today (Current-state impact). | Current source and tests | -| FR9 | While the Pointer menu surface is open, it participates in `family:overlay-dismissal` through the shared stack and provides descendant depth. One Escape press affects only the top-most present nested surface; ContextMenu closes only when it is that surface. | `family:overlay-dismissal` FR1–FR6; `ContextMenu.test.tsx` nested-layer case | -| FR10 | ContextMenu MUST be built on the public headless context menu (`spec:AST-107` FR8), with its invocation, open state, and cleanup the factory's and no behavior of its own beside the factory's. Implementation gap: no headless context menu ships; ContextMenu owns its invocation, open state, outside-press listener, and cleanup itself. | `spec:AST-107` FR8 | -| FR11 | Tab or Shift+Tab on a row of the pointer menu, its own or an open sub-menu flyout's at any depth, closes every open level and the menu and calls `onOpenChange(false)` once, as `component:DropdownMenu` FR23 states for its menu. | WAI-ARIA Menu Button pattern; current tests | -| FR12 | Under a finger, the hold commits at 400 ms and the menu displays at 735 ms, as UIKit's do. They are measured input, not motion, and stay internal (`spec:AST-094` FR4, FR45). | Measured: UIContextMenuInteraction on the iOS 26.2 simulator commits at 400 ms and displays its menu at 735 ms (`motion/uikit/contextMenu.ts`); `spec:AST-094` FR4, FR45 | +| ID | Invariant | Basis | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| FR1 | Every render contains a caller-provided Trigger area; with `isDisabled` no invocation opens a menu and the native context menu is left alone. Pointer presentation opens a Pointer menu surface at the current local cursor anchor; touch presentation opens a Touch sheet frame containing a Touch menu surface and, for data mode, a Touch action list and Touch action rows. | Current source, docs, and tests | +| FR2 | The `context-menu` target stays on the painted Pointer menu surface and alternative Touch menu surface, not on the Trigger area or cursor anchor. | `architecture:component-theming-surface`; current source | +| FR3 | Pointer action rows retain DropdownMenu ownership. The touch frame retains BottomSheet ownership, while data-driven touch lists and rows retain List ownership. | Current source and owner docs | +| FR4 | Compound `menuContent` remains a caller-supplied pointer-menu interior and may also render inside the touch frame; the data-driven touch path instead converts the same item data to List and ListItem presentation. | Current source and tests | +| FR5 | The Pointer menu surface carries `data-astryx-menu-press` and follows `module:DropdownMenu/useMenuPress` FR1–FR7: the row under the release acts, the highlight follows a held pointer, a mouse released outside closes and a finger leaves the menu open, the stray click never acts, and `touch-action` follows overflow. The long press that opened the menu does not close it as it lifts; a tap outside closes it, and a mouse press outside closes it at the press. Under a finger the menu MUST close as a pan that starts outside it begins (`spec:AST-094` FR32), MUST hand the held finger that opened it to the menu as the opening press, and MUST treat a pen's press outside as FR32 does (FR33). The lift and the grow apply only to a menu opened by touch (a touch-initiated open on a coarse pointer); a menu opened by mouse or keyboard keeps its released appearance and motion, with no lift, no tint and no grow. A touch-opened menu MUST grow out of the finger's point and shrink back into it (`spec:AST-094` FR31) as its own surface, by transform and opacity only (`spec:AST-094` FR5), with no second element such as a glass surface growing behind it, and on a block trigger MUST lift the trigger above the page with the menu beside it (FR33; DD1, DEC-2). The long-press presentation is a gesture component, so the trigger's lift plays `springs.contextMenuLift`, the menu's grow `springs.contextMenuMorph`, and the trigger's return, part of the menu's exit, `springs.contextMenuDismiss` (`spec:AST-094` FR4, FR7, FR45). Implementation gap: it keeps the layer's own entrance and exit, the opening finger is not handed to the menu so it cannot slide to a row, no trigger lift or page tint exists, and a pen press outside closes it at the press. Right-click, Shift+F10, long-press invocation, and keyboard navigation are as FR1 and FR8 state. | `module:DropdownMenu/useMenuPress`; `spec:AST-094` FR26–FR33 | +| FR6 | With `triggerAs="span"` the Trigger area is an inline element that owns a box, so the cursor anchor measures from the trigger's own rect on every invocation path. Right-click, Shift+F10, long-press, and `context-menu` target placement all use that box. | DEC-1 | +| FR7 | Sub-menu rows follow `component:DropdownMenu` FR11 inside the pointer menu: ContextMenu mounts the same drill-in view stack, named after its `label`, so a sub-menu drills in on a phone here too. A sub-menu with a header or footer shows its rows alone here (`component:DropdownMenu` FR20). | `component:DropdownMenu/DEC-4` | +| FR8 | In the pointer menu ArrowDown on the last row wraps to the first and ArrowUp on the first to the last; PageDown and PageUp page as in `component:DropdownMenu` FR12; a held key's auto-repeat does not activate; typeahead matches the row's label element alone. A disabled row is reached by these keys and by typeahead and stays inert, as `component:DropdownMenu` FR17 states. Implementation gap: the arrow keys and typeahead skip disabled rows today (Current-state impact). | Current source and tests | +| FR9 | While the Pointer menu surface is open, it participates in `family:overlay-dismissal` through the shared stack and provides descendant depth. One Escape press affects only the top-most present nested surface; ContextMenu closes only when it is that surface. | `family:overlay-dismissal` FR1–FR6; `ContextMenu.test.tsx` nested-layer case | +| FR10 | ContextMenu MUST be built on the public headless context menu (`spec:AST-107` FR8), with its invocation, open state, and cleanup the factory's and no behavior of its own beside the factory's. Implementation gap: no headless context menu ships; ContextMenu owns its invocation, open state, outside-press listener, and cleanup itself. | `spec:AST-107` FR8 | +| FR11 | Tab or Shift+Tab on a row of the pointer menu, its own or an open sub-menu flyout's at any depth, closes every open level and the menu and calls `onOpenChange(false)` once, as `component:DropdownMenu` FR23 states for its menu. | WAI-ARIA Menu Button pattern; current tests | +| FR12 | Under a finger, a block trigger whose menu opens at the finger grows during a hold toward 1.0735 times its size on `springs.contextMenuLift`, from the onset, 150 ms after the finger lands, and it returns after a lift before the commit on `springs.contextMenuDismiss`, as FR5 names them. The hold commits at 400 ms and the menu displays at 735 ms, as UIKit's do. They are measured input, not motion, and stay internal (`spec:AST-094` FR4, FR45). | Measured on iOS 26.2 (`motion/uikit/contextMenu.ts`): UIKit commits at 400 ms, shows the menu at 735 ms, and grows the view on a linear ramp chased by a spring. `spec:AST-094` FR4, FR45: the two moments are measured input, and the long-press presentation is a gesture component (FR5), so the growth and its return play their spring roles | ### Allowed variation @@ -248,25 +248,25 @@ anchor is positioning infrastructure rather than consumer anatomy. ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -| FR1, FR4 | `ContextMenu.test.tsx` rendering, invocation, bottom-sheet, adaptive, compound, and disabled cases ("renders menu with role=\"menu\"", "opens menu on right-click", "opens from a keyboard-invoked contextmenu (Shift+F10 / Menu key)", "opens for a finger still down at 735 ms (iOS 26.2), not a millisecond before", "opens the real menu content in a BottomSheet when requested", "uses the BottomSheet for adaptive presentation on compact touch", "renders menuContent as menu items", "does not open when isDisabled is true") | Right-click, keyboard, long press; pointer and sheet; data and compound; disabled | A missing invocation path, menu role, sheet, or compound interior, or a disabled trigger that opens, fails. Evidence gap: no test asserts that a disabled trigger leaves the native context menu alone. | `audit:ContextMenu/anatomy` | -| FR2 | `themingTargets.test.ts` | Rendered targets | A rendered class absent from the documented targets fails. Evidence gap: no test asserts `context-menu` on either painted surface or its absence from the trigger. | `audit:ContextMenu/theming` | -| FR3 | `ContextMenu.test.tsx` destructive-variant, section, divider, selectable, and alias cases | Shared rows through ContextMenu | A row that loses DropdownMenu's semantics or variant fails. Evidence gap: no test asserts the BottomSheet and List delegation beyond rendering. | `audit:ContextMenu/theming` | -| FR5 | `ContextMenu.test.tsx` "acts on the row under a finger release, once, and swallows the stray click" and outside-dismissal cases; `outsidePress.test.ts` pan cases; `ContextMenuLongPress.interaction.browser.spec.ts` "a new finger that pans outside the open menu closes it, and the page scrolls"; `ContextMenuLongPress.a11y.chromium.spec.ts` | Finger release; long press lift; tap, mouse, pen, and pan outside | A row acting on the press row, a stray click that acts, a long press that closes its own menu, a tap or mouse press outside that leaves it open, or a pan outside that leaves it open fails. Evidence gap: no test asserts the FR31 grow, the opening-finger handoff, or the FR33 lift. | `audit:ContextMenu/behavior` | -| FR5, DD1 touch scope | Browser tests on the touch story, the menu opened by a finger under a coarse pointer and by a mouse and by Shift+F10 under a fine pointer | Touch-opened, mouse-opened and keyboard-opened menus on a block trigger | A mouse- or keyboard-opened menu that lifts its trigger, tints the page or grows, or a touch-opened menu that blurs the page, grows a second surface, or animates anything but transform and opacity, fails. Evidence gap: the lift and the grow are unbuilt, so no test asserts either. | `audit:ContextMenu/behavior` | -| FR5 Reduce Motion | Browser tests on the touch story with reduced motion | A touch-opened menu opening and closing under Reduce Motion | A lift, grow or shrink that moves rather than playing an opacity-only cross-fade of 200 ms or less (`spec:AST-094` FR8) fails. Evidence gap: no test exists. | `audit:ContextMenu/behavior` | -| FR5 motion roles | Role registry tests over `spec:AST-094` FR4 | `springs.contextMenuLift`, `springs.contextMenuDismiss` and `springs.contextMenuMorph` at tempo 1 and `'standard'` | A lift, return or grow that plays a curve or a literal instead of its spring role, or a role off its FR4 table value, fails. | `audit:ContextMenu/behavior` | -| DEC-2 | Element-count browser test on the touch story (`spec:AST-017` FR60) | At rest and lifted, fine and coarse pointer | A lift element at rest, a copy of the trigger, or an open count beyond the layer, the shadow, the anchor and the placeholder fails. Evidence gap: the lift is unbuilt, so no test exists. | `audit:ContextMenu/anatomy` | -| FR6, AR1, ORD1 | `ContextMenu.test.tsx` "inline trigger (triggerAs)" and cursor-anchor cases; "does not put aria-haspopup on the role-less trigger wrapper (menus-15)"; `ContextMenu.triggerProps.test.tsx`; `ContextMenuCursorAnchor.a11y.browser.spec.ts` | `span` in a paragraph; default `div`; pointer and keyboard anchors | A block trigger in prose, a trigger with a role or `aria-haspopup`, or an anchor outside the trigger fails. | `audit:ContextMenu/behavior` | -| FR7 | `ContextMenu.test.tsx` "drills into nested data items inside the BottomSheet", "renders a submenu from a nested items array and keyboard-reaches an item after it", and "shows a sub-menu with a header or footer as its rows alone, and says why in development" | Sheet drill-in; pointer sub-menu; regions under ContextMenu | A missing sub-menu, an unreachable row after it, or a sub-menu dialog under ContextMenu fails. Evidence gap: no test here asserts the pointer drill-in on a phone. | `audit:ContextMenu/behavior` | -| FR8 | `ContextMenu.test.tsx` "wraps arrow navigation at the ends", typeahead cases, and disabled-row keyboard cases (`component:DropdownMenu` FR17) | Wrap; typeahead; disabled rows | A clamped arrow, a missing typeahead match, a disabled row the keys or typeahead skip, or a disabled row that acts or closes the menu fails. Evidence gap: no test asserts Page keys or auto-repeat. | `audit:ContextMenu/behavior` | -| FR9 | `ContextMenu.test.tsx` "lets a nested layer consume Escape before the context menu", "closes on Escape even when opened without auto-focus", and "ignores Escape during IME composition" | Nested layer; Escape; composition | A context menu that closes with its nested child, or on a composition Escape, fails. | `audit:ContextMenu/behavior` | -| FR10, PR1 | `ContextMenu.test.tsx` invocation and handler-composition cases ("composes the consumer context-menu handler with menu opening", "keeps component-owned opening when a consumer prevents default", "passes touch events through to the consumer’s own handlers", and the cases where a consumer `onTouchStart` or `onTouchEnd` runs beside the hold) | Right click, long press, keyboard | A context request that does not open the menu, or a dropped consumer handler, fails. Evidence gap: no test asserts listener cleanup on unmount; nothing tests a headless factory, which does not ship (the FR10 gap). | `audit:ContextMenu/behavior` | -| FR11 | `ContextMenu.test.tsx` "Tab from a flyout leaves the whole menu" case | a sub-menu row | A Tab that leaves the menu or a flyout open, or a second `onOpenChange(false)`, fails. | `audit:ContextMenu/accessibility` | -| FR12 | `ContextMenu.test.tsx` "opens for a finger still down at 735 ms (iOS 26.2), not a millisecond before" and "opens at the lift for a finger lifted after the commit (400 ms), as UIKit does"; `contextMenuPress.test.ts` | A held finger: the commit and the menu's display | A commit or a display off UIKit's moments fails. | `audit:ContextMenu/behavior` | -| Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and target | Missing, extra, prefixed, stale, or unclassified mappings fail repository validation. | `audit:ContextMenu/theming` | -| RTL relation | RTL audit curated D2 target (`apps/storybook/rtl-audit/targets.json`) on the `Presentation / BottomSheet submenu` story | BottomSheet opened into a submenu, LTR and RTL | The sheet header's back button not at the inline start of its heading in both directions fails D2. | `audit:ContextMenu/i18n-rtl` | +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| FR1, FR4 | `ContextMenu.test.tsx` rendering, invocation, bottom-sheet, adaptive, compound, and disabled cases ("renders menu with role=\"menu\"", "opens menu on right-click", "opens from a keyboard-invoked contextmenu (Shift+F10 / Menu key)", "opens for a finger still down at 735 ms (iOS 26.2), not a millisecond before", "opens the real menu content in a BottomSheet when requested", "uses the BottomSheet for adaptive presentation on compact touch", "renders menuContent as menu items", "does not open when isDisabled is true") | Right-click, keyboard, long press; pointer and sheet; data and compound; disabled | A missing invocation path, menu role, sheet, or compound interior, or a disabled trigger that opens, fails. Evidence gap: no test asserts that a disabled trigger leaves the native context menu alone. | `audit:ContextMenu/anatomy` | +| FR2 | `themingTargets.test.ts` | Rendered targets | A rendered class absent from the documented targets fails. Evidence gap: no test asserts `context-menu` on either painted surface or its absence from the trigger. | `audit:ContextMenu/theming` | +| FR3 | `ContextMenu.test.tsx` destructive-variant, section, divider, selectable, and alias cases | Shared rows through ContextMenu | A row that loses DropdownMenu's semantics or variant fails. Evidence gap: no test asserts the BottomSheet and List delegation beyond rendering. | `audit:ContextMenu/theming` | +| FR5 | `ContextMenu.test.tsx` "acts on the row under a finger release, once, and swallows the stray click" and outside-dismissal cases; `outsidePress.test.ts` pan cases; `ContextMenuLongPress.interaction.browser.spec.ts` "a new finger that pans outside the open menu closes it, and the page scrolls"; `ContextMenuLongPress.a11y.chromium.spec.ts` | Finger release; long press lift; tap, mouse, pen, and pan outside | A row acting on the press row, a stray click that acts, a long press that closes its own menu, a tap or mouse press outside that leaves it open, or a pan outside that leaves it open fails. Evidence gap: no test asserts the FR31 grow, the opening-finger handoff, or the FR33 lift. | `audit:ContextMenu/behavior` | +| FR5, DD1 touch scope | Browser tests on the touch story, the menu opened by a finger under a coarse pointer and by a mouse and by Shift+F10 under a fine pointer | Touch-opened, mouse-opened and keyboard-opened menus on a block trigger | A mouse- or keyboard-opened menu that lifts its trigger, tints the page or grows, or a touch-opened menu that blurs the page, grows a second surface, or animates anything but transform and opacity, fails. Evidence gap: the lift and the grow are unbuilt, so no test asserts either. | `audit:ContextMenu/behavior` | +| FR5 Reduce Motion | Browser tests on the touch story with reduced motion | A touch-opened menu opening and closing under Reduce Motion | A lift, grow or shrink that moves rather than playing an opacity-only cross-fade of 200 ms or less (`spec:AST-094` FR8) fails. Evidence gap: no test exists. | `audit:ContextMenu/behavior` | +| FR5 motion roles | Role registry tests over `spec:AST-094` FR4 | `springs.contextMenuLift`, `springs.contextMenuDismiss` and `springs.contextMenuMorph` at tempo 1 and `'standard'` | A lift, return or grow that plays a curve or a literal instead of its spring role, or a role off its FR4 table value, fails. | `audit:ContextMenu/behavior` | +| DEC-2 | Element-count browser test on the touch story (`spec:AST-017` FR60) | At rest and lifted, fine and coarse pointer | A lift element at rest, a copy of the trigger, or an open count beyond the layer, the shadow, the anchor and the placeholder fails. Evidence gap: the lift is unbuilt, so no test exists. | `audit:ContextMenu/anatomy` | +| FR6, AR1, ORD1 | `ContextMenu.test.tsx` "inline trigger (triggerAs)" and cursor-anchor cases; "does not put aria-haspopup on the role-less trigger wrapper (menus-15)"; `ContextMenu.triggerProps.test.tsx`; `ContextMenuCursorAnchor.a11y.browser.spec.ts` | `span` in a paragraph; default `div`; pointer and keyboard anchors | A block trigger in prose, a trigger with a role or `aria-haspopup`, or an anchor outside the trigger fails. | `audit:ContextMenu/behavior` | +| FR7 | `ContextMenu.test.tsx` "drills into nested data items inside the BottomSheet", "renders a submenu from a nested items array and keyboard-reaches an item after it", and "shows a sub-menu with a header or footer as its rows alone, and says why in development" | Sheet drill-in; pointer sub-menu; regions under ContextMenu | A missing sub-menu, an unreachable row after it, or a sub-menu dialog under ContextMenu fails. Evidence gap: no test here asserts the pointer drill-in on a phone. | `audit:ContextMenu/behavior` | +| FR8 | `ContextMenu.test.tsx` "wraps arrow navigation at the ends", typeahead cases, and disabled-row keyboard cases (`component:DropdownMenu` FR17) | Wrap; typeahead; disabled rows | A clamped arrow, a missing typeahead match, a disabled row the keys or typeahead skip, or a disabled row that acts or closes the menu fails. Evidence gap: no test asserts Page keys or auto-repeat. | `audit:ContextMenu/behavior` | +| FR9 | `ContextMenu.test.tsx` "lets a nested layer consume Escape before the context menu", "closes on Escape even when opened without auto-focus", and "ignores Escape during IME composition" | Nested layer; Escape; composition | A context menu that closes with its nested child, or on a composition Escape, fails. | `audit:ContextMenu/behavior` | +| FR10, PR1 | `ContextMenu.test.tsx` invocation and handler-composition cases ("composes the consumer context-menu handler with menu opening", "keeps component-owned opening when a consumer prevents default", "passes touch events through to the consumer’s own handlers", and the cases where a consumer `onTouchStart` or `onTouchEnd` runs beside the hold) | Right click, long press, keyboard | A context request that does not open the menu, or a dropped consumer handler, fails. Evidence gap: no test asserts listener cleanup on unmount; nothing tests a headless factory, which does not ship (the FR10 gap). | `audit:ContextMenu/behavior` | +| FR11 | `ContextMenu.test.tsx` "Tab from a flyout leaves the whole menu" case | a sub-menu row | A Tab that leaves the menu or a flyout open, or a second `onOpenChange(false)`, fails. | `audit:ContextMenu/accessibility` | +| FR12 | `ContextMenu.test.tsx` "opens for a finger still down at 735 ms (iOS 26.2), not a millisecond before" and "opens at the lift for a finger lifted after the commit (400 ms), as UIKit does"; `contextMenuPress.test.ts`; `contextMenuPressFeedback.test.tsx` "grows toward the held scale on springs.contextMenuLift, from the onset" and "returns on springs.contextMenuDismiss, at the time the recognizer ended the press"; `ContextMenuTouchGrowth.test.tsx` onset cases; the touch story's browser curve and busy-thread cases in Chromium, and an iOS Safari recording for WebKit | A held finger: the commit, the display, the onset, the growth, the early-lift return, their springs | A commit or a display off UIKit's moments, a growth before the onset, or a growth or return off its spring role fails | `audit:ContextMenu/behavior` | +| Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and target | Missing, extra, prefixed, stale, or unclassified mappings fail repository validation. | `audit:ContextMenu/theming` | +| RTL relation | RTL audit curated D2 target (`apps/storybook/rtl-audit/targets.json`) on the `Presentation / BottomSheet submenu` story | BottomSheet opened into a submenu, LTR and RTL | The sheet header's back button not at the inline start of its heading in both directions fails D2. | `audit:ContextMenu/i18n-rtl` | ## Current-state impact diff --git a/packages/core/src/ContextMenu/ContextMenu.doc.mjs b/packages/core/src/ContextMenu/ContextMenu.doc.mjs index 4a5b1745c7bbe..7390609678f8b 100644 --- a/packages/core/src/ContextMenu/ContextMenu.doc.mjs +++ b/packages/core/src/ContextMenu/ContextMenu.doc.mjs @@ -159,7 +159,7 @@ export const docs = { usage: { anatomy, description: - 'A right-click context menu that appears at the cursor position. Use to provide contextual actions for specific elements or regions without cluttering the UI with visible buttons. Under a finger, where the primary pointer is coarse, a held finger opens it with iOS timing: the menu opens at 735 ms, or as soon as the finger lifts after 400 ms.', + 'A right-click context menu that appears at the cursor position. Use to provide contextual actions for specific elements or regions without cluttering the UI with visible buttons. Under a finger, where the primary pointer is coarse, a held finger opens it with iOS timing: a block trigger whose menu opens at the finger starts to grow at 150 ms, and the menu opens at 735 ms, or as soon as the finger lifts after 400 ms.', bestPractices: [ { guidance: true, @@ -186,6 +186,11 @@ export const docs = { description: 'Keep a visible MoreMenu or equivalent trigger for important mobile actions; long-press must not be the only route.', }, + { + guidance: true, + description: + 'Leave a little room around a block trigger: a held finger grows it from its center, by about 7%, while the menu is up.', + }, { guidance: false, description: @@ -223,7 +228,7 @@ export const docs = { export const docsZh = { usage: { description: - '右键点击时在光标位置出现的上下文菜单。用于为特定元素或区域提供上下文操作,而不使 UI 杂乱。在主指针为粗指针(触摸屏)时,按住手指即可打开,时序与 iOS 相同:735 毫秒时菜单打开;若手指在 400 毫秒后抬起,菜单在抬起时立即打开。', + '右键点击时在光标位置出现的上下文菜单。用于为特定元素或区域提供上下文操作,而不使 UI 杂乱。在主指针为粗指针(触摸屏)时,按住手指即可打开,时序与 iOS 相同:150 毫秒时块级触发区域(菜单在手指处打开时)开始放大,735 毫秒时菜单打开;若手指在 400 毫秒后抬起,菜单在抬起时立即打开。', bestPractices: [ {guidance: true, description: '保持菜单项简洁和面向操作。'}, {guidance: true, description: '有很多项时使用分组和分隔线。'}, @@ -241,6 +246,11 @@ export const docsZh = { description: '重要的移动端操作还应提供可见的 MoreMenu 或同等入口,不要只依赖长按。', }, + { + guidance: true, + description: + '块级触发区域周围请留出少量空间:按住时它会从中心放大约 7%,直到菜单关闭。', + }, { guidance: false, description: '将上下文菜单作为访问重要操作的唯一方式。', @@ -259,7 +269,7 @@ export const docsDense = { usage: { anatomy, description: - 'A right-click context menu that appears at the cursor position. Use to provide contextual actions for specific elements or regions. Under a finger (a coarse primary pointer): a held finger opens it at 735 ms, or on a lift after 400 ms.', + 'A right-click context menu that appears at the cursor position. Use to provide contextual actions for specific elements or regions. Under a finger (a coarse primary pointer): a held finger opens it at 735 ms, or on a lift after 400 ms; a block trigger whose menu opens at the finger grows from 150 ms.', bestPractices: [ {guidance: true, description: 'Keep items concise and action-oriented.'}, { @@ -280,6 +290,11 @@ export const docsDense = { guidance: true, description: 'Keep a visible mobile entry point for important actions.', }, + { + guidance: true, + description: + 'Leave room around a block trigger: a held finger grows it about 7%.', + }, { guidance: false, description: 'Use as the only way to access important actions.', diff --git a/packages/core/src/ContextMenu/ContextMenu.test.tsx b/packages/core/src/ContextMenu/ContextMenu.test.tsx index 17213536c4441..e65447bba669b 100644 --- a/packages/core/src/ContextMenu/ContextMenu.test.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.test.tsx @@ -603,113 +603,113 @@ describe('ContextMenu', () => { fireEvent.contextMenu(trigger, {clientX: 0, clientY: 0, detail: 0}); expect(HTMLElement.prototype.showPopover).toHaveBeenCalled(); }); +}); - describe('a menu that was never opened', () => { - it('adds no popover element and no menu until its first open, which mounts and shows it', () => { - render( - -
Right-click me
-
, - ); - expect(document.querySelector('[popover]')).toBeNull(); - expect(screen.queryByRole('menu', {hidden: true})).toBeNull(); - openMenu(); - const popover = document.querySelector('[popover]'); - expect(popover).not.toBeNull(); - expect(popover?.hasAttribute('popover-open')).toBe(true); - expect( - screen.getByRole('menuitem', {name: 'Cut', hidden: true}), - ).toBeInTheDocument(); - }); +describe('a menu that was never opened', () => { + it('adds no popover element and no menu until its first open, which mounts and shows it', () => { + render( + +
Right-click me
+
, + ); + expect(document.querySelector('[popover]')).toBeNull(); + expect(screen.queryByRole('menu', {hidden: true})).toBeNull(); + openMenu(); + const popover = document.querySelector('[popover]'); + expect(popover).not.toBeNull(); + expect(popover?.hasAttribute('popover-open')).toBe(true); + expect( + screen.getByRole('menuitem', {name: 'Cut', hidden: true}), + ).toBeInTheDocument(); + }); - it('shows a first open as its layer attaches, and a later open within the event', () => { - render( - -
Right-click me
-
, + it('shows a first open as its layer attaches, and a later open within the event', () => { + render( + +
Right-click me
+
, + ); + const trigger = screen.getByText('Right-click me'); + const open = () => + trigger.dispatchEvent( + new MouseEvent('contextmenu', {bubbles: true, cancelable: true}), ); - const trigger = screen.getByText('Right-click me'); - const open = () => - trigger.dispatchEvent( - new MouseEvent('contextmenu', {bubbles: true, cancelable: true}), - ); - const showPopover = vi.mocked(HTMLElement.prototype.showPopover); - act(() => { - open(); - // No layer yet: the show waits for its popover element. - expect(showPopover).not.toHaveBeenCalled(); - }); - // The commit attached the layer, which showed it. - expect(showPopover).toHaveBeenCalledTimes(1); - fireEvent.keyDown(document, {key: 'Escape'}); - act(() => { - open(); - // Mounted: shown in the event itself. - expect(showPopover).toHaveBeenCalledTimes(2); - }); + const showPopover = vi.mocked(HTMLElement.prototype.showPopover); + act(() => { + open(); + // No layer yet: the show waits for its popover element. + expect(showPopover).not.toHaveBeenCalled(); }); - - it('a keyboard open moves focus to the first item once the menu shows, and Escape returns it', async () => { - render( - - - , - ); - const button = screen.getByRole('button', {name: 'Right-click me'}); - button.focus(); - // Shift+F10 / the Menu key: a contextmenu at (0, 0) with no click count. - fireEvent.contextMenu(button, {clientX: 0, clientY: 0, detail: 0}); - await waitFor(() => { - expect( - screen.getByRole('menuitem', {name: 'Cut', hidden: true}), - ).toHaveFocus(); - }); - fireEvent.keyDown(document, {key: 'Escape'}); - expect(button).toHaveFocus(); + // The commit attached the layer, which showed it. + expect(showPopover).toHaveBeenCalledTimes(1); + fireEvent.keyDown(document, {key: 'Escape'}); + act(() => { + open(); + // Mounted: shown in the event itself. + expect(showPopover).toHaveBeenCalledTimes(2); }); + }); - it('carries the same trigger and cursor anchor attributes as after it opened', () => { - render( - -
Right-click me
-
, - ); - const trigger = screen.getByTestId('ctx'); - const describe = () => ({ - trigger: [...trigger.attributes].map(a => `${a.name}=${a.value}`), - anchor: [...(trigger.lastElementChild?.attributes ?? [])].map( - a => a.name, - ), - anchorName: ( - (trigger.lastElementChild as HTMLElement | null)?.style as unknown as - Record | undefined - )?.anchorName, - }); - const before = describe(); - openMenu(); - fireEvent.keyDown(document, {key: 'Escape'}); - expect(describe()).toEqual(before); - expect(trigger.hasAttribute('aria-controls')).toBe(false); - expect(trigger.hasAttribute('aria-haspopup')).toBe(false); + it('a keyboard open moves focus to the first item once the menu shows, and Escape returns it', async () => { + render( + + + , + ); + const button = screen.getByRole('button', {name: 'Right-click me'}); + button.focus(); + // Shift+F10 / the Menu key: a contextmenu at (0, 0) with no click count. + fireEvent.contextMenu(button, {clientX: 0, clientY: 0, detail: 0}); + await waitFor(() => { + expect( + screen.getByRole('menuitem', {name: 'Cut', hidden: true}), + ).toHaveFocus(); }); + fireEvent.keyDown(document, {key: 'Escape'}); + expect(button).toHaveFocus(); + }); - it('keeps its layer mounted after the first close, and a second open shows the same menu', () => { - render( - -
Right-click me
-
, - ); - openMenu(); - const menu = screen.getByRole('menu', {hidden: true}); - fireEvent.keyDown(document, {key: 'Escape'}); - expect(menu.isConnected).toBe(true); - expect( - document.querySelector('[popover]')?.hasAttribute('popover-open'), - ).toBe(false); - openMenu(); - expect(screen.getByRole('menu', {hidden: true})).toBe(menu); - expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(2); + it('carries the same trigger and cursor anchor attributes as after it opened', () => { + render( + +
Right-click me
+
, + ); + const trigger = screen.getByTestId('ctx'); + const describe = () => ({ + trigger: [...trigger.attributes].map(a => `${a.name}=${a.value}`), + anchor: [...(trigger.lastElementChild?.attributes ?? [])].map( + a => a.name, + ), + anchorName: ( + (trigger.lastElementChild as HTMLElement | null)?.style as unknown as + Record | undefined + )?.anchorName, }); + const before = describe(); + openMenu(); + fireEvent.keyDown(document, {key: 'Escape'}); + expect(describe()).toEqual(before); + expect(trigger.hasAttribute('aria-controls')).toBe(false); + expect(trigger.hasAttribute('aria-haspopup')).toBe(false); + }); + + it('keeps its layer mounted after the first close, and a second open shows the same menu', () => { + render( + +
Right-click me
+
, + ); + openMenu(); + const menu = screen.getByRole('menu', {hidden: true}); + fireEvent.keyDown(document, {key: 'Escape'}); + expect(menu.isConnected).toBe(true); + expect( + document.querySelector('[popover]')?.hasAttribute('popover-open'), + ).toBe(false); + openMenu(); + expect(screen.getByRole('menu', {hidden: true})).toBe(menu); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(2); }); }); @@ -939,6 +939,11 @@ describe.each([ }, ); + it('leaves a disabled trigger out of the press machine (spec:AST-094 FR24)', () => { + const trigger = renderMenu({isDisabled: true}); + expect(trigger).not.toHaveAttribute('data-astryx-pressable'); + }); + it('lights no row when a touch opens it: focus goes to the menu itself, not its first item', () => { const trigger = renderMenu(); const press = finger(trigger); @@ -1178,6 +1183,11 @@ describe.each([ expect(onTouchEnd).toHaveBeenCalledTimes(1); }); + it('makes the trigger a pressable surface of core’s one press machine', () => { + const trigger = renderMenu(); + expect(trigger).toHaveAttribute('data-astryx-pressable'); + }); + it('keeps today’s menu with presentation="popover": a held finger opens it at 500 ms through today’s long press, with no growth and no scroll lock', () => { const trigger = renderMenu({presentation: 'popover'}); const shows = () => @@ -1713,6 +1723,34 @@ describe('ContextMenu compound mode', () => { expect(anchor.style.top).toBe('40px'); }); + it("places a right-click's anchor at the pointer offset inside a scaled container, with no scale of its own", () => { + render( + +
Right-click me
+
, + ); + const trigger = screen.getByTestId('ctx'); + // A 200x120 trigger inside an ancestor scaled to 0.5. + Object.defineProperty(trigger, 'offsetWidth', {value: 200}); + Object.defineProperty(trigger, 'offsetHeight', {value: 120}); + trigger.getBoundingClientRect = () => + ({ + left: 100, + top: 50, + right: 200, + bottom: 110, + width: 100, + height: 60, + }) as DOMRect; + fireEvent.contextMenu(trigger, {clientX: 150, clientY: 80, detail: 1}); + const anchor = getCursorAnchor(trigger); + expect(anchor.style.left).toBe('50px'); + expect(anchor.style.top).toBe('30px'); + // The ancestor's scale is not the press feedback's growth: nothing to cancel. + expect(anchor.style.scale).toBe(''); + expect(anchor.style.translate).toBe(''); + }); + it('anchors a keyboard-invoked menu to the trigger bottom-left', () => { render( diff --git a/packages/core/src/ContextMenu/ContextMenu.tsx b/packages/core/src/ContextMenu/ContextMenu.tsx index dafc716443ee3..83adbec81a3c2 100644 --- a/packages/core/src/ContextMenu/ContextMenu.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.tsx @@ -40,6 +40,7 @@ * - /packages/core/src/ContextMenu/ContextMenu.test.tsx * - /packages/core/src/ContextMenu/ContextMenuTouch.ts (the touch layer's one import site) * - /packages/core/src/ContextMenu/contextMenuPress.ts (touch timeline) + * - /packages/core/src/ContextMenu/contextMenuPressFeedback.ts (press feedback) * - /packages/core/src/ContextMenu/index.ts * - /apps/storybook/stories/ContextMenu.stories.tsx * - /packages/cli/assets/templates/blocks/components/ContextMenu/ (showcase blocks) @@ -82,6 +83,7 @@ import {useMenuOverflow} from '../DropdownMenu/useMenuOverflow'; import {useMenuDrillIn} from '../DropdownMenu/useMenuDrillIn'; import {useInternalListFocus} from '../hooks/useListFocus'; import {useLongPress} from '../hooks/useLongPress'; +import {pressableProps} from '../utils/pressFeedback'; import {useMenuPress} from '../hooks/useMenuPress'; import {useTypeahead} from '../hooks/useTypeahead'; import {shadowHostOf} from '../utils/flatTree'; @@ -236,6 +238,20 @@ const styles = stylex.create({ }, }); +/** An element's own computed `scale` on each axis; 1 where it has none. */ +function ownScale(element: HTMLElement): {x: number; y: number} { + const value = + element.ownerDocument.defaultView?.getComputedStyle(element).scale ?? ''; + if (value === '' || value === 'none') { + return {x: 1, y: 1}; + } + const [x, y = x] = value.trim().split(/\s+/).map(Number.parseFloat); + return { + x: Number.isFinite(x) && x > 0 ? x : 1, + y: Number.isFinite(y) && y > 0 ? y : 1, + }; +} + /** * Keeps the 1px cursor anchor inside the trigger along one axis. A point in * the trigger's last pixel (Shift+F10 opens on its bottom edge) steps back by @@ -425,18 +441,25 @@ export function ContextMenu({ const currentTitle = currentSubmenu?.label ?? label; const sheetLabel = typeof currentTitle === 'string' ? currentTitle : label; - // The mounted touch layer, which says whether it times a finger. + // The mounted touch layer, for its press feedback and whether it times a + // finger. const touchHandleRef = useRef(null); + // The menu closed: a finger's menu is over, and the trigger returns with + // the close itself (run by every close handler below). + const endTouchOpen = useCallback(() => { + touchHandleRef.current?.release(); + }, []); const updateOpenState = useCallback( (nextIsOpen: boolean) => { if (!nextIsOpen) { setSubmenuPath([]); + endTouchOpen(); } setIsOpen(nextIsOpen); onOpenChange?.(nextIsOpen); }, - [onOpenChange], + [endTouchOpen, onOpenChange], ); const outsidePress = useOutsidePress(); @@ -450,6 +473,7 @@ export function ContextMenu({ keepsMountAfterShow: true, onHide: useCallback(() => { outsidePress.stop(); + endTouchOpen(); setIsOpen(false); onOpenChange?.(false); // Restore focus to the element that was focused before opening. @@ -458,7 +482,7 @@ export function ContextMenu({ if (toRestore && document.contains(toRestore)) { toRestore.focus(); } - }, [onOpenChange, outsidePress]), + }, [endTouchOpen, onOpenChange, outsidePress]), onShow: useCallback(() => { setIsOpen(true); onOpenChange?.(true); @@ -625,6 +649,16 @@ export function ContextMenu({ [listNavKeyDown, closeMenu, typeahead, ownsEvent], ); + // How much the press feedback has grown a block trigger from its center: + // the trigger's own `scale`, not an ancestor's transform. + const triggerScale = useCallback((): {x: number; y: number} => { + const trigger = triggerRef.current; + if (trigger == null || triggerAs === 'span') { + return {x: 1, y: 1}; + } + return ownScale(trigger); + }, [triggerAs]); + // Place the 1px cursor anchor at a point in the trigger's local // coordinate space and open the menu. Positioning the anchor inside the // trigger (rather than storing viewport coordinates on the menu itself) is @@ -641,13 +675,29 @@ export function ContextMenu({ focusTarget: 'first' | 'menu' = 'first', ) => { const rect = triggerRef.current?.getBoundingClientRect(); - const x = rect ? keepAnchorInside(localX, rect.width) : localX; - const y = rect ? keepAnchorInside(localY, rect.height) : localY; + const scale = rect ? triggerScale() : {x: 1, y: 1}; + const x = rect ? keepAnchorInside(localX, rect.width / scale.x) : localX; + const y = rect ? keepAnchorInside(localY, rect.height / scale.y) : localY; positionRef.current = {x, y}; const anchorEl = cursorAnchorRef.current; if (anchorEl) { anchorEl.style.left = `${x}px`; anchorEl.style.top = `${y}px`; + // A trigger the press feedback has grown (from its center) would + // carry the anchor with it, and engines differ on whether anchor + // positioning follows transforms. Cancel the scale at the anchor, so + // its box is the same either way: where the point is on screen. + if (rect != null && (scale.x !== 1 || scale.y !== 1)) { + const centerX = rect.width / scale.x / 2; + const centerY = rect.height / scale.y / 2; + anchorEl.style.transformOrigin = '0 0'; + anchorEl.style.scale = `${1 / scale.x} ${1 / scale.y}`; + anchorEl.style.translate = `${(x - centerX) * (1 / scale.x - 1)}px ${(y - centerY) * (1 / scale.y - 1)}px`; + } else { + anchorEl.style.removeProperty('transform-origin'); + anchorEl.style.removeProperty('scale'); + anchorEl.style.removeProperty('translate'); + } } // Remember the element focused before opening so we can restore it on // close (Escape or outside-click), instead of dropping focus to . @@ -665,7 +715,7 @@ export function ContextMenu({ layer.show(); } }, - [layer, updateOpenState, usesBottomSheet], + [layer, updateOpenState, usesBottomSheet, triggerScale], ); // The hold's commit: the menu shows at its moment or at the lift, so its @@ -684,19 +734,25 @@ export function ContextMenu({ ); // A client point in the trigger's own coordinates, where the cursor anchor - // lives. It stays inside the trigger's box. + // lives. It is the offset from the trigger's laid-out box, before the press + // feedback's scale, and it stays inside that box. const toTriggerLocal = useCallback( (point: {x: number; y: number}): {x: number; y: number} => { const rect = triggerRef.current?.getBoundingClientRect(); if (rect == null) { return point; } + const scale = triggerScale(); + const width = rect.width / scale.x; + const height = rect.height / scale.y; + const left = rect.left + (rect.width - width) / 2; + const top = rect.top + (rect.height - height) / 2; return { - x: Math.min(Math.max(point.x - rect.left, 0), rect.width), - y: Math.min(Math.max(point.y - rect.top, 0), rect.height), + x: Math.min(Math.max(point.x - left, 0), width), + y: Math.min(Math.max(point.y - top, 0), height), }; }, - [], + [triggerScale], ); const handleContextMenu = useCallback( @@ -957,6 +1013,10 @@ export function ContextMenu({ ({ + elements: [] as HTMLElement[], + play: vi.fn(), + animate: vi.fn(), +})); + +vi.mock('../motion/animator', async importOriginal => { + const original = await importOriginal(); + return { + ...original, + createMotionEngine: (element: HTMLElement) => { + animator.elements.push(element); + const pending = () => ({finished: new Promise(() => {})}); + return { + element, + play: (...args: unknown[]) => { + animator.play(element, ...args); + return pending(); + }, + animate: (...args: unknown[]) => { + animator.animate(element, ...args); + return pending(); + }, + stop: () => {}, + dispose: () => {}, + get: () => 1, + getVelocity: () => 0, + reducedMotion: () => 'off', + }; + }, + }; +}); + +interface Contact { + id: number; + x: number; + y: number; +} + +function dispatchTouch( + type: 'touchstart' | 'touchmove' | 'touchend', + target: EventTarget, + changed: Contact[], + all: Contact[], +) { + const event = new Event(type, {bubbles: true, cancelable: true}); + const toTouch = (contact: Contact) => ({ + identifier: contact.id, + clientX: contact.x, + clientY: contact.y, + target, + }); + Object.defineProperty(event, 'changedTouches', {value: changed.map(toTouch)}); + Object.defineProperty(event, 'touches', {value: all.map(toTouch)}); + Object.defineProperty(event, 'timeStamp', {value: performance.now()}); + target.dispatchEvent(event); +} + +const advance = (ms: number) => + act(() => { + vi.advanceTimersByTime(ms); + }); + +/** The press machine's "on", as core's press machine writes it. */ +async function pressMachineOn(trigger: HTMLElement) { + trigger.setAttribute(PRESSED_ATTRIBUTE, 'on'); + await act(async () => {}); +} + +/** The press machine lets the press go. */ +async function pressMachineOff(trigger: HTMLElement) { + trigger.removeAttribute(PRESSED_ATTRIBUTE); + await act(async () => {}); +} + +// The gesture host remembers the latest event time, and each install of fake +// timers restarts `performance.now()` near 0. So every case moves the clock on. +let clockEpoch = 0; + +describe('ContextMenu’s growth under a finger', () => { + beforeEach(() => { + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + matches: query === '(pointer: coarse)', + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); + vi.useFakeTimers({ + toFake: [ + 'setTimeout', + 'clearTimeout', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'performance', + 'Date', + ], + }); + clockEpoch += 100_000; + vi.advanceTimersByTime(clockEpoch); + }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + animator.elements.length = 0; + }); + + function renderMenu() { + render( + +
Hold me
+
, + ); + return screen.getByTestId('ctx'); + } + + const scaleBack = (trigger: HTMLElement) => + animator.animate.mock.calls.filter( + ([element, to]) => + element === trigger && (to as {scale?: number}).scale === 1, + ); + + it('springs a grown trigger back when the finger lifts before the commit', async () => { + const trigger = renderMenu(); + const contact = {id: 1, x: 20, y: 20}; + dispatchTouch('touchstart', trigger, [contact], [contact]); + advance(150); + await pressMachineOn(trigger); + expect(growths(trigger)).toHaveLength(1); + advance(100); + dispatchTouch('touchend', trigger, [contact], []); + expect(scaleBack(trigger)).toHaveLength(1); + }); + + const growths = (trigger: HTMLElement) => + animator.animate.mock.calls.filter( + ([element, to]) => + element === trigger && ((to as {scale?: number}).scale ?? 1) > 1, + ); + const startOf = (call: unknown[]) => (call[3] as {at: number}).at; + + it('starts the growth at the onset, 150 ms after the finger lands, when the press machine marks the press as the finger lands', async () => { + const trigger = renderMenu(); + const contact = {id: 1, x: 20, y: 20}; + const landedAt = performance.now(); + dispatchTouch('touchstart', trigger, [contact], [contact]); + // Outside a scroll view the press machine marks a press as it lands. + await pressMachineOn(trigger); + advance(149); + expect(growths(trigger)).toHaveLength(0); + advance(1); + expect(growths(trigger).map(startOf)).toEqual([landedAt + 150]); + }); + + it('grows the trigger at the onset when the press machine marks the press before the recognizer has the finger', async () => { + const trigger = renderMenu(); + const contact = {id: 1, x: 20, y: 20}; + // In a browser the press machine's mark comes first: its listener runs + // before the recognizer's, and the mark is read between the two. + await pressMachineOn(trigger); + const landedAt = performance.now(); + dispatchTouch('touchstart', trigger, [contact], [contact]); + advance(150); + expect(growths(trigger).map(startOf)).toEqual([landedAt + 150]); + }); + + it('still grows at the onset after a drift past the press machine’s 10 px that the recognizer’s 11.5 px tolerance still follows', async () => { + const trigger = renderMenu(); + const contact = {id: 1, x: 20, y: 20}; + const landedAt = performance.now(); + dispatchTouch('touchstart', trigger, [contact], [contact]); + await pressMachineOn(trigger); + advance(60); + // An 11 px drift: the press machine lets the press go at 10 px. + const drifted = {...contact, x: 31}; + dispatchTouch('touchmove', trigger, [drifted], [drifted]); + await pressMachineOff(trigger); + advance(90); + expect(growths(trigger).map(startOf)).toEqual([landedAt + 150]); + }); + + it('grows at the onset after a drift the press machine never marks, as inside a scroll view, that the recognizer still follows', async () => { + const trigger = renderMenu(); + const contact = {id: 1, x: 20, y: 20}; + const landedAt = performance.now(); + dispatchTouch('touchstart', trigger, [contact], [contact]); + advance(60); + // An (8, 8) px drift, 11.3 px: past the machine's 10 px, so it never + // marks the press, and inside the recognizer's 11.5 px. + const drifted = {...contact, x: 28, y: 28}; + dispatchTouch('touchmove', trigger, [drifted], [drifted]); + advance(90); + expect(growths(trigger).map(startOf)).toEqual([landedAt + 150]); + }); + + it('grows at the onset, 150 ms after the finger lands, even when the press machine marks the press later', async () => { + const trigger = renderMenu(); + const contact = {id: 1, x: 20, y: 20}; + const landedAt = performance.now(); + dispatchTouch('touchstart', trigger, [contact], [contact]); + advance(150); + expect(growths(trigger).map(startOf)).toEqual([landedAt + 150]); + // Inside a scroll view the machine's own timer marks it later. + advance(90); + await pressMachineOn(trigger); + expect(growths(trigger)).toHaveLength(1); + }); + + it('grows a pen’s press at the onset from the press machine’s mark, which finds the recognizer following the pen', async () => { + const trigger = renderMenu(); + const landedAt = performance.now(); + const down = new Event('pointerdown', {bubbles: true, cancelable: true}); + Object.assign(down, { + pointerId: 7, + pointerType: 'pen', + isPrimary: true, + button: 0, + buttons: 1, + clientX: 20, + clientY: 20, + }); + Object.defineProperty(down, 'timeStamp', {value: landedAt}); + // The press machine marks the pen's press from the same pointerdown. + trigger.dispatchEvent(down); + await act(async () => {}); + expect(trigger.getAttribute(PRESSED_ATTRIBUTE)).toBe('on'); + advance(149); + expect(growths(trigger)).toHaveLength(0); + advance(1); + expect(growths(trigger).map(startOf)).toEqual([landedAt + 150]); + }); + + it('does not grow the trigger for a press its recognizer does not follow, such as a mouse', async () => { + const trigger = renderMenu(); + await pressMachineOn(trigger); + expect(growths(trigger)).toHaveLength(0); + }); +}); diff --git a/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx b/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx index a42432403acf2..136b9bc943635 100644 --- a/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx +++ b/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx @@ -7,7 +7,7 @@ * @input ContextMenu's touch layer (ContextMenuTouch.ts), the gesture * foundation and the UIKit context-menu constants * @output ContextMenuTouchLayer: renders nothing; runs the trigger's press - * recognizer while ContextMenu is under the touch model + * recognizer and press feedback while ContextMenu is under the touch model * @position Rendered by ContextMenu while the touch model applies * * SYNC: When modified, update /packages/core/src/ContextMenu/ContextMenu.tsx @@ -15,19 +15,28 @@ import {useImperativeHandle, useRef, type Ref, type RefObject} from 'react'; import {flushSync} from 'react-dom'; +import {isTerminal} from '../gestures/GestureRecognizer'; import {playHaptic} from '../gestures/haptics'; import {useGestureRecognizer} from '../gestures/useGestureRecognizer'; import {contextMenu} from '../motion/uikit/contextMenu'; -import {ContextMenuPressRecognizer} from './ContextMenuTouch'; +import {isElement} from '../utils/nodeType'; +import { + ContextMenuPressRecognizer, + useContextMenuPressFeedback, +} from './ContextMenuTouch'; /** What ContextMenu reaches of a mounted touch layer. */ export interface ContextMenuTouchHandle { + /** The menu the press opened closed: the trigger returns. */ + release(): void; /** Whether the recognizer is timing a finger on the trigger. */ isTracking(): boolean; } export interface ContextMenuTouchLayerProps { triggerRef: RefObject; + /** False for an inline trigger, which does not grow. */ + isShown: boolean; /** Receives the layer's handle as it mounts, and null as it unmounts. */ ref?: Ref; /** Open the menu at a client point (where the finger landed). */ @@ -49,15 +58,34 @@ export interface ContextMenuTouchLayerProps { * lift sends no click and no compatibility mouse events. The menu opens where * the finger landed: at `menuDisplayMs` if it is still down, or at the lift. * A lift before the commit, a second finger or a scroll gets no menu. So does - * a drift past the tolerance before the menu shows. + * a drift past the tolerance before the menu shows. The press feedback grows + * the trigger from the onset, 150 ms after the finger lands, while the + * recognizer follows the finger. It springs back when the recognizer ends the + * press with no menu, and it holds while that menu is up. */ export function ContextMenuTouchLayer({ triggerRef, + isShown, ref, onOpen, onCommit, onCancel, }: ContextMenuTouchLayerProps): null { + // Whether the recognizer follows a finger or a pen now. It is set below, + // where the recognizer exists. + const isFollowedRef = useRef<() => boolean>(() => false); + // When the finger the recognizer follows landed, for the growth's onset. + const landedAtRef = useRef<() => number | null>(() => null); + const followsFingerRef = useRef<() => boolean>(() => false); + const feedback = useContextMenuPressFeedback( + triggerRef, + isShown, + isFollowedRef, + landedAtRef, + followsFingerRef, + ); + const feedbackRef = useRef(feedback); + feedbackRef.current = feedback; const onOpenRef = useRef(onOpen); onOpenRef.current = onOpen; const onCommitRef = useRef(onCommit); @@ -73,16 +101,31 @@ export function ContextMenuTouchLayer({ () => { const recognizer = new ContextMenuPressRecognizer(); // Its target-action listeners never hear `failed`, so a press that ends - // with no menu releases what the commit mounted from here. - recognizer.addTransitionListener((_press, _from, to) => { + // with no menu springs back from here. + recognizer.addTransitionListener((press, _from, to) => { if (to === 'failed' || to === 'cancelled') { + feedbackRef.current.end(press.clockTime); onCancelRef.current?.(); } }); + // A finger's growth counts from the landing the recognizer records as + // it takes the touch. A pen's press waits for the press machine's mark. + recognizer.onPressBegan = (touch, time) => { + if (touch.pointerType === 'touch') { + feedbackRef.current.begin( + time, + isElement(touch.target) ? touch.target : null, + ); + } + }; return recognizer; }, press => { const open = () => { + // The press feedback grew the trigger from its center, and the cursor + // anchor inside it moves with that scale. So stop the growth where it + // is. ContextMenu places the anchor under the finger. + feedbackRef.current.hold(); hasPressOpenedRef.current = true; // eslint-disable-next-line @eslint-react/dom-no-flush-sync -- the menu shows in the task that decides it, as UIKit shows it at its moment, not a scheduled task later flushSync(() => { @@ -117,9 +160,17 @@ export function ContextMenuTouchLayer({ }, ); + isFollowedRef.current = () => + recognizer.numberOfTouches > 0 && !isTerminal(recognizer.state); + landedAtRef.current = () => recognizer.press?.time ?? null; + followsFingerRef.current = () => + isFollowedRef.current() && + recognizer.trackedTouches[0]?.pointerType === 'touch'; + useImperativeHandle( ref, () => ({ + release: () => feedbackRef.current.release(), isTracking: () => recognizer.numberOfTouches > 0, }), [recognizer], diff --git a/packages/core/src/ContextMenu/ContextMenuTouchLayerProps.test.tsx b/packages/core/src/ContextMenu/ContextMenuTouchLayerProps.test.tsx new file mode 100644 index 0000000000000..fd65b7b69febd --- /dev/null +++ b/packages/core/src/ContextMenu/ContextMenuTouchLayerProps.test.tsx @@ -0,0 +1,62 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file ContextMenuTouchLayerProps.test.tsx + * @input ContextMenu under a coarse pointer, with its touch layer replaced + * @output Checks what ContextMenu asks of its touch layer: a block trigger + * grows, and a trigger whose menu is a bottom sheet does not + * @position Unit tests of packages/core/src/ContextMenu/ContextMenu.tsx + */ + +import {render, waitFor} from '@testing-library/react'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {ContextMenu} from './ContextMenu'; +import {ContextMenuTouchLayer} from './ContextMenuTouchLayer'; + +vi.mock('./ContextMenuTouchLayer', () => ({ + ContextMenuTouchLayer: vi.fn(() => null), +})); + +function stubCoarsePointer() { + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + matches: query === '(pointer: coarse)', + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); +} + +describe('ContextMenu and its touch layer', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.mocked(ContextMenuTouchLayer).mockClear(); + }); + + async function shownProp(presentation?: 'bottom-sheet') { + stubCoarsePointer(); + render( + +
Hold me
+
, + ); + await waitFor(() => { + expect(ContextMenuTouchLayer).toHaveBeenCalled(); + }); + return vi.mocked(ContextMenuTouchLayer).mock.lastCall?.[0].isShown; + } + + it('grows a block trigger whose menu is anchored to it', async () => { + expect(await shownProp()).toBe(true); + }); + + it('does not grow a trigger whose menu is a bottom sheet', async () => { + expect(await shownProp('bottom-sheet')).toBe(false); + }); +}); diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index deddefbbed4bf..b83c187617373 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -20,24 +20,56 @@ import { type Page, } from '@astryxdesign/a11y-spec/playwright'; import { + blockMainThread, + busyWindowProof, + castStoryboard, + countBetween, drag, FRAME_STARVATION_MS, FrameStarvationError, + freezeTimers, + holdAnimations, installInputLog, + installLongTaskLog, + installTimerGate, + markElement, + merge, openStory, readInputLog, + releaseAnimations, + startScreencast, tap, + thawTimers, + timeline, touchDriver, + trackMarker, + traceRendering, + waitForAnimation, + withFinger, withStorybook, + writeEvidence, + animationStoryboard, type TouchDriver, } from '@astryxdesign/interaction-harness'; +import {solveSpring, toSpring} from '../../motion/spring'; import {contextMenu} from '../../motion/uikit/contextMenu'; +import {springs} from '../../motion/uikit/springs'; const storybook = withStorybook(test); -const {menuDisplayMs} = contextMenu.press; -// Core's press machine believes a press 150 ms after it lands -// (`utils/pressGesture.ts`). That module loads a StyleX stylesheet, which -// this Node-side spec cannot, so the onset is restated here. +const {commitMs, menuDisplayMs, heldScale} = contextMenu.press; +// The growth starts at the onset, 150 ms after the finger lands +// (`PRESS_ONSET_MS` in `utils/pressGesture.ts`). That module loads a StyleX +// stylesheet, which this Node-side spec cannot, so the onset is restated here. +const feedbackOnsetMs = 150; + +/** The growth's expected curve: `springs.contextMenuLift` from 1 to the held scale. */ +function playedCurve() { + const curve = solveSpring(toSpring(springs.contextMenuLift), { + from: 1, + to: heldScale, + }); + return {at: (t: number) => curve.value(Math.max(0, t))}; +} interface TouchStory { driver: TouchDriver; @@ -51,14 +83,17 @@ async function openTouchStory( story = 'core-contextmenu--touch-long-press', ): Promise { await installInputLog(page); + await installLongTaskLog(page); // Note when the menu is asked to show, on the page clock (no per-frame // work: rendering proofs share this page). await page.addInitScript(() => { const w = window as unknown as { __menuShownAt?: number | null; + __liftHandledAt?: number | null; __menuShownTimerAt?: number | null; }; w.__menuShownAt = null; + w.__liftHandledAt = null; w.__menuShownTimerAt = null; // When the timer task that runs now started. The hold's moments come from // the gesture arena's timers. The menu commits in the task that decides @@ -85,6 +120,15 @@ async function openTouchStory( timeout, ...args, )) as typeof window.setTimeout; + // When the page gets to the lift (the event's own timestamp is the + // hardware's, or the script's): the menu shows in the same task. + addEventListener( + 'touchend', + () => { + w.__liftHandledAt = performance.now(); + }, + {capture: true, passive: true}, + ); const show = HTMLElement.prototype.showPopover; HTMLElement.prototype.showPopover = function ( this: HTMLElement, @@ -148,6 +192,12 @@ async function lastInput( const touchStartedAt = async (page: Page) => lastInput(page, 'touchstart'); +const presentedScale = async (trigger: Locator) => + trigger.evaluate(element => { + const scale = getComputedStyle(element).scale; + return scale === 'none' ? 1 : Number.parseFloat(scale); + }); + /** A finger held still for `ms`, then lifted. */ const hold = (at: {x: number; y: number}, ms: number) => tap(at, {holdMs: ms}); @@ -262,6 +312,554 @@ test('the menu mounts at the commit, closed and out of the accessibility tree, a await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeVisible(); }); +test('with presentation="popover" a finger keeps today’s long press: the menu at 500 ms, with no growth and no iOS timeline', async ({ + page, +}) => { + const {driver, trigger, center} = await openTouchStory( + page, + 'core-contextmenu--touch-long-press-popover', + ); + // A coarse primary pointer: only the caller's choice keeps today's menu. + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(true); + expect(await trigger.getAttribute('data-astryx-pressable')).toBeNull(); + const playing = driver.play(hold(center, 1000)); + await page.waitForTimeout(350); + const scaleBeforeOpen = await presentedScale(trigger); + // Still held: the menu is up. At the lift, emulated Chromium sends + // compatibility mouse events that a device sends no click for. They dismiss + // today's menu. The touch model's recognizer claims the finger instead. + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeVisible({ + timeout: 550, + }); + await playing; + const start = await touchStartedAt(page); + const shown = await menuShownAt(page); + expect(shown, 'the menu never showed').not.toBeNull(); + const after = (shown as number) - start; + // Today's 500 ms, give or take a task and the render that shows the menu; + // far from the touch model's 735 ms. + expect(after).toBeGreaterThanOrEqual(500); + expect(after).toBeLessThan(550); + expect(scaleBeforeOpen).toBe(1); +}); + +test('a finger where the primary pointer is fine keeps today’s long press: the menu at 500 ms, with no growth', async ({ + browser, + page, +}) => { + // A touch screen whose primary pointer is fine (a laptop with a trackpad). + const context = await browser.newContext({ + viewport: page.viewportSize() ?? {width: 402, height: 874}, + hasTouch: true, + isMobile: false, + }); + const fine = await context.newPage(); + try { + await installInputLog(fine); + await openStory(fine, storybook(), 'core-contextmenu--touch-long-press'); + const coarse = await fine.evaluate( + () => matchMedia('(pointer: coarse)').matches, + ); + test.skip( + coarse, + 'this engine reports a coarse primary pointer whenever touch is emulated; the fine-primary finger is covered in jsdom', + ); + const trigger = fine.getByTestId('touch-context-menu'); + const box = await trigger.boundingBox(); + if (box == null) { + throw new Error('no trigger'); + } + expect(await trigger.getAttribute('data-astryx-pressable')).toBeNull(); + const driver = await touchDriver(fine); + const center = {x: box.x + box.width / 2, y: box.y + box.height / 2}; + const playing = driver.play(hold(center, 1000)); + await fine.waitForTimeout(350); + const scaleBeforeOpen = await presentedScale(trigger); + await expect(fine.getByRole('menu', {name: 'Photo actions'})).toBeVisible({ + timeout: 550, + }); + await playing; + expect(scaleBeforeOpen).toBe(1); + } finally { + await context.close(); + } +}); + +test('under Reduce Motion the card does not grow, and the menu still opens at 735 ms', async ({ + page, +}) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + const {driver, trigger, center} = await openTouchStory(page); + const playing = driver.play(hold(center, menuDisplayMs + 200)); + // Sampled while the finger is down: before the commit, after it, and just + // before the menu. + const scales: number[] = []; + for (const wait of [300, 250, 100]) { + await page.waitForTimeout(wait); + scales.push(await presentedScale(trigger)); + } + await playing; + const start = await touchStartedAt(page); + const shown = await menuShownAt(page); + expect(scales).toEqual([1, 1, 1]); + expect(await trigger.evaluate(el => el.getAnimations().length)).toBe(0); + expect(shown, 'the menu never showed').not.toBeNull(); + const after = (shown as number) - start; + // The touch's stamp and the page clock are each coarsened (0.1 ms). + expect(after).toBeGreaterThanOrEqual(menuDisplayMs - 0.5); + expect(after).toBeLessThan(menuDisplayMs + 20); + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeVisible(); +}); + +test('the trigger’s selection and callout lock: its own style keeps it off at rest, and the gesture host refuses a selection for a held press', async ({ + page, +}) => { + const {driver, trigger, center} = await openTouchStory(page); + // WebKit spells it `-webkit-user-select` only. + const readLock = async () => + trigger.evaluate(el => { + const host = el as HTMLElement; + const computed = getComputedStyle(host); + return { + inline: + host.style.getPropertyValue('user-select') || + host.style.getPropertyValue('-webkit-user-select'), + computed: + computed.getPropertyValue('user-select') || + computed.getPropertyValue('-webkit-user-select'), + }; + }); + // Off iOS, the host's lock refuses a selection that starts on the element. + const refusesSelection = async () => + trigger.evaluate(el => { + const event = new Event('selectstart', {bubbles: true, cancelable: true}); + el.dispatchEvent(event); + return event.defaultPrevented; + }); + // At rest the trigger's own style keeps selection off (spec:AST-017 + // FR59), and nothing refuses a selection. + const rest = await readLock(); + expect(rest.computed).toBe('none'); + expect(await refusesSelection()).toBe(false); + // A held finger, short of the commit: the host's lock refuses a selection. + const playing = driver.play(hold(center, commitMs - 100)); + await page.waitForTimeout(commitMs / 2); + const heldRefuses = await refusesSelection(); + const held = await readLock(); + await playing; + await page.waitForTimeout(100); + // Lifted with no menu: back to the rest state. + const liftedRefuses = await refusesSelection(); + const lifted = await readLock(); + expect(heldRefuses).toBe(true); + expect(held.computed).toBe('none'); + expect(liftedRefuses).toBe(false); + expect(lifted).toEqual(rest); + expect( + await page.getByRole('menu', {name: 'Photo actions'}).isVisible(), + ).toBe(false); +}); + +test('a touch open lights no row: the menu itself takes the focus, and every row keeps its resting background', async ({ + page, +}) => { + const {driver, center} = await openTouchStory(page); + const playing = driver.play(hold(center, menuDisplayMs + 300)); + const menu = page.getByRole('menu', {name: 'Photo actions'}); + await expect(menu).toBeVisible(); + // The focus moves on the frame after the menu shows. + await page.evaluate( + async () => + new Promise(resolve => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + resolve(); + }); + }); + }), + ); + await expect(menu).toBeFocused(); + // An internal focus target: no browser ring on it. + expect(await menu.evaluate(el => getComputedStyle(el).outlineStyle)).toBe( + 'none', + ); + const backgrounds = await menu + .getByRole('menuitem') + .evaluateAll(rows => + rows.map(row => getComputedStyle(row).backgroundColor), + ); + expect(new Set(backgrounds).size).toBe(1); + await playing; +}); + +test('a finger lifted after the commit gets the menu at the lift, and sends no click', async ({ + page, +}) => { + const {driver, center} = await openTouchStory(page); + await driver.play(hold(center, 530)); + await page.waitForTimeout(300); + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeVisible(); + const {lift, shown} = await page.evaluate(() => { + const w = window as unknown as { + __menuShownAt?: number | null; + __liftHandledAt?: number | null; + }; + return {lift: w.__liftHandledAt ?? null, shown: w.__menuShownAt ?? null}; + }); + expect(lift).not.toBeNull(); + expect(shown).not.toBeNull(); + // In the lift's own task: after it, and before the next frame could paint. + expect((shown as number) - (lift as number)).toBeGreaterThanOrEqual(0); + expect((shown as number) - (lift as number)).toBeLessThan(1000 / 60); + const log = await readInputLog(page); + const end = log.filter(record => record.type === 'touchend').at(-1); + expect(end?.defaultPrevented, JSON.stringify(log)).toBe(true); + expect(log.filter(record => record.type === 'click')).toEqual([]); +}); + +test('the lift after the menu shows keeps it open and sends no click', async ({ + page, +}) => { + const {driver, center} = await openTouchStory(page); + await driver.play(hold(center, menuDisplayMs + 150)); + await page.waitForTimeout(300); + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeVisible(); + const log = await readInputLog(page); + expect(log.filter(record => record.type === 'click')).toEqual([]); +}); + +test('the menu’s anchor sits under the finger, on screen and as laid out, while the trigger is grown, and the menu stays put', async ({ + page, +}) => { + const {driver, trigger} = await openTouchStory(page); + const box = await trigger.boundingBox(); + if (box == null) { + throw new Error('no trigger'); + } + const menu = page.getByRole('menu', {name: 'Photo actions'}); + // The cursor anchor (the trigger's last child), on screen and as laid out + // (engines differ on which one anchor positioning reads), and the menu. + const read = async () => + trigger.evaluate(el => { + const anchor = el.lastElementChild as HTMLElement | null; + const box = el.getBoundingClientRect(); + const host = el as HTMLElement; + const left = box.left + (box.width - host.offsetWidth) / 2; + const top = box.top + (box.height - host.offsetHeight) / 2; + const onScreen = anchor?.getBoundingClientRect(); + const list = document + .querySelector('[role="menu"]') + ?.getBoundingClientRect(); + return { + onScreen: onScreen == null ? null : {x: onScreen.x, y: onScreen.y}, + laidOut: + anchor == null + ? null + : { + x: left + host.clientLeft + anchor.offsetLeft, + y: top + host.clientTop + anchor.offsetTop, + }, + menu: list == null ? null : {x: list.x, y: list.y}, + }; + }); + // The middle of the card and near its far corner, where an anchor placed + // in the grown trigger's scaled coordinates would land furthest off. + for (const [fx, fy] of [ + [0.5, 0.5], + [0.9, 0.85], + ] as const) { + const p = { + x: Math.round(box.x + box.width * fx), + y: Math.round(box.y + box.height * fy), + }; + await driver.play(hold(p, menuDisplayMs + 100)); + await expect(menu).toBeVisible(); + const first = await read(); + await page.waitForTimeout(400); + const later = await read(); + await page.keyboard.press('Escape'); + await expect(menu).toBeHidden(); + // Let the trigger return to its size before the next press. + await page.waitForTimeout(1200); + const detail = JSON.stringify({p, first, later}); + expect(first.onScreen?.x, detail).toBeCloseTo(p.x, 0); + expect(first.onScreen?.y, detail).toBeCloseTo(p.y, 0); + expect(first.laidOut?.x, detail).toBeCloseTo(p.x, 0); + expect(first.laidOut?.y, detail).toBeCloseTo(p.y, 0); + expect(later.menu?.x, detail).toBeCloseTo(first.menu?.x ?? NaN, 0); + expect(later.menu?.y, detail).toBeCloseTo(first.menu?.y ?? NaN, 0); + } +}); + +test('a drag past the tolerance after the commit opens nothing', async ({ + page, +}) => { + const {driver, center} = await openTouchStory(page); + await driver.play( + drag( + center, + {x: center.x, y: center.y + 30}, + {durationMs: 50, holdMs: 500, restMs: 400}, + ), + ); + await page.waitForTimeout(200); + expect(await menuShownAt(page)).toBeNull(); +}); + +test('an early lift opens nothing, and the card springs back to its size', async ({ + page, +}) => { + const {driver, trigger, center} = await openTouchStory(page); + await driver.play(hold(center, 280)); + await page.waitForTimeout(commitMs + 200); + expect(await menuShownAt(page)).toBeNull(); + // The return spring settles within ~1.2 s; then the scale is gone. + await expect + .poll(async () => trigger.evaluate(el => (el as HTMLElement).style.scale), { + timeout: 3000, + }) + .toBe(''); + expect(await presentedScale(trigger)).toBe(1); +}); + +test('a second finger landing before the commit cancels: no menu, and the card springs back', async ({ + page, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + 'two-finger input is Chromium-only in Playwright (WebKit drops the first finger)', + ); + const {driver, trigger, center} = await openTouchStory(page); + // The first finger holds; a second lands 200 ms in, before the commit. + await driver.play( + merge( + hold(center, menuDisplayMs + 300), + withFinger(hold({x: center.x + 50, y: center.y}, 400), 1, 200), + ), + ); + await page.waitForTimeout(300); + expect(await menuShownAt(page)).toBeNull(); + expect(await presentedScale(trigger)).toBeCloseTo(1, 3); + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeHidden(); +}); + +test('a drift within the tolerance still opens the menu; past it on one axis cancels', async ({ + page, +}) => { + const {driver, center} = await openTouchStory(page); + // (8, 8): 11.3 px away, within the tolerance on each axis (a long press's + // 10 px distance would cancel it). A drift past the browser's own pan slop + // goes to the page instead, as a scroll view's pan takes it on iOS. Lifted + // after the commit: the menu opens at the lift. + await driver.play( + drag( + center, + {x: center.x + 8, y: center.y + 8}, + {durationMs: 60, holdMs: 100, restMs: commitMs}, + ), + ); + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeVisible(); + + const other = await openTouchStory(page); + await other.driver.play( + drag( + other.center, + {x: other.center.x, y: other.center.y + 12}, + {durationMs: 60, holdMs: 100, restMs: commitMs}, + ), + ); + await page.waitForTimeout(200); + expect(await menuShownAt(page)).toBeNull(); +}); + +test('the press feedback plays its computed curve on the compositor, frame by frame', async ({ + page, +}, testInfo) => { + const {driver, trigger, center} = await openTouchStory(page); + const played = playedCurve(); + const playing = driver.play(hold(center, commitMs + 300)); + await waitForAnimation(trigger, {minProgressMs: 16}); + await holdAnimations(page, trigger); + // Seek the held curve (its time 0 is the onset after the touch) to every + // 60 Hz frame and read the presented scale. + const samples = await trigger.evaluate((element, times) => { + const held = + (window as unknown as {__harnessHeld?: Animation[]}).__harnessHeld ?? []; + return times.map(t => { + for (const animation of held) { + animation.currentTime = t; + } + const scale = getComputedStyle(element).scale; + return {t, value: scale === 'none' ? 1 : Number.parseFloat(scale)}; + }); + }, timeline(560)); + await releaseAnimations(page); + await playing; + const at = (ms: number) => { + const t = ms - feedbackOnsetMs; + if (t <= 0) { + return 1; + } + // Linear between the seeked frames. + const i = samples.findIndex(s => s.t >= t); + if (i <= 0) { + return samples[Math.max(0, i)].value; + } + const a = samples[i - 1]; + const b = samples[i]; + return a.value + ((b.value - a.value) * (t - a.t)) / (b.t - a.t); + }; + // The card is 200 px wide, so a scale error times 200 is in pixels. + const errors = Array.from({length: 33}, (_, i) => feedbackOnsetMs + i * 17) + .filter(ms => ms <= commitMs + 300) + .map(ms => { + const expected = played.at((ms - feedbackOnsetMs) / 1000); + return { + ms, + expected, + web: at(ms), + errorPx: Math.abs(at(ms) - expected) * 200, + }; + }); + const rms = Math.sqrt( + errors.reduce((sum, e) => sum + e.errorPx ** 2, 0) / errors.length, + ); + await writeEvidence( + 'curves/contextmenu-press-feedback', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + {rmsPt: rms, errors, samples}, + null, + 1, + ), + }, + testInfo, + ); + testInfo.annotations.push({ + type: 'curve-parity', + description: `press feedback vs its computed curve (200 px card): rms ${rms.toFixed(3)} px over ${errors.length} frames`, + }); + expect(rms).toBeLessThan(0.1); + expect(Math.max(...errors.map(e => e.errorPx))).toBeLessThan(0.25); +}); + +test('the press feedback keeps advancing every frame while the main thread is blocked', async ({ + page, + browserName, +}, testInfo) => { + test.skip( + browserName === 'webkit', + "WebKit's Linux build runs the trigger's scale, as a transform too, on the main thread: beside a compositor control, its frames hold the trigger's width through the block (README: engine limits). The iOS simulator recording is the WebKit proof.", + ); + const {driver, trigger, center} = await openTouchStory(page); + // A wider card grows by about a CSS pixel a frame, which the screencast + // resolves; it stays inside the viewport at its largest. + await trigger.evaluate(el => { + const host = el as HTMLElement; + (host.firstElementChild as HTMLElement).style.width = '340px'; + }); + const played = playedCurve(); + await markElement(trigger, {mode: 'outline'}); + const cast = await startScreencast(page); + const playing = driver.play(hold(center, 1100)); + // The lift spring moves the card for about 200 ms, so the block covers + // its moving part. + const [feedback] = await waitForAnimation(trigger, {minProgressMs: 16}); + const block = await blockMainThread(page, 150); + await playing; + await page.waitForTimeout(100); + const castFrames = await cast.stop(); + const tracked = await trackMarker(page, castFrames); + const proof = busyWindowProof(tracked, block, {axis: 'width'}); + // What the curve grows by between the first and the last frame of the + // judged window, in the marker's pixels, against what those frames show. + const scaleAt = (epoch: number) => played.at((epoch - feedback.from) / 1000); + const judged = tracked.filter( + f => f.box != null && f.t >= block.from + 20 && f.t <= block.to - 20, + ); + const firstFrame = judged[0]; + const lastFrame = judged.at(-1); + if (firstFrame?.box == null || lastFrame?.box == null) { + throw new Error('the screencast shows no marker during the block'); + } + const restWidth = firstFrame.box.width / scaleAt(firstFrame.t); + const expectedGrowth = + restWidth * (scaleAt(lastFrame.t) - scaleAt(firstFrame.t)); + const shownGrowth = lastFrame.box.width - firstFrame.box.width; + // Widths arrive from the screencast in steps (2 px in Chromium here). One + // step is the instrument's resolution, and the only allowance below. + const widthStep = Math.min( + ...judged.slice(1).flatMap((f, i) => { + const change = Math.abs( + (f.box?.width ?? 0) - (judged[i].box?.width ?? 0), + ); + return change > 0 ? [change] : []; + }), + ); + await writeEvidence( + 'busy-main-thread/contextmenu-press-feedback', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + { + block, + feedback, + proof, + frames: tracked.map(f => ({ + t: +(f.t - block.from).toFixed(1), + w: f.box?.width ?? null, + })), + }, + null, + 1, + ), + }, + testInfo, + ); + const film = await castStoryboard(page, castFrames, { + name: 'contextmenu-press-feedback-busy-main-thread', + zero: block.from, + from: block.from - 40, + to: block.to + 40, + columns: 8, + cellScale: 0.35, + data: t => ({w: tracked.find(f => f.t === t)?.box?.width ?? -1}), + }); + await film.write(testInfo, {block, feedback, expectedGrowth}); + // Frames kept coming at display rate and the card kept growing, as much + // as the curve says, within the screencast's resolution. + expect(proof.medianFrameMs).toBeLessThan(20); + expect(proof.frames).toBeGreaterThanOrEqual(proof.expectedFrames - 2); + expect(proof.monotonic).toBe(true); + expect(proof.longestStallMs).toBeLessThan(4 * (1000 / 60) + 4); + expect(Number.isFinite(widthStep)).toBe(true); + expect(shownGrowth).toBeGreaterThanOrEqual(expectedGrowth - widthStep); +}); + +test('the press feedback does no Layout or Paint per frame', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'rendering traces are Chromium-only'); + const {driver, center} = await openTouchStory(page); + const timeOrigin = await page.evaluate(() => performance.timeOrigin); + const trace = await traceRendering(page, async () => { + // Lifted just before the commit: the feedback alone, then its return. + await driver.play(hold(center, commitMs - 20)); + }); + const start = timeOrigin + (await touchStartedAt(page)); + const during = countBetween( + trace, + start + feedbackOnsetMs + 50, + start + commitMs - 30, + ); + expect(during.Layout ?? 0, JSON.stringify(during)).toBe(0); + expect(during.Paint ?? 0, JSON.stringify(during)).toBe(0); + expect(trace.longTasks).toEqual([]); +}); + /** The story with somewhere to scroll below it, and its trigger's center. */ async function openScrollableStory(page: Page) { const story = await openTouchStory(page); @@ -282,6 +880,24 @@ async function openScrollableStory(page: Page) { return {...story, center, top}; } +test('the page does not scroll under the finger that held the menu open, and it does not lock the page', async ({ + page, +}) => { + const {driver, center, top} = await openScrollableStory(page); + const before = await top(); + // Held until the menu shows, then dragged up 200 px. + await driver.play( + drag( + center, + {x: center.x, y: center.y - 200}, + {durationMs: 300, holdMs: menuDisplayMs + 50, restMs: 100}, + ), + ); + expect(await top()).toBe(before); + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeVisible(); + expect(await page.evaluate(() => document.body.style.position)).toBe(''); +}); + test('a new finger that pans outside the open menu closes it, and the page scrolls', async ({ page, }) => { @@ -319,3 +935,67 @@ test('a new finger that pans outside the open menu closes it, and the page scrol expect(await top()).toBeLessThan(before); } }); + +test('a drag that cancels the press after the commit does not scroll the page either', async ({ + page, +}) => { + const {driver, center, top} = await openScrollableStory(page); + const before = await top(); + await driver.play( + drag( + center, + {x: center.x, y: center.y - 200}, + {durationMs: 300, holdMs: commitMs + 100, restMs: 100}, + ), + ); + expect(await menuShownAt(page)).toBeNull(); + expect(await top()).toBe(before); +}); + +test( + 'storyboard: the press feedback up to the menu', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {driver, trigger, center} = await openTouchStory(page); + const box = await trigger.boundingBox(); + if (box == null) { + throw new Error('no trigger'); + } + // Gate the page's timers first: the commit and the menu wait while the + // held feedback is photographed, and the finger stays down meanwhile. + await installTimerGate(page); + const playing = driver.play(hold(center, 8000)); + await waitForAnimation(trigger, {minProgressMs: 16}); + await freezeTimers(page); + const {storyboard} = await animationStoryboard(page, { + name: 'contextmenu-press-feedback', + clip: { + x: box.x - 24, + y: box.y - 24, + width: box.width + 48, + height: box.height + 48, + }, + scope: trigger, + // t counts from the visible ramp's start, the onset after the touch. + times: timeline(menuDisplayMs - feedbackOnsetMs, 1000 / 30), + measure: async () => ({ + scale: (await presentedScale(trigger)).toFixed(4), + }), + columns: 6, + cellScale: 0.6, + }); + await releaseAnimations(page); + await thawTimers(page); + await playing; + await storyboard.write(testInfo); + const scales = storyboard.frames.map(f => Number(f.data.scale)); + // The lift spring rises to its peak, overshoots by under 3% of its + // travel, and settles at the held scale before the menu displays. + const peak = scales.indexOf(Math.max(...scales)); + expect( + scales.slice(0, peak + 1).every((s, i, a) => i === 0 || s >= a[i - 1]), + ).toBe(true); + expect(scales[peak]).toBeLessThan(heldScale + (heldScale - 1) * 0.03); + expect(scales.at(-1)).toBeCloseTo(heldScale, 2); + }, +); diff --git a/packages/core/src/ContextMenu/contextMenuPress.test.ts b/packages/core/src/ContextMenu/contextMenuPress.test.ts index fdc7db94eb237..e78f6f4fca17d 100644 --- a/packages/core/src/ContextMenu/contextMenuPress.test.ts +++ b/packages/core/src/ContextMenu/contextMenuPress.test.ts @@ -9,7 +9,7 @@ * even when the timer runs late. It fails on travel past the long-press * tolerance, a lift, a second finger or the browser taking the touch. The * commit claims the finger and drops its click. A press that fails leaves - * the click alone + * the click alone. Each press it takes is reported once, at its landing * @position Unit tests of packages/core/src/ContextMenu/contextMenuPress.ts */ @@ -180,6 +180,17 @@ describe('ContextMenuPressRecognizer', () => { expect(states).toEqual(['failed@90', 'possible@90']); }); + it('reports the press it takes once, with its touch and the time it landed', () => { + const {harness, press} = setup(); + const began: [string, number][] = []; + press.onPressBegan = (touch, time) => { + began.push([touch.pointerType, time]); + }; + harness.finger(1).down(10, 20, 5, {recognizers: [press]}); + harness.finger(2).down(50, 0, 120, {recognizers: [press]}); + expect(began).toEqual([['touch', 5]]); + }); + it('keeps the press while it fails, so the feedback knows which one ended', () => { const {harness, press} = setup(); let pressAtFailure: unknown = 'unset'; diff --git a/packages/core/src/ContextMenu/contextMenuPress.ts b/packages/core/src/ContextMenu/contextMenuPress.ts index dfc25022eeb40..0829874683a51 100644 --- a/packages/core/src/ContextMenu/contextMenuPress.ts +++ b/packages/core/src/ContextMenu/contextMenuPress.ts @@ -76,6 +76,11 @@ export class ContextMenuPressRecognizer extends LongPressGestureRecognizer { * moves freely from here. */ isMenuDue = false; + /** + * Hears each press as the recognizer takes it: the touch, and the time it + * landed on the arena's clock. + */ + onPressBegan: ((touch: GestureTouch, time: number) => void) | null = null; /** The finger the hold follows. */ private pressTouchId: number | null = null; /** Where the finger landed, while its hold runs. The drift counts from here. */ @@ -121,6 +126,7 @@ export class ContextMenuPressRecognizer extends LongPressGestureRecognizer { this.holdFrom = {x: touch.x, y: touch.y}; this.holdStart = event.time; this.setTimer(event.time + commitMs, () => this.commit()); + this.onPressBegan?.(touch, event.time); } } diff --git a/packages/core/src/ContextMenu/contextMenuPressFeedback.test.tsx b/packages/core/src/ContextMenu/contextMenuPressFeedback.test.tsx new file mode 100644 index 0000000000000..2a1879ca09f9c --- /dev/null +++ b/packages/core/src/ContextMenu/contextMenuPressFeedback.test.tsx @@ -0,0 +1,332 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file contextMenuPressFeedback.test.tsx + * @input useContextMenuPressFeedback on a trigger, with the animator replaced + * @output Checks that the growth starts at the press machine's "on" and + * keeps going when the machine lets the press go. It returns when + * ContextMenu's recognizer ends the press with no menu. The growth plays + * `springs.contextMenuLift`, and each return, then or as the menu closes, + * `springs.contextMenuDismiss` + * @position Unit tests of packages/core/src/ContextMenu/contextMenuPressFeedback.ts + */ + +import {render} from '@testing-library/react'; +import {useRef} from 'react'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {contextMenu} from '../motion/uikit/contextMenu'; +import {springs} from '../motion/uikit/springs'; +import {PRESSED_ATTRIBUTE} from '../utils/pressFeedback'; +import { + useContextMenuPressFeedback, + type ContextMenuPressFeedback, +} from './contextMenuPressFeedback'; + +const animator = vi.hoisted(() => ({ + element: null as HTMLElement | null, + /** The element each spring ran on, in order. */ + animated: [] as HTMLElement[], + play: vi.fn((..._args: unknown[]) => ({ + finished: new Promise(() => {}), + })), + animate: vi.fn((..._args: unknown[]) => ({ + finished: new Promise(() => {}), + })), + stop: vi.fn(), + dispose: vi.fn(), + get: vi.fn(() => 1), + getVelocity: vi.fn(() => 0), + reducedMotion: vi.fn(() => 'off'), +})); + +vi.mock('../motion/animator', () => ({ + createMotionEngine: (element: HTMLElement) => { + animator.element = element; + return { + ...animator, + animate: (...args: unknown[]) => { + animator.animated.push(element); + return animator.animate(...args); + }, + }; + }, +})); + +let feedback: ContextMenuPressFeedback | null = null; + +function Trigger() { + const ref = useRef(null); + feedback = useContextMenuPressFeedback(ref, true); + return
; +} + +/** What the recognizer follows now, as ContextMenu's refs report it. */ +const recognizer = {isFollowing: true, isFinger: false}; +function TriggerWithRecognizer() { + const ref = useRef(null); + const isFollowedRef = useRef(() => recognizer.isFollowing); + const landedAtRef = useRef((): number | null => null); + const followsFingerRef = useRef( + () => recognizer.isFollowing && recognizer.isFinger, + ); + feedback = useContextMenuPressFeedback( + ref, + true, + isFollowedRef, + landedAtRef, + followsFingerRef, + ); + return
; +} + +/** A trigger whose feedback can go while the trigger stays, as when it is disabled. */ +function FeedbackLayer({ + triggerRef, +}: { + triggerRef: React.RefObject; +}) { + feedback = useContextMenuPressFeedback(triggerRef, true); + return null; +} + +function TriggerWithLayer({hasLayer}: {hasLayer: boolean}) { + const ref = useRef(null); + return ( + <> +
+ {hasLayer ? : null} + + ); +} + +/** A trigger whose content is a pressable control, as a Button is. */ +function TriggerWithControl() { + const ref = useRef(null); + feedback = useContextMenuPressFeedback(ref, true); + return ( +
+ +
+ ); +} + +const nested: { + outer: ContextMenuPressFeedback | null; + inner: ContextMenuPressFeedback | null; +} = {outer: null, inner: null}; + +/** A trigger inside another trigger, beside a plain control of the outer one. */ +function NestedTriggers({isInnerShown = true}: {isInnerShown?: boolean}) { + const outerRef = useRef(null); + const innerRef = useRef(null); + nested.outer = useContextMenuPressFeedback(outerRef, true); + nested.inner = useContextMenuPressFeedback(innerRef, isInnerShown); + return ( +
+ +
+
+ ); +} + +const growths = () => + animator.animate.mock.calls.filter( + ([to]) => ((to as {scale?: number}).scale ?? 1) > 1, + ); + +/** Lets the shared mutation observer deliver its records. */ +const flush = async () => + new Promise(resolve => { + setTimeout(resolve, 0); + }); + +describe('useContextMenuPressFeedback', () => { + afterEach(() => { + vi.clearAllMocks(); + animator.animated.length = 0; + feedback = null; + }); + + it('keeps growing when the press machine lets the press go, and springs back when the recognizer ends it', async () => { + const {getByTestId} = render(); + const trigger = getByTestId('trigger'); + trigger.setAttribute(PRESSED_ATTRIBUTE, 'on'); + await flush(); + expect(animator.animate).toHaveBeenCalledOnce(); + // The press machine's slop is shorter than the recognizer's tolerance. + trigger.removeAttribute(PRESSED_ATTRIBUTE); + await flush(); + expect(animator.animate).toHaveBeenCalledOnce(); + feedback?.end(performance.now()); + expect(animator.animate).toHaveBeenCalledTimes(2); + }); + + it('grows when the press machine marks a pressable control inside the trigger, the innermost surface it marks', async () => { + const {getByTestId} = render(); + getByTestId('control').setAttribute(PRESSED_ATTRIBUTE, 'on'); + await flush(); + expect(animator.animate).toHaveBeenCalledOnce(); + getByTestId('control').removeAttribute(PRESSED_ATTRIBUTE); + await flush(); + expect(animator.animate).toHaveBeenCalledOnce(); + }); + + it('grows only the innermost trigger when one trigger sits inside another', async () => { + const {getByTestId} = render(); + getByTestId('inner').setAttribute(PRESSED_ATTRIBUTE, 'on'); + await flush(); + expect(animator.animated).toEqual([getByTestId('inner')]); + getByTestId('inner').removeAttribute(PRESSED_ATTRIBUTE); + await flush(); + getByTestId('outer-control').setAttribute(PRESSED_ATTRIBUTE, 'on'); + await flush(); + expect(animator.animated).toEqual([ + getByTestId('inner'), + getByTestId('outer'), + ]); + }); + + it('grows no outer trigger for a press on a nested trigger that shows no growth', async () => { + const {getByTestId} = render(); + getByTestId('inner').setAttribute(PRESSED_ATTRIBUTE, 'on'); + await flush(); + expect(animator.animated).toEqual([]); + }); + + it('gives the trigger back its size when its feedback goes while the menu it opened is up', async () => { + const {getByTestId, rerender} = render(); + const trigger = getByTestId('trigger'); + trigger.setAttribute(PRESSED_ATTRIBUTE, 'on'); + await flush(); + expect(animator.animate).toHaveBeenCalledOnce(); + // The menu opened: the animator froze the growth on the element. + feedback?.hold(); + trigger.style.scale = '1.0735'; + rerender(); + expect(trigger.style.scale).toBe(''); + expect(animator.dispose).toHaveBeenCalledOnce(); + }); + + it('grows toward the held scale on springs.contextMenuLift, from the onset', async () => { + const {getByTestId} = render(); + const trigger = getByTestId('trigger'); + trigger.setAttribute(PRESSED_ATTRIBUTE, 'on'); + await flush(); + const [to, transition, options] = animator.animate.mock + .lastCall as unknown as [ + {scale: number}, + {spring: unknown}, + {at: number}, + ]; + expect(to).toEqual({scale: contextMenu.press.heldScale}); + expect(transition).toEqual({spring: springs.contextMenuLift}); + expect(typeof options.at).toBe('number'); + expect(animator.play).not.toHaveBeenCalled(); + }); + + it('returns on springs.contextMenuDismiss, at the time the recognizer ended the press', async () => { + const {getByTestId} = render(); + const trigger = getByTestId('trigger'); + trigger.setAttribute(PRESSED_ATTRIBUTE, 'on'); + await flush(); + feedback?.end(1000); + expect(animator.animate.mock.lastCall).toEqual([ + {scale: 1}, + {spring: springs.contextMenuDismiss}, + {at: 1000}, + ]); + expect(animator.play).not.toHaveBeenCalled(); + }); + + it('grows a finger at its onset, 150 ms after the landing the recognizer reports', async () => { + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'performance']}); + try { + const {getByTestId} = render(); + vi.advanceTimersByTime(1000); + // The recognizer reports the landing 40 ms late, on a busy main thread. + const landedAt = performance.now() - 40; + feedback?.begin(landedAt, getByTestId('trigger')); + await vi.advanceTimersByTimeAsync(109); + expect(growths()).toHaveLength(0); + await vi.advanceTimersByTimeAsync(1); + expect(growths().map(call => (call[2] as {at: number}).at)).toEqual([ + landedAt + 150, + ]); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps a finger’s onset when the press machine marks the press later', async () => { + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'performance']}); + try { + recognizer.isFinger = true; + const {getByTestId} = render(); + vi.advanceTimersByTime(1000); + const landedAt = performance.now(); + feedback?.begin(landedAt, getByTestId('trigger')); + await vi.advanceTimersByTimeAsync(240); + getByTestId('trigger').setAttribute(PRESSED_ATTRIBUTE, 'on'); + await vi.advanceTimersByTimeAsync(200); + expect(growths().map(call => (call[2] as {at: number}).at)).toEqual([ + landedAt + 150, + ]); + } finally { + recognizer.isFinger = false; + vi.useRealTimers(); + } + }); + + it('starts a pen’s growth at its mark after a finger’s press ended', async () => { + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'performance']}); + try { + recognizer.isFinger = true; + const {getByTestId} = render(); + const trigger = getByTestId('trigger'); + feedback?.begin(performance.now(), trigger); + await vi.advanceTimersByTimeAsync(150); + expect(growths()).toHaveLength(1); + feedback?.end(performance.now()); + // A pen's press: the press machine's mark starts its growth. + recognizer.isFinger = false; + trigger.setAttribute(PRESSED_ATTRIBUTE, 'on'); + await vi.advanceTimersByTimeAsync(0); + expect(growths()).toHaveLength(2); + } finally { + recognizer.isFinger = false; + vi.useRealTimers(); + } + }); + + it('grows only the innermost trigger for a finger the recognizer reports on a nested trigger', async () => { + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'performance']}); + try { + const {getByTestId} = render(); + const inner = getByTestId('inner'); + nested.outer?.begin(performance.now(), inner); + nested.inner?.begin(performance.now(), inner); + await vi.advanceTimersByTimeAsync(150); + expect(animator.animated).toEqual([inner]); + } finally { + vi.useRealTimers(); + } + }); + + it('returns on springs.contextMenuDismiss when the menu the press opened closes', async () => { + const {getByTestId} = render(); + const trigger = getByTestId('trigger'); + trigger.setAttribute(PRESSED_ATTRIBUTE, 'on'); + await flush(); + feedback?.hold(); + feedback?.release(); + expect(animator.animate.mock.lastCall).toEqual([ + {scale: 1}, + {spring: springs.contextMenuDismiss}, + ]); + expect(animator.play).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/ContextMenu/contextMenuPressFeedback.ts b/packages/core/src/ContextMenu/contextMenuPressFeedback.ts new file mode 100644 index 0000000000000..f9e808c1a6217 --- /dev/null +++ b/packages/core/src/ContextMenu/contextMenuPressFeedback.ts @@ -0,0 +1,317 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file contextMenuPressFeedback.ts + * @input UIKit's context-menu springs, the context-menu constants, the + * compositor animator, the presses ContextMenu's recognizer takes, and the + * press state core's press machine writes + * @output useContextMenuPressFeedback: hold and release, from ContextMenu's + * menu; the growth follows the recognizer's finger, or a pen's press mark + * @position The press feedback of ContextMenu's touch invocation + * + * SYNC: When modified, update /packages/core/src/ContextMenu/ContextMenu.tsx + */ + +// The growth starts at the onset, 150 ms after a finger lands, while the +// recognizer follows that finger. The recognizer reports the landing; a +// pointer-only pen's press waits for the press machine's "on". Only the +// recognizer's failure or cancel returns it. A long press is a gesture, so the +// growth plays UIKit's lift spring and each return its dismissal spring. The +// compositor plays them, so a busy main thread does not stall them. Once the +// menu opens, the trigger holds its size until the menu closes. Then it returns +// on the dismissal spring. An inline trigger and Reduce Motion show no growth. + +import {useCallback, useRef, type RefObject} from 'react'; +import {useAttachment} from '../hooks/useAttachment'; +import {usePressFeedback} from '../hooks/usePressFeedback'; +import {PRESSED_ATTRIBUTE} from '../utils/pressFeedback'; +import {PRESS_ONSET_MS} from '../utils/pressGesture'; +import {observeMutations} from '../utils/sharedMutationObserver'; +import {createMotionEngine, type Animator} from '../motion/animator'; +import {contextMenu} from '../motion/uikit/contextMenu'; +import { + CONTEXT_MENU_DISMISS_SPRING, + CONTEXT_MENU_LIFT_SPRING, +} from '../motion/uikit/springs'; + +const {heldScale} = contextMenu.press; + +export interface ContextMenuPressFeedback { + /** + * The recognizer took a finger that landed at `landedAt` on `target`: grow + * at its onset, 150 ms later. + */ + begin(landedAt: number, target: Element | null): void; + /** The menu the press opened closed: return on the dismissal spring. */ + release(): void; + /** + * The recognizer ended the press with no menu, at `time`: return on the + * dismissal spring. + */ + end(time: number): void; + /** + * The menu showed: the feedback stops where it is. The menu's cursor anchor + * sits inside the trigger, and anchor positioning follows its transformed + * box. So a trigger that still grows would carry the open menu with it. + */ + hold(): void; +} + +/** A press feedback with no recognizer to ask grows at every "on". */ +const ALWAYS_FOLLOWED: RefObject<() => boolean> = {current: () => true}; + +/** With no recognizer to ask, the onset is the "on" itself. */ +const NO_LANDING: RefObject<() => number | null> = {current: () => null}; + +/** With no recognizer to ask, every "on" is a pen's. */ +const NO_FINGER: RefObject<() => boolean> = {current: () => false}; + +// The triggers a press mark belongs to: every mounted trigger, whether it +// shows growth or not. A mark inside a trigger belongs to the nearest of them +// at or above it. So a press on a nested trigger grows no outer trigger. +const pressMarkOwners = new WeakSet(); + +function pressMarkOwnerOf(marked: Element): Element | null { + for ( + let node: Element | null = marked; + node != null; + node = node.parentElement + ) { + if (pressMarkOwners.has(node)) { + return node; + } + } + return null; +} + +/** + * The press feedback of a ContextMenu trigger. `isShown` is read at each + * press: false for an inline trigger. It follows the element behind + * `triggerRef`, so a replaced trigger (`triggerAs`) is followed too. The + * `isFollowed` ref says whether ContextMenu's recognizer follows a finger or + * a pen now: the growth starts only then. The `landedAt` ref gives the time + * that finger landed, so the growth starts at its onset. The `followsFinger` + * ref says whether that contact is a finger, whose onset `begin` sets. + */ +export function useContextMenuPressFeedback( + triggerRef: RefObject, + isShown: boolean, + isFollowed: RefObject<() => boolean> = ALWAYS_FOLLOWED, + landedAt: RefObject<() => number | null> = NO_LANDING, + followsFinger: RefObject<() => boolean> = NO_FINGER, +): ContextMenuPressFeedback { + // The press machine's document listener, while this feedback is mounted. + usePressFeedback(); + const animatorRef = useRef(null); + const isShownRef = useRef(isShown); + isShownRef.current = isShown; + // Whether the trigger carries a scale of ours (to put back and clear). + const isGrownRef = useRef(false); + // The menu the press opened is up: the growth holds until it closes. + const isHeldRef = useRef(false); + // Null while no shown trigger is attached, so `begin` grows nothing. + const fingerOnsetRef = useRef< + ((landedAt: number, target: Element | null) => void) | null + >(null); + + // The compositor animator lives with the trigger it follows. A replaced + // trigger, or a feedback that goes while the trigger stays, takes the + // animation and the scale of ours with it. + useAttachment( + triggerRef, + element => () => { + animatorRef.current?.dispose(); + animatorRef.current = null; + if (isGrownRef.current) { + isGrownRef.current = false; + element.style.removeProperty('scale'); + } + }, + null, + ); + + const animatorFor = useCallback((): Animator | null => { + const element = triggerRef.current; + if (element == null) { + return null; + } + animatorRef.current ??= createMotionEngine(element); + return animatorRef.current; + }, [triggerRef]); + + // Back at 1: drop the inline scale, which would otherwise leave the trigger + // a stacking context and a containing block for fixed descendants. + const settleBack = useCallback((finished: Promise) => { + void finished.then(isDone => { + const element = animatorRef.current?.element; + if (isDone && element != null && isGrownRef.current) { + isGrownRef.current = false; + element.style.removeProperty('scale'); + } + }); + }, []); + + const start = useCallback( + (time: number) => { + if (!isShownRef.current || isHeldRef.current || !isFollowed.current()) { + return; + } + const animator = animatorFor(); + const element = animator?.element; + // Under Reduce Motion the view does not grow (UIKit's + // `reducedMotionFeedback`), and no fade stands in for it. + if ( + animator == null || + element == null || + (animator.reducedMotion() !== 'off' && + !contextMenu.reducedMotionFeedback) + ) { + return; + } + isGrownRef.current = true; + // `time` is the onset: 150 ms after the finger lands, or a later mark. + animator.animate( + {scale: heldScale}, + {spring: CONTEXT_MENU_LIFT_SPRING}, + {at: time}, + ); + }, + [animatorFor, isFollowed], + ); + + const end = useCallback( + (time: number) => { + const animator = animatorRef.current; + const element = animator?.element; + if ( + animator == null || + element == null || + !isGrownRef.current || + isHeldRef.current + ) { + return; + } + settleBack( + animator.animate( + {scale: 1}, + {spring: CONTEXT_MENU_DISMISS_SPRING}, + {at: time}, + ).finished, + ); + }, + [settleBack], + ); + + const release = useCallback(() => { + isHeldRef.current = false; + const animator = animatorRef.current; + const element = animator?.element; + if (animator == null || element == null || !isGrownRef.current) { + return; + } + settleBack( + animator.animate({scale: 1}, {spring: CONTEXT_MENU_DISMISS_SPRING}) + .finished, + ); + }, [settleBack]); + + const begin = useCallback((landed: number, target: Element | null) => { + fingerOnsetRef.current?.(landed, target); + }, []); + + const hold = useCallback(() => { + isHeldRef.current = true; + if (isGrownRef.current) { + animatorRef.current?.stop(['scale']); + } + }, []); + + // Grow at the onset, 150 ms after the finger lands. The recognizer reports + // the landing as it takes the finger, whether or not the press machine + // marks the press. Inside a scroll view the machine marks late, and it never + // marks a press that travels past 10 px first. A pen's onset waits for the + // mark. The machine marks the innermost pressable surface, so a control + // inside the trigger can carry the mark. Only the recognizer ends the growth + // (`end`). + useAttachment( + triggerRef, + element => { + pressMarkOwners.add(element); + if (!isShown) { + return () => { + pressMarkOwners.delete(element); + }; + } + const isMarkedOn = () => + element.getAttribute(PRESSED_ATTRIBUTE) === 'on' || + [...element.querySelectorAll(`[${PRESSED_ATTRIBUTE}="on"]`)].some( + marked => pressMarkOwnerOf(marked) === element, + ); + let isPressed = isMarkedOn(); + let markedAt = 0; + let onsetTimer: ReturnType | undefined; + const startAt = (onsetAt: number) => { + const wait = onsetAt - performance.now(); + if (wait > 0) { + onsetTimer = setTimeout(() => start(onsetAt), wait); + } else { + start(onsetAt); + } + }; + // A pen's mark: the gesture host took that pen from the same input + // before the mark's record comes, so the recognizer follows it already. + const growAtMark = () => { + // The recognizer times a finger's onset from its landing. + if (followsFinger.current() || !isFollowed.current()) { + return; + } + const landed = landedAt.current(); + startAt( + landed == null + ? markedAt + : Math.max(markedAt, landed + PRESS_ONSET_MS), + ); + }; + fingerOnsetRef.current = (landed, target) => { + // A finger on a nested trigger grows only that trigger. + if (target != null && pressMarkOwnerOf(target) !== element) { + return; + } + clearTimeout(onsetTimer); + startAt(landed + PRESS_ONSET_MS); + }; + const stopObserving = observeMutations( + element, + {attributes: true, attributeFilter: [PRESSED_ATTRIBUTE], subtree: true}, + () => { + const next = isMarkedOn(); + if (next !== isPressed) { + isPressed = next; + if (next) { + if (!followsFinger.current()) { + clearTimeout(onsetTimer); + markedAt = performance.now(); + growAtMark(); + } + } else if (!isFollowed.current()) { + // A finger the recognizer still follows keeps its onset, past + // the machine's shorter travel. + clearTimeout(onsetTimer); + } + } + }, + ); + return () => { + clearTimeout(onsetTimer); + stopObserving(); + fingerOnsetRef.current = null; + pressMarkOwners.delete(element); + }; + }, + isShown, + ); + + return {begin, release, end, hold}; +} diff --git a/packages/core/src/motion/README.md b/packages/core/src/motion/README.md index c739deaa6c701..a5c31c92649d6 100644 --- a/packages/core/src/motion/README.md +++ b/packages/core/src/motion/README.md @@ -18,7 +18,7 @@ roles through `resolveMotion`. | `uikit/springs.ts` | Interaction springs (navigation, sheets, alerts, menus, controls, keyboard, banners) and the SwiftUI / UIKit presets. | | `uikit/scroll.ts` | UIScrollView: deceleration rates, rubber band, bounce-back spring, `delaysContentTouches`, scroll to top. | | `uikit/timing.ts` | Non-spring clocks and curves: press highlight, Core Animation's named curves, keyboard, Reduced Motion cross-fade. | -| `uikit/contextMenu.ts` | UIContextMenuInteraction: touch timeline (feedback, commit, menu), drift tolerance, commit haptic. | +| `uikit/contextMenu.ts` | UIContextMenuInteraction: touch timeline (feedback, commit, menu), drift tolerance, the press feedback's held scale, commit haptic. | | `uikit/paging.ts` | A paging UIScrollView: the integrator that settles a release on its page, the page rule, the catch, `setContentOffset(animated:)`. | | `uikit/alert.ts` | UIAlertController: how a finger is tracked across the actions (highlight onset and bounds). | | `uikit/navigation.ts` | UINavigationController: push velocity, parallax, dimming, transition clock, interactive pop finish rule. | diff --git a/packages/core/src/motion/uikit/contextMenu.ts b/packages/core/src/motion/uikit/contextMenu.ts index a6c168b433270..7d7743e20b664 100644 --- a/packages/core/src/motion/uikit/contextMenu.ts +++ b/packages/core/src/motion/uikit/contextMenu.ts @@ -4,12 +4,13 @@ * @file contextMenu.ts * @input Measurements of UIContextMenuInteraction on iOS 26.2 * @output `contextMenu`: UIContextMenuInteraction's touch timeline, drift - * tolerance and commit haptic + * tolerance, held scale and commit haptic * @position The context-menu domain of the UIKit constants table. Only * ContextMenu's own modules import it. The table's barrel does not * re-export it. * - * SYNC: When modified, update /packages/core/src/motion/MEASUREMENTS.md and + * SYNC: When modified, update /packages/core/src/motion/MEASUREMENTS.md, + * /packages/core/src/ContextMenu/contextMenuPressFeedback.ts and * /packages/core/src/ContextMenu/contextMenuPress.ts */ @@ -76,6 +77,18 @@ export const contextMenu = { * 0.5 s displayed at the lift (529 ms) */ menuDisplayMs: 735, + /** + * The size the press copy reaches when the menu displays, as a fraction + * of the view. It is the second ramp at `menuDisplayMs`: 1 + 0.1 per + * second since the touch. The trigger grows toward it on + * `springs.contextMenuLift`, not on UIKit's ramp. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, per-frame model frames of the press platter over a + * 200 × 120 pt view, Oct 23 2026: 0.1001 per second, linear over 19 + * frames, zero at the touch (−1.4 ms), at 735 ms + * @measure M-CM2 + */ + heldScale: 1.0735, }, /** * The click haptic at the commit, as a vibration where that is the only @@ -91,4 +104,14 @@ export const contextMenu = { * @measure M-CM5 */ commitHaptic: 'impactMedium', + /** + * Reduce Motion: whether the press feedback still grows the view. It does + * not; only the copy's shadow fades in, and the haptic and the menu stay. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, per-frame platter frames with Reduce Motion on, Prefer + * Cross-Fade off and on, Oct 23 2026: the platter stays at scale 1 and + * opacity 1 through the hold while its shadow opacity rises 0 → 0.31 by + * 0.6 s; a press lifted at 0.25 s does not grow either + */ + reducedMotionFeedback: false, } as const; diff --git a/packages/core/src/utils/pressableCoverage.test.ts b/packages/core/src/utils/pressableCoverage.test.ts index c4b2c88b0c5ba..d610665016df5 100644 --- a/packages/core/src/utils/pressableCoverage.test.ts +++ b/packages/core/src/utils/pressableCoverage.test.ts @@ -15,7 +15,10 @@ * holds it equal to the marked set: * * - A file paints a press when its StyleX carries an `':active` key, a - * `[data-astryx-press` arm, or composes `interactionOverlayStyles`. + * `[data-astryx-press` arm, or composes `interactionOverlayStyles`. It + * also paints one when its script follows the pressed state that a + * controller writes, as ContextMenu's growth does. A script that also + * writes that state is a press controller, so it is part of the model. * - A file is marked when it calls `usePressFeedback(`. * - A file whose press arms are read off an ancestor scope marker * (`stylex.when.ancestor(':active', someScope)`, the way an indicator @@ -71,6 +74,13 @@ const source = new Map( // a bare attribute selector or inside `:is(…)`. const PRESS_ARM = /':active|'(?::is\()?\[data-astryx-press=|interactionOverlayStyles\./; +function paintsPress(text: string): boolean { + return ( + PRESS_ARM.test(text) || + (text.includes('getAttribute(PRESSED_ATTRIBUTE)') && + !text.includes('setAttribute(PRESSED_ATTRIBUTE')) + ); +} const ANCESTOR_ARM = /stylex\.when\.ancestor\(\s*'(?::active|\[data-astryx-press=[^)]*)',\s*(\w+)\s*,?\s*\)/g; @@ -143,7 +153,7 @@ function importers(file: string): string[] { } const pressArmFiles = [...source.entries()] - .filter(([file, text]) => !MODEL_FILES.has(file) && PRESS_ARM.test(text)) + .filter(([file, text]) => !MODEL_FILES.has(file) && paintsPress(text)) .map(([file]) => file) .sort(); @@ -227,7 +237,7 @@ describe('every surface that paints a press is reachable by the touch press cont ); const unexplained = marked.filter(file => { const text = source.get(file) ?? ''; - if (PRESS_ARM.test(text)) { + if (paintsPress(text)) { return false; } return ![...scopeNames].some(scope => From 7f9d428f31937808390de8659a7202f9d2d15ac6 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 10:41:35 -0800 Subject: [PATCH 017/118] test(ContextMenu): each animation hold names the motion it judges main's hold now rejects a hold that names no judged motion. The curve and storyboard proofs judge the trigger's scale, so they say so. --- .../ContextMenuLongPress.interaction.browser.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index b83c187617373..f237674bbeed4 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -682,7 +682,9 @@ test('the press feedback plays its computed curve on the compositor, frame by fr const played = playedCurve(); const playing = driver.play(hold(center, commitMs + 300)); await waitForAnimation(trigger, {minProgressMs: 16}); - await holdAnimations(page, trigger); + await holdAnimations(page, trigger, { + judges: {target: trigger, property: 'scale'}, + }); // Seek the held curve (its time 0 is the onset after the touch) to every // 60 Hz frame and read the presented scale. const samples = await trigger.evaluate((element, times) => { @@ -976,6 +978,7 @@ test( height: box.height + 48, }, scope: trigger, + judges: {target: trigger, property: 'scale'}, // t counts from the visible ramp's start, the onset after the touch. times: timeline(menuDisplayMs - feedbackOnsetMs, 1000 / 30), measure: async () => ({ From 791357ddfaaf6364f63e96da0b05a59e8dc88157 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 10:41:35 -0800 Subject: [PATCH 018/118] test(ContextMenu): a late touch grows from rest at its onset (red) A touch that reaches the page 90 ms after its timestamp waits for Chromium's timer hold, so the growth starts about 50 ms late, and its back-dated curve skips its first frames. The proof wants the frame before the growth at rest, the first grown frame no further along than the spring one frame in, and that frame within two frames of the onset. --- ...tMenuLongPress.interaction.browser.spec.ts | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index f237674bbeed4..87bbbbb8c2c2f 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -675,6 +675,95 @@ test('a drift within the tolerance still opens the menu; past it on one axis can expect(await menuShownAt(page)).toBeNull(); }); +test('a touch that reaches the page late grows from the first frame at its onset, from rest', async ({ + page, +}) => { + const {driver, center} = await openTouchStory(page); + test.skip( + !driver.scriptedTimestamps, + 'only a Chromium touch takes its timestamp from the script', + ); + const played = playedCurve(); + await page.evaluate(() => { + const trigger = document.querySelector( + '[data-testid="touch-context-menu"]', + ); + const record = { + touchedAt: 0, + handledAt: 0, + frames: [] as {t: number; scale: number}[], + }; + (window as unknown as {__lateTouch: typeof record}).__lateTouch = record; + const sample = (t: number) => { + const scale = getComputedStyle(trigger as HTMLElement).scale; + record.frames.push({ + t, + scale: scale === 'none' ? 1 : Number.parseFloat(scale), + }); + if (t - record.touchedAt < 320) { + requestAnimationFrame(sample); + } + }; + addEventListener( + 'touchstart', + event => { + record.touchedAt = event.timeStamp; + record.handledAt = performance.now(); + // Added during the dispatch, it runs after the page's own touchstart + // listeners. So each frame, the sampler reads after the page's work. + addEventListener('touchstart', () => requestAnimationFrame(sample), { + once: true, + passive: true, + }); + }, + {capture: true, once: true, passive: true}, + ); + }); + const cdp = await page.context().newCDPSession(page); + // The touch reaches the page 90 ms after its own timestamp, as through a + // slow input path. Chromium then holds the page's timers until about + // 100 ms after it handles the touch. + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{...center, id: 1, radiusX: 1, radiusY: 1, force: 1}], + timestamp: (Date.now() - 90) / 1000, + }); + await page.waitForTimeout(250); + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: [], + timestamp: Date.now() / 1000, + }); + const {touchedAt, handledAt, frames} = await page.evaluate( + () => + ( + window as unknown as { + __lateTouch: { + touchedAt: number; + handledAt: number; + frames: {t: number; scale: number}[]; + }; + } + ).__lateTouch, + ); + expect(handledAt - touchedAt).toBeGreaterThanOrEqual(80); + const grown = frames.findIndex(frame => frame.scale > 1); + expect(grown, 'the trigger never grew').toBeGreaterThan(0); + const before = frames[grown - 1]; + const first = frames[grown]; + const frameMs = first.t - before.t; + // From rest: the frame before is at rest, and the first grown frame is no + // further along than the spring one frame in. + expect(before.scale).toBe(1); + expect(first.scale).toBeLessThanOrEqual(played.at(frameMs / 1000) + 0.0005); + // On time: the curve starts in the first frame at or after the onset. So + // its first grown frame comes within two frames of the onset. + expect(before.t - touchedAt).toBeGreaterThanOrEqual(feedbackOnsetMs); + expect(first.t - touchedAt).toBeLessThanOrEqual( + feedbackOnsetMs + 2 * frameMs + 1, + ); +}); + test('the press feedback plays its computed curve on the compositor, frame by frame', async ({ page, }, testInfo) => { From 6fd83b267732257cde29f694ff54ee3ab8aec0fc Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 10:41:35 -0800 Subject: [PATCH 019/118] fix(ContextMenu): a frame, not a timer, starts the touch growth Chromium holds every timer for about 100 ms after it handles a touchstart, so a touch that reached the page late grew late, and its back-dated curve skipped its first frames. The first frame at or after the onset now starts the spring from rest. The motion engine is built in the first frame after the finger lands, not in its touchstart handler. A lift, a move past the tolerance, a cancel and the menu's display stop the frame watch. --- .../ContextMenuTouchGrowth.test.tsx | 108 +++++++++++++++--- .../contextMenuPressFeedback.test.tsx | 91 ++++++++++++--- .../ContextMenu/contextMenuPressFeedback.ts | 82 +++++++++---- 3 files changed, 231 insertions(+), 50 deletions(-) diff --git a/packages/core/src/ContextMenu/ContextMenuTouchGrowth.test.tsx b/packages/core/src/ContextMenu/ContextMenuTouchGrowth.test.tsx index e6aea9a540b25..307a5479f6ab3 100644 --- a/packages/core/src/ContextMenu/ContextMenuTouchGrowth.test.tsx +++ b/packages/core/src/ContextMenu/ContextMenuTouchGrowth.test.tsx @@ -55,7 +55,7 @@ interface Contact { } function dispatchTouch( - type: 'touchstart' | 'touchmove' | 'touchend', + type: 'touchstart' | 'touchmove' | 'touchend' | 'touchcancel', target: EventTarget, changed: Contact[], all: Contact[], @@ -78,6 +78,10 @@ const advance = (ms: number) => vi.advanceTimersByTime(ms); }); +// Fake animation frames come every 16 ms. The growth starts in the first one +// at or after its onset. +const FRAME_MS = 16; + /** The press machine's "on", as core's press machine writes it. */ async function pressMachineOn(trigger: HTMLElement) { trigger.setAttribute(PRESSED_ATTRIBUTE, 'on'); @@ -148,7 +152,7 @@ describe('ContextMenu’s growth under a finger', () => { const trigger = renderMenu(); const contact = {id: 1, x: 20, y: 20}; dispatchTouch('touchstart', trigger, [contact], [contact]); - advance(150); + advance(150 + FRAME_MS); await pressMachineOn(trigger); expect(growths(trigger)).toHaveLength(1); advance(100); @@ -162,6 +166,16 @@ describe('ContextMenu’s growth under a finger', () => { element === trigger && ((to as {scale?: number}).scale ?? 1) > 1, ); const startOf = (call: unknown[]) => (call[3] as {at: number}).at; + // One growth, started from rest in the first frame at or after the onset. + const expectGrowthFromOnsetFrame = ( + trigger: HTMLElement, + landedAt: number, + ) => { + const starts = growths(trigger).map(startOf); + expect(starts).toHaveLength(1); + expect(starts[0]).toBeGreaterThanOrEqual(landedAt + 150); + expect(starts[0]).toBeLessThan(landedAt + 150 + FRAME_MS); + }; it('starts the growth at the onset, 150 ms after the finger lands, when the press machine marks the press as the finger lands', async () => { const trigger = renderMenu(); @@ -172,8 +186,8 @@ describe('ContextMenu’s growth under a finger', () => { await pressMachineOn(trigger); advance(149); expect(growths(trigger)).toHaveLength(0); - advance(1); - expect(growths(trigger).map(startOf)).toEqual([landedAt + 150]); + advance(1 + FRAME_MS); + expectGrowthFromOnsetFrame(trigger, landedAt); }); it('grows the trigger at the onset when the press machine marks the press before the recognizer has the finger', async () => { @@ -184,8 +198,8 @@ describe('ContextMenu’s growth under a finger', () => { await pressMachineOn(trigger); const landedAt = performance.now(); dispatchTouch('touchstart', trigger, [contact], [contact]); - advance(150); - expect(growths(trigger).map(startOf)).toEqual([landedAt + 150]); + advance(150 + FRAME_MS); + expectGrowthFromOnsetFrame(trigger, landedAt); }); it('still grows at the onset after a drift past the press machine’s 10 px that the recognizer’s 11.5 px tolerance still follows', async () => { @@ -199,8 +213,8 @@ describe('ContextMenu’s growth under a finger', () => { const drifted = {...contact, x: 31}; dispatchTouch('touchmove', trigger, [drifted], [drifted]); await pressMachineOff(trigger); - advance(90); - expect(growths(trigger).map(startOf)).toEqual([landedAt + 150]); + advance(90 + FRAME_MS); + expectGrowthFromOnsetFrame(trigger, landedAt); }); it('grows at the onset after a drift the press machine never marks, as inside a scroll view, that the recognizer still follows', async () => { @@ -213,8 +227,8 @@ describe('ContextMenu’s growth under a finger', () => { // marks the press, and inside the recognizer's 11.5 px. const drifted = {...contact, x: 28, y: 28}; dispatchTouch('touchmove', trigger, [drifted], [drifted]); - advance(90); - expect(growths(trigger).map(startOf)).toEqual([landedAt + 150]); + advance(90 + FRAME_MS); + expectGrowthFromOnsetFrame(trigger, landedAt); }); it('grows at the onset, 150 ms after the finger lands, even when the press machine marks the press later', async () => { @@ -222,8 +236,8 @@ describe('ContextMenu’s growth under a finger', () => { const contact = {id: 1, x: 20, y: 20}; const landedAt = performance.now(); dispatchTouch('touchstart', trigger, [contact], [contact]); - advance(150); - expect(growths(trigger).map(startOf)).toEqual([landedAt + 150]); + advance(150 + FRAME_MS); + expectGrowthFromOnsetFrame(trigger, landedAt); // Inside a scroll view the machine's own timer marks it later. advance(90); await pressMachineOn(trigger); @@ -250,10 +264,76 @@ describe('ContextMenu’s growth under a finger', () => { expect(trigger.getAttribute(PRESSED_ATTRIBUTE)).toBe('on'); advance(149); expect(growths(trigger)).toHaveLength(0); - advance(1); - expect(growths(trigger).map(startOf)).toEqual([landedAt + 150]); + advance(1 + FRAME_MS); + expectGrowthFromOnsetFrame(trigger, landedAt); + }); + + it('builds the motion engine in the first frame after the finger lands, not in its touchstart handler', async () => { + const trigger = renderMenu(); + const contact = {id: 1, x: 20, y: 20}; + dispatchTouch('touchstart', trigger, [contact], [contact]); + expect(animator.elements).not.toContain(trigger); + advance(FRAME_MS); + expect(animator.elements).toContain(trigger); + expect(growths(trigger)).toHaveLength(0); }); + it('shows nothing for a quick tap: no animation and no inline style', async () => { + const trigger = renderMenu(); + const contact = {id: 1, x: 20, y: 20}; + const style = trigger.getAttribute('style'); + dispatchTouch('touchstart', trigger, [contact], [contact]); + advance(40); + dispatchTouch('touchend', trigger, [contact], []); + advance(400); + expect(animator.animate).not.toHaveBeenCalled(); + expect(animator.play).not.toHaveBeenCalled(); + expect(trigger.getAttribute('style')).toBe(style); + }); + + it.each([ + [ + 'the finger lifts', + (trigger: HTMLElement, contact: Contact) => + dispatchTouch('touchend', trigger, [contact], []), + ], + [ + 'the finger moves past the hold’s tolerance', + (trigger: HTMLElement, contact: Contact) => { + const moved = {...contact, x: contact.x + 20}; + dispatchTouch('touchmove', trigger, [moved], [moved]); + }, + ], + [ + 'the browser cancels the touch', + (trigger: HTMLElement, contact: Contact) => + dispatchTouch('touchcancel', trigger, [contact], [contact]), + ], + ])( + 'stops watching frames for the onset when %s before it', + async (_, stop) => { + const trigger = renderMenu(); + const contact = {id: 1, x: 20, y: 20}; + dispatchTouch('touchstart', trigger, [contact], [contact]); + advance(60); + const frames = vi.spyOn(window, 'requestAnimationFrame'); + const cancels = vi.spyOn(window, 'cancelAnimationFrame'); + try { + stop(trigger, contact); + expect(cancels).toHaveBeenCalled(); + advance(200); + // The onset's watch asks for no frame after it stops. + expect( + frames.mock.calls.filter(([callback]) => callback.name === 'look'), + ).toEqual([]); + expect(growths(trigger)).toHaveLength(0); + } finally { + frames.mockRestore(); + cancels.mockRestore(); + } + }, + ); + it('does not grow the trigger for a press its recognizer does not follow, such as a mouse', async () => { const trigger = renderMenu(); await pressMachineOn(trigger); diff --git a/packages/core/src/ContextMenu/contextMenuPressFeedback.test.tsx b/packages/core/src/ContextMenu/contextMenuPressFeedback.test.tsx index 2a1879ca09f9c..10f4830f570e2 100644 --- a/packages/core/src/ContextMenu/contextMenuPressFeedback.test.tsx +++ b/packages/core/src/ContextMenu/contextMenuPressFeedback.test.tsx @@ -139,15 +139,39 @@ const growths = () => ); /** Lets the shared mutation observer deliver its records. */ +// The growth starts in the next animation frame. const flush = async () => new Promise(resolve => { - setTimeout(resolve, 0); + requestAnimationFrame(() => { + setTimeout(resolve, 0); + }); }); +// Fake animation frames come every 16 ms. +const FRAME_MS = 16; +const FAKE_TIMERS = { + toFake: [ + 'setTimeout', + 'clearTimeout', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'performance', + ] as const, +}; +const startsOf = () => growths().map(call => (call[2] as {at: number}).at); +// One growth, started from rest in the first frame at or after the onset. +const expectGrowthFromOnsetFrame = (onsetAt: number) => { + const starts = startsOf(); + expect(starts).toHaveLength(1); + expect(starts[0]).toBeGreaterThanOrEqual(onsetAt); + expect(starts[0]).toBeLessThan(onsetAt + FRAME_MS); +}; + describe('useContextMenuPressFeedback', () => { afterEach(() => { vi.clearAllMocks(); animator.animated.length = 0; + animator.element = null; feedback = null; }); @@ -243,7 +267,7 @@ describe('useContextMenuPressFeedback', () => { }); it('grows a finger at its onset, 150 ms after the landing the recognizer reports', async () => { - vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'performance']}); + vi.useFakeTimers({toFake: [...FAKE_TIMERS.toFake]}); try { const {getByTestId} = render(); vi.advanceTimersByTime(1000); @@ -252,17 +276,15 @@ describe('useContextMenuPressFeedback', () => { feedback?.begin(landedAt, getByTestId('trigger')); await vi.advanceTimersByTimeAsync(109); expect(growths()).toHaveLength(0); - await vi.advanceTimersByTimeAsync(1); - expect(growths().map(call => (call[2] as {at: number}).at)).toEqual([ - landedAt + 150, - ]); + await vi.advanceTimersByTimeAsync(1 + FRAME_MS); + expectGrowthFromOnsetFrame(landedAt + 150); } finally { vi.useRealTimers(); } }); it('keeps a finger’s onset when the press machine marks the press later', async () => { - vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'performance']}); + vi.useFakeTimers({toFake: [...FAKE_TIMERS.toFake]}); try { recognizer.isFinger = true; const {getByTestId} = render(); @@ -272,9 +294,7 @@ describe('useContextMenuPressFeedback', () => { await vi.advanceTimersByTimeAsync(240); getByTestId('trigger').setAttribute(PRESSED_ATTRIBUTE, 'on'); await vi.advanceTimersByTimeAsync(200); - expect(growths().map(call => (call[2] as {at: number}).at)).toEqual([ - landedAt + 150, - ]); + expectGrowthFromOnsetFrame(landedAt + 150); } finally { recognizer.isFinger = false; vi.useRealTimers(); @@ -282,19 +302,19 @@ describe('useContextMenuPressFeedback', () => { }); it('starts a pen’s growth at its mark after a finger’s press ended', async () => { - vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'performance']}); + vi.useFakeTimers({toFake: [...FAKE_TIMERS.toFake]}); try { recognizer.isFinger = true; const {getByTestId} = render(); const trigger = getByTestId('trigger'); feedback?.begin(performance.now(), trigger); - await vi.advanceTimersByTimeAsync(150); + await vi.advanceTimersByTimeAsync(150 + FRAME_MS); expect(growths()).toHaveLength(1); feedback?.end(performance.now()); // A pen's press: the press machine's mark starts its growth. recognizer.isFinger = false; trigger.setAttribute(PRESSED_ATTRIBUTE, 'on'); - await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(FRAME_MS); expect(growths()).toHaveLength(2); } finally { recognizer.isFinger = false; @@ -303,19 +323,60 @@ describe('useContextMenuPressFeedback', () => { }); it('grows only the innermost trigger for a finger the recognizer reports on a nested trigger', async () => { - vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'performance']}); + vi.useFakeTimers({toFake: [...FAKE_TIMERS.toFake]}); try { const {getByTestId} = render(); const inner = getByTestId('inner'); nested.outer?.begin(performance.now(), inner); nested.inner?.begin(performance.now(), inner); - await vi.advanceTimersByTimeAsync(150); + await vi.advanceTimersByTimeAsync(150 + FRAME_MS); expect(animator.animated).toEqual([inner]); } finally { vi.useRealTimers(); } }); + it('builds the motion engine in the first frame after the landing, before the onset', async () => { + vi.useFakeTimers({toFake: [...FAKE_TIMERS.toFake]}); + try { + const {getByTestId} = render(); + const trigger = getByTestId('trigger'); + feedback?.begin(performance.now(), trigger); + expect(animator.element).toBeNull(); + await vi.advanceTimersByTimeAsync(FRAME_MS); + expect(animator.element).toBe(trigger); + expect(growths()).toHaveLength(0); + } finally { + vi.useRealTimers(); + } + }); + + it.each([ + ['the recognizer ends the press', () => feedback?.end(performance.now())], + ['the menu shows', () => feedback?.hold()], + ])( + 'stops watching frames for the onset when %s before it', + async (_, stop) => { + vi.useFakeTimers({toFake: [...FAKE_TIMERS.toFake]}); + try { + const {getByTestId} = render(); + feedback?.begin(performance.now(), getByTestId('trigger')); + await vi.advanceTimersByTimeAsync(60); + const frames = vi.spyOn(window, 'requestAnimationFrame'); + try { + stop(); + await vi.advanceTimersByTimeAsync(200); + expect(frames).not.toHaveBeenCalled(); + expect(growths()).toHaveLength(0); + } finally { + frames.mockRestore(); + } + } finally { + vi.useRealTimers(); + } + }, + ); + it('returns on springs.contextMenuDismiss when the menu the press opened closes', async () => { const {getByTestId} = render(); const trigger = getByTestId('trigger'); diff --git a/packages/core/src/ContextMenu/contextMenuPressFeedback.ts b/packages/core/src/ContextMenu/contextMenuPressFeedback.ts index f9e808c1a6217..9e591e59b10a5 100644 --- a/packages/core/src/ContextMenu/contextMenuPressFeedback.ts +++ b/packages/core/src/ContextMenu/contextMenuPressFeedback.ts @@ -14,10 +14,12 @@ * SYNC: When modified, update /packages/core/src/ContextMenu/ContextMenu.tsx */ -// The growth starts at the onset, 150 ms after a finger lands, while the -// recognizer follows that finger. The recognizer reports the landing; a -// pointer-only pen's press waits for the press machine's "on". Only the -// recognizer's failure or cancel returns it. A long press is a gesture, so the +// The growth starts in the first frame at or after the onset, 150 ms after a +// finger lands, while the recognizer follows that finger. It plays its whole +// spring from rest, from that frame, as Core Animation starts an animation at +// its commit. The recognizer reports the landing; a pointer-only pen's press +// waits for the press machine's "on". Only the recognizer's failure or cancel +// returns it. A long press is a gesture, so the // growth plays UIKit's lift spring and each return its dismissal spring. The // compositor plays them, so a busy main thread does not stall them. Once the // menu opens, the trigger holds its size until the menu closes. Then it returns @@ -41,14 +43,14 @@ const {heldScale} = contextMenu.press; export interface ContextMenuPressFeedback { /** * The recognizer took a finger that landed at `landedAt` on `target`: grow - * at its onset, 150 ms later. + * from the first frame at or after its onset, 150 ms later. */ begin(landedAt: number, target: Element | null): void; /** The menu the press opened closed: return on the dismissal spring. */ release(): void; /** * The recognizer ended the press with no menu, at `time`: return on the - * dismissal spring. + * dismissal spring. A growth still to come does not start. */ end(time: number): void; /** @@ -73,6 +75,32 @@ const NO_FINGER: RefObject<() => boolean> = {current: () => false}; // at or above it. So a press on a nested trigger grows no outer trigger. const pressMarkOwners = new WeakSet(); +/** + * Calls `onDue` in the first animation frame that runs at or after `dueAt`, on + * the event clock, with the time its callback runs. `onWait` runs in each frame + * before it. It reads only the clock. A frame comes on time where a timer does + * not: Chromium holds the page's timers for about 100 ms after it handles a + * touchstart. Returns the function that stops it. + */ +function watchFramesFrom( + dueAt: number, + onWait: () => void, + onDue: (time: number) => void, +): () => void { + let frame = requestAnimationFrame(function look() { + const time = performance.now(); + if (time >= dueAt) { + onDue(time); + return; + } + onWait(); + frame = requestAnimationFrame(look); + }); + return () => { + cancelAnimationFrame(frame); + }; +} + function pressMarkOwnerOf(marked: Element): Element | null { for ( let node: Element | null = marked; @@ -115,6 +143,12 @@ export function useContextMenuPressFeedback( const fingerOnsetRef = useRef< ((landedAt: number, target: Element | null) => void) | null >(null); + // Stops the frame watch of a growth still to come. + const stopOnsetRef = useRef<(() => void) | null>(null); + const stopOnset = useCallback(() => { + stopOnsetRef.current?.(); + stopOnsetRef.current = null; + }, []); // The compositor animator lives with the trigger it follows. A replaced // trigger, or a feedback that goes while the trigger stays, takes the @@ -171,7 +205,8 @@ export function useContextMenuPressFeedback( return; } isGrownRef.current = true; - // `time` is the onset: 150 ms after the finger lands, or a later mark. + // `time` is when the first frame at or after the onset runs. The curve + // starts there from rest, so no frame skips into it. animator.animate( {scale: heldScale}, {spring: CONTEXT_MENU_LIFT_SPRING}, @@ -183,6 +218,7 @@ export function useContextMenuPressFeedback( const end = useCallback( (time: number) => { + stopOnset(); const animator = animatorRef.current; const element = animator?.element; if ( @@ -201,7 +237,7 @@ export function useContextMenuPressFeedback( ).finished, ); }, - [settleBack], + [settleBack, stopOnset], ); const release = useCallback(() => { @@ -222,13 +258,14 @@ export function useContextMenuPressFeedback( }, []); const hold = useCallback(() => { + stopOnset(); isHeldRef.current = true; if (isGrownRef.current) { animatorRef.current?.stop(['scale']); } - }, []); + }, [stopOnset]); - // Grow at the onset, 150 ms after the finger lands. The recognizer reports + // Grow from the onset, 150 ms after the finger lands. The recognizer reports // the landing as it takes the finger, whether or not the press machine // marks the press. Inside a scroll view the machine marks late, and it never // marks a press that travels past 10 px first. A pen's onset waits for the @@ -251,14 +288,18 @@ export function useContextMenuPressFeedback( ); let isPressed = isMarkedOn(); let markedAt = 0; - let onsetTimer: ReturnType | undefined; const startAt = (onsetAt: number) => { - const wait = onsetAt - performance.now(); - if (wait > 0) { - onsetTimer = setTimeout(() => start(onsetAt), wait); - } else { - start(onsetAt); - } + stopOnset(); + stopOnsetRef.current = watchFramesFrom( + onsetAt, + // The engine is built in a frame, not in the handler of the touch, + // so the frame that starts the growth only creates its animation. + animatorFor, + time => { + stopOnsetRef.current = null; + start(time); + }, + ); }; // A pen's mark: the gesture host took that pen from the same input // before the mark's record comes, so the recognizer follows it already. @@ -279,7 +320,6 @@ export function useContextMenuPressFeedback( if (target != null && pressMarkOwnerOf(target) !== element) { return; } - clearTimeout(onsetTimer); startAt(landed + PRESS_ONSET_MS); }; const stopObserving = observeMutations( @@ -291,20 +331,20 @@ export function useContextMenuPressFeedback( isPressed = next; if (next) { if (!followsFinger.current()) { - clearTimeout(onsetTimer); + stopOnset(); markedAt = performance.now(); growAtMark(); } } else if (!isFollowed.current()) { // A finger the recognizer still follows keeps its onset, past // the machine's shorter travel. - clearTimeout(onsetTimer); + stopOnset(); } } }, ); return () => { - clearTimeout(onsetTimer); + stopOnset(); stopObserving(); fingerOnsetRef.current = null; pressMarkOwners.delete(element); From d44956d08bd3c696d39fe4e0ef720513e34d930a Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 10:41:35 -0800 Subject: [PATCH 020/118] test(ContextMenu): the lift shows the menu inside its own dispatch The proof read "the menu shows in the lift's task" as "within one frame of the lift". Merged with main, the menu's render in that task takes 17 ms in the WebKit CI container, so the time proxy failed while the menu still showed before any frame. A page flag now records whether the menu shows while the lift's listeners run. --- ...tMenuLongPress.interaction.browser.spec.ts | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index 87bbbbb8c2c2f..f4ee001b28a2b 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -90,11 +90,14 @@ async function openTouchStory( const w = window as unknown as { __menuShownAt?: number | null; __liftHandledAt?: number | null; + __menuShownInLift?: boolean | null; __menuShownTimerAt?: number | null; }; w.__menuShownAt = null; w.__liftHandledAt = null; + w.__menuShownInLift = null; w.__menuShownTimerAt = null; + let isInLift = false; // When the timer task that runs now started. The hold's moments come from // the gesture arena's timers. The menu commits in the task that decides // it, so its show runs inside that timer task. @@ -120,12 +123,22 @@ async function openTouchStory( timeout, ...args, )) as typeof window.setTimeout; - // When the page gets to the lift (the event's own timestamp is the - // hardware's, or the script's): the menu shows in the same task. + // When the page gets to the lift. The event's own timestamp is the + // hardware's, or the script's. Also whether the menu shows while the + // lift's listeners run: then no frame paints between the two. addEventListener( 'touchend', () => { w.__liftHandledAt = performance.now(); + isInLift = true; + // Added during the dispatch, it runs after the page's own listeners. + addEventListener( + 'touchend', + () => { + isInLift = false; + }, + {once: true, passive: true}, + ); }, {capture: true, passive: true}, ); @@ -142,6 +155,7 @@ async function openTouchStory( // Only the first show counts, so a later one cannot stand in for it. if (w.__menuShownAt == null) { w.__menuShownAt = performance.now(); + w.__menuShownInLift = isInLift; w.__menuShownTimerAt = timerAt; } } @@ -501,18 +515,24 @@ test('a finger lifted after the commit gets the menu at the lift, and sends no c await driver.play(hold(center, 530)); await page.waitForTimeout(300); await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeVisible(); - const {lift, shown} = await page.evaluate(() => { + const {lift, shown, isInLift} = await page.evaluate(() => { const w = window as unknown as { __menuShownAt?: number | null; __liftHandledAt?: number | null; + __menuShownInLift?: boolean | null; + }; + return { + lift: w.__liftHandledAt ?? null, + shown: w.__menuShownAt ?? null, + isInLift: w.__menuShownInLift ?? null, }; - return {lift: w.__liftHandledAt ?? null, shown: w.__menuShownAt ?? null}; }); expect(lift).not.toBeNull(); expect(shown).not.toBeNull(); - // In the lift's own task: after it, and before the next frame could paint. + // In the lift's own dispatch: after it, and before the next frame could + // paint, however long the menu's render takes in that task. expect((shown as number) - (lift as number)).toBeGreaterThanOrEqual(0); - expect((shown as number) - (lift as number)).toBeLessThan(1000 / 60); + expect(isInLift).toBe(true); const log = await readInputLog(page); const end = log.filter(record => record.type === 'touchend').at(-1); expect(end?.defaultPrevented, JSON.stringify(log)).toBe(true); From 409e581997fa24733af5e3bbe8a178992bd3f5ce Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 10:41:35 -0800 Subject: [PATCH 021/118] test(ContextMenu): the menu loads the animator at once for its growth Merged with main, the lazy-sheet bundle proof pins that a menu with an on-demand sheet keeps the animator on demand. ContextMenu's touch growth plays on the animator from the first touch, so the animator loads with the menu. The proof now expects that for ContextMenu, and `rideKeyboard` still loads only with the sheet. --- scripts/lazy-sheet-bundle.test.mjs | 33 +++++++++++++++++++----------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/scripts/lazy-sheet-bundle.test.mjs b/scripts/lazy-sheet-bundle.test.mjs index d0f74abb83cb5..807fb86c36323 100644 --- a/scripts/lazy-sheet-bundle.test.mjs +++ b/scripts/lazy-sheet-bundle.test.mjs @@ -7,8 +7,9 @@ * @output Pins that a menu's static import keeps its on-demand bottom sheet's * keyboard code on demand. The sheet's keyboard ride (the animator and * `rideKeyboard`) stays in the chunks the import loads only when the sheet - * opens. This holds though the layer runtime's keyboard cap imports the - * keyboard module statically. + * opens. A menu that uses the animator at once loads it with the menu. This + * holds though the layer runtime's keyboard cap imports the keyboard module + * statically. * @position Node test over source, so it needs no build */ @@ -110,20 +111,28 @@ function rideIn(bytes) { describe('a menu with an on-demand bottom sheet', () => { it.each([ - ['DropdownMenu', 'packages/core/src/DropdownMenu/index.ts'], - ['ContextMenu', 'packages/core/src/ContextMenu/index.ts'], - ['Selector', 'packages/core/src/Selector/index.ts'], - ['MultiSelector', 'packages/core/src/MultiSelector/index.ts'], + ['DropdownMenu', 'packages/core/src/DropdownMenu/index.ts', []], + // ContextMenu's touch growth plays on the animator, so the animator loads + // with the menu. The sheet's own ride is then `rideKeyboard` alone. + [ + 'ContextMenu', + 'packages/core/src/ContextMenu/index.ts', + ['packages/core/src/motion/animator.ts'], + ], + ['Selector', 'packages/core/src/Selector/index.ts', []], + ['MultiSelector', 'packages/core/src/MultiSelector/index.ts', []], ])( "%s loads the sheet's keyboard ride only with the sheet", - (name, entry) => { + (name, entry, eagerRide) => { const {eager, onDemand} = importOf(entry, name); // The sheet still rides the keyboard, on demand. - expect(rideIn(onDemand)).toEqual([ - 'packages/core/src/keyboard/rideKeyboard.ts', - 'packages/core/src/motion/animator.ts', - ]); - expect(rideIn(eager)).toEqual([]); + expect(rideIn(onDemand)).toEqual( + [ + 'packages/core/src/keyboard/rideKeyboard.ts', + 'packages/core/src/motion/animator.ts', + ].filter(input => !eagerRide.includes(input)), + ); + expect(rideIn(eager)).toEqual(eagerRide); }, ); }); From abb70e167d42684a20ea59c03959758be7d7e72b Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 10:41:35 -0800 Subject: [PATCH 022/118] test(ContextMenu): a starved page fails the 735 ms proof by name A WebKit CI run showed the menu at 890 ms, inside the timer task that decided it, so that task itself started late. The proof now logs the page's frames. A deciding task that starts within a frame of its due time keeps the 20 ms bound. A later start that a frame gap over 50 ms covers fails with FrameStarvationError, its numbers named; any other late start fails bare. The bound does not move. --- ...tMenuLongPress.interaction.browser.spec.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index efba3d49c46db..deddefbbed4bf 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -21,6 +21,8 @@ import { } from '@astryxdesign/a11y-spec/playwright'; import { drag, + FRAME_STARVATION_MS, + FrameStarvationError, installInputLog, openStory, readInputLog, @@ -149,11 +151,79 @@ const touchStartedAt = async (page: Page) => lastInput(page, 'touchstart'); /** A finger held still for `ms`, then lifted. */ const hold = (at: {x: number; y: number}, ms: number) => tap(at, {holdMs: ms}); +/** One frame at 60 Hz, ms. */ +const FRAME_MS = 1000 / 60; + +/** Logs when each frame's callbacks run, on the page clock, until read. */ +async function startFrameLog(page: Page) { + await page.evaluate(() => { + const w = window as unknown as {__frameLog?: number[]}; + const log: number[] = []; + w.__frameLog = log; + const tick = () => { + log.push(performance.now()); + if (w.__frameLog === log) { + requestAnimationFrame(tick); + } + }; + requestAnimationFrame(tick); + }); +} + +/** Stops the frame log and reads it. */ +async function readFrameLog(page: Page): Promise { + return page.evaluate(() => { + const w = window as unknown as {__frameLog?: number[]}; + const log = w.__frameLog ?? []; + w.__frameLog = undefined; + return log; + }); +} + +/** + * The timer task that decides the menu starts within a frame of its due + * time. A later start that a frame gap over `FRAME_STARVATION_MS` covers + * fails by name: the page was starved of frames. Any other late start fails + * bare, because something on the page held the timer. + */ +function expectDecidedOnTime( + frames: ReadonlyArray, + dueAt: number, + startedAt: number, +) { + const lateMs = startedAt - dueAt; + if (lateMs <= FRAME_MS) { + return; + } + let longestGapMs = 0; + for (let i = 1; i < frames.length; i++) { + const coversLateStart = + frames[i - 1] <= dueAt + FRAME_MS && frames[i] >= startedAt - FRAME_MS; + if (coversLateStart) { + longestGapMs = Math.max(longestGapMs, frames[i] - frames[i - 1]); + } + } + if (longestGapMs > FRAME_STARVATION_MS) { + throw new FrameStarvationError({ + during: `the menu's timer task was due, and it started ${lateMs.toFixed(1)} ms late`, + frames: frames.filter(t => t > dueAt && t < startedAt).length, + min: Math.max(1, Math.floor(lateMs / FRAME_MS)), + longestGapMs, + }); + } + expect( + lateMs, + 'the timer task that decides the menu started late while frames ran', + ).toBeLessThanOrEqual(FRAME_MS); +} + test('a finger still down gets the menu at 735 ms (iOS 26.2), not before', async ({ page, }) => { const {driver, center} = await openTouchStory(page); + await startFrameLog(page); await driver.play(hold(center, menuDisplayMs + 200)); + const frames = await readFrameLog(page); const start = await touchStartedAt(page); const shown = await menuShownAt(page); const decided = await menuShownTimerAt(page); @@ -165,6 +235,7 @@ test('a finger still down gets the menu at 735 ms (iOS 26.2), not before', async 'the menu showed after the task that decided it', ).not.toBeNull(); expect((decided as number) - start).toBeGreaterThanOrEqual(menuDisplayMs - 1); + expectDecidedOnTime(frames, start + menuDisplayMs, decided as number); const after = (shown as number) - start; // The timer runs at its deadline, give or take a task. expect(after).toBeGreaterThanOrEqual(menuDisplayMs); From 983e46f38d1b10aa89835e80dba9ec844db299f7 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 10:41:35 -0800 Subject: [PATCH 023/118] feat(ContextMenu): the finger that opened the menu slides to an item Where a coarse pointer's held finger opens the menu, the same finger slides onto an item, which lights under it, and its lift selects it. A presentation="popover" or fine-pointer long press hands nothing over. It carries this pull request's own diff from bc4efd0469, replayed on main after #9814's squash, and folds these 11 commits (the history of bc4efd0469 has each one): - test(useMenuPress): a pointer that went down elsewhere is the menu's opening press - feat(useMenuPress): a pointer that went down elsewhere is the menu's opening press - test(ContextMenu): the finger a long press opened the menu under slides onto an item and its lift selects it - feat(ContextMenu): the finger a long press opened the menu under slides onto an item, and its lift selects it - docs(ContextMenu): FR12 records the slide onto an item after a long press opens the menu - docs(ContextMenu): the slide's release note and docs say the menu opens at the finger - docs(ContextMenu): FR12's slide follows a long press that opens the menu at the finger - docs(ContextMenu): FR5 points the slide to FR13 - docs(core): useMenuPress's @output keeps main's sentence - docs(ContextMenu): the slide changeset names where the slide applies - docs(ContextMenu): the slide changeset covers adaptive popovers too --- .changeset/contextmenu-touch-slide-to-pick.md | 7 + .../storybook/stories/ContextMenu.stories.tsx | 62 ++++--- .../ContextMenu/ContextMenu.spec.md | 13 +- .../core/src/ContextMenu/ContextMenu.doc.mjs | 4 +- .../core/src/ContextMenu/ContextMenu.test.tsx | 126 ++++++++++++++ packages/core/src/ContextMenu/ContextMenu.tsx | 31 ++-- .../src/ContextMenu/ContextMenuTouchLayer.tsx | 45 ++++- ...tMenuLongPress.interaction.browser.spec.ts | 122 +++++++++++++- packages/core/src/hooks/useMenuPress.test.tsx | 159 ++++++++++++++++++ packages/core/src/hooks/useMenuPress.ts | 118 ++++++++++--- 10 files changed, 612 insertions(+), 75 deletions(-) create mode 100644 .changeset/contextmenu-touch-slide-to-pick.md diff --git a/.changeset/contextmenu-touch-slide-to-pick.md b/.changeset/contextmenu-touch-slide-to-pick.md new file mode 100644 index 0000000000000..a1d8f0392d6b0 --- /dev/null +++ b/.changeset/contextmenu-touch-slide-to-pick.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] ContextMenu where the primary pointer is coarse and the menu opens at the finger (`presentation` unset, or `adaptive` where it stays a popover): after the held finger opens the menu, the same finger may slide onto an action without lifting, as on iOS. Lifting over an action picks it, and lifting over no action leaves the menu open. The finger picks only after it has moved 10 px in the open menu, so a still lift picks nothing. + +@vjeux diff --git a/apps/storybook/stories/ContextMenu.stories.tsx b/apps/storybook/stories/ContextMenu.stories.tsx index 0c25ea1cea0b4..c2892ca28c1fd 100644 --- a/apps/storybook/stories/ContextMenu.stories.tsx +++ b/apps/storybook/stories/ContextMenu.stories.tsx @@ -649,6 +649,12 @@ export const BottomSheetSubmenu: Story = { }; const touchStyles = stylex.create({ + demo: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: '12px', + }, card: { width: '200px', height: '120px', @@ -667,31 +673,47 @@ export const TouchLongPress: Story = { docs: { description: { story: - 'Where the primary pointer is coarse (a touch screen), hold a finger on the card. As on iOS, it starts to grow after 150 ms, a haptic plays at 400 ms where the device can play one, and the menu opens at 735 ms, or as soon as the finger lifts after 400 ms. A finger that lifts before 400 ms, or drifts more than 11.5 px on either axis before the menu opens, gets no menu, and the card springs back to its size. While the menu is up, the page still scrolls: a touch outside the menu closes it as it begins to pan. The card returns to its size when the menu closes.', + 'Where the primary pointer is coarse (a touch screen), hold a finger on the card. As on iOS, it starts to grow after 150 ms, a haptic plays at 400 ms where the device can play one, and the menu opens at 735 ms, or as soon as the finger lifts after 400 ms. Once the menu is up, the same finger may slide onto an action without lifting: lifting over an action picks it, and lifting over no action leaves the menu open. The last action picked shows below the card. A finger that lifts before 400 ms, or drifts more than 11.5 px on either axis before the menu opens, gets no menu, and the card springs back to its size. While the menu is up, the page still scrolls: a touch outside the menu closes it as it begins to pan. The card returns to its size when the menu closes.', }, }, }, - render: () => ( - {}}, - {label: 'Share', icon: ShareIcon, onClick: () => {}}, - {label: 'Save to Files', icon: ArrowDownTrayIcon, onClick: () => {}}, - {type: 'divider'}, - { - label: 'Delete', - icon: TrashIcon, - variant: 'destructive', - onClick: () => {}, - }, - ]}> -
Hold for actions
-
- ), + render: () => , }; +/** The touch story's menu, and the last action it picked. */ +function TouchLongPressDemo() { + const [lastAction, setLastAction] = useState(null); + const pick = (label: string) => () => setLastAction(label); + return ( +
+ +
Hold for actions
+
+

+ {lastAction == null ? 'No action yet' : `Last action: ${lastAction}`} +

+
+ ); +} + export const TouchLongPressPopover: Story = { name: 'Touch / long press, presentation="popover"', parameters: { diff --git a/docs/specs/components/ContextMenu/ContextMenu.spec.md b/docs/specs/components/ContextMenu/ContextMenu.spec.md index 5b9cc4172ffde..c12b61417580e 100644 --- a/docs/specs/components/ContextMenu/ContextMenu.spec.md +++ b/docs/specs/components/ContextMenu/ContextMenu.spec.md @@ -106,7 +106,7 @@ Consumer props, content modes, and presentation policy remain documented in | FR2 | The `context-menu` target stays on the painted Pointer menu surface and alternative Touch menu surface, not on the Trigger area or cursor anchor. | `architecture:component-theming-surface`; current source | | FR3 | Pointer action rows retain DropdownMenu ownership. The touch frame retains BottomSheet ownership, while data-driven touch lists and rows retain List ownership. | Current source and owner docs | | FR4 | Compound `menuContent` remains a caller-supplied pointer-menu interior and may also render inside the touch frame; the data-driven touch path instead converts the same item data to List and ListItem presentation. | Current source and tests | -| FR5 | The Pointer menu surface carries `data-astryx-menu-press` and follows `module:DropdownMenu/useMenuPress` FR1–FR7: the row under the release acts, the highlight follows a held pointer, a mouse released outside closes and a finger leaves the menu open, the stray click never acts, and `touch-action` follows overflow. The long press that opened the menu does not close it as it lifts; a tap outside closes it, and a mouse press outside closes it at the press. Under a finger the menu MUST close as a pan that starts outside it begins (`spec:AST-094` FR32), MUST hand the held finger that opened it to the menu as the opening press, and MUST treat a pen's press outside as FR32 does (FR33). The lift and the grow apply only to a menu opened by touch (a touch-initiated open on a coarse pointer); a menu opened by mouse or keyboard keeps its released appearance and motion, with no lift, no tint and no grow. A touch-opened menu MUST grow out of the finger's point and shrink back into it (`spec:AST-094` FR31) as its own surface, by transform and opacity only (`spec:AST-094` FR5), with no second element such as a glass surface growing behind it, and on a block trigger MUST lift the trigger above the page with the menu beside it (FR33; DD1, DEC-2). The long-press presentation is a gesture component, so the trigger's lift plays `springs.contextMenuLift`, the menu's grow `springs.contextMenuMorph`, and the trigger's return, part of the menu's exit, `springs.contextMenuDismiss` (`spec:AST-094` FR4, FR7, FR45). Implementation gap: it keeps the layer's own entrance and exit, the opening finger is not handed to the menu so it cannot slide to a row, no trigger lift or page tint exists, and a pen press outside closes it at the press. Right-click, Shift+F10, long-press invocation, and keyboard navigation are as FR1 and FR8 state. | `module:DropdownMenu/useMenuPress`; `spec:AST-094` FR26–FR33 | +| FR5 | The Pointer menu surface carries `data-astryx-menu-press` and follows `module:DropdownMenu/useMenuPress` FR1–FR7: the row under the release acts, the highlight follows a held pointer, a mouse released outside closes and a finger leaves the menu open, the stray click never acts, and `touch-action` follows overflow. The long press that opened the menu does not close it as it lifts; a tap outside closes it, and a mouse press outside closes it at the press. Under a finger the menu MUST close as a pan that starts outside it begins (`spec:AST-094` FR32), MUST hand the held finger that opened it to the menu as the opening press, and MUST treat a pen's press outside as FR32 does (FR33). The held finger may then slide onto an item and lift to select it (FR13). The lift and the grow apply only to a menu opened by touch (a touch-initiated open on a coarse pointer); a menu opened by mouse or keyboard keeps its released appearance and motion, with no lift, no tint and no grow. A touch-opened menu MUST grow out of the finger's point and shrink back into it (`spec:AST-094` FR31) as its own surface, by transform and opacity only (`spec:AST-094` FR5), with no second element such as a glass surface growing behind it, and on a block trigger MUST lift the trigger above the page with the menu beside it (FR33; DD1, DEC-2). The long-press presentation is a gesture component, so the trigger's lift plays `springs.contextMenuLift`, the menu's grow `springs.contextMenuMorph`, and the trigger's return, part of the menu's exit, `springs.contextMenuDismiss` (`spec:AST-094` FR4, FR7, FR45). Implementation gap: it keeps the layer's own entrance and exit, no trigger lift or page tint exists, and a pen press outside closes it at the press. Right-click, Shift+F10, long-press invocation, and keyboard navigation are as FR1 and FR8 state. | `module:DropdownMenu/useMenuPress`; `spec:AST-094` FR26–FR33 | | FR6 | With `triggerAs="span"` the Trigger area is an inline element that owns a box, so the cursor anchor measures from the trigger's own rect on every invocation path. Right-click, Shift+F10, long-press, and `context-menu` target placement all use that box. | DEC-1 | | FR7 | Sub-menu rows follow `component:DropdownMenu` FR11 inside the pointer menu: ContextMenu mounts the same drill-in view stack, named after its `label`, so a sub-menu drills in on a phone here too. A sub-menu with a header or footer shows its rows alone here (`component:DropdownMenu` FR20). | `component:DropdownMenu/DEC-4` | | FR8 | In the pointer menu ArrowDown on the last row wraps to the first and ArrowUp on the first to the last; PageDown and PageUp page as in `component:DropdownMenu` FR12; a held key's auto-repeat does not activate; typeahead matches the row's label element alone. A disabled row is reached by these keys and by typeahead and stays inert, as `component:DropdownMenu` FR17 states. Implementation gap: the arrow keys and typeahead skip disabled rows today (Current-state impact). | Current source and tests | @@ -114,6 +114,7 @@ Consumer props, content modes, and presentation policy remain documented in | FR10 | ContextMenu MUST be built on the public headless context menu (`spec:AST-107` FR8), with its invocation, open state, and cleanup the factory's and no behavior of its own beside the factory's. Implementation gap: no headless context menu ships; ContextMenu owns its invocation, open state, outside-press listener, and cleanup itself. | `spec:AST-107` FR8 | | FR11 | Tab or Shift+Tab on a row of the pointer menu, its own or an open sub-menu flyout's at any depth, closes every open level and the menu and calls `onOpenChange(false)` once, as `component:DropdownMenu` FR23 states for its menu. | WAI-ARIA Menu Button pattern; current tests | | FR12 | Under a finger, a block trigger whose menu opens at the finger grows during a hold toward 1.0735 times its size on `springs.contextMenuLift`, from the onset, 150 ms after the finger lands, and it returns after a lift before the commit on `springs.contextMenuDismiss`, as FR5 names them. The hold commits at 400 ms and the menu displays at 735 ms, as UIKit's do. They are measured input, not motion, and stay internal (`spec:AST-094` FR4, FR45). | Measured on iOS 26.2 (`motion/uikit/contextMenu.ts`): UIKit commits at 400 ms, shows the menu at 735 ms, and grows the view on a linear ramp chased by a spring. `spec:AST-094` FR4, FR45: the two moments are measured input, and the long-press presentation is a gesture component (FR5), so the growth and its return play their spring roles | +| FR13 | After a long press opens the menu at the finger, the same finger may slide onto an item without lifting; lifting over an item selects it, and lifting over no item leaves the menu open. A lift before the finger has moved UIMenu's handoff distance selects nothing (`spec:AST-094` FR33). | `module:DropdownMenu/useMenuPress` FR11; `spec:AST-094` FR33 | ### Allowed variation @@ -253,7 +254,7 @@ anchor is positioning infrastructure rather than consumer anatomy. | FR1, FR4 | `ContextMenu.test.tsx` rendering, invocation, bottom-sheet, adaptive, compound, and disabled cases ("renders menu with role=\"menu\"", "opens menu on right-click", "opens from a keyboard-invoked contextmenu (Shift+F10 / Menu key)", "opens for a finger still down at 735 ms (iOS 26.2), not a millisecond before", "opens the real menu content in a BottomSheet when requested", "uses the BottomSheet for adaptive presentation on compact touch", "renders menuContent as menu items", "does not open when isDisabled is true") | Right-click, keyboard, long press; pointer and sheet; data and compound; disabled | A missing invocation path, menu role, sheet, or compound interior, or a disabled trigger that opens, fails. Evidence gap: no test asserts that a disabled trigger leaves the native context menu alone. | `audit:ContextMenu/anatomy` | | FR2 | `themingTargets.test.ts` | Rendered targets | A rendered class absent from the documented targets fails. Evidence gap: no test asserts `context-menu` on either painted surface or its absence from the trigger. | `audit:ContextMenu/theming` | | FR3 | `ContextMenu.test.tsx` destructive-variant, section, divider, selectable, and alias cases | Shared rows through ContextMenu | A row that loses DropdownMenu's semantics or variant fails. Evidence gap: no test asserts the BottomSheet and List delegation beyond rendering. | `audit:ContextMenu/theming` | -| FR5 | `ContextMenu.test.tsx` "acts on the row under a finger release, once, and swallows the stray click" and outside-dismissal cases; `outsidePress.test.ts` pan cases; `ContextMenuLongPress.interaction.browser.spec.ts` "a new finger that pans outside the open menu closes it, and the page scrolls"; `ContextMenuLongPress.a11y.chromium.spec.ts` | Finger release; long press lift; tap, mouse, pen, and pan outside | A row acting on the press row, a stray click that acts, a long press that closes its own menu, a tap or mouse press outside that leaves it open, or a pan outside that leaves it open fails. Evidence gap: no test asserts the FR31 grow, the opening-finger handoff, or the FR33 lift. | `audit:ContextMenu/behavior` | +| FR5 | `ContextMenu.test.tsx` "acts on the row under a finger release, once, and swallows the stray click" and outside-dismissal cases; `outsidePress.test.ts` pan cases; `ContextMenuLongPress.interaction.browser.spec.ts` "a new finger that pans outside the open menu closes it, and the page scrolls"; `ContextMenuLongPress.a11y.chromium.spec.ts` | Finger release; long press lift; tap, mouse, pen, and pan outside | A row acting on the press row, a stray click that acts, a long press that closes its own menu, a tap or mouse press outside that leaves it open, or a pan outside that leaves it open fails. Evidence gap: no test asserts the FR31 grow or the FR33 lift. | `audit:ContextMenu/behavior` | | FR5, DD1 touch scope | Browser tests on the touch story, the menu opened by a finger under a coarse pointer and by a mouse and by Shift+F10 under a fine pointer | Touch-opened, mouse-opened and keyboard-opened menus on a block trigger | A mouse- or keyboard-opened menu that lifts its trigger, tints the page or grows, or a touch-opened menu that blurs the page, grows a second surface, or animates anything but transform and opacity, fails. Evidence gap: the lift and the grow are unbuilt, so no test asserts either. | `audit:ContextMenu/behavior` | | FR5 Reduce Motion | Browser tests on the touch story with reduced motion | A touch-opened menu opening and closing under Reduce Motion | A lift, grow or shrink that moves rather than playing an opacity-only cross-fade of 200 ms or less (`spec:AST-094` FR8) fails. Evidence gap: no test exists. | `audit:ContextMenu/behavior` | | FR5 motion roles | Role registry tests over `spec:AST-094` FR4 | `springs.contextMenuLift`, `springs.contextMenuDismiss` and `springs.contextMenuMorph` at tempo 1 and `'standard'` | A lift, return or grow that plays a curve or a literal instead of its spring role, or a role off its FR4 table value, fails. | `audit:ContextMenu/behavior` | @@ -265,6 +266,7 @@ anchor is positioning infrastructure rather than consumer anatomy. | FR10, PR1 | `ContextMenu.test.tsx` invocation and handler-composition cases ("composes the consumer context-menu handler with menu opening", "keeps component-owned opening when a consumer prevents default", "passes touch events through to the consumer’s own handlers", and the cases where a consumer `onTouchStart` or `onTouchEnd` runs beside the hold) | Right click, long press, keyboard | A context request that does not open the menu, or a dropped consumer handler, fails. Evidence gap: no test asserts listener cleanup on unmount; nothing tests a headless factory, which does not ship (the FR10 gap). | `audit:ContextMenu/behavior` | | FR11 | `ContextMenu.test.tsx` "Tab from a flyout leaves the whole menu" case | a sub-menu row | A Tab that leaves the menu or a flyout open, or a second `onOpenChange(false)`, fails. | `audit:ContextMenu/accessibility` | | FR12 | `ContextMenu.test.tsx` "opens for a finger still down at 735 ms (iOS 26.2), not a millisecond before" and "opens at the lift for a finger lifted after the commit (400 ms), as UIKit does"; `contextMenuPress.test.ts`; `contextMenuPressFeedback.test.tsx` "grows toward the held scale on springs.contextMenuLift, from the onset" and "returns on springs.contextMenuDismiss, at the time the recognizer ended the press"; `ContextMenuTouchGrowth.test.tsx` onset cases; the touch story's browser curve and busy-thread cases in Chromium, and an iOS Safari recording for WebKit | A held finger: the commit, the display, the onset, the growth, the early-lift return, their springs | A commit or a display off UIKit's moments, a growth before the onset, or a growth or return off its spring role fails | `audit:ContextMenu/behavior` | +| FR13 | `ContextMenu.test.tsx` "lets the finger the menu opened under slide onto an item, and its lift selects it" and its handoff, no-item and lift-open cases; `ContextMenuLongPress.interaction.browser.spec.ts` "the finger the menu opened under slides onto an item, which lights under it, and its lift selects it" and its early-lift and slide-past cases; `useMenuPress.test.tsx` continuation suite | Held until the menu opens: a slide onto an item, a still lift, a slide past the menu; a menu opened at the lift | A still lift that selects an item, a slide whose lift selects nothing or another item, a lift over no item that closes the menu, or a page that scrolls under the slide fails. | `audit:ContextMenu/behavior` | | Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and target | Missing, extra, prefixed, stale, or unclassified mappings fail repository validation. | `audit:ContextMenu/theming` | | RTL relation | RTL audit curated D2 target (`apps/storybook/rtl-audit/targets.json`) on the `Presentation / BottomSheet submenu` story | BottomSheet opened into a submenu, LTR and RTL | The sheet header's back button not at the inline start of its heading in both directions fails D2. | `audit:ContextMenu/i18n-rtl` | @@ -276,10 +278,9 @@ anchor is positioning infrastructure rather than consumer anatomy. - **No headless factory (FR10).** No headless context menu ships, so ContextMenu owns its invocation, open state, and outside-press listener itself, against `spec:AST-107` FR8. -- **Touch grow, handoff, and lift (FR5).** A touch-opened menu keeps the - layer's own entrance and exit instead of growing from the finger, the held - finger that opened it cannot slide to a row, and a block trigger is not - lifted over a tinted page; a pen press outside closes it at the press. All +- **Touch grow and lift (FR5).** A touch-opened menu keeps the layer's own + entrance and exit instead of growing from the finger, and a block trigger is + not lifted over a tinted page; a pen press outside closes it at the press. All are against `spec:AST-094` FR31, FR32, and FR33. A menu opened by mouse or keyboard already keeps its released appearance and motion. diff --git a/packages/core/src/ContextMenu/ContextMenu.doc.mjs b/packages/core/src/ContextMenu/ContextMenu.doc.mjs index 7390609678f8b..26fbc9e526aac 100644 --- a/packages/core/src/ContextMenu/ContextMenu.doc.mjs +++ b/packages/core/src/ContextMenu/ContextMenu.doc.mjs @@ -159,7 +159,7 @@ export const docs = { usage: { anatomy, description: - 'A right-click context menu that appears at the cursor position. Use to provide contextual actions for specific elements or regions without cluttering the UI with visible buttons. Under a finger, where the primary pointer is coarse, a held finger opens it with iOS timing: a block trigger whose menu opens at the finger starts to grow at 150 ms, and the menu opens at 735 ms, or as soon as the finger lifts after 400 ms.', + 'A right-click context menu that appears at the cursor position. Use to provide contextual actions for specific elements or regions without cluttering the UI with visible buttons. Under a finger, where the primary pointer is coarse, a held finger opens it with iOS timing: a block trigger whose menu opens at the finger starts to grow at 150 ms, and the menu opens at 735 ms, or as soon as the finger lifts after 400 ms. When the menu opens at the finger, the same finger can then slide onto an action and lift to pick it.', bestPractices: [ { guidance: true, @@ -269,7 +269,7 @@ export const docsDense = { usage: { anatomy, description: - 'A right-click context menu that appears at the cursor position. Use to provide contextual actions for specific elements or regions. Under a finger (a coarse primary pointer): a held finger opens it at 735 ms, or on a lift after 400 ms; a block trigger whose menu opens at the finger grows from 150 ms.', + 'A right-click context menu that appears at the cursor position. Use to provide contextual actions for specific elements or regions. Under a finger (a coarse primary pointer): a held finger opens it at 735 ms, or on a lift after 400 ms; a block trigger whose menu opens at the finger grows from 150 ms; when the menu opens at the finger, the same finger then slides onto an action and lifts to pick it.', bestPractices: [ {guidance: true, description: 'Keep items concise and action-oriented.'}, { diff --git a/packages/core/src/ContextMenu/ContextMenu.test.tsx b/packages/core/src/ContextMenu/ContextMenu.test.tsx index e65447bba669b..f9cddd35cecce 100644 --- a/packages/core/src/ContextMenu/ContextMenu.test.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.test.tsx @@ -761,6 +761,27 @@ const advance = (ms: number) => vi.advanceTimersByTime(ms); }); +/** A finger's pointer event, stamped on the (faked) clock. */ +function dispatchPointer( + type: 'pointerdown' | 'pointermove' | 'pointerup', + target: EventTarget, + {pointerId, x, y}: {pointerId: number; x: number; y: number}, +): void { + const event = new PointerEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + pointerId, + pointerType: 'touch', + clientX: x, + clientY: y, + button: 0, + buttons: type === 'pointerup' ? 0 : 1, + }); + Object.defineProperty(event, 'timeStamp', {value: performance.now()}); + target.dispatchEvent(event); +} + /** The primary pointer answers `matches` to `(pointer: coarse)`. */ function stubPointer(isCoarse: boolean) { vi.stubGlobal( @@ -1235,6 +1256,111 @@ describe.each([ expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); press.up(); }); + /** A menu with two rows, held open by a finger that is still down. */ + function holdUntilTheMenuShows() { + const onCopy = vi.fn(); + const onShare = vi.fn(); + render( + +
Long-press me
+
, + ); + const trigger = screen.getByTestId('ctx'); + const contact = {id: 1, x: 20, y: 20}; + // A finger's pointer comes first, then its touch, as browsers send them. + dispatchPointer('pointerdown', trigger, {pointerId: 7, x: 20, y: 20}); + dispatchTouch('touchstart', trigger, [contact], [contact]); + advance(contextMenu.press.menuDisplayMs); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + const row = (name: string) => + screen.getByRole('menuitem', {hidden: true, name}); + const slide = (target: EventTarget, x: number, y: number) => { + dispatchPointer('pointermove', target, {pointerId: 7, x, y}); + dispatchTouch( + 'touchmove', + trigger, + [{...contact, x, y}], + [{...contact, x, y}], + ); + }; + const lift = (target: EventTarget, x: number, y: number) => { + dispatchPointer('pointerup', target, {pointerId: 7, x, y}); + dispatchTouch('touchend', trigger, [{...contact, x, y}], []); + }; + return {onCopy, onShare, row, slide, lift}; + } + + it('lets the finger the menu opened under slide onto an item, and its lift selects it', () => { + const {onCopy, onShare, row, slide, lift} = holdUntilTheMenuShows(); + // UIMenu's handoff counts from the first position the open menu sees. + slide(row('Share'), 20, 30); + slide(row('Share'), 20, 45); + expect(row('Share')).toHaveFocus(); + act(() => { + lift(row('Share'), 20, 45); + }); + expect(onShare).toHaveBeenCalledTimes(1); + expect(onCopy).not.toHaveBeenCalled(); + }); + + it('selects nothing for a lift before UIMenu’s handoff, however long the finger rests on an item, and leaves the menu open', () => { + const {onCopy, onShare, row, slide, lift} = holdUntilTheMenuShows(); + slide(row('Share'), 20, 26); + // Past the settle time a mouse would pick here; a finger needs the + // handoff. + advance(400); + act(() => { + lift(row('Share'), 20, 26); + }); + expect(onShare).not.toHaveBeenCalled(); + expect(onCopy).not.toHaveBeenCalled(); + expect(HTMLElement.prototype.hidePopover).not.toHaveBeenCalled(); + }); + + it('leaves the menu open for a lift over no item after the slide', () => { + const {onCopy, onShare, row, slide, lift} = holdUntilTheMenuShows(); + slide(row('Share'), 20, 30); + slide(row('Share'), 20, 45); + const menu = screen.getByRole('menu', {hidden: true}); + slide(menu, 20, 300); + act(() => { + lift(menu, 20, 300); + }); + expect(onShare).not.toHaveBeenCalled(); + expect(onCopy).not.toHaveBeenCalled(); + expect(HTMLElement.prototype.hidePopover).not.toHaveBeenCalled(); + }); + + it('hands nothing over when the menu opens at the lift: the finger is gone', () => { + const onShare = vi.fn(); + render( + +
Long-press me
+
, + ); + const trigger = screen.getByTestId('ctx'); + const contact = {id: 1, x: 20, y: 20}; + dispatchPointer('pointerdown', trigger, {pointerId: 7, x: 20, y: 20}); + dispatchTouch('touchstart', trigger, [contact], [contact]); + advance(500); + act(() => { + dispatchPointer('pointerup', trigger, {pointerId: 7, x: 20, y: 20}); + dispatchTouch('touchend', trigger, [contact], []); + }); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + // A later move of that pointer id is no press of this menu's. + const row = screen.getByRole('menuitem', {hidden: true, name: 'Share'}); + dispatchPointer('pointermove', row, {pointerId: 7, x: 20, y: 60}); + expect(row).not.toHaveFocus(); + expect(onShare).not.toHaveBeenCalled(); + }); }, ); diff --git a/packages/core/src/ContextMenu/ContextMenu.tsx b/packages/core/src/ContextMenu/ContextMenu.tsx index 83adbec81a3c2..4d450dc91bc67 100644 --- a/packages/core/src/ContextMenu/ContextMenu.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.tsx @@ -84,12 +84,13 @@ import {useMenuDrillIn} from '../DropdownMenu/useMenuDrillIn'; import {useInternalListFocus} from '../hooks/useListFocus'; import {useLongPress} from '../hooks/useLongPress'; import {pressableProps} from '../utils/pressFeedback'; -import {useMenuPress} from '../hooks/useMenuPress'; +import {useDropdownMenuPress} from '../hooks/useMenuPress'; import {useTypeahead} from '../hooks/useTypeahead'; import {shadowHostOf} from '../utils/flatTree'; import {usePresentation} from '../utils/presentation'; import { ContextMenuTouchLayer, + type ContextMenuHeldPointer, type ContextMenuTouchHandle, } from './ContextMenuTouchLayer'; import {layerAnimations} from '../Layer/layerAnimations.stylex'; @@ -786,18 +787,32 @@ export function ContextMenu({ [isDisabled, openAtLocalPoint, getAnchorBaseRect], ); + const menuPress = useDropdownMenuPress({ + menuRef: listRef, + itemSelector: MENU_ITEM_SELECTOR, + onDismiss: closeMenu, + isEnabled: !usesBottomSheet, + }); + const {continueGesture} = menuPress; + // Touch invocation. Under the touch model, the touch layer runs UIKit's long // press and asks for the menu here, at the client point where the finger // landed. const openFromFinger = useCallback( - (point: {x: number; y: number}) => { + (point: {x: number; y: number}, held: ContextMenuHeldPointer | null) => { const local = toTriggerLocal(point); // The finger is still down: its lift belongs to the press that opened // the menu, so it does not close it. openingGestureRef.current = currentGesture(); openAtLocalPoint(local.x, local.y, triggerRef.current, 'menu'); + // A finger still down goes on into the menu as its opening press: it + // slides onto an item, and its lift selects it. A lift over no item + // leaves the menu open. + if (held != null) { + continueGesture({...held, time: Date.now()}); + } }, - [openAtLocalPoint, toTriggerLocal], + [continueGesture, openAtLocalPoint, toTriggerLocal], ); // Today's long press for a finger outside the touch model: under a fine @@ -896,16 +911,6 @@ export function ContextMenu({ isOpen && !usesBottomSheet, ); - // The press model: the row under a release acts, the highlight follows a - // held pointer, a mouse released outside dismisses and a finger leaves the - // menu open. - const menuPress = useMenuPress({ - menuRef: listRef, - itemSelector: MENU_ITEM_SELECTOR, - onDismiss: closeMenu, - isEnabled: !usesBottomSheet, - }); - const renderedMenu = (
; - /** Open the menu at a client point (where the finger landed). */ - onOpen: (point: {x: number; y: number}) => void; + /** + * Open the menu at a client point (where the finger landed). `held` is the + * finger or pen that is still down as the menu opens. It goes on into the + * menu. It is null for a menu that opens at the lift. + */ + onOpen: ( + point: {x: number; y: number}, + held: ContextMenuHeldPointer | null, + ) => void; /** * The hold committed: the menu shows at its moment or at the lift, so its * content can mount now, hidden. @@ -50,6 +64,22 @@ export interface ContextMenuTouchLayerProps { onCancel?: () => void; } +/** + * The pointer that the gesture host paired with the press's finger, for the + * menu's press model. Null where the finger has no pointer. + */ +function heldPointerOf( + press: ContextMenuPressRecognizer, + doc: Document | undefined, +): ContextMenuHeldPointer | null { + const touch = press.trackedTouches[0]; + if (touch == null || doc == null || touch.pointerType === 'mouse') { + return null; + } + const pointerId = getGestureHost(doc).pointerIdOf(touch); + return pointerId == null ? null : {pointerId, pointerType: touch.pointerType}; +} + /** * UIContextMenuInteraction's long press. iOS Safari never fires `contextmenu` * for a held finger. So the trigger runs its own recognizer on UIKit's @@ -57,7 +87,8 @@ export interface ContextMenuTouchLayerProps { * recognizer claims the finger. The page stops scrolling under it, and its * lift sends no click and no compatibility mouse events. The menu opens where * the finger landed: at `menuDisplayMs` if it is still down, or at the lift. - * A lift before the commit, a second finger or a scroll gets no menu. So does + * A finger still down goes on into the menu. A lift before the commit, a + * second finger or a scroll gets no menu. So does * a drift past the tolerance before the menu shows. The press feedback grows * the trigger from the onset, 150 ms after the finger lands, while the * recognizer follows the finger. It springs back when the recognizer ends the @@ -121,7 +152,7 @@ export function ContextMenuTouchLayer({ return recognizer; }, press => { - const open = () => { + const open = (held: ContextMenuHeldPointer | null) => { // The press feedback grew the trigger from its center, and the cursor // anchor inside it moves with that scale. So stop the growth where it // is. ContextMenu places the anchor under the finger. @@ -129,7 +160,7 @@ export function ContextMenuTouchLayer({ hasPressOpenedRef.current = true; // eslint-disable-next-line @eslint-react/dom-no-flush-sync -- the menu shows in the task that decides it, as UIKit shows it at its moment, not a scheduled task later flushSync(() => { - onOpenRef.current(press.press ?? press.location()); + onOpenRef.current(press.press ?? press.location(), held); }); }; switch (press.state) { @@ -144,12 +175,12 @@ export function ContextMenuTouchLayer({ break; case 'changed': if (press.isMenuDue && !hasPressOpenedRef.current) { - open(); + open(heldPointerOf(press, triggerRef.current?.ownerDocument)); } break; case 'ended': if (!hasPressOpenedRef.current) { - open(); + open(null); } break; case 'possible': diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index 97ca0567f393a..9dbcb16168968 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -6,9 +6,9 @@ * the interaction harness, the context-menu constants and UIKit's frames * @output Interaction and animation proofs for a context menu that a held * finger opens on UIKit's timeline. They cover the menu's moments, the - * press feedback against UIKit's frames and a blocked main thread. They - * also cover the page under the finger, a pan outside and a storyboard of - * the press + * held finger's slide onto an item, the press feedback against UIKit's + * frames and a blocked main thread. They also cover the page under the + * finger, a pan outside and a storyboard of the press * @position Interaction specs of packages/core/src/ContextMenu; Chromium's * finger stands for Android's engine, and WebKit's for iOS */ @@ -41,6 +41,7 @@ import { tap, thawTimers, timeline, + toTouchEvents, touchDriver, trackMarker, traceRendering, @@ -556,6 +557,121 @@ test('the lift after the menu shows keeps it open and sends no click', async ({ expect(log.filter(record => record.type === 'click')).toEqual([]); }); +/** The Share row's center: open the menu once with a held press, measure, close it. */ +async function shareRowCenter( + page: Page, + driver: TouchDriver, + at: {x: number; y: number}, +): Promise<{x: number; y: number}> { + const menu = page.getByRole('menu', {name: 'Photo actions'}); + await driver.play(hold(at, menuDisplayMs + 100)); + await expect(menu).toBeVisible(); + const row = await page.getByRole('menuitem', {name: 'Share'}).boundingBox(); + if (row == null) { + throw new Error('no Share row'); + } + await page.keyboard.press('Escape'); + await expect(menu).toBeHidden(); + // The trigger returns to its size before the next press. + await expect + .poll(async () => presentedScale(page.getByTestId('touch-context-menu'))) + .toBe(1); + return {x: row.x + row.width / 2, y: row.y + row.height / 2}; +} + +/** The text of the focused row, or null when no row holds the focus. */ +const litRow = async (page: Page) => + page.evaluate(() => + document.activeElement?.getAttribute('role') === 'menuitem' + ? (document.activeElement.textContent ?? '') + : null, + ); + +test('the finger the menu opened under slides onto an item, which lights under it, and its lift selects it', async ({ + page, +}) => { + const {driver, center} = await openTouchStory(page); + const share = await shareRowCenter(page, driver, center); + const script = drag(center, share, { + holdMs: menuDisplayMs + 100, + durationMs: 250, + restMs: 120, + }); + const lastMove = + toTouchEvents(script).filter(e => e.type === 'touchMove').length - 1; + let moves = 0; + let lit: string | null = null; + await driver.play(script, { + onEvent: async step => { + if (step.type === 'touchMove' && moves++ === lastMove) { + lit = await litRow(page); + } + }, + }); + expect(lit).toContain('Share'); + await expect(page.getByTestId('touch-context-menu-last')).toHaveText( + 'Last action: Share', + ); + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeHidden(); +}); + +test('a lift before UIMenu’s handoff selects nothing and leaves the menu open', async ({ + page, +}) => { + const {driver, center} = await openTouchStory(page); + // 6 px after the menu opens: short of UIMenu's 10 pt handoff. + await driver.play( + drag( + center, + {x: center.x, y: center.y + 6}, + {holdMs: menuDisplayMs + 100, durationMs: 100, restMs: 120}, + ), + ); + await page.waitForTimeout(300); + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeVisible(); + await expect(page.getByTestId('touch-context-menu-last')).toHaveText( + 'No action yet', + ); +}); + +test('a finger that slides on past the menu leaves it open with no row lit, selects nothing, and scrolls nothing', async ({ + page, +}) => { + const {driver, center, top} = await openScrollableStory(page); + const before = await top(); + // Held until the menu opens under it, then down and a little inward through + // the rows, and on past the menu's bottom. + const script = drag( + center, + {x: center.x + 24, y: center.y + 260}, + {holdMs: menuDisplayMs + 100, durationMs: 400, restMs: 120}, + ); + const lastMove = + toTouchEvents(script).filter(e => e.type === 'touchMove').length - 1; + let moves = 0; + let lit: string | null = null; + let isLitOnTheWay = false; + await driver.play(script, { + onEvent: async step => { + if (step.type !== 'touchMove') { + return; + } + const row = await litRow(page); + isLitOnTheWay ||= row != null; + if (moves++ === lastMove) { + lit = row; + } + }, + }); + expect(await top()).toBe(before); + expect(isLitOnTheWay).toBe(true); + expect(lit).toBeNull(); + await expect(page.getByTestId('touch-context-menu-last')).toHaveText( + 'No action yet', + ); + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeVisible(); +}); + test('the menu’s anchor sits under the finger, on screen and as laid out, while the trigger is grown, and the menu stays put', async ({ page, }) => { diff --git a/packages/core/src/hooks/useMenuPress.test.tsx b/packages/core/src/hooks/useMenuPress.test.tsx index d78c85cb2bcaa..2e6876f359070 100644 --- a/packages/core/src/hooks/useMenuPress.test.tsx +++ b/packages/core/src/hooks/useMenuPress.test.tsx @@ -22,6 +22,8 @@ import { __resetMenuPressForTest, useDropdownMenuPress, useMenuPress, + type DropdownMenuPressReturn, + type UseMenuPressReturn, } from './useMenuPress'; import {MENU_PRESS_HANDOFF_PX, MENU_PRESS_SETTLE_MS} from './menuPressGesture'; import {holdInput, isInputShield} from '../Layer/inputHold'; @@ -40,6 +42,8 @@ interface HarnessProps { getScroller?: () => HTMLElement | null; onHighlight?: (row: HTMLElement | null) => void; touchModel?: 'pointer' | 'uikit'; + /** Receives the hook's return, for calls the trigger does not make. */ + pressRef?: {current: DropdownMenuPressReturn | null}; } function Harness({ @@ -50,6 +54,7 @@ function Harness({ getScroller, onHighlight, touchModel, + pressRef, }: HarnessProps) { const menuRef = useRef(null); const triggerRef = useRef(null); @@ -78,6 +83,9 @@ function Harness({ onHighlight, touchModel, }); + if (pressRef != null) { + pressRef.current = press; + } return ( <> + `; + +function element(id: string): HTMLElement { + const found = document.getElementById(id); + if (found == null) { + throw new Error(`missing #${id}`); + } + return found; +} + +const log: LiftLog = {backdropTaps: 0, clicks: []}; +document.addEventListener( + 'click', + event => { + const target = event.target; + if (target instanceof Element) { + log.clicks.push(target.id || target.tagName.toLowerCase()); + } + }, + true, +); + +const lift = createLift(); + +function options(response: number): LiftOptions { + const spring = {response, dampingRatio: 0.9}; + return { + motion: { + lift: spring, + drop: spring, + clear: spring, + shadowIn: spring, + shadowOut: spring, + surfaceIn: spring, + surfaceOut: spring, + }, + scale: {maxScale: 1.15, scaleUpPoints: 26}, + surfaceStartWidth: 44, + attribute: 'data-lift', + onBackdropTap: () => { + log.backdropTaps += 1; + }, + }; +} + +window.__lift = { + log, + lift: (id, springResponse = 0.3) => + lift.lift(element(id), options(springResponse)), + showMenu: () => element('menu').showPopover(), + presentMenu: press => { + element('menu').showPopover(); + lift.presentSurface( + element('menu').querySelector('[role="menu"]') ?? + element('menu'), + press, + ); + }, + dismissMenu: () => lift.dismissSurface(element('menu')), + closeMenu: () => element('menu').hidePopover(), + drop: () => lift.drop(), + dispose: () => lift.dispose(), + isLifted: () => lift.isLifted(), +}; diff --git a/packages/core/src/lift/__tests__/lift.interaction.browser.spec.ts b/packages/core/src/lift/__tests__/lift.interaction.browser.spec.ts new file mode 100644 index 0000000000000..2281de2382e27 --- /dev/null +++ b/packages/core/src/lift/__tests__/lift.interaction.browser.spec.ts @@ -0,0 +1,512 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file lift.interaction.browser.spec.ts + * @input The lift bundled with esbuild onto a blank fixture page + * (lift.fixture.ts): a list of rows in a clipping, transformed panel and a + * menu popover; Chromium and WebKit + * @output Proof in real engines that a row lifts itself into the top layer + * at its lifted rect while one placeholder holds its spot (nothing else + * moves, and the row keeps its look); that every motion the lift plays is + * `translate`, `scale`, `transform` or `opacity` (spec:AST-094 FR5); that + * the menu grows out of the press and keeps nothing inline at rest, and + * its close shrinks the layer runtime's exit copy back into it, or, with + * no press, fades in where it rests; that the + * page has the layer, the shadow, the anchor and the placeholder more while + * lifted, whatever the row holds, at most 5 at any moment, and none after + * the drop + * (AST-017 FR60); that repeated lifts leave no node or listener behind; + * that a tap while it grows reaches nothing (FR35) and one on the backdrop + * afterwards is the backdrop tap + * @position Browser proof for lift.ts, run by `pnpm test:interaction`; jsdom + * has no layout, no top layer and no animations + */ + +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {expect, test, type Page} from '@playwright/test'; +import {build, type Plugin} from 'esbuild'; +// The page's `window.__lift`. +import type {LiftFixture} from './lift.fixture'; + +// The layer runtime's gutter is a `stylex.defineConsts` table: static values +// the StyleX compiler puts in place of the call. Unbundled by that compiler, +// the call returns its values here. +const stylexConsts: Plugin = { + name: 'stylex-consts', + setup(builder) { + // esbuild's filters are Go regular expressions, which take no flags. + builder.onResolve({filter: /^@stylexjs\/stylex$/}, () => ({ + path: 'stylex-consts', + namespace: 'stylex-consts', + })); + builder.onLoad({filter: /.*/, namespace: 'stylex-consts'}, () => ({ + contents: 'export const defineConsts = consts => consts;', + loader: 'js', + })); + }, +}; + +let bundle = ''; + +test.beforeAll(async () => { + const result = await build({ + entryPoints: [ + path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'lift.fixture.ts', + ), + ], + bundle: true, + write: false, + format: 'iife', + platform: 'browser', + target: 'es2022', + plugins: [stylexConsts], + }); + bundle = result.outputFiles[0].text; +}); + +// A real navigation (not setContent): the page keeps its own window. +async function open(page: Page) { + const html = ``; + await page.route('http://lift.test/', async route => + route.fulfill({contentType: 'text/html', body: html}), + ); + await page.goto('http://lift.test/'); +} + +type Box = [number, number, number, number]; +type LiftResult = NonNullable>; + +function expectBox(actual: Box, expected: Box, tolerance = 0.5) { + for (let i = 0; i < 4; i++) { + expect(Math.abs(actual[i] - expected[i])).toBeLessThanOrEqual(tolerance); + } +} + +test('a row lifts itself into the top layer at its lifted rect while one placeholder holds its spot: nothing else moves, and the row keeps its look', async ({ + page, +}) => { + await open(page); + const read = async () => + page.evaluate(() => { + const box = (element: Element | null): Box => { + const r = element?.getBoundingClientRect(); + return r == null ? [0, 0, 0, 0] : [r.x, r.y, r.width, r.height]; + }; + const row = document.getElementById('row2') as HTMLElement; + const style = getComputedStyle(row); + return { + row1: box(document.getElementById('row1')), + row2: box(row), + row3: box(document.getElementById('row3')), + row4: box(document.getElementById('row4')), + placeholder: box(document.querySelector('[data-lift-placeholder]')), + shadow: box(document.querySelector('[data-lift-shadow]')), + isOpen: row.matches(':popover-open'), + look: [ + style.color, + style.backgroundColor, + style.borderBottomWidth, + style.borderBottomColor, + style.paddingLeft, + style.fontSize, + style.display, + ], + }; + }); + const before = await read(); + const result = await page.evaluate(() => window.__lift.lift('row2', 0.2)); + expect(result).not.toBeNull(); + await page.waitForTimeout(800); + const after = await read(); + expect(after.isOpen).toBe(true); + // Grown by 26 px on its larger side, about its center: wider than the + // clipping panel, and drawn whole. + const lifted: LiftResult['lifted'] = result?.lifted ?? { + x: 0, + y: 0, + width: 0, + height: 0, + }; + expect(lifted.width).toBeCloseTo(361, 3); + expectBox(after.row2, [lifted.x, lifted.y, lifted.width, lifted.height]); + // Its shadow lies exactly under it. + expectBox(after.shadow, [lifted.x, lifted.y, lifted.width, lifted.height]); + expectBox(after.placeholder, before.row2); + expectBox(after.row1, before.row1); + expectBox(after.row3, before.row3); + expectBox(after.row4, before.row4); + expect(after.look).toEqual(before.look); + await page.evaluate(() => window.__lift.dispose()); +}); + +test('the shadow follows the row on every frame of its lift and of its drop', async ({ + page, +}) => { + await open(page); + // Every animation of the lift, held at the same moment `t` ms in. + const at = async (t: number) => + page.evaluate(time => { + for (const animation of document.getAnimations()) { + animation.pause(); + animation.currentTime = time; + } + const box = (element: Element | null): Box => { + const r = element?.getBoundingClientRect(); + return r == null ? [0, 0, 0, 0] : [r.x, r.y, r.width, r.height]; + }; + return { + row: box(document.getElementById('row2')), + shadow: box(document.querySelector('[data-lift-shadow]')), + }; + }, t); + await page.evaluate(() => window.__lift.lift('row2', 0.2)); + await page.waitForTimeout(50); + for (const t of [30, 90, 180]) { + const {row, shadow} = await at(t); + expectBox(shadow, row); + } + await page.evaluate(() => { + for (const animation of document.getAnimations()) { + animation.finish(); + } + window.__lift.drop(); + }); + await page.waitForTimeout(50); + for (const t of [30, 90]) { + const {row, shadow} = await at(t); + expectBox(shadow, row); + } + await page.evaluate(() => window.__lift.dispose()); +}); + +test('the tint clears from what it shows when the row drops, with no jump to clear', async ({ + page, +}) => { + await open(page); + await page.evaluate(() => window.__lift.lift('row2', 0.2)); + await page.waitForTimeout(600); + const tint = await page.evaluate(() => { + const layer = document.querySelector('[data-lift]'); + // The opacity keyframes of the tint animations on the layer's + // `::backdrop` (the layer itself animates nothing), oldest first. + // WebKit reports no `pseudoElement` on the effect. + const fades = () => + (layer?.getAnimations({subtree: true}) ?? []) + .filter(animation => { + const effect = animation.effect as KeyframeEffect | null; + return ( + effect?.target === layer && + animation.playState !== 'idle' && + effect.getKeyframes().some(frame => 'opacity' in frame) && + new Set(effect.getKeyframes().map(frame => frame.opacity)).size > 1 + ); + }) + .map(animation => + (animation.effect as KeyframeEffect) + .getKeyframes() + .map(frame => Number(frame.opacity)), + ); + const shown = fades().at(-1)?.at(-1) ?? -1; + window.__lift.drop(); + const clear = fades().at(-1) ?? []; + return {shown, from: clear[0] ?? -1, to: clear.at(-1) ?? -1}; + }); + const detail = JSON.stringify(tint); + // Risen, the tint clears from there to nothing. + expect(tint.shown, detail).toBeCloseTo(1, 2); + expect(tint.from, detail).toBeGreaterThan(0.95); + expect(tint.to, detail).toBeCloseTo(0, 2); + await page.evaluate(() => window.__lift.dispose()); +}); + +test('every motion the lift plays, the menu’s growth and close included, is translate, scale, transform or opacity (spec:AST-094 FR5)', async ({ + page, +}) => { + await open(page); + const properties = await page.evaluate(async () => { + const seen = new Set(); + const ignored = new Set([ + 'offset', + 'computedOffset', + 'easing', + 'composite', + ]); + const collect = () => { + for (const animation of document.getAnimations()) { + const effect = animation.effect as KeyframeEffect | null; + // A zero-length fill holds a value; it plays no motion. + if (effect == null || effect.getComputedTiming().duration === 0) { + continue; + } + for (const keyframe of effect.getKeyframes()) { + for (const property of Object.keys(keyframe)) { + if (!ignored.has(property)) { + seen.add(property); + } + } + } + } + }; + const frame = async () => + new Promise(resolve => requestAnimationFrame(resolve)); + window.__lift.lift('row2', 0.4); + window.__lift.presentMenu({x: 180, y: 330}); + collect(); + await frame(); + await frame(); + collect(); + window.__lift.dismissMenu(); + window.__lift.closeMenu(); + window.__lift.drop(); + collect(); + await frame(); + await frame(); + collect(); + return [...seen].sort(); + }); + expect(properties.length).toBeGreaterThan(0); + expect( + properties.filter( + property => + !['opacity', 'scale', 'transform', 'translate'].includes(property), + ), + ).toEqual([]); +}); + +test('the menu grows out of the press into its box and keeps nothing inline at rest; its close shrinks the exit copy back into the press', async ({ + page, +}) => { + await open(page); + await page.evaluate(() => window.__lift.lift('row2', 0.3)); + await page.waitForTimeout(600); + const press = {x: 180, y: 330}; + const early = await page.evaluate(async at => { + window.__lift.presentMenu(at); + await new Promise(resolve => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + const r = ( + document.getElementById('menu') as HTMLElement + ).getBoundingClientRect(); + return {x: r.x + r.width / 2, y: r.y + r.height / 2, width: r.width}; + }, press); + // Small, and nearer the press than its own center. + expect(early.width).toBeLessThan(125); + const fromPress = Math.hypot(early.x - press.x, early.y - press.y); + const fromBox = Math.hypot(early.x - 165, early.y - 510); + expect(fromPress).toBeLessThan(fromBox); + await page.waitForTimeout(900); + const rest = await page.evaluate(() => { + const menu = document.getElementById('menu') as HTMLElement; + const r = menu.getBoundingClientRect(); + return { + box: [r.x, r.y, r.width, r.height] as Box, + translate: menu.style.translate, + scale: menu.style.scale, + opacity: getComputedStyle(menu).opacity, + }; + }); + expectBox(rest.box, [40, 420, 250, 180]); + expect(rest.translate).toBe(''); + expect(rest.scale).toBe(''); + expect(rest.opacity).toBe('1'); + const copy = await page.evaluate(async () => { + window.__lift.dismissMenu(); + window.__lift.closeMenu(); + const placed = document.querySelector('[data-lift-exit]'); + if (placed == null) { + return null; + } + // Its narrowest over the next frames: it starts as the menu stands. + let width = Infinity; + for (let i = 0; i < 20 && width >= 250; i++) { + await new Promise(resolve => requestAnimationFrame(resolve)); + width = Math.min(width, placed.getBoundingClientRect().width); + } + return { + isOpen: placed.matches(':popover-open'), + inert: placed.inert, + hidden: placed.getAttribute('aria-hidden'), + width, + }; + }); + expect(copy).not.toBeNull(); + expect(copy?.isOpen).toBe(true); + expect(copy?.inert).toBe(true); + expect(copy?.hidden).toBe('true'); + // Shrinking already. + expect(copy?.width ?? 0).toBeLessThan(250); + await page.waitForTimeout(900); + expect( + await page.evaluate(() => document.querySelector('[data-lift-exit]')), + ).toBeNull(); + await page.evaluate(() => window.__lift.dispose()); +}); + +test('without a press the menu fades in where it rests: opacity only, no travel or growth', async ({ + page, +}) => { + await open(page); + await page.evaluate(() => window.__lift.lift('row2', 0.3)); + await page.waitForTimeout(600); + const early = await page.evaluate(async () => { + window.__lift.presentMenu(null); + await new Promise(resolve => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + const menu = document.getElementById('menu') as HTMLElement; + const r = menu.getBoundingClientRect(); + const moving = new Set(); + for (const animation of menu.getAnimations()) { + const effect = animation.effect as KeyframeEffect | null; + const frames = effect?.getKeyframes() ?? []; + for (const property of ['translate', 'scale', 'transform', 'opacity']) { + const values = new Set(frames.map(frame => String(frame[property]))); + if (values.size > 1) { + moving.add(property); + } + } + } + return { + box: [r.x, r.y, r.width, r.height] as Box, + opacity: Number(getComputedStyle(menu).opacity), + moving: [...moving].sort(), + }; + }); + expectBox(early.box, [40, 420, 250, 180]); + expect(early.opacity).toBeLessThan(1); + expect(early.moving).toEqual(['opacity']); + await page.waitForTimeout(900); + expect( + await page.evaluate( + () => + getComputedStyle(document.getElementById('menu') as HTMLElement) + .opacity, + ), + ).toBe('1'); + await page.evaluate(() => window.__lift.dispose()); +}); + +test('while lifted the page has the layer, the shadow, the anchor and the placeholder more, whatever the row holds, and never more than 5 (AST-017 FR60); a drop leaves the page as it was', async ({ + page, +}) => { + await open(page); + const counts = await page.evaluate(async () => { + // The layer runtime's exit copy is the transient copy INV7 allows (made + // by the layer runtime, gone within a second): not one of the lift's. + const count = () => + [...document.body.querySelectorAll('*')].filter( + element => element.closest('[data-lift-exit]') == null, + ).length; + const wait = async (ms: number) => + new Promise(resolve => setTimeout(resolve, ms)); + const row = document.getElementById('row2') as HTMLElement; + // As serialized: the same declarations, in the same order. + const style = row.style.cssText; + const rest = count(); + let most = 0; + let sampling = true; + const sample = () => { + most = Math.max(most, count() - rest); + if (sampling) { + requestAnimationFrame(sample); + } + }; + window.__lift.lift('row2', 0.3); + const deep = count() - rest; + requestAnimationFrame(sample); + window.__lift.presentMenu({x: 180, y: 330}); + await wait(700); + const open = count() - rest; + window.__lift.dismissMenu(); + window.__lift.closeMenu(); + window.__lift.drop(); + await wait(1200); + sampling = false; + const after = count() - rest; + const styleAfter = row.style.cssText; + const popoverAfter = row.getAttribute('popover'); + window.__lift.lift('row1', 0.3); + const shallow = count() - rest; + window.__lift.dispose(); + return { + deep, + open, + most, + after, + shallow, + style, + styleAfter, + popoverAfter, + lifted: window.__lift.isLifted(), + }; + }); + // A row of 13 elements and a row of 1: the same 4 more once grown (the + // layer, the shadow, the anchor and the placeholder), and the hold's + // shield while the lift grows. + expect(counts.deep).toBe(5); + expect(counts.shallow).toBe(5); + expect(counts.open).toBe(4); + expect(counts.most).toBeLessThanOrEqual(5); + expect(counts.after).toBe(0); + expect(counts.styleAfter).toBe(counts.style); + expect(counts.popoverAfter).toBeNull(); + expect(counts.lifted).toBe(false); +}); + +test('ten lifts and drops leave the DOM node and event listener counts where they were (no leaks)', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'DOM counters are a Chromium protocol'); + await open(page); + const cdp = await page.context().newCDPSession(page); + const counters = async () => { + await cdp.send('HeapProfiler.collectGarbage'); + return cdp.send('Memory.getDOMCounters'); + }; + const cycle = async () => + page.evaluate(async () => { + const wait = async (ms: number) => + new Promise(resolve => setTimeout(resolve, ms)); + window.__lift.lift('row2', 0.15); + window.__lift.presentMenu({x: 180, y: 330}); + await wait(400); + window.__lift.dismissMenu(); + window.__lift.closeMenu(); + window.__lift.drop(); + await wait(700); + }); + // One lift first: what a first lift sets up once (the hold's touch gate). + await cycle(); + const before = await counters(); + for (let i = 0; i < 10; i++) { + await cycle(); + } + const after = await counters(); + expect(after.jsEventListeners).toBe(before.jsEventListeners); + expect(after.nodes).toBeLessThanOrEqual(before.nodes); +}); + +test('a tap while the row and the menu grow reaches nothing; one on the backdrop once they settle is the backdrop tap, and reaches nothing under it either', async ({ + page, +}) => { + await open(page); + await page.evaluate(() => { + window.__lift.lift('row2', 0.8); + window.__lift.presentMenu({x: 180, y: 330}); + }); + await page.mouse.click(180, 700); + const growing = await page.evaluate(() => ({...window.__lift.log})); + expect(growing.backdropTaps).toBe(0); + expect(growing.clicks).not.toContain('page'); + await page.waitForTimeout(2000); + await page.mouse.click(180, 700); + const settled = await page.evaluate(() => ({...window.__lift.log})); + expect(settled.backdropTaps).toBe(1); + expect(settled.clicks).not.toContain('page'); + await page.evaluate(() => window.__lift.dispose()); +}); diff --git a/packages/core/src/lift/lift.test.ts b/packages/core/src/lift/lift.test.ts new file mode 100644 index 0000000000000..c31cca90e253b --- /dev/null +++ b/packages/core/src/lift/lift.test.ts @@ -0,0 +1,406 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file lift.test.ts + * @input vitest (jsdom), the lift, stand-ins for the popover API and for + * layout (jsdom has neither) and a spy on the presentation hold + * @output Checks the lift's bookkeeping in the page: while up it adds the + * layer and one placeholder whatever the source holds, and nothing at + * rest; the source keeps its place among its siblings; a drop or an + * unmount puts back exactly what the source had inline and takes the rest + * away; nothing of it (an element, a document listener, a timer) outlives + * it over repeated lifts; the gutter it reads; the holds it takes; taps on + * its backdrop + * @position Testing; the motion, the top layer and the counts in real + * engines are proven in __tests__/lift.interaction.browser.spec.ts + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import * as presentationHold from '../Layer/presentationHold'; +import {createLift, type LiftOptions} from './lift'; + +vi.mock('../Layer/presentationHold', async importOriginal => ({ + ...(await importOriginal()), + holdPresentation: vi.fn(() => () => {}), +})); + +const {holdPresentation} = presentationHold; + +const SPRING = {response: 0.3, dampingRatio: 1}; + +function options(overrides: Partial = {}): LiftOptions { + return { + motion: { + lift: SPRING, + drop: SPRING, + clear: SPRING, + shadowIn: SPRING, + shadowOut: SPRING, + surfaceIn: SPRING, + surfaceOut: SPRING, + }, + scale: {maxScale: 1.15, scaleUpPoints: 26}, + surfaceStartWidth: 44, + attribute: 'data-test-lift', + ...overrides, + }; +} + +function place( + element: HTMLElement, + x: number, + y: number, + w: number, + h: number, +) { + element.getBoundingClientRect = () => + DOMRect.fromRect({x, y, width: w, height: h}); + Object.defineProperty(element, 'offsetWidth', {value: w, configurable: true}); + Object.defineProperty(element, 'offsetHeight', { + value: h, + configurable: true, + }); +} + +/** A list of three rows; the middle one, the source, holds 12 elements. */ +function renderList(): {source: HTMLElement; before: HTMLElement} { + document.body.innerHTML = ` +
    +
  • Before
  • +
  • +
    123
    +
    456
    +
    789
    +
  • +
  • After
  • +
`; + const source = document.getElementById('source') as HTMLElement; + place(source, 20, 300, 335, 60); + return { + source, + before: document.getElementById('before') as HTMLElement, + }; +} + +const count = () => document.body.querySelectorAll('*').length; + +/** + * The source's inline style is what it was: its own values, and none of the + * lift's. (Property by property: jsdom's style object keeps a `margin` + * shorthand of its own making once all four margins were set, which a + * browser does not.) + */ +function expectInlineAsBefore(source: HTMLElement) { + const lifted = [ + 'position', + 'left', + 'top', + 'right', + 'bottom', + 'height', + 'min-width', + 'min-height', + 'max-width', + 'max-height', + 'box-sizing', + 'pointer-events', + 'transition', + 'padding-top', + 'border-top-width', + 'background-color', + 'overflow-x', + 'translate', + 'scale', + 'transform-origin', + ]; + expect({ + color: source.style.getPropertyValue('color'), + width: source.style.getPropertyValue('width'), + ...Object.fromEntries( + lifted.map(property => [ + property, + source.style.getPropertyValue(property), + ]), + ), + }).toEqual({ + color: 'red', + width: '50%', + ...Object.fromEntries(lifted.map(property => [property, ''])), + }); +} + +// Document listeners added and not yet removed, by type. +let documentListeners: Map; +const realAdd = document.addEventListener.bind(document); +const realRemove = document.removeEventListener.bind(document); +let open: Set; +const realShow = HTMLElement.prototype.showPopover; +const realHide = HTMLElement.prototype.hidePopover; +const realMatches = Element.prototype.matches; + +beforeEach(() => { + vi.useFakeTimers(); + open = new Set(); + HTMLElement.prototype.showPopover = function (this: HTMLElement) { + open.add(this); + }; + HTMLElement.prototype.hidePopover = function (this: HTMLElement) { + open.delete(this); + }; + // jsdom knows no popover state: `:popover-open` is this stand-in's. + Element.prototype.matches = function (this: Element, selector: string) { + return selector === ':popover-open' + ? open.has(this) + : realMatches.call(this, selector); + } as typeof realMatches; + documentListeners = new Map(); + vi.spyOn(document, 'addEventListener').mockImplementation( + (type, listener, opts) => { + documentListeners.set(type, (documentListeners.get(type) ?? 0) + 1); + const signal = + typeof opts === 'object' && opts != null ? opts.signal : undefined; + signal?.addEventListener('abort', () => { + documentListeners.set(type, (documentListeners.get(type) ?? 1) - 1); + }); + realAdd(type, listener, opts); + }, + ); + vi.spyOn(document, 'removeEventListener').mockImplementation( + (type, listener, opts) => { + documentListeners.set(type, (documentListeners.get(type) ?? 1) - 1); + realRemove(type, listener, opts); + }, + ); + vi.mocked(holdPresentation).mockClear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + HTMLElement.prototype.showPopover = realShow; + HTMLElement.prototype.hidePopover = realHide; + Element.prototype.matches = realMatches; + document.body.innerHTML = ''; +}); + +/** Every listener this test added to the document is gone. */ +function expectNoDocumentListeners() { + for (const [type, live] of documentListeners) { + expect({type, live: Math.max(live, 0)}).toEqual({type, live: 0}); + } +} + +describe('lift', () => { + it('adds the layer, its shadow and anchor, and one placeholder while lifted, whatever the source holds; the source keeps its place among its siblings', () => { + const {source, before} = renderList(); + const rest = count(); + const lift = createLift(); + const result = lift.lift(source, options()); + expect(result).not.toBeNull(); + // 13 elements lifted (the row and its 12), 4 added. + expect(count()).toBe(rest + 4); + expect(source.previousElementSibling).toBe(before); + const placeholder = source.nextElementSibling as HTMLElement; + expect(placeholder.hasAttribute('data-test-lift-placeholder')).toBe(true); + expect(placeholder.getAttribute('aria-hidden')).toBe('true'); + expect(placeholder.style.width).toBe('335px'); + expect(placeholder.style.height).toBe('60px'); + const layer = document.body.lastElementChild as HTMLElement; + expect(layer.hasAttribute('data-test-lift')).toBe(true); + expect(layer.getAttribute('popover')).toBe('manual'); + // The box a surface is placed against: the lifted rect, in the layer. + expect(result?.anchor.parentElement).toBe(layer); + expect(result?.anchor.style.width).toBe(`${result?.lifted.width}px`); + // The source itself is in the top layer, above the layer, at its box, + // and takes no hits. + expect(source.getAttribute('popover')).toBe('manual'); + expect([...open]).toEqual([layer, source]); + expect(source.style.getPropertyValue('position')).toBe('fixed'); + expect(source.style.getPropertyValue('left')).toBe('20px'); + expect(source.style.getPropertyValue('top')).toBe('300px'); + expect(source.style.getPropertyValue('width')).toBe('335px'); + expect(source.style.getPropertyValue('pointer-events')).toBe('none'); + expect(lift.isLifted()).toBe(true); + // Grown about its center by 26 px on its larger side. + expect(result?.lifted.width).toBeCloseTo(361, 5); + lift.dispose(); + }); + + it("resolves the layer runtime's gutter on each edge in px where the source is, with the app's declared inset, and the source's direction", () => { + const {source} = renderList(); + const original = window.getComputedStyle.bind(window); + let declaredInset = ''; + const gutter: Record = { + 'padding-top': '47px', + 'padding-bottom': '34px', + 'padding-left': '16px', + }; + vi.spyOn(window, 'getComputedStyle').mockImplementation( + (element, pseudo) => { + const style = original(element, pseudo); + // The source's direction, and the gutter as resolved inside it. + if (element === source) { + const rtl: CSSStyleDeclaration = { + ...style, + direction: 'rtl', + getPropertyValue: (property: string) => + style.getPropertyValue(property), + }; + return rtl; + } + if (element.parentElement !== source) { + return style; + } + declaredInset = (element as HTMLElement).style.getPropertyValue( + '--astryx-layer-inset-block-end', + ); + const resolved: CSSStyleDeclaration = { + ...style, + getPropertyValue: (property: string) => + gutter[property] ?? style.getPropertyValue(property), + }; + return resolved; + }, + ); + const lift = createLift(); + const result = lift.lift(source, options({inset: {blockEnd: 56}})); + expect(declaredInset).toBe('56px'); + expect(result?.viewport.gutter).toEqual({top: 47, bottom: 34, inline: 16}); + expect(result?.viewport.direction).toBe('rtl'); + lift.dispose(); + }); + + it('a drop puts back exactly what the source had inline, and takes the placeholder and the layer away', async () => { + const {source} = renderList(); + const rest = count(); + const lift = createLift(); + lift.lift(source, options()); + lift.drop(); + await vi.runAllTimersAsync(); + expectInlineAsBefore(source); + expect(source.hasAttribute('popover')).toBe(false); + expect(count()).toBe(rest); + expect(open.size).toBe(0); + expect(lift.isLifted()).toBe(false); + }); + + it('an unmount mid-lift ends it at once: no element, document listener or timer left', () => { + const {source} = renderList(); + const rest = count(); + const lift = createLift(); + lift.lift(source, options()); + lift.dispose(); + expectInlineAsBefore(source); + expect(count()).toBe(rest); + expect(open.size).toBe(0); + expectNoDocumentListeners(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('an unmount while the close waits for a press on the layer to lift leaves nothing behind either', async () => { + const {source} = renderList(); + const rest = count(); + const lift = createLift(); + lift.lift(source, options()); + const layer = document.body.lastElementChild as HTMLElement; + // A finger down on the layer, never lifted: the close waits for it. + const down = new Event('pointerdown', {bubbles: true}); + Object.assign(down, {pointerId: 7, isPrimary: true}); + layer.dispatchEvent(down); + lift.drop(); + await vi.runAllTimersAsync(); + expect(document.body.contains(layer)).toBe(true); + lift.dispose(); + expect(count()).toBe(rest); + expectNoDocumentListeners(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('twenty lifts and drops leave the page, the document listeners and the timers as they were (no leaks)', async () => { + const {source} = renderList(); + const rest = count(); + const lift = createLift(); + for (let i = 0; i < 20; i++) { + lift.lift(source, options()); + lift.drop(); + await vi.runAllTimersAsync(); + } + expect(count()).toBe(rest); + expectInlineAsBefore(source); + expectNoDocumentListeners(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('changes nothing without the popover API', () => { + const {source} = renderList(); + const html = document.body.innerHTML; + // @ts-expect-error: a browser without the popover API. + HTMLElement.prototype.showPopover = undefined; + const lift = createLift(); + expect(lift.lift(source, options())).toBeNull(); + expect(document.body.innerHTML).toBe(html); + }); + + it('a source out of the flow needs no placeholder', () => { + const {source} = renderList(); + source.style.position = 'absolute'; + const rest = count(); + const lift = createLift(); + lift.lift(source, options()); + expect(count()).toBe(rest + 3); + lift.dispose(); + }); + + it('holds the layer while the source grows and a surface grows (a presentation), and while it closes (the layer)', async () => { + const {source} = renderList(); + document.body.insertAdjacentHTML( + 'beforeend', + '', + ); + const menu = document.getElementById('menu') as HTMLElement; + place(menu, 40, 380, 250, 200); + const lift = createLift(); + lift.lift(source, options()); + const layer = document.querySelector('[data-test-lift]'); + expect(vi.mocked(holdPresentation)).toHaveBeenLastCalledWith(layer, { + scope: 'viewport', + until: expect.any(Promise), + }); + vi.mocked(holdPresentation).mockClear(); + lift.presentSurface( + menu.querySelector('[role="menu"]') as HTMLElement, + {x: 180, y: 330}, + ); + expect(vi.mocked(holdPresentation)).toHaveBeenLastCalledWith(layer, { + scope: 'viewport', + until: expect.any(Promise), + }); + lift.drop(); + expect(vi.mocked(holdPresentation)).toHaveBeenLastCalledWith(layer, { + scope: 'layer', + until: expect.any(Promise), + }); + await vi.runAllTimersAsync(); + lift.dispose(); + }); + + it('a press that went down and lifted on the layer is a backdrop tap; one that came from elsewhere is not', () => { + const {source} = renderList(); + const onBackdropTap = vi.fn(); + const lift = createLift(); + lift.lift(source, options({onBackdropTap})); + const layer = document.body.lastElementChild as HTMLElement; + const pointer = (type: string, pointerId: number) => { + const event = new Event(type, {bubbles: true}); + Object.assign(event, {pointerId, isPrimary: true}); + layer.dispatchEvent(event); + }; + // The finger that opened the menu lifts on the layer: not a tap there. + pointer('pointerup', 1); + expect(onBackdropTap).not.toHaveBeenCalled(); + pointer('pointerdown', 2); + pointer('pointerup', 2); + expect(onBackdropTap).toHaveBeenCalledTimes(1); + lift.dispose(); + }); +}); diff --git a/packages/core/src/lift/lift.ts b/packages/core/src/lift/lift.ts new file mode 100644 index 0000000000000..e51d603df90a9 --- /dev/null +++ b/packages/core/src/lift/lift.ts @@ -0,0 +1,1270 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file lift.ts + * @input An element to lift (the source) and, beside it, a surface that + * grows out of the press (a menu's popover); the caller's springs (motion + * roles a theme may retune), scale rule and tint; the rect morph + * (motion/rectMorph.ts); the layer runtime's presentation hold, exit copy, + * viewport gutter and declared inset + * @output Exports createLift: lift a source out of the page above a tinted + * backdrop, grow a surface out of the press beside it, shrink the surface + * back into the press, and return the source to its place + * @position The one lift-from-trigger presentation, internal to core and + * opt-in: only a surface that chooses it uses it (ContextMenu's touch + * invocation today). Layer stays generic: the top layer, dismissal, the + * presentation hold and the shared exit copy are its; this module only + * uses them + * + * UIKit's context menu lifts the long-pressed view out of the page: it grows + * on from the size the press gave it to its lifted size, a shadow rising + * under it, above a backdrop that tints the page; the menu grows out of the + * finger beside it, and when the menu closes it shrinks back into the finger + * while the view returns to its place and the backdrop clears. + * + * Here the source itself lifts, not a copy: it becomes a manual popover in + * the top layer, laid out at its own size where it stood, so no clipping, + * transformed or stacking ancestor cuts or covers it, while one empty + * placeholder holds its spot in the page, which does not move. What the + * browser's popover style would change (border, padding, colors, overflow) is + * kept as it was laid out, and the source takes no hits while lifted (a tap + * on it is a tap on the backdrop). Inheritance follows the DOM, so the page's + * theme still reaches it. The placeholder goes after the source, so the + * source keeps its place among its siblings. The tint is the `::backdrop` of + * a lift layer at the end of the body that covers the viewport and takes the + * page's touches (its color and the gutter's spacing resolved where the + * source is); the shadow is a box in the layer that follows the source on a + * rect morph of its own (an animated `::backdrop` of the source would + * restyle and lay out the page on every frame). The layer also holds an empty box + * at the lifted rect for a surface to be placed against: anchor positioning + * follows transforms, so a box inside the source would move with it. Nothing + * of the lift is in the DOM at rest; while lifted it adds four elements, the + * layer, the shadow, the box and the placeholder + * (`architecture:component-style-authoring` INV7, `spec:AST-017` FR60), and + * its close plays on the layer runtime's one exit copy + * (`placeLayerExitCopy`). + * + * Every motion is `translate`, `scale` or `opacity` on the compositor + * (spec:AST-094 FR5): the source and the surface on the rect morph, which + * interrupts itself with the velocity on screen (FR6), the shadow on one of + * its own along the same path; the tint as a spring-sampled Web Animation of + * the layer's `::backdrop`. A tap or + * a drag that lands while the lift and the surface grow reaches nothing + * (the presentation hold, FR35); the close holds the layer until the source + * is back, the surface's exit is done and every press on the layer has + * lifted. Its timers and document listeners end with it (FR24). Reduce + * Motion is the animator's (design:reduced-motion): opacity only. + * + * SYNC: When modified, update: + * - /packages/core/src/lift/lift.test.ts + * - /packages/core/src/lift/__tests__/lift.interaction.browser.spec.ts + */ + +import type {LayerInset} from '../Layer/LayerContext'; +import {placeLayerExitCopy} from '../Layer/layerExitCopy'; +import {layerInsetProperties} from '../Layer/layerInset'; +import {layerViewportInset} from '../Layer/layerViewportInset.stylex'; +import {holdPresentation} from '../Layer/presentationHold'; +import type {MotionHandle} from '../motion/animator'; +import {progressCurve} from '../motion/progress'; +import { + createRectMorph, + lerpPath, + type MorphPath, + type MorphRect, + type RectMorph, +} from '../motion/rectMorph'; +import {reducedMotionFade} from '../motion/reducedMotion'; +import {resolveMotion} from '../motion/resolveMotion'; +import { + SETTLING_EPSILON, + solveSpring, + toSpring, + type SpringDescription, +} from '../motion/spring'; +import { + liftedRect, + type LiftScaleRule, + type LiftViewport, +} from './liftGeometry'; + +/** + * How long the layer waits, once the last pointer that went down on it has + * lifted, for that touch's click before it hides anyway (a long press sends + * none). + */ +const CLICK_AFTER_LIFT_MS = 350; + +/** The springs of a lift; the caller's motion roles. */ +export interface LiftMotion { + /** The source grows to its lifted rect on it, and the tint fades in. */ + readonly lift: SpringDescription; + /** The source returns to its place on it. */ + readonly drop: SpringDescription; + /** The tint clears on it. */ + readonly clear: SpringDescription; + /** The shadow rises on it. */ + readonly shadowIn: SpringDescription; + /** The shadow fades on it. */ + readonly shadowOut: SpringDescription; + /** A surface grows out of the press on it. */ + readonly surfaceIn: SpringDescription; + /** A surface shrinks back into the press on it. */ + readonly surfaceOut: SpringDescription; +} + +export interface LiftOptions { + readonly motion: LiftMotion; + /** How far the source grows. */ + readonly scale: LiftScaleRule; + /** + * The width a surface grows out of, centered on the press (its height + * keeps the surface's aspect); a point by default. + */ + readonly surfaceStartWidth?: number; + /** + * The rect the source grows on from, viewport px: where a press feedback + * left it. Its box as laid out by default. + */ + readonly from?: MorphRect; + /** + * Called once the lift has read the page and before it writes to it: lets + * go of what the source was doing (a press feedback's scale), so letting go + * doesn't restyle the page before the lift reads it. + */ + readonly release?: () => void; + /** The tint, a CSS color resolved where the source is. */ + readonly tint?: string; + /** The shadow under the lifted source, a CSS box shadow. */ + readonly shadow?: string; + /** The inset an app declares for a bar over an edge (LayerProvider). */ + readonly inset?: LayerInset; + /** A finger lifted on the backdrop where it went down. */ + readonly onBackdropTap?: () => void; + /** + * Marks the lift's elements: the layer, its placeholder + * (`-placeholder`) and a surface's exit copy + * (`-exit`). + */ + readonly attribute?: string; +} + +/** Where a lift landed. */ +export interface LiftResult { + /** The source's box as laid out, viewport px. */ + readonly source: MorphRect; + /** The rect it lands on. */ + readonly lifted: MorphRect; + /** The viewport and the layer runtime's gutter it lifted in. */ + readonly viewport: LiftViewport; + /** + * An empty box at the lifted rect, in the layer: what a surface is placed + * against (an anchor inside the source would move with the source's + * transform, which anchor positioning follows). + */ + readonly anchor: HTMLElement; +} + +export interface Lift { + /** + * Lifts `source`: returns where it lands, or null without the popover API + * (nothing changes then). A lift already up ends at once. + */ + lift(source: HTMLElement, options: LiftOptions): LiftResult | null; + /** + * Grows `surface` (a popover laid out at rest, or an element inside one) + * out of `press` (client px) into its box, or, without a press, fades it + * in where it rests; and holds the lift until the lift and the surface + * settle (spec:AST-094 FR35). A no-op when nothing is lifted. + */ + presentSurface( + surface: HTMLElement, + press?: {x: number; y: number} | null, + ): void; + /** + * The surface is closing: its exit copy shrinks back into the press from + * wherever it stands. Call it while the surface still shows. + */ + dismissSurface(surface: HTMLElement): void; + /** Returns the source to its place; the layer leaves once it is back. */ + drop(): void; + /** Ends everything at once and leaves the page as it was. */ + dispose(): void; + /** Whether a source is lifted. */ + isLifted(): boolean; +} + +type SavedStyle = [property: string, value: string, priority: string][]; + +interface LiftedSurface { + readonly element: HTMLElement; + readonly morph: RectMorph; + readonly path: MorphPath; + readonly press: {x: number; y: number}; + presenting: MotionHandle | null; + dismissing: MotionHandle | null; +} + +interface LiftState { + readonly generation: number; + readonly options: LiftOptions; + /** The springs, as the source's theme set them at the lift. */ + readonly motion: LiftMotion; + readonly attribute: string; + readonly source: HTMLElement; + readonly sourceRect: MorphRect; + readonly lifted: MorphRect; + readonly layer: HTMLElement; + readonly placeholder: HTMLElement | null; + readonly saved: SavedStyle; + readonly transition: [value: string, priority: string]; + readonly morph: RectMorph; + readonly growing: MotionHandle; + /** The tint's color, held on the layer's `::backdrop`. */ + readonly layerFill: Animation | null; + /** + * The shadow under the source: a box in the layer laid out where the + * source is, which follows it frame for frame and fades on its own spring. + */ + readonly shadow: HTMLElement; + shadowAnimation: Animation | null; + /** + * Holds the source morph's clock at one instant (null lets it run), so the + * shadow reads the motion at the very moment the morph does. + */ + readonly holdClock: (at: number | null) => void; + /** The layer's own waits and listeners, released with it (FR24). */ + readonly waits: AbortController; + untilPointersLift: () => Promise; + tint: Animation | null; + surface: LiftedSurface | null; + releaseGrowHold: (() => void) | null; + releaseHold: (() => void) | null; + isSourceLifted: boolean; +} + +const px = (value: string | undefined) => Number.parseFloat(value ?? '') || 0; + +/** + * Sets `values` on `element` inline, `!important`, recording what was there + * for `restore`. + */ +function setInline( + element: HTMLElement, + values: Record, + saved: SavedStyle, +): void { + for (const [property, value] of Object.entries(values)) { + saved.push([ + property, + element.style.getPropertyValue(property), + element.style.getPropertyPriority(property), + ]); + element.style.setProperty(property, value, 'important'); + } +} + +function restore(element: HTMLElement, saved: SavedStyle): void { + for (let i = saved.length - 1; i >= 0; i--) { + const [property, value, priority] = saved[i]; + if (value === '') { + element.style.removeProperty(property); + } else { + element.style.setProperty(property, value, priority); + } + } +} + +/** Web Animation keyframes along `spring` from 0 to 1, sampled at 60 Hz. */ +function springKeyframes( + element: Element, + spring: SpringDescription, + frame: (progress: number) => Keyframe, +): {keyframes: Keyframe[]; duration: number} { + const curve = solveSpring(toSpring(resolveMotion(element, spring)), { + from: 0, + to: 1, + }); + const seconds = Math.max(curve.settleTime(SETTLING_EPSILON), 1 / 60); + const steps = Math.max(2, Math.ceil(seconds * 60)); + const keyframes: Keyframe[] = []; + for (let i = 0; i <= steps; i++) { + const t = (i / steps) * seconds; + keyframes.push({ + ...frame(i === steps ? 1 : curve.value(t)), + offset: i / steps, + }); + } + return {keyframes, duration: seconds * 1000}; +} + +/** + * Web Animation keyframes for a box laid out on `box` that travels from + * `from` to `to` on `travel` (starting with progress velocity `velocity`, as + * the rect morph does) while its opacity goes from `fade[0]` to `fade[1]` on + * `fadeSpring`, sampled at 60 Hz until both settle: one compositor animation + * of `transform` and `opacity`, frame for frame with the source's own morph. + */ +function shadowKeyframes( + box: MorphRect, + from: MorphRect, + to: MorphRect, + travel: SpringDescription | null, + fade: readonly [number, number], + fadeSpring: SpringDescription, + velocity = 0, +): {keyframes: Keyframe[]; duration: number} { + const move = travel == null ? null : progressCurve(travel, {velocity}); + const opacity = progressCurve(fadeSpring); + const seconds = Math.max(move?.duration ?? 0, opacity.duration, 1 / 60); + const steps = Math.max(2, Math.ceil(seconds * 60)); + const keyframes: Keyframe[] = []; + for (let i = 0; i <= steps; i++) { + const t = (i / steps) * seconds; + const p = i === steps || move == null ? 1 : move.at(t); + const q = i === steps ? 1 : opacity.at(t); + const rect = { + x: from.x + (to.x - from.x) * p, + y: from.y + (to.y - from.y) * p, + width: from.width + (to.width - from.width) * p, + }; + const scale = box.width > 0 ? rect.width / box.width : 1; + keyframes.push({ + transform: `translate(${(rect.x - box.x).toFixed(3)}px, ${(rect.y - box.y).toFixed(3)}px) scale(${scale.toFixed(5)})`, + opacity: String( + Math.min(Math.max(fade[0] + (fade[1] - fade[0]) * q, 0), 1), + ), + offset: i / steps, + }); + } + return {keyframes, duration: seconds * 1000}; +} + +/** Plays `keyframes` on `element`, or only their ends under Reduce Motion. */ +function playShadow( + element: HTMLElement, + {keyframes, duration}: {keyframes: Keyframe[]; duration: number}, + reduced: ReturnType, +): Animation | null { + if (typeof element.animate !== 'function') { + return null; + } + return element.animate( + reduced == null + ? keyframes + : [keyframes[0], keyframes[keyframes.length - 1]], + { + duration: reduced?.duration ?? duration, + easing: reduced?.easing ?? 'linear', + fill: 'forwards', + }, + ); +} + +/** + * The radius of the shape `source` shows: its own, or that of its only + * element child when that child fills it. + */ +function shapeRadius(source: HTMLElement): string { + const view = source.ownerDocument.defaultView; + const own = view?.getComputedStyle(source).borderRadius ?? ''; + if (px(own) > 0 || source.children.length === 0) { + return own; + } + const outer = source.getBoundingClientRect(); + for (const child of source.children) { + const inner = child.getBoundingClientRect(); + if ( + Math.abs(inner.width - outer.width) <= 1 && + Math.abs(inner.height - outer.height) <= 1 + ) { + return view?.getComputedStyle(child).borderRadius ?? own; + } + } + return own; +} + +/** + * Resolves CSS values where `element` is (its theme, its color scheme): on an + * empty, undisplayed child it removes again, so the element's own style and + * the page's layout stay as they were. A pair is a fallback and a value a + * browser may reject. + */ +function probe( + element: HTMLElement, + values: Record, +): Record { + const doc = element.ownerDocument; + const child = doc.createElement('span'); + child.style.setProperty('display', 'none'); + for (const [property, value] of Object.entries(values)) { + if (typeof value === 'string') { + child.style.setProperty(property, value); + } else { + child.style.setProperty(property, value[0]); + child.style.setProperty(property, value[1]); + } + } + element.append(child); + const style = doc.defaultView?.getComputedStyle(child); + const resolved: Record = {}; + for (const property of Object.keys(values)) { + resolved[property] = style?.getPropertyValue(property) ?? ''; + } + child.remove(); + return resolved; +} + +/** + * The lift's springs as `element`'s theme sets them, read once: plain + * springs, which nothing reads from the page again. + */ +function resolvedMotion(element: Element, motion: LiftMotion): LiftMotion { + const resolve = (spring: SpringDescription): SpringDescription => ({ + ...resolveMotion(element, spring), + }); + return { + lift: resolve(motion.lift), + drop: resolve(motion.drop), + clear: resolve(motion.clear), + shadowIn: resolve(motion.shadowIn), + shadowOut: resolve(motion.shadowOut), + surfaceIn: resolve(motion.surfaceIn), + surfaceOut: resolve(motion.surfaceOut), + }; +} + +/** Plays `keyframes` on `element`'s `::backdrop`, holding the end. */ +function animateBackdrop( + element: HTMLElement, + keyframes: Keyframe[], + options: KeyframeAnimationOptions, +): Animation | null { + if (typeof element.animate !== 'function') { + return null; + } + try { + return element.animate(keyframes, { + fill: 'forwards', + ...options, + pseudoElement: '::backdrop', + }); + } catch { + // No `::backdrop` animations: the lift shows without its decoration. + return null; + } +} + +/** The `::backdrop` opacity `element` shows now, or `fallback`. */ +function backdropOpacity(element: HTMLElement, fallback: number): number { + try { + const shown = Number.parseFloat( + getComputedStyle(element, '::backdrop').opacity, + ); + return Number.isFinite(shown) ? shown : fallback; + } catch { + return fallback; + } +} + +/** + * Fades `element`'s `::backdrop` to `to` on `spring`, from `from` or what it + * shows; `reduced` is the Reduce Motion fade when read already. Opacity + * alone, so the compositor plays it. + */ +function fadeBackdrop( + element: HTMLElement, + to: number, + spring: SpringDescription, + { + from = backdropOpacity(element, to === 1 ? 0 : 1), + reduced = reducedMotionFade(element), + }: {from?: number; reduced?: ReturnType} = {}, +): Animation | null { + if (reduced != null) { + return animateBackdrop( + element, + [{opacity: String(from)}, {opacity: String(to)}], + {duration: reduced.duration, easing: reduced.easing}, + ); + } + const {keyframes, duration} = springKeyframes(element, spring, p => ({ + opacity: String(from + (to - from) * p), + })); + return animateBackdrop(element, keyframes, {duration}); +} + +/** + * The lift layer: covers the viewport from the top layer and takes the + * page's touches while the source is up; its `::backdrop` is the tint. + */ +function createLayer(doc: Document, attribute: string): HTMLElement { + const layer = doc.createElement('span'); + layer.setAttribute('popover', 'manual'); + layer.setAttribute('aria-hidden', 'true'); + layer.setAttribute(attribute, ''); + Object.assign(layer.style, { + position: 'fixed', + inset: '0', + width: '100%', + height: '100%', + maxWidth: 'none', + maxHeight: 'none', + margin: '0', + padding: '0', + border: '0', + backgroundColor: 'transparent', + color: 'inherit', + overflow: 'visible', + pointerEvents: 'auto', + touchAction: 'none', + }); + return layer; +} + +/** + * The placeholder that holds a source's spot in the page while it is lifted: + * empty, hidden, at its laid-out size, with its margins and its place in a + * flex or grid container. + */ +function placeholderStyle( + computed: CSSStyleDeclaration, + box: MorphRect, +): Partial { + const display = + computed.display === 'inline' + ? 'inline-block' + : computed.display === 'contents' || computed.display === 'none' + ? 'block' + : computed.display; + return { + display, + boxSizing: 'border-box', + width: `${box.width}px`, + height: `${box.height}px`, + marginTop: computed.marginTop, + marginRight: computed.marginRight, + marginBottom: computed.marginBottom, + marginLeft: computed.marginLeft, + flexGrow: computed.flexGrow, + flexShrink: computed.flexShrink, + flexBasis: computed.flexBasis, + alignSelf: computed.alignSelf, + justifySelf: computed.justifySelf, + order: computed.order, + gridRowStart: computed.gridRowStart, + gridRowEnd: computed.gridRowEnd, + gridColumnStart: computed.gridColumnStart, + gridColumnEnd: computed.gridColumnEnd, + cssFloat: computed.cssFloat, + verticalAlign: computed.verticalAlign, + visibility: 'hidden', + pointerEvents: 'none', + }; +} + +/** The properties the browser's popover style sets, kept as laid out. */ +const KEPT_PROPERTIES = [ + 'border-top-width', + 'border-right-width', + 'border-bottom-width', + 'border-left-width', + 'border-top-style', + 'border-right-style', + 'border-bottom-style', + 'border-left-style', + 'border-top-color', + 'border-right-color', + 'border-bottom-color', + 'border-left-color', + 'padding-top', + 'padding-right', + 'padding-bottom', + 'padding-left', + 'color', + 'background-color', + 'overflow-x', + 'overflow-y', +] as const; + +/** Creates a lift; one per component that offers it. */ +export function createLift(): Lift { + let state: LiftState | null = null; + let generation = 0; + + const releaseGrowHold = (current: LiftState) => { + current.releaseGrowHold?.(); + current.releaseGrowHold = null; + }; + + // Puts the source back in the page as it was laid out. + const settleSource = (current: LiftState) => { + if (!current.isSourceLifted) { + return; + } + current.isSourceLifted = false; + const {source} = current; + current.shadowAnimation?.cancel(); + current.shadowAnimation = null; + current.morph.dispose(); + if (source.matches(':popover-open')) { + source.hidePopover(); + } + source.removeAttribute('popover'); + restore(source, current.saved); + current.placeholder?.remove(); + // The restored values apply before the source's own transitions return, + // so none of them animates. + void source.ownerDocument.defaultView?.getComputedStyle(source).width; + const [value, priority] = current.transition; + if (value === '') { + source.style.removeProperty('transition'); + } else { + source.style.setProperty('transition', value, priority); + } + }; + + const endSurface = (current: LiftState) => { + const surface = current.surface; + current.surface = null; + surface?.morph.dispose(); + }; + + const hideLayer = (current: LiftState) => { + releaseGrowHold(current); + current.releaseHold?.(); + current.releaseHold = null; + current.waits.abort(); + current.tint?.cancel(); + current.tint = null; + current.layerFill?.cancel(); + const {layer} = current; + if (layer.matches(':popover-open')) { + layer.hidePopover(); + } + layer.remove(); + }; + + const end = (current: LiftState) => { + endSurface(current); + settleSource(current); + hideLayer(current); + if (state === current) { + state = null; + } + }; + + // Every pointer that went down on the layer and is still down (the tint + // while it presents, the hold's shield while it closes), read from the + // document so an inert target hides none; and whether the last one to lift + // still owes its click (a touch's click comes after its pointer's up). + const watchPointers = (current: LiftState) => { + const {layer, waits} = current; + const doc = layer.ownerDocument; + const pointers = new Set(); + let isClickPending = false; + const options = {capture: true, signal: waits.signal}; + doc.addEventListener( + 'pointerdown', + event => { + if (event.composedPath().includes(layer)) { + pointers.add(event.pointerId); + } + }, + options, + ); + const up = (event: PointerEvent) => { + if (pointers.delete(event.pointerId) && event.type === 'pointerup') { + isClickPending = true; + } + }; + doc.addEventListener('pointerup', up, options); + doc.addEventListener('pointercancel', up, options); + doc.addEventListener( + 'click', + () => { + isClickPending = false; + }, + options, + ); + // Settles once no pointer is down on the layer and the last one's click + // has landed too, or shortly after (a long press sends none). + return async (): Promise => + new Promise(resolve => { + const wait = () => { + if (waits.signal.aborted) { + return; + } + if (pointers.size > 0) { + const lifted = () => { + if (pointers.size > 0) { + return; + } + doc.removeEventListener('pointerup', lifted, true); + doc.removeEventListener('pointercancel', lifted, true); + wait(); + }; + doc.addEventListener('pointerup', lifted, options); + doc.addEventListener('pointercancel', lifted, options); + return; + } + if (!isClickPending) { + resolve(); + return; + } + const timer = setTimeout(finish, CLICK_AFTER_LIFT_MS); + waits.signal.addEventListener('abort', () => clearTimeout(timer), { + once: true, + }); + function finish() { + doc.removeEventListener('click', finish, true); + clearTimeout(timer); + isClickPending = false; + wait(); + } + doc.addEventListener('click', finish, options); + }; + wait(); + }); + }; + + const lift = ( + source: HTMLElement, + options: LiftOptions, + ): LiftResult | null => { + if (state != null) { + end(state); + } + if (typeof source.showPopover !== 'function' || !source.isConnected) { + return null; + } + const attribute = options.attribute ?? 'data-astryx-lift'; + const doc = source.ownerDocument; + const view = doc.defaultView; + const computed = view?.getComputedStyle(source); + if (computed == null) { + return null; + } + // The lift reads everything before it writes to the page, so the page + // restyles and lays out once for it. + // The source as laid out (a scale on it, the press feedback's, is about + // its center). + const shown = source.getBoundingClientRect(); + const width = source.offsetWidth || shown.width; + const height = source.offsetHeight || shown.height; + const sourceRect: MorphRect = { + x: shown.left + (shown.width - width) / 2, + y: shown.top + (shown.height - height) / 2, + width, + height, + }; + const from = options.from ?? sourceRect; + // What it keeps of its look and its layout. + const kept: Record = {}; + for (const property of KEPT_PROPERTIES) { + kept[property] = computed.getPropertyValue(property); + } + const isInFlow = + computed.position !== 'absolute' && computed.position !== 'fixed'; + const placeholderValues = isInFlow + ? placeholderStyle(computed, sourceRect) + : null; + const direction = computed.direction === 'rtl' ? 'rtl' : 'ltr'; + const radius = shapeRadius(source); + const motion = resolvedMotion(source, options.motion); + const reduced = reducedMotionFade(source); + // The tint, the shadow and the layer runtime's gutter (spec:AST-059, + // with the inset an app declares), resolved where the source is. + const probed = probe(source, { + ...Object.fromEntries( + Object.entries(layerInsetProperties(options.inset)).map( + ([property, value]) => [property, String(value)], + ), + ), + 'background-color': options.tint ?? 'var(--color-overlay)', + 'box-shadow': options.shadow ?? 'var(--shadow-high)', + 'padding-top': [ + `calc(${layerViewportInset.gutterBlockStartFallback})`, + `calc(${layerViewportInset.gutterBlockStart})`, + ], + 'padding-bottom': [ + `calc(${layerViewportInset.gutterBlockEndFallback})`, + `calc(${layerViewportInset.gutterBlockEnd})`, + ], + 'padding-left': [ + `calc(${layerViewportInset.gutterInlineFallback})`, + `calc(${layerViewportInset.gutterInline})`, + ], + }); + const tint = probed['background-color']; + const shadow = probed['box-shadow']; + const viewport: LiftViewport = { + width: doc.documentElement.clientWidth || view?.innerWidth || 0, + height: view?.innerHeight ?? doc.documentElement.clientHeight, + gutter: { + top: px(probed['padding-top']), + bottom: px(probed['padding-bottom']), + inline: px(probed['padding-left']), + }, + direction, + }; + const lifted = liftedRect(sourceRect, viewport, options.scale); + // Writes from here on. + options.release?.(); + const layer = createLayer(doc, attribute); + doc.body.append(layer); + const shadowEl = doc.createElement('span'); + shadowEl.setAttribute(`${attribute}-shadow`, ''); + Object.assign(shadowEl.style, { + position: 'absolute', + left: `${sourceRect.x}px`, + top: `${sourceRect.y}px`, + width: `${sourceRect.width}px`, + height: `${sourceRect.height}px`, + borderRadius: radius, + boxShadow: shadow, + opacity: '0', + pointerEvents: 'none', + // Its keyframes scale about the top-left corner, as the source's do. + transformOrigin: '0 0', + }); + layer.append(shadowEl); + const anchor = doc.createElement('span'); + anchor.setAttribute(`${attribute}-anchor`, ''); + Object.assign(anchor.style, { + position: 'absolute', + left: `${lifted.x}px`, + top: `${lifted.y}px`, + width: `${lifted.width}px`, + height: `${lifted.height}px`, + pointerEvents: 'none', + }); + layer.append(anchor); + let placeholder: HTMLElement | null = null; + if (placeholderValues != null) { + placeholder = doc.createElement('span'); + placeholder.setAttribute(`${attribute}-placeholder`, ''); + placeholder.setAttribute('aria-hidden', 'true'); + Object.assign(placeholder.style, placeholderValues); + } + generation += 1; + const transition: [string, string] = [ + source.style.getPropertyValue('transition'), + source.style.getPropertyPriority('transition'), + ]; + const saved: SavedStyle = []; + // No transition of its own reacts to the lift's values. + source.style.setProperty('transition', 'none', 'important'); + setInline( + source, + { + // Longhands only: each is put back as it was. + position: 'fixed', + left: `${sourceRect.x}px`, + top: `${sourceRect.y}px`, + right: 'auto', + bottom: 'auto', + width: `${sourceRect.width}px`, + height: `${sourceRect.height}px`, + 'min-width': '0', + 'min-height': '0', + 'max-width': 'none', + 'max-height': 'none', + 'margin-top': '0', + 'margin-right': '0', + 'margin-bottom': '0', + 'margin-left': '0', + 'box-sizing': 'border-box', + ...kept, + 'pointer-events': 'none', + }, + saved, + ); + if (placeholder != null) { + source.after(placeholder); + } + source.setAttribute('popover', 'manual'); + const waits = new AbortController(); + // The source grows out of where the press left it into its lifted rect, + // laid out at its own size, so its content scales as UIKit's snapshot + // does. Under Reduce Motion it lands at once and fades in. + let heldAt: number | null = null; + const holdClock = (at: number | null) => { + heldAt = at; + }; + const morph = createRectMorph(source, { + now: () => heldAt ?? performance.now(), + }); + // The layer first: the source shows above its tint. + layer.showPopover(); + source.showPopover(); + holdClock(performance.now()); + const growing = morph.present( + lerpPath(from, lifted, {box: sourceRect, scale: 'uniform'}), + motion.lift, + {crossfadeAs: 'entrance'}, + ); + holdClock(null); + // The tint: the layer's `::backdrop`, its color held there. + const layerFill = animateBackdrop( + layer, + [ + {backgroundColor: tint, opacity: '0'}, + {backgroundColor: tint, opacity: '0'}, + ], + {duration: 0}, + ); + // The shadow rises with the source: it follows the source's path on the + // lift's spring and fades in on its own, from the frame the source starts + // in, as one compositor animation (an animated `::backdrop` of the source + // would restyle and lay out the page on every frame). Under Reduce Motion + // it fades in where the source lands. + const rise = playShadow( + shadowEl, + reduced == null + ? shadowKeyframes( + sourceRect, + from, + lifted, + motion.lift, + [0, 1], + motion.shadowIn, + ) + : shadowKeyframes( + sourceRect, + lifted, + lifted, + null, + [0, 1], + motion.shadowIn, + ), + reduced, + ); + void growing.started.then(at => { + if (rise != null && current.shadowAnimation === rise) { + rise.startTime = at; + } + }); + const current: LiftState = { + generation, + options, + motion, + attribute, + source, + sourceRect, + lifted, + layer, + placeholder, + saved, + transition, + morph, + growing, + layerFill, + shadow: shadowEl, + shadowAnimation: rise, + holdClock, + waits, + untilPointersLift: async () => {}, + tint: fadeBackdrop(layer, 1, motion.lift, {from: 0, reduced}), + surface: null, + releaseGrowHold: null, + releaseHold: null, + isSourceLifted: true, + }; + state = current; + // Taps on the backdrop: only a pointer that went down on the layer lifts + // there (WebKit hands the layer the lift of the finger that opened the + // menu). + let backdropPointer: number | null = null; + const listen = {signal: waits.signal}; + layer.addEventListener( + 'pointerdown', + event => { + backdropPointer = event.isPrimary ? event.pointerId : null; + }, + listen, + ); + layer.addEventListener( + 'pointerup', + event => { + const wentDownHere = backdropPointer === event.pointerId; + backdropPointer = null; + if (wentDownHere) { + current.options.onBackdropTap?.(); + } + }, + listen, + ); + layer.addEventListener( + 'pointercancel', + () => { + backdropPointer = null; + }, + listen, + ); + current.untilPointersLift = watchPointers(current); + // No touch reaches the layer, or anything under it, while the source + // grows (spec:AST-094 FR35); a surface extends the hold to its own + // growth. + current.releaseGrowHold = holdPresentation(layer, { + scope: 'viewport', + until: growing.finished, + }); + return {source: sourceRect, lifted, viewport, anchor}; + }; + + // The lift layer takes no touch until the lift and the surface settle + // (spec:AST-094 FR35). The hold's shield joins the top layer after the + // surface, so it covers it too; keys and assistive technology stay live. + const holdWhileGrowing = ( + current: LiftState, + surfaceSettled: Promise, + ) => { + releaseGrowHold(current); + const until = Promise.all([current.growing.finished, surfaceSettled]); + current.releaseGrowHold = holdPresentation(current.layer, { + scope: 'viewport', + until, + }); + }; + + const presentSurface = ( + surfaceElement: HTMLElement, + pressAt?: {x: number; y: number} | null, + ) => { + const current = state; + if (current == null || !current.isSourceLifted) { + return; + } + const element = + surfaceElement.closest('[popover]') ?? surfaceElement; + endSurface(current); + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + return; + } + const box: MorphRect = { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }; + // Without a press it fades in where it rests, and a close shrinks it + // into the lifted source's center. + const press = pressAt ?? { + x: current.lifted.x + current.lifted.width / 2, + y: current.lifted.y + current.lifted.height / 2, + }; + const startWidth = Math.min( + current.options.surfaceStartWidth ?? 0, + box.width, + ); + const startHeight = (startWidth * box.height) / box.width; + const start: MorphRect = + pressAt == null + ? box + : { + x: press.x - startWidth / 2, + y: press.y - startHeight / 2, + width: startWidth, + height: startHeight, + }; + const path = lerpPath(start, box, { + box, + scale: 'uniform', + opacity: [0, 1], + }); + // The hold's shield shows before the growth writes anything, so showing + // it restyles only the shield, not the page the growth just changed. + let surfaceSettled = () => {}; + holdWhileGrowing( + current, + new Promise(resolve => { + surfaceSettled = resolve; + }), + ); + const morph = createRectMorph(element); + const presenting = morph.present(path, current.motion.surfaceIn, { + crossfadeAs: 'entrance', + }); + void presenting.finished.then(surfaceSettled, surfaceSettled); + current.surface = { + element, + morph, + path, + press: {x: press.x, y: press.y}, + presenting, + dismissing: null, + }; + }; + + const dismissSurface = (surfaceElement: HTMLElement) => { + const current = state; + if (current == null) { + return; + } + const element = + surfaceElement.closest('[popover]') ?? surfaceElement; + // A close of a surface that no longer shows (a second tap on the fading + // tint) leaves the running exit alone. + const shown = element.getBoundingClientRect(); + if (shown.width <= 0 || shown.height <= 0) { + return; + } + let surface = current.surface; + if (surface == null || surface.element !== element) { + // A surface that showed without growing: it shrinks from rest into the + // source's center. + endSurface(current); + const box: MorphRect = { + x: shown.left, + y: shown.top, + width: shown.width, + height: shown.height, + }; + const press = { + x: current.lifted.x + current.lifted.width / 2, + y: current.lifted.y + current.lifted.height / 2, + }; + const startWidth = Math.min( + current.options.surfaceStartWidth ?? 0, + box.width, + ); + const startHeight = (startWidth * box.height) / box.width; + surface = { + element, + morph: createRectMorph(element), + path: lerpPath( + { + x: press.x - startWidth / 2, + y: press.y - startHeight / 2, + width: startWidth, + height: startHeight, + }, + box, + {box, scale: 'uniform', opacity: [0, 1]}, + ), + press, + presenting: null, + dismissing: null, + }; + current.surface = surface; + } + // The layer runtime's exit copy (inert, unnamed, in the top layer where + // the surface is drawn), taken while it still shows. Without a top layer + // there is no copy, and the close is immediate. + const copy = placeLayerExitCopy(element, `${current.attribute}-exit`); + if (copy == null) { + return; + } + surface.dismissing = surface.morph.dismiss( + surface.path, + current.motion.surfaceOut, + {target: copy, crossfadeAs: 'exit'}, + ); + }; + + const drop = () => { + const current = state; + if (current == null || !current.isSourceLifted) { + return; + } + releaseGrowHold(current); + const {layer, motion, source, sourceRect, lifted} = current; + // The tint clears from what it shows now, read before its rise stops. + const tintFrom = backdropOpacity(layer, 1); + current.tint?.cancel(); + current.tint = fadeBackdrop(layer, 0, motion.clear, {from: tintFrom}); + // The source returns to its place from wherever it is now, with its + // velocity; its shadow follows it back frame for frame (the same start, + // the same velocity along the way back) and fades on its own spring. + // Under Reduce Motion both fade where they are. + // One instant for the shadow's reading and the morph's own, and the + // morph's velocity there (from the rect it presents a millisecond + // before: the morph keeps its own clock, synced to its animations'). + const at = performance.now(); + current.holdClock(at - 1); + const before = current.morph.presentedRect() ?? lifted; + current.holdClock(at); + const presented = current.morph.presentedRect() ?? lifted; + const shadowEl = current.shadow; + const shownOpacity = + Number.parseFloat( + shadowEl.ownerDocument.defaultView?.getComputedStyle(shadowEl) + .opacity ?? '', + ) || 0; + current.shadowAnimation?.cancel(); + const reducedFade = reducedMotionFade(source); + // That velocity as progress along the way back (from where it is drawn + // to the source's place), as the morph reads it. + const span = presented.width - sourceRect.width; + const widthVelocity = (presented.width - before.width) * 1000; + const backVelocity = Math.abs(span) > 1e-6 ? -widthVelocity / span : 0; + current.shadowAnimation = playShadow( + shadowEl, + shadowKeyframes( + sourceRect, + presented, + reducedFade == null ? sourceRect : presented, + reducedFade == null ? motion.drop : null, + [shownOpacity, 0], + motion.shadowOut, + backVelocity, + ), + reducedFade, + ); + const returning = current.morph.dismiss( + lerpPath(sourceRect, lifted, {box: sourceRect, scale: 'uniform'}), + motion.drop, + {target: source, crossfadeAs: 'exit'}, + ); + current.holdClock(null); + const exit = current.surface?.dismissing; + const settled = Promise.all([returning.finished, exit?.finished]); + void settled.then(() => { + if (state !== current) { + return; + } + endSurface(current); + settleSource(current); + // Under Reduce Motion the source faded out where it stood: it fades + // back in at its place. + const back = reducedMotionFade(source); + if (back != null && typeof source.animate === 'function') { + source.animate([{opacity: 0}, {opacity: 1}], { + duration: back.duration, + easing: back.easing, + }); + } + }); + // The close holds the layer: nothing reaches the page under it until the + // source is back, the surface's exit is done and every press on the + // layer has lifted. + const ended = settled.then(async () => current.untilPointersLift()); + current.releaseHold?.(); + current.releaseHold = holdPresentation(layer, { + scope: 'layer', + until: ended, + }); + void ended.then(() => { + if (state === current) { + end(current); + } + }); + }; + + return { + lift, + presentSurface, + dismissSurface, + drop, + dispose: () => { + if (state != null) { + end(state); + } + }, + isLifted: () => state?.isSourceLifted === true, + }; +} diff --git a/packages/core/src/lift/liftGeometry.test.ts b/packages/core/src/lift/liftGeometry.test.ts new file mode 100644 index 0000000000000..9ac455d682687 --- /dev/null +++ b/packages/core/src/lift/liftGeometry.test.ts @@ -0,0 +1,103 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file liftGeometry.test.ts + * @input vitest, the lift geometry + * @output Checks how far a source grows (the cap, the points on its larger + * side, the viewport's width), where it lands inside the gutter, and which + * side of it a surface takes, in either direction + * @position Unit tests of packages/core/src/lift/liftGeometry.ts; a + * surface's own numbers (UIKit's, for ContextMenu) are tested with it + */ + +import {describe, expect, it} from 'vitest'; +import { + liftedRect, + liftScale, + surfacePlacement, + type LiftViewport, +} from './liftGeometry'; + +const RULE = {maxScale: 1.15, scaleUpPoints: 26}; +const VIEWPORT: LiftViewport = { + width: 402, + height: 874, + gutter: {top: 82, bottom: 54, inline: 16}, +}; + +describe('liftScale', () => { + it('caps a small source at the rule’s largest factor', () => { + expect(liftScale(60, 60, VIEWPORT, RULE)).toBe(1.15); + }); + + it('grows a larger source by the rule’s points on its larger side', () => { + expect(liftScale(200, 120, VIEWPORT, RULE)).toBeCloseTo(226 / 200, 10); + }); + + it('shrinks a source wider than the viewport less its gutter', () => { + expect(liftScale(402, 60, VIEWPORT, RULE)).toBeCloseTo(370 / 402, 10); + }); + + it('leaves an empty source alone', () => { + expect(liftScale(0, 0, VIEWPORT, RULE)).toBe(1); + }); +}); + +describe('liftedRect', () => { + it('grows about the source’s center', () => { + const lifted = liftedRect( + {x: 101, y: 400, width: 200, height: 120}, + VIEWPORT, + RULE, + ); + expect(lifted.x + lifted.width / 2).toBeCloseTo(201, 10); + expect(lifted.y + lifted.height / 2).toBeCloseTo(460, 10); + expect(lifted.width).toBeCloseTo(226, 10); + }); + + it('moves inside the gutter on every edge', () => { + expect( + liftedRect({x: 4, y: 400, width: 60, height: 60}, VIEWPORT, RULE).x, + ).toBe(16); + const right = liftedRect( + {x: 340, y: 400, width: 60, height: 60}, + VIEWPORT, + RULE, + ); + expect(right.x + right.width).toBeCloseTo(386, 10); + expect( + liftedRect({x: 100, y: 60, width: 60, height: 60}, VIEWPORT, RULE).y, + ).toBe(82); + const bottom = liftedRect( + {x: 100, y: 800, width: 60, height: 60}, + VIEWPORT, + RULE, + ); + expect(bottom.y + bottom.height).toBeCloseTo(820, 10); + }); +}); + +describe('surfacePlacement', () => { + const middle = {x: 88, y: 368, width: 226, height: 135.67}; + + it('goes below the source, centered, where it fits above the bottom gutter', () => { + expect(surfacePlacement(middle, 200, VIEWPORT, 16)).toEqual({ + side: 'below', + alignment: 'center', + }); + }); + + it('goes above where it does not fit below', () => { + expect(surfacePlacement(middle, 400, VIEWPORT, 16).side).toBe('above'); + }); + + it('is flush with the edge the source is held against, and the sides swap in a right-to-left page', () => { + const atLeft = {...middle, x: 16}; + const atRight = {...middle, x: 386 - middle.width}; + expect(surfacePlacement(atLeft, 200, VIEWPORT, 16).alignment).toBe('start'); + expect(surfacePlacement(atRight, 200, VIEWPORT, 16).alignment).toBe('end'); + const rtl: LiftViewport = {...VIEWPORT, direction: 'rtl'}; + expect(surfacePlacement(atLeft, 200, rtl, 16).alignment).toBe('end'); + expect(surfacePlacement(atRight, 200, rtl, 16).alignment).toBe('start'); + }); +}); diff --git a/packages/core/src/lift/liftGeometry.ts b/packages/core/src/lift/liftGeometry.ts new file mode 100644 index 0000000000000..2660705ea6c0b --- /dev/null +++ b/packages/core/src/lift/liftGeometry.ts @@ -0,0 +1,149 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file liftGeometry.ts + * @input A source's box, the viewport and the layer runtime's gutter, and + * the caller's scale rule and gap + * @output Exports liftScale, liftedRect and surfacePlacement: how far a + * lifted source grows, where it lands inside the gutter, and which side of + * it a surface (a menu) takes + * @position The geometry of the lift presentation (lift.ts); internal to + * core + * + * A lifted source grows about its center by the caller's rule (UIKit's + * preview platter: up to a factor, by a number of points on its larger + * side, and never wider than the screen less the gutter), then moves inside + * the layer runtime's viewport gutter (spec:AST-059) if it crosses it. A + * surface sits a gap away: below the source when it fits above the bottom + * gutter, else above; centered on the source, or flush with the edge the + * source is held against. + * + * SYNC: When modified, update /packages/core/src/lift/liftGeometry.test.ts + */ + +import type {MorphRect} from '../motion/rectMorph'; + +/** + * The layer runtime's viewport gutter on each edge, CSS px + * (Layer/layerViewportInset.stylex.ts, spec:AST-059): the spacing step or the + * edge's safe-area inset, whichever is larger, plus the inset an app declares + * for a bar floating over that edge. + */ +export interface LiftGutter { + readonly top: number; + readonly bottom: number; + readonly inline: number; +} + +/** + * The viewport a source lifts in, CSS px, the layer runtime's gutter, and the + * page's direction (a surface's alignment is logical). + */ +export interface LiftViewport { + readonly width: number; + readonly height: number; + readonly gutter: LiftGutter; + readonly direction?: 'ltr' | 'rtl'; +} + +/** How far a lifted source grows. */ +export interface LiftScaleRule { + /** The largest factor it grows by. */ + readonly maxScale: number; + /** Below `maxScale`, it grows by this many px on its larger side. */ + readonly scaleUpPoints: number; +} + +/** Where a surface sits against the lifted source. */ +export interface SurfacePlacement { + readonly side: 'above' | 'below'; + readonly alignment: 'start' | 'center' | 'end'; +} + +/** + * The factor a `width` × `height` source grows by: up to `rule.maxScale`, by + * `rule.scaleUpPoints` on its larger side, and never wider than the viewport + * less its gutter (which can make it smaller than 1). + */ +export function liftScale( + width: number, + height: number, + viewport: LiftViewport, + rule: LiftScaleRule, +): number { + const side = Math.max(width, height); + if (side <= 0) { + return 1; + } + const fitsWidth = + width > 0 + ? (viewport.width - 2 * viewport.gutter.inline) / width + : Infinity; + return Math.min(rule.maxScale, (side + rule.scaleUpPoints) / side, fitsWidth); +} + +/** + * The lifted source's rect: `source` scaled by `liftScale` about its center, + * then moved inside the viewport's gutter if it crosses it. + */ +export function liftedRect( + source: MorphRect, + viewport: LiftViewport, + rule: LiftScaleRule, +): MorphRect { + const {gutter} = viewport; + const scale = liftScale(source.width, source.height, viewport, rule); + const width = source.width * scale; + const height = source.height * scale; + const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), Math.max(min, max)); + const top = gutter.top; + const bottom = viewport.height - gutter.bottom; + return { + x: clamp( + source.x + source.width / 2 - width / 2, + gutter.inline, + viewport.width - gutter.inline - width, + ), + y: clamp(source.y + source.height / 2 - height / 2, top, bottom - height), + width, + height, + }; +} + +/** + * A surface's place: below the lifted source when `surfaceHeight` fits + * between it, `gap` and the bottom gutter, else above; centered on the + * source, or flush with the source's edge when the source is held against a + * side of the viewport. + */ +export function surfacePlacement( + lifted: MorphRect, + surfaceHeight: number, + viewport: LiftViewport, + gap: number, +): SurfacePlacement { + const {gutter} = viewport; + const bottom = viewport.height - gutter.bottom; + const side = + lifted.y + lifted.height + gap + surfaceHeight <= bottom + ? 'below' + : 'above'; + const epsilon = 0.5; + const atLeft = lifted.x <= gutter.inline + epsilon; + const atRight = + lifted.x + lifted.width >= viewport.width - gutter.inline - epsilon; + // Flush with the edge the source is held against; the alignment is + // logical, so a right-to-left page swaps the physical sides. + const isRtl = viewport.direction === 'rtl'; + const alignment = atLeft + ? isRtl + ? 'end' + : 'start' + : atRight + ? isRtl + ? 'start' + : 'end' + : 'center'; + return {side, alignment}; +} diff --git a/packages/core/src/lift/useLift.ts b/packages/core/src/lift/useLift.ts new file mode 100644 index 0000000000000..4fb6dcd254cef --- /dev/null +++ b/packages/core/src/lift/useLift.ts @@ -0,0 +1,30 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file useLift.ts + * @input React + * @output Exports useLift: a component's one lift (lift.ts), ended when the + * component unmounts + * @position The React entry to the lift presentation; internal to core and + * opt-in, like the module itself + * + * SYNC: When modified, update /packages/core/src/lift/lift.test.ts + */ + +import {useEffect, useRef} from 'react'; +import {createLift, type Lift} from './lift'; + +/** + * The component's lift. It stays the same object for the component's life; + * an unmount ends whatever it has up and leaves the page as it was + * (spec:AST-094 FR24). + */ +export function useLift(): Lift { + const liftRef = useRef(null); + liftRef.current ??= createLift(); + const lift = liftRef.current; + useEffect(() => () => lift.dispose(), [lift]); + return lift; +} From a13c9b00c79adfc364b68ab50e1da1bfe44513e6 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 26 Oct 2026 06:58:13 -0700 Subject: [PATCH 039/118] docs(lift): the lift's springs are the caller's, not motion roles Under main's rule (themes do not set motion roles; the touch tables register FR4's roles only), a lift's springs are its caller's own values. lift.ts's header, LiftMotion, LiftState and resolvedMotion docs and the module's README no longer say a theme retunes them. Comments only. --- packages/core/src/lift/README.md | 3 +-- packages/core/src/lift/lift.ts | 13 +++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/core/src/lift/README.md b/packages/core/src/lift/README.md index 918fdb53f7aa6..f901d5d7fe576 100644 --- a/packages/core/src/lift/README.md +++ b/packages/core/src/lift/README.md @@ -13,8 +13,7 @@ Layer stays generic. The top layer, dismissal, the presentation hold (`Layer/presentationHold.ts`) and the shared exit copy (`Layer/layerExitCopy.ts`) belong to Layer; the lift only uses them. Its motion is the rect morph's (`motion/rectMorph.ts`), and its springs, scale -rule and gap are the caller's own motion roles and constants, so a theme -retunes them where the surface declares them. +rule and gap are the caller's own. ## What a lift adds diff --git a/packages/core/src/lift/lift.ts b/packages/core/src/lift/lift.ts index e51d603df90a9..41fa54ff10a3e 100644 --- a/packages/core/src/lift/lift.ts +++ b/packages/core/src/lift/lift.ts @@ -5,8 +5,8 @@ /** * @file lift.ts * @input An element to lift (the source) and, beside it, a surface that - * grows out of the press (a menu's popover); the caller's springs (motion - * roles a theme may retune), scale rule and tint; the rect morph + * grows out of the press (a menu's popover); the caller's springs, scale + * rule and tint; the rect morph * (motion/rectMorph.ts); the layer runtime's presentation hold, exit copy, * viewport gutter and declared inset * @output Exports createLift: lift a source out of the page above a tinted @@ -97,7 +97,7 @@ import { */ const CLICK_AFTER_LIFT_MS = 350; -/** The springs of a lift; the caller's motion roles. */ +/** The springs of a lift: the caller's. */ export interface LiftMotion { /** The source grows to its lifted rect on it, and the tint fades in. */ readonly lift: SpringDescription; @@ -210,7 +210,7 @@ interface LiftedSurface { interface LiftState { readonly generation: number; readonly options: LiftOptions; - /** The springs, as the source's theme set them at the lift. */ + /** The springs, resolved where the source is at the lift. */ readonly motion: LiftMotion; readonly attribute: string; readonly source: HTMLElement; @@ -418,8 +418,9 @@ function probe( } /** - * The lift's springs as `element`'s theme sets them, read once: plain - * springs, which nothing reads from the page again. + * The lift's springs as they resolve at `element` (a spring that is a motion + * role, through its role), read once: plain springs, which nothing reads + * from the page again. */ function resolvedMotion(element: Element, motion: LiftMotion): LiftMotion { const resolve = (spring: SpringDescription): SpringDescription => ({ From b3e79dfc74f0e5b625d28ee551307673f71bfd0f Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 26 Oct 2026 11:36:30 -0700 Subject: [PATCH 040/118] refactor(lift): the lift holds input through the layer runtime's input hold The layer runtime's presentation hold is now the input hold (Layer/inputHold, holdInput). The lift's import, its unit test's spy and the module's docs follow the new names. Behavior is unchanged. --- packages/core/src/lift/README.md | 6 +++--- packages/core/src/lift/lift.test.ts | 22 +++++++++++----------- packages/core/src/lift/lift.ts | 14 +++++++------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/core/src/lift/README.md b/packages/core/src/lift/README.md index f901d5d7fe576..1a52eaceaf9f1 100644 --- a/packages/core/src/lift/README.md +++ b/packages/core/src/lift/README.md @@ -9,8 +9,8 @@ default, and any surface that lifts uses this module, never a copy of it. Nothing here is exported from the package entry point; components import it by path. -Layer stays generic. The top layer, dismissal, the presentation hold -(`Layer/presentationHold.ts`) and the shared exit copy +Layer stays generic. The top layer, dismissal, the input hold +(`Layer/inputHold.ts`) and the shared exit copy (`Layer/layerExitCopy.ts`) belong to Layer; the lift only uses them. Its motion is the rect morph's (`motion/rectMorph.ts`), and its springs, scale rule and gap are the caller's own. @@ -26,7 +26,7 @@ is the tint); the shadow, a box in the layer that follows the trigger on a rect morph of its own (an animated `::backdrop` of the trigger would restyle and lay out the page on every frame); an empty box at the lifted rect for the surface to be placed against (anchor positioning follows transforms, so -a box inside the trigger would move with it); and the placeholder. The presentation hold's shield joins while the lift grows and +a box inside the trigger would move with it); and the placeholder. The input hold's shield joins while the lift grows and while it closes: never more than five (`spec:AST-017` FR60, DEC-29). The exit copy the surface shrinks on is the layer runtime's transient copy. At rest there is nothing. diff --git a/packages/core/src/lift/lift.test.ts b/packages/core/src/lift/lift.test.ts index c31cca90e253b..ba936e10847ec 100644 --- a/packages/core/src/lift/lift.test.ts +++ b/packages/core/src/lift/lift.test.ts @@ -3,7 +3,7 @@ /** * @file lift.test.ts * @input vitest (jsdom), the lift, stand-ins for the popover API and for - * layout (jsdom has neither) and a spy on the presentation hold + * layout (jsdom has neither) and a spy on the input hold * @output Checks the lift's bookkeeping in the page: while up it adds the * layer and one placeholder whatever the source holds, and nothing at * rest; the source keeps its place among its siblings; a drop or an @@ -16,15 +16,15 @@ */ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import * as presentationHold from '../Layer/presentationHold'; +import * as inputHold from '../Layer/inputHold'; import {createLift, type LiftOptions} from './lift'; -vi.mock('../Layer/presentationHold', async importOriginal => ({ - ...(await importOriginal()), - holdPresentation: vi.fn(() => () => {}), +vi.mock('../Layer/inputHold', async importOriginal => ({ + ...(await importOriginal()), + holdInput: vi.fn(() => () => {}), })); -const {holdPresentation} = presentationHold; +const {holdInput} = inputHold; const SPRING = {response: 0.3, dampingRatio: 1}; @@ -171,7 +171,7 @@ beforeEach(() => { realRemove(type, listener, opts); }, ); - vi.mocked(holdPresentation).mockClear(); + vi.mocked(holdInput).mockClear(); }); afterEach(() => { @@ -362,21 +362,21 @@ describe('lift', () => { const lift = createLift(); lift.lift(source, options()); const layer = document.querySelector('[data-test-lift]'); - expect(vi.mocked(holdPresentation)).toHaveBeenLastCalledWith(layer, { + expect(vi.mocked(holdInput)).toHaveBeenLastCalledWith(layer, { scope: 'viewport', until: expect.any(Promise), }); - vi.mocked(holdPresentation).mockClear(); + vi.mocked(holdInput).mockClear(); lift.presentSurface( menu.querySelector('[role="menu"]') as HTMLElement, {x: 180, y: 330}, ); - expect(vi.mocked(holdPresentation)).toHaveBeenLastCalledWith(layer, { + expect(vi.mocked(holdInput)).toHaveBeenLastCalledWith(layer, { scope: 'viewport', until: expect.any(Promise), }); lift.drop(); - expect(vi.mocked(holdPresentation)).toHaveBeenLastCalledWith(layer, { + expect(vi.mocked(holdInput)).toHaveBeenLastCalledWith(layer, { scope: 'layer', until: expect.any(Promise), }); diff --git a/packages/core/src/lift/lift.ts b/packages/core/src/lift/lift.ts index 41fa54ff10a3e..171ee4b0d6e00 100644 --- a/packages/core/src/lift/lift.ts +++ b/packages/core/src/lift/lift.ts @@ -7,7 +7,7 @@ * @input An element to lift (the source) and, beside it, a surface that * grows out of the press (a menu's popover); the caller's springs, scale * rule and tint; the rect morph - * (motion/rectMorph.ts); the layer runtime's presentation hold, exit copy, + * (motion/rectMorph.ts); the layer runtime's input hold, exit copy, * viewport gutter and declared inset * @output Exports createLift: lift a source out of the page above a tinted * backdrop, grow a surface out of the press beside it, shrink the surface @@ -15,7 +15,7 @@ * @position The one lift-from-trigger presentation, internal to core and * opt-in: only a surface that chooses it uses it (ContextMenu's touch * invocation today). Layer stays generic: the top layer, dismissal, the - * presentation hold and the shared exit copy are its; this module only + * input hold and the shared exit copy are its; this module only * uses them * * UIKit's context menu lifts the long-pressed view out of the page: it grows @@ -52,7 +52,7 @@ * its own along the same path; the tint as a spring-sampled Web Animation of * the layer's `::backdrop`. A tap or * a drag that lands while the lift and the surface grow reaches nothing - * (the presentation hold, FR35); the close holds the layer until the source + * (the input hold, FR35); the close holds the layer until the source * is back, the surface's exit is done and every press on the layer has * lifted. Its timers and document listeners end with it (FR24). Reduce * Motion is the animator's (design:reduced-motion): opacity only. @@ -66,7 +66,7 @@ import type {LayerInset} from '../Layer/LayerContext'; import {placeLayerExitCopy} from '../Layer/layerExitCopy'; import {layerInsetProperties} from '../Layer/layerInset'; import {layerViewportInset} from '../Layer/layerViewportInset.stylex'; -import {holdPresentation} from '../Layer/presentationHold'; +import {holdInput} from '../Layer/inputHold'; import type {MotionHandle} from '../motion/animator'; import {progressCurve} from '../motion/progress'; import { @@ -1009,7 +1009,7 @@ export function createLift(): Lift { // No touch reaches the layer, or anything under it, while the source // grows (spec:AST-094 FR35); a surface extends the hold to its own // growth. - current.releaseGrowHold = holdPresentation(layer, { + current.releaseGrowHold = holdInput(layer, { scope: 'viewport', until: growing.finished, }); @@ -1025,7 +1025,7 @@ export function createLift(): Lift { ) => { releaseGrowHold(current); const until = Promise.all([current.growing.finished, surfaceSettled]); - current.releaseGrowHold = holdPresentation(current.layer, { + current.releaseGrowHold = holdInput(current.layer, { scope: 'viewport', until, }); @@ -1245,7 +1245,7 @@ export function createLift(): Lift { // layer has lifted. const ended = settled.then(async () => current.untilPointersLift()); current.releaseHold?.(); - current.releaseHold = holdPresentation(layer, { + current.releaseHold = holdInput(layer, { scope: 'layer', until: ended, }); From 68c64c179a6acb909d14e79e6e93f16aa7c4c4ec Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 26 Oct 2026 12:33:01 -0700 Subject: [PATCH 041/118] refactor(liftFromTrigger): the lift-from-trigger module takes its record's name architecture:layer-runtime INV15 calls it the lift-from-trigger module, and in spec:AST-094 a bare "lift" is the finger leaving the screen. The module moves to packages/core/src/liftFromTrigger/ and its exports take the name: createLiftFromTrigger, useLiftFromTrigger, LiftFromTrigger{,Motion,Options,Result}, and the geometry's LiftFromTrigger{Gutter,Viewport,ScaleRule}, liftFromTriggerScale and liftFromTriggerRect. Files, headers, SYNC paths and tests follow. Behavior is unchanged. --- .../src/{lift => liftFromTrigger}/README.md | 14 ++--- .../__tests__/liftFromTrigger.fixture.ts} | 16 +++--- ...ftFromTrigger.interaction.browser.spec.ts} | 14 ++--- .../liftFromTrigger.test.ts} | 33 +++++++----- .../liftFromTrigger.ts} | 54 ++++++++++--------- .../liftFromTriggerGeometry.test.ts} | 49 ++++++++++------- .../liftFromTriggerGeometry.ts} | 39 ++++++++------ .../useLiftFromTrigger.ts} | 14 ++--- 8 files changed, 132 insertions(+), 101 deletions(-) rename packages/core/src/{lift => liftFromTrigger}/README.md (72%) rename packages/core/src/{lift/__tests__/lift.fixture.ts => liftFromTrigger/__tests__/liftFromTrigger.fixture.ts} (90%) rename packages/core/src/{lift/__tests__/lift.interaction.browser.spec.ts => liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts} (97%) rename packages/core/src/{lift/lift.test.ts => liftFromTrigger/liftFromTrigger.test.ts} (95%) rename packages/core/src/{lift/lift.ts => liftFromTrigger/liftFromTrigger.ts} (97%) rename packages/core/src/{lift/liftGeometry.test.ts => liftFromTrigger/liftFromTriggerGeometry.test.ts} (69%) rename packages/core/src/{lift/liftGeometry.ts => liftFromTrigger/liftFromTriggerGeometry.ts} (81%) rename packages/core/src/{lift/useLift.ts => liftFromTrigger/useLiftFromTrigger.ts} (54%) diff --git a/packages/core/src/lift/README.md b/packages/core/src/liftFromTrigger/README.md similarity index 72% rename from packages/core/src/lift/README.md rename to packages/core/src/liftFromTrigger/README.md index 1a52eaceaf9f1..92e02b89072cb 100644 --- a/packages/core/src/lift/README.md +++ b/packages/core/src/liftFromTrigger/README.md @@ -1,4 +1,4 @@ -# Lift +# LiftFromTrigger The one lift-from-trigger presentation, internal to core and opt-in. A surface that chooses it (ContextMenu's touch invocation today) lifts its @@ -45,9 +45,9 @@ document listeners end with it, and an unmount ends everything at once ## Files -| File | What it is | -| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| `lift.ts` | `createLift`: lift a trigger, grow and close a surface beside it, drop, end. | -| `useLift.ts` | A component's one lift, ended when it unmounts. | -| `liftGeometry.ts` | How far the trigger grows, where it lands inside the layer runtime's gutter, which side the surface takes. | -| `__tests__/lift.interaction.browser.spec.ts` | Browser proofs (Chromium and WebKit): the top layer, the placeholder, the properties that move, counts, leaks. | +| File | What it is | +| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `liftFromTrigger.ts` | `createLiftFromTrigger`: lift a trigger, grow and close a surface beside it, drop, end. | +| `useLiftFromTrigger.ts` | A component's one lift, ended when it unmounts. | +| `liftFromTriggerGeometry.ts` | How far the trigger grows, where it lands inside the layer runtime's gutter, which side the surface takes. | +| `__tests__/liftFromTrigger.interaction.browser.spec.ts` | Browser proofs (Chromium and WebKit): the top layer, the placeholder, the properties that move, counts, leaks. | diff --git a/packages/core/src/lift/__tests__/lift.fixture.ts b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.fixture.ts similarity index 90% rename from packages/core/src/lift/__tests__/lift.fixture.ts rename to packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.fixture.ts index 17465ff30ac50..38bf875ef0681 100644 --- a/packages/core/src/lift/__tests__/lift.fixture.ts +++ b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.fixture.ts @@ -1,17 +1,21 @@ // Copyright (c) Meta Platforms, Inc. and affiliates. /** - * @file lift.fixture.ts + * @file liftFromTrigger.fixture.ts * @input Nothing: bundled by esbuild and injected into a blank page * @output A page with a list of rows inside a clipping, transformed panel * (the first row rounded by `:first-child`, the second holding twelve * elements), a menu popover beside it, and `window.__lift`: lift a row, * grow and close the menu, drop, end, and the backdrop taps and the * clicks the page received - * @position Browser fixture of lift.interaction.browser.spec.ts; test-only + * @position Browser fixture of liftFromTrigger.interaction.browser.spec.ts; test-only */ -import {createLift, type LiftOptions, type LiftResult} from '../lift'; +import { + createLiftFromTrigger, + type LiftFromTriggerOptions, + type LiftFromTriggerResult, +} from '../liftFromTrigger'; interface LiftLog { backdropTaps: number; @@ -21,7 +25,7 @@ interface LiftLog { /** The page's handle on its lift. */ export interface LiftFixture { log: LiftLog; - lift: (id: string, springResponse?: number) => LiftResult | null; + lift: (id: string, springResponse?: number) => LiftFromTriggerResult | null; presentMenu: (press: {x: number; y: number} | null) => void; dismissMenu: () => void; closeMenu: () => void; @@ -85,9 +89,9 @@ document.addEventListener( true, ); -const lift = createLift(); +const lift = createLiftFromTrigger(); -function options(response: number): LiftOptions { +function options(response: number): LiftFromTriggerOptions { const spring = {response, dampingRatio: 0.9}; return { motion: { diff --git a/packages/core/src/lift/__tests__/lift.interaction.browser.spec.ts b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts similarity index 97% rename from packages/core/src/lift/__tests__/lift.interaction.browser.spec.ts rename to packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts index 2281de2382e27..5da38b0fa1823 100644 --- a/packages/core/src/lift/__tests__/lift.interaction.browser.spec.ts +++ b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts @@ -1,9 +1,9 @@ // Copyright (c) Meta Platforms, Inc. and affiliates. /** - * @file lift.interaction.browser.spec.ts + * @file liftFromTrigger.interaction.browser.spec.ts * @input The lift bundled with esbuild onto a blank fixture page - * (lift.fixture.ts): a list of rows in a clipping, transformed panel and a + * (liftFromTrigger.fixture.ts): a list of rows in a clipping, transformed panel and a * menu popover; Chromium and WebKit * @output Proof in real engines that a row lifts itself into the top layer * at its lifted rect while one placeholder holds its spot (nothing else @@ -18,7 +18,7 @@ * (AST-017 FR60); that repeated lifts leave no node or listener behind; * that a tap while it grows reaches nothing (FR35) and one on the backdrop * afterwards is the backdrop tap - * @position Browser proof for lift.ts, run by `pnpm test:interaction`; jsdom + * @position Browser proof for liftFromTrigger.ts, run by `pnpm test:interaction`; jsdom * has no layout, no top layer and no animations */ @@ -27,7 +27,7 @@ import {fileURLToPath} from 'node:url'; import {expect, test, type Page} from '@playwright/test'; import {build, type Plugin} from 'esbuild'; // The page's `window.__lift`. -import type {LiftFixture} from './lift.fixture'; +import type {LiftFixture} from './liftFromTrigger.fixture'; // The layer runtime's gutter is a `stylex.defineConsts` table: static values // the StyleX compiler puts in place of the call. Unbundled by that compiler, @@ -54,7 +54,7 @@ test.beforeAll(async () => { entryPoints: [ path.join( path.dirname(fileURLToPath(import.meta.url)), - 'lift.fixture.ts', + 'liftFromTrigger.fixture.ts', ), ], bundle: true, @@ -77,7 +77,7 @@ async function open(page: Page) { } type Box = [number, number, number, number]; -type LiftResult = NonNullable>; +type LiftFromTriggerResult = NonNullable>; function expectBox(actual: Box, expected: Box, tolerance = 0.5) { for (let i = 0; i < 4; i++) { @@ -124,7 +124,7 @@ test('a row lifts itself into the top layer at its lifted rect while one placeho expect(after.isOpen).toBe(true); // Grown by 26 px on its larger side, about its center: wider than the // clipping panel, and drawn whole. - const lifted: LiftResult['lifted'] = result?.lifted ?? { + const lifted: LiftFromTriggerResult['lifted'] = result?.lifted ?? { x: 0, y: 0, width: 0, diff --git a/packages/core/src/lift/lift.test.ts b/packages/core/src/liftFromTrigger/liftFromTrigger.test.ts similarity index 95% rename from packages/core/src/lift/lift.test.ts rename to packages/core/src/liftFromTrigger/liftFromTrigger.test.ts index ba936e10847ec..f4c60f4c37389 100644 --- a/packages/core/src/lift/lift.test.ts +++ b/packages/core/src/liftFromTrigger/liftFromTrigger.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Meta Platforms, Inc. and affiliates. /** - * @file lift.test.ts + * @file liftFromTrigger.test.ts * @input vitest (jsdom), the lift, stand-ins for the popover API and for * layout (jsdom has neither) and a spy on the input hold * @output Checks the lift's bookkeeping in the page: while up it adds the @@ -12,12 +12,15 @@ * it over repeated lifts; the gutter it reads; the holds it takes; taps on * its backdrop * @position Testing; the motion, the top layer and the counts in real - * engines are proven in __tests__/lift.interaction.browser.spec.ts + * engines are proven in __tests__/liftFromTrigger.interaction.browser.spec.ts */ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import * as inputHold from '../Layer/inputHold'; -import {createLift, type LiftOptions} from './lift'; +import { + createLiftFromTrigger, + type LiftFromTriggerOptions, +} from './liftFromTrigger'; vi.mock('../Layer/inputHold', async importOriginal => ({ ...(await importOriginal()), @@ -28,7 +31,9 @@ const {holdInput} = inputHold; const SPRING = {response: 0.3, dampingRatio: 1}; -function options(overrides: Partial = {}): LiftOptions { +function options( + overrides: Partial = {}, +): LiftFromTriggerOptions { return { motion: { lift: SPRING, @@ -194,7 +199,7 @@ describe('lift', () => { it('adds the layer, its shadow and anchor, and one placeholder while lifted, whatever the source holds; the source keeps its place among its siblings', () => { const {source, before} = renderList(); const rest = count(); - const lift = createLift(); + const lift = createLiftFromTrigger(); const result = lift.lift(source, options()); expect(result).not.toBeNull(); // 13 elements lifted (the row and its 12), 4 added. @@ -262,7 +267,7 @@ describe('lift', () => { return resolved; }, ); - const lift = createLift(); + const lift = createLiftFromTrigger(); const result = lift.lift(source, options({inset: {blockEnd: 56}})); expect(declaredInset).toBe('56px'); expect(result?.viewport.gutter).toEqual({top: 47, bottom: 34, inline: 16}); @@ -273,7 +278,7 @@ describe('lift', () => { it('a drop puts back exactly what the source had inline, and takes the placeholder and the layer away', async () => { const {source} = renderList(); const rest = count(); - const lift = createLift(); + const lift = createLiftFromTrigger(); lift.lift(source, options()); lift.drop(); await vi.runAllTimersAsync(); @@ -287,7 +292,7 @@ describe('lift', () => { it('an unmount mid-lift ends it at once: no element, document listener or timer left', () => { const {source} = renderList(); const rest = count(); - const lift = createLift(); + const lift = createLiftFromTrigger(); lift.lift(source, options()); lift.dispose(); expectInlineAsBefore(source); @@ -300,7 +305,7 @@ describe('lift', () => { it('an unmount while the close waits for a press on the layer to lift leaves nothing behind either', async () => { const {source} = renderList(); const rest = count(); - const lift = createLift(); + const lift = createLiftFromTrigger(); lift.lift(source, options()); const layer = document.body.lastElementChild as HTMLElement; // A finger down on the layer, never lifted: the close waits for it. @@ -319,7 +324,7 @@ describe('lift', () => { it('twenty lifts and drops leave the page, the document listeners and the timers as they were (no leaks)', async () => { const {source} = renderList(); const rest = count(); - const lift = createLift(); + const lift = createLiftFromTrigger(); for (let i = 0; i < 20; i++) { lift.lift(source, options()); lift.drop(); @@ -336,7 +341,7 @@ describe('lift', () => { const html = document.body.innerHTML; // @ts-expect-error: a browser without the popover API. HTMLElement.prototype.showPopover = undefined; - const lift = createLift(); + const lift = createLiftFromTrigger(); expect(lift.lift(source, options())).toBeNull(); expect(document.body.innerHTML).toBe(html); }); @@ -345,7 +350,7 @@ describe('lift', () => { const {source} = renderList(); source.style.position = 'absolute'; const rest = count(); - const lift = createLift(); + const lift = createLiftFromTrigger(); lift.lift(source, options()); expect(count()).toBe(rest + 3); lift.dispose(); @@ -359,7 +364,7 @@ describe('lift', () => { ); const menu = document.getElementById('menu') as HTMLElement; place(menu, 40, 380, 250, 200); - const lift = createLift(); + const lift = createLiftFromTrigger(); lift.lift(source, options()); const layer = document.querySelector('[data-test-lift]'); expect(vi.mocked(holdInput)).toHaveBeenLastCalledWith(layer, { @@ -387,7 +392,7 @@ describe('lift', () => { it('a press that went down and lifted on the layer is a backdrop tap; one that came from elsewhere is not', () => { const {source} = renderList(); const onBackdropTap = vi.fn(); - const lift = createLift(); + const lift = createLiftFromTrigger(); lift.lift(source, options({onBackdropTap})); const layer = document.body.lastElementChild as HTMLElement; const pointer = (type: string, pointerId: number) => { diff --git a/packages/core/src/lift/lift.ts b/packages/core/src/liftFromTrigger/liftFromTrigger.ts similarity index 97% rename from packages/core/src/lift/lift.ts rename to packages/core/src/liftFromTrigger/liftFromTrigger.ts index 171ee4b0d6e00..c9b2853d3ed9c 100644 --- a/packages/core/src/lift/lift.ts +++ b/packages/core/src/liftFromTrigger/liftFromTrigger.ts @@ -3,13 +3,13 @@ 'use client'; /** - * @file lift.ts + * @file liftFromTrigger.ts * @input An element to lift (the source) and, beside it, a surface that * grows out of the press (a menu's popover); the caller's springs, scale * rule and tint; the rect morph * (motion/rectMorph.ts); the layer runtime's input hold, exit copy, * viewport gutter and declared inset - * @output Exports createLift: lift a source out of the page above a tinted + * @output Exports createLiftFromTrigger: lift a source out of the page above a tinted * backdrop, grow a surface out of the press beside it, shrink the surface * back into the press, and return the source to its place * @position The one lift-from-trigger presentation, internal to core and @@ -58,8 +58,8 @@ * Motion is the animator's (design:reduced-motion): opacity only. * * SYNC: When modified, update: - * - /packages/core/src/lift/lift.test.ts - * - /packages/core/src/lift/__tests__/lift.interaction.browser.spec.ts + * - /packages/core/src/liftFromTrigger/liftFromTrigger.test.ts + * - /packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts */ import type {LayerInset} from '../Layer/LayerContext'; @@ -85,10 +85,10 @@ import { type SpringDescription, } from '../motion/spring'; import { - liftedRect, - type LiftScaleRule, - type LiftViewport, -} from './liftGeometry'; + liftFromTriggerRect, + type LiftFromTriggerScaleRule, + type LiftFromTriggerViewport, +} from './liftFromTriggerGeometry'; /** * How long the layer waits, once the last pointer that went down on it has @@ -98,7 +98,7 @@ import { const CLICK_AFTER_LIFT_MS = 350; /** The springs of a lift: the caller's. */ -export interface LiftMotion { +export interface LiftFromTriggerMotion { /** The source grows to its lifted rect on it, and the tint fades in. */ readonly lift: SpringDescription; /** The source returns to its place on it. */ @@ -115,10 +115,10 @@ export interface LiftMotion { readonly surfaceOut: SpringDescription; } -export interface LiftOptions { - readonly motion: LiftMotion; +export interface LiftFromTriggerOptions { + readonly motion: LiftFromTriggerMotion; /** How far the source grows. */ - readonly scale: LiftScaleRule; + readonly scale: LiftFromTriggerScaleRule; /** * The width a surface grows out of, centered on the press (its height * keeps the surface's aspect); a point by default. @@ -152,13 +152,13 @@ export interface LiftOptions { } /** Where a lift landed. */ -export interface LiftResult { +export interface LiftFromTriggerResult { /** The source's box as laid out, viewport px. */ readonly source: MorphRect; /** The rect it lands on. */ readonly lifted: MorphRect; /** The viewport and the layer runtime's gutter it lifted in. */ - readonly viewport: LiftViewport; + readonly viewport: LiftFromTriggerViewport; /** * An empty box at the lifted rect, in the layer: what a surface is placed * against (an anchor inside the source would move with the source's @@ -167,12 +167,15 @@ export interface LiftResult { readonly anchor: HTMLElement; } -export interface Lift { +export interface LiftFromTrigger { /** * Lifts `source`: returns where it lands, or null without the popover API * (nothing changes then). A lift already up ends at once. */ - lift(source: HTMLElement, options: LiftOptions): LiftResult | null; + lift( + source: HTMLElement, + options: LiftFromTriggerOptions, + ): LiftFromTriggerResult | null; /** * Grows `surface` (a popover laid out at rest, or an element inside one) * out of `press` (client px) into its box, or, without a press, fades it @@ -209,9 +212,9 @@ interface LiftedSurface { interface LiftState { readonly generation: number; - readonly options: LiftOptions; + readonly options: LiftFromTriggerOptions; /** The springs, resolved where the source is at the lift. */ - readonly motion: LiftMotion; + readonly motion: LiftFromTriggerMotion; readonly attribute: string; readonly source: HTMLElement; readonly sourceRect: MorphRect; @@ -422,7 +425,10 @@ function probe( * role, through its role), read once: plain springs, which nothing reads * from the page again. */ -function resolvedMotion(element: Element, motion: LiftMotion): LiftMotion { +function resolvedMotion( + element: Element, + motion: LiftFromTriggerMotion, +): LiftFromTriggerMotion { const resolve = (spring: SpringDescription): SpringDescription => ({ ...resolveMotion(element, spring), }); @@ -591,7 +597,7 @@ const KEPT_PROPERTIES = [ ] as const; /** Creates a lift; one per component that offers it. */ -export function createLift(): Lift { +export function createLiftFromTrigger(): LiftFromTrigger { let state: LiftState | null = null; let generation = 0; @@ -733,8 +739,8 @@ export function createLift(): Lift { const lift = ( source: HTMLElement, - options: LiftOptions, - ): LiftResult | null => { + options: LiftFromTriggerOptions, + ): LiftFromTriggerResult | null => { if (state != null) { end(state); } @@ -801,7 +807,7 @@ export function createLift(): Lift { }); const tint = probed['background-color']; const shadow = probed['box-shadow']; - const viewport: LiftViewport = { + const viewport: LiftFromTriggerViewport = { width: doc.documentElement.clientWidth || view?.innerWidth || 0, height: view?.innerHeight ?? doc.documentElement.clientHeight, gutter: { @@ -811,7 +817,7 @@ export function createLift(): Lift { }, direction, }; - const lifted = liftedRect(sourceRect, viewport, options.scale); + const lifted = liftFromTriggerRect(sourceRect, viewport, options.scale); // Writes from here on. options.release?.(); const layer = createLayer(doc, attribute); diff --git a/packages/core/src/lift/liftGeometry.test.ts b/packages/core/src/liftFromTrigger/liftFromTriggerGeometry.test.ts similarity index 69% rename from packages/core/src/lift/liftGeometry.test.ts rename to packages/core/src/liftFromTrigger/liftFromTriggerGeometry.test.ts index 9ac455d682687..b1101df78cf22 100644 --- a/packages/core/src/lift/liftGeometry.test.ts +++ b/packages/core/src/liftFromTrigger/liftFromTriggerGeometry.test.ts @@ -1,51 +1,57 @@ // Copyright (c) Meta Platforms, Inc. and affiliates. /** - * @file liftGeometry.test.ts + * @file liftFromTriggerGeometry.test.ts * @input vitest, the lift geometry * @output Checks how far a source grows (the cap, the points on its larger * side, the viewport's width), where it lands inside the gutter, and which * side of it a surface takes, in either direction - * @position Unit tests of packages/core/src/lift/liftGeometry.ts; a + * @position Unit tests of packages/core/src/liftFromTrigger/liftFromTriggerGeometry.ts; a * surface's own numbers (UIKit's, for ContextMenu) are tested with it */ import {describe, expect, it} from 'vitest'; import { - liftedRect, - liftScale, + liftFromTriggerRect, + liftFromTriggerScale, surfacePlacement, - type LiftViewport, -} from './liftGeometry'; + type LiftFromTriggerViewport, +} from './liftFromTriggerGeometry'; const RULE = {maxScale: 1.15, scaleUpPoints: 26}; -const VIEWPORT: LiftViewport = { +const VIEWPORT: LiftFromTriggerViewport = { width: 402, height: 874, gutter: {top: 82, bottom: 54, inline: 16}, }; -describe('liftScale', () => { +describe('liftFromTriggerScale', () => { it('caps a small source at the rule’s largest factor', () => { - expect(liftScale(60, 60, VIEWPORT, RULE)).toBe(1.15); + expect(liftFromTriggerScale(60, 60, VIEWPORT, RULE)).toBe(1.15); }); it('grows a larger source by the rule’s points on its larger side', () => { - expect(liftScale(200, 120, VIEWPORT, RULE)).toBeCloseTo(226 / 200, 10); + expect(liftFromTriggerScale(200, 120, VIEWPORT, RULE)).toBeCloseTo( + 226 / 200, + 10, + ); }); it('shrinks a source wider than the viewport less its gutter', () => { - expect(liftScale(402, 60, VIEWPORT, RULE)).toBeCloseTo(370 / 402, 10); + expect(liftFromTriggerScale(402, 60, VIEWPORT, RULE)).toBeCloseTo( + 370 / 402, + 10, + ); }); it('leaves an empty source alone', () => { - expect(liftScale(0, 0, VIEWPORT, RULE)).toBe(1); + expect(liftFromTriggerScale(0, 0, VIEWPORT, RULE)).toBe(1); }); }); -describe('liftedRect', () => { +describe('liftFromTriggerRect', () => { it('grows about the source’s center', () => { - const lifted = liftedRect( + const lifted = liftFromTriggerRect( {x: 101, y: 400, width: 200, height: 120}, VIEWPORT, RULE, @@ -57,18 +63,23 @@ describe('liftedRect', () => { it('moves inside the gutter on every edge', () => { expect( - liftedRect({x: 4, y: 400, width: 60, height: 60}, VIEWPORT, RULE).x, + liftFromTriggerRect({x: 4, y: 400, width: 60, height: 60}, VIEWPORT, RULE) + .x, ).toBe(16); - const right = liftedRect( + const right = liftFromTriggerRect( {x: 340, y: 400, width: 60, height: 60}, VIEWPORT, RULE, ); expect(right.x + right.width).toBeCloseTo(386, 10); expect( - liftedRect({x: 100, y: 60, width: 60, height: 60}, VIEWPORT, RULE).y, + liftFromTriggerRect( + {x: 100, y: 60, width: 60, height: 60}, + VIEWPORT, + RULE, + ).y, ).toBe(82); - const bottom = liftedRect( + const bottom = liftFromTriggerRect( {x: 100, y: 800, width: 60, height: 60}, VIEWPORT, RULE, @@ -96,7 +107,7 @@ describe('surfacePlacement', () => { const atRight = {...middle, x: 386 - middle.width}; expect(surfacePlacement(atLeft, 200, VIEWPORT, 16).alignment).toBe('start'); expect(surfacePlacement(atRight, 200, VIEWPORT, 16).alignment).toBe('end'); - const rtl: LiftViewport = {...VIEWPORT, direction: 'rtl'}; + const rtl: LiftFromTriggerViewport = {...VIEWPORT, direction: 'rtl'}; expect(surfacePlacement(atLeft, 200, rtl, 16).alignment).toBe('end'); expect(surfacePlacement(atRight, 200, rtl, 16).alignment).toBe('start'); }); diff --git a/packages/core/src/lift/liftGeometry.ts b/packages/core/src/liftFromTrigger/liftFromTriggerGeometry.ts similarity index 81% rename from packages/core/src/lift/liftGeometry.ts rename to packages/core/src/liftFromTrigger/liftFromTriggerGeometry.ts index 2660705ea6c0b..a2c603b7c18c8 100644 --- a/packages/core/src/lift/liftGeometry.ts +++ b/packages/core/src/liftFromTrigger/liftFromTriggerGeometry.ts @@ -1,13 +1,13 @@ // Copyright (c) Meta Platforms, Inc. and affiliates. /** - * @file liftGeometry.ts + * @file liftFromTriggerGeometry.ts * @input A source's box, the viewport and the layer runtime's gutter, and * the caller's scale rule and gap - * @output Exports liftScale, liftedRect and surfacePlacement: how far a + * @output Exports liftFromTriggerScale, liftFromTriggerRect and surfacePlacement: how far a * lifted source grows, where it lands inside the gutter, and which side of * it a surface (a menu) takes - * @position The geometry of the lift presentation (lift.ts); internal to + * @position The geometry of the lift presentation (liftFromTrigger.ts); internal to * core * * A lifted source grows about its center by the caller's rule (UIKit's @@ -18,7 +18,7 @@ * gutter, else above; centered on the source, or flush with the edge the * source is held against. * - * SYNC: When modified, update /packages/core/src/lift/liftGeometry.test.ts + * SYNC: When modified, update /packages/core/src/liftFromTrigger/liftFromTriggerGeometry.test.ts */ import type {MorphRect} from '../motion/rectMorph'; @@ -29,7 +29,7 @@ import type {MorphRect} from '../motion/rectMorph'; * edge's safe-area inset, whichever is larger, plus the inset an app declares * for a bar floating over that edge. */ -export interface LiftGutter { +export interface LiftFromTriggerGutter { readonly top: number; readonly bottom: number; readonly inline: number; @@ -39,15 +39,15 @@ export interface LiftGutter { * The viewport a source lifts in, CSS px, the layer runtime's gutter, and the * page's direction (a surface's alignment is logical). */ -export interface LiftViewport { +export interface LiftFromTriggerViewport { readonly width: number; readonly height: number; - readonly gutter: LiftGutter; + readonly gutter: LiftFromTriggerGutter; readonly direction?: 'ltr' | 'rtl'; } /** How far a lifted source grows. */ -export interface LiftScaleRule { +export interface LiftFromTriggerScaleRule { /** The largest factor it grows by. */ readonly maxScale: number; /** Below `maxScale`, it grows by this many px on its larger side. */ @@ -65,11 +65,11 @@ export interface SurfacePlacement { * `rule.scaleUpPoints` on its larger side, and never wider than the viewport * less its gutter (which can make it smaller than 1). */ -export function liftScale( +export function liftFromTriggerScale( width: number, height: number, - viewport: LiftViewport, - rule: LiftScaleRule, + viewport: LiftFromTriggerViewport, + rule: LiftFromTriggerScaleRule, ): number { const side = Math.max(width, height); if (side <= 0) { @@ -83,16 +83,21 @@ export function liftScale( } /** - * The lifted source's rect: `source` scaled by `liftScale` about its center, + * The lifted source's rect: `source` scaled by `liftFromTriggerScale` about its center, * then moved inside the viewport's gutter if it crosses it. */ -export function liftedRect( +export function liftFromTriggerRect( source: MorphRect, - viewport: LiftViewport, - rule: LiftScaleRule, + viewport: LiftFromTriggerViewport, + rule: LiftFromTriggerScaleRule, ): MorphRect { const {gutter} = viewport; - const scale = liftScale(source.width, source.height, viewport, rule); + const scale = liftFromTriggerScale( + source.width, + source.height, + viewport, + rule, + ); const width = source.width * scale; const height = source.height * scale; const clamp = (value: number, min: number, max: number) => @@ -120,7 +125,7 @@ export function liftedRect( export function surfacePlacement( lifted: MorphRect, surfaceHeight: number, - viewport: LiftViewport, + viewport: LiftFromTriggerViewport, gap: number, ): SurfacePlacement { const {gutter} = viewport; diff --git a/packages/core/src/lift/useLift.ts b/packages/core/src/liftFromTrigger/useLiftFromTrigger.ts similarity index 54% rename from packages/core/src/lift/useLift.ts rename to packages/core/src/liftFromTrigger/useLiftFromTrigger.ts index 4fb6dcd254cef..f69d3a40a2b0f 100644 --- a/packages/core/src/lift/useLift.ts +++ b/packages/core/src/liftFromTrigger/useLiftFromTrigger.ts @@ -3,27 +3,27 @@ 'use client'; /** - * @file useLift.ts + * @file useLiftFromTrigger.ts * @input React - * @output Exports useLift: a component's one lift (lift.ts), ended when the + * @output Exports useLiftFromTrigger: a component's one lift (liftFromTrigger.ts), ended when the * component unmounts * @position The React entry to the lift presentation; internal to core and * opt-in, like the module itself * - * SYNC: When modified, update /packages/core/src/lift/lift.test.ts + * SYNC: When modified, update /packages/core/src/liftFromTrigger/liftFromTrigger.test.ts */ import {useEffect, useRef} from 'react'; -import {createLift, type Lift} from './lift'; +import {createLiftFromTrigger, type LiftFromTrigger} from './liftFromTrigger'; /** * The component's lift. It stays the same object for the component's life; * an unmount ends whatever it has up and leaves the page as it was * (spec:AST-094 FR24). */ -export function useLift(): Lift { - const liftRef = useRef(null); - liftRef.current ??= createLift(); +export function useLiftFromTrigger(): LiftFromTrigger { + const liftRef = useRef(null); + liftRef.current ??= createLiftFromTrigger(); const lift = liftRef.current; useEffect(() => () => lift.dispose(), [lift]); return lift; From d2c974ea46d4e70e89e1f5821b98ced7a28450d4 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 26 Oct 2026 22:14:13 -0700 Subject: [PATCH 042/118] test(liftFromTrigger): a lifted list item leaves its list valid and the numbers after it as they were A lifted row's spot was held by a span, so a list held a span among its items and an ordered list numbered the items after it one higher while the row was lifted. Two browser proofs, red on the span: every child of the list is an li while lifted, at the row's size whatever the page styles an li with, and a later item's marker paints the same. --- ...iftFromTrigger.interaction.browser.spec.ts | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts index 5da38b0fa1823..c307c536d1366 100644 --- a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts +++ b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts @@ -142,6 +142,109 @@ test('a row lifts itself into the top layer at its lifted rect while one placeho await page.evaluate(() => window.__lift.dispose()); }); +test('a lifted list item leaves its list valid: an li holds its spot, so the list keeps only li children, at the item\u2019s size whatever the page styles an li with', async ({ + page, +}) => { + await open(page); + // A rule that would make any other li taller than the row it stands for. + await page.addStyleTag({ + content: 'li:not([id]) { min-height: 100px; padding: 40px 0; }', + }); + const row2 = await page.evaluate(() => { + const r = document.getElementById('row2')?.getBoundingClientRect(); + return r == null ? null : {height: r.height}; + }); + const row3Before = await page.evaluate( + () => document.getElementById('row3')?.getBoundingClientRect().top ?? -1, + ); + await page.evaluate(() => window.__lift.lift('row2', 0.2)); + await page.waitForTimeout(400); + const read = await page.evaluate(() => { + const list = document.getElementById('list'); + const placeholder = document.querySelector('[data-lift-placeholder]'); + return { + children: [...(list?.children ?? [])].map(child => child.localName), + placeholder: + placeholder == null + ? null + : { + tag: placeholder.localName, + parent: placeholder.parentElement?.id ?? null, + hidden: placeholder.getAttribute('aria-hidden'), + height: placeholder.getBoundingClientRect().height, + }, + row3: document.getElementById('row3')?.getBoundingClientRect().top ?? -1, + }; + }); + const detail = JSON.stringify(read); + expect(read.children, detail).toEqual(['li', 'li', 'li', 'li', 'li']); + expect(read.placeholder, detail).toEqual({ + tag: 'li', + parent: 'list', + hidden: 'true', + height: row2?.height ?? -1, + }); + expect(read.row3, detail).toBe(row3Before); + await page.evaluate(() => window.__lift.dispose()); +}); + +test('a lifted item of an ordered list leaves the numbers of the items after it as they were', async ({ + page, +}) => { + await open(page); + // An ordered list with its markers outside, and no tint and no shadow: a + // later item's marker paints the same while an item is lifted unless its + // number changes. + const clip = await page.evaluate(() => { + const root = document.documentElement.style; + root.setProperty('--color-overlay', 'transparent'); + root.setProperty('--shadow-high', 'none'); + const panel = document.querySelector('.panel'); + if (panel != null) { + panel.style.display = 'none'; + } + const holder = document.createElement('div'); + holder.style.cssText = + 'position: absolute; left: 20px; top: 120px; width: 335px;'; + holder.innerHTML = `
    ${[ + 1, 2, 3, 4, 5, + ] + .map( + i => + `
  1. Item ${i}
  2. `, + ) + .join('')}
`; + document.body.append(holder); + const list = document.getElementById('numbered')?.getBoundingClientRect(); + const later = document.getElementById('item5')?.getBoundingClientRect(); + return list == null || later == null + ? null + : { + x: list.left, + y: later.top, + width: later.left - list.left, + height: later.height, + }; + }); + if (clip == null) { + throw new Error('no ordered list'); + } + const before = await page.screenshot({clip}); + await page.evaluate(() => window.__lift.lift('item2', 0.2)); + await page.waitForTimeout(600); + const lifted = await page.screenshot({clip}); + const placeholder = await page.evaluate(() => { + const element = document.querySelector('[data-lift-placeholder]'); + return element == null + ? null + : {tag: element.localName, display: getComputedStyle(element).display}; + }); + // The fifth item still reads "5.". + expect(Buffer.compare(lifted, before), JSON.stringify(placeholder)).toBe(0); + expect(placeholder?.tag).toBe('li'); + await page.evaluate(() => window.__lift.dispose()); +}); + test('the shadow follows the row on every frame of its lift and of its drop', async ({ page, }) => { From e0eb8e183c772f5586605de09ee8786d81975c16 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 26 Oct 2026 22:17:57 -0700 Subject: [PATCH 043/118] fix(liftFromTrigger): an li holds a list item's spot, and takes no number In a ul, ol or menu the placeholder is an li, so the list keeps only li children, as a row menu attached to its list item needs. It is laid out as a block, never a list item, so the items after it keep their numbers, and its own minimum and maximum sizes, padding and border are reset, so the page's rules for an li never change the spot it holds. --- .../src/liftFromTrigger/liftFromTrigger.ts | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/core/src/liftFromTrigger/liftFromTrigger.ts b/packages/core/src/liftFromTrigger/liftFromTrigger.ts index c9b2853d3ed9c..64b794844053c 100644 --- a/packages/core/src/liftFromTrigger/liftFromTrigger.ts +++ b/packages/core/src/liftFromTrigger/liftFromTrigger.ts @@ -32,7 +32,8 @@ * kept as it was laid out, and the source takes no hits while lifted (a tap * on it is a tap on the backdrop). Inheritance follows the DOM, so the page's * theme still reaches it. The placeholder goes after the source, so the - * source keeps its place among its siblings. The tint is the `::backdrop` of + * source keeps its place among its siblings; in a list it is an `li` that + * is not a list item, so the list stays valid and keeps its numbers. The tint is the `::backdrop` of * a lift layer at the end of the body that covers the viewport and takes the * page's touches (its color and the gutter's spacing resolved where the * source is); the shadow is a box in the layer that follows the source on a @@ -531,26 +532,51 @@ function createLayer(doc: Document, attribute: string): HTMLElement { return layer; } +/** + * The element that holds a source's spot: an `li` in a list, so the list + * keeps only `li` children (HTML's content model for `ul`, `ol` and `menu`), + * and a `span` elsewhere. + */ +function placeholderTag(source: HTMLElement): 'li' | 'span' { + const parent = source.parentElement?.localName; + return parent === 'ul' || parent === 'ol' || parent === 'menu' + ? 'li' + : 'span'; +} + /** * The placeholder that holds a source's spot in the page while it is lifted: * empty, hidden, at its laid-out size, with its margins and its place in a - * flex or grid container. + * flex or grid container. It is never a list item itself, so it takes no + * number and the items after it keep theirs, and the page's own rules for + * its tag (an `li`'s minimum height, its padding) never change its size. */ function placeholderStyle( computed: CSSStyleDeclaration, box: MorphRect, ): Partial { + const shown = computed.getPropertyValue('display'); const display = - computed.display === 'inline' + shown === 'inline' ? 'inline-block' - : computed.display === 'contents' || computed.display === 'none' + : shown === 'contents' || shown === 'none' ? 'block' - : computed.display; + : shown.includes('list-item') + ? shown.includes('inline') + ? 'inline-block' + : 'block' + : shown; return { display, boxSizing: 'border-box', width: `${box.width}px`, height: `${box.height}px`, + minWidth: '0', + minHeight: '0', + maxWidth: 'none', + maxHeight: 'none', + padding: '0', + border: '0', marginTop: computed.marginTop, marginRight: computed.marginRight, marginBottom: computed.marginBottom, @@ -851,7 +877,7 @@ export function createLiftFromTrigger(): LiftFromTrigger { layer.append(anchor); let placeholder: HTMLElement | null = null; if (placeholderValues != null) { - placeholder = doc.createElement('span'); + placeholder = doc.createElement(placeholderTag(source)); placeholder.setAttribute(`${attribute}-placeholder`, ''); placeholder.setAttribute('aria-hidden', 'true'); Object.assign(placeholder.style, placeholderValues); From 08cb1b7cdb301d980b35ee524537bbd12daeca40 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 27 Oct 2026 12:45:37 -0700 Subject: [PATCH 044/118] test(liftFromTrigger): the zoom's copy of a source, through the lift module (INV15) liftCopy places one copy of the source's picture in the caller's host at the source's box (aspect fill, its corners, inert, hidden from assistive technology), against the host's offset, borders and scroll, while the source hides in place with no placeholder; an image source gives its own picture, another the caller's src; a drop shows the source as it was and leaves nothing, twice is once, and with two copies of one source it shows only when the last drops; nothing changes for a source with no box. Red: liftCopy does not exist. --- .../liftFromTrigger/liftFromTrigger.test.ts | 135 +++++++++++++++++- 1 file changed, 134 insertions(+), 1 deletion(-) diff --git a/packages/core/src/liftFromTrigger/liftFromTrigger.test.ts b/packages/core/src/liftFromTrigger/liftFromTrigger.test.ts index f4c60f4c37389..b23a1f8b39078 100644 --- a/packages/core/src/liftFromTrigger/liftFromTrigger.test.ts +++ b/packages/core/src/liftFromTrigger/liftFromTrigger.test.ts @@ -10,7 +10,9 @@ * unmount puts back exactly what the source had inline and takes the rest * away; nothing of it (an element, a document listener, a timer) outlives * it over repeated lifts; the gutter it reads; the holds it takes; taps on - * its backdrop + * its backdrop. And the zoom's copy (liftCopy): one copy of the source's + * picture in the caller's host at the source's box while the source hides + * in place, and a drop that leaves the page as it was * @position Testing; the motion, the top layer and the counts in real * engines are proven in __tests__/liftFromTrigger.interaction.browser.spec.ts */ @@ -20,6 +22,7 @@ import * as inputHold from '../Layer/inputHold'; import { createLiftFromTrigger, type LiftFromTriggerOptions, + liftCopy, } from './liftFromTrigger'; vi.mock('../Layer/inputHold', async importOriginal => ({ @@ -409,3 +412,133 @@ describe('lift', () => { lift.dispose(); }); }); + +describe('liftCopy', () => { + /** + * A viewer's host over the page, and a grid of thumbnails: one a button + * around a picture, one a bare picture with its own inline visibility, one + * that shows no picture. + */ + function renderGrid() { + document.body.innerHTML = ` +
+
    +
  • +
  • B
  • +
  • C
  • +
`; + const host = document.getElementById('host') as HTMLElement; + place(host, 0, 0, 390, 844); + const thumb = document.getElementById('thumb') as HTMLElement; + thumb.style.borderRadius = '12px'; + place(thumb, 40, 300, 100, 100); + const bare = document.getElementById('bare') as HTMLElement; + place(bare, 150, 300, 100, 100); + const plain = document.getElementById('plain') as HTMLElement; + place(plain, 260, 300, 100, 100); + return {host, thumb, bare, plain}; + } + + it("places one copy of the source's picture in the host at the source's box (aspect fill, its corners, inert, hidden from assistive technology); the source hides in place and nothing else is added", () => { + const {host, thumb} = renderGrid(); + const before = count(); + const copy = liftCopy(thumb, {host}); + expect(copy).not.toBeNull(); + expect(count()).toBe(before + 1); + const element = copy?.element as HTMLImageElement; + expect(element.parentElement).toBe(host); + expect(element.localName).toBe('img'); + expect(element.src).toBe('https://example.test/a-thumb.jpg'); + expect(element.alt).toBe(''); + expect(element.getAttribute('aria-hidden')).toBe('true'); + expect(element.hasAttribute('data-astryx-lift-copy')).toBe(true); + expect([ + element.style.position, + element.style.left, + element.style.top, + element.style.width, + element.style.height, + ]).toEqual(['absolute', '40px', '300px', '100px', '100px']); + expect(element.style.objectFit).toBe('cover'); + expect(element.style.borderRadius).toBe('12px'); + expect(element.style.pointerEvents).toBe('none'); + expect(copy?.box).toEqual({x: 40, y: 300, width: 100, height: 100}); + // The source hides where it is: it keeps its layout, so no placeholder. + expect(thumb.style.visibility).toBe('hidden'); + expect(thumb.parentElement?.children).toHaveLength(1); + copy?.drop(); + }); + + it("lays the copy out against the host's own box: its offset, borders and scroll", () => { + const {host, thumb} = renderGrid(); + place(host, 10, 20, 300, 600); + Object.defineProperty(host, 'clientLeft', {value: 2, configurable: true}); + Object.defineProperty(host, 'clientTop', {value: 3, configurable: true}); + Object.defineProperty(host, 'scrollLeft', {value: 5, configurable: true}); + Object.defineProperty(host, 'scrollTop', {value: 7, configurable: true}); + const copy = liftCopy(thumb, {host}); + expect([copy?.element.style.left, copy?.element.style.top]).toEqual([ + '33px', + '284px', + ]); + copy?.drop(); + }); + + it("takes an image source's own picture, or `src` for a source that shows none", () => { + const {host, bare, plain} = renderGrid(); + const fromImage = liftCopy(bare, {host, src: 'https://example.test/b.jpg'}); + expect(fromImage?.element.src).toBe('https://example.test/b-thumb.jpg'); + fromImage?.drop(); + const fromSrc = liftCopy(plain, {host, src: 'https://example.test/c.jpg'}); + expect(fromSrc?.element.src).toBe('https://example.test/c.jpg'); + fromSrc?.drop(); + }); + + it('a drop takes the copy away and shows the source as it was; a second drop does nothing', () => { + const {host, thumb, bare} = renderGrid(); + const before = count(); + const copy = liftCopy(thumb, {host}); + const other = liftCopy(bare, {host}); + copy?.drop(); + copy?.drop(); + expect(thumb.style.visibility).toBe(''); + expect(thumb.getAttribute('style')).not.toContain('visibility'); + expect(bare.style.visibility).toBe('hidden'); + other?.drop(); + // Its own inline visibility comes back. + expect(bare.style.visibility).toBe('visible'); + expect(count()).toBe(before); + expect(host.children).toHaveLength(0); + }); + + it('two copies of one source: it shows again only when the last of them drops', () => { + const {host, thumb} = renderGrid(); + const opening = liftCopy(thumb, {host}); + const closing = liftCopy(thumb, {host}); + expect(host.children).toHaveLength(2); + opening?.drop(); + expect(thumb.style.visibility).toBe('hidden'); + closing?.drop(); + expect(thumb.style.visibility).toBe(''); + }); + + it('`attribute` names the mark on the copy', () => { + const {host, thumb} = renderGrid(); + const copy = liftCopy(thumb, {host, attribute: 'data-lightbox-portal'}); + expect(copy?.element.hasAttribute('data-lightbox-portal')).toBe(true); + expect(copy?.element.hasAttribute('data-astryx-lift-copy')).toBe(false); + copy?.drop(); + }); + + it('changes nothing for a source that is not in the page or has no box', () => { + const {host, thumb} = renderGrid(); + const before = count(); + const detached = document.createElement('img'); + place(detached, 0, 0, 100, 100); + expect(liftCopy(detached, {host})).toBeNull(); + place(thumb, 40, 300, 0, 0); + expect(liftCopy(thumb, {host})).toBeNull(); + expect(thumb.style.visibility).toBe(''); + expect(count()).toBe(before); + }); +}); From d5fb3789c28ec48e3ba4ab31bc78126cabd765b8 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 27 Oct 2026 12:47:40 -0700 Subject: [PATCH 045/118] feat(liftFromTrigger): liftCopy, the zoom's copy of a source through the lift module (INV15) A zoom from a thumbnail into a viewer needs the thumbnail's picture between the viewer's backdrop and its photo, which a trigger in the top layer cannot be (it would cover the viewer, or sit under its backdrop). liftCopy is INV15's clone: one image of the source's picture (aspect fill, its corners) in the caller's host at the source's box, inert and hidden from assistive technology, while the source hides in place and keeps its layout. The caller moves the copy on the rect morph, which turns an interrupted motion around from the rect on screen; drop() takes the copy away and shows the source as it was once the last copy of it drops. Internal, not exported. --- .changeset/lift-module.md | 2 +- packages/core/src/liftFromTrigger/README.md | 25 ++- .../src/liftFromTrigger/liftFromTrigger.ts | 177 +++++++++++++++++- 3 files changed, 195 insertions(+), 9 deletions(-) diff --git a/.changeset/lift-module.md b/.changeset/lift-module.md index b0d245df28b34..6d6b6ea44e3c2 100644 --- a/.changeset/lift-module.md +++ b/.changeset/lift-module.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: one opt-in lift-from-trigger presentation for touch surfaces. The trigger itself lifts into the top layer above a tinted backdrop; a surface grows out of the press beside it on the shared rect morph and shrinks back into it on the layer runtime's exit copy; the trigger returns to its place. Translate, scale and opacity only; nothing mounted at rest, four elements while lifted. Not exported from the package entry point; no component uses it yet. +[chore] Internal: one opt-in lift-from-trigger presentation for touch surfaces. The trigger itself lifts into the top layer above a tinted backdrop; a surface grows out of the press beside it on the shared rect morph and shrinks back into it on the layer runtime's exit copy; the trigger returns to its place. Translate, scale and opacity only; nothing mounted at rest, four elements while lifted. A zoom from a thumbnail lifts a copy of the thumbnail's picture into the viewer instead, while the thumbnail hides in place. Not exported from the package entry point; no component uses it yet. @vjeux diff --git a/packages/core/src/liftFromTrigger/README.md b/packages/core/src/liftFromTrigger/README.md index 92e02b89072cb..ae013e6432886 100644 --- a/packages/core/src/liftFromTrigger/README.md +++ b/packages/core/src/liftFromTrigger/README.md @@ -1,10 +1,11 @@ # LiftFromTrigger The one lift-from-trigger presentation, internal to core and opt-in. A -surface that chooses it (ContextMenu's touch invocation today) lifts its -trigger out of the page above a tinted backdrop, grows a surface (a menu) out -of the press beside it, shrinks that surface back into the press when it -closes, and returns the trigger to its place. Nothing else gets it by +surface that chooses it (ContextMenu's touch invocation) lifts its trigger +out of the page above a tinted backdrop, grows a surface (a menu) out of the +press beside it, shrinks that surface back into the press when it closes, and +returns the trigger to its place. A viewer's zoom from a thumbnail lifts a +copy of the thumbnail's picture instead (below). Nothing else gets it by default, and any surface that lifts uses this module, never a copy of it. Nothing here is exported from the package entry point; components import it by path. @@ -31,6 +32,22 @@ while it closes: never more than five (`spec:AST-017` FR60, DEC-29). The exit copy the surface shrinks on is the layer runtime's transient copy. At rest there is nothing. +## The zoom's copy + +A zoom from a thumbnail into a viewer (UIKit's `.zoom`) needs the +thumbnail's picture between the viewer's backdrop and its photo, which a +trigger in the top layer cannot be: it would cover the whole viewer, or sit +under its backdrop. So `liftCopy(source, {host, src})` lifts a copy, the one +transient clone `architecture:layer-runtime` INV15 allows: one image of the +source's picture (aspect fill, its corners) in the viewer's host at the +source's box, inert and hidden from assistive technology, while the source +hides where it stands and keeps its layout (no placeholder). The viewer moves +the copy on the rect morph with its own paths and springs; a close during the +opening turns the same copy around from the rect on screen, with its +velocity. `drop()` at the landing, or on unmount, takes the copy away and +shows the source as it was, once the last copy of it drops. While a copy is +up the page has one element more. + ## Motion Every motion is `translate`, `scale` or `opacity` on the compositor diff --git a/packages/core/src/liftFromTrigger/liftFromTrigger.ts b/packages/core/src/liftFromTrigger/liftFromTrigger.ts index 64b794844053c..8956352117673 100644 --- a/packages/core/src/liftFromTrigger/liftFromTrigger.ts +++ b/packages/core/src/liftFromTrigger/liftFromTrigger.ts @@ -11,12 +11,13 @@ * viewport gutter and declared inset * @output Exports createLiftFromTrigger: lift a source out of the page above a tinted * backdrop, grow a surface out of the press beside it, shrink the surface - * back into the press, and return the source to its place + * back into the press, and return the source to its place; and liftCopy: + * a copy of a source's picture in a viewer's host, for a zoom * @position The one lift-from-trigger presentation, internal to core and * opt-in: only a surface that chooses it uses it (ContextMenu's touch - * invocation today). Layer stays generic: the top layer, dismissal, the - * input hold and the shared exit copy are its; this module only - * uses them + * invocation; a viewer's zoom from a thumbnail). Layer stays generic: the + * top layer, dismissal, the input hold and the shared exit copy are its; + * this module only uses them * * UIKit's context menu lifts the long-pressed view out of the page: it grows * on from the size the press gave it to its lifted size, a shadow rising @@ -47,6 +48,16 @@ * its close plays on the layer runtime's one exit copy * (`placeLayerExitCopy`). * + * A zoom from a thumbnail into a viewer lifts a copy instead + * (`architecture:layer-runtime` INV15's clone): the copy has to show between + * the viewer's backdrop and its photo, which a source in the top layer cannot + * (it would cover the whole viewer, or sit under its backdrop). liftCopy puts + * one copy of the source's picture in the viewer's host at the source's box, + * inert and hidden from assistive technology, and hides the source where it + * stands (it keeps its layout, so no placeholder); the viewer moves the copy + * on the rect morph, which turns an interrupted motion around from the rect + * on screen, and a drop shows the source again and takes the copy away. + * * Every motion is `translate`, `scale` or `opacity` on the compositor * (spec:AST-094 FR5): the source and the surface on the rect morph, which * interrupts itself with the velocity on screen (FR6), the shadow on one of @@ -200,6 +211,38 @@ export interface LiftFromTrigger { isLifted(): boolean; } +/** Where a copy goes, and how it is marked. */ +export interface LiftCopyOptions { + /** + * The element the copy goes in (a viewer's container): positioned, so the + * copy is laid out against its box; its stacking decides what the copy + * shows over and under. + */ + readonly host: HTMLElement; + /** The picture for a source that shows none. */ + readonly src?: string; + /** Marks the copy. @default 'data-astryx-lift-copy' */ + readonly attribute?: string; +} + +/** A copy of a source's picture, lifted into a host while the source hides. */ +export interface LiftedCopy { + /** + * The copy: an image in the host, laid out at the source's box (aspect + * fill, its corners), inert and hidden from assistive technology. Move it + * on the rect morph (`createRectMorph(copy.element)`). + */ + readonly element: HTMLImageElement; + /** The source's box, client px, where the copy is laid out. */ + readonly box: MorphRect; + /** + * Takes the copy away and shows the source again, as it was, once the last + * copy of it drops: at the landing, or on unmount (nothing is left either + * way). A second call does nothing. + */ + drop(): void; +} + type SavedStyle = [property: string, value: string, priority: string][]; interface LiftedSurface { @@ -1301,3 +1344,129 @@ export function createLiftFromTrigger(): LiftFromTrigger { isLifted: () => state?.isSourceLifted === true, }; } + +/** + * Sources a copy hides: how many copies hold each, and the visibility it had + * inline before the first, which it gets back when the last drops. + */ +const hiddenSources = new WeakMap< + HTMLElement, + {holders: number; visibility: [value: string, priority: string]} +>(); + +/** Hides `source` where it stands until the returned call lets go. */ +function hideSource(source: HTMLElement): () => void { + const held = hiddenSources.get(source); + if (held == null) { + hiddenSources.set(source, { + holders: 1, + visibility: [ + source.style.getPropertyValue('visibility'), + source.style.getPropertyPriority('visibility'), + ], + }); + source.style.setProperty('visibility', 'hidden', 'important'); + } else { + held.holders += 1; + } + let isReleased = false; + return () => { + const entry = hiddenSources.get(source); + if (isReleased || entry == null) { + return; + } + isReleased = true; + entry.holders -= 1; + if (entry.holders > 0) { + return; + } + hiddenSources.delete(source); + const [value, priority] = entry.visibility; + if (value === '') { + source.style.removeProperty('visibility'); + } else { + source.style.setProperty('visibility', value, priority); + } + }; +} + +/** The picture a source shows: an image's own, or the first one inside it. */ +function pictureOf(source: HTMLElement): string { + const image = + source instanceof HTMLImageElement + ? source + : source.querySelector('img'); + return image?.currentSrc || image?.src || ''; +} + +/** + * Lifts a copy of `source`'s picture into `host` for a zoom + * (`architecture:layer-runtime` INV15's clone): one image laid out at the + * source's box (aspect fill, its corners), inert and hidden from assistive + * technology, while the source hides where it stands. The caller moves the + * copy on the rect morph and drops it at the landing. Null, with nothing + * changed, for a source that is not in the page or has no box. + */ +export function liftCopy( + source: HTMLElement, + {host, src, attribute = 'data-astryx-lift-copy'}: LiftCopyOptions, +): LiftedCopy | null { + if (!source.isConnected || !host.isConnected) { + return null; + } + // Reads before writes: the page lays out once for the copy. + const shown = source.getBoundingClientRect(); + if (shown.width <= 0 || shown.height <= 0) { + return null; + } + const box: MorphRect = { + x: shown.left, + y: shown.top, + width: shown.width, + height: shown.height, + }; + // The host's padding box is the copy's containing block. + const origin = host.getBoundingClientRect(); + const left = box.x - origin.left - host.clientLeft + host.scrollLeft; + const top = box.y - origin.top - host.clientTop + host.scrollTop; + const radius = shapeRadius(source); + const picture = pictureOf(source) || src || ''; + const copy = source.ownerDocument.createElement('img'); + copy.src = picture; + copy.alt = ''; + copy.setAttribute('aria-hidden', 'true'); + copy.setAttribute('inert', ''); + copy.setAttribute(attribute, ''); + Object.assign(copy.style, { + position: 'absolute', + left: `${left}px`, + top: `${top}px`, + width: `${box.width}px`, + height: `${box.height}px`, + // Exactly the box, whatever the host's styles say about images. + margin: '0', + padding: '0', + border: '0', + maxWidth: 'none', + maxHeight: 'none', + boxSizing: 'border-box', + objectFit: 'cover', + borderRadius: radius, + pointerEvents: 'none', + }); + host.append(copy); + const release = hideSource(source); + let isDropped = false; + return { + element: copy, + box, + drop: () => { + if (isDropped) { + return; + } + isDropped = true; + copy.remove(); + release(); + }, + }; +} From 9d4bf2b8ae46b615b3ebf85519caf3893a7a64a0 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 27 Oct 2026 12:54:31 -0700 Subject: [PATCH 046/118] test(liftFromTrigger): browser proofs of the zoom's copy On the fixture, in Chromium and WebKit: the copy lifts into a viewer at the thumbnail's box, one element more and not in the top layer, so the viewer's stacking puts it under the photo, while the thumbnail hides and keeps its layout; it grows toward the photo on the rect morph, a close once it is well on its way turns the same element around from the rect on screen with no frame jumping to either end, and it lands in the thumbnail; the drop shows the thumbnail and leaves the page as it was. --- packages/core/src/liftFromTrigger/README.md | 12 +- .../__tests__/liftFromTrigger.fixture.ts | 22 ++- ...iftFromTrigger.interaction.browser.spec.ts | 156 +++++++++++++++++- 3 files changed, 180 insertions(+), 10 deletions(-) diff --git a/packages/core/src/liftFromTrigger/README.md b/packages/core/src/liftFromTrigger/README.md index ae013e6432886..57d07ef027d01 100644 --- a/packages/core/src/liftFromTrigger/README.md +++ b/packages/core/src/liftFromTrigger/README.md @@ -62,9 +62,9 @@ document listeners end with it, and an unmount ends everything at once ## Files -| File | What it is | -| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| `liftFromTrigger.ts` | `createLiftFromTrigger`: lift a trigger, grow and close a surface beside it, drop, end. | -| `useLiftFromTrigger.ts` | A component's one lift, ended when it unmounts. | -| `liftFromTriggerGeometry.ts` | How far the trigger grows, where it lands inside the layer runtime's gutter, which side the surface takes. | -| `__tests__/liftFromTrigger.interaction.browser.spec.ts` | Browser proofs (Chromium and WebKit): the top layer, the placeholder, the properties that move, counts, leaks. | +| File | What it is | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `liftFromTrigger.ts` | `createLiftFromTrigger`: lift a trigger, grow and close a surface beside it, drop, end. `liftCopy`: the zoom's copy. | +| `useLiftFromTrigger.ts` | A component's one lift, ended when it unmounts. | +| `liftFromTriggerGeometry.ts` | How far the trigger grows, where it lands inside the layer runtime's gutter, which side the surface takes. | +| `__tests__/liftFromTrigger.interaction.browser.spec.ts` | Browser proofs (Chromium and WebKit): the top layer, the placeholder, the properties that move, counts, leaks, the zoom's copy. | diff --git a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.fixture.ts b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.fixture.ts index 38bf875ef0681..9528151553a35 100644 --- a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.fixture.ts +++ b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.fixture.ts @@ -7,12 +7,16 @@ * (the first row rounded by `:first-child`, the second holding twelve * elements), a menu popover beside it, and `window.__lift`: lift a row, * grow and close the menu, drop, end, and the backdrop taps and the - * clicks the page received + * clicks the page received; and a thumbnail, a hidden viewer with its + * photo, and `window.__zoom` (liftCopy and the rect morph) for the zoom's + * copy * @position Browser fixture of liftFromTrigger.interaction.browser.spec.ts; test-only */ +import {createRectMorph, lerpPath} from '../../motion/rectMorph'; import { createLiftFromTrigger, + liftCopy, type LiftFromTriggerOptions, type LiftFromTriggerResult, } from '../liftFromTrigger'; @@ -35,9 +39,17 @@ export interface LiftFixture { isLifted: () => boolean; } +/** What a zoom proof drives: the copy, and the rect morph that moves it. */ +export interface ZoomFixture { + liftCopy: typeof liftCopy; + createRectMorph: typeof createRectMorph; + lerpPath: typeof lerpPath; +} + declare global { interface Window { __lift: LiftFixture; + __zoom: ZoomFixture; } } @@ -67,7 +79,11 @@ document.body.innerHTML = `
- `; + + + `; function element(id: string): HTMLElement { const found = document.getElementById(id); @@ -112,6 +128,8 @@ function options(response: number): LiftFromTriggerOptions { }; } +window.__zoom = {liftCopy, createRectMorph, lerpPath}; + window.__lift = { log, lift: (id, springResponse = 0.3) => diff --git a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts index c307c536d1366..22bae5939672b 100644 --- a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts +++ b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts @@ -4,7 +4,8 @@ * @file liftFromTrigger.interaction.browser.spec.ts * @input The lift bundled with esbuild onto a blank fixture page * (liftFromTrigger.fixture.ts): a list of rows in a clipping, transformed panel and a - * menu popover; Chromium and WebKit + * menu popover, a thumbnail and a viewer with its photo; Chromium and + * WebKit * @output Proof in real engines that a row lifts itself into the top layer * at its lifted rect while one placeholder holds its spot (nothing else * moves, and the row keeps its look); that every motion the lift plays is @@ -17,7 +18,11 @@ * the drop * (AST-017 FR60); that repeated lifts leave no node or listener behind; * that a tap while it grows reaches nothing (FR35) and one on the backdrop - * afterwards is the backdrop tap + * afterwards is the backdrop tap; that the zoom's copy lifts into the + * viewer at the thumbnail's box (one element more, stacked by the viewer, + * the thumbnail hidden in place), grows on the rect morph, turns around + * from the rect on screen as the same element and lands in the thumbnail, + * and that its drop leaves the page as it was * @position Browser proof for liftFromTrigger.ts, run by `pnpm test:interaction`; jsdom * has no layout, no top layer and no animations */ @@ -613,3 +618,150 @@ test('a tap while the row and the menu grow reaches nothing; one on the backdrop expect(settled.clicks).not.toContain('page'); await page.evaluate(() => window.__lift.dispose()); }); + +test("a zoom's copy lifts into the viewer at the thumbnail's box, where the viewer's stacking puts it under the photo, while the thumbnail hides in place; one element more", async ({ + page, +}) => { + await open(page); + const read = await page.evaluate(() => { + const box = (element: Element | null): Box => { + const r = element?.getBoundingClientRect(); + return r == null ? [0, 0, 0, 0] : [r.x, r.y, r.width, r.height]; + }; + const viewer = document.getElementById('viewer') as HTMLElement; + const thumb = document.getElementById('thumb') as HTMLElement; + viewer.style.display = 'block'; + const count = () => document.body.querySelectorAll('*').length; + const rest = count(); + const thumbBefore = box(thumb); + const copy = window.__zoom.liftCopy(thumb, {host: viewer}); + if (copy == null) { + throw new Error('no copy'); + } + // Under the photo: the viewer's stacking, as Lightbox sets it. + copy.element.style.zIndex = '-1'; + const result = { + added: count() - rest, + copy: box(copy.element), + thumb: box(thumb), + thumbBefore, + thumbVisibility: getComputedStyle(thumb).visibility, + parentIsViewer: copy.element.parentElement === viewer, + inTopLayer: + copy.element.hasAttribute('popover') || + copy.element.matches(':popover-open'), + radius: getComputedStyle(copy.element).borderTopLeftRadius, + fit: getComputedStyle(copy.element).objectFit, + hidden: copy.element.getAttribute('aria-hidden'), + }; + copy.drop(); + return { + ...result, + afterAdded: count() - rest, + afterVisibility: getComputedStyle(thumb).visibility, + }; + }); + expect(read.added).toBe(1); + expectBox(read.copy, read.thumbBefore); + // The thumbnail keeps its layout: nothing moves in the page. + expectBox(read.thumb, read.thumbBefore); + expect(read.thumbVisibility).toBe('hidden'); + expect(read.parentIsViewer).toBe(true); + expect(read.inTopLayer).toBe(false); + expect(read.radius).toBe('10px'); + expect(read.fit).toBe('cover'); + expect(read.hidden).toBe('true'); + expect(read.afterAdded).toBe(0); + expect(read.afterVisibility).toBe('visible'); +}); + +test('the copy grows to the photo on the rect morph, a close mid-growth turns the same copy around from the rect on screen with no jump, and it lands in the thumbnail; the drop leaves the page as it was', async ({ + page, +}) => { + await open(page); + const run = await page.evaluate(async () => { + const viewer = document.getElementById('viewer') as HTMLElement; + const thumb = document.getElementById('thumb') as HTMLElement; + const photo = document.getElementById('photo') as HTMLElement; + viewer.style.display = 'block'; + const count = () => document.body.querySelectorAll('*').length; + const rest = count(); + const copy = window.__zoom.liftCopy(thumb, {host: viewer}); + if (copy == null) { + throw new Error('no copy'); + } + const element = copy.element; + const r = photo.getBoundingClientRect(); + const fitted = {x: r.x, y: r.y, width: r.width, height: r.height}; + const morph = window.__zoom.createRectMorph(element); + const path = window.__zoom.lerpPath(copy.box, fitted, { + box: copy.box, + scale: 'uniform', + }); + const spring = {response: 0.6, dampingRatio: 1}; + // One sample a frame of where the copy is drawn, and whether it is still + // the same element in the host. + const samples: {t: number; width: number; phase: string}[] = []; + let phase = 'open'; + let sampling = true; + let remounted = false; + // The close comes once the copy is well on its way (150 px wide), however + // fast the engine draws frames. + let midway = () => {}; + const isMidway = new Promise(resolve => { + midway = resolve; + setTimeout(resolve, 2000); + }); + const sample = (t: number) => { + remounted ||= !element.isConnected || element.parentElement !== viewer; + const width = element.getBoundingClientRect().width; + samples.push({t, width, phase}); + if (width >= 150) { + midway(); + } + if (sampling) { + requestAnimationFrame(sample); + } + }; + requestAnimationFrame(sample); + morph.present(path, spring); + await isMidway; + // The close turns the opening around: the same copy, from where it is. + phase = 'close'; + const closing = morph.dismiss(path, spring, {target: element}); + await closing.finished; + sampling = false; + const landed = element.getBoundingClientRect(); + const thumbBox = thumb.getBoundingClientRect(); + morph.dispose(); + copy.drop(); + return { + samples, + remounted, + landed: [landed.x, landed.y, landed.width, landed.height] as Box, + thumb: [thumbBox.x, thumbBox.y, thumbBox.width, thumbBox.height] as Box, + afterAdded: count() - rest, + afterVisibility: getComputedStyle(thumb).visibility, + photoWidth: fitted.width, + }; + }); + expect(run.remounted).toBe(false); + const opening = run.samples.filter(s => s.phase === 'open'); + const closing = run.samples.filter(s => s.phase === 'close'); + expect(opening.length).toBeGreaterThan(1); + expect(closing.length).toBeGreaterThan(1); + // It was growing toward the photo when the close came. + const lastOpen = opening[opening.length - 1].width; + expect(lastOpen).toBeGreaterThanOrEqual(150); + expect(lastOpen).toBeLessThan(run.photoWidth); + // The close starts where the opening was drawn: its first frame is nearer + // that than either end, so no frame jumps back to the thumbnail or on to + // the photo. + expect(Math.abs(closing[0].width - lastOpen)).toBeLessThan( + Math.min(lastOpen - 80, run.photoWidth - lastOpen), + ); + // It lands exactly in the thumbnail. + expectBox(run.landed, run.thumb, 1); + expect(run.afterAdded).toBe(0); + expect(run.afterVisibility).toBe('visible'); +}); From 5ef5f3699cbeb14b78801967f89071d315c9692d Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 27 Oct 2026 00:42:49 -0700 Subject: [PATCH 047/118] docs(layer-runtime): the lift-from-trigger module exists The gaps paragraph said no lift module exists. With the module in packages/core/src/liftFromTrigger/, it names it under INV15 and keeps the gap that remains: no runtime backdrop tint. --- docs/architecture/layer-runtime.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/architecture/layer-runtime.md b/docs/architecture/layer-runtime.md index 6b215ab97950d..ef6b0f94ae461 100644 --- a/docs/architecture/layer-runtime.md +++ b/docs/architecture/layer-runtime.md @@ -663,7 +663,8 @@ be updated only as that work ships. INV14 and INV15 are not met yet, and `spec:AST-003` does not cover them. The runtime owns the shared exit copy (INV13, `placeLayerExitCopy`) and the shared presentation hold (`Layer/presentationHold.ts`), which Popover uses for its -sheet and anchored presentations; no runtime backdrop tint or lift module +sheet and anchored presentations, and the one lift-from-trigger module exists +(INV15, `packages/core/src/liftFromTrigger/`); no runtime backdrop tint exists, and Dialog, Drawer, BottomSheet, Lightbox, MobileNav, ToastViewport, and Selector each build their own top-layer mounting, exit, backdrop, or presentation hold. From 1d704a2b16a1e768c026a241aefa5ce0a32a0d0d Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 19:17:35 -0800 Subject: [PATCH 048/118] fix(liftFromTrigger): each spring reaches the rect morph as a motion The rect morph takes a motion, `{spring}` or a duration with a curve, where it took a bare spring. The lift wraps each of its four springs, and the zoom proof its one. --- .../liftFromTrigger.interaction.browser.spec.ts | 4 ++-- .../core/src/liftFromTrigger/liftFromTrigger.ts | 14 ++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts index 22bae5939672b..066ac5cf6a1c5 100644 --- a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts +++ b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts @@ -724,11 +724,11 @@ test('the copy grows to the photo on the rect morph, a close mid-growth turns th } }; requestAnimationFrame(sample); - morph.present(path, spring); + morph.present(path, {spring}); await isMidway; // The close turns the opening around: the same copy, from where it is. phase = 'close'; - const closing = morph.dismiss(path, spring, {target: element}); + const closing = morph.dismiss(path, {spring}, {target: element}); await closing.finished; sampling = false; const landed = element.getBoundingClientRect(); diff --git a/packages/core/src/liftFromTrigger/liftFromTrigger.ts b/packages/core/src/liftFromTrigger/liftFromTrigger.ts index 8956352117673..00781e12eb244 100644 --- a/packages/core/src/liftFromTrigger/liftFromTrigger.ts +++ b/packages/core/src/liftFromTrigger/liftFromTrigger.ts @@ -979,7 +979,7 @@ export function createLiftFromTrigger(): LiftFromTrigger { holdClock(performance.now()); const growing = morph.present( lerpPath(from, lifted, {box: sourceRect, scale: 'uniform'}), - motion.lift, + {spring: motion.lift}, {crossfadeAs: 'entrance'}, ); holdClock(null); @@ -1162,9 +1162,11 @@ export function createLiftFromTrigger(): LiftFromTrigger { }), ); const morph = createRectMorph(element); - const presenting = morph.present(path, current.motion.surfaceIn, { - crossfadeAs: 'entrance', - }); + const presenting = morph.present( + path, + {spring: current.motion.surfaceIn}, + {crossfadeAs: 'entrance'}, + ); void presenting.finished.then(surfaceSettled, surfaceSettled); current.surface = { element, @@ -1237,7 +1239,7 @@ export function createLiftFromTrigger(): LiftFromTrigger { } surface.dismissing = surface.morph.dismiss( surface.path, - current.motion.surfaceOut, + {spring: current.motion.surfaceOut}, {target: copy, crossfadeAs: 'exit'}, ); }; @@ -1293,7 +1295,7 @@ export function createLiftFromTrigger(): LiftFromTrigger { ); const returning = current.morph.dismiss( lerpPath(sourceRect, lifted, {box: sourceRect, scale: 'uniform'}), - motion.drop, + {spring: motion.drop}, {target: source, crossfadeAs: 'exit'}, ); current.holdClock(null); From 23bf224984c7c56fa1046683841803c0cabacc22 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 19:17:35 -0800 Subject: [PATCH 049/118] refactor(liftFromTrigger): the caller names the lift's marks A private data attribute is written only in its owner module, so the lift writes no name of its own. A lift takes the attribute its caller owns for the layer and its parts, and a zoom's copy is marked only when its caller passes one. --- .../liftFromTrigger/liftFromTrigger.test.ts | 2 -- .../src/liftFromTrigger/liftFromTrigger.ts | 18 ++++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/core/src/liftFromTrigger/liftFromTrigger.test.ts b/packages/core/src/liftFromTrigger/liftFromTrigger.test.ts index b23a1f8b39078..0837ee9fe59d0 100644 --- a/packages/core/src/liftFromTrigger/liftFromTrigger.test.ts +++ b/packages/core/src/liftFromTrigger/liftFromTrigger.test.ts @@ -451,7 +451,6 @@ describe('liftCopy', () => { expect(element.src).toBe('https://example.test/a-thumb.jpg'); expect(element.alt).toBe(''); expect(element.getAttribute('aria-hidden')).toBe('true'); - expect(element.hasAttribute('data-astryx-lift-copy')).toBe(true); expect([ element.style.position, element.style.left, @@ -526,7 +525,6 @@ describe('liftCopy', () => { const {host, thumb} = renderGrid(); const copy = liftCopy(thumb, {host, attribute: 'data-lightbox-portal'}); expect(copy?.element.hasAttribute('data-lightbox-portal')).toBe(true); - expect(copy?.element.hasAttribute('data-astryx-lift-copy')).toBe(false); copy?.drop(); }); diff --git a/packages/core/src/liftFromTrigger/liftFromTrigger.ts b/packages/core/src/liftFromTrigger/liftFromTrigger.ts index 00781e12eb244..2c6d15bb726e9 100644 --- a/packages/core/src/liftFromTrigger/liftFromTrigger.ts +++ b/packages/core/src/liftFromTrigger/liftFromTrigger.ts @@ -156,11 +156,11 @@ export interface LiftFromTriggerOptions { /** A finger lifted on the backdrop where it went down. */ readonly onBackdropTap?: () => void; /** - * Marks the lift's elements: the layer, its placeholder - * (`-placeholder`) and a surface's exit copy - * (`-exit`). + * Marks the lift's elements, a private attribute the caller owns: the + * layer, its placeholder (`-placeholder`) and a surface's exit + * copy (`-exit`). */ - readonly attribute?: string; + readonly attribute: string; } /** Where a lift landed. */ @@ -221,7 +221,7 @@ export interface LiftCopyOptions { readonly host: HTMLElement; /** The picture for a source that shows none. */ readonly src?: string; - /** Marks the copy. @default 'data-astryx-lift-copy' */ + /** Marks the copy, when given: a private attribute the caller owns. */ readonly attribute?: string; } @@ -816,7 +816,7 @@ export function createLiftFromTrigger(): LiftFromTrigger { if (typeof source.showPopover !== 'function' || !source.isConnected) { return null; } - const attribute = options.attribute ?? 'data-astryx-lift'; + const {attribute} = options; const doc = source.ownerDocument; const view = doc.defaultView; const computed = view?.getComputedStyle(source); @@ -1411,7 +1411,7 @@ function pictureOf(source: HTMLElement): string { */ export function liftCopy( source: HTMLElement, - {host, src, attribute = 'data-astryx-lift-copy'}: LiftCopyOptions, + {host, src, attribute}: LiftCopyOptions, ): LiftedCopy | null { if (!source.isConnected || !host.isConnected) { return null; @@ -1438,7 +1438,9 @@ export function liftCopy( copy.alt = ''; copy.setAttribute('aria-hidden', 'true'); copy.setAttribute('inert', ''); - copy.setAttribute(attribute, ''); + if (attribute != null) { + copy.setAttribute(attribute, ''); + } Object.assign(copy.style, { position: 'absolute', left: `${left}px`, From fe3b5c27b229f3eab1c95d70019beb1de1c06499 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 19:17:35 -0800 Subject: [PATCH 050/118] docs(liftFromTrigger): comments say why, not which record The header keeps to its inputs, outputs and place, and the README holds what a lift adds; it gains the placeholder's list rule and the lifted source taking no hits. Inline comments drop their record citations and keep the reason. --- packages/core/src/liftFromTrigger/README.md | 5 +- .../src/liftFromTrigger/liftFromTrigger.ts | 91 ++++--------------- .../liftFromTriggerGeometry.ts | 21 ++--- .../src/liftFromTrigger/useLiftFromTrigger.ts | 3 +- 4 files changed, 29 insertions(+), 91 deletions(-) diff --git a/packages/core/src/liftFromTrigger/README.md b/packages/core/src/liftFromTrigger/README.md index 57d07ef027d01..1099323b95e87 100644 --- a/packages/core/src/liftFromTrigger/README.md +++ b/packages/core/src/liftFromTrigger/README.md @@ -21,7 +21,10 @@ rule and gap are the caller's own. The trigger itself lifts, not a copy: it becomes a manual popover in the top layer, laid out at its own size where it stood, so no clipping, transformed or stacking ancestor cuts or covers it, and an empty placeholder after it -holds its spot, so the page does not move. While lifted the page has four +holds its spot, so the page does not move. In a list the placeholder is an +`li` that is not a list item, so the list stays valid and keeps its numbers. +The trigger takes no hits while lifted: a tap on it is a tap on the backdrop. +While lifted the page has four elements more, whatever the trigger holds: the lift layer (its `::backdrop` is the tint); the shadow, a box in the layer that follows the trigger on a rect morph of its own (an animated `::backdrop` of the trigger would restyle diff --git a/packages/core/src/liftFromTrigger/liftFromTrigger.ts b/packages/core/src/liftFromTrigger/liftFromTrigger.ts index 2c6d15bb726e9..54fa627f5547a 100644 --- a/packages/core/src/liftFromTrigger/liftFromTrigger.ts +++ b/packages/core/src/liftFromTrigger/liftFromTrigger.ts @@ -4,70 +4,14 @@ /** * @file liftFromTrigger.ts - * @input An element to lift (the source) and, beside it, a surface that - * grows out of the press (a menu's popover); the caller's springs, scale - * rule and tint; the rect morph - * (motion/rectMorph.ts); the layer runtime's input hold, exit copy, - * viewport gutter and declared inset - * @output Exports createLiftFromTrigger: lift a source out of the page above a tinted - * backdrop, grow a surface out of the press beside it, shrink the surface - * back into the press, and return the source to its place; and liftCopy: - * a copy of a source's picture in a viewer's host, for a zoom + * @input An element to lift (the source), a surface that grows out of the + * press beside it, the caller's springs, scale rule, tint and attribute, + * the rect morph, and the layer runtime's input hold, exit copy and inset + * @output createLiftFromTrigger, which lifts a source above a tinted + * backdrop, grows and shrinks a surface at the press, and puts the source + * back; and liftCopy, a copy of a source's picture for a zoom * @position The one lift-from-trigger presentation, internal to core and - * opt-in: only a surface that chooses it uses it (ContextMenu's touch - * invocation; a viewer's zoom from a thumbnail). Layer stays generic: the - * top layer, dismissal, the input hold and the shared exit copy are its; - * this module only uses them - * - * UIKit's context menu lifts the long-pressed view out of the page: it grows - * on from the size the press gave it to its lifted size, a shadow rising - * under it, above a backdrop that tints the page; the menu grows out of the - * finger beside it, and when the menu closes it shrinks back into the finger - * while the view returns to its place and the backdrop clears. - * - * Here the source itself lifts, not a copy: it becomes a manual popover in - * the top layer, laid out at its own size where it stood, so no clipping, - * transformed or stacking ancestor cuts or covers it, while one empty - * placeholder holds its spot in the page, which does not move. What the - * browser's popover style would change (border, padding, colors, overflow) is - * kept as it was laid out, and the source takes no hits while lifted (a tap - * on it is a tap on the backdrop). Inheritance follows the DOM, so the page's - * theme still reaches it. The placeholder goes after the source, so the - * source keeps its place among its siblings; in a list it is an `li` that - * is not a list item, so the list stays valid and keeps its numbers. The tint is the `::backdrop` of - * a lift layer at the end of the body that covers the viewport and takes the - * page's touches (its color and the gutter's spacing resolved where the - * source is); the shadow is a box in the layer that follows the source on a - * rect morph of its own (an animated `::backdrop` of the source would - * restyle and lay out the page on every frame). The layer also holds an empty box - * at the lifted rect for a surface to be placed against: anchor positioning - * follows transforms, so a box inside the source would move with it. Nothing - * of the lift is in the DOM at rest; while lifted it adds four elements, the - * layer, the shadow, the box and the placeholder - * (`architecture:component-style-authoring` INV7, `spec:AST-017` FR60), and - * its close plays on the layer runtime's one exit copy - * (`placeLayerExitCopy`). - * - * A zoom from a thumbnail into a viewer lifts a copy instead - * (`architecture:layer-runtime` INV15's clone): the copy has to show between - * the viewer's backdrop and its photo, which a source in the top layer cannot - * (it would cover the whole viewer, or sit under its backdrop). liftCopy puts - * one copy of the source's picture in the viewer's host at the source's box, - * inert and hidden from assistive technology, and hides the source where it - * stands (it keeps its layout, so no placeholder); the viewer moves the copy - * on the rect morph, which turns an interrupted motion around from the rect - * on screen, and a drop shows the source again and takes the copy away. - * - * Every motion is `translate`, `scale` or `opacity` on the compositor - * (spec:AST-094 FR5): the source and the surface on the rect morph, which - * interrupts itself with the velocity on screen (FR6), the shadow on one of - * its own along the same path; the tint as a spring-sampled Web Animation of - * the layer's `::backdrop`. A tap or - * a drag that lands while the lift and the surface grow reaches nothing - * (the input hold, FR35); the close holds the layer until the source - * is back, the surface's exit is done and every press on the layer has - * lifted. Its timers and document listeners end with it (FR24). Reduce - * Motion is the animator's (design:reduced-motion): opacity only. + * opt-in. README.md says what a lift adds to the page and how it moves * * SYNC: When modified, update: * - /packages/core/src/liftFromTrigger/liftFromTrigger.test.ts @@ -192,7 +136,8 @@ export interface LiftFromTrigger { * Grows `surface` (a popover laid out at rest, or an element inside one) * out of `press` (client px) into its box, or, without a press, fades it * in where it rests; and holds the lift until the lift and the surface - * settle (spec:AST-094 FR35). A no-op when nothing is lifted. + * settle, so a tap during the growth reaches nothing. A no-op when nothing + * is lifted. */ presentSurface( surface: HTMLElement, @@ -282,7 +227,7 @@ interface LiftState { * shadow reads the motion at the very moment the morph does. */ readonly holdClock: (at: number | null) => void; - /** The layer's own waits and listeners, released with it (FR24). */ + /** The layer's own waits and listeners, released with it. */ readonly waits: AbortController; untilPointersLift: () => Promise; tint: Animation | null; @@ -851,8 +796,8 @@ export function createLiftFromTrigger(): LiftFromTrigger { const radius = shapeRadius(source); const motion = resolvedMotion(source, options.motion); const reduced = reducedMotionFade(source); - // The tint, the shadow and the layer runtime's gutter (spec:AST-059, - // with the inset an app declares), resolved where the source is. + // The tint, the shadow and the layer runtime's gutter, with the inset an + // app declares, resolved where the source is. const probed = probe(source, { ...Object.fromEntries( Object.entries(layerInsetProperties(options.inset)).map( @@ -1082,8 +1027,7 @@ export function createLiftFromTrigger(): LiftFromTrigger { ); current.untilPointersLift = watchPointers(current); // No touch reaches the layer, or anything under it, while the source - // grows (spec:AST-094 FR35); a surface extends the hold to its own - // growth. + // grows; a surface extends the hold to its own growth. current.releaseGrowHold = holdInput(layer, { scope: 'viewport', until: growing.finished, @@ -1091,8 +1035,8 @@ export function createLiftFromTrigger(): LiftFromTrigger { return {source: sourceRect, lifted, viewport, anchor}; }; - // The lift layer takes no touch until the lift and the surface settle - // (spec:AST-094 FR35). The hold's shield joins the top layer after the + // The lift layer takes no touch until the lift and the surface settle. + // The hold's shield joins the top layer after the // surface, so it covers it too; keys and assistive technology stay live. const holdWhileGrowing = ( current: LiftState, @@ -1402,9 +1346,8 @@ function pictureOf(source: HTMLElement): string { } /** - * Lifts a copy of `source`'s picture into `host` for a zoom - * (`architecture:layer-runtime` INV15's clone): one image laid out at the - * source's box (aspect fill, its corners), inert and hidden from assistive + * Lifts a copy of `source`'s picture into `host` for a zoom, the lift's one + * transient copy: one image laid out at the source's box (aspect fill, its corners), inert and hidden from assistive * technology, while the source hides where it stands. The caller moves the * copy on the rect morph and drops it at the landing. Null, with nothing * changed, for a source that is not in the page or has no box. diff --git a/packages/core/src/liftFromTrigger/liftFromTriggerGeometry.ts b/packages/core/src/liftFromTrigger/liftFromTriggerGeometry.ts index a2c603b7c18c8..5bf3f7129f0e4 100644 --- a/packages/core/src/liftFromTrigger/liftFromTriggerGeometry.ts +++ b/packages/core/src/liftFromTrigger/liftFromTriggerGeometry.ts @@ -4,19 +4,12 @@ * @file liftFromTriggerGeometry.ts * @input A source's box, the viewport and the layer runtime's gutter, and * the caller's scale rule and gap - * @output Exports liftFromTriggerScale, liftFromTriggerRect and surfacePlacement: how far a - * lifted source grows, where it lands inside the gutter, and which side of - * it a surface (a menu) takes - * @position The geometry of the lift presentation (liftFromTrigger.ts); internal to - * core - * - * A lifted source grows about its center by the caller's rule (UIKit's - * preview platter: up to a factor, by a number of points on its larger - * side, and never wider than the screen less the gutter), then moves inside - * the layer runtime's viewport gutter (spec:AST-059) if it crosses it. A - * surface sits a gap away: below the source when it fits above the bottom - * gutter, else above; centered on the source, or flush with the edge the - * source is held against. + * @output liftFromTriggerScale, liftFromTriggerRect and surfacePlacement: + * how far a lifted source grows, where it lands inside the gutter, and + * which side of it a surface (a menu) takes + * @position The geometry of the lift presentation (liftFromTrigger.ts); + * internal to core. A source grows about its center by the caller's rule, + * as UIKit's preview platter does, then moves inside the gutter * * SYNC: When modified, update /packages/core/src/liftFromTrigger/liftFromTriggerGeometry.test.ts */ @@ -25,7 +18,7 @@ import type {MorphRect} from '../motion/rectMorph'; /** * The layer runtime's viewport gutter on each edge, CSS px - * (Layer/layerViewportInset.stylex.ts, spec:AST-059): the spacing step or the + * (Layer/layerViewportInset.stylex.ts): the spacing step or the * edge's safe-area inset, whichever is larger, plus the inset an app declares * for a bar floating over that edge. */ diff --git a/packages/core/src/liftFromTrigger/useLiftFromTrigger.ts b/packages/core/src/liftFromTrigger/useLiftFromTrigger.ts index f69d3a40a2b0f..ad3b1a4ba315f 100644 --- a/packages/core/src/liftFromTrigger/useLiftFromTrigger.ts +++ b/packages/core/src/liftFromTrigger/useLiftFromTrigger.ts @@ -18,8 +18,7 @@ import {createLiftFromTrigger, type LiftFromTrigger} from './liftFromTrigger'; /** * The component's lift. It stays the same object for the component's life; - * an unmount ends whatever it has up and leaves the page as it was - * (spec:AST-094 FR24). + * an unmount ends whatever it has up and leaves the page as it was. */ export function useLiftFromTrigger(): LiftFromTrigger { const liftRef = useRef(null); From 6a58c1101e2117cdf3a4ddc0f569e268837bfc92 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 19:57:02 -0800 Subject: [PATCH 051/118] test(liftFromTrigger): a finger taps the growing lift, from the harness The layer runtime's input hold judges each event by its own pointer, so a mouse keeps its clicks during a presentation. The lift is a touch presentation, so its hold proof taps with a finger, before and after the growth settles. The proofs import Playwright through the interaction harness, as the lint rule asks. --- .../__tests__/liftFromTrigger.interaction.browser.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts index 066ac5cf6a1c5..b87ce5868a17a 100644 --- a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts +++ b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts @@ -29,7 +29,7 @@ import path from 'node:path'; import {fileURLToPath} from 'node:url'; -import {expect, test, type Page} from '@playwright/test'; +import {expect, test, type Page} from '@astryxdesign/a11y-spec/playwright'; import {build, type Plugin} from 'esbuild'; // The page's `window.__lift`. import type {LiftFixture} from './liftFromTrigger.fixture'; @@ -607,12 +607,12 @@ test('a tap while the row and the menu grow reaches nothing; one on the backdrop window.__lift.lift('row2', 0.8); window.__lift.presentMenu({x: 180, y: 330}); }); - await page.mouse.click(180, 700); + await page.touchscreen.tap(180, 700); const growing = await page.evaluate(() => ({...window.__lift.log})); expect(growing.backdropTaps).toBe(0); expect(growing.clicks).not.toContain('page'); await page.waitForTimeout(2000); - await page.mouse.click(180, 700); + await page.touchscreen.tap(180, 700); const settled = await page.evaluate(() => ({...window.__lift.log})); expect(settled.backdropTaps).toBe(1); expect(settled.clicks).not.toContain('page'); From 8e4f4c5121d24ef7511cb3b8084632654c1d90d6 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 20:16:13 -0800 Subject: [PATCH 052/118] docs(liftFromTrigger): comment sentences keep to 25 words Each comment sentence in the module, its tests and its fixture keeps to 25 words, one topic each. A long sentence is split, or cut where the README says it already. The header keeps to 12 lines. No code changes. --- .../__tests__/liftFromTrigger.fixture.ts | 12 +- ...iftFromTrigger.interaction.browser.spec.ts | 47 ++--- .../liftFromTrigger/liftFromTrigger.test.ts | 19 +- .../src/liftFromTrigger/liftFromTrigger.ts | 166 +++++++++--------- .../liftFromTriggerGeometry.test.ts | 6 +- .../liftFromTriggerGeometry.ts | 23 ++- 6 files changed, 126 insertions(+), 147 deletions(-) diff --git a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.fixture.ts b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.fixture.ts index 9528151553a35..8a30b6ec656fc 100644 --- a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.fixture.ts +++ b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.fixture.ts @@ -3,13 +3,11 @@ /** * @file liftFromTrigger.fixture.ts * @input Nothing: bundled by esbuild and injected into a blank page - * @output A page with a list of rows inside a clipping, transformed panel - * (the first row rounded by `:first-child`, the second holding twelve - * elements), a menu popover beside it, and `window.__lift`: lift a row, - * grow and close the menu, drop, end, and the backdrop taps and the - * clicks the page received; and a thumbnail, a hidden viewer with its - * photo, and `window.__zoom` (liftCopy and the rect morph) for the zoom's - * copy + * @output A page with a list of rows in a clipping, transformed panel, and a + * menu popover beside it. The first row is rounded by `:first-child`, and + * the second holds twelve elements. `window.__lift` lifts a row, grows and + * closes the menu, drops and ends, and logs taps and clicks. A thumbnail + * and a hidden viewer serve the zoom's copy through `window.__zoom` * @position Browser fixture of liftFromTrigger.interaction.browser.spec.ts; test-only */ diff --git a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts index b87ce5868a17a..ef993b7400ed1 100644 --- a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts +++ b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts @@ -3,26 +3,14 @@ /** * @file liftFromTrigger.interaction.browser.spec.ts * @input The lift bundled with esbuild onto a blank fixture page - * (liftFromTrigger.fixture.ts): a list of rows in a clipping, transformed panel and a - * menu popover, a thumbnail and a viewer with its photo; Chromium and - * WebKit - * @output Proof in real engines that a row lifts itself into the top layer - * at its lifted rect while one placeholder holds its spot (nothing else - * moves, and the row keeps its look); that every motion the lift plays is - * `translate`, `scale`, `transform` or `opacity` (spec:AST-094 FR5); that - * the menu grows out of the press and keeps nothing inline at rest, and - * its close shrinks the layer runtime's exit copy back into it, or, with - * no press, fades in where it rests; that the - * page has the layer, the shadow, the anchor and the placeholder more while - * lifted, whatever the row holds, at most 5 at any moment, and none after - * the drop - * (AST-017 FR60); that repeated lifts leave no node or listener behind; - * that a tap while it grows reaches nothing (FR35) and one on the backdrop - * afterwards is the backdrop tap; that the zoom's copy lifts into the - * viewer at the thumbnail's box (one element more, stacked by the viewer, - * the thumbnail hidden in place), grows on the rect morph, turns around - * from the rect on screen as the same element and lands in the thumbnail, - * and that its drop leaves the page as it was + * (liftFromTrigger.fixture.ts), in Chromium and WebKit + * @output Proof in real engines of the lift and the zoom's copy. A row lifts + * into the top layer while a placeholder holds its spot. Every motion is + * `translate`, `scale`, `transform` or `opacity`. The menu grows out of the + * press and shrinks back into it on close. The page has at most five more + * elements while lifted, and none after the drop. A tap while it grows + * reaches nothing. The zoom's copy grows on the rect morph, turns around + * and lands in the thumbnail * @position Browser proof for liftFromTrigger.ts, run by `pnpm test:interaction`; jsdom * has no layout, no top layer and no animations */ @@ -197,8 +185,8 @@ test('a lifted item of an ordered list leaves the numbers of the items after it page, }) => { await open(page); - // An ordered list with its markers outside, and no tint and no shadow: a - // later item's marker paints the same while an item is lifted unless its + // An ordered list with its markers outside, and no tint and no shadow. A + // later item's marker paints the same while an item is lifted, unless its // number changes. const clip = await page.evaluate(() => { const root = document.documentElement.style; @@ -503,8 +491,8 @@ test('while lifted the page has the layer, the shadow, the anchor and the placeh }) => { await open(page); const counts = await page.evaluate(async () => { - // The layer runtime's exit copy is the transient copy INV7 allows (made - // by the layer runtime, gone within a second): not one of the lift's. + // The layer runtime's exit copy is not one of the lift's elements: the + // runtime makes it, and it is gone within a second. const count = () => [...document.body.querySelectorAll('*')].filter( element => element.closest('[data-lift-exit]') == null, @@ -552,9 +540,9 @@ test('while lifted the page has the layer, the shadow, the anchor and the placeh lifted: window.__lift.isLifted(), }; }); - // A row of 13 elements and a row of 1: the same 4 more once grown (the - // layer, the shadow, the anchor and the placeholder), and the hold's - // shield while the lift grows. + // A row of 13 elements and a row of 1 add the same 4 once grown: the layer, + // the shadow, the anchor and the placeholder. The hold's shield joins while + // the lift grows. expect(counts.deep).toBe(5); expect(counts.shallow).toBe(5); expect(counts.open).toBe(4); @@ -754,9 +742,8 @@ test('the copy grows to the photo on the rect morph, a close mid-growth turns th const lastOpen = opening[opening.length - 1].width; expect(lastOpen).toBeGreaterThanOrEqual(150); expect(lastOpen).toBeLessThan(run.photoWidth); - // The close starts where the opening was drawn: its first frame is nearer - // that than either end, so no frame jumps back to the thumbnail or on to - // the photo. + // The close starts where the opening was drawn. Its first frame is nearer + // that than either end, so no frame jumps to the thumbnail or the photo. expect(Math.abs(closing[0].width - lastOpen)).toBeLessThan( Math.min(lastOpen - 80, run.photoWidth - lastOpen), ); diff --git a/packages/core/src/liftFromTrigger/liftFromTrigger.test.ts b/packages/core/src/liftFromTrigger/liftFromTrigger.test.ts index 0837ee9fe59d0..d0db0379ea10b 100644 --- a/packages/core/src/liftFromTrigger/liftFromTrigger.test.ts +++ b/packages/core/src/liftFromTrigger/liftFromTrigger.test.ts @@ -4,15 +4,10 @@ * @file liftFromTrigger.test.ts * @input vitest (jsdom), the lift, stand-ins for the popover API and for * layout (jsdom has neither) and a spy on the input hold - * @output Checks the lift's bookkeeping in the page: while up it adds the - * layer and one placeholder whatever the source holds, and nothing at - * rest; the source keeps its place among its siblings; a drop or an - * unmount puts back exactly what the source had inline and takes the rest - * away; nothing of it (an element, a document listener, a timer) outlives - * it over repeated lifts; the gutter it reads; the holds it takes; taps on - * its backdrop. And the zoom's copy (liftCopy): one copy of the source's - * picture in the caller's host at the source's box while the source hides - * in place, and a drop that leaves the page as it was + * @output Checks the lift's bookkeeping in the page. While up it adds the + * layer and one placeholder, and nothing at rest. A drop or an unmount puts + * back what the source had inline. Nothing outlives repeated lifts. It also + * checks the gutter, the holds, backdrop taps and the zoom's copy * @position Testing; the motion, the top layer and the counts in real * engines are proven in __tests__/liftFromTrigger.interaction.browser.spec.ts */ @@ -415,9 +410,9 @@ describe('lift', () => { describe('liftCopy', () => { /** - * A viewer's host over the page, and a grid of thumbnails: one a button - * around a picture, one a bare picture with its own inline visibility, one - * that shows no picture. + * A viewer's host over the page, and a grid of three thumbnails. One is a + * button around a picture, one a bare picture with its own inline + * visibility, and one shows no picture. */ function renderGrid() { document.body.innerHTML = ` diff --git a/packages/core/src/liftFromTrigger/liftFromTrigger.ts b/packages/core/src/liftFromTrigger/liftFromTrigger.ts index 54fa627f5547a..a9d5787eec270 100644 --- a/packages/core/src/liftFromTrigger/liftFromTrigger.ts +++ b/packages/core/src/liftFromTrigger/liftFromTrigger.ts @@ -4,18 +4,15 @@ /** * @file liftFromTrigger.ts - * @input An element to lift (the source), a surface that grows out of the - * press beside it, the caller's springs, scale rule, tint and attribute, - * the rect morph, and the layer runtime's input hold, exit copy and inset - * @output createLiftFromTrigger, which lifts a source above a tinted - * backdrop, grows and shrinks a surface at the press, and puts the source - * back; and liftCopy, a copy of a source's picture for a zoom - * @position The one lift-from-trigger presentation, internal to core and - * opt-in. README.md says what a lift adds to the page and how it moves + * @input A source to lift, a surface to grow beside it, and the caller's + * springs, scale rule, tint and attribute + * @output createLiftFromTrigger, which lifts a source and grows a surface at + * the press; and liftCopy, a copy of a source's picture for a zoom + * @position The one lift-from-trigger presentation, internal and opt-in. + * README.md says what a lift adds to the page and how it moves * - * SYNC: When modified, update: - * - /packages/core/src/liftFromTrigger/liftFromTrigger.test.ts - * - /packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts + * SYNC: When modified, update liftFromTrigger.test.ts and + * __tests__/liftFromTrigger.interaction.browser.spec.ts. */ import type {LayerInset} from '../Layer/LayerContext'; @@ -47,9 +44,8 @@ import { } from './liftFromTriggerGeometry'; /** - * How long the layer waits, once the last pointer that went down on it has - * lifted, for that touch's click before it hides anyway (a long press sends - * none). + * How long the layer waits for a touch's click after the last pointer on it + * lifts. A long press sends no click, so the layer then hides anyway. */ const CLICK_AFTER_LIFT_MS = 350; @@ -86,9 +82,9 @@ export interface LiftFromTriggerOptions { */ readonly from?: MorphRect; /** - * Called once the lift has read the page and before it writes to it: lets - * go of what the source was doing (a press feedback's scale), so letting go - * doesn't restyle the page before the lift reads it. + * Called after the lift reads the page and before it writes to it. It lets + * go of what the source was doing, such as a press feedback's scale. The + * release then cannot restyle the page before the lift reads it. */ readonly release?: () => void; /** The tint, a CSS color resolved where the source is. */ @@ -116,9 +112,9 @@ export interface LiftFromTriggerResult { /** The viewport and the layer runtime's gutter it lifted in. */ readonly viewport: LiftFromTriggerViewport; /** - * An empty box at the lifted rect, in the layer: what a surface is placed - * against (an anchor inside the source would move with the source's - * transform, which anchor positioning follows). + * An empty box at the lifted rect, in the layer, for a surface to be + * placed against. Anchor positioning follows transforms, so an anchor + * inside the source would move with it. */ readonly anchor: HTMLElement; } @@ -133,11 +129,11 @@ export interface LiftFromTrigger { options: LiftFromTriggerOptions, ): LiftFromTriggerResult | null; /** - * Grows `surface` (a popover laid out at rest, or an element inside one) - * out of `press` (client px) into its box, or, without a press, fades it - * in where it rests; and holds the lift until the lift and the surface - * settle, so a tap during the growth reaches nothing. A no-op when nothing - * is lifted. + * Grows `surface` out of `press` (client px) into its box: a popover laid + * out at rest, or an element inside one. Without a press, it fades the + * surface in where it rests. The lift holds input until the lift and the + * surface settle, so a tap during the growth reaches nothing. A no-op when + * nothing is lifted. */ presentSurface( surface: HTMLElement, @@ -159,9 +155,9 @@ export interface LiftFromTrigger { /** Where a copy goes, and how it is marked. */ export interface LiftCopyOptions { /** - * The element the copy goes in (a viewer's container): positioned, so the - * copy is laid out against its box; its stacking decides what the copy - * shows over and under. + * The element the copy goes in, such as a viewer's container. It is + * positioned, so the copy is laid out against its box. Its stacking decides + * what the copy shows over and under. */ readonly host: HTMLElement; /** The picture for a source that shows none. */ @@ -181,9 +177,9 @@ export interface LiftedCopy { /** The source's box, client px, where the copy is laid out. */ readonly box: MorphRect; /** - * Takes the copy away and shows the source again, as it was, once the last - * copy of it drops: at the landing, or on unmount (nothing is left either - * way). A second call does nothing. + * Takes the copy away. Once the last copy of the source drops, the source + * shows again as it was. Call it at the landing or on unmount; nothing is + * left either way. A second call does nothing. */ drop(): void; } @@ -217,8 +213,8 @@ interface LiftState { /** The tint's color, held on the layer's `::backdrop`. */ readonly layerFill: Animation | null; /** - * The shadow under the source: a box in the layer laid out where the - * source is, which follows it frame for frame and fades on its own spring. + * The shadow under the source, a box in the layer laid out where the source + * is. It follows the source frame for frame and fades on its own spring. */ readonly shadow: HTMLElement; shadowAnimation: Animation | null; @@ -293,11 +289,12 @@ function springKeyframes( } /** - * Web Animation keyframes for a box laid out on `box` that travels from - * `from` to `to` on `travel` (starting with progress velocity `velocity`, as - * the rect morph does) while its opacity goes from `fade[0]` to `fade[1]` on - * `fadeSpring`, sampled at 60 Hz until both settle: one compositor animation - * of `transform` and `opacity`, frame for frame with the source's own morph. + * Web Animation keyframes for a box laid out on `box`. It travels from + * `from` to `to` on `travel`, starting at progress velocity `velocity`, as the + * rect morph does. Its opacity goes from `fade[0]` to `fade[1]` on + * `fadeSpring`. The keyframes sample both at 60 Hz until both settle. The + * result is one compositor animation of `transform` and `opacity`, frame for + * frame with the source's morph. */ function shadowKeyframes( box: MorphRect, @@ -379,10 +376,10 @@ function shapeRadius(source: HTMLElement): string { } /** - * Resolves CSS values where `element` is (its theme, its color scheme): on an - * empty, undisplayed child it removes again, so the element's own style and - * the page's layout stay as they were. A pair is a fallback and a value a - * browser may reject. + * Resolves CSS values where `element` is, in its theme and color scheme. It + * reads them on an empty, undisplayed child and removes the child again. So + * the element's own style and the page's layout stay as they were. A pair is + * a fallback and a value a browser may reject. */ function probe( element: HTMLElement, @@ -410,9 +407,9 @@ function probe( } /** - * The lift's springs as they resolve at `element` (a spring that is a motion - * role, through its role), read once: plain springs, which nothing reads - * from the page again. + * The lift's springs as they resolve at `element`, read once. A spring that + * is a motion role resolves through its role. The result is plain springs, + * and nothing reads the page for them again. */ function resolvedMotion( element: Element, @@ -521,9 +518,8 @@ function createLayer(doc: Document, attribute: string): HTMLElement { } /** - * The element that holds a source's spot: an `li` in a list, so the list - * keeps only `li` children (HTML's content model for `ul`, `ol` and `menu`), - * and a `span` elsewhere. + * The element that holds a source's spot: an `li` in a list, and a `span` + * elsewhere. HTML lets `ul`, `ol` and `menu` hold only `li` children. */ function placeholderTag(source: HTMLElement): 'li' | 'span' { const parent = source.parentElement?.localName; @@ -533,11 +529,11 @@ function placeholderTag(source: HTMLElement): 'li' | 'span' { } /** - * The placeholder that holds a source's spot in the page while it is lifted: - * empty, hidden, at its laid-out size, with its margins and its place in a - * flex or grid container. It is never a list item itself, so it takes no - * number and the items after it keep theirs, and the page's own rules for - * its tag (an `li`'s minimum height, its padding) never change its size. + * The placeholder that holds a source's spot while it is lifted. It is empty + * and hidden, at the source's laid-out size, margins and place in a flex or + * grid container. It is never a list item, so it takes no number and the + * items after it keep theirs. The page's own rules for its tag, such as an + * `li`'s minimum height or padding, never change its size. */ function placeholderStyle( computed: CSSStyleDeclaration, @@ -677,10 +673,11 @@ export function createLiftFromTrigger(): LiftFromTrigger { } }; - // Every pointer that went down on the layer and is still down (the tint - // while it presents, the hold's shield while it closes), read from the - // document so an inert target hides none; and whether the last one to lift - // still owes its click (a touch's click comes after its pointer's up). + // Every pointer still down that went down on the layer: on the tint as it + // presents, or the shield as it closes. The watch reads them from the + // document, so an inert target hides none. It also knows whether the last + // one to lift still owes its click. A touch's click comes after its + // pointer's up. const watchPointers = (current: LiftState) => { const {layer, waits} = current; const doc = layer.ownerDocument; @@ -908,9 +905,9 @@ export function createLiftFromTrigger(): LiftFromTrigger { } source.setAttribute('popover', 'manual'); const waits = new AbortController(); - // The source grows out of where the press left it into its lifted rect, - // laid out at its own size, so its content scales as UIKit's snapshot - // does. Under Reduce Motion it lands at once and fades in. + // The source grows from where the press left it into its lifted rect. It + // stays laid out at its own size, so its content scales as UIKit's + // snapshot does. Under Reduce Motion it lands at once and fades in. let heldAt: number | null = null; const holdClock = (at: number | null) => { heldAt = at; @@ -937,11 +934,11 @@ export function createLiftFromTrigger(): LiftFromTrigger { ], {duration: 0}, ); - // The shadow rises with the source: it follows the source's path on the - // lift's spring and fades in on its own, from the frame the source starts - // in, as one compositor animation (an animated `::backdrop` of the source - // would restyle and lay out the page on every frame). Under Reduce Motion - // it fades in where the source lands. + // The shadow rises with the source. It follows the source's path on the + // lift's spring and fades in on its own spring, from the source's first + // frame. It is one compositor animation: an animated `::backdrop` of the + // source would restyle and lay out the page on every frame. Under Reduce + // Motion it fades in where the source lands. const rise = playShadow( shadowEl, reduced == null @@ -995,9 +992,9 @@ export function createLiftFromTrigger(): LiftFromTrigger { isSourceLifted: true, }; state = current; - // Taps on the backdrop: only a pointer that went down on the layer lifts - // there (WebKit hands the layer the lift of the finger that opened the - // menu). + // Taps on the backdrop count only for a pointer that went down on the + // layer. WebKit hands the layer the lift of the finger that opened the + // menu. let backdropPointer: number | null = null; const listen = {signal: waits.signal}; layer.addEventListener( @@ -1200,12 +1197,13 @@ export function createLiftFromTrigger(): LiftFromTrigger { current.tint?.cancel(); current.tint = fadeBackdrop(layer, 0, motion.clear, {from: tintFrom}); // The source returns to its place from wherever it is now, with its - // velocity; its shadow follows it back frame for frame (the same start, - // the same velocity along the way back) and fades on its own spring. - // Under Reduce Motion both fade where they are. - // One instant for the shadow's reading and the morph's own, and the - // morph's velocity there (from the rect it presents a millisecond - // before: the morph keeps its own clock, synced to its animations'). + // velocity. Its shadow follows it back frame for frame, with the same + // start and velocity, and fades on its own spring. Under Reduce Motion + // both fade where they are. + // The shadow and the morph read one instant, and the morph's velocity + // there. That velocity comes from the rect the morph presents a + // millisecond before. The morph keeps its own clock, synced to its + // animations. const at = performance.now(); current.holdClock(at - 1); const before = current.morph.presentedRect() ?? lifted; @@ -1261,9 +1259,9 @@ export function createLiftFromTrigger(): LiftFromTrigger { }); } }); - // The close holds the layer: nothing reaches the page under it until the - // source is back, the surface's exit is done and every press on the - // layer has lifted. + // The close holds the layer, so nothing reaches the page under it. The + // hold ends when the source is back, the surface's exit is done and every + // press has lifted. const ended = settled.then(async () => current.untilPointersLift()); current.releaseHold?.(); current.releaseHold = holdInput(layer, { @@ -1292,8 +1290,9 @@ export function createLiftFromTrigger(): LiftFromTrigger { } /** - * Sources a copy hides: how many copies hold each, and the visibility it had - * inline before the first, which it gets back when the last drops. + * Sources a copy hides: how many copies hold each, and its inline visibility + * before the first. A source gets that visibility back when its last copy + * drops. */ const hiddenSources = new WeakMap< HTMLElement, @@ -1347,10 +1346,11 @@ function pictureOf(source: HTMLElement): string { /** * Lifts a copy of `source`'s picture into `host` for a zoom, the lift's one - * transient copy: one image laid out at the source's box (aspect fill, its corners), inert and hidden from assistive - * technology, while the source hides where it stands. The caller moves the - * copy on the rect morph and drops it at the landing. Null, with nothing - * changed, for a source that is not in the page or has no box. + * transient copy. The copy is one image at the source's box, with aspect fill + * and its corners. It is inert and hidden from assistive technology, and the + * source hides where it stands. The caller moves the copy on the rect morph + * and drops it at the landing. Null, with nothing changed, for a source that + * is not in the page or has no box. */ export function liftCopy( source: HTMLElement, diff --git a/packages/core/src/liftFromTrigger/liftFromTriggerGeometry.test.ts b/packages/core/src/liftFromTrigger/liftFromTriggerGeometry.test.ts index b1101df78cf22..eb5a5b9601497 100644 --- a/packages/core/src/liftFromTrigger/liftFromTriggerGeometry.test.ts +++ b/packages/core/src/liftFromTrigger/liftFromTriggerGeometry.test.ts @@ -3,9 +3,9 @@ /** * @file liftFromTriggerGeometry.test.ts * @input vitest, the lift geometry - * @output Checks how far a source grows (the cap, the points on its larger - * side, the viewport's width), where it lands inside the gutter, and which - * side of it a surface takes, in either direction + * @output Checks how far a source grows: the cap, the points on its larger + * side and the viewport's width. It also checks where it lands inside the + * gutter, and a surface's side in either direction * @position Unit tests of packages/core/src/liftFromTrigger/liftFromTriggerGeometry.ts; a * surface's own numbers (UIKit's, for ContextMenu) are tested with it */ diff --git a/packages/core/src/liftFromTrigger/liftFromTriggerGeometry.ts b/packages/core/src/liftFromTrigger/liftFromTriggerGeometry.ts index 5bf3f7129f0e4..6e66019977ba2 100644 --- a/packages/core/src/liftFromTrigger/liftFromTriggerGeometry.ts +++ b/packages/core/src/liftFromTrigger/liftFromTriggerGeometry.ts @@ -4,9 +4,8 @@ * @file liftFromTriggerGeometry.ts * @input A source's box, the viewport and the layer runtime's gutter, and * the caller's scale rule and gap - * @output liftFromTriggerScale, liftFromTriggerRect and surfacePlacement: - * how far a lifted source grows, where it lands inside the gutter, and - * which side of it a surface (a menu) takes + * @output liftFromTriggerScale, liftFromTriggerRect and surfacePlacement: a + * lifted source's growth, its place inside the gutter, and a surface's side * @position The geometry of the lift presentation (liftFromTrigger.ts); * internal to core. A source grows about its center by the caller's rule, * as UIKit's preview platter does, then moves inside the gutter @@ -17,10 +16,10 @@ import type {MorphRect} from '../motion/rectMorph'; /** - * The layer runtime's viewport gutter on each edge, CSS px - * (Layer/layerViewportInset.stylex.ts): the spacing step or the - * edge's safe-area inset, whichever is larger, plus the inset an app declares - * for a bar floating over that edge. + * The layer runtime's viewport gutter on each edge, in CSS px + * (Layer/layerViewportInset.stylex.ts). It is the spacing step or the edge's + * safe-area inset, whichever is larger. It adds the inset an app declares for + * a bar over that edge. */ export interface LiftFromTriggerGutter { readonly top: number; @@ -55,8 +54,8 @@ export interface SurfacePlacement { /** * The factor a `width` × `height` source grows by: up to `rule.maxScale`, by - * `rule.scaleUpPoints` on its larger side, and never wider than the viewport - * less its gutter (which can make it smaller than 1). + * `rule.scaleUpPoints` on its larger side. It is never wider than the viewport + * less its gutter, which can make the factor less than 1. */ export function liftFromTriggerScale( width: number, @@ -111,9 +110,9 @@ export function liftFromTriggerRect( /** * A surface's place: below the lifted source when `surfaceHeight` fits - * between it, `gap` and the bottom gutter, else above; centered on the - * source, or flush with the source's edge when the source is held against a - * side of the viewport. + * between it, `gap` and the bottom gutter, else above. It centers on the + * source, or sits flush with the source's edge when the source is held against + * a side of the viewport. */ export function surfacePlacement( lifted: MorphRect, From 96ef39516d8f9cddb25d3e9b0d8e48bf25740456 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 23:57:39 -0800 Subject: [PATCH 053/118] test(liftFromTrigger): a tall source fits between the gutters A source taller than the viewport less its top and bottom gutters landed past the bottom gutter, and a surface with no room above or below was still placed above. Red: the scale ignores the usable height, and placement never reports that neither side fits. --- .../liftFromTriggerGeometry.test.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/core/src/liftFromTrigger/liftFromTriggerGeometry.test.ts b/packages/core/src/liftFromTrigger/liftFromTriggerGeometry.test.ts index eb5a5b9601497..3cc4e4860f066 100644 --- a/packages/core/src/liftFromTrigger/liftFromTriggerGeometry.test.ts +++ b/packages/core/src/liftFromTrigger/liftFromTriggerGeometry.test.ts @@ -44,6 +44,13 @@ describe('liftFromTriggerScale', () => { ); }); + it('shrinks a source taller than the viewport less its top and bottom gutters', () => { + expect(liftFromTriggerScale(300, 900, VIEWPORT, RULE)).toBeCloseTo( + 738 / 900, + 10, + ); + }); + it('leaves an empty source alone', () => { expect(liftFromTriggerScale(0, 0, VIEWPORT, RULE)).toBe(1); }); @@ -86,6 +93,16 @@ describe('liftFromTriggerRect', () => { ); expect(bottom.y + bottom.height).toBeCloseTo(820, 10); }); + + it('keeps a source taller than the room between the gutters inside both', () => { + const tall = liftFromTriggerRect( + {x: 51, y: 0, width: 300, height: 900}, + VIEWPORT, + RULE, + ); + expect(tall.y).toBeCloseTo(82, 10); + expect(tall.y + tall.height).toBeCloseTo(820, 10); + }); }); describe('surfacePlacement', () => { @@ -99,7 +116,12 @@ describe('surfacePlacement', () => { }); it('goes above where it does not fit below', () => { - expect(surfacePlacement(middle, 400, VIEWPORT, 16).side).toBe('above'); + const low = {...middle, y: 600}; + expect(surfacePlacement(low, 400, VIEWPORT, 16).side).toBe('above'); + }); + + it('reports that neither side fits when the surface is taller than the room above and below', () => { + expect(surfacePlacement(middle, 400, VIEWPORT, 16).side).toBe('none'); }); it('is flush with the edge the source is held against, and the sides swap in a right-to-left page', () => { From da70e021860de21d8756c099551219f39b7ce12c Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 23:59:15 -0800 Subject: [PATCH 054/118] fix(liftFromTrigger): the lift fits between the gutters on both axes A lifted source is never taller than the viewport less its top and bottom gutters, as it is never wider. A surface goes above only when it fits under the top gutter; with room on neither side, placement answers `none` and the layer runtime's own fallback places it. --- .../liftFromTriggerGeometry.ts | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/packages/core/src/liftFromTrigger/liftFromTriggerGeometry.ts b/packages/core/src/liftFromTrigger/liftFromTriggerGeometry.ts index 6e66019977ba2..a39c2389c83f8 100644 --- a/packages/core/src/liftFromTrigger/liftFromTriggerGeometry.ts +++ b/packages/core/src/liftFromTrigger/liftFromTriggerGeometry.ts @@ -48,14 +48,18 @@ export interface LiftFromTriggerScaleRule { /** Where a surface sits against the lifted source. */ export interface SurfacePlacement { - readonly side: 'above' | 'below'; + /** + * `none` when the surface fits on neither side: the caller then leaves it to + * the layer runtime's own fallback. + */ + readonly side: 'above' | 'below' | 'none'; readonly alignment: 'start' | 'center' | 'end'; } /** * The factor a `width` × `height` source grows by: up to `rule.maxScale`, by - * `rule.scaleUpPoints` on its larger side. It is never wider than the viewport - * less its gutter, which can make the factor less than 1. + * `rule.scaleUpPoints` on its larger side. It is never wider or taller than + * the viewport less its gutters, which can make the factor less than 1. */ export function liftFromTriggerScale( width: number, @@ -67,11 +71,19 @@ export function liftFromTriggerScale( if (side <= 0) { return 1; } + const {gutter} = viewport; const fitsWidth = - width > 0 - ? (viewport.width - 2 * viewport.gutter.inline) / width + width > 0 ? (viewport.width - 2 * gutter.inline) / width : Infinity; + const fitsHeight = + height > 0 + ? (viewport.height - gutter.top - gutter.bottom) / height : Infinity; - return Math.min(rule.maxScale, (side + rule.scaleUpPoints) / side, fitsWidth); + return Math.min( + rule.maxScale, + (side + rule.scaleUpPoints) / side, + fitsWidth, + fitsHeight, + ); } /** @@ -110,9 +122,10 @@ export function liftFromTriggerRect( /** * A surface's place: below the lifted source when `surfaceHeight` fits - * between it, `gap` and the bottom gutter, else above. It centers on the - * source, or sits flush with the source's edge when the source is held against - * a side of the viewport. + * between it, `gap` and the bottom gutter. Else it goes above, when it fits + * under the top gutter. With room on neither side it answers `none`. It + * centers on the source, or sits flush with the source's edge when the source + * is held against a side of the viewport. */ export function surfacePlacement( lifted: MorphRect, @@ -122,10 +135,9 @@ export function surfacePlacement( ): SurfacePlacement { const {gutter} = viewport; const bottom = viewport.height - gutter.bottom; - const side = - lifted.y + lifted.height + gap + surfaceHeight <= bottom - ? 'below' - : 'above'; + const fitsBelow = lifted.y + lifted.height + gap + surfaceHeight <= bottom; + const fitsAbove = lifted.y - gap - surfaceHeight >= gutter.top; + const side = fitsBelow ? 'below' : fitsAbove ? 'above' : 'none'; const epsilon = 0.5; const atLeft = lifted.x <= gutter.inline + epsilon; const atRight = From 67116fb07b07be79cfa15cb3e72e02c32157712d Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sun, 8 Nov 2026 00:03:49 -0800 Subject: [PATCH 055/118] test(liftFromTrigger): a pinch on the lifted backdrop zooms the page While a row is lifted, a two-finger pinch on the lift layer must zoom the page, tap nothing and reach nothing beneath. Red in Chromium: the layer's `touch-action: none` keeps the page at scale 1. WebKit skips, as the input hold's pinch case does: it pinch-zooms from injected touches only in Mobile Safari on the simulator. --- ...iftFromTrigger.interaction.browser.spec.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts index ef993b7400ed1..5c5e5c847920e 100644 --- a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts +++ b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts @@ -18,6 +18,7 @@ import path from 'node:path'; import {fileURLToPath} from 'node:url'; import {expect, test, type Page} from '@astryxdesign/a11y-spec/playwright'; +import {pinch, touchDriver} from '@astryxdesign/interaction-harness'; import {build, type Plugin} from 'esbuild'; // The page's `window.__lift`. import type {LiftFixture} from './liftFromTrigger.fixture'; @@ -607,6 +608,53 @@ test('a tap while the row and the menu grow reaches nothing; one on the backdrop await page.evaluate(() => window.__lift.dispose()); }); +test('a pinch on the backdrop while a row is lifted zooms the page, and it neither taps the backdrop nor reaches the page (spec:AST-094 FR10, FR35)', async ({ + page, + browserName, +}) => { + test.skip( + browserName === 'webkit', + 'needs pinch-zoom from injected touches (Mobile Safari on the simulator)', + ); + await open(page); + const driver = await touchDriver(page); + // The lift settles first: the row at its lifted width, the tint risen. + const settled = await page.evaluate(async () => { + const result = window.__lift.lift('row2', 0.2); + const row = document.getElementById('row2'); + const layer = document.querySelector('[data-lift]'); + if (result == null || row == null || layer == null) { + return 'no lift'; + } + for (let frame = 0; frame < 120; frame++) { + await new Promise(resolve => requestAnimationFrame(resolve)); + const width = row.getBoundingClientRect().width; + const tint = Number(getComputedStyle(layer, '::backdrop').opacity); + if (Math.abs(width - result.lifted.width) < 0.5 && tint > 0.999) { + return 'settled'; + } + } + return 'the lift did not settle within 120 frames'; + }); + expect(settled).toBe('settled'); + const scale = async () => + page.evaluate(() => window.visualViewport?.scale ?? 1); + expect(await scale()).toBe(1); + await driver.play( + pinch({ + center: {x: 180, y: 700}, + fromDistance: 60, + toDistance: 220, + durationMs: 200, + }), + ); + await expect.poll(scale).toBeGreaterThan(1.2); + const log = await page.evaluate(() => ({...window.__lift.log})); + expect(log.backdropTaps).toBe(0); + expect(log.clicks).not.toContain('page'); + await page.evaluate(() => window.__lift.dispose()); +}); + test("a zoom's copy lifts into the viewer at the thumbnail's box, where the viewer's stacking puts it under the photo, while the thumbnail hides in place; one element more", async ({ page, }) => { From ff7e46881c062ec3242f0fdd36af188d874e54dd Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sun, 8 Nov 2026 00:03:49 -0800 Subject: [PATCH 056/118] fix(liftFromTrigger): the lift layer keeps the page's pinch-zoom The layer takes `touch-action: pinch-zoom`, as the input hold's shield does: a one-finger pan still scrolls nothing under the lift, and a pinch zooms the page. --- packages/core/src/liftFromTrigger/liftFromTrigger.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/src/liftFromTrigger/liftFromTrigger.ts b/packages/core/src/liftFromTrigger/liftFromTrigger.ts index a9d5787eec270..4789108f61d84 100644 --- a/packages/core/src/liftFromTrigger/liftFromTrigger.ts +++ b/packages/core/src/liftFromTrigger/liftFromTrigger.ts @@ -512,7 +512,9 @@ function createLayer(doc: Document, attribute: string): HTMLElement { color: 'inherit', overflow: 'visible', pointerEvents: 'auto', - touchAction: 'none', + // A one-finger pan scrolls nothing under the lift, but a pinch still + // zooms the page. + touchAction: 'pinch-zoom', }); return layer; } From 9e7b0b4039ea6b6935e2b540d217574f9ab5acca Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sun, 8 Nov 2026 00:07:30 -0800 Subject: [PATCH 057/118] test(liftFromTrigger): a drop mid-rise turns the tint with its velocity A drop while the tint still rises must keep the tint's velocity, so the tint rises a little past where the drop found it before it clears. Red in both engines: the clear restarts from rest at the opacity shown, and the tint falls from the first frame after the drop. --- ...iftFromTrigger.interaction.browser.spec.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts index 5c5e5c847920e..5f8247e5160a4 100644 --- a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts +++ b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts @@ -319,6 +319,42 @@ test('the tint clears from what it shows when the row drops, with no jump to cle await page.evaluate(() => window.__lift.dispose()); }); +test('a drop while the tint rises turns it around with its velocity: it keeps rising a little before it clears (spec:AST-094 FR6)', async ({ + page, +}) => { + await open(page); + const tint = await page.evaluate(async () => { + const frame = async () => + new Promise(resolve => requestAnimationFrame(() => resolve())); + const shown = () => { + const layer = document.querySelector('[data-lift]'); + return layer == null + ? -1 + : Number(getComputedStyle(layer, '::backdrop').opacity); + }; + window.__lift.lift('row2', 0.8); + // WebKit shows the stylesheet's opacity until the rise's first frame. + let rising = -1; + for (let i = 0; i < 60 && !(rising > 0.1 && rising < 0.5); i++) { + await frame(); + rising = shown(); + } + window.__lift.drop(); + const after: number[] = []; + for (let i = 0; i < 8; i++) { + await frame(); + after.push(shown()); + } + return {rising, after}; + }); + const detail = JSON.stringify(tint); + expect(tint.rising, detail).toBeGreaterThan(0.05); + expect(tint.rising, detail).toBeLessThan(0.6); + // Still moving up at the drop, it rises past where the drop found it. + expect(Math.max(...tint.after), detail).toBeGreaterThan(tint.rising + 0.03); + await page.evaluate(() => window.__lift.dispose()); +}); + test('every motion the lift plays, the menu’s growth and close included, is translate, scale, transform or opacity (spec:AST-094 FR5)', async ({ page, }) => { From 917d51d86c5f962400ee4f2b0b4586cb94cbc9bd Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sun, 8 Nov 2026 00:14:23 -0800 Subject: [PATCH 058/118] fix(liftFromTrigger): the tint fades on the animator's backdrop channel The tint is the opacity of the layer's `::backdrop` on its own motion engine in `pseudoElement` mode, in place of keyframes the lift sampled itself. A drop while the tint rises now turns it with its velocity, and Reduce Motion is the animator's, as for every other motion of the lift. The backdrop's color stays held by its own animation. The tint-clear proof reads what the backdrop shows: a risen tint holds no animation. --- packages/core/src/liftFromTrigger/README.md | 3 +- ...iftFromTrigger.interaction.browser.spec.ts | 6 +- .../src/liftFromTrigger/liftFromTrigger.ts | 129 +++++------------- 3 files changed, 41 insertions(+), 97 deletions(-) diff --git a/packages/core/src/liftFromTrigger/README.md b/packages/core/src/liftFromTrigger/README.md index 1099323b95e87..643c039400c7b 100644 --- a/packages/core/src/liftFromTrigger/README.md +++ b/packages/core/src/liftFromTrigger/README.md @@ -56,7 +56,8 @@ up the page has one element more. Every motion is `translate`, `scale` or `opacity` on the compositor (`spec:AST-094` FR5): the trigger, its shadow and the surface on the rect morph, which continues an interrupted motion with the velocity on screen (FR6); the -tint as a spring-sampled Web Animation of the layer's `::backdrop`. A +tint on the animator, as the opacity of the layer's `::backdrop`, so a drop +while it rises turns it with its velocity too. A tap or a drag that lands while the lift and the surface grow reaches nothing (FR35). The close holds the layer until the trigger is back, the surface's exit is done and every press on the layer has lifted. Its timers and diff --git a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts index 5f8247e5160a4..37cc868d56ea2 100644 --- a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts +++ b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts @@ -306,7 +306,11 @@ test('the tint clears from what it shows when the row drops, with no jump to cle .getKeyframes() .map(frame => Number(frame.opacity)), ); - const shown = fades().at(-1)?.at(-1) ?? -1; + // A risen tint holds no animation: the stylesheet shows it. + const shown = + layer == null + ? -1 + : Number(getComputedStyle(layer, '::backdrop').opacity); window.__lift.drop(); const clear = fades().at(-1) ?? []; return {shown, from: clear[0] ?? -1, to: clear.at(-1) ?? -1}; diff --git a/packages/core/src/liftFromTrigger/liftFromTrigger.ts b/packages/core/src/liftFromTrigger/liftFromTrigger.ts index 4789108f61d84..fcb63490512e0 100644 --- a/packages/core/src/liftFromTrigger/liftFromTrigger.ts +++ b/packages/core/src/liftFromTrigger/liftFromTrigger.ts @@ -20,7 +20,11 @@ import {placeLayerExitCopy} from '../Layer/layerExitCopy'; import {layerInsetProperties} from '../Layer/layerInset'; import {layerViewportInset} from '../Layer/layerViewportInset.stylex'; import {holdInput} from '../Layer/inputHold'; -import type {MotionHandle} from '../motion/animator'; +import { + createMotionEngine, + type Animator, + type MotionHandle, +} from '../motion/animator'; import {progressCurve} from '../motion/progress'; import { createRectMorph, @@ -31,12 +35,7 @@ import { } from '../motion/rectMorph'; import {reducedMotionFade} from '../motion/reducedMotion'; import {resolveMotion} from '../motion/resolveMotion'; -import { - SETTLING_EPSILON, - solveSpring, - toSpring, - type SpringDescription, -} from '../motion/spring'; +import type {SpringDescription} from '../motion/spring'; import { liftFromTriggerRect, type LiftFromTriggerScaleRule, @@ -226,7 +225,13 @@ interface LiftState { /** The layer's own waits and listeners, released with it. */ readonly waits: AbortController; untilPointersLift: () => Promise; - tint: Animation | null; + /** + * The tint's animator: the opacity of the layer's `::backdrop`. The + * stylesheet shows the backdrop opaque, so a risen tint holds nothing. + */ + readonly tints: Animator; + /** The tint's fade on `tints`. */ + tint: MotionHandle | null; surface: LiftedSurface | null; releaseGrowHold: (() => void) | null; releaseHold: (() => void) | null; @@ -265,29 +270,6 @@ function restore(element: HTMLElement, saved: SavedStyle): void { } } -/** Web Animation keyframes along `spring` from 0 to 1, sampled at 60 Hz. */ -function springKeyframes( - element: Element, - spring: SpringDescription, - frame: (progress: number) => Keyframe, -): {keyframes: Keyframe[]; duration: number} { - const curve = solveSpring(toSpring(resolveMotion(element, spring)), { - from: 0, - to: 1, - }); - const seconds = Math.max(curve.settleTime(SETTLING_EPSILON), 1 / 60); - const steps = Math.max(2, Math.ceil(seconds * 60)); - const keyframes: Keyframe[] = []; - for (let i = 0; i <= steps; i++) { - const t = (i / steps) * seconds; - keyframes.push({ - ...frame(i === steps ? 1 : curve.value(t)), - offset: i / steps, - }); - } - return {keyframes, duration: seconds * 1000}; -} - /** * Web Animation keyframes for a box laid out on `box`. It travels from * `from` to `to` on `travel`, starting at progress velocity `velocity`, as the @@ -429,64 +411,23 @@ function resolvedMotion( }; } -/** Plays `keyframes` on `element`'s `::backdrop`, holding the end. */ -function animateBackdrop( +/** Holds `color` as the background of `element`'s `::backdrop`. */ +function holdBackdropColor( element: HTMLElement, - keyframes: Keyframe[], - options: KeyframeAnimationOptions, + color: string, ): Animation | null { if (typeof element.animate !== 'function') { return null; } try { - return element.animate(keyframes, { - fill: 'forwards', - ...options, - pseudoElement: '::backdrop', - }); - } catch { - // No `::backdrop` animations: the lift shows without its decoration. - return null; - } -} - -/** The `::backdrop` opacity `element` shows now, or `fallback`. */ -function backdropOpacity(element: HTMLElement, fallback: number): number { - try { - const shown = Number.parseFloat( - getComputedStyle(element, '::backdrop').opacity, + return element.animate( + [{backgroundColor: color}, {backgroundColor: color}], + {duration: 0, fill: 'forwards', pseudoElement: '::backdrop'}, ); - return Number.isFinite(shown) ? shown : fallback; } catch { - return fallback; - } -} - -/** - * Fades `element`'s `::backdrop` to `to` on `spring`, from `from` or what it - * shows; `reduced` is the Reduce Motion fade when read already. Opacity - * alone, so the compositor plays it. - */ -function fadeBackdrop( - element: HTMLElement, - to: number, - spring: SpringDescription, - { - from = backdropOpacity(element, to === 1 ? 0 : 1), - reduced = reducedMotionFade(element), - }: {from?: number; reduced?: ReturnType} = {}, -): Animation | null { - if (reduced != null) { - return animateBackdrop( - element, - [{opacity: String(from)}, {opacity: String(to)}], - {duration: reduced.duration, easing: reduced.easing}, - ); + // No `::backdrop` animations: the lift shows without its tint. + return null; } - const {keyframes, duration} = springKeyframes(element, spring, p => ({ - opacity: String(from + (to - from) * p), - })); - return animateBackdrop(element, keyframes, {duration}); } /** @@ -656,7 +597,7 @@ export function createLiftFromTrigger(): LiftFromTrigger { current.releaseHold?.(); current.releaseHold = null; current.waits.abort(); - current.tint?.cancel(); + current.tints.dispose(); current.tint = null; current.layerFill?.cancel(); const {layer} = current; @@ -917,8 +858,14 @@ export function createLiftFromTrigger(): LiftFromTrigger { const morph = createRectMorph(source, { now: () => heldAt ?? performance.now(), }); - // The layer first: the source shows above its tint. + // The layer first: the source shows above its tint. The popover makes + // its `::backdrop` new, so the tint starts clear in the same task. + const tints = createMotionEngine(layer, { + pseudoElement: '::backdrop', + base: {opacity: 1}, + }); layer.showPopover(); + tints.set({opacity: 0}); source.showPopover(); holdClock(performance.now()); const growing = morph.present( @@ -928,14 +875,7 @@ export function createLiftFromTrigger(): LiftFromTrigger { ); holdClock(null); // The tint: the layer's `::backdrop`, its color held there. - const layerFill = animateBackdrop( - layer, - [ - {backgroundColor: tint, opacity: '0'}, - {backgroundColor: tint, opacity: '0'}, - ], - {duration: 0}, - ); + const layerFill = holdBackdropColor(layer, tint); // The shadow rises with the source. It follows the source's path on the // lift's spring and fades in on its own spring, from the source's first // frame. It is one compositor animation: an animated `::backdrop` of the @@ -987,7 +927,8 @@ export function createLiftFromTrigger(): LiftFromTrigger { holdClock, waits, untilPointersLift: async () => {}, - tint: fadeBackdrop(layer, 1, motion.lift, {from: 0, reduced}), + tints, + tint: tints.animate({opacity: 1}, {spring: motion.lift}), surface: null, releaseGrowHold: null, releaseHold: null, @@ -1194,10 +1135,8 @@ export function createLiftFromTrigger(): LiftFromTrigger { } releaseGrowHold(current); const {layer, motion, source, sourceRect, lifted} = current; - // The tint clears from what it shows now, read before its rise stops. - const tintFrom = backdropOpacity(layer, 1); - current.tint?.cancel(); - current.tint = fadeBackdrop(layer, 0, motion.clear, {from: tintFrom}); + // The tint clears from what it shows now, with its velocity. + current.tint = current.tints.animate({opacity: 0}, {spring: motion.clear}); // The source returns to its place from wherever it is now, with its // velocity. Its shadow follows it back frame for frame, with the same // start and velocity, and fades on its own spring. Under Reduce Motion From 50f150c928b4420d44be57e8caf5257140374a3d Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sun, 8 Nov 2026 00:23:50 -0800 Subject: [PATCH 059/118] refactor(liftFromTrigger): the shadow rides a rect morph of its own The shadow played keyframes the lift sampled itself, where the README says it rides a rect morph. It now plays the source's path on `createRectMorph`, on the source's clock, with its fade on its own spring as the morph's `fade` timing. Its drop is the morph's dismissal from the rect on screen, with its velocity. Reduce Motion is the animator's. The frame-for-frame proof passes unchanged in both engines. --- .../src/liftFromTrigger/liftFromTrigger.ts | 181 ++++-------------- 1 file changed, 38 insertions(+), 143 deletions(-) diff --git a/packages/core/src/liftFromTrigger/liftFromTrigger.ts b/packages/core/src/liftFromTrigger/liftFromTrigger.ts index fcb63490512e0..9d184f92e3b01 100644 --- a/packages/core/src/liftFromTrigger/liftFromTrigger.ts +++ b/packages/core/src/liftFromTrigger/liftFromTrigger.ts @@ -25,7 +25,6 @@ import { type Animator, type MotionHandle, } from '../motion/animator'; -import {progressCurve} from '../motion/progress'; import { createRectMorph, lerpPath, @@ -216,10 +215,11 @@ interface LiftState { * is. It follows the source frame for frame and fades on its own spring. */ readonly shadow: HTMLElement; - shadowAnimation: Animation | null; + /** The shadow's rect morph, on the source's path and clock. */ + readonly shadowMorph: RectMorph; /** - * Holds the source morph's clock at one instant (null lets it run), so the - * shadow reads the motion at the very moment the morph does. + * Holds the clock of both morphs at one instant (null lets it run), so the + * shadow starts each motion where the source does. */ readonly holdClock: (at: number | null) => void; /** The layer's own waits and listeners, released with it. */ @@ -270,70 +270,6 @@ function restore(element: HTMLElement, saved: SavedStyle): void { } } -/** - * Web Animation keyframes for a box laid out on `box`. It travels from - * `from` to `to` on `travel`, starting at progress velocity `velocity`, as the - * rect morph does. Its opacity goes from `fade[0]` to `fade[1]` on - * `fadeSpring`. The keyframes sample both at 60 Hz until both settle. The - * result is one compositor animation of `transform` and `opacity`, frame for - * frame with the source's morph. - */ -function shadowKeyframes( - box: MorphRect, - from: MorphRect, - to: MorphRect, - travel: SpringDescription | null, - fade: readonly [number, number], - fadeSpring: SpringDescription, - velocity = 0, -): {keyframes: Keyframe[]; duration: number} { - const move = travel == null ? null : progressCurve(travel, {velocity}); - const opacity = progressCurve(fadeSpring); - const seconds = Math.max(move?.duration ?? 0, opacity.duration, 1 / 60); - const steps = Math.max(2, Math.ceil(seconds * 60)); - const keyframes: Keyframe[] = []; - for (let i = 0; i <= steps; i++) { - const t = (i / steps) * seconds; - const p = i === steps || move == null ? 1 : move.at(t); - const q = i === steps ? 1 : opacity.at(t); - const rect = { - x: from.x + (to.x - from.x) * p, - y: from.y + (to.y - from.y) * p, - width: from.width + (to.width - from.width) * p, - }; - const scale = box.width > 0 ? rect.width / box.width : 1; - keyframes.push({ - transform: `translate(${(rect.x - box.x).toFixed(3)}px, ${(rect.y - box.y).toFixed(3)}px) scale(${scale.toFixed(5)})`, - opacity: String( - Math.min(Math.max(fade[0] + (fade[1] - fade[0]) * q, 0), 1), - ), - offset: i / steps, - }); - } - return {keyframes, duration: seconds * 1000}; -} - -/** Plays `keyframes` on `element`, or only their ends under Reduce Motion. */ -function playShadow( - element: HTMLElement, - {keyframes, duration}: {keyframes: Keyframe[]; duration: number}, - reduced: ReturnType, -): Animation | null { - if (typeof element.animate !== 'function') { - return null; - } - return element.animate( - reduced == null - ? keyframes - : [keyframes[0], keyframes[keyframes.length - 1]], - { - duration: reduced?.duration ?? duration, - easing: reduced?.easing ?? 'linear', - fill: 'forwards', - }, - ); -} - /** * The radius of the shape `source` shows: its own, or that of its only * element child when that child fills it. @@ -566,8 +502,7 @@ export function createLiftFromTrigger(): LiftFromTrigger { } current.isSourceLifted = false; const {source} = current; - current.shadowAnimation?.cancel(); - current.shadowAnimation = null; + current.shadowMorph.dispose(); current.morph.dispose(); if (source.matches(':popover-open')) { source.hidePopover(); @@ -735,7 +670,6 @@ export function createLiftFromTrigger(): LiftFromTrigger { const direction = computed.direction === 'rtl' ? 'rtl' : 'ltr'; const radius = shapeRadius(source); const motion = resolvedMotion(source, options.motion); - const reduced = reducedMotionFade(source); // The tint, the shadow and the layer runtime's gutter, with the inset an // app declares, resolved where the source is. const probed = probe(source, { @@ -855,9 +789,9 @@ export function createLiftFromTrigger(): LiftFromTrigger { const holdClock = (at: number | null) => { heldAt = at; }; - const morph = createRectMorph(source, { - now: () => heldAt ?? performance.now(), - }); + const now = () => heldAt ?? performance.now(); + const morph = createRectMorph(source, {now}); + const shadowMorph = createRectMorph(shadowEl, {now}); // The layer first: the source shows above its tint. The popover makes // its `::backdrop` new, so the tint starts clear in the same task. const tints = createMotionEngine(layer, { @@ -873,40 +807,22 @@ export function createLiftFromTrigger(): LiftFromTrigger { {spring: motion.lift}, {crossfadeAs: 'entrance'}, ); + // The shadow rises with the source, frame for frame: the same path, + // spring and clock. It fades in on its own spring. It is a box in the + // layer, because an animated `::backdrop` of the source would restyle and + // lay out the page on every frame. + shadowMorph.present( + lerpPath(from, lifted, { + box: sourceRect, + scale: 'uniform', + opacity: [0, 1], + }), + {spring: motion.lift}, + {fade: {spring: motion.shadowIn}, crossfadeAs: 'entrance'}, + ); holdClock(null); // The tint: the layer's `::backdrop`, its color held there. const layerFill = holdBackdropColor(layer, tint); - // The shadow rises with the source. It follows the source's path on the - // lift's spring and fades in on its own spring, from the source's first - // frame. It is one compositor animation: an animated `::backdrop` of the - // source would restyle and lay out the page on every frame. Under Reduce - // Motion it fades in where the source lands. - const rise = playShadow( - shadowEl, - reduced == null - ? shadowKeyframes( - sourceRect, - from, - lifted, - motion.lift, - [0, 1], - motion.shadowIn, - ) - : shadowKeyframes( - sourceRect, - lifted, - lifted, - null, - [0, 1], - motion.shadowIn, - ), - reduced, - ); - void growing.started.then(at => { - if (rise != null && current.shadowAnimation === rise) { - rise.startTime = at; - } - }); const current: LiftState = { generation, options, @@ -923,7 +839,7 @@ export function createLiftFromTrigger(): LiftFromTrigger { growing, layerFill, shadow: shadowEl, - shadowAnimation: rise, + shadowMorph, holdClock, waits, untilPointersLift: async () => {}, @@ -1138,43 +1054,22 @@ export function createLiftFromTrigger(): LiftFromTrigger { // The tint clears from what it shows now, with its velocity. current.tint = current.tints.animate({opacity: 0}, {spring: motion.clear}); // The source returns to its place from wherever it is now, with its - // velocity. Its shadow follows it back frame for frame, with the same - // start and velocity, and fades on its own spring. Under Reduce Motion - // both fade where they are. - // The shadow and the morph read one instant, and the morph's velocity - // there. That velocity comes from the rect the morph presents a - // millisecond before. The morph keeps its own clock, synced to its - // animations. - const at = performance.now(); - current.holdClock(at - 1); - const before = current.morph.presentedRect() ?? lifted; - current.holdClock(at); - const presented = current.morph.presentedRect() ?? lifted; - const shadowEl = current.shadow; - const shownOpacity = - Number.parseFloat( - shadowEl.ownerDocument.defaultView?.getComputedStyle(shadowEl) - .opacity ?? '', - ) || 0; - current.shadowAnimation?.cancel(); - const reducedFade = reducedMotionFade(source); - // That velocity as progress along the way back (from where it is drawn - // to the source's place), as the morph reads it. - const span = presented.width - sourceRect.width; - const widthVelocity = (presented.width - before.width) * 1000; - const backVelocity = Math.abs(span) > 1e-6 ? -widthVelocity / span : 0; - current.shadowAnimation = playShadow( - shadowEl, - shadowKeyframes( - sourceRect, - presented, - reducedFade == null ? sourceRect : presented, - reducedFade == null ? motion.drop : null, - [shownOpacity, 0], - motion.shadowOut, - backVelocity, - ), - reducedFade, + // velocity. Its shadow follows it back frame for frame: the same instant, + // path and spring. It fades on its own spring. Under Reduce Motion both + // fade where they are. + current.holdClock(performance.now()); + current.shadowMorph.dismiss( + lerpPath(sourceRect, lifted, { + box: sourceRect, + scale: 'uniform', + opacity: [0, 1], + }), + {spring: motion.drop}, + { + target: current.shadow, + fade: {spring: motion.shadowOut}, + crossfadeAs: 'exit', + }, ); const returning = current.morph.dismiss( lerpPath(sourceRect, lifted, {box: sourceRect, scale: 'uniform'}), From 87b4151e9619aa440abcbb660b73a1c16685f9ae Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sun, 8 Nov 2026 01:16:37 -0800 Subject: [PATCH 060/118] test(liftFromTrigger): a dropped row's margin returns with no transition The lift zeroes a row's own margin, and the drop must put it back at once, with none of the row's transitions running. The proof guards the style flush that ends the restore: with the flush removed, the margin's transition runs, as that mutation shows. Page script cannot count layouts, so no proof shows the extra layout itself. --- ...iftFromTrigger.interaction.browser.spec.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts index 37cc868d56ea2..2fc9826f45a0c 100644 --- a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts +++ b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts @@ -359,6 +359,47 @@ test('a drop while the tint rises turns it around with its velocity: it keeps ri await page.evaluate(() => window.__lift.dispose()); }); +test('a drop puts the row back with no transition running, whatever transitions the row has: its own margin returns at once', async ({ + page, +}) => { + await open(page); + const running = await page.evaluate(async () => { + const frame = async () => + new Promise(resolve => requestAnimationFrame(() => resolve())); + const row = document.getElementById('row2') as HTMLElement; + // The lift zeroes the row's margin; the drop puts it back. + row.style.marginLeft = '10px'; + row.style.transition = 'margin-left 1s, translate 1s, scale 1s'; + window.__lift.lift('row2', 0.2); + for (let i = 0; i < 30; i++) { + await frame(); + } + window.__lift.drop(); + let frames = 0; + for (; frames < 180 && window.__lift.isLifted(); frames++) { + await frame(); + } + await frame(); + return { + settled: !window.__lift.isLifted(), + frames, + transitions: row + .getAnimations() + .filter(animation => animation instanceof CSSTransition) + .map(animation => animation.transitionProperty), + transition: row.style.transition, + }; + }); + const detail = JSON.stringify(running); + expect(running.settled, detail).toBe(true); + // The row's own transitions are back, and none of them ran on the return. + expect(running.transition, detail).toBe( + 'margin-left 1s, translate 1s, scale 1s', + ); + expect(running.transitions, detail).toEqual([]); + await page.evaluate(() => window.__lift.dispose()); +}); + test('every motion the lift plays, the menu’s growth and close included, is translate, scale, transform or opacity (spec:AST-094 FR5)', async ({ page, }) => { From 29677fd39e135fb21d74a1064e0cc7caaaca3236 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sun, 8 Nov 2026 01:16:37 -0800 Subject: [PATCH 061/118] fix(liftFromTrigger): the restore flushes style with a non-geometry read `settleSource` read the source's computed `width`, a geometry value, between its restore writes and the next lift's reads. So a lift that replaced another laid the page out twice in one task. It reads `transitionProperty` instead: the restored values still apply before the transitions return, with no layout. --- packages/core/src/liftFromTrigger/liftFromTrigger.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/src/liftFromTrigger/liftFromTrigger.ts b/packages/core/src/liftFromTrigger/liftFromTrigger.ts index 9d184f92e3b01..c37b8a8d8cf92 100644 --- a/packages/core/src/liftFromTrigger/liftFromTrigger.ts +++ b/packages/core/src/liftFromTrigger/liftFromTrigger.ts @@ -511,8 +511,10 @@ export function createLiftFromTrigger(): LiftFromTrigger { restore(source, current.saved); current.placeholder?.remove(); // The restored values apply before the source's own transitions return, - // so none of them animates. - void source.ownerDocument.defaultView?.getComputedStyle(source).width; + // so none of them animates. A style read, not a geometry one, so it lays + // nothing out. + void source.ownerDocument.defaultView?.getComputedStyle(source) + .transitionProperty; const [value, priority] = current.transition; if (value === '') { source.style.removeProperty('transition'); From 5f3626202b9a1d9c442a24dd31b1d15e7a6b2b80 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 9 Nov 2026 19:40:37 -0800 Subject: [PATCH 062/118] fix(liftFromTrigger): the lifted trigger casts no shadow The spec owner asked to drop the lift's shadow, since it made the lift look worse. The lift keeps its zoom, tint, surface placement and backdrop tap, and drops the shadow box with its rect morph, springs and option. The page gains three elements while lifted, not four: the layer, the anchor and the placeholder. The proofs assert no shadow element, and the frame-for-frame shadow proof goes. --- .changeset/lift-module.md | 2 +- packages/core/src/liftFromTrigger/README.md | 15 ++-- .../__tests__/liftFromTrigger.fixture.ts | 4 +- ...iftFromTrigger.interaction.browser.spec.ts | 71 ++++----------- .../liftFromTrigger/liftFromTrigger.test.ts | 12 +-- .../src/liftFromTrigger/liftFromTrigger.ts | 89 +------------------ 6 files changed, 34 insertions(+), 159 deletions(-) diff --git a/.changeset/lift-module.md b/.changeset/lift-module.md index 6d6b6ea44e3c2..bd57749793065 100644 --- a/.changeset/lift-module.md +++ b/.changeset/lift-module.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: one opt-in lift-from-trigger presentation for touch surfaces. The trigger itself lifts into the top layer above a tinted backdrop; a surface grows out of the press beside it on the shared rect morph and shrinks back into it on the layer runtime's exit copy; the trigger returns to its place. Translate, scale and opacity only; nothing mounted at rest, four elements while lifted. A zoom from a thumbnail lifts a copy of the thumbnail's picture into the viewer instead, while the thumbnail hides in place. Not exported from the package entry point; no component uses it yet. +[chore] Internal: one opt-in lift-from-trigger presentation for touch surfaces. The trigger itself lifts into the top layer above a tinted backdrop; a surface grows out of the press beside it on the shared rect morph and shrinks back into it on the layer runtime's exit copy; the trigger returns to its place. Translate, scale and opacity only; nothing mounted at rest, three elements while lifted, and no shadow. A zoom from a thumbnail lifts a copy of the thumbnail's picture into the viewer instead, while the thumbnail hides in place. Not exported from the package entry point; no component uses it yet. @vjeux diff --git a/packages/core/src/liftFromTrigger/README.md b/packages/core/src/liftFromTrigger/README.md index 643c039400c7b..e5f598902eddb 100644 --- a/packages/core/src/liftFromTrigger/README.md +++ b/packages/core/src/liftFromTrigger/README.md @@ -24,14 +24,13 @@ or stacking ancestor cuts or covers it, and an empty placeholder after it holds its spot, so the page does not move. In a list the placeholder is an `li` that is not a list item, so the list stays valid and keeps its numbers. The trigger takes no hits while lifted: a tap on it is a tap on the backdrop. -While lifted the page has four +While lifted the page has three elements more, whatever the trigger holds: the lift layer (its `::backdrop` -is the tint); the shadow, a box in the layer that follows the trigger on a -rect morph of its own (an animated `::backdrop` of the trigger would restyle -and lay out the page on every frame); an empty box at the lifted rect for -the surface to be placed against (anchor positioning follows transforms, so -a box inside the trigger would move with it); and the placeholder. The input hold's shield joins while the lift grows and -while it closes: never more than five (`spec:AST-017` FR60, DEC-29). The +is the tint); an empty box at the lifted rect for the surface to be placed +against (anchor positioning follows transforms, so a box inside the trigger +would move with it); and the placeholder. The lifted trigger casts no +shadow. The input hold's shield joins while the lift grows and while it +closes: never more than four (`spec:AST-017` FR60, DEC-29). The exit copy the surface shrinks on is the layer runtime's transient copy. At rest there is nothing. @@ -54,7 +53,7 @@ up the page has one element more. ## Motion Every motion is `translate`, `scale` or `opacity` on the compositor -(`spec:AST-094` FR5): the trigger, its shadow and the surface on the rect morph, +(`spec:AST-094` FR5): the trigger and the surface on the rect morph, which continues an interrupted motion with the velocity on screen (FR6); the tint on the animator, as the opacity of the layer's `::backdrop`, so a drop while it rises turns it with its velocity too. A diff --git a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.fixture.ts b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.fixture.ts index 8a30b6ec656fc..a2852ad904f32 100644 --- a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.fixture.ts +++ b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.fixture.ts @@ -52,7 +52,7 @@ declare global { } document.documentElement.style.cssText = - '--color-overlay: rgba(1, 18, 40, 0.4); --shadow-high: 0 8px 24px rgba(0, 0, 0, 0.3); --spacing-4: 16px;'; + '--color-overlay: rgba(1, 18, 40, 0.4); --spacing-4: 16px;'; document.body.style.margin = '0'; document.body.style.font = '16px sans-serif'; const deep = Array.from( @@ -112,8 +112,6 @@ function options(response: number): LiftFromTriggerOptions { lift: spring, drop: spring, clear: spring, - shadowIn: spring, - shadowOut: spring, surfaceIn: spring, surfaceOut: spring, }, diff --git a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts index 2fc9826f45a0c..8b2ef8710f4b4 100644 --- a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts +++ b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts @@ -7,7 +7,7 @@ * @output Proof in real engines of the lift and the zoom's copy. A row lifts * into the top layer while a placeholder holds its spot. Every motion is * `translate`, `scale`, `transform` or `opacity`. The menu grows out of the - * press and shrinks back into it on close. The page has at most five more + * press and shrinks back into it on close. The page has at most four more * elements while lifted, and none after the drop. A tap while it grows * reaches nothing. The zoom's copy grows on the rect morph, turns around * and lands in the thumbnail @@ -97,7 +97,7 @@ test('a row lifts itself into the top layer at its lifted rect while one placeho row3: box(document.getElementById('row3')), row4: box(document.getElementById('row4')), placeholder: box(document.querySelector('[data-lift-placeholder]')), - shadow: box(document.querySelector('[data-lift-shadow]')), + hasShadow: document.querySelector('[data-lift-shadow]') != null, isOpen: row.matches(':popover-open'), look: [ style.color, @@ -126,8 +126,8 @@ test('a row lifts itself into the top layer at its lifted rect while one placeho }; expect(lifted.width).toBeCloseTo(361, 3); expectBox(after.row2, [lifted.x, lifted.y, lifted.width, lifted.height]); - // Its shadow lies exactly under it. - expectBox(after.shadow, [lifted.x, lifted.y, lifted.width, lifted.height]); + // It casts no shadow. + expect(after.hasShadow).toBe(false); expectBox(after.placeholder, before.row2); expectBox(after.row1, before.row1); expectBox(after.row3, before.row3); @@ -186,13 +186,12 @@ test('a lifted item of an ordered list leaves the numbers of the items after it page, }) => { await open(page); - // An ordered list with its markers outside, and no tint and no shadow. A - // later item's marker paints the same while an item is lifted, unless its - // number changes. + // An ordered list with its markers outside, and no tint. A later item's + // marker paints the same while an item is lifted, unless its number + // changes. const clip = await page.evaluate(() => { const root = document.documentElement.style; root.setProperty('--color-overlay', 'transparent'); - root.setProperty('--shadow-high', 'none'); const panel = document.querySelector('.panel'); if (panel != null) { panel.style.display = 'none'; @@ -239,46 +238,6 @@ test('a lifted item of an ordered list leaves the numbers of the items after it await page.evaluate(() => window.__lift.dispose()); }); -test('the shadow follows the row on every frame of its lift and of its drop', async ({ - page, -}) => { - await open(page); - // Every animation of the lift, held at the same moment `t` ms in. - const at = async (t: number) => - page.evaluate(time => { - for (const animation of document.getAnimations()) { - animation.pause(); - animation.currentTime = time; - } - const box = (element: Element | null): Box => { - const r = element?.getBoundingClientRect(); - return r == null ? [0, 0, 0, 0] : [r.x, r.y, r.width, r.height]; - }; - return { - row: box(document.getElementById('row2')), - shadow: box(document.querySelector('[data-lift-shadow]')), - }; - }, t); - await page.evaluate(() => window.__lift.lift('row2', 0.2)); - await page.waitForTimeout(50); - for (const t of [30, 90, 180]) { - const {row, shadow} = await at(t); - expectBox(shadow, row); - } - await page.evaluate(() => { - for (const animation of document.getAnimations()) { - animation.finish(); - } - window.__lift.drop(); - }); - await page.waitForTimeout(50); - for (const t of [30, 90]) { - const {row, shadow} = await at(t); - expectBox(shadow, row); - } - await page.evaluate(() => window.__lift.dispose()); -}); - test('the tint clears from what it shows when the row drops, with no jump to clear', async ({ page, }) => { @@ -568,7 +527,7 @@ test('without a press the menu fades in where it rests: opacity only, no travel await page.evaluate(() => window.__lift.dispose()); }); -test('while lifted the page has the layer, the shadow, the anchor and the placeholder more, whatever the row holds, and never more than 5 (AST-017 FR60); a drop leaves the page as it was', async ({ +test('while lifted the page has the layer, the anchor and the placeholder more, whatever the row holds, and never more than 4 (AST-017 FR60); a drop leaves the page as it was', async ({ page, }) => { await open(page); @@ -622,13 +581,13 @@ test('while lifted the page has the layer, the shadow, the anchor and the placeh lifted: window.__lift.isLifted(), }; }); - // A row of 13 elements and a row of 1 add the same 4 once grown: the layer, - // the shadow, the anchor and the placeholder. The hold's shield joins while - // the lift grows. - expect(counts.deep).toBe(5); - expect(counts.shallow).toBe(5); - expect(counts.open).toBe(4); - expect(counts.most).toBeLessThanOrEqual(5); + // A row of 13 elements and a row of 1 add the same 3 once grown: the layer, + // the anchor and the placeholder. The hold's shield joins while the lift + // grows. + expect(counts.deep).toBe(4); + expect(counts.shallow).toBe(4); + expect(counts.open).toBe(3); + expect(counts.most).toBeLessThanOrEqual(4); expect(counts.after).toBe(0); expect(counts.styleAfter).toBe(counts.style); expect(counts.popoverAfter).toBeNull(); diff --git a/packages/core/src/liftFromTrigger/liftFromTrigger.test.ts b/packages/core/src/liftFromTrigger/liftFromTrigger.test.ts index d0db0379ea10b..dcff98c342869 100644 --- a/packages/core/src/liftFromTrigger/liftFromTrigger.test.ts +++ b/packages/core/src/liftFromTrigger/liftFromTrigger.test.ts @@ -37,8 +37,6 @@ function options( lift: SPRING, drop: SPRING, clear: SPRING, - shadowIn: SPRING, - shadowOut: SPRING, surfaceIn: SPRING, surfaceOut: SPRING, }, @@ -194,14 +192,15 @@ function expectNoDocumentListeners() { } describe('lift', () => { - it('adds the layer, its shadow and anchor, and one placeholder while lifted, whatever the source holds; the source keeps its place among its siblings', () => { + it('adds the layer, its anchor and one placeholder while lifted, and no shadow, whatever the source holds; the source keeps its place among its siblings', () => { const {source, before} = renderList(); const rest = count(); const lift = createLiftFromTrigger(); const result = lift.lift(source, options()); expect(result).not.toBeNull(); - // 13 elements lifted (the row and its 12), 4 added. - expect(count()).toBe(rest + 4); + // 13 elements lifted (the row and its 12), 3 added. + expect(count()).toBe(rest + 3); + expect(document.querySelector('[data-test-lift-shadow]')).toBeNull(); expect(source.previousElementSibling).toBe(before); const placeholder = source.nextElementSibling as HTMLElement; expect(placeholder.hasAttribute('data-test-lift-placeholder')).toBe(true); @@ -350,7 +349,8 @@ describe('lift', () => { const rest = count(); const lift = createLiftFromTrigger(); lift.lift(source, options()); - expect(count()).toBe(rest + 3); + // The layer and its anchor only. + expect(count()).toBe(rest + 2); lift.dispose(); }); diff --git a/packages/core/src/liftFromTrigger/liftFromTrigger.ts b/packages/core/src/liftFromTrigger/liftFromTrigger.ts index c37b8a8d8cf92..2ed5a51244894 100644 --- a/packages/core/src/liftFromTrigger/liftFromTrigger.ts +++ b/packages/core/src/liftFromTrigger/liftFromTrigger.ts @@ -55,10 +55,6 @@ export interface LiftFromTriggerMotion { readonly drop: SpringDescription; /** The tint clears on it. */ readonly clear: SpringDescription; - /** The shadow rises on it. */ - readonly shadowIn: SpringDescription; - /** The shadow fades on it. */ - readonly shadowOut: SpringDescription; /** A surface grows out of the press on it. */ readonly surfaceIn: SpringDescription; /** A surface shrinks back into the press on it. */ @@ -87,8 +83,6 @@ export interface LiftFromTriggerOptions { readonly release?: () => void; /** The tint, a CSS color resolved where the source is. */ readonly tint?: string; - /** The shadow under the lifted source, a CSS box shadow. */ - readonly shadow?: string; /** The inset an app declares for a bar over an edge (LayerProvider). */ readonly inset?: LayerInset; /** A finger lifted on the backdrop where it went down. */ @@ -210,18 +204,6 @@ interface LiftState { readonly growing: MotionHandle; /** The tint's color, held on the layer's `::backdrop`. */ readonly layerFill: Animation | null; - /** - * The shadow under the source, a box in the layer laid out where the source - * is. It follows the source frame for frame and fades on its own spring. - */ - readonly shadow: HTMLElement; - /** The shadow's rect morph, on the source's path and clock. */ - readonly shadowMorph: RectMorph; - /** - * Holds the clock of both morphs at one instant (null lets it run), so the - * shadow starts each motion where the source does. - */ - readonly holdClock: (at: number | null) => void; /** The layer's own waits and listeners, released with it. */ readonly waits: AbortController; untilPointersLift: () => Promise; @@ -340,8 +322,6 @@ function resolvedMotion( lift: resolve(motion.lift), drop: resolve(motion.drop), clear: resolve(motion.clear), - shadowIn: resolve(motion.shadowIn), - shadowOut: resolve(motion.shadowOut), surfaceIn: resolve(motion.surfaceIn), surfaceOut: resolve(motion.surfaceOut), }; @@ -502,7 +482,6 @@ export function createLiftFromTrigger(): LiftFromTrigger { } current.isSourceLifted = false; const {source} = current; - current.shadowMorph.dispose(); current.morph.dispose(); if (source.matches(':popover-open')) { source.hidePopover(); @@ -670,10 +649,9 @@ export function createLiftFromTrigger(): LiftFromTrigger { ? placeholderStyle(computed, sourceRect) : null; const direction = computed.direction === 'rtl' ? 'rtl' : 'ltr'; - const radius = shapeRadius(source); const motion = resolvedMotion(source, options.motion); - // The tint, the shadow and the layer runtime's gutter, with the inset an - // app declares, resolved where the source is. + // The tint and the layer runtime's gutter, with the inset an app + // declares, resolved where the source is. const probed = probe(source, { ...Object.fromEntries( Object.entries(layerInsetProperties(options.inset)).map( @@ -681,7 +659,6 @@ export function createLiftFromTrigger(): LiftFromTrigger { ), ), 'background-color': options.tint ?? 'var(--color-overlay)', - 'box-shadow': options.shadow ?? 'var(--shadow-high)', 'padding-top': [ `calc(${layerViewportInset.gutterBlockStartFallback})`, `calc(${layerViewportInset.gutterBlockStart})`, @@ -696,7 +673,6 @@ export function createLiftFromTrigger(): LiftFromTrigger { ], }); const tint = probed['background-color']; - const shadow = probed['box-shadow']; const viewport: LiftFromTriggerViewport = { width: doc.documentElement.clientWidth || view?.innerWidth || 0, height: view?.innerHeight ?? doc.documentElement.clientHeight, @@ -712,22 +688,6 @@ export function createLiftFromTrigger(): LiftFromTrigger { options.release?.(); const layer = createLayer(doc, attribute); doc.body.append(layer); - const shadowEl = doc.createElement('span'); - shadowEl.setAttribute(`${attribute}-shadow`, ''); - Object.assign(shadowEl.style, { - position: 'absolute', - left: `${sourceRect.x}px`, - top: `${sourceRect.y}px`, - width: `${sourceRect.width}px`, - height: `${sourceRect.height}px`, - borderRadius: radius, - boxShadow: shadow, - opacity: '0', - pointerEvents: 'none', - // Its keyframes scale about the top-left corner, as the source's do. - transformOrigin: '0 0', - }); - layer.append(shadowEl); const anchor = doc.createElement('span'); anchor.setAttribute(`${attribute}-anchor`, ''); Object.assign(anchor.style, { @@ -787,13 +747,7 @@ export function createLiftFromTrigger(): LiftFromTrigger { // The source grows from where the press left it into its lifted rect. It // stays laid out at its own size, so its content scales as UIKit's // snapshot does. Under Reduce Motion it lands at once and fades in. - let heldAt: number | null = null; - const holdClock = (at: number | null) => { - heldAt = at; - }; - const now = () => heldAt ?? performance.now(); - const morph = createRectMorph(source, {now}); - const shadowMorph = createRectMorph(shadowEl, {now}); + const morph = createRectMorph(source); // The layer first: the source shows above its tint. The popover makes // its `::backdrop` new, so the tint starts clear in the same task. const tints = createMotionEngine(layer, { @@ -803,26 +757,11 @@ export function createLiftFromTrigger(): LiftFromTrigger { layer.showPopover(); tints.set({opacity: 0}); source.showPopover(); - holdClock(performance.now()); const growing = morph.present( lerpPath(from, lifted, {box: sourceRect, scale: 'uniform'}), {spring: motion.lift}, {crossfadeAs: 'entrance'}, ); - // The shadow rises with the source, frame for frame: the same path, - // spring and clock. It fades in on its own spring. It is a box in the - // layer, because an animated `::backdrop` of the source would restyle and - // lay out the page on every frame. - shadowMorph.present( - lerpPath(from, lifted, { - box: sourceRect, - scale: 'uniform', - opacity: [0, 1], - }), - {spring: motion.lift}, - {fade: {spring: motion.shadowIn}, crossfadeAs: 'entrance'}, - ); - holdClock(null); // The tint: the layer's `::backdrop`, its color held there. const layerFill = holdBackdropColor(layer, tint); const current: LiftState = { @@ -840,9 +779,6 @@ export function createLiftFromTrigger(): LiftFromTrigger { morph, growing, layerFill, - shadow: shadowEl, - shadowMorph, - holdClock, waits, untilPointersLift: async () => {}, tints, @@ -1056,29 +992,12 @@ export function createLiftFromTrigger(): LiftFromTrigger { // The tint clears from what it shows now, with its velocity. current.tint = current.tints.animate({opacity: 0}, {spring: motion.clear}); // The source returns to its place from wherever it is now, with its - // velocity. Its shadow follows it back frame for frame: the same instant, - // path and spring. It fades on its own spring. Under Reduce Motion both - // fade where they are. - current.holdClock(performance.now()); - current.shadowMorph.dismiss( - lerpPath(sourceRect, lifted, { - box: sourceRect, - scale: 'uniform', - opacity: [0, 1], - }), - {spring: motion.drop}, - { - target: current.shadow, - fade: {spring: motion.shadowOut}, - crossfadeAs: 'exit', - }, - ); + // velocity. Under Reduce Motion it fades where it is. const returning = current.morph.dismiss( lerpPath(sourceRect, lifted, {box: sourceRect, scale: 'uniform'}), {spring: motion.drop}, {target: source, crossfadeAs: 'exit'}, ); - current.holdClock(null); const exit = current.surface?.dismissing; const settled = Promise.all([returning.finished, exit?.finished]); void settled.then(() => { From d2e52c026da8305d5a5fb50a0747cd1bdf30f4f1 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 9 Nov 2026 19:40:37 -0800 Subject: [PATCH 063/118] test(liftFromTrigger): the growth sample reads its animations at 30 ms The proof that the menu grows out of the press read the menu two frames after it began. One WebKit run under the CI profile drew it at 144 px then, past the 125 px bound, because a late frame let the growth run on. The sample now pauses the menu's own animations 30 ms in, reads the box, and ends the growth, so frame timing cannot move it. --- ...iftFromTrigger.interaction.browser.spec.ts | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts index 8b2ef8710f4b4..868ace6c7c5b4 100644 --- a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts +++ b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.interaction.browser.spec.ts @@ -425,11 +425,27 @@ test('the menu grows out of the press into its box and keeps nothing inline at r await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)), ); - const r = ( - document.getElementById('menu') as HTMLElement - ).getBoundingClientRect(); - return {x: r.x + r.width / 2, y: r.y + r.height / 2, width: r.width}; + // The growth 30 ms in, read from its own animations: a late frame + // cannot move the sample. Then the growth ends at once. + const menu = document.getElementById('menu') as HTMLElement; + const animations = menu.getAnimations(); + for (const animation of animations) { + animation.pause(); + animation.currentTime = 30; + } + const r = menu.getBoundingClientRect(); + for (const animation of animations) { + animation.finish(); + } + return { + x: r.x + r.width / 2, + y: r.y + r.height / 2, + width: r.width, + animations: animations.length, + }; }, press); + // The growth runs on the menu itself, so the sample is 30 ms into it. + expect(early.animations).toBeGreaterThan(0); // Small, and nearer the press than its own center. expect(early.width).toBeLessThan(125); const fromPress = Math.hypot(early.x - press.x, early.y - press.y); From bae645b8e7ab45e972383e51b7245fe74c31805d Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 9 Nov 2026 22:02:57 -0800 Subject: [PATCH 064/118] fix(liftFromTrigger): a source lifts at its layout width The lift pinned the source at `offsetWidth` and `offsetHeight`, which round. A source laid out a fraction of a pixel wider than its rounded width, such as a short label in a card, lost that fraction when lifted, and its text wrapped onto a second line past its box. The lift now pins the border box's size from the computed style, which keeps the fraction, and falls back to the offset size for a box with no used size in px. --- .../__tests__/liftFromTrigger.fixture.ts | 1 + ...iftFromTrigger.interaction.browser.spec.ts | 27 +++++++++++++ .../src/liftFromTrigger/liftFromTrigger.ts | 39 +++++++++++++++++-- 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.fixture.ts b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.fixture.ts index a2852ad904f32..eed1e23c9c57f 100644 --- a/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.fixture.ts +++ b/packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.fixture.ts @@ -77,6 +77,7 @@ document.body.innerHTML = `
+