From f20b09612dc555d68924d28f70219d6a49fc20b2 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 31 Oct 2026 23:21:25 -0700 Subject: [PATCH] feat(core): one shared edge reveal in the animated-layout module, which the SideNav rail moves onto spec:AST-105 FR9's edge reveal, the one edge reveal in Core: - The one-state form (playLayoutChange, holdLayoutExit) reveals and conceals from the block-end edge by default, or from the block-start or an inline edge it is given, right to left mirrored. Once the content on that edge's side glides, the moving edge rides that glide frame by frame, so no gap opens, and a reversal (a close while it opens, an open while it closes) starts from what the element shows. - The two-state form (holdLayoutEdge, playLayoutEdge) holds the old state as the swap's copy, clips the larger state on the edge from the extent it shows, rides a gliding element, and raises the element above the copy while it conceals; the caller adds only its own fades. Reduce Motion follows design:reduced-motion DR2 and DR3. - A resized element with no copy (reportLayoutEdge, a resize controller's pane): measured before its size change commits and played after the new size applies; a smaller extent is held at the extent it was laid out at, its edge's margin freeing the difference, and clipped to the new one. - A conceal by a start edge frees the element's space on that edge's side, so the element stays where it shows while the content before it moves; a layout where that would move the element itself is not held. - The edge rides the content beside it relative to any glide that moves the element itself in the same batch, such as an item above them closing in a single-open CollapsibleGroup; the AnimatedLayout story gains one. - The SideNav rail plays through the two-state form on its inline-end edge and keeps only its labels' and icons' fades. Collapsible and TreeList keep their block-end form and gain the ride and the reversal. Nothing public changes: the new functions stay out of every entry point. --- .changeset/edge-reveal-reversal.md | 7 + .../stories/AnimatedLayout.stories.tsx | 34 +- .../src/SideNav/SideNavRailMotion.test.tsx | 25 + packages/core/src/SideNav/railMotion.ts | 252 ++-- ...animatedLayout.interaction.browser.spec.ts | 667 ++++++++++- ...LayoutAdopters.interaction.browser.spec.ts | 362 +++++- .../motion/__tests__/animatedLayoutBundle.ts | 3 + .../motion/animatedLayout.boundary.test.ts | 10 +- .../src/motion/animatedLayout.edge.test.ts | 788 +++++++++++++ packages/core/src/motion/animatedLayout.ts | 1022 ++++++++++++++++- 10 files changed, 2940 insertions(+), 230 deletions(-) create mode 100644 .changeset/edge-reveal-reversal.md create mode 100644 packages/core/src/motion/animatedLayout.edge.test.ts 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