From 5b0abfba644a398962e129e1491d82f027ee428b Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 20:09:23 -0700 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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}>
    `, }, + { + // 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) => {