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..2064edfb7ab6a 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 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/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 4de3fa57df8de..1d3535ac1ca66 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,31 @@ 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('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); + expect(curve.at(0)).toBe(1); + }); +}); diff --git a/packages/core/src/motion/progress.ts b/packages/core/src/motion/progress.ts index 8b97de255de90..bd2cfdb68a938 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), `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) @@ -39,6 +40,7 @@ */ import type {Animator, MotionCurve, MotionProperty} from './animator'; +import {cubicBezier, type BezierPoints} from './bezier'; import { SETTLING_EPSILON, solveSpring, @@ -69,6 +71,73 @@ export function progressCurve( }; } +/** + * 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 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 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 (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); + }, + }; +} + +/** + * `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 new file mode 100644 index 0000000000000..4a26cf964db2f --- /dev/null +++ b/packages/core/src/motion/rectMorph.test.ts @@ -0,0 +1,754 @@ +// 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 {cubicBezier} from './bezier'; +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 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 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, {velocity: 6}); + 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 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. + 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('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]}); + 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..d7564fb169638 --- /dev/null +++ b/packages/core/src/motion/rectMorph.ts @@ -0,0 +1,481 @@ +// 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 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 + * - /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'; + +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. 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} + | {readonly spring: SpringDescription}; + +/** + * How a present or a dismissal plays. + */ +export interface RectMorphPlayOptions { + /** + * 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; + /** + * 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); + } + }; + + // At rest the element needs no compositing layer, so set the values 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); + }; + + // 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 = + 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 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, + 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`. 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 | undefined, + ) => + 'spring' in motion + ? progressCurve(resolveMotion(on, motion.spring), {velocity}) + : timedProgressCurve(motion.duration, motion.easing, {velocity}); + + const curvesFor = ( + on: Element, + path: MorphPath, + motion: RectMorphMotion, + play: RectMorphPlayOptions, + velocity: number | undefined, + ) => { + 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; + 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', + ); + // 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 ?? + (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. + 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. 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(); + if (given == null) { + 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(); + } + }, + }; +}