From 5b0abfba644a398962e129e1491d82f027ee428b Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 20:09:23 -0700 Subject: [PATCH 01/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] fix(core): animated layout measures every grouped change, drops refused ones and retargets on the compositor - A single-mode CollapsibleGroup measures every item a toggle changes (the one that closes as well as the one that opens), so the items between glide with the content after the group. Items holding a handle register their measurement with the group. - A measurement no commit claims by the next frame (a controlled owner refused the toggle) is dropped, so it never moves unchanged rows later. A change nothing measured (an owner set isOpen directly) lands at once, entrance included. - The follower glide is one replace-composited translate, which the compositor runs (architecture:react-component-runtime INV12). An element already gliding is retargeted: its glide stops and the new one starts from where it is on screen, so it keeps moving without a jump. - INV12's source boundary check (no layout property animated, the walk only through parentElement / nextElementSibling, nothing public, only the hook and the adopters use it) and a real-browser case on a throttled phone over a 10-level by 50-sibling tree with two changes in one frame. --- packages/core/src/Collapsible/Collapsible.tsx | 60 ++++++-- .../core/src/Collapsible/CollapsibleGroup.tsx | 34 ++++- .../Collapsible/CollapsibleGroupContext.tsx | 6 + packages/core/src/TreeList/TreeList.tsx | 4 +- ...animatedLayout.interaction.browser.spec.ts | 120 +++++++++++++-- .../motion/animatedLayout.boundary.test.ts | 129 ++++++++++++++++ .../core/src/motion/animatedLayout.test.ts | 119 +++++++++++---- packages/core/src/motion/animatedLayout.ts | 140 ++++++++++++------ .../motion/animatedLayoutAdopters.test.tsx | 86 ++++++++++- 9 files changed, 592 insertions(+), 106 deletions(-) create mode 100644 packages/core/src/motion/animatedLayout.boundary.test.ts diff --git a/packages/core/src/Collapsible/Collapsible.tsx b/packages/core/src/Collapsible/Collapsible.tsx index ba5488d01d433..a3f8ddb815939 100644 --- a/packages/core/src/Collapsible/Collapsible.tsx +++ b/packages/core/src/Collapsible/Collapsible.tsx @@ -29,6 +29,8 @@ import { use, + useCallback, + useEffect, useId, useLayoutEffect, useMemo, @@ -54,7 +56,10 @@ import { reportLayoutChange, type AnimatedLayout, } from '../motion/animatedLayout'; -import {CollapsibleGroupPresentationContext} from './CollapsibleGroupContext'; +import { + CollapsibleGroupContext, + CollapsibleGroupPresentationContext, +} from './CollapsibleGroupContext'; import type {CollapsibleChevronPosition} from './CollapsibleGroupContext'; import {Icon} from '../Icon'; import {mergeProps} from '../utils'; @@ -355,44 +360,66 @@ export function Collapsible({ value, }); - // Activation is blocked by this guard rather than the native `disabled` - // attribute, so the trigger keeps `aria-disabled` semantics and stays - // discoverable. A native `disabled` button would silently swallow events - // (e.g. a wrapping tooltip's hover) — the system-wide disabled convention. // With an `animatedLayout` handle, the content's size change animates: the // content after this Collapsible, up to the handle's container, glides, and // the revealed content plays its own entrance on the same timing // (`architecture:react-component-runtime` INV12). Without one, nothing // changes. const contentRef = useRef(null); + const triggerRef = useRef(null); const layoutChange = useMemo( () => reportLayoutChange(animatedLayout, contentRef), [animatedLayout], ); + // Before a change commits: the chevron (the trigger's Icon, its `icon` + // theming target) turns on the glide's motion, and the content after this + // Collapsible is measured where it is now. + const measureChange = useCallback(() => { + applyLayoutChangeTransition( + animatedLayout, + triggerRef.current?.querySelector(':scope > .astryx-icon') ?? + null, + ); + layoutChange.measureBefore(); + }, [animatedLayout, layoutChange]); + // In a group, a toggle can change other items too (opening one in single + // mode closes the open one): the group measures every item it changes, so + // the rows between glide with the rest. + const registerLayoutChange = use( + CollapsibleGroupContext, + )?.registerLayoutChange; + useEffect(() => { + if ( + animatedLayout == null || + value == null || + registerLayoutChange == null + ) { + return undefined; + } + return registerLayoutChange(value, measureChange); + }, [animatedLayout, value, registerLayoutChange, measureChange]); const wasOpenRef = useRef(isOpen); useLayoutEffect(() => { if (wasOpenRef.current === isOpen) { return; } wasOpenRef.current = isOpen; - layoutChange.animate(); - if (isOpen && contentRef.current != null) { + // A change nothing measured (an owner set `isOpen` directly) lands at + // once, entrance included, so the content and what follows stay in step. + if (layoutChange.animate() && isOpen && contentRef.current != null) { playLayoutChange(animatedLayout, contentRef.current); } }, [isOpen, layoutChange, animatedLayout]); - const handleToggle = (event: React.MouseEvent) => { + // Activation is blocked by this guard rather than the native `disabled` + // attribute, so the trigger keeps `aria-disabled` semantics and stays + // discoverable. A native `disabled` button would silently swallow events + // (e.g. a wrapping tooltip's hover) — the system-wide disabled convention. + const handleToggle = () => { if (isDisabled) { return; } - // Before the change commits: the chevron turns on the glide's motion, - // and the content after this Collapsible is measured where it is now. - // The chevron is the trigger's Icon (its `icon` theming target). - applyLayoutChangeTransition( - animatedLayout, - event.currentTarget.querySelector(':scope > .astryx-icon'), - ); - layoutChange.measureBefore(); + measureChange(); toggle(); }; @@ -449,6 +476,7 @@ export function Collapsible({ )} {...props}>
  • -
    - ); -} - -function LoadMore({animatedLayout}: {animatedLayout: AnimatedLayoutHandle}) { - const [loaded, setLoaded] = useState(false); - const {ref, before} = useReportedChange(animatedLayout, loaded); - return ( -
    -

    Load more: a skeleton replaced by taller content

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

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

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

    {title}

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

    Ships in 2 business days.

    -

    Free returns for 30 days.

    -

    Tracking arrives by email.

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

    Cache size, retries and timeouts.

    -

    Changes apply on the next sync.

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

    Cache size, retries and timeouts.

    -

    Changes apply on the next sync.

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

    Ships in 2 business days.

    +

    Free returns for 30 days.

    +

    Tracking arrives by email.

    +
    +
    +
    After the change (moves down)
    +
    +
    +
    + ); +} + +/** A centered column: the content before the change glides too. */ +export const CenteredColumn: Story = { + name: 'Centered column', + render: () => , +}; diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 142e9799329dc..66b003f65a7f8 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -166,10 +166,13 @@ those contracts. through the shared rectangle morph (`architecture:layer-runtime` INV15; `spec:AST-094` FR5) once Core has that morph; until then, the module plays its own compositor translate. It never animates height. Its walk visits only - the following element siblings at each ancestor level up to the area, through - `parentElement` and `nextElementSibling`, descending only into an element - with `display: contents`, so its cost grows with depth plus following - siblings. Changes in one area in the same frame are measured together. A + the element siblings on either side at each ancestor level up to the area, + through `parentElement`, `nextElementSibling` and `previousElementSibling`, + descending only into an element with `display: contents`, and each level's + walk stops at the first sibling outside the band a change can bring into + view (the area's visible part plus one viewport, or the changed element's + size if larger, above and below), so its cost grows with depth plus the + siblings near the visible part, never with the length of a list. Changes in one area in the same frame are measured together. A component reports a change through an internal, non-exported function that takes the `useAnimatedLayout` handle, so the handle stays member-free, and each moving element is one the page already renders diff --git a/docs/specs/AST-105-animated-layout/spec.md b/docs/specs/AST-105-animated-layout/spec.md index 2cad8554e896f..99a27a65be6ef 100644 --- a/docs/specs/AST-105-animated-layout/spec.md +++ b/docs/specs/AST-105-animated-layout/spec.md @@ -67,9 +67,10 @@ const animatedLayout = useAnimatedLayout({containerRef: scrollRef}); - **FR3 — The content after a change glides within the area.** For any change a component holding the handle reports, such as a disclosure opening, content loading in, a message appearing, or an item inserted or removed, - the content after it inside the area moves from its old place to its new - one instead of jumping, at every ancestor level up to the area and never - past it. An element moves only when its layout rectangle changed and it is + the content it shifts inside the area moves from its old place to its new + one instead of jumping: the siblings after it, and the siblings before it + that its layout moves (a centered, end-aligned or space-between row, or + `order`), at every ancestor level up to the area and never past it. An element moves only when its layout rectangle changed and it is in the area's visible part. Which elements move is listed under Coverage. Shifted content may mix Astryx components and plain app elements, and none of it opts in. Several changes in one area, nested or not, compose: each @@ -110,6 +111,7 @@ What moves inside an area, each case with its test: | Sticky elements | In flow while unstuck, so moves when its rectangle changed; a stuck element takes its final place | A sticky header above and below its stuck point | | Floats | Moves when its rectangle changed | A float after the changed element | | Flex and grid items after the changed element, including wrap and row direction | Moves when its rectangle changed | A wrapping flex row and a row-direction neighbor that does not shift | +| Siblings before the changed element, at each ancestor level | Moves when its rectangle changed: a centered, end-aligned or space-between row, or `order`, moves them | A centered row, a space-between row and an end-aligned row | | Hidden, `display: none`, or zero-size elements | Skipped | One of each after the changed element | | Changed elements outside the area's visible part | Take their final place without animation | A long list with changed rows above and below the fold | | Elements under a scaled or transformed ancestor | Moves by its layout change, corrected for the ancestor's scale | A moving row inside a scaled panel | @@ -122,8 +124,6 @@ Out of coverage, each taking its final place at once: - content outside the area, and content in another document or an iframe; - inline text and other non-replaced inline content, which cannot take a transform; -- flex or grid items earlier in the DOM that `order` places after the change, - since only content after the change in the DOM moves; - text reflowing inside a paragraph beside a float; - elements the app moves itself with its own transforms; - a closing element whose space no end margin frees (a grid item whose 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 69ff68f1663a9..9ac7c88682efb 100644 --- a/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts +++ b/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts @@ -622,6 +622,112 @@ test.describe('spec:AST-105 FR2–FR4', () => { expect(startOffset(after.first).y).toBeCloseTo(-160, 0); }); + for (const row of [ + { + name: 'a centered row', + justify: 'center', + items: ['before', 'changed', 'after'], + // The row grows by 80 px: an item before moves left by half of it. + expectX: 40, + }, + { + name: 'a space-between row', + justify: 'space-between', + items: ['first', 'before', 'changed', 'last'], + // Four items: the gaps shrink by a third of the growth each. + expectX: 80 / 3, + }, + { + name: 'an end-aligned row', + justify: 'flex-end', + items: ['before', 'changed'], + expectX: 80, + }, + ]) { + test(`an earlier sibling ${row.name} moves glides from its old place (spec:AST-105 Coverage)`, async ({ + page, + }) => { + await open( + page, + `
    + ${row.items + .map( + id => + `
    `, + ) + .join('')} +
    `, + ); + await change(page, { + changed: '#changed', + mutate: `document.querySelector('#changed').style.width = '140px'`, + }); + const [motion] = await page.evaluate(() => window.motionOf('#before')); + expect(motion).toBeDefined(); + expect(startOffset(motion.first).x).toBeCloseTo(row.expectX, 0); + }); + } + + test('in a long list the walk reads only the rows near the visible part, and rows a tall collapse pulls up into view glide in (FR3)', async ({ + page, + }) => { + await open( + page, + `
    + ${'
    '.repeat(1000)} +
    + ${Array.from({length: 1000}, (_, i) => `
    `).join('')} +
    `, + ); + const result = await page.evaluate(async () => { + const area = document.querySelector('#area') as HTMLElement; + const tall = document.querySelector('#tall') as HTMLElement; + // The tall item's bottom part in view. + area.scrollTop = tall.offsetTop + 1000 - area.clientHeight / 2; + const prototype = Element.prototype; + const original = prototype.getBoundingClientRect; + let reads = 0; + prototype.getBoundingClientRect = function getBoundingClientRect( + this: Element, + ) { + reads += 1; + return original.call(this); + }; + const layout = window.AnimatedLayout.createAnimatedLayout(() => area); + const report = window.AnimatedLayout.reportLayoutChange( + layout, + () => tall, + ); + report.measureBefore(); + tall.style.height = '0px'; + report.animate(); + await new Promise(resolve => { + queueMicrotask(resolve); + }); + prototype.getBoundingClientRect = original; + // Rows now in view that were below the area's visible part plus one + // viewport before the collapse: the band reached them because the + // collapsing item is taller than a viewport. + const areaRect = area.getBoundingClientRect(); + const inView = [ + ...area.querySelectorAll('[id^="r"]'), + ].filter(row => { + const rect = row.getBoundingClientRect(); + return rect.bottom > areaRect.top && rect.top < areaRect.bottom; + }); + const lowest = inView.at(-1); + return { + reads, + rows: inView.length, + lowestGlides: lowest?.getAnimations().length ?? 0, + }; + }); + // Two thousand rows, but only those near the visible part are read. + expect(result.reads).toBeLessThan(200); + expect(result.rows).toBeGreaterThan(0); + expect(result.lowestGlides).toBe(1); + }); + test('the glide never scrolls the area (FR3)', async ({page}) => { // The same change with and without a handle, from the same scroll // position: the area's scroll (the browser's own scroll anchoring diff --git a/packages/core/src/motion/animatedLayout.boundary.test.ts b/packages/core/src/motion/animatedLayout.boundary.test.ts index f02e6f0dd3681..021c0829e8e39 100644 --- a/packages/core/src/motion/animatedLayout.boundary.test.ts +++ b/packages/core/src/motion/animatedLayout.boundary.test.ts @@ -6,8 +6,9 @@ * every core source file that imports the module * @output The source boundary check `architecture:react-component-runtime` * INV12 asks for: the module animates no layout property, its walk reaches - * elements only through parentElement / nextElementSibling (and - * firstElementChild under `display: contents`), the reporting functions are + * elements only through parentElement and the element siblings on either + * side (and firstElementChild under `display: contents`), stopping at the + * band around the visible part, the reporting functions are * not public, and only the hook and the adopting components use it * @position Testing; the real-browser half of INV12's verification is in * __tests__/animatedLayout.interaction.browser.spec.ts @@ -64,7 +65,7 @@ describe('architecture:react-component-runtime INV12 source boundary', () => { ]); }); - it('walks only through parentElement and nextElementSibling, entering display: contents alone', () => { + it('walks only through parentElement and the element siblings on either side, entering display: contents alone, and stops at the band', () => { const body = code(moduleSource); for (const forbidden of [ 'TreeWalker', @@ -74,7 +75,6 @@ describe('architecture:react-component-runtime INV12 source boundary', () => { '.children', 'childNodes', 'lastElementChild', - 'previousElementSibling', ]) { expect(body, forbidden).not.toContain(forbidden); } @@ -87,6 +87,13 @@ describe('architecture:react-component-runtime INV12 source boundary', () => { /display === 'contents'\)\s*\{\s*for\s*\(\s*let child = \w+\.firstElementChild/g, ) ?? []; expect(contentsDescents).toHaveLength(2); + // Each level's sibling walk ends at the first sibling past the band. + expect(body).toMatch( + /sibling != null && take\(sibling, true\);\s*sibling = sibling\.nextElementSibling/, + ); + expect(body).toMatch( + /sibling != null && take\(sibling, false\);\s*sibling = sibling\.previousElementSibling/, + ); }); it('keeps the reporting functions out of every public entry point', () => { diff --git a/packages/core/src/motion/animatedLayout.test.ts b/packages/core/src/motion/animatedLayout.test.ts index bddff357ba3c3..2d0bf0ce10c82 100644 --- a/packages/core/src/motion/animatedLayout.test.ts +++ b/packages/core/src/motion/animatedLayout.test.ts @@ -712,3 +712,80 @@ describe('elements the app moves itself (spec:AST-105 Coverage)', () => { expect(moved).toEqual(['plain']); }); }); + +describe('siblings before the change (spec:AST-105 FR3, Coverage)', () => { + it('moves an earlier sibling a centered row shifts when a later item grows', async () => { + const {area, changed, phase} = setUp( + '
    ', + ); + // A centered row: the item before the growing one moves left by half. + placeAt( + document.getElementById('first') as HTMLElement, + {before: [160, 0, 40, 24], after: [120, 0, 40, 24]}, + phase, + ); + await runChange( + createAnimatedLayout(() => area), + changed, + phase, + ); + const moves = played.filter( + p => + (p.element as HTMLElement).id === 'first' && + p.keyframes[0].translate != null, + ); + expect(moves).toHaveLength(1); + expect(translateOf(moves[0]).x).toBeCloseTo(40); + }); + + it('leaves an earlier sibling whose rectangle did not change', async () => { + const {area, changed, phase} = setUp( + '
    ', + ); + placeAt( + document.getElementById('first') as HTMLElement, + {before: [0, 0, 400, 40]}, + phase, + ); + await runChange( + createAnimatedLayout(() => area), + changed, + phase, + ); + expect( + played.filter(p => (p.element as HTMLElement).id === 'first'), + ).toEqual([]); + }); +}); + +describe('the walk stops at the band a change can bring into view', () => { + it('reads no sibling past the band, after or before the change', async () => { + // A 600 px area: the band runs a viewport above and below it. + const rows = Array.from({length: 40}, (_, i) => `
    `); + const {area, changed, phase} = setUp( + `
    ${rows.slice(0, 20).join('')}
    ${rows.slice(20).join('')}
    `, + ); + const reads = new Map(); + rows.forEach((_, i) => { + const row = document.getElementById(`r${i}`) as HTMLElement; + // Rows 100 px apart, the changed element between rows 19 and 20 at + // 1000 px: 20 rows above it reach -1000 px, 20 below reach 3000 px. + const top = i < 20 ? (i - 20) * 100 + 1000 : (i - 20) * 100 + 1100; + vi.spyOn(row, 'getBoundingClientRect').mockImplementation(() => { + reads.set(row.id, (reads.get(row.id) ?? 0) + 1); + const shift = phase.current === 'after' && i >= 20 ? 80 : 0; + return new DOMRect(0, top - 1000 + shift, 400, 100); + }); + }); + await runChange( + createAnimatedLayout(() => area), + changed, + phase, + ); + // The band is -600..1200 px: rows past it are never read. + expect(reads.get('r39')).toBeUndefined(); + expect(reads.get('r0')).toBeUndefined(); + expect(reads.get('r20')).toBeGreaterThan(0); + expect(reads.get('r19')).toBeGreaterThan(0); + }); +}); diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index 947d726f3b571..f3d59d0d58efc 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -18,9 +18,15 @@ * 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, through - * `parentElement` and `nextElementSibling`, descending only into an element - * with `display: contents` (it has no box). + * records the rectangles of the element siblings after it and before it (a + * centered, end-aligned or space-between row, or `order`, moves those too), + * through `parentElement`, `nextElementSibling` and + * `previousElementSibling`, descending only into an element with + * `display: contents` (it has no box). Each level's walk stops at the first + * sibling outside the band a change can move content into view from: the + * area's visible part plus one viewport, or the changed element's size if + * larger, above and below. So its cost grows with depth plus the siblings + * near the visible part, never with a list's length. * - `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. @@ -315,17 +321,52 @@ function takesPart(element: Element, style: CSSStyleDeclaration): boolean { } /** - * 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. + * The band of the page a change can move content into or out of view: the + * area's visible part, extended above and below by one viewport or by the + * changed element's size, whichever is larger (a collapse pulls content up by + * at most that size). Content outside it before the change stays outside it, + * so the walk stops there. */ -function collectFollowers(changed: Element, area: Element): Element[] { +interface Band { + /** Its top edge, in client px. */ + readonly start: number; + /** Its bottom edge, in client px. */ + readonly end: number; +} + +function bandFor(changed: Element, area: Element): Band { + const visible = visibleRect(area); + const reach = Math.max( + visible.height, + changed.getBoundingClientRect().height, + ); + const start = visible.top - reach; + const end = visible.bottom + reach; + return {start, end}; +} + +/** + * The elements a change can move, each with its rectangle before the change: + * at each ancestor level up to the area, the siblings after the changed + * element and the siblings before it (a centered, end-aligned or + * space-between row, or `order`, moves those too). A `display: contents` + * element has no box: its children stand in its place. At each level the + * walk stops at the first sibling past `band` (the siblings after it are + * further along), so its cost grows with depth plus the siblings near the + * visible part, never with the length of a list. + */ +function collectCandidates( + changed: Element, + area: Element, + band: Band, +): Map { const view = changed.ownerDocument.defaultView; - const out: Element[] = []; - const take = (element: Element) => { + const out = new Map(); + /** Takes `element`; returns false once it lies past the band. */ + const take = (element: Element, isAfter: boolean): boolean => { const style = view?.getComputedStyle(element); if (style == null) { - return; + return true; } if (style.display === 'contents') { for ( @@ -333,13 +374,19 @@ function collectFollowers(changed: Element, area: Element): Element[] { child != null; child = child.nextElementSibling ) { - take(child); + take(child, isAfter); } - return; + return true; + } + if (!takesPart(element, style)) { + return true; } - if (takesPart(element, style)) { - out.push(element); + const rect = element.getBoundingClientRect(); + if (isAfter ? rect.top > band.end : rect.bottom < band.start) { + return false; } + out.set(element, rect); + return true; }; for ( let node: Element | null = changed; @@ -348,10 +395,17 @@ function collectFollowers(changed: Element, area: Element): Element[] { ) { for ( let sibling = node.nextElementSibling; - sibling != null; + sibling != null && take(sibling, true); sibling = sibling.nextElementSibling ) { - take(sibling); + // The siblings after it, until one lies past the band. + } + for ( + let sibling = node.previousElementSibling; + sibling != null && take(sibling, false); + sibling = sibling.previousElementSibling + ) { + // The siblings before it, until one lies before the band. } } return out; @@ -365,6 +419,8 @@ interface Batch { readonly area: Element; readonly changed: Set; readonly before: Map; + /** What each changed element's walk found, so a dropped change moves none. */ + readonly candidates: Map; scheduled: boolean; } @@ -377,6 +433,7 @@ function batchFor(area: Element): Batch { area, changed: new Set(), before: new Map(), + candidates: new Map(), scheduled: false, }; batches.set(area, created); @@ -445,13 +502,14 @@ function scaleOf(element: Element, rect: DOMRect): {x: number; y: number} { function flush(batch: Batch): void { batches.delete(batch.area); + // What each change's walk measured before it: an element added by the + // change has no old place, and one it removed is gone. 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); + for (const element of batch.candidates.get(changed) ?? []) { + if (element.isConnected) { + followers.add(element); + } } } if (followers.size === 0) { @@ -619,11 +677,17 @@ export function reportLayoutChange( } 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()); + const candidates = collectCandidates( + element, + area, + bandFor(element, area), + ); + for (const [candidate, rect] of candidates) { + if (!batch.before.has(candidate)) { + batch.before.set(candidate, rect); } } + batch.candidates.set(element, [...candidates.keys()]); }, animate() { const element = resolve(target); From e43e3280cea28979a6a92eebb4b6b5ec9ccb488b Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 18:48:56 -0700 Subject: [PATCH 31/43] perf(core): a held exit reads where the content after it goes once, where its end margin moves that content linearly The perf page showed a collapse in a fully rendered 10,000-row list still costing about 109 ms with the handle against 3 ms without it: holdLayoutExit laid the page out three times (where the content goes with the element out of flow, a first guess, then a check), on top of the glide's own reading. Where the end margin is the only negative margin in its collapse (no block margins of its own, the next box's start and the parent's end not negative, block flow or a column flex box), the margin moves the content after it by exactly its own change, so the one reading of where that content goes sets it: no guess and no check. Everything else (rows, grids, negative margins, floats) keeps the guess and the check. Tests: jsdom counts the reads (two where linear, more where a negative margin collapses) and grid refusals set the grid explicitly; real-browser cases in block flow and in a column flex box with a gap read the row twice and land it exactly. --- ...animatedLayout.interaction.browser.spec.ts | 57 ++++++++++++ .../core/src/motion/animatedLayout.test.ts | 28 +++++- packages/core/src/motion/animatedLayout.ts | 90 ++++++++++++++++++- 3 files changed, 169 insertions(+), 6 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 9ac7c88682efb..d5f822b63c73d 100644 --- a/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts +++ b/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts @@ -776,6 +776,63 @@ test.describe('spec:AST-105 FR2–FR4', () => { expect(follower[0].duration).toBeLessThan(1200); }); + for (const parent of [ + {name: 'block flow', style: 'display: block'}, + { + name: 'a column flex box with a gap', + style: 'display: flex; flex-direction: column; gap: 12px', + }, + ]) { + test(`an exit in ${parent.name} reads where the content after it goes once, and lands it exactly (FR4)`, async ({ + page, + }) => { + await open( + page, + `
    +
    Details
    +
    `, + ); + const result = await page.evaluate(async () => { + const area = document.querySelector('#area'); + const content = document.querySelector('#content') as HTMLElement; + const a = document.querySelector('#a') as HTMLElement; + const layout = window.AnimatedLayout.createAnimatedLayout(() => area); + const report = window.AnimatedLayout.reportLayoutChange( + layout, + () => content, + ); + report.measureBefore(); + report.animate(); + // Where the row lands once the content is gone. + content.style.display = 'none'; + const target = a.getBoundingClientRect().top; + content.style.display = ''; + const original = a.getBoundingClientRect; + let reads = 0; + a.getBoundingClientRect = function getBoundingClientRect() { + reads += 1; + return original.call(this); + }; + const held = window.AnimatedLayout.holdLayoutExit( + layout, + content, + () => {}, + ); + const holdReads = reads; + a.getBoundingClientRect = original; + await new Promise(resolve => { + queueMicrotask(resolve); + }); + a.getAnimations().forEach(animation => animation.finish()); + return {held, holdReads, landed: a.getBoundingClientRect().top, target}; + }); + expect(result.held).toBe(true); + // Where it is and where it goes: no guess and no check. + expect(result.holdReads).toBe(2); + expect(Math.abs(result.landed - result.target)).toBeLessThan(0.5); + }); + } + test('a closing element exits on the same motion while the content after it glides up at once (FR4)', async ({ page, }) => { diff --git a/packages/core/src/motion/animatedLayout.test.ts b/packages/core/src/motion/animatedLayout.test.ts index 2d0bf0ce10c82..6df42004b572f 100644 --- a/packages/core/src/motion/animatedLayout.test.ts +++ b/packages/core/src/motion/animatedLayout.test.ts @@ -523,6 +523,27 @@ describe('the changed element exits (spec:AST-105 FR4)', () => { expect(content.style.pointerEvents).toBe(''); }); + it('reads where the content after it goes once, where no other margin collapses with its end margin', () => { + const {content, after, layout} = exitSetUp(); + const reads = vi.mocked(after.getBoundingClientRect); + reads.mockClear(); + expect(holdLayoutExit(layout, content, vi.fn())).toBe(true); + // Where it is now and where it goes: no guess and no check, so a long + // page is laid out twice fewer. + expect(reads).toHaveBeenCalledTimes(2); + expect(content.style.getPropertyValue('margin-block-end')).toBe('-88px'); + }); + + it('guesses and checks where a negative margin would collapse with its end margin', () => { + const {content, after, layout} = exitSetUp(); + after.style.marginTop = '-4px'; + const reads = vi.mocked(after.getBoundingClientRect); + reads.mockClear(); + expect(holdLayoutExit(layout, content, vi.fn())).toBe(true); + expect(reads.mock.calls.length).toBeGreaterThan(2); + expect(content.style.getPropertyValue('margin-block-end')).toBe('-88px'); + }); + it('stops an exit when the content opens again', () => { const {content, layout} = exitSetUp(); const done = vi.fn(); @@ -545,7 +566,8 @@ describe('the changed element exits (spec:AST-105 FR4)', () => { }); it('lands at once where no end margin frees its space (a grid track)', () => { - const {content, after, layout} = exitSetUp(); + const {area, content, after, layout} = exitSetUp(); + area.style.display = 'grid'; // The row after it ignores the margin: it moves only when the content // is hidden. vi.spyOn(after, 'getBoundingClientRect').mockImplementation( @@ -567,7 +589,9 @@ describe('the changed element exits (spec:AST-105 FR4)', () => { }); it("a refused hold keeps the element's own end margin and lets the content after it take its place at once", async () => { - const {content, after, layout} = exitSetUp(); + const {area, content, after, layout} = exitSetUp(); + // A grid track, which no end margin frees. + area.style.display = 'grid'; content.style.setProperty('margin-block-end', '12px'); vi.spyOn(after, 'getBoundingClientRect').mockImplementation( () => diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index f3d59d0d58efc..e08c656a9f751 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -814,6 +814,74 @@ function placeOf( return {block: rect.top, inline: rtl ? -rect.right : rect.left}; } +/** + * A computed length in px; NaN for anything else (`auto`). An unset value + * reads as 0: an engine without layout reports nothing for a margin. + */ +function lengthOf(value: string): number { + if (value === '') { + return 0; + } + return value.endsWith('px') ? Number.parseFloat(value) : Number.NaN; +} + +/** A computed keyword, with an engine's empty value read as its initial one. */ +function keywordOf(value: string, initial: string): string { + return value === '' ? initial : value; +} + +/** + * Whether a block-end margin on `element` moves `next` by exactly the + * margin: the element has no block margins of its own and does not float, + * and the margins it would collapse with (the next box's start, and its + * parent's end when it is the parent's last box) are not negative, so its + * own negative margin is the only negative one in the collapse. Its parent + * lays it out in block flow or a column flex box (where margins do not + * collapse). Style reads only: no layout. + */ +function endMarginMovesLinearly( + element: Element, + next: Element, + view: Window | null, +): boolean { + const parentElement = element.parentElement; + if (view == null || parentElement == null) { + return false; + } + const style = view.getComputedStyle(element); + const parent = view.getComputedStyle(parentElement); + const isColumn = + parent.display.includes('flex') && parent.flexDirection === 'column'; + const isBlockFlow = + parent.display === 'block' || + parent.display === 'flow-root' || + parent.display === 'list-item'; + if ( + (!isColumn && !isBlockFlow) || + keywordOf(style.float, 'none') !== 'none' || + keywordOf(style.writingMode, 'horizontal-tb') !== 'horizontal-tb' || + lengthOf(style.marginTop) !== 0 || + lengthOf(style.marginBottom) !== 0 + ) { + return false; + } + if (isColumn) { + return true; + } + const nextStyle = view.getComputedStyle(next); + if ( + keywordOf(nextStyle.float, 'none') !== 'none' || + keywordOf(nextStyle.clear, 'none') !== 'none' || + !(lengthOf(nextStyle.marginTop) >= 0) + ) { + return false; + } + // The last box of its parent: the parent's end margin joins the collapse. + return ( + next.parentElement === parentElement || lengthOf(parent.marginBottom) >= 0 + ); +} + /** * Keeps `element` where it is, in flow (so it scrolls and clips with its * container), while the content after it takes the place it takes once the @@ -850,6 +918,10 @@ function giveUpSpace(element: HTMLElement, area: Element): boolean { ? null : nextInFlow(element.parentElement, area); const checked = afterParent == null ? [next] : [next, afterParent]; + // Where the content after it is now, and the element's scale, read in the + // layout the caller's visibility check already laid out. + const current = placeOf(next, rtl); + const scale = scaleOf(element, element.getBoundingClientRect()); // 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). @@ -863,14 +935,24 @@ function giveUpSpace(element: HTMLElement, area: Element): boolean { } else { element.style.setProperty('position', position, positionPriority); } + // The reading is in screen px, the margin in the element's own: under a + // scaled ancestor a distance is divided by the scale on this axis. + const perPx = isRow ? scale.x : scale.y; + if (!isRow && endMarginMovesLinearly(element, next, view)) { + // The end margin moves the content after it by exactly its own change + // (no other margin collapses with it), so one reading of where that + // content goes is enough: no guess and no check, two layouts fewer. + element.style.setProperty( + property, + `${(target.block - current.block) / perPx}px`, + ); + return true; + } // 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]; - // 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); + const correction = (target[axis] - guess) / perPx; element.style.setProperty(property, `${-size + correction}px`); // Everything checked must land on both axes: a flex-row item that sets // its row's height frees no block space with an inline margin. From 24eaa878f71696b3eba4f6dc019b991d1312dfa6 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 18:58:44 -0700 Subject: [PATCH 32/43] perf(core): the glide reads every follower before it starts any, so a long page is laid out once The perf page's 10,000-row list showed about 100 ms per toggle with the handle against 3 ms without it. A CPU profile put two thirds of it in computed-style reads: the flush read each follower's transform and scale (movedByApp, scaleOf) just before starting that follower's glide, so every started glide made the next reading lay the whole page out again, once per visible follower. The flush now reads every place, transform and scale first, then starts the glides. Test (failing first): no style is read once a glide has started. --- .../core/src/motion/animatedLayout.test.ts | 37 +++++++++++++++++++ packages/core/src/motion/animatedLayout.ts | 16 +++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/core/src/motion/animatedLayout.test.ts b/packages/core/src/motion/animatedLayout.test.ts index 6df42004b572f..da037e091aa1b 100644 --- a/packages/core/src/motion/animatedLayout.test.ts +++ b/packages/core/src/motion/animatedLayout.test.ts @@ -194,6 +194,43 @@ describe('the walk (spec:AST-105 FR3, Coverage)', () => { } }); + it('reads every follower (place, transform, scale) before it starts any glide, so a long page is laid out once', async () => { + const {area, changed, phase} = setUp( + '
    ', + ); + for (const [id, y] of [ + ['a', 0], + ['b', 40], + ['c', 80], + ] as const) { + placeAt( + document.getElementById(id) as HTMLElement, + {before: [0, y, 400, 40], after: [0, y + 80, 400, 40]}, + phase, + ); + } + // A style or layout read after a glide started lays the page out again. + const readsAfterAGlide: string[] = []; + const computed = window.getComputedStyle.bind(window); + vi.spyOn(window, 'getComputedStyle').mockImplementation( + (element: Element, pseudo?: string | null) => { + if (played.some(p => p.keyframes[0].translate != null)) { + readsAfterAGlide.push((element as HTMLElement).id); + } + return computed(element, pseudo); + }, + ); + await runChange( + createAnimatedLayout(() => area), + changed, + phase, + ); + expect(played.filter(p => p.keyframes[0].translate != null)).toHaveLength( + 3, + ); + expect(readsAfterAGlide).toEqual([]); + }); + it('takes the children of a display: contents wrapper and skips absolute, fixed, hidden and display: none elements', async () => { const {area, changed, phase} = setUp( `
    diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index e08c656a9f751..816bca1281103 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -530,6 +530,8 @@ function flush(batch: Batch): void { after?: DOMRect; rawX?: number; rawY?: number; + isMovedByApp?: boolean; + scale?: {x: number; y: number}; }[] = []; for (const element of followers) { const before = batch.before.get(element); @@ -558,6 +560,9 @@ function flush(batch: Batch): void { } entries.push({element, before, carried, carriedMs}); } + // Every reading (places, transforms, scales) before any glide starts: a + // glide started between two readings would make the next one lay the page + // out again, once per element (on a long page, the whole cost). const byElement = new Map(); for (const entry of entries) { entry.after = entry.element.getBoundingClientRect(); @@ -565,6 +570,13 @@ function flush(batch: Batch): void { entry.rawY = entry.before.top - entry.after.top; byElement.set(entry.element, entry); } + for (const entry of entries) { + const after = entry.after as DOMRect; + if (intersects(entry.before, visible) || intersects(after, visible)) { + entry.isMovedByApp = movedByApp(entry.element); + entry.scale = scaleOf(entry.element, after); + } + } for (const entry of entries) { const {element, before, carried, carriedMs} = entry; const after = entry.after as DOMRect; @@ -598,14 +610,14 @@ function flush(batch: Batch): void { }); continue; } - if (movedByApp(element)) { + if (entry.isMovedByApp === true) { // The app moves it itself: it takes its final place at once // (`spec:AST-105` Coverage). continue; } // Where it shows now, against its new place, in its own px; the part the // carried glide does not cover is the new move. - const scale = scaleOf(element, after); + const scale = entry.scale ?? {x: 1, y: 1}; const now = carried(0); const moveX = dx / scale.x - now.x; const moveY = dy / scale.y - now.y; From 3ea5336fd57348dbebc24c4e744b278f75b88b15 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 19:57:03 -0700 Subject: [PATCH 33/43] fix(core): the animated-layout walk glides a moved ancestor, reaches all of a frame's changes, and stops inside display: contents too CodeBunny's round on the extension: - A centered column moves the card around the change as well as the content before it: the changed element's ancestors up to the area are now measured and glide where their place changed (content inside one moves only by its own change), so nothing slides over the opening content. - Changes in one frame add up: the band grows by every changed element's size, and each change's walk is re-run with it, so rows several collapses pull up from beyond one change's band glide too. - A display: contents wrapper's children are walked in the walk's direction and stop at the band, so a wrapped list is not read in full. - AST-105: the coverage row names ancestors (a centered column) and order among the siblings near the visible part, with its test; an item order brings in from beyond the band is out of coverage. INV12 names the ancestors and the frame's summed size. Tests (failing first where behavior changed): a centered column's card glides and its title rides with it; two 500 px collapses reach rows past one band; a contents-wrapped list stops at the band; an order-placed earlier sibling glides; a real-browser centered column. --- docs/architecture/react-component-runtime.md | 13 +- docs/specs/AST-105-animated-layout/spec.md | 38 +++--- ...animatedLayout.interaction.browser.spec.ts | 24 ++++ .../motion/animatedLayout.boundary.test.ts | 11 +- .../core/src/motion/animatedLayout.test.ts | 124 ++++++++++++++++++ packages/core/src/motion/animatedLayout.ts | 69 ++++++---- 6 files changed, 224 insertions(+), 55 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 66b003f65a7f8..fe4811c07449a 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -166,12 +166,13 @@ those contracts. through the shared rectangle morph (`architecture:layer-runtime` INV15; `spec:AST-094` FR5) once Core has that morph; until then, the module plays its own compositor translate. It never animates height. Its walk visits only - the element siblings on either side at each ancestor level up to the area, - through `parentElement`, `nextElementSibling` and `previousElementSibling`, - descending only into an element with `display: contents`, and each level's - walk stops at the first sibling outside the band a change can bring into - view (the area's visible part plus one viewport, or the changed element's - size if larger, above and below), so its cost grows with depth plus the + the changed element's ancestors up to the area and the element siblings on + either side at each of their levels, through `parentElement`, + `nextElementSibling` and `previousElementSibling`, descending only into an + element with `display: contents`, and each level's walk stops at the first + sibling outside the band the changes in a frame can bring into view (the + area's visible part plus one viewport, or the changed elements' summed size + if larger, above and below), so its cost grows with depth plus the siblings near the visible part, never with the length of a list. Changes in one area in the same frame are measured together. A component reports a change through an internal, non-exported function that takes the `useAnimatedLayout` handle, so the handle stays member-free, and diff --git a/docs/specs/AST-105-animated-layout/spec.md b/docs/specs/AST-105-animated-layout/spec.md index 99a27a65be6ef..d1eb1387e918b 100644 --- a/docs/specs/AST-105-animated-layout/spec.md +++ b/docs/specs/AST-105-animated-layout/spec.md @@ -70,7 +70,8 @@ const animatedLayout = useAnimatedLayout({containerRef: scrollRef}); the content it shifts inside the area moves from its old place to its new one instead of jumping: the siblings after it, and the siblings before it that its layout moves (a centered, end-aligned or space-between row, or - `order`), at every ancestor level up to the area and never past it. An element moves only when its layout rectangle changed and it is + `order`), and its own ancestors where the layout moves them (a centered + column), at every ancestor level up to the area and never past it. An element moves only when its layout rectangle changed and it is in the area's visible part. Which elements move is listed under Coverage. Shifted content may mix Astryx components and plain app elements, and none of it opts in. Several changes in one area, nested or not, compose: each @@ -103,21 +104,21 @@ const animatedLayout = useAnimatedLayout({containerRef: scrollRef}); What moves inside an area, each case with its test: -| Case | Behavior | Test | -| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -| Normal-flow block siblings after the changed element, at each ancestor level up to the area | Moves when its rectangle changed | Nested changed element with block siblings at three levels | -| An element with `display: contents` | Has no box; its children are taken in its place | A following `display: contents` wrapper with two children | -| Absolutely positioned or fixed elements | Skipped and take their final place; an absolute child of a moving element rides with it | One of each, plus an absolute badge on a moving row | -| Sticky elements | In flow while unstuck, so moves when its rectangle changed; a stuck element takes its final place | A sticky header above and below its stuck point | -| Floats | Moves when its rectangle changed | A float after the changed element | -| Flex and grid items after the changed element, including wrap and row direction | Moves when its rectangle changed | A wrapping flex row and a row-direction neighbor that does not shift | -| Siblings before the changed element, at each ancestor level | Moves when its rectangle changed: a centered, end-aligned or space-between row, or `order`, moves them | A centered row, a space-between row and an end-aligned row | -| Hidden, `display: none`, or zero-size elements | Skipped | One of each after the changed element | -| Changed elements outside the area's visible part | Take their final place without animation | A long list with changed rows above and below the fold | -| Elements under a scaled or transformed ancestor | Moves by its layout change, corrected for the ancestor's scale | A moving row inside a scaled panel | -| Portals and top-layer content | Never moved | A popover open from a following row | -| Elements already moving | Keep their running motion and add the new move to it | Two changes 100 ms apart | -| Nested areas | Motion stops at the area of the handle the changed component holds; an inner area moves as one unit within an outer one | An area inside an area, changed in the inner one | +| Case | Behavior | Test | +| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| Normal-flow block siblings after the changed element, at each ancestor level up to the area | Moves when its rectangle changed | Nested changed element with block siblings at three levels | +| An element with `display: contents` | Has no box; its children are taken in its place | A following `display: contents` wrapper with two children | +| Absolutely positioned or fixed elements | Skipped and take their final place; an absolute child of a moving element rides with it | One of each, plus an absolute badge on a moving row | +| Sticky elements | In flow while unstuck, so moves when its rectangle changed; a stuck element takes its final place | A sticky header above and below its stuck point | +| Floats | Moves when its rectangle changed | A float after the changed element | +| Flex and grid items after the changed element, including wrap and row direction | Moves when its rectangle changed | A wrapping flex row and a row-direction neighbor that does not shift | +| Siblings before the changed element, and its ancestors, at each level | Moves when its rectangle changed: a centered, end-aligned or space-between row, a centered column, or `order` among the siblings near the visible part moves them | A centered row, a space-between row, an end-aligned row, a centered column and an `order`-placed sibling | +| Hidden, `display: none`, or zero-size elements | Skipped | One of each after the changed element | +| Changed elements outside the area's visible part | Take their final place without animation | A long list with changed rows above and below the fold | +| Elements under a scaled or transformed ancestor | Moves by its layout change, corrected for the ancestor's scale | A moving row inside a scaled panel | +| Portals and top-layer content | Never moved | A popover open from a following row | +| Elements already moving | Keep their running motion and add the new move to it | Two changes 100 ms apart | +| Nested areas | Motion stops at the area of the handle the changed component holds; an inner area moves as one unit within an outer one | An area inside an area, changed in the inner one | Out of coverage, each taking its final place at once: @@ -131,7 +132,10 @@ Out of coverage, each taking its final place at once: height): it hides at once, without its exit, and the content after it takes its final place at once; - an anchored popover tied to a moving element, which layer anchoring keeps - in place (`architecture:layer-runtime`). + in place (`architecture:layer-runtime`); +- an item that `order` brings into view from beyond the area's visible part + plus one viewport (or the changes' size, if larger) in document order, + which the walk does not reach. ## Current-state impact 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 9ac7c88682efb..87b599ff7639e 100644 --- a/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts +++ b/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts @@ -668,6 +668,30 @@ test.describe('spec:AST-105 FR2–FR4', () => { }); } + test('in a centered column the card around the change glides with the content before it, so nothing slides over the opening content (spec:AST-105 Coverage)', async ({ + page, + }) => { + await open( + page, + `
    +
    +
    Title
    +
    `, + ); + await change(page, { + changed: '#changed', + mutate: `document.querySelector('#changed').style.height = '120px'`, + }); + const card = await page.evaluate(() => window.motionOf('#card')); + const before = await page.evaluate(() => window.motionOf('#before')); + const title = await page.evaluate(() => window.motionOf('#title')); + // The card and the row above it glide up together from their old place; + // the title inside the card rides with it. + expect(startOffset(card[0].first).y).toBeCloseTo(60, 0); + expect(startOffset(before[0].first).y).toBeCloseTo(60, 0); + expect(title).toEqual([]); + }); + test('in a long list the walk reads only the rows near the visible part, and rows a tall collapse pulls up into view glide in (FR3)', async ({ page, }) => { diff --git a/packages/core/src/motion/animatedLayout.boundary.test.ts b/packages/core/src/motion/animatedLayout.boundary.test.ts index 021c0829e8e39..9b727a8ca22e5 100644 --- a/packages/core/src/motion/animatedLayout.boundary.test.ts +++ b/packages/core/src/motion/animatedLayout.boundary.test.ts @@ -74,17 +74,16 @@ describe('architecture:react-component-runtime INV12 source boundary', () => { 'getElementsBy', '.children', 'childNodes', - 'lastElementChild', ]) { expect(body, forbidden).not.toContain(forbidden); } - // Every descent is a `display: contents` branch: the follower walk's and - // the exit's search for the next box. - const descents = body.match(/firstElementChild/g) ?? []; - expect(descents).toHaveLength(2); + // Every descent is a `display: contents` branch: the walk's (in its own + // direction, first child or last) and the exit's search for the next box. + const descents = body.match(/(first|last)ElementChild/g) ?? []; + expect(descents).toHaveLength(3); const contentsDescents = body.match( - /display === 'contents'\)\s*\{\s*for\s*\(\s*let child = \w+\.firstElementChild/g, + /display === 'contents'\)\s*\{\s*for\s*\(\s*let child =[^;]*\.(first|last)ElementChild/g, ) ?? []; expect(contentsDescents).toHaveLength(2); // Each level's sibling walk ends at the first sibling past the band. diff --git a/packages/core/src/motion/animatedLayout.test.ts b/packages/core/src/motion/animatedLayout.test.ts index 2d0bf0ce10c82..1040d97d0fd11 100644 --- a/packages/core/src/motion/animatedLayout.test.ts +++ b/packages/core/src/motion/animatedLayout.test.ts @@ -789,3 +789,127 @@ describe('the walk stops at the band a change can bring into view', () => { expect(reads.get('r19')).toBeGreaterThan(0); }); }); + +describe('the walk reaches what the changes can move (spec:AST-105 FR3)', () => { + it("glides the change's ancestor that a centered column moves, and the content inside it moves only with it", async () => { + const {area, changed, phase} = setUp( + '
    ', + ); + // The column centers its content: opening the change moves the card and + // the content above it up by half the growth. + placeAt( + document.getElementById('above') as HTMLElement, + {before: [0, 100, 400, 40], after: [0, 60, 400, 40]}, + phase, + ); + placeAt( + document.getElementById('card') as HTMLElement, + {before: [0, 140, 400, 60], after: [0, 100, 400, 140]}, + phase, + ); + placeAt( + document.getElementById('title') as HTMLElement, + {before: [0, 140, 400, 20], after: [0, 100, 400, 20]}, + phase, + ); + await runChange( + createAnimatedLayout(() => area), + changed, + phase, + ); + const glides = new Map( + played + .filter(p => p.keyframes[0].translate != null) + .map(p => [(p.element as HTMLElement).id, translateOf(p).y]), + ); + expect(glides.get('card')).toBeCloseTo(40); + expect(glides.get('above')).toBeCloseTo(40); + // The title rides with its card: it moved by nothing of its own. + expect(glides.has('title')).toBe(false); + }); + + it('reaches the band that all the changes in one frame can pull into view', () => { + const rows = Array.from({length: 30}, (_, i) => `
    `); + document.body.innerHTML = `
    ${rows.join('')}
    `; + const area = document.getElementById('area') as HTMLElement; + vi.spyOn(area, 'getBoundingClientRect').mockReturnValue( + new DOMRect(0, 0, 400, 560), + ); + // Two 500 px groups closing at once: the rows below them come up 1,000 px. + for (const [id, y] of [ + ['a', 0], + ['b', 500], + ] as const) { + vi.spyOn( + document.getElementById(id) as HTMLElement, + 'getBoundingClientRect', + ).mockReturnValue(new DOMRect(0, y, 400, 500)); + } + const reads = new Set(); + rows.forEach((_, i) => { + const row = document.getElementById(`r${i}`) as HTMLElement; + vi.spyOn(row, 'getBoundingClientRect').mockImplementation(() => { + reads.add(row.id); + return new DOMRect(0, 1000 + i * 100, 400, 100); + }); + }); + const layout = createAnimatedLayout(() => area); + reportLayoutChange(layout, () => + document.getElementById('a'), + ).measureBefore(); + reportLayoutChange(layout, () => + document.getElementById('b'), + ).measureBefore(); + // One group's band ends at 560 + 560; both together reach 560 + 1,000. + expect(reads.has('r5')).toBe(true); + expect(reads.has('r20')).toBe(false); + }); + + it('stops inside a display: contents wrapper at the band too', () => { + const rows = Array.from({length: 40}, (_, i) => `
    `); + const {area, changed} = setUp( + `
    ${rows.join('')}
    `, + ); + void area; + const reads = new Set(); + rows.forEach((_, i) => { + const row = document.getElementById(`c${i}`) as HTMLElement; + vi.spyOn(row, 'getBoundingClientRect').mockImplementation(() => { + reads.add(row.id); + return new DOMRect(0, i * 100, 400, 100); + }); + }); + reportLayoutChange( + createAnimatedLayout(() => area), + () => changed, + ).measureBefore(); + // The band ends a viewport below the 600 px area. + expect(reads.has('c12')).toBe(true); + expect(reads.has('c30')).toBe(false); + }); + + it('moves an earlier sibling that order places after the change, near the visible part', async () => { + const {area, changed, phase} = setUp( + '
    ', + ); + // First in the DOM, laid out after the change: it moves down when the + // change grows. + placeAt( + document.getElementById('ordered') as HTMLElement, + {before: [0, 0, 400, 40], after: [0, 80, 400, 40]}, + phase, + ); + await runChange( + createAnimatedLayout(() => area), + changed, + phase, + ); + const glide = played.find( + p => + (p.element as HTMLElement).id === 'ordered' && + p.keyframes[0].translate != null, + ); + expect(glide).toBeDefined(); + expect(translateOf(glide!).y).toBeCloseTo(-80); + }); +}); diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index f3d59d0d58efc..e5903930611dd 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -323,9 +323,10 @@ function takesPart(element: Element, style: CSSStyleDeclaration): boolean { /** * The band of the page a change can move content into or out of view: the * area's visible part, extended above and below by one viewport or by the - * changed element's size, whichever is larger (a collapse pulls content up by - * at most that size). Content outside it before the change stays outside it, - * so the walk stops there. + * size of every element changing in the frame, whichever is larger (a + * collapse pulls content up by at most its size, and changes in one frame + * add up). Content outside it before the change stays outside it, so the + * walk stops there. */ interface Band { /** Its top edge, in client px. */ @@ -334,12 +335,9 @@ interface Band { readonly end: number; } -function bandFor(changed: Element, area: Element): Band { +function bandFor(area: Element, changedSize: number): Band { const visible = visibleRect(area); - const reach = Math.max( - visible.height, - changed.getBoundingClientRect().height, - ); + const reach = Math.max(visible.height, changedSize); const start = visible.top - reach; const end = visible.bottom + reach; return {start, end}; @@ -347,11 +345,12 @@ function bandFor(changed: Element, area: Element): Band { /** * The elements a change can move, each with its rectangle before the change: - * at each ancestor level up to the area, the siblings after the changed - * element and the siblings before it (a centered, end-aligned or - * space-between row, or `order`, moves those too). A `display: contents` - * element has no box: its children stand in its place. At each level the - * walk stops at the first sibling past `band` (the siblings after it are + * the changed element's ancestors up to the area (a centered column moves + * the card around the change), and at each of their levels the siblings + * after and before (a centered, end-aligned or space-between row, or + * `order`, moves those too). A `display: contents` element has no box: its + * children stand in its place, walked in the same direction. At each level + * the walk stops at the first sibling past `band` (the siblings after it are * further along), so its cost grows with depth plus the siblings near the * visible part, never with the length of a list. */ @@ -370,11 +369,17 @@ function collectCandidates( } if (style.display === 'contents') { for ( - let child = element.firstElementChild; + let child = isAfter + ? element.firstElementChild + : element.lastElementChild; child != null; - child = child.nextElementSibling + child = isAfter + ? child.nextElementSibling + : child.previousElementSibling ) { - take(child, isAfter); + if (!take(child, isAfter)) { + return false; + } } return true; } @@ -393,6 +398,11 @@ function collectCandidates( node != null && node !== area; node = node.parentElement ) { + if (node !== changed) { + // An ancestor of the change: where its layout moves it (a centered + // column), it glides, and what is inside moves only by its own change. + out.set(node, node.getBoundingClientRect()); + } for ( let sibling = node.nextElementSibling; sibling != null && take(sibling, true); @@ -421,6 +431,8 @@ interface Batch { readonly before: Map; /** What each changed element's walk found, so a dropped change moves none. */ readonly candidates: Map; + /** The changed elements' sizes before the change, added up. */ + changedSize: number; scheduled: boolean; } @@ -434,6 +446,7 @@ function batchFor(area: Element): Batch { changed: new Set(), before: new Map(), candidates: new Map(), + changedSize: 0, scheduled: false, }; batches.set(area, created); @@ -676,18 +689,22 @@ export function reportLayoutChange( return; } const batch = batchFor(area); - batch.changed.add(element); - const candidates = collectCandidates( - element, - area, - bandFor(element, area), - ); - for (const [candidate, rect] of candidates) { - if (!batch.before.has(candidate)) { - batch.before.set(candidate, rect); + if (!batch.changed.has(element)) { + batch.changed.add(element); + batch.changedSize += element.getBoundingClientRect().height; + } + // Changes in one frame add up: every change's walk reaches the band + // their summed size can pull into view. + const band = bandFor(area, batch.changedSize); + for (const changed of batch.changed) { + const candidates = collectCandidates(changed, area, band); + for (const [candidate, rect] of candidates) { + if (!batch.before.has(candidate)) { + batch.before.set(candidate, rect); + } } + batch.candidates.set(changed, [...candidates.keys()]); } - batch.candidates.set(element, [...candidates.keys()]); }, animate() { const element = resolve(target); From 007f4142037845183ec25c1b800337799e6c702d Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 13:10:47 -0700 Subject: [PATCH 34/43] docs(spec): Collapsible FR10 and TreeList FR8 cite AST-105 for the shared motion The adopter rows cited AST-108 FR4a, which is not on main yet. AST-105 FR1-FR4 already decides the shared motion they name. --- .../core/src/Collapsible/Collapsible.spec.md | 24 +++++++++---------- packages/core/src/TreeList/TreeList.spec.md | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/core/src/Collapsible/Collapsible.spec.md b/packages/core/src/Collapsible/Collapsible.spec.md index 86df5947a9bff..29ce1ab1d9f43 100644 --- a/packages/core/src/Collapsible/Collapsible.spec.md +++ b/packages/core/src/Collapsible/Collapsible.spec.md @@ -88,18 +88,18 @@ not parallel public names for this axis. ## Behavioral and layout contract -| ID | Invariant | Basis | -| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -| FR1 | Collapsible MUST expose `chevronPosition?: 'start' \| 'end'`; omission resolves to `end`. | owner API decision; preserves released default | -| FR2 | `end` MUST render Collapsible's chevron after the trigger label in logical order. Collapsed `end` points downward; expanded `end` points upward. | shipped trailing behavior plus approved position contract | -| FR3 | `start` MUST render Collapsible's chevron before the trigger label in logical order. Collapsed `start` points inward toward the label/content, so its inline direction mirrors under RTL; expanded `start` points downward. | approved leading-disclosure behavior | -| FR4 | Position changes the chevron glyph/direction as well as DOM order. Reusing a vertical down chevron and rotating it sideways MUST NOT make collapsed `start` point away from its content. | direction and representation decision | -| FR5 | Collapsible owns and renders the chevron for both positions. The position axis MUST NOT accept `none`, arbitrary React content, a cue callback, a renderer, or another replacement escape hatch. | constrained public-axis decision | -| FR6 | Trigger-label content MUST retain the usable row width needed by composed labels at either position. Moving the chevron to `start` MUST NOT strand the label at the opposite edge or prevent composed label content from using the remaining row. | approved layout rationale and existing grow behavior | -| FR7 | CollapsibleGroup MAY provide one `chevronPosition` default to its direct Collapsible items. A direct item's explicit position MUST override the group default. | approved group-coherence behavior | -| FR8 | A Collapsible nested inside another item's revealed content MUST begin a new presentation scope and use its own explicit position or the component default. The outer group's position MUST NOT leak into the nested disclosure. | approved nested isolation behavior | -| FR9 | Chevron position MUST NOT change disclosure semantics. The same trigger remains a button, retains `aria-expanded`, identifies the controlled region with `aria-controls`, and follows the existing state owner. | existing accessibility and behavior contract | -| FR10 | Collapsible MUST accept `animatedLayout?: AnimatedLayout`. With a handle, opening and closing report the content's size change through it, so the content after the Collapsible glides inside the handle's container, and the revealed content and the chevron move on the same motion (`spec:AST-105` FR1–FR4; AST-108 FR4a). Without one, opening and closing are unchanged. The prop MUST NOT change disclosure semantics. | `spec:AST-105` FR1, FR2 | +| ID | Invariant | Basis | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| FR1 | Collapsible MUST expose `chevronPosition?: 'start' \| 'end'`; omission resolves to `end`. | owner API decision; preserves released default | +| FR2 | `end` MUST render Collapsible's chevron after the trigger label in logical order. Collapsed `end` points downward; expanded `end` points upward. | shipped trailing behavior plus approved position contract | +| FR3 | `start` MUST render Collapsible's chevron before the trigger label in logical order. Collapsed `start` points inward toward the label/content, so its inline direction mirrors under RTL; expanded `start` points downward. | approved leading-disclosure behavior | +| FR4 | Position changes the chevron glyph/direction as well as DOM order. Reusing a vertical down chevron and rotating it sideways MUST NOT make collapsed `start` point away from its content. | direction and representation decision | +| FR5 | Collapsible owns and renders the chevron for both positions. The position axis MUST NOT accept `none`, arbitrary React content, a cue callback, a renderer, or another replacement escape hatch. | constrained public-axis decision | +| FR6 | Trigger-label content MUST retain the usable row width needed by composed labels at either position. Moving the chevron to `start` MUST NOT strand the label at the opposite edge or prevent composed label content from using the remaining row. | approved layout rationale and existing grow behavior | +| FR7 | CollapsibleGroup MAY provide one `chevronPosition` default to its direct Collapsible items. A direct item's explicit position MUST override the group default. | approved group-coherence behavior | +| FR8 | A Collapsible nested inside another item's revealed content MUST begin a new presentation scope and use its own explicit position or the component default. The outer group's position MUST NOT leak into the nested disclosure. | approved nested isolation behavior | +| FR9 | Chevron position MUST NOT change disclosure semantics. The same trigger remains a button, retains `aria-expanded`, identifies the controlled region with `aria-controls`, and follows the existing state owner. | existing accessibility and behavior contract | +| FR10 | Collapsible MUST accept `animatedLayout?: AnimatedLayout`. With a handle, opening and closing report the content's size change through it, so the content after the Collapsible glides inside the handle's container, and the revealed content and the chevron move on the same motion (`spec:AST-105` FR1–FR4). Without one, opening and closing are unchanged. The prop MUST NOT change disclosure semantics. | `spec:AST-105` FR1, FR2 | ### Allowed variation diff --git a/packages/core/src/TreeList/TreeList.spec.md b/packages/core/src/TreeList/TreeList.spec.md index 1b85f2fc4a500..0b7ccbcde8c09 100644 --- a/packages/core/src/TreeList/TreeList.spec.md +++ b/packages/core/src/TreeList/TreeList.spec.md @@ -106,7 +106,7 @@ Supporting one does not justify omitting another from the durable contract. | FR5 | `TreeListItemData` MUST admit `className`, `style`, and `xstyle` as one per-item styling seam. Every supplied value MUST compose on the same Item row box; an implementation MUST NOT treat current support for only one mechanism as authority to exclude the other two. | Owner decision; `architecture:public-component-api` INV5–INV6; `spec:AST-002/DEC-1` | Settled intent; implementation may be staged | | FR6 | The Item row box is the logical element that paints the row, owns its hover state, and contains Start content, Item label and description, and End content. The styling seam MUST NOT move to the semantic `
  • ` or another element without a compatibility decision. | Owner decision and the caller-owned per-row interaction boundary | Settled intent | | FR7 | A click on an Item row outside its Item label performs the row's action: an Item with `onClick` runs it; otherwise an Item with children toggles; otherwise an Item whose only action is its link follows that link as Enter does, with modified and middle clicks following the shared enlarged-surface rule in `family:navigation-destinations`. The Chevron and controls in Start content and End content keep their own activation, and a disabled Item does nothing. | Owner decision; `family:navigation-destinations` | Settled intent; implementation may be staged | -| FR8 | `TreeList` MUST accept `animatedLayout?: AnimatedLayout`. With a handle, expanding and collapsing a row report the row's size change through it, so the content after the row glides inside the handle's container, and the shown rows and the row's Chevron move on the same motion (`spec:AST-105` FR1–FR4; AST-108 FR4a). Without one, expanding and collapsing are unchanged. The prop MUST NOT change tree semantics or keyboard behavior. | `spec:AST-105` FR1, FR2 | Current behavior | +| FR8 | `TreeList` MUST accept `animatedLayout?: AnimatedLayout`. With a handle, expanding and collapsing a row report the row's size change through it, so the content after the row glides inside the handle's container, and the shown rows and the row's Chevron move on the same motion (`spec:AST-105` FR1–FR4). Without one, expanding and collapsing are unchanged. The prop MUST NOT change tree semantics or keyboard behavior. | `spec:AST-105` FR1, FR2 | Current behavior | ### Allowed variation From 186bd96ba9d9251e2046a92820b3490db172c7a9 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 16:48:56 -0700 Subject: [PATCH 35/43] fix(core): the walk reads each element once a frame, stops at the band both ways, and skips positioned ancestors CodeBunny on #8634: - Every report re-walked every change already in the frame, so 100 changes in one frame read 592,325 rectangles. A frame's walks now share one cache of rectangles and boxes, the visible part is read once, and the band at least doubles when the frame's changes outgrow it, so earlier changes are walked again only a few times. Test: 100 changes over 200 rows read each row at most once (17 s and failing on the previous head). - Each level's walk now stops at the first sibling outside the band in either direction, as INV12 says, so a change outside the band walks no further than its neighbours. Test: a change at row 900 of 1,000 reads at most 3 elements. - An absolutely positioned, fixed or top-layer ancestor of the change takes its final place, as the Coverage rows say (they glided 80 px). Chromium cases for an absolute and a fixed ancestor. - The Coverage row's order case gets a Chromium test beside the centered, space-between and end-aligned rows. --- ...animatedLayout.interaction.browser.spec.ts | 41 +++++++ .../motion/animatedLayout.boundary.test.ts | 2 +- .../core/src/motion/animatedLayout.test.ts | 50 +++++++++ packages/core/src/motion/animatedLayout.ts | 100 +++++++++++++----- 4 files changed, 165 insertions(+), 28 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 87b599ff7639e..9d1804d56537d 100644 --- a/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts +++ b/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts @@ -668,6 +668,47 @@ test.describe('spec:AST-105 FR2–FR4', () => { }); } + test('an earlier sibling that order places after the change glides from its old place (spec:AST-105 Coverage)', async ({ + page, + }) => { + await open( + page, + `
    +
    +
    +
    `, + ); + await change(page, { + changed: '#changed', + mutate: `document.querySelector('#changed').style.width = '140px'`, + }); + const [motion] = await page.evaluate(() => window.motionOf('#ordered')); + expect(motion).toBeDefined(); + // First in the DOM, laid out after the change: it starts 80 px back. + expect(startOffset(motion.first).x).toBeCloseTo(-80, 0); + }); + + for (const position of ['absolute', 'fixed']) { + test(`a ${position} ancestor of the change takes its final place (spec:AST-105 Coverage)`, async ({ + page, + }) => { + await open( + page, + `
    +
    +
    +
    +
    `, + ); + await change(page); + // Anchored at its bottom, it grows upward: its top moves, yet it never + // glides. + expect(await page.evaluate(() => window.motionOf('#anchored'))).toEqual( + [], + ); + }); + } + test('in a centered column the card around the change glides with the content before it, so nothing slides over the opening content (spec:AST-105 Coverage)', async ({ page, }) => { diff --git a/packages/core/src/motion/animatedLayout.boundary.test.ts b/packages/core/src/motion/animatedLayout.boundary.test.ts index 9b727a8ca22e5..5a57a0608e75c 100644 --- a/packages/core/src/motion/animatedLayout.boundary.test.ts +++ b/packages/core/src/motion/animatedLayout.boundary.test.ts @@ -83,7 +83,7 @@ describe('architecture:react-component-runtime INV12 source boundary', () => { expect(descents).toHaveLength(3); const contentsDescents = body.match( - /display === 'contents'\)\s*\{\s*for\s*\(\s*let child =[^;]*\.(first|last)ElementChild/g, + /(?:display|part) === 'contents'\)\s*\{\s*for\s*\(\s*let child =[^;]*\.(first|last)ElementChild/g, ) ?? []; expect(contentsDescents).toHaveLength(2); // Each level's sibling walk ends at the first sibling past the band. diff --git a/packages/core/src/motion/animatedLayout.test.ts b/packages/core/src/motion/animatedLayout.test.ts index 1040d97d0fd11..07551008b4674 100644 --- a/packages/core/src/motion/animatedLayout.test.ts +++ b/packages/core/src/motion/animatedLayout.test.ts @@ -865,6 +865,56 @@ describe('the walk reaches what the changes can move (spec:AST-105 FR3)', () => expect(reads.has('r20')).toBe(false); }); + it('reads each element once however many changes report in one frame', () => { + const rows = Array.from({length: 200}, (_, i) => `
    `); + document.body.innerHTML = `
    ${rows.join('')}
    `; + const area = document.getElementById('area') as HTMLElement; + vi.spyOn(area, 'getBoundingClientRect').mockReturnValue( + new DOMRect(0, 0, 400, 6000), + ); + let reads = 0; + rows.forEach((_, i) => { + vi.spyOn( + document.getElementById(`r${i}`) as HTMLElement, + 'getBoundingClientRect', + ).mockImplementation(() => { + reads += 1; + return new DOMRect(0, i * 30, 400, 30); + }); + }); + const layout = createAnimatedLayout(() => area); + // A tree collapsing 100 rows in one update reports 100 changes. + for (let i = 0; i < 100; i += 1) { + reportLayoutChange(layout, () => + document.getElementById(`r${i}`), + ).measureBefore(); + } + expect(reads).toBeLessThanOrEqual(rows.length); + }); + + it('walks no further than the first sibling outside the band from a change outside it', () => { + const rows = Array.from({length: 1000}, (_, i) => `
    `); + document.body.innerHTML = `
    ${rows.join('')}
    `; + const area = document.getElementById('area') as HTMLElement; + vi.spyOn(area, 'getBoundingClientRect').mockReturnValue( + new DOMRect(0, 0, 400, 600), + ); + const reads = new Set(); + rows.forEach((_, i) => { + const row = document.getElementById(`r${i}`) as HTMLElement; + vi.spyOn(row, 'getBoundingClientRect').mockImplementation(() => { + reads.add(row.id); + return new DOMRect(0, i * 100, 400, 100); + }); + }); + reportLayoutChange( + createAnimatedLayout(() => area), + () => document.getElementById('r900'), + ).measureBefore(); + // The change itself, and one sibling on each side, which lie outside. + expect(reads.size).toBeLessThanOrEqual(3); + }); + it('stops inside a display: contents wrapper at the band too', () => { const rows = Array.from({length: 40}, (_, i) => `
    `); const {area, changed} = setUp( diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index e5903930611dd..21644897c33bd 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -335,12 +335,42 @@ interface Band { readonly end: number; } -function bandFor(area: Element, changedSize: number): Band { - const visible = visibleRect(area); - const reach = Math.max(visible.height, changedSize); - const start = visible.top - reach; - const end = visible.bottom + reach; - return {start, end}; +function bandFor(visible: DOMRect, reach: number): Band { + return {start: visible.top - reach, end: visible.bottom + reach}; +} + +/** + * What one frame's walks read, kept so a level two changes share, or a walk + * repeated when the band grows, reads each element once. + */ +interface WalkCache { + readonly rects: Map; + /** Whether the element takes part, or `contents` for a boxless element. */ + readonly parts: Map; +} + +function partOf(element: Element, cache: WalkCache): boolean | 'contents' { + let part = cache.parts.get(element); + if (part === undefined) { + const style = element.ownerDocument.defaultView?.getComputedStyle(element); + part = + style == null + ? false + : style.display === 'contents' + ? 'contents' + : takesPart(element, style); + cache.parts.set(element, part); + } + return part; +} + +function rectOf(element: Element, cache: WalkCache): DOMRect { + let rect = cache.rects.get(element); + if (rect === undefined) { + rect = element.getBoundingClientRect(); + cache.rects.set(element, rect); + } + return rect; } /** @@ -358,16 +388,13 @@ function collectCandidates( changed: Element, area: Element, band: Band, + cache: WalkCache, ): Map { - const view = changed.ownerDocument.defaultView; const out = new Map(); - /** Takes `element`; returns false once it lies past the band. */ + /** Takes `element`; returns false once it lies outside the band. */ const take = (element: Element, isAfter: boolean): boolean => { - const style = view?.getComputedStyle(element); - if (style == null) { - return true; - } - if (style.display === 'contents') { + const part = partOf(element, cache); + if (part === 'contents') { for ( let child = isAfter ? element.firstElementChild @@ -383,11 +410,11 @@ function collectCandidates( } return true; } - if (!takesPart(element, style)) { + if (!part) { return true; } - const rect = element.getBoundingClientRect(); - if (isAfter ? rect.top > band.end : rect.bottom < band.start) { + const rect = rectOf(element, cache); + if (rect.top > band.end || rect.bottom < band.start) { return false; } out.set(element, rect); @@ -398,10 +425,11 @@ function collectCandidates( node != null && node !== area; node = node.parentElement ) { - if (node !== changed) { - // An ancestor of the change: where its layout moves it (a centered - // column), it glides, and what is inside moves only by its own change. - out.set(node, node.getBoundingClientRect()); + if (node !== changed && partOf(node, cache) === true) { + // An ancestor of the change in flow: where its layout moves it (a + // centered column), it glides, and what is inside moves only by its own + // change. A positioned or top-layer ancestor takes its final place. + out.set(node, rectOf(node, cache)); } for ( let sibling = node.nextElementSibling; @@ -433,6 +461,11 @@ interface Batch { readonly candidates: Map; /** The changed elements' sizes before the change, added up. */ changedSize: number; + /** How far past the visible part the walks reach (0 before the first). */ + reach: number; + /** The area's visible part, read once per frame. */ + visible: DOMRect | null; + readonly cache: WalkCache; scheduled: boolean; } @@ -447,6 +480,9 @@ function batchFor(area: Element): Batch { before: new Map(), candidates: new Map(), changedSize: 0, + reach: 0, + visible: null, + cache: {rects: new Map(), parts: new Map()}, scheduled: false, }; batches.set(area, created); @@ -689,15 +725,25 @@ export function reportLayoutChange( return; } const batch = batchFor(area); - if (!batch.changed.has(element)) { - batch.changed.add(element); - batch.changedSize += element.getBoundingClientRect().height; + if (batch.changed.has(element)) { + return; } + batch.changed.add(element); + batch.changedSize += rectOf(element, batch.cache).height; + batch.visible ??= visibleRect(area); // Changes in one frame add up: every change's walk reaches the band - // their summed size can pull into view. - const band = bandFor(area, batch.changedSize); - for (const changed of batch.changed) { - const candidates = collectCandidates(changed, area, band); + // their summed size can pull into view. When that outgrows the band + // walked so far, the band at least doubles and every change is walked + // again (from the cache), so a frame of many changes re-walks a few + // times, not once per change. + const needed = Math.max(batch.visible.height, batch.changedSize); + const walks = needed > batch.reach ? [...batch.changed] : [element]; + if (needed > batch.reach) { + batch.reach = Math.max(needed, batch.reach * 2); + } + const band = bandFor(batch.visible, batch.reach); + for (const changed of walks) { + const candidates = collectCandidates(changed, area, band, batch.cache); for (const [candidate, rect] of candidates) { if (!batch.before.has(candidate)) { batch.before.set(candidate, rect); From 774399987c14d0f8aec2708bff61f4664dafa056 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 16:50:39 -0700 Subject: [PATCH 36/43] fix(core): an exit always guesses and checks where the content after it lands CodeBunny on #8639: the direct-margin path read where the content after a closing element goes once and set the margin from it, but its test (endMarginMovesLinearly) accepted any column flex box and looked only at the parent and the next box. In a centered or space-between column, or under a disclosure that ends a column inside a flex row, the content after the element stopped short (20, 49 and 100 px) and then jumped when the element hid. The path is gone: every exit takes the guess and the measured correction, and falls back when anything checked misses. Chromium cases: in block flow, a gap column, a centered column, a space-between column and a column that ends a flex row, the content after an exit lands where it goes once the element hides, and does not move when it does. --- ...animatedLayout.interaction.browser.spec.ts | 58 +++++++------ .../core/src/motion/animatedLayout.test.ts | 13 +-- packages/core/src/motion/animatedLayout.ts | 83 +------------------ 3 files changed, 37 insertions(+), 117 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 e6c4268adc0b6..ca3f0324a7ec7 100644 --- a/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts +++ b/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts @@ -800,60 +800,70 @@ test.describe('spec:AST-105 FR2–FR4', () => { expect(follower[0].duration).toBeLessThan(1200); }); - for (const parent of [ - {name: 'block flow', style: 'display: block'}, + for (const layout of [ + { + name: 'block flow', + html: `
    Details
    `, + }, { name: 'a column flex box with a gap', - style: 'display: flex; flex-direction: column; gap: 12px', + html: `
    Details
    `, + }, + { + name: 'a centered column', + html: `
    Details
    `, + }, + { + name: 'a space-between column', + html: `
    Details
    `, + }, + { + name: 'a column that ends a flex row', + html: `
    Details
    `, }, ]) { - test(`an exit in ${parent.name} reads where the content after it goes once, and lands it exactly (FR4)`, async ({ + test(`an exit in ${layout.name} lands the content after it exactly where it goes once hidden (FR4)`, async ({ page, }) => { await open( page, - `
    -
    Details
    -
    `, + `
    ${layout.html}
    `, ); const result = await page.evaluate(async () => { const area = document.querySelector('#area'); const content = document.querySelector('#content') as HTMLElement; const a = document.querySelector('#a') as HTMLElement; - const layout = window.AnimatedLayout.createAnimatedLayout(() => area); + const handle = window.AnimatedLayout.createAnimatedLayout(() => area); const report = window.AnimatedLayout.reportLayoutChange( - layout, + handle, () => content, ); report.measureBefore(); report.animate(); - // Where the row lands once the content is gone. + // Where the content after it lands once the element is gone. content.style.display = 'none'; const target = a.getBoundingClientRect().top; content.style.display = ''; - const original = a.getBoundingClientRect; - let reads = 0; - a.getBoundingClientRect = function getBoundingClientRect() { - reads += 1; - return original.call(this); - }; const held = window.AnimatedLayout.holdLayoutExit( - layout, + handle, content, () => {}, ); - const holdReads = reads; - a.getBoundingClientRect = original; await new Promise(resolve => { queueMicrotask(resolve); }); a.getAnimations().forEach(animation => animation.finish()); - return {held, holdReads, landed: a.getBoundingClientRect().top, target}; + const landed = a.getBoundingClientRect().top; + // When the element hides, nothing after it moves again. + content.style.display = 'none'; + window.AnimatedLayout.releaseLayoutExit(content); + const hidden = a.getBoundingClientRect().top; + return {held, landed, hidden, target}; }); - expect(result.held).toBe(true); - // Where it is and where it goes: no guess and no check. - expect(result.holdReads).toBe(2); - expect(Math.abs(result.landed - result.target)).toBeLessThan(0.5); + if (result.held) { + expect(Math.abs(result.landed - result.target)).toBeLessThan(0.5); + } + expect(Math.abs(result.hidden - result.target)).toBeLessThan(0.5); }); } diff --git a/packages/core/src/motion/animatedLayout.test.ts b/packages/core/src/motion/animatedLayout.test.ts index afb589caf27c4..2f7fa54e5e413 100644 --- a/packages/core/src/motion/animatedLayout.test.ts +++ b/packages/core/src/motion/animatedLayout.test.ts @@ -560,18 +560,7 @@ describe('the changed element exits (spec:AST-105 FR4)', () => { expect(content.style.pointerEvents).toBe(''); }); - it('reads where the content after it goes once, where no other margin collapses with its end margin', () => { - const {content, after, layout} = exitSetUp(); - const reads = vi.mocked(after.getBoundingClientRect); - reads.mockClear(); - expect(holdLayoutExit(layout, content, vi.fn())).toBe(true); - // Where it is now and where it goes: no guess and no check, so a long - // page is laid out twice fewer. - expect(reads).toHaveBeenCalledTimes(2); - expect(content.style.getPropertyValue('margin-block-end')).toBe('-88px'); - }); - - it('guesses and checks where a negative margin would collapse with its end margin', () => { + it('guesses, then corrects, where a negative margin would collapse with its end margin', () => { const {content, after, layout} = exitSetUp(); after.style.marginTop = '-4px'; const reads = vi.mocked(after.getBoundingClientRect); diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index 5dcb4cece40a2..6a1fcda3a91bd 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -843,74 +843,6 @@ function placeOf( return {block: rect.top, inline: rtl ? -rect.right : rect.left}; } -/** - * A computed length in px; NaN for anything else (`auto`). An unset value - * reads as 0: an engine without layout reports nothing for a margin. - */ -function lengthOf(value: string): number { - if (value === '') { - return 0; - } - return value.endsWith('px') ? Number.parseFloat(value) : Number.NaN; -} - -/** A computed keyword, with an engine's empty value read as its initial one. */ -function keywordOf(value: string, initial: string): string { - return value === '' ? initial : value; -} - -/** - * Whether a block-end margin on `element` moves `next` by exactly the - * margin: the element has no block margins of its own and does not float, - * and the margins it would collapse with (the next box's start, and its - * parent's end when it is the parent's last box) are not negative, so its - * own negative margin is the only negative one in the collapse. Its parent - * lays it out in block flow or a column flex box (where margins do not - * collapse). Style reads only: no layout. - */ -function endMarginMovesLinearly( - element: Element, - next: Element, - view: Window | null, -): boolean { - const parentElement = element.parentElement; - if (view == null || parentElement == null) { - return false; - } - const style = view.getComputedStyle(element); - const parent = view.getComputedStyle(parentElement); - const isColumn = - parent.display.includes('flex') && parent.flexDirection === 'column'; - const isBlockFlow = - parent.display === 'block' || - parent.display === 'flow-root' || - parent.display === 'list-item'; - if ( - (!isColumn && !isBlockFlow) || - keywordOf(style.float, 'none') !== 'none' || - keywordOf(style.writingMode, 'horizontal-tb') !== 'horizontal-tb' || - lengthOf(style.marginTop) !== 0 || - lengthOf(style.marginBottom) !== 0 - ) { - return false; - } - if (isColumn) { - return true; - } - const nextStyle = view.getComputedStyle(next); - if ( - keywordOf(nextStyle.float, 'none') !== 'none' || - keywordOf(nextStyle.clear, 'none') !== 'none' || - !(lengthOf(nextStyle.marginTop) >= 0) - ) { - return false; - } - // The last box of its parent: the parent's end margin joins the collapse. - return ( - next.parentElement === parentElement || lengthOf(parent.marginBottom) >= 0 - ); -} - /** * Keeps `element` where it is, in flow (so it scrolls and clips with its * container), while the content after it takes the place it takes once the @@ -947,9 +879,8 @@ function giveUpSpace(element: HTMLElement, area: Element): boolean { ? null : nextInFlow(element.parentElement, area); const checked = afterParent == null ? [next] : [next, afterParent]; - // Where the content after it is now, and the element's scale, read in the - // layout the caller's visibility check already laid out. - const current = placeOf(next, rtl); + // The element's scale, read in the layout the caller's visibility check + // already laid out. const scale = scaleOf(element, element.getBoundingClientRect()); // 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 @@ -967,16 +898,6 @@ function giveUpSpace(element: HTMLElement, area: Element): boolean { // The reading is in screen px, the margin in the element's own: under a // scaled ancestor a distance is divided by the scale on this axis. const perPx = isRow ? scale.x : scale.y; - if (!isRow && endMarginMovesLinearly(element, next, view)) { - // The end margin moves the content after it by exactly its own change - // (no other margin collapses with it), so one reading of where that - // content goes is enough: no guess and no check, two layouts fewer. - element.style.setProperty( - property, - `${(target.block - current.block) / perPx}px`, - ); - return true; - } // 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`); From 9fadd7297ccb18b7a24493b4af2f3bb56be584cd Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 17:37:48 -0700 Subject: [PATCH 37/43] fix(core): without scroll anchoring a change above the band walks to it; inside a positioned ancestor only later content moves CodeBunny on #8634, with the owner's decision (a, scoped): - With the walk stopping at the first sibling outside the band, a change above the band never reached the rows in view, which shift and jumped where the scroller opts out of scroll anchoring (0 of 14 glided). There, the walk after a change above the band goes on to the band; with default anchoring the rows in view stay put and the walk still stops at once. INV12 states the cost. Chromium cases for both. - Inside a positioned or top-layer ancestor that takes its final place, the siblings before the change no longer glide (a Collapsible docked at the bottom had its trigger glide under the revealing content). Chromium case. - A Changeset names the earlier-sibling glide, and the adopters' Changeset says the content around a change glides, not only after it. Both new Chromium cases fail on the previous head. --- .changeset/animated-layout-adopters.md | 2 +- .../animated-layout-earlier-siblings.md | 7 +++ docs/architecture/react-component-runtime.md | 5 +- ...animatedLayout.interaction.browser.spec.ts | 54 +++++++++++++++++++ packages/core/src/motion/animatedLayout.ts | 52 ++++++++++++++++-- 5 files changed, 113 insertions(+), 7 deletions(-) create mode 100644 .changeset/animated-layout-earlier-siblings.md diff --git a/.changeset/animated-layout-adopters.md b/.changeset/animated-layout-adopters.md index 0c8e9ef99d48a..86d0e6ac1b8fa 100644 --- a/.changeset/animated-layout-adopters.md +++ b/.changeset/animated-layout-adopters.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[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, the revealed content reveals, closing content conceals before it hides, and the chevron turns, all on one shared motion, 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. A single-mode CollapsibleGroup glides every item it changes. 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 around it inside that container (after it, and before it where the layout centers or end-aligns it) glides to its new place instead of jumping, the revealed content reveals, closing content conceals before it hides, and the chevron turns, all on one shared motion, 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. A single-mode CollapsibleGroup glides every item it changes. Without the prop, nothing changes. @cixzhang diff --git a/.changeset/animated-layout-earlier-siblings.md b/.changeset/animated-layout-earlier-siblings.md new file mode 100644 index 0000000000000..dfb3af4f920bc --- /dev/null +++ b/.changeset/animated-layout-earlier-siblings.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] With an `animatedLayout` handle, content before a Collapsible or TreeList change glides too where the layout moves it: an item before the change in a centered, end-aligned or space-between row, a sibling `order` places after it, and the card around a change in a centered column. A positioned ancestor of the change, and the content before the change inside it, take their final place at once. + +@cixzhang diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index aab2666aeddcf..31229b6e554a7 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -181,7 +181,10 @@ those contracts. sibling outside the band the changes in a frame can bring into view (the area's visible part plus one viewport, or the changed elements' summed size if larger, above and below), so its cost grows with depth plus the - siblings near the visible part, never with the length of a list. Changes in one area in the same frame are measured together. A + siblings near the visible part, never with the length of a list. Where the + scroll container opts out of scroll anchoring, a change above the band + walks forward to the band; that cost grows with the distance. Changes in + one area in the same frame are measured together. A component reports a change through an internal, non-exported function that takes the `useAnimatedLayout` handle, so the handle stays member-free, and each moving element is one the page already renders 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 9d1804d56537d..ca2ba17a4efe5 100644 --- a/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts +++ b/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts @@ -709,6 +709,60 @@ test.describe('spec:AST-105 FR2–FR4', () => { }); } + test('inside a positioned ancestor the siblings before the change take their final place (spec:AST-105 Coverage)', async ({ + page, + }) => { + await open( + page, + `
    +
    +
    +
    +
    `, + ); + await change(page); + // Docked at the bottom, the trigger rises as the content opens below it, + // but takes its place at once, so the revealing content never covers it. + expect(await page.evaluate(() => window.motionOf('#trigger'))).toEqual([]); + }); + + for (const anchoring of ['auto', 'none']) { + test(`a change above the band with scroll anchoring ${anchoring === 'auto' ? 'on' : 'off'}: ${anchoring === 'auto' ? 'the rows in view stay put and the walk stops' : 'the rows in view glide'} (FR3)`, async ({ + page, + }) => { + await open( + page, + `
    ${Array.from( + {length: 1000}, + (_, i) => `
    `, + ).join('')}
    `, + ); + const result = await page.evaluate(async () => { + const area = document.querySelector('#area') as HTMLElement; + area.scrollTop = 3600 + 600; + const firstInView = document.querySelector('#r95') as HTMLElement; + const before = firstInView.getBoundingClientRect().top; + await window.change({ + changed: '#grow', + mutate: `document.querySelector('#grow').classList.add('open')`, + }); + const moving = [...area.children].filter( + row => row.getAnimations().length > 0, + ).length; + document.getAnimations().forEach(animation => animation.finish()); + return {moving, before, after: firstInView.getBoundingClientRect().top}; + }); + if (anchoring === 'auto') { + // The browser keeps the rows in view where they were: nothing moves. + expect(result.moving).toBe(0); + expect(Math.abs(result.after - result.before)).toBeLessThan(1); + } else { + // They shift down 80 px, and glide there. + expect(result.moving).toBeGreaterThanOrEqual(12); + } + }); + } + test('in a centered column the card around the change glides with the content before it, so nothing slides over the opening content (spec:AST-105 Coverage)', async ({ page, }) => { diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index 21644897c33bd..3304873270902 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -380,15 +380,19 @@ function rectOf(element: Element, cache: WalkCache): DOMRect { * after and before (a centered, end-aligned or space-between row, or * `order`, moves those too). A `display: contents` element has no box: its * children stand in its place, walked in the same direction. At each level - * the walk stops at the first sibling past `band` (the siblings after it are - * further along), so its cost grows with depth plus the siblings near the - * visible part, never with the length of a list. + * the walk stops at the first sibling outside `band`, so its cost grows with + * depth plus the siblings near the visible part, never with the length of a + * list; where the area opts out of scroll anchoring (`walksToBand`), the walk + * after a change above the band goes on to the band, a cost that grows with + * that distance. Inside a positioned or top-layer ancestor only the siblings + * after the change are taken. */ function collectCandidates( changed: Element, area: Element, band: Band, cache: WalkCache, + walksToBand: boolean, ): Map { const out = new Map(); /** Takes `element`; returns false once it lies outside the band. */ @@ -414,17 +418,39 @@ function collectCandidates( return true; } const rect = rectOf(element, cache); - if (rect.top > band.end || rect.bottom < band.start) { + if (rect.top > band.end) { return false; } + if (rect.bottom < band.start) { + // Above the band. Where the scroller opts out of scroll anchoring, the + // content after a change above the band shifts the rows in view, so + // the walk after it goes on to the band; with anchoring, it stops. + return isAfter && walksToBand; + } out.set(element, rect); return true; }; + // The change's chain up to the area, and from where it is inside a + // positioned or top-layer ancestor: that ancestor takes its final place, + // so inside it only the content after the change moves (the siblings + // before it stay, as the content revealing beside them does). + const chain: Element[] = []; for ( let node: Element | null = changed; node != null && node !== area; node = node.parentElement ) { + chain.push(node); + } + // The levels below this index are inside a positioned ancestor. + let positionedFrom = 0; + for (let index = chain.length - 1; index > 0; index -= 1) { + if (partOf(chain[index], cache) === false) { + positionedFrom = index; + break; + } + } + for (const [level, node] of chain.entries()) { if (node !== changed && partOf(node, cache) === true) { // An ancestor of the change in flow: where its layout moves it (a // centered column), it glides, and what is inside moves only by its own @@ -438,6 +464,9 @@ function collectCandidates( ) { // The siblings after it, until one lies past the band. } + if (level < positionedFrom) { + continue; + } for ( let sibling = node.previousElementSibling; sibling != null && take(sibling, false); @@ -465,6 +494,8 @@ interface Batch { reach: number; /** The area's visible part, read once per frame. */ visible: DOMRect | null; + /** Whether the area opts out of scroll anchoring, read once per frame. */ + walksToBand: boolean | null; readonly cache: WalkCache; scheduled: boolean; } @@ -482,6 +513,7 @@ function batchFor(area: Element): Batch { changedSize: 0, reach: 0, visible: null, + walksToBand: null, cache: {rects: new Map(), parts: new Map()}, scheduled: false, }; @@ -731,6 +763,10 @@ export function reportLayoutChange( batch.changed.add(element); batch.changedSize += rectOf(element, batch.cache).height; batch.visible ??= visibleRect(area); + batch.walksToBand ??= + area.ownerDocument.defaultView + ?.getComputedStyle(area) + .getPropertyValue('overflow-anchor') === 'none'; // Changes in one frame add up: every change's walk reaches the band // their summed size can pull into view. When that outgrows the band // walked so far, the band at least doubles and every change is walked @@ -743,7 +779,13 @@ export function reportLayoutChange( } const band = bandFor(batch.visible, batch.reach); for (const changed of walks) { - const candidates = collectCandidates(changed, area, band, batch.cache); + const candidates = collectCandidates( + changed, + area, + band, + batch.cache, + batch.walksToBand, + ); for (const [candidate, rect] of candidates) { if (!batch.before.has(candidate)) { batch.before.set(candidate, rect); From 73d7b43706b3f26d26c6954cc97a5d62e4ddf7a4 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 18:19:51 -0700 Subject: [PATCH 38/43] fix(core): inside a positioned, sticky or top-layer ancestor only the content after the change moves CodeBunny on #8634: - A stuck sticky footer around an opening Collapsible glided off the area's bottom edge, while the Sticky row and FR7 keep stuck elements put. A sticky ancestor now takes its final place like a positioned one. - Inside such an ancestor the siblings before the change stayed but the wrapper around it glided, opening a gap under a header in a docked panel. One rule now applies to ancestors and siblings alike: inside a positioned, sticky or top-layer ancestor only the content after the change moves; that ancestor, the wrappers inside it and the content before the change take their final place. A new AST-105 Coverage row states it; the Changeset says the same. Chromium cases: a header and a card around the change in a bottom-docked panel, and a bottom-stuck sticky footer; both fail on the previous head. --- .../animated-layout-earlier-siblings.md | 2 +- docs/specs/AST-105-animated-layout/spec.md | 33 ++++++++++--------- ...animatedLayout.interaction.browser.spec.ts | 31 ++++++++++++++--- packages/core/src/motion/animatedLayout.ts | 25 +++++++++----- 4 files changed, 61 insertions(+), 30 deletions(-) diff --git a/.changeset/animated-layout-earlier-siblings.md b/.changeset/animated-layout-earlier-siblings.md index dfb3af4f920bc..467f2df05d33f 100644 --- a/.changeset/animated-layout-earlier-siblings.md +++ b/.changeset/animated-layout-earlier-siblings.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[fix] With an `animatedLayout` handle, content before a Collapsible or TreeList change glides too where the layout moves it: an item before the change in a centered, end-aligned or space-between row, a sibling `order` places after it, and the card around a change in a centered column. A positioned ancestor of the change, and the content before the change inside it, take their final place at once. +[fix] With an `animatedLayout` handle, content before a Collapsible or TreeList change glides too where the layout moves it: an item before the change in a centered, end-aligned or space-between row, a sibling `order` places after it, and the card around a change in a centered column. Inside a positioned, sticky or top-layer ancestor of the change, only the content after the change moves: that ancestor, the wrappers inside it and the content before the change take their final place at once. @cixzhang diff --git a/docs/specs/AST-105-animated-layout/spec.md b/docs/specs/AST-105-animated-layout/spec.md index 4c6b4f92850da..be1ac9b7112d5 100644 --- a/docs/specs/AST-105-animated-layout/spec.md +++ b/docs/specs/AST-105-animated-layout/spec.md @@ -115,22 +115,23 @@ const animatedLayout = useAnimatedLayout({containerRef: scrollRef}); What moves inside an area, each case with its test: -| Case | Behavior | Test | -| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | -| Normal-flow block siblings after the changed element, at each ancestor level up to the area | Moves when its rectangle changed | Nested changed element with block siblings at three levels | -| An element with `display: contents` | Has no box; its children are taken in its place | A following `display: contents` wrapper with two children | -| Absolutely positioned or fixed elements | Skipped and take their final place; an absolute child of a moving element rides with it | One of each, plus an absolute badge on a moving row | -| Sticky elements | In flow while unstuck, so moves when its rectangle changed; a stuck element takes its final place | A sticky header above and below its stuck point | -| Floats | Moves when its rectangle changed | A float after the changed element | -| Flex and grid items after the changed element, including wrap and row direction | Moves when its rectangle changed | A wrapping flex row and a row-direction neighbor that does not shift | -| Siblings before the changed element, and its ancestors, at each level | Moves when its rectangle changed: a centered, end-aligned or space-between row, a centered column, or `order` among the siblings near the visible part moves them | A centered row, a space-between row, an end-aligned row, a centered column and an `order`-placed sibling | -| Hidden, `display: none`, or zero-size elements | Skipped | One of each after the changed element | -| Changed elements outside the area's visible part | Take their final place without animation | A long list with changed rows above and below the fold | -| Elements under a scaled or transformed ancestor | Moves by its layout change, corrected for the ancestor's scale | A moving row inside a scaled panel | -| Portals and top-layer content | Never moved | A popover open from a following row | -| Elements already moving | Keep their running motion and add the new move to it | Two changes 100 ms apart | -| Nested areas | Motion stops at the area of the handle the changed component holds; an inner area moves as one unit within an outer one | An area inside an area, changed in the inner one | -| Table rows after an opening or closing detail row | The rows glide; a closing detail row hides at once, and its space is held while the rows after it glide up. A table that draws column lines (`component:Table` FR15) does not glide: it and the content after it take their final places at once | A detail row opening and closing in a table with a pinned column, and in a table with column lines | +| Case | Behavior | Test | +| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | +| Normal-flow block siblings after the changed element, at each ancestor level up to the area | Moves when its rectangle changed | Nested changed element with block siblings at three levels | +| An element with `display: contents` | Has no box; its children are taken in its place | A following `display: contents` wrapper with two children | +| Absolutely positioned or fixed elements | Skipped and take their final place; an absolute child of a moving element rides with it | One of each, plus an absolute badge on a moving row | +| Content inside an absolutely positioned, fixed, sticky or top-layer ancestor of the change | Only the content after the change moves; that ancestor, the ancestors inside it and the siblings before the change take their final place | A header above a card around the change in a bottom-docked panel, and a bottom-stuck sticky footer around the change | +| Sticky elements | In flow while unstuck, so moves when its rectangle changed; a stuck element takes its final place | A sticky header above and below its stuck point | +| Floats | Moves when its rectangle changed | A float after the changed element | +| Flex and grid items after the changed element, including wrap and row direction | Moves when its rectangle changed | A wrapping flex row and a row-direction neighbor that does not shift | +| Siblings before the changed element, and its ancestors, at each level | Moves when its rectangle changed: a centered, end-aligned or space-between row, a centered column, or `order` among the siblings near the visible part moves them | A centered row, a space-between row, an end-aligned row, a centered column and an `order`-placed sibling | +| Hidden, `display: none`, or zero-size elements | Skipped | One of each after the changed element | +| Changed elements outside the area's visible part | Take their final place without animation | A long list with changed rows above and below the fold | +| Elements under a scaled or transformed ancestor | Moves by its layout change, corrected for the ancestor's scale | A moving row inside a scaled panel | +| Portals and top-layer content | Never moved | A popover open from a following row | +| Elements already moving | Keep their running motion and add the new move to it | Two changes 100 ms apart | +| Nested areas | Motion stops at the area of the handle the changed component holds; an inner area moves as one unit within an outer one | An area inside an area, changed in the inner one | +| Table rows after an opening or closing detail row | The rows glide; a closing detail row hides at once, and its space is held while the rows after it glide up. A table that draws column lines (`component:Table` FR15) does not glide: it and the content after it take their final places at once | A detail row opening and closing in a table with a pinned column, and in a table with column lines | Out of coverage, each taking its final place at once: 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 ca2ba17a4efe5..f071ddf1b90d3 100644 --- a/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts +++ b/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts @@ -709,21 +709,42 @@ test.describe('spec:AST-105 FR2–FR4', () => { }); } - test('inside a positioned ancestor the siblings before the change take their final place (spec:AST-105 Coverage)', async ({ + test('inside a positioned ancestor only the content after the change moves: the header before it and the card around it take their final place (spec:AST-105 Coverage)', async ({ page, }) => { await open( page, `
    -
    + +
    +
    `, ); await change(page); - // Docked at the bottom, the trigger rises as the content opens below it, - // but takes its place at once, so the revealing content never covers it. - expect(await page.evaluate(() => window.motionOf('#trigger'))).toEqual([]); + // Docked at the bottom, the panel rises as the content opens, and + // everything around the change takes its place at once with it, so no + // gap opens and the revealing content never covers what is above it. + for (const id of ['#header', '#card', '#trigger']) { + expect(await page.evaluate(s => window.motionOf(s), id), id).toEqual([]); + } + }); + + test('a stuck sticky footer around the change stays at the area edge (spec:AST-105 Coverage, FR7)', async ({ + page, + }) => { + await open( + page, + `
    ${Array.from( + {length: 30}, + () => '
    ', + ).join( + '', + )}
    `, + ); + await change(page); + expect(await page.evaluate(() => window.motionOf('#footer'))).toEqual([]); }); for (const anchoring of ['auto', 'none']) { diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index 3304873270902..4c94d0650f28d 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -384,8 +384,8 @@ function rectOf(element: Element, cache: WalkCache): DOMRect { * depth plus the siblings near the visible part, never with the length of a * list; where the area opts out of scroll anchoring (`walksToBand`), the walk * after a change above the band goes on to the band, a cost that grows with - * that distance. Inside a positioned or top-layer ancestor only the siblings - * after the change are taken. + * that distance. Inside a positioned, sticky or top-layer ancestor only the + * content after the change is taken. */ function collectCandidates( changed: Element, @@ -431,9 +431,9 @@ function collectCandidates( return true; }; // The change's chain up to the area, and from where it is inside a - // positioned or top-layer ancestor: that ancestor takes its final place, - // so inside it only the content after the change moves (the siblings - // before it stay, as the content revealing beside them does). + // positioned, sticky or top-layer ancestor: that ancestor takes its final + // place, so inside it only the content after the change moves; the + // ancestors and the siblings before the change stay with it. const chain: Element[] = []; for ( let node: Element | null = changed; @@ -444,17 +444,26 @@ function collectCandidates( } // The levels below this index are inside a positioned ancestor. let positionedFrom = 0; + const view = changed.ownerDocument.defaultView; for (let index = chain.length - 1; index > 0; index -= 1) { - if (partOf(chain[index], cache) === false) { + if ( + partOf(chain[index], cache) === false || + view?.getComputedStyle(chain[index]).position === 'sticky' + ) { positionedFrom = index; break; } } for (const [level, node] of chain.entries()) { - if (node !== changed && partOf(node, cache) === true) { + if ( + node !== changed && + level > positionedFrom && + partOf(node, cache) === true + ) { // An ancestor of the change in flow: where its layout moves it (a // centered column), it glides, and what is inside moves only by its own - // change. A positioned or top-layer ancestor takes its final place. + // change. A positioned, sticky or top-layer ancestor, and every + // ancestor inside it, takes its final place. out.set(node, rectOf(node, cache)); } for ( From 60d3e00f3d37b7f52c393e422d94fc88ad4c4991 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 19:03:23 -0700 Subject: [PATCH 39/43] fix(core): an ancestor moves by its layout change alone, not the area's scroll; only a stuck sticky ancestor stays put CodeBunny on #8634: - When a change lands above the visible part, scroll anchoring re-scrolls the area, and every recorded ancestor moved by that scroll, so rows in view inside a wrapper slid 51 px (or faded, under Reduce Motion on a touch screen). The area's scroll change is now taken out of an ancestor's move. Chromium: the wrapped scroll-anchoring case, with and without Reduce Motion; the plain one fails on the previous head. - Every sticky ancestor took its final place, stuck or not, so in a centered column the row before an unstuck sticky card glided across it. Only a stuck sticky ancestor (resting against the area's edge at its inset) stays put now; an unstuck one is in flow. The Coverage row and the Changeset say 'stuck sticky'. Chromium case; it fails on the previous head. --- .../animated-layout-earlier-siblings.md | 2 +- docs/specs/AST-105-animated-layout/spec.md | 34 +++++----- ...animatedLayout.interaction.browser.spec.ts | 62 +++++++++++++++++++ packages/core/src/motion/animatedLayout.ts | 51 ++++++++++++--- 4 files changed, 124 insertions(+), 25 deletions(-) diff --git a/.changeset/animated-layout-earlier-siblings.md b/.changeset/animated-layout-earlier-siblings.md index 467f2df05d33f..a16e6bca2bd48 100644 --- a/.changeset/animated-layout-earlier-siblings.md +++ b/.changeset/animated-layout-earlier-siblings.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[fix] With an `animatedLayout` handle, content before a Collapsible or TreeList change glides too where the layout moves it: an item before the change in a centered, end-aligned or space-between row, a sibling `order` places after it, and the card around a change in a centered column. Inside a positioned, sticky or top-layer ancestor of the change, only the content after the change moves: that ancestor, the wrappers inside it and the content before the change take their final place at once. +[fix] With an `animatedLayout` handle, content before a Collapsible or TreeList change glides too where the layout moves it: an item before the change in a centered, end-aligned or space-between row, a sibling `order` places after it, and the card around a change in a centered column. Inside a positioned, stuck sticky or top-layer ancestor of the change, only the content after the change moves: that ancestor, the wrappers inside it and the content before the change take their final place at once. @cixzhang diff --git a/docs/specs/AST-105-animated-layout/spec.md b/docs/specs/AST-105-animated-layout/spec.md index be1ac9b7112d5..b8046fe95b2a0 100644 --- a/docs/specs/AST-105-animated-layout/spec.md +++ b/docs/specs/AST-105-animated-layout/spec.md @@ -115,23 +115,23 @@ const animatedLayout = useAnimatedLayout({containerRef: scrollRef}); What moves inside an area, each case with its test: -| Case | Behavior | Test | -| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | -| Normal-flow block siblings after the changed element, at each ancestor level up to the area | Moves when its rectangle changed | Nested changed element with block siblings at three levels | -| An element with `display: contents` | Has no box; its children are taken in its place | A following `display: contents` wrapper with two children | -| Absolutely positioned or fixed elements | Skipped and take their final place; an absolute child of a moving element rides with it | One of each, plus an absolute badge on a moving row | -| Content inside an absolutely positioned, fixed, sticky or top-layer ancestor of the change | Only the content after the change moves; that ancestor, the ancestors inside it and the siblings before the change take their final place | A header above a card around the change in a bottom-docked panel, and a bottom-stuck sticky footer around the change | -| Sticky elements | In flow while unstuck, so moves when its rectangle changed; a stuck element takes its final place | A sticky header above and below its stuck point | -| Floats | Moves when its rectangle changed | A float after the changed element | -| Flex and grid items after the changed element, including wrap and row direction | Moves when its rectangle changed | A wrapping flex row and a row-direction neighbor that does not shift | -| Siblings before the changed element, and its ancestors, at each level | Moves when its rectangle changed: a centered, end-aligned or space-between row, a centered column, or `order` among the siblings near the visible part moves them | A centered row, a space-between row, an end-aligned row, a centered column and an `order`-placed sibling | -| Hidden, `display: none`, or zero-size elements | Skipped | One of each after the changed element | -| Changed elements outside the area's visible part | Take their final place without animation | A long list with changed rows above and below the fold | -| Elements under a scaled or transformed ancestor | Moves by its layout change, corrected for the ancestor's scale | A moving row inside a scaled panel | -| Portals and top-layer content | Never moved | A popover open from a following row | -| Elements already moving | Keep their running motion and add the new move to it | Two changes 100 ms apart | -| Nested areas | Motion stops at the area of the handle the changed component holds; an inner area moves as one unit within an outer one | An area inside an area, changed in the inner one | -| Table rows after an opening or closing detail row | The rows glide; a closing detail row hides at once, and its space is held while the rows after it glide up. A table that draws column lines (`component:Table` FR15) does not glide: it and the content after it take their final places at once | A detail row opening and closing in a table with a pinned column, and in a table with column lines | +| Case | Behavior | Test | +| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | +| Normal-flow block siblings after the changed element, at each ancestor level up to the area | Moves when its rectangle changed | Nested changed element with block siblings at three levels | +| An element with `display: contents` | Has no box; its children are taken in its place | A following `display: contents` wrapper with two children | +| Absolutely positioned or fixed elements | Skipped and take their final place; an absolute child of a moving element rides with it | One of each, plus an absolute badge on a moving row | +| Content inside an absolutely positioned, fixed, stuck sticky or top-layer ancestor of the change | Only the content after the change moves; that ancestor, the ancestors inside it and the siblings before the change take their final place | A header above a card around the change in a bottom-docked panel, and a bottom-stuck sticky footer around the change | +| Sticky elements | In flow while unstuck, so moves when its rectangle changed; a stuck element takes its final place | A sticky header above and below its stuck point | +| Floats | Moves when its rectangle changed | A float after the changed element | +| Flex and grid items after the changed element, including wrap and row direction | Moves when its rectangle changed | A wrapping flex row and a row-direction neighbor that does not shift | +| Siblings before the changed element, and its ancestors, at each level | Moves when its rectangle changed: a centered, end-aligned or space-between row, a centered column, or `order` among the siblings near the visible part moves them | A centered row, a space-between row, an end-aligned row, a centered column and an `order`-placed sibling | +| Hidden, `display: none`, or zero-size elements | Skipped | One of each after the changed element | +| Changed elements outside the area's visible part | Take their final place without animation | A long list with changed rows above and below the fold | +| Elements under a scaled or transformed ancestor | Moves by its layout change, corrected for the ancestor's scale | A moving row inside a scaled panel | +| Portals and top-layer content | Never moved | A popover open from a following row | +| Elements already moving | Keep their running motion and add the new move to it | Two changes 100 ms apart | +| Nested areas | Motion stops at the area of the handle the changed component holds; an inner area moves as one unit within an outer one | An area inside an area, changed in the inner one | +| Table rows after an opening or closing detail row | The rows glide; a closing detail row hides at once, and its space is held while the rows after it glide up. A table that draws column lines (`component:Table` FR15) does not glide: it and the content after it take their final places at once | A detail row opening and closing in a table with a pinned column, and in a table with column lines | Out of coverage, each taking its final place at once: 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 f071ddf1b90d3..10145d8240622 100644 --- a/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts +++ b/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts @@ -747,6 +747,44 @@ test.describe('spec:AST-105 FR2–FR4', () => { expect(await page.evaluate(() => window.motionOf('#footer'))).toEqual([]); }); + for (const reduce of [false, true]) { + test(`a change above the visible part inside a wrapper: with scroll anchoring the rows in view and their wrapper stay put${reduce ? ' under Reduce Motion' : ''} (FR3)`, async ({ + page, + }) => { + if (reduce) { + await page.emulateMedia({reducedMotion: 'reduce'}); + } + await open( + page, + `
    ${Array.from( + {length: 1000}, + (_, i) => `
    `, + ).join('')}
    `, + ); + const result = await page.evaluate(async () => { + const area = document.querySelector('#area') as HTMLElement; + area.scrollTop = 4200; + const inView = document.querySelector('#r95') as HTMLElement; + const before = inView.getBoundingClientRect().top; + await window.change({ + changed: '#grow', + mutate: `document.querySelector('#grow').classList.add('open')`, + }); + const wrapper = document.querySelector('#wrapper') as HTMLElement; + const moving = [wrapper, ...wrapper.children].filter( + element => element.getAnimations().length > 0, + ).length; + const shown = inView.getBoundingClientRect().top; + document.getAnimations().forEach(animation => animation.finish()); + return {moving, before, shown}; + }); + // The browser keeps the rows in view where they were; nothing glides + // or fades, the wrapper around them included. + expect(result.moving).toBe(0); + expect(Math.abs(result.shown - result.before)).toBeLessThan(1); + }); + } + for (const anchoring of ['auto', 'none']) { test(`a change above the band with scroll anchoring ${anchoring === 'auto' ? 'on' : 'off'}: ${anchoring === 'auto' ? 'the rows in view stay put and the walk stops' : 'the rows in view glide'} (FR3)`, async ({ page, @@ -784,6 +822,30 @@ test.describe('spec:AST-105 FR2–FR4', () => { }); } + test('in a centered column an unstuck sticky card around the change glides with the content before it (spec:AST-105 Coverage)', async ({ + page, + }) => { + await open( + page, + `
    +
    +
    +
    +
    `, + ); + await change(page); + const [above] = await page.evaluate(() => window.motionOf('#above')); + const [card] = await page.evaluate(() => window.motionOf('#card')); + // Not stuck, the card is in flow: it rises with the row above it, so + // that row never crosses it. + expect(above).toBeDefined(); + expect(card).toBeDefined(); + expect(startOffset(card.first).y).toBeCloseTo( + startOffset(above.first).y, + 0, + ); + }); + test('in a centered column the card around the change glides with the content before it, so nothing slides over the opening content (spec:AST-105 Coverage)', async ({ page, }) => { diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index 4c94d0650f28d..d028aa616b2a9 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -364,6 +364,26 @@ function partOf(element: Element, cache: WalkCache): boolean | 'contents' { return part; } +/** + * Whether a sticky element is stuck: resting against the area's edge at its + * inset. A stuck element takes its final place; an unstuck one is in flow. + */ +function isStuck(element: Element, area: Element, cache: WalkCache): boolean { + const style = element.ownerDocument.defaultView?.getComputedStyle(element); + if (style?.position !== 'sticky') { + return false; + } + const rect = rectOf(element, cache); + const edge = rectOf(area, cache); + const top = Number.parseFloat(style.top); + const bottom = Number.parseFloat(style.bottom); + return ( + (Number.isFinite(top) && Math.abs(rect.top - (edge.top + top)) < 1) || + (Number.isFinite(bottom) && + Math.abs(rect.bottom - (edge.bottom - bottom)) < 1) + ); +} + function rectOf(element: Element, cache: WalkCache): DOMRect { let rect = cache.rects.get(element); if (rect === undefined) { @@ -384,7 +404,7 @@ function rectOf(element: Element, cache: WalkCache): DOMRect { * depth plus the siblings near the visible part, never with the length of a * list; where the area opts out of scroll anchoring (`walksToBand`), the walk * after a change above the band goes on to the band, a cost that grows with - * that distance. Inside a positioned, sticky or top-layer ancestor only the + * that distance. Inside a positioned, stuck sticky or top-layer ancestor only the * content after the change is taken. */ function collectCandidates( @@ -393,6 +413,7 @@ function collectCandidates( band: Band, cache: WalkCache, walksToBand: boolean, + ancestors: Set, ): Map { const out = new Map(); /** Takes `element`; returns false once it lies outside the band. */ @@ -431,7 +452,7 @@ function collectCandidates( return true; }; // The change's chain up to the area, and from where it is inside a - // positioned, sticky or top-layer ancestor: that ancestor takes its final + // positioned, stuck sticky or top-layer ancestor: that ancestor takes its final // place, so inside it only the content after the change moves; the // ancestors and the siblings before the change stay with it. const chain: Element[] = []; @@ -444,11 +465,10 @@ function collectCandidates( } // The levels below this index are inside a positioned ancestor. let positionedFrom = 0; - const view = changed.ownerDocument.defaultView; for (let index = chain.length - 1; index > 0; index -= 1) { if ( partOf(chain[index], cache) === false || - view?.getComputedStyle(chain[index]).position === 'sticky' + isStuck(chain[index], area, cache) ) { positionedFrom = index; break; @@ -462,9 +482,10 @@ function collectCandidates( ) { // An ancestor of the change in flow: where its layout moves it (a // centered column), it glides, and what is inside moves only by its own - // change. A positioned, sticky or top-layer ancestor, and every + // change. A positioned, stuck sticky or top-layer ancestor, and every // ancestor inside it, takes its final place. out.set(node, rectOf(node, cache)); + ancestors.add(node); } for ( let sibling = node.nextElementSibling; @@ -505,6 +526,10 @@ interface Batch { visible: DOMRect | null; /** Whether the area opts out of scroll anchoring, read once per frame. */ walksToBand: boolean | null; + /** The change's ancestors among the candidates. */ + readonly ancestors: Set; + /** The area's scroll offset before the change, read once per frame. */ + scrollBefore: {readonly x: number; readonly y: number} | null; readonly cache: WalkCache; scheduled: boolean; } @@ -523,6 +548,8 @@ function batchFor(area: Element): Batch { reach: 0, visible: null, walksToBand: null, + ancestors: new Set(), + scrollBefore: null, cache: {rects: new Map(), parts: new Map()}, scheduled: false, }; @@ -649,10 +676,18 @@ function flush(batch: Batch): void { entries.push({element, before, carried, carriedMs}); } const byElement = new Map(); + // An ancestor of the change moves by its layout change alone: when scroll + // anchoring re-scrolls the area (a change above the visible part), its + // place in view shifts by the scroll while the content in view stays. + const scrolledX = (batch.scrollBefore?.x ?? 0) - batch.area.scrollLeft; + const scrolledY = (batch.scrollBefore?.y ?? 0) - batch.area.scrollTop; for (const entry of entries) { entry.after = entry.element.getBoundingClientRect(); - entry.rawX = entry.before.left - entry.after.left; - entry.rawY = entry.before.top - entry.after.top; + const isAncestor = batch.ancestors.has(entry.element); + entry.rawX = + entry.before.left - entry.after.left + (isAncestor ? scrolledX : 0); + entry.rawY = + entry.before.top - entry.after.top + (isAncestor ? scrolledY : 0); byElement.set(entry.element, entry); } for (const entry of entries) { @@ -772,6 +807,7 @@ export function reportLayoutChange( batch.changed.add(element); batch.changedSize += rectOf(element, batch.cache).height; batch.visible ??= visibleRect(area); + batch.scrollBefore ??= {x: area.scrollLeft, y: area.scrollTop}; batch.walksToBand ??= area.ownerDocument.defaultView ?.getComputedStyle(area) @@ -794,6 +830,7 @@ export function reportLayoutChange( band, batch.cache, batch.walksToBand, + batch.ancestors, ); for (const [candidate, rect] of candidates) { if (!batch.before.has(candidate)) { From 71abedd20eff8be74ccc5f58789df1dca4a31838 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 19:39:55 -0700 Subject: [PATCH 40/43] fix(core): an ancestor nets out any scroll of the area's content; stuck reads the scrollport; a column-flow level walks through CodeBunny on #8634: - Only the area's own scroll was netted out of an ancestor's move, so in a panel the page scrolls, the page's scroll anchoring made the ancestors glide and drew the rows in view 51 px off. An ancestor now moves by its place against the area's scrolled content, which nets out a scroll of the area, an outer scroller or the page. - A sticky element was judged stuck against the area's edges; it sticks to its scrollport (the nearest scrolling ancestor, or the viewport), so a footer stuck to the viewport bottom glided 51 px below it. It is judged against its scrollport now. - In a multi-column list (or a wrapping column flex box, or a column-flow grid), DOM order does not run down the page, so the band stop left a later column's rows to jump. A level laid out across the block direction now runs through its siblings; INV12 states that cost. Chromium cases: a page-scrolled panel with a wrapper, a viewport-stuck footer in a page-scrolled panel, and a 120-row two-column list. All three fail on the previous head. --- docs/architecture/react-component-runtime.md | 5 +- ...animatedLayout.interaction.browser.spec.ts | 77 +++++++++++++ packages/core/src/motion/animatedLayout.ts | 105 ++++++++++++++---- 3 files changed, 167 insertions(+), 20 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 31229b6e554a7..c0886e147b040 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -183,7 +183,10 @@ those contracts. if larger, above and below), so its cost grows with depth plus the siblings near the visible part, never with the length of a list. Where the scroll container opts out of scroll anchoring, a change above the band - walks forward to the band; that cost grows with the distance. Changes in + walks forward to the band; that cost grows with the distance. In a + container whose children run across the block direction (multi-column, a + wrapping column flex box, or a column-flow grid), the walk runs through + that level, a cost that grows with its children. Changes in one area in the same frame are measured together. A component reports a change through an internal, non-exported function that takes the `useAnimatedLayout` handle, so the handle stays member-free, and 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 10145d8240622..3c542e814c9b7 100644 --- a/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts +++ b/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts @@ -785,6 +785,83 @@ test.describe('spec:AST-105 FR2–FR4', () => { }); } + test('a change above the view in a panel the page scrolls: with the page anchoring its scroll, the rows in view and their wrapper stay put (FR3)', async ({ + page, + }) => { + await open( + page, + `
    ${Array.from( + {length: 1000}, + (_, i) => `
    `, + ).join('')}
    `, + ); + const result = await page.evaluate(async () => { + window.scrollTo(0, 4200); + const inView = document.querySelector('#r95') as HTMLElement; + const before = inView.getBoundingClientRect().top; + await window.change({ + changed: '#grow', + mutate: `document.querySelector('#grow').classList.add('open')`, + }); + const wrapper = document.querySelector('#wrapper') as HTMLElement; + const moving = [wrapper, ...wrapper.children].filter( + element => element.getAnimations().length > 0, + ).length; + const shown = inView.getBoundingClientRect().top; + document.getAnimations().forEach(animation => animation.finish()); + return {moving, before, shown}; + }); + expect(result.moving).toBe(0); + expect(Math.abs(result.shown - result.before)).toBeLessThan(1); + }); + + test('a sticky footer stuck to the viewport in a panel the page scrolls stays at the edge (FR7)', async ({ + page, + }) => { + await open( + page, + `
    ${Array.from( + {length: 60}, + () => '
    ', + ).join( + '', + )}${Array.from( + {length: 60}, + () => '
    ', + ).join('')}
    `, + ); + await page.evaluate(() => window.scrollTo(0, 200)); + await change(page); + expect(await page.evaluate(() => window.motionOf('#footer'))).toEqual([]); + }); + + test('in a multi-column list the rows of a later column glide too (spec:AST-105 Coverage)', async ({ + page, + }) => { + await open( + page, + `
    ${[ + '
    ', + ...Array.from( + {length: 120}, + (_, i) => + `
    `, + ), + ].join('')}
    `, + ); + await change(page); + const moving = await page.evaluate( + () => + [...document.querySelectorAll('#list > .row')].filter( + row => row.getAnimations().length > 0, + ).length, + ); + // Every row shifts one slot along the columns; the rows in view glide, + // the second column's included, though they come past the band in DOM + // order. + expect(moving).toBeGreaterThanOrEqual(20); + }); + for (const anchoring of ['auto', 'none']) { test(`a change above the band with scroll anchoring ${anchoring === 'auto' ? 'on' : 'off'}: ${anchoring === 'auto' ? 'the rows in view stay put and the walk stops' : 'the rows in view glide'} (FR3)`, async ({ page, diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index d028aa616b2a9..5a44d8b1690fe 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -365,22 +365,73 @@ function partOf(element: Element, cache: WalkCache): boolean | 'contents' { } /** - * Whether a sticky element is stuck: resting against the area's edge at its - * inset. A stuck element takes its final place; an unstuck one is in flow. + * Whether a container lays its children out across the block direction, so + * DOM order does not run down the page: multi-column, a wrapping column flex + * box, or a grid that flows by column. The band says nothing about where its + * later children sit, so a walk there runs through the level. */ -function isStuck(element: Element, area: Element, cache: WalkCache): boolean { +function flowsAcross(container: Element | null): boolean { + if (container == null) { + return false; + } + const style = + container.ownerDocument.defaultView?.getComputedStyle(container); + if (style == null) { + return false; + } + const count = style.getPropertyValue('column-count'); + const width = style.getPropertyValue('column-width'); + return ( + (count !== '' && count !== 'auto') || + (width !== '' && width !== 'auto') || + (style.display.includes('flex') && + style.flexDirection.startsWith('column') && + style.flexWrap.startsWith('wrap')) || + (style.display.includes('grid') && + style.getPropertyValue('grid-auto-flow').startsWith('column')) + ); +} + +/** + * The box a sticky element sticks to: its nearest scrolling ancestor's, or + * the viewport. + */ +function scrollportOf(element: Element): {start: number; end: number} { + const view = element.ownerDocument.defaultView; + for ( + let node = element.parentElement; + node != null && node !== element.ownerDocument.documentElement; + node = node.parentElement + ) { + const overflow = view?.getComputedStyle(node).overflowY ?? 'visible'; + if (overflow !== 'visible' && overflow !== 'clip') { + const box = node.getBoundingClientRect(); + return { + start: box.top + node.clientTop, + end: box.top + node.clientTop + node.clientHeight, + }; + } + } + return {start: 0, end: view?.innerHeight ?? 0}; +} + +/** + * Whether a sticky element is stuck: resting against its scrollport's edge + * at its inset. A stuck element takes its final place; an unstuck one is in + * flow. + */ +function isStuck(element: Element, cache: WalkCache): boolean { const style = element.ownerDocument.defaultView?.getComputedStyle(element); if (style?.position !== 'sticky') { return false; } const rect = rectOf(element, cache); - const edge = rectOf(area, cache); + const edge = scrollportOf(element); const top = Number.parseFloat(style.top); const bottom = Number.parseFloat(style.bottom); return ( - (Number.isFinite(top) && Math.abs(rect.top - (edge.top + top)) < 1) || - (Number.isFinite(bottom) && - Math.abs(rect.bottom - (edge.bottom - bottom)) < 1) + (Number.isFinite(top) && Math.abs(rect.top - (edge.start + top)) < 1) || + (Number.isFinite(bottom) && Math.abs(rect.bottom - (edge.end - bottom)) < 1) ); } @@ -417,6 +468,8 @@ function collectCandidates( ): Map { const out = new Map(); /** Takes `element`; returns false once it lies outside the band. */ + /** The current level runs through its siblings, ignoring the band. */ + let runsThrough = false; const take = (element: Element, isAfter: boolean): boolean => { const part = partOf(element, cache); if (part === 'contents') { @@ -439,6 +492,10 @@ function collectCandidates( return true; } const rect = rectOf(element, cache); + if (runsThrough) { + out.set(element, rect); + return true; + } if (rect.top > band.end) { return false; } @@ -466,15 +523,13 @@ function collectCandidates( // The levels below this index are inside a positioned ancestor. let positionedFrom = 0; for (let index = chain.length - 1; index > 0; index -= 1) { - if ( - partOf(chain[index], cache) === false || - isStuck(chain[index], area, cache) - ) { + if (partOf(chain[index], cache) === false || isStuck(chain[index], cache)) { positionedFrom = index; break; } } for (const [level, node] of chain.entries()) { + runsThrough = flowsAcross(node.parentElement); if ( node !== changed && level > positionedFrom && @@ -528,8 +583,12 @@ interface Batch { walksToBand: boolean | null; /** The change's ancestors among the candidates. */ readonly ancestors: Set; - /** The area's scroll offset before the change, read once per frame. */ - scrollBefore: {readonly x: number; readonly y: number} | null; + /** + * Where the area's content sat before the change, read once per frame: the + * area's place in view less its scroll offset, so a scroll of the area, an + * outer scroller or the page shows up as a change of it. + */ + originBefore: {readonly x: number; readonly y: number} | null; readonly cache: WalkCache; scheduled: boolean; } @@ -549,7 +608,7 @@ function batchFor(area: Element): Batch { visible: null, walksToBand: null, ancestors: new Set(), - scrollBefore: null, + originBefore: null, cache: {rects: new Map(), parts: new Map()}, scheduled: false, }; @@ -585,6 +644,12 @@ function intersects(a: DOMRect, b: DOMRect): boolean { } /** The part of the area the person can see: its box within the viewport. */ +/** Where the area's scrolled content starts, in client px. */ +function originOf(area: Element): {x: number; y: number} { + const box = area.getBoundingClientRect(); + return {x: box.left - area.scrollLeft, y: box.top - area.scrollTop}; +} + function visibleRect(area: Element): DOMRect { const box = area.getBoundingClientRect(); const view = area.ownerDocument.defaultView; @@ -677,10 +742,12 @@ function flush(batch: Batch): void { } const byElement = new Map(); // An ancestor of the change moves by its layout change alone: when scroll - // anchoring re-scrolls the area (a change above the visible part), its - // place in view shifts by the scroll while the content in view stays. - const scrolledX = (batch.scrollBefore?.x ?? 0) - batch.area.scrollLeft; - const scrolledY = (batch.scrollBefore?.y ?? 0) - batch.area.scrollTop; + // anchoring re-scrolls the area, an outer scroller or the page (a change + // above the visible part), its place in view shifts by the scroll while + // the content in view stays. + const origin = originOf(batch.area); + const scrolledX = origin.x - (batch.originBefore?.x ?? origin.x); + const scrolledY = origin.y - (batch.originBefore?.y ?? origin.y); for (const entry of entries) { entry.after = entry.element.getBoundingClientRect(); const isAncestor = batch.ancestors.has(entry.element); @@ -807,7 +874,7 @@ export function reportLayoutChange( batch.changed.add(element); batch.changedSize += rectOf(element, batch.cache).height; batch.visible ??= visibleRect(area); - batch.scrollBefore ??= {x: area.scrollLeft, y: area.scrollTop}; + batch.originBefore ??= originOf(area); batch.walksToBand ??= area.ownerDocument.defaultView ?.getComputedStyle(area) From d2dfc6fa46c83489dfd640fb590bfafbefc9ecbd Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 20:23:07 -0700 Subject: [PATCH 41/43] fix(core): siblings before the change stay with their card when the area moves; the body is no scroller of its own CodeBunny on #8634: - When the area itself moves as its content grows (a dialog re-centered, a panel the page centers or docks), the card around the change took its final place while the siblings before the change glided by the area's move, drawing a trigger over the opening content. The siblings before the change now net out the same move as the ancestors around them. - With a non-visible overflow on body (overflow-x: hidden), the body read as a sticky footer's scroller, so a footer stuck to the viewport glided past the edge. The body's overflow applies to the viewport while the root's is visible, so the viewport is its scrollport then. Chromium cases for both; both fail on the previous head. --- ...animatedLayout.interaction.browser.spec.ts | 41 +++++++++++++++++++ packages/core/src/motion/animatedLayout.ts | 30 +++++++++++--- 2 files changed, 66 insertions(+), 5 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 3c542e814c9b7..1fd06e2597031 100644 --- a/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts +++ b/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts @@ -862,6 +862,47 @@ test.describe('spec:AST-105 FR2–FR4', () => { expect(moving).toBeGreaterThanOrEqual(20); }); + test('in a dialog re-centered as it grows, the header and trigger before the change stay with their card (FR3)', async ({ + page, + }) => { + await open( + page, + `
    +
    + +
    +
    +
    `, + ); + await change(page); + // The dialog rises by half the opening at once; what is before the + // change in it rises with it, never over the opening content. + for (const id of ['#header', '#card', '#trigger']) { + expect(await page.evaluate(s => window.motionOf(s), id), id).toEqual([]); + } + }); + + test('a sticky footer stuck to the viewport stays at the edge when the body sets an overflow (FR7)', async ({ + page, + }) => { + await open( + page, + `
    ${Array.from( + {length: 60}, + () => '
    ', + ).join( + '', + )}${Array.from( + {length: 60}, + () => '
    ', + ).join('')}
    `, + 'body { overflow-x: hidden; }', + ); + await page.evaluate(() => window.scrollTo(0, 200)); + await change(page); + expect(await page.evaluate(() => window.motionOf('#footer'))).toEqual([]); + }); + for (const anchoring of ['auto', 'none']) { test(`a change above the band with scroll anchoring ${anchoring === 'auto' ? 'on' : 'off'}: ${anchoring === 'auto' ? 'the rows in view stay put and the walk stops' : 'the rows in view glide'} (FR3)`, async ({ page, diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index 5a44d8b1690fe..99fcb99d925b8 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -404,6 +404,15 @@ function scrollportOf(element: Element): {start: number; end: number} { node = node.parentElement ) { const overflow = view?.getComputedStyle(node).overflowY ?? 'visible'; + if ( + node === element.ownerDocument.body && + view?.getComputedStyle(element.ownerDocument.documentElement) + .overflowY === 'visible' + ) { + // The body's overflow applies to the viewport while the root's is + // visible: the body is no scroller of its own. + continue; + } if (overflow !== 'visible' && overflow !== 'clip') { const box = node.getBoundingClientRect(); return { @@ -465,6 +474,7 @@ function collectCandidates( cache: WalkCache, walksToBand: boolean, ancestors: Set, + befores: Set, ): Map { const out = new Map(); /** Takes `element`; returns false once it lies outside the band. */ @@ -506,6 +516,9 @@ function collectCandidates( return isAfter && walksToBand; } out.set(element, rect); + if (!isAfter) { + befores.add(element); + } return true; }; // The change's chain up to the area, and from where it is inside a @@ -583,6 +596,8 @@ interface Batch { walksToBand: boolean | null; /** The change's ancestors among the candidates. */ readonly ancestors: Set; + /** The siblings before the change among the candidates. */ + readonly befores: Set; /** * Where the area's content sat before the change, read once per frame: the * area's place in view less its scroll offset, so a scroll of the area, an @@ -608,6 +623,7 @@ function batchFor(area: Element): Batch { visible: null, walksToBand: null, ancestors: new Set(), + befores: new Set(), originBefore: null, cache: {rects: new Map(), parts: new Map()}, scheduled: false, @@ -741,16 +757,19 @@ function flush(batch: Batch): void { entries.push({element, before, carried, carriedMs}); } const byElement = new Map(); - // An ancestor of the change moves by its layout change alone: when scroll - // anchoring re-scrolls the area, an outer scroller or the page (a change - // above the visible part), its place in view shifts by the scroll while - // the content in view stays. + // An ancestor of the change, and a sibling before it, move by their layout + // change alone: when scroll anchoring re-scrolls the area, an outer + // scroller or the page (a change above the visible part), or the area + // itself moves as its content grows (a dialog re-centered), their place in + // view shifts by that while their place in the area stays. const origin = originOf(batch.area); const scrolledX = origin.x - (batch.originBefore?.x ?? origin.x); const scrolledY = origin.y - (batch.originBefore?.y ?? origin.y); for (const entry of entries) { entry.after = entry.element.getBoundingClientRect(); - const isAncestor = batch.ancestors.has(entry.element); + // The siblings before the change stay with the ancestors around them. + const isAncestor = + batch.ancestors.has(entry.element) || batch.befores.has(entry.element); entry.rawX = entry.before.left - entry.after.left + (isAncestor ? scrolledX : 0); entry.rawY = @@ -898,6 +917,7 @@ export function reportLayoutChange( batch.cache, batch.walksToBand, batch.ancestors, + batch.befores, ); for (const [candidate, rect] of candidates) { if (!batch.before.has(candidate)) { From 51d65e7b3d2b64e654ef14c4ed291b21e1d0476c Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 21:13:12 -0700 Subject: [PATCH 42/43] fix(core): the scroll correction applies only where it makes a move smaller; grid placement from past the band is out of coverage CodeBunny on #8634: - With two changes in one frame (a group closing an item above the view and opening one in view), the scroll anchoring one change caused was added to the siblings before the other change, so rows the browser kept in place glided from 344 px. An ancestor or an earlier sibling now takes the scroll and area correction only where it makes its move smaller. Chromium case; it fails on the previous head. - A grid item that grid placement brings into view from past the band in DOM order is not reached, as with order. AST-105's out-of-coverage list names grid placement beside order (owner approval needed). --- docs/specs/AST-105-animated-layout/spec.md | 7 ++-- ...animatedLayout.interaction.browser.spec.ts | 40 +++++++++++++++++++ packages/core/src/motion/animatedLayout.ts | 13 ++++-- 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/docs/specs/AST-105-animated-layout/spec.md b/docs/specs/AST-105-animated-layout/spec.md index b8046fe95b2a0..ab2d560499e82 100644 --- a/docs/specs/AST-105-animated-layout/spec.md +++ b/docs/specs/AST-105-animated-layout/spec.md @@ -146,9 +146,10 @@ Out of coverage, each taking its final place at once: takes its final place at once, other than a table detail row (Coverage); - an anchored popover tied to a moving element, which layer anchoring keeps in place (`architecture:layer-runtime`); -- an item that `order` brings into view from beyond the area's visible part - plus one viewport (or the changes' size, if larger) in document order, - which the walk does not reach. +- an item that `order`, or a grid placement (`grid-row`, `grid-column`, a + named area or dense packing), brings into view from beyond the area's + visible part plus one viewport (or the changes' size, if larger) in + document order, which the walk does not reach. ## Current-state impact 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 1fd06e2597031..c7870455d8d6d 100644 --- a/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts +++ b/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts @@ -903,6 +903,46 @@ test.describe('spec:AST-105 FR2–FR4', () => { expect(await page.evaluate(() => window.motionOf('#footer'))).toEqual([]); }); + test('closing an item above the view while opening one in view, in one frame: the rows the browser keeps in place stay put (FR3)', async ({ + page, + }) => { + await open( + page, + `
    ${Array.from( + {length: 30}, + (_, i) => + i === 14 + ? '
    ' + : `
    `, + ).join('')}
    `, + ); + const result = await page.evaluate(async () => { + const area = document.querySelector('#area') as HTMLElement; + area.scrollTop = 395; + const layout = window.AnimatedLayout.createAnimatedLayout(() => area); + const shut = window.AnimatedLayout.reportLayoutChange(layout, () => + document.querySelector('#shut'), + ); + const grow = window.AnimatedLayout.reportLayoutChange(layout, () => + document.querySelector('#grow'), + ); + shut.measureBefore(); + grow.measureBefore(); + (document.querySelector('#shut') as HTMLElement).style.height = '0px'; + document.querySelector('#grow')?.classList.add('open'); + shut.animate(); + grow.animate(); + await new Promise(resolve => { + queueMicrotask(resolve); + }); + // The rows in view before the opening one: anchoring keeps them still. + return [5, 6, 7, 8, 9].map( + i => document.querySelector(`#q${i}`)?.getAnimations().length ?? -1, + ); + }); + expect(result).toEqual([0, 0, 0, 0, 0]); + }); + for (const anchoring of ['auto', 'none']) { test(`a change above the band with scroll anchoring ${anchoring === 'auto' ? 'on' : 'off'}: ${anchoring === 'auto' ? 'the rows in view stay put and the walk stops' : 'the rows in view glide'} (FR3)`, async ({ page, diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index 99fcb99d925b8..eb352ea370ec9 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -765,15 +765,20 @@ function flush(batch: Batch): void { const origin = originOf(batch.area); const scrolledX = origin.x - (batch.originBefore?.x ?? origin.x); const scrolledY = origin.y - (batch.originBefore?.y ?? origin.y); + // Netted only where that makes the move smaller: an element the browser + // kept in place (scroll anchoring for another change in the same frame) + // stays where it is. + const nearer = (raw: number, shift: number) => + Math.abs(raw + shift) < Math.abs(raw) ? raw + shift : raw; for (const entry of entries) { entry.after = entry.element.getBoundingClientRect(); + const rawX = entry.before.left - entry.after.left; + const rawY = entry.before.top - entry.after.top; // The siblings before the change stay with the ancestors around them. const isAncestor = batch.ancestors.has(entry.element) || batch.befores.has(entry.element); - entry.rawX = - entry.before.left - entry.after.left + (isAncestor ? scrolledX : 0); - entry.rawY = - entry.before.top - entry.after.top + (isAncestor ? scrolledY : 0); + entry.rawX = isAncestor ? nearer(rawX, scrolledX) : rawX; + entry.rawY = isAncestor ? nearer(rawY, scrolledY) : rawY; byElement.set(entry.element, entry); } for (const entry of entries) { From 9012c844f2a2533da589d751462078776a1fb2ee Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 22:24:09 -0700 Subject: [PATCH 43/43] chore(core): a perf Changeset for the flush's read order; the space-between exit case has a row after the landing one CodeBunny on #8639: core source changed without a Changeset (the lint check's coverage step). A [perf] patch Changeset names it. The space-between browser case could not fail with the landing row last in the column; a row after it makes it exercise the exit's landing. --- .changeset/animated-layout-exit-reads.md | 7 +++++++ .../__tests__/animatedLayout.interaction.browser.spec.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 .changeset/animated-layout-exit-reads.md diff --git a/.changeset/animated-layout-exit-reads.md b/.changeset/animated-layout-exit-reads.md new file mode 100644 index 0000000000000..e46c2fc95fccf --- /dev/null +++ b/.changeset/animated-layout-exit-reads.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[perf] With an `animatedLayout` handle, a Collapsible or TreeList change in a long list reads every row's place before any glide starts, so the page is laid out once rather than once per gliding row. + +@cixzhang 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 97a4f92a577ce..861ae64b1b43b 100644 --- a/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts +++ b/packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts @@ -1151,7 +1151,7 @@ test.describe('spec:AST-105 FR2–FR4', () => { }, { name: 'a space-between column', - html: `
    Details
    `, + html: `
    Details
    `, }, { name: 'a column that ends a flex row',