diff --git a/.changeset/animated-layout.md b/.changeset/animated-layout.md new file mode 100644 index 0000000000000..68b665f8f7aa8 --- /dev/null +++ b/.changeset/animated-layout.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the one module for size-change glides (`spec:AST-105`, `architecture:react-component-runtime` INV12). After a reported size change, the content after it inside a container glides to its new place on one shared internal motion role, and the changed element enters or exits on the same motion. Not exported; no component uses it yet. The public `useAnimatedLayout` hook and Collapsible's and TreeList's `animatedLayout` prop arrive together in a later release. + +@cixzhang diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 55aa43644fe40..142e9799329dc 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -164,8 +164,9 @@ those contracts. `spec:AST-105` defines. It measures the layout before and after a reported change and moves shifted content with a compositor-only translate played through the shared rectangle morph (`architecture:layer-runtime` INV15; - `spec:AST-094` FR5); it never animates height. Its walk visits only the - following element siblings at each ancestor level up to the area, through + `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 + the following element siblings at each ancestor level up to the area, through `parentElement` and `nextElementSibling`, descending only into an element with `display: contents`, so its cost grows with depth plus following siblings. Changes in one area in the same frame are measured together. A diff --git a/docs/specs/AST-105-animated-layout/spec.md b/docs/specs/AST-105-animated-layout/spec.md index da6e3b060e54c..2cad8554e896f 100644 --- a/docs/specs/AST-105-animated-layout/spec.md +++ b/docs/specs/AST-105-animated-layout/spec.md @@ -126,6 +126,10 @@ Out of coverage, each taking its final place at once: since only content after the change in the DOM moves; - text reflowing inside a paragraph beside a float; - elements the app moves itself with its own transforms; +- a closing element whose space no end margin frees (a grid item whose + track or gap changes when it hides, or a flex-row item that sets its row's + height): it hides at once, without its exit, and the content after it + takes its final place at once; - an anchored popover tied to a moving element, which layer anchoring keeps in place (`architecture:layer-runtime`). diff --git a/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts b/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts new file mode 100644 index 0000000000000..69ff68f1663a9 --- /dev/null +++ b/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts @@ -0,0 +1,1041 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file animatedLayout.interaction.browser.spec.ts + * @input The animated-layout module bundled with esbuild onto fixture pages + * written inline, real Chromium and WebKit (the interaction projects: a + * phone with a coarse pointer) plus a fine-pointer describe + * @output One proof per `spec:AST-105` Coverage row, and FR2–FR4: which + * elements glide after a reported size change, by how much, on one shared + * start and spring, never past the area and never scrolling it; nothing + * without a handle; Reduce Motion by pointer + * @position Real-browser verification of `spec:AST-105` and + * `architecture:react-component-runtime` INV12 + */ + +import {fileURLToPath} from 'node:url'; +import {expect, test, type Page} from '@playwright/test'; +import {build} from 'esbuild'; +import type * as Bundle from './animatedLayoutBundle'; + +/** One animation on an element: its first frame and its timing. */ +interface MotionRead { + first: string | null; + opacity: string | null; + composite: string; + duration: number; + startTime: number | null; +} + +declare global { + interface Window { + AnimatedLayout: typeof Bundle; + /** Runs one reported change; resolves after the glide is set up. */ + change: (options: { + changed: string; + mutate: string; + area?: string; + handle?: boolean; + }) => Promise; + /** Animations on an element: their first transform and timing. */ + motionOf: (selector: string) => MotionRead[]; + } +} + +let script = ''; + +test.beforeAll(async () => { + const result = await build({ + entryPoints: [ + fileURLToPath(new URL('./animatedLayoutBundle.ts', import.meta.url)), + ], + bundle: true, + write: false, + format: 'iife', + globalName: 'AnimatedLayout', + target: 'es2022', + }); + script = result.outputFiles[0].text; +}); + +const BASE_STYLE = ` + html, body { margin: 0; font: 14px sans-serif; background: #fff; } + .area { position: relative; height: 560px; overflow: auto; } + .row { height: 40px; margin: 0 0 4px; background: #eef; } + .grow { height: 0; overflow: hidden; background: #fd9; } + .grow.open { height: 80px; } +`; + +const HELPERS = ` +window.change = async ({changed, mutate, area = '#area', handle = true}) => { + const container = document.querySelector(area); + const target = document.querySelector(changed); + const layout = handle + ? AnimatedLayout.createAnimatedLayout(() => container) + : null; + const report = AnimatedLayout.reportLayoutChange(layout, () => target); + report.measureBefore(); + new Function(mutate)(); + report.animate(); + AnimatedLayout.playLayoutChange(layout, target); + await new Promise(resolve => queueMicrotask(resolve)); +}; +window.motionOf = selector => { + const element = document.querySelector(selector); + return element.getAnimations({subtree: false}).map(animation => { + const effect = animation.effect; + const frames = effect.getKeyframes(); + const timing = effect.getTiming(); + return { + first: frames[0].translate ?? frames[0].transform ?? null, + opacity: frames[0].opacity == null ? null : String(frames[0].opacity), + composite: effect.composite, + duration: Number(timing.duration), + startTime: animation.startTime == null ? null : Number(animation.startTime), + }; + }); +}; +`; + +async function open(page: Page, body: string, style = '') { + const html = ` + +${body} + +`; + await page.route('http://layout.test/', async route => + route.fulfill({contentType: 'text/html', body: html}), + ); + await page.goto('http://layout.test/'); +} + +const OPEN_GROW = `document.querySelector('#grow').classList.add('open')`; + +/** Reports one change from the test (its options cross into the page). */ +async function change( + page: Page, + options: { + changed?: string; + mutate?: string; + area?: string; + handle?: boolean; + } = {}, +) { + await page.evaluate(async o => window.change(o), { + changed: '#grow', + mutate: OPEN_GROW, + ...options, + }); +} + +/** Until every animation on the page has finished. */ +async function settle(page: Page) { + await expect + .poll(async () => page.evaluate(() => document.getAnimations().length), { + timeout: 5000, + }) + .toBe(0); +} + +/** The translate an animation starts from, in px. */ +function startOffset(first: string | null): {x: number; y: number} { + // The browser shortens `-80px 0px` to `-80px`. + const match = /^(-?[\d.e+-]+)px(?:\s+(-?[\d.e+-]+)px)?/.exec(first ?? ''); + return match == null + ? {x: 0, y: 0} + : {x: Number(match[1]), y: match[2] == null ? 0 : Number(match[2])}; +} + +test.describe('spec:AST-105 Coverage', () => { + test('normal-flow siblings at each ancestor level glide, and nothing past the area moves', async ({ + page, + }) => { + await open( + page, + `
+
+
+
+
+
+
+
+
+
+
`, + ); + await change(page); + for (const id of ['#s3', '#s2', '#s1', '#s0']) { + const [motion] = await page.evaluate(s => window.motionOf(s), id); + expect(motion, id).toBeDefined(); + expect(startOffset(motion.first).y, id).toBeCloseTo(-80, 0); + expect(motion.composite, id).toBe('replace'); + } + expect(await page.evaluate(() => window.motionOf('#outside'))).toEqual([]); + }); + + test('a display: contents wrapper has no box: its children glide in its place', async ({ + page, + }) => { + await open( + page, + `
+
`, + ); + await change(page); + expect(await page.evaluate(() => window.motionOf('#wrap'))).toEqual([]); + for (const id of ['#c1', '#c2']) { + expect(await page.evaluate(s => window.motionOf(s), id)).toHaveLength(1); + } + }); + + test('absolute and fixed elements take their final place; an absolute badge rides with its row', async ({ + page, + }) => { + await open( + page, + `
+
+
+
`, + ); + const before = await page.locator('#badge').boundingBox(); + await change(page); + expect(await page.evaluate(() => window.motionOf('#abs'))).toEqual([]); + expect(await page.evaluate(() => window.motionOf('#fixed'))).toEqual([]); + expect(await page.evaluate(() => window.motionOf('#badge'))).toEqual([]); + // Halfway through, the row and its badge are between the two places. + await page.evaluate(() => { + for (const animation of document + .querySelector('#row') + ?.getAnimations() ?? []) { + animation.pause(); + animation.currentTime = 60; + } + }); + const mid = await page.locator('#badge').boundingBox(); + expect(mid?.y ?? 0).toBeGreaterThan(before?.y ?? 0); + expect(mid?.y ?? 0).toBeLessThan((before?.y ?? 0) + 80); + }); + + test('a sticky element glides while unstuck and stays put while stuck', async ({ + page, + }) => { + // Both headers come after the change; the first is stuck at the area's + // top, the second is still in flow below. No scroll anchoring, so content + // after a change above the fold moves. + await open( + page, + `
+
+ ${'
'.repeat(2)} +
+ ${'
'.repeat(10)} +
+ ${'
'.repeat(20)} +
`, + ); + const stuckTop = await page.evaluate(() => { + const area = document.querySelector('#area') as HTMLElement; + area.scrollTop = 300; + const top = area.getBoundingClientRect().top; + return ( + (document.querySelector('#stuck')?.getBoundingClientRect().top ?? 0) - + top + ); + }); + // Precondition: the first header is stuck at the top. + expect(stuckTop).toBeCloseTo(0, 0); + await change(page); + expect(await page.evaluate(() => window.motionOf('#stuck'))).toEqual([]); + const [unstuck] = await page.evaluate(() => window.motionOf('#unstuck')); + expect(startOffset(unstuck.first).y).toBeCloseTo(-80, 0); + }); + + test('a float after the change glides', async ({page}) => { + await open( + page, + `
+
`, + ); + await change(page); + expect(await page.evaluate(() => window.motionOf('#float'))).toHaveLength( + 1, + ); + }); + + test('flex items: an item that wraps glides, a row-direction neighbor whose rectangle does not change stays', async ({ + page, + }) => { + await open( + page, + `
+
+
+
+
+
+
+
+
+
+
+
`, + ); + // The first chip widens: the last item wraps to a new line and glides. + const t2Before = await page.evaluate( + () => document.querySelector('#t2')?.getBoundingClientRect().top ?? 0, + ); + await change(page, { + changed: '#chip', + mutate: `document.querySelector('#chip').style.width = '120px'`, + }); + const t2After = await page.evaluate( + () => document.querySelector('#t2')?.getBoundingClientRect().top ?? 0, + ); + const [wrapped] = await page.evaluate(() => window.motionOf('#t2')); + expect(wrapped).toBeDefined(); + // It wrapped: its layout moved down a line, and it starts where it was. + expect(startOffset(wrapped.first).y).toBeLessThan(-20); + // A round trip after the change: it has barely left its old place. + expect(Math.abs(t2After - t2Before)).toBeLessThan(4); + await settle(page); + // An item grows taller: its row-direction neighbor keeps its rectangle and + // stays; the content after the row glides. + await change(page, { + changed: '#tall', + mutate: `document.querySelector('#tall').style.height = '110px'`, + }); + expect(await page.evaluate(() => window.motionOf('#neighbor'))).toEqual([]); + const [below] = await page.evaluate(() => window.motionOf('#below')); + expect(startOffset(below.first).y).toBeCloseTo(-80, 0); + }); + + test('hidden, display: none and zero-size elements are skipped', async ({ + page, + }) => { + await open( + page, + `
+ + +
+
`, + ); + await change(page); + for (const id of ['#hidden', '#none', '#empty']) { + expect(await page.evaluate(s => window.motionOf(s), id), id).toEqual([]); + } + expect(await page.evaluate(() => window.motionOf('#after'))).toHaveLength( + 1, + ); + }); + + test('elements outside the visible part land without animation', async ({ + page, + }) => { + await open( + page, + `
+ ${Array.from({length: 40}, (_, i) => `
`).join('')}
`, + ); + await change(page); + expect(await page.evaluate(() => window.motionOf('#r0'))).toHaveLength(1); + // Far below the fold before and after. + expect(await page.evaluate(() => window.motionOf('#r39'))).toEqual([]); + // A change above the fold, out of sight: the changed element plays nothing. + await settle(page); + await page.evaluate(() => { + (document.querySelector('#area') as HTMLElement).scrollTop = 1200; + }); + await change(page, { + changed: '#r2', + mutate: `document.querySelector('#r2').style.height = '120px'`, + }); + expect(await page.evaluate(() => window.motionOf('#r2'))).toEqual([]); + }); + + test('a row under a scaled panel moves by its layout change, corrected for the scale', async ({ + page, + }) => { + await open( + page, + `
+
`, + ); + await change(page); + const [motion] = await page.evaluate(() => window.motionOf('#scaled')); + // 80 px of layout: 40 px on screen, translated in the panel's own space. + expect(startOffset(motion.first).y).toBeCloseTo(-80, 0); + }); + + test('top-layer content is never moved', async ({page}) => { + await open( + page, + `
+
menu
`, + ); + await page.evaluate(() => + (document.querySelector('#pop') as HTMLElement).showPopover(), + ); + await change(page); + expect(await page.evaluate(() => window.motionOf('#pop'))).toEqual([]); + }); + + test('an element already moving keeps its motion and adds the new move: no jump, no lost speed', async ({ + page, + }) => { + await open( + page, + `
+
`, + ); + await change(page); + await page.waitForTimeout(100); + const [first] = await page.evaluate(() => window.motionOf('#after')); + // The row's on-screen speed over the frame before the second change and + // the frame after it, and its place either side of the change. + const run = await page.evaluate(async mutate => { + const row = document.querySelector('#after') as HTMLElement; + const frame = async () => + new Promise(resolve => requestAnimationFrame(resolve)); + const t0 = await frame(); + const y0 = row.getBoundingClientRect().top; + const t1 = await frame(); + const y1 = row.getBoundingClientRect().top; + await window.change({changed: '#second', mutate}); + const y1After = row.getBoundingClientRect().top; + const t2 = await frame(); + const y2 = row.getBoundingClientRect().top; + return { + before: (y1 - y0) / (t1 - t0), + after: (y2 - y1After) / (t2 - t1), + y1, + y1After, + }; + }, `document.querySelector('#second').classList.add('open')`); + const motions = await page.evaluate(() => window.motionOf('#after')); + expect(motions).toHaveLength(1); + expect(motions[0].composite).toBe('replace'); + expect(motions[0].startTime).not.toBe(first.startTime); + expect(run.y1After).toBeCloseTo(run.y1, 0); + // Still gliding down at least as fast: the new move adds to the motion. + expect(run.before).toBeGreaterThan(0); + expect(run.after).toBeGreaterThan(run.before * 0.9); + // It still lands where layout put it: 160 px below where it began. + await settle(page); + const landed = await page.evaluate(() => { + const area = document.querySelector('#area')?.getBoundingClientRect(); + const row = document.querySelector('#after')?.getBoundingClientRect(); + return (row?.top ?? 0) - (area?.top ?? 0); + }); + expect(landed).toBeCloseTo(160, 0); + }); + + test('nested areas: motion stops at the inner area; an inner area moves as one unit', async ({ + page, + }) => { + await open( + page, + `
+
+
`, + ); + await change(page, {area: '#inner'}); + expect( + await page.evaluate(() => window.motionOf('#innerAfter')), + ).toHaveLength(1); + expect(await page.evaluate(() => window.motionOf('#outerAfter'))).toEqual( + [], + ); + await settle(page); + await change(page, { + changed: '#outerGrow', + mutate: `document.querySelector('#outerGrow').classList.add('open')`, + }); + expect(await page.evaluate(() => window.motionOf('#inner'))).toHaveLength( + 1, + ); + expect( + (await page.evaluate(() => window.motionOf('#innerAfter'))).length, + ).toBe(0); + }); +}); + +test.describe('architecture:react-component-runtime INV12', () => { + test('a 10-level by 50-sibling tree on a throttled phone: one move per follower, none inside a subtree, none past the area, no height', async ({ + page, + browserName, + }) => { + if (browserName === 'chromium') { + const cdp = await page.context().newCDPSession(page); + await cdp.send('Emulation.setCPUThrottlingRate', {rate: 4}); + } + // Level k holds the path to the changed element plus 49 rows, each with + // a child of its own (a subtree the walk must not enter). + const level = (depth: number): string => { + const rows = Array.from( + {length: 49}, + () => + `
`, + ).join(''); + const inner = + depth === 9 + ? '
' + : level(depth + 1); + return `
${inner}
${rows}`; + }; + await open( + page, + `
${level(0)}
+
`, + ); + // Two changes in one frame, read in the same task: on a throttled page a + // second round trip can outlast the glide. + const report = await page.evaluate(async () => { + const area = document.querySelector('#area'); + const layout = window.AnimatedLayout.createAnimatedLayout(() => area); + const one = window.AnimatedLayout.reportLayoutChange(layout, () => + document.querySelector('#g1'), + ); + const two = window.AnimatedLayout.reportLayoutChange(layout, () => + document.querySelector('#g2'), + ); + one.measureBefore(); + two.measureBefore(); + document.querySelector('#g1')?.classList.add('open'); + document.querySelector('#g2')?.classList.add('open'); + one.animate(); + two.animate(); + await new Promise(resolve => { + queueMicrotask(resolve); + }); + const animations = document.getAnimations(); + const targets = animations.map( + a => (a.effect as KeyframeEffect).target as Element, + ); + return { + total: animations.length, + distinct: new Set(targets).size, + intoSubtree: targets.filter(t => t.classList.contains('inner')).length, + pastArea: targets.filter(t => t.id === 'outside').length, + animatesHeight: animations.some(a => + (a.effect as KeyframeEffect) + .getKeyframes() + .some(f => 'height' in f || 'top' in f), + ), + followers: targets.filter(t => t.classList.contains('sib')).length, + }; + }); + expect(report.followers).toBeGreaterThan(0); + expect(report.distinct).toBe(report.total); + expect(report.intoSubtree).toBe(0); + expect(report.pastArea).toBe(0); + expect(report.animatesHeight).toBe(false); + }); +}); + +test.describe('spec:AST-105 FR2–FR4', () => { + const PAGE = `
+
+ ${'
'.repeat(30)}
`; + + test('without a handle nothing animates (FR2)', async ({page}) => { + await open(page, PAGE); + await change(page, {handle: false}); + for (const id of ['#grow', '#a', '#b']) { + expect(await page.evaluate(s => window.motionOf(s), id), id).toEqual([]); + } + }); + + test('two changes in one frame move each element once, by its total change (FR3)', async ({ + page, + }) => { + await open( + page, + `
`, + ); + await page.evaluate(async () => { + const area = document.querySelector('#area'); + const layout = window.AnimatedLayout.createAnimatedLayout(() => area); + const one = window.AnimatedLayout.reportLayoutChange(layout, () => + document.querySelector('#g1'), + ); + const two = window.AnimatedLayout.reportLayoutChange(layout, () => + document.querySelector('#g2'), + ); + one.measureBefore(); + two.measureBefore(); + document.querySelector('#g1')?.classList.add('open'); + document.querySelector('#g2')?.classList.add('open'); + one.animate(); + two.animate(); + await new Promise(resolve => { + queueMicrotask(resolve); + }); + }); + const motions = await page.evaluate(() => window.motionOf('#after')); + expect(motions).toHaveLength(1); + expect(startOffset(motions[0].first).y).toBeCloseTo(-160, 0); + // The second changed element, pushed down by the first, moves with its + // row from its old place. + const [second] = await page.evaluate(() => window.motionOf('#g2')); + expect(startOffset(second.first).y).toBeCloseTo(-80, 0); + }); + + test('nested changes compose whichever is reported first: a row inside a moving folder moves only by its own change (FR3)', async ({ + page, + }) => { + await open( + page, + `
+
+
`, + ); + await page.evaluate(async () => { + const area = document.querySelector('#area'); + const layout = window.AnimatedLayout.createAnimatedLayout(() => area); + // The inner change is reported first. + const inner = window.AnimatedLayout.reportLayoutChange(layout, () => + document.querySelector('#inner'), + ); + const outer = window.AnimatedLayout.reportLayoutChange(layout, () => + document.querySelector('#a'), + ); + inner.measureBefore(); + outer.measureBefore(); + document.querySelector('#inner')?.classList.add('open'); + document.querySelector('#a')?.classList.add('open'); + inner.animate(); + outer.animate(); + await new Promise(resolve => { + queueMicrotask(resolve); + }); + }); + const [folder] = await page.evaluate(() => window.motionOf('#folder')); + const [row] = await page.evaluate(() => window.motionOf('#row')); + const [after] = await page.evaluate(() => window.motionOf('#after')); + // The folder moves by the first change; the row inside it by the inner + // one alone; what follows by both. + expect(startOffset(folder.first).y).toBeCloseTo(-80, 0); + expect(startOffset(row.first).y).toBeCloseTo(-80, 0); + expect(startOffset(after.first).y).toBeCloseTo(-160, 0); + }); + + test('the glide never scrolls the area (FR3)', async ({page}) => { + // The same change with and without a handle, from the same scroll + // position: the area's scroll (the browser's own scroll anchoring + // included) is identical, frame by frame, so the glide adds none. + const scrollsOf = async (handle: boolean) => { + await open(page, PAGE); + return page.evaluate( + async ({mutate, withHandle}) => { + const area = document.querySelector('#area') as HTMLElement; + area.scrollTop = 40; + const frame = async () => + new Promise(resolve => requestAnimationFrame(resolve)); + await frame(); + await frame(); + await window.change({changed: '#grow', mutate, handle: withHandle}); + const tops: number[] = []; + for (let i = 0; i < 40; i++) { + tops.push(area.scrollTop); + await frame(); + } + return tops; + }, + {mutate: OPEN_GROW, withHandle: handle}, + ); + }; + const without = await scrollsOf(false); + const withHandle = await scrollsOf(true); + expect(withHandle).toEqual(without); + }); + + test('the changed element and its followers start on one frame on springs.settle (FR4)', async ({ + page, + }) => { + await open(page, PAGE); + await change(page); + await page.waitForTimeout(50); + const grow = await page.evaluate(() => window.motionOf('#grow')); + const follower = await page.evaluate(() => window.motionOf('#a')); + expect(grow).toHaveLength(1); + expect(follower).toHaveLength(1); + expect(grow[0].startTime).toBe(follower[0].startTime); + expect(grow[0].duration).toBeCloseTo(follower[0].duration, 3); + // springs.settle is SwiftUI's smooth spring (duration 0.5 s, no bounce): + // it settles within a second. + expect(follower[0].duration).toBeGreaterThan(300); + expect(follower[0].duration).toBeLessThan(1200); + }); + + test('a closing element exits on the same motion while the content after it glides up at once (FR4)', async ({ + page, + }) => { + await open( + page, + `
Details
+
`, + ); + const result = await page.evaluate(async () => { + const area = document.querySelector('#area'); + const content = document.querySelector('#content') as HTMLElement; + const layout = window.AnimatedLayout.createAnimatedLayout(() => area); + const report = window.AnimatedLayout.reportLayoutChange( + layout, + () => content, + ); + report.measureBefore(); + // The component closes it but keeps it rendered for the exit, holding + // it once the change reports it measured. + report.animate(); + let finished = false; + const held = window.AnimatedLayout.holdLayoutExit(layout, content, () => { + finished = true; + }); + await new Promise(resolve => { + queueMicrotask(resolve); + }); + const exit = content.getAnimations()[0]; + const timing = exit?.effect?.getTiming(); + const followerStart = window.motionOf('#a')[0]?.first ?? null; + const visible = getComputedStyle(content).display !== 'none'; + await exit?.finished; + // The finish event follows the finished promise. + await new Promise(resolve => { + setTimeout(resolve, 50); + }); + return { + held, + visible, + position: getComputedStyle(content).position, + marginEnd: content.style.getPropertyValue('margin-block-end'), + followerStart, + exitDuration: Number(timing?.duration), + followerDuration: window.motionOf('#a')[0]?.duration ?? null, + finished, + }; + }); + expect(result.held).toBe(true); + expect(result.visible).toBe(true); + // In flow, where it was, giving up its space. + expect(result.position).toBe('static'); + expect(result.marginEnd).toBe('-80px'); + // The row below starts 80 px lower, where it was, and glides up. + expect(startOffset(result.followerStart).y).toBeCloseTo(80, 0); + expect(result.finished).toBe(true); + }); + + test('an exiting element in a scrolled area stays where it is, scrolls with the area and keeps its width (FR4)', async ({ + page, + }) => { + await open( + page, + `
+ ${'
'.repeat(3)} +
Details
+ ${'
'.repeat(30)} +
`, + ); + const result = await page.evaluate(async () => { + const area = document.querySelector('#area') as HTMLElement; + const content = document.querySelector('#content') as HTMLElement; + area.scrollTop = 60; + const before = content.getBoundingClientRect(); + const layout = window.AnimatedLayout.createAnimatedLayout(() => area); + const report = window.AnimatedLayout.reportLayoutChange( + layout, + () => content, + ); + report.measureBefore(); + report.animate(); + window.AnimatedLayout.holdLayoutExit(layout, content, () => {}); + await new Promise(resolve => { + queueMicrotask(resolve); + }); + const held = content.getBoundingClientRect(); + area.scrollTop = 80; + const scrolled = content.getBoundingClientRect(); + return {before, held, scrolled}; + }); + expect(result.held.top).toBeCloseTo(result.before.top, 0); + expect(result.held.width).toBeCloseTo(result.before.width, 0); + // It scrolls with the area (so the area still clips it). + expect(result.scrolled.top).toBeCloseTo(result.before.top - 20, 0); + }); + + for (const layout of [ + { + name: 'a flex column with a gap', + html: `
+
+
Details
+
+
`, + }, + { + name: 'collapsing margins', + html: `
+
+
Details
+
+
`, + }, + { + // What follows is inside a `display: contents` wrapper, as Tooltip and + // Theme render one. + name: 'a flex column whose next item is a display: contents wrapper', + html: `
+
+
Details
+
+
`, + }, + { + // Under a scaled ancestor: the correction is read in screen px. + name: 'a gap stack under a scaled ancestor', + html: `
+
+
+
Details
+
+
+
`, + }, + { + // Collapsible's shape: the content is the last child of a plain block + // wrapper, and what follows sits after the wrapper. + name: 'a plain wrapper, as its last child', + html: `
+
+
Details
+
+

Next

`, + }, + ]) { + test(`an exiting element frees exactly its space in ${layout.name}: the content after it does not jump when it hides (FR3, FR4)`, async ({ + page, + }) => { + await open(page, `
${layout.html}
`); + const result = await page.evaluate(async () => { + const area = document.querySelector('#area'); + const content = document.querySelector('#content') as HTMLElement; + const after = document.querySelector('#after') as HTMLElement; + const layoutHandle = window.AnimatedLayout.createAnimatedLayout( + () => area, + ); + const report = window.AnimatedLayout.reportLayoutChange( + layoutHandle, + () => content, + ); + report.measureBefore(); + const measured = report.animate(); + const held = window.AnimatedLayout.holdLayoutExit( + layoutHandle, + content, + () => {}, + ); + const inert = content.hasAttribute('inert'); + await new Promise(resolve => { + queueMicrotask(resolve); + }); + const glides = after.getAnimations().length; + for (const animation of document.getAnimations()) { + animation.finish(); + } + const settled = after.getBoundingClientRect().top; + // The component hides it once its exit ends. + content.style.display = 'none'; + window.AnimatedLayout.releaseLayoutExit(content); + const hidden = after.getBoundingClientRect().top; + return {measured, held, inert, glides, settled, hidden}; + }); + expect(result.measured).toBe(true); + expect(result.held).toBe(true); + expect(result.inert).toBe(true); + expect(result.glides).toBe(1); + expect(result.settled).toBeCloseTo(result.hidden, 0); + }); + } + + test("a closing flex-row item that sets its row's height hides at once, and the content after the row takes its final place at once", async ({ + page, + }) => { + await open( + page, + `
+
+
+
+
+
+
`, + ); + const result = await page.evaluate(async () => { + const area = document.querySelector('#area'); + const content = document.querySelector('#content') as HTMLElement; + const after = document.querySelector('#after') as HTMLElement; + const layoutHandle = window.AnimatedLayout.createAnimatedLayout( + () => area, + ); + const report = window.AnimatedLayout.reportLayoutChange( + layoutHandle, + () => content, + ); + report.measureBefore(); + report.animate(); + const held = window.AnimatedLayout.holdLayoutExit( + layoutHandle, + content, + () => {}, + ); + // The component hides it in the same commit. + content.style.display = 'none'; + await new Promise(resolve => { + queueMicrotask(resolve); + }); + return { + held, + margin: content.style.getPropertyValue('margin-inline-end'), + glides: after.getAnimations().length, + }; + }); + expect(result).toEqual({held: false, margin: '', glides: 0}); + }); + + test('holding the exit keeps the CSS animations inside the element running (FR4)', async ({ + page, + }) => { + await open( + page, + ` +
+
+
+
`, + ); + const result = await page.evaluate(async () => { + const area = document.querySelector('#area'); + const content = document.querySelector('#content') as HTMLElement; + const spinner = document.querySelector('#spinner') as HTMLElement; + await new Promise(resolve => { + setTimeout(resolve, 300); + }); + let restarts = 0; + spinner.addEventListener('animationstart', () => { + restarts += 1; + }); + const before = Number(spinner.getAnimations()[0]?.currentTime); + const layoutHandle = window.AnimatedLayout.createAnimatedLayout( + () => area, + ); + const report = window.AnimatedLayout.reportLayoutChange( + layoutHandle, + () => content, + ); + report.measureBefore(); + report.animate(); + const held = window.AnimatedLayout.holdLayoutExit( + layoutHandle, + content, + () => {}, + ); + const after = Number(spinner.getAnimations()[0]?.currentTime); + await new Promise(resolve => { + setTimeout(resolve, 100); + }); + return {held, before, after, restarts}; + }); + expect(result.held).toBe(true); + // The spinner kept its time: no restart from 0. + expect(result.after).toBeGreaterThanOrEqual(result.before); + expect(result.restarts).toBe(0); + }); + + test('a follower the app moves with its own transform takes its final place at once', async ({ + page, + }) => { + await open( + page, + `
+
+
+
`, + ); + await page.evaluate(() => { + document + .querySelector('#spun') + ?.animate([{rotate: '0deg'}, {rotate: '3deg'}], { + duration: 5000, + iterations: Infinity, + }); + }); + await change(page); + expect(await page.evaluate(() => window.motionOf('#moved'))).toEqual([]); + const spun = await page.evaluate(() => window.motionOf('#spun')); + expect(spun.filter(m => m.first?.includes('px') === true)).toEqual([]); + expect(await page.evaluate(() => window.motionOf('#plain'))).toHaveLength( + 1, + ); + }); + + test("a scope's --motion-springs-settle changes nothing: the role is internal (FR4)", async ({ + page, + }) => { + await open(page, PAGE); + await change(page); + const [shared] = await page.evaluate(() => window.motionOf('#a')); + await open(page, PAGE, '#area { --motion-springs-settle: 0.2s 0; }'); + await change(page); + const [scoped] = await page.evaluate(() => window.motionOf('#a')); + expect(scoped.duration).toBeCloseTo(shared.duration, 3); + }); + + test('under Reduce Motion with a coarse pointer, both cross-fade with no travel (FR4)', async ({ + page, + }) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + await open(page, PAGE); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(true); + await change(page); + for (const id of ['#grow', '#a']) { + const [motion] = await page.evaluate(s => window.motionOf(s), id); + expect(motion.first, id).toBeNull(); + expect(motion.opacity, id).toBe('0'); + expect(motion.duration, id).toBeLessThanOrEqual(200); + } + }); +}); + +test.describe('spec:AST-105 FR5 and FR4 under a fine pointer', () => { + test.use({ + isMobile: false, + hasTouch: false, + viewport: {width: 1280, height: 800}, + }); + + const PAGE = `
+
`; + + test('the glide applies under a mouse (FR5)', async ({page}) => { + await open(page, PAGE); + expect( + await page.evaluate(() => matchMedia('(pointer: fine)').matches), + ).toBe(true); + await change(page); + expect(await page.evaluate(() => window.motionOf('#a'))).toHaveLength(1); + }); + + test('under Reduce Motion with a fine pointer the change is immediate (FR4)', async ({ + page, + }) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + await open(page, PAGE); + await change(page); + expect(await page.evaluate(() => window.motionOf('#grow'))).toEqual([]); + expect(await page.evaluate(() => window.motionOf('#a'))).toEqual([]); + }); +}); diff --git a/packages/core/src/motion/__tests__/animatedLayoutBundle.ts b/packages/core/src/motion/__tests__/animatedLayoutBundle.ts new file mode 100644 index 0000000000000..e616620f26dc0 --- /dev/null +++ b/packages/core/src/motion/__tests__/animatedLayoutBundle.ts @@ -0,0 +1,17 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file animatedLayoutBundle.ts + * @input The animated-layout module the browser proofs drive + * @output Re-exports it as one entry point + * @position The esbuild entry `animatedLayout.interaction.browser.spec.ts` + * bundles into its page (global `AnimatedLayout`) + */ + +export { + createAnimatedLayout, + holdLayoutExit, + playLayoutChange, + releaseLayoutExit, + reportLayoutChange, +} from '../animatedLayout'; diff --git a/packages/core/src/motion/animatedLayout.boundary.test.ts b/packages/core/src/motion/animatedLayout.boundary.test.ts new file mode 100644 index 0000000000000..9ac1d1947eafd --- /dev/null +++ b/packages/core/src/motion/animatedLayout.boundary.test.ts @@ -0,0 +1,131 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file animatedLayout.boundary.test.ts + * @input The source of motion/animatedLayout.ts, the core entry points and + * every core source file that imports the module + * @output The source boundary check `architecture:react-component-runtime` + * INV12 asks for: the module animates no layout property, its walk reaches + * elements only through parentElement / nextElementSibling (and + * firstElementChild under `display: contents`), the reporting functions are + * not public, and only the hook and the adopting components use it + * @position Testing; the real-browser half of INV12's verification is in + * __tests__/animatedLayout.interaction.browser.spec.ts + */ + +import {existsSync, readFileSync, readdirSync, statSync} from 'node:fs'; +import {dirname, join, relative} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {describe, expect, it} from 'vitest'; + +const here = dirname(fileURLToPath(import.meta.url)); +const coreSrc = join(here, '..'); +const moduleSource = readFileSync(join(here, 'animatedLayout.ts'), 'utf8'); + +/** The source with its comments removed, so prose cannot pass or fail it. */ +function code(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''); +} + +function sourceFiles(dir: string): string[] { + const out: string[] = []; + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) { + if (name !== '__tests__' && name !== 'node_modules') { + out.push(...sourceFiles(path)); + } + } else if ( + /\.(ts|tsx)$/.test(name) && + !/\.(test|spec)\.(ts|tsx)$/.test(name) + ) { + out.push(path); + } + } + return out; +} + +describe('architecture:react-component-runtime INV12 source boundary', () => { + it('animates no layout property: only translate, opacity and the entrance clip', () => { + const body = code(moduleSource); + // No keyframe or style object names a box property. + expect(body).not.toMatch( + /(?:^|[{,])\s*(height|width|top|left|right|bottom|margin\w*|padding\w*|inset\w*)\s*:/m, + ); + const animated = [ + ...body.matchAll(/[{,\s](translate|opacity|clipPath)\s*:/g), + ] + .map(match => match[1]) + .sort(); + expect([...new Set(animated)]).toEqual([ + 'clipPath', + 'opacity', + 'translate', + ]); + }); + + it('walks only through parentElement and nextElementSibling, entering display: contents alone', () => { + const body = code(moduleSource); + for (const forbidden of [ + 'TreeWalker', + 'createTreeWalker', + 'querySelector', + 'getElementsBy', + '.children', + 'childNodes', + 'lastElementChild', + 'previousElementSibling', + ]) { + expect(body, forbidden).not.toContain(forbidden); + } + // Every descent is a `display: contents` branch: the follower walk's and + // the exit's search for the next box. + const descents = body.match(/firstElementChild/g) ?? []; + expect(descents).toHaveLength(2); + const contentsDescents = + body.match( + /display === 'contents'\)\s*\{\s*for\s*\(\s*let child = \w+\.firstElementChild/g, + ) ?? []; + expect(contentsDescents).toHaveLength(2); + }); + + it('keeps the reporting functions out of every public entry point', () => { + const entries = [ + join(coreSrc, 'index.ts'), + join(coreSrc, 'hooks', 'index.ts'), + join(coreSrc, 'motion', 'index.ts'), + ]; + for (const entry of entries) { + if (!existsSync(entry)) { + continue; + } + const source = readFileSync(entry, 'utf8'); + for (const name of [ + 'reportLayoutChange', + 'playLayoutChange', + 'applyLayoutChangeTransition', + 'createAnimatedLayout', + ]) { + expect( + source, + `${relative(coreSrc, entry)} exports ${name}`, + ).not.toMatch(new RegExp(`\\b${name}\\b`)); + } + } + }); + + it('is used only by the hook and the components that adopt it', () => { + const users = sourceFiles(coreSrc) + .filter(path => path !== join(here, 'animatedLayout.ts')) + .filter(path => + /from '[./]+(motion\/)?animatedLayout'/.test( + readFileSync(path, 'utf8'), + ), + ) + .map(path => relative(coreSrc, path)) + .sort(); + // The hook and the components that adopt the handle join this list as + // they adopt it. + expect(users).toEqual([]); + }); +}); diff --git a/packages/core/src/motion/animatedLayout.test.ts b/packages/core/src/motion/animatedLayout.test.ts new file mode 100644 index 0000000000000..fe38c6fcff8c0 --- /dev/null +++ b/packages/core/src/motion/animatedLayout.test.ts @@ -0,0 +1,677 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file animatedLayout.test.ts + * @input vitest (jsdom), the animated-layout module, a recording stand-in for + * Element.animate and stubbed layout rectangles (jsdom lays nothing out) + * @output Checks the handle (`spec:AST-105` FR1), that nothing runs without + * one (FR2), which elements the walk takes and how it moves them (FR3), + * the shared `springs.settle` motion and Reduce Motion by pointer (FR4), + * and the indicator's transition + * @position Testing; the real geometry is proven in + * __tests__/animatedLayout.interaction.browser.spec.ts + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import { + createAnimatedLayout, + holdLayoutExit, + playLayoutChange, + releaseLayoutExit, + reportLayoutChange, +} from './animatedLayout'; + +interface Played { + element: Element; + keyframes: Keyframe[]; + options: KeyframeAnimationOptions; + cancel: ReturnType; + finish?: () => void; +} + +let played: Played[] = []; +let media: Record = {}; +const originalAnimate = Element.prototype.animate; +const originalMatchMedia = window.matchMedia; + +beforeEach(() => { + played = []; + media = {}; + Element.prototype.animate = function ( + this: Element, + keyframes: Keyframe[] | PropertyIndexedKeyframes | null, + options?: number | KeyframeAnimationOptions, + ) { + const cancel = vi.fn(); + played.push({ + element: this, + keyframes: keyframes as Keyframe[], + options: options as KeyframeAnimationOptions, + cancel, + }); + // Every glide reads as 100 ms in when a later change retargets it. + const entry = played[played.length - 1]; + return { + cancel, + addEventListener(type: string, listener: () => void) { + if (type === 'finish') { + entry.finish = listener; + } + }, + currentTime: 100, + } as unknown as Animation; + }; + window.matchMedia = (query: string) => + ({ + matches: media[query] === true, + media: query, + addEventListener() {}, + removeEventListener() {}, + }) as unknown as MediaQueryList; +}); + +afterEach(() => { + Element.prototype.animate = originalAnimate; + window.matchMedia = originalMatchMedia; + document.body.innerHTML = ''; +}); + +/** Gives `element` a rectangle that follows `phase` (before or after). */ +function placeAt( + element: Element, + rects: { + before: [number, number, number, number]; + after?: [number, number, number, number]; + }, + phase: {current: 'before' | 'after'}, +) { + vi.spyOn(element, 'getBoundingClientRect').mockImplementation(() => { + const [x, y, w, h] = + phase.current === 'before' || rects.after == null + ? rects.before + : rects.after; + return new DOMRect(x, y, w, h); + }); +} + +const flushMicrotasks = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + +/** + * An area with a changed element and the given followers after it, each + * shifted by `shift` px down after the change unless it says otherwise. + */ +function setUp(html: string) { + document.body.innerHTML = html; + const area = document.getElementById('area') as HTMLElement; + const changed = document.getElementById('changed') as HTMLElement; + const phase: {current: 'before' | 'after'} = {current: 'before'}; + placeAt(area, {before: [0, 0, 400, 600]}, phase); + placeAt(changed, {before: [0, 0, 400, 0], after: [0, 0, 400, 80]}, phase); + return {area, changed, phase}; +} + +function translateOf(entry: Played): {x: number; y: number} { + const first = String(entry.keyframes[0].translate ?? ''); + const match = /^(-?[\d.e+-]+)px\s+(-?[\d.e+-]+)px/.exec(first); + return match == null + ? {x: 0, y: 0} + : {x: Number(match[1]), y: Number(match[2])}; +} + +async function runChange( + layout: ReturnType | null, + changed: Element, + phase: {current: 'before' | 'after'}, +) { + const report = reportLayoutChange(layout, () => changed); + report.measureBefore(); + phase.current = 'after'; + report.animate(); + playLayoutChange(layout, changed); + await flushMicrotasks(); +} + +describe('the handle (spec:AST-105 FR1)', () => { + it('has no public members', () => { + const area = document.createElement('div'); + const handle = createAnimatedLayout(() => area); + expect(Object.keys(handle)).toEqual([]); + expect(Object.getOwnPropertyNames(handle)).toEqual([]); + expect(Object.isFrozen(handle)).toBe(true); + }); +}); + +describe('without a handle (spec:AST-105 FR2)', () => { + it('measures nothing and plays nothing', async () => { + const {changed, phase} = setUp( + '
', + ); + const a = document.getElementById('a') as HTMLElement; + placeAt(a, {before: [0, 0, 400, 40], after: [0, 80, 400, 40]}, phase); + await runChange(null, changed, phase); + expect(played).toEqual([]); + }); +}); + +describe('the walk (spec:AST-105 FR3, Coverage)', () => { + it('moves following siblings at every level up to the area, by their layout change, and nothing past it', async () => { + const {area, changed, phase} = setUp( + `
+
`, + ); + for (const [id, y] of [ + ['s2', 0], + ['s1', 50], + ['s0', 100], + ['outside', 700], + ] as const) { + placeAt( + document.getElementById(id) as HTMLElement, + {before: [0, y, 400, 40], after: [0, y + 80, 400, 40]}, + phase, + ); + } + await runChange( + createAnimatedLayout(() => area), + changed, + phase, + ); + const moved = played + .filter(p => p.keyframes[0].translate != null) + .map(p => (p.element as HTMLElement).id); + expect(moved.sort()).toEqual(['s0', 's1', 's2']); + for (const entry of played.filter(p => p.keyframes[0].translate != null)) { + expect(translateOf(entry).y).toBeCloseTo(-80); + // One replace-composited translate, which the compositor runs. + expect(entry.options.composite).toBeUndefined(); + expect(entry.options.easing).toBe('linear'); + // Lands where layout put it. + expect(String(entry.keyframes.at(-1)?.translate)).toBe('0px 0px'); + } + }); + + it('takes the children of a display: contents wrapper and skips absolute, fixed, hidden and display: none elements', async () => { + const {area, changed, phase} = setUp( + `
+
+
+
+ +
`, + ); + for (const id of ['c1', 'c2', 'abs', 'fixed', 'hidden', 'none']) { + placeAt( + document.getElementById(id) as HTMLElement, + {before: [0, 10, 400, 40], after: [0, 90, 400, 40]}, + phase, + ); + } + await runChange( + createAnimatedLayout(() => area), + changed, + phase, + ); + const moved = played + .filter(p => p.keyframes[0].translate != null) + .map(p => (p.element as HTMLElement).id); + expect(moved.sort()).toEqual(['c1', 'c2']); + }); + + it('leaves an element whose rectangle did not change, and one outside the visible part before and after', async () => { + const {area, changed, phase} = setUp( + '
', + ); + placeAt( + document.getElementById('still') as HTMLElement, + {before: [0, 10, 400, 40]}, + phase, + ); + placeAt( + document.getElementById('far') as HTMLElement, + {before: [0, 2000, 400, 40], after: [0, 2080, 400, 40]}, + phase, + ); + await runChange( + createAnimatedLayout(() => area), + changed, + phase, + ); + expect(played.filter(p => p.keyframes[0].translate != null)).toEqual([]); + }); + + it('moves each element once for two changes in one frame, by the total', async () => { + document.body.innerHTML = + '
'; + const area = document.getElementById('area') as HTMLElement; + const phase: {current: 'before' | 'after'} = {current: 'before'}; + placeAt(area, {before: [0, 0, 400, 600]}, phase); + placeAt( + document.getElementById('after') as HTMLElement, + {before: [0, 0, 400, 40], after: [0, 160, 400, 40]}, + phase, + ); + const layout = createAnimatedLayout(() => area); + const one = reportLayoutChange(layout, () => document.getElementById('g1')); + const two = reportLayoutChange(layout, () => document.getElementById('g2')); + one.measureBefore(); + two.measureBefore(); + phase.current = 'after'; + one.animate(); + two.animate(); + await flushMicrotasks(); + const moves = played.filter(p => (p.element as HTMLElement).id === 'after'); + expect(moves).toHaveLength(1); + expect(translateOf(moves[0]).y).toBeCloseTo(-160); + }); + + it('never scrolls the area', async () => { + const {area, changed, phase} = setUp( + '
', + ); + area.scrollTop = 0; + placeAt( + document.getElementById('a') as HTMLElement, + {before: [0, 0, 400, 40], after: [0, 80, 400, 40]}, + phase, + ); + const scrollTo = vi.fn(); + area.scrollTo = scrollTo; + await runChange( + createAnimatedLayout(() => area), + changed, + phase, + ); + expect(scrollTo).not.toHaveBeenCalled(); + expect(area.scrollTop).toBe(0); + }); +}); + +describe('one shared motion (spec:AST-105 FR4)', () => { + async function changeOnce(style = '') { + const {area, changed, phase} = setUp( + `
`, + ); + placeAt( + document.getElementById('a') as HTMLElement, + {before: [0, 0, 400, 40], after: [0, 80, 400, 40]}, + phase, + ); + await runChange( + createAnimatedLayout(() => area), + changed, + phase, + ); + } + + it('plays the changed element and its followers on springs.settle, with one duration', async () => { + await changeOnce(); + const follower = played.find(p => (p.element as HTMLElement).id === 'a'); + const own = played.find(p => (p.element as HTMLElement).id === 'changed'); + expect(follower).toBeDefined(); + expect(own).toBeDefined(); + expect(own?.options.duration).toBe(follower?.options.duration); + // SwiftUI's smooth spring (0.5 s, no bounce) settles in about 0.6 s. + expect(Number(follower?.options.duration)).toBeGreaterThan(400); + expect(Number(follower?.options.duration)).toBeLessThan(900); + }); + + it("ignores a scope's --motion-springs-settle: the role is internal", async () => { + await changeOnce(); + const shared = played.find(p => (p.element as HTMLElement).id === 'a'); + played = []; + document.body.innerHTML = ''; + await changeOnce('--motion-springs-settle: 0.2s 0'); + const scoped = played.find(p => (p.element as HTMLElement).id === 'a'); + expect(scoped?.options.duration).toBe(shared?.options.duration); + }); + + it('cross-fades with no travel under Reduce Motion on a coarse pointer', async () => { + media['(prefers-reduced-motion: reduce)'] = true; + media['(pointer: coarse)'] = true; + await changeOnce(); + expect(played).toHaveLength(2); + for (const entry of played) { + expect(entry.keyframes[0].translate).toBeUndefined(); + expect(entry.keyframes[0].opacity).toBe(0); + expect(Number(entry.options.duration)).toBeLessThanOrEqual(200); + } + }); + + it('changes at once under Reduce Motion on a fine pointer', async () => { + media['(prefers-reduced-motion: reduce)'] = true; + await changeOnce(); + expect(played).toEqual([]); + }); + + it('retargets an element already gliding: one glide, started from where it is on screen', async () => { + const {area, changed, phase} = setUp( + '
', + ); + const a = document.getElementById('a') as HTMLElement; + let top = 0; + vi.spyOn(a, 'getBoundingClientRect').mockImplementation( + () => new DOMRect(0, top, 400, 40), + ); + const layout = createAnimatedLayout(() => area); + // First change: the row's layout moves 0 -> 80. + let report = reportLayoutChange(layout, () => changed); + report.measureBefore(); + top = 80; + report.animate(); + await flushMicrotasks(); + const first = played.find(p => p.element === a); + expect(translateOf(first as Played).y).toBeCloseTo(-80); + // Mid-glide it shows at 50 on screen; the second change moves its layout + // 80 -> 120. + played = []; + phase.current = 'before'; + top = 50; + report = reportLayoutChange(layout, () => changed); + report.measureBefore(); + top = 120; + report.animate(); + await flushMicrotasks(); + expect(first?.cancel).toHaveBeenCalledTimes(1); + const second = played.filter(p => p.element === a); + expect(second).toHaveLength(1); + // From 50 on screen to 120: it starts 70 px above its new place. + expect(translateOf(second[0]).y).toBeCloseTo(-70); + // And it keeps the first glide's speed 100 ms in (about 6 px a frame); + // a new spring from rest moves about 1.4 px in its first frame. + const next = translateOf({ + ...second[0], + keyframes: second[0].keyframes.slice(1), + }); + expect(next.y - -70).toBeGreaterThan(3); + }); + + it('drops a measurement no commit claims by the next frame', async () => { + const {area, changed, phase} = setUp( + '
', + ); + const a = document.getElementById('a') as HTMLElement; + let top = 200; + vi.spyOn(a, 'getBoundingClientRect').mockImplementation( + () => new DOMRect(0, top, 400, 40), + ); + const layout = createAnimatedLayout(() => area); + // A refused toggle: measured, never committed. + reportLayoutChange(layout, () => changed).measureBefore(); + await new Promise(resolve => { + requestAnimationFrame(resolve); + }); + await new Promise(resolve => { + requestAnimationFrame(resolve); + }); + // The area scrolls 150 px; then a real change moves the row 80 px. + top = 50; + phase.current = 'before'; + const report = reportLayoutChange(layout, () => changed); + report.measureBefore(); + top = 130; + expect(report.animate()).toBe(true); + await flushMicrotasks(); + const moves = played.filter(p => p.element === a); + expect(moves).toHaveLength(1); + expect(translateOf(moves[0]).y).toBeCloseTo(-80); + }); + + it('reports an unmeasured change, which moves nothing', async () => { + const {area, changed} = setUp( + '
', + ); + const report = reportLayoutChange( + createAnimatedLayout(() => area), + () => changed, + ); + expect(report.animate()).toBe(false); + await flushMicrotasks(); + expect(played).toEqual([]); + }); +}); + +describe('the changed element exits (spec:AST-105 FR4)', () => { + function exitSetUp() { + document.body.innerHTML = + '
Details

Next

'; + const area = document.getElementById('area') as HTMLElement; + const content = document.getElementById('content') as HTMLElement; + vi.spyOn(area, 'getBoundingClientRect').mockReturnValue( + new DOMRect(0, 0, 400, 600), + ); + vi.spyOn(content, 'getBoundingClientRect').mockReturnValue( + new DOMRect(0, 40, 400, 80), + ); + Object.defineProperty(content, 'offsetHeight', {value: 80}); + // The row after it: 8 px of gap below the 80 px content, which a hidden + // content gives back too, and its end margin pulls the row up. + const after = document.getElementById('after') as HTMLElement; + vi.spyOn(after, 'getBoundingClientRect').mockImplementation(() => { + if ( + content.style.position === 'absolute' || + content.style.display === 'none' + ) { + return new DOMRect(0, 40, 400, 20); + } + const margin = + Number.parseFloat(content.style.getPropertyValue('margin-block-end')) || + 0; + return new DOMRect(0, 40 + 80 + 8 + margin, 400, 20); + }); + const layout = createAnimatedLayout(() => area); + // The close is measured: only a measured close exits. + const change = reportLayoutChange(layout, () => content); + change.measureBefore(); + change.animate(); + return {area, content, after, layout}; + } + + it('holds nothing for a close no change measured', () => { + const {area, content} = exitSetUp(); + const unmeasured = createAnimatedLayout(() => area); + const other = document.createElement('div'); + area.append(other); + expect(holdLayoutExit(unmeasured, other, vi.fn())).toBe(false); + expect(other).not.toHaveAttribute('inert'); + expect(content).not.toHaveAttribute('inert'); + }); + + it('puts back an inert and inline values the element had before its exit', () => { + const {content, layout} = exitSetUp(); + content.setAttribute('inert', ''); + content.style.setProperty('pointer-events', 'auto'); + expect(holdLayoutExit(layout, content, vi.fn())).toBe(true); + expect(content.style.pointerEvents).toBe('none'); + releaseLayoutExit(content); + expect(content).toHaveAttribute('inert'); + expect(content.style.pointerEvents).toBe('auto'); + expect(content.style.getPropertyValue('margin-block-end')).toBe(''); + }); + + it('keeps the content where it is while it gives up its space, conceals it on the spring, then hands it back', () => { + const {content, layout} = exitSetUp(); + const done = vi.fn(); + const written = vi.spyOn(content.style, 'setProperty'); + expect(holdLayoutExit(layout, content, done)).toBe(true); + // Measured out of flow, never with `display: none` (which would restart + // the CSS animations inside it). + expect( + written.mock.calls.some(([property]) => property === 'display'), + ).toBe(false); + expect(content.style.position).toBe(''); + // In flow (it scrolls and clips with its container), taking exactly the + // space it frees when hidden, gap included; out of reach. + expect(content.style.position).toBe(''); + expect(content.style.getPropertyValue('margin-block-end')).toBe('-88px'); + expect(content.style.display).toBe(''); + expect(content).toHaveAttribute('inert'); + expect(content.style.pointerEvents).toBe('none'); + const exit = played.find(p => p.element === content); + expect(exit?.keyframes[0].opacity).toBe(1); + expect(String(exit?.keyframes.at(-1)?.clipPath)).toBe('inset(0 0 100% 0)'); + expect(exit?.options.fill).toBe('forwards'); + expect(done).not.toHaveBeenCalled(); + exit?.finish?.(); + expect(done).toHaveBeenCalledTimes(1); + releaseLayoutExit(content); + expect(content.style.getPropertyValue('margin-block-end')).toBe(''); + expect(content).not.toHaveAttribute('inert'); + expect(content.style.pointerEvents).toBe(''); + }); + + it('stops an exit when the content opens again', () => { + const {content, layout} = exitSetUp(); + const done = vi.fn(); + holdLayoutExit(layout, content, done); + const exit = played.find(p => p.element === content); + releaseLayoutExit(content); + expect(exit?.cancel).toHaveBeenCalledTimes(1); + expect(content.style.getPropertyValue('margin-block-end')).toBe(''); + exit?.finish?.(); + expect(done).not.toHaveBeenCalled(); + }); + + it('lands at once without a handle, and under Reduce Motion with a fine pointer', () => { + const {content, layout} = exitSetUp(); + expect(holdLayoutExit(null, content, vi.fn())).toBe(false); + media['(prefers-reduced-motion: reduce)'] = true; + expect(holdLayoutExit(layout, content, vi.fn())).toBe(false); + expect(content.style.getPropertyValue('margin-block-end')).toBe(''); + expect(played).toEqual([]); + }); + + it('lands at once where no end margin frees its space (a grid track)', () => { + const {content, after, layout} = exitSetUp(); + // The row after it ignores the margin: it moves only when the content + // is hidden. + vi.spyOn(after, 'getBoundingClientRect').mockImplementation( + () => + new DOMRect( + 0, + content.style.position === 'absolute' || + content.style.display === 'none' + ? 40 + : 128, + 400, + 20, + ), + ); + expect(holdLayoutExit(layout, content, vi.fn())).toBe(false); + expect(content.style.getPropertyValue('margin-block-end')).toBe(''); + expect(content).not.toHaveAttribute('inert'); + expect(played).toEqual([]); + }); + + it("a refused hold keeps the element's own end margin and lets the content after it take its place at once", async () => { + const {content, after, layout} = exitSetUp(); + content.style.setProperty('margin-block-end', '12px'); + vi.spyOn(after, 'getBoundingClientRect').mockImplementation( + () => + new DOMRect( + 0, + content.style.position === 'absolute' || + content.style.display === 'none' + ? 40 + : 128, + 400, + 20, + ), + ); + expect(holdLayoutExit(layout, content, vi.fn())).toBe(false); + expect(content.style.getPropertyValue('margin-block-end')).toBe('12px'); + // The component hides it; the flush glides nothing. + content.style.display = 'none'; + await flushMicrotasks(); + expect(played).toEqual([]); + }); + + it('fades under Reduce Motion with a coarse pointer', () => { + const {content, layout} = exitSetUp(); + media['(prefers-reduced-motion: reduce)'] = true; + media['(pointer: coarse)'] = true; + expect(holdLayoutExit(layout, content, vi.fn())).toBe(true); + const exit = played.find(p => p.element === content); + expect(exit?.keyframes.map(k => k.opacity)).toEqual([1, 0]); + expect(exit?.keyframes[0].clipPath).toBeUndefined(); + }); +}); + +describe('two changes in one frame (spec:AST-105 FR3)', () => { + it('moves a changed element an earlier change displaced with its row, by its total change', async () => { + document.body.innerHTML = + '
'; + const area = document.getElementById('area') as HTMLElement; + const phase: {current: 'before' | 'after'} = {current: 'before'}; + placeAt(area, {before: [0, 0, 400, 600]}, phase); + // The second group opens and is pushed down 80 px by the first. + placeAt( + document.getElementById('g2') as HTMLElement, + {before: [0, 0, 400, 40], after: [0, 80, 400, 120]}, + phase, + ); + const layout = createAnimatedLayout(() => area); + const one = reportLayoutChange(layout, () => document.getElementById('g1')); + const two = reportLayoutChange(layout, () => document.getElementById('g2')); + one.measureBefore(); + two.measureBefore(); + phase.current = 'after'; + one.animate(); + two.animate(); + await flushMicrotasks(); + const moves = played.filter(p => (p.element as HTMLElement).id === 'g2'); + expect(moves).toHaveLength(1); + expect(translateOf(moves[0]).y).toBeCloseTo(-80); + }); + + it('does not move an element that was not rendered before the change', async () => { + document.body.innerHTML = + '
'; + const area = document.getElementById('area') as HTMLElement; + const phase: {current: 'before' | 'after'} = {current: 'before'}; + placeAt(area, {before: [0, 0, 400, 600]}, phase); + placeAt( + document.getElementById('shown') as HTMLElement, + {before: [0, 0, 0, 0], after: [0, 80, 400, 40]}, + phase, + ); + const report = reportLayoutChange( + createAnimatedLayout(() => area), + () => document.getElementById('g1'), + ); + report.measureBefore(); + phase.current = 'after'; + report.animate(); + await flushMicrotasks(); + expect(played).toEqual([]); + }); +}); + +describe('elements the app moves itself (spec:AST-105 Coverage)', () => { + it('leaves a follower with its own transform or translate to take its place at once', async () => { + const {area, changed, phase} = setUp( + `
+
+
+
`, + ); + for (const id of ['transformed', 'translated', 'plain']) { + placeAt( + document.getElementById(id) as HTMLElement, + {before: [0, 10, 400, 40], after: [0, 90, 400, 40]}, + phase, + ); + } + await runChange( + createAnimatedLayout(() => area), + changed, + phase, + ); + const moved = played + .filter(p => p.keyframes[0].translate != null) + .map(p => (p.element as HTMLElement).id); + expect(moved).toEqual(['plain']); + }); +}); diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts new file mode 100644 index 0000000000000..ed60b5b766aaf --- /dev/null +++ b/packages/core/src/motion/animatedLayout.ts @@ -0,0 +1,965 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file animatedLayout.ts + * @input The DOM (layout rectangles, computed style), Web Animations, the + * internal `springs.settle` motion role (motion/roles.ts) and the Reduce Motion + * cross-fade clock (uikit/timing.ts) + * @output Exports `createAnimatedLayout` and the opaque `AnimatedLayout` + * handle, `reportLayoutChange`, `playLayoutChange` and `holdLayoutExit` / + * `releaseLayoutExit`: the one module that produces the + * size-change glide `spec:AST-105` defines + * (`architecture:react-component-runtime` INV12) + * @position Motion foundation, internal to core. A handle comes from the + * public `useAnimatedLayout` hook; a component that takes it reports its + * size changes through this module. + * + * How it works: + * - `measureBefore()` runs before the change commits: from the changed + * element it walks up to the handle's container and, at each level, + * records the rectangle of every following element sibling, through + * `parentElement` and `nextElementSibling`, descending only into an element + * with `display: contents` (it has no box). + * - `animate()` runs after the commit (a layout effect). Every change in the + * same area reported in one frame is measured together after the commit, + * once, in a microtask, so each element moves once, by its total change. + * - An element moves only when its rectangle changed and it is in the area's + * visible part, before or after. Absolute, fixed, top-layer, hidden and + * zero-size elements are skipped (an absolute child rides with its moving + * parent); a stuck sticky element does not change rectangle, so it stays. + * - The move is a compositor-only `translate` (replace composition) from the + * old place to the new one, sampled from `springs.settle` into linear + * keyframes and corrected for a scaled ancestor. An element already gliding + * keeps that motion and adds the new move: the rest of its curve and the + * new move are summed into one new glide, velocity included. Nothing + * animates height, and nothing scrolls the area. + * - A measurement no commit claims by the next frame is dropped. + * - The changed element plays its own entrance on the same spring, started in + * the same frame (`playLayoutChange`), or its exit: kept where it is while + * it gives up its space and conceals (`holdLayoutExit`). + * - Every pointer (`spec:AST-105` FR5). Under Reduce Motion nothing travels + * (`design:reduced-motion` DR1–DR3): a cross-fade under a coarse primary + * pointer, an immediate change otherwise. + * - Without a handle nothing happens: the component keeps its jump + * (`spec:AST-105` FR2). + */ + +import {motionRole} from './roles'; +import {isSpring} from './roleValue'; +import {solveSpring, toSpring, type SpringDescription} from './spring'; +import {registerSpringsTable, SETTLE_SPRING} from './uikit/springs'; +import {reducedMotionCrossFade} from './uikit/timing'; + +/** The shared motion role (`spec:AST-105` FR4). */ +const ROLE_ID = 'springs.settle'; +/** Below this many pixels a shift is not worth a move. */ +const MIN_SHIFT_PX = 0.5; +/** The touch cross-fade's cap (`design:reduced-motion` DR2). */ +const CROSS_FADE_CAP_MS = 200; +/** Keyframes per second the spring is sampled at. */ +const SAMPLES_PER_SECOND = 60; + +// --------------------------------------------------------------------------- +// Motion +// --------------------------------------------------------------------------- + +/** How a change plays under the reader's settings. */ +type LayoutMotion = + | {readonly kind: 'spring'; readonly progress: SpringProgress} + | { + readonly kind: 'fade'; + readonly durationMs: number; + readonly easing: string; + } + | {readonly kind: 'instant'}; + +/** The settle spring as remaining distance (1 → 0) over its duration. */ +interface SpringProgress { + readonly durationMs: number; + /** Remaining fraction of the move at `t` ms. */ + remaining(t: number): number; +} + +function matches(element: Element, query: string): boolean { + const view = element.ownerDocument.defaultView; + return ( + typeof view?.matchMedia === 'function' && view.matchMedia(query).matches + ); +} + +/** + * `springs.settle` from the internal role registry: one shared value for the + * glide, the changed element and the indicator. Motion roles are internal + * (`spec:AST-094` DEC-11): no theme, scope or custom property changes it. + */ +function settleSpring(): SpringDescription { + registerSpringsTable(); + const value = motionRole(ROLE_ID)?.value; + return isSpring(value) ? value : SETTLE_SPRING; +} + +function springProgress(description: SpringDescription): SpringProgress { + const curve = solveSpring(toSpring(description), {from: 1, to: 0}); + const durationMs = Math.max(1, curve.settleTime(0.001) * 1000); + return { + durationMs, + remaining: t => (t >= durationMs ? 0 : curve.value(t / 1000)), + }; +} + +/** The motion a change in `area` plays now. */ +function layoutMotion(area: Element): LayoutMotion { + if (matches(area, '(prefers-reduced-motion: reduce)')) { + if (!matches(area, '(pointer: coarse)')) { + return {kind: 'instant'}; + } + const [x1, y1, x2, y2] = reducedMotionCrossFade.curve; + return { + kind: 'fade', + durationMs: Math.min( + CROSS_FADE_CAP_MS, + reducedMotionCrossFade.durationMs, + ), + easing: `cubic-bezier(${x1}, ${y1}, ${x2}, ${y2})`, + }; + } + return {kind: 'spring', progress: springProgress(settleSpring())}; +} + +/** Linear keyframes over `durationMs`, one per frame, from `frame(t ms)`. */ +function sampleOver( + durationMs: number, + frame: (t: number) => Keyframe, +): Keyframe[] { + const count = Math.max( + 2, + Math.ceil((durationMs / 1000) * SAMPLES_PER_SECOND) + 1, + ); + const keyframes: Keyframe[] = []; + for (let i = 0; i < count; i++) { + const offset = i / (count - 1); + keyframes.push({...frame(offset * durationMs), offset}); + } + return keyframes; +} + +/** Linear keyframes sampled from the spring, one per frame. */ +function sampleKeyframes( + progress: SpringProgress, + frame: (remaining: number) => Keyframe, +): Keyframe[] { + return sampleOver(progress.durationMs, t => frame(progress.remaining(t))); +} + +function round(value: number): number { + return Math.round(value * 1000) / 1000; +} + +// --------------------------------------------------------------------------- +// Playing +// --------------------------------------------------------------------------- + +/** + * Play `keyframes` on `element`. Every option here (linear easing over sampled + * keyframes, replace composition) is one any Web Animations engine takes, so + * a failure means no Web Animations at all: the change then lands at once. + */ +function safeAnimate( + element: Element, + keyframes: Keyframe[], + options: KeyframeAnimationOptions, +): Animation | null { + if (typeof (element as HTMLElement).animate !== 'function') { + return null; + } + let animation: Animation; + try { + animation = (element as HTMLElement).animate(keyframes, options); + } catch { + return null; + } + ours.add(animation); + // Started on the document timeline's current time, not on the next frame: + // a retargeted glide keeps moving through the frame it is replaced in, and + // every animation of one change shares the same start. + const now = element.ownerDocument.timeline?.currentTime; + if (now != null) { + animation.startTime = now; + } + return animation; +} + +/** Every animation this module started, so it can tell the app's apart. */ +const ours = new WeakSet(); + +const TRANSFORM_PROPERTIES = ['transform', 'translate', 'rotate', 'scale']; + +/** + * Whether the app moves `element` itself: its own `transform`, `translate`, + * `rotate` or `scale`, or a running animation of one this module did not + * start. + */ +function movedByApp(element: Element): boolean { + const style = element.ownerDocument.defaultView?.getComputedStyle(element); + if ( + style != null && + TRANSFORM_PROPERTIES.some(property => { + const value = style.getPropertyValue(property); + return value !== '' && value !== 'none'; + }) + ) { + return true; + } + if (typeof element.getAnimations !== 'function') { + return false; + } + return element.getAnimations().some(animation => { + if (ours.has(animation)) { + return false; + } + const effect = animation.effect; + if (!(effect instanceof KeyframeEffect)) { + return false; + } + return effect + .getKeyframes() + .some(frame => TRANSFORM_PROPERTIES.some(property => property in frame)); + }); +} + +/** An offset in the element's own px, as a function of ms into a glide. */ +type OffsetCurve = (t: number) => {readonly x: number; readonly y: number}; + +const AT_REST: OffsetCurve = () => ({x: 0, y: 0}); + +/** A glide an element is playing, so a new change can retarget it. */ +interface Glide { + readonly animation: Animation; + readonly offsetAt: OffsetCurve; + readonly durationMs: number; +} + +const glides = new WeakMap(); + +// --------------------------------------------------------------------------- +// Handles +// --------------------------------------------------------------------------- + +const AREA = Symbol('astryx.animatedLayout.area'); + +/** + * The opaque handle `useAnimatedLayout` returns (`spec:AST-105` FR1): no + * public members. A component that receives it reports its size changes to + * the handle's container, the area the glide stays inside. + */ +export interface AnimatedLayout { + readonly [AREA]: () => Element | null; +} + +/** A handle over `area` (the container element, read when a change runs). */ +export function createAnimatedLayout( + area: () => Element | null, +): AnimatedLayout { + // Not enumerable and frozen: the handle shows no fields to a caller. + return Object.freeze( + Object.defineProperty({}, AREA, {value: area}), + ) as AnimatedLayout; +} + +function areaOf(handle: AnimatedLayout, element: Element): Element | null { + const area = handle[AREA](); + return area != null && area !== element && area.contains(element) + ? area + : null; +} + +// --------------------------------------------------------------------------- +// The walk +// --------------------------------------------------------------------------- + +/** + * Whether `element` takes part: in flow, rendered, not in the top layer. + * `display: contents` is handled by the caller. + */ +function takesPart(element: Element, style: CSSStyleDeclaration): boolean { + if ( + style.display === 'none' || + style.visibility === 'hidden' || + style.position === 'absolute' || + style.position === 'fixed' + ) { + return false; + } + try { + if (element.matches(':popover-open, :modal')) { + return false; + } + } catch { + // An engine without these pseudo-classes has no top layer to skip. + } + return true; +} + +/** + * The elements that can shift when `changed` changes size: at each ancestor + * level up to `area`, every following element sibling. `display: contents` is + * replaced by its children. + */ +function collectFollowers(changed: Element, area: Element): Element[] { + const view = changed.ownerDocument.defaultView; + const out: Element[] = []; + const take = (element: Element) => { + const style = view?.getComputedStyle(element); + if (style == null) { + return; + } + if (style.display === 'contents') { + for ( + let child = element.firstElementChild; + child != null; + child = child.nextElementSibling + ) { + take(child); + } + return; + } + if (takesPart(element, style)) { + out.push(element); + } + }; + for ( + let node: Element | null = changed; + node != null && node !== area; + node = node.parentElement + ) { + for ( + let sibling = node.nextElementSibling; + sibling != null; + sibling = sibling.nextElementSibling + ) { + take(sibling); + } + } + return out; +} + +// --------------------------------------------------------------------------- +// Batches: every change in one area in one frame moves together +// --------------------------------------------------------------------------- + +interface Batch { + readonly area: Element; + readonly changed: Set; + readonly before: Map; + scheduled: boolean; +} + +const batches = new Map(); + +function batchFor(area: Element): Batch { + let batch = batches.get(area); + if (batch == null) { + const created: Batch = { + area, + changed: new Set(), + before: new Map(), + scheduled: false, + }; + batches.set(area, created); + // A measurement no commit claims by the next frame (a controlled owner + // refused the change) is dropped, so it never moves rows later and holds + // no elements. + const view = area.ownerDocument.defaultView; + const drop = () => { + if (batches.get(area) === created && !created.scheduled) { + batches.delete(area); + } + }; + if (typeof view?.requestAnimationFrame === 'function') { + view.requestAnimationFrame(drop); + } else { + setTimeout(drop, 16); + } + batch = created; + } + return batch; +} + +function intersects(a: DOMRect, b: DOMRect): boolean { + return ( + a.width > 0 && + a.height > 0 && + a.right > b.left && + a.left < b.right && + a.bottom > b.top && + a.top < b.bottom + ); +} + +/** The part of the area the person can see: its box within the viewport. */ +function visibleRect(area: Element): DOMRect { + const box = area.getBoundingClientRect(); + const view = area.ownerDocument.defaultView; + const width = view?.innerWidth ?? box.right; + const height = view?.innerHeight ?? box.bottom; + const left = Math.max(box.left, 0); + const top = Math.max(box.top, 0); + return new DOMRect( + left, + top, + Math.max(0, Math.min(box.right, width) - left), + Math.max(0, Math.min(box.bottom, height) - top), + ); +} + +/** The element's rendered scale against its layout size (a scaled ancestor). */ +function scaleOf(element: Element, rect: DOMRect): {x: number; y: number} { + const html = element as HTMLElement; + const x = + html.offsetWidth > 0 && rect.width > 0 ? rect.width / html.offsetWidth : 1; + const y = + html.offsetHeight > 0 && rect.height > 0 + ? rect.height / html.offsetHeight + : 1; + // Subpixel rounding makes an unscaled element read a hair off 1; only a + // real scale is corrected. + return { + x: Math.abs(x - 1) < 0.01 ? 1 : x || 1, + y: Math.abs(y - 1) < 0.01 ? 1 : y || 1, + }; +} + +function flush(batch: Batch): void { + batches.delete(batch.area); + const followers = new Set(); + for (const changed of batch.changed) { + if (!changed.isConnected) { + continue; + } + for (const element of collectFollowers(changed, batch.area)) { + followers.add(element); + } + } + if (followers.size === 0) { + return; + } + const motion = layoutMotion(batch.area); + if (motion.kind === 'instant') { + return; + } + const visible = visibleRect(batch.area); + // Every running glide stops first and every new place is read before any + // glide starts, so no reading includes another's new motion. + const entries: { + readonly element: Element; + readonly before: DOMRect; + readonly carried: OffsetCurve; + readonly carriedMs: number; + after?: DOMRect; + rawX?: number; + rawY?: number; + }[] = []; + for (const element of followers) { + const before = batch.before.get(element); + if (before == null || (before.width === 0 && before.height === 0)) { + // Added by the change, or not rendered before it: it has no old place. + // A changed element an earlier change displaced does have one, and + // moves with its row while it plays its own entrance or exit. + continue; + } + // An element already gliding keeps that motion and adds the new move: the + // running glide stops (so its rectangle is its new layout place) and the + // rest of its curve carries into the one new glide, velocity included, + // summed with the new move. + const running = glides.get(element); + let carried = AT_REST; + let carriedMs = 0; + if (running != null) { + const time = Number(running.animation.currentTime); + const elapsed = Number.isFinite(time) ? Math.max(0, time) : 0; + if (elapsed < running.durationMs) { + carried = t => running.offsetAt(elapsed + t); + carriedMs = running.durationMs - elapsed; + } + running.animation.cancel(); + glides.delete(element); + } + entries.push({element, before, carried, carriedMs}); + } + const byElement = new Map(); + for (const entry of entries) { + entry.after = entry.element.getBoundingClientRect(); + entry.rawX = entry.before.left - entry.after.left; + entry.rawY = entry.before.top - entry.after.top; + byElement.set(entry.element, entry); + } + for (const entry of entries) { + const {element, before, carried, carriedMs} = entry; + const after = entry.after as DOMRect; + // Nested changes compose: an element inside another moving element moves + // only by its own change, whatever order the changes were reported in. + let dx = entry.rawX ?? 0; + let dy = entry.rawY ?? 0; + for ( + let ancestor = element.parentElement; + ancestor != null && ancestor !== batch.area; + ancestor = ancestor.parentElement + ) { + const moving = byElement.get(ancestor); + if (moving != null) { + dx -= moving.rawX ?? 0; + dy -= moving.rawY ?? 0; + break; + } + } + if (Math.abs(dx) < MIN_SHIFT_PX && Math.abs(dy) < MIN_SHIFT_PX) { + continue; + } + if (!intersects(before, visible) && !intersects(after, visible)) { + // Outside the visible part before and after: it lands silently. + continue; + } + if (motion.kind === 'fade') { + safeAnimate(element, [{opacity: 0}, {opacity: 1}], { + duration: motion.durationMs, + easing: motion.easing, + }); + continue; + } + if (movedByApp(element)) { + // The app moves it itself: it takes its final place at once + // (`spec:AST-105` Coverage). + continue; + } + // Where it shows now, against its new place, in its own px; the part the + // carried glide does not cover is the new move. + const scale = scaleOf(element, after); + const now = carried(0); + const moveX = dx / scale.x - now.x; + const moveY = dy / scale.y - now.y; + const {progress} = motion; + const offsetAt: OffsetCurve = t => { + const remaining = progress.remaining(t); + const carry = carried(t); + return {x: carry.x + moveX * remaining, y: carry.y + moveY * remaining}; + }; + const durationMs = Math.max(progress.durationMs, carriedMs); + // One replace-composited `translate`: the compositor runs it (INV12). + const animation = safeAnimate( + element, + sampleOver(durationMs, t => { + const offset = offsetAt(t); + return {translate: `${offset.x}px ${offset.y}px`}; + }), + {duration: durationMs, easing: 'linear'}, + ); + if (animation != null) { + const glide: Glide = {animation, offsetAt, durationMs}; + glides.set(element, glide); + animation.addEventListener?.('finish', () => { + if (glides.get(element) === glide) { + glides.delete(element); + } + }); + } + } +} + +type Target = {readonly current: Element | null} | (() => Element | null); + +function resolve(target: Target): Element | null { + return typeof target === 'function' ? target() : target.current; +} + +export interface LayoutChange { + /** Before the change commits (in the event handler that causes it). */ + measureBefore(): void; + /** + * After it commits (a layout effect). Returns whether the change was + * measured: an unmeasured change (an owner set the state directly) moves + * nothing, and the component plays no entrance for it either. + */ + animate(): boolean; +} + +/** + * Report a size change of `target` to the handle's area. Call + * `measureBefore()` before the change commits and `animate()` after it; the + * content after `target`, up to the area, glides from its old place. + * Changes in one area in one frame move together. Without a handle, or + * outside its container, nothing moves. + */ +export function reportLayoutChange( + handle: AnimatedLayout | null | undefined, + target: Target, +): LayoutChange { + return { + measureBefore() { + const element = resolve(target); + if (element == null || handle == null) { + return; + } + const area = areaOf(handle, element); + if (area == null) { + return; + } + const batch = batchFor(area); + batch.changed.add(element); + for (const follower of collectFollowers(element, area)) { + if (!batch.before.has(follower)) { + batch.before.set(follower, follower.getBoundingClientRect()); + } + } + }, + animate() { + const element = resolve(target); + if (element == null || handle == null) { + return false; + } + const area = areaOf(handle, element); + const batch = area == null ? undefined : batches.get(area); + if (batch == null || !batch.changed.has(element)) { + return false; + } + if (!batch.scheduled) { + batch.scheduled = true; + // After every layout effect of this commit, before paint. + queueMicrotask(() => flush(batch)); + } + return true; + }, + }; +} + +/** + * The changed element's own animation, on the same motion as the content + * after it: content that appears reveals downward from its top edge. Plays + * only with a handle, inside its area and in the area's visible part. + */ +export function playLayoutChange( + handle: AnimatedLayout | null | undefined, + element: Element, +): void { + if (handle == null) { + return; + } + const area = areaOf(handle, element); + if (area == null) { + return; + } + if (!intersects(element.getBoundingClientRect(), visibleRect(area))) { + return; + } + const motion = layoutMotion(area); + if (motion.kind === 'instant') { + return; + } + if (motion.kind === 'fade') { + safeAnimate(element, [{opacity: 0}, {opacity: 1}], { + duration: motion.durationMs, + easing: motion.easing, + }); + return; + } + safeAnimate( + element, + sampleKeyframes(motion.progress, remaining => ({ + opacity: 1 - remaining, + clipPath: `inset(0 0 ${round(remaining * 100)}% 0)`, + })), + {duration: motion.progress.durationMs, easing: 'linear'}, + ); +} + +/** The inline properties an exit sets, so a release removes exactly them. */ +const EXIT_PROPERTIES = [ + 'margin-block-end', + 'margin-inline-end', + 'pointer-events', +] as const; + +/** + * The first element after `element` in the area that takes part in layout: + * its next sibling, or its nearest ancestor's below the area. A wrapper's + * edge is not used: a negative end margin can collapse through it. + */ +function nextInFlow(element: Element, area: Element): Element | null { + const view = element.ownerDocument.defaultView; + // The first box at or inside `from` (a `display: contents` element has + // none of its own: its children stand in its place). + const firstBox = (from: Element): Element | null => { + const style = view?.getComputedStyle(from); + if (style == null) { + return from; + } + if (style.display === 'contents') { + for ( + let child = from.firstElementChild; + child != null; + child = child.nextElementSibling + ) { + const box = firstBox(child); + if (box != null) { + return box; + } + } + return null; + } + return takesPart(from, style) ? from : null; + }; + let node: Element | null = element; + while (node != null && node !== area) { + for ( + let sibling = node.nextElementSibling; + sibling != null; + sibling = sibling.nextElementSibling + ) { + const box = firstBox(sibling); + if (box != null) { + return box; + } + } + node = node.parentElement; + } + return null; +} + +/** + * An element's place: its block start, and its inline start (RTL read right + * to left). + */ +function placeOf( + element: Element, + rtl: boolean, +): {block: number; inline: number} { + const rect = element.getBoundingClientRect(); + return {block: rect.top, inline: rtl ? -rect.right : rect.left}; +} + +/** + * Keeps `element` where it is, in flow (so it scrolls and clips with its + * container), while the content after it takes the place it takes once the + * element is hidden: a negative end margin measured against that content, + * so a gap or collapsing margins come out exact. Returns false, setting + * nothing, when no margin reaches that place. + */ +function giveUpSpace(element: HTMLElement, area: Element): boolean { + const view = element.ownerDocument.defaultView; + const parent = + element.parentElement == null + ? undefined + : view?.getComputedStyle(element.parentElement); + const isRow = + parent != null && + parent.display.includes('flex') && + parent.flexDirection.startsWith('row'); + const rtl = view?.getComputedStyle(element).direction === 'rtl'; + const property = isRow ? 'margin-inline-end' : 'margin-block-end'; + const size = isRow ? element.offsetWidth : element.offsetHeight; + const next = nextInFlow(element, area); + if (next == null) { + // Nothing after it in the area to land anywhere. + element.style.setProperty(property, `${-size}px`); + return true; + } + const own = element.style.getPropertyValue(property); + const ownPriority = element.style.getPropertyPriority(property); + const axis = isRow ? 'inline' : 'block'; + // The content after its container must land too: a flex-row item that + // sets its row's height moves what follows the row. + const afterParent = + element.parentElement == null || element.parentElement === area + ? null + : nextInFlow(element.parentElement, area); + const checked = afterParent == null ? [next] : [next, afterParent]; + // Where the content after it goes once it is hidden, read with it taken + // out of flow (not `display: none`, which would restart the CSS animations + // inside it). + const position = element.style.getPropertyValue('position'); + const positionPriority = element.style.getPropertyPriority('position'); + element.style.setProperty('position', 'absolute', 'important'); + const targets = checked.map(box => placeOf(box, rtl)); + const target = targets[0]; + if (position === '') { + element.style.removeProperty('position'); + } else { + element.style.setProperty('position', position, positionPriority); + } + // A first guess (its box), then one correction from the measured result: + // a gap, a collapsed margin or the element's own margins. + element.style.setProperty(property, `${-size}px`); + const guess = placeOf(next, rtl)[axis]; + // The reading is in screen px, the margin in the element's own: under a + // scaled ancestor the correction is divided by the scale on this axis. + const scale = scaleOf(element, element.getBoundingClientRect()); + const correction = (target[axis] - guess) / (isRow ? scale.x : scale.y); + element.style.setProperty(property, `${-size + correction}px`); + // Everything checked must land on both axes: a flex-row item that sets + // its row's height frees no block space with an inline margin. + const missed = checked.some((box, index) => { + const landed = placeOf(box, rtl); + return ( + Math.abs(landed.block - targets[index].block) > 1 || + Math.abs(landed.inline - targets[index].inline) > 1 + ); + }); + if (missed) { + if (own === '') { + element.style.removeProperty(property); + } else { + element.style.setProperty(property, own, ownPriority); + } + return false; + } + return true; +} + +/** Exits holding an element back, so a release can stop one. */ +const exits = new WeakMap(); + +/** What an exit replaced on its element, so a release puts back exactly it. */ +interface Replaced { + readonly inert: boolean; + readonly styles: ReadonlyArray; +} +const replaced = new WeakMap(); + +/** + * The changed element's exit (`spec:AST-105` FR4): content that disappears + * keeps its place on screen but gives up its space (a negative end margin + * measured against the layout without it, so the content after it glides up + * at once to exactly where it ends, and it still scrolls and clips with its + * container), is inert (no focus, no pointer, nothing read), conceals upward + * from its bottom edge on the same motion, and then `done` runs, when the + * component hides or unmounts it. Under Reduce Motion with a coarse primary pointer it + * fades instead. Call it in the layout effect of the commit that closes the + * element, after the change's `animate()` reported it measured, in the same + * task: the glide reads the layout once the element has given up its space. + * + * Returns false, holding nothing, when the exit lands at once: no handle, a + * close no change in the area measured, outside the area or its visible part, + * Reduce Motion with a fine pointer, or a layout no end margin can free (a + * grid track and its gap, a flex-row item that sets its row's height); in + * that last case the content after it takes its final place at once too. + * The component then hides the element itself. + */ +export function holdLayoutExit( + handle: AnimatedLayout | null | undefined, + element: HTMLElement, + done: () => void, +): boolean { + if (handle == null) { + return false; + } + const area = areaOf(handle, element); + if ( + area == null || + !intersects(element.getBoundingClientRect(), visibleRect(area)) + ) { + return false; + } + // Only a measured close exits: the content after it glides into its place. + const batch = batches.get(area); + if ( + batch == null || + ![...batch.changed].some( + changed => changed === element || changed.contains(element), + ) + ) { + return false; + } + const motion = layoutMotion(area); + if (motion.kind === 'instant') { + return false; + } + releaseLayoutExit(element); + const prior: Replaced = { + inert: element.hasAttribute('inert'), + styles: EXIT_PROPERTIES.map( + property => + [ + property, + element.style.getPropertyValue(property), + element.style.getPropertyPriority(property), + ] as const, + ), + }; + if (!giveUpSpace(element, area)) { + // A layout no end margin can free (a grid track and its gap, a flex-row + // item that sets its row's height): it hides at once, and the content + // after it takes its final place at once (`spec:AST-105` out of + // coverage). + for (const changed of [...batch.changed]) { + if (changed === element || changed.contains(element)) { + batch.changed.delete(changed); + } + } + return false; + } + replaced.set(element, prior); + // Out of reach while it conceals: no focus, no pointer, nothing read. + element.setAttribute('inert', ''); + element.style.setProperty('pointer-events', 'none'); + const animation = + motion.kind === 'fade' + ? safeAnimate(element, [{opacity: 1}, {opacity: 0}], { + duration: motion.durationMs, + easing: motion.easing, + fill: 'forwards', + }) + : safeAnimate( + element, + sampleKeyframes(motion.progress, remaining => ({ + opacity: remaining, + clipPath: `inset(0 0 ${round((1 - remaining) * 100)}% 0)`, + })), + { + duration: motion.progress.durationMs, + easing: 'linear', + fill: 'forwards', + }, + ); + exits.set(element, animation); + if (animation == null) { + // No Web Animations: nothing to wait for. + done(); + return true; + } + animation.addEventListener?.('finish', () => { + if (exits.get(element) === animation) { + done(); + } + }); + return true; +} + +/** + * Puts an element `holdLayoutExit` held back: stops its exit and puts back + * what the exit replaced (its `inert` and inline values as they were). Called + * once the component has hidden the element, or when the element opens again + * mid-exit. + */ +export function releaseLayoutExit(element: HTMLElement): void { + if (!exits.has(element)) { + return; + } + exits.get(element)?.cancel(); + exits.delete(element); + const prior = replaced.get(element); + replaced.delete(element); + if (prior?.inert !== true) { + element.removeAttribute('inert'); + } + for (const [property, value, priority] of prior?.styles ?? []) { + if (value === '') { + element.style.removeProperty(property); + } else { + element.style.setProperty(property, value, priority); + } + } +} + +/** @internal For tests: the motion a change in `area` plays. */ +export function layoutMotionFor(area: Element): LayoutMotion { + return layoutMotion(area); +}