From 3dd581e9cf96efc699363fc686e340c14b884132 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 3 Nov 2026 06:35:52 -0800 Subject: [PATCH 01/37] 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 02/37] 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 03/37] 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 04/37] 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 05/37] 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 06/37] 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 07/37] 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 08/37] 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 3fdca8fc1061ee9b2e42d2f814a57103abd63b3a Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 19:38:55 -0800 Subject: [PATCH 09/37] fix(rectMorph): AST-094 links the morph; the proof imports a safe test The morph's comment cited AST-094 FR45 as its only link to that record. AST-094 now lists the file, and the comment keeps the fact without the citation. The proof imports `test` from the a11y-spec package, whose contexts reach no remote host, as main's lint asks. --- docs/specs/AST-094-touch-presentations/spec.md | 1 + .../motion/__tests__/rectMorph.interaction.browser.spec.ts | 2 +- packages/core/src/motion/rectMorph.ts | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md index 068161c04fb61..05832f7f5e23b 100644 --- a/docs/specs/AST-094-touch-presentations/spec.md +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -35,6 +35,7 @@ verified_by: "packages/core/src/Layer/holdEntranceInput.ts", "packages/core/src/Layer/inputHold.ts", "packages/core/src/hooks/usePager.ts", + "packages/core/src/motion/rectMorph.ts", "packages/core/src/motion/uikit/segmented.ts", "packages/core/src/motion/uikit/slider.ts" ] diff --git a/packages/core/src/motion/__tests__/rectMorph.interaction.browser.spec.ts b/packages/core/src/motion/__tests__/rectMorph.interaction.browser.spec.ts index f4305542366ce..52070f692e438 100644 --- a/packages/core/src/motion/__tests__/rectMorph.interaction.browser.spec.ts +++ b/packages/core/src/motion/__tests__/rectMorph.interaction.browser.spec.ts @@ -25,7 +25,7 @@ import { traceRendering, trackMarker, } from '@astryxdesign/interaction-harness'; -import {expect, test, type Page} from '@playwright/test'; +import {expect, test, type Page} from '@astryxdesign/a11y-spec/playwright'; import {build} from 'esbuild'; // The page's `Motion` global (the bundle) is declared beside the animator's // proofs, in animator.interaction.browser.spec.ts. diff --git a/packages/core/src/motion/rectMorph.ts b/packages/core/src/motion/rectMorph.ts index 8daa485bb0354..f96ac28428a20 100644 --- a/packages/core/src/motion/rectMorph.ts +++ b/packages/core/src/motion/rectMorph.ts @@ -107,8 +107,8 @@ const INLINE_PROPERTIES = ['translate', 'scale', 'transform-origin'] as const; /** * 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. + * spring. The timing follows the component's motion record. 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. */ From d4903e2729a5f064954a9b63fc3f94266a36e489 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 20:06:16 -0800 Subject: [PATCH 10/37] test(SnapPaging): the pager's bundle lists the progress module The pager's timed settle takes its curve from motion/progress.ts, so the hook adds that module over Carousel alone. The bundle test lists it as pager code. --- scripts/snap-paging-bundle.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/snap-paging-bundle.test.mjs b/scripts/snap-paging-bundle.test.mjs index 6187b08061406..6241842062ca8 100644 --- a/scripts/snap-paging-bundle.test.mjs +++ b/scripts/snap-paging-bundle.test.mjs @@ -30,7 +30,7 @@ const PAGER_CODE = new RegExp( 'hooks/usePager\\.ts', 'SnapPaging/SnapPaging\\.ts', 'gestures/', - 'motion/(animator|paging|presentation|projection|reducedMotionPlan|resolveMotion|resolveSnapPoint|roleValue|rubberBand|spring|themeTempo)\\.ts', + 'motion/(animator|paging|presentation|progress|projection|reducedMotionPlan|resolveMotion|resolveSnapPoint|roleValue|rubberBand|spring|themeTempo)\\.ts', 'motion/uikit/(gestures|paging|scroll)\\.ts', 'Layer/inputHold\\.ts', 'utils/(clickGuard|isApplePlatform|primaryClick|selectionLock)\\.ts', From 1b794afbdb8d10e9d06e0d3ac2e4e66842df8682 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 23:50:34 -0800 Subject: [PATCH 11/37] test(motion): the popover's proofs read Popover's live geometry The morph's popover proofs played a copy of Popover's growth that had drifted: it lacked the overlap rule, so an overlapping popover grew from its left edge where Popover grows from its top. The proofs now import popoverCollapsedState, popoverFrame and frameOf from Popover. A new overlapping case, which the deleted copy fails, pins the growth from the top edge. --- packages/core/src/motion/morphPath.test.ts | 146 +++++++++------------ 1 file changed, 61 insertions(+), 85 deletions(-) diff --git a/packages/core/src/motion/morphPath.test.ts b/packages/core/src/motion/morphPath.test.ts index 2f1f7e630da21..ba05771518861 100644 --- a/packages/core/src/motion/morphPath.test.ts +++ b/packages/core/src/motion/morphPath.test.ts @@ -21,6 +21,12 @@ import { type MorphPath, type MorphRect, } from './morphPath'; +import { + frameOf, + popoverCollapsedState, + popoverFrame, + type PopoverCollapsedState, +} from '../Popover/popoverSurfaceFrame'; import {progressCurve} from './progress'; const SPRING = {response: 0.4, dampingRatio: 1}; @@ -148,67 +154,43 @@ describe('morphCurves', () => { // 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 is on main, so its proofs read its live geometry. -/** 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, - }; - }; +/** Popover's live geometry, in the morph's rect form. */ +function popoverState(surface: MorphRect, source: MorphRect) { + const box = (rect: MorphRect) => ({ + left: rect.x, + top: rect.y, + width: rect.width, + height: rect.height, + }); + return popoverCollapsedState({surface: box(surface), source: box(source)}); +} + +/** The rect a Popover frame shows: its scale about its origin. */ +function popoverRect( + surface: MorphRect, + state: PopoverCollapsedState, + frame: {scaleX: number; scaleY: number}, +): MorphRect { return { - state, - rectAt: (p: number) => - frameRect( - state.scale + (1 - state.scale) * p, - state.length + (1 - state.length) * p, - ), - frameRect, + x: surface.x + state.originX * (1 - frame.scaleX), + y: surface.y + state.originY * (1 - frame.scaleY), + width: surface.width * frame.scaleX, + height: surface.height * frame.scaleY, }; } +function popoverPath(surface: MorphRect, state: PopoverCollapsedState) { + return edgeGrowPath({ + box: surface, + origin: {x: state.originX, y: state.originY}, + axis: state.axis, + collapsedScale: state.scale, + collapsedLength: state.length, + }); +} + /** DropdownMenu (#8080, menuSurfaceMotion.ts): menuCollapsedState, scaled per axis about its origin. */ function menuModel(surface: MorphRect, anchor: {x: number; y: number}) { const collapsedSize = 22; @@ -323,56 +305,50 @@ describe('the presentations that move onto the morph are the same rect at every 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}, + overlapping: {x: 41, y: 100, width: 140, height: 200}, }; + // A popover that overlaps its source grows from below: from its top edge. + expect(popoverState(surfaces.overlapping, source)).toMatchObject({ + axis: 'y', + originY: 0, + }); 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, - }); + const state = popoverState(surface, source); + const path = popoverPath(surface, state); for (const p of PROGRESSES) { - expectRect(path.rectAt(p), model.rectAt(p), 0.5); - expect(path.opacityAt!(p)).toBeCloseTo(p, 9); + const frame = popoverFrame(state, p); + expectRect(path.rectAt(p), popoverRect(surface, state, frame), 0.5); + expect(path.opacityAt!(p)).toBeCloseTo(frame.opacity, 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, { + const state = popoverState(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, - }); + const path = popoverPath(surface, state); + const rectOf = (scale: number, length: number) => + popoverRect( + surface, + state, + frameOf({scale, length, opacity: 1}, state.axis), + ); // Mid-dismissal: scale 0.4, length 0.6 (readState), opacity 0.5. - const onScreen = model.frameRect(0.4, 0.6); + const onScreen = rectOf(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, - ); + // Popover's curves: each of scale, length and opacity on a line. + expectRect(back.rectAt(p), rectOf(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, - ), + rectOf(0.4 + (state.scale - 0.4) * p, 0.6 + (state.length - 0.6) * p), 0.5, ); expect(away.opacityAt!(p)).toBeCloseTo(0.5 - 0.5 * p, 9); From 3bfa524a35b90ffdc06a037bee1e60c2f83fe836 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sun, 8 Nov 2026 00:08:46 -0800 Subject: [PATCH 12/37] refactor(motion): one eased progress, with a retargeted velocity The rect morph's timedProgressCurve played, from rest, the curve that easedProgress already plays for Popover, SegmentedControl and the layout glide. easedProgress moves into progress.ts and takes an optional velocity: the Hermite carry that the pager and the morph use. From rest its values do not change. Its velocity is a central difference that stays inside the curve, and from rest it starts on the curve's own slope. timedProgressCurve goes, and timedCurve maps easedProgress onto values, keeping only its carry for no distance. --- .../core/src/Popover/popoverSurfaceMotion.ts | 8 +- .../useSegmentedSelectionMotion.ts | 3 +- packages/core/src/motion/paging.test.ts | 6 +- packages/core/src/motion/progress.test.ts | 89 ++++++++++- packages/core/src/motion/progress.ts | 149 ++++++++++++------ packages/core/src/motion/rectMorph.test.ts | 4 +- packages/core/src/motion/rectMorph.ts | 7 +- .../src/motion/themeTiming.server.test.ts | 1 - packages/core/src/motion/themeTiming.test.ts | 36 +---- packages/core/src/motion/themeTiming.ts | 44 +----- 10 files changed, 203 insertions(+), 144 deletions(-) diff --git a/packages/core/src/Popover/popoverSurfaceMotion.ts b/packages/core/src/Popover/popoverSurfaceMotion.ts index fb3ff9f304a2a..8d81b05bd0b0c 100644 --- a/packages/core/src/Popover/popoverSurfaceMotion.ts +++ b/packages/core/src/Popover/popoverSurfaceMotion.ts @@ -27,12 +27,8 @@ import { type MotionHandle, } from '../motion/animator'; import {holdInput} from '../Layer/inputHold'; -import {mapProgress, mapRange} from '../motion/progress'; -import { - easedProgress, - readThemeTiming, - type ThemeTimingTokens, -} from '../motion/themeTiming'; +import {easedProgress, mapProgress, mapRange} from '../motion/progress'; +import {readThemeTiming, type ThemeTimingTokens} from '../motion/themeTiming'; import {placePopoverExitCopy} from '../privateAttributes/popoverExit.stylex'; import { frameOf, diff --git a/packages/core/src/SegmentedControl/useSegmentedSelectionMotion.ts b/packages/core/src/SegmentedControl/useSegmentedSelectionMotion.ts index 5d760e4b22552..3c4a4462cb469 100644 --- a/packages/core/src/SegmentedControl/useSegmentedSelectionMotion.ts +++ b/packages/core/src/SegmentedControl/useSegmentedSelectionMotion.ts @@ -41,7 +41,8 @@ import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; import {observeResize} from '../utils/sharedResizeObserver'; import {titleOfSegment} from '../privateAttributes/segmentTitle.stylex'; import {mapProgress, mapRange} from '../motion/progress'; -import {easedProgress, readThemeTiming} from '../motion/themeTiming'; +import {easedProgress} from '../motion/progress'; +import {readThemeTiming} from '../motion/themeTiming'; import {useSegmentedTouchTracking} from './useSegmentedTouchTracking'; /** The segments, in DOM order. */ diff --git a/packages/core/src/motion/paging.test.ts b/packages/core/src/motion/paging.test.ts index 7f58d9647c06b..04b0683b5e6e5 100644 --- a/packages/core/src/motion/paging.test.ts +++ b/packages/core/src/motion/paging.test.ts @@ -414,7 +414,11 @@ describe('solveAnimatedPageScroll: setContentOffset(_:animated:)', () => { expect(curve.duration).toBe(0.3); expect(curve.at(0.3)).toBe(distance); expect(curve.velocity(0.3)).toBe(0); - expect(curve.velocity(0)).toBe(0); + // From rest it starts on its curve's own slope. + expect(curve.velocity(0)).toBeCloseTo( + (curve.at(1e-6) - curve.at(0)) / 1e-6, + 2, + ); } }); diff --git a/packages/core/src/motion/progress.test.ts b/packages/core/src/motion/progress.test.ts index 1d3535ac1ca66..e90fb12d92266 100644 --- a/packages/core/src/motion/progress.test.ts +++ b/packages/core/src/motion/progress.test.ts @@ -2,12 +2,14 @@ import {describe, expect, it} from 'vitest'; import type {MotionProperty} from './animator'; +import {cubicBezier} from './bezier'; import { + easedProgress, mapProgress, mapRange, presentedProgressVelocity, progressCurve, - timedProgressCurve, + timedCurve, type ProgressChannel, } from './progress'; import {solveSpring, toSpring} from './spring'; @@ -172,9 +174,39 @@ describe('presentedProgressVelocity', () => { }); }); -describe('timedProgressCurve', () => { +describe('easedProgress', () => { + it('eases from 0 to 1 over its length, and a curve of no length is at its end', () => { + const curve = easedProgress(0.2, x => x * x); + expect(curve.duration).toBe(0.2); + expect(curve.at(0)).toBe(0); + expect(curve.at(0.1)).toBeCloseTo(0.25, 6); + expect(curve.at(0.2)).toBe(1); + expect(curve.velocity(0.1)).toBeCloseTo(5, 3); + expect(curve.velocity(0.2)).toBe(0); + const none = easedProgress(0, x => x); + expect(none.at(0)).toBe(1); + expect(none.velocity(0)).toBe(0); + }); + + it('with jumps, a jump carries no speed and a smooth stretch keeps its slope', () => { + const steps = (x: number) => Math.floor(4 * x) / 4; + // Without the option, the jump at a quarter of 300 ms reads as a spike. + expect(easedProgress(0.3, steps).velocity(0.075)).toBeGreaterThan(100); + const jumping = easedProgress(0.3, steps, {jumps: true}); + for (const t of [0, 0.0749, 0.075, 0.0751, 0.15, 0.225, 0.2995]) { + expect(jumping.velocity(t), `${t} s`).toBe(0); + } + expect( + easedProgress(0.2, x => x * x, {jumps: true}).velocity(0.1), + ).toBeCloseTo(5, 1); + }); +}); + +describe('easedProgress with a retargeted velocity', () => { + const EASE = cubicBezier([0.25, 0.1, 0.25, 1]); + 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]); + const curve = easedProgress(0.3, EASE); expect(curve.duration).toBeCloseTo(0.3, 9); expect(curve.at(0)).toBeCloseTo(0, 9); expect(curve.at(0.15)).toBeGreaterThan(0.5); @@ -185,17 +217,64 @@ describe('timedProgressCurve', () => { }); 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}); + const curve = easedProgress(0.3, EASE, {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); + // Its own velocity numerics follow the curve it plays. + for (const t of [0.01, 0.1, 0.2, 0.29]) { + expect(curve.velocity(t), `${t} s`).toBeCloseTo( + (curve.at(t + 1e-6) - curve.at(t - 1e-6)) / 2e-6, + 1, + ); + } + }); + + it('from rest is the eased curve itself', () => { + const curve = easedProgress(0.3, EASE, {}); + for (const t of [0, 0.05, 0.15, 0.25]) { + expect(curve.at(t)).toBe(EASE(t / 0.3)); + } }); it('lands at once for a zero duration', () => { - const curve = timedProgressCurve(0, [0, 0, 1, 1]); + const curve = easedProgress(0, EASE, {velocity: 6}); expect(curve.duration).toBe(0); expect(curve.at(0)).toBe(1); }); }); + +describe('timedCurve', () => { + it('is easedProgress on its values, with the velocity in those units', () => { + const curve = timedCurve({ + from: 100, + to: 400, + durationMs: 300, + easing: [0.25, 0.1, 0.25, 1], + velocity: 1800, + }); + const progress = easedProgress(0.3, cubicBezier([0.25, 0.1, 0.25, 1]), { + velocity: 6, + }); + for (const t of [0, 0.05, 0.15, 0.3]) { + expect(curve.at(t)).toBeCloseTo(100 + 300 * progress.at(t), 9); + } + expect(curve.velocity(0)).toBe(1800); + }); + + it('with no distance, only the carry plays and it comes back on time', () => { + const curve = timedCurve({ + from: 50, + to: 50, + durationMs: 300, + easing: [0, 0, 1, 1], + velocity: 600, + }); + expect(curve.velocity(0)).toBe(600); + expect(curve.at(0.1)).toBeGreaterThan(50); + expect(curve.at(0.3)).toBe(50); + expect(curve.velocity(0.3)).toBe(0); + }); +}); diff --git a/packages/core/src/motion/progress.ts b/packages/core/src/motion/progress.ts index bd2cfdb68a938..ce29886e532f6 100644 --- a/packages/core/src/motion/progress.ts +++ b/packages/core/src/motion/progress.ts @@ -3,12 +3,13 @@ /** * @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), `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) + * @output Exports `progressCurve`, a spring's progress from 0 to 1 as a + * MotionCurve. Exports `easedProgress`, a duration and easing that keeps a + * retargeted velocity, and `timedCurve`, that curve on values. Exports + * `mapProgress`, one curve as a function of another, and `mapRange`, the + * clamped linear map most companions use. Exports + * `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 @@ -71,15 +72,71 @@ export function progressCurve( }; } +/** The step that reads an easing's starting slope. */ +const START_STEP = 1e-4; + /** - * A timed motion from `from` to `to` over `durationMs`, eased by the - * cubic-bézier `easing`: the curve a theme duration and easing play. + * The eased progress from 0 to 1 over `seconds`, as a MotionCurve whose + * velocity is in progress per second. A curve of no length is at its end. + * With `jumps`, a jump in the easing, as in `steps()`, carries no speed. The + * gentler of the slopes just before and just after counts. * - * `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 + * `velocity` (progress per second) is the velocity of a motion this one + * retargets. The curve starts at it and still ends at 1, 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. + * term, v × seconds × u(1 − u)², less the easing's own starting slope. A + * motion from rest is then the eased curve itself. + */ +export function easedProgress( + seconds: number, + ease: (x: number) => number, + { + jumps = false, + velocity, + }: {readonly jumps?: boolean; readonly velocity?: number} = {}, +): MotionCurve { + const carry = + velocity == null || seconds <= 0 + ? 0 + : velocity * seconds - ease(START_STEP) / START_STEP; + const at = (t: number) => { + if (seconds <= 0 || t >= seconds) { + return 1; + } + const u = Math.max(0, t) / seconds; + return carry === 0 ? ease(u) : ease(u) + carry * u * (1 - u) * (1 - u); + }; + return { + duration: seconds, + at, + velocity: t => { + if (seconds <= 0 || t >= seconds) { + return 0; + } + if (t <= 0) { + // From rest, the curve's own starting slope, to second order. + const h = Math.min(1e-3, seconds / 100); + return velocity ?? (4 * at(h) - 3 * at(0) - at(2 * h)) / (2 * h); + } + // A central difference that stays inside the curve. + const h = Math.min(1e-4, t / 2, (seconds - t) / 2); + if (!jumps) { + return (at(t + h) - at(t - h)) / (2 * h); + } + const here = at(t); + const before = (here - at(t - h)) / h; + const after = (at(t + h) - here) / h; + return Math.abs(after) < Math.abs(before) ? after : before; + }, + }; +} + +/** + * A timed motion from `from` to `to` over `durationMs`, eased by the + * cubic-bézier `easing`: `easedProgress` on those values. `velocity` (units + * per second) is the velocity of a motion this one retargets, which the + * curve starts at. With no distance to cover, only that carry plays: the + * curve leaves and comes back to `to`, at rest and on time. */ export function timedCurve({ from, @@ -94,50 +151,40 @@ export function timedCurve({ easing: BezierPoints; velocity?: number; }): MotionCurve { - const duration = Math.max(0, durationMs) / 1000; - if (duration === 0) { - return {duration, at: () => to, velocity: () => 0}; - } + const seconds = Math.max(0, durationMs) / 1000; const distance = to - from; - const ease = cubicBezier(easing); - 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); - }; + if (distance === 0 && seconds > 0 && velocity != null && velocity !== 0) { + const carry = velocity * seconds; + return { + duration: seconds, + at: t => { + const u = Math.min(1, Math.max(0, t / seconds)); + return from + carry * u * (1 - u) * (1 - u); + }, + velocity: t => { + if (t <= 0) { + return velocity; + } + if (t >= seconds) { + return 0; + } + const u = t / seconds; + return velocity * (1 - u) * (1 - 3 * u); + }, + }; + } + const progress = easedProgress( + seconds, + cubicBezier(easing), + velocity == null || distance === 0 ? {} : {velocity: velocity / distance}, + ); 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); - }, + duration: seconds, + at: t => from + distance * progress.at(t), + velocity: t => distance * progress.velocity(t), }; } -/** - * `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 111beaa1da8ed..2e75ae58d12e7 100644 --- a/packages/core/src/motion/rectMorph.test.ts +++ b/packages/core/src/motion/rectMorph.test.ts @@ -16,7 +16,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {createMotionEngine} from './animator'; import {cubicBezier} from './bezier'; -import {progressCurve, timedProgressCurve} from './progress'; +import {easedProgress, progressCurve} from './progress'; import { createRectMorph, edgeGrowPath, @@ -282,7 +282,7 @@ describe('createRectMorph', () => { morph.present(path, {duration: 300, easing}, {velocity: 6}); await playFrame(); expect(live(element, 'scale').length).toBe(1); - const progress = timedProgressCurve(300, easing, {velocity: 6}); + const progress = easedProgress(0.3, cubicBezier(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 f96ac28428a20..a6c311344930e 100644 --- a/packages/core/src/motion/rectMorph.ts +++ b/packages/core/src/motion/rectMorph.ts @@ -28,11 +28,12 @@ import { type MotionValues, } from './animator'; import { + easedProgress, presentedProgressVelocity, progressCurve, - timedProgressCurve, type ProgressChannel, } from './progress'; +import {cubicBezier} from './bezier'; import { lerpPath, morphCurves, @@ -325,7 +326,9 @@ export function createRectMorph( ) => 'spring' in motion ? progressCurve(resolveMotion(on, motion.spring), {velocity}) - : timedProgressCurve(motion.duration, motion.easing, {velocity}); + : easedProgress(motion.duration / 1000, cubicBezier(motion.easing), { + velocity, + }); const curvesFor = ( on: Element, diff --git a/packages/core/src/motion/themeTiming.server.test.ts b/packages/core/src/motion/themeTiming.server.test.ts index 8996658744248..0adcec7a7103e 100644 --- a/packages/core/src/motion/themeTiming.server.test.ts +++ b/packages/core/src/motion/themeTiming.server.test.ts @@ -38,7 +38,6 @@ describe('the theme-timing reader on the server', () => { it('imports with no window or document', () => { expect(importOnServer(path.join(__dirname, 'themeTiming.ts'))).toEqual([ 'declaredTimingTokens', - 'easedProgress', 'readThemeTiming', 'readThemeTokenText', 'readThemeTokens', diff --git a/packages/core/src/motion/themeTiming.test.ts b/packages/core/src/motion/themeTiming.test.ts index e07eb74f9103d..f6e3c280e0b80 100644 --- a/packages/core/src/motion/themeTiming.test.ts +++ b/packages/core/src/motion/themeTiming.test.ts @@ -2,13 +2,12 @@ /** * @file themeTiming.test.ts - * @input vitest (jsdom), themeTiming's readers and easedProgress, the - * theme's duration and easing defaults + * @input vitest (jsdom), themeTiming's readers, the theme's duration and + * easing defaults * @output Checks how a motion reads its timing: from the element's own * transition, and from tokens set where the element is or at their * defaults. A default that refers to a token reads it there. Also checks - * the values that give no curve, the progress velocity and easedProgress's - * curve, and that a jump in an easing carries no speed. jsdom resolves no + * the values that give no curve and the progress velocity. jsdom resolves no * `calc()` and plays no `linear()` or `steps()`: a stand-in plays one, and * the browser proof covers both. * @position Testing; validates themeTiming.ts @@ -19,7 +18,6 @@ import {cubicBezier} from './bezier'; import {parseCssEasing} from './roles'; import { declaredTimingTokens, - easedProgress, readThemeTiming, readThemeTokens, resolveTiming, @@ -203,31 +201,3 @@ describe('resolveTiming', () => { expect(document.body.children).toHaveLength(1); }); }); - -describe('easedProgress', () => { - it('eases from 0 to 1 over its length, and a curve of no length is at its end', () => { - const curve = easedProgress(0.2, x => x * x); - expect(curve.duration).toBe(0.2); - expect(curve.at(0)).toBe(0); - expect(curve.at(0.1)).toBeCloseTo(0.25, 6); - expect(curve.at(0.2)).toBe(1); - expect(curve.velocity(0.1)).toBeCloseTo(5, 3); - expect(curve.velocity(0.2)).toBe(0); - const none = easedProgress(0, x => x); - expect(none.at(0)).toBe(1); - expect(none.velocity(0)).toBe(0); - }); - - it('with jumps, a jump carries no speed and a smooth stretch keeps its slope', () => { - const steps = (x: number) => Math.floor(4 * x) / 4; - // Without the option, the jump at a quarter of 300 ms reads as a spike. - expect(easedProgress(0.3, steps).velocity(0.075)).toBeGreaterThan(100); - const jumping = easedProgress(0.3, steps, {jumps: true}); - for (const t of [0, 0.0749, 0.075, 0.0751, 0.15, 0.225, 0.2995]) { - expect(jumping.velocity(t), `${t} s`).toBe(0); - } - expect( - easedProgress(0.2, x => x * x, {jumps: true}).velocity(0.1), - ).toBeCloseTo(5, 1); - }); -}); diff --git a/packages/core/src/motion/themeTiming.ts b/packages/core/src/motion/themeTiming.ts index e7069f7da0138..ca5720e2f2879 100644 --- a/packages/core/src/motion/themeTiming.ts +++ b/packages/core/src/motion/themeTiming.ts @@ -8,8 +8,7 @@ * tokens where the element is. It plays an easing that is not a cubic * Bézier as the browser plays it. readThemeTokens and resolveTiming: that * duration and easing as values. readThemeTokenText: a token's text where - * the element is. easedProgress: the curve for a length and an easing - * already read. + * the element is. * @position Motion foundation, internal to core. Motion that a tap, a key or * the program starts reads its duration and easing from the theme with it. * @@ -19,6 +18,7 @@ import type {MotionCurve} from './animator'; import {cubicBezier} from './bezier'; +import {easedProgress} from './progress'; import {parseCssEasing, parseMotionRoleValue} from './roles'; import type {MotionCurvePoints} from './roleValue'; import {resolveTokenReferences} from '../theme/tokenReferences'; @@ -61,46 +61,6 @@ export interface ResolvedTiming { readonly points: MotionCurvePoints | null; } -/** - * The eased progress from 0 to 1 over `seconds`, as a MotionCurve whose - * velocity is in progress per second. A curve of no length is at its end. - * With `jumps`, a jump in the easing, as in `steps()`, carries no speed. The - * gentler of the slopes just before and just after counts. - */ -export function easedProgress( - seconds: number, - ease: (x: number) => number, - {jumps = false}: {readonly jumps?: boolean} = {}, -): MotionCurve { - const at = (t: number) => - seconds <= 0 || t >= seconds ? 1 : ease(Math.max(0, t) / seconds); - // A central difference, one step inside the curve at either end. - const step = Math.min(1e-3, seconds / 100); - return { - duration: seconds, - at, - velocity: t => { - if (seconds <= 0 || t >= seconds) { - return 0; - } - const before = Math.max(0, t - step); - const after = Math.min(seconds, t + step); - if (!jumps) { - return (at(after) - at(before)) / (after - before); - } - const here = at(t); - const slopes: number[] = []; - if (t > before) { - slopes.push((here - at(before)) / (t - before)); - } - if (after > t) { - slopes.push((at(after) - here) / (after - t)); - } - return slopes.reduce((a, b) => (Math.abs(b) < Math.abs(a) ? b : a)); - }, - }; -} - function firstDurationMs(style: CSSStyleDeclaration): number | null { return parseMotionRoleValue( 'duration', From ad7f8fbf2893e38d4b54a4db72cb7d65543053ea Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sun, 8 Nov 2026 00:24:05 -0800 Subject: [PATCH 13/37] test(motion): the popover's turn-around reads Popover's live curves The morph's turn-around proof compared restartFrom with lines written in the test, so a change to Popover's readState or surfaceCurves left it green. Popover exports both for the proofs, which now replay its own turn-around. morphPath.ts's header keeps only its tags: the paths' own JSDoc says the rest. --- .../core/src/Popover/popoverSurfaceMotion.ts | 17 +++++-- packages/core/src/motion/morphPath.test.ts | 49 ++++++++++++------- packages/core/src/motion/morphPath.ts | 16 ------ 3 files changed, 46 insertions(+), 36 deletions(-) diff --git a/packages/core/src/Popover/popoverSurfaceMotion.ts b/packages/core/src/Popover/popoverSurfaceMotion.ts index fb3ff9f304a2a..df4a0757584fd 100644 --- a/packages/core/src/Popover/popoverSurfaceMotion.ts +++ b/packages/core/src/Popover/popoverSurfaceMotion.ts @@ -56,8 +56,11 @@ export type { /** * The curves from `from` to `to` as `progress` moves from 0 to 1. The scale * and the length move together, so the scale along the axis is their product. + * + * @internal Read by the rect morph's popover proofs; not exported from the + * package. */ -function surfaceCurves( +export function surfaceCurves( from: SurfaceState, to: SurfaceState, axis: 'x' | 'y', @@ -82,8 +85,16 @@ function timingOn(element: Element, tokens: ThemeTimingTokens): MotionCurve { return readThemeTiming(element, tokens) ?? easedProgress(0, x => x); } -/** What `animator` shows now, split back into scale and length. */ -function readState(animator: Animator, axis: 'x' | 'y'): SurfaceState { +/** + * What `animator` shows now, split back into scale and length. + * + * @internal Read by the rect morph's popover proofs; not exported from the + * package. + */ +export function readState( + animator: Pick, + axis: 'x' | 'y', +): SurfaceState { const across = animator.get(axis === 'x' ? 'scaleY' : 'scaleX'); const along = animator.get(axis === 'x' ? 'scaleX' : 'scaleY'); return { diff --git a/packages/core/src/motion/morphPath.test.ts b/packages/core/src/motion/morphPath.test.ts index ba05771518861..f30278851c6f7 100644 --- a/packages/core/src/motion/morphPath.test.ts +++ b/packages/core/src/motion/morphPath.test.ts @@ -25,8 +25,10 @@ import { frameOf, popoverCollapsedState, popoverFrame, + PRESENTED, type PopoverCollapsedState, } from '../Popover/popoverSurfaceFrame'; +import {readState, surfaceCurves} from '../Popover/popoverSurfaceMotion'; import {progressCurve} from './progress'; const SPRING = {response: 0.4, dampingRatio: 1}; @@ -332,26 +334,39 @@ describe('the presentations that move onto the morph are the same rect at every height: 44, }); const path = popoverPath(surface, state); - const rectOf = (scale: number, length: number) => - popoverRect( - surface, - state, - frameOf({scale, length, opacity: 1}, state.axis), - ); - // Mid-dismissal: scale 0.4, length 0.6 (readState), opacity 0.5. - const onScreen = rectOf(0.4, 0.6); + // Mid-dismissal: scale 0.4, length 0.6, opacity 0.5 on screen. + const shown = frameOf({scale: 0.4, length: 0.6, opacity: 0.5}, state.axis); + const onScreen = popoverRect(surface, state, shown); + // Popover's own turn-around: it reads that frame back and plays on. + const from = readState( + {get: (property: string) => shown[property as keyof typeof shown]}, + state.axis, + ); + const linear: MotionCurve = {duration: 1, at: t => t, velocity: () => 1}; + const toRest = surfaceCurves(from, PRESENTED, state.axis, linear); + const toSource = surfaceCurves( + from, + {scale: state.scale, length: state.length, opacity: 0}, + state.axis, + linear, + ); 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]) { - // Popover's curves: each of scale, length and opacity on a line. - expectRect(back.rectAt(p), rectOf(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), - rectOf(0.4 + (state.scale - 0.4) * p, 0.6 + (state.length - 0.6) * p), - 0.5, - ); - expect(away.opacityAt!(p)).toBeCloseTo(0.5 - 0.5 * p, 9); + for (const [morph, live] of [ + [back, toRest], + [away, toSource], + ] as const) { + expectRect( + morph.rectAt(p), + popoverRect(surface, state, { + scaleX: live.scaleX.at(p), + scaleY: live.scaleY.at(p), + }), + 0.5, + ); + expect(morph.opacityAt!(p)).toBeCloseTo(live.opacity.at(p), 9); + } } }); diff --git a/packages/core/src/motion/morphPath.ts b/packages/core/src/motion/morphPath.ts index 622f2720b4215..a5c48e905b101 100644 --- a/packages/core/src/motion/morphPath.ts +++ b/packages/core/src/motion/morphPath.ts @@ -14,22 +14,6 @@ * 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 From 33a3ee89bf21f7c0aab04e44db5c13c6b01fbb17 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sun, 8 Nov 2026 00:52:45 -0800 Subject: [PATCH 14/37] docs(changeset): the rect morph's note names the eased-progress move The note said timedProgressCurve joins the progress helpers, which this pull request no longer adds. It now says easedProgress moves to them with a retargeted velocity, and timedCurve joins them. --- .changeset/motion-rect-morph.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/motion-rect-morph.md b/.changeset/motion-rect-morph.md index e7003e85f8e4b..b8bdc4c49e1a1 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. 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. +[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. `easedProgress` moves to the progress helpers and keeps a retargeted velocity; `timedCurve` joins them. Not exported from the package entry point; no component uses it yet. @vjeux From 822013a38081dacb5ec9d9b70259949957678933 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sun, 8 Nov 2026 23:55:36 -0800 Subject: [PATCH 15/37] test(motion): the popover's turn-around compares opacity as drawn Popover's surface curves now stop their opacity at 0 or a floor above it. Past its end, a turn-around toward the source takes the popover path's opacity below 0, where Popover's stops at 0. An opacity draws clamped to 0 through 1, so both draw 0. The proof now compares the two as drawn, which holds with Popover's curves before and after the floor. --- packages/core/src/motion/morphPath.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/core/src/motion/morphPath.test.ts b/packages/core/src/motion/morphPath.test.ts index f30278851c6f7..7e7e1e68ba7b8 100644 --- a/packages/core/src/motion/morphPath.test.ts +++ b/packages/core/src/motion/morphPath.test.ts @@ -352,6 +352,10 @@ describe('the presentations that move onto the morph are the same rect at every ); const back = path.restartFrom!(onScreen, 0.5, 'rest'); const away = path.restartFrom!(onScreen, 0.5, 'source'); + // An opacity draws clamped to 0 through 1. Past its end, a turn-around + // toward the source takes the path below 0, where Popover's curves stop + // at 0. Both draw 0, so the proof compares them as drawn. + const drawn = (opacity: number) => Math.min(1, Math.max(0, opacity)); for (const p of [0, 0.25, 0.5, 0.75, 1, 1.05]) { for (const [morph, live] of [ [back, toRest], @@ -365,7 +369,10 @@ describe('the presentations that move onto the morph are the same rect at every }), 0.5, ); - expect(morph.opacityAt!(p)).toBeCloseTo(live.opacity.at(p), 9); + expect(drawn(morph.opacityAt!(p))).toBeCloseTo( + drawn(live.opacity.at(p)), + 9, + ); } } }); From 6354fa3fe4dcdc57b324eb93ca02aaa3276f56b3 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 9 Nov 2026 19:55:25 -0800 Subject: [PATCH 16/37] fix(motion): a popover's morph never reads an opacity below 0 A popover's grow path read its opacity on a straight line, so before its start and past its end it went below 0: a turn-around toward the source read -0.025 at 1.05. An opacity below 0 draws as 0, so opacityAt now stops at 0. The popover proofs compare opacities as drawn, since Popover's own model still reads below 0 there. --- packages/core/src/motion/morphPath.test.ts | 35 ++++++++++++++++++---- packages/core/src/motion/morphPath.ts | 4 ++- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/core/src/motion/morphPath.test.ts b/packages/core/src/motion/morphPath.test.ts index 7e7e1e68ba7b8..01b2392147cd5 100644 --- a/packages/core/src/motion/morphPath.test.ts +++ b/packages/core/src/motion/morphPath.test.ts @@ -285,6 +285,12 @@ function cardModel(box: MorphRect) { /** 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); +/** + * An opacity as drawn: clamped to 0 through 1. A popover's path stops at 0, + * while Popover's own model reads below 0 before its start and past its end. + */ +const drawn = (opacity: number) => Math.min(1, Math.max(0, opacity)); + 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}; @@ -320,7 +326,7 @@ describe('the presentations that move onto the morph are the same rect at every for (const p of PROGRESSES) { const frame = popoverFrame(state, p); expectRect(path.rectAt(p), popoverRect(surface, state, frame), 0.5); - expect(path.opacityAt!(p)).toBeCloseTo(frame.opacity, 9); + expect(path.opacityAt!(p)).toBeCloseTo(Math.max(0, frame.opacity), 9); } } }); @@ -352,10 +358,6 @@ describe('the presentations that move onto the morph are the same rect at every ); const back = path.restartFrom!(onScreen, 0.5, 'rest'); const away = path.restartFrom!(onScreen, 0.5, 'source'); - // An opacity draws clamped to 0 through 1. Past its end, a turn-around - // toward the source takes the path below 0, where Popover's curves stop - // at 0. Both draw 0, so the proof compares them as drawn. - const drawn = (opacity: number) => Math.min(1, Math.max(0, opacity)); for (const p of [0, 0.25, 0.5, 0.75, 1, 1.05]) { for (const [morph, live] of [ [back, toRest], @@ -377,6 +379,29 @@ describe('the presentations that move onto the morph are the same rect at every } }); + it('a popover never reads an opacity below 0, before its start or past its end', () => { + const surface = {x: 41, y: 204, width: 320, height: 253}; + const state = popoverState(surface, { + x: 141, + y: 160, + width: 120, + height: 44, + }); + const path = popoverPath(surface, state); + const shown = frameOf({scale: 0.4, length: 0.6, opacity: 0.5}, state.axis); + const away = path.restartFrom!( + popoverRect(surface, state, shown), + 0.5, + 'source', + ); + for (const p of [-0.1, -0.05, 0]) { + expect(path.opacityAt!(p)).toBe(0); + } + for (const p of [1, 1.05, 1.1]) { + expect(away.opacityAt!(p)).toBe(0); + } + }); + 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; diff --git a/packages/core/src/motion/morphPath.ts b/packages/core/src/motion/morphPath.ts index a5c48e905b101..0a528093c3c46 100644 --- a/packages/core/src/motion/morphPath.ts +++ b/packages/core/src/motion/morphPath.ts @@ -143,7 +143,9 @@ function growPath( height: box.height * scaleY, }; }, - opacityAt: p => stateAt(p).opacity, + // Before its start and past its end, the straight line reads below 0, + // which draws as 0. + opacityAt: p => Math.max(0, 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; From 75391adb299d4efaac87a0b3e3f8dbdfd122b3e8 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 9 Nov 2026 19:57:12 -0800 Subject: [PATCH 17/37] feat(DropdownMenu): a finger or a pen opens the menu as it presses Under a finger or a pen, a DropdownMenu or MoreMenu trigger opens its menu as it is pressed, instead of after a 500 ms hold (spec:AST-094 FR29, FR43). Inside a scroll view it opens once the 150 ms content-touch delay passes with no scroll, so a scroll that starts on the trigger still scrolls. The finger that opened the menu picks a row only after moving 10 px. presentation="popover" keeps the previous behavior. useMenuPress with no longPressDelayMs follows the same rules, and longPressDelayMs: 500 keeps the previous hold. --- .changeset/menu-press-public-default.md | 7 + .changeset/menu-press-uimenu.md | 7 + .../DropdownMenu/DropdownMenu.spec.md | 71 ++- packages/core/api-report.md | 2 +- .../core/src/Breadcrumbs/BreadcrumbItem.tsx | 4 +- packages/core/src/ContextMenu/ContextMenu.tsx | 4 +- .../src/DropdownMenu/DropdownMenu.doc.mjs | 4 +- .../src/DropdownMenu/DropdownMenu.test.tsx | 228 +++++++++- .../core/src/DropdownMenu/DropdownMenu.tsx | 21 +- .../src/DropdownMenu/DropdownMenuSubMenu.tsx | 4 +- .../MenuBottomSheetActionList.tsx | 4 +- ...MenuTouchPress.interaction.browser.spec.ts | 396 ++++++++++++++++ .../__tests__/MenuPress.a11y.chromium.spec.ts | 36 ++ packages/core/src/MoreMenu/MoreMenu.doc.mjs | 4 +- packages/core/src/MoreMenu/MoreMenu.tsx | 6 +- packages/core/src/NavMenu/NavHeadingMenu.tsx | 4 +- packages/core/src/Selector/Selector.tsx | 4 +- packages/core/src/SideNav/SideNavHeading.tsx | 4 +- packages/core/src/TabList/TabMenu.tsx | 4 +- packages/core/src/TopNav/TopNavHeading.tsx | 4 +- packages/core/src/TopNav/TopNavMenu.tsx | 4 +- packages/core/src/hooks/menuPressGesture.ts | 26 +- .../core/src/hooks/menuPressValues.test.ts | 13 +- packages/core/src/hooks/menuPressValues.ts | 18 +- packages/core/src/hooks/menuScrollView.ts | 22 + packages/core/src/hooks/menuTriggerPress.ts | 226 +++++++++ packages/core/src/hooks/useMenuPress.doc.mjs | 26 +- packages/core/src/hooks/useMenuPress.test.tsx | 427 +++++++++++++++++- packages/core/src/hooks/useMenuPress.ts | 355 ++++++++------- packages/core/src/motion/MEASUREMENTS.md | 8 +- packages/core/src/motion/uikit/gestures.ts | 21 +- 31 files changed, 1661 insertions(+), 303 deletions(-) create mode 100644 .changeset/menu-press-public-default.md create mode 100644 .changeset/menu-press-uimenu.md create mode 100644 packages/core/src/DropdownMenu/__tests__/DropdownMenuTouchPress.interaction.browser.spec.ts create mode 100644 packages/core/src/hooks/menuScrollView.ts create mode 100644 packages/core/src/hooks/menuTriggerPress.ts diff --git a/.changeset/menu-press-public-default.md b/.changeset/menu-press-public-default.md new file mode 100644 index 0000000000000..050b2f23dca26 --- /dev/null +++ b/.changeset/menu-press-public-default.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feature] `useMenuPress` with no `longPressDelayMs` now follows DropdownMenu's finger rules on every device (`spec:AST-094` FR25, FR29, FR43): a finger or a pen opens the menu as it presses (inside a scroll view, once the 150 ms content-touch delay has passed), and the finger that opened it picks a row only after moving 10 px. To keep the previous behavior, a 500 ms hold and a pick at the opening finger's lift, pass `longPressDelayMs: 500`. + +@vjeux diff --git a/.changeset/menu-press-uimenu.md b/.changeset/menu-press-uimenu.md new file mode 100644 index 0000000000000..adf1c6b6cd86f --- /dev/null +++ b/.changeset/menu-press-uimenu.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feature] Under a finger or a pen, on every device, a DropdownMenu or MoreMenu trigger now opens its menu as it is pressed, instead of after a 500 ms hold (`spec:AST-094` FR29, FR43). Inside a scroll view it opens once the 150 ms content-touch delay has passed with no scroll, so a scroll that starts on it still scrolls. To keep the previous behavior, a tap that opens at the lift and a 500 ms hold, pass `presentation="popover"`. A mouse is unchanged. + +@vjeux diff --git a/docs/specs/components/DropdownMenu/DropdownMenu.spec.md b/docs/specs/components/DropdownMenu/DropdownMenu.spec.md index d93a49e5e7977..ab1b071fb336a 100644 --- a/docs/specs/components/DropdownMenu/DropdownMenu.spec.md +++ b/docs/specs/components/DropdownMenu/DropdownMenu.spec.md @@ -16,6 +16,7 @@ verified_by: packages/core/src/DropdownMenu/DropdownMenuSubMenu.test.tsx, packages/core/src/DropdownMenu/__tests__/DropdownMenuDrillIn.a11y.chromium.spec.ts, packages/core/src/DropdownMenu/__tests__/MenuPress.a11y.chromium.spec.ts, + packages/core/src/DropdownMenu/__tests__/DropdownMenuTouchPress.interaction.browser.spec.ts, packages/core/src/DropdownMenu/__tests__/MenuHeaderFooter.a11y.chromium.spec.ts, packages/core/src/DropdownMenu/__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts, packages/core/src/ContextMenu/ContextMenu.test.tsx, @@ -39,15 +40,15 @@ system_specs: [spec:AST-020/FR15, spec:AST-094, spec:AST-102] ## Contract at a glance -| Area | Contract | -| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Public contract | None changes. DropdownMenu takes a `button` (Button props) or `renderTrigger`, data-driven `items` or compound children, `presentation`, `isMenuOpen` and `onOpenChange`, placement and alignment, `menuMaxHeight`, `header`, and `footer`. | -| Behavior | A Menu Button trigger opens an anchored pointer menu or a bottom-sheet action list. Rows act on the release under the shared press model; sub-menus fly out beside their row or drill in on a compact touch display; a header or footer makes the pointer popover a modal dialog around the menu. | -| End-user impact | People pick actions by mouse, finger, or keyboard; keyboard users get a focus return with a ring and pointer users without one; on a phone sub-menus replace the rows instead of flying off-screen. Under a finger the menu opens after a 500 ms hold or at a tap's click, does not grow from its trigger, and a pan outside does not close it. | -| Builder impact | Builders choose the trigger, the rows, and the regions; rows stay menu items and every non-item control goes in `header` or `footer`. | -| 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. Finger press-open (FR8), touch grow and pan dismissal (FR21), the still mouse (FR22), and Trigger Button gaps are in Current-state impact. | -| Review checks | Reject a control placed among the rows, a lost trigger ARIA wiring, a drilled-in view that leaves the menu box, a header or footer without the dialog, a lost focus-return modality, or a target moved off its painted element. | -| Governing rules | `spec:AST-020` FR15; `spec:AST-094` FR26–FR35; `module:DropdownMenu/useMenuPress`; `architecture:interaction-modality` INV1; `architecture:component-theming-surface`; `architecture:layer-runtime`; `family:overlay-dismissal`; `family:navigation-destinations`. | +| Area | Contract | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public contract | None changes. DropdownMenu takes a `button` (Button props) or `renderTrigger`, data-driven `items` or compound children, `presentation`, `isMenuOpen` and `onOpenChange`, placement and alignment, `menuMaxHeight`, `header`, and `footer`. | +| Behavior | A Menu Button trigger opens an anchored pointer menu or a bottom-sheet action list. Rows act on the release under the shared press model; sub-menus fly out beside their row or drill in on a compact touch display; a header or footer makes the pointer popover a modal dialog around the menu. | +| End-user impact | People pick actions by mouse, finger, or keyboard; keyboard users get a focus return with a ring and pointer users without one; on a phone sub-menus replace the rows instead of flying off-screen. Under a finger the menu opens as the finger presses, or after the content-touch delay inside a scroll view; it does not grow from its trigger, and a pan outside does not close it. | +| Builder impact | Builders choose the trigger, the rows, and the regions; rows stay menu items and every non-item control goes in `header` or `footer`. | +| 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. Touch grow and pan dismissal (FR21), the still mouse (FR22), and Trigger Button gaps are in Current-state impact. | +| Review checks | Reject a control placed among the rows, a lost trigger ARIA wiring, a drilled-in view that leaves the menu box, a header or footer without the dialog, a lost focus-return modality, or a target moved off its painted element. | +| Governing rules | `spec:AST-020` FR15; `spec:AST-094` FR26–FR35; `module:DropdownMenu/useMenuPress`; `architecture:interaction-modality` INV1; `architecture:component-theming-surface`; `architecture:layer-runtime`; `family:overlay-dismissal`; `family:navigation-destinations`. | This table is a review projection; the body below is authoritative. @@ -131,7 +132,7 @@ component-local concepts; each keeps its released default. | FR5 | Both menu surfaces carry `data-astryx-menu-press` and follow `module:DropdownMenu/useMenuPress` FR1–FR7, FR10, and FR11: 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 browser's stray click never acts, and the Pointer menu surface declares `touch-action` by overflow. The grow, collapse, and pan dismissal under a finger are FR21. | `module:DropdownMenu/useMenuPress`; `spec:AST-094` FR26–FR28 | | FR6 | When the pointer menu closes, focus returns to the Trigger button with a visible ring only when the menu was driven by keyboard, and after a pointer pick the returned focus opens no trigger tooltip (`component:Tooltip` FR4); a press outside that landed on a focusable control keeps focus there. | `architecture:interaction-modality` INV1; DEC-1 | | FR7 | `menuMaxHeight` replaces the 300px term of the menu's and its popover viewport's block-size cap while the viewport gutters still bound both; the touch sheet ignores it. | DEC-2 | -| FR8 | The Trigger button opens the pointer menu on a mouse press-down, with a settle rule on the opening release, and pressing the trigger of an open menu closes it without reopening in the same gesture; a mouse click and the keyboard open as before. The mouse press-open is shipped behavior, not a requirement until `module:DropdownMenu/useMenuPress` DEC-4 is ruled. Under a finger the trigger MUST open the menu as `spec:AST-094` FR29 states: as the finger presses in a fixed bar, after the 150 ms content-touch delay or at a tap's lift inside a scroll view; `presentation="popover"` keeps the tap open. Implementation gap: a held finger opens it after a 500 ms hold, and a tap opens it at its click. | `module:DropdownMenu/useMenuPress` FR8, FR9; `spec:AST-094` FR29 | +| FR8 | The Trigger button opens the pointer menu on a mouse press-down, with a settle rule on the opening release, and pressing the trigger of an open menu closes it without reopening in the same gesture; a mouse click and the keyboard open as before. The mouse press-open is shipped behavior, not a requirement until `module:DropdownMenu/useMenuPress` DEC-4 is ruled. Under a finger the trigger MUST open the menu as `spec:AST-094` FR29 states: as the finger presses in a fixed bar, after the 150 ms content-touch delay or at a tap's lift inside a scroll view; `presentation="popover"` keeps the tap open. | `module:DropdownMenu/useMenuPress` FR8, FR9; `spec:AST-094` FR29 | | FR9 | With `renderTrigger`, the rendered control carries `aria-haspopup`, `aria-expanded`, `aria-controls` and the `id` the menu's `aria-labelledby` points at, and receives the same press-model, keyboard-open and toggle-click handlers as the Button path; the menu is named by that control. A disabled custom trigger never opens the menu or the sheet. `button` and `renderTrigger` are mutually exclusive: both given, `renderTrigger` wins and a dev warning fires, in both presentations. A drilled-in Back row (FR11) returns to the menu by that same name: the caller's `aria-label` when given, else the control's accessible name, read as the menu opens. | DEC-3 | | FR10 | An action row with `href` renders as one anchor carrying `role="menuitem"` (the pointer menu) or the touch sheet's link row, routed through `LinkProvider`; a plain click runs `onClick`, then closes the menu (`hasCloseOnSelect`), then lets the browser navigate; a modified click does not run `onClick`, closes the menu, and is left to the browser; in the pointer menu a middle click closes the menu (`hasCloseOnSelect`), then runs the caller's `onAuxClick`, while the touch sheet leaves a middle click to the browser and stays open; a right click is left to the context menu; Enter and Space activate through a synthesized click that carries the key's modifiers. A disabled link row has no `href`; a blocked destination renders inertly. | Current behavior; `family:navigation-destinations` | | FR11 | When a compact touch display opened the pointer menu (the query the root presentation uses for its bottom sheet, sampled once as it opens), an `adaptive` sub-menu row drills in: its rows and a leading Back row named "Back to " replace the menu's rows in the same Pointer menu surface, no flyout opens, the drilled list is a `menu` named by the row's label, and Back, Escape and ArrowLeft return to the row with focus on it; a pick inside closes the whole menu, and closing the menu resets the drilled view. Roving focus and typeahead scope to the shown rows; sibling rows and dividers need no knowledge of it. `presentation="flyout"` keeps the flyout; `"drill-in"` drills in on any pointer. | DEC-4; `spec:AST-094` FR34 | @@ -344,35 +345,33 @@ than adding a DropdownMenu-owned heading target. ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------- | -| FR1, FR4 | `DropdownMenu.test.tsx` rendering cases ("renders menu with role=\"menu\"", "renders data-driven actions in a bottom sheet when requested", "uses the BottomSheet for adaptive presentation on compact touch", "uses the shared menu BottomSheet frame", "keeps adaptive presentation anchored without compact touch") and section, divider, and compound cases | Root pointer, root touch, adaptive | A missing surface, sheet frame, or menu role fails. Evidence gap: no test asserts the `dropdown-menu` target on the Touch menu surface or `list-item` on touch rows. | `audit:DropdownMenu/anatomy` | -| FR2 | `DropdownMenu.test.tsx` theming-slot cases ("exposes a themeable slot on the section heading", "exposes a themeable slot on the menu divider"); `DropdownMenuSubMenu.test.tsx` "exposes a themeable slot on the submenu indicator icon"; `themingTargets.test.ts` | Heading, divider, submenu indicator | A missing heading, divider, or indicator target fails; a rendered class absent from the documented targets fails. Evidence gap: no test asserts `dropdown-menu`, `dropdown-menu-item`, or `dropdown-menu-radio` placement. | `audit:DropdownMenu/theming` | -| FR3 | `DropdownMenu.test.tsx` button customization, icon-only, and hasChevron cases | Button variants and sizes; icon-only; no chevron | A Button prop that stops reaching the trigger fails. Evidence gap: the delegated Trigger Button gaps are asserted by `component:Button`'s own records, and no test here asserts the BottomSheet, List, Indicator, or Icon delegation beyond rendering. | `audit:DropdownMenu/theming` | -| FR5 | `DropdownMenu.test.tsx` press-model cases ("a finger that lands on one row and lifts on another acts on the second, once", "a mouse released outside closes the menu; a finger released outside leaves it open", "marks the menu as carrying the press model and owns touch scrolling"); `MenuPress.a11y.chromium.spec.ts`; the module record's own map | Finger and mouse release; scrolling menu | A row that acts on press-down, a mouse release outside that keeps the menu, or a missing press marker fails. | `audit:DropdownMenu/behavior` | -| FR6 | `DropdownMenu.test.tsx` focus-return cases ("restores focus to the trigger after keyboard dismissal", "returns focus to the trigger after pointer dismissal without a focus ring", "leaves focus on the control a press outside landed on") and the "focus return after a pointer pick" and "a trigger the caller renders" cases | Keyboard and pointer close; Button and custom trigger | A lost focus return, a ring after a pointer close, no ring after a keyboard close, or focus pulled off a control pressed outside fails. | `audit:DropdownMenu/accessibility` | -| FR7 | `DropdownMenu.test.tsx` menuMaxHeight cases | A cap above 300px; viewport bound | A menu clipped to 300px under a raised cap, or one that escapes the viewport, fails. Evidence gap: no test asserts that the touch sheet ignores the cap. | `audit:DropdownMenu/layout` | -| FR8 | `DropdownMenu.test.tsx` press-model trigger cases ("a mouse press on the trigger opens the menu and a drag-release acts on the row under it", "the opening release before the settle time acts on nothing and the menu stays", "pressing the trigger of an open menu closes it and does not reopen it in the same gesture", "a held touch on the trigger opens with the finger down and a slide picks", "a tap on the trigger still opens through its click") | Mouse press-open; held finger; tap | A mouse that opens only on click, an opening release that acts early, or a re-open in the closing gesture fails. The held-touch case asserts the current 500 ms hold, the FR8 gap pending conformance. | `audit:DropdownMenu/behavior` | -| FR8 | `DropdownMenuOpeningRelease.interaction.browser.spec.ts` (Chromium, WebKit): an opening release off the trigger, under a trigger that moved away, or on the trigger after a show that waited leaves the menu open; a settled release outside closes it | Mouse release off the trigger | An opening release before the settle time that the browser's light dismiss closes fails | `audit:DropdownMenu/behavior` | -| FR9, AR1 | `DropdownMenu.test.tsx` custom trigger cases; `DropdownMenuSubMenu.test.tsx` "a menu hung off a custom trigger" cases | Custom trigger open, keyboard, mouse, disabled; both props given; Back row names | A custom trigger that does not open, toggle, or name the menu, a disabled one that opens, a missing warning, or a wrong Back row name fails. | `audit:DropdownMenu/accessibility` | -| FR10, AR2, ORD1 | `DropdownMenu.test.tsx` "DropdownMenuItem href" and "link rows — the browser's own clicks" cases | Plain, modified, middle, and right click; Enter; bottom sheet; disabled; blocked | A row that is not an anchor, a modified click that runs `onClick`, a middle click that does not close, a disabled row with an address, or a live blocked destination fails. Evidence gap: no test asserts the order of `onClick`, close, and navigation. | `audit:DropdownMenu/behavior` | -| FR11, AR3 | `DropdownMenuSubMenu.test.tsx` "drill-in on a phone" cases; `DropdownMenuDrillIn.a11y.chromium.spec.ts` | Phone and laptop; forced presentations; theme breakpoint; reset on close | A flyout on a phone, a drill-in on a laptop by default, a wrong name, a Back that loses focus, or a drilled view that survives a close fails. | `audit:DropdownMenu/accessibility` | -| FR12, AR4 | `DropdownMenu.test.tsx` keyboard cases ("ArrowDown on the last row wraps to the first, ArrowUp on the first to the last", "ArrowUp on the trigger opens with the last row highlighted", "PageDown moves to the last row and PageUp back to the first", "the opening key's auto-repeat does not act on the highlighted row"), open-focus cases, typeahead cases, and "has aria-haspopup and aria-expanded attributes" | Wrap, Page keys, open keys, auto-repeat, typeahead | A missing wrap, a Page key that does not move, an ArrowUp open on the first row, an auto-repeat that acts, or a missing typeahead match fails. Evidence gap: no test asserts PageDown past the visible rows, typeahead ignoring chords or composition, or `aria-controls` on the Button trigger. | `audit:DropdownMenu/accessibility` | -| FR13 | `DropdownMenuSubMenu.test.tsx` safe-triangle cases | Diagonal path, pause inside, leaving both | A flyout that closes inside the triangle or stays open after leaving it fails. | `audit:DropdownMenu/behavior` | -| FR14 | `DropdownMenu.test.tsx` section cases | Titled and untitled sections | A missing group role or title fails. | `audit:DropdownMenu/accessibility` | -| FR15, AR5 | `DropdownMenu.test.tsx` header and footer cases, including the hidden-control, open-sub-menu, and header radio-group cases; `MenuHeaderFooter.a11y.chromium.spec.ts` | Regions present and absent; hidden controls; open flyout; custom trigger; bottom sheet | A region inside the menu, a missing or unnamed dialog, a wrong `aria-haspopup`, a Tab that leaves the popover, a hidden control taken as a stop, or a region whose keys the menu takes fails. | `audit:DropdownMenu/accessibility` | -| FR17, AR6 | DropdownMenu, MoreMenu, ContextMenu and NavHeadingMenu keyboard tests, plus a real-browser keyboard check of focus order and blocked activation and a real-browser accessibility-tree check that a focused disabled row is announced as disabled (`spec:AST-085` Platform support); disabled-row cases | Disabled rows | A disabled row that runs a caller handler, closes the menu, is skipped by the arrow keys or type-ahead, or is not announced as disabled fails. | `audit:DropdownMenu/accessibility` | -| FR20, AR7 | `DropdownMenuSubMenu.test.tsx` header and footer cases, including "on a phone"; `SubMenuHeaderFooter.a11y.chromium.spec.ts`; `ContextMenu.test.tsx` "shows a sub-menu with a header or footer as its rows alone, and says why in development" | Fine pointer and phone; Escape paths; nested flyouts; pointer leaving | A region inside a menu, an unnamed dialog, a Tab walk that does not wrap, an Escape that closes the root, or a flyout that closes while focus is inside fails. | `audit:DropdownMenu/accessibility` | -| FR21 | Evidence gap: no test, matching the FR21 gap | — | — | `audit:DropdownMenu/behavior` | -| FR22 | `DropdownMenu.test.tsx` "DropdownMenu entrance under a finger" cases; `useMenuPress.test.tsx` "an input hold over the menu" cases; `DropdownMenuEntranceHold.interaction.browser.spec.ts` | A tap outside and a finger's slide while the menu enters; a mouse click | A tap during the entrance that closes the menu, clicks the page or moves focus, a slide that picks nothing, or a held mouse fails. | `audit:DropdownMenu/behavior` | -| FR23 | `DropdownMenu.test.tsx` Tab cases; `DropdownMenuSubMenu.test.tsx` "Tab from a flyout leaves the whole menu" suite | top level, one and two levels down, Shift+Tab | A Tab that leaves any level open, a second `onOpenChange(false)`, or a prevented Tab fails. | `audit:DropdownMenu/accessibility` | -| PR1 | Evidence gap: no test asserts the listener and observer bounds | — | — | `audit:DropdownMenu/behavior` | -| Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and targets | Missing, extra, prefixed, stale, or alias-backed mappings fail repository validation. | `audit:DropdownMenu/theming` | +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------- | +| FR1, FR4 | `DropdownMenu.test.tsx` rendering cases ("renders menu with role=\"menu\"", "renders data-driven actions in a bottom sheet when requested", "uses the BottomSheet for adaptive presentation on compact touch", "uses the shared menu BottomSheet frame", "keeps adaptive presentation anchored without compact touch") and section, divider, and compound cases | Root pointer, root touch, adaptive | A missing surface, sheet frame, or menu role fails. Evidence gap: no test asserts the `dropdown-menu` target on the Touch menu surface or `list-item` on touch rows. | `audit:DropdownMenu/anatomy` | +| FR2 | `DropdownMenu.test.tsx` theming-slot cases ("exposes a themeable slot on the section heading", "exposes a themeable slot on the menu divider"); `DropdownMenuSubMenu.test.tsx` "exposes a themeable slot on the submenu indicator icon"; `themingTargets.test.ts` | Heading, divider, submenu indicator | A missing heading, divider, or indicator target fails; a rendered class absent from the documented targets fails. Evidence gap: no test asserts `dropdown-menu`, `dropdown-menu-item`, or `dropdown-menu-radio` placement. | `audit:DropdownMenu/theming` | +| FR3 | `DropdownMenu.test.tsx` button customization, icon-only, and hasChevron cases | Button variants and sizes; icon-only; no chevron | A Button prop that stops reaching the trigger fails. Evidence gap: the delegated Trigger Button gaps are asserted by `component:Button`'s own records, and no test here asserts the BottomSheet, List, Indicator, or Icon delegation beyond rendering. | `audit:DropdownMenu/theming` | +| FR5 | `DropdownMenu.test.tsx` press-model cases ("a finger that lands on one row and lifts on another acts on the second, once", "a mouse released outside closes the menu; a finger released outside leaves it open", "marks the menu as carrying the press model and owns touch scrolling"); `MenuPress.a11y.chromium.spec.ts`; the module record's own map | Finger and mouse release; scrolling menu | A row that acts on press-down, a mouse release outside that keeps the menu, or a missing press marker fails. | `audit:DropdownMenu/behavior` | +| FR6 | `DropdownMenu.test.tsx` focus-return cases ("restores focus to the trigger after keyboard dismissal", "returns focus to the trigger after pointer dismissal without a focus ring", "leaves focus on the control a press outside landed on") and the "focus return after a pointer pick" and "a trigger the caller renders" cases | Keyboard and pointer close; Button and custom trigger | A lost focus return, a ring after a pointer close, no ring after a keyboard close, or focus pulled off a control pressed outside fails. | `audit:DropdownMenu/accessibility` | +| FR7 | `DropdownMenu.test.tsx` menuMaxHeight cases | A cap above 300px; viewport bound | A menu clipped to 300px under a raised cap, or one that escapes the viewport, fails. Evidence gap: no test asserts that the touch sheet ignores the cap. | `audit:DropdownMenu/layout` | +| FR8 | `DropdownMenu.test.tsx` press-model trigger cases ("a mouse press on the trigger opens the menu and a drag-release acts on the row under it", "the opening release before the settle time acts on nothing and the menu stays", "pressing the trigger of an open menu closes it and does not reopen it in the same gesture", "under a coarse pointer a finger opens the menu as it presses the trigger (no scroll view), and a slide picks", "a tap on the trigger opens the menu on the press, and its click does not close it") | Mouse press-open; held finger; tap | A mouse that opens only on click, an opening release that acts early, or a re-open in the closing gesture fails. A finger press that waits for a hold, or a tap whose click toggles the menu its press opened, fails. | `audit:DropdownMenu/behavior` | +| FR8 | `DropdownMenuOpeningRelease.interaction.browser.spec.ts` (Chromium, WebKit): an opening release off the trigger, under a trigger that moved away, or on the trigger after a show that waited leaves the menu open; a settled release outside closes it | Mouse release off the trigger | An opening release before the settle time that the browser's light dismiss closes fails | `audit:DropdownMenu/behavior` | +| FR9, AR1 | `DropdownMenu.test.tsx` custom trigger cases; `DropdownMenuSubMenu.test.tsx` "a menu hung off a custom trigger" cases | Custom trigger open, keyboard, mouse, disabled; both props given; Back row names | A custom trigger that does not open, toggle, or name the menu, a disabled one that opens, a missing warning, or a wrong Back row name fails. | `audit:DropdownMenu/accessibility` | +| FR10, AR2, ORD1 | `DropdownMenu.test.tsx` "DropdownMenuItem href" and "link rows — the browser's own clicks" cases | Plain, modified, middle, and right click; Enter; bottom sheet; disabled; blocked | A row that is not an anchor, a modified click that runs `onClick`, a middle click that does not close, a disabled row with an address, or a live blocked destination fails. Evidence gap: no test asserts the order of `onClick`, close, and navigation. | `audit:DropdownMenu/behavior` | +| FR11, AR3 | `DropdownMenuSubMenu.test.tsx` "drill-in on a phone" cases; `DropdownMenuDrillIn.a11y.chromium.spec.ts` | Phone and laptop; forced presentations; theme breakpoint; reset on close | A flyout on a phone, a drill-in on a laptop by default, a wrong name, a Back that loses focus, or a drilled view that survives a close fails. | `audit:DropdownMenu/accessibility` | +| FR12, AR4 | `DropdownMenu.test.tsx` keyboard cases ("ArrowDown on the last row wraps to the first, ArrowUp on the first to the last", "ArrowUp on the trigger opens with the last row highlighted", "PageDown moves to the last row and PageUp back to the first", "the opening key's auto-repeat does not act on the highlighted row"), open-focus cases, typeahead cases, and "has aria-haspopup and aria-expanded attributes" | Wrap, Page keys, open keys, auto-repeat, typeahead | A missing wrap, a Page key that does not move, an ArrowUp open on the first row, an auto-repeat that acts, or a missing typeahead match fails. Evidence gap: no test asserts PageDown past the visible rows, typeahead ignoring chords or composition, or `aria-controls` on the Button trigger. | `audit:DropdownMenu/accessibility` | +| FR13 | `DropdownMenuSubMenu.test.tsx` safe-triangle cases | Diagonal path, pause inside, leaving both | A flyout that closes inside the triangle or stays open after leaving it fails. | `audit:DropdownMenu/behavior` | +| FR14 | `DropdownMenu.test.tsx` section cases | Titled and untitled sections | A missing group role or title fails. | `audit:DropdownMenu/accessibility` | +| FR15, AR5 | `DropdownMenu.test.tsx` header and footer cases, including the hidden-control, open-sub-menu, and header radio-group cases; `MenuHeaderFooter.a11y.chromium.spec.ts` | Regions present and absent; hidden controls; open flyout; custom trigger; bottom sheet | A region inside the menu, a missing or unnamed dialog, a wrong `aria-haspopup`, a Tab that leaves the popover, a hidden control taken as a stop, or a region whose keys the menu takes fails. | `audit:DropdownMenu/accessibility` | +| FR17, AR6 | DropdownMenu, MoreMenu, ContextMenu and NavHeadingMenu keyboard tests, plus a real-browser keyboard check of focus order and blocked activation and a real-browser accessibility-tree check that a focused disabled row is announced as disabled (`spec:AST-085` Platform support); disabled-row cases | Disabled rows | A disabled row that runs a caller handler, closes the menu, is skipped by the arrow keys or type-ahead, or is not announced as disabled fails. | `audit:DropdownMenu/accessibility` | +| FR20, AR7 | `DropdownMenuSubMenu.test.tsx` header and footer cases, including "on a phone"; `SubMenuHeaderFooter.a11y.chromium.spec.ts`; `ContextMenu.test.tsx` "shows a sub-menu with a header or footer as its rows alone, and says why in development" | Fine pointer and phone; Escape paths; nested flyouts; pointer leaving | A region inside a menu, an unnamed dialog, a Tab walk that does not wrap, an Escape that closes the root, or a flyout that closes while focus is inside fails. | `audit:DropdownMenu/accessibility` | +| FR21 | Evidence gap: no test, matching the FR21 gap | — | — | `audit:DropdownMenu/behavior` | +| FR22 | `DropdownMenu.test.tsx` "DropdownMenu entrance under a finger" cases; `useMenuPress.test.tsx` "an input hold over the menu" cases; `DropdownMenuEntranceHold.interaction.browser.spec.ts` | A tap outside and a finger's slide while the menu enters; a mouse click | A tap during the entrance that closes the menu, clicks the page or moves focus, a slide that picks nothing, or a held mouse fails. | `audit:DropdownMenu/behavior` | +| FR23 | `DropdownMenu.test.tsx` Tab cases; `DropdownMenuSubMenu.test.tsx` "Tab from a flyout leaves the whole menu" suite | top level, one and two levels down, Shift+Tab | A Tab that leaves any level open, a second `onOpenChange(false)`, or a prevented Tab fails. | `audit:DropdownMenu/accessibility` | +| PR1 | Evidence gap: no test asserts the listener and observer bounds | — | — | `audit:DropdownMenu/behavior` | +| Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and targets | Missing, extra, prefixed, stale, or alias-backed mappings fail repository validation. | `audit:DropdownMenu/theming` | ## Current-state impact -- **Finger press-open (FR8).** A held finger opens the menu after a 500 ms - hold, and a tap at its click, instead of as `spec:AST-094` FR29 states. - **Touch grow and pan dismissal (FR21).** Under a finger the menu appears and leaves with the layer's own entrance and exit instead of growing out of and back into the Trigger button, and a pan that starts outside does not close diff --git a/packages/core/api-report.md b/packages/core/api-report.md index 7fe253d6ab808..72aa27ef8ffae 100644 --- a/packages/core/api-report.md +++ b/packages/core/api-report.md @@ -6085,7 +6085,7 @@ interface UseMenuPressOptions hitTest?: (x: number, y: number) => Element | null isEnabled?: boolean = true itemSelector: string - longPressDelayMs?: number = 500 + longPressDelayMs?: number menuRef: RefObject onActivate?: (row: HTMLElement, release: PointerEvent) => void onDismiss?: () => void diff --git a/packages/core/src/Breadcrumbs/BreadcrumbItem.tsx b/packages/core/src/Breadcrumbs/BreadcrumbItem.tsx index a2d1f85b7f9ab..8291507c51ff9 100644 --- a/packages/core/src/Breadcrumbs/BreadcrumbItem.tsx +++ b/packages/core/src/Breadcrumbs/BreadcrumbItem.tsx @@ -52,7 +52,7 @@ import {focusOutlineProps} from '../utils/focusOutline.stylex'; import {Icon} from '../Icon'; import {usePopover} from '../Popover/usePopover'; import {useListFocus} from '../hooks/useListFocus'; -import {useMenuPress} from '../hooks/useMenuPress'; +import {useMenuSurfacePress} from '../hooks/useMenuPress'; import {pressOnItemsOnly} from '../hooks/menuPressOnItems'; import {menuTouchActionStyles} from '../DropdownMenu/menuTouchAction.stylex'; import {useMenuOverflow} from '../DropdownMenu/useMenuOverflow'; @@ -585,7 +585,7 @@ function BreadcrumbMenuTrigger({ // A press that slides across the menu's items acts on the item it lifts on, // the highlight following the pointer, as DropdownMenu's does // (`family:action-groups` FR1-FR3, FR5); a mouse released outside closes it. - const menuPress = useMenuPress({ + const menuPress = useMenuSurfacePress({ menuRef: listRef, itemSelector: MENU_ITEM_SELECTOR, onDismiss: closeMenu, diff --git a/packages/core/src/ContextMenu/ContextMenu.tsx b/packages/core/src/ContextMenu/ContextMenu.tsx index 5f6c2ef64a906..2da3a4496c68b 100644 --- a/packages/core/src/ContextMenu/ContextMenu.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.tsx @@ -78,7 +78,7 @@ import { import {useMenuOverflow} from '../DropdownMenu/useMenuOverflow'; import {useMenuDrillIn} from '../DropdownMenu/useMenuDrillIn'; import {useInternalListFocus} from '../hooks/useListFocus'; -import {useMenuPress} from '../hooks/useMenuPress'; +import {useMenuSurfacePress} from '../hooks/useMenuPress'; import {useTypeahead} from '../hooks/useTypeahead'; import {useLongPress} from '../hooks/useLongPress'; import {layerAnimations} from '../Layer/layerAnimations.stylex'; @@ -768,7 +768,7 @@ export function ContextMenu({ // 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({ + const menuPress = useMenuSurfacePress({ menuRef: listRef, itemSelector: MENU_ITEM_SELECTOR, onDismiss: closeMenu, diff --git a/packages/core/src/DropdownMenu/DropdownMenu.doc.mjs b/packages/core/src/DropdownMenu/DropdownMenu.doc.mjs index 43a75cb0e587a..0eb3e1264dbd1 100644 --- a/packages/core/src/DropdownMenu/DropdownMenu.doc.mjs +++ b/packages/core/src/DropdownMenu/DropdownMenu.doc.mjs @@ -187,7 +187,7 @@ export const docs = { name: 'renderTrigger', type: '(props: DropdownMenuTriggerProps) => ReactNode', description: - 'Render the control the menu hangs off — an IconButton, a chip, an avatar, a list row — instead of the built-in Button. Spread the given props onto it: they carry the press model (a mouse opens on press-down, a held finger opens with the finger down), the keyboard opens, the toggle click, and `aria-haspopup`/`aria-expanded`/`aria-controls`/`id`; the menu is then named by that control through `aria-labelledby`. Mutually exclusive with `button`.', + 'Render the control the menu hangs off — an IconButton, a chip, an avatar, a list row — instead of the built-in Button. Spread the given props onto it: they carry the press model (a mouse opens on press-down, a finger or a pen opens as it presses), the keyboard opens, the toggle click, and `aria-haspopup`/`aria-expanded`/`aria-controls`/`id`; the menu is then named by that control through `aria-labelledby`. Mutually exclusive with `button`.', }, { @@ -201,7 +201,7 @@ export const docs = { name: 'presentation', type: "'popover' | 'bottom-sheet' | 'adaptive'", description: - "Presentation surface for data-driven items. 'popover' stays anchored, 'bottom-sheet' always renders the actions in a modal BottomSheet, and 'adaptive' uses a BottomSheet on compact coarse-pointer layouts while remaining anchored elsewhere. Compound children currently support popover only. Omitted, the menu is anchored, and under a finger, on every device, the finger that opened it picks a row only after moving 10 px. Pass 'popover' to keep the pointer behavior on every device, the settle rule included.", + "Presentation surface for data-driven items. 'popover' stays anchored, 'bottom-sheet' always renders the actions in a modal BottomSheet, and 'adaptive' uses a BottomSheet on compact coarse-pointer layouts while remaining anchored elsewhere. Compound children currently support popover only. Omitted, the menu is anchored, and under a finger or a pen, on every device, its trigger opens as it is pressed (inside a scroll view, after the 150 ms content-touch delay), and the opening finger picks after moving 10 px. Pass 'popover' to keep the pointer behavior on every device, a 500 ms hold included.", }, { name: 'isMenuOpen', diff --git a/packages/core/src/DropdownMenu/DropdownMenu.test.tsx b/packages/core/src/DropdownMenu/DropdownMenu.test.tsx index 121de94761d4f..cdd5d45a07a6b 100644 --- a/packages/core/src/DropdownMenu/DropdownMenu.test.tsx +++ b/packages/core/src/DropdownMenu/DropdownMenu.test.tsx @@ -3379,27 +3379,30 @@ describe('DropdownMenu press model', () => { expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); }); - it('a held touch on the trigger opens with the finger down, and a slide past the handoff picks (spec:AST-094 FR29, FR43)', () => { + it('under a coarse pointer a finger opens the menu as it presses the trigger (no scroll view), and a slide picks', () => { + stubCoarsePointer(); vi.useFakeTimers(); try { const onPick = vi.fn(); const trigger = renderMenu(onPick); + // A trigger in no scroll view opens on the press. fireEvent.pointerDown(trigger, {...touch, clientX: 0, clientY: 0}); - expect(trigger).toHaveAttribute('aria-expanded', 'false'); - act(() => { - vi.advanceTimersByTime(500); - }); expect(trigger).toHaveAttribute('aria-expanded', 'true'); - // The handoff counts from the first position the open menu sees. + // UIMenu's handoff: the finger picks once it travels from the first + // sample the menu saw. fireEvent.pointerMove(item('Delete'), {...touch, clientX: 0, clientY: 3}); - const past = { + expect(item('Delete')).not.toHaveFocus(); + fireEvent.pointerMove(item('Delete'), { ...touch, clientX: 0, clientY: 3 + UIKIT_GESTURES.menuPress.handoff.value, - }; - fireEvent.pointerMove(item('Delete'), past); + }); expect(item('Delete')).toHaveFocus(); - fireEvent.pointerUp(item('Delete'), past); + fireEvent.pointerUp(item('Delete'), { + ...touch, + clientX: 0, + clientY: 3 + UIKIT_GESTURES.menuPress.handoff.value, + }); expect(onPick).toHaveBeenCalledWith('Delete'); // The click the browser aims at the trigger for this gesture is spent. fireEvent.click(trigger, {detail: 1}); @@ -3478,7 +3481,7 @@ describe('DropdownMenu press model', () => { }); it('presentation="popover" pins the settle rule under a finger: once the opening press has settled, a still lift picks the row under it', () => { - // spec:AST-094 FR25: a moved touch default comes back through + // A moved touch default comes back through // `presentation`, never through a public threshold. stubCoarsePointer(); vi.useFakeTimers(); @@ -3504,6 +3507,37 @@ describe('DropdownMenu press model', () => { } }); + it('presentation="popover" pins the released trigger under a finger: it opens at 500 ms, not on the press', () => { + // A moved touch default comes back through + // `presentation`, never through a public threshold. + stubCoarsePointer(); + vi.useFakeTimers(); + try { + const onPick = vi.fn<(label: string) => void>(); + render( + + onPick('Edit')} /> + onPick('Delete')} /> + , + ); + const trigger = screen.getByRole('button', {name: /Pinned/}); + fireEvent.pointerDown(trigger, {...touch, clientX: 0, clientY: 0}); + // Not on the press, as the touch modes would open it. + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + act(() => { + vi.advanceTimersByTime(MENU_PRESS_LONG_PRESS_MS - 1); + }); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + act(() => { + vi.advanceTimersByTime(1); + }); + expect(MENU_PRESS_LONG_PRESS_MS).toBe(500); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + } finally { + vi.useRealTimers(); + } + }); + it('a menu a held finger opens keeps its trigger as the invoker until the finger lifts', () => { // A release on the trigger is outside the `popover="auto"` menu, so the // browser's light dismiss would close it; holding the invoker through @@ -3630,13 +3664,183 @@ describe('DropdownMenu press model', () => { }); }); - it('a tap on the trigger still opens through its click', () => { + it('a tap on the trigger opens the menu on the press, and its click does not close it', () => { const trigger = renderMenu(); fireEvent.pointerDown(trigger, touch); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); fireEvent.pointerUp(trigger, touch); fireEvent.click(trigger, {detail: 1}); expect(trigger).toHaveAttribute('aria-expanded', 'true'); }); + + // A finger follows the menus' finger rules on every device: a touchscreen + // laptop (a fine primary pointer) as on a phone. Each gesture is still read + // as a finger or a mouse. `presentation="popover"` keeps the released + // finger behavior. + describe('a finger follows the same rules on every device; each gesture is still a finger or a mouse', () => { + function stubFinePointer() { + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); + expect(window.matchMedia(TOUCH_QUERY).matches).toBe(false); + } + const touchmoveIsPrevented = () => { + const event = new Event('touchmove', {bubbles: true, cancelable: true}); + document.body.dispatchEvent(event); + return event.defaultPrevented; + }; + + it('fine primary pointer: a finger opens the menu as it presses the trigger, as on a phone (spec:AST-094 FR29, FR43)', () => { + stubFinePointer(); + const trigger = renderMenu(); + fireEvent.pointerDown(trigger, {...touch, clientX: 0, clientY: 0}); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + fireEvent.pointerUp(trigger, {...touch, clientX: 0, clientY: 0}); + // The tap's own click comes after the lift; it must not toggle. + fireEvent.click(trigger, {detail: 1}); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); + + it('fine primary pointer: a pen opens the menu as it presses the trigger, as a finger does (spec:AST-094 FR29, FR43; spec:AST-017 FR59)', () => { + stubFinePointer(); + const trigger = renderMenu(); + const pen = {pointerType: 'pen', pointerId: 7, clientX: 0, clientY: 0}; + fireEvent.pointerDown(trigger, pen); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + fireEvent.pointerUp(trigger, pen); + // The pen's own click comes after the lift; it must not toggle. + fireEvent.click(trigger, {detail: 1}); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); + + it('fine primary pointer, presentation="popover": a finger keeps the released behavior, its press opening nothing and its tap opening the menu through the click (spec:AST-094 FR25)', () => { + stubFinePointer(); + render( + + + + , + ); + const trigger = screen.getByRole('button', {name: /Pinned/}); + fireEvent.pointerDown(trigger, {...touch, clientX: 0, clientY: 0}); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + fireEvent.pointerUp(trigger, {...touch, clientX: 0, clientY: 0}); + fireEvent.click(trigger, {detail: 1}); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); + + it('fine primary pointer: the finger that opened the menu picks only once it travels the handoff, as on a phone (spec:AST-094 FR29, FR43)', () => { + stubFinePointer(); + const onPick = vi.fn<(label: string) => void>(); + const trigger = renderMenu(onPick); + fireEvent.pointerDown(trigger, {...touch, clientX: 0, clientY: 0}); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + // The finger's later touchmoves are the menu's, not a scroll. + expect(touchmoveIsPrevented()).toBe(true); + // A finger that barely moved lights nothing, however long it rests. + fireEvent.pointerMove(item('Delete'), {...touch, clientX: 0, clientY: 3}); + expect(item('Delete')).not.toHaveFocus(); + // UIMenu's handoff: the finger picks once it travels from the first + // sample the menu saw. + const handoff = 3 + UIKIT_GESTURES.menuPress.handoff.value; + fireEvent.pointerMove(item('Delete'), { + ...touch, + clientX: 0, + clientY: handoff, + }); + expect(item('Delete')).toHaveFocus(); + fireEvent.pointerUp(item('Delete'), { + ...touch, + clientX: 0, + clientY: handoff, + }); + expect(onPick).toHaveBeenCalledWith('Delete'); + expect(touchmoveIsPrevented()).toBe(false); + }); + + it('fine primary pointer: a finger that lifts outside the menu leaves it open; a mouse released outside closes it', async () => { + stubFinePointer(); + const user = userEvent.setup(); + const trigger = renderMenu(); + await user.click(trigger); + fireEvent.pointerDown(item('Edit'), touch); + fireEvent.pointerUp(document.body, touch); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + fireEvent.pointerDown(item('Edit'), mouse); + fireEvent.pointerUp(document.body, mouse); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); + + function renderCountingMenu() { + const onOpenChange = vi.fn<(isOpen: boolean) => void>(); + const onPick = vi.fn<(label: string) => void>(); + render( + + onPick('Edit')} /> + onPick('Delete')} /> + , + ); + return { + trigger: screen.getByRole('button', {name: /Actions/}), + onOpenChange, + onPick, + }; + } + + it('coarse primary pointer: a finger opens the menu once, as it presses, and it closes once at a pick (module AR5)', () => { + stubCoarsePointer(); + const {trigger, onOpenChange, onPick} = renderCountingMenu(); + fireEvent.pointerDown(trigger, {...touch, clientX: 0, clientY: 0}); + fireEvent.pointerUp(trigger, {...touch, clientX: 0, clientY: 0}); + // The tap's own click comes after the lift; it must not toggle. + fireEvent.click(trigger, {detail: 1}); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect(onOpenChange.mock.calls).toEqual([[true]]); + fireEvent.pointerDown(item('Delete'), touch); + fireEvent.pointerUp(item('Delete'), touch); + expect(onPick).toHaveBeenCalledWith('Delete'); + expect(onOpenChange.mock.calls).toEqual([[true], [false]]); + }); + + it('fine primary pointer: a finger opens the menu once, as it presses, and it closes once at a pick, as on a phone (module AR5, spec:AST-094 FR43)', () => { + stubFinePointer(); + const {trigger, onOpenChange, onPick} = renderCountingMenu(); + fireEvent.pointerDown(trigger, {...touch, clientX: 0, clientY: 0}); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + fireEvent.pointerUp(trigger, {...touch, clientX: 0, clientY: 0}); + // The tap's own click comes after the lift; it must not toggle. + fireEvent.click(trigger, {detail: 1}); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect(onOpenChange.mock.calls).toEqual([[true]]); + fireEvent.pointerDown(item('Delete'), touch); + fireEvent.pointerUp(item('Delete'), touch); + expect(onPick).toHaveBeenCalledWith('Delete'); + expect(onOpenChange.mock.calls).toEqual([[true], [false]]); + }); + + it('coarse primary pointer: a mouse keeps the mouse path: it opens on its press, and a press in the menu released outside closes it', () => { + stubCoarsePointer(); + const trigger = renderMenu(); + fireEvent.pointerDown(trigger, mouse); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + fireEvent.pointerUp(trigger, mouse); + fireEvent.click(trigger, {detail: 1}); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + fireEvent.pointerDown(item('Edit'), mouse); + fireEvent.pointerUp(document.body, mouse); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); + }); }); describe('DropdownMenu trigger handler composition', () => { diff --git a/packages/core/src/DropdownMenu/DropdownMenu.tsx b/packages/core/src/DropdownMenu/DropdownMenu.tsx index 78a85aeddaf35..0504ff2776a74 100644 --- a/packages/core/src/DropdownMenu/DropdownMenu.tsx +++ b/packages/core/src/DropdownMenu/DropdownMenu.tsx @@ -79,6 +79,7 @@ import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; import {useMergedRefs} from '../hooks/useMergedRefs'; import {useInternalListFocus} from '../hooks/useListFocus'; import {useDropdownMenuPress} from '../hooks/useMenuPress'; +import {isInScrollView} from '../hooks/menuScrollView'; import type {MenuPressPointerType} from '../hooks/menuPressGesture'; import {useTypeahead} from '../hooks/useTypeahead'; import {useFocusReturnVisibility} from '../hooks/useFocusReturnVisibility'; @@ -468,10 +469,11 @@ interface DropdownMenuDataProps extends DropdownMenuBaseProps { * Surface used to present the actions. Pass a value selected by product * policy (for example, a compact-touch media query) when the presentation * should adapt. Bottom-sheet presentation is intended for short action sets. - * Omitted, the menu is the anchored popover. Under a finger, on every - * device, the finger that opened it picks as UIMenu's does, once it has - * moved. `'popover'` pins the anchored popover with the pointer behavior on - * every device (the opening press follows the settle rule). + * Omitted, the menu is the anchored popover. On every device, a finger or + * a pen opens it as it presses the trigger, as UIMenu's pull-down button + * does. The finger that opened it picks once it has moved. `'popover'` + * pins the anchored popover with the pointer behavior on every device. A + * finger held for 500 ms opens it, and the settle rule decides its release. */ presentation?: DropdownMenuPresentation; items: DropdownMenuOption[]; @@ -480,9 +482,11 @@ interface DropdownMenuDataProps extends DropdownMenuBaseProps { interface DropdownMenuCompoundProps extends DropdownMenuBaseProps { /** - * `'popover'` pins the pointer behavior on every device (the opening press - * follows the settle rule). Omitted, on every device, the finger - * that opened the menu picks as UIMenu's does, once it has moved. + * `'popover'` pins the pointer behavior on every device. A finger held for + * 500 ms opens the menu, and the settle rule decides its release. Omitted, + * on every device, a finger or a pen opens the menu as it presses, as + * UIMenu's pull-down button does. The finger that opened it picks once it + * has moved. */ presentation?: 'popover'; items?: undefined; @@ -1478,9 +1482,8 @@ function DropdownMenuPopover({ itemSelector: MENU_ITEM_SELECTOR, onTriggerPress: handleTriggerPress, onDismiss: closeMenu, - // Under a finger, on every device, the opening finger picks as UIMenu's - // does. `presentation="popover"` pins the pointer model. touchModel: pressTouchModel, + isInScrollView, }); cancelMenuPressRef.current = menuPress.cancel; diff --git a/packages/core/src/DropdownMenu/DropdownMenuSubMenu.tsx b/packages/core/src/DropdownMenu/DropdownMenuSubMenu.tsx index 1069c1c748dca..9dc854b75590b 100644 --- a/packages/core/src/DropdownMenu/DropdownMenuSubMenu.tsx +++ b/packages/core/src/DropdownMenu/DropdownMenuSubMenu.tsx @@ -73,7 +73,7 @@ import {useMenuHover} from '../hooks/useMenuHover'; import { isMenuPressActivation, MENU_PRESS_SURFACE_ATTR, - useMenuPress, + useMenuSurfacePress, } from '../hooks/useMenuPress'; import {useTypeahead} from '../hooks/useTypeahead'; import {useMenuOverflow} from './useMenuOverflow'; @@ -1061,7 +1061,7 @@ export function DropdownMenuSubMenu( // The press model for this flyout: the row under a release acts, the // highlight follows a held pointer. A mouse released outside every level // dismisses the whole menu; a finger leaves it open. - const menuPress = useMenuPress({ + const menuPress = useMenuSurfacePress({ menuRef, itemSelector: MENU_ITEM_SELECTOR, onDismiss: nestedMenuContext.closeMenu, diff --git a/packages/core/src/DropdownMenu/MenuBottomSheetActionList.tsx b/packages/core/src/DropdownMenu/MenuBottomSheetActionList.tsx index f903644e7aa72..00e1463af62be 100644 --- a/packages/core/src/DropdownMenu/MenuBottomSheetActionList.tsx +++ b/packages/core/src/DropdownMenu/MenuBottomSheetActionList.tsx @@ -16,7 +16,7 @@ import {Heading} from '../Heading/Heading'; import {Icon, renderIconSlot} from '../Icon/Icon'; import {List} from '../List/List'; import {ListItem} from '../List/ListItem'; -import {useMenuPress} from '../hooks/useMenuPress'; +import {useMenuSurfacePress} from '../hooks/useMenuPress'; import {useMergedRefs} from '../hooks/useMergedRefs'; import {colorVars, spacingVars} from '../theme/tokens.stylex'; import {rtlStyles} from '../utils/rtlStyles'; @@ -74,7 +74,7 @@ export function MenuBottomSheetActionList({ // The press model: the row under a finger's RELEASE acts, and the // highlight (focus) follows the finger across the rows. The sheet is modal, // so a release outside it is the scrim's, not a dismissal of ours. - const menuPress = useMenuPress({ + const menuPress = useMenuSurfacePress({ menuRef: listRef, itemSelector: SHEET_ROW_SELECTOR, }); diff --git a/packages/core/src/DropdownMenu/__tests__/DropdownMenuTouchPress.interaction.browser.spec.ts b/packages/core/src/DropdownMenu/__tests__/DropdownMenuTouchPress.interaction.browser.spec.ts new file mode 100644 index 0000000000000..61150bce57b2b --- /dev/null +++ b/packages/core/src/DropdownMenu/__tests__/DropdownMenuTouchPress.interaction.browser.spec.ts @@ -0,0 +1,396 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file DropdownMenuTouchPress.interaction.browser.spec.ts + * @input The built Default DropdownMenu story, real touch input (Chromium + * CDP / WebKit's input command), the interaction harness, and UIMenu's + * values from the UIKit constants table + * @output Interaction proofs for a finger on DropdownMenu's trigger, one + * touch model on every engine. The menu opens as the finger presses a + * trigger in no scroll view, and the lift picks nothing. A tap opens it as + * it presses, and its lift leaves it open. In scrolling page content, a + * held finger opens it once the 150 ms content-touch delay passes. A scroll + * that starts on the trigger takes the touch. A press that slides onto a + * row, past the 10 pt handoff, picks that row once. A tap and a pick each + * open and close the menu once. A storyboard shows the press, hold, slide + * and lift. + * @position Evidence for the finger's press model in DropdownMenu (and + * MoreMenu, which renders it); the machine itself is unit-tested in + * menuPressGesture.test.ts and useMenuPress.test.tsx + */ + +import { + expect, + test, + type Locator, + type Page, +} from '@astryxdesign/a11y-spec/playwright'; +import { + drag, + frames, + installLongTaskLog, + openStory, + Storyboard, + tap, + touchDriver, + waitForRest, + withStorybook, + type TouchDriver, +} from '@astryxdesign/interaction-harness'; +import {scroll} from '../../motion/uikit/scroll'; + +/** The element's rendered box; a missing one fails the spec. */ +async function boxOf(locator: Locator): Promise<{ + x: number; + y: number; + width: number; + height: number; +}> { + const box = await locator.boundingBox(); + if (box == null) { + throw new Error('the element has no rendered box'); + } + return box; +} + +const storybook = withStorybook(test); + +interface Box { + x: number; + y: number; + width: number; + height: number; +} + +interface OpenStory { + driver: TouchDriver; + trigger: Locator; + popover: Locator; + center: {x: number; y: number}; + triggerBox: Box; +} + +/** Page-side log of the gesture and the menu: performance.now() times. */ +async function installMenuLog(page: Page) { + await page.evaluate(() => { + const log = { + down: [] as number[], + up: [] as number[], + cancel: [] as number[], + opened: [] as number[], + closed: [] as number[], + shown: [] as number[], + picked: [] as string[], + }; + (window as unknown as {__menuLog: typeof log}).__menuLog = log; + document.addEventListener( + 'pointerdown', + () => log.down.push(performance.now()), + true, + ); + document.addEventListener( + 'pointerup', + () => log.up.push(performance.now()), + true, + ); + document.addEventListener( + 'pointercancel', + () => log.cancel.push(performance.now()), + true, + ); + // The moment the menu shows: `beforetoggle` fires inside showPopover, + // while `toggle` follows in a task. + document.addEventListener( + 'beforetoggle', + event => { + if (event.newState === 'open') { + log.shown.push(performance.now()); + } + }, + true, + ); + document.addEventListener( + 'toggle', + event => { + // A popover hidden from assistive technology (a close's exit copy) + // is no menu a screen reader hears (module AR5). + if ( + event.target instanceof Element && + event.target.getAttribute('aria-hidden') === 'true' + ) { + return; + } + const state = event.newState; + (state === 'open' ? log.opened : log.closed).push(performance.now()); + }, + true, + ); + // The story reports a pick by logging "