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,
+ ``,
+ '#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 = `
+ `;
+ 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