diff --git a/.changeset/edge-reveal-reversal.md b/.changeset/edge-reveal-reversal.md new file mode 100644 index 0000000000000..0c9e5fe2561f8 --- /dev/null +++ b/.changeset/edge-reveal-reversal.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] With an `animatedLayout` handle, a Collapsible or a TreeList row closed while it opens, or opened again while it closes, continues from what it shows: the content's edge stays on the content after it on every frame, instead of jumping back to fully shown or hidden. Under Reduce Motion with a touch pointer, where the content cross-fades instead, a reversal still starts its fade over. + +@vjeux diff --git a/apps/storybook/stories/AnimatedLayout.stories.tsx b/apps/storybook/stories/AnimatedLayout.stories.tsx index 994f1cbe07165..0ef0e3eb6b300 100644 --- a/apps/storybook/stories/AnimatedLayout.stories.tsx +++ b/apps/storybook/stories/AnimatedLayout.stories.tsx @@ -5,7 +5,7 @@ import type {Meta, StoryObj} from '@storybook/react'; import {ScrollableArea} from '@astryxdesign/core/ScrollableArea'; import {TreeList} from '@astryxdesign/core/TreeList'; import type {TreeListItemData} from '@astryxdesign/core/TreeList'; -import {Collapsible} from '@astryxdesign/core/Collapsible'; +import {Collapsible, CollapsibleGroup} from '@astryxdesign/core/Collapsible'; import {useAnimatedLayout} from '@astryxdesign/core/hooks'; const meta: Meta = { @@ -143,6 +143,38 @@ function Demo() { New +
+
+ + +

+ Return any item within 30 days. +

+

Items must be unused.

+

Refunds take 5 business days.

+

Exchanges ship free.

+

+ Gift returns get store credit. +

+
+ +

Add a note at checkout.

+
+ +

Choose a two-hour window.

