From 5b0abfba644a398962e129e1491d82f027ee428b Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 20:09:23 -0700 Subject: [PATCH 01/48] feat(core): useAnimatedLayout, one module that glides the layout shift around a size change When something inside a registered area changes size, the content after it glides from its old place to its new one with a compositor-only translate instead of jumping (architecture:react-component-runtime INV12, proposed in #8463). - motion/animatedLayout.ts: one batched measurement before and after the change; a bounded parentElement/nextElementSibling walk up to the area (no TreeWalker), descending only through display: contents; absolute and fixed elements skipped; only elements whose rectangle changed and that are visible move; scale-corrected; Web Animations translate with composite: 'add', feature-tested per call with fallbacks; offscreen elements land silently; Reduce Motion cross-fades with no travel; every pointer. - useAnimatedLayout(ref) (public) registers an area; reportLayoutChange stays internal. ScrollableArea registers its viewport itself. - Collapsible and TreeList adopt it: the shown content plays its own entrance, and the chevron turns, on the same placeholder layout-change role as the glide. - Storybook: Core/Animated layout, with a nested tree, a Collapsible, a field error, load more, wrapping tags, plain divs, a display: contents wrapper, a sticky header and an absolute badge. Draft for trying it out; tests, docs and a changeset follow. --- .../stories/AnimatedLayout.stories.tsx | 338 ++++++++++++++ packages/core/src/Collapsible/Collapsible.tsx | 34 +- .../src/ScrollableArea/ScrollableArea.tsx | 12 +- packages/core/src/TreeList/TreeList.tsx | 50 ++- packages/core/src/TreeList/TreeListItem.tsx | 4 +- packages/core/src/hooks/index.ts | 2 + packages/core/src/hooks/useAnimatedLayout.ts | 46 ++ packages/core/src/motion/animatedLayout.ts | 411 ++++++++++++++++++ 8 files changed, 891 insertions(+), 6 deletions(-) create mode 100644 apps/storybook/stories/AnimatedLayout.stories.tsx create mode 100644 packages/core/src/hooks/useAnimatedLayout.ts create mode 100644 packages/core/src/motion/animatedLayout.ts diff --git a/apps/storybook/stories/AnimatedLayout.stories.tsx b/apps/storybook/stories/AnimatedLayout.stories.tsx new file mode 100644 index 0000000000000..304c7b0630181 --- /dev/null +++ b/apps/storybook/stories/AnimatedLayout.stories.tsx @@ -0,0 +1,338 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import { + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; +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 {TextInput} from '@astryxdesign/core/TextInput'; +import {Button} from '@astryxdesign/core/Button'; +import {useAnimatedLayout} from '@astryxdesign/core/hooks'; +// The internal reporting half of the module: a plain app element whose size +// changes reports it here, as Astryx components do internally. +import { + playLayoutChange, + reportLayoutChange, +} from '../../../packages/core/src/motion/animatedLayout'; + +const meta: Meta = { + title: 'Core/Animated layout', + parameters: {layout: 'padded'}, +}; + +export default meta; + +type Story = StoryObj; + +const card = { + padding: 12, + margin: '0 0 8px', + borderRadius: 12, + border: '1px solid var(--color-border)', + background: 'var(--color-background-surface)', + color: 'var(--color-text-primary)', + fontFamily: 'var(--font-family-body)', + fontSize: 14, +} as const; + +const plain = { + ...card, + borderStyle: 'dashed', + background: 'var(--color-background-muted)', +} as const; + +const label = { + margin: '0 0 6px', + fontSize: 12, + fontWeight: 600, + color: 'var(--color-text-secondary)', + fontFamily: 'var(--font-family-body)', +} as const; + +/** + * A plain element's size change, reported to the nearest area: `before()` + * runs in the handler that causes the change; the move plays when `dep` + * commits, and the changed element plays its own entrance. + */ +function useReportedChange(dep: unknown) { + const ref = useRef(null); + const change = useMemo(() => reportLayoutChange(ref), []); + const first = useRef(true); + useLayoutEffect(() => { + if (first.current) { + first.current = false; + return; + } + change.animate(); + if (ref.current != null) { + playLayoutChange(ref.current, 'enter'); + } + }, [dep, change]); + return {ref, before: () => change.measureBefore()}; +} + +const TREE: TreeListItemData[] = [ + { + id: 'photos', + label: 'Photos', + children: [ + {id: 'beach', label: 'Beach.jpg'}, + { + id: 'trips', + label: 'Trips', + children: [ + {id: 'snow', label: 'Snow.jpg'}, + {id: 'lake', label: 'Lake.jpg'}, + ], + }, + {id: 'party', label: 'Party.jpg'}, + ], + }, + { + id: 'docs', + label: 'Documents', + children: [ + {id: 'cv', label: 'Resume.pdf'}, + {id: 'tax', label: 'Taxes.pdf'}, + ], + }, + {id: 'notes', label: 'Notes.txt'}, +]; + +function FieldError() { + const [email, setEmail] = useState('not-an-email'); + const [error, setError] = useState(null); + const {ref, before} = useReportedChange(error); + return ( +
+

A field error on Validate

+ +
+ {error != null && ( +

+ {error} +

+ )} +
+
+
+
+ ); +} + +function LoadMore() { + const [loaded, setLoaded] = useState(false); + const {ref, before} = useReportedChange(loaded); + return ( +
+

Load more: a skeleton replaced by taller content

+
+ {loaded ? ( +
+ Alex Rivera + Product designer · Seattle + Joined in 2021 · 48 projects + Prefers async reviews; pairs on Thursdays. +
+ ) : ( +
+ )} +
+
+
+
+ ); +} + +function Tags() { + const [tags, setTags] = useState(['design', 'motion', 'layout']); + const {ref, before} = useReportedChange(tags.length); + return ( +
+

“+ Add tag”: chips that wrap to a new line

+
+ {tags.map(tag => ( + + {tag} + + ))} +
+
+
+
+ ); +} + +function Section({title, children}: {title: string; children: ReactNode}) { + return ( +
+

{title}

+ {children} +
+ ); +} + +/** + * Everything inside the ScrollableArea is one area: a size change anywhere + * in it glides the content after the change. The sticky header and the + * absolute badge stay put; the `display: contents` wrapper's children move + * on their own. + */ +export const AnimatedLayout: Story = { + name: 'Animated layout', + render: () => ( +
+ +
+ Sticky header (stays put) +
+
+
+
+ +
+
+
+
+ +

Ships in 2 business days.

+

Free returns for 30 days.

+

Tracking arrives by email.

+
+
+
+ + + +
+ A plain app div with an absolute badge (the badge rides with it) + + New + +
+
+
Inside a display: contents wrapper (1)
+
Inside a display: contents wrapper (2)
+
+ {Array.from({length: 8}, (_, i) => ( +
+ Plain app content after the changes ({i + 1}) +
+ ))} +
+
+
+ ), +}; + +function PanelDemo() { + const panel = useRef(null); + useAnimatedLayout(panel); + return ( +
+
+ +

Cache size, retries and timeouts.

+

Changes apply on the next sync.

+
+
+
Content after, in an app panel
+
More content
+
+ ); +} + +/** An app's own container opts in with `useAnimatedLayout(ref)`. */ +export const AppPanel: Story = { + name: 'App panel (useAnimatedLayout)', + render: () => , +}; diff --git a/packages/core/src/Collapsible/Collapsible.tsx b/packages/core/src/Collapsible/Collapsible.tsx index db4ba0d6eb55c..d146ff90fc515 100644 --- a/packages/core/src/Collapsible/Collapsible.tsx +++ b/packages/core/src/Collapsible/Collapsible.tsx @@ -27,7 +27,14 @@ * - /packages/cli/assets/templates/blocks/components/Collapsible/ (showcase blocks) */ -import {use, useId, type ReactNode} from 'react'; +import { + use, + useId, + useLayoutEffect, + useMemo, + useRef, + type ReactNode, +} from 'react'; import * as stylex from '@stylexjs/stylex'; import { borderVars, @@ -41,6 +48,7 @@ import { } from '../theme/tokens.stylex'; import {useCollapsible} from './useCollapsible'; +import {playLayoutChange, reportLayoutChange} from '../motion/animatedLayout'; import {CollapsibleGroupPresentationContext} from './CollapsibleGroupContext'; import type {CollapsibleChevronPosition} from './CollapsibleGroupContext'; import {Icon} from '../Icon'; @@ -116,7 +124,9 @@ const styles = stylex.create({ height: typeScaleVars['--text-large-size'], fontSize: typeScaleVars['--text-large-size'], transitionProperty: 'transform', - transitionDuration: durationVars['--duration-fast'], + // The layout-change role (a placeholder until the theme names it): the + // chevron turns on the same timing as the content's glide (INV12). + transitionDuration: durationVars['--duration-medium-min'], transitionTimingFunction: easeVars['--ease-standard'], }, chevronOpen: { @@ -339,10 +349,29 @@ export function Collapsible({ // attribute, so the trigger keeps `aria-disabled` semantics and stays // discoverable. A native `disabled` button would silently swallow events // (e.g. a wrapping tooltip's hover) — the system-wide disabled convention. + // The content's size change animates inside the nearest animated-layout + // area: the content after this Collapsible glides, and the revealed content + // plays its own entrance on the same timing + // (`architecture:react-component-runtime` INV12). + const contentRef = useRef(null); + const layoutChange = useMemo(() => reportLayoutChange(contentRef), []); + const wasOpenRef = useRef(isOpen); + useLayoutEffect(() => { + if (wasOpenRef.current === isOpen) { + return; + } + wasOpenRef.current = isOpen; + layoutChange.animate(); + if (isOpen && contentRef.current != null) { + playLayoutChange(contentRef.current, 'enter'); + } + }, [isOpen, layoutChange]); + const handleToggle = () => { if (isDisabled) { return; } + layoutChange.measureBefore(); toggle(); }; @@ -433,6 +462,7 @@ export function Collapsible({ {!isChevronAtStart && chevron}
(null); + useAnimatedLayout(areaRef); + const viewportRef = useMergedRefs(ref, areaRef); const viewportProps = getViewportProps({ ...props, - ref, + ref: viewportRef, ...mergedViewportProps, }); diff --git a/packages/core/src/TreeList/TreeList.tsx b/packages/core/src/TreeList/TreeList.tsx index dca4724b7eff1..a9803736a61aa 100644 --- a/packages/core/src/TreeList/TreeList.tsx +++ b/packages/core/src/TreeList/TreeList.tsx @@ -15,7 +15,15 @@ * - /packages/cli/assets/templates/blocks/components/TreeList/ (showcase blocks) */ -import {useId, useState, useMemo, useCallback, type ReactNode} from 'react'; +import { + useId, + useState, + useMemo, + useCallback, + useLayoutEffect, + useRef, + type ReactNode, +} from 'react'; import * as stylex from '@stylexjs/stylex'; import {spacingVars} from '../theme/tokens.stylex'; import {mergeProps} from '../utils'; @@ -28,6 +36,11 @@ import type { } from './TreeListTypes'; import {themeProps} from '../utils/themeProps'; import {useTreeFocusInternal} from '../hooks/useTreeFocus'; +import { + playLayoutChange, + reportLayoutChange, + type LayoutChange, +} from '../motion/animatedLayout'; // ============================================================================= // Types @@ -211,8 +224,25 @@ export function TreeList({ Map >(() => new Map()); + // A row's size change (its children shown or hidden) animates inside the + // nearest animated-layout area: the rows and content after it glide, and + // the shown group plays its own entrance on the same timing + // (`architecture:react-component-runtime` INV12). Read through a ref + // because the tree element comes from the focus hook below. + const treeElementRef = useRef(null); + const pendingLayoutRef = useRef<{change: LayoutChange; id: string} | null>( + null, + ); const handleToggle = useCallback( (id: string) => { + const row = treeElementRef.current?.querySelector( + `[data-tree-id="${CSS.escape(id)}"]`, + ); + if (row != null) { + const change = reportLayoutChange(() => row); + change.measureBefore(); + pendingLayoutRef.current = {change, id}; + } setExpandedKeysOverride(prev => { const next = new Map(prev); const isCurrentlyExpanded = prev.has(id) @@ -259,6 +289,21 @@ export function TreeList({ return false; }, []); + useLayoutEffect(() => { + const pending = pendingLayoutRef.current; + if (pending == null) { + return; + } + pendingLayoutRef.current = null; + pending.change.animate(); + const group = treeElementRef.current?.querySelector( + `[data-tree-id="${CSS.escape(pending.id)}"] > [role="group"]`, + ); + if (group != null) { + playLayoutChange(group, 'enter'); + } + }, [expandedKeysOverride]); + const {treeRef, handleKeyDown, handleFocus} = useTreeFocusInternal({ onToggleExpand: handleToggle, @@ -268,6 +313,9 @@ export function TreeList({ // the chevron toggle, start content or description. matchLabelledBy: true, }); + useLayoutEffect(() => { + treeElementRef.current = treeRef.current; + }); const handleRootKeyDown = useCallback( (e: React.KeyboardEvent) => { diff --git a/packages/core/src/TreeList/TreeListItem.tsx b/packages/core/src/TreeList/TreeListItem.tsx index 97edf6064d6d6..7334258ba68db 100644 --- a/packages/core/src/TreeList/TreeListItem.tsx +++ b/packages/core/src/TreeList/TreeListItem.tsx @@ -257,7 +257,9 @@ const styles = stylex.create({ height: spacingVars['--spacing-4'], fontSize: spacingVars['--spacing-4'], transitionProperty: 'transform', - transitionDuration: durationVars['--duration-fast'], + // The layout-change role (a placeholder until the theme names it): the + // chevron turns on the same timing as the rows' glide (INV12). + transitionDuration: durationVars['--duration-medium-min'], transitionTimingFunction: easeVars['--ease-standard'], }, // The RTL mirror is folded into each state's transform rather than living on diff --git a/packages/core/src/hooks/index.ts b/packages/core/src/hooks/index.ts index f465e2a473933..cd9074718fe83 100644 --- a/packages/core/src/hooks/index.ts +++ b/packages/core/src/hooks/index.ts @@ -86,6 +86,8 @@ export type { export {useScrollLock} from './useScrollLock'; +export {useAnimatedLayout} from './useAnimatedLayout'; + export {useEntryAnimation} from './useEntryAnimation'; export type {EntryAnimationPreset} from './useEntryAnimation'; diff --git a/packages/core/src/hooks/useAnimatedLayout.ts b/packages/core/src/hooks/useAnimatedLayout.ts new file mode 100644 index 0000000000000..ed3461b014dfe --- /dev/null +++ b/packages/core/src/hooks/useAnimatedLayout.ts @@ -0,0 +1,46 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file useAnimatedLayout.ts + * @input React (useLayoutEffect), motion/animatedLayout.ts + * @output Exports `useAnimatedLayout`: marks a container as the area in which + * size changes animate + * @position Public hook over the one layout-change module + * (`architecture:react-component-runtime` INV12) + * + * SYNC: When modified, update /packages/core/src/motion/animatedLayout.ts + */ + +import {useLayoutEffect, type RefObject} from 'react'; +import {registerLayoutArea} from '../motion/animatedLayout'; + +/** + * When something inside this area changes size, the changed element and the + * content after it animate together on one shared motion token instead of + * jumping. Fixed and sticky elements stay put, Reduce Motion is respected, + * ScrollableArea applies it automatically, and nesting stops at the nearest + * area, so nothing animates twice. + * + * @example + * ``` + * const panelRef = useRef(null); + * useAnimatedLayout(panelRef); + * return ( + *
+ * + *

Notes

+ *
+ * ); + * ``` + */ +export function useAnimatedLayout(ref: RefObject): void { + useLayoutEffect(() => { + const element = ref.current; + if (element == null) { + return undefined; + } + return registerLayoutArea(element); + }, [ref]); +} diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts new file mode 100644 index 0000000000000..d539e3e494891 --- /dev/null +++ b/packages/core/src/motion/animatedLayout.ts @@ -0,0 +1,411 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file animatedLayout.ts + * @input The DOM (layout rectangles, computed style), Web Animations + * @output Exports `registerLayoutArea`, `reportLayoutChange`, + * `playLayoutChange` and `layoutChangeTiming`: the one module that animates + * the layout shift around an element whose size changes inside a registered + * area (`architecture:react-component-runtime` INV12) + * @position Motion foundation. `useAnimatedLayout` (public) registers an + * area; ScrollableArea registers its viewport itself; Collapsible and + * TreeList report their size changes. Internal to core. + * + * How it works: + * - `measureBefore()` runs before the change commits: from the changed + * element it walks up to its nearest registered area and, at each level, + * records the rectangle of every following element sibling (a manual + * `parentElement` / `nextElementSibling` walk that never descends into a + * subtree, except through `display: contents`, which has no box). + * - `animate()` runs after the commit (a layout effect). Every change in the + * same area reported in one frame is measured together after the commit, + * once, in a microtask, so each element moves once by its total change. + * - An element moves only when its rectangle changed and it is visible in + * the area, before or after. Absolute and fixed elements are skipped (an + * absolute child rides with its moving parent), a stuck sticky element does + * not change rectangle so it stays, and a nested registered area moves as + * one unit. + * - The move is a compositor-only translate from the old place to the new + * one, added to any motion already running (`composite: 'add'`), corrected + * for a scaled ancestor. Nothing animates height. + * - Every pointer. Under Reduce Motion nothing travels: moved content + * cross-fades in at its new place. + * - The changed element plays its own animation (`playLayoutChange`) on the + * same timing, read from one theme role, so both start on the same frame. + */ + +/** + * The layout-change motion role. A placeholder until the theme names the + * role: the medium-short duration and the standard ease, read from the + * element's theme so a theme override reaches every participant at once. + */ +const DURATION_VAR = '--duration-medium-min'; +const EASE_VAR = '--ease-standard'; +const DEFAULT_DURATION_MS = 310; +const DEFAULT_EASE = 'cubic-bezier(0.24, 1, 0.4, 1)'; +/** Below this many pixels a shift is not worth a move. */ +const MIN_SHIFT_PX = 0.5; + +export interface LayoutChangeTiming { + readonly duration: number; + readonly easing: string; +} + +/** The shared timing for a change under `element`'s theme. */ +export function layoutChangeTiming(element: Element): LayoutChangeTiming { + const view = element.ownerDocument.defaultView; + const style = view?.getComputedStyle(element); + const rawDuration = style?.getPropertyValue(DURATION_VAR).trim() ?? ''; + const rawEase = style?.getPropertyValue(EASE_VAR).trim() ?? ''; + return { + duration: parseDuration(rawDuration) ?? DEFAULT_DURATION_MS, + easing: rawEase === '' ? DEFAULT_EASE : rawEase, + }; +} + +function parseDuration(value: string): number | null { + const match = /^([\d.]+)(ms|s)$/.exec(value); + if (match == null) { + return null; + } + const amount = Number(match[1]); + return match[2] === 's' ? amount * 1000 : amount; +} + +function prefersReducedMotion(element: Element): boolean { + const view = element.ownerDocument.defaultView; + return ( + typeof view?.matchMedia === 'function' && + view.matchMedia('(prefers-reduced-motion: reduce)').matches + ); +} + +// --------------------------------------------------------------------------- +// Feature tests: each option is tried through `element.animate` itself, so an +// engine that rejects one (an older iOS WebKit on `linear()` or +// `composite: 'add'`) falls back for that call instead of throwing. +// --------------------------------------------------------------------------- + +let supportsComposite: boolean | null = null; + +/** + * Play `keyframes` on `element`, dropping `composite` and then the easing + * when the engine rejects them. Returns null when Web Animations is missing. + */ +function safeAnimate( + element: Element, + keyframes: Keyframe[], + options: KeyframeAnimationOptions, +): Animation | null { + if (typeof (element as HTMLElement).animate !== 'function') { + return null; + } + const attempts: KeyframeAnimationOptions[] = []; + if (options.composite != null && supportsComposite !== false) { + attempts.push(options); + } + const {composite: _composite, ...withoutComposite} = options; + attempts.push(withoutComposite); + attempts.push({...withoutComposite, easing: 'ease-out'}); + for (const attempt of attempts) { + try { + const animation = (element as HTMLElement).animate(keyframes, attempt); + if (attempt.composite != null) { + supportsComposite = true; + } + return animation; + } catch { + if (attempt.composite != null) { + supportsComposite = false; + } + } + } + return null; +} + +// --------------------------------------------------------------------------- +// Areas +// --------------------------------------------------------------------------- + +const areas = new Map(); + +/** + * Register `element` as an area: a size change inside it animates the + * content after the change, up to and never past it. Returns the + * unregister function. Registering twice keeps one area until both release. + */ +export function registerLayoutArea(element: Element): () => void { + areas.set(element, (areas.get(element) ?? 0) + 1); + let released = false; + return () => { + if (released) { + return; + } + released = true; + const count = (areas.get(element) ?? 1) - 1; + if (count <= 0) { + areas.delete(element); + } else { + areas.set(element, count); + } + }; +} + +function nearestArea(element: Element): Element | null { + for ( + let node: Element | null = element.parentElement; + node != null; + node = node.parentElement + ) { + if (areas.has(node)) { + return node; + } + } + return null; +} + +// --------------------------------------------------------------------------- +// The walk +// --------------------------------------------------------------------------- + +/** + * The elements that can shift when `changed` changes size: at each ancestor + * level up to `area`, every following element sibling. `display: contents` + * is replaced by its children; hidden and out-of-flow elements are skipped. + */ +function collectFollowers(changed: Element, area: Element): Element[] { + const view = changed.ownerDocument.defaultView; + const out: Element[] = []; + const take = (element: Element) => { + const style = view?.getComputedStyle(element); + if (style == null) { + return; + } + if (style.display === 'contents') { + for ( + let child = element.firstElementChild; + child != null; + child = child.nextElementSibling + ) { + take(child); + } + return; + } + if ( + style.display === 'none' || + style.position === 'absolute' || + style.position === 'fixed' + ) { + return; + } + out.push(element); + }; + for ( + let node: Element | null = changed; + node != null && node !== area; + node = node.parentElement + ) { + for ( + let sibling = node.nextElementSibling; + sibling != null; + sibling = sibling.nextElementSibling + ) { + take(sibling); + } + } + return out; +} + +// --------------------------------------------------------------------------- +// Batches: every change in one area in one frame moves together +// --------------------------------------------------------------------------- + +interface Batch { + readonly area: Element; + readonly changed: Set; + readonly before: Map; + scheduled: boolean; +} + +const batches = new Map(); + +function batchFor(area: Element): Batch { + let batch = batches.get(area); + if (batch == null) { + batch = {area, changed: new Set(), before: new Map(), scheduled: false}; + batches.set(area, batch); + } + return batch; +} + +function intersects(a: DOMRect, b: DOMRect): boolean { + return ( + a.width > 0 && + a.height > 0 && + a.right > b.left && + a.left < b.right && + a.bottom > b.top && + a.top < b.bottom + ); +} + +/** The part of the area the person can see: its box within the viewport. */ +function visibleRect(area: Element): DOMRect { + const box = area.getBoundingClientRect(); + const view = area.ownerDocument.defaultView; + const width = view?.innerWidth ?? box.right; + const height = view?.innerHeight ?? box.bottom; + const left = Math.max(box.left, 0); + const top = Math.max(box.top, 0); + return new DOMRect( + left, + top, + Math.max(0, Math.min(box.right, width) - left), + Math.max(0, Math.min(box.bottom, height) - top), + ); +} + +/** The element's rendered scale against its layout size (a scaled ancestor). */ +function scaleOf(element: Element, rect: DOMRect): {x: number; y: number} { + const html = element as HTMLElement; + const x = + html.offsetWidth > 0 && rect.width > 0 ? rect.width / html.offsetWidth : 1; + const y = + html.offsetHeight > 0 && rect.height > 0 + ? rect.height / html.offsetHeight + : 1; + return {x: x || 1, y: y || 1}; +} + +function flush(batch: Batch): void { + batches.delete(batch.area); + const followers = new Set(); + for (const changed of batch.changed) { + if (!changed.isConnected) { + continue; + } + for (const element of collectFollowers(changed, batch.area)) { + followers.add(element); + } + } + if (followers.size === 0) { + return; + } + const visible = visibleRect(batch.area); + const timing = layoutChangeTiming(batch.area); + const reduced = prefersReducedMotion(batch.area); + for (const element of followers) { + const before = batch.before.get(element); + if (before == null || batch.changed.has(element)) { + // Added by the change, or itself a changed element: it plays its own. + continue; + } + const after = element.getBoundingClientRect(); + const dx = before.left - after.left; + const dy = before.top - after.top; + if (Math.abs(dx) < MIN_SHIFT_PX && Math.abs(dy) < MIN_SHIFT_PX) { + continue; + } + if (!intersects(before, visible) && !intersects(after, visible)) { + // Offscreen before and after: it lands silently. + continue; + } + if (reduced) { + safeAnimate(element, [{opacity: 0}, {opacity: 1}], { + duration: timing.duration, + easing: 'linear', + }); + continue; + } + const scale = scaleOf(element, after); + safeAnimate( + element, + [ + {transform: `translate(${dx / scale.x}px, ${dy / scale.y}px)`}, + {transform: 'translate(0px, 0px)'}, + ], + {duration: timing.duration, easing: timing.easing, composite: 'add'}, + ); + } +} + +type Target = {readonly current: Element | null} | (() => Element | null); + +function resolve(target: Target): Element | null { + return typeof target === 'function' ? target() : target.current; +} + +export interface LayoutChange { + /** Before the change commits (in the event handler that causes it). */ + measureBefore(): void; + /** After it commits (a layout effect); a no-op without `measureBefore`. */ + animate(): void; +} + +/** + * Report a size change of `target` to its nearest registered area. Call + * `measureBefore()` before the change commits and `animate()` after it; the + * content after `target`, up to the area, glides from its old place. + * Changes in one area in one frame move together. Without an area, nothing + * moves. + */ +export function reportLayoutChange(target: Target): LayoutChange { + return { + measureBefore() { + const element = resolve(target); + if (element == null) { + return; + } + const area = nearestArea(element); + if (area == null) { + return; + } + const batch = batchFor(area); + batch.changed.add(element); + for (const follower of collectFollowers(element, area)) { + if (!batch.before.has(follower)) { + batch.before.set(follower, follower.getBoundingClientRect()); + } + } + }, + animate() { + const element = resolve(target); + if (element == null) { + return; + } + const area = nearestArea(element); + const batch = area == null ? undefined : batches.get(area); + if (batch == null || batch.scheduled) { + return; + } + batch.scheduled = true; + // After every layout effect of this commit, before paint. + queueMicrotask(() => flush(batch)); + }, + }; +} + +/** + * The changed element's own animation, on the shared timing: content that + * appears fades and reveals downward; under Reduce Motion it only fades. + */ +export function playLayoutChange( + element: Element, + kind: 'enter' | 'exit' = 'enter', +): void { + if (nearestArea(element) == null) { + return; + } + const timing = layoutChangeTiming(element); + const reduced = prefersReducedMotion(element); + const shown = reduced + ? [{opacity: 0}, {opacity: 1}] + : [ + {opacity: 0, clipPath: 'inset(0 0 100% 0)'}, + {opacity: 1, clipPath: 'inset(0 0 0% 0)'}, + ]; + safeAnimate(element, kind === 'enter' ? shown : [...shown].reverse(), { + duration: timing.duration, + easing: reduced ? 'linear' : timing.easing, + }); +} From b476220d9eb926ee1620e77e711c7cb4434926e6 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 20:31:32 -0700 Subject: [PATCH 02/48] feat(core): useAnimatedLayout({containerRef}) returns a handle that Collapsible and TreeList take as animatedLayout The API shape the owner set: the hook takes {containerRef} and returns an opaque handle; Collapsible and TreeList accept an animatedLayout prop and report their size changes through it, with no context registration. Without the prop, a component keeps today's behavior, its chevron included. ScrollableArea no longer registers itself: an app passes the ScrollableArea's ref as containerRef. The story uses exactly this shape. Adds the changeset. --- .changeset/animated-layout.md | 7 ++ .../stories/AnimatedLayout.stories.tsx | 87 +++++++++++------ packages/core/src/Collapsible/Collapsible.tsx | 41 +++++--- .../src/ScrollableArea/ScrollableArea.tsx | 12 +-- packages/core/src/TreeList/TreeList.tsx | 22 +++-- packages/core/src/TreeList/TreeListItem.tsx | 13 ++- packages/core/src/hooks/index.ts | 4 + packages/core/src/hooks/useAnimatedLayout.ts | 58 ++++++----- packages/core/src/motion/animatedLayout.ts | 95 +++++++++---------- 9 files changed, 205 insertions(+), 134 deletions(-) create mode 100644 .changeset/animated-layout.md diff --git a/.changeset/animated-layout.md b/.changeset/animated-layout.md new file mode 100644 index 0000000000000..8ba15972715fb --- /dev/null +++ b/.changeset/animated-layout.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feature] `useAnimatedLayout({containerRef})` returns a handle that Collapsible and TreeList take as their new `animatedLayout` prop: when one of them opens or closes, the content after it, up to that container, glides to its new place instead of jumping, and the chevron turns on the same timing. Fixed and stuck sticky elements stay put, and under Reduce Motion the content cross-fades instead of moving. Without the prop, nothing changes. + +@cixzhang diff --git a/apps/storybook/stories/AnimatedLayout.stories.tsx b/apps/storybook/stories/AnimatedLayout.stories.tsx index 304c7b0630181..61a7d15a84a4a 100644 --- a/apps/storybook/stories/AnimatedLayout.stories.tsx +++ b/apps/storybook/stories/AnimatedLayout.stories.tsx @@ -15,6 +15,7 @@ import {Collapsible} from '@astryxdesign/core/Collapsible'; import {TextInput} from '@astryxdesign/core/TextInput'; import {Button} from '@astryxdesign/core/Button'; import {useAnimatedLayout} from '@astryxdesign/core/hooks'; +import type {AnimatedLayout as AnimatedLayoutHandle} from '@astryxdesign/core/hooks'; // The internal reporting half of the module: a plain app element whose size // changes reports it here, as Astryx components do internally. import { @@ -57,13 +58,16 @@ const label = { } as const; /** - * A plain element's size change, reported to the nearest area: `before()` + * A plain element's size change, reported through the handle: `before()` * runs in the handler that causes the change; the move plays when `dep` * commits, and the changed element plays its own entrance. */ -function useReportedChange(dep: unknown) { +function useReportedChange(animatedLayout: AnimatedLayoutHandle, dep: unknown) { const ref = useRef(null); - const change = useMemo(() => reportLayoutChange(ref), []); + const change = useMemo( + () => reportLayoutChange(animatedLayout, ref), + [animatedLayout], + ); const first = useRef(true); useLayoutEffect(() => { if (first.current) { @@ -72,9 +76,9 @@ function useReportedChange(dep: unknown) { } change.animate(); if (ref.current != null) { - playLayoutChange(ref.current, 'enter'); + playLayoutChange(animatedLayout, ref.current, 'enter'); } - }, [dep, change]); + }, [dep, change, animatedLayout]); return {ref, before: () => change.measureBefore()}; } @@ -106,10 +110,10 @@ const TREE: TreeListItemData[] = [ {id: 'notes', label: 'Notes.txt'}, ]; -function FieldError() { +function FieldError({animatedLayout}: {animatedLayout: AnimatedLayoutHandle}) { const [email, setEmail] = useState('not-an-email'); const [error, setError] = useState(null); - const {ref, before} = useReportedChange(error); + const {ref, before} = useReportedChange(animatedLayout, error); return (

A field error on Validate

@@ -146,9 +150,9 @@ function FieldError() { ); } -function LoadMore() { +function LoadMore({animatedLayout}: {animatedLayout: AnimatedLayoutHandle}) { const [loaded, setLoaded] = useState(false); - const {ref, before} = useReportedChange(loaded); + const {ref, before} = useReportedChange(animatedLayout, loaded); return (

Load more: a skeleton replaced by taller content

@@ -184,9 +188,9 @@ function LoadMore() { ); } -function Tags() { +function Tags({animatedLayout}: {animatedLayout: AnimatedLayoutHandle}) { const [tags, setTags] = useState(['design', 'motion', 'layout']); - const {ref, before} = useReportedChange(tags.length); + const {ref, before} = useReportedChange(animatedLayout, tags.length); return (

“+ Add tag”: chips that wrap to a new line

@@ -235,16 +239,18 @@ function Section({title, children}: {title: string; children: ReactNode}) { } /** - * Everything inside the ScrollableArea is one area: a size change anywhere - * in it glides the content after the change. The sticky header and the - * absolute badge stay put; the `display: contents` wrapper's children move - * on their own. + * The sticky header and the absolute badge stay put; the `display: contents` + * wrapper's children move on their own. */ -export const AnimatedLayout: Story = { - name: 'Animated layout', - render: () => ( +function Demo() { + const containerRef = useRef(null); + const animatedLayout = useAnimatedLayout({containerRef}); + return (
- +
- +
- +

Ships in 2 business days.

Free returns for 30 days.

Tracking arrives by email.

- - - + + +
A plain app div with an absolute badge (the badge rides with it)
- ), + ); +} + +/** + * The ScrollableArea is the container: `useAnimatedLayout({containerRef})` + * returns a handle, and each component that changes size takes it. + */ +export const AnimatedLayout: Story = { + name: 'Animated layout', + render: () => , }; function PanelDemo() { - const panel = useRef(null); - useAnimatedLayout(panel); + const containerRef = useRef(null); + const animatedLayout = useAnimatedLayout({containerRef}); return (
- +

Cache size, retries and timeouts.

Changes apply on the next sync.

@@ -331,8 +356,8 @@ function PanelDemo() { ); } -/** An app's own container opts in with `useAnimatedLayout(ref)`. */ +/** An app's own panel as the container. */ export const AppPanel: Story = { - name: 'App panel (useAnimatedLayout)', + name: 'App panel', render: () => , }; diff --git a/packages/core/src/Collapsible/Collapsible.tsx b/packages/core/src/Collapsible/Collapsible.tsx index d146ff90fc515..8edd7c9c4c395 100644 --- a/packages/core/src/Collapsible/Collapsible.tsx +++ b/packages/core/src/Collapsible/Collapsible.tsx @@ -48,7 +48,11 @@ import { } from '../theme/tokens.stylex'; import {useCollapsible} from './useCollapsible'; -import {playLayoutChange, reportLayoutChange} from '../motion/animatedLayout'; +import { + playLayoutChange, + reportLayoutChange, + type AnimatedLayout, +} from '../motion/animatedLayout'; import {CollapsibleGroupPresentationContext} from './CollapsibleGroupContext'; import type {CollapsibleChevronPosition} from './CollapsibleGroupContext'; import {Icon} from '../Icon'; @@ -124,11 +128,14 @@ const styles = stylex.create({ height: typeScaleVars['--text-large-size'], fontSize: typeScaleVars['--text-large-size'], transitionProperty: 'transform', - // The layout-change role (a placeholder until the theme names it): the - // chevron turns on the same timing as the content's glide (INV12). - transitionDuration: durationVars['--duration-medium-min'], + transitionDuration: durationVars['--duration-fast'], transitionTimingFunction: easeVars['--ease-standard'], }, + // With an animated layout, the chevron turns on the layout-change role (a + // placeholder until the theme names it), the content's glide timing. + chevronAnimatedLayout: { + transitionDuration: durationVars['--duration-medium-min'], + }, chevronOpen: { transform: 'rotate(180deg)', }, @@ -252,6 +259,12 @@ export interface CollapsibleProps extends BaseProps { * CollapsibleGroup owns grouped state and calls its `onChange` instead. */ onOpenChange?: (isOpen: boolean) => void; + /** + * The handle from `useAnimatedLayout({containerRef})`. With it, opening and + * closing glides the content after this Collapsible, up to that container, + * instead of jumping, and the chevron turns on the same timing. + */ + animatedLayout?: AnimatedLayout; /** * Logical position of the disclosure chevron. @@ -327,6 +340,7 @@ export function Collapsible({ onOpenChange, chevronPosition, value, + animatedLayout, ref, xstyle, className, @@ -349,12 +363,16 @@ export function Collapsible({ // attribute, so the trigger keeps `aria-disabled` semantics and stays // discoverable. A native `disabled` button would silently swallow events // (e.g. a wrapping tooltip's hover) — the system-wide disabled convention. - // The content's size change animates inside the nearest animated-layout - // area: the content after this Collapsible glides, and the revealed content - // plays its own entrance on the same timing - // (`architecture:react-component-runtime` INV12). + // With an `animatedLayout` handle, the content's size change animates: the + // content after this Collapsible, up to the handle's container, glides, and + // the revealed content plays its own entrance on the same timing + // (`architecture:react-component-runtime` INV12). Without one, nothing + // changes. const contentRef = useRef(null); - const layoutChange = useMemo(() => reportLayoutChange(contentRef), []); + const layoutChange = useMemo( + () => reportLayoutChange(animatedLayout, contentRef), + [animatedLayout], + ); const wasOpenRef = useRef(isOpen); useLayoutEffect(() => { if (wasOpenRef.current === isOpen) { @@ -363,9 +381,9 @@ export function Collapsible({ wasOpenRef.current = isOpen; layoutChange.animate(); if (isOpen && contentRef.current != null) { - playLayoutChange(contentRef.current, 'enter'); + playLayoutChange(animatedLayout, contentRef.current, 'enter'); } - }, [isOpen, layoutChange]); + }, [isOpen, layoutChange, animatedLayout]); const handleToggle = () => { if (isDisabled) { @@ -402,6 +420,7 @@ export function Collapsible({ color="secondary" xstyle={[ styles.chevron, + animatedLayout != null && styles.chevronAnimatedLayout, isChevronAtStart && styles.chevronStart, isChevronAtStart ? isOpen diff --git a/packages/core/src/ScrollableArea/ScrollableArea.tsx b/packages/core/src/ScrollableArea/ScrollableArea.tsx index d29a3e43cb2c4..8ec4355b37b73 100644 --- a/packages/core/src/ScrollableArea/ScrollableArea.tsx +++ b/packages/core/src/ScrollableArea/ScrollableArea.tsx @@ -15,7 +15,7 @@ * - /packages/cli/assets/templates/blocks/components/ScrollableArea/ (showcase blocks) */ -import {useRef, type ReactNode} from 'react'; +import type {ReactNode} from 'react'; import * as stylex from '@stylexjs/stylex'; import type {BaseProps} from '../BaseProps'; import { @@ -43,8 +43,6 @@ import {colorVars} from '../theme/tokens.stylex'; import {focusOutlineStyles} from '../utils/focusOutline.stylex'; import {mergeProps} from '../utils/mergeProps'; import {themeProps} from '../utils/themeProps'; -import {useAnimatedLayout} from '../hooks/useAnimatedLayout'; -import {useMergedRefs} from '../hooks/useMergedRefs'; const styles = stylex.create({ viewport: { @@ -215,15 +213,9 @@ export function ScrollableArea({ className, style, ); - // The viewport is an animated-layout area: a size change inside it glides - // the content after the change (`architecture:react-component-runtime` - // INV12). - const areaRef = useRef(null); - useAnimatedLayout(areaRef); - const viewportRef = useMergedRefs(ref, areaRef); const viewportProps = getViewportProps({ ...props, - ref: viewportRef, + ref, ...mergedViewportProps, }); diff --git a/packages/core/src/TreeList/TreeList.tsx b/packages/core/src/TreeList/TreeList.tsx index a9803736a61aa..0d282c82ab146 100644 --- a/packages/core/src/TreeList/TreeList.tsx +++ b/packages/core/src/TreeList/TreeList.tsx @@ -39,6 +39,7 @@ import {useTreeFocusInternal} from '../hooks/useTreeFocus'; import { playLayoutChange, reportLayoutChange, + type AnimatedLayout, type LayoutChange, } from '../motion/animatedLayout'; @@ -52,6 +53,13 @@ export interface TreeListProps extends BaseProps { /** Ref forwarded to the root element */ ref?: React.Ref; + /** + * The handle from `useAnimatedLayout({containerRef})`. With it, expanding + * or collapsing a row glides the rows and content after it, up to that + * container, instead of jumping, and the chevron turns on the same timing. + */ + animatedLayout?: AnimatedLayout; + /** * Tree items as a recursive data structure. * Each item can have nested `children` arrays. @@ -209,6 +217,7 @@ export function TreeList({ 'aria-labelledby': ariaLabelledby, ref, onKeyDown: onKeyDownProp, + animatedLayout, ...restProps }: TreeListProps) { const headerId = useId(); @@ -224,8 +233,8 @@ export function TreeList({ Map >(() => new Map()); - // A row's size change (its children shown or hidden) animates inside the - // nearest animated-layout area: the rows and content after it glide, and + // With an `animatedLayout` handle, a row's size change (its children shown + // or hidden) animates up to the handle's container: the rows and content after it glide, and // the shown group plays its own entrance on the same timing // (`architecture:react-component-runtime` INV12). Read through a ref // because the tree element comes from the focus hook below. @@ -239,7 +248,7 @@ export function TreeList({ `[data-tree-id="${CSS.escape(id)}"]`, ); if (row != null) { - const change = reportLayoutChange(() => row); + const change = reportLayoutChange(animatedLayout, () => row); change.measureBefore(); pendingLayoutRef.current = {change, id}; } @@ -252,7 +261,7 @@ export function TreeList({ return next; }); }, - [expandedKeysFromProps], + [expandedKeysFromProps, animatedLayout], ); // --------------------------------------------------------------------------- @@ -300,9 +309,9 @@ export function TreeList({ `[data-tree-id="${CSS.escape(pending.id)}"] > [role="group"]`, ); if (group != null) { - playLayoutChange(group, 'enter'); + playLayoutChange(animatedLayout, group, 'enter'); } - }, [expandedKeysOverride]); + }, [expandedKeysOverride, animatedLayout]); const {treeRef, handleKeyDown, handleFocus} = useTreeFocusInternal({ @@ -402,6 +411,7 @@ export function TreeList({ posInSet={index + 1} setSize={items.length} isTabbable={item.id === initialTabbableId} + hasAnimatedLayout={animatedLayout != null} /> ); }); diff --git a/packages/core/src/TreeList/TreeListItem.tsx b/packages/core/src/TreeList/TreeListItem.tsx index 7334258ba68db..273e923c78b20 100644 --- a/packages/core/src/TreeList/TreeListItem.tsx +++ b/packages/core/src/TreeList/TreeListItem.tsx @@ -257,11 +257,14 @@ const styles = stylex.create({ height: spacingVars['--spacing-4'], fontSize: spacingVars['--spacing-4'], transitionProperty: 'transform', - // The layout-change role (a placeholder until the theme names it): the - // chevron turns on the same timing as the rows' glide (INV12). - transitionDuration: durationVars['--duration-medium-min'], + transitionDuration: durationVars['--duration-fast'], transitionTimingFunction: easeVars['--ease-standard'], }, + // With an animated layout, the chevron turns on the layout-change role (a + // placeholder until the theme names it), the rows' glide timing. + chevronAnimatedLayout: { + transitionDuration: durationVars['--duration-medium-min'], + }, // The RTL mirror is folded into each state's transform rather than living on // a parent span. Both are `transform`, so on one element the later value // would win — spelling out `scaleX(-1) rotate(...)` per state composes them @@ -373,6 +376,8 @@ export interface TreeListItemInternalProps { * then owns the tab stop dynamically. */ isTabbable: boolean; + /** The tree has an animated layout: the chevron turns on its timing. */ + hasAnimatedLayout?: boolean; } // ============================================================================= @@ -406,6 +411,7 @@ export function TreeListItem({ posInSet, setSize, isTabbable, + hasAnimatedLayout = false, }: TreeListItemInternalProps) { const pressable = usePressFeedback(); const t = useTranslator(); @@ -536,6 +542,7 @@ export function TreeListItem({ color="inherit" xstyle={[ styles.chevronSvg, + hasAnimatedLayout && styles.chevronAnimatedLayout, isExpanded ? styles.chevronExpanded : styles.chevronCollapsed, ]} /> diff --git a/packages/core/src/hooks/index.ts b/packages/core/src/hooks/index.ts index cd9074718fe83..5ee93b5cb4840 100644 --- a/packages/core/src/hooks/index.ts +++ b/packages/core/src/hooks/index.ts @@ -87,6 +87,10 @@ export type { export {useScrollLock} from './useScrollLock'; export {useAnimatedLayout} from './useAnimatedLayout'; +export type { + AnimatedLayout, + UseAnimatedLayoutOptions, +} from './useAnimatedLayout'; export {useEntryAnimation} from './useEntryAnimation'; export type {EntryAnimationPreset} from './useEntryAnimation'; diff --git a/packages/core/src/hooks/useAnimatedLayout.ts b/packages/core/src/hooks/useAnimatedLayout.ts index ed3461b014dfe..8ed4e21dc7d55 100644 --- a/packages/core/src/hooks/useAnimatedLayout.ts +++ b/packages/core/src/hooks/useAnimatedLayout.ts @@ -4,43 +4,57 @@ /** * @file useAnimatedLayout.ts - * @input React (useLayoutEffect), motion/animatedLayout.ts - * @output Exports `useAnimatedLayout`: marks a container as the area in which - * size changes animate + * @input React (useMemo, useRef), motion/animatedLayout.ts + * @output Exports `useAnimatedLayout` and the opaque `AnimatedLayout` handle + * type * @position Public hook over the one layout-change module * (`architecture:react-component-runtime` INV12) * * SYNC: When modified, update /packages/core/src/motion/animatedLayout.ts */ -import {useLayoutEffect, type RefObject} from 'react'; -import {registerLayoutArea} from '../motion/animatedLayout'; +import {useMemo, useRef, type RefObject} from 'react'; +import { + createAnimatedLayout, + type AnimatedLayout, +} from '../motion/animatedLayout'; + +export type {AnimatedLayout} from '../motion/animatedLayout'; + +export interface UseAnimatedLayoutOptions { + /** The container whose content glides: a scroll container or a panel. */ + containerRef: RefObject; +} /** - * When something inside this area changes size, the changed element and the - * content after it animate together on one shared motion token instead of - * jumping. Fixed and sticky elements stay put, Reduce Motion is respected, - * ScrollableArea applies it automatically, and nesting stops at the nearest - * area, so nothing animates twice. + * A handle for animating size changes inside a container. Pass it to a + * component's `animatedLayout` prop: when that component changes size, the + * changed element and the content after it, up to the container, animate + * together on one shared motion token instead of jumping. Fixed and stuck + * sticky elements stay put and Reduce Motion is respected. Without a handle + * a component keeps today's behavior. * * @example * ``` - * const panelRef = useRef(null); - * useAnimatedLayout(panelRef); + * const containerRef = useRef(null); + * const animatedLayout = useAnimatedLayout({containerRef}); * return ( - *
- * + *
+ * *

Notes

*
* ); * ``` */ -export function useAnimatedLayout(ref: RefObject): void { - useLayoutEffect(() => { - const element = ref.current; - if (element == null) { - return undefined; - } - return registerLayoutArea(element); - }, [ref]); +export function useAnimatedLayout({ + containerRef, +}: UseAnimatedLayoutOptions): AnimatedLayout { + // The container is read when a change runs, so the handle stays stable + // across renders and a later ref assignment is still seen. + const refHolder = useRef(containerRef); + refHolder.current = containerRef; + return useMemo( + () => createAnimatedLayout(() => refHolder.current.current), + [], + ); } diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index d539e3e494891..9175b60da5a86 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -3,17 +3,19 @@ /** * @file animatedLayout.ts * @input The DOM (layout rectangles, computed style), Web Animations - * @output Exports `registerLayoutArea`, `reportLayoutChange`, - * `playLayoutChange` and `layoutChangeTiming`: the one module that animates - * the layout shift around an element whose size changes inside a registered - * area (`architecture:react-component-runtime` INV12) - * @position Motion foundation. `useAnimatedLayout` (public) registers an - * area; ScrollableArea registers its viewport itself; Collapsible and - * TreeList report their size changes. Internal to core. + * @output Exports `createAnimatedLayout` and the opaque `AnimatedLayout` + * handle, `reportLayoutChange`, `playLayoutChange` and + * `layoutChangeTiming`: the one module that animates the layout shift + * around an element whose size changes inside a container + * (`architecture:react-component-runtime` INV12) + * @position Motion foundation. `useAnimatedLayout({containerRef})` (public) + * returns a handle; Collapsible and TreeList take it as their + * `animatedLayout` prop and report their size changes through it. Internal + * to core. * * How it works: * - `measureBefore()` runs before the change commits: from the changed - * element it walks up to its nearest registered area and, at each level, + * element it walks up to the handle's container and, at each level, * records the rectangle of every following element sibling (a manual * `parentElement` / `nextElementSibling` walk that never descends into a * subtree, except through `display: contents`, which has no box). @@ -22,9 +24,8 @@ * once, in a microtask, so each element moves once by its total change. * - An element moves only when its rectangle changed and it is visible in * the area, before or after. Absolute and fixed elements are skipped (an - * absolute child rides with its moving parent), a stuck sticky element does - * not change rectangle so it stays, and a nested registered area moves as - * one unit. + * absolute child rides with its moving parent), and a stuck sticky element + * does not change rectangle, so it stays. * - The move is a compositor-only translate from the old place to the new * one, added to any motion already running (`composite: 'add'`), corrected * for a scaled ancestor. Nothing animates height. @@ -124,44 +125,32 @@ function safeAnimate( } // --------------------------------------------------------------------------- -// Areas +// Handles // --------------------------------------------------------------------------- -const areas = new Map(); +const AREA = Symbol('astryx.animatedLayout.area'); /** - * Register `element` as an area: a size change inside it animates the - * content after the change, up to and never past it. Returns the - * unregister function. Registering twice keeps one area until both release. + * The opaque handle `useAnimatedLayout` returns: a component that receives + * it reports its size changes to the handle's container, the area the walk + * stops at. Without a handle a component keeps its jump. */ -export function registerLayoutArea(element: Element): () => void { - areas.set(element, (areas.get(element) ?? 0) + 1); - let released = false; - return () => { - if (released) { - return; - } - released = true; - const count = (areas.get(element) ?? 1) - 1; - if (count <= 0) { - areas.delete(element); - } else { - areas.set(element, count); - } - }; +export interface AnimatedLayout { + readonly [AREA]: () => Element | null; } -function nearestArea(element: Element): Element | null { - for ( - let node: Element | null = element.parentElement; - node != null; - node = node.parentElement - ) { - if (areas.has(node)) { - return node; - } - } - return null; +/** A handle over `area` (the container element, read when a change runs). */ +export function createAnimatedLayout( + area: () => Element | null, +): AnimatedLayout { + return {[AREA]: area}; +} + +function areaOf(handle: AnimatedLayout, element: Element): Element | null { + const area = handle[AREA](); + return area != null && area !== element && area.contains(element) + ? area + : null; } // --------------------------------------------------------------------------- @@ -343,20 +332,23 @@ export interface LayoutChange { } /** - * Report a size change of `target` to its nearest registered area. Call + * Report a size change of `target` to the handle's area. Call * `measureBefore()` before the change commits and `animate()` after it; the * content after `target`, up to the area, glides from its old place. - * Changes in one area in one frame move together. Without an area, nothing - * moves. + * Changes in one area in one frame move together. Without a handle, or + * outside its container, nothing moves. */ -export function reportLayoutChange(target: Target): LayoutChange { +export function reportLayoutChange( + handle: AnimatedLayout | null | undefined, + target: Target, +): LayoutChange { return { measureBefore() { const element = resolve(target); - if (element == null) { + if (element == null || handle == null) { return; } - const area = nearestArea(element); + const area = areaOf(handle, element); if (area == null) { return; } @@ -370,10 +362,10 @@ export function reportLayoutChange(target: Target): LayoutChange { }, animate() { const element = resolve(target); - if (element == null) { + if (element == null || handle == null) { return; } - const area = nearestArea(element); + const area = areaOf(handle, element); const batch = area == null ? undefined : batches.get(area); if (batch == null || batch.scheduled) { return; @@ -390,10 +382,11 @@ export function reportLayoutChange(target: Target): LayoutChange { * appears fades and reveals downward; under Reduce Motion it only fades. */ export function playLayoutChange( + handle: AnimatedLayout | null | undefined, element: Element, kind: 'enter' | 'exit' = 'enter', ): void { - if (nearestArea(element) == null) { + if (handle == null || areaOf(handle, element) == null) { return; } const timing = layoutChangeTiming(element); From 52c08dcc18acdf7a336d1d49f58fc88b60514f33 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 20:42:40 -0700 Subject: [PATCH 03/48] fix(core): the layout glide corrects only a real ancestor scale, not subpixel rounding A real-Chromium check showed unscaled followers moving by 79.92 px for an 80 px shift: subpixel rounding made rect.width / offsetWidth read a hair off 1. A scale within 1% of 1 is now treated as none. --- packages/core/src/motion/animatedLayout.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index 9175b60da5a86..b0de565d42f95 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -263,7 +263,12 @@ function scaleOf(element: Element, rect: DOMRect): {x: number; y: number} { html.offsetHeight > 0 && rect.height > 0 ? rect.height / html.offsetHeight : 1; - return {x: x || 1, y: y || 1}; + // Subpixel rounding makes an unscaled element read a hair off 1; only a + // real scale is corrected. + return { + x: Math.abs(x - 1) < 0.01 ? 1 : x || 1, + y: Math.abs(y - 1) < 0.01 ? 1 : y || 1, + }; } function flush(batch: Batch): void { From 7cfe5e015ed2e7af93f91ad4f90c5425b455bbcd Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 20:45:05 -0700 Subject: [PATCH 04/48] chore(storybook): the Animated layout story's tags row is narrow, so + Add tag wraps and its followers move --- apps/storybook/stories/AnimatedLayout.stories.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/storybook/stories/AnimatedLayout.stories.tsx b/apps/storybook/stories/AnimatedLayout.stories.tsx index 61a7d15a84a4a..15984f543f9c4 100644 --- a/apps/storybook/stories/AnimatedLayout.stories.tsx +++ b/apps/storybook/stories/AnimatedLayout.stories.tsx @@ -189,12 +189,15 @@ function LoadMore({animatedLayout}: {animatedLayout: AnimatedLayoutHandle}) { } function Tags({animatedLayout}: {animatedLayout: AnimatedLayoutHandle}) { + // Three chips nearly fill the narrow row, so the next one wraps. const [tags, setTags] = useState(['design', 'motion', 'layout']); const {ref, before} = useReportedChange(animatedLayout, tags.length); return (

“+ Add tag”: chips that wrap to a new line

-
+
{tags.map(tag => ( Date: Mon, 26 Oct 2026 06:02:19 -0700 Subject: [PATCH 05/48] wip(core): animated layout follows AST-105 (springs.settle, Reduce Motion by pointer, visible-part rule for the changed element, top-layer and hidden skipped, chevron on the spring) --- packages/core/src/Collapsible/Collapsible.tsx | 14 +- packages/core/src/TreeList/TreeList.tsx | 10 +- packages/core/src/TreeList/TreeListItem.tsx | 9 - packages/core/src/hooks/useAnimatedLayout.ts | 27 +- packages/core/src/motion/animatedLayout.ts | 355 +++++++++++++----- 5 files changed, 289 insertions(+), 126 deletions(-) diff --git a/packages/core/src/Collapsible/Collapsible.tsx b/packages/core/src/Collapsible/Collapsible.tsx index 8edd7c9c4c395..c7c0d9a0ebc5d 100644 --- a/packages/core/src/Collapsible/Collapsible.tsx +++ b/packages/core/src/Collapsible/Collapsible.tsx @@ -49,6 +49,7 @@ import { import {useCollapsible} from './useCollapsible'; import { + applyLayoutChangeTransition, playLayoutChange, reportLayoutChange, type AnimatedLayout, @@ -131,11 +132,6 @@ const styles = stylex.create({ transitionDuration: durationVars['--duration-fast'], transitionTimingFunction: easeVars['--ease-standard'], }, - // With an animated layout, the chevron turns on the layout-change role (a - // placeholder until the theme names it), the content's glide timing. - chevronAnimatedLayout: { - transitionDuration: durationVars['--duration-medium-min'], - }, chevronOpen: { transform: 'rotate(180deg)', }, @@ -369,6 +365,7 @@ export function Collapsible({ // (`architecture:react-component-runtime` INV12). Without one, nothing // changes. const contentRef = useRef(null); + const chevronRef = useRef(null); const layoutChange = useMemo( () => reportLayoutChange(animatedLayout, contentRef), [animatedLayout], @@ -381,7 +378,7 @@ export function Collapsible({ wasOpenRef.current = isOpen; layoutChange.animate(); if (isOpen && contentRef.current != null) { - playLayoutChange(animatedLayout, contentRef.current, 'enter'); + playLayoutChange(animatedLayout, contentRef.current); } }, [isOpen, layoutChange, animatedLayout]); @@ -389,6 +386,9 @@ export function Collapsible({ if (isDisabled) { return; } + // Before the change commits: the chevron turns on the glide's motion, + // and the content after this Collapsible is measured where it is now. + applyLayoutChangeTransition(animatedLayout, chevronRef.current); layoutChange.measureBefore(); toggle(); }; @@ -407,6 +407,7 @@ export function Collapsible({ const chevron = ( ('[data-tree-toggle] svg'), + ); const change = reportLayoutChange(animatedLayout, () => row); change.measureBefore(); pendingLayoutRef.current = {change, id}; @@ -309,7 +316,7 @@ export function TreeList({ `[data-tree-id="${CSS.escape(pending.id)}"] > [role="group"]`, ); if (group != null) { - playLayoutChange(animatedLayout, group, 'enter'); + playLayoutChange(animatedLayout, group); } }, [expandedKeysOverride, animatedLayout]); @@ -411,7 +418,6 @@ export function TreeList({ posInSet={index + 1} setSize={items.length} isTabbable={item.id === initialTabbableId} - hasAnimatedLayout={animatedLayout != null} /> ); }); diff --git a/packages/core/src/TreeList/TreeListItem.tsx b/packages/core/src/TreeList/TreeListItem.tsx index 273e923c78b20..97edf6064d6d6 100644 --- a/packages/core/src/TreeList/TreeListItem.tsx +++ b/packages/core/src/TreeList/TreeListItem.tsx @@ -260,11 +260,6 @@ const styles = stylex.create({ transitionDuration: durationVars['--duration-fast'], transitionTimingFunction: easeVars['--ease-standard'], }, - // With an animated layout, the chevron turns on the layout-change role (a - // placeholder until the theme names it), the rows' glide timing. - chevronAnimatedLayout: { - transitionDuration: durationVars['--duration-medium-min'], - }, // The RTL mirror is folded into each state's transform rather than living on // a parent span. Both are `transform`, so on one element the later value // would win — spelling out `scaleX(-1) rotate(...)` per state composes them @@ -376,8 +371,6 @@ export interface TreeListItemInternalProps { * then owns the tab stop dynamically. */ isTabbable: boolean; - /** The tree has an animated layout: the chevron turns on its timing. */ - hasAnimatedLayout?: boolean; } // ============================================================================= @@ -411,7 +404,6 @@ export function TreeListItem({ posInSet, setSize, isTabbable, - hasAnimatedLayout = false, }: TreeListItemInternalProps) { const pressable = usePressFeedback(); const t = useTranslator(); @@ -542,7 +534,6 @@ export function TreeListItem({ color="inherit" xstyle={[ styles.chevronSvg, - hasAnimatedLayout && styles.chevronAnimatedLayout, isExpanded ? styles.chevronExpanded : styles.chevronCollapsed, ]} /> diff --git a/packages/core/src/hooks/useAnimatedLayout.ts b/packages/core/src/hooks/useAnimatedLayout.ts index 8ed4e21dc7d55..861a5600c0b6a 100644 --- a/packages/core/src/hooks/useAnimatedLayout.ts +++ b/packages/core/src/hooks/useAnimatedLayout.ts @@ -4,16 +4,16 @@ /** * @file useAnimatedLayout.ts - * @input React (useMemo, useRef), motion/animatedLayout.ts + * @input React (useMemo), motion/animatedLayout.ts * @output Exports `useAnimatedLayout` and the opaque `AnimatedLayout` handle * type - * @position Public hook over the one layout-change module + * @position Public hook (`spec:AST-105`) over the one size-change module * (`architecture:react-component-runtime` INV12) * * SYNC: When modified, update /packages/core/src/motion/animatedLayout.ts */ -import {useMemo, useRef, type RefObject} from 'react'; +import {useMemo, type RefObject} from 'react'; import { createAnimatedLayout, type AnimatedLayout, @@ -27,12 +27,15 @@ export interface UseAnimatedLayoutOptions { } /** - * A handle for animating size changes inside a container. Pass it to a - * component's `animatedLayout` prop: when that component changes size, the - * changed element and the content after it, up to the container, animate - * together on one shared motion token instead of jumping. Fixed and stuck - * sticky elements stay put and Reduce Motion is respected. Without a handle - * a component keeps today's behavior. + * Returns a handle to pass to a component whose size changes, such as + * TreeList or Collapsible. When that component changes size, it and the + * content after it inside `containerRef` animate together on one shared + * motion role. Fixed and stuck sticky elements stay put, the glide never + * scrolls the area, and Reduce Motion is respected. + * + * `containerRef` names the area: an app passes the ref it already gives its + * scroll container or panel (a ScrollableArea's ref reaches its native scroll + * viewport). The handle has no public members (`spec:AST-105` FR1). * * @example * ``` @@ -51,10 +54,8 @@ export function useAnimatedLayout({ }: UseAnimatedLayoutOptions): AnimatedLayout { // The container is read when a change runs, so the handle stays stable // across renders and a later ref assignment is still seen. - const refHolder = useRef(containerRef); - refHolder.current = containerRef; return useMemo( - () => createAnimatedLayout(() => refHolder.current.current), - [], + () => createAnimatedLayout(() => containerRef.current), + [containerRef], ); } diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index b0de565d42f95..0d616a9706971 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -2,96 +2,178 @@ /** * @file animatedLayout.ts - * @input The DOM (layout rectangles, computed style), Web Animations + * @input The DOM (layout rectangles, computed style), Web Animations, the + * `springs.settle` motion role (motion/roles.ts) and the Reduce Motion + * cross-fade clock (uikit/timing.ts) * @output Exports `createAnimatedLayout` and the opaque `AnimatedLayout` * handle, `reportLayoutChange`, `playLayoutChange` and - * `layoutChangeTiming`: the one module that animates the layout shift - * around an element whose size changes inside a container + * `applyLayoutChangeTransition`: the one module that produces the + * size-change glide `spec:AST-105` defines * (`architecture:react-component-runtime` INV12) - * @position Motion foundation. `useAnimatedLayout({containerRef})` (public) - * returns a handle; Collapsible and TreeList take it as their - * `animatedLayout` prop and report their size changes through it. Internal - * to core. + * @position Motion foundation, internal to core. `useAnimatedLayout` + * (public) returns a handle; Collapsible and TreeList take it as their + * `animatedLayout` prop and report their size changes through it. * * How it works: * - `measureBefore()` runs before the change commits: from the changed * element it walks up to the handle's container and, at each level, - * records the rectangle of every following element sibling (a manual - * `parentElement` / `nextElementSibling` walk that never descends into a - * subtree, except through `display: contents`, which has no box). + * records the rectangle of every following element sibling, through + * `parentElement` and `nextElementSibling`, descending only into an element + * with `display: contents` (it has no box). * - `animate()` runs after the commit (a layout effect). Every change in the * same area reported in one frame is measured together after the commit, - * once, in a microtask, so each element moves once by its total change. - * - An element moves only when its rectangle changed and it is visible in - * the area, before or after. Absolute and fixed elements are skipped (an - * absolute child rides with its moving parent), and a stuck sticky element - * does not change rectangle, so it stays. + * once, in a microtask, so each element moves once, by its total change. + * - An element moves only when its rectangle changed and it is in the area's + * visible part, before or after. Absolute, fixed, top-layer, hidden and + * zero-size elements are skipped (an absolute child rides with its moving + * parent); a stuck sticky element does not change rectangle, so it stays. * - The move is a compositor-only translate from the old place to the new - * one, added to any motion already running (`composite: 'add'`), corrected - * for a scaled ancestor. Nothing animates height. - * - Every pointer. Under Reduce Motion nothing travels: moved content - * cross-fades in at its new place. - * - The changed element plays its own animation (`playLayoutChange`) on the - * same timing, read from one theme role, so both start on the same frame. + * one, sampled from `springs.settle` into linear keyframes and added to any + * motion already running (`composite: 'add'`), corrected for a scaled + * ancestor. Nothing animates height, and nothing scrolls the area. + * - The changed element plays its own entrance on the same spring, started in + * the same frame (`playLayoutChange`), and a disclosure's indicator turns + * on it too (`applyLayoutChangeTransition`). + * - Every pointer (`spec:AST-105` FR5). Under Reduce Motion nothing travels + * (`design:reduced-motion` DR1–DR3): a cross-fade under a coarse primary + * pointer, an immediate change otherwise. + * - Without a handle nothing happens: the component keeps its jump + * (`spec:AST-105` FR2). */ -/** - * The layout-change motion role. A placeholder until the theme names the - * role: the medium-short duration and the standard ease, read from the - * element's theme so a theme override reaches every participant at once. - */ -const DURATION_VAR = '--duration-medium-min'; -const EASE_VAR = '--ease-standard'; -const DEFAULT_DURATION_MS = 310; -const DEFAULT_EASE = 'cubic-bezier(0.24, 1, 0.4, 1)'; +import {motionRole, parseMotionRoleValue} from './roles'; +import {solveSpring, toSpring, type SpringDescription} from './spring'; +import {registerSpringsTable, SETTLE_SPRING} from './uikit/springs'; +import {reducedMotionCrossFade} from './uikit/timing'; + +/** The shared motion role (`spec:AST-105` FR4). */ +const ROLE_ID = 'springs.settle'; /** Below this many pixels a shift is not worth a move. */ const MIN_SHIFT_PX = 0.5; +/** The touch cross-fade's cap (`design:reduced-motion` DR2). */ +const CROSS_FADE_CAP_MS = 200; +/** Keyframes per second the spring is sampled at. */ +const SAMPLES_PER_SECOND = 60; -export interface LayoutChangeTiming { - readonly duration: number; - readonly easing: string; +// --------------------------------------------------------------------------- +// Motion +// --------------------------------------------------------------------------- + +/** How a change plays under the reader's settings. */ +type LayoutMotion = + | {readonly kind: 'spring'; readonly progress: SpringProgress} + | { + readonly kind: 'fade'; + readonly durationMs: number; + readonly easing: string; + } + | {readonly kind: 'instant'}; + +/** The settle spring as remaining distance (1 → 0) over its duration. */ +interface SpringProgress { + readonly durationMs: number; + /** Remaining fraction of the move at `t` ms. */ + remaining(t: number): number; } -/** The shared timing for a change under `element`'s theme. */ -export function layoutChangeTiming(element: Element): LayoutChangeTiming { +function matches(element: Element, query: string): boolean { const view = element.ownerDocument.defaultView; - const style = view?.getComputedStyle(element); - const rawDuration = style?.getPropertyValue(DURATION_VAR).trim() ?? ''; - const rawEase = style?.getPropertyValue(EASE_VAR).trim() ?? ''; + return ( + typeof view?.matchMedia === 'function' && view.matchMedia(query).matches + ); +} + +/** + * `springs.settle` as the area's theme sets it: the role's custom property + * when it holds a spring, the table's value otherwise. + */ +function settleSpring(area: Element): SpringDescription { + registerSpringsTable(); + const role = motionRole(ROLE_ID); + if (role == null) { + return SETTLE_SPRING; + } + const text = + area.ownerDocument.defaultView + ?.getComputedStyle(area) + .getPropertyValue(role.variable) ?? ''; + return parseMotionRoleValue('spring', text) ?? SETTLE_SPRING; +} + +function springProgress(description: SpringDescription): SpringProgress { + const curve = solveSpring(toSpring(description), {from: 1, to: 0}); + const durationMs = Math.max(1, curve.settleTime(0.001) * 1000); return { - duration: parseDuration(rawDuration) ?? DEFAULT_DURATION_MS, - easing: rawEase === '' ? DEFAULT_EASE : rawEase, + durationMs, + remaining: t => (t >= durationMs ? 0 : curve.value(t / 1000)), }; } -function parseDuration(value: string): number | null { - const match = /^([\d.]+)(ms|s)$/.exec(value); - if (match == null) { - return null; +/** The motion a change in `area` plays now. */ +function layoutMotion(area: Element): LayoutMotion { + if (matches(area, '(prefers-reduced-motion: reduce)')) { + if (!matches(area, '(pointer: coarse)')) { + return {kind: 'instant'}; + } + const [x1, y1, x2, y2] = reducedMotionCrossFade.curve; + return { + kind: 'fade', + durationMs: Math.min( + CROSS_FADE_CAP_MS, + reducedMotionCrossFade.durationMs, + ), + easing: `cubic-bezier(${x1}, ${y1}, ${x2}, ${y2})`, + }; } - const amount = Number(match[1]); - return match[2] === 's' ? amount * 1000 : amount; + return {kind: 'spring', progress: springProgress(settleSpring(area))}; } -function prefersReducedMotion(element: Element): boolean { - const view = element.ownerDocument.defaultView; - return ( - typeof view?.matchMedia === 'function' && - view.matchMedia('(prefers-reduced-motion: reduce)').matches +/** Linear keyframes sampled from the spring, one per frame. */ +function sampleKeyframes( + progress: SpringProgress, + frame: (remaining: number) => Keyframe, +): Keyframe[] { + const count = Math.max( + 2, + Math.ceil((progress.durationMs / 1000) * SAMPLES_PER_SECOND) + 1, ); + const keyframes: Keyframe[] = []; + for (let i = 0; i < count; i++) { + const offset = i / (count - 1); + keyframes.push({ + ...frame(progress.remaining(offset * progress.durationMs)), + offset, + }); + } + return keyframes; +} + +/** The spring as a CSS `linear()` easing, for a CSS transition. */ +function linearEasing(progress: SpringProgress): string { + const stops: string[] = []; + const count = 24; + for (let i = 0; i <= count; i++) { + const fraction = i / count; + const value = 1 - progress.remaining(fraction * progress.durationMs); + stops.push(`${round(value)} ${round(fraction * 100)}%`); + } + return `linear(${stops.join(', ')})`; +} + +function round(value: number): number { + return Math.round(value * 1000) / 1000; } // --------------------------------------------------------------------------- // Feature tests: each option is tried through `element.animate` itself, so an -// engine that rejects one (an older iOS WebKit on `linear()` or -// `composite: 'add'`) falls back for that call instead of throwing. +// engine that rejects one falls back for that call instead of throwing. // --------------------------------------------------------------------------- let supportsComposite: boolean | null = null; /** - * Play `keyframes` on `element`, dropping `composite` and then the easing - * when the engine rejects them. Returns null when Web Animations is missing. + * Play `keyframes` on `element`, dropping `composite` and then the easing when + * the engine rejects them. Returns null when Web Animations is missing. */ function safeAnimate( element: Element, @@ -131,9 +213,9 @@ function safeAnimate( const AREA = Symbol('astryx.animatedLayout.area'); /** - * The opaque handle `useAnimatedLayout` returns: a component that receives - * it reports its size changes to the handle's container, the area the walk - * stops at. Without a handle a component keeps its jump. + * The opaque handle `useAnimatedLayout` returns (`spec:AST-105` FR1): no + * public members. A component that receives it reports its size changes to + * the handle's container, the area the glide stays inside. */ export interface AnimatedLayout { readonly [AREA]: () => Element | null; @@ -143,7 +225,10 @@ export interface AnimatedLayout { export function createAnimatedLayout( area: () => Element | null, ): AnimatedLayout { - return {[AREA]: area}; + // Not enumerable and frozen: the handle shows no fields to a caller. + return Object.freeze( + Object.defineProperty({}, AREA, {value: area}), + ) as AnimatedLayout; } function areaOf(handle: AnimatedLayout, element: Element): Element | null { @@ -157,10 +242,33 @@ function areaOf(handle: AnimatedLayout, element: Element): Element | null { // The walk // --------------------------------------------------------------------------- +/** + * Whether `element` takes part: in flow, rendered, not in the top layer. + * `display: contents` is handled by the caller. + */ +function takesPart(element: Element, style: CSSStyleDeclaration): boolean { + if ( + style.display === 'none' || + style.visibility === 'hidden' || + style.position === 'absolute' || + style.position === 'fixed' + ) { + return false; + } + try { + if (element.matches(':popover-open, :modal')) { + return false; + } + } catch { + // An engine without these pseudo-classes has no top layer to skip. + } + return true; +} + /** * The elements that can shift when `changed` changes size: at each ancestor - * level up to `area`, every following element sibling. `display: contents` - * is replaced by its children; hidden and out-of-flow elements are skipped. + * level up to `area`, every following element sibling. `display: contents` is + * replaced by its children. */ function collectFollowers(changed: Element, area: Element): Element[] { const view = changed.ownerDocument.defaultView; @@ -180,14 +288,9 @@ function collectFollowers(changed: Element, area: Element): Element[] { } return; } - if ( - style.display === 'none' || - style.position === 'absolute' || - style.position === 'fixed' - ) { - return; + if (takesPart(element, style)) { + out.push(element); } - out.push(element); }; for ( let node: Element | null = changed; @@ -285,9 +388,11 @@ function flush(batch: Batch): void { if (followers.size === 0) { return; } + const motion = layoutMotion(batch.area); + if (motion.kind === 'instant') { + return; + } const visible = visibleRect(batch.area); - const timing = layoutChangeTiming(batch.area); - const reduced = prefersReducedMotion(batch.area); for (const element of followers) { const before = batch.before.get(element); if (before == null || batch.changed.has(element)) { @@ -301,24 +406,29 @@ function flush(batch: Batch): void { continue; } if (!intersects(before, visible) && !intersects(after, visible)) { - // Offscreen before and after: it lands silently. + // Outside the visible part before and after: it lands silently. continue; } - if (reduced) { + if (motion.kind === 'fade') { safeAnimate(element, [{opacity: 0}, {opacity: 1}], { - duration: timing.duration, - easing: 'linear', + duration: motion.durationMs, + easing: motion.easing, }); continue; } const scale = scaleOf(element, after); + const x = dx / scale.x; + const y = dy / scale.y; safeAnimate( element, - [ - {transform: `translate(${dx / scale.x}px, ${dy / scale.y}px)`}, - {transform: 'translate(0px, 0px)'}, - ], - {duration: timing.duration, easing: timing.easing, composite: 'add'}, + sampleKeyframes(motion.progress, remaining => ({ + transform: `translate(${x * remaining}px, ${y * remaining}px)`, + })), + { + duration: motion.progress.durationMs, + easing: 'linear', + composite: 'add', + }, ); } } @@ -383,27 +493,82 @@ export function reportLayoutChange( } /** - * The changed element's own animation, on the shared timing: content that - * appears fades and reveals downward; under Reduce Motion it only fades. + * The changed element's own animation, on the same motion as the content + * after it: content that appears reveals downward from its top edge. Plays + * only with a handle, inside its area and in the area's visible part. */ export function playLayoutChange( handle: AnimatedLayout | null | undefined, element: Element, - kind: 'enter' | 'exit' = 'enter', ): void { - if (handle == null || areaOf(handle, element) == null) { + if (handle == null) { return; } - const timing = layoutChangeTiming(element); - const reduced = prefersReducedMotion(element); - const shown = reduced - ? [{opacity: 0}, {opacity: 1}] - : [ - {opacity: 0, clipPath: 'inset(0 0 100% 0)'}, - {opacity: 1, clipPath: 'inset(0 0 0% 0)'}, - ]; - safeAnimate(element, kind === 'enter' ? shown : [...shown].reverse(), { - duration: timing.duration, - easing: reduced ? 'linear' : timing.easing, - }); + const area = areaOf(handle, element); + if (area == null) { + return; + } + if (!intersects(element.getBoundingClientRect(), visibleRect(area))) { + return; + } + const motion = layoutMotion(area); + if (motion.kind === 'instant') { + return; + } + if (motion.kind === 'fade') { + safeAnimate(element, [{opacity: 0}, {opacity: 1}], { + duration: motion.durationMs, + easing: motion.easing, + }); + return; + } + safeAnimate( + element, + sampleKeyframes(motion.progress, remaining => ({ + opacity: 1 - remaining, + clipPath: `inset(0 0 ${round(remaining * 100)}% 0)`, + })), + {duration: motion.progress.durationMs, easing: 'linear'}, + ); +} + +/** + * 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 + * immediate change under Reduce Motion. Without a handle the inline values are + * cleared, so the component's own transition applies. An engine without + * `linear()` drops that value and keeps the component's easing. + */ +export function applyLayoutChangeTransition( + handle: AnimatedLayout | null | undefined, + element: HTMLElement | SVGElement | null, +): void { + if (element == null) { + return; + } + const area = handle == null ? null : areaOf(handle, element); + if (area == null) { + element.style.removeProperty('transition-duration'); + element.style.removeProperty('transition-timing-function'); + return; + } + const motion = layoutMotion(area); + if (motion.kind !== 'spring') { + element.style.setProperty('transition-duration', '0s'); + element.style.removeProperty('transition-timing-function'); + return; + } + element.style.setProperty( + 'transition-duration', + `${Math.round(motion.progress.durationMs)}ms`, + ); + element.style.setProperty( + 'transition-timing-function', + linearEasing(motion.progress), + ); +} + +/** @internal For tests: the motion a change in `area` plays. */ +export function layoutMotionFor(area: Element): LayoutMotion { + return layoutMotion(area); } From b60f171466cf3d2f48a81c3f7ed9a087883e553e Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 06:47:10 -0700 Subject: [PATCH 06/48] feat(core): useAnimatedLayout follows AST-105 - One shared motion role: the glide, the changed element's entrance and the disclosure chevron all play springs.settle, resolved through the internal motion-role registry (a theme's --motion-springs-settle reaches all three); the glide's keyframes are sampled from the spring and played linearly. - Reduce Motion by pointer (design:reduced-motion DR1-DR3): a cross-fade under a coarse primary pointer, an immediate change otherwise. - The changed element outside the area's visible part lands without animation; hidden and top-layer elements are skipped. - Collapsible and TreeList take the chevron from their Icon's icon target and set its transition before the change commits; no handle leaves every inline value untouched. - Tests: the walk, motion and handle in jsdom; Collapsible and TreeList with and without a handle; one real-browser case per AST-105 Coverage row, plus FR2-FR5. - Docs: the useAnimatedLayout hook doc (FR7 text), the animatedLayout prop in the Collapsible and TreeList docs and records, and a Before and after story. --- .changeset/animated-layout.md | 2 +- .../stories/AnimatedLayout.stories.tsx | 70 ++- .../core/src/Collapsible/Collapsible.doc.mjs | 6 + .../core/src/Collapsible/Collapsible.spec.md | 49 +- packages/core/src/Collapsible/Collapsible.tsx | 10 +- packages/core/src/TreeList/TreeList.doc.mjs | 13 + packages/core/src/TreeList/TreeList.spec.md | 9 +- packages/core/src/TreeList/TreeList.tsx | 6 +- .../core/src/hooks/useAnimatedLayout.doc.mjs | 101 ++++ ...animatedLayout.interaction.browser.spec.ts | 536 ++++++++++++++++++ .../motion/__tests__/animatedLayoutBundle.ts | 15 + .../core/src/motion/animatedLayout.test.ts | 384 +++++++++++++ .../motion/animatedLayoutAdopters.test.tsx | 158 ++++++ 13 files changed, 1324 insertions(+), 35 deletions(-) create mode 100644 packages/core/src/hooks/useAnimatedLayout.doc.mjs create mode 100644 packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts create mode 100644 packages/core/src/motion/__tests__/animatedLayoutBundle.ts create mode 100644 packages/core/src/motion/animatedLayout.test.ts create mode 100644 packages/core/src/motion/animatedLayoutAdopters.test.tsx diff --git a/.changeset/animated-layout.md b/.changeset/animated-layout.md index 8ba15972715fb..1b132726202c2 100644 --- a/.changeset/animated-layout.md +++ b/.changeset/animated-layout.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[feature] `useAnimatedLayout({containerRef})` returns a handle that Collapsible and TreeList take as their new `animatedLayout` prop: when one of them opens or closes, the content after it, up to that container, glides to its new place instead of jumping, and the chevron turns on the same timing. Fixed and stuck sticky elements stay put, and under Reduce Motion the content cross-fades instead of moving. Without the prop, nothing changes. +[feature] `useAnimatedLayout({containerRef})` returns a handle that Collapsible and TreeList take as their new `animatedLayout` prop (`spec:AST-105`). When one of them opens or closes, the content after it inside that container glides to its new place instead of jumping, on one shared motion role with the revealed content and the chevron, under every pointer, a mouse included. Fixed and stuck sticky elements stay put, the glide never scrolls the container, and under Reduce Motion the change cross-fades on touch and happens at once with a mouse. Without the prop, nothing changes. @cixzhang diff --git a/apps/storybook/stories/AnimatedLayout.stories.tsx b/apps/storybook/stories/AnimatedLayout.stories.tsx index 15984f543f9c4..628fe48052558 100644 --- a/apps/storybook/stories/AnimatedLayout.stories.tsx +++ b/apps/storybook/stories/AnimatedLayout.stories.tsx @@ -76,7 +76,7 @@ function useReportedChange(animatedLayout: AnimatedLayoutHandle, dep: unknown) { } change.animate(); if (ref.current != null) { - playLayoutChange(animatedLayout, ref.current, 'enter'); + playLayoutChange(animatedLayout, ref.current); } }, [dep, change, animatedLayout]); return {ref, before: () => change.measureBefore()}; @@ -364,3 +364,71 @@ export const AppPanel: Story = { name: 'App panel', render: () => , }; + +const COMPARE_TREE: TreeListItemData[] = [ + { + id: 'src', + label: 'src', + children: [ + {id: 'app', label: 'App.tsx'}, + {id: 'index', label: 'index.ts'}, + {id: 'styles', label: 'styles.css'}, + ], + }, + {id: 'readme', label: 'README.md'}, +]; + +function CompareColumn({ + title, + isAnimated, +}: { + title: string; + isAnimated: boolean; +}) { + const containerRef = useRef(null); + const handle = useAnimatedLayout({containerRef}); + const animatedLayout = isAnimated ? handle : undefined; + return ( +
+
{title}
+
+
+ +

Cache size, retries and timeouts.

+

Changes apply on the next sync.

+
+
+
Content after the Collapsible
+
+ +
+
Content after the tree
+
More content
+
+
+ ); +} + +/** + * The same Collapsible and TreeList without a handle (content after them + * jumps) and with one (it glides), side by side, for any pointer. + */ +export const BeforeAndAfter: Story = { + name: 'Before and after', + render: () => ( +
+ + +
+ ), +}; diff --git a/packages/core/src/Collapsible/Collapsible.doc.mjs b/packages/core/src/Collapsible/Collapsible.doc.mjs index 55a13f275832f..9dc763a561031 100644 --- a/packages/core/src/Collapsible/Collapsible.doc.mjs +++ b/packages/core/src/Collapsible/Collapsible.doc.mjs @@ -93,6 +93,12 @@ export const docs = { description: 'Identifier used for group coordination. When set inside a CollapsibleGroup, the group owns open state and its onChange is the notification callback.', }, + { + name: 'animatedLayout', + type: 'AnimatedLayout', + description: + 'The handle from useAnimatedLayout({containerRef}). With it, opening and closing glides the content after this Collapsible, up to that container, on one shared motion role instead of jumping, and the revealed content and the chevron move on the same motion. Without it, the content after it takes its place at once.', + }, { name: 'ref', type: 'React.Ref', diff --git a/packages/core/src/Collapsible/Collapsible.spec.md b/packages/core/src/Collapsible/Collapsible.spec.md index 79a6a6061d87c..d7fa2480d84e1 100644 --- a/packages/core/src/Collapsible/Collapsible.spec.md +++ b/packages/core/src/Collapsible/Collapsible.spec.md @@ -75,11 +75,12 @@ Consumer migration instructions belong in consumer docs and release notes. ## Public concepts -| Concept | Closed values or states | Meaning | Availability | Default | Owner | Stability | Invalid-value behavior | -| ---------------------- | ----------------------- | ---------------------------------------------------------------------------- | -------------------------------- | ----------------------------------------- | -------------------------------------------- | --------- | ----------------------------------------- | -| chevron position | `start`, `end` | logical side of the trigger label that owns Collapsible's disclosure chevron | Collapsible and CollapsibleGroup | `end` | `component:Collapsible` | stable | rejected by the public type | -| disclosure state | collapsed, expanded | whether the controlled region is hidden or revealed | every Collapsible | existing state owner | `component:Collapsible` or group state owner | stable | existing controlled/uncontrolled behavior | -| group position default | absent, `start`, `end` | common position supplied to direct group items | CollapsibleGroup | absent, so item default resolves to `end` | `component:Collapsible` | stable | rejected by the public type | +| Concept | Closed values or states | Meaning | Availability | Default | Owner | Stability | Invalid-value behavior | +| ---------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | -------------------------------- | ------------------------------------------------ | -------------------------------------------- | --------- | ----------------------------------------- | +| chevron position | `start`, `end` | logical side of the trigger label that owns Collapsible's disclosure chevron | Collapsible and CollapsibleGroup | `end` | `component:Collapsible` | stable | rejected by the public type | +| disclosure state | collapsed, expanded | whether the controlled region is hidden or revealed | every Collapsible | existing state owner | `component:Collapsible` or group state owner | stable | existing controlled/uncontrolled behavior | +| group position default | absent, `start`, `end` | common position supplied to direct group items | CollapsibleGroup | absent, so item default resolves to `end` | `component:Collapsible` | stable | rejected by the public type | +| animated layout | absent, a `useAnimatedLayout` handle | whether opening and closing glide the content after the Collapsible, up to the handle's container (`spec:AST-105`) | every Collapsible | absent: content after it takes its place at once | `spec:AST-105` | stable | rejected by the public type | The public prop is named `chevronPosition`. The exported value type is `CollapsibleChevronPosition`. `placement`, `side`, and rendering terminology are @@ -87,17 +88,18 @@ not parallel public names for this axis. ## Behavioral and layout contract -| ID | Invariant | Basis | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -| FR1 | Collapsible MUST expose `chevronPosition?: 'start' \| 'end'`; omission resolves to `end`. | owner API decision; preserves released default | -| FR2 | `end` MUST render Collapsible's chevron after the trigger label in logical order. Collapsed `end` points downward; expanded `end` points upward. | shipped trailing behavior plus approved position contract | -| FR3 | `start` MUST render Collapsible's chevron before the trigger label in logical order. Collapsed `start` points inward toward the label/content, so its inline direction mirrors under RTL; expanded `start` points downward. | approved leading-disclosure behavior | -| FR4 | Position changes the chevron glyph/direction as well as DOM order. Reusing a vertical down chevron and rotating it sideways MUST NOT make collapsed `start` point away from its content. | direction and representation decision | -| FR5 | Collapsible owns and renders the chevron for both positions. The position axis MUST NOT accept `none`, arbitrary React content, a cue callback, a renderer, or another replacement escape hatch. | constrained public-axis decision | -| FR6 | Trigger-label content MUST retain the usable row width needed by composed labels at either position. Moving the chevron to `start` MUST NOT strand the label at the opposite edge or prevent composed label content from using the remaining row. | approved layout rationale and existing grow behavior | -| FR7 | CollapsibleGroup MAY provide one `chevronPosition` default to its direct Collapsible items. A direct item's explicit position MUST override the group default. | approved group-coherence behavior | -| FR8 | A Collapsible nested inside another item's revealed content MUST begin a new presentation scope and use its own explicit position or the component default. The outer group's position MUST NOT leak into the nested disclosure. | approved nested isolation behavior | -| FR9 | Chevron position MUST NOT change disclosure semantics. The same trigger remains a button, retains `aria-expanded`, identifies the controlled region with `aria-controls`, and follows the existing state owner. | existing accessibility and behavior contract | +| ID | Invariant | Basis | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| FR1 | Collapsible MUST expose `chevronPosition?: 'start' \| 'end'`; omission resolves to `end`. | owner API decision; preserves released default | +| FR2 | `end` MUST render Collapsible's chevron after the trigger label in logical order. Collapsed `end` points downward; expanded `end` points upward. | shipped trailing behavior plus approved position contract | +| FR3 | `start` MUST render Collapsible's chevron before the trigger label in logical order. Collapsed `start` points inward toward the label/content, so its inline direction mirrors under RTL; expanded `start` points downward. | approved leading-disclosure behavior | +| FR4 | Position changes the chevron glyph/direction as well as DOM order. Reusing a vertical down chevron and rotating it sideways MUST NOT make collapsed `start` point away from its content. | direction and representation decision | +| FR5 | Collapsible owns and renders the chevron for both positions. The position axis MUST NOT accept `none`, arbitrary React content, a cue callback, a renderer, or another replacement escape hatch. | constrained public-axis decision | +| FR6 | Trigger-label content MUST retain the usable row width needed by composed labels at either position. Moving the chevron to `start` MUST NOT strand the label at the opposite edge or prevent composed label content from using the remaining row. | approved layout rationale and existing grow behavior | +| FR7 | CollapsibleGroup MAY provide one `chevronPosition` default to its direct Collapsible items. A direct item's explicit position MUST override the group default. | approved group-coherence behavior | +| FR8 | A Collapsible nested inside another item's revealed content MUST begin a new presentation scope and use its own explicit position or the component default. The outer group's position MUST NOT leak into the nested disclosure. | approved nested isolation behavior | +| FR9 | Chevron position MUST NOT change disclosure semantics. The same trigger remains a button, retains `aria-expanded`, identifies the controlled region with `aria-controls`, and follows the existing state owner. | existing accessibility and behavior contract | +| FR10 | Collapsible MUST accept `animatedLayout?: AnimatedLayout`. With a handle, opening and closing report the content's size change through it, so the content after the Collapsible glides inside the handle's container, and the revealed content and the chevron move on the same motion (`spec:AST-105` FR1–FR4). Without one, opening and closing are unchanged. The prop MUST NOT change disclosure semantics. | `spec:AST-105` FR1, FR2 | ### Allowed variation @@ -194,13 +196,14 @@ to the Group container anatomy here. ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | -| ------------------ | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | -| FR1–FR5 | Collapsible prop/type, DOM-order, glyph/direction, and no-extra-values tests | omitted/end/start; collapsed/expanded; LTR/RTL | default changes, `start` follows the label, collapsed leading cue points outward, or an unsupported cue value is admitted | audit:Collapsible/public-api | -| FR6 | composed trigger layout and geometry tests | plain text; composed label with trailing content; start/end | label becomes shrink-wrapped/stranded or composed content cannot use remaining row | audit:Collapsible/layout | -| FR7–FR8, ORD1 | group inheritance, item override, and nested reset tests | group start/end; item override; nested omission | group value is ignored, item cannot override, or position leaks into nested content | audit:Collapsible/behavior | -| FR9, AR1–AR3, ORD2 | disclosure semantics via `DISCLOSURE_PATTERN`, plus component-owned chevron and real RTL rendering | collapsed/expanded; controlled/uncontrolled; enabled/disabled; start/end; LTR/RTL | position changes state behavior, the trigger loses its controlled-content relationship, content visibility diverges from state, accessible output duplicates, or leading direction fails to mirror | audit:Collapsible/accessibility | -| Theming anatomy | focused target-reflection assertions plus `themingTargets.test.ts` | standalone/grouped; open/closed; enabled/disabled; start/end; divided/density | a style-driving axis or state disappears from its owning target, a target loses its anatomy owner, or the chevron stops delegating to Icon | audit:Collapsible/theming | +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | +| FR1–FR5 | Collapsible prop/type, DOM-order, glyph/direction, and no-extra-values tests | omitted/end/start; collapsed/expanded; LTR/RTL | default changes, `start` follows the label, collapsed leading cue points outward, or an unsupported cue value is admitted | audit:Collapsible/public-api | +| FR6 | composed trigger layout and geometry tests | plain text; composed label with trailing content; start/end | label becomes shrink-wrapped/stranded or composed content cannot use remaining row | audit:Collapsible/layout | +| FR7–FR8, ORD1 | group inheritance, item override, and nested reset tests | group start/end; item override; nested omission | group value is ignored, item cannot override, or position leaks into nested content | audit:Collapsible/behavior | +| FR9, AR1–AR3, ORD2 | disclosure semantics via `DISCLOSURE_PATTERN`, plus component-owned chevron and real RTL rendering | collapsed/expanded; controlled/uncontrolled; enabled/disabled; start/end; LTR/RTL | position changes state behavior, the trigger loses its controlled-content relationship, content visibility diverges from state, accessible output duplicates, or leading direction fails to mirror | audit:Collapsible/accessibility | +| FR10 | `animatedLayout.test.ts` and `animatedLayout.interaction.browser.spec.ts` (`spec:AST-105` Verification), plus `animatedLayoutAdopters.test.tsx` (Collapsible with and without a handle) | no handle; a handle; opening and closing | +| Theming anatomy | focused target-reflection assertions plus `themingTargets.test.ts` | standalone/grouped; open/closed; enabled/disabled; start/end; divided/density | a style-driving axis or state disappears from its owning target, a target loses its anatomy owner, or the chevron stops delegating to Icon | audit:Collapsible/theming | ## Decision log diff --git a/packages/core/src/Collapsible/Collapsible.tsx b/packages/core/src/Collapsible/Collapsible.tsx index c7c0d9a0ebc5d..ba5488d01d433 100644 --- a/packages/core/src/Collapsible/Collapsible.tsx +++ b/packages/core/src/Collapsible/Collapsible.tsx @@ -365,7 +365,6 @@ export function Collapsible({ // (`architecture:react-component-runtime` INV12). Without one, nothing // changes. const contentRef = useRef(null); - const chevronRef = useRef(null); const layoutChange = useMemo( () => reportLayoutChange(animatedLayout, contentRef), [animatedLayout], @@ -382,13 +381,17 @@ export function Collapsible({ } }, [isOpen, layoutChange, animatedLayout]); - const handleToggle = () => { + const handleToggle = (event: React.MouseEvent) => { if (isDisabled) { return; } // Before the change commits: the chevron turns on the glide's motion, // and the content after this Collapsible is measured where it is now. - applyLayoutChangeTransition(animatedLayout, chevronRef.current); + // The chevron is the trigger's Icon (its `icon` theming target). + applyLayoutChangeTransition( + animatedLayout, + event.currentTarget.querySelector(':scope > .astryx-icon'), + ); layoutChange.measureBefore(); toggle(); }; @@ -407,7 +410,6 @@ export function Collapsible({ const chevron = ( ` or another element without a compatibility decision. | Owner decision and the caller-owned per-row interaction boundary | Settled intent | | FR7 | A click on an Item row outside its Item label performs the row's action: an Item with `onClick` runs it; otherwise an Item with children toggles; otherwise an Item whose only action is its link follows that link as Enter does, with modified and middle clicks following the shared enlarged-surface rule in `family:navigation-destinations`. The Chevron and controls in Start content and End content keep their own activation, and a disabled Item does nothing. | Owner decision; `family:navigation-destinations` | Settled intent; implementation may be staged | +| FR8 | `TreeList` MUST accept `animatedLayout?: AnimatedLayout`. With a handle, expanding and collapsing a row report the row's size change through it, so the content after the row glides inside the handle's container, and the shown rows and the row's Chevron move on the same motion (`spec:AST-105` FR1–FR4). Without one, expanding and collapsing are unchanged. The prop MUST NOT change tree semantics or keyboard behavior. | `spec:AST-105` FR1, FR2 | Current behavior | ### Allowed variation @@ -262,6 +264,7 @@ authorize or prohibit a future target. | AR1 | Tree View accessibility contract (`internal/a11y-spec`, pending) bound to TreeList, in jsdom and Chromium; `TreeList.test.tsx` row-naming cases ("names a parent row by its label alone, not the chevron toggle", "names a plain row by its label and describes it with its description", "names and describes link and action rows themselves, not only their inner control", "keeps start and end content, including controls, out of the row name", "typeahead matches the label, not the description or start content") and `TreeListRowNames.a11y.chromium.spec.ts` for the browser-computed names; `TreeList.test.tsx` structural ARIA cases ("sets aria-level, aria-posinset, and aria-setsize at the top level", "sets aria-level/posinset/setsize correctly at deeper levels", "sets aria-level across three depths"); `TreeList.test.tsx` and `useTreeFocus.test.tsx` keyboard suites | Flat and nested trees; expanded/collapsed; selected/unselected; LTR and RTL; typeahead | A row loses its role, label name, state or position, two rows hold a Tab stop, an arrow key stops moving or stops mirroring under RTL, Home/End or typeahead stop working, or the tree claims multi-select | `audit:TreeList/accessibility` | | FR5, FR6, ORD1 | Focused `TreeList.test.tsx` row-identity and styling-composition coverage, required with implementation | One input at a time; all three together; sibling rows | Dropping any mechanism, applying mechanisms to different elements, targeting the `
  • `, changing the merge order, or styling a sibling row fails. | `audit:TreeList/api` | | FR7 | Focused `TreeList.test.tsx` row-click coverage, required with implementation | Action, parent, link, and disabled rows; clicks in the indent and padding, on the Chevron, and on Start and End content controls; plain, modified, and middle clicks | A click outside the label does nothing or runs the wrong action, a link-row click reaches ancestors twice, the Chevron or a Start or End content control activates the row, or a disabled row acts. | `audit:TreeList/behavior` | +| FR8 | `animatedLayout.test.ts` and `animatedLayout.interaction.browser.spec.ts` (`spec:AST-105` Verification), plus `animatedLayoutAdopters.test.tsx` (TreeList with and without a handle) | | Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and five current local targets | Missing, extra, duplicated, prefixed, or stale mappings fail repository validation. | `audit:TreeList/theming` | Focused target-placement assertions cover all five local targets. Runtime support diff --git a/packages/core/src/TreeList/TreeList.tsx b/packages/core/src/TreeList/TreeList.tsx index 58a1e4720a007..5d27713579ad1 100644 --- a/packages/core/src/TreeList/TreeList.tsx +++ b/packages/core/src/TreeList/TreeList.tsx @@ -249,11 +249,11 @@ export function TreeList({ `[data-tree-id="${CSS.escape(id)}"]`, ); if (row != null) { - // The row's own chevron (the first toggle in it; its children's come - // after) turns on the glide's motion. + // The row's own chevron (the Icon in its toggle, the first one in the + // row; its children's come after) turns on the glide's motion. applyLayoutChangeTransition( animatedLayout, - row.querySelector('[data-tree-toggle] svg'), + row.querySelector('[data-tree-toggle] > .astryx-icon'), ); const change = reportLayoutChange(animatedLayout, () => row); change.measureBefore(); diff --git a/packages/core/src/hooks/useAnimatedLayout.doc.mjs b/packages/core/src/hooks/useAnimatedLayout.doc.mjs new file mode 100644 index 0000000000000..1ba0f81e0297a --- /dev/null +++ b/packages/core/src/hooks/useAnimatedLayout.doc.mjs @@ -0,0 +1,101 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** @type {import('@astryxdesign/cli/authoring').HookDoc} */ +export const docs = { + name: 'useAnimatedLayout', + displayName: 'useAnimatedLayout', + keywords: [ + 'animated layout', + 'layout shift', + 'glide', + 'expand', + 'collapse', + 'disclosure', + 'reflow', + 'jump', + 'motion', + 'transition', + ], + params: [ + { + name: 'options.containerRef', + type: 'RefObject', + description: + 'The area the glide stays inside: the ref you already give your scroll container or panel. A ScrollableArea ref reaches its native scroll viewport, so pass that.', + required: true, + }, + ], + returns: [ + { + name: 'animatedLayout', + type: 'AnimatedLayout', + description: + 'An opaque handle with no public members. Pass it to the animatedLayout prop of a component whose size changes, such as TreeList or Collapsible.', + }, + ], + usage: { + description: + 'Returns a handle to pass to a component whose size changes, such as TreeList or Collapsible. When that component changes size, it and the content after it inside `containerRef` animate together on one shared motion role. Fixed and stuck sticky elements stay put, the glide never scrolls the area, and Reduce Motion is respected. It applies under every pointer, a mouse included. Without the handle a component keeps its jump: content after it takes its place at once. Content after the change may mix Astryx components and your own elements; none of it opts in.', + bestPractices: [ + { + guidance: true, + description: + 'Create one handle per scroll container or panel and pass the same handle to every component in it whose size changes.', + }, + { + guidance: true, + description: + 'Pass the ref you already give ScrollableArea as containerRef; ScrollableArea does not register itself.', + }, + { + guidance: false, + description: + 'Wrap the content after a disclosure in an extra element to make it move; everything after the change inside the container moves on its own.', + }, + { + guidance: false, + description: + 'Animate height yourself alongside it; the glide moves content with a compositor translate and never animates height.', + }, + ], + }, + relatedComponents: ['TreeList', 'Collapsible', 'ScrollableArea'], + importPath: '@astryxdesign/core/hooks', + category: 'animation', +}; + +/** @type {import('@astryxdesign/cli/authoring').HookTranslationDoc} */ +export const docsDense = { + description: + 'Handle for a component whose size changes (TreeList, Collapsible): it + content after it inside containerRef glide on one shared motion role. Fixed/stuck sticky stay; never scrolls the area; Reduce Motion respected; every pointer. No handle = snap.', + paramDescriptions: { + 'options.containerRef': + 'area the glide stays inside; pass the ref you give your scroll container (a ScrollableArea ref reaches its viewport).', + }, + returnDescriptions: { + animatedLayout: + "opaque handle, no members; pass to a component's animatedLayout prop.", + }, + usage: { + description: + 'One handle per scroll container/panel, passed to every component in it whose size changes. Content after a change (Astryx or app elements) glides; no opt-in per element. Omit the handle to keep the jump.', + bestPractices: [ + { + guidance: true, + description: + 'One handle per area, shared by its size-changing components.', + }, + { + guidance: true, + description: + 'Pass the ScrollableArea ref as containerRef; it does not register itself.', + }, + { + guidance: false, + description: + 'Wrap following content in an extra element to make it move.', + }, + {guidance: false, description: 'Animate height yourself alongside it.'}, + ], + }, +}; diff --git a/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts b/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts new file mode 100644 index 0000000000000..510dbd904a731 --- /dev/null +++ b/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts @@ -0,0 +1,536 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file animatedLayout.interaction.browser.spec.ts + * @input The animated-layout module bundled with esbuild onto fixture pages + * written inline, real Chromium and WebKit (the interaction projects: a + * phone with a coarse pointer) plus a fine-pointer describe + * @output One proof per `spec:AST-105` Coverage row, and FR2–FR4: which + * elements glide after a reported size change, by how much, on one shared + * start and spring, never past the area and never scrolling it; nothing + * without a handle; Reduce Motion by pointer + * @position Real-browser verification of `spec:AST-105` and + * `architecture:react-component-runtime` INV12 + */ + +import {fileURLToPath} from 'node:url'; +import {expect, test, type Page} from '@playwright/test'; +import {build} from 'esbuild'; +import type * as Bundle from './animatedLayoutBundle'; + +/** One animation on an element: its first frame and its timing. */ +interface MotionRead { + first: string | null; + opacity: string | null; + composite: string; + duration: number; + startTime: number | null; +} + +declare global { + interface Window { + AnimatedLayout: typeof Bundle; + /** Runs one reported change; resolves after the glide is set up. */ + change: (options: { + changed: string; + mutate: string; + area?: string; + handle?: boolean; + }) => Promise; + /** Animations on an element: their first transform and timing. */ + motionOf: (selector: string) => MotionRead[]; + } +} + +let script = ''; + +test.beforeAll(async () => { + const result = await build({ + entryPoints: [ + fileURLToPath(new URL('./animatedLayoutBundle.ts', import.meta.url)), + ], + bundle: true, + write: false, + format: 'iife', + globalName: 'AnimatedLayout', + target: 'es2022', + }); + script = result.outputFiles[0].text; +}); + +const BASE_STYLE = ` + html, body { margin: 0; font: 14px sans-serif; background: #fff; } + .area { position: relative; height: 560px; overflow: auto; } + .row { height: 40px; margin: 0 0 4px; background: #eef; } + .grow { height: 0; overflow: hidden; background: #fd9; } + .grow.open { height: 80px; } +`; + +const HELPERS = ` +window.change = async ({changed, mutate, area = '#area', handle = true}) => { + const container = document.querySelector(area); + const target = document.querySelector(changed); + const layout = handle + ? AnimatedLayout.createAnimatedLayout(() => container) + : null; + const report = AnimatedLayout.reportLayoutChange(layout, () => target); + report.measureBefore(); + new Function(mutate)(); + report.animate(); + AnimatedLayout.playLayoutChange(layout, target); + await new Promise(resolve => { + queueMicrotask(resolve); + }); +}; +window.motionOf = selector => { + const element = document.querySelector(selector); + return element.getAnimations({subtree: false}).map(animation => { + const effect = animation.effect; + const frames = effect.getKeyframes(); + const timing = effect.getTiming(); + return { + first: frames[0].transform ?? null, + opacity: frames[0].opacity == null ? null : String(frames[0].opacity), + composite: effect.composite, + duration: Number(timing.duration), + startTime: animation.startTime == null ? null : Number(animation.startTime), + }; + }); +}; +`; + +async function open(page: Page, body: string, style = '') { + const html = ` + +${body} + +`; + await page.route('http://layout.test/', async route => + route.fulfill({contentType: 'text/html', body: html}), + ); + await page.goto('http://layout.test/'); +} + +const OPEN_GROW = `document.querySelector('#grow').classList.add('open')`; + +/** Reports one change from the test (its options cross into the page). */ +async function change( + page: Page, + options: { + changed?: string; + mutate?: string; + area?: string; + handle?: boolean; + } = {}, +) { + await page.evaluate(async o => window.change(o), { + changed: '#grow', + mutate: OPEN_GROW, + ...options, + }); +} + +/** The translate an animation starts from, in px. */ +function startOffset(first: string | null): {x: number; y: number} { + const match = /translate\(([-\d.e]+)px,\s*([-\d.e]+)px\)/.exec(first ?? ''); + return match == null + ? {x: 0, y: 0} + : {x: Number(match[1]), y: Number(match[2])}; +} + +test.describe('spec:AST-105 Coverage', () => { + test('normal-flow siblings at each ancestor level glide, and nothing past the area moves', async ({ + page, + }) => { + await open( + page, + `
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    `, + ); + await change(page); + for (const id of ['#s3', '#s2', '#s1', '#s0']) { + const [motion] = await page.evaluate(s => window.motionOf(s), id); + expect(motion, id).toBeDefined(); + expect(startOffset(motion.first).y, id).toBeCloseTo(-80, 0); + expect(motion.composite, id).toBe('add'); + } + expect(await page.evaluate(() => window.motionOf('#outside'))).toEqual([]); + }); + + test('a display: contents wrapper has no box: its children glide in its place', async ({ + page, + }) => { + await open( + page, + `
    +
    `, + ); + await change(page); + expect(await page.evaluate(() => window.motionOf('#wrap'))).toEqual([]); + for (const id of ['#c1', '#c2']) { + expect(await page.evaluate(s => window.motionOf(s), id)).toHaveLength(1); + } + }); + + test('absolute and fixed elements take their final place; an absolute badge rides with its row', async ({ + page, + }) => { + await open( + page, + `
    +
    +
    +
    `, + ); + const before = await page.locator('#badge').boundingBox(); + await change(page); + expect(await page.evaluate(() => window.motionOf('#abs'))).toEqual([]); + expect(await page.evaluate(() => window.motionOf('#fixed'))).toEqual([]); + expect(await page.evaluate(() => window.motionOf('#badge'))).toEqual([]); + // Halfway through, the row and its badge are between the two places. + await page.evaluate(() => { + for (const animation of document + .querySelector('#row') + ?.getAnimations() ?? []) { + animation.pause(); + animation.currentTime = 60; + } + }); + const mid = await page.locator('#badge').boundingBox(); + expect(mid?.y ?? 0).toBeGreaterThan(before?.y ?? 0); + expect(mid?.y ?? 0).toBeLessThan((before?.y ?? 0) + 80); + }); + + test('a sticky element glides while unstuck and stays put while stuck', async ({ + page, + }) => { + await open( + page, + `
    + ${'
    '.repeat(4)} +
    + ${'
    '.repeat(4)} +
    +
    + ${'
    '.repeat(20)} +
    `, + ); + // Scrolled past the first header: it is stuck at the area's top. + await page.evaluate(() => { + (document.querySelector('#area') as HTMLElement).scrollTop = 200; + }); + await change(page); + expect(await page.evaluate(() => window.motionOf('#stuck'))).toEqual([]); + expect(await page.evaluate(() => window.motionOf('#unstuck'))).toHaveLength( + 1, + ); + }); + + test('a float after the change glides', async ({page}) => { + await open( + page, + `
    +
    `, + ); + await change(page); + expect(await page.evaluate(() => window.motionOf('#float'))).toHaveLength( + 1, + ); + }); + + test('flex items: a wrapped item glides, a row-direction neighbor that does not shift stays', async ({ + page, + }) => { + await open( + page, + `
    +
    +
    + + + + +
    `, + ); + // The first chip widens: its row-direction neighbor moves sideways. + await change(page, { + changed: '#chip', + mutate: `document.querySelector('#chip').style.width = '120px'`, + }); + const [neighbor] = await page.evaluate(() => window.motionOf('#neighbor')); + expect(startOffset(neighbor.first).x).toBeCloseTo(-80, 0); + // A tag inserted before the last wraps it to a new line. + await change(page, { + changed: '#tags', + mutate: `const tag = document.createElement('span'); tag.style.cssText = 'width: 60px; height: 24px; background: #ddd'; document.querySelector('#last').before(tag)`, + }); + // The wrapping row itself grew: the change is reported on it, and its own + // children are not followers (only content after it is). + expect(await page.evaluate(() => window.motionOf('#last'))).toEqual([]); + }); + + test('hidden, display: none and zero-size elements are skipped', async ({ + page, + }) => { + await open( + page, + `
    + + +
    +
    `, + ); + await change(page); + for (const id of ['#hidden', '#none', '#empty']) { + expect(await page.evaluate(s => window.motionOf(s), id), id).toEqual([]); + } + expect(await page.evaluate(() => window.motionOf('#after'))).toHaveLength( + 1, + ); + }); + + test('elements outside the visible part land without animation', async ({ + page, + }) => { + await open( + page, + `
    + ${Array.from({length: 40}, (_, i) => `
    `).join('')}
    `, + ); + await change(page); + expect(await page.evaluate(() => window.motionOf('#r0'))).toHaveLength(1); + // Far below the fold before and after. + expect(await page.evaluate(() => window.motionOf('#r39'))).toEqual([]); + // A change above the fold, out of sight: the changed element plays nothing. + await page.evaluate(() => { + (document.querySelector('#area') as HTMLElement).scrollTop = 1200; + }); + await change(page, { + changed: '#r2', + mutate: `document.querySelector('#r2').style.height = '120px'`, + }); + expect(await page.evaluate(() => window.motionOf('#r2'))).toEqual([]); + }); + + test('a row under a scaled panel moves by its layout change, corrected for the scale', async ({ + page, + }) => { + await open( + page, + `
    +
    `, + ); + await change(page); + const [motion] = await page.evaluate(() => window.motionOf('#scaled')); + // 80 px of layout: 40 px on screen, translated in the panel's own space. + expect(startOffset(motion.first).y).toBeCloseTo(-80, 0); + }); + + test('top-layer content is never moved', async ({page}) => { + await open( + page, + `
    +
    menu
    `, + ); + await page.evaluate(() => + (document.querySelector('#pop') as HTMLElement).showPopover(), + ); + await change(page); + expect(await page.evaluate(() => window.motionOf('#pop'))).toEqual([]); + }); + + test('an element already moving keeps its motion and adds the new move', async ({ + page, + }) => { + await open( + page, + `
    +
    `, + ); + await change(page); + await page.waitForTimeout(100); + await change(page, { + changed: '#second', + mutate: `document.querySelector('#second').classList.add('open')`, + }); + const motions = await page.evaluate(() => window.motionOf('#after')); + expect(motions).toHaveLength(2); + expect(motions.every(m => m.composite === 'add')).toBe(true); + }); + + test('nested areas: motion stops at the inner area; an inner area moves as one unit', async ({ + page, + }) => { + await open( + page, + `
    +
    +
    `, + ); + await change(page, {area: '#inner'}); + expect( + await page.evaluate(() => window.motionOf('#innerAfter')), + ).toHaveLength(1); + expect(await page.evaluate(() => window.motionOf('#outerAfter'))).toEqual( + [], + ); + await page.waitForTimeout(700); + await change(page, { + changed: '#outerGrow', + mutate: `document.querySelector('#outerGrow').classList.add('open')`, + }); + expect(await page.evaluate(() => window.motionOf('#inner'))).toHaveLength( + 1, + ); + expect( + (await page.evaluate(() => window.motionOf('#innerAfter'))).length, + ).toBe(0); + }); +}); + +test.describe('spec:AST-105 FR2–FR4', () => { + const PAGE = `
    +
    + ${'
    '.repeat(30)}
    `; + + test('without a handle nothing animates (FR2)', async ({page}) => { + await open(page, PAGE); + await change(page, {handle: false}); + for (const id of ['#grow', '#a', '#b']) { + expect(await page.evaluate(s => window.motionOf(s), id), id).toEqual([]); + } + }); + + test('two changes in one frame move each element once, by its total change (FR3)', async ({ + page, + }) => { + await open( + page, + `
    `, + ); + await page.evaluate(async () => { + const area = document.querySelector('#area'); + const layout = window.AnimatedLayout.createAnimatedLayout(() => area); + const one = window.AnimatedLayout.reportLayoutChange(layout, () => + document.querySelector('#g1'), + ); + const two = window.AnimatedLayout.reportLayoutChange(layout, () => + document.querySelector('#g2'), + ); + one.measureBefore(); + two.measureBefore(); + document.querySelector('#g1')?.classList.add('open'); + document.querySelector('#g2')?.classList.add('open'); + one.animate(); + two.animate(); + await new Promise(resolve => { + queueMicrotask(resolve); + }); + }); + const motions = await page.evaluate(() => window.motionOf('#after')); + expect(motions).toHaveLength(1); + expect(startOffset(motions[0].first).y).toBeCloseTo(-160, 0); + }); + + test('the glide never scrolls the area (FR3)', async ({page}) => { + await open(page, PAGE); + await page.evaluate(() => { + (document.querySelector('#area') as HTMLElement).scrollTop = 40; + }); + const scrolls = await page.evaluate(async (mutate: string) => { + const area = document.querySelector('#area') as HTMLElement; + const seen: number[] = []; + area.addEventListener('scroll', () => seen.push(area.scrollTop)); + await window.change({changed: '#grow', mutate}); + await new Promise(resolve => { + setTimeout(resolve, 700); + }); + return {seen, top: area.scrollTop}; + }, OPEN_GROW); + expect(scrolls.seen).toEqual([]); + expect(scrolls.top).toBe(40); + }); + + test('the changed element and its followers start on one frame on springs.settle (FR4)', async ({ + page, + }) => { + await open(page, PAGE); + await change(page); + await page.waitForTimeout(50); + const grow = await page.evaluate(() => window.motionOf('#grow')); + const follower = await page.evaluate(() => window.motionOf('#a')); + expect(grow).toHaveLength(1); + expect(follower).toHaveLength(1); + expect(grow[0].startTime).toBe(follower[0].startTime); + expect(grow[0].duration).toBeCloseTo(follower[0].duration, 3); + // springs.settle is SwiftUI's smooth spring (duration 0.5 s, no bounce): + // it settles within a second. + expect(follower[0].duration).toBeGreaterThan(300); + expect(follower[0].duration).toBeLessThan(1200); + }); + + test("a theme's springs.settle reaches both (FR4)", async ({page}) => { + await open(page, PAGE, '#area { --motion-springs-settle: 0.2s 0; }'); + await change(page); + const [follower] = await page.evaluate(() => window.motionOf('#a')); + const [grow] = await page.evaluate(() => window.motionOf('#grow')); + expect(follower.duration).toBeLessThan(500); + expect(grow.duration).toBeCloseTo(follower.duration, 3); + }); + + test('under Reduce Motion with a coarse pointer, both cross-fade with no travel (FR4)', async ({ + page, + }) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + await open(page, PAGE); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(true); + await change(page); + for (const id of ['#grow', '#a']) { + const [motion] = await page.evaluate(s => window.motionOf(s), id); + expect(motion.first, id).toBeNull(); + expect(motion.opacity, id).toBe('0'); + expect(motion.duration, id).toBeLessThanOrEqual(200); + } + }); +}); + +test.describe('spec:AST-105 FR5 and FR4 under a fine pointer', () => { + test.use({ + isMobile: false, + hasTouch: false, + viewport: {width: 1280, height: 800}, + }); + + const PAGE = `
    +
    `; + + test('the glide applies under a mouse (FR5)', async ({page}) => { + await open(page, PAGE); + expect( + await page.evaluate(() => matchMedia('(pointer: fine)').matches), + ).toBe(true); + await change(page); + expect(await page.evaluate(() => window.motionOf('#a'))).toHaveLength(1); + }); + + test('under Reduce Motion with a fine pointer the change is immediate (FR4)', async ({ + page, + }) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + await open(page, PAGE); + await change(page); + expect(await page.evaluate(() => window.motionOf('#grow'))).toEqual([]); + expect(await page.evaluate(() => window.motionOf('#a'))).toEqual([]); + }); +}); diff --git a/packages/core/src/motion/__tests__/animatedLayoutBundle.ts b/packages/core/src/motion/__tests__/animatedLayoutBundle.ts new file mode 100644 index 0000000000000..854a83ec78643 --- /dev/null +++ b/packages/core/src/motion/__tests__/animatedLayoutBundle.ts @@ -0,0 +1,15 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file animatedLayoutBundle.ts + * @input The animated-layout module the browser proofs drive + * @output Re-exports it as one entry point + * @position The esbuild entry `animatedLayout.interaction.browser.spec.ts` + * bundles into its page (global `AnimatedLayout`) + */ + +export { + createAnimatedLayout, + playLayoutChange, + reportLayoutChange, +} from '../animatedLayout'; diff --git a/packages/core/src/motion/animatedLayout.test.ts b/packages/core/src/motion/animatedLayout.test.ts new file mode 100644 index 0000000000000..7998ada3eb1f2 --- /dev/null +++ b/packages/core/src/motion/animatedLayout.test.ts @@ -0,0 +1,384 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file animatedLayout.test.ts + * @input vitest (jsdom), the animated-layout module, a recording stand-in for + * Element.animate and stubbed layout rectangles (jsdom lays nothing out) + * @output Checks the handle (`spec:AST-105` FR1), that nothing runs without + * one (FR2), which elements the walk takes and how it moves them (FR3), + * the shared `springs.settle` motion and Reduce Motion by pointer (FR4), + * and the indicator's transition + * @position Testing; the real geometry is proven in + * __tests__/animatedLayout.interaction.browser.spec.ts + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import { + applyLayoutChangeTransition, + createAnimatedLayout, + playLayoutChange, + reportLayoutChange, +} from './animatedLayout'; + +interface Played { + element: Element; + keyframes: Keyframe[]; + options: KeyframeAnimationOptions; +} + +let played: Played[] = []; +let media: Record = {}; +const originalAnimate = Element.prototype.animate; +const originalMatchMedia = window.matchMedia; + +beforeEach(() => { + played = []; + media = {}; + Element.prototype.animate = function ( + this: Element, + keyframes: Keyframe[] | PropertyIndexedKeyframes | null, + options?: number | KeyframeAnimationOptions, + ) { + played.push({ + element: this, + keyframes: keyframes as Keyframe[], + options: options as KeyframeAnimationOptions, + }); + return {} as Animation; + }; + window.matchMedia = (query: string) => + ({ + matches: media[query] === true, + media: query, + addEventListener() {}, + removeEventListener() {}, + }) as unknown as MediaQueryList; +}); + +afterEach(() => { + Element.prototype.animate = originalAnimate; + window.matchMedia = originalMatchMedia; + document.body.innerHTML = ''; +}); + +/** Gives `element` a rectangle that follows `phase` (before or after). */ +function placeAt( + element: Element, + rects: { + before: [number, number, number, number]; + after?: [number, number, number, number]; + }, + phase: {current: 'before' | 'after'}, +) { + vi.spyOn(element, 'getBoundingClientRect').mockImplementation(() => { + const [x, y, w, h] = + phase.current === 'before' || rects.after == null + ? rects.before + : rects.after; + return new DOMRect(x, y, w, h); + }); +} + +const flushMicrotasks = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + +/** + * An area with a changed element and the given followers after it, each + * shifted by `shift` px down after the change unless it says otherwise. + */ +function setUp(html: string) { + document.body.innerHTML = html; + const area = document.getElementById('area') as HTMLElement; + const changed = document.getElementById('changed') as HTMLElement; + const phase: {current: 'before' | 'after'} = {current: 'before'}; + placeAt(area, {before: [0, 0, 400, 600]}, phase); + placeAt(changed, {before: [0, 0, 400, 0], after: [0, 0, 400, 80]}, phase); + return {area, changed, phase}; +} + +function translateOf(entry: Played): {x: number; y: number} { + const first = String(entry.keyframes[0].transform ?? ''); + const match = /translate\(([-\d.e]+)px,\s*([-\d.e]+)px\)/.exec(first); + return match == null + ? {x: 0, y: 0} + : {x: Number(match[1]), y: Number(match[2])}; +} + +async function runChange( + layout: ReturnType | null, + changed: Element, + phase: {current: 'before' | 'after'}, +) { + const report = reportLayoutChange(layout, () => changed); + report.measureBefore(); + phase.current = 'after'; + report.animate(); + playLayoutChange(layout, changed); + await flushMicrotasks(); +} + +describe('the handle (spec:AST-105 FR1)', () => { + it('has no public members', () => { + const area = document.createElement('div'); + const handle = createAnimatedLayout(() => area); + expect(Object.keys(handle)).toEqual([]); + expect(Object.getOwnPropertyNames(handle)).toEqual([]); + expect(Object.isFrozen(handle)).toBe(true); + }); +}); + +describe('without a handle (spec:AST-105 FR2)', () => { + it('measures nothing and plays nothing', async () => { + const {changed, phase} = setUp( + '
    ', + ); + const a = document.getElementById('a') as HTMLElement; + placeAt(a, {before: [0, 0, 400, 40], after: [0, 80, 400, 40]}, phase); + await runChange(null, changed, phase); + expect(played).toEqual([]); + }); +}); + +describe('the walk (spec:AST-105 FR3, Coverage)', () => { + it('moves following siblings at every level up to the area, by their layout change, and nothing past it', async () => { + const {area, changed, phase} = setUp( + `
    +
    `, + ); + for (const [id, y] of [ + ['s2', 0], + ['s1', 50], + ['s0', 100], + ['outside', 700], + ] as const) { + placeAt( + document.getElementById(id) as HTMLElement, + {before: [0, y, 400, 40], after: [0, y + 80, 400, 40]}, + phase, + ); + } + await runChange( + createAnimatedLayout(() => area), + changed, + phase, + ); + const moved = played + .filter(p => p.keyframes[0].transform != null) + .map(p => (p.element as HTMLElement).id); + expect(moved.sort()).toEqual(['s0', 's1', 's2']); + for (const entry of played.filter(p => p.keyframes[0].transform != null)) { + expect(translateOf(entry).y).toBeCloseTo(-80); + expect(entry.options.composite).toBe('add'); + expect(entry.options.easing).toBe('linear'); + // Lands where layout put it. + expect(String(entry.keyframes.at(-1)?.transform)).toBe( + 'translate(0px, 0px)', + ); + } + }); + + it('takes the children of a display: contents wrapper and skips absolute, fixed, hidden and display: none elements', async () => { + const {area, changed, phase} = setUp( + `
    +
    +
    +
    + +
    `, + ); + for (const id of ['c1', 'c2', 'abs', 'fixed', 'hidden', 'none']) { + placeAt( + document.getElementById(id) as HTMLElement, + {before: [0, 10, 400, 40], after: [0, 90, 400, 40]}, + phase, + ); + } + await runChange( + createAnimatedLayout(() => area), + changed, + phase, + ); + const moved = played + .filter(p => p.keyframes[0].transform != null) + .map(p => (p.element as HTMLElement).id); + expect(moved.sort()).toEqual(['c1', 'c2']); + }); + + it('leaves an element whose rectangle did not change, and one outside the visible part before and after', async () => { + const {area, changed, phase} = setUp( + '
    ', + ); + placeAt( + document.getElementById('still') as HTMLElement, + {before: [0, 10, 400, 40]}, + phase, + ); + placeAt( + document.getElementById('far') as HTMLElement, + {before: [0, 2000, 400, 40], after: [0, 2080, 400, 40]}, + phase, + ); + await runChange( + createAnimatedLayout(() => area), + changed, + phase, + ); + expect(played.filter(p => p.keyframes[0].transform != null)).toEqual([]); + }); + + it('moves each element once for two changes in one frame, by the total', async () => { + document.body.innerHTML = + '
    '; + const area = document.getElementById('area') as HTMLElement; + const phase: {current: 'before' | 'after'} = {current: 'before'}; + placeAt(area, {before: [0, 0, 400, 600]}, phase); + placeAt( + document.getElementById('after') as HTMLElement, + {before: [0, 0, 400, 40], after: [0, 160, 400, 40]}, + phase, + ); + const layout = createAnimatedLayout(() => area); + const one = reportLayoutChange(layout, () => document.getElementById('g1')); + const two = reportLayoutChange(layout, () => document.getElementById('g2')); + one.measureBefore(); + two.measureBefore(); + phase.current = 'after'; + one.animate(); + two.animate(); + await flushMicrotasks(); + const moves = played.filter(p => (p.element as HTMLElement).id === 'after'); + expect(moves).toHaveLength(1); + expect(translateOf(moves[0]).y).toBeCloseTo(-160); + }); + + it('never scrolls the area', async () => { + const {area, changed, phase} = setUp( + '
    ', + ); + area.scrollTop = 0; + placeAt( + document.getElementById('a') as HTMLElement, + {before: [0, 0, 400, 40], after: [0, 80, 400, 40]}, + phase, + ); + const scrollTo = vi.fn(); + area.scrollTo = scrollTo; + await runChange( + createAnimatedLayout(() => area), + changed, + phase, + ); + expect(scrollTo).not.toHaveBeenCalled(); + expect(area.scrollTop).toBe(0); + }); +}); + +describe('one shared motion (spec:AST-105 FR4)', () => { + async function changeOnce(style = '') { + const {area, changed, phase} = setUp( + `
    `, + ); + placeAt( + document.getElementById('a') as HTMLElement, + {before: [0, 0, 400, 40], after: [0, 80, 400, 40]}, + phase, + ); + await runChange( + createAnimatedLayout(() => area), + changed, + phase, + ); + } + + it('plays the changed element and its followers on springs.settle, with one duration', async () => { + await changeOnce(); + const follower = played.find(p => (p.element as HTMLElement).id === 'a'); + const own = played.find(p => (p.element as HTMLElement).id === 'changed'); + expect(follower).toBeDefined(); + expect(own).toBeDefined(); + expect(own?.options.duration).toBe(follower?.options.duration); + // SwiftUI's smooth spring (0.5 s, no bounce) settles in about 0.6 s. + expect(Number(follower?.options.duration)).toBeGreaterThan(400); + expect(Number(follower?.options.duration)).toBeLessThan(900); + }); + + it("reads a theme's springs.settle from the area", async () => { + await changeOnce('--motion-springs-settle: 0.2s 0'); + const follower = played.find(p => (p.element as HTMLElement).id === 'a'); + expect(Number(follower?.options.duration)).toBeLessThan(400); + }); + + it('cross-fades with no travel under Reduce Motion on a coarse pointer', async () => { + media['(prefers-reduced-motion: reduce)'] = true; + media['(pointer: coarse)'] = true; + await changeOnce(); + expect(played).toHaveLength(2); + for (const entry of played) { + expect(entry.keyframes[0].transform).toBeUndefined(); + expect(entry.keyframes[0].opacity).toBe(0); + expect(Number(entry.options.duration)).toBeLessThanOrEqual(200); + } + }); + + it('changes at once under Reduce Motion on a fine pointer', async () => { + media['(prefers-reduced-motion: reduce)'] = true; + await changeOnce(); + expect(played).toEqual([]); + }); + + it('falls back when an engine rejects composite', async () => { + const accept = Element.prototype.animate; + Element.prototype.animate = function ( + this: Element, + keyframes: Keyframe[] | PropertyIndexedKeyframes | null, + options?: number | KeyframeAnimationOptions, + ) { + if (typeof options === 'object' && options.composite != null) { + throw new TypeError('composite unsupported'); + } + return accept.call(this, keyframes, options); + }; + await changeOnce(); + const follower = played.find(p => (p.element as HTMLElement).id === 'a'); + expect(follower).toBeDefined(); + expect(follower?.options.composite).toBeUndefined(); + }); +}); + +describe('the indicator transition', () => { + it('takes the spring as a linear() easing with a handle, and clears without one', () => { + document.body.innerHTML = '
    '; + const area = document.getElementById('area') as HTMLElement; + const chevron = document.getElementById( + 'chevron', + ) as unknown as SVGSVGElement; + applyLayoutChangeTransition( + createAnimatedLayout(() => area), + chevron, + ); + expect( + chevron.style.getPropertyValue('transition-timing-function'), + ).toMatch(/^linear\(0 0%, .*1 100%\)$/); + expect(chevron.style.getPropertyValue('transition-duration')).toMatch( + /^\d+ms$/, + ); + applyLayoutChangeTransition(null, chevron); + expect(chevron.style.getPropertyValue('transition-duration')).toBe(''); + }); + + it('is immediate under Reduce Motion', () => { + media['(prefers-reduced-motion: reduce)'] = true; + document.body.innerHTML = '
    '; + const area = document.getElementById('area') as HTMLElement; + const chevron = document.getElementById( + 'chevron', + ) as unknown as SVGSVGElement; + applyLayoutChangeTransition( + createAnimatedLayout(() => area), + chevron, + ); + expect(chevron.style.getPropertyValue('transition-duration')).toBe('0s'); + }); +}); diff --git a/packages/core/src/motion/animatedLayoutAdopters.test.tsx b/packages/core/src/motion/animatedLayoutAdopters.test.tsx new file mode 100644 index 0000000000000..e5b955080d4dd --- /dev/null +++ b/packages/core/src/motion/animatedLayoutAdopters.test.tsx @@ -0,0 +1,158 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file animatedLayoutAdopters.test.tsx + * @input vitest (jsdom), React Testing Library, Collapsible, TreeList, + * useAnimatedLayout, a recording stand-in for Element.animate and stubbed + * rectangles for the content after the changed component + * @output Checks each adopting component with and without a handle + * (`spec:AST-105` FR1, FR2): with one, a toggle glides the content after it + * inside the container and the chevron takes the spring; without one, + * nothing animates and the chevron keeps its own transition + * @position Testing; the walk and the motion are covered in + * animatedLayout.test.ts and, in real browsers, + * __tests__/animatedLayout.interaction.browser.spec.ts + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {render, renderHook, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {useRef, type ReactNode} from 'react'; +import {Collapsible} from '../Collapsible/Collapsible'; +import {TreeList} from '../TreeList/TreeList'; +import type {TreeListItemData} from '../TreeList/TreeListTypes'; +import { + useAnimatedLayout, + type AnimatedLayout, +} from '../hooks/useAnimatedLayout'; + +let animated: Element[] = []; +const originalAnimate = Element.prototype.animate; + +beforeEach(() => { + animated = []; + Element.prototype.animate = function (this: Element) { + animated.push(this); + return {} as Animation; + }; +}); + +afterEach(() => { + Element.prototype.animate = originalAnimate; + vi.restoreAllMocks(); +}); + +/** + * A container with `children` and a following paragraph that sits 80 px lower + * once anything has been measured (jsdom lays nothing out). + */ +function Area({ + children, + withHandle, +}: { + children: (layout: AnimatedLayout | undefined) => ReactNode; + withHandle: boolean; +}) { + const containerRef = useRef(null); + const layout = useAnimatedLayout({containerRef}); + return ( +
    + {children(withHandle ? layout : undefined)} +

    Notes

    +
    + ); +} + +function stubGeometry() { + const area = screen.getByTestId('area'); + const after = screen.getByTestId('after'); + vi.spyOn(area, 'getBoundingClientRect').mockReturnValue( + new DOMRect(0, 0, 400, 600), + ); + let reads = 0; + vi.spyOn(after, 'getBoundingClientRect').mockImplementation(() => { + reads += 1; + // The first read is the measurement before the change commits. + return new DOMRect(0, reads === 1 ? 40 : 120, 400, 20); + }); + return after; +} + +const flush = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + +describe('useAnimatedLayout', () => { + it('returns the same member-free handle on every render', () => { + const {result, rerender} = renderHook(() => + useAnimatedLayout({containerRef: useRef(null)}), + ); + const first = result.current; + rerender(); + expect(result.current).toBe(first); + expect(Object.keys(first)).toEqual([]); + }); +}); + +describe.each([ + { + name: 'Collapsible', + render: (layout: AnimatedLayout | undefined) => ( + + Hidden content + + ), + toggle: () => screen.getByRole('button', {name: 'Details'}), + chevron: () => + screen + .getByRole('button', {name: 'Details'}) + .querySelector(':scope > .astryx-icon'), + }, + { + name: 'TreeList', + render: (layout: AnimatedLayout | undefined) => ( + + ), + toggle: () => + document.querySelector('[data-tree-toggle]') ?? + document.body, + chevron: () => + document.querySelector('[data-tree-toggle] > .astryx-icon'), + }, +])('$name adopts animatedLayout (spec:AST-105 FR1, FR2)', component => { + it('glides the content after it inside the container with a handle', async () => { + const user = userEvent.setup(); + render({component.render}); + const after = stubGeometry(); + await user.click(component.toggle()); + await flush(); + expect(animated).toContain(after); + expect( + component.chevron()?.style.getPropertyValue('transition-timing-function'), + ).toMatch(/^linear\(/); + }); + + it('changes nothing without a handle', async () => { + const user = userEvent.setup(); + render({component.render}); + stubGeometry(); + await user.click(component.toggle()); + await flush(); + expect(animated).toEqual([]); + expect( + component.chevron()?.style.getPropertyValue('transition-duration'), + ).toBe(''); + }); +}); From ac58ce323cf6d431858e7e354a8bdc2eaa085dd3 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 06:47:27 -0700 Subject: [PATCH 07/48] chore(storybook): the story's internal-reporting demos say they are story-only --- apps/storybook/stories/AnimatedLayout.stories.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/storybook/stories/AnimatedLayout.stories.tsx b/apps/storybook/stories/AnimatedLayout.stories.tsx index 628fe48052558..e9a4e824f5ef5 100644 --- a/apps/storybook/stories/AnimatedLayout.stories.tsx +++ b/apps/storybook/stories/AnimatedLayout.stories.tsx @@ -16,8 +16,9 @@ import {TextInput} from '@astryxdesign/core/TextInput'; import {Button} from '@astryxdesign/core/Button'; import {useAnimatedLayout} from '@astryxdesign/core/hooks'; import type {AnimatedLayout as AnimatedLayoutHandle} from '@astryxdesign/core/hooks'; -// The internal reporting half of the module: a plain app element whose size -// changes reports it here, as Astryx components do internally. +// Story-only: these demos drive the internal reporting half directly, as an +// adopting component does, to show cases no adopter covers yet. It is not app +// API: an app passes the handle to a component (spec:AST-105 FR1). import { playLayoutChange, reportLayoutChange, From 427f850bbff6560a763166ae7a8ae699841a4c85 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 06:53:19 -0700 Subject: [PATCH 08/48] chore(docsite): the ShadCN route lock lists the useAnimatedLayout hook page --- internal/shadcn-registry/routes.lock.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/shadcn-registry/routes.lock.json b/internal/shadcn-registry/routes.lock.json index 97b4458b92bda..b09c556e1629f 100644 --- a/internal/shadcn-registry/routes.lock.json +++ b/internal/shadcn-registry/routes.lock.json @@ -3744,6 +3744,11 @@ "path": "examples/visually-hidden/supplementary-context", "aliases": [] }, + { + "name": "hook-use-animated-layout", + "path": "hooks/use-animated-layout", + "aliases": [] + }, { "name": "hook-use-announce", "path": "hooks/use-announce", From 1bccf04198a254181f64fbe7b3c472877cbe3be2 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 07:02:15 -0700 Subject: [PATCH 09/48] fix(core): the animated layout reads springs.settle from the internal registry only Motion roles are internal (spec:AST-094 DEC-11): the glide, the changed element and the chevron share the registry's one value, and a scope's --motion-springs-settle changes nothing. The tests assert that instead of an override. --- ...animatedLayout.interaction.browser.spec.ts | 13 ++++++---- .../core/src/motion/animatedLayout.test.ts | 10 +++++--- packages/core/src/motion/animatedLayout.ts | 25 ++++++++----------- 3 files changed, 25 insertions(+), 23 deletions(-) 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 510dbd904a731..239edfee464d9 100644 --- a/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts +++ b/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts @@ -478,13 +478,16 @@ test.describe('spec:AST-105 FR2–FR4', () => { expect(follower[0].duration).toBeLessThan(1200); }); - test("a theme's springs.settle reaches both (FR4)", async ({page}) => { + test("a scope's --motion-springs-settle changes nothing: the role is internal (FR4)", async ({ + page, + }) => { + await open(page, PAGE); + await change(page); + const [shared] = await page.evaluate(() => window.motionOf('#a')); await open(page, PAGE, '#area { --motion-springs-settle: 0.2s 0; }'); await change(page); - const [follower] = await page.evaluate(() => window.motionOf('#a')); - const [grow] = await page.evaluate(() => window.motionOf('#grow')); - expect(follower.duration).toBeLessThan(500); - expect(grow.duration).toBeCloseTo(follower.duration, 3); + const [scoped] = await page.evaluate(() => window.motionOf('#a')); + expect(scoped.duration).toBeCloseTo(shared.duration, 3); }); test('under Reduce Motion with a coarse pointer, both cross-fade with no travel (FR4)', async ({ diff --git a/packages/core/src/motion/animatedLayout.test.ts b/packages/core/src/motion/animatedLayout.test.ts index 7998ada3eb1f2..41bceb6630f12 100644 --- a/packages/core/src/motion/animatedLayout.test.ts +++ b/packages/core/src/motion/animatedLayout.test.ts @@ -304,10 +304,14 @@ describe('one shared motion (spec:AST-105 FR4)', () => { expect(Number(follower?.options.duration)).toBeLessThan(900); }); - it("reads a theme's springs.settle from the area", async () => { + it("ignores a scope's --motion-springs-settle: the role is internal", async () => { + await changeOnce(); + const shared = played.find(p => (p.element as HTMLElement).id === 'a'); + played = []; + document.body.innerHTML = ''; await changeOnce('--motion-springs-settle: 0.2s 0'); - const follower = played.find(p => (p.element as HTMLElement).id === 'a'); - expect(Number(follower?.options.duration)).toBeLessThan(400); + const scoped = played.find(p => (p.element as HTMLElement).id === 'a'); + expect(scoped?.options.duration).toBe(shared?.options.duration); }); it('cross-fades with no travel under Reduce Motion on a coarse pointer', async () => { diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index 0d616a9706971..0477e1a055be9 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -3,7 +3,7 @@ /** * @file animatedLayout.ts * @input The DOM (layout rectangles, computed style), Web Animations, the - * `springs.settle` motion role (motion/roles.ts) and the Reduce Motion + * internal `springs.settle` motion role (motion/roles.ts) and the Reduce Motion * cross-fade clock (uikit/timing.ts) * @output Exports `createAnimatedLayout` and the opaque `AnimatedLayout` * handle, `reportLayoutChange`, `playLayoutChange` and @@ -41,7 +41,8 @@ * (`spec:AST-105` FR2). */ -import {motionRole, parseMotionRoleValue} from './roles'; +import {motionRole} from './roles'; +import {isSpring} from './roleValue'; import {solveSpring, toSpring, type SpringDescription} from './spring'; import {registerSpringsTable, SETTLE_SPRING} from './uikit/springs'; import {reducedMotionCrossFade} from './uikit/timing'; @@ -84,20 +85,14 @@ function matches(element: Element, query: string): boolean { } /** - * `springs.settle` as the area's theme sets it: the role's custom property - * when it holds a spring, the table's value otherwise. + * `springs.settle` from the internal role registry: one shared value for the + * glide, the changed element and the indicator. Motion roles are internal + * (`spec:AST-094` DEC-11): no theme, scope or custom property changes it. */ -function settleSpring(area: Element): SpringDescription { +function settleSpring(): SpringDescription { registerSpringsTable(); - const role = motionRole(ROLE_ID); - if (role == null) { - return SETTLE_SPRING; - } - const text = - area.ownerDocument.defaultView - ?.getComputedStyle(area) - .getPropertyValue(role.variable) ?? ''; - return parseMotionRoleValue('spring', text) ?? SETTLE_SPRING; + const value = motionRole(ROLE_ID)?.value; + return isSpring(value) ? value : SETTLE_SPRING; } function springProgress(description: SpringDescription): SpringProgress { @@ -125,7 +120,7 @@ function layoutMotion(area: Element): LayoutMotion { easing: `cubic-bezier(${x1}, ${y1}, ${x2}, ${y2})`, }; } - return {kind: 'spring', progress: springProgress(settleSpring(area))}; + return {kind: 'spring', progress: springProgress(settleSpring())}; } /** Linear keyframes sampled from the spring, one per frame. */ From b894315cf12601b93958b0b56353c565ed3601e8 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 07:32:38 -0700 Subject: [PATCH 10/48] test(core): the animated-layout browser proofs settle between changes and compare scrolling with and without a handle Run in real Chromium (all 20 cases, three repeats): the page helper's microtask wait is plain JavaScript; the out-of-view and nested-area cases wait for the first change's glide to finish before the second; and the FR3 scroll case compares the area's scroll frame by frame with and without a handle, since the browser's own scroll anchoring moves it either way. --- ...animatedLayout.interaction.browser.spec.ts | 59 ++++++++++++------- 1 file changed, 39 insertions(+), 20 deletions(-) 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 239edfee464d9..3f514018c2199 100644 --- a/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts +++ b/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts @@ -78,9 +78,7 @@ window.change = async ({changed, mutate, area = '#area', handle = true}) => { new Function(mutate)(); report.animate(); AnimatedLayout.playLayoutChange(layout, target); - await new Promise(resolve => { - queueMicrotask(resolve); - }); + await new Promise(resolve => queueMicrotask(resolve)); }; window.motionOf = selector => { const element = document.querySelector(selector); @@ -130,6 +128,15 @@ async function change( }); } +/** Until every animation on the page has finished. */ +async function settle(page: Page) { + await expect + .poll(async () => page.evaluate(() => document.getAnimations().length), { + timeout: 5000, + }) + .toBe(0); +} + /** The translate an animation starts from, in px. */ function startOffset(first: string | null): {x: number; y: number} { const match = /translate\(([-\d.e]+)px,\s*([-\d.e]+)px\)/.exec(first ?? ''); @@ -311,6 +318,7 @@ test.describe('spec:AST-105 Coverage', () => { // Far below the fold before and after. expect(await page.evaluate(() => window.motionOf('#r39'))).toEqual([]); // A change above the fold, out of sight: the changed element plays nothing. + await settle(page); await page.evaluate(() => { (document.querySelector('#area') as HTMLElement).scrollTop = 1200; }); @@ -383,7 +391,7 @@ test.describe('spec:AST-105 Coverage', () => { expect(await page.evaluate(() => window.motionOf('#outerAfter'))).toEqual( [], ); - await page.waitForTimeout(700); + await settle(page); await change(page, { changed: '#outerGrow', mutate: `document.querySelector('#outerGrow').classList.add('open')`, @@ -442,22 +450,33 @@ test.describe('spec:AST-105 FR2–FR4', () => { }); test('the glide never scrolls the area (FR3)', async ({page}) => { - await open(page, PAGE); - await page.evaluate(() => { - (document.querySelector('#area') as HTMLElement).scrollTop = 40; - }); - const scrolls = await page.evaluate(async (mutate: string) => { - const area = document.querySelector('#area') as HTMLElement; - const seen: number[] = []; - area.addEventListener('scroll', () => seen.push(area.scrollTop)); - await window.change({changed: '#grow', mutate}); - await new Promise(resolve => { - setTimeout(resolve, 700); - }); - return {seen, top: area.scrollTop}; - }, OPEN_GROW); - expect(scrolls.seen).toEqual([]); - expect(scrolls.top).toBe(40); + // The same change with and without a handle, from the same scroll + // position: the area's scroll (the browser's own scroll anchoring + // included) is identical, frame by frame, so the glide adds none. + const scrollsOf = async (handle: boolean) => { + await open(page, PAGE); + return page.evaluate( + async ({mutate, withHandle}) => { + const area = document.querySelector('#area') as HTMLElement; + area.scrollTop = 40; + const frame = async () => + new Promise(resolve => requestAnimationFrame(resolve)); + await frame(); + await frame(); + await window.change({changed: '#grow', mutate, handle: withHandle}); + const tops: number[] = []; + for (let i = 0; i < 40; i++) { + tops.push(area.scrollTop); + await frame(); + } + return tops; + }, + {mutate: OPEN_GROW, withHandle: handle}, + ); + }; + const without = await scrollsOf(false); + const withHandle = await scrollsOf(true); + expect(withHandle).toEqual(without); }); test('the changed element and its followers start on one frame on springs.settle (FR4)', async ({ From ea01d0c0d83d07875a28d5b9d15146936cafcc4b Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 08:28:15 -0700 Subject: [PATCH 11/48] fix(core): animated layout measures every grouped change, drops refused ones and retargets on the compositor - A single-mode CollapsibleGroup measures every item a toggle changes (the one that closes as well as the one that opens), so the items between glide with the content after the group. Items holding a handle register their measurement with the group. - A measurement no commit claims by the next frame (a controlled owner refused the toggle) is dropped, so it never moves unchanged rows later. A change nothing measured (an owner set isOpen directly) lands at once, entrance included. - The follower glide is one replace-composited translate, which the compositor runs (architecture:react-component-runtime INV12). An element already gliding is retargeted: its glide stops and the new one starts from where it is on screen, so it keeps moving without a jump. - INV12's source boundary check (no layout property animated, the walk only through parentElement / nextElementSibling, nothing public, only the hook and the adopters use it) and a real-browser case on a throttled phone over a 10-level by 50-sibling tree with two changes in one frame. --- packages/core/src/Collapsible/Collapsible.tsx | 60 ++++++-- .../core/src/Collapsible/CollapsibleGroup.tsx | 34 ++++- .../Collapsible/CollapsibleGroupContext.tsx | 6 + packages/core/src/TreeList/TreeList.tsx | 4 +- ...animatedLayout.interaction.browser.spec.ts | 120 +++++++++++++-- .../motion/animatedLayout.boundary.test.ts | 129 ++++++++++++++++ .../core/src/motion/animatedLayout.test.ts | 119 +++++++++++---- packages/core/src/motion/animatedLayout.ts | 140 ++++++++++++------ .../motion/animatedLayoutAdopters.test.tsx | 86 ++++++++++- 9 files changed, 592 insertions(+), 106 deletions(-) create mode 100644 packages/core/src/motion/animatedLayout.boundary.test.ts diff --git a/packages/core/src/Collapsible/Collapsible.tsx b/packages/core/src/Collapsible/Collapsible.tsx index ba5488d01d433..a3f8ddb815939 100644 --- a/packages/core/src/Collapsible/Collapsible.tsx +++ b/packages/core/src/Collapsible/Collapsible.tsx @@ -29,6 +29,8 @@ import { use, + useCallback, + useEffect, useId, useLayoutEffect, useMemo, @@ -54,7 +56,10 @@ import { reportLayoutChange, type AnimatedLayout, } from '../motion/animatedLayout'; -import {CollapsibleGroupPresentationContext} from './CollapsibleGroupContext'; +import { + CollapsibleGroupContext, + CollapsibleGroupPresentationContext, +} from './CollapsibleGroupContext'; import type {CollapsibleChevronPosition} from './CollapsibleGroupContext'; import {Icon} from '../Icon'; import {mergeProps} from '../utils'; @@ -355,44 +360,66 @@ export function Collapsible({ value, }); - // Activation is blocked by this guard rather than the native `disabled` - // attribute, so the trigger keeps `aria-disabled` semantics and stays - // discoverable. A native `disabled` button would silently swallow events - // (e.g. a wrapping tooltip's hover) — the system-wide disabled convention. // With an `animatedLayout` handle, the content's size change animates: the // content after this Collapsible, up to the handle's container, glides, and // the revealed content plays its own entrance on the same timing // (`architecture:react-component-runtime` INV12). Without one, nothing // changes. const contentRef = useRef(null); + const triggerRef = useRef(null); const layoutChange = useMemo( () => reportLayoutChange(animatedLayout, contentRef), [animatedLayout], ); + // Before a change commits: the chevron (the trigger's Icon, its `icon` + // theming target) turns on the glide's motion, and the content after this + // Collapsible is measured where it is now. + const measureChange = useCallback(() => { + applyLayoutChangeTransition( + animatedLayout, + triggerRef.current?.querySelector(':scope > .astryx-icon') ?? + null, + ); + layoutChange.measureBefore(); + }, [animatedLayout, layoutChange]); + // In a group, a toggle can change other items too (opening one in single + // mode closes the open one): the group measures every item it changes, so + // the rows between glide with the rest. + const registerLayoutChange = use( + CollapsibleGroupContext, + )?.registerLayoutChange; + useEffect(() => { + if ( + animatedLayout == null || + value == null || + registerLayoutChange == null + ) { + return undefined; + } + return registerLayoutChange(value, measureChange); + }, [animatedLayout, value, registerLayoutChange, measureChange]); const wasOpenRef = useRef(isOpen); useLayoutEffect(() => { if (wasOpenRef.current === isOpen) { return; } wasOpenRef.current = isOpen; - layoutChange.animate(); - if (isOpen && contentRef.current != null) { + // A change nothing measured (an owner set `isOpen` directly) lands at + // once, entrance included, so the content and what follows stay in step. + if (layoutChange.animate() && isOpen && contentRef.current != null) { playLayoutChange(animatedLayout, contentRef.current); } }, [isOpen, layoutChange, animatedLayout]); - const handleToggle = (event: React.MouseEvent) => { + // Activation is blocked by this guard rather than the native `disabled` + // attribute, so the trigger keeps `aria-disabled` semantics and stays + // discoverable. A native `disabled` button would silently swallow events + // (e.g. a wrapping tooltip's hover) — the system-wide disabled convention. + const handleToggle = () => { if (isDisabled) { return; } - // Before the change commits: the chevron turns on the glide's motion, - // and the content after this Collapsible is measured where it is now. - // The chevron is the trigger's Icon (its `icon` theming target). - applyLayoutChangeTransition( - animatedLayout, - event.currentTarget.querySelector(':scope > .astryx-icon'), - ); - layoutChange.measureBefore(); + measureChange(); toggle(); }; @@ -449,6 +476,7 @@ export function Collapsible({ )} {...props}>
  • -
    - ); -} - -function LoadMore({animatedLayout}: {animatedLayout: AnimatedLayoutHandle}) { - const [loaded, setLoaded] = useState(false); - const {ref, before} = useReportedChange(animatedLayout, loaded); - return ( -
    -

    Load more: a skeleton replaced by taller content

    -
    - {loaded ? ( -
    - Alex Rivera - Product designer · Seattle - Joined in 2021 · 48 projects - Prefers async reviews; pairs on Thursdays. -
    - ) : ( -
    - )} -
    -
    -
    -
    - ); -} - -function Tags({animatedLayout}: {animatedLayout: AnimatedLayoutHandle}) { - // Three chips nearly fill the narrow row, so the next one wraps. - const [tags, setTags] = useState(['design', 'motion', 'layout']); - const {ref, before} = useReportedChange(animatedLayout, tags.length); - return ( -
    -

    “+ Add tag”: chips that wrap to a new line

    -
    - {tags.map(tag => ( - - {tag} - - ))} -
    -
    -
    -
    - ); -} - -function Section({title, children}: {title: string; children: ReactNode}) { - return ( -
    -

    {title}

    - {children} -
    - ); -} - -/** - * The sticky header and the absolute badge stay put; the `display: contents` - * wrapper's children move on their own. - */ -function Demo() { - const containerRef = useRef(null); - const animatedLayout = useAnimatedLayout({containerRef}); - return ( -
    - -
    - Sticky header (stays put) -
    -
    -
    -
    - -
    -
    -
    -
    - -

    Ships in 2 business days.

    -

    Free returns for 30 days.

    -

    Tracking arrives by email.

    -
    -
    -
    - - - -
    - A plain app div with an absolute badge (the badge rides with it) - - New - -
    -
    -
    Inside a display: contents wrapper (1)
    -
    Inside a display: contents wrapper (2)
    -
    - {Array.from({length: 8}, (_, i) => ( -
    - Plain app content after the changes ({i + 1}) -
    - ))} -
    -
    -
    - ); -} - -/** - * The ScrollableArea is the container: `useAnimatedLayout({containerRef})` - * returns a handle, and each component that changes size takes it. - */ -export const AnimatedLayout: Story = { - name: 'Animated layout', - render: () => , -}; - -function PanelDemo() { - const containerRef = useRef(null); - const animatedLayout = useAnimatedLayout({containerRef}); - return ( -
    -
    - -

    Cache size, retries and timeouts.

    -

    Changes apply on the next sync.

    -
    -
    -
    Content after, in an app panel
    -
    More content
    -
    - ); -} - -/** An app's own panel as the container. */ -export const AppPanel: Story = { - name: 'App panel', - render: () => , -}; - -const COMPARE_TREE: TreeListItemData[] = [ - { - id: 'src', - label: 'src', - children: [ - {id: 'app', label: 'App.tsx'}, - {id: 'index', label: 'index.ts'}, - {id: 'styles', label: 'styles.css'}, - ], - }, - {id: 'readme', label: 'README.md'}, -]; - -function CompareColumn({ - title, - isAnimated, -}: { - title: string; - isAnimated: boolean; -}) { - const containerRef = useRef(null); - const handle = useAnimatedLayout({containerRef}); - const animatedLayout = isAnimated ? handle : undefined; - return ( -
    -
    {title}
    -
    -
    - -

    Cache size, retries and timeouts.

    -

    Changes apply on the next sync.

    -
    -
    -
    Content after the Collapsible
    -
    - -
    -
    Content after the tree
    -
    More content
    -
    -
    - ); -} - -/** - * The same Collapsible and TreeList without a handle (content after them - * jumps) and with one (it glides), side by side, for any pointer. - */ -export const BeforeAndAfter: Story = { - name: 'Before and after', - render: () => ( -
    - - -
    - ), -}; diff --git a/packages/core/src/Collapsible/Collapsible.doc.mjs b/packages/core/src/Collapsible/Collapsible.doc.mjs index 9dc763a561031..55a13f275832f 100644 --- a/packages/core/src/Collapsible/Collapsible.doc.mjs +++ b/packages/core/src/Collapsible/Collapsible.doc.mjs @@ -93,12 +93,6 @@ export const docs = { description: 'Identifier used for group coordination. When set inside a CollapsibleGroup, the group owns open state and its onChange is the notification callback.', }, - { - name: 'animatedLayout', - type: 'AnimatedLayout', - description: - 'The handle from useAnimatedLayout({containerRef}). With it, opening and closing glides the content after this Collapsible, up to that container, on one shared motion role instead of jumping, and the revealed content and the chevron move on the same motion. Without it, the content after it takes its place at once.', - }, { name: 'ref', type: 'React.Ref', diff --git a/packages/core/src/Collapsible/Collapsible.spec.md b/packages/core/src/Collapsible/Collapsible.spec.md index d7fa2480d84e1..79a6a6061d87c 100644 --- a/packages/core/src/Collapsible/Collapsible.spec.md +++ b/packages/core/src/Collapsible/Collapsible.spec.md @@ -75,12 +75,11 @@ Consumer migration instructions belong in consumer docs and release notes. ## Public concepts -| Concept | Closed values or states | Meaning | Availability | Default | Owner | Stability | Invalid-value behavior | -| ---------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | -------------------------------- | ------------------------------------------------ | -------------------------------------------- | --------- | ----------------------------------------- | -| chevron position | `start`, `end` | logical side of the trigger label that owns Collapsible's disclosure chevron | Collapsible and CollapsibleGroup | `end` | `component:Collapsible` | stable | rejected by the public type | -| disclosure state | collapsed, expanded | whether the controlled region is hidden or revealed | every Collapsible | existing state owner | `component:Collapsible` or group state owner | stable | existing controlled/uncontrolled behavior | -| group position default | absent, `start`, `end` | common position supplied to direct group items | CollapsibleGroup | absent, so item default resolves to `end` | `component:Collapsible` | stable | rejected by the public type | -| animated layout | absent, a `useAnimatedLayout` handle | whether opening and closing glide the content after the Collapsible, up to the handle's container (`spec:AST-105`) | every Collapsible | absent: content after it takes its place at once | `spec:AST-105` | stable | rejected by the public type | +| Concept | Closed values or states | Meaning | Availability | Default | Owner | Stability | Invalid-value behavior | +| ---------------------- | ----------------------- | ---------------------------------------------------------------------------- | -------------------------------- | ----------------------------------------- | -------------------------------------------- | --------- | ----------------------------------------- | +| chevron position | `start`, `end` | logical side of the trigger label that owns Collapsible's disclosure chevron | Collapsible and CollapsibleGroup | `end` | `component:Collapsible` | stable | rejected by the public type | +| disclosure state | collapsed, expanded | whether the controlled region is hidden or revealed | every Collapsible | existing state owner | `component:Collapsible` or group state owner | stable | existing controlled/uncontrolled behavior | +| group position default | absent, `start`, `end` | common position supplied to direct group items | CollapsibleGroup | absent, so item default resolves to `end` | `component:Collapsible` | stable | rejected by the public type | The public prop is named `chevronPosition`. The exported value type is `CollapsibleChevronPosition`. `placement`, `side`, and rendering terminology are @@ -88,18 +87,17 @@ not parallel public names for this axis. ## Behavioral and layout contract -| ID | Invariant | Basis | -| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -| FR1 | Collapsible MUST expose `chevronPosition?: 'start' \| 'end'`; omission resolves to `end`. | owner API decision; preserves released default | -| FR2 | `end` MUST render Collapsible's chevron after the trigger label in logical order. Collapsed `end` points downward; expanded `end` points upward. | shipped trailing behavior plus approved position contract | -| FR3 | `start` MUST render Collapsible's chevron before the trigger label in logical order. Collapsed `start` points inward toward the label/content, so its inline direction mirrors under RTL; expanded `start` points downward. | approved leading-disclosure behavior | -| FR4 | Position changes the chevron glyph/direction as well as DOM order. Reusing a vertical down chevron and rotating it sideways MUST NOT make collapsed `start` point away from its content. | direction and representation decision | -| FR5 | Collapsible owns and renders the chevron for both positions. The position axis MUST NOT accept `none`, arbitrary React content, a cue callback, a renderer, or another replacement escape hatch. | constrained public-axis decision | -| FR6 | Trigger-label content MUST retain the usable row width needed by composed labels at either position. Moving the chevron to `start` MUST NOT strand the label at the opposite edge or prevent composed label content from using the remaining row. | approved layout rationale and existing grow behavior | -| FR7 | CollapsibleGroup MAY provide one `chevronPosition` default to its direct Collapsible items. A direct item's explicit position MUST override the group default. | approved group-coherence behavior | -| FR8 | A Collapsible nested inside another item's revealed content MUST begin a new presentation scope and use its own explicit position or the component default. The outer group's position MUST NOT leak into the nested disclosure. | approved nested isolation behavior | -| FR9 | Chevron position MUST NOT change disclosure semantics. The same trigger remains a button, retains `aria-expanded`, identifies the controlled region with `aria-controls`, and follows the existing state owner. | existing accessibility and behavior contract | -| FR10 | Collapsible MUST accept `animatedLayout?: AnimatedLayout`. With a handle, opening and closing report the content's size change through it, so the content after the Collapsible glides inside the handle's container, and the revealed content and the chevron move on the same motion (`spec:AST-105` FR1–FR4). Without one, opening and closing are unchanged. The prop MUST NOT change disclosure semantics. | `spec:AST-105` FR1, FR2 | +| ID | Invariant | Basis | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| FR1 | Collapsible MUST expose `chevronPosition?: 'start' \| 'end'`; omission resolves to `end`. | owner API decision; preserves released default | +| FR2 | `end` MUST render Collapsible's chevron after the trigger label in logical order. Collapsed `end` points downward; expanded `end` points upward. | shipped trailing behavior plus approved position contract | +| FR3 | `start` MUST render Collapsible's chevron before the trigger label in logical order. Collapsed `start` points inward toward the label/content, so its inline direction mirrors under RTL; expanded `start` points downward. | approved leading-disclosure behavior | +| FR4 | Position changes the chevron glyph/direction as well as DOM order. Reusing a vertical down chevron and rotating it sideways MUST NOT make collapsed `start` point away from its content. | direction and representation decision | +| FR5 | Collapsible owns and renders the chevron for both positions. The position axis MUST NOT accept `none`, arbitrary React content, a cue callback, a renderer, or another replacement escape hatch. | constrained public-axis decision | +| FR6 | Trigger-label content MUST retain the usable row width needed by composed labels at either position. Moving the chevron to `start` MUST NOT strand the label at the opposite edge or prevent composed label content from using the remaining row. | approved layout rationale and existing grow behavior | +| FR7 | CollapsibleGroup MAY provide one `chevronPosition` default to its direct Collapsible items. A direct item's explicit position MUST override the group default. | approved group-coherence behavior | +| FR8 | A Collapsible nested inside another item's revealed content MUST begin a new presentation scope and use its own explicit position or the component default. The outer group's position MUST NOT leak into the nested disclosure. | approved nested isolation behavior | +| FR9 | Chevron position MUST NOT change disclosure semantics. The same trigger remains a button, retains `aria-expanded`, identifies the controlled region with `aria-controls`, and follows the existing state owner. | existing accessibility and behavior contract | ### Allowed variation @@ -196,14 +194,13 @@ to the Group container anatomy here. ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | -| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | -| FR1–FR5 | Collapsible prop/type, DOM-order, glyph/direction, and no-extra-values tests | omitted/end/start; collapsed/expanded; LTR/RTL | default changes, `start` follows the label, collapsed leading cue points outward, or an unsupported cue value is admitted | audit:Collapsible/public-api | -| FR6 | composed trigger layout and geometry tests | plain text; composed label with trailing content; start/end | label becomes shrink-wrapped/stranded or composed content cannot use remaining row | audit:Collapsible/layout | -| FR7–FR8, ORD1 | group inheritance, item override, and nested reset tests | group start/end; item override; nested omission | group value is ignored, item cannot override, or position leaks into nested content | audit:Collapsible/behavior | -| FR9, AR1–AR3, ORD2 | disclosure semantics via `DISCLOSURE_PATTERN`, plus component-owned chevron and real RTL rendering | collapsed/expanded; controlled/uncontrolled; enabled/disabled; start/end; LTR/RTL | position changes state behavior, the trigger loses its controlled-content relationship, content visibility diverges from state, accessible output duplicates, or leading direction fails to mirror | audit:Collapsible/accessibility | -| FR10 | `animatedLayout.test.ts` and `animatedLayout.interaction.browser.spec.ts` (`spec:AST-105` Verification), plus `animatedLayoutAdopters.test.tsx` (Collapsible with and without a handle) | no handle; a handle; opening and closing | -| Theming anatomy | focused target-reflection assertions plus `themingTargets.test.ts` | standalone/grouped; open/closed; enabled/disabled; start/end; divided/density | a style-driving axis or state disappears from its owning target, a target loses its anatomy owner, or the chevron stops delegating to Icon | audit:Collapsible/theming | +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| ------------------ | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | +| FR1–FR5 | Collapsible prop/type, DOM-order, glyph/direction, and no-extra-values tests | omitted/end/start; collapsed/expanded; LTR/RTL | default changes, `start` follows the label, collapsed leading cue points outward, or an unsupported cue value is admitted | audit:Collapsible/public-api | +| FR6 | composed trigger layout and geometry tests | plain text; composed label with trailing content; start/end | label becomes shrink-wrapped/stranded or composed content cannot use remaining row | audit:Collapsible/layout | +| FR7–FR8, ORD1 | group inheritance, item override, and nested reset tests | group start/end; item override; nested omission | group value is ignored, item cannot override, or position leaks into nested content | audit:Collapsible/behavior | +| FR9, AR1–AR3, ORD2 | disclosure semantics via `DISCLOSURE_PATTERN`, plus component-owned chevron and real RTL rendering | collapsed/expanded; controlled/uncontrolled; enabled/disabled; start/end; LTR/RTL | position changes state behavior, the trigger loses its controlled-content relationship, content visibility diverges from state, accessible output duplicates, or leading direction fails to mirror | audit:Collapsible/accessibility | +| Theming anatomy | focused target-reflection assertions plus `themingTargets.test.ts` | standalone/grouped; open/closed; enabled/disabled; start/end; divided/density | a style-driving axis or state disappears from its owning target, a target loses its anatomy owner, or the chevron stops delegating to Icon | audit:Collapsible/theming | ## Decision log diff --git a/packages/core/src/Collapsible/Collapsible.tsx b/packages/core/src/Collapsible/Collapsible.tsx index a3f8ddb815939..db4ba0d6eb55c 100644 --- a/packages/core/src/Collapsible/Collapsible.tsx +++ b/packages/core/src/Collapsible/Collapsible.tsx @@ -27,16 +27,7 @@ * - /packages/cli/assets/templates/blocks/components/Collapsible/ (showcase blocks) */ -import { - use, - useCallback, - useEffect, - useId, - useLayoutEffect, - useMemo, - useRef, - type ReactNode, -} from 'react'; +import {use, useId, type ReactNode} from 'react'; import * as stylex from '@stylexjs/stylex'; import { borderVars, @@ -50,16 +41,7 @@ import { } from '../theme/tokens.stylex'; import {useCollapsible} from './useCollapsible'; -import { - applyLayoutChangeTransition, - playLayoutChange, - reportLayoutChange, - type AnimatedLayout, -} from '../motion/animatedLayout'; -import { - CollapsibleGroupContext, - CollapsibleGroupPresentationContext, -} from './CollapsibleGroupContext'; +import {CollapsibleGroupPresentationContext} from './CollapsibleGroupContext'; import type {CollapsibleChevronPosition} from './CollapsibleGroupContext'; import {Icon} from '../Icon'; import {mergeProps} from '../utils'; @@ -260,12 +242,6 @@ export interface CollapsibleProps extends BaseProps { * CollapsibleGroup owns grouped state and calls its `onChange` instead. */ onOpenChange?: (isOpen: boolean) => void; - /** - * The handle from `useAnimatedLayout({containerRef})`. With it, opening and - * closing glides the content after this Collapsible, up to that container, - * instead of jumping, and the chevron turns on the same timing. - */ - animatedLayout?: AnimatedLayout; /** * Logical position of the disclosure chevron. @@ -341,7 +317,6 @@ export function Collapsible({ onOpenChange, chevronPosition, value, - animatedLayout, ref, xstyle, className, @@ -360,57 +335,6 @@ export function Collapsible({ value, }); - // With an `animatedLayout` handle, the content's size change animates: the - // content after this Collapsible, up to the handle's container, glides, and - // the revealed content plays its own entrance on the same timing - // (`architecture:react-component-runtime` INV12). Without one, nothing - // changes. - const contentRef = useRef(null); - const triggerRef = useRef(null); - const layoutChange = useMemo( - () => reportLayoutChange(animatedLayout, contentRef), - [animatedLayout], - ); - // Before a change commits: the chevron (the trigger's Icon, its `icon` - // theming target) turns on the glide's motion, and the content after this - // Collapsible is measured where it is now. - const measureChange = useCallback(() => { - applyLayoutChangeTransition( - animatedLayout, - triggerRef.current?.querySelector(':scope > .astryx-icon') ?? - null, - ); - layoutChange.measureBefore(); - }, [animatedLayout, layoutChange]); - // In a group, a toggle can change other items too (opening one in single - // mode closes the open one): the group measures every item it changes, so - // the rows between glide with the rest. - const registerLayoutChange = use( - CollapsibleGroupContext, - )?.registerLayoutChange; - useEffect(() => { - if ( - animatedLayout == null || - value == null || - registerLayoutChange == null - ) { - return undefined; - } - return registerLayoutChange(value, measureChange); - }, [animatedLayout, value, registerLayoutChange, measureChange]); - const wasOpenRef = useRef(isOpen); - useLayoutEffect(() => { - if (wasOpenRef.current === isOpen) { - return; - } - wasOpenRef.current = isOpen; - // A change nothing measured (an owner set `isOpen` directly) lands at - // once, entrance included, so the content and what follows stay in step. - if (layoutChange.animate() && isOpen && contentRef.current != null) { - playLayoutChange(animatedLayout, contentRef.current); - } - }, [isOpen, layoutChange, animatedLayout]); - // Activation is blocked by this guard rather than the native `disabled` // attribute, so the trigger keeps `aria-disabled` semantics and stays // discoverable. A native `disabled` button would silently swallow events @@ -419,7 +343,6 @@ export function Collapsible({ if (isDisabled) { return; } - measureChange(); toggle(); }; @@ -476,7 +399,6 @@ export function Collapsible({ )} {...props}>
    `, }, + { + // What follows is inside a `display: contents` wrapper, as Tooltip and + // Theme render one. + name: 'a flex column whose next item is a display: contents wrapper', + html: `
    +
    +
    Details
    +
    +
    `, + }, { // Collapsible's shape: the content is the last child of a plain block // wrapper, and what follows sits after the wrapper. @@ -799,6 +848,51 @@ test.describe('spec:AST-105 FR2–FR4', () => { }); } + test("a closing flex-row item that sets its row's height hides at once, and the content after the row takes its final place at once", async ({ + page, + }) => { + await open( + page, + `
    +
    +
    +
    +
    +
    +
    `, + ); + const result = await page.evaluate(async () => { + const area = document.querySelector('#area'); + const content = document.querySelector('#content') as HTMLElement; + const after = document.querySelector('#after') as HTMLElement; + const layoutHandle = window.AnimatedLayout.createAnimatedLayout( + () => area, + ); + const report = window.AnimatedLayout.reportLayoutChange( + layoutHandle, + () => content, + ); + report.measureBefore(); + report.animate(); + const held = window.AnimatedLayout.holdLayoutExit( + layoutHandle, + content, + () => {}, + ); + // The component hides it in the same commit. + content.style.display = 'none'; + await new Promise(resolve => { + queueMicrotask(resolve); + }); + return { + held, + margin: content.style.getPropertyValue('margin-inline-end'), + glides: after.getAnimations().length, + }; + }); + expect(result).toEqual({held: false, margin: '', glides: 0}); + }); + test('a follower the app moves with its own transform takes its final place at once', async ({ page, }) => { diff --git a/packages/core/src/motion/animatedLayout.boundary.test.ts b/packages/core/src/motion/animatedLayout.boundary.test.ts index d3bdc1c6b0123..9ac1d1947eafd 100644 --- a/packages/core/src/motion/animatedLayout.boundary.test.ts +++ b/packages/core/src/motion/animatedLayout.boundary.test.ts @@ -78,12 +78,15 @@ describe('architecture:react-component-runtime INV12 source boundary', () => { ]) { expect(body, forbidden).not.toContain(forbidden); } - // The one descent is the `display: contents` branch. + // Every descent is a `display: contents` branch: the follower walk's and + // the exit's search for the next box. const descents = body.match(/firstElementChild/g) ?? []; - expect(descents).toHaveLength(1); - expect(body).toMatch( - /display === 'contents'\)\s*\{\s*for\s*\(\s*let child = element\.firstElementChild/, - ); + expect(descents).toHaveLength(2); + const contentsDescents = + body.match( + /display === 'contents'\)\s*\{\s*for\s*\(\s*let child = \w+\.firstElementChild/g, + ) ?? []; + expect(contentsDescents).toHaveLength(2); }); it('keeps the reporting functions out of every public entry point', () => { diff --git a/packages/core/src/motion/animatedLayout.test.ts b/packages/core/src/motion/animatedLayout.test.ts index 63067296a6afb..239bc676bd18f 100644 --- a/packages/core/src/motion/animatedLayout.test.ts +++ b/packages/core/src/motion/animatedLayout.test.ts @@ -547,6 +547,21 @@ describe('the changed element exits (spec:AST-105 FR4)', () => { expect(played).toEqual([]); }); + it("a refused hold keeps the element's own end margin and lets the content after it take its place at once", async () => { + const {content, after, layout} = exitSetUp(); + content.style.setProperty('margin-block-end', '12px'); + vi.spyOn(after, 'getBoundingClientRect').mockImplementation( + () => + new DOMRect(0, content.style.display === 'none' ? 40 : 128, 400, 20), + ); + expect(holdLayoutExit(layout, content, vi.fn())).toBe(false); + expect(content.style.getPropertyValue('margin-block-end')).toBe('12px'); + // The component hides it; the flush glides nothing. + content.style.display = 'none'; + await flushMicrotasks(); + expect(played).toEqual([]); + }); + it('fades under Reduce Motion with a coarse pointer', () => { const {content, layout} = exitSetUp(); media['(prefers-reduced-motion: reduce)'] = true; diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index 58337c8daf2a9..c85da753e4b86 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -448,6 +448,17 @@ function flush(batch: Batch): void { return; } const visible = visibleRect(batch.area); + // Every running glide stops first and every new place is read before any + // glide starts, so no reading includes another's new motion. + const entries: { + readonly element: Element; + readonly before: DOMRect; + readonly carried: OffsetCurve; + readonly carriedMs: number; + after?: DOMRect; + rawX?: number; + rawY?: number; + }[] = []; for (const element of followers) { const before = batch.before.get(element); if (before == null || (before.width === 0 && before.height === 0)) { @@ -457,9 +468,9 @@ function flush(batch: Batch): void { continue; } // An element already gliding keeps that motion and adds the new move: the - // running glide stops (so the rectangle below is its new layout place) - // and the rest of its curve carries into the one new glide, velocity - // included, summed with the new move. + // running glide stops (so its rectangle is its new layout place) and the + // rest of its curve carries into the one new glide, velocity included, + // summed with the new move. const running = glides.get(element); let carried = AT_REST; let carriedMs = 0; @@ -473,9 +484,34 @@ function flush(batch: Batch): void { running.animation.cancel(); glides.delete(element); } - const after = element.getBoundingClientRect(); - const dx = before.left - after.left; - const dy = before.top - after.top; + entries.push({element, before, carried, carriedMs}); + } + const byElement = new Map(); + for (const entry of entries) { + entry.after = entry.element.getBoundingClientRect(); + entry.rawX = entry.before.left - entry.after.left; + entry.rawY = entry.before.top - entry.after.top; + byElement.set(entry.element, entry); + } + for (const entry of entries) { + const {element, before, carried, carriedMs} = entry; + const after = entry.after as DOMRect; + // Nested changes compose: an element inside another moving element moves + // only by its own change, whatever order the changes were reported in. + let dx = entry.rawX ?? 0; + let dy = entry.rawY ?? 0; + for ( + let ancestor = element.parentElement; + ancestor != null && ancestor !== batch.area; + ancestor = ancestor.parentElement + ) { + const moving = byElement.get(ancestor); + if (moving != null) { + dx -= moving.rawX ?? 0; + dy -= moving.rawY ?? 0; + break; + } + } if (Math.abs(dx) < MIN_SHIFT_PX && Math.abs(dy) < MIN_SHIFT_PX) { continue; } @@ -649,28 +685,55 @@ const EXIT_PROPERTIES = [ */ function nextInFlow(element: Element, area: Element): Element | null { const view = element.ownerDocument.defaultView; + // The first box at or inside `from` (a `display: contents` element has + // none of its own: its children stand in its place). + const firstBox = (from: Element): Element | null => { + const style = view?.getComputedStyle(from); + if (style == null) { + return from; + } + if (style.display === 'contents') { + for ( + let child = from.firstElementChild; + child != null; + child = child.nextElementSibling + ) { + const box = firstBox(child); + if (box != null) { + return box; + } + } + return null; + } + return takesPart(from, style) ? from : null; + }; let node: Element | null = element; while (node != null && node !== area) { - let sibling = node.nextElementSibling; - while (sibling != null) { - const style = view?.getComputedStyle(sibling); - if (style == null || takesPart(sibling, style)) { - return sibling; + for ( + let sibling = node.nextElementSibling; + sibling != null; + sibling = sibling.nextElementSibling + ) { + const box = firstBox(sibling); + if (box != null) { + return box; } - sibling = sibling.nextElementSibling; } node = node.parentElement; } return null; } -/** An element's start edge along the main axis (RTL rows read right to left). */ -function startEdge(element: Element, isRow: boolean, rtl: boolean): number { +/** + * An element's place: its block start, and its inline start (RTL read right + * to left). + */ +function placeOf( + element: Element, + rtl: boolean, +): {block: number; inline: number} { const rect = element.getBoundingClientRect(); - if (!isRow) { - return rect.top; - } - return rtl ? -rect.right : rect.left; + return {block: rect.top, inline: rtl ? -rect.right : rect.left}; } /** @@ -699,20 +762,43 @@ function giveUpSpace(element: HTMLElement, area: Element): boolean { element.style.setProperty(property, `${-size}px`); return true; } + const own = element.style.getPropertyValue(property); + const ownPriority = element.style.getPropertyPriority(property); + const axis = isRow ? 'inline' : 'block'; + // The content after its container must land too: a flex-row item that + // sets its row's height moves what follows the row. + const afterParent = + element.parentElement == null || element.parentElement === area + ? null + : nextInFlow(element.parentElement, area); + const checked = afterParent == null ? [next] : [next, afterParent]; // Where the content after it goes once it is hidden. const display = element.style.getPropertyValue('display'); const priority = element.style.getPropertyPriority('display'); element.style.setProperty('display', 'none', 'important'); - const target = startEdge(next, isRow, rtl); + const targets = checked.map(box => placeOf(box, rtl)); + const target = targets[0]; element.style.setProperty('display', display, priority); - const before = startEdge(next, isRow, rtl); // 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 = startEdge(next, isRow, rtl); - element.style.setProperty(property, `${-size + (target - guess)}px`); - if (Math.abs(startEdge(next, isRow, rtl) - target) > 1 && before !== target) { - element.style.removeProperty(property); + const guess = placeOf(next, rtl)[axis]; + element.style.setProperty(property, `${-size + (target[axis] - guess)}px`); + // Everything checked must land on both axes: a flex-row item that sets + // its row's height frees no block space with an inline margin. + const missed = checked.some((box, index) => { + const landed = placeOf(box, rtl); + return ( + Math.abs(landed.block - targets[index].block) > 1 || + Math.abs(landed.inline - targets[index].inline) > 1 + ); + }); + if (missed) { + if (own === '') { + element.style.removeProperty(property); + } else { + element.style.setProperty(property, own, ownPriority); + } return false; } return true; @@ -743,7 +829,8 @@ const replaced = new WeakMap(); * Returns false, holding nothing, when the exit lands at once: no handle, a * close no change in the area measured, outside the area or its visible part, * Reduce Motion with a fine pointer, or a layout no end margin can free (a - * grid track and its gap). + * grid track and its gap, a flex-row item that sets its row's height); in + * that last case the content after it takes its final place at once too. * The component then hides the element itself. */ export function holdLayoutExit( @@ -788,8 +875,15 @@ export function holdLayoutExit( ), }; if (!giveUpSpace(element, area)) { - // A layout no end margin can free (a grid track and its gap): it lands - // at once rather than jump when it hides. + // A layout no end margin can free (a grid track and its gap, a flex-row + // item that sets its row's height): it hides at once, and the content + // after it takes its final place at once (`spec:AST-105` out of + // coverage). + for (const changed of [...batch.changed]) { + if (changed === element || changed.contains(element)) { + batch.changed.delete(changed); + } + } return false; } replaced.set(element, prior); From e4b19b0610d0d42f46bb109494ac5a73ac5291e6 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 15:09:21 -0700 Subject: [PATCH 25/48] fix(core): the exit's correction follows a scaled ancestor, and its measurement keeps CSS animations running - The space correction is read in screen px and the margin is in the element's own: it is now divided by the element's scale on that axis, so an exit under a scaled ancestor frees exactly its space (AST-105 Coverage). - The layout without the element is read with it taken out of flow by a temporary position: absolute, not display: none, which restarted the CSS animations inside it. - Real-browser cases: a gap stack under a scaled ancestor, and a spinner inside the closing element that keeps its time with no animationstart. --- ...animatedLayout.interaction.browser.spec.ts | 60 +++++++++++++++++++ .../core/src/motion/animatedLayout.test.ts | 32 +++++++++- packages/core/src/motion/animatedLayout.ts | 22 +++++-- 3 files changed, 105 insertions(+), 9 deletions(-) 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 85fb6239d9ee0..69ff68f1663a9 100644 --- a/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts +++ b/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts @@ -792,6 +792,17 @@ test.describe('spec:AST-105 FR2–FR4', () => {
    `, }, + { + // Under a scaled ancestor: the correction is read in screen px. + name: 'a gap stack under a scaled ancestor', + html: `
    +
    +
    +
    Details
    +
    +
    +
    `, + }, { // Collapsible's shape: the content is the last child of a plain block // wrapper, and what follows sits after the wrapper. @@ -893,6 +904,55 @@ test.describe('spec:AST-105 FR2–FR4', () => { expect(result).toEqual({held: false, margin: '', glides: 0}); }); + test('holding the exit keeps the CSS animations inside the element running (FR4)', async ({ + page, + }) => { + await open( + page, + ` +
    +
    +
    +
    `, + ); + const result = await page.evaluate(async () => { + const area = document.querySelector('#area'); + const content = document.querySelector('#content') as HTMLElement; + const spinner = document.querySelector('#spinner') as HTMLElement; + await new Promise(resolve => { + setTimeout(resolve, 300); + }); + let restarts = 0; + spinner.addEventListener('animationstart', () => { + restarts += 1; + }); + const before = Number(spinner.getAnimations()[0]?.currentTime); + const layoutHandle = window.AnimatedLayout.createAnimatedLayout( + () => area, + ); + const report = window.AnimatedLayout.reportLayoutChange( + layoutHandle, + () => content, + ); + report.measureBefore(); + report.animate(); + const held = window.AnimatedLayout.holdLayoutExit( + layoutHandle, + content, + () => {}, + ); + const after = Number(spinner.getAnimations()[0]?.currentTime); + await new Promise(resolve => { + setTimeout(resolve, 100); + }); + return {held, before, after, restarts}; + }); + expect(result.held).toBe(true); + // The spinner kept its time: no restart from 0. + expect(result.after).toBeGreaterThanOrEqual(result.before); + expect(result.restarts).toBe(0); + }); + test('a follower the app moves with its own transform takes its final place at once', async ({ page, }) => { diff --git a/packages/core/src/motion/animatedLayout.test.ts b/packages/core/src/motion/animatedLayout.test.ts index 239bc676bd18f..fe38c6fcff8c0 100644 --- a/packages/core/src/motion/animatedLayout.test.ts +++ b/packages/core/src/motion/animatedLayout.test.ts @@ -450,7 +450,10 @@ describe('the changed element exits (spec:AST-105 FR4)', () => { // content gives back too, and its end margin pulls the row up. const after = document.getElementById('after') as HTMLElement; vi.spyOn(after, 'getBoundingClientRect').mockImplementation(() => { - if (content.style.display === 'none') { + if ( + content.style.position === 'absolute' || + content.style.display === 'none' + ) { return new DOMRect(0, 40, 400, 20); } const margin = @@ -491,7 +494,14 @@ describe('the changed element exits (spec:AST-105 FR4)', () => { it('keeps the content where it is while it gives up its space, conceals it on the spring, then hands it back', () => { const {content, layout} = exitSetUp(); const done = vi.fn(); + const written = vi.spyOn(content.style, 'setProperty'); expect(holdLayoutExit(layout, content, done)).toBe(true); + // Measured out of flow, never with `display: none` (which would restart + // the CSS animations inside it). + expect( + written.mock.calls.some(([property]) => property === 'display'), + ).toBe(false); + expect(content.style.position).toBe(''); // In flow (it scrolls and clips with its container), taking exactly the // space it frees when hidden, gap included; out of reach. expect(content.style.position).toBe(''); @@ -539,7 +549,15 @@ describe('the changed element exits (spec:AST-105 FR4)', () => { // is hidden. vi.spyOn(after, 'getBoundingClientRect').mockImplementation( () => - new DOMRect(0, content.style.display === 'none' ? 40 : 128, 400, 20), + new DOMRect( + 0, + content.style.position === 'absolute' || + content.style.display === 'none' + ? 40 + : 128, + 400, + 20, + ), ); expect(holdLayoutExit(layout, content, vi.fn())).toBe(false); expect(content.style.getPropertyValue('margin-block-end')).toBe(''); @@ -552,7 +570,15 @@ describe('the changed element exits (spec:AST-105 FR4)', () => { content.style.setProperty('margin-block-end', '12px'); vi.spyOn(after, 'getBoundingClientRect').mockImplementation( () => - new DOMRect(0, content.style.display === 'none' ? 40 : 128, 400, 20), + new DOMRect( + 0, + content.style.position === 'absolute' || + content.style.display === 'none' + ? 40 + : 128, + 400, + 20, + ), ); expect(holdLayoutExit(layout, content, vi.fn())).toBe(false); expect(content.style.getPropertyValue('margin-block-end')).toBe('12px'); diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index c85da753e4b86..ed60b5b766aaf 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -772,18 +772,28 @@ function giveUpSpace(element: HTMLElement, area: Element): boolean { ? null : nextInFlow(element.parentElement, area); const checked = afterParent == null ? [next] : [next, afterParent]; - // Where the content after it goes once it is hidden. - const display = element.style.getPropertyValue('display'); - const priority = element.style.getPropertyPriority('display'); - element.style.setProperty('display', 'none', 'important'); + // Where the content after it goes once it is hidden, read with it taken + // out of flow (not `display: none`, which would restart the CSS animations + // inside it). + const position = element.style.getPropertyValue('position'); + const positionPriority = element.style.getPropertyPriority('position'); + element.style.setProperty('position', 'absolute', 'important'); const targets = checked.map(box => placeOf(box, rtl)); const target = targets[0]; - element.style.setProperty('display', display, priority); + if (position === '') { + element.style.removeProperty('position'); + } else { + element.style.setProperty('position', position, positionPriority); + } // A first guess (its box), then one correction from the measured result: // a gap, a collapsed margin or the element's own margins. element.style.setProperty(property, `${-size}px`); const guess = placeOf(next, rtl)[axis]; - element.style.setProperty(property, `${-size + (target[axis] - guess)}px`); + // The reading is in screen px, the margin in the element's own: under a + // scaled ancestor the correction is divided by the scale on this axis. + const scale = scaleOf(element, element.getBoundingClientRect()); + const correction = (target[axis] - guess) / (isRow ? scale.x : scale.y); + element.style.setProperty(property, `${-size + correction}px`); // Everything checked must land on both axes: a flex-row item that sets // its row's height frees no block space with an inline margin. const missed = checked.some((box, index) => { From c08d59bf41858dee3c813ac46249ec018a542f11 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 16:00:13 -0700 Subject: [PATCH 26/48] fix(core): Collapsible and TreeList report every change, whatever caused it spec:AST-105 FR1: the component reports each change. A click was the only path measured; an owner-set isOpen, a group's controlled value or isExpanded changed in items landed at once. Both components now measure in the commit itself, before React changes the DOM, through a small internal LayoutChangeSnapshot (getSnapshotBeforeUpdate): it runs only for a render that commits, once under StrictMode, and keeps render pure. The exit state is derived from the open state as it changes, during render, so any close keeps the content for its exit; the chevron takes the spring on every change. CollapsibleGroup no longer needs its measurement registry: each item measures its own change. Also: the set-state-in-effect reasons name the layout reads holdLayoutExit decides from, the Verification rows fill every column, and a browser spec renders the Core/Animated layout story (a ScrollableArea ref, Collapsible and TreeList opening and closing). Tests: an owner-set isOpen, a group's controlled value and isExpanded in items glide; one measurement in StrictMode and none for a refused change. --- .../core/src/Collapsible/Collapsible.spec.md | 16 +- packages/core/src/Collapsible/Collapsible.tsx | 73 +++---- .../core/src/Collapsible/CollapsibleGroup.tsx | 34 +--- .../Collapsible/CollapsibleGroupContext.tsx | 6 - packages/core/src/TreeList/TreeList.spec.md | 2 +- packages/core/src/TreeList/TreeList.tsx | 186 ++++++++++++------ .../core/src/motion/LayoutChangeSnapshot.tsx | 50 +++++ ...LayoutAdopters.interaction.browser.spec.ts | 148 ++++++++++++++ .../motion/animatedLayoutAdopters.test.tsx | 86 ++++++-- 9 files changed, 439 insertions(+), 162 deletions(-) create mode 100644 packages/core/src/motion/LayoutChangeSnapshot.tsx create mode 100644 packages/core/src/motion/__tests__/animatedLayoutAdopters.interaction.browser.spec.ts diff --git a/packages/core/src/Collapsible/Collapsible.spec.md b/packages/core/src/Collapsible/Collapsible.spec.md index d7fa2480d84e1..29ce1ab1d9f43 100644 --- a/packages/core/src/Collapsible/Collapsible.spec.md +++ b/packages/core/src/Collapsible/Collapsible.spec.md @@ -196,14 +196,14 @@ to the Group container anatomy here. ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | -| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | -| FR1–FR5 | Collapsible prop/type, DOM-order, glyph/direction, and no-extra-values tests | omitted/end/start; collapsed/expanded; LTR/RTL | default changes, `start` follows the label, collapsed leading cue points outward, or an unsupported cue value is admitted | audit:Collapsible/public-api | -| FR6 | composed trigger layout and geometry tests | plain text; composed label with trailing content; start/end | label becomes shrink-wrapped/stranded or composed content cannot use remaining row | audit:Collapsible/layout | -| FR7–FR8, ORD1 | group inheritance, item override, and nested reset tests | group start/end; item override; nested omission | group value is ignored, item cannot override, or position leaks into nested content | audit:Collapsible/behavior | -| FR9, AR1–AR3, ORD2 | disclosure semantics via `DISCLOSURE_PATTERN`, plus component-owned chevron and real RTL rendering | collapsed/expanded; controlled/uncontrolled; enabled/disabled; start/end; LTR/RTL | position changes state behavior, the trigger loses its controlled-content relationship, content visibility diverges from state, accessible output duplicates, or leading direction fails to mirror | audit:Collapsible/accessibility | -| FR10 | `animatedLayout.test.ts` and `animatedLayout.interaction.browser.spec.ts` (`spec:AST-105` Verification), plus `animatedLayoutAdopters.test.tsx` (Collapsible with and without a handle) | no handle; a handle; opening and closing | -| Theming anatomy | focused target-reflection assertions plus `themingTargets.test.ts` | standalone/grouped; open/closed; enabled/disabled; start/end; divided/density | a style-driving axis or state disappears from its owning target, a target loses its anatomy owner, or the chevron stops delegating to Icon | audit:Collapsible/theming | +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | +| FR1–FR5 | Collapsible prop/type, DOM-order, glyph/direction, and no-extra-values tests | omitted/end/start; collapsed/expanded; LTR/RTL | default changes, `start` follows the label, collapsed leading cue points outward, or an unsupported cue value is admitted | audit:Collapsible/public-api | +| FR6 | composed trigger layout and geometry tests | plain text; composed label with trailing content; start/end | label becomes shrink-wrapped/stranded or composed content cannot use remaining row | audit:Collapsible/layout | +| FR7–FR8, ORD1 | group inheritance, item override, and nested reset tests | group start/end; item override; nested omission | group value is ignored, item cannot override, or position leaks into nested content | audit:Collapsible/behavior | +| FR9, AR1–AR3, ORD2 | disclosure semantics via `DISCLOSURE_PATTERN`, plus component-owned chevron and real RTL rendering | collapsed/expanded; controlled/uncontrolled; enabled/disabled; start/end; LTR/RTL | position changes state behavior, the trigger loses its controlled-content relationship, content visibility diverges from state, accessible output duplicates, or leading direction fails to mirror | audit:Collapsible/accessibility | +| FR10 | `animatedLayoutAdopters.test.tsx` (with and without a handle; an owner-set `isOpen` and a group's controlled value glide; one measurement in StrictMode and none for a refused change; the exit kept inert until it ends) and `animatedLayoutAdopters.interaction.browser.spec.ts` (the `Core/Animated layout` story through a ScrollableArea ref, opening and closing) | no handle; a handle; click, owner-set, and group-driven changes; opening and closing | the content after it jumps with a handle, an owner-set change lands at once, closing content vanishes before its exit or stays reachable during it, or anything changes without a handle | audit:Collapsible/behavior | +| Theming anatomy | focused target-reflection assertions plus `themingTargets.test.ts` | standalone/grouped; open/closed; enabled/disabled; start/end; divided/density | a style-driving axis or state disappears from its owning target, a target loses its anatomy owner, or the chevron stops delegating to Icon | audit:Collapsible/theming | ## Decision log diff --git a/packages/core/src/Collapsible/Collapsible.tsx b/packages/core/src/Collapsible/Collapsible.tsx index 39d061122716b..709ad4af7878c 100644 --- a/packages/core/src/Collapsible/Collapsible.tsx +++ b/packages/core/src/Collapsible/Collapsible.tsx @@ -30,7 +30,6 @@ import { use, useCallback, - useEffect, useId, useLayoutEffect, useMemo, @@ -51,6 +50,7 @@ import { } from '../theme/tokens.stylex'; import {useCollapsible} from './useCollapsible'; +import {LayoutChangeSnapshot} from '../motion/LayoutChangeSnapshot'; import { applyLayoutChangeTransition, holdLayoutExit, @@ -59,10 +59,7 @@ import { reportLayoutChange, type AnimatedLayout, } from '../motion/animatedLayout'; -import { - CollapsibleGroupContext, - CollapsibleGroupPresentationContext, -} from './CollapsibleGroupContext'; +import {CollapsibleGroupPresentationContext} from './CollapsibleGroupContext'; import type {CollapsibleChevronPosition} from './CollapsibleGroupContext'; import {Icon} from '../Icon'; import {mergeProps} from '../utils'; @@ -374,17 +371,11 @@ export function Collapsible({ () => reportLayoutChange(animatedLayout, contentRef), [animatedLayout], ); - // A measured close keeps the content rendered (the exit makes it inert and - // gives up its space) while it - // plays its exit, then hides it (`spec:AST-105` FR4). - const [isExiting, setIsExiting] = useState(false); - const endExit = useCallback(() => { - // eslint-disable-next-line @eslint-react/set-state-in-effect -- the content was kept rendered only to play its exit; an exit that lands at once hides it before the frame paints - setIsExiting(false); - }, []); - // Before a change commits: the chevron (the trigger's Icon, its `icon` - // theming target) turns on the glide's motion, and the content after this - // Collapsible is measured where it is now. + // Every open-state change, whatever caused it (a click, an owner-set + // `isOpen`, a group's value), is measured before it lands (`spec:AST-105` + // FR1): LayoutChangeSnapshot calls this in the commit, before the DOM + // changes. The chevron (the trigger's Icon, its `icon` theming target) + // turns on the glide's motion too. const measureChange = useCallback(() => { applyLayoutChangeTransition( animatedLayout, @@ -392,27 +383,21 @@ export function Collapsible({ null, ); layoutChange.measureBefore(); - if (animatedLayout != null) { - // Closing keeps the content for its exit; opening mid-exit ends it. - setIsExiting(isOpen); - } - }, [animatedLayout, layoutChange, isOpen]); - // In a group, a toggle can change other items too (opening one in single - // mode closes the open one): the group measures every item it changes, so - // the rows between glide with the rest. - const registerLayoutChange = use( - CollapsibleGroupContext, - )?.registerLayoutChange; - useEffect(() => { - if ( - animatedLayout == null || - value == null || - registerLayoutChange == null - ) { - return undefined; - } - return registerLayoutChange(value, measureChange); - }, [animatedLayout, value, registerLayoutChange, measureChange]); + }, [animatedLayout, layoutChange]); + // A close with a handle keeps the content rendered while it plays its exit + // (the exit makes it inert and gives up its space), then hides it + // (`spec:AST-105` FR4). Derived from the open state as it changes, during + // render, so the closing commit already keeps it. + const [renderedIsOpen, setRenderedIsOpen] = useState(isOpen); + const [isExiting, setIsExiting] = useState(false); + if (renderedIsOpen !== isOpen) { + setRenderedIsOpen(isOpen); + setIsExiting(!isOpen && animatedLayout != null); + } + const endExit = useCallback(() => { + // eslint-disable-next-line @eslint-react/set-state-in-effect -- holdLayoutExit decides from layout read in the closing commit (the content's place in the area's visible part, Reduce Motion and the pointer, and whether an end margin frees its space); an exit it does not hold hides the content before the frame paints + setIsExiting(false); + }, []); const wasOpenRef = useRef(isOpen); useLayoutEffect(() => { if (wasOpenRef.current === isOpen) { @@ -423,9 +408,8 @@ export function Collapsible({ if (content == null) { return; } - // A change nothing measured (an owner set `isOpen` directly) lands at - // once, entrance and exit included, so the content and what follows stay - // in step. + // A change that was not measured (no handle) lands at once, entrance and + // exit included, so the content and what follows stay in step. const isMeasured = layoutChange.animate(); if (isOpen) { // Opened again mid-exit: the content takes its place in flow. @@ -440,8 +424,9 @@ export function Collapsible({ isMeasured && holdLayoutExit(animatedLayout, content, endExit); if (isExiting && !isHeld) { - // An exit that lands at once (unmeasured, out of view, or Reduce Motion - // with a fine pointer): hide the content before this frame paints. + // An exit holdLayoutExit did not hold (unmeasured, out of view, Reduce + // Motion with a fine pointer, or no margin frees its space): hide the + // content before this frame paints. endExit(); } }, [isOpen, isExiting, layoutChange, animatedLayout, endExit]); @@ -460,7 +445,6 @@ export function Collapsible({ if (isDisabled) { return; } - measureChange(); toggle(); }; @@ -551,6 +535,9 @@ export function Collapsible({ {trigger} {!isChevronAtStart && chevron} + {animatedLayout != null && ( + + )}
    void>()); - const registerLayoutChange = useCallback( - (itemValue: string, measure: () => void) => { - const layoutChanges = layoutChangesRef.current; - layoutChanges.set(itemValue, measure); - return () => { - if (layoutChanges.get(itemValue) === measure) { - layoutChanges.delete(itemValue); - } - }; - }, - [], - ); - const toggle = useCallback( (itemValue: string) => { let nextValues: string[]; @@ -243,12 +221,6 @@ export function CollapsibleGroup({ : [...openValues, itemValue]; } - for (const [value, measure] of layoutChangesRef.current) { - if (openValues.includes(value) !== nextValues.includes(value)) { - measure(); - } - } - if (!isControlled) { setInternalValue(nextValues); } @@ -266,8 +238,8 @@ export function CollapsibleGroup({ ); const contextValue = useMemo( - () => ({isOpen, toggle, registerLayoutChange}), - [isOpen, toggle, registerLayoutChange], + () => ({isOpen, toggle}), + [isOpen, toggle], ); const resolvedDensity = density ?? (hasDividers ? 'balanced' : null); diff --git a/packages/core/src/Collapsible/CollapsibleGroupContext.tsx b/packages/core/src/Collapsible/CollapsibleGroupContext.tsx index b3395b8b2934b..d3dacd6e86e98 100644 --- a/packages/core/src/Collapsible/CollapsibleGroupContext.tsx +++ b/packages/core/src/Collapsible/CollapsibleGroupContext.tsx @@ -27,12 +27,6 @@ export interface CollapsibleGroupContextValue { isOpen: (value: string) => boolean; /** Toggle the open state of a given value. */ toggle: (value: string) => void; - /** - * Registers an item's layout-change measurement (it holds an - * `animatedLayout` handle). Before a toggle commits, the group measures - * every item whose open state the toggle changes. Returns the unregister. - */ - registerLayoutChange?: (value: string, measure: () => void) => () => void; } /** diff --git a/packages/core/src/TreeList/TreeList.spec.md b/packages/core/src/TreeList/TreeList.spec.md index 267eca457a81c..0b7ccbcde8c09 100644 --- a/packages/core/src/TreeList/TreeList.spec.md +++ b/packages/core/src/TreeList/TreeList.spec.md @@ -264,7 +264,7 @@ authorize or prohibit a future target. | AR1 | Tree View accessibility contract (`internal/a11y-spec`, pending) bound to TreeList, in jsdom and Chromium; `TreeList.test.tsx` row-naming cases ("names a parent row by its label alone, not the chevron toggle", "names a plain row by its label and describes it with its description", "names and describes link and action rows themselves, not only their inner control", "keeps start and end content, including controls, out of the row name", "typeahead matches the label, not the description or start content") and `TreeListRowNames.a11y.chromium.spec.ts` for the browser-computed names; `TreeList.test.tsx` structural ARIA cases ("sets aria-level, aria-posinset, and aria-setsize at the top level", "sets aria-level/posinset/setsize correctly at deeper levels", "sets aria-level across three depths"); `TreeList.test.tsx` and `useTreeFocus.test.tsx` keyboard suites | Flat and nested trees; expanded/collapsed; selected/unselected; LTR and RTL; typeahead | A row loses its role, label name, state or position, two rows hold a Tab stop, an arrow key stops moving or stops mirroring under RTL, Home/End or typeahead stop working, or the tree claims multi-select | `audit:TreeList/accessibility` | | FR5, FR6, ORD1 | Focused `TreeList.test.tsx` row-identity and styling-composition coverage, required with implementation | One input at a time; all three together; sibling rows | Dropping any mechanism, applying mechanisms to different elements, targeting the `
  • `, changing the merge order, or styling a sibling row fails. | `audit:TreeList/api` | | FR7 | Focused `TreeList.test.tsx` row-click coverage, required with implementation | Action, parent, link, and disabled rows; clicks in the indent and padding, on the Chevron, and on Start and End content controls; plain, modified, and middle clicks | A click outside the label does nothing or runs the wrong action, a link-row click reaches ancestors twice, the Chevron or a Start or End content control activates the row, or a disabled row acts. | `audit:TreeList/behavior` | -| FR8 | `animatedLayout.test.ts` and `animatedLayout.interaction.browser.spec.ts` (`spec:AST-105` Verification), plus `animatedLayoutAdopters.test.tsx` (TreeList with and without a handle) | +| FR8 | `animatedLayoutAdopters.test.tsx` (with and without a handle; `isExpanded` changed in `items` glides; the collapsing group kept inert and out of the keyboard order until its exit ends) and `animatedLayoutAdopters.interaction.browser.spec.ts` (the `Core/Animated layout` story through a ScrollableArea ref, expanding and collapsing) | No handle; a handle; click, keyboard, and `items`-driven changes; expanding and collapsing | The rows after a row jump with a handle, an `items` change lands at once, a collapsing group vanishes before its exit or its rows stay focusable during it, or anything changes without a handle | `audit:TreeList/behavior` | | Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and five current local targets | Missing, extra, duplicated, prefixed, or stale mappings fail repository validation. | `audit:TreeList/theming` | Focused target-placement assertions cover all five local targets. Runtime support diff --git a/packages/core/src/TreeList/TreeList.tsx b/packages/core/src/TreeList/TreeList.tsx index 2b3dd236326c9..7673621c6d40b 100644 --- a/packages/core/src/TreeList/TreeList.tsx +++ b/packages/core/src/TreeList/TreeList.tsx @@ -36,6 +36,7 @@ import type { } from './TreeListTypes'; import {themeProps} from '../utils/themeProps'; import {useTreeFocusInternal} from '../hooks/useTreeFocus'; +import {LayoutChangeSnapshot} from '../motion/LayoutChangeSnapshot'; import { applyLayoutChangeTransition, holdLayoutExit, @@ -207,6 +208,22 @@ function findInitialTabbableId(items: TreeListItemData[]): string | undefined { * /> * ``` */ +/** Whether two sets of row ids hold the same rows. */ +function sameIds(a: ReadonlySet, b: ReadonlySet): boolean { + if (a === b) { + return true; + } + if (a.size !== b.size) { + return false; + } + for (const id of a) { + if (!b.has(id)) { + return false; + } + } + return true; +} + export function TreeList({ items, density = 'balanced', @@ -242,18 +259,52 @@ export function TreeList({ // (`architecture:react-component-runtime` INV12). Read through a ref // because the tree element comes from the focus hook below. const treeElementRef = useRef(null); - const pendingLayoutRef = useRef<{ - change: LayoutChange; - id: string; - isExpanding: boolean; - } | null>(null); - // Rows whose measured collapse is still playing its exit: their child group - // stays rendered (inert, giving up its space) until it finishes (`spec:AST-105` FR4). + // The rows expanded now, whatever expanded them (a click, the keyboard, or + // `isExpanded` changed in `items`). + const expandedIds = useMemo(() => { + const out = new Set(); + const walk = (list: ReadonlyArray) => { + for (const item of list) { + if (item.children == null || item.children.length === 0) { + continue; + } + const isExpanded = expandedKeysOverride.has(item.id) + ? (expandedKeysOverride.get(item.id) ?? false) + : expandedKeysFromProps.has(item.id); + if (isExpanded) { + out.add(item.id); + walk(item.children); + } + } + }; + walk(items); + return out; + }, [items, expandedKeysOverride, expandedKeysFromProps]); + // Rows whose collapse is still playing its exit: their child group stays + // rendered (the exit makes it inert and gives up its space) until it ends + // (`spec:AST-105` FR4). Derived from the expanded rows as they change, + // during render, so the collapsing commit already keeps the group. const [exitingIds, setExitingIds] = useState>( () => new Set(), ); + const [renderedExpandedIds, setRenderedExpandedIds] = useState(expandedIds); + if (!sameIds(renderedExpandedIds, expandedIds)) { + setRenderedExpandedIds(expandedIds); + if (animatedLayout != null) { + const next = new Set(exitingIds); + for (const id of renderedExpandedIds) { + if (!expandedIds.has(id)) { + next.add(id); + } + } + for (const id of expandedIds) { + next.delete(id); + } + setExitingIds(next); + } + } const endExit = useCallback((id: string) => { - // eslint-disable-next-line @eslint-react/set-state-in-effect -- the group was kept rendered only to play its exit; an exit that lands at once removes it before the frame paints + // eslint-disable-next-line @eslint-react/set-state-in-effect -- holdLayoutExit decides from layout read in the collapsing commit (the group's place in the area's visible part, Reduce Motion and the pointer, and whether an end margin frees its space); a group it does not hold goes before the frame paints setExitingIds(prev => { if (!prev.has(id)) { return prev; @@ -263,47 +314,53 @@ export function TreeList({ return next; }); }, []); - const handleToggle = useCallback( - (id: string) => { - const isExpanded = expandedKeysOverride.has(id) - ? (expandedKeysOverride.get(id) ?? false) - : expandedKeysFromProps.has(id); - const row = treeElementRef.current?.querySelector( - `[data-tree-id="${CSS.escape(id)}"]`, - ); - if (row != null) { - // The row's own chevron (the Icon in its toggle, the first one in the - // row; its children's come after) turns on the glide's motion. + // Every expansion change is measured before it lands (`spec:AST-105` FR1): + // LayoutChangeSnapshot calls this in the commit, before the DOM changes, + // for each row whose expansion changed. The row's own chevron (the Icon in + // its toggle, the first one in the row) turns on the glide's motion. + const pendingLayoutRef = useRef< + {change: LayoutChange; id: string; isExpanding: boolean}[] + >([]); + const measureChanges = useCallback( + (previous: ReadonlySet) => { + const changed = [ + ...[...previous].filter(id => !expandedIds.has(id)), + ...[...expandedIds].filter(id => !previous.has(id)), + ]; + for (const id of changed) { + const row = treeElementRef.current?.querySelector( + `[data-tree-id="${CSS.escape(id)}"]`, + ); + if (row == null) { + continue; + } applyLayoutChangeTransition( animatedLayout, row.querySelector('[data-tree-toggle] > .astryx-icon'), ); const change = reportLayoutChange(animatedLayout, () => row); change.measureBefore(); - pendingLayoutRef.current = {change, id, isExpanding: !isExpanded}; - } - if (animatedLayout != null) { - // A collapse keeps the group for its exit; expanding mid-exit ends it. - setExitingIds(prev => { - if (isExpanded === prev.has(id)) { - return prev; - } - const next = new Set(prev); - if (isExpanded) { - next.add(id); - } else { - next.delete(id); - } - return next; + pendingLayoutRef.current.push({ + change, + id, + isExpanding: expandedIds.has(id), }); } + }, + [animatedLayout, expandedIds], + ); + const handleToggle = useCallback( + (id: string) => { setExpandedKeysOverride(prev => { const next = new Map(prev); - next.set(id, !isExpanded); + const isCurrentlyExpanded = prev.has(id) + ? (prev.get(id) ?? false) + : expandedKeysFromProps.has(id); + next.set(id, !isCurrentlyExpanded); return next; }); }, - [expandedKeysOverride, expandedKeysFromProps, animatedLayout], + [expandedKeysFromProps], ); // --------------------------------------------------------------------------- @@ -341,34 +398,35 @@ export function TreeList({ }, []); useLayoutEffect(() => { - const pending = pendingLayoutRef.current; - if (pending == null) { - return; - } - pendingLayoutRef.current = null; - const isMeasured = pending.change.animate(); - const group = treeElementRef.current?.querySelector( - `[data-tree-id="${CSS.escape(pending.id)}"] > [role="group"]`, - ); - if (pending.isExpanding) { - if (group != null) { - // Expanded again mid-exit: the group takes its place in flow. - releaseLayoutExit(group); - if (isMeasured) { - playLayoutChange(animatedLayout, group); + const pendings = pendingLayoutRef.current; + pendingLayoutRef.current = []; + for (const pending of pendings) { + const isMeasured = pending.change.animate(); + const group = treeElementRef.current?.querySelector( + `[data-tree-id="${CSS.escape(pending.id)}"] > [role="group"]`, + ); + if (pending.isExpanding) { + if (group != null) { + // Expanded again mid-exit: the group takes its place in flow. + releaseLayoutExit(group); + if (isMeasured) { + playLayoutChange(animatedLayout, group); + } } + continue; + } + const isHeld = + isMeasured && + group != null && + holdLayoutExit(animatedLayout, group, () => endExit(pending.id)); + if (!isHeld) { + // An exit holdLayoutExit did not hold (out of view, Reduce Motion + // with a fine pointer, or no margin frees its space): the group goes + // before this frame paints. + endExit(pending.id); } - return; - } - const isHeld = - isMeasured && - group != null && - holdLayoutExit(animatedLayout, group, () => endExit(pending.id)); - if (!isHeld) { - // An exit that lands at once: the group goes in this commit. - endExit(pending.id); } - }, [expandedKeysOverride, animatedLayout, endExit]); + }, [expandedIds, animatedLayout, endExit]); const {treeRef, handleKeyDown, handleFocus} = useTreeFocusInternal({ @@ -494,6 +552,12 @@ export function TreeList({ {header}
  • )} + {animatedLayout != null && ( + + )}
      { + /** The state whose change moves the layout. */ + value: T; + /** Runs before the DOM changes, with the value being left. */ + onBeforeChange: (previous: T) => void; +} + +export class LayoutChangeSnapshot extends Component< + LayoutChangeSnapshotProps +> { + override getSnapshotBeforeUpdate( + previous: Readonly>, + ): null { + if (!Object.is(previous.value, this.props.value)) { + this.props.onBeforeChange(previous.value); + } + return null; + } + + // React asks for this beside getSnapshotBeforeUpdate. + override componentDidUpdate(): void {} + + override render(): null { + return null; + } +} diff --git a/packages/core/src/motion/__tests__/animatedLayoutAdopters.interaction.browser.spec.ts b/packages/core/src/motion/__tests__/animatedLayoutAdopters.interaction.browser.spec.ts new file mode 100644 index 0000000000000..59b5a1f67ec54 --- /dev/null +++ b/packages/core/src/motion/__tests__/animatedLayoutAdopters.interaction.browser.spec.ts @@ -0,0 +1,148 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file animatedLayoutAdopters.interaction.browser.spec.ts + * @input The built Storybook's `Core/Animated layout` story: a TreeList and a + * Collapsible that take the handle `useAnimatedLayout` returns for a + * ScrollableArea's ref + * @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 + * @position Testing; `component:Collapsible` FR10 and `component:TreeList` + * FR8. The module's own proofs are animatedLayout.interaction.browser.spec.ts + * + * SYNC: When modified, update the Verification rows in Collapsible.spec.md + * and TreeList.spec.md. + */ + +import {openStory, withStorybook} from '@astryxdesign/interaction-harness'; +import {expect, test, type Page} from '@playwright/test'; + +const origin = withStorybook(test); +const STORY = 'core-animated-layout--animated-layout'; + +/** The translate an element's running animation starts from, in px (y). */ +async function glideOf(page: Page, selector: string): Promise { + return page.evaluate(sel => { + const element = document.querySelector(sel); + for (const animation of element?.getAnimations() ?? []) { + const frames = (animation.effect as KeyframeEffect).getKeyframes(); + const first = frames[0]?.translate; + if (typeof first === 'string') { + const match = /^(-?[\d.e+-]+)px(?:\s+(-?[\d.e+-]+)px)?/.exec(first); + return match == null ? 0 : Number(match[2] ?? 0); + } + } + return null; + }, selector); +} + +/** Marks the first element whose own text starts with `text`. */ +async function mark(page: Page, text: string, id: string): Promise { + await page.evaluate( + ({t, i}) => { + const element = [...document.querySelectorAll('div')].find(el => + el.firstChild?.textContent?.trim().startsWith(t), + ); + element?.setAttribute('data-test-id', i); + }, + {t: text, i: id}, + ); +} + +async function viewportScroll(page: Page): Promise { + return page.evaluate(() => { + const viewport = [...document.querySelectorAll('*')].find( + el => el.scrollHeight > el.clientHeight + 1 && el.clientHeight > 300, + ); + return viewport?.scrollTop ?? -1; + }); +} + +test.describe('component:Collapsible FR10 and component:TreeList FR8 (spec:AST-105)', () => { + test('a Collapsible in a ScrollableArea glides the content after it as it opens and closes, and the closing content exits inert until it hides', async ({ + page, + }) => { + await openStory(page, origin(), STORY); + await mark(page, 'A plain app div', 'after'); + const scrollBefore = await viewportScroll(page); + const trigger = page.getByRole('button', {name: 'Shipping details'}); + const contentId = await trigger.getAttribute('aria-controls'); + const content = `[id="${contentId}"]`; + + await trigger.click(); + // The content after it glides down from where it was; the content enters. + expect(await glideOf(page, '[data-test-id="after"]')).toBeLessThan(-20); + expect( + await page.evaluate( + sel => document.querySelector(sel)?.getAnimations().length ?? 0, + content, + ), + ).toBeGreaterThan(0); + await page.waitForTimeout(1200); + + await trigger.click(); + const closing = await page.evaluate(sel => { + const element = document.querySelector(sel) as HTMLElement; + return { + shown: getComputedStyle(element).display !== 'none', + inert: element.hasAttribute('inert'), + exits: element.getAnimations().length, + }; + }, content); + expect(closing).toEqual({shown: true, inert: true, exits: 1}); + expect(await glideOf(page, '[data-test-id="after"]')).toBeGreaterThan(20); + // Once its exit ends it hides, and nothing jumps the area's scroll. + await expect + .poll(async () => + page.evaluate( + sel => + getComputedStyle(document.querySelector(sel) as HTMLElement) + .display, + content, + ), + ) + .toBe('none'); + expect(await viewportScroll(page)).toBe(scrollBefore); + }); + + 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, + }) => { + await openStory(page, origin(), STORY); + const documents = page.getByRole('treeitem', {name: 'Documents'}); + const notes = '[data-tree-id="notes"]'; + await documents.locator('[data-tree-toggle]').click(); + // Notes.txt glides down while Documents' rows enter. + expect(await glideOf(page, notes)).toBeLessThan(-20); + expect( + await page.evaluate( + () => + document + .querySelector('[data-tree-id="docs"] > [role="group"]') + ?.getAnimations().length ?? 0, + ), + ).toBeGreaterThan(0); + await page.waitForTimeout(1200); + + await documents.locator('[data-tree-toggle]').click(); + const closing = await page.evaluate(() => { + const group = document.querySelector( + '[data-tree-id="docs"] > [role="group"]', + ); + return {kept: group != null, inert: group?.hasAttribute('inert')}; + }); + expect(closing).toEqual({kept: true, inert: true}); + expect(await glideOf(page, notes)).toBeGreaterThan(20); + await expect + .poll(async () => + page.evaluate( + () => + document.querySelector('[data-tree-id="docs"] > [role="group"]') == + null, + ), + ) + .toBe(true); + }); +}); diff --git a/packages/core/src/motion/animatedLayoutAdopters.test.tsx b/packages/core/src/motion/animatedLayoutAdopters.test.tsx index c94acef89aec2..1f62bfc11ab66 100644 --- a/packages/core/src/motion/animatedLayoutAdopters.test.tsx +++ b/packages/core/src/motion/animatedLayoutAdopters.test.tsx @@ -17,7 +17,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {act, render, renderHook, screen} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import {useRef, type ReactNode} from 'react'; +import {StrictMode, useRef, type ReactNode} from 'react'; import {Collapsible} from '../Collapsible/Collapsible'; import {CollapsibleGroup} from '../Collapsible/CollapsibleGroup'; import {TreeList} from '../TreeList/TreeList'; @@ -222,7 +222,7 @@ describe('Collapsible changes another component made (spec:AST-105 FR3)', () => expect(animated).toContain(payment); }); - it('lands an owner-set isOpen at once, entrance included', async () => { + it('glides an owner-set isOpen too (spec:AST-105 FR1: each change is reported)', async () => { const {rerender} = render( {layout => ( @@ -232,16 +232,7 @@ describe('Collapsible changes another component made (spec:AST-105 FR3)', () => )} , ); - stubGeometry(); - // The content is in view, so an entrance would play if one ran. - const contentId = screen - .getByRole('button', {name: 'Details'}) - .getAttribute('aria-controls'); - const content = document.getElementById(contentId ?? ''); - expect(content).not.toBeNull(); - vi.spyOn(content as HTMLElement, 'getBoundingClientRect').mockReturnValue( - new DOMRect(0, 40, 400, 40), - ); + const after = stubGeometry(); rerender( {layout => ( @@ -252,6 +243,77 @@ describe('Collapsible changes another component made (spec:AST-105 FR3)', () => , ); await flush(); + expect(animated).toContain(after); + }); + + it("glides a group's controlled value change", async () => { + const group = (value: string, layout: AnimatedLayout | undefined) => ( + {}}> + + First + + + Second + + + ); + const {rerender} = render( + {layout => group('one', layout)}, + ); + const after = stubGeometry(); + rerender({layout => group('two', layout)}); + await flush(); + expect(animated).toContain(after); + }); + + it('glides a TreeList row whose isExpanded changed in items', async () => { + const tree = (isExpanded: boolean, layout: AnimatedLayout | undefined) => ( + + ); + const {rerender} = render( + {layout => tree(false, layout)}, + ); + const after = stubGeometry(); + rerender({layout => tree(true, layout)}); + await flush(); + expect(animated).toContain(after); + }); + + it('measures once in StrictMode, and not at all for a change that never lands', async () => { + const user = userEvent.setup(); + render( + + + {layout => ( + // An owner that refuses every toggle: nothing changes, so + // nothing is measured and no batch is left behind. + {}} + animatedLayout={layout}> + Hidden content + + )} + + , + ); + const after = screen.getByTestId('after'); + const reads = vi.spyOn(after, 'getBoundingClientRect'); + await user.click(screen.getByRole('button', {name: 'Details'})); + await flush(); + expect(reads).not.toHaveBeenCalled(); expect(animated).toEqual([]); }); }); From 7c96972692b8b7c120a743e4abf7f6dac8ccc254 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 16:02:34 -0700 Subject: [PATCH 27/48] test(core): the adopters' browser spec serves Storybook through a11y-spec, a dependency core already has The interaction harness is not one of core's dependencies, so importing it broke the package typecheck. --- ...LayoutAdopters.interaction.browser.spec.ts | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) 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 59b5a1f67ec54..51775e22baad7 100644 --- a/packages/core/src/motion/__tests__/animatedLayoutAdopters.interaction.browser.spec.ts +++ b/packages/core/src/motion/__tests__/animatedLayoutAdopters.interaction.browser.spec.ts @@ -16,12 +16,42 @@ * and TreeList.spec.md. */ -import {openStory, withStorybook} from '@astryxdesign/interaction-harness'; import {expect, test, type Page} from '@playwright/test'; +import { + DEFAULT_STORYBOOK_DIR, + serveStorybook, + type StaticServer, +} from '@astryxdesign/a11y-spec/storybook'; + +let storybook: StaticServer | undefined; +test.beforeAll(async () => { + storybook = await serveStorybook( + process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR, + ); +}); +test.afterAll(async () => { + await storybook?.close(); +}); -const origin = withStorybook(test); const STORY = 'core-animated-layout--animated-layout'; +/** Loads the story and waits for its first render and two frames. */ +async function openStory(page: Page): Promise { + await page.goto( + `${storybook?.origin ?? ''}/iframe.html?id=${STORY}&viewMode=story`, + ); + await page + .locator('#storybook-root > *') + .first() + .waitFor({state: 'attached'}); + await page.evaluate(async () => { + await document.fonts.ready; + await new Promise(resolve => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + }); +} + /** The translate an element's running animation starts from, in px (y). */ async function glideOf(page: Page, selector: string): Promise { return page.evaluate(sel => { @@ -64,7 +94,7 @@ test.describe('component:Collapsible FR10 and component:TreeList FR8 (spec:AST-1 test('a Collapsible in a ScrollableArea glides the content after it as it opens and closes, and the closing content exits inert until it hides', async ({ page, }) => { - await openStory(page, origin(), STORY); + await openStory(page); await mark(page, 'A plain app div', 'after'); const scrollBefore = await viewportScroll(page); const trigger = page.getByRole('button', {name: 'Shipping details'}); @@ -110,7 +140,7 @@ test.describe('component:Collapsible FR10 and component:TreeList FR8 (spec:AST-1 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, }) => { - await openStory(page, origin(), STORY); + await openStory(page); const documents = page.getByRole('treeitem', {name: 'Documents'}); const notes = '[data-tree-id="notes"]'; await documents.locator('[data-tree-toggle]').click(); From 7ca7a040a7b0dd9cc685820a8f7350ee773b590b Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 16:50:40 -0700 Subject: [PATCH 28/48] fix(core): a TreeList row collapsing with an expanded child moves what follows once; the chevron drops its spring without a handle - A changed row inside another changed row (a child left expanded when its parent collapses) is not reported as its own change: only the outer group exits and gives up its space, the child's group leaves with it when that exit ends, and the content after them moves once, with no jump back (AST-105 FR3). - Without a handle, Collapsible's and TreeList's chevrons drop the spring a handle set, so they keep the theme's own turn (AST-105 FR2). - Tests: a nested collapse holds only the outer group (jsdom) and moves the rows after it once with no jump (real browser, the story's Photos with Trips open); removing the handle clears the chevron's spring. --- packages/core/src/Collapsible/Collapsible.tsx | 12 +++ packages/core/src/TreeList/TreeList.tsx | 60 ++++++++++++-- ...LayoutAdopters.interaction.browser.spec.ts | 39 +++++++++ .../motion/animatedLayoutAdopters.test.tsx | 81 +++++++++++++++++++ 4 files changed, 187 insertions(+), 5 deletions(-) diff --git a/packages/core/src/Collapsible/Collapsible.tsx b/packages/core/src/Collapsible/Collapsible.tsx index 709ad4af7878c..411a414f10fdf 100644 --- a/packages/core/src/Collapsible/Collapsible.tsx +++ b/packages/core/src/Collapsible/Collapsible.tsx @@ -384,6 +384,18 @@ export function Collapsible({ ); layoutChange.measureBefore(); }, [animatedLayout, layoutChange]); + // Without a handle the chevron keeps the theme's own turn: a spring a handle + // set stays only while it is passed (`spec:AST-105` FR2). + useLayoutEffect(() => { + if (animatedLayout == null) { + applyLayoutChangeTransition( + null, + triggerRef.current?.querySelector( + ':scope > .astryx-icon', + ) ?? null, + ); + } + }, [animatedLayout]); // A close with a handle keeps the content rendered while it plays its exit // (the exit makes it inert and gives up its space), then hides it // (`spec:AST-105` FR4). Derived from the open state as it changes, during diff --git a/packages/core/src/TreeList/TreeList.tsx b/packages/core/src/TreeList/TreeList.tsx index 7673621c6d40b..3bcaf8033f32f 100644 --- a/packages/core/src/TreeList/TreeList.tsx +++ b/packages/core/src/TreeList/TreeList.tsx @@ -304,7 +304,10 @@ export function TreeList({ } } const endExit = useCallback((id: string) => { - // eslint-disable-next-line @eslint-react/set-state-in-effect -- holdLayoutExit decides from layout read in the collapsing commit (the group's place in the area's visible part, Reduce Motion and the pointer, and whether an end margin frees its space); a group it does not hold goes before the frame paints + // Called from the exit effect: holdLayoutExit decides from layout read + // in the collapsing commit (the group's place in the area's visible part, + // Reduce Motion and the pointer, and whether an end margin frees its + // space); a group it does not hold goes before the frame paints. setExitingIds(prev => { if (!prev.has(id)) { return prev; @@ -319,7 +322,13 @@ export function TreeList({ // for each row whose expansion changed. The row's own chevron (the Icon in // its toggle, the first one in the row) turns on the glide's motion. const pendingLayoutRef = useRef< - {change: LayoutChange; id: string; isExpanding: boolean}[] + { + change: LayoutChange; + id: string; + isExpanding: boolean; + /** Changed rows inside this one: they move and exit with it. */ + nested: string[]; + }[] >([]); const measureChanges = useCallback( (previous: ReadonlySet) => { @@ -327,11 +336,32 @@ export function TreeList({ ...[...previous].filter(id => !expandedIds.has(id)), ...[...expandedIds].filter(id => !previous.has(id)), ]; + const rows = new Map(); for (const id of changed) { const row = treeElementRef.current?.querySelector( `[data-tree-id="${CSS.escape(id)}"]`, ); - if (row == null) { + if (row != null) { + rows.set(id, row); + } + } + // A changed row inside another changed row (a child left expanded when + // its parent collapses) is not a change of its own: its group goes with + // the outer one, so the content after them moves once (`spec:AST-105` + // FR3). + const outerOf = (row: HTMLElement): string | undefined => + [...rows].find( + ([, other]) => other !== row && other.contains(row), + )?.[0]; + const nested = new Map(); + for (const [id, row] of rows) { + const outer = outerOf(row); + if (outer != null) { + nested.set(outer, [...(nested.get(outer) ?? []), id]); + } + } + for (const [id, row] of rows) { + if (outerOf(row) != null) { continue; } applyLayoutChangeTransition( @@ -344,11 +374,24 @@ export function TreeList({ change, id, isExpanding: expandedIds.has(id), + nested: nested.get(id) ?? [], }); } }, [animatedLayout, expandedIds], ); + // Without a handle the chevrons keep the theme's own turn: a spring a + // handle set stays only while it is passed (`spec:AST-105` FR2). + useLayoutEffect(() => { + if (animatedLayout != null) { + return; + } + for (const chevron of treeElementRef.current?.querySelectorAll( + '[data-tree-toggle] > .astryx-icon', + ) ?? []) { + applyLayoutChangeTransition(null, chevron); + } + }, [animatedLayout]); const handleToggle = useCallback( (id: string) => { setExpandedKeysOverride(prev => { @@ -415,15 +458,22 @@ export function TreeList({ } continue; } + // The rows nested in it leave with it, once its exit ends. + const endAll = () => { + endExit(pending.id); + for (const id of pending.nested) { + endExit(id); + } + }; const isHeld = isMeasured && group != null && - holdLayoutExit(animatedLayout, group, () => endExit(pending.id)); + holdLayoutExit(animatedLayout, group, endAll); if (!isHeld) { // An exit holdLayoutExit did not hold (out of view, Reduce Motion // with a fine pointer, or no margin frees its space): the group goes // before this frame paints. - endExit(pending.id); + endAll(); } } }, [expandedIds, animatedLayout, endExit]); 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 51775e22baad7..00a990070e9d0 100644 --- a/packages/core/src/motion/__tests__/animatedLayoutAdopters.interaction.browser.spec.ts +++ b/packages/core/src/motion/__tests__/animatedLayoutAdopters.interaction.browser.spec.ts @@ -175,4 +175,43 @@ test.describe('component:Collapsible FR10 and component:TreeList FR8 (spec:AST-1 ) .toBe(true); }); + + test('collapsing a row with an expanded child moves the rows after it once, with no jump when the exit ends (spec:AST-105 FR3)', async ({ + page, + }) => { + await openStory(page); + const photos = page.getByRole('treeitem', {name: 'Photos'}); + await photos.locator('[data-tree-toggle]').first().click(); + await page.waitForTimeout(1200); + await page + .getByRole('treeitem', {name: 'Trips'}) + .locator('[data-tree-toggle]') + .first() + .click(); + await page.waitForTimeout(1200); + const documents = '[data-tree-id="docs"]'; + const openTop = await page.evaluate( + sel => document.querySelector(sel)?.getBoundingClientRect().top ?? 0, + documents, + ); + await photos.locator('[data-tree-toggle]').first().click(); + // It glides from where it was: its offset is the whole distance it moves. + const offset = await glideOf(page, documents); + await expect + .poll(async () => + page.evaluate( + () => + document.querySelector( + '[data-tree-id="photos"] > [role="group"]', + ) == null, + ), + ) + .toBe(true); + const closedTop = await page.evaluate( + sel => document.querySelector(sel)?.getBoundingClientRect().top ?? 0, + documents, + ); + expect(offset).not.toBeNull(); + expect(Math.abs((offset ?? 0) - (openTop - closedTop))).toBeLessThan(2); + }); }); diff --git a/packages/core/src/motion/animatedLayoutAdopters.test.tsx b/packages/core/src/motion/animatedLayoutAdopters.test.tsx index 1f62bfc11ab66..8d2617c6a6ef1 100644 --- a/packages/core/src/motion/animatedLayoutAdopters.test.tsx +++ b/packages/core/src/motion/animatedLayoutAdopters.test.tsx @@ -397,6 +397,87 @@ describe("a close plays the content's exit (spec:AST-105 FR4)", () => { expect(groupOf()).toBeNull(); }); + it('collapses a row with an expanded child as one change: only the outer group exits, and the child goes with it', async () => { + const user = userEvent.setup(); + render( + + {layout => ( + + )} + , + ); + stubGeometry(); + const groupOf = (id: string) => + document.querySelector( + `[data-tree-id="${id}"] > [role="group"]`, + ); + vi.spyOn( + groupOf('photos') as HTMLElement, + 'getBoundingClientRect', + ).mockReturnValue(new DOMRect(0, 40, 400, 80)); + await user.click( + document.querySelector( + '[data-tree-id="photos"] > div [data-tree-toggle]', + ) ?? document.body, + ); + await flush(); + const outer = groupOf('photos'); + // Only the outer group is held; the child's group stays inside it as it + // was, so the content after them moves once. + expect(outer?.style.getPropertyValue('margin-block-end')).not.toBe(''); + expect(groupOf('trips')?.style.getPropertyValue('margin-block-end')).toBe( + '', + ); + act(() => finishes.get(outer as HTMLElement)?.()); + expect(groupOf('photos')).toBeNull(); + }); + + it("drops the chevron's spring when the handle is removed", async () => { + const user = userEvent.setup(); + const {rerender} = render( + + {layout => ( + + Content + + )} + , + ); + await user.click(screen.getByRole('button', {name: 'Details'})); + const chevron = screen + .getByRole('button', {name: 'Details'}) + .querySelector(':scope > .astryx-icon'); + expect(chevron?.style.getPropertyValue('transition-duration')).not.toBe(''); + rerender( + + {layout => ( + + Content + + )} + , + ); + expect(chevron?.style.getPropertyValue('transition-duration')).toBe(''); + }); + it('lands a close at once without a handle', async () => { const user = userEvent.setup(); render( From 5695f69cfa480f00098f279ca05c4389cafff8cf Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 16:52:45 -0700 Subject: [PATCH 29/48] docs(core): Collapsible FR10 and TreeList FR8 cite AST-108 FR4a for the chevron The chevron turning while the content glides is a coordinated pair: it derives its transition from the same springs.settle motion, as AST-108 FR4a (proposed) asks of coordinated animations. --- .../core/src/Collapsible/Collapsible.spec.md | 24 +++++++++---------- packages/core/src/TreeList/TreeList.spec.md | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/core/src/Collapsible/Collapsible.spec.md b/packages/core/src/Collapsible/Collapsible.spec.md index 29ce1ab1d9f43..86df5947a9bff 100644 --- a/packages/core/src/Collapsible/Collapsible.spec.md +++ b/packages/core/src/Collapsible/Collapsible.spec.md @@ -88,18 +88,18 @@ not parallel public names for this axis. ## Behavioral and layout contract -| ID | Invariant | Basis | -| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -| FR1 | Collapsible MUST expose `chevronPosition?: 'start' \| 'end'`; omission resolves to `end`. | owner API decision; preserves released default | -| FR2 | `end` MUST render Collapsible's chevron after the trigger label in logical order. Collapsed `end` points downward; expanded `end` points upward. | shipped trailing behavior plus approved position contract | -| FR3 | `start` MUST render Collapsible's chevron before the trigger label in logical order. Collapsed `start` points inward toward the label/content, so its inline direction mirrors under RTL; expanded `start` points downward. | approved leading-disclosure behavior | -| FR4 | Position changes the chevron glyph/direction as well as DOM order. Reusing a vertical down chevron and rotating it sideways MUST NOT make collapsed `start` point away from its content. | direction and representation decision | -| FR5 | Collapsible owns and renders the chevron for both positions. The position axis MUST NOT accept `none`, arbitrary React content, a cue callback, a renderer, or another replacement escape hatch. | constrained public-axis decision | -| FR6 | Trigger-label content MUST retain the usable row width needed by composed labels at either position. Moving the chevron to `start` MUST NOT strand the label at the opposite edge or prevent composed label content from using the remaining row. | approved layout rationale and existing grow behavior | -| FR7 | CollapsibleGroup MAY provide one `chevronPosition` default to its direct Collapsible items. A direct item's explicit position MUST override the group default. | approved group-coherence behavior | -| FR8 | A Collapsible nested inside another item's revealed content MUST begin a new presentation scope and use its own explicit position or the component default. The outer group's position MUST NOT leak into the nested disclosure. | approved nested isolation behavior | -| FR9 | Chevron position MUST NOT change disclosure semantics. The same trigger remains a button, retains `aria-expanded`, identifies the controlled region with `aria-controls`, and follows the existing state owner. | existing accessibility and behavior contract | -| FR10 | Collapsible MUST accept `animatedLayout?: AnimatedLayout`. With a handle, opening and closing report the content's size change through it, so the content after the Collapsible glides inside the handle's container, and the revealed content and the chevron move on the same motion (`spec:AST-105` FR1–FR4). Without one, opening and closing are unchanged. The prop MUST NOT change disclosure semantics. | `spec:AST-105` FR1, FR2 | +| ID | Invariant | Basis | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| FR1 | Collapsible MUST expose `chevronPosition?: 'start' \| 'end'`; omission resolves to `end`. | owner API decision; preserves released default | +| FR2 | `end` MUST render Collapsible's chevron after the trigger label in logical order. Collapsed `end` points downward; expanded `end` points upward. | shipped trailing behavior plus approved position contract | +| FR3 | `start` MUST render Collapsible's chevron before the trigger label in logical order. Collapsed `start` points inward toward the label/content, so its inline direction mirrors under RTL; expanded `start` points downward. | approved leading-disclosure behavior | +| FR4 | Position changes the chevron glyph/direction as well as DOM order. Reusing a vertical down chevron and rotating it sideways MUST NOT make collapsed `start` point away from its content. | direction and representation decision | +| FR5 | Collapsible owns and renders the chevron for both positions. The position axis MUST NOT accept `none`, arbitrary React content, a cue callback, a renderer, or another replacement escape hatch. | constrained public-axis decision | +| FR6 | Trigger-label content MUST retain the usable row width needed by composed labels at either position. Moving the chevron to `start` MUST NOT strand the label at the opposite edge or prevent composed label content from using the remaining row. | approved layout rationale and existing grow behavior | +| FR7 | CollapsibleGroup MAY provide one `chevronPosition` default to its direct Collapsible items. A direct item's explicit position MUST override the group default. | approved group-coherence behavior | +| FR8 | A Collapsible nested inside another item's revealed content MUST begin a new presentation scope and use its own explicit position or the component default. The outer group's position MUST NOT leak into the nested disclosure. | approved nested isolation behavior | +| FR9 | Chevron position MUST NOT change disclosure semantics. The same trigger remains a button, retains `aria-expanded`, identifies the controlled region with `aria-controls`, and follows the existing state owner. | existing accessibility and behavior contract | +| FR10 | Collapsible MUST accept `animatedLayout?: AnimatedLayout`. With a handle, opening and closing report the content's size change through it, so the content after the Collapsible glides inside the handle's container, and the revealed content and the chevron move on the same motion (`spec:AST-105` FR1–FR4; AST-108 FR4a). Without one, opening and closing are unchanged. The prop MUST NOT change disclosure semantics. | `spec:AST-105` FR1, FR2 | ### Allowed variation diff --git a/packages/core/src/TreeList/TreeList.spec.md b/packages/core/src/TreeList/TreeList.spec.md index 0b7ccbcde8c09..1b85f2fc4a500 100644 --- a/packages/core/src/TreeList/TreeList.spec.md +++ b/packages/core/src/TreeList/TreeList.spec.md @@ -106,7 +106,7 @@ Supporting one does not justify omitting another from the durable contract. | FR5 | `TreeListItemData` MUST admit `className`, `style`, and `xstyle` as one per-item styling seam. Every supplied value MUST compose on the same Item row box; an implementation MUST NOT treat current support for only one mechanism as authority to exclude the other two. | Owner decision; `architecture:public-component-api` INV5–INV6; `spec:AST-002/DEC-1` | Settled intent; implementation may be staged | | FR6 | The Item row box is the logical element that paints the row, owns its hover state, and contains Start content, Item label and description, and End content. The styling seam MUST NOT move to the semantic `
    • ` or another element without a compatibility decision. | Owner decision and the caller-owned per-row interaction boundary | Settled intent | | FR7 | A click on an Item row outside its Item label performs the row's action: an Item with `onClick` runs it; otherwise an Item with children toggles; otherwise an Item whose only action is its link follows that link as Enter does, with modified and middle clicks following the shared enlarged-surface rule in `family:navigation-destinations`. The Chevron and controls in Start content and End content keep their own activation, and a disabled Item does nothing. | Owner decision; `family:navigation-destinations` | Settled intent; implementation may be staged | -| FR8 | `TreeList` MUST accept `animatedLayout?: AnimatedLayout`. With a handle, expanding and collapsing a row report the row's size change through it, so the content after the row glides inside the handle's container, and the shown rows and the row's Chevron move on the same motion (`spec:AST-105` FR1–FR4). Without one, expanding and collapsing are unchanged. The prop MUST NOT change tree semantics or keyboard behavior. | `spec:AST-105` FR1, FR2 | Current behavior | +| FR8 | `TreeList` MUST accept `animatedLayout?: AnimatedLayout`. With a handle, expanding and collapsing a row report the row's size change through it, so the content after the row glides inside the handle's container, and the shown rows and the row's Chevron move on the same motion (`spec:AST-105` FR1–FR4; AST-108 FR4a). Without one, expanding and collapsing are unchanged. The prop MUST NOT change tree semantics or keyboard behavior. | `spec:AST-105` FR1, FR2 | Current behavior | ### Allowed variation From 4c4ac7beb9e4dd0cf3e3a58be287737141d23a17 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 11:50:08 -0700 Subject: [PATCH 30/48] feat(core): the animated-layout module's in-place swap (spec:AST-105 FR8) holdLayoutSwap holds an area's old content as one transient copy, inert and hidden from assistive technology, that joins no form or radio group and loads no frame or media; playLayoutSwap lays it over its old place and plays it out while the incoming elements enter, on FR4's one motion with one start time, opacity-only under Reduce Motion with a coarse pointer, and removes it when the swap ends. The copy mechanism lives in motion/transientCopy.ts, which only the module imports. Nothing adopts the swap yet. --- .changeset/animated-layout-swap.md | 7 + .../motion/animatedLayout.boundary.test.ts | 10 + .../src/motion/animatedLayout.swap.test.ts | 212 ++++++++++++++++++ packages/core/src/motion/animatedLayout.ts | 145 +++++++++++- packages/core/src/motion/transientCopy.ts | 146 ++++++++++++ 5 files changed, 519 insertions(+), 1 deletion(-) create mode 100644 .changeset/animated-layout-swap.md create mode 100644 packages/core/src/motion/animatedLayout.swap.test.ts create mode 100644 packages/core/src/motion/transientCopy.ts diff --git a/.changeset/animated-layout-swap.md b/.changeset/animated-layout-swap.md new file mode 100644 index 0000000000000..1180e10ee014b --- /dev/null +++ b/.changeset/animated-layout-swap.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] The animated-layout module gains its internal in-place swap: old content held as one inert, transient copy while new content enters in its place, on the shared motion. Nothing uses it yet and nothing public changes. + +@cixzhang diff --git a/packages/core/src/motion/animatedLayout.boundary.test.ts b/packages/core/src/motion/animatedLayout.boundary.test.ts index f02e6f0dd3681..3d636ecf3c50c 100644 --- a/packages/core/src/motion/animatedLayout.boundary.test.ts +++ b/packages/core/src/motion/animatedLayout.boundary.test.ts @@ -114,6 +114,16 @@ describe('architecture:react-component-runtime INV12 source boundary', () => { } }); + it("keeps the swap's copy mechanism private to the module", () => { + const users = sourceFiles(coreSrc) + .filter(path => + /from '[./]+(motion\/)?transientCopy'/.test(readFileSync(path, 'utf8')), + ) + .map(path => relative(coreSrc, path)) + .sort(); + expect(users).toEqual(['motion/animatedLayout.ts']); + }); + it('is used only by the hook and the components that adopt it', () => { const users = sourceFiles(coreSrc) .filter(path => path !== join(here, 'animatedLayout.ts')) diff --git a/packages/core/src/motion/animatedLayout.swap.test.ts b/packages/core/src/motion/animatedLayout.swap.test.ts new file mode 100644 index 0000000000000..74e18de0be9bc --- /dev/null +++ b/packages/core/src/motion/animatedLayout.swap.test.ts @@ -0,0 +1,212 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file animatedLayout.swap.test.ts + * @input vitest (jsdom), the animated-layout module, a recording stand-in for + * Element.animate + * @output Checks the in-place swap (`spec:AST-105` FR8): one inert transient + * copy of old content that takes no part in the page, played on FR4's one + * motion with the incoming elements, opacity-only under Reduce Motion with + * a coarse pointer, nothing under an immediate change, and removed when the + * swap ends + * @position Testing; the real geometry is proven by the adopters' + * real-browser frames + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import { + createAnimatedLayout, + holdLayoutSwap, + playLayoutSwap, +} from './animatedLayout'; + +interface Played { + element: Element; + keyframes: Keyframe[]; + options: KeyframeAnimationOptions; + startTime: number | null; + finish?: () => void; +} + +let played: Played[] = []; +let media: Record = {}; +const originalAnimate = Element.prototype.animate; +const originalMatchMedia = window.matchMedia; + +beforeEach(() => { + played = []; + media = {}; + Object.defineProperty(document, 'timeline', { + configurable: true, + value: {currentTime: 500}, + }); + 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, + }; + played.push(entry); + return { + cancel: vi.fn(), + addEventListener(type: string, listener: () => void) { + if (type === 'finish') { + entry.finish = listener; + } + }, + set startTime(value: number) { + entry.startTime = value; + }, + } as unknown as Animation; + }; + window.matchMedia = (query: string) => + ({ + matches: media[query] === true, + media: query, + addEventListener() {}, + removeEventListener() {}, + }) as unknown as MediaQueryList; +}); + +afterEach(() => { + Element.prototype.animate = originalAnimate; + window.matchMedia = originalMatchMedia; + document.body.innerHTML = ''; +}); + +function setup() { + document.body.innerHTML = ` +
      +
      +
      +

      Projects

      + + +
      +
      +
      `; + const area = document.getElementById('area') as HTMLElement; + const handle = createAnimatedLayout(() => area); + const content = document.getElementById('content') as HTMLElement; + const host = document.getElementById('host') as HTMLElement; + return {handle, content, host}; +} + +const travel = (p: number): Keyframe => ({ + opacity: 1 - p, + translate: `${-200 * p}px 0px`, +}); + +describe('the in-place swap (spec:AST-105 FR8)', () => { + it('holds old content as one inert copy that takes no part in the page', () => { + const {handle, content} = setup(); + const swap = holdLayoutSwap(handle, content); + expect(swap).not.toBeNull(); + const copy = swap?.copy as HTMLElement; + expect(copy.getAttribute('aria-hidden')).toBe('true'); + expect(copy.hasAttribute('inert')).toBe(true); + expect(copy.querySelector('[id]')).toBeNull(); + expect(copy.querySelector('[data-testid]')).toBeNull(); + // A copied checked radio joins no group; a copied frame loads nothing. + expect(copy.querySelector('input')?.hasAttribute('name')).toBe(false); + expect(copy.querySelector('iframe')?.hasAttribute('src')).toBe(false); + expect(copy.isConnected).toBe(false); + }); + + it('plays the copy out and the incoming elements in on one motion, then removes the copy', () => { + const {handle, content, host} = setup(); + const swap = holdLayoutSwap(handle, content); + const title = document.getElementById('title') as HTMLElement; + const end = playLayoutSwap(swap!, { + host, + copy: travel, + incoming: [{element: title, frames: p => ({opacity: p})}], + }); + const copy = swap!.copy; + expect(copy.parentElement).toBe(host); + const out = played.find(entry => entry.element === copy); + expect(out?.keyframes[0]).toMatchObject({opacity: 1, translate: '0px 0px'}); + expect(out?.keyframes.at(-1)).toMatchObject({ + opacity: 0, + translate: '-200px 0px', + }); + const enter = played.find(entry => entry.element === title); + expect(enter?.keyframes[0].opacity).toBe(0); + expect(enter?.keyframes.at(-1)?.opacity).toBe(1); + // One clock: the same duration, started on the same timeline time. + expect(new Set(played.map(entry => entry.options.duration)).size).toBe(1); + expect(new Set(played.map(entry => entry.startTime))).toEqual( + new Set([500]), + ); + played[0].finish?.(); + expect(copy.isConnected).toBe(false); + expect(typeof end).toBe('function'); + }); + + it("fades the copy's text on its own curve when asked", () => { + const {handle, content, host} = setup(); + const swap = holdLayoutSwap(handle, content); + playLayoutSwap(swap!, { + host, + copy: travel, + copyText: p => 1 - Math.min(1, p / 0.4), + incoming: [], + }); + const text = played.find( + entry => + entry.element !== swap!.copy && + entry.element.textContent?.trim() === 'Projects', + ); + expect(text?.keyframes.at(-1)?.opacity).toBe(0); + }); + + it('copies only the visible part of a scroll container, scrolled as it was', () => { + const {handle, content} = setup(); + Object.defineProperty(content, 'scrollTop', {value: 40}); + Object.defineProperty(content, 'clientWidth', {value: 600}); + Object.defineProperty(content, 'clientHeight', {value: 300}); + const swap = holdLayoutSwap(handle, content, {isVisiblePart: true}); + expect(swap?.rect.width).toBe(600); + expect(swap?.rect.height).toBe(300); + const inner = swap?.copy.firstElementChild as HTMLElement; + expect(inner.style.top).toBe('-40px'); + expect(inner.style.width).toBe('600px'); + expect(inner.hasAttribute('role')).toBe(false); + }); + + it('only cross-fades under Reduce Motion with a coarse pointer (design:reduced-motion DR2)', () => { + media['(prefers-reduced-motion: reduce)'] = true; + media['(pointer: coarse)'] = true; + const {handle, content, host} = setup(); + const swap = holdLayoutSwap(handle, content); + const title = document.getElementById('title') as HTMLElement; + playLayoutSwap(swap!, { + host, + copy: travel, + copyText: () => 0, + incoming: [{element: title, frames: p => ({opacity: p, scale: '2'})}], + }); + expect(played.length).toBe(2); + for (const entry of played) { + for (const frame of entry.keyframes) { + expect(Object.keys(frame)).toEqual(['opacity']); + } + } + }); + + it('holds nothing under Reduce Motion with a fine pointer, without a handle, or outside the area', () => { + const {handle, content} = setup(); + expect(holdLayoutSwap(null, content)).toBeNull(); + const outside = document.createElement('div'); + document.body.append(outside); + expect(holdLayoutSwap(handle, outside)).toBeNull(); + media['(prefers-reduced-motion: reduce)'] = true; + expect(holdLayoutSwap(handle, content)).toBeNull(); + expect(played).toEqual([]); + }); +}); diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index 947d726f3b571..ba58c01a7ea4c 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -7,7 +7,8 @@ * cross-fade clock (uikit/timing.ts) * @output Exports `createAnimatedLayout` and the opaque `AnimatedLayout` * handle, `reportLayoutChange`, `playLayoutChange`, `holdLayoutExit` / - * `releaseLayoutExit` and `applyLayoutChangeTransition`: the one module + * `releaseLayoutExit`, the in-place swap (`holdLayoutSwap` / + * `playLayoutSwap`) and `applyLayoutChangeTransition`: the one module * that produces the * size-change glide `spec:AST-105` defines * (`architecture:react-component-runtime` INV12) @@ -39,6 +40,12 @@ * 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`). + * - 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`, + * `playLayoutSwap`; `spec:AST-105` FR8). The caller shapes the frames over + * the swap's progress; under Reduce Motion with a coarse pointer only + * opacity changes. * - Every pointer (`spec:AST-105` FR5). Under Reduce Motion nothing travels * (`design:reduced-motion` DR1–DR3): a cross-fade under a coarse primary * pointer, an immediate change otherwise. @@ -47,6 +54,14 @@ */ import {motionRole} from './roles'; +import { + inertCopy, + isCopyable, + paintedBackground, + placeOver, + visiblePartCopy, + wrapText, +} from './transientCopy'; import {isSpring} from './roleValue'; import {solveSpring, toSpring, type SpringDescription} from './spring'; import {registerSpringsTable, SETTLE_SPRING} from './uikit/springs'; @@ -973,6 +988,134 @@ export function releaseLayoutExit(element: HTMLElement): void { } } +// --------------------------------------------------------------------------- +// The in-place swap +// --------------------------------------------------------------------------- + +/** + * Old content held as one transient copy while new content enters in its + * place (`spec:AST-105` FR8): inert, hidden from assistive technology, and + * removed when the swap ends (`architecture:component-style-authoring` INV7). + */ +export interface LayoutSwap { + readonly area: Element; + readonly copy: HTMLElement; + /** Where the old content showed, in viewport coordinates. */ + readonly rect: DOMRect; +} + +/** How a swap plays, as frames over its progress (0 → 1). */ +export interface LayoutSwapMotion { + /** The box the copy is laid out in, over the old content's place. */ + readonly host: Element; + /** The copy's frames while it travels. */ + readonly copy: (p: number) => Keyframe; + /** Its text's opacity, on its own curve, when that differs from the copy's. */ + readonly copyText?: (p: number) => number; + /** The copy paints above its neighbours in the background it covers. */ + readonly isOpaque?: boolean; + /** Elements the page already renders, entering with the swap. */ + readonly incoming: ReadonlyArray<{ + readonly element: Element; + readonly frames: (p: number) => Keyframe; + }>; +} + +/** + * Before a change commits: hold `element`'s old content as one transient copy + * (`spec:AST-105` FR8). With `isVisiblePart`, the copy is the element's + * visible part, its content scrolled as it was (a scroll container). Returns + * null without a handle, outside its area, under an immediate change, or for + * content too large to copy. + */ +export function holdLayoutSwap( + handle: AnimatedLayout | null | undefined, + element: HTMLElement, + {isVisiblePart = false}: {isVisiblePart?: boolean} = {}, +): LayoutSwap | null { + if (handle == null || typeof element.animate !== 'function') { + return null; + } + const area = areaOf(handle, element); + if ( + area == null || + layoutMotion(area).kind === 'instant' || + !isCopyable(element) + ) { + return null; + } + const box = element.getBoundingClientRect(); + if (!isVisiblePart) { + return {area, copy: inertCopy(element), rect: box}; + } + const {copy, rect} = visiblePartCopy(element); + return {area, copy, rect}; +} + +/** + * After the change commits, before paint: the copy leaves while the incoming + * elements enter, on FR4's one motion, every part started on the same + * timeline time. Under Reduce Motion with a coarse pointer, only opacity + * changes (`design:reduced-motion` DR2). Returns what ends the swap at once; + * it also ends when its motion does, and the copy is removed. + */ +export function playLayoutSwap( + swap: LayoutSwap, + how: LayoutSwapMotion, +): () => void { + const motion = layoutMotion(swap.area); + if (motion.kind === 'instant') { + return () => {}; + } + const {copy} = swap; + placeOver(copy, how.host, swap.rect); + if (how.isOpaque === true) { + copy.style.zIndex = '1'; + copy.style.backgroundColor = paintedBackground(how.host); + } + const travels = motion.kind === 'spring'; + const options: KeyframeAnimationOptions = travels + ? {duration: motion.progress.durationMs, easing: 'linear', fill: 'both'} + : {duration: motion.durationMs, easing: motion.easing, fill: 'both'}; + const frames = (shape: (p: number) => Keyframe): Keyframe[] => + travels + ? sampleKeyframes(motion.progress, remaining => shape(1 - remaining)) + : [shape(0), shape(1)]; + const animations: Animation[] = []; + const play = (element: Element, shape: (p: number) => Keyframe) => { + const animation = safeAnimate(element, frames(shape), options); + if (animation != null) { + animations.push(animation); + } + }; + play(copy, travels ? how.copy : p => ({opacity: 1 - p})); + const {copyText} = how; + if (travels && copyText != null) { + for (const span of wrapText(copy)) { + play(span, p => ({opacity: copyText(p)})); + } + } + for (const {element, frames: shape} of how.incoming) { + play(element, travels ? shape : p => ({opacity: p})); + } + let isDone = false; + const end = () => { + if (isDone) { + return; + } + isDone = true; + animations.forEach(animation => animation.cancel()); + copy.remove(); + }; + if (animations.length === 0) { + end(); + } else { + // Every part runs on one clock, so the first to finish ends them all. + animations[0].addEventListener?.('finish', end); + } + return end; +} + /** * 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 diff --git a/packages/core/src/motion/transientCopy.ts b/packages/core/src/motion/transientCopy.ts new file mode 100644 index 0000000000000..58884e0a1b5c6 --- /dev/null +++ b/packages/core/src/motion/transientCopy.ts @@ -0,0 +1,146 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file transientCopy.ts + * @input The DOM + * @output Exports `isCopyable`, `inertCopy`, `visiblePartCopy`, `placeOver`, + * `paintedBackground` and `wrapText`: how Core makes, lays out and fades one + * transient copy of old content + * @position Motion foundation, internal to core; the copy mechanism the + * in-place swap uses (`spec:AST-105` FR8, + * `architecture:component-style-authoring` INV7). Only + * motion/animatedLayout.ts imports it. + * + * A copy takes no part in the page: it is inert and hidden from assistive + * technology, carries no ids or test ids, joins no form or radio group (a + * copied checked radio would uncheck the live one), and its frames and media + * load nothing. Laying it out changes no ancestor's position. + * + * SYNC: When modified, update /packages/core/src/motion/animatedLayout.ts + */ + +/** Above this many elements, old content is not copied: the new one enters. */ +const MAX_COPIED_ELEMENTS = 1500; + +/** Whether `element` is small enough to copy. */ +export function isCopyable(element: Element): boolean { + return element.getElementsByTagName('*').length <= MAX_COPIED_ELEMENTS; +} + +/** + * A copy that takes no part: no ids or test ids, no form or radio group (a + * copied checked radio would uncheck the live one), and copied frames and + * media load nothing. + */ +export function inertCopy(element: HTMLElement): HTMLElement { + const copy = element.cloneNode(true) as HTMLElement; + for (const node of [copy, ...copy.querySelectorAll('*')]) { + for (const name of ['id', 'data-testid', 'name', 'form', 'autofocus']) { + node.removeAttribute(name); + } + } + for (const node of copy.querySelectorAll( + 'iframe, video, audio, source, embed, object', + )) { + for (const name of ['src', 'srcdoc', 'data', 'autoplay']) { + node.removeAttribute(name); + } + } + copy.setAttribute('aria-hidden', 'true'); + copy.setAttribute('inert', ''); + copy.style.pointerEvents = 'none'; + return copy; +} + +/** The background `element` shows: its own, or the nearest ancestor's. */ +export function paintedBackground(element: Element): string { + for (let node: Element | null = element; node; node = node.parentElement) { + const color = getComputedStyle(node).backgroundColor; + if (color !== 'transparent' && !/^rgba\(.*,\s*0\)$/.test(color)) { + return color; + } + } + return 'Canvas'; +} + +/** + * Lay `element` over `rect` (viewport coordinates), absolutely, as the last + * child of `host`, wherever its containing block is: no ancestor's position + * changes. + */ +export function placeOver( + element: HTMLElement, + host: Element, + rect: DOMRect, +): void { + Object.assign(element.style, { + position: 'absolute', + margin: '0px', + left: '0px', + top: '0px', + width: `${rect.width}px`, + height: `${rect.height}px`, + }); + host.appendChild(element); + const origin = element.getBoundingClientRect(); + element.style.left = `${rect.left - origin.left}px`; + element.style.top = `${rect.top - origin.top}px`; +} + +/** + * `element`'s visible part as a copy: its content scrolled as it was, in a + * holder the size of the visible part (a scroll container), and where that + * part showed, in viewport coordinates. + */ +export function visiblePartCopy(element: HTMLElement): { + copy: HTMLElement; + rect: DOMRect; +} { + const content = inertCopy(element); + content.removeAttribute('role'); + content.removeAttribute('tabindex'); + Object.assign(content.style, { + position: 'absolute', + top: `${-element.scrollTop}px`, + left: '0px', + boxSizing: 'border-box', + width: `${element.clientWidth}px`, + height: 'auto', + padding: getComputedStyle(element).padding, + margin: '0px', + overflow: 'visible', + translate: 'none', + }); + const holder = element.ownerDocument.createElement('div'); + holder.setAttribute('aria-hidden', 'true'); + holder.setAttribute('inert', ''); + Object.assign(holder.style, {overflow: 'hidden', pointerEvents: 'none'}); + holder.appendChild(content); + const box = element.getBoundingClientRect(); + return { + copy: holder, + rect: DOMRect.fromRect({ + x: box.left + element.clientLeft, + y: box.top + element.clientTop, + width: element.clientWidth, + height: element.clientHeight, + }), + }; +} + +/** Wraps each run of text in a copy in its own span, so the text can fade. */ +export function wrapText(copy: HTMLElement): HTMLElement[] { + const walker = copy.ownerDocument.createTreeWalker(copy, 4); + const texts: Text[] = []; + while (walker.nextNode()) { + if (walker.currentNode.textContent?.trim()) { + texts.push(walker.currentNode as Text); + } + } + return texts.map(text => { + const span = copy.ownerDocument.createElement('span'); + text.replaceWith(span); + span.appendChild(text); + return span; + }); +} From 600302f0b1f95a1e3c04fa78cce4fa4154bed13d Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 11:51:00 -0700 Subject: [PATCH 31/48] fix(core): an opaque swap copy paints the background it shows --- packages/core/src/motion/animatedLayout.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index ba58c01a7ea4c..f1e72eb66375a 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -1012,7 +1012,7 @@ export interface LayoutSwapMotion { readonly copy: (p: number) => Keyframe; /** Its text's opacity, on its own curve, when that differs from the copy's. */ readonly copyText?: (p: number) => number; - /** The copy paints above its neighbours in the background it covers. */ + /** The copy paints above its neighbours, in the background it shows. */ readonly isOpaque?: boolean; /** Elements the page already renders, entering with the swap. */ readonly incoming: ReadonlyArray<{ @@ -1071,7 +1071,7 @@ export function playLayoutSwap( placeOver(copy, how.host, swap.rect); if (how.isOpaque === true) { copy.style.zIndex = '1'; - copy.style.backgroundColor = paintedBackground(how.host); + copy.style.backgroundColor = paintedBackground(copy); } const travels = motion.kind === 'spring'; const options: KeyframeAnimationOptions = travels From 7a86690b53c795f61cf40b6ae13f97d5a3576b14 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 12:05:17 -0700 Subject: [PATCH 32/48] feat(core): a swap's copy can ride the glide of the element it sat in rideWith makes the copy follow that element's actual glide frame by frame (offset less travel), started after the batch flushes and before paint; when the element lands silently (out of view, moved by the app), the copy takes its new place at once with it. The old and new content then share one moving column on every frame. --- .../src/motion/animatedLayout.swap.test.ts | 100 ++++++++++++++++++ packages/core/src/motion/animatedLayout.ts | 64 ++++++++--- 2 files changed, 152 insertions(+), 12 deletions(-) diff --git a/packages/core/src/motion/animatedLayout.swap.test.ts b/packages/core/src/motion/animatedLayout.swap.test.ts index 74e18de0be9bc..b05046afa7f0e 100644 --- a/packages/core/src/motion/animatedLayout.swap.test.ts +++ b/packages/core/src/motion/animatedLayout.swap.test.ts @@ -18,6 +18,7 @@ import { createAnimatedLayout, holdLayoutSwap, playLayoutSwap, + reportLayoutChange, } from './animatedLayout'; interface Played { @@ -210,3 +211,102 @@ describe('the in-place swap (spec:AST-105 FR8)', () => { expect(played).toEqual([]); }); }); + +describe('a swap that rides a glide (spec:AST-105 FR8 with FR3)', () => { + function rideSetup() { + document.body.innerHTML = ` +
      +
      +

      Projects

      +
      `; + const area = document.getElementById('area') as HTMLElement; + const rail = document.getElementById('rail') as HTMLElement; + const column = document.getElementById('column') as HTMLElement; + const content = document.getElementById('content') as HTMLElement; + let phase: 'before' | 'after' = 'before'; + const railWidth = () => (phase === 'before' ? 260 : 48); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + function (this: HTMLElement) { + if (this === area) { + return DOMRect.fromRect({x: 0, y: 0, width: 1000, height: 600}); + } + if (this === rail) { + return DOMRect.fromRect({ + x: 0, + y: 0, + width: railWidth(), + height: 600, + }); + } + if (this === column || this === content) { + return DOMRect.fromRect({ + x: railWidth(), + y: 0, + width: 1000 - railWidth(), + height: 600, + }); + } + return DOMRect.fromRect({x: 0, y: 0, width: 0, height: 0}); + }, + ); + return { + handle: createAnimatedLayout(() => area), + area, + rail, + column, + content, + after: () => (phase = 'after'), + }; + } + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('moves the copy with the glide of the element it rides, frame by frame', async () => { + const {handle, area, rail, column, content, after} = rideSetup(); + const swap = holdLayoutSwap(handle, content); + const change = reportLayoutChange(handle, {current: rail}); + change.measureBefore(); + after(); + change.animate(); + playLayoutSwap(swap!, { + host: area, + copy: p => ({opacity: 1 - p}), + rideWith: {element: column, travel: {x: 212, y: 0}}, + incoming: [], + }); + await Promise.resolve(); + await Promise.resolve(); + const glide = played.find(entry => entry.element === column); + const ride = played.find(entry => entry.element === swap!.copy); + expect(glide?.keyframes[0].translate).toBe('212px 0px'); + expect(ride?.keyframes[0]).toMatchObject({ + opacity: 1, + translate: '0px 0px', + }); + expect(ride?.keyframes.at(-1)).toMatchObject({ + opacity: 0, + translate: '-212px 0px', + }); + // The same frames: at every sample the copy sits where the column does, + // less the travel. + expect(ride?.keyframes.length).toBe(glide?.keyframes.length); + }); + + it('takes its new place at once when the element it rides does not glide', async () => { + const {handle, area, column, content, after} = rideSetup(); + const swap = holdLayoutSwap(handle, content); + after(); + playLayoutSwap(swap!, { + host: area, + copy: p => ({opacity: 1 - p}), + rideWith: {element: column, travel: {x: 212, y: 0}}, + incoming: [], + }); + await Promise.resolve(); + const ride = played.find(entry => entry.element === swap!.copy); + expect(ride?.keyframes[0].translate).toBe('-212px 0px'); + expect(ride?.keyframes.at(-1)?.translate).toBe('-212px 0px'); + }); +}); diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index f1e72eb66375a..aab917d6caf09 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -1014,6 +1014,16 @@ export interface LayoutSwapMotion { readonly copyText?: (p: number) => number; /** The copy paints above its neighbours, in the background it shows. */ readonly isOpaque?: boolean; + /** + * An element the glide moves, such as the column the old content sat in: + * the copy rides its glide, frame by frame, by `travel` (the old place less + * the new). Without a glide (it lands silently), the copy takes its new + * place at once, as the element does. + */ + readonly rideWith?: { + readonly element: Element; + readonly travel: {readonly x: number; readonly y: number}; + }; /** Elements the page already renders, entering with the swap. */ readonly incoming: ReadonlyArray<{ readonly element: Element; @@ -1082,13 +1092,52 @@ export function playLayoutSwap( ? sampleKeyframes(motion.progress, remaining => shape(1 - remaining)) : [shape(0), shape(1)]; const animations: Animation[] = []; + let isDone = false; + const end = () => { + if (isDone) { + return; + } + isDone = true; + animations.forEach(animation => animation.cancel()); + copy.remove(); + }; const play = (element: Element, shape: (p: number) => Keyframe) => { const animation = safeAnimate(element, frames(shape), options); if (animation != null) { animations.push(animation); } }; - play(copy, travels ? how.copy : p => ({opacity: 1 - p})); + const {rideWith} = how; + if (travels && rideWith != null) { + // After this commit's glides start (the batch flushes in a microtask + // queued before this one), before the frame paints. + queueMicrotask(() => { + if (isDone) { + return; + } + const glide = glides.get(rideWith.element); + const durationMs = glide?.durationMs ?? motion.progress.durationMs; + const animation = safeAnimate( + copy, + sampleOver(durationMs, t => { + const offset = glide?.offsetAt(t) ?? AT_REST(t); + const x = offset.x - rideWith.travel.x; + const y = offset.y - rideWith.travel.y; + return { + ...how.copy(1 - motion.progress.remaining(t)), + translate: `${x}px ${y}px`, + }; + }), + {duration: durationMs, easing: 'linear', fill: 'both'}, + ); + if (animation != null) { + animations.push(animation); + animation.addEventListener?.('finish', end); + } + }); + } else { + play(copy, travels ? how.copy : p => ({opacity: 1 - p})); + } const {copyText} = how; if (travels && copyText != null) { for (const span of wrapText(copy)) { @@ -1098,20 +1147,11 @@ export function playLayoutSwap( for (const {element, frames: shape} of how.incoming) { play(element, travels ? shape : p => ({opacity: p})); } - let isDone = false; - const end = () => { - if (isDone) { - return; - } - isDone = true; - animations.forEach(animation => animation.cancel()); - copy.remove(); - }; - if (animations.length === 0) { + if (animations.length === 0 && !(travels && rideWith != null)) { end(); } else { // Every part runs on one clock, so the first to finish ends them all. - animations[0].addEventListener?.('finish', end); + animations[0]?.addEventListener?.('finish', end); } return end; } From f7d08509850cbed804186cd153f4ed673de8e89b Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 12:39:02 -0700 Subject: [PATCH 33/48] fix(core): a swap copy keeps its old rect, its canvas pixels and its sideways scroll - placeOver lays the copy out border-box, so a content-box element's copy no longer grows by its padding and border. - inertCopy paints each copied canvas with its source's pixels. - visiblePartCopy offsets by scrollLeft and drops the copied border, since the holder is the visible part inside it. --- .../src/motion/animatedLayout.swap.test.ts | 29 +++++++++++++++++++ packages/core/src/motion/transientCopy.ts | 15 +++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/core/src/motion/animatedLayout.swap.test.ts b/packages/core/src/motion/animatedLayout.swap.test.ts index b05046afa7f0e..a506f9334c158 100644 --- a/packages/core/src/motion/animatedLayout.swap.test.ts +++ b/packages/core/src/motion/animatedLayout.swap.test.ts @@ -88,6 +88,7 @@ function setup() {

      Projects

      +
    `; @@ -166,9 +167,34 @@ describe('the in-place swap (spec:AST-105 FR8)', () => { expect(text?.keyframes.at(-1)?.opacity).toBe(0); }); + it("paints a copied canvas with its source's pixels", () => { + const drawImage = vi.fn(); + const getContext = vi + .spyOn(HTMLCanvasElement.prototype, 'getContext') + .mockImplementation( + () => ({drawImage}) as unknown as CanvasRenderingContext2D, + ); + const {handle, content} = setup(); + holdLayoutSwap(handle, content); + expect(drawImage).toHaveBeenCalledWith( + content.querySelector('canvas'), + 0, + 0, + ); + getContext.mockRestore(); + }); + + it('lays the copy out on its old rect whatever its box sizing', () => { + const {handle, content, host} = setup(); + const swap = holdLayoutSwap(handle, content); + playLayoutSwap(swap!, {host, copy: travel, incoming: []}); + expect(swap!.copy.style.boxSizing).toBe('border-box'); + }); + it('copies only the visible part of a scroll container, scrolled as it was', () => { const {handle, content} = setup(); Object.defineProperty(content, 'scrollTop', {value: 40}); + Object.defineProperty(content, 'scrollLeft', {value: 30}); Object.defineProperty(content, 'clientWidth', {value: 600}); Object.defineProperty(content, 'clientHeight', {value: 300}); const swap = holdLayoutSwap(handle, content, {isVisiblePart: true}); @@ -176,6 +202,9 @@ describe('the in-place swap (spec:AST-105 FR8)', () => { expect(swap?.rect.height).toBe(300); const inner = swap?.copy.firstElementChild as HTMLElement; expect(inner.style.top).toBe('-40px'); + expect(inner.style.left).toBe('-30px'); + // The holder is the part inside the border: the copy carries none. + expect(inner.style.borderWidth).toBe('0px'); expect(inner.style.width).toBe('600px'); expect(inner.hasAttribute('role')).toBe(false); }); diff --git a/packages/core/src/motion/transientCopy.ts b/packages/core/src/motion/transientCopy.ts index 58884e0a1b5c6..7b8a10a0b5853 100644 --- a/packages/core/src/motion/transientCopy.ts +++ b/packages/core/src/motion/transientCopy.ts @@ -46,6 +46,16 @@ export function inertCopy(element: HTMLElement): HTMLElement { node.removeAttribute(name); } } + // A clone's canvas is blank: paint each with its source's pixels. + const sources = element.querySelectorAll('canvas'); + copy.querySelectorAll('canvas').forEach((canvas, index) => { + const source = sources[index]; + try { + canvas.getContext('2d')?.drawImage(source, 0, 0); + } catch { + // A canvas the copy cannot read (tainted, or a lost context) stays blank. + } + }); copy.setAttribute('aria-hidden', 'true'); copy.setAttribute('inert', ''); copy.style.pointerEvents = 'none'; @@ -75,6 +85,7 @@ export function placeOver( ): void { Object.assign(element.style, { position: 'absolute', + boxSizing: 'border-box', margin: '0px', left: '0px', top: '0px', @@ -102,11 +113,13 @@ export function visiblePartCopy(element: HTMLElement): { Object.assign(content.style, { position: 'absolute', top: `${-element.scrollTop}px`, - left: '0px', + left: `${-element.scrollLeft}px`, boxSizing: 'border-box', width: `${element.clientWidth}px`, height: 'auto', padding: getComputedStyle(element).padding, + // The holder is the visible part, inside the border. + borderWidth: '0px', margin: '0px', overflow: 'visible', translate: 'none', From 007f4142037845183ec25c1b800337799e6c702d Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 13:10:47 -0700 Subject: [PATCH 34/48] docs(spec): Collapsible FR10 and TreeList FR8 cite AST-105 for the shared motion The adopter rows cited AST-108 FR4a, which is not on main yet. AST-105 FR1-FR4 already decides the shared motion they name. --- .../core/src/Collapsible/Collapsible.spec.md | 24 +++++++++---------- packages/core/src/TreeList/TreeList.spec.md | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/core/src/Collapsible/Collapsible.spec.md b/packages/core/src/Collapsible/Collapsible.spec.md index 86df5947a9bff..29ce1ab1d9f43 100644 --- a/packages/core/src/Collapsible/Collapsible.spec.md +++ b/packages/core/src/Collapsible/Collapsible.spec.md @@ -88,18 +88,18 @@ not parallel public names for this axis. ## Behavioral and layout contract -| ID | Invariant | Basis | -| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -| FR1 | Collapsible MUST expose `chevronPosition?: 'start' \| 'end'`; omission resolves to `end`. | owner API decision; preserves released default | -| FR2 | `end` MUST render Collapsible's chevron after the trigger label in logical order. Collapsed `end` points downward; expanded `end` points upward. | shipped trailing behavior plus approved position contract | -| FR3 | `start` MUST render Collapsible's chevron before the trigger label in logical order. Collapsed `start` points inward toward the label/content, so its inline direction mirrors under RTL; expanded `start` points downward. | approved leading-disclosure behavior | -| FR4 | Position changes the chevron glyph/direction as well as DOM order. Reusing a vertical down chevron and rotating it sideways MUST NOT make collapsed `start` point away from its content. | direction and representation decision | -| FR5 | Collapsible owns and renders the chevron for both positions. The position axis MUST NOT accept `none`, arbitrary React content, a cue callback, a renderer, or another replacement escape hatch. | constrained public-axis decision | -| FR6 | Trigger-label content MUST retain the usable row width needed by composed labels at either position. Moving the chevron to `start` MUST NOT strand the label at the opposite edge or prevent composed label content from using the remaining row. | approved layout rationale and existing grow behavior | -| FR7 | CollapsibleGroup MAY provide one `chevronPosition` default to its direct Collapsible items. A direct item's explicit position MUST override the group default. | approved group-coherence behavior | -| FR8 | A Collapsible nested inside another item's revealed content MUST begin a new presentation scope and use its own explicit position or the component default. The outer group's position MUST NOT leak into the nested disclosure. | approved nested isolation behavior | -| FR9 | Chevron position MUST NOT change disclosure semantics. The same trigger remains a button, retains `aria-expanded`, identifies the controlled region with `aria-controls`, and follows the existing state owner. | existing accessibility and behavior contract | -| FR10 | Collapsible MUST accept `animatedLayout?: AnimatedLayout`. With a handle, opening and closing report the content's size change through it, so the content after the Collapsible glides inside the handle's container, and the revealed content and the chevron move on the same motion (`spec:AST-105` FR1–FR4; AST-108 FR4a). Without one, opening and closing are unchanged. The prop MUST NOT change disclosure semantics. | `spec:AST-105` FR1, FR2 | +| ID | Invariant | Basis | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| FR1 | Collapsible MUST expose `chevronPosition?: 'start' \| 'end'`; omission resolves to `end`. | owner API decision; preserves released default | +| FR2 | `end` MUST render Collapsible's chevron after the trigger label in logical order. Collapsed `end` points downward; expanded `end` points upward. | shipped trailing behavior plus approved position contract | +| FR3 | `start` MUST render Collapsible's chevron before the trigger label in logical order. Collapsed `start` points inward toward the label/content, so its inline direction mirrors under RTL; expanded `start` points downward. | approved leading-disclosure behavior | +| FR4 | Position changes the chevron glyph/direction as well as DOM order. Reusing a vertical down chevron and rotating it sideways MUST NOT make collapsed `start` point away from its content. | direction and representation decision | +| FR5 | Collapsible owns and renders the chevron for both positions. The position axis MUST NOT accept `none`, arbitrary React content, a cue callback, a renderer, or another replacement escape hatch. | constrained public-axis decision | +| FR6 | Trigger-label content MUST retain the usable row width needed by composed labels at either position. Moving the chevron to `start` MUST NOT strand the label at the opposite edge or prevent composed label content from using the remaining row. | approved layout rationale and existing grow behavior | +| FR7 | CollapsibleGroup MAY provide one `chevronPosition` default to its direct Collapsible items. A direct item's explicit position MUST override the group default. | approved group-coherence behavior | +| FR8 | A Collapsible nested inside another item's revealed content MUST begin a new presentation scope and use its own explicit position or the component default. The outer group's position MUST NOT leak into the nested disclosure. | approved nested isolation behavior | +| FR9 | Chevron position MUST NOT change disclosure semantics. The same trigger remains a button, retains `aria-expanded`, identifies the controlled region with `aria-controls`, and follows the existing state owner. | existing accessibility and behavior contract | +| FR10 | Collapsible MUST accept `animatedLayout?: AnimatedLayout`. With a handle, opening and closing report the content's size change through it, so the content after the Collapsible glides inside the handle's container, and the revealed content and the chevron move on the same motion (`spec:AST-105` FR1–FR4). Without one, opening and closing are unchanged. The prop MUST NOT change disclosure semantics. | `spec:AST-105` FR1, FR2 | ### Allowed variation diff --git a/packages/core/src/TreeList/TreeList.spec.md b/packages/core/src/TreeList/TreeList.spec.md index 1b85f2fc4a500..0b7ccbcde8c09 100644 --- a/packages/core/src/TreeList/TreeList.spec.md +++ b/packages/core/src/TreeList/TreeList.spec.md @@ -106,7 +106,7 @@ Supporting one does not justify omitting another from the durable contract. | FR5 | `TreeListItemData` MUST admit `className`, `style`, and `xstyle` as one per-item styling seam. Every supplied value MUST compose on the same Item row box; an implementation MUST NOT treat current support for only one mechanism as authority to exclude the other two. | Owner decision; `architecture:public-component-api` INV5–INV6; `spec:AST-002/DEC-1` | Settled intent; implementation may be staged | | FR6 | The Item row box is the logical element that paints the row, owns its hover state, and contains Start content, Item label and description, and End content. The styling seam MUST NOT move to the semantic `
  • ` or another element without a compatibility decision. | Owner decision and the caller-owned per-row interaction boundary | Settled intent | | FR7 | A click on an Item row outside its Item label performs the row's action: an Item with `onClick` runs it; otherwise an Item with children toggles; otherwise an Item whose only action is its link follows that link as Enter does, with modified and middle clicks following the shared enlarged-surface rule in `family:navigation-destinations`. The Chevron and controls in Start content and End content keep their own activation, and a disabled Item does nothing. | Owner decision; `family:navigation-destinations` | Settled intent; implementation may be staged | -| FR8 | `TreeList` MUST accept `animatedLayout?: AnimatedLayout`. With a handle, expanding and collapsing a row report the row's size change through it, so the content after the row glides inside the handle's container, and the shown rows and the row's Chevron move on the same motion (`spec:AST-105` FR1–FR4; AST-108 FR4a). Without one, expanding and collapsing are unchanged. The prop MUST NOT change tree semantics or keyboard behavior. | `spec:AST-105` FR1, FR2 | Current behavior | +| FR8 | `TreeList` MUST accept `animatedLayout?: AnimatedLayout`. With a handle, expanding and collapsing a row report the row's size change through it, so the content after the row glides inside the handle's container, and the shown rows and the row's Chevron move on the same motion (`spec:AST-105` FR1–FR4). Without one, expanding and collapsing are unchanged. The prop MUST NOT change tree semantics or keyboard behavior. | `spec:AST-105` FR1, FR2 | Current behavior | ### Allowed variation From f2f3d64095dc597bf74976a49ff5767660359826 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 13:50:29 -0700 Subject: [PATCH 35/48] fix(core): a swap copy labels no live control, keeps nested scroll, and fades SVG labels - inertCopy drops for, so a copied label no longer names the live control. - Scrolled descendants restore their offsets once the copy is laid out. - copyText fades an SVG label through its text element (a span does not render inside SVG). - A WebGL canvas drawn without preserveDrawingBuffer cannot be read once it is presented, so its copy stays blank; recorded in the code. --- .../src/motion/animatedLayout.swap.test.ts | 46 ++++++++++++ packages/core/src/motion/transientCopy.ts | 71 ++++++++++++++++--- 2 files changed, 107 insertions(+), 10 deletions(-) diff --git a/packages/core/src/motion/animatedLayout.swap.test.ts b/packages/core/src/motion/animatedLayout.swap.test.ts index a506f9334c158..8b8cc73bacfc5 100644 --- a/packages/core/src/motion/animatedLayout.swap.test.ts +++ b/packages/core/src/motion/animatedLayout.swap.test.ts @@ -89,6 +89,9 @@ function setup() { + +
    Rows
    + Axis
  • `; @@ -184,6 +187,49 @@ describe('the in-place swap (spec:AST-105 FR8)', () => { getContext.mockRestore(); }); + it('labels no live control: a copied label drops its for', () => { + const {handle, content} = setup(); + const swap = holdLayoutSwap(handle, content); + const label = Array.from(swap!.copy.querySelectorAll('label')).find( + element => element.textContent === 'Pro plan', + ); + expect(label).toBeDefined(); + expect(label?.hasAttribute('for')).toBe(false); + }); + + it('keeps a scrolled descendant scrolled as it was once laid out', () => { + const {handle, content, host} = setup(); + const scroller = document.getElementById('scroller') as HTMLElement; + Object.defineProperty(scroller, 'scrollTop', {value: 25}); + const set = vi.spyOn(Element.prototype, 'scrollTop', 'set'); + const swap = holdLayoutSwap(handle, content); + playLayoutSwap(swap!, {host, copy: travel, incoming: []}); + const copied = set.mock.contexts.find( + element => (element as Element).textContent === 'Rows', + ); + expect(copied).toBeDefined(); + expect(set).toHaveBeenCalledWith(25); + set.mockRestore(); + }); + + it('fades a copied SVG label through its text element, not a span', () => { + const {handle, content, host} = setup(); + const swap = holdLayoutSwap(handle, content); + playLayoutSwap(swap!, { + host, + copy: travel, + copyText: p => 1 - p, + incoming: [], + }); + expect(swap!.copy.querySelector('svg span')).toBeNull(); + const axis = played.find( + entry => + entry.element.namespaceURI === 'http://www.w3.org/2000/svg' && + entry.element.textContent === 'Axis', + ); + expect(axis?.element.tagName.toLowerCase()).toBe('text'); + }); + it('lays the copy out on its old rect whatever its box sizing', () => { const {handle, content, host} = setup(); const swap = holdLayoutSwap(handle, content); diff --git a/packages/core/src/motion/transientCopy.ts b/packages/core/src/motion/transientCopy.ts index 7b8a10a0b5853..f42e66c312af9 100644 --- a/packages/core/src/motion/transientCopy.ts +++ b/packages/core/src/motion/transientCopy.ts @@ -27,15 +27,38 @@ export function isCopyable(element: Element): boolean { return element.getElementsByTagName('*').length <= MAX_COPIED_ELEMENTS; } +/** Scroll offsets a copy restores once laid out (a clone carries none). */ +const scrollOffsets = new WeakMap< + HTMLElement, + ReadonlyArray +>(); + /** * A copy that takes no part: no ids or test ids, no form or radio group (a - * copied checked radio would uncheck the live one), and copied frames and - * media load nothing. + * copied checked radio would uncheck the live one), no label for a live + * control, and copied frames and media load nothing. Scrolled descendants + * keep their offsets once the copy is laid out (`placeOver`). */ export function inertCopy(element: HTMLElement): HTMLElement { const copy = element.cloneNode(true) as HTMLElement; - for (const node of [copy, ...copy.querySelectorAll('*')]) { - for (const name of ['id', 'data-testid', 'name', 'form', 'autofocus']) { + const sourceNodes = [element, ...element.querySelectorAll('*')]; + const copyNodes = [copy, ...copy.querySelectorAll('*')]; + const offsets: [Element, number, number][] = []; + sourceNodes.forEach((source, index) => { + if (source.scrollTop !== 0 || source.scrollLeft !== 0) { + offsets.push([copyNodes[index], source.scrollTop, source.scrollLeft]); + } + }); + scrollOffsets.set(copy, offsets); + for (const node of copyNodes) { + for (const name of [ + 'id', + 'data-testid', + 'name', + 'form', + 'for', + 'autofocus', + ]) { node.removeAttribute(name); } } @@ -46,7 +69,9 @@ export function inertCopy(element: HTMLElement): HTMLElement { node.removeAttribute(name); } } - // A clone's canvas is blank: paint each with its source's pixels. + // A clone's canvas is blank: paint each with its source's pixels. A WebGL + // canvas drawn without `preserveDrawingBuffer` cannot be read once it is + // presented, by any API, so its copy stays blank. const sources = element.querySelectorAll('canvas'); copy.querySelectorAll('canvas').forEach((canvas, index) => { const source = sources[index]; @@ -96,6 +121,10 @@ export function placeOver( const origin = element.getBoundingClientRect(); element.style.left = `${rect.left - origin.left}px`; element.style.top = `${rect.top - origin.top}px`; + for (const [node, top, left] of scrollOffsets.get(element) ?? []) { + node.scrollTop = top; + node.scrollLeft = left; + } } /** @@ -129,6 +158,12 @@ export function visiblePartCopy(element: HTMLElement): { holder.setAttribute('inert', ''); Object.assign(holder.style, {overflow: 'hidden', pointerEvents: 'none'}); holder.appendChild(content); + // The content's own offset is its position; its scrolled descendants + // restore theirs when the holder is laid out. + scrollOffsets.set( + holder, + (scrollOffsets.get(content) ?? []).filter(([node]) => node !== content), + ); const box = element.getBoundingClientRect(); return { copy: holder, @@ -141,8 +176,14 @@ export function visiblePartCopy(element: HTMLElement): { }; } -/** Wraps each run of text in a copy in its own span, so the text can fade. */ -export function wrapText(copy: HTMLElement): HTMLElement[] { +const SVG = 'http://www.w3.org/2000/svg'; + +/** + * The elements that let a copy's text fade: each run of HTML text wrapped in + * its own span, and each SVG `text` element as it is (a span does not render + * inside SVG). + */ +export function wrapText(copy: HTMLElement): Element[] { const walker = copy.ownerDocument.createTreeWalker(copy, 4); const texts: Text[] = []; while (walker.nextNode()) { @@ -150,10 +191,20 @@ export function wrapText(copy: HTMLElement): HTMLElement[] { texts.push(walker.currentNode as Text); } } - return texts.map(text => { + const faded = new Set(); + for (const text of texts) { + const parent = text.parentElement; + if (parent?.namespaceURI === SVG) { + const svgText = parent.closest('text'); + if (svgText != null) { + faded.add(svgText); + } + continue; + } const span = copy.ownerDocument.createElement('span'); text.replaceWith(span); span.appendChild(text); - return span; - }); + faded.add(span); + } + return [...faded]; } From d771226e8c01a44c26eb147a2a72e80106c73482 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 14:20:24 -0700 Subject: [PATCH 36/48] fix(core): the glide moves an element by its inline-start edge, so a right-to-left follower glides In a right-to-left document an element that narrows keeps its left edge and moves its right one; the glide measured the left edge and so jumped. --- .../src/motion/animatedLayout.swap.test.ts | 53 +++++++++++++++++++ packages/core/src/motion/animatedLayout.ts | 9 +++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/packages/core/src/motion/animatedLayout.swap.test.ts b/packages/core/src/motion/animatedLayout.swap.test.ts index 8b8cc73bacfc5..1fdcf1a5ec115 100644 --- a/packages/core/src/motion/animatedLayout.swap.test.ts +++ b/packages/core/src/motion/animatedLayout.swap.test.ts @@ -385,3 +385,56 @@ describe('a swap that rides a glide (spec:AST-105 FR8 with FR3)', () => { expect(ride?.keyframes.at(-1)?.translate).toBe('-212px 0px'); }); }); + +describe('the glide in a right-to-left document', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('moves a follower by its inline-start edge, the right one', async () => { + document.body.innerHTML = ` +
    +
    +
    +
    `; + const area = document.getElementById('area') as HTMLElement; + const rail = document.getElementById('rail') as HTMLElement; + const column = document.getElementById('column') as HTMLElement; + let railWidth = 260; + // The rail sits on the right; the column keeps its left edge at 0. + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + function (this: HTMLElement) { + if (this === area) { + return DOMRect.fromRect({x: 0, y: 0, width: 1000, height: 600}); + } + if (this === rail) { + return DOMRect.fromRect({ + x: 1000 - railWidth, + y: 0, + width: railWidth, + height: 600, + }); + } + if (this === column) { + return DOMRect.fromRect({ + x: 0, + y: 0, + width: 1000 - railWidth, + height: 600, + }); + } + return DOMRect.fromRect({x: 0, y: 0, width: 0, height: 0}); + }, + ); + const handle = createAnimatedLayout(() => area); + const change = reportLayoutChange(handle, {current: rail}); + change.measureBefore(); + railWidth = 48; + change.animate(); + await Promise.resolve(); + const glide = played.find(entry => entry.element === column); + // Its right edge moved from 740 to 952: it starts 212 px back. + expect(glide?.keyframes[0].translate).toBe('-212px 0px'); + expect(glide?.keyframes.at(-1)?.translate).toBe('0px 0px'); + }); +}); diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index aab917d6caf09..561fed6755088 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -518,7 +518,14 @@ function flush(batch: Batch): void { const byElement = new Map(); for (const entry of entries) { entry.after = entry.element.getBoundingClientRect(); - entry.rawX = entry.before.left - entry.after.left; + // The move is the inline-start edge's: in a right-to-left document an + // element that narrows keeps its left edge and moves its right one. + const isRtl = + entry.element.ownerDocument.defaultView?.getComputedStyle(entry.element) + .direction === 'rtl'; + entry.rawX = isRtl + ? entry.before.right - entry.after.right + : entry.before.left - entry.after.left; entry.rawY = entry.before.top - entry.after.top; byElement.set(entry.element, entry); } From 9fbc11874b9b3a9d1be7ad305d26032d589509a9 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 14:58:23 -0700 Subject: [PATCH 37/48] fix(core): swap copies land scroll at once, keep their size under a scaled host, and decline unreadable canvases; the glide reads the container's direction - Restored scroll offsets use scrollTo with behavior 'instant', so a smooth strip does not scroll inside the copy. - placeOver divides by the host's scale, so a scaled host draws the copy at the old size. - Content holding a canvas that cannot be read back (WebGL without preserveDrawingBuffer, tainted) is not copied; the new content enters. A rule keyed on a removed id not styling the copy is recorded as a limit. - The glide's inline-start edge follows the container's direction. - The changeset names the right-to-left glide fix for Collapsible and TreeList. --- .changeset/animated-layout-swap.md | 2 +- .../src/motion/animatedLayout.swap.test.ts | 71 ++++++++++++++++--- packages/core/src/motion/animatedLayout.ts | 6 +- packages/core/src/motion/transientCopy.ts | 66 ++++++++++++++--- 4 files changed, 124 insertions(+), 21 deletions(-) diff --git a/.changeset/animated-layout-swap.md b/.changeset/animated-layout-swap.md index 1180e10ee014b..4d1d513b9dd1a 100644 --- a/.changeset/animated-layout-swap.md +++ b/.changeset/animated-layout-swap.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] The animated-layout module gains its internal in-place swap: old content held as one inert, transient copy while new content enters in its place, on the shared motion. Nothing uses it yet and nothing public changes. +[fix] The animated-layout module gains its internal in-place swap: old content held as one inert, transient copy while new content enters in its place, on the shared motion. Nothing uses it yet and nothing public changes. In right-to-left containers, content after a Collapsible or TreeList size change (with an `animatedLayout` handle) now glides by its inline-start edge instead of jumping. @cixzhang diff --git a/packages/core/src/motion/animatedLayout.swap.test.ts b/packages/core/src/motion/animatedLayout.swap.test.ts index 1fdcf1a5ec115..980067fcefa4c 100644 --- a/packages/core/src/motion/animatedLayout.swap.test.ts +++ b/packages/core/src/motion/animatedLayout.swap.test.ts @@ -175,7 +175,11 @@ describe('the in-place swap (spec:AST-105 FR8)', () => { const getContext = vi .spyOn(HTMLCanvasElement.prototype, 'getContext') .mockImplementation( - () => ({drawImage}) as unknown as CanvasRenderingContext2D, + () => + ({ + drawImage, + getImageData: () => ({data: new Uint8ClampedArray([0, 0, 0, 255])}), + }) as unknown as CanvasRenderingContext2D, ); const {handle, content} = setup(); holdLayoutSwap(handle, content); @@ -187,6 +191,50 @@ describe('the in-place swap (spec:AST-105 FR8)', () => { getContext.mockRestore(); }); + it('does not copy content holding a canvas it cannot read back', () => { + const getContext = vi + .spyOn(HTMLCanvasElement.prototype, 'getContext') + .mockImplementation( + () => + ({ + drawImage: vi.fn(), + // A WebGL canvas without preserveDrawingBuffer reads back empty. + getImageData: () => ({data: new Uint8ClampedArray(16 * 16 * 4)}), + }) as unknown as CanvasRenderingContext2D, + ); + const {handle, content} = setup(); + expect(holdLayoutSwap(handle, content)).toBeNull(); + getContext.mockRestore(); + }); + + it('draws the copy at the old size under a scaled host', () => { + const {handle, content, host} = setup(); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + function (this: HTMLElement) { + if (this === content) { + return DOMRect.fromRect({x: 100, y: 50, width: 400, height: 200}); + } + if (this.hasAttribute('inert')) { + // The host is scaled by half: the copy draws at half its styles. + return DOMRect.fromRect({ + x: 10, + y: 10, + width: parseFloat(this.style.width) / 2, + height: parseFloat(this.style.height) / 2, + }); + } + return DOMRect.fromRect({x: 0, y: 0, width: 0, height: 0}); + }, + ); + const swap = holdLayoutSwap(handle, content); + playLayoutSwap(swap!, {host, copy: travel, incoming: []}); + expect(swap!.copy.style.width).toBe('800px'); + expect(swap!.copy.style.height).toBe('400px'); + expect(swap!.copy.style.left).toBe('180px'); + expect(swap!.copy.style.top).toBe('80px'); + vi.restoreAllMocks(); + }); + it('labels no live control: a copied label drops its for', () => { const {handle, content} = setup(); const swap = holdLayoutSwap(handle, content); @@ -201,15 +249,22 @@ describe('the in-place swap (spec:AST-105 FR8)', () => { const {handle, content, host} = setup(); const scroller = document.getElementById('scroller') as HTMLElement; Object.defineProperty(scroller, 'scrollTop', {value: 25}); - const set = vi.spyOn(Element.prototype, 'scrollTop', 'set'); + const scrollTo = vi.fn(); + const original = Element.prototype.scrollTo; + Element.prototype.scrollTo = scrollTo; const swap = holdLayoutSwap(handle, content); playLayoutSwap(swap!, {host, copy: travel, incoming: []}); - const copied = set.mock.contexts.find( + const copied = scrollTo.mock.contexts.find( element => (element as Element).textContent === 'Rows', ); expect(copied).toBeDefined(); - expect(set).toHaveBeenCalledWith(25); - set.mockRestore(); + // At once, whatever the copy's own scroll-behavior (a smooth strip). + expect(scrollTo).toHaveBeenCalledWith({ + top: 25, + left: 0, + behavior: 'instant', + }); + Element.prototype.scrollTo = original; }); it('fades a copied SVG label through its text element, not a span', () => { @@ -391,11 +446,11 @@ describe('the glide in a right-to-left document', () => { vi.restoreAllMocks(); }); - it('moves a follower by its inline-start edge, the right one', async () => { + it("moves a follower by its container's inline-start edge, even with its own direction", async () => { document.body.innerHTML = ` -
    +
    -
    +
    `; const area = document.getElementById('area') as HTMLElement; const rail = document.getElementById('rail') as HTMLElement; diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index 561fed6755088..fb157b17994f1 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -518,10 +518,12 @@ function flush(batch: Batch): void { const byElement = new Map(); for (const entry of entries) { entry.after = entry.element.getBoundingClientRect(); - // The move is the inline-start edge's: in a right-to-left document an + // The move is the inline-start edge's, by the container's direction (it + // decides where the element sits): in a right-to-left container an // element that narrows keeps its left edge and moves its right one. + const container = entry.element.parentElement ?? entry.element; const isRtl = - entry.element.ownerDocument.defaultView?.getComputedStyle(entry.element) + container.ownerDocument.defaultView?.getComputedStyle(container) .direction === 'rtl'; entry.rawX = isRtl ? entry.before.right - entry.after.right diff --git a/packages/core/src/motion/transientCopy.ts b/packages/core/src/motion/transientCopy.ts index f42e66c312af9..d4cd7b8e3aa24 100644 --- a/packages/core/src/motion/transientCopy.ts +++ b/packages/core/src/motion/transientCopy.ts @@ -14,7 +14,11 @@ * A copy takes no part in the page: it is inert and hidden from assistive * technology, carries no ids or test ids, joins no form or radio group (a * copied checked radio would uncheck the live one), and its frames and media - * load nothing. Laying it out changes no ancestor's position. + * load nothing. Laying it out changes no ancestor's position, and a scaled + * host draws it at the old size. Limits: a rule keyed on a removed `id` does + * not style the copy, and content holding a canvas whose pixels cannot be + * read back (a WebGL canvas without `preserveDrawingBuffer`, or a tainted + * one) is not copied: the new content enters without an old copy. * * SYNC: When modified, update /packages/core/src/motion/animatedLayout.ts */ @@ -22,9 +26,39 @@ /** Above this many elements, old content is not copied: the new one enters. */ const MAX_COPIED_ELEMENTS = 1500; -/** Whether `element` is small enough to copy. */ +/** Whether a canvas's pixels can be read back: drawn into a small probe. */ +function isReadable(canvas: HTMLCanvasElement): boolean { + if (canvas.width === 0 || canvas.height === 0) { + return true; + } + const probe = canvas.ownerDocument.createElement('canvas'); + probe.width = 16; + probe.height = 16; + const context = probe.getContext('2d', {willReadFrequently: true}); + if (context == null) { + // No 2D canvas here (a test environment): nothing to tell. + return true; + } + try { + context.drawImage(canvas, 0, 0, 16, 16); + const {data} = context.getImageData(0, 0, 16, 16); + // A canvas that reads back empty is either blank or unreadable; either + // way its copy would show nothing, so the content is not copied. + return data.some((value, index) => index % 4 === 3 && value !== 0); + } catch { + return false; + } +} + +/** + * Whether `element` can be copied faithfully: small enough, and every canvas + * in it readable. + */ export function isCopyable(element: Element): boolean { - return element.getElementsByTagName('*').length <= MAX_COPIED_ELEMENTS; + return ( + element.getElementsByTagName('*').length <= MAX_COPIED_ELEMENTS && + Array.from(element.getElementsByTagName('canvas')).every(isReadable) + ); } /** Scroll offsets a copy restores once laid out (a clone carries none). */ @@ -69,9 +103,8 @@ export function inertCopy(element: HTMLElement): HTMLElement { node.removeAttribute(name); } } - // A clone's canvas is blank: paint each with its source's pixels. A WebGL - // canvas drawn without `preserveDrawingBuffer` cannot be read once it is - // presented, by any API, so its copy stays blank. + // A clone's canvas is blank: paint each with its source's pixels (content + // with an unreadable canvas is not copied: `isCopyable`). const sources = element.querySelectorAll('canvas'); copy.querySelectorAll('canvas').forEach((canvas, index) => { const source = sources[index]; @@ -118,12 +151,25 @@ export function placeOver( height: `${rect.height}px`, }); host.appendChild(element); + // The origin, and the host's scale: the rect is in viewport pixels, the + // copy's styles in the host's own. const origin = element.getBoundingClientRect(); - element.style.left = `${rect.left - origin.left}px`; - element.style.top = `${rect.top - origin.top}px`; + const scaleX = + origin.width > 0 && rect.width > 0 ? origin.width / rect.width : 1; + const scaleY = + origin.height > 0 && rect.height > 0 ? origin.height / rect.height : 1; + element.style.width = `${rect.width / scaleX}px`; + element.style.height = `${rect.height / scaleY}px`; + element.style.left = `${(rect.left - origin.left) / scaleX}px`; + element.style.top = `${(rect.top - origin.top) / scaleY}px`; for (const [node, top, left] of scrollOffsets.get(element) ?? []) { - node.scrollTop = top; - node.scrollLeft = left; + // At once, whatever the element's own `scroll-behavior`. + if (typeof node.scrollTo === 'function') { + node.scrollTo({top, left, behavior: 'instant'}); + } else { + node.scrollTop = top; + node.scrollLeft = left; + } } } From 72a67c3dc44bdf88911a481a4a5616fb5441fa18 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 15:30:33 -0700 Subject: [PATCH 38/48] fix(core): a swap copy reads its canvases at full size and its scale from its layout size - A canvas is painted and read back at its own size, so a hairline or a dot keeps the copy; content declines the copy only when a canvas reads back empty (unreadable or blank), as the header now says. - placeOver takes the host's scale from the rendered size over the layout size (offsetWidth/offsetHeight), so a size the copy's own CSS imposes is not taken for a scale. --- .../src/motion/animatedLayout.swap.test.ts | 50 ++++++++++- packages/core/src/motion/animatedLayout.ts | 7 +- packages/core/src/motion/transientCopy.ts | 87 ++++++++++--------- 3 files changed, 97 insertions(+), 47 deletions(-) diff --git a/packages/core/src/motion/animatedLayout.swap.test.ts b/packages/core/src/motion/animatedLayout.swap.test.ts index 980067fcefa4c..fd39344fd386d 100644 --- a/packages/core/src/motion/animatedLayout.swap.test.ts +++ b/packages/core/src/motion/animatedLayout.swap.test.ts @@ -207,25 +207,57 @@ describe('the in-place swap (spec:AST-105 FR8)', () => { getContext.mockRestore(); }); - it('draws the copy at the old size under a scaled host', () => { + it('keeps a canvas with only a hairline drawn: thin marks read back', () => { + const data = new Uint8ClampedArray(40 * 20 * 4); + data[data.length - 1] = 255; + const getContext = vi + .spyOn(HTMLCanvasElement.prototype, 'getContext') + .mockImplementation( + () => + ({ + drawImage: vi.fn(), + getImageData: () => ({data}), + }) as unknown as CanvasRenderingContext2D, + ); + const {handle, content} = setup(); + expect(holdLayoutSwap(handle, content)).not.toBeNull(); + getContext.mockRestore(); + }); + + /** The copy renders at `scale` of its layout size, `extra` px taller. */ + function stubCopyLayout(scale: number, extra = 0) { const {handle, content, host} = setup(); + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation( + function (this: HTMLElement) { + return parseFloat(this.style.width) || 0; + }, + ); + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation( + function (this: HTMLElement) { + return (parseFloat(this.style.height) || 0) + extra; + }, + ); vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( function (this: HTMLElement) { if (this === content) { return DOMRect.fromRect({x: 100, y: 50, width: 400, height: 200}); } if (this.hasAttribute('inert')) { - // The host is scaled by half: the copy draws at half its styles. return DOMRect.fromRect({ x: 10, y: 10, - width: parseFloat(this.style.width) / 2, - height: parseFloat(this.style.height) / 2, + width: this.offsetWidth * scale, + height: this.offsetHeight * scale, }); } return DOMRect.fromRect({x: 0, y: 0, width: 0, height: 0}); }, ); + return {handle, content, host}; + } + + it('draws the copy at the old size under a scaled host', () => { + const {handle, content, host} = stubCopyLayout(0.5); const swap = holdLayoutSwap(handle, content); playLayoutSwap(swap!, {host, copy: travel, incoming: []}); expect(swap!.copy.style.width).toBe('800px'); @@ -235,6 +267,16 @@ describe('the in-place swap (spec:AST-105 FR8)', () => { vi.restoreAllMocks(); }); + it("does not take a size the copy's own CSS imposes for a scale", () => { + // A `min-height: 100%` makes the copy 143 px taller than asked. + const {handle, content, host} = stubCopyLayout(1, 143); + const swap = holdLayoutSwap(handle, content); + playLayoutSwap(swap!, {host, copy: travel, incoming: []}); + expect(swap!.copy.style.top).toBe('40px'); + expect(swap!.copy.style.width).toBe('400px'); + vi.restoreAllMocks(); + }); + it('labels no live control: a copied label drops its for', () => { const {handle, content} = setup(); const swap = holdLayoutSwap(handle, content); diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index fb157b17994f1..b31687f9d640f 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -1065,10 +1065,11 @@ export function holdLayoutSwap( } const box = element.getBoundingClientRect(); if (!isVisiblePart) { - return {area, copy: inertCopy(element), rect: box}; + const copy = inertCopy(element); + return copy == null ? null : {area, copy, rect: box}; } - const {copy, rect} = visiblePartCopy(element); - return {area, copy, rect}; + const part = visiblePartCopy(element); + return part == null ? null : {area, ...part}; } /** diff --git a/packages/core/src/motion/transientCopy.ts b/packages/core/src/motion/transientCopy.ts index d4cd7b8e3aa24..c62c92bda5bb2 100644 --- a/packages/core/src/motion/transientCopy.ts +++ b/packages/core/src/motion/transientCopy.ts @@ -16,9 +16,10 @@ * copied checked radio would uncheck the live one), and its frames and media * load nothing. Laying it out changes no ancestor's position, and a scaled * host draws it at the old size. Limits: a rule keyed on a removed `id` does - * not style the copy, and content holding a canvas whose pixels cannot be - * read back (a WebGL canvas without `preserveDrawingBuffer`, or a tainted - * one) is not copied: the new content enters without an old copy. + * not style the copy, and content holding a canvas whose pixels read back + * empty is not copied, so the new content enters without an old copy: a + * canvas that cannot be read back (a WebGL canvas without + * `preserveDrawingBuffer`, a tainted one) or a blank one. * * SYNC: When modified, update /packages/core/src/motion/animatedLayout.ts */ @@ -26,41 +27,42 @@ /** Above this many elements, old content is not copied: the new one enters. */ const MAX_COPIED_ELEMENTS = 1500; -/** Whether a canvas's pixels can be read back: drawn into a small probe. */ -function isReadable(canvas: HTMLCanvasElement): boolean { +/** Whether `element` is small enough to copy. */ +export function isCopyable(element: Element): boolean { + return element.getElementsByTagName('*').length <= MAX_COPIED_ELEMENTS; +} + +/** + * Paints `canvas` with `source`'s pixels at its own size; false when they + * read back empty: unreadable (a WebGL canvas without + * `preserveDrawingBuffer`, a tainted one) or blank. + */ +function paintCanvas( + canvas: HTMLCanvasElement, + source: HTMLCanvasElement, +): boolean { if (canvas.width === 0 || canvas.height === 0) { return true; } - const probe = canvas.ownerDocument.createElement('canvas'); - probe.width = 16; - probe.height = 16; - const context = probe.getContext('2d', {willReadFrequently: true}); + const context = canvas.getContext('2d', {willReadFrequently: true}); if (context == null) { - // No 2D canvas here (a test environment): nothing to tell. + // No 2D canvas here (a test environment): nothing to paint or tell. return true; } try { - context.drawImage(canvas, 0, 0, 16, 16); - const {data} = context.getImageData(0, 0, 16, 16); - // A canvas that reads back empty is either blank or unreadable; either - // way its copy would show nothing, so the content is not copied. - return data.some((value, index) => index % 4 === 3 && value !== 0); + context.drawImage(source, 0, 0); + const {data} = context.getImageData(0, 0, canvas.width, canvas.height); + for (let index = 3; index < data.length; index += 4) { + if (data[index] !== 0) { + return true; + } + } + return false; } catch { return false; } } -/** - * Whether `element` can be copied faithfully: small enough, and every canvas - * in it readable. - */ -export function isCopyable(element: Element): boolean { - return ( - element.getElementsByTagName('*').length <= MAX_COPIED_ELEMENTS && - Array.from(element.getElementsByTagName('canvas')).every(isReadable) - ); -} - /** Scroll offsets a copy restores once laid out (a clone carries none). */ const scrollOffsets = new WeakMap< HTMLElement, @@ -73,7 +75,7 @@ const scrollOffsets = new WeakMap< * control, and copied frames and media load nothing. Scrolled descendants * keep their offsets once the copy is laid out (`placeOver`). */ -export function inertCopy(element: HTMLElement): HTMLElement { +export function inertCopy(element: HTMLElement): HTMLElement | null { const copy = element.cloneNode(true) as HTMLElement; const sourceNodes = [element, ...element.querySelectorAll('*')]; const copyNodes = [copy, ...copy.querySelectorAll('*')]; @@ -103,17 +105,13 @@ export function inertCopy(element: HTMLElement): HTMLElement { node.removeAttribute(name); } } - // A clone's canvas is blank: paint each with its source's pixels (content - // with an unreadable canvas is not copied: `isCopyable`). + // A clone's canvas is blank: paint each with its source's pixels. Content + // with a canvas that reads back empty is not copied. const sources = element.querySelectorAll('canvas'); - copy.querySelectorAll('canvas').forEach((canvas, index) => { - const source = sources[index]; - try { - canvas.getContext('2d')?.drawImage(source, 0, 0); - } catch { - // A canvas the copy cannot read (tainted, or a lost context) stays blank. - } - }); + const canvases = Array.from(copy.querySelectorAll('canvas')); + if (!canvases.every((canvas, index) => paintCanvas(canvas, sources[index]))) { + return null; + } copy.setAttribute('aria-hidden', 'true'); copy.setAttribute('inert', ''); copy.style.pointerEvents = 'none'; @@ -153,11 +151,17 @@ export function placeOver( host.appendChild(element); // The origin, and the host's scale: the rect is in viewport pixels, the // copy's styles in the host's own. + // The rendered size over the layout size, as the glide's `scaleOf` reads + // it, so a size the copy's own CSS imposes is not taken for a scale. const origin = element.getBoundingClientRect(); const scaleX = - origin.width > 0 && rect.width > 0 ? origin.width / rect.width : 1; + origin.width > 0 && element.offsetWidth > 0 + ? origin.width / element.offsetWidth + : 1; const scaleY = - origin.height > 0 && rect.height > 0 ? origin.height / rect.height : 1; + origin.height > 0 && element.offsetHeight > 0 + ? origin.height / element.offsetHeight + : 1; element.style.width = `${rect.width / scaleX}px`; element.style.height = `${rect.height / scaleY}px`; element.style.left = `${(rect.left - origin.left) / scaleX}px`; @@ -181,8 +185,11 @@ export function placeOver( export function visiblePartCopy(element: HTMLElement): { copy: HTMLElement; rect: DOMRect; -} { +} | null { const content = inertCopy(element); + if (content == null) { + return null; + } content.removeAttribute('role'); content.removeAttribute('tabindex'); Object.assign(content.style, { From 2f074f8d1cf33b74516e702d2fad24b437f483b2 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 16:05:20 -0700 Subject: [PATCH 39/48] fix(core): a ridden copy starts where it was laid; a fractional copy keeps its place; frames can play with no copy - rideWith moves the copy by the glide's offset against its start, so a change made during a running glide no longer counts that glide twice; travel stands only for a missing glide. - placeOver takes the requested size as the layout size within a pixel, since offsetWidth/offsetHeight round. - playLayoutFrames plays frames on elements the page renders on the same motion with no copy (a surface reaching over a gap when no copy is held). --- .../src/motion/animatedLayout.swap.test.ts | 70 ++++++++++++++++++- packages/core/src/motion/animatedLayout.ts | 49 ++++++++++++- packages/core/src/motion/transientCopy.ts | 18 +++-- 3 files changed, 128 insertions(+), 9 deletions(-) diff --git a/packages/core/src/motion/animatedLayout.swap.test.ts b/packages/core/src/motion/animatedLayout.swap.test.ts index fd39344fd386d..ee1eea590f0b2 100644 --- a/packages/core/src/motion/animatedLayout.swap.test.ts +++ b/packages/core/src/motion/animatedLayout.swap.test.ts @@ -17,6 +17,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import { createAnimatedLayout, holdLayoutSwap, + playLayoutFrames, playLayoutSwap, reportLayoutChange, } from './animatedLayout'; @@ -445,7 +446,9 @@ describe('a swap that rides a glide (spec:AST-105 FR8 with FR3)', () => { playLayoutSwap(swap!, { host: area, copy: p => ({opacity: 1 - p}), - rideWith: {element: column, travel: {x: 212, y: 0}}, + // The travel stands only for a missing glide: the copy rides the + // glide from where it was laid, whatever the travel says. + rideWith: {element: column, travel: {x: 150, y: 0}}, incoming: [], }); await Promise.resolve(); @@ -535,3 +538,68 @@ describe('the glide in a right-to-left document', () => { expect(glide?.keyframes.at(-1)?.translate).toBe('0px 0px'); }); }); + +describe('more swap layout cases', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('lays a fractional-size copy on its old place: the layout size rounds', () => { + const {handle, content, host} = setup(); + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation( + function (this: HTMLElement) { + return Math.round(parseFloat(this.style.width) || 0); + }, + ); + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation( + function (this: HTMLElement) { + return Math.round(parseFloat(this.style.height) || 0); + }, + ); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + function (this: HTMLElement) { + if (this === content) { + return DOMRect.fromRect({x: 0, y: 687, width: 420, height: 20.4}); + } + if (this.hasAttribute('inert')) { + // Unscaled: drawn at the requested size. + return DOMRect.fromRect({ + x: 0, + y: 0, + width: parseFloat(this.style.width), + height: parseFloat(this.style.height), + }); + } + return DOMRect.fromRect({x: 0, y: 0, width: 0, height: 0}); + }, + ); + const swap = holdLayoutSwap(handle, content); + playLayoutSwap(swap!, {host, copy: travel, incoming: []}); + expect(swap!.copy.style.top).toBe('687px'); + expect(swap!.copy.style.height).toBe('20.4px'); + }); + + it('plays frames on the same motion with no copy, opacity-only under DR2', () => { + const {handle} = setup(); + const title = document.getElementById('title') as HTMLElement; + playLayoutFrames(handle, [ + { + element: title, + frames: p => ({boxShadow: `${100 * (1 - p)}px 0px 0px 0px red`}), + }, + ]); + const spring = played.find(entry => entry.element === title); + expect(spring?.keyframes[0].boxShadow).toBe('100px 0px 0px 0px red'); + expect(spring?.keyframes.at(-1)?.boxShadow).toBe('0px 0px 0px 0px red'); + expect(spring?.startTime).toBe(500); + played = []; + media['(prefers-reduced-motion: reduce)'] = true; + media['(pointer: coarse)'] = true; + playLayoutFrames(handle, [{element: title, frames: () => ({scale: '2'})}]); + expect(played[0].keyframes).toEqual([{opacity: 0}, {opacity: 1}]); + played = []; + media['(pointer: coarse)'] = false; + playLayoutFrames(handle, [{element: title, frames: () => ({scale: '2'})}]); + expect(played).toEqual([]); + }); +}); diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index b31687f9d640f..a80a34f0a0de1 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -1031,6 +1031,7 @@ export interface LayoutSwapMotion { */ readonly rideWith?: { readonly element: Element; + /** Its old place less its new, for when it does not glide. */ readonly travel: {readonly x: number; readonly y: number}; }; /** Elements the page already renders, entering with the swap. */ @@ -1130,9 +1131,13 @@ export function playLayoutSwap( const animation = safeAnimate( copy, sampleOver(durationMs, t => { + // Where the element shows against where it showed at the start, + // which is where the copy was laid (a running glide's offset + // included); without a glide it lands at once, by `travel`. const offset = glide?.offsetAt(t) ?? AT_REST(t); - const x = offset.x - rideWith.travel.x; - const y = offset.y - rideWith.travel.y; + const start = glide?.offsetAt(0) ?? rideWith.travel; + const x = offset.x - start.x; + const y = offset.y - start.y; return { ...how.copy(1 - motion.progress.remaining(t)), translate: `${x}px ${y}px`, @@ -1166,6 +1171,46 @@ export function playLayoutSwap( return end; } +/** + * After a change commits, before paint: elements the page already renders + * play frames over the change's progress on FR4's one motion, with no copy, + * such as a surface reaching over a gap a glide opens. Under Reduce Motion + * with a coarse pointer, only opacity changes; under an immediate change, + * nothing plays. Returns what ends them at once. + */ +export function playLayoutFrames( + handle: AnimatedLayout | null | undefined, + parts: LayoutSwapMotion['incoming'], +): () => void { + const area = handle?.[AREA]() ?? null; + const motion = area == null ? null : layoutMotion(area); + if (motion == null || motion.kind === 'instant') { + return () => {}; + } + const animations: Animation[] = []; + for (const {element, frames: shape} of parts) { + const animation = + motion.kind === 'spring' + ? safeAnimate( + element, + sampleKeyframes(motion.progress, remaining => shape(1 - remaining)), + { + duration: motion.progress.durationMs, + easing: 'linear', + fill: 'both', + }, + ) + : safeAnimate(element, [{opacity: 0}, {opacity: 1}], { + duration: motion.durationMs, + easing: motion.easing, + }); + if (animation != null) { + animations.push(animation); + } + } + return () => animations.forEach(animation => animation.cancel()); +} + /** * 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 diff --git a/packages/core/src/motion/transientCopy.ts b/packages/core/src/motion/transientCopy.ts index c62c92bda5bb2..9a0a1f7b288f2 100644 --- a/packages/core/src/motion/transientCopy.ts +++ b/packages/core/src/motion/transientCopy.ts @@ -153,15 +153,21 @@ export function placeOver( // copy's styles in the host's own. // The rendered size over the layout size, as the glide's `scaleOf` reads // it, so a size the copy's own CSS imposes is not taken for a scale. + // The layout size rounds to whole pixels: within a pixel of the requested + // size, the requested size is the layout size. const origin = element.getBoundingClientRect(); + const layoutWidth = + Math.abs(element.offsetWidth - rect.width) < 1 + ? rect.width + : element.offsetWidth; + const layoutHeight = + Math.abs(element.offsetHeight - rect.height) < 1 + ? rect.height + : element.offsetHeight; const scaleX = - origin.width > 0 && element.offsetWidth > 0 - ? origin.width / element.offsetWidth - : 1; + origin.width > 0 && layoutWidth > 0 ? origin.width / layoutWidth : 1; const scaleY = - origin.height > 0 && element.offsetHeight > 0 - ? origin.height / element.offsetHeight - : 1; + origin.height > 0 && layoutHeight > 0 ? origin.height / layoutHeight : 1; element.style.width = `${rect.width / scaleX}px`; element.style.height = `${rect.height / scaleY}px`; element.style.left = `${(rect.left - origin.left) / scaleX}px`; From 8148cfeafa3ae508e0d30a995be5d0645d363490 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 16:36:33 -0700 Subject: [PATCH 40/48] fix(core): playLayoutFrames releases its frames when the motion ends --- .../core/src/motion/animatedLayout.swap.test.ts | 17 +++++++++++++++++ packages/core/src/motion/animatedLayout.ts | 6 +++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/core/src/motion/animatedLayout.swap.test.ts b/packages/core/src/motion/animatedLayout.swap.test.ts index ee1eea590f0b2..8c593c5e78429 100644 --- a/packages/core/src/motion/animatedLayout.swap.test.ts +++ b/packages/core/src/motion/animatedLayout.swap.test.ts @@ -592,6 +592,23 @@ describe('more swap layout cases', () => { expect(spring?.keyframes[0].boxShadow).toBe('100px 0px 0px 0px red'); expect(spring?.keyframes.at(-1)?.boxShadow).toBe('0px 0px 0px 0px red'); expect(spring?.startTime).toBe(500); + // Released when the motion ends: the element shows its own styles again. + const cancel = vi.fn(); + played = []; + const original = Element.prototype.animate; + Element.prototype.animate = function ( + this: Element, + ...args: Parameters + ) { + const animation = original.apply(this, args); + (animation as unknown as {cancel: () => void}).cancel = cancel; + return animation; + }; + playLayoutFrames(handle, [{element: title, frames: () => ({opacity: 1})}]); + played[0].finish?.(); + expect(cancel).toHaveBeenCalled(); + Element.prototype.animate = original; + played = []; played = []; media['(prefers-reduced-motion: reduce)'] = true; media['(pointer: coarse)'] = true; diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index a80a34f0a0de1..fcd33d58350e1 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -1208,7 +1208,11 @@ export function playLayoutFrames( animations.push(animation); } } - return () => animations.forEach(animation => animation.cancel()); + // Held through the motion, then released: every part shares one clock, so + // the first to finish ends them all and each element shows its own styles. + const end = () => animations.forEach(animation => animation.cancel()); + animations[0]?.addEventListener?.('finish', end); + return end; } /** From b150c1550153e6f7ff442b9e76c831b5d9836623 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 17:56:29 -0700 Subject: [PATCH 41/48] feat(core): a new change can take a swap's copy over mid-swap (handOff) --- .../src/motion/animatedLayout.swap.test.ts | 15 +++++++++++ packages/core/src/motion/animatedLayout.ts | 27 ++++++++++++++++--- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/core/src/motion/animatedLayout.swap.test.ts b/packages/core/src/motion/animatedLayout.swap.test.ts index 8c593c5e78429..fc96b7e7ba86b 100644 --- a/packages/core/src/motion/animatedLayout.swap.test.ts +++ b/packages/core/src/motion/animatedLayout.swap.test.ts @@ -620,3 +620,18 @@ describe('more swap layout cases', () => { expect(played).toEqual([]); }); }); + +describe('handing a swap copy over', () => { + it('leaves the copy where it shows for a new change, which removes it', () => { + const {handle, content, host} = setup(); + const swap = holdLayoutSwap(handle, content); + const end = playLayoutSwap(swap!, {host, copy: travel, incoming: []}); + const copy = end.handOff(); + expect(copy).toBe(swap!.copy); + expect(copy?.isConnected).toBe(true); + // Ending it now no longer removes the copy: it belongs to the caller. + end(); + expect(copy?.isConnected).toBe(true); + expect(end.handOff()).toBeNull(); + }); +}); diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index fcd33d58350e1..67ab59d7e5649 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -1013,6 +1013,15 @@ export interface LayoutSwap { readonly rect: DOMRect; } +/** + * Ends a swap at once, removing its copy; `handOff` instead ends it leaving + * the copy where and as it shows, for a new change to take over (null once + * the swap has ended). + */ +export type LayoutSwapEnd = (() => void) & { + readonly handOff: () => HTMLElement | null; +}; + /** How a swap plays, as frames over its progress (0 → 1). */ export interface LayoutSwapMotion { /** The box the copy is laid out in, over the old content's place. */ @@ -1083,10 +1092,10 @@ export function holdLayoutSwap( export function playLayoutSwap( swap: LayoutSwap, how: LayoutSwapMotion, -): () => void { +): LayoutSwapEnd { const motion = layoutMotion(swap.area); if (motion.kind === 'instant') { - return () => {}; + return Object.assign(() => {}, {handOff: () => null}); } const {copy} = swap; placeOver(copy, how.host, swap.rect); @@ -1168,7 +1177,19 @@ export function playLayoutSwap( // Every part runs on one clock, so the first to finish ends them all. animations[0]?.addEventListener?.('finish', end); } - return end; + // A new change can take the copy over mid-swap: it stays where it shows, + // at the opacity it shows, and the caller removes it. + const handOff = (): HTMLElement | null => { + if (isDone) { + return null; + } + const {opacity} = getComputedStyle(copy); + isDone = true; + animations.forEach(animation => animation.cancel()); + copy.style.opacity = opacity; + return copy; + }; + return Object.assign(end, {handOff}); } /** From 013de6510f7d73588a0868232d654e103e8ce5b3 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 17:57:18 -0700 Subject: [PATCH 42/48] feat(core): a swap calls onEnd when it ends --- .../core/src/motion/animatedLayout.swap.test.ts | 15 +++++++++++++++ packages/core/src/motion/animatedLayout.ts | 3 +++ 2 files changed, 18 insertions(+) diff --git a/packages/core/src/motion/animatedLayout.swap.test.ts b/packages/core/src/motion/animatedLayout.swap.test.ts index fc96b7e7ba86b..1a60b06a2ac4d 100644 --- a/packages/core/src/motion/animatedLayout.swap.test.ts +++ b/packages/core/src/motion/animatedLayout.swap.test.ts @@ -634,4 +634,19 @@ describe('handing a swap copy over', () => { expect(copy?.isConnected).toBe(true); expect(end.handOff()).toBeNull(); }); + + it('calls onEnd once when the swap ends', () => { + const {handle, content, host} = setup(); + const swap = holdLayoutSwap(handle, content); + const onEnd = vi.fn(); + const end = playLayoutSwap(swap!, { + host, + copy: travel, + incoming: [], + onEnd, + }); + played[0].finish?.(); + end(); + expect(onEnd).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index 67ab59d7e5649..f608f3e9d227c 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -1043,6 +1043,8 @@ export interface LayoutSwapMotion { /** Its old place less its new, for when it does not glide. */ readonly travel: {readonly x: number; readonly y: number}; }; + /** Called once when the swap ends, however it ends (not on hand-off). */ + readonly onEnd?: () => void; /** Elements the page already renders, entering with the swap. */ readonly incoming: ReadonlyArray<{ readonly element: Element; @@ -1120,6 +1122,7 @@ export function playLayoutSwap( isDone = true; animations.forEach(animation => animation.cancel()); copy.remove(); + how.onEnd?.(); }; const play = (element: Element, shape: (p: number) => Keyframe) => { const animation = safeAnimate(element, frames(shape), options); From f6c8eab7c36bff40971af7e683a22983915d82b2 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 18:39:59 -0700 Subject: [PATCH 43/48] fix(core): a handed-off swap copy keeps its travel, clip and text fade; text of style sheets and textareas is never wrapped - handOff writes the copy's running opacity, translate and clip, and each faded text element's opacity, inline before ending its frames. - wrapText skips text inside style, script, textarea, title and noscript, so a copied style sheet keeps its rules and a textarea its default text. - Kbd.tsx returns to main's text (an unrelated formatting change from the merge). --- packages/core/src/Kbd/Kbd.tsx | 2 +- .../src/motion/animatedLayout.swap.test.ts | 56 +++++++++++++++++++ packages/core/src/motion/animatedLayout.ts | 24 ++++++-- packages/core/src/motion/transientCopy.ts | 5 ++ 4 files changed, 82 insertions(+), 5 deletions(-) diff --git a/packages/core/src/Kbd/Kbd.tsx b/packages/core/src/Kbd/Kbd.tsx index 39c0df235f55c..6340505199e73 100644 --- a/packages/core/src/Kbd/Kbd.tsx +++ b/packages/core/src/Kbd/Kbd.tsx @@ -90,7 +90,7 @@ function getKeyDisplay(key: string, isMac: boolean, t: TranslatorFn): string { if (key === 'mod') { return isMac ? '\u2318' : t('@astryx.kbd.key.controlShort'); } - if (key === 'escape') {return t('@astryx.kbd.key.escapeShort');} + if (key === 'escape') return t('@astryx.kbd.key.escapeShort'); return KEY_DISPLAY.get(key) ?? key.toUpperCase(); } diff --git a/packages/core/src/motion/animatedLayout.swap.test.ts b/packages/core/src/motion/animatedLayout.swap.test.ts index 1a60b06a2ac4d..39c2dd0fbc7ca 100644 --- a/packages/core/src/motion/animatedLayout.swap.test.ts +++ b/packages/core/src/motion/animatedLayout.swap.test.ts @@ -635,6 +635,62 @@ describe('handing a swap copy over', () => { expect(end.handOff()).toBeNull(); }); + it('hands the copy over where and as it shows: travel, clip and text fade', () => { + const {handle, content, host} = setup(); + const swap = holdLayoutSwap(handle, content); + const end = playLayoutSwap(swap!, { + host, + copy: travel, + copyText: p => 1 - p, + incoming: [], + }); + const real = window.getComputedStyle; + vi.spyOn(window, 'getComputedStyle').mockImplementation( + (element: Element): CSSStyleDeclaration => { + const style = real(element); + if (element === swap!.copy) { + return Object.assign(Object.create(style), { + opacity: '0.7', + translate: '-60px 0px', + clipPath: 'inset(0px 20px 0px 0px)', + }); + } + if (element.parentElement?.id === '' && element.tagName === 'SPAN') { + return Object.assign(Object.create(style), {opacity: '0.3'}); + } + return style; + }, + ); + const copy = end.handOff(); + vi.restoreAllMocks(); + expect(copy?.style.translate).toBe('-60px 0px'); + expect(copy?.style.opacity).toBe('0.7'); + expect(copy?.style.clipPath).toBe('inset(0px 20px 0px 0px)'); + const heading = copy?.querySelector('h2 > span') as HTMLElement | null; + expect(heading?.style.opacity).toBe('0.3'); + }); + + it('leaves the text of a style sheet or a textarea in place when its text fades', () => { + const {handle, content, host} = setup(); + content.insertAdjacentHTML( + 'beforeend', + '', + ); + const swap = holdLayoutSwap(handle, content); + playLayoutSwap(swap!, { + host, + copy: travel, + copyText: p => 1 - p, + incoming: [], + }); + expect(swap!.copy.querySelector('style')?.children).toHaveLength(0); + expect(swap!.copy.querySelector('style')?.textContent).toContain( + 'color: red', + ); + expect(swap!.copy.querySelector('textarea')?.children).toHaveLength(0); + expect(swap!.copy.querySelector('textarea')?.value).toBe('Draft'); + }); + it('calls onEnd once when the swap ends', () => { const {handle, content, host} = setup(); const swap = holdLayoutSwap(handle, content); diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index f608f3e9d227c..f0f6f3acee7b3 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -1166,8 +1166,9 @@ export function playLayoutSwap( play(copy, travels ? how.copy : p => ({opacity: 1 - p})); } const {copyText} = how; - if (travels && copyText != null) { - for (const span of wrapText(copy)) { + const fadedText = travels && copyText != null ? wrapText(copy) : []; + if (copyText != null) { + for (const span of fadedText) { play(span, p => ({opacity: copyText(p)})); } } @@ -1186,10 +1187,25 @@ export function playLayoutSwap( if (isDone) { return null; } - const {opacity} = getComputedStyle(copy); + // What the running frames show, written inline before they end: the + // copy's opacity, travel and clip, and its text's fade. + const shown = getComputedStyle(copy); + const frozen = { + opacity: shown.opacity, + translate: shown.translate, + clipPath: shown.clipPath, + }; + const texts = fadedText.map( + element => [element, getComputedStyle(element).opacity] as const, + ); isDone = true; animations.forEach(animation => animation.cancel()); - copy.style.opacity = opacity; + copy.style.opacity = frozen.opacity; + copy.style.translate = frozen.translate; + copy.style.clipPath = frozen.clipPath; + for (const [element, opacity] of texts) { + (element as HTMLElement | SVGElement).style.opacity = opacity; + } return copy; }; return Object.assign(end, {handOff}); diff --git a/packages/core/src/motion/transientCopy.ts b/packages/core/src/motion/transientCopy.ts index 9a0a1f7b288f2..b7c1f3a6503c9 100644 --- a/packages/core/src/motion/transientCopy.ts +++ b/packages/core/src/motion/transientCopy.ts @@ -253,6 +253,11 @@ export function wrapText(copy: HTMLElement): Element[] { const faded = new Set(); for (const text of texts) { const parent = text.parentElement; + // Text that is not rendered as text (a style sheet, a script, a + // textarea's default value, a title) stays where it is. + if (parent?.closest('style, script, textarea, title, noscript') != null) { + continue; + } if (parent?.namespaceURI === SVG) { const svgText = parent.closest('text'); if (svgText != null) { From 179b3bf9a8fbfa13fdfa7375f82b3b1f2f24f5e7 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 19:15:59 -0700 Subject: [PATCH 44/48] feat(core): every part of a ridden swap plays over the ridden glide and is told how far it has moved With rideWith, the copy, its text and the incoming parts all play over the ridden element's glide, and their frames receive its travel so far, so a caller can keep an edge on that glide (a clip that follows a column); isCopyMoved false keeps the copy in place. handOff is removed: a reversed swap is drawn with one copy. --- .../src/motion/animatedLayout.swap.test.ts | 80 +++---- packages/core/src/motion/animatedLayout.ts | 212 +++++++++--------- 2 files changed, 141 insertions(+), 151 deletions(-) diff --git a/packages/core/src/motion/animatedLayout.swap.test.ts b/packages/core/src/motion/animatedLayout.swap.test.ts index 39c2dd0fbc7ca..82d7696bedd5f 100644 --- a/packages/core/src/motion/animatedLayout.swap.test.ts +++ b/packages/core/src/motion/animatedLayout.swap.test.ts @@ -469,6 +469,36 @@ describe('a swap that rides a glide (spec:AST-105 FR8 with FR3)', () => { expect(ride?.keyframes.length).toBe(glide?.keyframes.length); }); + it('tells every part how far the ridden element has moved, on its glide', async () => { + const {handle, area, rail, column, content, after} = rideSetup(); + const swap = holdLayoutSwap(handle, content); + const change = reportLayoutChange(handle, {current: rail}); + change.measureBefore(); + after(); + change.animate(); + const title = document.createElement('h3'); + area.appendChild(title); + playLayoutSwap(swap!, { + host: area, + // A copy that stays put and follows the ride by its clip instead. + copy: (_p, ride) => ({clipPath: `inset(0px ${-ride.x}px 0px 0px)`}), + rideWith: {element: column, travel: {x: 212, y: 0}, isCopyMoved: false}, + incoming: [ + {element: title, frames: (_p, ride) => ({marginLeft: `${ride.x}px`})}, + ], + }); + await Promise.resolve(); + await Promise.resolve(); + const glide = played.find(entry => entry.element === column); + const copy = played.find(entry => entry.element === swap!.copy); + const incoming = played.find(entry => entry.element === title); + expect(copy?.keyframes[0].translate).toBeUndefined(); + expect(copy?.keyframes[0].clipPath).toBe('inset(0px 0px 0px 0px)'); + expect(copy?.keyframes.at(-1)?.clipPath).toBe('inset(0px 212px 0px 0px)'); + expect(incoming?.keyframes.at(-1)?.marginLeft).toBe('-212px'); + expect(incoming?.keyframes.length).toBe(glide?.keyframes.length); + }); + it('takes its new place at once when the element it rides does not glide', async () => { const {handle, area, column, content, after} = rideSetup(); const swap = holdLayoutSwap(handle, content); @@ -621,55 +651,7 @@ describe('more swap layout cases', () => { }); }); -describe('handing a swap copy over', () => { - it('leaves the copy where it shows for a new change, which removes it', () => { - const {handle, content, host} = setup(); - const swap = holdLayoutSwap(handle, content); - const end = playLayoutSwap(swap!, {host, copy: travel, incoming: []}); - const copy = end.handOff(); - expect(copy).toBe(swap!.copy); - expect(copy?.isConnected).toBe(true); - // Ending it now no longer removes the copy: it belongs to the caller. - end(); - expect(copy?.isConnected).toBe(true); - expect(end.handOff()).toBeNull(); - }); - - it('hands the copy over where and as it shows: travel, clip and text fade', () => { - const {handle, content, host} = setup(); - const swap = holdLayoutSwap(handle, content); - const end = playLayoutSwap(swap!, { - host, - copy: travel, - copyText: p => 1 - p, - incoming: [], - }); - const real = window.getComputedStyle; - vi.spyOn(window, 'getComputedStyle').mockImplementation( - (element: Element): CSSStyleDeclaration => { - const style = real(element); - if (element === swap!.copy) { - return Object.assign(Object.create(style), { - opacity: '0.7', - translate: '-60px 0px', - clipPath: 'inset(0px 20px 0px 0px)', - }); - } - if (element.parentElement?.id === '' && element.tagName === 'SPAN') { - return Object.assign(Object.create(style), {opacity: '0.3'}); - } - return style; - }, - ); - const copy = end.handOff(); - vi.restoreAllMocks(); - expect(copy?.style.translate).toBe('-60px 0px'); - expect(copy?.style.opacity).toBe('0.7'); - expect(copy?.style.clipPath).toBe('inset(0px 20px 0px 0px)'); - const heading = copy?.querySelector('h2 > span') as HTMLElement | null; - expect(heading?.style.opacity).toBe('0.3'); - }); - +describe('ending a swap', () => { it('leaves the text of a style sheet or a textarea in place when its text fades', () => { const {handle, content, host} = setup(); content.insertAdjacentHTML( diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index f0f6f3acee7b3..966d6009d8633 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -44,8 +44,9 @@ * assistive technology, while new content enters in its place, on the same * motion, and removes it when the swap ends (`holdLayoutSwap`, * `playLayoutSwap`; `spec:AST-105` FR8). The caller shapes the frames over - * the swap's progress; under Reduce Motion with a coarse pointer only - * opacity changes. + * 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. * - Every pointer (`spec:AST-105` FR5). Under Reduce Motion nothing travels * (`design:reduced-motion` DR1–DR3): a cross-fade under a coarse primary * pointer, an immediate change otherwise. @@ -1013,45 +1014,48 @@ export interface LayoutSwap { readonly rect: DOMRect; } +/** How far a ridden element has moved since the swap began. */ +export interface LayoutRide { + readonly x: number; + readonly y: number; +} + /** - * Ends a swap at once, removing its copy; `handOff` instead ends it leaving - * the copy where and as it shows, for a new change to take over (null once - * the swap has ended). + * How a swap plays, as frames over its progress (0 → 1) and, with + * `rideWith`, the ridden element's travel so far. */ -export type LayoutSwapEnd = (() => void) & { - readonly handOff: () => HTMLElement | null; -}; - -/** How a swap plays, as frames over its progress (0 → 1). */ export interface LayoutSwapMotion { /** The box the copy is laid out in, over the old content's place. */ readonly host: Element; /** The copy's frames while it travels. */ - readonly copy: (p: number) => Keyframe; + readonly copy: (p: number, ride: LayoutRide) => Keyframe; /** Its text's opacity, on its own curve, when that differs from the copy's. */ readonly copyText?: (p: number) => number; /** The copy paints above its neighbours, in the background it shows. */ readonly isOpaque?: boolean; /** - * An element the glide moves, such as the column the old content sat in: - * the copy rides its glide, frame by frame, by `travel` (the old place less - * the new). Without a glide (it lands silently), the copy takes its new - * place at once, as the element does. + * An element the glide moves, such as the column the old content sat in. + * Every part plays over its glide, frame by frame, and is told how far it + * has moved; the copy rides it unless `isCopyMoved` is false. Without a + * glide (it lands silently) the ride is the whole `travel` at once (its + * old place less its new). */ readonly rideWith?: { readonly element: Element; - /** Its old place less its new, for when it does not glide. */ - readonly travel: {readonly x: number; readonly y: number}; + readonly travel: LayoutRide; + readonly isCopyMoved?: boolean; }; - /** Called once when the swap ends, however it ends (not on hand-off). */ + /** Called once when the swap ends, however it ends. */ readonly onEnd?: () => void; /** Elements the page already renders, entering with the swap. */ readonly incoming: ReadonlyArray<{ readonly element: Element; - readonly frames: (p: number) => Keyframe; + readonly frames: (p: number, ride: LayoutRide) => Keyframe; }>; } +const NO_RIDE: LayoutRide = {x: 0, y: 0}; + /** * Before a change commits: hold `element`'s old content as one transient copy * (`spec:AST-105` FR8). With `isVisiblePart`, the copy is the element's @@ -1094,10 +1098,10 @@ export function holdLayoutSwap( export function playLayoutSwap( swap: LayoutSwap, how: LayoutSwapMotion, -): LayoutSwapEnd { +): () => void { const motion = layoutMotion(swap.area); if (motion.kind === 'instant') { - return Object.assign(() => {}, {handOff: () => null}); + return () => {}; } const {copy} = swap; placeOver(copy, how.host, swap.rect); @@ -1105,14 +1109,6 @@ export function playLayoutSwap( copy.style.zIndex = '1'; copy.style.backgroundColor = paintedBackground(copy); } - const travels = motion.kind === 'spring'; - const options: KeyframeAnimationOptions = travels - ? {duration: motion.progress.durationMs, easing: 'linear', fill: 'both'} - : {duration: motion.durationMs, easing: motion.easing, fill: 'both'}; - const frames = (shape: (p: number) => Keyframe): Keyframe[] => - travels - ? sampleKeyframes(motion.progress, remaining => shape(1 - remaining)) - : [shape(0), shape(1)]; const animations: Animation[] = []; let isDone = false; const end = () => { @@ -1124,91 +1120,101 @@ export function playLayoutSwap( copy.remove(); how.onEnd?.(); }; - const play = (element: Element, shape: (p: number) => Keyframe) => { - const animation = safeAnimate(element, frames(shape), options); - if (animation != null) { - animations.push(animation); + const {copyText, rideWith} = how; + const fadedText = + copyText != null && motion.kind === 'spring' ? wrapText(copy) : []; + // Each part as a function of time: its frame, or the copy's with its ride. + const parts: [Element, (p: number, ride: LayoutRide) => Keyframe][] = []; + if (motion.kind === 'spring') { + const moves = rideWith != null && rideWith.isCopyMoved !== false; + parts.push([ + copy, + (p, ride) => ({ + ...how.copy(p, ride), + ...(moves ? {translate: `${ride.x}px ${ride.y}px`} : null), + }), + ]); + for (const span of fadedText) { + parts.push([span, p => ({opacity: copyText?.(p) ?? 1})]); } - }; - const {rideWith} = how; - if (travels && rideWith != null) { - // After this commit's glides start (the batch flushes in a microtask - // queued before this one), before the frame paints. - queueMicrotask(() => { - if (isDone) { - return; - } - const glide = glides.get(rideWith.element); - const durationMs = glide?.durationMs ?? motion.progress.durationMs; + for (const {element, frames} of how.incoming) { + parts.push([element, frames]); + } + } else { + // Reduce Motion with a coarse pointer: only opacity changes. + parts.push([copy, p => ({opacity: 1 - p})]); + for (const {element} of how.incoming) { + parts.push([element, p => ({opacity: p})]); + } + } + const playAll = ( + durationMs: number, + progressAt: (t: number) => number, + rideAt: (t: number) => LayoutRide, + options: KeyframeAnimationOptions, + sample: (shape: (t: number) => Keyframe) => Keyframe[], + ) => { + for (const [element, shape] of parts) { const animation = safeAnimate( - copy, - sampleOver(durationMs, t => { - // Where the element shows against where it showed at the start, - // which is where the copy was laid (a running glide's offset - // included); without a glide it lands at once, by `travel`. - const offset = glide?.offsetAt(t) ?? AT_REST(t); - const start = glide?.offsetAt(0) ?? rideWith.travel; - const x = offset.x - start.x; - const y = offset.y - start.y; - return { - ...how.copy(1 - motion.progress.remaining(t)), - translate: `${x}px ${y}px`, - }; - }), - {duration: durationMs, easing: 'linear', fill: 'both'}, + element, + sample(t => shape(progressAt(t), rideAt(t))), + {duration: durationMs, fill: 'both', ...options}, ); if (animation != null) { animations.push(animation); - animation.addEventListener?.('finish', end); } - }); - } else { - play(copy, travels ? how.copy : p => ({opacity: 1 - p})); - } - const {copyText} = how; - const fadedText = travels && copyText != null ? wrapText(copy) : []; - if (copyText != null) { - for (const span of fadedText) { - play(span, p => ({opacity: copyText(p)})); } + if (animations.length === 0) { + end(); + } else { + // Every part runs on one clock, so the first to finish ends them all. + animations[0].addEventListener?.('finish', end); + } + }; + if (motion.kind === 'fade') { + playAll( + motion.durationMs, + t => (t <= 0 ? 0 : 1), + () => NO_RIDE, + {easing: motion.easing}, + shape => [shape(0), shape(motion.durationMs)], + ); + return end; } - for (const {element, frames: shape} of how.incoming) { - play(element, travels ? shape : p => ({opacity: p})); - } - if (animations.length === 0 && !(travels && rideWith != null)) { - end(); - } else { - // Every part runs on one clock, so the first to finish ends them all. - animations[0]?.addEventListener?.('finish', end); + const {progress} = motion; + const progressAt = (t: number) => 1 - progress.remaining(t); + if (rideWith == null) { + playAll( + progress.durationMs, + progressAt, + () => NO_RIDE, + {easing: 'linear'}, + shape => sampleOver(progress.durationMs, shape), + ); + return end; } - // A new change can take the copy over mid-swap: it stays where it shows, - // at the opacity it shows, and the caller removes it. - const handOff = (): HTMLElement | null => { + // After this commit's glides start (the batch flushes in a microtask queued + // before this one), before the frame paints: every part plays over the + // ridden element's glide, told how far it has moved from where it showed. + queueMicrotask(() => { if (isDone) { - return null; + return; } - // What the running frames show, written inline before they end: the - // copy's opacity, travel and clip, and its text's fade. - const shown = getComputedStyle(copy); - const frozen = { - opacity: shown.opacity, - translate: shown.translate, - clipPath: shown.clipPath, + const glide = glides.get(rideWith.element); + const durationMs = glide?.durationMs ?? progress.durationMs; + const rideAt = (t: number): LayoutRide => { + if (glide == null) { + return {x: -rideWith.travel.x, y: -rideWith.travel.y}; + } + const now = glide.offsetAt(t); + const start = glide.offsetAt(0); + return {x: now.x - start.x, y: now.y - start.y}; }; - const texts = fadedText.map( - element => [element, getComputedStyle(element).opacity] as const, + playAll(durationMs, progressAt, rideAt, {easing: 'linear'}, shape => + sampleOver(durationMs, shape), ); - isDone = true; - animations.forEach(animation => animation.cancel()); - copy.style.opacity = frozen.opacity; - copy.style.translate = frozen.translate; - copy.style.clipPath = frozen.clipPath; - for (const [element, opacity] of texts) { - (element as HTMLElement | SVGElement).style.opacity = opacity; - } - return copy; - }; - return Object.assign(end, {handOff}); + }); + return end; } /** @@ -1233,7 +1239,9 @@ export function playLayoutFrames( motion.kind === 'spring' ? safeAnimate( element, - sampleKeyframes(motion.progress, remaining => shape(1 - remaining)), + sampleKeyframes(motion.progress, remaining => + shape(1 - remaining, NO_RIDE), + ), { duration: motion.progress.durationMs, easing: 'linear', From a234c3c6ce2e3e4a54b88d3519e072e1b79855c4 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 20:07:05 -0700 Subject: [PATCH 45/48] fix(core): a swap copy laid outside its ancestors keeps what it inherited from them inertCopy reads the element's inherited properties and custom properties; placeOver writes those that differ from the host's on the copy, so a copy riding a column keeps that column's type and variables. Rules matched through an ancestor between the host and the element are named as a limit. The boundary test also checks that the swap functions are not exported. --- .../motion/animatedLayout.boundary.test.ts | 3 + .../src/motion/animatedLayout.swap.test.ts | 46 ++++++++++++- packages/core/src/motion/transientCopy.ts | 69 ++++++++++++++++++- 3 files changed, 114 insertions(+), 4 deletions(-) diff --git a/packages/core/src/motion/animatedLayout.boundary.test.ts b/packages/core/src/motion/animatedLayout.boundary.test.ts index 3d636ecf3c50c..bfd4a904df9d8 100644 --- a/packages/core/src/motion/animatedLayout.boundary.test.ts +++ b/packages/core/src/motion/animatedLayout.boundary.test.ts @@ -105,6 +105,9 @@ describe('architecture:react-component-runtime INV12 source boundary', () => { 'playLayoutChange', 'applyLayoutChangeTransition', 'createAnimatedLayout', + 'holdLayoutSwap', + 'playLayoutSwap', + 'playLayoutFrames', ]) { expect( source, diff --git a/packages/core/src/motion/animatedLayout.swap.test.ts b/packages/core/src/motion/animatedLayout.swap.test.ts index 82d7696bedd5f..1c0ff028c37c0 100644 --- a/packages/core/src/motion/animatedLayout.swap.test.ts +++ b/packages/core/src/motion/animatedLayout.swap.test.ts @@ -484,7 +484,10 @@ describe('a swap that rides a glide (spec:AST-105 FR8 with FR3)', () => { copy: (_p, ride) => ({clipPath: `inset(0px ${-ride.x}px 0px 0px)`}), rideWith: {element: column, travel: {x: 212, y: 0}, isCopyMoved: false}, incoming: [ - {element: title, frames: (_p, ride) => ({marginLeft: `${ride.x}px`})}, + { + element: title, + frames: (_p, ride) => ({translate: `${ride.x}px 0px`}), + }, ], }); await Promise.resolve(); @@ -495,7 +498,7 @@ describe('a swap that rides a glide (spec:AST-105 FR8 with FR3)', () => { expect(copy?.keyframes[0].translate).toBeUndefined(); expect(copy?.keyframes[0].clipPath).toBe('inset(0px 0px 0px 0px)'); expect(copy?.keyframes.at(-1)?.clipPath).toBe('inset(0px 212px 0px 0px)'); - expect(incoming?.keyframes.at(-1)?.marginLeft).toBe('-212px'); + expect(incoming?.keyframes.at(-1)?.translate).toBe('-212px 0px'); expect(incoming?.keyframes.length).toBe(glide?.keyframes.length); }); @@ -651,6 +654,45 @@ describe('more swap layout cases', () => { }); }); +describe('a copy laid outside its ancestors', () => { + it('keeps what it inherited from them where the host passes something else', () => { + const {handle, content} = setup(); + const area = document.getElementById('area') as HTMLElement; + // The element inherits 13 px type and a custom property from an ancestor + // between it and the host; the host passes 16 px and the same color. + const computed: Record> = { + content: {'font-size': '13px', color: 'rgb(0, 0, 0)', '--row-gap': '6px'}, + area: {'font-size': '16px', color: 'rgb(0, 0, 0)'}, + }; + const real = window.getComputedStyle; + vi.spyOn(window, 'getComputedStyle').mockImplementation( + (element: Element): CSSStyleDeclaration => { + const style = real(element); + const values = computed[element.id]; + if (values == null) { + return style; + } + const custom = Object.keys(values).filter(name => + name.startsWith('--'), + ); + // Only what the copy reads: values by name, and the custom ones. + return Object.assign(Object.create(style) as CSSStyleDeclaration, { + getPropertyValue: (name: string) => values[name] ?? '', + ...Object.fromEntries(custom.map((name, index) => [index, name])), + length: custom.length, + }); + }, + ); + const swap = holdLayoutSwap(handle, content); + playLayoutSwap(swap!, {host: area, copy: travel, incoming: []}); + vi.restoreAllMocks(); + expect(swap!.copy.style.fontSize).toBe('13px'); + expect(swap!.copy.style.getPropertyValue('--row-gap')).toBe('6px'); + // What the host already passes is not written again. + expect(swap!.copy.style.color).toBe(''); + }); +}); + describe('ending a swap', () => { it('leaves the text of a style sheet or a textarea in place when its text fades', () => { const {handle, content, host} = setup(); diff --git a/packages/core/src/motion/transientCopy.ts b/packages/core/src/motion/transientCopy.ts index b7c1f3a6503c9..53719b4e7ba4e 100644 --- a/packages/core/src/motion/transientCopy.ts +++ b/packages/core/src/motion/transientCopy.ts @@ -15,8 +15,12 @@ * technology, carries no ids or test ids, joins no form or radio group (a * copied checked radio would uncheck the live one), and its frames and media * load nothing. Laying it out changes no ancestor's position, and a scaled - * host draws it at the old size. Limits: a rule keyed on a removed `id` does - * not style the copy, and content holding a canvas whose pixels read back + * host draws it at the old size. Laid in a host outside the element's own + * ancestors, it keeps what it inherited from them: its inherited properties + * and custom properties that differ from the host's are written on it. + * Limits: a rule keyed on a removed `id`, or matched through an ancestor + * between the host and the element (`.collapsed .label`), does not style the + * copy as it styled the element; and content holding a canvas whose pixels read back * empty is not copied, so the new content enters without an old copy: a * canvas that cannot be read back (a WebGL canvas without * `preserveDrawingBuffer`, a tainted one) or a blank one. @@ -64,6 +68,55 @@ function paintCanvas( } /** Scroll offsets a copy restores once laid out (a clone carries none). */ +/** Inherited properties a copy carries when laid outside its ancestors. */ +const INHERITED_PROPERTIES = [ + 'color', + 'cursor', + 'direction', + 'font-family', + 'font-feature-settings', + 'font-size', + 'font-stretch', + 'font-style', + 'font-variant', + 'font-variation-settings', + 'font-weight', + 'hyphens', + 'letter-spacing', + 'line-height', + 'list-style-type', + 'overflow-wrap', + 'tab-size', + 'text-align', + 'text-indent', + 'text-shadow', + 'text-transform', + 'visibility', + 'white-space', + 'word-break', + 'word-spacing', + 'writing-mode', +]; + +/** Each copy's inherited values, read from its element when it was made. */ +const inheritedValues = new WeakMap>(); + +/** The element's inherited properties and custom properties, as computed. */ +function readInherited(element: Element): Map { + const values = new Map(); + const style = getComputedStyle(element); + for (const name of INHERITED_PROPERTIES) { + values.set(name, style.getPropertyValue(name)); + } + for (let index = 0; index < style.length; index++) { + const name = style[index]; + if (name.startsWith('--')) { + values.set(name, style.getPropertyValue(name)); + } + } + return values; +} + const scrollOffsets = new WeakMap< HTMLElement, ReadonlyArray @@ -86,6 +139,7 @@ export function inertCopy(element: HTMLElement): HTMLElement | null { } }); scrollOffsets.set(copy, offsets); + inheritedValues.set(copy, readInherited(element)); for (const node of copyNodes) { for (const name of [ 'id', @@ -149,6 +203,13 @@ export function placeOver( height: `${rect.height}px`, }); host.appendChild(element); + // What it inherited where it was, where the host passes something else. + const hostStyle = getComputedStyle(host); + for (const [name, value] of inheritedValues.get(element) ?? []) { + if (value !== '' && hostStyle.getPropertyValue(name) !== value) { + element.style.setProperty(name, value); + } + } // The origin, and the host's scale: the rect is in viewport pixels, the // copy's styles in the host's own. // The rendered size over the layout size, as the glide's `scaleOf` reads @@ -223,6 +284,10 @@ export function visiblePartCopy(element: HTMLElement): { holder, (scrollOffsets.get(content) ?? []).filter(([node]) => node !== content), ); + const values = inheritedValues.get(content); + if (values != null) { + inheritedValues.set(holder, values); + } const box = element.getBoundingClientRect(); return { copy: holder, From 9b6ad1c754cabb44e90d2d1eb6f5e89fcd6d9c91 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 20:47:49 -0700 Subject: [PATCH 46/48] fix(core): a swap copy carries a unitless line-height as its factor The computed style reports a unitless line-height in pixels; the copy now takes the factor from the typed style map where it has one, so descendants with another font size keep their line spacing. A stray doc comment moves back to scrollOffsets. --- .../src/motion/animatedLayout.swap.test.ts | 31 +++++++++++++++++++ packages/core/src/motion/transientCopy.ts | 13 +++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/core/src/motion/animatedLayout.swap.test.ts b/packages/core/src/motion/animatedLayout.swap.test.ts index 1c0ff028c37c0..92bc2ac69d6cd 100644 --- a/packages/core/src/motion/animatedLayout.swap.test.ts +++ b/packages/core/src/motion/animatedLayout.swap.test.ts @@ -691,6 +691,37 @@ describe('a copy laid outside its ancestors', () => { // What the host already passes is not written again. expect(swap!.copy.style.color).toBe(''); }); + + it('carries a unitless line-height as its factor, not as pixels', () => { + const {handle, content} = setup(); + const area = document.getElementById('area') as HTMLElement; + // The computed style says 18.85px; the typed style map says 1.45. + const real = window.getComputedStyle; + vi.spyOn(window, 'getComputedStyle').mockImplementation( + (element: Element): CSSStyleDeclaration => { + const style = real(element); + const values: Record = + element === content + ? {'line-height': '18.85px'} + : element === area + ? {'line-height': 'normal'} + : {}; + return Object.assign(Object.create(style) as CSSStyleDeclaration, { + getPropertyValue: (name: string) => values[name] ?? '', + length: 0, + }); + }, + ); + (content as unknown as {computedStyleMap: () => unknown}).computedStyleMap = + () => ({ + get: (name: string) => + name === 'line-height' ? {unit: 'number', value: 1.45} : undefined, + }); + const swap = holdLayoutSwap(handle, content); + playLayoutSwap(swap!, {host: area, copy: travel, incoming: []}); + vi.restoreAllMocks(); + expect(swap!.copy.style.lineHeight).toBe('1.45'); + }); }); describe('ending a swap', () => { diff --git a/packages/core/src/motion/transientCopy.ts b/packages/core/src/motion/transientCopy.ts index 53719b4e7ba4e..1b1be113f057b 100644 --- a/packages/core/src/motion/transientCopy.ts +++ b/packages/core/src/motion/transientCopy.ts @@ -67,7 +67,6 @@ function paintCanvas( } } -/** Scroll offsets a copy restores once laid out (a clone carries none). */ /** Inherited properties a copy carries when laid outside its ancestors. */ const INHERITED_PROPERTIES = [ 'color', @@ -108,6 +107,17 @@ function readInherited(element: Element): Map { for (const name of INHERITED_PROPERTIES) { values.set(name, style.getPropertyValue(name)); } + // A unitless line-height inherits as its factor, but the computed style + // reports it in pixels: carry the factor where the typed style map has it. + const lineHeight = element.computedStyleMap?.().get('line-height'); + if ( + lineHeight != null && + 'unit' in lineHeight && + lineHeight.unit === 'number' && + 'value' in lineHeight + ) { + values.set('line-height', String(lineHeight.value)); + } for (let index = 0; index < style.length; index++) { const name = style[index]; if (name.startsWith('--')) { @@ -117,6 +127,7 @@ function readInherited(element: Element): Map { return values; } +/** Scroll offsets a copy restores once laid out (a clone carries none). */ const scrollOffsets = new WeakMap< HTMLElement, ReadonlyArray From 81388fd9324ce20ea2520850127be04c82e2769e Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 21:31:44 -0700 Subject: [PATCH 47/48] fix(core): without a typed style map, a swap copy carries its line-height as a factor too Where computedStyleMap is missing (Firefox), the copy takes the computed line-height over the font size, and leaves it to inherit when the host passes the same factor. The module header also names playLayoutFrames. --- .../src/motion/animatedLayout.swap.test.ts | 32 +++++++++++++++++++ packages/core/src/motion/animatedLayout.ts | 6 +++- packages/core/src/motion/transientCopy.ts | 24 ++++++++++++-- 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/packages/core/src/motion/animatedLayout.swap.test.ts b/packages/core/src/motion/animatedLayout.swap.test.ts index 92bc2ac69d6cd..fb33ad083ca96 100644 --- a/packages/core/src/motion/animatedLayout.swap.test.ts +++ b/packages/core/src/motion/animatedLayout.swap.test.ts @@ -722,6 +722,38 @@ describe('a copy laid outside its ancestors', () => { vi.restoreAllMocks(); expect(swap!.copy.style.lineHeight).toBe('1.45'); }); + + it('carries a line-height factor without a typed style map, and leaves an equal one to inherit', () => { + const {handle, content} = setup(); + const area = document.getElementById('area') as HTMLElement; + const computed = new Map>([ + // 13 px type at 1.5 inside a 16 px area at 1.25: the factor differs. + [content, {'font-size': '13px', 'line-height': '19.5px'}], + [area, {'font-size': '16px', 'line-height': '20px'}], + ]); + const real = window.getComputedStyle; + vi.spyOn(window, 'getComputedStyle').mockImplementation( + (element: Element): CSSStyleDeclaration => { + const values = computed.get(element); + if (values == null) { + return real(element); + } + return Object.assign( + Object.create(real(element)) as CSSStyleDeclaration, + {getPropertyValue: (name: string) => values[name] ?? '', length: 0}, + ); + }, + ); + const swap = holdLayoutSwap(handle, content); + playLayoutSwap(swap!, {host: area, copy: travel, incoming: []}); + expect(swap!.copy.style.lineHeight).toBe('1.5'); + // The same factor in the host: nothing written, the factor inherits. + computed.set(area, {'font-size': '16px', 'line-height': '24px'}); + const again = holdLayoutSwap(handle, content); + playLayoutSwap(again!, {host: area, copy: travel, incoming: []}); + vi.restoreAllMocks(); + expect(again!.copy.style.lineHeight).toBe(''); + }); }); describe('ending a swap', () => { diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index 966d6009d8633..6933251086420 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -8,7 +8,8 @@ * @output Exports `createAnimatedLayout` and the opaque `AnimatedLayout` * handle, `reportLayoutChange`, `playLayoutChange`, `holdLayoutExit` / * `releaseLayoutExit`, the in-place swap (`holdLayoutSwap` / - * `playLayoutSwap`) and `applyLayoutChangeTransition`: the one module + * `playLayoutSwap`), `playLayoutFrames` and `applyLayoutChangeTransition`: + * the one module * that produces the * size-change glide `spec:AST-105` defines * (`architecture:react-component-runtime` INV12) @@ -47,6 +48,9 @@ * 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. + * - 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. * - Every pointer (`spec:AST-105` FR5). Under Reduce Motion nothing travels * (`design:reduced-motion` DR1–DR3): a cross-fade under a coarse primary * pointer, an immediate change otherwise. diff --git a/packages/core/src/motion/transientCopy.ts b/packages/core/src/motion/transientCopy.ts index 1b1be113f057b..89976c62c42c2 100644 --- a/packages/core/src/motion/transientCopy.ts +++ b/packages/core/src/motion/transientCopy.ts @@ -100,6 +100,15 @@ const INHERITED_PROPERTIES = [ /** Each copy's inherited values, read from its element when it was made. */ const inheritedValues = new WeakMap>(); +/** A computed line-height over its font size, or null for `normal`. */ +function lineHeightFactor(style: CSSStyleDeclaration): number | null { + const lineHeight = parseFloat(style.getPropertyValue('line-height')); + const fontSize = parseFloat(style.getPropertyValue('font-size')); + return Number.isFinite(lineHeight) && fontSize > 0 + ? Math.round((lineHeight / fontSize) * 1000) / 1000 + : null; +} + /** The element's inherited properties and custom properties, as computed. */ function readInherited(element: Element): Map { const values = new Map(); @@ -108,7 +117,8 @@ function readInherited(element: Element): Map { values.set(name, style.getPropertyValue(name)); } // A unitless line-height inherits as its factor, but the computed style - // reports it in pixels: carry the factor where the typed style map has it. + // reports it in pixels: carry the factor, from the typed style map where + // there is one, else as the pixels over the font size. const lineHeight = element.computedStyleMap?.().get('line-height'); if ( lineHeight != null && @@ -117,6 +127,11 @@ function readInherited(element: Element): Map { 'value' in lineHeight ) { values.set('line-height', String(lineHeight.value)); + } else { + const factor = lineHeightFactor(style); + if (factor != null) { + values.set('line-height', String(factor)); + } } for (let index = 0; index < style.length; index++) { const name = style[index]; @@ -216,8 +231,13 @@ export function placeOver( host.appendChild(element); // What it inherited where it was, where the host passes something else. const hostStyle = getComputedStyle(host); + const hostFactor = lineHeightFactor(hostStyle); for (const [name, value] of inheritedValues.get(element) ?? []) { - if (value !== '' && hostStyle.getPropertyValue(name) !== value) { + const passed = + name === 'line-height' && hostFactor != null + ? String(hostFactor) + : hostStyle.getPropertyValue(name); + if (value !== '' && passed !== value) { element.style.setProperty(name, value); } } From 3e3322e6bacceee1c49f60f2adbb180a9efe3a8a Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 22:07:24 -0700 Subject: [PATCH 48/48] fix(core): a swap copy keeps a length line-height as its pixels The typed style map tells a factor from a length; only without one does the copy take the computed pixels over the font size. A carried length is compared with the host's computed line-height, a factor with the host's factor. --- .../src/motion/animatedLayout.swap.test.ts | 31 +++++++++++++++++++ packages/core/src/motion/transientCopy.ts | 27 +++++++++------- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/packages/core/src/motion/animatedLayout.swap.test.ts b/packages/core/src/motion/animatedLayout.swap.test.ts index fb33ad083ca96..60f03e0a73e8b 100644 --- a/packages/core/src/motion/animatedLayout.swap.test.ts +++ b/packages/core/src/motion/animatedLayout.swap.test.ts @@ -754,6 +754,37 @@ describe('a copy laid outside its ancestors', () => { vi.restoreAllMocks(); expect(again!.copy.style.lineHeight).toBe(''); }); + + it('keeps a length line-height as its pixels when the typed style map reports a length', () => { + const {handle, content} = setup(); + const area = document.getElementById('area') as HTMLElement; + const computed = new Map>([ + [content, {'font-size': '13px', 'line-height': '20px'}], + [area, {'font-size': '16px', 'line-height': '24px'}], + ]); + const real = window.getComputedStyle; + vi.spyOn(window, 'getComputedStyle').mockImplementation( + (element: Element): CSSStyleDeclaration => { + const values = computed.get(element); + if (values == null) { + return real(element); + } + return Object.assign( + Object.create(real(element)) as CSSStyleDeclaration, + {getPropertyValue: (name: string) => values[name] ?? '', length: 0}, + ); + }, + ); + (content as unknown as {computedStyleMap: () => unknown}).computedStyleMap = + () => ({ + get: (name: string) => + name === 'line-height' ? {unit: 'px', value: 20} : undefined, + }); + const swap = holdLayoutSwap(handle, content); + playLayoutSwap(swap!, {host: area, copy: travel, incoming: []}); + vi.restoreAllMocks(); + expect(swap!.copy.style.lineHeight).toBe('20px'); + }); }); describe('ending a swap', () => { diff --git a/packages/core/src/motion/transientCopy.ts b/packages/core/src/motion/transientCopy.ts index 89976c62c42c2..932f35a6e37f9 100644 --- a/packages/core/src/motion/transientCopy.ts +++ b/packages/core/src/motion/transientCopy.ts @@ -117,16 +117,19 @@ function readInherited(element: Element): Map { values.set(name, style.getPropertyValue(name)); } // A unitless line-height inherits as its factor, but the computed style - // reports it in pixels: carry the factor, from the typed style map where - // there is one, else as the pixels over the font size. - const lineHeight = element.computedStyleMap?.().get('line-height'); - if ( - lineHeight != null && - 'unit' in lineHeight && - lineHeight.unit === 'number' && - 'value' in lineHeight - ) { - values.set('line-height', String(lineHeight.value)); + // reports it in pixels. The typed style map tells a factor from a length + // (a length stays its pixels); without one, the pixels over the font size + // stand for the factor. + if (typeof element.computedStyleMap === 'function') { + const lineHeight = element.computedStyleMap().get('line-height'); + if ( + lineHeight != null && + 'unit' in lineHeight && + lineHeight.unit === 'number' && + 'value' in lineHeight + ) { + values.set('line-height', String(lineHeight.value)); + } } else { const factor = lineHeightFactor(style); if (factor != null) { @@ -233,8 +236,10 @@ export function placeOver( const hostStyle = getComputedStyle(host); const hostFactor = lineHeightFactor(hostStyle); for (const [name, value] of inheritedValues.get(element) ?? []) { + // A carried factor is compared with the host's factor, a length with + // the host's computed length. const passed = - name === 'line-height' && hostFactor != null + name === 'line-height' && /^[\d.]+$/.test(value) && hostFactor != null ? String(hostFactor) : hostStyle.getPropertyValue(name); if (value !== '' && passed !== value) {