+
+
+
+
Inside a display: contents wrapper (1)
Inside a display: contents wrapper (2)
diff --git a/packages/core/src/SideNav/SideNavRailMotion.test.tsx b/packages/core/src/SideNav/SideNavRailMotion.test.tsx index 1d3d868079e3c..4be402ba5637f 100644 --- a/packages/core/src/SideNav/SideNavRailMotion.test.tsx +++ b/packages/core/src/SideNav/SideNavRailMotion.test.tsx @@ -1,5 +1,7 @@ // Copyright (c) Meta Platforms, Inc. and affiliates. +import {readFileSync} from 'node:fs'; +import {join} from 'node:path'; import {act, fireEvent, render, screen} from '@testing-library/react'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; @@ -610,6 +612,29 @@ describe('SideNav rail motion (component:SideNav FR6, component:AppShell FR17)', expect((copy as HTMLElement).style.width).toBe(`${EXPANDED}px`); }); + it("plays the animated-layout module's two-state edge reveal on its inline edge, keeping only its labels' and icons' fades (spec:AST-105 FR9)", () => { + const source = readFileSync(join(__dirname, 'railMotion.ts'), 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/.*$/gm, ''); + // The module holds the old rail, clips its inline-end edge and rides the + // content's glide (architecture:react-component-runtime INV12). + expect(source).toMatch(/\bholdLayoutEdge\(/); + expect(source).toMatch(/\bplayLayoutEdge\(/); + expect(source).toMatch(/edge: 'inline-end'/); + // No clip, copy or ride of the rail's own. + for (const own of [ + 'clipPath', + 'inset(', + 'holdLayoutSwap', + 'playLayoutSwap', + 'playLayoutFrames', + 'zIndex', + 'getBoundingClientRect', + ]) { + expect(source, own).not.toContain(own); + } + }); + it('changes at once without Web Animations', async () => { delete (HTMLElement.prototype as unknown as {animate?: unknown}).animate; render(); diff --git a/packages/core/src/SideNav/railMotion.ts b/packages/core/src/SideNav/railMotion.ts index 564916ab7f560..be7f8d71c442c 100644 --- a/packages/core/src/SideNav/railMotion.ts +++ b/packages/core/src/SideNav/railMotion.ts @@ -5,7 +5,7 @@ /** * @file railMotion.ts * @input The rail's outer element, the animated-layout module's handle, - * motion and in-place swap (motion/animatedLayout.ts) + * motion and two-state edge reveal (motion/animatedLayout.ts) * @output Exports `SideNavShellContext` (what AppShell lends its SideNav), * `createRailLayout`, `snapshotRail` and `playRail`: the rail's collapse * motion @@ -14,28 +14,30 @@ * * Collapsing or expanding the rail plays one motion on one clock, the * `springs.settle` role the animated-layout module reads (`spec:AST-105` - * FR4): - * - Layout snaps to the final rail width at once; nothing animates a width. - * - A collapse covers the old rail by a clip: the module's in-place swap - * (`spec:AST-105` FR8) holds the old, expanded rail as one transient copy - * and the clip closes it to the new width. The copy's text fades over the - * first 40%; the real collapsed rail, raised above the copy, fades in over - * the last 30%, so the real rail, icons only, is all that remains. - * - An expand reveals the real rail by a clip from the old width, its text - * fading in over the last 60% so no label shows at the collapsed width, - * under the old, collapsed rail held by the same swap, which fades over the - * first 60%: the first frame is the frame before the click. - * - In AppShell the rail's edge rides the content column's glide, frame by - * frame, so the two never part, also when a change interrupts a running - * one; the copy is laid in the row that holds both. + * FR4). The rail is FR9's edge reveal in its inline two-state form, which + * the module produces (`architecture:react-component-runtime` INV12): layout + * snaps to the final rail width at once, nothing animates a width, and the + * module clips the rail's inline-end edge from the width it shows to the new + * one. A collapse covers the old, expanded rail, held as one transient copy, + * with the real collapsed rail raised above it; an expand reveals the real + * rail under the old, collapsed one. In AppShell that edge rides the content + * column's glide, frame by frame, so the two never part, also when a change + * interrupts a running one. Every starting clip applies in the commit's + * layout effect, before the first frame paints. Under Reduce Motion it + * follows `design:reduced-motion`: opacity only under a coarse primary + * pointer (DR2), an immediate change otherwise (DR3). Without Web + * Animations, the change lands at once. + * + * What this file adds is the rail's own fades, on the same progress: + * - A collapse fades the copy's text over the first 40% and the copy over + * the last 30%, while the real collapsed rail, its icons, fades in over the + * last 30%, so the real rail, icons only, is all that remains. + * - An expand fades the real rail's text in over the last 60%, so no label + * shows at the collapsed width, while the old, collapsed rail fades over + * the first 60%: the first frame is the frame before the click. * - A change made during a running motion starts from what the rail shows: * its width, its labels' opacity and its collapsed icons' opacity, so the * rail stays continuous. - * - Every starting clip applies in the commit's layout effect, before the - * first frame paints. - * - Under Reduce Motion it follows `design:reduced-motion`: opacity only - * under a coarse primary pointer (DR2), an immediate change otherwise - * (DR3). Without Web Animations, the change lands at once. * * SYNC: When modified, update SideNav.tsx, AppShell.tsx and * SideNavRailMotion.test.tsx. @@ -44,11 +46,10 @@ import {createContext} from 'react'; import { createAnimatedLayout, - holdLayoutSwap, - layoutMotionFor, - playLayoutFrames, - playLayoutSwap, + holdLayoutEdge, + playLayoutEdge, type AnimatedLayout, + type LayoutEdgeHold, type LayoutSwap, } from '../motion/animatedLayout'; @@ -88,12 +89,12 @@ export interface RailShown { } export interface RailSnapshot { - readonly rail: HTMLElement; - readonly railRect: DOMRect; + /** The old rail, held for the module's edge reveal. */ + readonly hold: LayoutEdgeHold; + /** The old rail as one transient copy, or null when it cannot be held. */ + readonly swap: LayoutSwap | null; /** What the rail shows when the change lands. */ readonly shown: RailShown; - /** The old rail, held by the swap. */ - readonly swap: LayoutSwap | null; } /** A playing rail motion. */ @@ -114,19 +115,22 @@ export function snapshotRail( running: RailShown | null, isCollapsed: boolean, ): RailSnapshot | null { - if (rail == null || typeof rail.animate !== 'function') { + // The rail's edge is the one beside the content, its inline end. + const hold = holdLayoutEdge(handle, rail, { + edge: 'inline-end', + shown: running?.width ?? null, + }); + if (hold == null) { return null; } - const railRect = rail.getBoundingClientRect(); return { - rail, - railRect, + hold, + swap: hold.swap, shown: running ?? { - width: railRect.width, + width: hold.extent, labels: isCollapsed ? 0 : 1, collapsed: isCollapsed ? 1 : 0, }, - swap: holdLayoutSwap(handle, rail), }; } @@ -141,17 +145,6 @@ function textElements(rail: HTMLElement): Element[] { ); } -/** `element`'s ancestor (or itself) that sits directly in `host`. */ -function childOf(host: Element, element: HTMLElement): HTMLElement | null { - let node: HTMLElement | null = element; - while (node != null && node.parentElement !== host) { - node = node.parentElement; - } - return node; -} - -const AT_REST: RailMotion = {end: () => {}, shown: () => null}; - /** * After the change commits, before paint: play the rail's motion from the * snapshot, on the module's motion. With a `column` (in AppShell), the rail's @@ -163,19 +156,8 @@ export function playRail( handle: AnimatedLayout, column: HTMLElement | null = null, ): RailMotion { - const {railRect, swap, shown: from} = snapshot; - // The rail as it is now: a resizable rail's nav remounts when its resize - // wrapper comes and goes with the collapse. - const rail = current?.isConnected === true ? current : snapshot.rail; - if (!rail.isConnected) { - return AT_REST; - } - const motion = layoutMotionFor(rail); - const next = rail.getBoundingClientRect(); - if (motion.kind === 'instant' || Math.abs(next.width - from.width) < 0.5) { - return AT_REST; - } - const isCollapse = next.width < from.width; + const {shown: from} = snapshot; + let isCollapse = false; // The labels and the collapsed icons over the motion's progress, from what // the rail showed. const labelsAt = (p: number) => @@ -186,130 +168,42 @@ export function playRail( isCollapse ? from.collapsed + (1 - from.collapsed) * clamp((p - 0.7) / 0.3) : from.collapsed * (1 - clamp(p / 0.6)); - const rtl = getComputedStyle(rail).direction === 'rtl'; - const sign = rtl ? -1 : 1; - const inset = (px: number) => - rtl ? `inset(0px 0px 0px ${px}px)` : `inset(0px ${px}px 0px 0px)`; - // The row that holds the rail and the column, or the rail's parent. - const host = - column?.parentElement != null && column.parentElement.contains(rail) - ? column.parentElement - : rail.parentElement; - // Riding needs the swap: its frames carry the column's travel. Without one - // (old content that cannot be copied) the clip follows the motion's curve. - const rides = host != null && column?.parentElement === host && swap != null; - // The width the rail shows over the motion: on the column's glide (its - // travel so far added to where it started), or on the motion's curve. - const widthAt = (p: number, ride: {x: number}) => - rides - ? from.width + sign * ride.x - : from.width + (next.width - from.width) * p; - const now = () => Number(rail.ownerDocument.timeline?.currentTime ?? 0); - const startedAt = now(); - const durationMs = - motion.kind === 'spring' ? motion.progress.durationMs : motion.durationMs; - const shown = (): RailShown | null => { - const elapsed = now() - startedAt; - if (elapsed >= durationMs) { - return null; - } - const p = - motion.kind === 'spring' ? 1 - motion.progress.remaining(elapsed) : 0; - let width = from.width + (next.width - from.width) * p; - if (rides && column != null) { - // The rail's edge is the column's: read it where it shows. - const edge = column.getBoundingClientRect(); - const own = rail.getBoundingClientRect(); - width = rtl ? own.right - edge.right : edge.left - own.left; - } - return {width, labels: labelsAt(p), collapsed: collapsedAt(p)}; - }; - const rideWith = - rides && column != null - ? { - element: column, - travel: {x: sign * (from.width - next.width), y: 0}, - isCopyMoved: false, - } - : undefined; - // The copy keeps the rail's inline-start edge where the rail is now, so it - // meets the real rail wherever SideNav sits (a centered rail moves). - const at = DOMRect.fromRect({ - x: rtl ? next.right - railRect.width : next.left, - y: next.top, - width: railRect.width, - height: railRect.height, + const motion = playLayoutEdge(snapshot.hold, current, handle, { + edge: 'inline-end', + rideWith: column, + parts: (isConceal, rail) => { + isCollapse = isConceal; + return isConceal + ? { + // The old rail's text fades early, the copy late, as the real + // collapsed rail, raised above it, fades in. + copyOpacity: p => 1 - clamp((p - 0.7) / 0.3), + copyText: labelsAt, + incoming: [ + {element: rail, frames: p => ({opacity: collapsedAt(p)})}, + ], + } + : { + // The old, collapsed rail fades early; the real rail's text, late. + copyOpacity: collapsedAt, + incoming: textElements(rail).map(element => ({ + element, + frames: (p: number) => ({opacity: labelsAt(p)}), + })), + }; + }, }); - - if (isCollapse) { - // A collapse covers the old rail, held by the swap, from the width it - // shows down to the new one; the real, collapsed rail fades in above it. - if (swap == null || host == null) { - return AT_REST; - } - const raised = childOf(host, rail); - const restore = - raised == null + return { + end: motion.end, + shown: () => { + const now = motion.shown(); + return now == null ? null : { - position: raised.style.position, - zIndex: raised.style.zIndex, + width: now.extent, + labels: labelsAt(now.progress), + collapsed: collapsedAt(now.progress), }; - if (raised != null) { - if (getComputedStyle(raised).position === 'static') { - raised.style.position = 'relative'; - } - raised.style.zIndex = '2'; - } - const end = playLayoutSwap( - {...swap, rect: at}, - { - host, - isOpaque: true, - copy: (p, ride) => ({ - clipPath: inset(railRect.width - widthAt(p, ride)), - opacity: 1 - clamp((p - 0.7) / 0.3), - }), - copyText: labelsAt, - rideWith, - onEnd: () => { - if (raised != null && restore != null) { - raised.style.position = restore.position; - raised.style.zIndex = restore.zIndex; - } - }, - incoming: [{element: rail, frames: p => ({opacity: collapsedAt(p)})}], - }, - ); - return {end, shown}; - } - - // An expand reveals the real rail from the width it showed, its text fading - // in late, under the old, collapsed rail fading early. - const incoming = [ - { - element: rail, - frames: (p: number, ride: {x: number}) => ({ - clipPath: inset(next.width - widthAt(p, ride)), - }), }, - ...textElements(rail).map(element => ({ - element, - frames: (p: number) => ({opacity: labelsAt(p)}), - })), - ]; - const end = - swap != null && host != null - ? playLayoutSwap( - {...swap, rect: at}, - { - host, - isOpaque: true, - copy: p => ({opacity: collapsedAt(p)}), - rideWith, - incoming, - }, - ) - : playLayoutFrames(handle, incoming); - return {end, shown}; + }; } diff --git a/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts b/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts index 85073edf984ac..9544b2461894a 100644 --- a/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts +++ b/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts @@ -8,7 +8,10 @@ * @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 + * without a handle; Reduce Motion by pointer; and FR9's edge reveal, a + * block-end and an inline-edge reveal and conceal and the two-state form, + * each element's moving edge on the content beside it on every frame, a + * reversal included * @position Real-browser verification of `spec:AST-105` and * `architecture:react-component-runtime` INV12 */ @@ -39,6 +42,44 @@ declare global { }) => Promise; /** Animations on an element: their first transform and timing. */ motionOf: (selector: string) => MotionRead[]; + /** + * Pauses every animation `ms` into its motion: the page as it shows then, + * however far apart the browser draws its frames. + */ + holdAt: (ms: number) => void; + /** Plays on every animation `holdAt` left paused. */ + resumeAll: () => void; + /** + * An element's box where its glide's own keyframes put it at the glide's + * current time. WebKit evaluates a compositor-run translate a little + * ahead of the frame (measured: up to 1.6 px at a fast glide's peak + * speed, 0 in some frames) while a clip on the main thread reads exactly + * its keyframes, so the edge proofs place what the edge rides by its + * keyframes: the same clock the module rides on. + */ + glideBoxOf: (element: Element) => DOMRect; + /** + * The px an element's `inset()` clip hides from each side, from its clip + * animation's own keyframes at its current time (the computed clip when + * nothing animates it). With `glideBoxOf`, the edge proofs read both + * sides of a ride on the module's own clock; `computedInsetOf` and the + * rect read what the engine evaluates, which in WebKit runs a little + * apart (a compositor translate ahead of the frame, a main-thread clip a + * frame behind on a late first frame) and is reported beside them. + */ + insetOf: (element: Element) => { + top: number; + right: number; + bottom: number; + left: number; + }; + /** The px an element's computed `inset()` clip hides from each side. */ + computedInsetOf: (element: Element) => { + top: number; + right: number; + bottom: number; + left: number; + }; } } @@ -80,6 +121,101 @@ window.change = async ({changed, mutate, area = '#area', handle = true}) => { AnimatedLayout.playLayoutChange(layout, target); await new Promise(resolve => queueMicrotask(resolve)); }; +window.holdAt = ms => { + for (const animation of document.getAnimations()) { + animation.pause(); + animation.currentTime = ms; + } +}; +window.resumeAll = () => { + for (const animation of document.getAnimations()) { + if (animation.playState === 'paused') { + animation.play(); + } + } +}; +window.glideBoxOf = element => { + // Where layout puts it (offsets ignore transforms; this page lays out on + // whole pixels), plus its glide's translate from the glide's keyframes at + // its current time: no evaluated transform is read at all. + let x = 0; + let y = 0; + for (let node = element; node != null; node = node.offsetParent) { + x += node.offsetLeft + (node === element ? 0 : node.clientLeft); + y += node.offsetTop + (node === element ? 0 : node.clientTop); + } + x -= window.scrollX; + y -= window.scrollY; + const animation = element + .getAnimations() + .filter(a => a.effect.getKeyframes().some(k => k.translate != null)) + .at(-1); + if (animation != null) { + const numbers = value => + [...String(value).matchAll(/(-?[\\d.e+-]+)px/g)].map(m => Number(m[1])); + const frames = animation.effect.getKeyframes(); + const duration = Number(animation.effect.getTiming().duration); + const at = Math.min(1, Math.max(0, Number(animation.currentTime) / duration)); + let i = 0; + while (i < frames.length - 2 && frames[i + 1].computedOffset < at) { + i += 1; + } + const [x0 = 0, y0 = 0] = numbers(frames[i].translate); + const [x1 = 0, y1 = 0] = numbers(frames[i + 1].translate); + const span = frames[i + 1].computedOffset - frames[i].computedOffset; + const f = span > 0 ? (at - frames[i].computedOffset) / span : 1; + x += x0 + (x1 - x0) * f; + y += y0 + (y1 - y0) * f; + } + return new DOMRect(x, y, element.offsetWidth, element.offsetHeight); +}; +const parseInset = (value, box) => { + const out = {top: 0, right: 0, bottom: 0, left: 0}; + const match = /^inset\\(([^)]*?)(?:\\s+round\\s[^)]*)?\\)$/.exec( + String(value ?? ''), + ); + if (match == null) { + return out; + } + const [t, r = t, b = t, l = r] = match[1].trim().split(/\\s+/); + const px = (value, size) => + value.endsWith('%') ? (parseFloat(value) / 100) * size : parseFloat(value); + return { + top: px(t, box.height), + right: px(r, box.width), + bottom: px(b, box.height), + left: px(l, box.width), + }; +}; +window.computedInsetOf = element => + parseInset(getComputedStyle(element).clipPath, element.getBoundingClientRect()); +window.insetOf = element => { + const box = element.getBoundingClientRect(); + const animation = element + .getAnimations() + .filter(a => a.effect.getKeyframes().some(k => k.clipPath != null)) + .at(-1); + if (animation == null) { + return window.computedInsetOf(element); + } + const frames = animation.effect.getKeyframes(); + const duration = Number(animation.effect.getTiming().duration); + const at = Math.min(1, Math.max(0, Number(animation.currentTime) / duration)); + let i = 0; + while (i < frames.length - 2 && frames[i + 1].computedOffset < at) { + i += 1; + } + const a = parseInset(frames[i].clipPath, box); + const b = parseInset(frames[i + 1].clipPath, box); + const span = frames[i + 1].computedOffset - frames[i].computedOffset; + const f = span > 0 ? (at - frames[i].computedOffset) / span : 1; + return { + top: a.top + (b.top - a.top) * f, + right: a.right + (b.right - a.right) * f, + bottom: a.bottom + (b.bottom - a.bottom) * f, + left: a.left + (b.left - a.left) * f, + }; +}; window.motionOf = selector => { const element = document.querySelector(selector); return element.getAnimations({subtree: false}).map(animation => { @@ -1597,6 +1733,535 @@ test.describe('spec:AST-105 FR2–FR4', () => { }); }); +/** One frame of an edge reveal: the moving edge, the content beside it. */ +interface EdgeFrame { + phase: string; + /** Where the element's moving edge shows (its box less its clip). */ + edge: number; + /** Where the facing edge of the content beside it shows. */ + beside: number; + /** How much of the element shows along the edge's axis. */ + shown: number; + /** + * How far the engine's evaluated clip and rect stand from the keyframes' + * values at the animations' current time, both sides summed. + */ + skew: number; +} + +/** + * The edge proofs read both sides of a ride on the module's clock. Chromium + * evaluates exactly that (its skew stays under half a px); WebKit's skew is + * reported, an engine clock the module cannot reach. + */ +function reportSkew(frames: EdgeFrame[], browserName: string) { + const skew = Math.max(0, ...frames.map(f => f.skew)); + test.info().annotations.push({ + type: 'evaluation skew (px)', + description: String(skew), + }); + if (browserName === 'chromium') { + expect(skew).toBeLessThan(0.5); + } +} + +test.describe('spec:AST-105 FR9 (the edge reveal)', () => { + test('a block-start reveal and conceal, in a column that stacks from its end, keep the moving edge on the content before it on every frame, and the conceal leaves the element where it shows', async ({ + page, + browserName, + }) => { + await open( + page, + `
+
+ +
`, + ); + const result = await page.evaluate(async () => { + const AL = window.AnimatedLayout; + const area = document.querySelector('#area'); + const content = document.querySelector('#content') as HTMLElement; + const a = document.querySelector('#a') as HTMLElement; + const layout = AL.createAnimatedLayout(() => area); + const frames: EdgeFrame[] = []; + const bottoms: number[] = []; + const read = (phase: string) => { + const box = content.getBoundingClientRect(); + const inset = window.insetOf(content); + const beside = window.glideBoxOf(a); + frames.push({ + phase, + edge: box.top + inset.top, + beside: beside.bottom, + shown: box.height - inset.top - inset.bottom, + skew: + Math.abs(window.computedInsetOf(content).top - inset.top) + + Math.abs(a.getBoundingClientRect().top - beside.top), + }); + if (phase === 'closing') { + bottoms.push(box.bottom); + } + }; + const frame = async () => + new Promise(resolve => { + requestAnimationFrame(() => resolve()); + }); + await frame(); + // Shown: the content before it glides up out of its way, and its top + // edge comes down with it. + const opening = AL.reportLayoutChange(layout, () => content); + opening.measureBefore(); + content.style.display = 'block'; + opening.animate(); + AL.playLayoutChange(layout, content, {edge: 'block-start'}); + for (let i = 0; i < 60; i++) { + await frame(); + read('opening'); + } + const openBottom = content.getBoundingClientRect().bottom; + // The gap the layout leaves between them at rest (the rows' margins). + const restGap = + content.getBoundingClientRect().top - a.getBoundingClientRect().bottom; + // Hidden again: the content before it glides back down over it, its + // top edge on that content, while it stays where it shows. + const closing = AL.reportLayoutChange(layout, () => content); + closing.measureBefore(); + closing.animate(); + const isHeld = AL.holdLayoutExit(layout, content, () => {}, { + edge: 'block-start', + }); + for (let i = 0; i < 60; i++) { + await frame(); + read('closing'); + } + return {frames, bottoms, openBottom, restGap, isHeld}; + }); + expect(result.isHeld).toBe(true); + const shown = result.frames.filter(f => f.shown > 0.5); + expect(shown.filter(f => f.phase === 'opening').length).toBeGreaterThan(4); + expect(shown.filter(f => f.phase === 'closing').length).toBeGreaterThan(4); + for (const f of shown) { + expect( + Math.abs(f.edge - f.beside - result.restGap), + JSON.stringify({...f, restGap: result.restGap}), + ).toBeLessThan(1.5); + } + // It stays where it shows while it conceals: no jump by its own size. + for (const bottom of result.bottoms) { + expect(Math.abs(bottom - result.openBottom)).toBeLessThan(1); + } + reportSkew(shown, browserName); + }); + + test('a block-end reveal keeps its edge on the content after it on every frame, and a conceal that reverses it starts from what it shows', async ({ + page, + browserName, + }) => { + await open( + page, + `
+ +
`, + ); + const frames = await page.evaluate(async () => { + const area = document.querySelector('#area'); + const content = document.querySelector('#content') as HTMLElement; + const a = document.querySelector('#a') as HTMLElement; + const layout = window.AnimatedLayout.createAnimatedLayout(() => area); + const out: EdgeFrame[] = []; + const read = (phase: string) => { + const box = content.getBoundingClientRect(); + const inset = window.insetOf(content); + out.push({ + phase, + edge: box.bottom - inset.bottom, + beside: window.glideBoxOf(a).top, + shown: box.height - inset.top - inset.bottom, + skew: + Math.abs(window.computedInsetOf(content).bottom - inset.bottom) + + Math.abs(a.getBoundingClientRect().top - window.glideBoxOf(a).top), + }); + }; + const frame = async () => + new Promise(resolve => { + requestAnimationFrame(() => resolve()); + }); + await frame(); + const opening = window.AnimatedLayout.reportLayoutChange( + layout, + () => content, + ); + opening.measureBefore(); + content.style.display = ''; + opening.animate(); + window.AnimatedLayout.playLayoutChange(layout, content); + for (let i = 0; i < 2; i++) { + await frame(); + read('opening'); + } + // Closed again 120 ms into the reveal, as the page shows it then. + window.holdAt(120); + read('reversed'); + const closing = window.AnimatedLayout.reportLayoutChange( + layout, + () => content, + ); + closing.measureBefore(); + closing.animate(); + window.AnimatedLayout.holdLayoutExit(layout, content, () => {}); + await new Promise(resolve => { + queueMicrotask(resolve); + }); + // Where the conceal starts, on its own clock, before any frame. + read('restart'); + window.resumeAll(); + for (let i = 0; i < 60; i++) { + await frame(); + read('closing'); + } + return out; + }); + const shown = frames.filter(f => f.shown > 0.5); + expect(shown.length).toBeGreaterThan(10); + // Wherever the row after it shows, the content shows down to it. + for (const f of shown) { + expect(Math.abs(f.beside - f.edge), JSON.stringify(f)).toBeLessThan(1.5); + } + reportSkew(shown, browserName); + // It closes from what it showed: no jump back to its whole height. + const reversed = frames.find(f => f.phase === 'reversed') as EdgeFrame; + const restart = frames.find(f => f.phase === 'restart') as EdgeFrame; + expect(reversed.shown).toBeGreaterThan(5); + expect(reversed.shown).toBeLessThan(75); + expect(Math.abs(restart.shown - reversed.shown)).toBeLessThan(1.5); + }); + + for (const direction of ['ltr', 'rtl'] as const) { + test(`an inline-end reveal and conceal keep its edge on the item after it on every frame (${direction})`, async ({ + page, + browserName, + }) => { + await open( + page, + `
+
+ +
+
`, + ); + const frames = await page.evaluate(async rtl => { + const area = document.querySelector('#area'); + const content = document.querySelector('#content') as HTMLElement; + const a = document.querySelector('#a') as HTMLElement; + const layout = window.AnimatedLayout.createAnimatedLayout(() => area); + const out: EdgeFrame[] = []; + const read = (phase: string) => { + const box = content.getBoundingClientRect(); + const next = window.glideBoxOf(a); + const inset = window.insetOf(content); + const computed = window.computedInsetOf(content); + out.push({ + phase, + edge: rtl ? box.left + inset.left : box.right - inset.right, + beside: rtl ? next.right : next.left, + shown: box.width - inset.left - inset.right, + skew: + Math.abs(computed.left - inset.left) + + Math.abs(computed.right - inset.right) + + Math.abs(a.getBoundingClientRect().left - next.left), + }); + }; + const frame = async () => + new Promise(resolve => { + requestAnimationFrame(() => resolve()); + }); + await frame(); + const opening = window.AnimatedLayout.reportLayoutChange( + layout, + () => content, + ); + opening.measureBefore(); + content.style.display = ''; + opening.animate(); + window.AnimatedLayout.playLayoutChange(layout, content, { + edge: 'inline-end', + }); + for (let i = 0; i < 5; i++) { + await frame(); + read('opening'); + } + await new Promise(resolve => { + requestAnimationFrame(() => { + read('reversed'); + const closing = window.AnimatedLayout.reportLayoutChange( + layout, + () => content, + ); + closing.measureBefore(); + closing.animate(); + window.AnimatedLayout.holdLayoutExit(layout, content, () => {}, { + edge: 'inline-end', + }); + // Where the conceal starts, on its own clock, once it rides. + queueMicrotask(() => { + read('restart'); + resolve(); + }); + }); + }); + for (let i = 0; i < 60; i++) { + await frame(); + read('closing'); + } + return out; + }, direction === 'rtl'); + const shown = frames.filter(f => f.shown > 0.5); + expect(shown.length).toBeGreaterThan(10); + for (const f of shown) { + expect(Math.abs(f.beside - f.edge), JSON.stringify(f)).toBeLessThan( + 1.5, + ); + } + reportSkew(shown, browserName); + const reversed = frames.find(f => f.phase === 'reversed') as EdgeFrame; + const restart = frames.find(f => f.phase === 'restart') as EdgeFrame; + expect(Math.abs(restart.shown - reversed.shown)).toBeLessThan(1.5); + }); + } + + for (const direction of ['ltr', 'rtl'] as const) { + test(`the two-state form holds the old state as one copy and keeps the edge on the column it rides, a reversal included (${direction})`, async ({ + page, + browserName, + }) => { + await open( + page, + `
+ +
The page
+
`, + '#panel.narrow { width: 48px !important; }', + ); + const result = await page.evaluate(async rtl => { + const area = document.querySelector('#area'); + const row = document.querySelector('#row') as HTMLElement; + const panel = document.querySelector('#panel') as HTMLElement; + const column = document.querySelector('#column') as HTMLElement; + const AL = window.AnimatedLayout; + const layout = AL.createAnimatedLayout(() => area); + const frames: EdgeFrame[] = []; + let copies = 0; + const read = (phase: string, element: Element) => { + const box = element.getBoundingClientRect(); + const next = window.glideBoxOf(column); + const inset = window.insetOf(element); + const computed = window.computedInsetOf(element); + frames.push({ + phase, + edge: rtl ? box.left + inset.left : box.right - inset.right, + beside: rtl ? next.right : next.left, + shown: box.width - inset.left - inset.right, + skew: + Math.abs(computed.left - inset.left) + + Math.abs(computed.right - inset.right) + + Math.abs(column.getBoundingClientRect().left - next.left), + }); + }; + const copyOf = () => + row.querySelector( + ':scope > [inert][aria-hidden="true"]', + ); + const frame = async () => + new Promise(resolve => { + requestAnimationFrame(() => resolve()); + }); + await frame(); + // Collapse: the old, wide panel is held and covered on its edge. + let hold = AL.holdLayoutEdge(layout, panel, {edge: 'inline-end'}); + let report = AL.reportLayoutChange(layout, () => panel); + report.measureBefore(); + panel.classList.add('narrow'); + report.animate(); + const motion = AL.playLayoutEdge(hold!, panel, layout, { + edge: 'inline-end', + rideWith: column, + }); + for (let i = 0; i < 2; i++) { + await frame(); + const copy = copyOf(); + copies = Math.max(copies, copy == null ? 0 : 1); + if (copy != null) { + read('collapsing', copy); + } + } + // Expanded again 120 ms into the collapse, from the width it shows + // then. + window.holdAt(120); + const copy = copyOf(); + copies = Math.max(copies, copy == null ? 0 : 1); + if (copy != null) { + read('collapsing', copy); + } + const shown = motion.shown()?.extent ?? null; + motion.end(); + hold = AL.holdLayoutEdge(layout, panel, { + edge: 'inline-end', + shown, + }); + report = AL.reportLayoutChange(layout, () => panel); + report.measureBefore(); + panel.classList.remove('narrow'); + report.animate(); + AL.playLayoutEdge(hold!, panel, layout, { + edge: 'inline-end', + rideWith: column, + }); + await new Promise(resolve => { + queueMicrotask(resolve); + }); + window.resumeAll(); + for (let i = 0; i < 60; i++) { + await frame(); + read('expanding', panel); + } + return {frames, copies, left: row.querySelectorAll('[inert]').length}; + }, direction === 'rtl'); + expect(result.copies).toBe(1); + // The copy is gone once the motion ends. + expect(result.left).toBe(0); + const moving = result.frames.filter( + f => f.shown > 48.5 && f.shown < 259.5, + ); + expect(moving.length).toBeGreaterThan(5); + for (const f of moving) { + expect(Math.abs(f.beside - f.edge), JSON.stringify(f)).toBeLessThan( + 1.5, + ); + } + reportSkew(moving, browserName); + // The expand starts where the collapse got to. + const lastCollapse = result.frames + .filter(f => f.phase === 'collapsing') + .at(-1) as EdgeFrame; + const firstExpand = result.frames.find( + f => f.phase === 'expanding', + ) as EdgeFrame; + expect(Math.abs(firstExpand.shown - lastCollapse.shown)).toBeLessThan(30); + }); + } + + for (const [side, direction, collapsed] of [ + ['start', 'ltr', 48], + ['start', 'rtl', 0], + ['end', 'ltr', 48], + ] as const) { + test(`a resized pane with no copy collapses and opens by the edge facing its handle, that edge on the handle on every frame, a reversal included (${side} pane, ${direction}, to ${collapsed} px)`, async ({ + page, + browserName, + }) => { + const pane = ``; + const grip = `
`; + const content = `
The page
`; + await open( + page, + `
${ + side === 'start' ? pane + grip + content : content + grip + pane + }
`, + `#pane.collapsed { width: ${collapsed}px !important; }`, + ); + const result = await page.evaluate( + async ({atStart, rtl}) => { + const area = document.querySelector('#area'); + const paneEl = document.querySelector('#pane') as HTMLElement; + const gripEl = document.querySelector('#grip') as HTMLElement; + const AL = window.AnimatedLayout; + const layout = AL.createAnimatedLayout(() => area); + // The edge facing the handle, and the handle's side facing it. + const edge = atStart ? 'inline-end' : 'inline-start'; + const physicalRight = atStart !== rtl; + const frames: EdgeFrame[] = []; + const read = (phase: string) => { + const box = paneEl.getBoundingClientRect(); + const next = window.glideBoxOf(gripEl); + const inset = window.insetOf(paneEl); + const computed = window.computedInsetOf(paneEl); + frames.push({ + phase, + edge: physicalRight + ? box.right - inset.right + : box.left + inset.left, + beside: physicalRight ? next.left : next.right, + shown: box.width - inset.left - inset.right, + skew: + Math.abs(computed.left - inset.left) + + Math.abs(computed.right - inset.right) + + Math.abs(gripEl.getBoundingClientRect().left - next.left), + }); + }; + const frame = async () => + new Promise(resolve => { + requestAnimationFrame(() => resolve()); + }); + const change = (collapse: boolean) => { + const report = AL.reportLayoutEdge(layout, () => paneEl, {edge}); + report.measureBefore(); + paneEl.classList.toggle('collapsed', collapse); + report.animate(); + }; + await frame(); + change(true); + for (let i = 0; i < 2; i++) { + await frame(); + read('collapsing'); + } + // Opened again 120 ms into the collapse, as the page shows it then. + window.holdAt(120); + read('reversed'); + change(false); + await new Promise(resolve => { + queueMicrotask(resolve); + }); + // Where the open starts, on its own clock, before any frame. + read('restart'); + window.resumeAll(); + for (let i = 0; i < 60; i++) { + await frame(); + read('opening'); + } + return { + frames, + held: paneEl.style.getPropertyValue('inline-size'), + rest: paneEl.getBoundingClientRect().width, + }; + }, + {atStart: side === 'start', rtl: direction === 'rtl'}, + ); + const moving = result.frames.filter(f => f.shown > 0.5); + expect(moving.length).toBeGreaterThan(10); + // Wherever the handle shows, the pane shows up to it: no gap opens. + for (const f of moving) { + expect(Math.abs(f.beside - f.edge), JSON.stringify(f)).toBeLessThan( + 1.5, + ); + } + reportSkew(moving, browserName); + // The open starts where the collapse got to. + const reversed = result.frames.find( + f => f.phase === 'reversed', + ) as EdgeFrame; + const restart = result.frames.find( + f => f.phase === 'restart', + ) as EdgeFrame; + expect(reversed.shown).toBeGreaterThan(collapsed + 2); + expect(reversed.shown).toBeLessThan(258); + expect(Math.abs(restart.shown - reversed.shown)).toBeLessThan(1.5); + // It lands open at its own size, holding nothing. + expect(result.held).toBe(''); + expect(result.rest).toBeCloseTo(260, 0); + }); + } +}); + test.describe('spec:AST-105 FR5 and FR4 under a fine pointer', () => { test.use({ isMobile: false, diff --git a/packages/core/src/motion/__tests__/animatedLayoutAdopters.interaction.browser.spec.ts b/packages/core/src/motion/__tests__/animatedLayoutAdopters.interaction.browser.spec.ts index bc6a9bd8c8781..9a39a02668b37 100644 --- a/packages/core/src/motion/__tests__/animatedLayoutAdopters.interaction.browser.spec.ts +++ b/packages/core/src/motion/__tests__/animatedLayoutAdopters.interaction.browser.spec.ts @@ -8,7 +8,9 @@ * @output The adopters' browser proofs `spec:AST-105` Verification asks for: * with a handle, through a ScrollableArea ref, opening glides the content * after the component while the revealed content enters, and closing - * glides it while the closing content exits, inert, until it hides + * glides it while the closing content exits, inert, until it hides; and a + * Collapsible's block-end edge reveal (FR9), its edge on the content after + * it on every frame, through a close while it opens * @position Testing; `component:Collapsible` FR10 and `component:TreeList` * FR9. The module's own proofs are animatedLayout.interaction.browser.spec.ts * @@ -81,6 +83,225 @@ async function mark(page: Page, text: string, id: string): Promise { ); } +interface EdgeRideFrame { + phase: string; + /** From the revealed content's shown bottom edge to the content after it. */ + gap: number; + /** How much of the revealed content shows. */ + shown: number; +} + +interface EdgeRide { + frames: EdgeRideFrame[]; + /** The gap the layout leaves between the open content and what follows. */ + restGap: number; + height: number; + /** + * How much farther than the content's own height the content after it + * travels: a gap the open content adds in its own container, which no clip + * on the content can show. + */ + beyond: number; +} + +/** + * Opens `content` with `trigger`, closes it again 120 ms into the reveal (as + * the page shows it then: every animation paused there, however far apart the + * browser draws its frames), and reads on every frame where the content's + * shown bottom edge stands against the content after it. Both sides are read + * on the module's own clock: the clip from its animation's keyframes and the + * content after it from its glide's keyframes, at their current time (WebKit + * evaluates a main-thread clip and a compositor translate a little apart). + */ +async function edgeRide( + page: Page, + selectors: {trigger: string; content: string; after: string}, +): Promise { + return page.evaluate(async ({trigger, content: contentSelector, after}) => { + const button = document.querySelector(trigger) as HTMLElement; + const content = () => document.querySelector(contentSelector); + const next = document.querySelector(after) as HTMLElement; + const at = (animation: Animation) => { + const effect = animation.effect as KeyframeEffect; + return Math.min( + 1, + Math.max( + 0, + Number(animation.currentTime) / Number(effect.getTiming().duration), + ), + ); + }; + const bracket = (frames: ComputedKeyframe[], offset: number) => { + let i = 0; + while (i < frames.length - 2 && frames[i + 1].computedOffset < offset) { + i += 1; + } + const o0 = frames[i].computedOffset; + const o1 = frames[i + 1].computedOffset; + return {i, f: o1 > o0 ? (offset - o0) / (o1 - o0) : 1}; + }; + const bottomOf = (value: unknown, box: DOMRect) => { + const match = /^inset\(([^)]*)\)$/.exec( + typeof value === 'string' ? value : '', + ); + if (match == null) { + return 0; + } + const [t, , b = t] = match[1].trim().split(/\s+/); + return b.endsWith('%') + ? (parseFloat(b) / 100) * box.height + : parseFloat(b); + }; + const insetBottom = (element: HTMLElement) => { + const box = element.getBoundingClientRect(); + const animation = element + .getAnimations() + .filter(a => + (a.effect as KeyframeEffect) + .getKeyframes() + .some(k => k.clipPath != null), + ) + .at(-1); + if (animation == null) { + return bottomOf(getComputedStyle(element).clipPath, box); + } + const frames = (animation.effect as KeyframeEffect).getKeyframes(); + const {i, f} = bracket(frames, at(animation)); + const b0 = bottomOf(frames[i].clipPath, box); + const b1 = bottomOf(frames[i + 1].clipPath, box); + return b0 + (b1 - b0) * f; + }; + const y = (value: unknown) => + [...String(value).matchAll(/(-?[\d.e+-]+)px/g)].map(m => + Number(m[1]), + )[1] ?? 0; + const glideTop = (element: HTMLElement) => { + const rect = element.getBoundingClientRect(); + const animation = element + .getAnimations() + .filter(a => + (a.effect as KeyframeEffect) + .getKeyframes() + .some(k => k.translate != null), + ) + .at(-1); + if (animation == null) { + return rect.top; + } + const frames = (animation.effect as KeyframeEffect).getKeyframes(); + const {i, f} = bracket(frames, at(animation)); + const y0 = y(frames[i].translate); + const y1 = y(frames[i + 1].translate); + return ( + rect.top - y(getComputedStyle(element).translate) + y0 + (y1 - y0) * f + ); + }; + const frames: EdgeRideFrame[] = []; + const read = (phase: string) => { + const element = content(); + if (element == null) { + return; + } + const box = element.getBoundingClientRect(); + const hidden = insetBottom(element); + frames.push({ + phase, + gap: glideTop(next) - (box.bottom - hidden), + shown: + getComputedStyle(element).display === 'none' + ? 0 + : box.height - hidden, + }); + }; + const frame = async () => + new Promise(resolve => { + requestAnimationFrame(() => resolve()); + }); + const settle = async () => { + for (let i = 0; i < 90; i++) { + await frame(); + } + }; + const closedTop = next.getBoundingClientRect().top; + button.click(); + await settle(); + const box = (content() as HTMLElement).getBoundingClientRect(); + const openTop = next.getBoundingClientRect().top; + const restGap = openTop - box.bottom; + const height = box.height; + const beyond = Math.max(0, openTop - closedTop - height); + button.click(); + await settle(); + // Open, then close again 120 ms into the reveal, as the page shows it + // then. + button.click(); + for (let i = 0; i < 2; i++) { + await frame(); + read('opening'); + } + for (const animation of document.getAnimations()) { + animation.pause(); + animation.currentTime = 120; + } + read('reversed'); + const clipOf = () => + content() + ?.getAnimations() + .filter(a => + (a.effect as KeyframeEffect) + .getKeyframes() + .some(k => k.clipPath != null), + ) + .at(-1) ?? null; + const opening = clipOf(); + button.click(); + // Until the close commits (a component may commit its state in a later + // task), with every animation still paused where the close found it. + for (let i = 0; i < 50 && clipOf() === opening; i++) { + await new Promise(resolve => { + setTimeout(resolve, 0); + }); + } + for (const animation of document.getAnimations()) { + if (animation.playState === 'paused') { + animation.play(); + } + } + for (let i = 0; i < 60; i++) { + await frame(); + read('closing'); + } + return {frames, restGap, height, beyond}; + }, selectors); +} + +/** + * Wherever the content after it shows, the revealed content reaches it: no gap + * opens past the one at rest, and none closes past what the content's own + * container gap accounts for. The close starts from what shows. + */ +function expectEdgeRide(result: EdgeRide): void { + const shown = result.frames.filter(f => f.shown > 0.5); + expect(shown.length).toBeGreaterThan(8); + expect(result.beyond).toBeLessThan(8); + for (const f of shown) { + const detail = JSON.stringify({ + ...f, + restGap: result.restGap, + beyond: result.beyond, + }); + expect(f.gap, detail).toBeLessThan(result.restGap + 1.5); + expect(f.gap, detail).toBeGreaterThan(result.restGap - result.beyond - 1.5); + } + const reversed = result.frames.find(f => f.phase === 'reversed'); + const next = result.frames.find(f => f.phase === 'closing'); + expect(reversed?.shown).toBeGreaterThan(2); + expect(reversed?.shown).toBeLessThan(result.height - 2); + expect(Math.abs((next?.shown ?? 0) - (reversed?.shown ?? 0))).toBeLessThan( + result.height / 4, + ); +} + async function viewportScroll(page: Page): Promise { return page.evaluate(() => { const viewport = [...document.querySelectorAll('*')].find( @@ -137,6 +358,145 @@ test.describe('component:Collapsible FR10 and component:TreeList FR9 (spec:AST-1 expect(await viewportScroll(page)).toBe(scrollBefore); }); + test("a Collapsible's revealed content keeps its block-end edge on the content after it on every frame, and a close while it opens conceals from what it shows (FR9)", async ({ + page, + }) => { + await openStory(page); + await mark(page, 'A plain app div', 'after'); + const trigger = page.getByRole('button', {name: 'Shipping details'}); + const contentId = await trigger.getAttribute('aria-controls'); + expectEdgeRide( + await edgeRide(page, { + trigger: `button[aria-controls="${contentId ?? ''}"]`, + content: `[id="${contentId ?? ''}"]`, + after: '[data-test-id="after"]', + }), + ); + }); + + test("a TreeList row's revealed group keeps its block-end edge on the rows after it on every frame, and a collapse while it expands conceals from what it shows (FR9)", async ({ + page, + }) => { + await openStory(page); + expectEdgeRide( + await edgeRide(page, { + trigger: '[data-tree-id="docs"] [data-tree-toggle]', + content: '[data-tree-id="docs"] > [role="group"]', + after: '[data-tree-id="notes"]', + }), + ); + }); + + test('in a single-open CollapsibleGroup, the item that opens while a taller one above it closes keeps its block-end edge on the next item on every frame (FR9)', async ({ + page, + }) => { + await openStory(page); + const result = await page.evaluate(async () => { + const trigger = (name: string) => + [...document.querySelectorAll('button')].find( + button => button.textContent?.trim() === name, + ) as HTMLButtonElement; + const opening = trigger('Gift options'); + const content = () => + document.getElementById(opening.getAttribute('aria-controls') ?? ''); + // The next item: the root of the Collapsible after the one that opens. + const nextItem = () => { + let node: HTMLElement | null = trigger('Delivery windows'); + while (node != null && node.parentElement?.contains(opening) !== true) { + node = node.parentElement; + } + return node as HTMLElement; + }; + // The px the content's clip hides at its bottom, from its clip + // animation's keyframes at its current time (the module's clock). + const bottomOf = (value: unknown, height: number) => { + const match = /^inset\(([^)]*)\)$/.exec( + typeof value === 'string' ? value : '', + ); + if (match == null) { + return 0; + } + const [t, , b = t] = match[1].trim().split(/\s+/); + return b.endsWith('%') ? (parseFloat(b) / 100) * height : parseFloat(b); + }; + const hiddenBottom = (element: HTMLElement) => { + const height = element.getBoundingClientRect().height; + const animation = element + .getAnimations() + .filter(a => + (a.effect as KeyframeEffect) + .getKeyframes() + .some(k => k.clipPath != null), + ) + .at(-1); + if (animation == null) { + return bottomOf(getComputedStyle(element).clipPath, height); + } + const effect = animation.effect as KeyframeEffect; + const frames = effect.getKeyframes(); + const at = Math.min( + 1, + Math.max( + 0, + Number(animation.currentTime) / Number(effect.getTiming().duration), + ), + ); + let i = 0; + while (i < frames.length - 2 && frames[i + 1].computedOffset < at) { + i += 1; + } + const o0 = frames[i].computedOffset; + const o1 = frames[i + 1].computedOffset; + const f = o1 > o0 ? (at - o0) / (o1 - o0) : 1; + const b0 = bottomOf(frames[i].clipPath, height); + const b1 = bottomOf(frames[i + 1].clipPath, height); + return b0 + (b1 - b0) * f; + }; + const frame = async () => + new Promise(resolve => { + requestAnimationFrame(() => resolve()); + }); + const settle = async () => { + for (let i = 0; i < 90; i++) { + await frame(); + } + }; + // The taller item first, then the shorter one below it, which closes it. + trigger('Return policy').click(); + await settle(); + opening.click(); + const frames: {gap: number; shown: number}[] = []; + for (let i = 0; i < 60; i++) { + await frame(); + const element = content(); + if (element == null || getComputedStyle(element).display === 'none') { + continue; + } + const box = element.getBoundingClientRect(); + const hidden = hiddenBottom(element); + frames.push({ + gap: nextItem().getBoundingClientRect().top - (box.bottom - hidden), + shown: box.height - hidden, + }); + } + await settle(); + const box = (content() as HTMLElement).getBoundingClientRect(); + return { + frames, + restGap: nextItem().getBoundingClientRect().top - box.bottom, + height: box.height, + }; + }); + const shown = result.frames.filter(f => f.shown > 0.5); + expect(shown.length).toBeGreaterThan(4); + // The opening content never shows over the next item, nor short of it. + for (const f of shown) { + const detail = JSON.stringify({...f, restGap: result.restGap}); + expect(f.gap, detail).toBeGreaterThan(result.restGap - 1.5); + expect(f.gap, detail).toBeLessThan(result.restGap + 1.5); + } + }); + test('a TreeList row glides the rows after it as it expands and collapses, and the collapsing group exits inert until it unmounts', async ({ page, }) => { diff --git a/packages/core/src/motion/__tests__/animatedLayoutBundle.ts b/packages/core/src/motion/__tests__/animatedLayoutBundle.ts index e616620f26dc0..d74379b04aba9 100644 --- a/packages/core/src/motion/__tests__/animatedLayoutBundle.ts +++ b/packages/core/src/motion/__tests__/animatedLayoutBundle.ts @@ -10,8 +10,11 @@ export { createAnimatedLayout, + holdLayoutEdge, holdLayoutExit, playLayoutChange, + playLayoutEdge, releaseLayoutExit, reportLayoutChange, + reportLayoutEdge, } from '../animatedLayout'; diff --git a/packages/core/src/motion/animatedLayout.boundary.test.ts b/packages/core/src/motion/animatedLayout.boundary.test.ts index 387a927f14c16..7e426f5977bcc 100644 --- a/packages/core/src/motion/animatedLayout.boundary.test.ts +++ b/packages/core/src/motion/animatedLayout.boundary.test.ts @@ -78,14 +78,15 @@ describe('architecture:react-component-runtime INV12 source boundary', () => { expect(body, forbidden).not.toContain(forbidden); } // Every descent is a `display: contents` branch: the walk's (in its own - // direction, first child or last) and the exit's search for the next box. + // direction, first child or last), and the searches for the box after an + // element (an exit, an end edge's reveal) and before it (a start edge's). const descents = body.match(/(first|last)ElementChild/g) ?? []; - expect(descents).toHaveLength(3); + expect(descents).toHaveLength(4); const contentsDescents = body.match( /(?:display|part) === 'contents'\)\s*\{\s*for\s*\(\s*let child =[^;]*\.(first|last)ElementChild/g, ) ?? []; - expect(contentsDescents).toHaveLength(2); + expect(contentsDescents).toHaveLength(3); // Each level's sibling walk ends at the first sibling past the band. expect(body).toMatch( /sibling != null && take\(sibling, true\);\s*sibling = sibling\.nextElementSibling/, @@ -114,6 +115,9 @@ describe('architecture:react-component-runtime INV12 source boundary', () => { 'holdLayoutSwap', 'playLayoutSwap', 'playLayoutFrames', + 'holdLayoutEdge', + 'playLayoutEdge', + 'reportLayoutEdge', ]) { expect( source, diff --git a/packages/core/src/motion/animatedLayout.edge.test.ts b/packages/core/src/motion/animatedLayout.edge.test.ts new file mode 100644 index 0000000000000..7e740d49038b0 --- /dev/null +++ b/packages/core/src/motion/animatedLayout.edge.test.ts @@ -0,0 +1,788 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file animatedLayout.edge.test.ts + * @input vitest (jsdom), the animated-layout module, a recording stand-in for + * Element.animate (its KeyframeEffect's `setKeyframes` and `updateTiming` + * included, and a current time read off a stubbed document timeline) and + * stubbed layout rectangles (jsdom lays nothing out) + * @output Checks `spec:AST-105` FR9's edge reveal: the one-state reveal and + * conceal from each edge, left to right and right to left; a reversal that + * starts from what shows; the moving edge riding the glide of the content + * after it, through a reversal too; and the two-state form, which holds the + * old state as one copy while the edge rides a gliding element, the element + * raised above the copy while it conceals, a reversal starting from the + * extent it shows, and Reduce Motion by pointer; and a resized element's + * reveal with no copy (a resize controller's pane): held at the extent it + * was laid out at while it conceals, its edge's margin freeing the + * difference, clipped to its new extent, riding the content beside it + * @position Testing; the real geometry is proven in + * __tests__/animatedLayout.interaction.browser.spec.ts and, for the SideNav + * rail's two-state form, SideNav's rail-motion specs + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import { + createAnimatedLayout, + holdLayoutEdge, + holdLayoutExit, + playLayoutChange, + playLayoutEdge, + releaseLayoutExit, + reportLayoutChange, + reportLayoutEdge, + type LayoutEdge, +} from './animatedLayout'; + +interface Played { + element: Element; + keyframes: Keyframe[]; + options: KeyframeAnimationOptions; + startTime: number | null; + cancel: ReturnType; + /** What `setKeyframes` replaced the frames with, if anything. */ + ridden: Keyframe[] | null; + /** The duration `updateTiming` set, if any. */ + duration: number | null; + finish?: () => void; +} + +let played: Played[] = []; +let media: Record = {}; +const originalAnimate = Element.prototype.animate; +const originalMatchMedia = window.matchMedia; + +function setTimeline(currentTime: number) { + Object.defineProperty(document, 'timeline', { + configurable: true, + value: {currentTime}, + }); +} + +function timelineNow(): number { + return Number(document.timeline?.currentTime ?? 0); +} + +beforeEach(() => { + played = []; + media = {}; + setTimeline(1000); + Element.prototype.animate = function ( + this: Element, + keyframes: Keyframe[] | PropertyIndexedKeyframes | null, + options?: number | KeyframeAnimationOptions, + ) { + const entry: Played = { + element: this, + keyframes: keyframes as Keyframe[], + options: options as KeyframeAnimationOptions, + startTime: null, + cancel: vi.fn(), + ridden: null, + duration: null, + }; + played.push(entry); + return { + cancel: entry.cancel, + addEventListener(type: string, listener: () => void) { + if (type === 'finish') { + entry.finish = listener; + } + }, + get startTime() { + return entry.startTime; + }, + set startTime(value: number | null) { + entry.startTime = value; + }, + // As a browser reads it: the timeline's time since the start. + get currentTime() { + return entry.startTime == null ? 0 : timelineNow() - entry.startTime; + }, + effect: { + setKeyframes(frames: Keyframe[]) { + entry.ridden = frames; + }, + updateTiming(timing: OptionalEffectTiming) { + entry.duration = Number(timing.duration); + }, + }, + } 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; + vi.restoreAllMocks(); + document.body.innerHTML = ''; +}); + +const flushMicrotasks = async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +}; + +function lastOn(element: Element): Played | undefined { + return played.filter(entry => entry.element === element).at(-1); +} + +/** The keyframe a running animation shows `elapsed` ms in, and that time. */ +function frameAt(entry: Played, index: number): {frame: Keyframe; at: number} { + const frame = entry.keyframes[index]; + return {frame, at: Number(frame.offset) * Number(entry.options.duration)}; +} + +/** + * A column area: a revealed element (`#content`, 80 px tall once open) and + * `followers` rows after it, which the change pushes down by 80 px. + */ +function columnSetup({ + direction = 'ltr', + followers = 0, +}: {direction?: 'ltr' | 'rtl'; followers?: number} = {}) { + const rows = Array.from( + {length: followers}, + (_, i) => `
Row ${i}
`, + ).join(''); + document.body.innerHTML = ` +
+
Shipping details
${rows} +
`; + const area = document.getElementById('area') as HTMLElement; + const content = document.getElementById('content') as HTMLElement; + let isOpen = false; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + function (this: HTMLElement) { + if (this === area) { + return DOMRect.fromRect({x: 0, y: 0, width: 400, height: 600}); + } + if (this === content) { + return DOMRect.fromRect({x: 0, y: 0, width: 400, height: 80}); + } + const index = /^row(\d+)$/.exec(this.id)?.[1]; + if (index != null) { + return DOMRect.fromRect({ + x: 0, + y: (isOpen ? 80 : 0) + Number(index) * 40, + width: 400, + height: 40, + }); + } + return DOMRect.fromRect({x: 0, y: 0, width: 0, height: 0}); + }, + ); + const handle = createAnimatedLayout(() => area); + return { + area, + content, + handle, + row: (i: number) => document.getElementById(`row${i}`) as HTMLElement, + /** Opens or closes the content, as a commit would, around the report. */ + toggle(open: boolean) { + const report = reportLayoutChange(handle, () => content); + report.measureBefore(); + isOpen = open; + report.animate(); + }, + }; +} + +describe('the one-state edge reveal (spec:AST-105 FR9)', () => { + const firstLast = (entry: Played | undefined) => [ + String(entry?.keyframes[0].clipPath), + String(entry?.keyframes.at(-1)?.clipPath), + ]; + + it('reveals from the block-end edge by default', () => { + const {content, handle, toggle} = columnSetup(); + toggle(true); + playLayoutChange(handle, content); + expect(firstLast(lastOn(content))).toEqual([ + 'inset(0 0 100% 0)', + 'inset(0 0 0% 0)', + ]); + }); + + it.each<[LayoutEdge, 'ltr' | 'rtl', string]>([ + ['block-start', 'ltr', 'inset(100% 0 0 0)'], + ['inline-end', 'ltr', 'inset(0 100% 0 0)'], + ['inline-start', 'ltr', 'inset(0 0 0 100%)'], + ['inline-end', 'rtl', 'inset(0 0 0 100%)'], + ['inline-start', 'rtl', 'inset(0 100% 0 0)'], + ])('reveals from its %s edge (%s)', (edge, direction, hidden) => { + const {content, handle, toggle} = columnSetup({direction}); + toggle(true); + playLayoutChange(handle, content, {edge}); + const [first, last] = firstLast(lastOn(content)); + expect(first).toBe(hidden); + expect(last).toBe(hidden.replace('100%', '0%')); + }); + + it('conceals to the edge it is given, left to right and right to left', async () => { + for (const [direction, hidden] of [ + ['ltr', 'inset(0 100% 0 0)'], + ['rtl', 'inset(0 0 0 100%)'], + ] as const) { + setTimeline(1000); + const {content, handle, toggle} = columnSetup({ + direction, + followers: 1, + }); + toggle(true); + playLayoutChange(handle, content); + await flushMicrotasks(); + // Long after the reveal ended. + setTimeline(61_000); + toggle(false); + expect( + holdLayoutExit(handle, content, vi.fn(), {edge: 'inline-end'}), + ).toBe(true); + const exit = lastOn(content); + expect(String(exit?.keyframes[0].clipPath)).toBe( + hidden.replace('100%', '0%'), + ); + expect(String(exit?.keyframes.at(-1)?.clipPath)).toBe(hidden); + releaseLayoutExit(content); + } + }); + + it('a conceal that reverses a running reveal starts from what the reveal shows', async () => { + const {content, handle, toggle} = columnSetup(); + toggle(true); + playLayoutChange(handle, content); + await flushMicrotasks(); + const reveal = lastOn(content) as Played; + const {frame, at} = frameAt(reveal, 6); + setTimeline(1000 + at); + toggle(false); + expect(holdLayoutExit(handle, content, vi.fn())).toBe(true); + const conceal = lastOn(content) as Played; + expect(conceal).not.toBe(reveal); + // The running reveal stops where it shows, and the conceal starts there. + expect(reveal.cancel).toHaveBeenCalled(); + expect(String(conceal.keyframes[0].clipPath)).toBe(String(frame.clipPath)); + expect(Number(conceal.keyframes[0].opacity)).toBeCloseTo( + Number(frame.opacity), + 3, + ); + expect(String(conceal.keyframes.at(-1)?.clipPath)).toBe( + 'inset(0 0 100% 0)', + ); + }); + + it('a reveal that reverses a running conceal starts from what the conceal shows', async () => { + const {content, handle, toggle} = columnSetup(); + toggle(true); + playLayoutChange(handle, content); + await flushMicrotasks(); + setTimeline(61_000); + toggle(false); + holdLayoutExit(handle, content, vi.fn()); + await flushMicrotasks(); + const conceal = lastOn(content) as Played; + const {frame, at} = frameAt(conceal, 6); + setTimeline(61_000 + at); + // Opened again mid-exit, as an adopter does it. + toggle(true); + releaseLayoutExit(content); + playLayoutChange(handle, content); + const reveal = lastOn(content) as Played; + expect(reveal).not.toBe(conceal); + expect(String(reveal.keyframes[0].clipPath)).toBe(String(frame.clipPath)); + expect(Number(reveal.keyframes[0].opacity)).toBeCloseTo( + Number(frame.opacity), + 3, + ); + expect(String(reveal.keyframes.at(-1)?.clipPath)).toBe('inset(0 0 0% 0)'); + }); + + it("rides the glide of the content after it: its edge is that content's, frame by frame", async () => { + const {content, handle, row, toggle} = columnSetup({followers: 2}); + toggle(true); + playLayoutChange(handle, content); + await flushMicrotasks(); + const glide = lastOn(row(0)) as Played; + const reveal = played.find(entry => entry.element === content) as Played; + expect(reveal.ridden).not.toBeNull(); + expect(reveal.duration).toBe(Number(glide.options.duration)); + expect(reveal.ridden).toHaveLength(glide.keyframes.length); + // Wherever the row shows, the content shows down to it. + reveal.ridden?.forEach((frame, index) => { + const y = Number( + /px\s+(-?[\d.e+-]+)px/.exec( + String(glide.keyframes[index].translate), + )?.[1], + ); + expect(frame.clipPath).toBe(`inset(0px 0px ${Math.max(0, -y)}px 0px)`); + expect(Number(frame.opacity)).toBeCloseTo(1 - Math.max(0, -y) / 80, 6); + }); + }); + + it('through a reversal, conceals on the new glide of the content after it', async () => { + const {content, handle, row, toggle} = columnSetup({followers: 1}); + toggle(true); + playLayoutChange(handle, content); + await flushMicrotasks(); + const glide = lastOn(row(0)) as Played; + const index = 6; + setTimeline( + 1000 + + Number(glide.keyframes[index].offset) * Number(glide.options.duration), + ); + toggle(false); + holdLayoutExit(handle, content, vi.fn()); + await flushMicrotasks(); + const conceal = lastOn(content) as Played; + const again = lastOn(row(0)) as Played; + expect(again).not.toBe(glide); + expect(conceal.duration).toBe(Number(again.options.duration)); + // Its edge is the row's, wherever the row's carried glide shows it: what + // the content shows is the 80 px less the row's distance above its place. + conceal.ridden?.forEach((frame, i) => { + const y = Number( + /px\s+(-?[\d.e+-]+)px/.exec(String(again.keyframes[i].translate))?.[1], + ); + expect(frame.clipPath).toBe( + `inset(0px 0px ${Math.min(80, Math.max(0, 80 - y))}px 0px)`, + ); + }); + expect(String(conceal.ridden?.at(-1)?.clipPath)).toBe( + 'inset(0px 0px 80px 0px)', + ); + }); +}); + +/** + * A row area: a panel 260 px wide that collapses to 48 beside a column, which + * the change moves by the difference (the SideNav rail beside AppShell's + * content). + */ +function rowSetup({direction = 'ltr'}: {direction?: 'ltr' | 'rtl'} = {}) { + document.body.innerHTML = ` +
+
+
+
The page
+
+
`; + const area = document.getElementById('area') as HTMLElement; + const host = document.getElementById('row') as HTMLElement; + const side = document.getElementById('side') as HTMLElement; + const panel = document.getElementById('panel') as HTMLElement; + const column = document.getElementById('column') as HTMLElement; + let width = 260; + const rtl = direction === 'rtl'; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + function (this: HTMLElement) { + if (this === area || this === host) { + return DOMRect.fromRect({x: 0, y: 0, width: 1000, height: 600}); + } + if (this === panel || this === side) { + return DOMRect.fromRect({ + x: rtl ? 1000 - width : 0, + y: 0, + width, + height: 600, + }); + } + if (this === column) { + return DOMRect.fromRect({ + x: rtl ? 0 : width, + y: 0, + width: 1000 - width, + height: 600, + }); + } + return DOMRect.fromRect({x: 0, y: 0, width: 0, height: 0}); + }, + ); + const handle = createAnimatedLayout(() => area); + return { + handle, + host, + side, + panel, + column, + /** Sets the panel's width as a commit would, the column's glide around it. */ + resize(next: number, shown: number | null = null) { + const hold = holdLayoutEdge(handle, panel, {edge: 'inline-end', shown}); + const report = reportLayoutChange(handle, () => panel); + report.measureBefore(); + width = next; + report.animate(); + return hold; + }, + }; +} + +describe('the two-state edge reveal (spec:AST-105 FR9)', () => { + it('conceals by clipping the old state, held as one copy, from what it shows to the new extent, on the glide it rides', async () => { + const {handle, host, side, panel, column, resize} = rowSetup(); + const hold = resize(48); + expect(hold?.swap).not.toBeNull(); + expect(hold?.extent).toBe(260); + const icons = vi.fn((p: number) => ({opacity: p})); + playLayoutEdge(hold!, panel, handle, { + edge: 'inline-end', + rideWith: column, + parts: isConceal => { + expect(isConceal).toBe(true); + return { + copyOpacity: p => 1 - p, + incoming: [{element: panel, frames: icons}], + }; + }, + }); + await flushMicrotasks(); + const copy = hold!.swap!.copy; + expect(copy.parentElement).toBe(host); + const clip = lastOn(copy) as Played; + expect(clip.keyframes[0].clipPath).toBe('inset(0px 0px 0px 0px)'); + expect(clip.keyframes.at(-1)?.clipPath).toBe('inset(0px 212px 0px 0px)'); + expect(clip.keyframes.at(-1)?.opacity).toBe(0); + // Every frame of the copy follows the column's glide. + const glide = lastOn(column) as Played; + expect(clip.keyframes).toHaveLength(glide.keyframes.length); + // The panel, raised above its copy while it conceals, shows the caller's + // own frames, and drops the raise once the reveal ends. + expect(side.style.zIndex).toBe('2'); + expect(icons).toHaveBeenCalled(); + clip.finish?.(); + expect(side.style.zIndex).toBe(''); + expect(copy.isConnected).toBe(false); + }); + + it('reveals by clipping the element open from what showed, under the old state', async () => { + const {handle, panel, column, resize} = rowSetup(); + resize(48); + await flushMicrotasks(); + setTimeline(61_000); + played = []; + const hold = resize(260); + playLayoutEdge(hold!, panel, handle, { + edge: 'inline-end', + rideWith: column, + parts: isConceal => ({copyOpacity: p => (isConceal ? 1 : 1 - p)}), + }); + await flushMicrotasks(); + const reveal = played.find( + entry => entry.element === panel && 'clipPath' in entry.keyframes[0], + ) as Played; + expect(reveal.keyframes[0].clipPath).toBe('inset(0px 212px 0px 0px)'); + expect(reveal.keyframes.at(-1)?.clipPath).toBe('inset(0px 0px 0px 0px)'); + const old = lastOn(hold!.swap!.copy) as Played; + expect(old.keyframes[0].opacity).toBe(1); + expect(old.keyframes.at(-1)?.opacity).toBe(0); + }); + + it('clips the edge on the left right to left', async () => { + const {handle, panel, column, resize} = rowSetup({direction: 'rtl'}); + const hold = resize(48); + playLayoutEdge(hold!, panel, handle, { + edge: 'inline-end', + rideWith: column, + }); + await flushMicrotasks(); + const clip = lastOn(hold!.swap!.copy) as Played; + expect(clip.keyframes.at(-1)?.clipPath).toBe('inset(0px 0px 0px 212px)'); + }); + + it('starts a reversal from the extent it shows', async () => { + const {handle, panel, column, resize} = rowSetup(); + const hold = resize(48); + const motion = playLayoutEdge(hold!, panel, handle, { + edge: 'inline-end', + rideWith: column, + }); + await flushMicrotasks(); + motion.end(); + played = []; + // Partway, the panel showed 150 px wide; the adopter hands that over. + const again = resize(260, 150); + expect(again?.extent).toBe(150); + playLayoutEdge(again!, panel, handle, { + edge: 'inline-end', + rideWith: column, + }); + await flushMicrotasks(); + const reveal = played.find( + entry => entry.element === panel && 'clipPath' in entry.keyframes[0], + ) as Played; + expect(reveal.keyframes[0].clipPath).toBe('inset(0px 110px 0px 0px)'); + }); + + it('reports what it shows while it runs, and nothing once it has settled', async () => { + const {handle, panel, resize} = rowSetup(); + const hold = resize(48); + const motion = playLayoutEdge(hold!, panel, handle, {edge: 'inline-end'}); + expect(motion.shown()).toEqual({extent: 260, progress: 0}); + setTimeline(1000 + 120); + const partway = motion.shown(); + expect(partway?.progress).toBeGreaterThan(0); + expect(partway?.progress).toBeLessThan(1); + expect(partway?.extent).toBeCloseTo( + 260 + (48 - 260) * Number(partway?.progress), + 6, + ); + setTimeline(1000 + 60_000); + expect(motion.shown()).toBeNull(); + }); + + it('only cross-fades under Reduce Motion with a coarse pointer (design:reduced-motion DR2)', async () => { + media = { + '(prefers-reduced-motion: reduce)': true, + '(pointer: coarse)': true, + }; + const {handle, panel, column, resize} = rowSetup(); + const hold = resize(48); + playLayoutEdge(hold!, panel, handle, { + edge: 'inline-end', + rideWith: column, + }); + await flushMicrotasks(); + for (const entry of played) { + for (const frame of entry.keyframes) { + expect(Object.keys(frame).filter(key => key !== 'offset')).toEqual([ + 'opacity', + ]); + } + } + }); + + it('changes at once under Reduce Motion with a fine pointer (design:reduced-motion DR3)', async () => { + media = {'(prefers-reduced-motion: reduce)': true}; + const {handle, panel, column, resize} = rowSetup(); + const hold = resize(48); + // Nothing to hold: the change lands at once. + expect(hold?.swap).toBeNull(); + const motion = playLayoutEdge(hold!, panel, handle, { + edge: 'inline-end', + rideWith: column, + }); + await flushMicrotasks(); + expect(played).toEqual([]); + expect(motion.shown()).toBeNull(); + }); +}); + +/** + * A row like a Layout with a resizable pane: the pane, a handle and the + * content, which the change moves by the pane's difference. The pane sits at + * the inline start (`side: 'start'`) or at the inline end. + */ +function paneSetup({ + direction = 'ltr', + side = 'start', +}: {direction?: 'ltr' | 'rtl'; side?: 'start' | 'end'} = {}) { + const pane = + ''; + const handleEl = '
'; + const content = '
The page
'; + document.body.innerHTML = ` +
+
${ + side === 'start' ? pane + handleEl + content : content + handleEl + pane + }
+
`; + const area = document.getElementById('area') as HTMLElement; + const row = document.getElementById('row') as HTMLElement; + const paneEl = document.getElementById('pane') as HTMLElement; + const grip = document.getElementById('grip') as HTMLElement; + const main = document.getElementById('content') as HTMLElement; + let width = 260; + // Physical x of each box: the pane at the row's start or end edge, as the + // direction lays it out. + const atLeft = (side === 'start') === (direction === 'ltr'); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + function (this: HTMLElement) { + if (this === area || this === row) { + return DOMRect.fromRect({x: 0, y: 0, width: 1000, height: 600}); + } + if (this === paneEl) { + return DOMRect.fromRect({ + x: atLeft ? 0 : 1000 - width, + y: 0, + width, + height: 600, + }); + } + if (this === grip) { + return DOMRect.fromRect({ + x: atLeft ? width : 1000 - width - 8, + y: 0, + width: 8, + height: 600, + }); + } + if (this === main) { + return DOMRect.fromRect({ + x: atLeft ? width + 8 : 0, + y: 0, + width: 1000 - width - 8, + height: 600, + }); + } + return DOMRect.fromRect({x: 0, y: 0, width: 0, height: 0}); + }, + ); + const handle = createAnimatedLayout(() => area); + return { + handle, + pane: paneEl, + grip, + /** Collapses or opens the pane, as the controller does, around the report. */ + resize(next: number, edge: LayoutEdge) { + const report = reportLayoutEdge(handle, () => paneEl, {edge}); + report.measureBefore(); + width = next; + report.animate(); + return report; + }, + }; +} + +describe('the edge reveal of a resized element, with no copy (spec:AST-105 FR9)', () => { + const clipsOf = (element: Element) => + played.filter( + entry => entry.element === element && 'clipPath' in entry.keyframes[0], + ); + + it('conceals by holding the element at the extent it was laid out at, freeing the difference by its edge’s margin, and clipping it to its new extent', () => { + const {pane, resize} = paneSetup(); + resize(48, 'inline-end'); + // Held at 260 px while it conceals; the margin frees 212 px, so the + // content beyond it stays where the new size puts it. + expect(pane.style.getPropertyValue('inline-size')).toBe('260px'); + expect(pane.style.getPropertyValue('margin-inline-end')).toBe('-212px'); + const [clip] = clipsOf(pane); + expect(clip.keyframes[0].clipPath).toBe('inset(0px 0px 0px 0px)'); + expect(clip.keyframes.at(-1)?.clipPath).toBe('inset(0px 212px 0px 0px)'); + // It slides: nothing fades. + expect(clip.keyframes.some(frame => 'opacity' in frame)).toBe(false); + // The hold goes when the motion ends. + clip.finish?.(); + expect(pane.style.getPropertyValue('inline-size')).toBe(''); + expect(pane.style.getPropertyValue('margin-inline-end')).toBe(''); + }); + + it('conceals entirely to a size of 0', () => { + const {pane, resize} = paneSetup(); + resize(0, 'inline-end'); + expect(pane.style.getPropertyValue('margin-inline-end')).toBe('-260px'); + expect(clipsOf(pane)[0].keyframes.at(-1)?.clipPath).toBe( + 'inset(0px 260px 0px 0px)', + ); + }); + + it('reveals from the extent it showed, holding nothing', () => { + const {pane, resize} = paneSetup(); + resize(48, 'inline-end'); + clipsOf(pane)[0].finish?.(); + played = []; + setTimeline(61_000); + resize(260, 'inline-end'); + expect(pane.style.getPropertyValue('inline-size')).toBe(''); + const [clip] = clipsOf(pane); + expect(clip.keyframes[0].clipPath).toBe('inset(0px 212px 0px 0px)'); + expect(clip.keyframes.at(-1)?.clipPath).toBe('inset(0px 0px 0px 0px)'); + }); + + it('rides the glide of the content beside it, frame by frame', async () => { + const {pane, grip, resize} = paneSetup(); + resize(48, 'inline-end'); + await flushMicrotasks(); + const glide = lastOn(grip) as Played; + const [clip] = clipsOf(pane); + expect(clip.ridden).toHaveLength(glide.keyframes.length); + expect(clip.duration).toBe(Number(glide.options.duration)); + // Wherever the handle shows, the pane shows up to it. + clip.ridden?.forEach((frame, index) => { + const x = Number( + /^(-?[\d.e+-]+)px/.exec(String(glide.keyframes[index].translate))?.[1], + ); + expect(frame.clipPath).toBe( + `inset(0px ${Math.min(212, Math.max(0, 212 - x))}px 0px 0px)`, + ); + }); + }); + + it('starts a change made while it runs from the extent it shows', async () => { + const {pane, resize} = paneSetup(); + resize(48, 'inline-end'); + const [conceal] = clipsOf(pane); + const index = 6; + const frame = conceal.keyframes[index]; + setTimeline(1000 + Number(frame.offset) * Number(conceal.options.duration)); + const shown = + 260 - Number(/inset\(0px ([\d.]+)px/.exec(String(frame.clipPath))?.[1]); + played = []; + resize(260, 'inline-end'); + // The hold is gone, and the reveal starts where the conceal got to. + expect(pane.style.getPropertyValue('inline-size')).toBe(''); + expect(conceal.cancel).toHaveBeenCalled(); + const [reveal] = clipsOf(pane); + expect(String(reveal.keyframes[0].clipPath)).toBe( + `inset(0px ${260 - shown}px 0px 0px)`, + ); + }); + + it('cancel ends it at once and puts back what it held', () => { + const {pane, resize} = paneSetup(); + const report = resize(48, 'inline-end'); + expect(pane.style.getPropertyValue('inline-size')).toBe('260px'); + report.cancel(); + expect(pane.style.getPropertyValue('inline-size')).toBe(''); + expect(pane.style.getPropertyValue('margin-inline-end')).toBe(''); + expect(clipsOf(pane)[0].cancel).toHaveBeenCalled(); + }); + + it('a pane at the inline end conceals by its inline-start edge, freeing its inline-start margin', () => { + const {pane, resize} = paneSetup({side: 'end'}); + resize(48, 'inline-start'); + expect(pane.style.getPropertyValue('margin-inline-start')).toBe('-212px'); + expect(clipsOf(pane)[0].keyframes.at(-1)?.clipPath).toBe( + 'inset(0px 0px 0px 212px)', + ); + }); + + it('mirrors the clip right to left', () => { + const {pane, resize} = paneSetup({direction: 'rtl'}); + resize(48, 'inline-end'); + expect(clipsOf(pane)[0].keyframes.at(-1)?.clipPath).toBe( + 'inset(0px 0px 0px 212px)', + ); + }); + + it('under Reduce Motion with a coarse pointer a reveal fades in and a conceal lands at once (design:reduced-motion DR2)', () => { + media = { + '(prefers-reduced-motion: reduce)': true, + '(pointer: coarse)': true, + }; + const {pane, resize} = paneSetup(); + resize(48, 'inline-end'); + expect(pane.style.getPropertyValue('inline-size')).toBe(''); + expect(played.filter(entry => entry.element === pane)).toEqual([]); + resize(260, 'inline-end'); + const fades = played.filter(entry => entry.element === pane); + expect(fades).toHaveLength(1); + expect(Object.keys(fades[0].keyframes[0])).toEqual(['opacity']); + }); + + it('under Reduce Motion with a fine pointer every change lands at once (design:reduced-motion DR3)', () => { + media = {'(prefers-reduced-motion: reduce)': true}; + const {pane, resize} = paneSetup(); + resize(48, 'inline-end'); + resize(260, 'inline-end'); + expect(played.filter(entry => entry.element === pane)).toEqual([]); + expect(pane.style.getPropertyValue('inline-size')).toBe(''); + }); +}); diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index 516b6fc516c65..da0a84a01791d 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -8,11 +8,10 @@ * @output Exports `createAnimatedLayout` and the opaque `AnimatedLayout` * handle, `reportLayoutChange`, `playLayoutChange`, `holdLayoutExit` / * `releaseLayoutExit`, the in-place swap (`holdLayoutSwap` / - * `playLayoutSwap`), `playLayoutFrames` and `applyLayoutChangeTransition`: - * the one module - * that produces the - * size-change glide `spec:AST-105` defines - * (`architecture:react-component-runtime` INV12) + * `playLayoutSwap`), the edge reveal's two-state form (`holdLayoutEdge` / + * `playLayoutEdge`), `playLayoutFrames` and `applyLayoutChangeTransition`: + * the one module that produces the size-change glide and the edge reveal + * `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. @@ -45,8 +44,13 @@ * - 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`). A disclosure's - * indicator turns on the same motion (`applyLayoutChangeTransition`). + * it gives up its space and conceals (`holdLayoutExit`). Both are FR9's + * edge reveal: from its block-end edge by default, or from the block-start + * or an inline edge it is given. Once the content on that edge's side + * glides, the edge rides that glide, frame by frame, so the two never part; + * a reversal (a close while it opens) starts from what the element shows. + * A disclosure's indicator turns on the same motion + * (`applyLayoutChangeTransition`). * - A swap holds old content as one transient copy, inert and hidden from * assistive technology, while new content enters in its place, on the same * motion, and removes it when the swap ends (`holdLayoutSwap`, @@ -54,6 +58,11 @@ * the swap's progress and, riding a gliding element, how far it has moved, * so every part keeps to that glide; under Reduce Motion with a coarse * pointer only opacity changes. + * - FR9's two-state form holds an element's old state as the swap's copy + * while its edge rides a gliding element, such as a rail beside its + * content (`holdLayoutEdge`, `playLayoutEdge`): the larger state is + * clipped on that edge, from the extent the element shows, and the caller + * adds only its own fades. * - Frames on elements the page renders can play on the same motion with no * copy (`playLayoutFrames`), such as a surface reaching over a gap a glide * opens, and are released when the motion ends. @@ -1147,15 +1156,322 @@ export function reportLayoutChange( }; } +// --------------------------------------------------------------------------- +// The edge reveal (`spec:AST-105` FR9) +// --------------------------------------------------------------------------- + +/** + * The edge an element reveals from or conceals to (`spec:AST-105` FR9): its + * block-end edge by default, or its block-start, inline-start or inline-end + * edge, such as a panel collapsing beside content. An inline edge follows + * the element's direction. + */ +export type LayoutEdge = + 'block-end' | 'block-start' | 'inline-start' | 'inline-end'; + +/** Whether `element` lays its inline axis out right to left. */ +function isRtl(element: Element): boolean { + return ( + element.ownerDocument.defaultView?.getComputedStyle(element).direction === + 'rtl' + ); +} + +/** + * The `clip-path` that hides `side` of an element from `edge`: `inset()`'s + * sides are physical (top, right, bottom, left), so an inline edge is + * mirrored right to left. `zero` is how the other three are written. + */ +function edgeClip( + edge: LayoutEdge, + side: string, + zero: string, + rtl: boolean, +): string { + const sides = [zero, zero, zero, zero]; + const index = + edge === 'block-start' + ? 0 + : edge === 'block-end' + ? 2 + : (edge === 'inline-end') !== rtl + ? 1 + : 3; + sides[index] = side; + return `inset(${sides.join(' ')})`; +} + +/** A hidden fraction of the element, as a percentage clip on `edge`. */ +function fractionClip(edge: LayoutEdge, hidden: number, rtl: boolean): string { + return edgeClip(edge, `${round(hidden * 100)}%`, '0', rtl); +} + +/** A hidden length of the element, in its own px, as a clip on `edge`. */ +function pxClip(edge: LayoutEdge, hidden: number, rtl: boolean): string { + return edgeClip(edge, `${hidden}px`, '0px', rtl); +} + +/** + * The element's size along `edge`'s axis, in its own px (a scaled ancestor + * divided out), read from the layout the caller already laid out. + */ +function extentOf(element: Element, edge: LayoutEdge): number { + const rect = element.getBoundingClientRect(); + const scale = scaleOf(element, rect); + return edge.startsWith('inline') + ? rect.width / scale.x + : rect.height / scale.y; +} + +/** + * The last element before `element` in the area that takes part in layout: + * its previous sibling, or its nearest ancestor's below the area (the mirror + * of `nextInFlow`, for a start edge). + */ +function previousInFlow(element: Element, area: Element): Element | null { + const view = element.ownerDocument.defaultView; + const lastBox = (from: Element): Element | null => { + const style = view?.getComputedStyle(from); + if (style == null) { + return from; + } + if (style.display === 'contents') { + for ( + let child = from.lastElementChild; + child != null; + child = child.previousElementSibling + ) { + const box = lastBox(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.previousElementSibling; + sibling != null; + sibling = sibling.previousElementSibling + ) { + const box = lastBox(sibling); + if (box != null) { + return box; + } + } + node = node.parentElement; + } + return null; +} + +/** + * The glide that moves `neighbour`: its own, or that of its nearest gliding + * ancestor below the area that does not also hold `element` (one that holds + * both moves the two together, so the edge does not ride it). + */ +function glideMoving( + neighbour: Element, + element: Element, + area: Element, +): Glide | null { + for ( + let node: Element | null = neighbour; + node != null && node !== area && !node.contains(element); + node = node.parentElement + ) { + const glide = glides.get(node); + if (glide != null) { + return glide; + } + } + return null; +} + +/** A one-state reveal or conceal running on an element. */ +interface EdgeMotion { + readonly animation: Animation; + /** The fraction of the element it shows `t` ms in (1: all of it). */ + shownAt: (t: number) => number; +} + +/** Each element's running one-state reveal or conceal. */ +const edgeMotions = new WeakMap(); + +/** + * What a one-state motion an adopter stopped (`releaseLayoutExit`) showed, + * and the timeline time it stopped at: a reveal started in the same frame, + * the reversal that stopped it, starts there. + */ +const stoppedShown = new WeakMap< + Element, + {readonly fraction: number; readonly at: number} +>(); + +function timelineNow(element: Element): number { + return Number(element.ownerDocument.timeline?.currentTime ?? Number.NaN); +} + +/** What `motion` shows now, by its animation's own clock. */ +function shownNow(motion: EdgeMotion): number { + const time = Number(motion.animation.currentTime); + return motion.shownAt(Number.isFinite(time) ? Math.max(0, time) : 0); +} + +/** + * What `element` shows as a reveal or conceal starts: where the one-state + * motion this one reverses had got to (it stops there), or `atRest`, all of + * it for a conceal and nothing for a reveal. + */ +function shownAtStart(element: Element, atRest: number): number { + const running = edgeMotions.get(element); + if (running != null) { + edgeMotions.delete(element); + const fraction = shownNow(running); + running.animation.cancel(); + return fraction; + } + const stopped = stoppedShown.get(element); + stoppedShown.delete(element); + if (stopped != null && stopped.at === timelineNow(element)) { + return stopped.fraction; + } + return atRest; +} + +/** Records `animation` as `element`'s running one-state motion. */ +function trackEdgeMotion( + element: Element, + animation: Animation, + shownAt: (t: number) => number, +): EdgeMotion { + const motion: EdgeMotion = {animation, shownAt}; + edgeMotions.set(element, motion); + animation.addEventListener?.('finish', () => { + if (edgeMotions.get(element) === motion) { + edgeMotions.delete(element); + } + }); + return motion; +} + +/** + * After this commit's glides start (the batch flushes in a microtask queued + * before this one), before the frame paints: once the content on `edge`'s + * side glides, the moving edge rides that glide, frame by frame, so the two + * never part, a reversal included (the glide carries its running motion). + * `hiddenAt` turns that content's offset towards the edge's side into the + * length the element hides; `frame` turns that length into a keyframe. + * Without that glide (nothing on that side in the area, or nothing that + * moves) the motion keeps its own curve. The animation is kept, its frames + * replaced; `onRide` hears the ridden hidden length over time. + */ +function rideNeighbour( + element: Element, + area: Element, + edge: LayoutEdge, + rtl: boolean, + animation: Animation, + isCurrent: () => boolean, + hiddenAt: (towards: number) => number, + frame: (hidden: number) => Keyframe, + onRide: (hidden: (t: number) => number) => void, +): void { + const effect = animation.effect as KeyframeEffect | null; + if (effect == null || typeof effect.setKeyframes !== 'function') { + return; + } + queueMicrotask(() => { + if (!isCurrent()) { + return; + } + const isEnd = edge.endsWith('end'); + const neighbour = isEnd + ? nextInFlow(element, area) + : previousInFlow(element, area); + const glide = + neighbour == null ? null : glideMoving(neighbour, element, area); + if (neighbour == null || glide == null) { + return; + } + // A glide that moves the element itself in the same batch, short of what + // it shares with the neighbour (an item above them closing): the edge + // rides the neighbour's offset from the element, not from the page. + const own = glideMoving(element, neighbour, area); + const durationMs = Math.max(glide.durationMs, own?.durationMs ?? 0); + // The neighbour's offset from its place relative to the element's, + // towards the edge's side. + const sign = isEnd ? 1 : -1; + const hidden = (t: number): number => { + const offset = glide.offsetAt(t); + const moved = own?.offsetAt(t) ?? {x: 0, y: 0}; + return hiddenAt( + sign * + (edge.startsWith('inline') + ? (rtl ? -1 : 1) * (offset.x - moved.x) + : offset.y - moved.y), + ); + }; + effect.setKeyframes(sampleOver(durationMs, t => frame(hidden(t)))); + effect.updateTiming?.({duration: durationMs}); + onRide(hidden); + }); +} + +/** + * The one-state reveal or conceal rides the content on its edge's side: the + * element shows what lies between its fixed edge and that content, a reveal + * less by however far the content still is from its place towards it, a + * conceal all of it less however far the content has come, fading with what + * it hides. + */ +function rideEdge( + element: Element, + area: Element, + edge: LayoutEdge, + rtl: boolean, + motion: EdgeMotion, + isConceal: boolean, +): void { + const extent = extentOf(element, edge); + if (!(extent > 0)) { + return; + } + rideNeighbour( + element, + area, + edge, + rtl, + motion.animation, + () => edgeMotions.get(element) === motion, + towards => + Math.min(extent, Math.max(0, isConceal ? extent - towards : -towards)), + hidden => ({ + opacity: 1 - hidden / extent, + clipPath: pxClip(edge, hidden, rtl), + }), + hiddenAt => { + motion.shownAt = t => 1 - hiddenAt(t) / extent; + }, + ); +} + /** * 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. - * Returns the entrance it started, or null when it played none. + * after it (`spec:AST-105` FR4): content that appears reveals from its + * block-end edge downward, or from the `edge` it is given (FR9), and its edge + * rides the glide of the content on that side once it starts. A reveal that + * reverses a running conceal (an adopter opens it again mid-exit, after + * `releaseLayoutExit`) starts from what the element shows. Plays only with a + * handle, inside its area and in the area's visible part. Returns the + * entrance it started, or null when it played none. */ export function playLayoutChange( handle: AnimatedLayout | null | undefined, element: Element, + {edge = 'block-end'}: {edge?: LayoutEdge} = {}, ): Animation | null { if (handle == null) { return null; @@ -1171,26 +1487,40 @@ export function playLayoutChange( if (motion.kind === 'instant') { return null; } + const from = shownAtStart(element, 0); if (motion.kind === 'fade') { - return safeAnimate(element, [{opacity: 0}, {opacity: 1}], { + return safeAnimate(element, [{opacity: from}, {opacity: 1}], { duration: motion.durationMs, easing: motion.easing, }); } - return safeAnimate( + const {progress} = motion; + const rtl = edge.startsWith('inline') && isRtl(element); + const animation = safeAnimate( element, - sampleKeyframes(motion.progress, remaining => ({ - opacity: 1 - remaining, - clipPath: `inset(0 0 ${round(remaining * 100)}% 0)`, - })), - {duration: motion.progress.durationMs, easing: 'linear'}, + sampleKeyframes(progress, remaining => { + const hidden = (1 - from) * remaining; + return {opacity: 1 - hidden, clipPath: fractionClip(edge, hidden, rtl)}; + }), + {duration: progress.durationMs, easing: 'linear'}, ); + if (animation != null) { + const tracked = trackEdgeMotion( + element, + animation, + t => 1 - (1 - from) * progress.remaining(t), + ); + rideEdge(element, area, edge, rtl, tracked, false); + } + return animation; } /** The inline properties an exit sets, so a release removes exactly them. */ const EXIT_PROPERTIES = [ 'margin-block-end', 'margin-inline-end', + 'margin-block-start', + 'margin-inline-start', 'pointer-events', ] as const; @@ -1259,7 +1589,11 @@ function placeOf( * 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 { +function giveUpSpace( + element: HTMLElement, + area: Element, + edge: LayoutEdge = 'block-end', +): boolean { const view = element.ownerDocument.defaultView; const parent = element.parentElement == null @@ -1270,24 +1604,43 @@ function giveUpSpace(element: HTMLElement, area: Element): boolean { 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; + // The default block-end exit frees its space at its end, on the axis its + // container lays it out on; an exit by another edge frees it on that + // edge's side, so the element stays where it shows and the content beyond + // that edge moves (FR9). + const isStart = edge.endsWith('start'); + const property = + edge === 'block-end' + ? isRow + ? 'margin-inline-end' + : 'margin-block-end' + : `margin-${edge}`; + const isInline = property.includes('inline'); + const size = isInline ? element.offsetWidth : element.offsetHeight; const next = nextInFlow(element, area); - if (next == null) { + if (next == null && !isStart) { // 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'; + const axis = isInline ? '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]; + // By a start edge, what lies before it lands too, and the element itself + // stays where it shows. + const before = isStart ? previousInFlow(element, area) : null; + const checked = [next, afterParent, before, isStart ? element : null].filter( + (box): box is Element => box != null, + ); + if (checked.length === 0) { + return false; + } // The element's scale, read in the layout the caller's visibility check // already laid out. const scale = scaleOf(element, element.getBoundingClientRect()); @@ -1296,8 +1649,11 @@ function giveUpSpace(element: HTMLElement, area: Element): boolean { // inside it). const position = element.style.getPropertyValue('position'); const positionPriority = element.style.getPropertyPriority('position'); + const shown = isStart ? placeOf(element, rtl) : null; element.style.setProperty('position', 'absolute', 'important'); - const targets = checked.map(box => placeOf(box, rtl)); + const targets = checked.map(box => + box === element && shown != null ? shown : placeOf(box, rtl), + ); const target = targets[0]; if (position === '') { element.style.removeProperty('position'); @@ -1310,7 +1666,7 @@ function giveUpSpace(element: HTMLElement, area: Element): boolean { // 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]; + const guess = placeOf(checked[0], rtl)[axis]; const correction = (target[axis] - guess) / perPx; element.style.setProperty(property, `${-size + correction}px`); // Everything checked must land on both axes: a flex-row item that sets @@ -1355,6 +1711,11 @@ const replaced = new WeakMap(); * 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. * + * It conceals to its block-end edge by default, or to the `edge` it is given + * (`spec:AST-105` FR9), and its edge rides the glide of the content on that + * side once it starts; a conceal that reverses a running reveal (a close + * while it opens) starts from what the element shows. + * * 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 @@ -1366,6 +1727,7 @@ export function holdLayoutExit( handle: AnimatedLayout | null | undefined, element: HTMLElement, done: () => void, + {edge = 'block-end'}: {edge?: LayoutEdge} = {}, ): boolean { if (handle == null) { return false; @@ -1403,7 +1765,7 @@ export function holdLayoutExit( ] as const, ), }; - if (!giveUpSpace(element, area)) { + if (!giveUpSpace(element, area, edge)) { // 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 @@ -1419,25 +1781,36 @@ export function holdLayoutExit( // 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', - }, - ); + // A close while it opens: the reveal stops where it shows, and this starts + // there. + const from = shownAtStart(element, 1); + const rtl = edge.startsWith('inline') && isRtl(element); + let animation: Animation | null; + if (motion.kind === 'fade') { + animation = safeAnimate(element, [{opacity: from}, {opacity: 0}], { + duration: motion.durationMs, + easing: motion.easing, + fill: 'forwards', + }); + } else { + const {progress} = motion; + animation = safeAnimate( + element, + sampleKeyframes(progress, remaining => { + const shown = from * remaining; + return {opacity: shown, clipPath: fractionClip(edge, 1 - shown, rtl)}; + }), + {duration: progress.durationMs, easing: 'linear', fill: 'forwards'}, + ); + if (animation != null) { + const tracked = trackEdgeMotion( + element, + animation, + t => from * progress.remaining(t), + ); + rideEdge(element, area, edge, rtl, tracked, true); + } + } exits.set(element, animation); if (animation == null) { // No Web Animations: nothing to wait for. @@ -1462,6 +1835,16 @@ export function releaseLayoutExit(element: HTMLElement): void { if (!exits.has(element)) { return; } + // What the exit showed as it stopped: a reveal in the same frame (opened + // again mid-exit) starts there. + const running = edgeMotions.get(element); + if (running != null) { + edgeMotions.delete(element); + stoppedShown.set(element, { + fraction: shownNow(running), + at: timelineNow(element), + }); + } exits.get(element)?.cancel(); exits.delete(element); const prior = replaced.get(element); @@ -1743,6 +2126,555 @@ export function playLayoutFrames( return end; } +// --------------------------------------------------------------------------- +// The edge reveal's two-state form (`spec:AST-105` FR9) +// --------------------------------------------------------------------------- + +/** + * An element's old state, held before a change commits for FR9's two-state + * edge reveal. + */ +export interface LayoutEdgeHold { + readonly element: HTMLElement; + /** Where it showed, in viewport coordinates. */ + readonly rect: DOMRect; + /** + * Its size along the edge's axis as it shows: what a running reveal shows + * mid-motion, or its box. + */ + readonly extent: number; + /** Its old state as one transient copy, or null when it cannot be held. */ + readonly swap: LayoutSwap | null; +} + +/** A playing two-state edge reveal. */ +export interface LayoutEdgeMotion { + /** Ends it at once (a new change, an unmount). */ + end(): void; + /** + * What it shows now: its extent along the edge's axis and the motion's + * progress (0 → 1), or null once it has settled. + */ + shown(): {readonly extent: number; readonly progress: number} | null; +} + +/** What the caller adds to a two-state edge reveal: only its own fades. */ +export interface LayoutEdgeParts { + /** The old state's copy's opacity over the progress (1 when absent). */ + readonly copyOpacity?: (p: number) => number; + /** Its text's opacity, on its own curve, when that differs from the copy's. */ + readonly copyText?: (p: number) => number; + /** Parts the page renders, playing with the reveal (the new state's text). */ + readonly incoming?: ReadonlyArray<{ + readonly element: Element; + readonly frames: (p: number) => Keyframe; + }>; +} + +const SETTLED: LayoutEdgeMotion = {end: () => {}, shown: () => null}; + +/** + * Before a change commits: hold `element`'s old state for FR9's two-state edge + * reveal, its box and the extent it shows along `edge`'s axis (`shown`, from a + * running reveal, or its box), and its old state as one transient copy (null + * when it cannot be copied, or under an immediate change). Returns null + * without the element or without Web Animations. + */ +export function holdLayoutEdge( + handle: AnimatedLayout | null | undefined, + element: HTMLElement | null, + { + edge = 'block-end', + shown = null, + }: {edge?: LayoutEdge; shown?: number | null} = {}, +): LayoutEdgeHold | null { + if (element == null || typeof element.animate !== 'function') { + return null; + } + const rect = element.getBoundingClientRect(); + return { + element, + rect, + extent: shown ?? (edge.startsWith('inline') ? rect.width : rect.height), + swap: holdLayoutSwap(handle, element), + }; +} + +/** + * After the change commits, before paint: FR9's two-state edge reveal, on + * FR4's one motion. The state that is larger along `edge`'s axis is clipped + * on that edge, from the extent the element showed to the new one: a conceal + * covers the old state, held as the swap's copy, with the element raised + * above it; a reveal opens the element, under the old state. With + * `rideWith`, an element the glide moves after that edge (a rail's content + * column), the edge rides its glide, frame by frame, so the two never part, + * also when this reverses a running reveal; without it the edge follows the + * motion's curve. `parts` gives the caller's own fades, told whether the edge + * conceals and the element as it is now. Under Reduce Motion it follows + * `design:reduced-motion`: opacity only under a coarse primary pointer (DR2), + * an immediate change otherwise (DR3). `current` is the element after the + * commit, when it remounted. + */ +export function playLayoutEdge( + hold: LayoutEdgeHold, + current: HTMLElement | null, + handle: AnimatedLayout, + { + edge = 'block-end', + rideWith = null, + parts, + }: { + edge?: LayoutEdge; + rideWith?: Element | null; + parts?: (isConceal: boolean, element: HTMLElement) => LayoutEdgeParts; + } = {}, +): LayoutEdgeMotion { + const {rect: before, swap, extent: from} = hold; + const element = current?.isConnected === true ? current : hold.element; + if (!element.isConnected) { + return SETTLED; + } + const motion = layoutMotion(element); + const next = element.getBoundingClientRect(); + const isInline = edge.startsWith('inline'); + const extentOfBox = (box: DOMRect) => (isInline ? box.width : box.height); + const to = extentOfBox(next); + if (motion.kind === 'instant' || Math.abs(to - from) < 0.5) { + return SETTLED; + } + const isConceal = to < from; + const isEnd = edge.endsWith('end'); + const rtl = isInline && isRtl(element); + // The row that holds the element and the ridden element, or its parent. + const host = + rideWith?.parentElement != null && rideWith.parentElement.contains(element) + ? rideWith.parentElement + : element.parentElement; + // Riding needs the swap: its frames carry the ridden element's travel. + // Without one (old state that cannot be copied) the edge follows the + // motion's curve. + const rides = + host != null && + rideWith != null && + rideWith.parentElement === host && + swap != null; + // The ridden element's travel so far, towards the edge's side. + const sign = isEnd ? 1 : -1; + const towards = (ride: LayoutRide) => + sign * (isInline ? (rtl ? -ride.x : ride.x) : ride.y); + const extentAt = (p: number, ride: LayoutRide) => + rides ? from + towards(ride) : from + (to - from) * p; + const clip = (hidden: number) => pxClip(edge, hidden, rtl); + const now = () => Number(element.ownerDocument.timeline?.currentTime ?? 0); + const startedAt = now(); + const durationMs = + motion.kind === 'spring' ? motion.progress.durationMs : motion.durationMs; + const shown = (): {extent: number; progress: number} | null => { + const elapsed = now() - startedAt; + if (elapsed >= durationMs) { + return null; + } + const progress = + motion.kind === 'spring' ? 1 - motion.progress.remaining(elapsed) : 0; + let extent = from + (to - from) * progress; + if (rides && rideWith != null) { + // The edge is the ridden element's: read it where it shows. + const ridden = rideWith.getBoundingClientRect(); + const own = element.getBoundingClientRect(); + extent = !isInline + ? isEnd + ? ridden.top - own.top + : own.bottom - ridden.bottom + : isEnd !== rtl + ? ridden.left - own.left + : own.right - ridden.right; + } + return {extent, progress}; + }; + // The ride, when the ridden element does not glide: its whole travel at + // once, physical (its old place less its new). + const travel = sign * (from - to); + const rideSpec = + rides && rideWith != null + ? { + element: rideWith, + travel: isInline + ? {x: rtl ? -travel : travel, y: 0} + : {x: 0, y: travel}, + isCopyMoved: false, + } + : undefined; + // The copy keeps the element's fixed edge (the one opposite `edge`) where + // the element is now, so it meets the element wherever it sits. + const fixedAtStart = isEnd !== (isInline && rtl); + const at = new DOMRect( + isInline && !fixedAtStart ? next.right - before.width : next.left, + !isInline && !isEnd ? next.bottom - before.height : next.top, + before.width, + before.height, + ); + const own = parts?.(isConceal, element) ?? {}; + const copyOpacity = own.copyOpacity ?? (() => 1); + + if (isConceal) { + // A conceal covers the old state, held by the swap, from the extent it + // shows down to the new one; the element, raised above it, shows through. + if (swap == null || host == null) { + return SETTLED; + } + let raised: HTMLElement | null = element; + while (raised != null && raised.parentElement !== host) { + raised = raised.parentElement; + } + const restore = + raised == null + ? null + : {position: raised.style.position, zIndex: raised.style.zIndex}; + if (raised != null) { + if (getComputedStyle(raised).position === 'static') { + raised.style.position = 'relative'; + } + raised.style.zIndex = '2'; + } + const end = playLayoutSwap( + {...swap, rect: at}, + { + host, + isOpaque: true, + copy: (p, ride) => ({ + clipPath: clip(extentOfBox(before) - extentAt(p, ride)), + opacity: copyOpacity(p), + }), + copyText: own.copyText, + rideWith: rideSpec, + onEnd: () => { + if (raised != null && restore != null) { + raised.style.position = restore.position; + raised.style.zIndex = restore.zIndex; + } + }, + incoming: own.incoming ?? [], + }, + ); + return {end, shown}; + } + + // A reveal opens the element from the extent it showed, under the old + // state held by the same swap. + const incoming = [ + { + element, + frames: (p: number, ride: LayoutRide) => ({ + clipPath: clip(to - extentAt(p, ride)), + }), + }, + ...(own.incoming ?? []), + ]; + const end = + swap != null && host != null + ? playLayoutSwap( + {...swap, rect: at}, + { + host, + isOpaque: true, + copy: p => ({opacity: copyOpacity(p)}), + copyText: own.copyText, + rideWith: rideSpec, + incoming, + }, + ) + : playLayoutFrames(handle, incoming); + return {end, shown}; +} + +// --------------------------------------------------------------------------- +// The edge reveal of a resized element (`spec:AST-105` FR9, no copy) +// --------------------------------------------------------------------------- + +/** A resized element's edge reveal, reported around its change. */ +export interface LayoutEdgeReport { + /** Before the change commits: measures the element and what moves. */ + measureBefore(): void; + /** + * After the new size is applied, before paint: the element reveals or + * conceals by its edge, from the extent it showed to its new one, and the + * content beside it glides on the same motion. + */ + animate(): void; + /** Ends a running reveal or conceal at once, removing what it holds. */ + cancel(): void; +} + +/** A running edge reveal of a resized element. */ +interface ResizeReveal { + readonly animation: Animation | null; + /** The extent it shows `t` ms in, in its own px. */ + shownAt: (t: number) => number; + /** Its extent along the edge's axis as laid out, held or not. */ + readonly box: number; + /** Ends it, putting back what it replaced. */ + release(): void; +} + +const resizeReveals = new WeakMap(); + +/** The inline properties a size hold sets, by axis. */ +const HOLD_PROPERTIES = { + inline: [ + 'inline-size', + 'min-inline-size', + 'max-inline-size', + 'flex-basis', + 'flex-grow', + 'flex-shrink', + ], + block: [ + 'block-size', + 'min-block-size', + 'max-block-size', + 'flex-basis', + 'flex-grow', + 'flex-shrink', + ], +} as const; + +/** + * Holds `element` at `extent` own px along `edge`'s axis while it conceals to + * `keep`, freeing the difference by the margin on that edge's side, so what + * lies beyond the edge stays where the new size puts it. Returns what puts + * back every value it set, or null when no margin frees it (the content on + * that side did not stay put), having set nothing. + */ +function holdExtent( + element: HTMLElement, + area: Element, + edge: LayoutEdge, + extent: number, + keep: number, +): (() => void) | null { + const axis = edge.startsWith('inline') ? 'inline' : 'block'; + const margin = `margin-${edge}`; + const properties = [...HOLD_PROPERTIES[axis], margin]; + const prior = properties.map( + property => + [ + property, + element.style.getPropertyValue(property), + element.style.getPropertyPriority(property), + ] as const, + ); + const restore = () => { + for (const [property, value, priority] of prior) { + if (value === '') { + element.style.removeProperty(property); + } else { + element.style.setProperty(property, value, priority); + } + } + }; + const isEnd = edge.endsWith('end'); + const rtl = axis === 'inline' && isRtl(element); + const beyond = isEnd + ? nextInFlow(element, area) + : previousInFlow(element, area); + const before = beyond == null ? null : placeOf(beyond, rtl); + const size = `${extent}px`; + element.style.setProperty(`${axis}-size`, size, 'important'); + element.style.setProperty(`min-${axis}-size`, size, 'important'); + element.style.setProperty(`max-${axis}-size`, size, 'important'); + element.style.setProperty('flex-basis', 'auto', 'important'); + element.style.setProperty('flex-grow', '0', 'important'); + element.style.setProperty('flex-shrink', '0', 'important'); + element.style.setProperty(margin, `${keep - extent}px`, 'important'); + if (beyond != null && before != null) { + // One correction from the measured result (a gap, the element's own + // margin), then everything beyond the edge must be where it was. + const landed = placeOf(beyond, rtl); + const scale = scaleOf(element, element.getBoundingClientRect()); + const perPx = axis === 'inline' ? scale.x : scale.y; + const sign = isEnd ? 1 : -1; + const drift = (landed[axis] - before[axis]) * sign; + if (Math.abs(drift) > 0.5) { + element.style.setProperty( + margin, + `${keep - extent - drift / perPx}px`, + 'important', + ); + } + const settled = placeOf(beyond, rtl); + if ( + Math.abs(settled.block - before.block) > 1 || + Math.abs(settled.inline - before.inline) > 1 + ) { + restore(); + return null; + } + } + return restore; +} + +/** + * Reports a resized element's edge reveal (`spec:AST-105` FR9) with no copy: + * a resize controller's pane collapsing or opening beside the content it + * shares a row with (`component:Resizable` FR8, `component:LayoutPanel` + * FR7). Measure before the change commits and animate after the + * new size is applied, as `reportLayoutChange` does; the content beside the + * element glides through that report. A larger extent reveals by `edge` from + * the extent the element showed. A smaller one conceals: the element is held + * at the extent it was laid out at, the difference freed by the margin on + * that edge's side so the content beyond it stays where the new size puts it, + * and clipped down to its new extent; the hold goes when the motion ends. The + * moving edge rides the glide of the content on that side, frame by frame, + * and a change made while it runs starts from the extent it shows. It slides: + * nothing fades. Under Reduce Motion with a coarse primary pointer a reveal + * fades in and a conceal lands at once (`design:reduced-motion` DR2); with a + * fine one every change lands at once (DR3). + */ +export function reportLayoutEdge( + handle: AnimatedLayout | null | undefined, + getElement: () => HTMLElement | null, + {edge = 'block-end'}: {edge?: LayoutEdge} = {}, +): LayoutEdgeReport { + const report = reportLayoutChange(handle, getElement); + /** + * What the element showed, its laid-out extent and its box, before the + * change. + */ + let measured: {shown: number; box: number; rect: DOMRect} | null = null; + const release = (element: HTMLElement | null) => { + if (element == null) { + return; + } + resizeReveals.get(element)?.release(); + }; + return { + measureBefore() { + const element = getElement(); + measured = null; + if (element != null) { + const running = resizeReveals.get(element); + const elapsed = Number(running?.animation?.currentTime); + const rect = element.getBoundingClientRect(); + measured = + running == null + ? { + shown: extentOf(element, edge), + box: extentOf(element, edge), + rect, + } + : { + shown: running.shownAt(Number.isFinite(elapsed) ? elapsed : 0), + box: running.box, + rect, + }; + running?.release(); + } + report.measureBefore(); + }, + animate() { + report.animate(); + const element = getElement(); + const before = measured; + measured = null; + if (handle == null || element == null || before == null) { + return; + } + const area = areaOf(handle, element); + if (area == null) { + return; + } + const after = extentOf(element, edge); + if (Math.abs(after - before.shown) < 0.5) { + return; + } + // In the area's visible part before or after: a pane collapsing to + // nothing has no box after. + const visible = visibleRect(area); + if ( + !intersects(element.getBoundingClientRect(), visible) && + !intersects(before.rect, visible) + ) { + return; + } + const motion = layoutMotion(area); + if (motion.kind === 'instant') { + return; + } + const isConceal = after < before.shown; + if (motion.kind === 'fade') { + if (!isConceal) { + safeAnimate(element, [{opacity: 0}, {opacity: 1}], { + duration: motion.durationMs, + easing: motion.easing, + }); + } + return; + } + const {progress} = motion; + const rtl = edge.startsWith('inline') && isRtl(element); + // The box the clip works in: the new one for a reveal; for a conceal, + // the one it was laid out at, held while it runs. + const box = isConceal ? Math.max(before.box, before.shown) : after; + const restore = isConceal + ? holdExtent(element, area, edge, box, after) + : () => {}; + if (restore == null) { + return; + } + // Hidden at the start and at the end, in the box's own px. + const from = box - before.shown; + const to = box - after; + const curve = (t: number) => to + (from - to) * progress.remaining(t); + const animation = safeAnimate( + element, + sampleOver(progress.durationMs, t => ({ + clipPath: pxClip(edge, curve(t), rtl), + })), + {duration: progress.durationMs, easing: 'linear'}, + ); + const reveal: ResizeReveal = { + animation, + shownAt: t => box - curve(t), + box, + release: () => { + if (resizeReveals.get(element) !== reveal) { + return; + } + resizeReveals.delete(element); + animation?.cancel(); + restore(); + }, + }; + if (animation == null) { + restore(); + return; + } + resizeReveals.set(element, reveal); + animation.addEventListener?.('finish', reveal.release); + rideNeighbour( + element, + area, + edge, + rtl, + animation, + () => resizeReveals.get(element) === reveal, + // Anywhere in its box: the content beside it may carry a running + // motion past where this one starts or ends, and the edge follows. + towards => + Math.min(box, Math.max(0, isConceal ? to - towards : -towards)), + hidden => ({clipPath: pxClip(edge, hidden, rtl)}), + hiddenAt => { + reveal.shownAt = t => box - hiddenAt(t); + }, + ); + }, + cancel() { + measured = null; + release(getElement()); + }, + }; +} + /** * A disclosure's indicator (its chevron) turns on the same motion as the * glide: its CSS transition takes the spring as a `linear()` easing, and an