From 5b0abfba644a398962e129e1491d82f027ee428b Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 20:09:23 -0700 Subject: [PATCH 01/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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 685ad78a9128035920ce6a018c6ecef194827b27 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 22:21:14 -0700 Subject: [PATCH 34/58] fix(core): a pinned table column draws its own row dividers With collapsed borders the table paints every border at its cell's static place. A sticky cell moves and its border does not, so the line under a pinned column was whichever scrolled cell's border passed beneath it, and went missing where that cell drew none. The pinned cell's ::before, already the row overlay's layer, now reaches the bottom of the cell's border area, covers what the table painted there with the pinned base, and draws the divider itself. The row divider reads three custom properties, --table-row-divider-width, -style and -color, on body and header cells alike; the real border and the pinned divider read the same ones, so a theme sets them in one place. An app that styles a cell's or row's border-bottom directly is read once on mount and on resize (the collapsed winner of the cell's and row's border) and the pinned divider takes it. Static rendering is pixel-identical to before at rest and scrolled. Tests: a cross-browser spec checks the pinned divider matches its row at rest and scrolled, survives a scrolled cell with no border, and carries a theme's color and an app's row border; a story shows both. --- .changeset/table-pinned-dividers.md | 5 + .../stories/TableStickyColumns.stories.tsx | 36 +++- packages/core/src/Table/TableCell.tsx | 16 +- .../core/src/Table/TableHeaderCell.test.tsx | 16 +- packages/core/src/Table/TableHeaderCell.tsx | 9 +- .../TableStickyDividers.a11y.browser.spec.ts | 173 ++++++++++++++++++ .../stickyColumns/useTableStickyColumns.tsx | 130 ++++++++++++- 7 files changed, 371 insertions(+), 14 deletions(-) create mode 100644 .changeset/table-pinned-dividers.md create mode 100644 packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts diff --git a/.changeset/table-pinned-dividers.md b/.changeset/table-pinned-dividers.md new file mode 100644 index 0000000000000..847bd47a28f80 --- /dev/null +++ b/.changeset/table-pinned-dividers.md @@ -0,0 +1,5 @@ +--- +'@astryxdesign/core': patch +--- + +Table: a pinned column (`useTableStickyColumns`) now draws its own row dividers, so the line under it no longer goes missing while the table scrolls horizontally. Row dividers read `--table-row-divider-width`, `--table-row-divider-style` and `--table-row-divider-color`, which a theme or an app can set on the table, a row or a cell; pinned cells follow them, and an app's own `border-bottom` on a row or cell. diff --git a/apps/storybook/stories/TableStickyColumns.stories.tsx b/apps/storybook/stories/TableStickyColumns.stories.tsx index 6780e7f255fb3..3b813637862bb 100644 --- a/apps/storybook/stories/TableStickyColumns.stories.tsx +++ b/apps/storybook/stories/TableStickyColumns.stories.tsx @@ -1,6 +1,6 @@ // Copyright (c) Meta Platforms, Inc. and affiliates. -import {useState} from 'react'; +import React, {useState} from 'react'; import type {Meta, StoryObj} from '@storybook/react'; import { Table, @@ -138,6 +138,40 @@ export const PinStart: Story = { }, }; +/** + * A pinned column draws each row's divider itself, so the line under it moves + * with the column. A theme's divider color (`--table-row-divider-color`, set + * on the wrapper here) and an app's own `border-bottom` on a row (the second + * row) both carry over to the pinned cells. + */ +export const PinnedDividers: Story = { + render: () => { + const sticky = useTableStickyColumns({startKeys: ['name']}); + return ( +
    + +

    + A blue theme divider on every row and an app's red 2 px border on + the second row, both on the pinned Name column as you scroll. +

    + + + ); + }, +}; + /** * Pin the trailing `Status` column to the end edge. */ diff --git a/packages/core/src/Table/TableCell.tsx b/packages/core/src/Table/TableCell.tsx index f5a716e59b810..d66e496330307 100644 --- a/packages/core/src/Table/TableCell.tsx +++ b/packages/core/src/Table/TableCell.tsx @@ -143,19 +143,25 @@ const contextMenuCellStyles = stylex.create({ }, }); +// The row divider reads three custom properties a theme or an app can set on +// the table, a row or a cell (`--table-row-divider-width`, `-style`, `-color`). +// Anything that draws a stand-in for this border (a pinned cell's divider) +// reads the same properties, so the two never disagree. const dividerRowStyles = stylex.create({ cell: { - borderBottomWidth: { - default: borderVars['--border-width'], + // The width this cell actually draws, for a stand-in to match. + '--_table-divider-width': { + default: `var(--table-row-divider-width, ${borderVars['--border-width']})`, // Skip border on cells in the last body row to avoid a // redundant line at the bottom of the table. // Scoped to tableRowMarker so only the parent is checked — // without the scope, (also a :last-child) would match // and suppress borders on every row. - [stylex.when.ancestor(':last-child', tableRowMarker)]: '0', + [stylex.when.ancestor(':last-child', tableRowMarker)]: '0px', }, - borderBottomStyle: 'solid', - borderBottomColor: colorVars['--color-border'], + borderBottomWidth: 'var(--_table-divider-width)', + borderBottomStyle: 'var(--table-row-divider-style, solid)', + borderBottomColor: `var(--table-row-divider-color, ${colorVars['--color-border']})`, }, }); diff --git a/packages/core/src/Table/TableHeaderCell.test.tsx b/packages/core/src/Table/TableHeaderCell.test.tsx index baa3f34006079..16b72edb64251 100644 --- a/packages/core/src/Table/TableHeaderCell.test.tsx +++ b/packages/core/src/Table/TableHeaderCell.test.tsx @@ -172,9 +172,19 @@ describe('TableHeaderCell', () => { ); const declarations = headerCellDeclarations(container); // The header/body separator is unconditional inside a table. - expect(declarations).toContain('border-bottom-width: var(--border-width)'); - expect(declarations).toContain('border-bottom-style: solid'); - expect(declarations).toContain('border-bottom-color: var(--color-border)'); + // It reads the row-divider custom properties, defaulting to the tokens. + expect(declarations).toContain( + '--_table-divider-width: var(--table-row-divider-width,var(--border-width))', + ); + expect(declarations).toContain( + 'border-bottom-width: var(--_table-divider-width)', + ); + expect(declarations).toContain( + 'border-bottom-style: var(--table-row-divider-style,solid)', + ); + expect(declarations).toContain( + 'border-bottom-color: var(--table-row-divider-color,var(--color-border))', + ); // …but the column divider is not. expect(declarations.filter(d => d.includes('border-inline-end'))).toEqual( [], diff --git a/packages/core/src/Table/TableHeaderCell.tsx b/packages/core/src/Table/TableHeaderCell.tsx index 6d1670223dd21..9d1549246bdcf 100644 --- a/packages/core/src/Table/TableHeaderCell.tsx +++ b/packages/core/src/Table/TableHeaderCell.tsx @@ -81,11 +81,14 @@ const headerStyles = stylex.create({ }, }); +// The header's divider reads the same custom properties as a body row's +// (see TableCell.tsx), so a theme sets every row divider in one place. const headerDividerStyles = stylex.create({ cell: { - borderBottomWidth: borderVars['--border-width'], - borderBottomStyle: 'solid', - borderBottomColor: colorVars['--color-border'], + '--_table-divider-width': `var(--table-row-divider-width, ${borderVars['--border-width']})`, + borderBottomWidth: 'var(--_table-divider-width)', + borderBottomStyle: 'var(--table-row-divider-style, solid)', + borderBottomColor: `var(--table-row-divider-color, ${colorVars['--color-border']})`, }, }); diff --git a/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts b/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts new file mode 100644 index 0000000000000..f01ffcfea480e --- /dev/null +++ b/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts @@ -0,0 +1,173 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// @pr-a11y scope ^packages/core/src/Table/ ^apps/storybook/stories/TableStickyColumns + +/** + * @file TableStickyDividers.a11y.browser.spec.ts + * @input Built TableStickyColumns stories in real Chromium and WebKit + * @output Browser evidence that a pinned column draws each row's divider + * itself: the line under it moves with it, matches the row's own divider, + * and carries a theme's divider color and an app's row border + * @position Regression proof for the sticky-columns plugin's pinned dividers + * + * With collapsed borders the table paints every border at its cell's static + * place. A sticky cell moves and its border does not, so the line under a + * pinned column used to be whichever scrolled cell's border passed beneath it, + * and was missing where that cell had none. + */ + +import {expect, test, type Page} from '@playwright/test'; +import { + DEFAULT_STORYBOOK_DIR, + serveStorybook, + type StaticServer, +} from '@astryxdesign/a11y-spec/storybook'; + +let storybook: StaticServer; +test.beforeAll(async () => { + storybook = await serveStorybook( + process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR, + ); +}); +test.afterAll(async () => { + await storybook?.close(); +}); + +async function mount(page: Page, story: string) { + await page.goto( + `${storybook.origin}/iframe.html?id=core-tablestickycolumns--${story}&viewMode=story`, + ); + const wrapper = page.locator('.astryx-table-scroll-wrapper').first(); + await expect(wrapper).toBeVisible(); + return wrapper; +} + +async function scrollTo(page: Page, left: number) { + await page.evaluate(x => { + const wrapper = document.querySelector( + '.astryx-table-scroll-wrapper', + ) as HTMLElement; + wrapper.scrollLeft = x; + }, left); + // Two frames: the scroll lands and paints. + await page.evaluate( + () => + new Promise(resolve => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); +} + +type Rgb = [number, number, number]; + +/** + * The darkest pixel within 3 px of a body row's bottom edge, at an x inside + * the pinned cell and at an x inside the first unpinned cell visible past it. + */ +async function dividerPixels( + page: Page, + row: number, +): Promise<{pinned: Rgb; unpinned: Rgb}> { + const points = await page.evaluate(index => { + const rows = document.querySelectorAll( + '.astryx-table-scroll-wrapper tbody tr', + ); + const tr = rows[index] as HTMLElement; + const pinned = tr.children[0].getBoundingClientRect(); + return { + y: Math.round(pinned.bottom), + pinnedX: Math.round(pinned.left + pinned.width / 2), + unpinnedX: Math.round(pinned.right + 40), + }; + }, row); + const sample = async (x: number): Promise => { + const shot = await page.screenshot({ + clip: {x, y: points.y - 3, width: 1, height: 6}, + scale: 'css', + }); + return page.evaluate(async b64 => { + const image = new Image(); + image.src = `data:image/png;base64,${b64}`; + await image.decode(); + const canvas = document.createElement('canvas'); + canvas.width = image.width; + canvas.height = image.height; + const context = canvas.getContext('2d')!; + context.drawImage(image, 0, 0); + const data = context.getImageData(0, 0, image.width, image.height).data; + let best: [number, number, number] = [255, 255, 255]; + for (let i = 0; i < data.length; i += 4) { + if (data[i] + data[i + 1] + data[i + 2] < best[0] + best[1] + best[2]) { + best = [data[i], data[i + 1], data[i + 2]]; + } + } + return best; + }, shot.toString('base64')); + }; + return { + pinned: await sample(points.pinnedX), + unpinned: await sample(points.unpinnedX), + }; +} + +const near = (a: Rgb, b: Rgb, tolerance = 6) => + a.every((channel, i) => Math.abs(channel - b[i]) <= tolerance); + +test('the divider under a pinned cell matches its row, at rest and scrolled', async ({ + page, +}) => { + await mount(page, 'pin-start'); + for (const left of [0, 150]) { + await scrollTo(page, left); + for (const row of [0, 1, 2]) { + const {pinned, unpinned} = await dividerPixels(page, row); + expect(near(pinned, unpinned), `row ${row} at ${left}px`).toBe(true); + expect(pinned[0], `row ${row} at ${left}px is drawn`).toBeLessThan(250); + } + } +}); + +test('a pinned cell keeps its divider where the cell scrolled beneath it has none', async ({ + page, +}) => { + await mount(page, 'pin-start'); + await scrollTo(page, 150); + const before = await dividerPixels(page, 1); + await page.evaluate(() => { + const row = document.querySelectorAll( + '.astryx-table-scroll-wrapper tbody tr', + )[1]; + const pinned = row.children[0].getBoundingClientRect(); + for (const cell of Array.from(row.children).slice(1)) { + const rect = cell.getBoundingClientRect(); + if (rect.left <= pinned.right && rect.right >= pinned.left) { + (cell as HTMLElement).style.borderBottomColor = 'transparent'; + } + } + }); + const after = await dividerPixels(page, 1); + expect(near(after.pinned, before.pinned)).toBe(true); +}); + +test("a theme's divider color and an app's row border carry over to the pinned cells", async ({ + page, +}) => { + await mount(page, 'pinned-dividers'); + for (const left of [0, 150]) { + await scrollTo(page, left); + // Row 1: the theme's blue divider, under the pinned cell as elsewhere. + const themed = await dividerPixels(page, 0); + expect(near(themed.pinned, themed.unpinned, 24), `themed at ${left}px`).toBe( + true, + ); + expect(themed.pinned[2], `themed at ${left}px is blue`).toBeGreaterThan( + themed.pinned[0] + 60, + ); + // Row 2: the app's red row border wins the collapse; the pinned cell + // draws it too. + const app = await dividerPixels(page, 1); + expect(near(app.pinned, app.unpinned, 24), `app at ${left}px`).toBe(true); + expect(app.pinned[0], `app at ${left}px is red`).toBeGreaterThan( + app.pinned[2] + 60, + ); + } +}); diff --git a/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx b/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx index 9b900de6e0015..02e9fff4bb0d9 100644 --- a/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +++ b/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx @@ -159,6 +159,23 @@ function resolveStickySide( const SHADOW_VAR_START = '--table-sticky-shadow-start'; const SHADOW_VAR_END = '--table-sticky-shadow-end'; +// A pinned cell draws its own row divider. With collapsed borders the table +// paints every border at its cell's static place: a sticky cell moves, its +// border does not, so without this the line under a pinned column would be +// whichever scrolled cell's border passes beneath it, and missing where that +// cell has none. The cell's ::before (already the row overlay's layer) extends +// over the cell's border area, covers whatever the table painted there with +// the pinned base, and draws the divider with the same width, style and color +// the cell's own border reads (TableCell.tsx). +const PINNED_ATTRIBUTE = 'data-astryx-table-pinned'; +/** Spread into a pinned cell's props: the attribute the divider sync finds. */ +const PINNED_PROPS = {[PINNED_ATTRIBUTE]: ''} as Record; +const PINNED_DIVIDER_WIDTH = '--_table-pinned-divider-width'; +const PINNED_DIVIDER_STYLE = '--_table-pinned-divider-style'; +const PINNED_DIVIDER_COLOR = '--_table-pinned-divider-color'; +const PINNED_BASE = `var(--table-sticky-background, ${colorVars['--color-background-card']})`; +const DIVIDER_WIDTH = `var(${PINNED_DIVIDER_WIDTH}, var(--_table-divider-width, 0px))`; + const stickyStyles = stylex.create({ cell: { position: 'sticky', @@ -176,6 +193,19 @@ const stickyStyles = stylex.create({ zIndex: -1, pointerEvents: 'none', backgroundColor: 'var(--table-row-overlay, transparent)', + // The divider: the layer reaches the bottom of the cell's border area, + // paints the pinned base there (hiding the table's static border and any + // scrolled cell's border), then the divider over it. + marginBottom: `calc(-1 * ${DIVIDER_WIDTH})`, + borderBottomWidth: DIVIDER_WIDTH, + borderBottomStyle: `var(${PINNED_DIVIDER_STYLE}, var(--table-row-divider-style, solid))`, + borderBottomColor: `var(${PINNED_DIVIDER_COLOR}, var(--table-row-divider-color, ${colorVars['--color-border']}))`, + backgroundImage: `linear-gradient(${PINNED_BASE}, ${PINNED_BASE})`, + backgroundSize: `100% ${DIVIDER_WIDTH}`, + backgroundPosition: 'bottom', + backgroundRepeat: 'no-repeat', + backgroundOrigin: 'border-box', + backgroundClip: 'border-box', transitionProperty: 'background-color', transitionDuration: durationVars['--duration-fast'], transitionTimingFunction: easeVars['--ease-standard'], @@ -256,6 +286,96 @@ const shadowStyles = stylex.create({ }, }); +// ============================================================================= +// Pinned dividers: app overrides +// ============================================================================= + +/** Border styles by collapsed-border precedence, weakest first (CSS 2.1). */ +const STYLE_RANK = [ + 'inset', + 'groove', + 'outset', + 'ridge', + 'dotted', + 'dashed', + 'solid', + 'double', +]; + +interface BottomEdge { + width: number; + style: string; + color: string; +} + +function bottomEdge(element: Element): BottomEdge { + const style = getComputedStyle(element); + return { + width: parseFloat(style.borderBottomWidth) || 0, + style: style.borderBottomStyle, + color: style.borderBottomColor, + }; +} + +/** + * The bottom border the table paints for a cell: the cell's own, or its row's + * where an app styles the row and the row's wins the collapse (wider, then + * the stronger style; the cell wins a tie). + */ +function collapsedBottom(cell: Element): BottomEdge { + const own = bottomEdge(cell); + const row = cell.parentElement; + if (row == null) { + return own; + } + const other = bottomEdge(row); + if (own.style === 'hidden' || other.style === 'hidden') { + return {width: 0, style: 'none', color: own.color}; + } + const drawn = (edge: BottomEdge) => edge.style !== 'none' && edge.width > 0; + if (!drawn(other)) { + return own; + } + if (!drawn(own) || other.width > own.width) { + return other; + } + if (other.width < own.width) { + return own; + } + return STYLE_RANK.indexOf(other.style) > STYLE_RANK.indexOf(own.style) + ? other + : own; +} + +/** + * Where an app styles a cell's or row's `border-bottom` directly, past the + * custom properties, the pinned divider takes the border the table paints. + * Read on mount and on resize, never on scroll. + */ +function syncPinnedDividers(root: HTMLElement): void { + for (const cell of root.querySelectorAll( + `[${PINNED_ATTRIBUTE}]`, + )) { + cell.style.removeProperty(PINNED_DIVIDER_WIDTH); + cell.style.removeProperty(PINNED_DIVIDER_STYLE); + cell.style.removeProperty(PINNED_DIVIDER_COLOR); + const want = collapsedBottom(cell); + const drawn = getComputedStyle(cell, '::before'); + const drawnWidth = parseFloat(drawn.borderBottomWidth) || 0; + const matches = + want.width === drawnWidth && + (want.width === 0 || + (want.style === drawn.borderBottomStyle && + want.color === drawn.borderBottomColor)); + if (matches) { + continue; + } + cell.style.setProperty(PINNED_DIVIDER_WIDTH, `${want.width}px`); + cell.style.setProperty(PINNED_DIVIDER_STYLE, want.style); + cell.style.setProperty(PINNED_DIVIDER_COLOR, want.color); + } +} + // Stable empty default so an unset start/end doesn't allocate a fresh array // (and bust the memo) on every render. const EMPTY: string[] = []; @@ -319,11 +439,15 @@ export function useTableStickyColumns>( ); } }; + const resize = () => { + update(); + syncPinnedDividers(el); + }; el.addEventListener('scroll', update, {passive: true}); const resizeObserver = - typeof ResizeObserver !== 'undefined' ? new ResizeObserver(update) : null; + typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null; resizeObserver?.observe(el); - update(); + resize(); detachRef.current = () => { el.removeEventListener('scroll', update); resizeObserver?.disconnect(); @@ -361,6 +485,7 @@ export function useTableStickyColumns>( ...props, htmlProps: { ...props.htmlProps, + ...PINNED_PROPS, style: {...props.htmlProps.style, ...offsetStyle}, }, xstyle: [ @@ -389,6 +514,7 @@ export function useTableStickyColumns>( ...props, htmlProps: { ...props.htmlProps, + ...PINNED_PROPS, style: {...props.htmlProps.style, ...offsetStyle}, }, xstyle: [ From cc491f740af7370c175c54feda3a2c691987d934 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 22:27:24 -0700 Subject: [PATCH 35/58] fix(core): the pinned divider's plumbing stays private The row divider adds no theming API: cells keep their border tokens, and the pinned divider reads private plumbing only. --_table-row-divider-width is the width a cell's divider draws (0 on the last body row), and --_table-row-divider-app-width, -style and -color carry a border an app or theme sets on a cell or row directly, read on mount and on resize. The story themes the border through the existing --color-border token, and the Changeset describes the fix alone. --- .changeset/table-pinned-dividers.md | 2 +- .../stories/TableStickyColumns.stories.tsx | 13 ++++++----- packages/core/src/Table/TableCell.tsx | 22 +++++++++---------- .../core/src/Table/TableHeaderCell.test.tsx | 16 +++----------- packages/core/src/Table/TableHeaderCell.tsx | 11 +++++----- .../stickyColumns/useTableStickyColumns.tsx | 17 +++++++------- 6 files changed, 36 insertions(+), 45 deletions(-) diff --git a/.changeset/table-pinned-dividers.md b/.changeset/table-pinned-dividers.md index 847bd47a28f80..9f36c54ac1024 100644 --- a/.changeset/table-pinned-dividers.md +++ b/.changeset/table-pinned-dividers.md @@ -2,4 +2,4 @@ '@astryxdesign/core': patch --- -Table: a pinned column (`useTableStickyColumns`) now draws its own row dividers, so the line under it no longer goes missing while the table scrolls horizontally. Row dividers read `--table-row-divider-width`, `--table-row-divider-style` and `--table-row-divider-color`, which a theme or an app can set on the table, a row or a cell; pinned cells follow them, and an app's own `border-bottom` on a row or cell. +Table: a pinned column (`useTableStickyColumns`) now draws its own row dividers, so the line under it no longer goes missing while the table scrolls horizontally. A theme's border color and an app's own `border-bottom` on a row or cell carry onto the pinned cells. diff --git a/apps/storybook/stories/TableStickyColumns.stories.tsx b/apps/storybook/stories/TableStickyColumns.stories.tsx index 3b813637862bb..48848322cc25c 100644 --- a/apps/storybook/stories/TableStickyColumns.stories.tsx +++ b/apps/storybook/stories/TableStickyColumns.stories.tsx @@ -140,9 +140,9 @@ export const PinStart: Story = { /** * A pinned column draws each row's divider itself, so the line under it moves - * with the column. A theme's divider color (`--table-row-divider-color`, set - * on the wrapper here) and an app's own `border-bottom` on a row (the second - * row) both carry over to the pinned cells. + * with the column. A theme's border color (`--color-border`, set on the + * wrapper here) and an app's own `border-bottom` on a row (the second row) + * both carry over to the pinned cells. */ export const PinnedDividers: Story = { render: () => { @@ -153,13 +153,14 @@ export const PinnedDividers: Story = { style={ { maxWidth: 720, - '--table-row-divider-color': 'rgb(37, 99, 235)', + '--color-border': 'rgb(37, 99, 235)', } as React.CSSProperties }>

    - A blue theme divider on every row and an app's red 2 px border on - the second row, both on the pinned Name column as you scroll. + A blue theme border color on every row and an app's red 2 px + border on the second row, both on the pinned Name column as you + scroll.

    is checked — // without the scope, (also a :last-child) would match // and suppress borders on every row. - [stylex.when.ancestor(':last-child', tableRowMarker)]: '0px', + [stylex.when.ancestor(':last-child', tableRowMarker)]: '0', }, - borderBottomWidth: 'var(--_table-divider-width)', - borderBottomStyle: 'var(--table-row-divider-style, solid)', - borderBottomColor: `var(--table-row-divider-color, ${colorVars['--color-border']})`, + borderBottomStyle: 'solid', + borderBottomColor: colorVars['--color-border'], }, }); diff --git a/packages/core/src/Table/TableHeaderCell.test.tsx b/packages/core/src/Table/TableHeaderCell.test.tsx index 16b72edb64251..baa3f34006079 100644 --- a/packages/core/src/Table/TableHeaderCell.test.tsx +++ b/packages/core/src/Table/TableHeaderCell.test.tsx @@ -172,19 +172,9 @@ describe('TableHeaderCell', () => { ); const declarations = headerCellDeclarations(container); // The header/body separator is unconditional inside a table. - // It reads the row-divider custom properties, defaulting to the tokens. - expect(declarations).toContain( - '--_table-divider-width: var(--table-row-divider-width,var(--border-width))', - ); - expect(declarations).toContain( - 'border-bottom-width: var(--_table-divider-width)', - ); - expect(declarations).toContain( - 'border-bottom-style: var(--table-row-divider-style,solid)', - ); - expect(declarations).toContain( - 'border-bottom-color: var(--table-row-divider-color,var(--color-border))', - ); + expect(declarations).toContain('border-bottom-width: var(--border-width)'); + expect(declarations).toContain('border-bottom-style: solid'); + expect(declarations).toContain('border-bottom-color: var(--color-border)'); // …but the column divider is not. expect(declarations.filter(d => d.includes('border-inline-end'))).toEqual( [], diff --git a/packages/core/src/Table/TableHeaderCell.tsx b/packages/core/src/Table/TableHeaderCell.tsx index 9d1549246bdcf..9e1f60e65fe00 100644 --- a/packages/core/src/Table/TableHeaderCell.tsx +++ b/packages/core/src/Table/TableHeaderCell.tsx @@ -81,14 +81,13 @@ const headerStyles = stylex.create({ }, }); -// The header's divider reads the same custom properties as a body row's -// (see TableCell.tsx), so a theme sets every row divider in one place. const headerDividerStyles = stylex.create({ cell: { - '--_table-divider-width': `var(--table-row-divider-width, ${borderVars['--border-width']})`, - borderBottomWidth: 'var(--_table-divider-width)', - borderBottomStyle: 'var(--table-row-divider-style, solid)', - borderBottomColor: `var(--table-row-divider-color, ${colorVars['--color-border']})`, + // For a pinned header cell's own divider to match (plugins/stickyColumns). + '--_table-row-divider-width': borderVars['--border-width'], + borderBottomWidth: borderVars['--border-width'], + borderBottomStyle: 'solid', + borderBottomColor: colorVars['--color-border'], }, }); diff --git a/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx b/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx index 02e9fff4bb0d9..abd2bf8029795 100644 --- a/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +++ b/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx @@ -165,16 +165,17 @@ const SHADOW_VAR_END = '--table-sticky-shadow-end'; // whichever scrolled cell's border passes beneath it, and missing where that // cell has none. The cell's ::before (already the row overlay's layer) extends // over the cell's border area, covers whatever the table painted there with -// the pinned base, and draws the divider with the same width, style and color -// the cell's own border reads (TableCell.tsx). +// the pinned base, and draws the divider with the cell's own border tokens +// (TableCell.tsx); a border an app or a theme sets on the cell or row directly +// is read on mount and on resize (syncPinnedDividers). const PINNED_ATTRIBUTE = 'data-astryx-table-pinned'; /** Spread into a pinned cell's props: the attribute the divider sync finds. */ const PINNED_PROPS = {[PINNED_ATTRIBUTE]: ''} as Record; -const PINNED_DIVIDER_WIDTH = '--_table-pinned-divider-width'; -const PINNED_DIVIDER_STYLE = '--_table-pinned-divider-style'; -const PINNED_DIVIDER_COLOR = '--_table-pinned-divider-color'; +const PINNED_DIVIDER_WIDTH = '--_table-row-divider-app-width'; +const PINNED_DIVIDER_STYLE = '--_table-row-divider-app-style'; +const PINNED_DIVIDER_COLOR = '--_table-row-divider-app-color'; const PINNED_BASE = `var(--table-sticky-background, ${colorVars['--color-background-card']})`; -const DIVIDER_WIDTH = `var(${PINNED_DIVIDER_WIDTH}, var(--_table-divider-width, 0px))`; +const DIVIDER_WIDTH = `var(${PINNED_DIVIDER_WIDTH}, var(--_table-row-divider-width, 0px))`; const stickyStyles = stylex.create({ cell: { @@ -198,8 +199,8 @@ const stickyStyles = stylex.create({ // scrolled cell's border), then the divider over it. marginBottom: `calc(-1 * ${DIVIDER_WIDTH})`, borderBottomWidth: DIVIDER_WIDTH, - borderBottomStyle: `var(${PINNED_DIVIDER_STYLE}, var(--table-row-divider-style, solid))`, - borderBottomColor: `var(${PINNED_DIVIDER_COLOR}, var(--table-row-divider-color, ${colorVars['--color-border']}))`, + borderBottomStyle: `var(${PINNED_DIVIDER_STYLE}, solid)`, + borderBottomColor: `var(${PINNED_DIVIDER_COLOR}, ${colorVars['--color-border']})`, backgroundImage: `linear-gradient(${PINNED_BASE}, ${PINNED_BASE})`, backgroundSize: `100% ${DIVIDER_WIDTH}`, backgroundPosition: 'bottom', From be5b92fe82b5632296f609e7ca167f91b71b5347 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 23:15:57 -0700 Subject: [PATCH 36/58] feat(core): Table rows glide when a row's panel opens or closes, and their dividers move with them useTableRowExpansion takes an animatedLayout handle (spec:AST-105 adopters). A panel opening or closing is measured before the commit (LayoutChangeSnapshot, in the scroll wrapper's beforeTable) and the rows after it glide to their new places; an opening panel plays in on the same motion, and the chevron turns on it (AST-108 FR4a). Without a handle rows jump, as before; under Reduce Motion they change at once. Dividers move with their rows (D37 d). With collapsed borders the table paints a border at its cell's static place, so a moving row left its divider behind. While a row moves, its cells' borders go transparent (width kept, nothing reflows) and the row's ::after draws the same divider along its bottom edge; a cell's pseudo-element would be clipped by the cell's overflow, the row's is not. The row's own border, an app's or a theme's included, is read once when the glide starts (the collapsed winner of the cell's and the row's) through the private plumbing #8654 uses; pinned cells keep drawing their own. Only the moving rows are visited: the walk goes out from the changed row and stops at still rows, reading before writing. The table's scroll wrapper clips its rows, and a closing panel shrinks it at once, so the rows gliding up would be cut off at its new bottom. For the glide it keeps its old height as bottom padding, given back with an equal negative margin, so nothing outside moves differently; both go when the rows stop. Tests: a handle glides the rows after an opening panel and turns the chevron on the glide's motion; no handle changes nothing; a moving row is marked and carries its divider only while it moves. Stories: Gliding rows (a pinned column, horizontal scroll) and a 2,000-row table. --- .changeset/table-row-expansion-glide.md | 5 + .../stories/TableRowExpansion.stories.tsx | 89 +++++++- packages/core/src/Table/TableCell.tsx | 3 + packages/core/src/Table/TableRow.tsx | 3 + packages/core/src/Table/glidingDivider.ts | 212 ++++++++++++++++++ .../useTableRowExpansion.glide.test.tsx | 164 ++++++++++++++ .../rowExpansion/useTableRowExpansion.tsx | 200 ++++++++++++++++- .../stickyColumns/useTableStickyColumns.tsx | 64 +----- .../src/Table/useTableRowExpansion.doc.mjs | 8 + .../motion/animatedLayout.boundary.test.ts | 1 + 10 files changed, 685 insertions(+), 64 deletions(-) create mode 100644 .changeset/table-row-expansion-glide.md create mode 100644 packages/core/src/Table/glidingDivider.ts create mode 100644 packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx diff --git a/.changeset/table-row-expansion-glide.md b/.changeset/table-row-expansion-glide.md new file mode 100644 index 0000000000000..2234eebc46993 --- /dev/null +++ b/.changeset/table-row-expansion-glide.md @@ -0,0 +1,5 @@ +--- +'@astryxdesign/core': patch +--- + +Table: `useTableRowExpansion` takes an `animatedLayout` handle from `useAnimatedLayout`. With it, opening or closing a row's panel glides the rows after it to their new places instead of jumping, and each row's divider moves with its row. Under Reduce Motion the rows change at once. diff --git a/apps/storybook/stories/TableRowExpansion.stories.tsx b/apps/storybook/stories/TableRowExpansion.stories.tsx index 93be197c4fa8c..c4087dd37f69a 100644 --- a/apps/storybook/stories/TableRowExpansion.stories.tsx +++ b/apps/storybook/stories/TableRowExpansion.stories.tsx @@ -1,13 +1,15 @@ // Copyright (c) Meta Platforms, Inc. and affiliates. -import {useState} from 'react'; +import {useMemo, useRef, useState} from 'react'; import type {Meta, StoryObj} from '@storybook/react'; import { Table, useTableRowExpansion, + useTableStickyColumns, pixel, proportional, } from '@astryxdesign/core/Table'; +import {useAnimatedLayout} from '@astryxdesign/core/hooks'; import type {TableColumn} from '@astryxdesign/core/Table'; import {VStack, HStack} from '@astryxdesign/core/Stack'; import {Text, Heading} from '@astryxdesign/core/Text'; @@ -164,3 +166,88 @@ export const NotAllRowsExpandable: Story = { ); }, }; + +/** + * With an `animatedLayout` handle, opening or closing a row's panel glides the + * rows after it instead of jumping them, the panel plays in on the same + * motion, and each row's divider moves with its row. The Customer column is + * pinned and the table scrolls horizontally, so the pinned cells' own + * dividers move too. Under Reduce Motion the rows change at once. + */ +export const GlidingRows: Story = { + render: () => { + const containerRef = useRef(null); + const animatedLayout = useAnimatedLayout({containerRef}); + const [expandedKeys, setExpandedKeys] = useState>( + () => new Set(), + ); + const expansion = useTableRowExpansion({ + expandedKeys, + onToggle: key => setExpandedKeys(prev => toggleKey(prev, key)), + getRowKey: item => item.id, + renderExpanded: item => , + animatedLayout, + }); + const sticky = useTableStickyColumns({startKeys: ['customer']}); + const wideColumns = useMemo( + () => columns.map(column => ({...column, width: pixel(220)})), + [], + ); + return ( +
    +
    +

    Content after the table glides too.

    + + ); + }, +}; + +const manyOrders: Order[] = Array.from({length: 2000}, (_, index) => ({ + ...orders[index % orders.length], + id: `ord-${String(index + 1).padStart(4, '0')}`, +})); + +/** + * A long table (2,000 rows, not virtualized) with an `animatedLayout` + * handle: a glide moves only the rows near the visible part, so opening a + * panel costs the same as in a short table. + */ +export const GlidingRowsLongTable: StoryObj<{glide?: boolean}> = { + args: {glide: true}, + render: ({glide}) => { + const containerRef = useRef(null); + const animatedLayout = useAnimatedLayout({containerRef}); + const [expandedKeys, setExpandedKeys] = useState>( + () => new Set(), + ); + const expansion = useTableRowExpansion({ + expandedKeys, + onToggle: key => setExpandedKeys(prev => toggleKey(prev, key)), + getRowKey: item => item.id, + renderExpanded: item => , + animatedLayout: glide === false ? undefined : animatedLayout, + }); + return ( +
    +
    + + ); + }, +}; diff --git a/packages/core/src/Table/TableCell.tsx b/packages/core/src/Table/TableCell.tsx index b5b0076e0ff19..4cd6021877910 100644 --- a/packages/core/src/Table/TableCell.tsx +++ b/packages/core/src/Table/TableCell.tsx @@ -38,6 +38,7 @@ import { mergeXStyle, } from './useTableCellStyles'; import {mergeProps} from '../utils'; +import {glidingDividerStyles} from './glidingDivider'; import {themeProps} from '../utils/themeProps'; /** Props for TableCell — thin `. + // While a glide moves the row, its divider moves with it. + rowStyles.push(glidingDividerStyles.row); } if (xstyle) { diff --git a/packages/core/src/Table/glidingDivider.ts b/packages/core/src/Table/glidingDivider.ts new file mode 100644 index 0000000000000..2276a9515b60a --- /dev/null +++ b/packages/core/src/Table/glidingDivider.ts @@ -0,0 +1,212 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file glidingDivider.ts + * @input Table rows a row glide moves (Web Animations on the ``), their + * computed borders, StyleX + * @output Exports `glidingDividerStyles` (a row's stand-in divider while it + * glides), `markGlidingRows` (marks the moving rows for that glide and + * carries each row's own border onto the stand-in) and `collapsedBottom` + * @position Table internals. With collapsed borders the table paints every + * border at its cell's static place, so a row that moves leaves its divider + * behind. For the glide only, the cells' borders go transparent (their width + * stays, so nothing reflows) and the row's ::after draws the same divider, + * which moves with the row. A pinned cell draws its own over it + * (plugins/stickyColumns). + * + * SYNC: When modified, update TableCell.tsx, plugins/rowExpansion and + * plugins/stickyColumns. + */ + +import * as stylex from '@stylexjs/stylex'; +import {colorVars} from '../theme/tokens.stylex'; +import {tableRowMarker} from './table.stylex'; + +/** On a row while a glide moves it. */ +export const GLIDING_ATTRIBUTE = 'data-astryx-row-gliding'; +const GLIDING = '[data-astryx-row-gliding]'; + +/** A row's own divider, carried for the glide (or a pinned cell's). */ +export const APP_DIVIDER_WIDTH = '--_table-row-divider-app-width'; +export const APP_DIVIDER_STYLE = '--_table-row-divider-app-style'; +export const APP_DIVIDER_COLOR = '--_table-row-divider-app-color'; + +export const glidingDividerStyles = stylex.create({ + /** On every cell with a row divider: it goes transparent while the row moves. */ + cell: { + borderBottomColor: { + default: colorVars['--color-border'], + [stylex.when.ancestor(GLIDING, tableRowMarker)]: 'transparent', + }, + }, + /** + * On every row: while it moves, its ::after draws the divider across the + * row's bottom edge, where the collapsed border sits, and moves with it. A + * cell's ::before or ::after is clipped by the cell's own overflow; the + * row's is not. The row's motion (a `translate`) makes it the line's + * containing block, and `position: relative` keeps it so. + */ + row: { + position: { + default: null, + [GLIDING]: 'relative', + }, + '::after': { + content: { + default: null, + [GLIDING]: '""', + }, + position: 'absolute', + insetInline: 0, + bottom: 0, + height: 0, + borderBottomWidth: `var(${APP_DIVIDER_WIDTH}, 0px)`, + borderBottomStyle: `var(${APP_DIVIDER_STYLE}, solid)`, + borderBottomColor: `var(${APP_DIVIDER_COLOR}, ${colorVars['--color-border']})`, + pointerEvents: 'none', + }, + }, +}); + +/** Border styles by collapsed-border precedence, weakest first (CSS 2.1). */ +const STYLE_RANK = [ + 'inset', + 'groove', + 'outset', + 'ridge', + 'dotted', + 'dashed', + 'solid', + 'double', +]; + +export interface BottomEdge { + width: number; + style: string; + color: string; +} + +function bottomEdge(element: Element): BottomEdge { + const style = getComputedStyle(element); + return { + width: parseFloat(style.borderBottomWidth) || 0, + style: style.borderBottomStyle, + color: style.borderBottomColor, + }; +} + +/** + * The bottom border the table paints for a cell: the cell's own, or its row's + * where an app styles the row and the row's wins the collapse (wider, then + * the stronger style; the cell wins a tie). + */ +export function collapsedBottom(cell: Element): BottomEdge { + const own = bottomEdge(cell); + const row = cell.parentElement; + if (row == null) { + return own; + } + const other = bottomEdge(row); + if (own.style === 'hidden' || other.style === 'hidden') { + return {width: 0, style: 'none', color: own.color}; + } + const drawn = (edge: BottomEdge) => edge.style !== 'none' && edge.width > 0; + if (!drawn(other)) { + return own; + } + if (!drawn(own) || other.width > own.width) { + return other; + } + if (other.width < own.width) { + return own; + } + return STYLE_RANK.indexOf(other.style) > STYLE_RANK.indexOf(own.style) + ? other + : own; +} + +function endWhenStill(row: HTMLElement): void { + const running = row.getAnimations(); + if (running.length === 0) { + row.removeAttribute(GLIDING_ATTRIBUTE); + row.style.removeProperty(APP_DIVIDER_WIDTH); + row.style.removeProperty(APP_DIVIDER_STYLE); + row.style.removeProperty(APP_DIVIDER_COLOR); + return; + } + // A new glide may replace this one (cancelling it): look again then. + void Promise.all( + running.map(async animation => { + await animation.finished.catch(() => undefined); + }), + ).then(() => endWhenStill(row)); +} + +/** Whether a glide (or an entrance) is moving the row now. */ +function isMoving(row: Element): row is HTMLElement { + return ( + row instanceof HTMLTableRowElement && + typeof row.getAnimations === 'function' && + row.getAnimations().length > 0 + ); +} + +/** + * Marks the rows a glide is moving around `changed`, so their cells draw a + * divider that moves with them, and unmarks each when its motion ends. A + * glide moves a contiguous run of rows next to the change and stops near the + * visible part, so this walks out from the changed row each way and stops + * once rows are still: it never visits the rest of a long table. The row's + * own bottom border (an app's or a theme's, past the tokens) is read here, + * before its cells' borders go transparent, and carried onto the stand-in. + * + * Every read comes before any write: a write between two reads would make + * the browser restyle the whole table again for the next one. Resolves when + * the rows it found have stopped. + */ +export async function markGlidingRows(changed: HTMLElement): Promise { + const moving: HTMLElement[] = []; + if (isMoving(changed)) { + moving.push(changed); + } + for (const step of [ + 'nextElementSibling', + 'previousElementSibling', + ] as const) { + let row = changed[step]; + // The changed row's own panel may be still: look one row past it. + let stillInARow = 0; + while (row != null && stillInARow < 2) { + if (isMoving(row)) { + moving.push(row); + stillInARow = 0; + } else { + stillInARow += 1; + } + row = row[step]; + } + } + const fresh = moving.filter(row => !row.hasAttribute(GLIDING_ATTRIBUTE)); + const edges = fresh.map(row => + row.lastElementChild == null ? null : collapsedBottom(row.lastElementChild), + ); + fresh.forEach((row, index) => { + const edge = edges[index]; + if (edge != null) { + row.style.setProperty(APP_DIVIDER_WIDTH, `${edge.width}px`); + row.style.setProperty(APP_DIVIDER_STYLE, edge.style); + row.style.setProperty(APP_DIVIDER_COLOR, edge.color); + } + row.setAttribute(GLIDING_ATTRIBUTE, ''); + }); + for (const row of fresh) { + endWhenStill(row); + } + await Promise.all( + moving.flatMap(row => + row.getAnimations().map(async animation => { + await animation.finished.catch(() => undefined); + }), + ), + ); +} diff --git a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx new file mode 100644 index 0000000000000..340e41072ea5c --- /dev/null +++ b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx @@ -0,0 +1,164 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file useTableRowExpansion.glide.test.tsx + * @input useTableRowExpansion with and without an `animatedLayout` handle, a + * stubbed layout, recorded Web Animations + * @output Verifies that opening a row's panel glides the rows after it with + * a handle (the chevron on the same motion), changes nothing without one, + * and that a gliding row is marked for its moving divider only while it + * moves (`spec:AST-105` FR1, FR2) + * @position Table row-expansion adoption of the size-change module + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {render, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {useRef, useState} from 'react'; +import {Table} from '../../Table'; +import {pixel} from '../../columnUtils'; +import type {TableColumn} from '../../types'; +import {useTableRowExpansion} from './useTableRowExpansion'; +import {useAnimatedLayout} from '../../../hooks/useAnimatedLayout'; +import {markGlidingRows} from '../../glidingDivider'; + +type Row = {id: string; name: string}; +const rows: Row[] = [ + {id: 'a', name: 'Alpha'}, + {id: 'b', name: 'Bravo'}, + {id: 'c', name: 'Charlie'}, +]; +const columns: TableColumn[] = [ + {key: 'name', header: 'Name', width: pixel(200)}, +]; + +let animated: Element[] = []; +const originalAnimate = Element.prototype.animate; + +beforeEach(() => { + animated = []; + Element.prototype.animate = function animate(this: Element) { + animated.push(this); + return { + cancel() {}, + addEventListener() {}, + } as unknown as Animation; + }; +}); + +afterEach(() => { + Element.prototype.animate = originalAnimate; + vi.restoreAllMocks(); +}); + +function ExpandableTable({withHandle}: {withHandle: boolean}) { + const containerRef = useRef(null); + const animatedLayout = useAnimatedLayout({containerRef}); + const [expandedKeys, setExpandedKeys] = useState(() => new Set()); + const expansion = useTableRowExpansion({ + expandedKeys, + onToggle: key => + setExpandedKeys(previous => { + const next = new Set(previous); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }), + getRowKey: row => row.id, + renderExpanded: row =>

    Details for {row.name}

    , + animatedLayout: withHandle ? animatedLayout : undefined, + }); + return ( +
    +
    ` wrapper */ @@ -234,6 +235,8 @@ export function TableCell({ dividerColumnStyles.cell, ), ...(hasContextMenu ? [contextMenuCellStyles.cell] : []), + // While a glide moves the row, its divider moves with it. + glidingDividerStyles.cell, ] : []; diff --git a/packages/core/src/Table/TableRow.tsx b/packages/core/src/Table/TableRow.tsx index 124aebf5ffe28..cc48f98c42115 100644 --- a/packages/core/src/Table/TableRow.tsx +++ b/packages/core/src/Table/TableRow.tsx @@ -21,6 +21,7 @@ import * as stylex from '@stylexjs/stylex'; import {colorVars, durationVars, easeVars} from '../theme/tokens.stylex'; import type {StyleXStyles} from '../theme/types'; import {tableRowMarker} from './table.stylex'; +import {glidingDividerStyles} from './glidingDivider'; import {TableContext} from './TableContext'; import {mergeProps} from '../utils'; import {themeProps} from '../utils/themeProps'; @@ -182,6 +183,8 @@ export function TableRow({ if (ctx.dividers === 'rows' || ctx.dividers === 'grid') { // Note: last-body-row border removal is handled by TableCell // to avoid affecting the header row in
    + + ); +} + +/** The area, and the row after the first, report a layout; it moves down. */ +function stubGeometry() { + vi.spyOn(screen.getByTestId('area'), 'getBoundingClientRect').mockReturnValue( + new DOMRect(0, 0, 400, 600), + ); + const next = document.querySelector('tr[data-expansion-key="b"]')!; + let reads = 0; + vi.spyOn(next, 'getBoundingClientRect').mockImplementation(() => { + reads += 1; + // The first read is the measurement before the panel opens. + return new DOMRect(0, reads === 1 ? 40 : 120, 400, 40); + }); + return next; +} + +const flush = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + +describe('useTableRowExpansion adopts animatedLayout (spec:AST-105 FR1, FR2)', () => { + it('glides the rows after an opening panel, with a handle', async () => { + const user = userEvent.setup(); + render(); + const next = stubGeometry(); + await user.click(screen.getAllByRole('button', {name: /expand/i})[0]); + await flush(); + expect(animated).toContain(next); + // The chevron turns on the glide's motion. + expect( + document + .querySelector('[data-expansion-toggle] > *') + ?.style.getPropertyValue('transition-timing-function'), + ).toMatch(/^linear\(/); + }); + + it('changes nothing without a handle', async () => { + const user = userEvent.setup(); + render(); + stubGeometry(); + await user.click(screen.getAllByRole('button', {name: /expand/i})[0]); + await flush(); + expect(animated).toEqual([]); + }); +}); + +describe('markGlidingRows', () => { + it('marks a moving row and carries its divider while it moves, and clears both after', async () => { + const body = document.createElement('tbody'); + const row = document.createElement('tr'); + row.append(document.createElement('td')); + body.append(row); + document.body.append(body); + let finish = () => {}; + const motion = { + finished: new Promise(resolve => { + finish = resolve; + }), + } as unknown as Animation; + let running = [motion]; + Object.assign(row, {getAnimations: () => running}); + + void markGlidingRows(row); + expect(row.hasAttribute('data-astryx-row-gliding')).toBe(true); + expect(row.style.getPropertyValue('--_table-row-divider-app-width')).toBe( + '0px', + ); + + running = []; + finish(); + await flush(); + await flush(); + expect(row.hasAttribute('data-astryx-row-gliding')).toBe(false); + expect(row.style.getPropertyValue('--_table-row-divider-app-width')).toBe( + '', + ); + body.remove(); + }); +}); diff --git a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx index adabb5e99734b..f922e2bf2a126 100644 --- a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +++ b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx @@ -18,7 +18,13 @@ * - /packages/core/src/Table/useTableRowExpansion.doc.mjs */ -import {useMemo, useRef, type ReactNode} from 'react'; +import { + useCallback, + useLayoutEffect, + useMemo, + useRef, + type ReactNode, +} from 'react'; import * as stylex from '@stylexjs/stylex'; import { spacingVars, @@ -45,7 +51,17 @@ import type { BodyRowRenderProps, BodyCellRenderProps, HeaderCellRenderProps, + ScrollWrapperRenderProps, } from '../../types'; +import {LayoutChangeSnapshot} from '../../../motion/LayoutChangeSnapshot'; +import { + applyLayoutChangeTransition, + playLayoutChange, + reportLayoutChange, + type AnimatedLayout, + type LayoutChange, +} from '../../../motion/animatedLayout'; +import {glidingDividerStyles, markGlidingRows} from '../../glidingDivider'; // ============================================================================= // Config @@ -107,6 +123,13 @@ export interface UseTableRowExpansionConfig> { * @default false (only the chevron toggles expansion). */ hasRowClickExpansion?: boolean; + /** + * A handle from `useAnimatedLayout`. With it, opening or closing a row's + * panel glides the rows after it to their new places instead of jumping, + * and an opening panel plays its entrance on the same motion. Without it, + * rows jump, as before. + */ + animatedLayout?: AnimatedLayout; } // ============================================================================= @@ -214,9 +237,15 @@ const expansionStyles = stylex.create({ */ const dividerStyles = stylex.create({ expandedRowCell: { + '--_table-row-divider-width': '0px', borderBottomWidth: 0, }, panelCell: { + // The width this divider draws, for its stand-in during a glide. + '--_table-row-divider-width': { + default: borderVars['--border-width'], + [stylex.when.ancestor(':last-child', tableRowMarker)]: '0px', + }, borderBottomWidth: { default: borderVars['--border-width'], // Same rule TableCell uses: no trailing line under the last row of the @@ -308,6 +337,7 @@ function ExpansionChevron({ return ( `). Read once per commit. + */ +export function panelDividerFor( + row: Element, + panelCell: Element, +): BottomEdge | null { + const rowCell = row.lastElementChild; + if (rowCell == null) { + return null; + } + const cellStyle = getComputedStyle(rowCell); + const own: BottomEdge = { + width: parseFloat(getComputedStyle(panelCell).borderBottomWidth) || 0, + style: cellStyle.borderBottomStyle, + color: cellStyle.borderBottomColor, + }; + const rowStyle = getComputedStyle(row); + if (rowStyle.borderBottomStyle === 'none') { + return own; + } + const other: BottomEdge = { + width: parseFloat(rowStyle.borderBottomWidth) || 0, + style: rowStyle.borderBottomStyle, + color: rowStyle.borderBottomColor, + }; + return other.width > own.width || + (other.width === own.width && + STYLE_RANK.indexOf(other.style) > STYLE_RANK.indexOf(own.style)) + ? other + : own; +} + /** * A row's own bottom border, for its stand-in while it moves; null for a row * already carrying one. Read where the glide reads the layout (`animate`'s diff --git a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx index 62e159a676e6b..756b89dff711c 100644 --- a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx +++ b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx @@ -129,6 +129,41 @@ describe('useTableRowExpansion adopts animatedLayout (spec:AST-105 FR1, FR2)', ( }); }); +describe('an open panel carries its row divider (Table FR14)', () => { + it("takes the row's own cell border, a theme's or an app's, and the row's own border where it wins", async () => { + const user = userEvent.setup(); + render(); + const row = document.querySelector( + 'tr[data-expansion-key="a"]', + )!; + // An app's (or a theme's) border on the row's cells. + for (const cell of row.querySelectorAll('td')) { + cell.style.borderBottomColor = 'rgb(255, 0, 0)'; + cell.style.borderBottomStyle = 'dashed'; + } + await user.click(screen.getAllByRole('button', {name: /expand/i})[0]); + const panel = document.querySelector( + 'tr[data-expansion-panel]', + )!; + expect(panel.style.getPropertyValue('--_table-panel-divider-color')).toBe( + 'rgb(255, 0, 0)', + ); + expect(panel.style.getPropertyValue('--_table-panel-divider-style')).toBe( + 'dashed', + ); + // An app's own border on the row wins the collapse when it is wider; + // the next commit (opening another row) reads it. + row.style.borderBottom = '3px solid rgb(0, 0, 255)'; + await user.click(screen.getAllByRole('button', {name: /expand/i})[0]); + expect(panel.style.getPropertyValue('--_table-panel-divider-width')).toBe( + '3px', + ); + expect(panel.style.getPropertyValue('--_table-panel-divider-color')).toBe( + 'rgb(0, 0, 255)', + ); + }); +}); + describe('markGlidingRows', () => { it('marks the moving rows and carries their divider while they move, and clears both after', async () => { const body = document.createElement('tbody'); diff --git a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx index 83c0fc66777d6..8aeba61aba792 100644 --- a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +++ b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx @@ -65,6 +65,7 @@ import { import { glidingDividerStyles, markGlidingRows, + panelDividerFor, rowEdge, type BottomEdge, } from '../../glidingDivider'; @@ -246,21 +247,24 @@ const dividerStyles = stylex.create({ '--_table-row-divider-width': '0px', borderBottomWidth: 0, }, + // The row's divider, carried down: its row's themed or app-set border + // reaches the panel through `--_table-panel-divider-*` on the panel row + // (set after each commit), the tokens otherwise. panelCell: { // The width this divider draws, for its stand-in during a glide. '--_table-row-divider-width': { - default: borderVars['--border-width'], + default: `var(--_table-panel-divider-width, ${borderVars['--border-width']})`, [stylex.when.ancestor(':last-child', tableRowMarker)]: '0px', }, borderBottomWidth: { - default: borderVars['--border-width'], + default: `var(--_table-panel-divider-width, ${borderVars['--border-width']})`, // Same rule TableCell uses: no trailing line under the last row of the // table. Scoped to the marker so , also a :last-child, does not // match and suppress every panel's border. [stylex.when.ancestor(':last-child', tableRowMarker)]: '0', }, - borderBottomStyle: 'solid', - borderBottomColor: colorVars['--color-border'], + borderBottomStyle: 'var(--_table-panel-divider-style, solid)', + borderBottomColor: `var(--_table-panel-divider-color, ${colorVars['--color-border']})`, }, }); @@ -513,6 +517,39 @@ export function useTableRowExpansion>( }, [animatedLayout, expandedKeys, rowFor], ); + // Each open panel carries its row's divider (a theme's `table-cell` border + // or an app's own never reach the panel's cell, which is not a table cell + // of the row). Written only when it differs, so a commit that changes + // nothing writes nothing. + useLayoutEffect(() => { + const wrapper = scrollWrapperRef.current; + if (wrapper == null) { + return; + } + const carried: [HTMLElement, BottomEdge][] = []; + for (const panel of wrapper.querySelectorAll( + 'tr[data-expansion-panel]', + )) { + const row = panel.previousElementSibling; + const cell = panel.firstElementChild; + const edge = + row == null || cell == null ? null : panelDividerFor(row, cell); + if (edge != null) { + carried.push([panel, edge]); + } + } + for (const [panel, edge] of carried) { + const set = (name: string, value: string) => { + if (panel.style.getPropertyValue(name) !== value) { + panel.style.setProperty(name, value); + } + }; + set('--_table-panel-divider-width', `${edge.width}px`); + set('--_table-panel-divider-style', edge.style); + set('--_table-panel-divider-color', edge.color); + } + }); + useLayoutEffect(() => { const pendings = pendingLayoutRef.current; pendingLayoutRef.current = []; From d7cc6249b53c1c0ac20e78677fb8a492564a0f4f Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 01:41:43 -0700 Subject: [PATCH 39/58] test(Table): the panel divider tests read the carried width's token default --- .../useTableRowExpansion.test.tsx | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx index 47950ccc27648..9e945c1701233 100644 --- a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +++ b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx @@ -296,9 +296,10 @@ describe('useTableRowExpansion (detail panel)', () => { for (const cell of cellsOf('Ada')) { expect(cell).toHaveStyle({borderBottomWidth: '0'}); } - expect(panelCell()).toHaveStyle({ - borderBottomWidth: 'var(--border-width)', - }); + // The token width, unless the row hands down its own. + expect(getComputedStyle(panelCell()).borderBottomWidth).toMatch( + /var\(--_table-panel-divider-width,\s*var\(--border-width\)\)/, + ); }); it('leaves collapsed rows keeping their own divider', () => { @@ -310,16 +311,17 @@ describe('useTableRowExpansion (detail panel)', () => { it('draws no panel divider on a table without row dividers', () => { render(); - expect(panelCell()).not.toHaveStyle({ - borderBottomWidth: 'var(--border-width)', - }); + expect(getComputedStyle(panelCell()).borderBottomWidth).not.toMatch( + /--border-width/, + ); }); it('draws the panel divider under grid dividers too', () => { render(); - expect(panelCell()).toHaveStyle({ - borderBottomWidth: 'var(--border-width)', - }); + // The token width, unless the row hands down its own. + expect(getComputedStyle(panelCell()).borderBottomWidth).toMatch( + /var\(--_table-panel-divider-width,\s*var\(--border-width\)\)/, + ); }); }); From f23d42b34d69fc6eb83e41228fd00df52fe23767 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 02:22:30 -0700 Subject: [PATCH 40/58] fix(core): the pinned divider's read runs on mount and resize only; its test compares every pixel CodeBunny on #8654: - The scroll container's merged ref was rebuilt on every render, so React re-attached it and re-read every pinned cell's border each render. One merged ref per inner ref now: the scroll listeners, the resize observer and the pinned dividers' read run on mount and on resize. - The theme and app border browser test compared only the darkest pixel, which a pinned line of the theme's blue over the app's red still passed. It compares the whole sampled column now. - The Changeset carries its category and credit. --- .changeset/table-pinned-dividers.md | 4 +- .../TableStickyDividers.a11y.browser.spec.ts | 64 ++++++++++++------- .../stickyColumns/useTableStickyColumns.tsx | 38 +++++++---- 3 files changed, 72 insertions(+), 34 deletions(-) diff --git a/.changeset/table-pinned-dividers.md b/.changeset/table-pinned-dividers.md index 9f36c54ac1024..2fc2e1cf22fae 100644 --- a/.changeset/table-pinned-dividers.md +++ b/.changeset/table-pinned-dividers.md @@ -2,4 +2,6 @@ '@astryxdesign/core': patch --- -Table: a pinned column (`useTableStickyColumns`) now draws its own row dividers, so the line under it no longer goes missing while the table scrolls horizontally. A theme's border color and an app's own `border-bottom` on a row or cell carry onto the pinned cells. +[fix] Table: a pinned column (`useTableStickyColumns`) now draws its own row dividers, so the line under it no longer goes missing while the table scrolls horizontally. A theme's border color and an app's own `border-bottom` on a row or cell carry onto the pinned cells. + +@cixzhang diff --git a/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts b/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts index f01ffcfea480e..766e9bdb8cb86 100644 --- a/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts +++ b/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts @@ -50,7 +50,7 @@ async function scrollTo(page: Page, left: number) { }, left); // Two frames: the scroll lands and paints. await page.evaluate( - () => + async () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)), ), @@ -60,13 +60,14 @@ async function scrollTo(page: Page, left: number) { type Rgb = [number, number, number]; /** - * The darkest pixel within 3 px of a body row's bottom edge, at an x inside - * the pinned cell and at an x inside the first unpinned cell visible past it. + * The column of pixels within 3 px of a body row's bottom edge, top to + * bottom, at an x inside the pinned cell and at an x inside the first + * unpinned cell visible past it. */ async function dividerPixels( page: Page, row: number, -): Promise<{pinned: Rgb; unpinned: Rgb}> { +): Promise<{pinned: Rgb[]; unpinned: Rgb[]}> { const points = await page.evaluate(index => { const rows = document.querySelectorAll( '.astryx-table-scroll-wrapper tbody tr', @@ -79,7 +80,7 @@ async function dividerPixels( unpinnedX: Math.round(pinned.right + 40), }; }, row); - const sample = async (x: number): Promise => { + const sample = async (x: number): Promise => { const shot = await page.screenshot({ clip: {x, y: points.y - 3, width: 1, height: 6}, scale: 'css', @@ -91,16 +92,17 @@ async function dividerPixels( const canvas = document.createElement('canvas'); canvas.width = image.width; canvas.height = image.height; - const context = canvas.getContext('2d')!; + const context = canvas.getContext('2d'); + if (context == null) { + return []; + } context.drawImage(image, 0, 0); const data = context.getImageData(0, 0, image.width, image.height).data; - let best: [number, number, number] = [255, 255, 255]; + const column: [number, number, number][] = []; for (let i = 0; i < data.length; i += 4) { - if (data[i] + data[i + 1] + data[i + 2] < best[0] + best[1] + best[2]) { - best = [data[i], data[i + 1], data[i + 2]]; - } + column.push([data[i], data[i + 1], data[i + 2]]); } - return best; + return column; }, shot.toString('base64')); }; return { @@ -111,6 +113,14 @@ async function dividerPixels( const near = (a: Rgb, b: Rgb, tolerance = 6) => a.every((channel, i) => Math.abs(channel - b[i]) <= tolerance); +/** Every pixel of one column matches the other's, top to bottom. */ +const sameColumn = (a: Rgb[], b: Rgb[], tolerance = 6) => + a.length === b.length && a.every((rgb, i) => near(rgb, b[i], tolerance)); +/** The darkest pixel of a column: the divider's own color. */ +const darkest = (column: Rgb[]): Rgb => + column.reduce((best, rgb) => + rgb[0] + rgb[1] + rgb[2] < best[0] + best[1] + best[2] ? rgb : best, + ); test('the divider under a pinned cell matches its row, at rest and scrolled', async ({ page, @@ -120,8 +130,13 @@ test('the divider under a pinned cell matches its row, at rest and scrolled', as await scrollTo(page, left); for (const row of [0, 1, 2]) { const {pinned, unpinned} = await dividerPixels(page, row); - expect(near(pinned, unpinned), `row ${row} at ${left}px`).toBe(true); - expect(pinned[0], `row ${row} at ${left}px is drawn`).toBeLessThan(250); + expect(sameColumn(pinned, unpinned), `row ${row} at ${left}px`).toBe( + true, + ); + expect( + darkest(pinned)[0], + `row ${row} at ${left}px is drawn`, + ).toBeLessThan(250); } } }); @@ -145,7 +160,7 @@ test('a pinned cell keeps its divider where the cell scrolled beneath it has non } }); const after = await dividerPixels(page, 1); - expect(near(after.pinned, before.pinned)).toBe(true); + expect(sameColumn(after.pinned, before.pinned)).toBe(true); }); test("a theme's divider color and an app's row border carry over to the pinned cells", async ({ @@ -156,18 +171,23 @@ test("a theme's divider color and an app's row border carry over to the pinned c await scrollTo(page, left); // Row 1: the theme's blue divider, under the pinned cell as elsewhere. const themed = await dividerPixels(page, 0); - expect(near(themed.pinned, themed.unpinned, 24), `themed at ${left}px`).toBe( - true, - ); - expect(themed.pinned[2], `themed at ${left}px is blue`).toBeGreaterThan( - themed.pinned[0] + 60, + expect( + sameColumn(themed.pinned, themed.unpinned, 24), + `themed at ${left}px`, + ).toBe(true); + const blue = darkest(themed.pinned); + expect(blue[2], `themed at ${left}px is blue`).toBeGreaterThan( + blue[0] + 60, ); // Row 2: the app's red row border wins the collapse; the pinned cell // draws it too. + // Every pixel of the column: a pinned line of the theme's blue over the + // app's red (the row's border not carried) fails here. const app = await dividerPixels(page, 1); - expect(near(app.pinned, app.unpinned, 24), `app at ${left}px`).toBe(true); - expect(app.pinned[0], `app at ${left}px is red`).toBeGreaterThan( - app.pinned[2] + 60, + expect(sameColumn(app.pinned, app.unpinned, 24), `app at ${left}px`).toBe( + true, ); + const red = darkest(app.pinned); + expect(red[0], `app at ${left}px is red`).toBeGreaterThan(red[2] + 60); } }); diff --git a/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx b/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx index abd2bf8029795..2682b390f55d6 100644 --- a/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +++ b/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx @@ -170,7 +170,7 @@ const SHADOW_VAR_END = '--table-sticky-shadow-end'; // is read on mount and on resize (syncPinnedDividers). const PINNED_ATTRIBUTE = 'data-astryx-table-pinned'; /** Spread into a pinned cell's props: the attribute the divider sync finds. */ -const PINNED_PROPS = {[PINNED_ATTRIBUTE]: ''} as Record; +const PINNED_PROPS: Record = {[PINNED_ATTRIBUTE]: ''}; const PINNED_DIVIDER_WIDTH = '--_table-row-divider-app-width'; const PINNED_DIVIDER_STYLE = '--_table-row-divider-app-style'; const PINNED_DIVIDER_COLOR = '--_table-row-divider-app-color'; @@ -409,6 +409,11 @@ export function useTableStickyColumns>( // listeners (synchronizing with the DOM) rather than React state, so // scrolling never triggers re-renders. const detachRef = useRef<(() => void) | null>(null); + // The scroll container's merged ref, kept while the inner ref is the same. + const mergedScrollWrapperRef = useRef<{ + inner: ScrollWrapperRenderProps['htmlProps']['ref'] | null; + merged: (node: HTMLDivElement | null) => void; + } | null>(null); const attachScrollShadow = useCallback((el: HTMLDivElement | null) => { detachRef.current?.(); detachRef.current = null; @@ -535,16 +540,27 @@ export function useTableStickyColumns>( return props; } // Compose with any ref a prior plugin (e.g. virtualization) set on the - // scroll container. - const existingRef = props.htmlProps.ref; - const mergedRef = (node: HTMLDivElement | null) => { - attachScrollShadow(node); - if (typeof existingRef === 'function') { - existingRef(node); - } else if (existingRef != null) { - // RefObject — assign through its writable `.current`. - existingRef.current = node; - } + // scroll container. One merged ref per inner ref, so React does not + // re-attach it on every render: the scroll listeners, the resize + // observer and the pinned dividers' read (syncPinnedDividers, on + // mount and on resize) run once, not once per render. + const existingRef = props.htmlProps.ref ?? null; + const cached = mergedScrollWrapperRef.current; + const mergedRef = + cached != null && cached.inner === existingRef + ? cached.merged + : (node: HTMLDivElement | null) => { + attachScrollShadow(node); + if (typeof existingRef === 'function') { + existingRef(node); + } else if (existingRef != null) { + // RefObject — assign through its writable `.current`. + existingRef.current = node; + } + }; + mergedScrollWrapperRef.current = { + inner: existingRef, + merged: mergedRef, }; return { ...props, From 0fb803752ff16384ccfb1a23c924597ead57c7a8 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 02:28:05 -0700 Subject: [PATCH 41/58] fix(core): the gliding row's divider is drawn by one of its cells, not a box in the row CodeBunny on #8656: - The row's ::after was a generated box in the , and Chromium laid the table out again with it: in a table whose columns do not fill it, every column snapped to its authored width for each glide. The line is now the ::after of one of the row's cells (the first that scrolls with the row), positioned against the row: the cell is not positioned, so its overflow does not clip it, and a box inside a cell leaves the column widths alone. Every declaration is on the line cell only, so a pinned cell's own ::after (its scroll shadow) is untouched. - The transparent-border rule matched any marked ancestor row, so a table nested in a moving panel lost its dividers. The moving row's own cells are marked now. - The glide test's animate stub had no finished promise: unhandled rejection, exit 1. --- packages/core/src/Table/glidingDivider.ts | 86 +++++++++++++------ .../useTableRowExpansion.glide.test.tsx | 24 +++++- 2 files changed, 84 insertions(+), 26 deletions(-) diff --git a/packages/core/src/Table/glidingDivider.ts b/packages/core/src/Table/glidingDivider.ts index ddbc2990536ad..650a225d42ed9 100644 --- a/packages/core/src/Table/glidingDivider.ts +++ b/packages/core/src/Table/glidingDivider.ts @@ -9,9 +9,10 @@ * carries each row's own border onto the stand-in) and `collapsedBottom` * @position Table internals. With collapsed borders the table paints every * border at its cell's static place, so a row that moves leaves its divider - * behind. For the glide only, the cells' borders go transparent (their width - * stays, so nothing reflows) and the row's ::after draws the same divider, - * which moves with the row. A pinned cell draws its own over it + * behind. For the glide only, the moving row's cells are marked: their + * borders go transparent (their width stays, so nothing reflows) and one of + * them draws the same divider across the whole row in its ::after, which + * moves with the row. A pinned cell draws its own over it * (plugins/stickyColumns). * * SYNC: When modified, update TableCell.tsx, plugins/rowExpansion and @@ -20,11 +21,21 @@ import * as stylex from '@stylexjs/stylex'; import {colorVars} from '../theme/tokens.stylex'; -import {tableRowMarker} from './table.stylex'; /** On a row while a glide moves it. */ export const GLIDING_ATTRIBUTE = 'data-astryx-row-gliding'; const GLIDING = '[data-astryx-row-gliding]'; +/** + * On each cell of a moving row; `line` on the one that draws the row's + * divider. On the cells themselves, so a table nested in a moving row keeps + * its own dividers. + */ +const CELL_ATTRIBUTE = 'data-astryx-cell-gliding'; +const CELL_GLIDING = '[data-astryx-cell-gliding]'; +const CELL_LINE = '[data-astryx-cell-gliding="line"]'; + +/** On a pinned (sticky) cell: plugins/stickyColumns. */ +const PINNED_ATTRIBUTE = 'data-astryx-table-pinned'; /** A row's own divider, carried for the glide (or a pinned cell's). */ export const APP_DIVIDER_WIDTH = '--_table-row-divider-app-width'; @@ -32,41 +43,54 @@ export const APP_DIVIDER_STYLE = '--_table-row-divider-app-style'; export const APP_DIVIDER_COLOR = '--_table-row-divider-app-color'; export const glidingDividerStyles = stylex.create({ - /** On every cell with a row divider: it goes transparent while the row moves. */ + /** + * On every cell with a row divider: it goes transparent while the row + * moves, and the row's line cell draws the divider across the row's bottom + * edge, where the collapsed border sits. The line is positioned against the + * row (the cell is not positioned), so the cell's own overflow does not clip + * it, and it is a box inside a cell, not a box in the row, so the table's + * column widths do not change. + */ cell: { borderBottomColor: { // A detail panel's cell carries its row's divider color (see // plugins/rowExpansion); every other cell reads the token. default: `var(--_table-panel-divider-color, ${colorVars['--color-border']})`, - [stylex.when.ancestor(GLIDING, tableRowMarker)]: 'transparent', + [CELL_GLIDING]: 'transparent', + }, + // Every declaration only on the line cell: a pinned cell's own ::after + // (its scroll shadow) stays untouched. + '::after': { + content: {default: null, [CELL_LINE]: '""'}, + position: {default: null, [CELL_LINE]: 'absolute'}, + insetInline: {default: null, [CELL_LINE]: 0}, + bottom: {default: null, [CELL_LINE]: 0}, + height: {default: null, [CELL_LINE]: 0}, + borderBottomWidth: { + default: null, + [CELL_LINE]: `var(${APP_DIVIDER_WIDTH}, 0px)`, + }, + borderBottomStyle: { + default: null, + [CELL_LINE]: `var(${APP_DIVIDER_STYLE}, solid)`, + }, + borderBottomColor: { + default: null, + [CELL_LINE]: `var(${APP_DIVIDER_COLOR}, ${colorVars['--color-border']})`, + }, + pointerEvents: {default: null, [CELL_LINE]: 'none'}, }, }, /** - * On every row: while it moves, its ::after draws the divider across the - * row's bottom edge, where the collapsed border sits, and moves with it. A - * cell's ::before or ::after is clipped by the cell's own overflow; the - * row's is not. The row's motion (a `translate`) makes it the line's - * containing block, and `position: relative` keeps it so. + * On every row: while it moves it is the line's containing block. Its + * motion (a `translate`) makes it one; `position: relative` keeps it so + * for a panel playing in, which does not move. */ row: { position: { default: null, [GLIDING]: 'relative', }, - '::after': { - content: { - default: null, - [GLIDING]: '""', - }, - position: 'absolute', - insetInline: 0, - bottom: 0, - height: 0, - borderBottomWidth: `var(${APP_DIVIDER_WIDTH}, 0px)`, - borderBottomStyle: `var(${APP_DIVIDER_STYLE}, solid)`, - borderBottomColor: `var(${APP_DIVIDER_COLOR}, ${colorVars['--color-border']})`, - pointerEvents: 'none', - }, }, }); @@ -232,6 +256,15 @@ export async function markGlidingRows( row.style.setProperty(APP_DIVIDER_STYLE, edge.style); row.style.setProperty(APP_DIVIDER_COLOR, edge.color); row.setAttribute(GLIDING_ATTRIBUTE, ''); + // The first cell that scrolls with the row draws the line; a pinned + // cell is positioned (the line would be its width) and draws its own. + let hasLine = false; + for (const cell of row.children) { + const isLine: boolean = + !hasLine && !cell.hasAttribute(PINNED_ATTRIBUTE); + cell.setAttribute(CELL_ATTRIBUTE, isLine ? 'line' : ''); + hasLine ||= isLine; + } } markedBy.set(row, batch); } @@ -247,6 +280,9 @@ export async function markGlidingRows( } markedBy.delete(row); row.removeAttribute(GLIDING_ATTRIBUTE); + for (const cell of row.children) { + cell.removeAttribute(CELL_ATTRIBUTE); + } row.style.removeProperty(APP_DIVIDER_WIDTH); row.style.removeProperty(APP_DIVIDER_STYLE); row.style.removeProperty(APP_DIVIDER_COLOR); diff --git a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx index 756b89dff711c..0eb00d3b7a2da 100644 --- a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx +++ b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx @@ -42,6 +42,7 @@ beforeEach(() => { return { cancel() {}, addEventListener() {}, + finished: Promise.resolve(), } as unknown as Animation; }; }); @@ -167,12 +168,25 @@ describe('an open panel carries its row divider (Table FR14)', () => { describe('markGlidingRows', () => { it('marks the moving rows and carries their divider while they move, and clears both after', async () => { const body = document.createElement('tbody'); + // Each row: a pinned cell, then two that scroll with it. const rows = [0, 1, 2, 3, 4].map(() => { const row = document.createElement('tr'); - row.append(document.createElement('td')); + const pinned = document.createElement('td'); + pinned.setAttribute('data-astryx-table-pinned', ''); + row.append( + pinned, + document.createElement('td'), + document.createElement('td'), + ); body.append(row); return row; }); + // A table nested in a moving row keeps its own dividers. + const nested = document.createElement('table'); + nested.innerHTML = ''; + rows[2].cells[1].append(nested); + const cellMarks = (row: HTMLTableRowElement) => + [...row.cells].map(cell => cell.getAttribute('data-astryx-cell-gliding')); document.body.append(body); let finish = () => {}; const glide = { @@ -193,6 +207,13 @@ describe('markGlidingRows', () => { expect( rows[2].style.getPropertyValue('--_table-row-divider-app-width'), ).toBe('0px'); + // The first cell that scrolls draws the line; the pinned one draws its + // own; the nested table's cell is not marked. + expect(cellMarks(rows[2])).toEqual(['', 'line', '']); + expect(cellMarks(rows[4])).toEqual([null, null, null]); + expect( + nested.querySelector('td')?.hasAttribute('data-astryx-cell-gliding'), + ).toBe(false); finish(); await done; @@ -202,6 +223,7 @@ describe('markGlidingRows', () => { expect( rows[2].style.getPropertyValue('--_table-row-divider-app-width'), ).toBe(''); + expect(cellMarks(rows[2])).toEqual([null, null, null]); body.remove(); }); }); From 88917e61ba0367b5b1b56f6e9b7d9ec4bd31905d Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 03:25:30 -0700 Subject: [PATCH 42/58] fix(core): pinned dividers and edge shadows follow each commit, not only mount and resize CodeBunny on #8654: - A border that changed after mount without a resize (a row highlighted on click) never reached the pinned cells. The plugin now re-reads them after each commit, every read before any write, writing a cell only when what it carries changes. - The stable merged ref left the edge shadows refreshing only on scroll and wrapper resize, so columns added without a resize left the end shadow off. The resize observer watches the table too, and the shadows refresh after each commit. New story PinnedDividersLive and two browser tests: a border set through React state after mount reaches the pinned cell at rest and scrolled, and the end shadow turns on when columns are added. Both checked in real Chromium (green 22,163,74 under pinned and unpinned cells alike; the end shadow 0, then 1). --- .../stories/TableStickyColumns.stories.tsx | 41 ++++++++++++ .../TableStickyDividers.a11y.browser.spec.ts | 28 ++++++++ .../stickyColumns/useTableStickyColumns.tsx | 65 +++++++++++++++---- 3 files changed, 121 insertions(+), 13 deletions(-) diff --git a/apps/storybook/stories/TableStickyColumns.stories.tsx b/apps/storybook/stories/TableStickyColumns.stories.tsx index 48848322cc25c..8d9a542499c22 100644 --- a/apps/storybook/stories/TableStickyColumns.stories.tsx +++ b/apps/storybook/stories/TableStickyColumns.stories.tsx @@ -173,6 +173,47 @@ export const PinnedDividers: Story = { }, }; +/** + * A border that changes after mount reaches the pinned cells, and the edge + * shadows follow columns added without a resize. "Highlight" sets the third + * row's border through React state; "Add columns" widens the table inside the + * same wrapper. + */ +export const PinnedDividersLive: Story = { + render: () => { + const sticky = useTableStickyColumns({ + startKeys: ['name'], + endKeys: ['status'], + }); + const [isHighlighted, setIsHighlighted] = useState(false); + const [hasMoreColumns, setHasMoreColumns] = useState(false); + const shown = hasMoreColumns + ? columns + : columns.filter(column => ['name', 'status'].includes(column.key)); + return ( +
    + {isHighlighted && ( + + )} +

    + {' '} + +

    +
    + + ); + }, +}; + /** * Pin the trailing `Status` column to the end edge. */ diff --git a/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts b/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts index 766e9bdb8cb86..01ca87a58f1d5 100644 --- a/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts +++ b/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts @@ -191,3 +191,31 @@ test("a theme's divider color and an app's row border carry over to the pinned c expect(red[0], `app at ${left}px is red`).toBeGreaterThan(red[2] + 60); } }); + +test('a border that changes after mount reaches the pinned cell', async ({ + page, +}) => { + await mount(page, 'pinned-dividers-live'); + await page.getByRole('button', {name: 'Add columns'}).click(); + await page.getByRole('button', {name: 'Highlight'}).click(); + for (const left of [0, 150]) { + await scrollTo(page, left); + const {pinned, unpinned} = await dividerPixels(page, 2); + expect(sameColumn(pinned, unpinned, 24), `at ${left}px`).toBe(true); + const green = darkest(pinned); + expect(green[1], `at ${left}px is green`).toBeGreaterThan(green[0] + 60); + } +}); + +test('the end shadow follows columns added without a resize', async ({ + page, +}) => { + const wrapper = await mount(page, 'pinned-dividers-live'); + const shadow = async () => + wrapper.evaluate(el => + (el as HTMLElement).style.getPropertyValue('--table-sticky-shadow-end'), + ); + expect(await shadow()).toBe('0'); + await page.getByRole('button', {name: 'Add columns'}).click(); + await expect.poll(shadow).toBe('1'); +}); diff --git a/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx b/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx index 2682b390f55d6..2663fa9b8a2c1 100644 --- a/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +++ b/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx @@ -13,7 +13,13 @@ * - /packages/core/src/Table/index.ts (exports) */ -import {useCallback, useMemo, useRef, type CSSProperties} from 'react'; +import { + useCallback, + useLayoutEffect, + useMemo, + useRef, + type CSSProperties, +} from 'react'; import * as stylex from '@stylexjs/stylex'; import {colorVars, durationVars, easeVars} from '../../../theme/tokens.stylex'; import type { @@ -167,7 +173,7 @@ const SHADOW_VAR_END = '--table-sticky-shadow-end'; // over the cell's border area, covers whatever the table painted there with // the pinned base, and draws the divider with the cell's own border tokens // (TableCell.tsx); a border an app or a theme sets on the cell or row directly -// is read on mount and on resize (syncPinnedDividers). +// is read after each commit and on resize (syncPinnedDividers). const PINNED_ATTRIBUTE = 'data-astryx-table-pinned'; /** Spread into a pinned cell's props: the attribute the divider sync finds. */ const PINNED_PROPS: Record = {[PINNED_ATTRIBUTE]: ''}; @@ -351,29 +357,45 @@ function collapsedBottom(cell: Element): BottomEdge { /** * Where an app styles a cell's or row's `border-bottom` directly, past the * custom properties, the pinned divider takes the border the table paints. - * Read on mount and on resize, never on scroll. + * Read after each commit and on resize, never on scroll. Every read comes + * before any write, so the style is computed once for all pinned cells, and a + * cell is written only when what it carries changes. */ function syncPinnedDividers(root: HTMLElement): void { + const changes: [HTMLElement, string[]][] = []; for (const cell of root.querySelectorAll( `[${PINNED_ATTRIBUTE}]`, )) { - cell.style.removeProperty(PINNED_DIVIDER_WIDTH); - cell.style.removeProperty(PINNED_DIVIDER_STYLE); - cell.style.removeProperty(PINNED_DIVIDER_COLOR); const want = collapsedBottom(cell); + const value = [`${want.width}px`, want.style, want.color]; + const carried = cell.style.getPropertyValue(PINNED_DIVIDER_WIDTH); + if (carried !== '') { + // It carries a border already: keep it while it is still the one. + const same = + carried === value[0] && + cell.style.getPropertyValue(PINNED_DIVIDER_STYLE) === value[1] && + cell.style.getPropertyValue(PINNED_DIVIDER_COLOR) === value[2]; + if (!same) { + changes.push([cell, value]); + } + continue; + } + // It draws its tokens: carry the border only where the table paints + // another. const drawn = getComputedStyle(cell, '::before'); - const drawnWidth = parseFloat(drawn.borderBottomWidth) || 0; const matches = - want.width === drawnWidth && + want.width === (parseFloat(drawn.borderBottomWidth) || 0) && (want.width === 0 || (want.style === drawn.borderBottomStyle && want.color === drawn.borderBottomColor)); - if (matches) { - continue; + if (!matches) { + changes.push([cell, value]); } - cell.style.setProperty(PINNED_DIVIDER_WIDTH, `${want.width}px`); - cell.style.setProperty(PINNED_DIVIDER_STYLE, want.style); - cell.style.setProperty(PINNED_DIVIDER_COLOR, want.color); + } + for (const [cell, [width, style, color]] of changes) { + cell.style.setProperty(PINNED_DIVIDER_WIDTH, width); + cell.style.setProperty(PINNED_DIVIDER_STYLE, style); + cell.style.setProperty(PINNED_DIVIDER_COLOR, color); } } @@ -409,6 +431,8 @@ export function useTableStickyColumns>( // listeners (synchronizing with the DOM) rather than React state, so // scrolling never triggers re-renders. const detachRef = useRef<(() => void) | null>(null); + // Re-reads the edge shadows and the pinned dividers; set while attached. + const refreshRef = useRef<(() => void) | null>(null); // The scroll container's merged ref, kept while the inner ref is the same. const mergedScrollWrapperRef = useRef<{ inner: ScrollWrapperRenderProps['htmlProps']['ref'] | null; @@ -417,6 +441,7 @@ export function useTableStickyColumns>( const attachScrollShadow = useCallback((el: HTMLDivElement | null) => { detachRef.current?.(); detachRef.current = null; + refreshRef.current = null; if (!el) { return; } @@ -453,13 +478,27 @@ export function useTableStickyColumns>( const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null; resizeObserver?.observe(el); + // The table too: columns added or widened change what scrolls without + // resizing the wrapper. + const table = el.querySelector('table'); + if (table != null) { + resizeObserver?.observe(table); + } resize(); + refreshRef.current = resize; detachRef.current = () => { el.removeEventListener('scroll', update); resizeObserver?.disconnect(); }; }, []); + // After each commit: a border that changed with the render (a row + // highlighted on click) reaches the pinned divider, and the edge shadows + // follow content that changed without a resize. + useLayoutEffect(() => { + refreshRef.current?.(); + }); + return useMemo( (): TablePlugin => ({ transformHeaderCell( From 82c240b5de84af9d6ea6cc9e5f942d2517fa13c0 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 04:12:50 -0700 Subject: [PATCH 43/58] fix(core): the pinned divider draws the cell's own border in CSS and carries only what wins over it CodeBunny on #8654: - A border that changed without a re-render of the hook's owner (an app's :hover rule, a colour-scheme switch after a carried border went away) stayed old under the pinned column. The pinned cell's ::before now inherits the cell's own border colour and style, so the cell's border, token or app rule, reaches it in CSS. Only a border that wins the collapse over the cell's own is carried (the cell's carry state), and the carry is dropped once the cell's own wins again. - The collapse now includes the next row's top border (its cell's and the row's), so a wider top border on the next row shows under the pinned column at its full width. Browser tests: a :hover rule's colour, a carry dropped after a highlight is undone, and the next row's 3 px top border, under the pinned cell as under the rest of the row. Checked in real Chromium at rest and scrolled 150 px, with the earlier theme, app-row and pin-start checks. --- .../stories/TableStickyColumns.stories.tsx | 16 +- .../TableStickyDividers.a11y.browser.spec.ts | 52 ++++++ .../stickyColumns/useTableStickyColumns.tsx | 164 +++++++++++------- 3 files changed, 166 insertions(+), 66 deletions(-) diff --git a/apps/storybook/stories/TableStickyColumns.stories.tsx b/apps/storybook/stories/TableStickyColumns.stories.tsx index 8d9a542499c22..99067d75c6ff4 100644 --- a/apps/storybook/stories/TableStickyColumns.stories.tsx +++ b/apps/storybook/stories/TableStickyColumns.stories.tsx @@ -175,9 +175,11 @@ export const PinnedDividers: Story = { /** * A border that changes after mount reaches the pinned cells, and the edge - * shadows follow columns added without a resize. "Highlight" sets the third - * row's border through React state; "Add columns" widens the table inside the - * same wrapper. + * shadows follow columns added without a resize. Hovering a row turns its + * line purple (an app's `:hover` rule on the cells); "Highlight" sets the + * third row's border through React state; "Thick top" gives the fourth row a + * 3 px top border, which wins the collapse over the third row's bottom; "Add + * columns" widens the table inside the same wrapper. */ export const PinnedDividersLive: Story = { render: () => { @@ -187,18 +189,26 @@ export const PinnedDividersLive: Story = { }); const [isHighlighted, setIsHighlighted] = useState(false); const [hasMoreColumns, setHasMoreColumns] = useState(false); + const [hasThickTop, setHasThickTop] = useState(false); const shown = hasMoreColumns ? columns : columns.filter(column => ['name', 'status'].includes(column.key)); return (
    + {isHighlighted && ( )} + {hasThickTop && ( + + )}

    {' '} + {' '} diff --git a/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts b/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts index 01ca87a58f1d5..84aa402506947 100644 --- a/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts +++ b/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts @@ -219,3 +219,55 @@ test('the end shadow follows columns added without a resize', async ({ await page.getByRole('button', {name: 'Add columns'}).click(); await expect.poll(shadow).toBe('1'); }); + +test("an app's :hover rule on the cells reaches the pinned cell in CSS", async ({ + page, +}) => { + await mount(page, 'pinned-dividers-live'); + await page.getByRole('button', {name: 'Add columns'}).click(); + for (const left of [0, 150]) { + await scrollTo(page, left); + await page + .locator('.astryx-table-scroll-wrapper tbody tr') + .nth(0) + .hover({position: {x: 400, y: 10}}); + const {pinned, unpinned} = await dividerPixels(page, 0); + expect(sameColumn(pinned, unpinned, 24), `at ${left}px`).toBe(true); + const purple = darkest(pinned); + expect(purple[2], `at ${left}px is purple`).toBeGreaterThan(purple[1] + 60); + } +}); + +test('a pinned cell stops carrying a border once its own wins again', async ({ + page, +}) => { + await mount(page, 'pinned-dividers-live'); + await page.getByRole('button', {name: 'Add columns'}).click(); + const highlight = page.getByRole('button', {name: 'Highlight'}); + await highlight.click(); + await highlight.click(); + const pinnedCell = page + .locator('.astryx-table-scroll-wrapper tbody tr') + .nth(2) + .locator('td') + .first(); + // Nothing carried: the token (and a later theme change) reaches it in CSS. + await expect(pinnedCell).toHaveAttribute('data-astryx-table-pinned', ''); + const {pinned, unpinned} = await dividerPixels(page, 2); + expect(sameColumn(pinned, unpinned)).toBe(true); +}); + +test("the next row's wider top border wins under the pinned cell too", async ({ + page, +}) => { + await mount(page, 'pinned-dividers-live'); + await page.getByRole('button', {name: 'Add columns'}).click(); + await page.getByRole('button', {name: 'Thick top'}).click(); + for (const left of [0, 150]) { + await scrollTo(page, left); + const {pinned, unpinned} = await dividerPixels(page, 2); + expect(sameColumn(pinned, unpinned, 24), `at ${left}px`).toBe(true); + const orange = darkest(pinned); + expect(orange[0], `at ${left}px is orange`).toBeGreaterThan(orange[2] + 60); + } +}); diff --git a/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx b/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx index 2663fa9b8a2c1..0cad3246f3b24 100644 --- a/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +++ b/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx @@ -171,12 +171,18 @@ const SHADOW_VAR_END = '--table-sticky-shadow-end'; // whichever scrolled cell's border passes beneath it, and missing where that // cell has none. The cell's ::before (already the row overlay's layer) extends // over the cell's border area, covers whatever the table painted there with -// the pinned base, and draws the divider with the cell's own border tokens -// (TableCell.tsx); a border an app or a theme sets on the cell or row directly -// is read after each commit and on resize (syncPinnedDividers). +// the pinned base, and draws the divider in the cell's own border color and +// style (inherited, so a theme, a token switch or an app's rule on the cell, +// `:hover` included, carries over in CSS) at the cell's divider width +// (TableCell.tsx). Where another border wins the collapse instead (an app's +// border on the row, or the next row's top border), it is read after each +// commit and on resize and carried as the cell's `carry` state +// (syncPinnedDividers). const PINNED_ATTRIBUTE = 'data-astryx-table-pinned'; /** Spread into a pinned cell's props: the attribute the divider sync finds. */ const PINNED_PROPS: Record = {[PINNED_ATTRIBUTE]: ''}; +const CARRY = 'carry'; +const CARRYING = '[data-astryx-table-pinned="carry"]'; const PINNED_DIVIDER_WIDTH = '--_table-row-divider-app-width'; const PINNED_DIVIDER_STYLE = '--_table-row-divider-app-style'; const PINNED_DIVIDER_COLOR = '--_table-row-divider-app-color'; @@ -205,8 +211,14 @@ const stickyStyles = stylex.create({ // scrolled cell's border), then the divider over it. marginBottom: `calc(-1 * ${DIVIDER_WIDTH})`, borderBottomWidth: DIVIDER_WIDTH, - borderBottomStyle: `var(${PINNED_DIVIDER_STYLE}, solid)`, - borderBottomColor: `var(${PINNED_DIVIDER_COLOR}, ${colorVars['--color-border']})`, + borderBottomStyle: { + default: 'inherit', + [CARRYING]: `var(${PINNED_DIVIDER_STYLE}, solid)`, + }, + borderBottomColor: { + default: 'inherit', + [CARRYING]: `var(${PINNED_DIVIDER_COLOR}, ${colorVars['--color-border']})`, + }, backgroundImage: `linear-gradient(${PINNED_BASE}, ${PINNED_BASE})`, backgroundSize: `100% ${DIVIDER_WIDTH}`, backgroundPosition: 'bottom', @@ -315,87 +327,113 @@ interface BottomEdge { color: string; } -function bottomEdge(element: Element): BottomEdge { +function edgeOf(element: Element | null, side: 'Bottom' | 'Top'): BottomEdge { + if (element == null) { + return {width: 0, style: 'none', color: ''}; + } const style = getComputedStyle(element); return { - width: parseFloat(style.borderBottomWidth) || 0, - style: style.borderBottomStyle, - color: style.borderBottomColor, + width: + parseFloat( + style.getPropertyValue(`border-${side.toLowerCase()}-width`), + ) || 0, + style: style.getPropertyValue(`border-${side.toLowerCase()}-style`), + color: style.getPropertyValue(`border-${side.toLowerCase()}-color`), }; } /** - * The bottom border the table paints for a cell: the cell's own, or its row's - * where an app styles the row and the row's wins the collapse (wider, then - * the stronger style; the cell wins a tie). + * The border the table paints under a cell, by the collapsing rules (CSS 2.1 + * 17.6.2.1): of the cell's own bottom, the cell's below it (top), its row's + * bottom and the next row's top, `hidden` wins, then the wider, then the + * stronger style; a tie goes to a cell over a row, and to the upper one. + * `isOwn` says the cell's own border won. */ -function collapsedBottom(cell: Element): BottomEdge { - const own = bottomEdge(cell); +function collapsedBottom(cell: HTMLTableCellElement): BottomEdge & { + isOwn: boolean; +} { const row = cell.parentElement; - if (row == null) { - return own; - } - const other = bottomEdge(row); - if (own.style === 'hidden' || other.style === 'hidden') { - return {width: 0, style: 'none', color: own.color}; - } - const drawn = (edge: BottomEdge) => edge.style !== 'none' && edge.width > 0; - if (!drawn(other)) { - return own; - } - if (!drawn(own) || other.width > own.width) { - return other; + const nextRow = row?.nextElementSibling ?? null; + const nextCell = nextRow?.children[cell.cellIndex] ?? null; + // In tie order. + const candidates = [ + edgeOf(cell, 'Bottom'), + edgeOf(nextCell, 'Top'), + edgeOf(row, 'Bottom'), + edgeOf(nextRow, 'Top'), + ]; + const own = candidates[0]; + if (candidates.some(edge => edge.style === 'hidden')) { + return { + width: 0, + style: 'none', + color: own.color, + isOwn: own.style === 'hidden', + }; } - if (other.width < own.width) { - return own; + const strength = (edge: BottomEdge) => + edge.style === 'none' || edge.width === 0 + ? [-1, -1] + : [edge.width, STYLE_RANK.indexOf(edge.style)]; + let winner = own; + for (const edge of candidates.slice(1)) { + const [width, rank] = strength(edge); + const [bestWidth, bestRank] = strength(winner); + if (width > bestWidth || (width === bestWidth && rank > bestRank)) { + winner = edge; + } } - return STYLE_RANK.indexOf(other.style) > STYLE_RANK.indexOf(own.style) - ? other - : own; + return {...winner, isOwn: winner === own}; } /** - * Where an app styles a cell's or row's `border-bottom` directly, past the - * custom properties, the pinned divider takes the border the table paints. - * Read after each commit and on resize, never on scroll. Every read comes - * before any write, so the style is computed once for all pinned cells, and a - * cell is written only when what it carries changes. + * Where another border than the cell's own wins the collapse (an app's border + * on the row, the next row's top border, or a width on the cell past its + * divider width), the pinned divider carries it; otherwise it draws the + * cell's own border in CSS and carries nothing, so a later token or theme + * change reaches it. Read after each commit and on resize, never on scroll. + * Every read comes before any write, and a cell is written only when what it + * carries changes. */ function syncPinnedDividers(root: HTMLElement): void { - const changes: [HTMLElement, string[]][] = []; - for (const cell of root.querySelectorAll( + const changes: [HTMLTableCellElement, string[] | null][] = []; + for (const cell of root.querySelectorAll( `[${PINNED_ATTRIBUTE}]`, )) { - const want = collapsedBottom(cell); - const value = [`${want.width}px`, want.style, want.color]; - const carried = cell.style.getPropertyValue(PINNED_DIVIDER_WIDTH); - if (carried !== '') { - // It carries a border already: keep it while it is still the one. - const same = - carried === value[0] && - cell.style.getPropertyValue(PINNED_DIVIDER_STYLE) === value[1] && - cell.style.getPropertyValue(PINNED_DIVIDER_COLOR) === value[2]; - if (!same) { - changes.push([cell, value]); + const edge = collapsedBottom(cell); + const dividerWidth = + parseFloat( + getComputedStyle(cell).getPropertyValue('--_table-row-divider-width'), + ) || 0; + const isCarrying = cell.getAttribute(PINNED_ATTRIBUTE) === CARRY; + if (edge.isOwn && edge.width === dividerWidth) { + if (isCarrying) { + changes.push([cell, null]); } continue; } - // It draws its tokens: carry the border only where the table paints - // another. - const drawn = getComputedStyle(cell, '::before'); - const matches = - want.width === (parseFloat(drawn.borderBottomWidth) || 0) && - (want.width === 0 || - (want.style === drawn.borderBottomStyle && - want.color === drawn.borderBottomColor)); - if (!matches) { + const value = [`${edge.width}px`, edge.style, edge.color]; + const isSame = + isCarrying && + cell.style.getPropertyValue(PINNED_DIVIDER_WIDTH) === value[0] && + cell.style.getPropertyValue(PINNED_DIVIDER_STYLE) === value[1] && + cell.style.getPropertyValue(PINNED_DIVIDER_COLOR) === value[2]; + if (!isSame) { changes.push([cell, value]); } } - for (const [cell, [width, style, color]] of changes) { - cell.style.setProperty(PINNED_DIVIDER_WIDTH, width); - cell.style.setProperty(PINNED_DIVIDER_STYLE, style); - cell.style.setProperty(PINNED_DIVIDER_COLOR, color); + for (const [cell, value] of changes) { + if (value == null) { + cell.setAttribute(PINNED_ATTRIBUTE, ''); + cell.style.removeProperty(PINNED_DIVIDER_WIDTH); + cell.style.removeProperty(PINNED_DIVIDER_STYLE); + cell.style.removeProperty(PINNED_DIVIDER_COLOR); + continue; + } + cell.style.setProperty(PINNED_DIVIDER_WIDTH, value[0]); + cell.style.setProperty(PINNED_DIVIDER_STYLE, value[1]); + cell.style.setProperty(PINNED_DIVIDER_COLOR, value[2]); + cell.setAttribute(PINNED_ATTRIBUTE, CARRY); } } From ed2c0bd9b539d8533e0cd7c6078baa2061df2728 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 05:07:05 -0700 Subject: [PATCH 44/58] fix(core): the pinned divider reads across the header edge and CSS row states; an open panel carries its row's divider CodeBunny on #8654: - The collapse stopped at the header row, so a wider top border on the first body row was drawn over by the pinned header's line. It now reads across the section edge, the thead and tbody borders included. - A row border recoloured by CSS state alone (tr:hover) kept its carried colour under the pinned column. Pointer and focus changes inside the table re-read the rows they touch (and the row above each), once per frame. Moved here from #8656 (one divider PR, one glide PR): an open detail panel's divider carries its row's border (Table FR14), a theme's table-cell styling or an app's own on the row's cells, and the row's own where it wins, as private --_table-panel-divider-* values on the panel's cell, each always written so a nested panel never inherits them. The collapse helpers move to Table/rowDivider.ts, shared by both plugins. Browser tests (two new, ten in all) pass against the built stories in real Chromium; a jsdom test covers the panel. --- .changeset/table-pinned-dividers.md | 2 +- .../stories/TableStickyColumns.stories.tsx | 21 ++- .../TableStickyDividers.a11y.browser.spec.ts | 52 ++++++- .../useTableRowExpansion.test.tsx | 53 +++++-- .../rowExpansion/useTableRowExpansion.tsx | 36 ++++- .../stickyColumns/useTableStickyColumns.tsx | 139 +++++++---------- packages/core/src/Table/rowDivider.ts | 143 ++++++++++++++++++ 7 files changed, 344 insertions(+), 102 deletions(-) create mode 100644 packages/core/src/Table/rowDivider.ts diff --git a/.changeset/table-pinned-dividers.md b/.changeset/table-pinned-dividers.md index 2fc2e1cf22fae..58e75b133f1f9 100644 --- a/.changeset/table-pinned-dividers.md +++ b/.changeset/table-pinned-dividers.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[fix] Table: a pinned column (`useTableStickyColumns`) now draws its own row dividers, so the line under it no longer goes missing while the table scrolls horizontally. A theme's border color and an app's own `border-bottom` on a row or cell carry onto the pinned cells. +[fix] Table: a pinned column (`useTableStickyColumns`) now draws its own row dividers, so the line under it no longer goes missing while the table scrolls horizontally. A theme's border color and an app's own borders on a row or cell carry onto the pinned cells, and an open detail panel (`useTableRowExpansion`) carries its row's divider the same way. @cixzhang diff --git a/apps/storybook/stories/TableStickyColumns.stories.tsx b/apps/storybook/stories/TableStickyColumns.stories.tsx index 99067d75c6ff4..2d612eed0a004 100644 --- a/apps/storybook/stories/TableStickyColumns.stories.tsx +++ b/apps/storybook/stories/TableStickyColumns.stories.tsx @@ -178,8 +178,11 @@ export const PinnedDividers: Story = { * shadows follow columns added without a resize. Hovering a row turns its * line purple (an app's `:hover` rule on the cells); "Highlight" sets the * third row's border through React state; "Thick top" gives the fourth row a - * 3 px top border, which wins the collapse over the third row's bottom; "Add - * columns" widens the table inside the same wrapper. + * 3 px top border, which wins the collapse over the third row's bottom; "Row + * borders" draws an app's 2 px blue border on the rows that turns red on + * hover (`tr:hover`); "First row top" gives the first body row a 3 px top + * border, which wins over the header's bottom; "Add columns" widens the + * table inside the same wrapper. */ export const PinnedDividersLive: Story = { render: () => { @@ -190,6 +193,8 @@ export const PinnedDividersLive: Story = { const [isHighlighted, setIsHighlighted] = useState(false); const [hasMoreColumns, setHasMoreColumns] = useState(false); const [hasThickTop, setHasThickTop] = useState(false); + const [hasRowBorders, setHasRowBorders] = useState(false); + const [hasFirstRowTop, setHasFirstRowTop] = useState(false); const shown = hasMoreColumns ? columns : columns.filter(column => ['name', 'status'].includes(column.key)); @@ -199,6 +204,12 @@ export const PinnedDividersLive: Story = { {isHighlighted && ( )} + {hasRowBorders && ( + + )} + {hasFirstRowTop && ( + + )} {hasThickTop && ( )} @@ -209,6 +220,12 @@ export const PinnedDividersLive: Story = { {' '} + {' '} + {' '} diff --git a/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts b/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts index 84aa402506947..4f4065670419e 100644 --- a/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts +++ b/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts @@ -60,7 +60,8 @@ async function scrollTo(page: Page, left: number) { type Rgb = [number, number, number]; /** - * The column of pixels within 3 px of a body row's bottom edge, top to + * The column of pixels within 3 px of a body row's bottom edge (-1: the + * header row's), top to * bottom, at an x inside the pinned cell and at an x inside the first * unpinned cell visible past it. */ @@ -69,10 +70,13 @@ async function dividerPixels( row: number, ): Promise<{pinned: Rgb[]; unpinned: Rgb[]}> { const points = await page.evaluate(index => { - const rows = document.querySelectorAll( - '.astryx-table-scroll-wrapper tbody tr', - ); - const tr = rows[index] as HTMLElement; + const tr = ( + index < 0 + ? document.querySelector('.astryx-table-scroll-wrapper thead tr') + : document.querySelectorAll('.astryx-table-scroll-wrapper tbody tr')[ + index + ] + ) as HTMLElement; const pinned = tr.children[0].getBoundingClientRect(); return { y: Math.round(pinned.bottom), @@ -271,3 +275,41 @@ test("the next row's wider top border wins under the pinned cell too", async ({ expect(orange[0], `at ${left}px is orange`).toBeGreaterThan(orange[2] + 60); } }); + +test('a row border recoloured by CSS state alone (tr:hover) reaches the pinned cell', async ({ + page, +}) => { + await mount(page, 'pinned-dividers-live'); + await page.getByRole('button', {name: 'Add columns'}).click(); + await page.getByRole('button', {name: 'Row borders'}).click(); + for (const left of [0, 150]) { + await scrollTo(page, left); + await page + .locator('.astryx-table-scroll-wrapper tbody tr') + .nth(0) + .hover({position: {x: 400, y: 10}}); + await expect + .poll(async () => { + const {pinned, unpinned} = await dividerPixels(page, 0); + const red = darkest(pinned); + return sameColumn(pinned, unpinned, 24) && red[0] > red[2] + 60; + }, `at ${left}px`) + .toBe(true); + } +}); + +test("the first body row's wider top border wins under the pinned header too", async ({ + page, +}) => { + await mount(page, 'pinned-dividers-live'); + await page.getByRole('button', {name: 'Add columns'}).click(); + await page.getByRole('button', {name: 'First row top'}).click(); + for (const left of [0, 150]) { + await scrollTo(page, left); + // The header's bottom edge, under the pinned header cell and past it. + const {pinned, unpinned} = await dividerPixels(page, -1); + expect(sameColumn(pinned, unpinned, 24), `at ${left}px`).toBe(true); + const orange = darkest(pinned); + expect(orange[0], `at ${left}px is orange`).toBeGreaterThan(orange[2] + 60); + } +}); diff --git a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx index 47950ccc27648..2843c197140d7 100644 --- a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +++ b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx @@ -296,9 +296,10 @@ describe('useTableRowExpansion (detail panel)', () => { for (const cell of cellsOf('Ada')) { expect(cell).toHaveStyle({borderBottomWidth: '0'}); } - expect(panelCell()).toHaveStyle({ - borderBottomWidth: 'var(--border-width)', - }); + // The token width, unless the row hands down its own. + expect(getComputedStyle(panelCell()).borderBottomWidth).toMatch( + /var\(--_table-panel-divider-width,\s*var\(--border-width\)\)/, + ); }); it('leaves collapsed rows keeping their own divider', () => { @@ -310,16 +311,50 @@ describe('useTableRowExpansion (detail panel)', () => { it('draws no panel divider on a table without row dividers', () => { render(); - expect(panelCell()).not.toHaveStyle({ - borderBottomWidth: 'var(--border-width)', - }); + expect(getComputedStyle(panelCell()).borderBottomWidth).not.toMatch( + /--border-width/, + ); + }); + + it("carries the row's own divider onto the panel (Table FR14)", () => { + render(); + const row = cellsOf('Ada')[0].parentElement as HTMLTableRowElement; + // An app's (or a theme's) border on the row's cells. + for (const cell of row.cells) { + cell.style.borderBottomColor = 'rgb(255, 0, 0)'; + cell.style.borderBottomStyle = 'dashed'; + } + fireEvent.click(screen.getAllByRole('button', {name: /expand/i})[0]); + const cell = panelCell(); + expect(cell.style.getPropertyValue('--_table-panel-divider-color')).toBe( + 'rgb(255, 0, 0)', + ); + expect(cell.style.getPropertyValue('--_table-panel-divider-style')).toBe( + 'dashed', + ); + // The panel keeps its own width (a value, never inherited from a panel + // around it) while the row's own border does not win. + expect(cell.style.getPropertyValue('--_table-panel-divider-width')).toBe( + 'initial', + ); + // An app's wider border on the row itself wins; the next commit + // (opening another row) reads it. + row.style.borderBottom = '3px solid rgb(0, 0, 255)'; + fireEvent.click(screen.getAllByRole('button', {name: /expand/i})[0]); + expect(cell.style.getPropertyValue('--_table-panel-divider-width')).toBe( + '3px', + ); + expect(cell.style.getPropertyValue('--_table-panel-divider-color')).toBe( + 'rgb(0, 0, 255)', + ); }); it('draws the panel divider under grid dividers too', () => { render(); - expect(panelCell()).toHaveStyle({ - borderBottomWidth: 'var(--border-width)', - }); + // The token width, unless the row hands down its own. + expect(getComputedStyle(panelCell()).borderBottomWidth).toMatch( + /var\(--_table-panel-divider-width,\s*var\(--border-width\)\)/, + ); }); }); diff --git a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx index adabb5e99734b..0fe7532160459 100644 --- a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +++ b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx @@ -18,7 +18,7 @@ * - /packages/core/src/Table/useTableRowExpansion.doc.mjs */ -import {useMemo, useRef, type ReactNode} from 'react'; +import {useLayoutEffect, useMemo, useRef, type ReactNode} from 'react'; import * as stylex from '@stylexjs/stylex'; import { spacingVars, @@ -29,6 +29,7 @@ import { easeVars, } from '../../../theme/tokens.stylex'; import {tableRowMarker} from '../../table.stylex'; +import {panelDividerFor} from '../../rowDivider'; import {Icon} from '../../../Icon'; import {VisuallyHidden} from '../../../VisuallyHidden'; import {resolveContextActions} from '../../tableContextMenu'; @@ -216,16 +217,19 @@ const dividerStyles = stylex.create({ expandedRowCell: { borderBottomWidth: 0, }, + // The row's divider, carried down: its row's themed or app-set border + // reaches the panel through `--_table-panel-divider-*` on the panel's cell + // (set after each commit, see ExpansionPanelCell), the tokens otherwise. panelCell: { borderBottomWidth: { - default: borderVars['--border-width'], + default: `var(--_table-panel-divider-width, ${borderVars['--border-width']})`, // Same rule TableCell uses: no trailing line under the last row of the // table. Scoped to the marker so

    , also a :last-child, does not // match and suppress every panel's border. [stylex.when.ancestor(':last-child', tableRowMarker)]: '0', }, - borderBottomStyle: 'solid', - borderBottomColor: colorVars['--color-border'], + borderBottomStyle: 'var(--_table-panel-divider-style, solid)', + borderBottomColor: `var(--_table-panel-divider-color, ${colorVars['--color-border']})`, }, }); @@ -352,8 +356,32 @@ function ExpansionPanelCell({ }) { const ctx = useTableContext(); const hasRowDividers = ctx?.dividers === 'rows' || ctx?.dividers === 'grid'; + const cellRef = useRef(null); + // The panel carries its row's divider (a theme's `table-cell` border or an + // app's own never reach this cell, which is not one of the row's cells). + // Read after each commit; written only when it differs. + useLayoutEffect(() => { + const cell = cellRef.current; + const row = cell?.parentElement?.previousElementSibling; + if (cell == null || row == null || !hasRowDividers) { + return; + } + const edge = panelDividerFor(row); + if (edge == null) { + return; + } + const set = (name: string, value: string) => { + if (cell.style.getPropertyValue(name) !== value) { + cell.style.setProperty(name, value); + } + }; + set('--_table-panel-divider-width', edge.width); + set('--_table-panel-divider-style', edge.style); + set('--_table-panel-divider-color', edge.color); + }); return ( `), as custom property + * values for the panel's cell. Every value is written, `initial` where the + * panel keeps its own, so a panel nested in another never inherits that + * one's. + */ +export function panelDividerFor(row: Element): { + width: string; + style: string; + color: string; +} | null { + const rowCell = row.lastElementChild; + if (rowCell == null) { + return null; + } + const rowStyle = getComputedStyle(row); + const rowWidth = parseFloat(rowStyle.borderBottomWidth) || 0; + if (rowStyle.borderBottomStyle !== 'none' && rowWidth > 0) { + return { + width: `${rowWidth}px`, + style: rowStyle.borderBottomStyle, + color: rowStyle.borderBottomColor, + }; + } + const cellStyle = getComputedStyle(rowCell); + return { + width: 'initial', + style: cellStyle.borderBottomStyle, + color: cellStyle.borderBottomColor, + }; +} From 57ab35fd75739c8cc42a259e88db16fdac1dc184 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 05:20:59 -0700 Subject: [PATCH 45/58] fix(core): the glide outranks theme and app cell colours, keeps the last row under the others, and skips grid tables CodeBunny on #8656, with D37 scope (c): - A theme's table-cell border colour or an app stylesheet on td borders outranked the glide's transparent rule, so the static line stayed at the row's final place. The marked cells' colour is written inline for the glide and given back after. - The table's last row glides a pixel farther than the rows above it, so mid-glide it covered the pinned divider above it. Each moving row is its own layer (z-index 1, the last row 0, both under a sticky header). - Grid tables do not glide yet (Table FR15): their column lines are painted at the cells' static places too. With dividers="grid" the rows change at once; doc and Changeset say so. - Without a handle the chevrons drop the spring's inline transition (AST-105 FR2), as Collapsible and TreeList do. - The panel-divider carry moved to #8654; the panel keeps what it carries while its row glides. Browser spec TableRowGlide (seven checks, run twice against the built stories in real Chromium): the pinned divider under every moving row matches the unpinned one; a theme's blue and an app's red move with their rows on pinned and unpinned cells; the chevron turns on the glide; a grid table and Reduce Motion do not glide; under a finger Reduce Motion fades in place without a translate; column widths hold; the open panel draws its divider. GlidingRows now has seven rows and themed/grid controls. --- .changeset/table-row-expansion-glide.md | 2 +- .../stories/TableRowExpansion.stories.tsx | 37 +++-- .../TableRowGlide.a11y.browser.spec.ts | 140 +++++++++++++++--- packages/core/src/Table/glidingDivider.ts | 20 +++ .../useTableRowExpansion.glide.test.tsx | 18 ++- .../rowExpansion/useTableRowExpansion.tsx | 35 ++++- .../src/Table/useTableRowExpansion.doc.mjs | 4 +- 7 files changed, 214 insertions(+), 42 deletions(-) diff --git a/.changeset/table-row-expansion-glide.md b/.changeset/table-row-expansion-glide.md index 2234eebc46993..ddbfdc4686294 100644 --- a/.changeset/table-row-expansion-glide.md +++ b/.changeset/table-row-expansion-glide.md @@ -2,4 +2,4 @@ '@astryxdesign/core': patch --- -Table: `useTableRowExpansion` takes an `animatedLayout` handle from `useAnimatedLayout`. With it, opening or closing a row's panel glides the rows after it to their new places instead of jumping, and each row's divider moves with its row. Under Reduce Motion the rows change at once. +Table: `useTableRowExpansion` takes an `animatedLayout` handle from `useAnimatedLayout`. With it, opening or closing a row's panel glides the rows after it to their new places instead of jumping, and each row's divider moves with its row. Under Reduce Motion, and in a table with `dividers="grid"`, the rows change at once. diff --git a/apps/storybook/stories/TableRowExpansion.stories.tsx b/apps/storybook/stories/TableRowExpansion.stories.tsx index c4087dd37f69a..510ab5dd0fa9d 100644 --- a/apps/storybook/stories/TableRowExpansion.stories.tsx +++ b/apps/storybook/stories/TableRowExpansion.stories.tsx @@ -1,6 +1,6 @@ // Copyright (c) Meta Platforms, Inc. and affiliates. -import {useMemo, useRef, useState} from 'react'; +import React, {useMemo, useRef, useState} from 'react'; import type {Meta, StoryObj} from '@storybook/react'; import { Table, @@ -167,15 +167,24 @@ export const NotAllRowsExpandable: Story = { }, }; +const manyOrders: Order[] = Array.from({length: 2000}, (_, index) => ({ + ...orders[index % orders.length], + id: `ord-${String(index + 1).padStart(4, '0')}`, +})); +const sevenOrders = manyOrders.slice(0, 7); + /** * With an `animatedLayout` handle, opening or closing a row's panel glides the * rows after it instead of jumping them, the panel plays in on the same * motion, and each row's divider moves with its row. The Customer column is * pinned and the table scrolls horizontally, so the pinned cells' own - * dividers move too. Under Reduce Motion the rows change at once. + * dividers move too. Under Reduce Motion, and with `dividers="grid"` (the + * `grid` control), the rows change at once. The `themed` control sets a blue + * theme border and an app's red border on the third row's cells. */ -export const GlidingRows: Story = { - render: () => { +export const GlidingRows: StoryObj<{themed?: boolean; grid?: boolean}> = { + args: {themed: false, grid: false}, + render: ({themed, grid}) => { const containerRef = useRef(null); const animatedLayout = useAnimatedLayout({containerRef}); const [expandedKeys, setExpandedKeys] = useState>( @@ -193,14 +202,19 @@ export const GlidingRows: Story = { () => columns.map(column => ({...column, width: pixel(220)})), [], ); + const style: React.CSSProperties & Record = { + maxWidth: 640, + ...(themed === true ? {'--color-border': 'rgb(37, 99, 235)'} : {}), + }; return ( -
    +
    + {themed && ( + + )}
    edge.style === 'hidden')) { - return { - width: 0, - style: 'none', - color: own.color, - isOwn: own.style === 'hidden', - }; - } - const strength = (edge: BottomEdge) => - edge.style === 'none' || edge.width === 0 - ? [-1, -1] - : [edge.width, STYLE_RANK.indexOf(edge.style)]; - let winner = own; - for (const edge of candidates.slice(1)) { - const [width, rank] = strength(edge); - const [bestWidth, bestRank] = strength(winner); - if (width > bestWidth || (width === bestWidth && rank > bestRank)) { - winner = edge; - } - } - return {...winner, isOwn: winner === own}; -} - /** * Where another border than the cell's own wins the collapse (an app's border * on the row, the next row's top border, or a width on the cell past its @@ -395,11 +319,20 @@ function collapsedBottom(cell: HTMLTableCellElement): BottomEdge & { * Every read comes before any write, and a cell is written only when what it * carries changes. */ -function syncPinnedDividers(root: HTMLElement): void { +function syncPinnedDividers( + root: HTMLElement, + rows?: ReadonlySet, +): void { const changes: [HTMLTableCellElement, string[] | null][] = []; - for (const cell of root.querySelectorAll( - `[${PINNED_ATTRIBUTE}]`, - )) { + const cells = + rows == null + ? root.querySelectorAll(`[${PINNED_ATTRIBUTE}]`) + : [...rows].flatMap(row => [ + ...row.querySelectorAll( + `:scope > [${PINNED_ATTRIBUTE}]`, + ), + ]); + for (const cell of cells) { const edge = collapsedBottom(cell); const dividerWidth = parseFloat( @@ -513,6 +446,46 @@ export function useTableStickyColumns>( syncPinnedDividers(el); }; el.addEventListener('scroll', update, {passive: true}); + // A row border can change by CSS state alone (`tr:hover`, + // `:focus-within`): on pointer and focus changes, the rows they touch + // (and the row above each, whose divider a row's top border joins) are + // read again, once per frame. + const touched = new Set(); + let frame = 0; + const onStateChange = (event: Event) => { + for (const target of [ + event.target, + (event as PointerEvent | FocusEvent).relatedTarget, + ]) { + const row = target instanceof Element ? target.closest('tr') : null; + if (row == null || !el.contains(row)) { + continue; + } + touched.add(row); + const above = + row.previousElementSibling ?? + row.parentElement?.previousElementSibling?.lastElementChild; + if (above != null) { + touched.add(above); + } + } + if (touched.size > 0 && frame === 0) { + frame = requestAnimationFrame(() => { + frame = 0; + syncPinnedDividers(el, new Set(touched)); + touched.clear(); + }); + } + }; + const stateEvents = [ + 'pointerover', + 'pointerout', + 'focusin', + 'focusout', + ] as const; + for (const type of stateEvents) { + el.addEventListener(type, onStateChange, {passive: true}); + } const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null; resizeObserver?.observe(el); @@ -526,6 +499,10 @@ export function useTableStickyColumns>( refreshRef.current = resize; detachRef.current = () => { el.removeEventListener('scroll', update); + for (const type of stateEvents) { + el.removeEventListener(type, onStateChange); + } + cancelAnimationFrame(frame); resizeObserver?.disconnect(); }; }, []); diff --git a/packages/core/src/Table/rowDivider.ts b/packages/core/src/Table/rowDivider.ts new file mode 100644 index 0000000000000..d16f629d72cf8 --- /dev/null +++ b/packages/core/src/Table/rowDivider.ts @@ -0,0 +1,143 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file rowDivider.ts + * @input Table cells and rows, their computed borders + * @output Exports `collapsedBottom` (the border the table paints under a + * cell, by the collapsing rules) and `panelDividerFor` (the divider a + * detail panel takes from its row) + * @position Table internals, shared by the sticky-columns and row-expansion + * plugins: a divider drawn somewhere else than the table paints it (a + * pinned cell's, a detail panel's) reads here which border the table would + * paint, so a theme's or an app's border carries over. + * + * SYNC: When modified, update plugins/stickyColumns and plugins/rowExpansion. + */ + +/** Border styles by collapsed-border precedence, weakest first (CSS 2.1). */ +const STYLE_RANK = [ + 'inset', + 'groove', + 'outset', + 'ridge', + 'dotted', + 'dashed', + 'solid', + 'double', +]; + +export interface BottomEdge { + width: number; + style: string; + color: string; +} + +function edgeOf(element: Element | null, side: 'Bottom' | 'Top'): BottomEdge { + if (element == null) { + return {width: 0, style: 'none', color: ''}; + } + const style = getComputedStyle(element); + return { + width: + parseFloat( + style.getPropertyValue(`border-${side.toLowerCase()}-width`), + ) || 0, + style: style.getPropertyValue(`border-${side.toLowerCase()}-style`), + color: style.getPropertyValue(`border-${side.toLowerCase()}-color`), + }; +} + +/** The row below `row`, across a section edge (thead to tbody) too. */ +function rowBelow(row: Element | null): Element | null { + if (row == null) { + return null; + } + return ( + row.nextElementSibling ?? + row.parentElement?.nextElementSibling?.firstElementChild ?? + null + ); +} + +/** + * The border the table paints under a cell, by the collapsing rules (CSS 2.1 + * 17.6.2.1): of the cell's own bottom, the cell's below it (top), its row's + * bottom, the next row's top, and at a section edge (thead to tbody) the two + * row groups' borders, `hidden` wins, then the wider, then the stronger + * style; a tie goes to a cell over a row over a row group, and to the upper + * one. `isOwn` says the cell's own border won. + */ +export function collapsedBottom(cell: HTMLTableCellElement): BottomEdge & { + isOwn: boolean; +} { + const row = cell.parentElement; + const nextRow = rowBelow(row); + const nextCell = nextRow?.children[cell.cellIndex] ?? null; + const isSectionEdge = row != null && row.nextElementSibling == null; + // In tie order. + const candidates = [ + edgeOf(cell, 'Bottom'), + edgeOf(nextCell, 'Top'), + edgeOf(row, 'Bottom'), + edgeOf(nextRow, 'Top'), + edgeOf(isSectionEdge ? (row?.parentElement ?? null) : null, 'Bottom'), + edgeOf(isSectionEdge ? (nextRow?.parentElement ?? null) : null, 'Top'), + ]; + const own = candidates[0]; + if (candidates.some(edge => edge.style === 'hidden')) { + return { + width: 0, + style: 'none', + color: own.color, + isOwn: own.style === 'hidden', + }; + } + const strength = (edge: BottomEdge) => + edge.style === 'none' || edge.width === 0 + ? [-1, -1] + : [edge.width, STYLE_RANK.indexOf(edge.style)]; + let winner = own; + for (const edge of candidates.slice(1)) { + const [width, rank] = strength(edge); + const [bestWidth, bestRank] = strength(winner); + if (width > bestWidth || (width === bestWidth && rank > bestRank)) { + winner = edge; + } + } + return {...winner, isOwn: winner === own}; +} + +/** + * The divider a detail panel takes from its row: the color and style of the + * row's cells (a theme's `table-cell` styling or an app's own, which never + * reach the panel's cell), at the panel's own width unless the row's own + * border wins the collapse (an app styling the `
    ({ - ...orders[index % orders.length], - id: `ord-${String(index + 1).padStart(4, '0')}`, -})); - /** * A long table (2,000 rows, not virtualized) with an `animatedLayout` * handle: a glide moves only the rows near the visible part, so opening a diff --git a/packages/core/src/Table/__tests__/TableRowGlide.a11y.browser.spec.ts b/packages/core/src/Table/__tests__/TableRowGlide.a11y.browser.spec.ts index deec710941b09..dd0c042c81a79 100644 --- a/packages/core/src/Table/__tests__/TableRowGlide.a11y.browser.spec.ts +++ b/packages/core/src/Table/__tests__/TableRowGlide.a11y.browser.spec.ts @@ -49,12 +49,16 @@ async function toggleAndHold(page: Page, index: number, ms: number) { HTMLElement | undefined )?.click(); await new Promise(resolve => requestAnimationFrame(resolve)); + // The glide's own motion on rows (script animations), not a CSS + // transition a row's hover or press state runs. const rows = document .getAnimations() .filter( animation => + !(animation instanceof CSSTransition) && + !(animation instanceof CSSAnimation) && (animation.effect as KeyframeEffect | null)?.target?.nodeName === - 'TR', + 'TR', ); for (const animation of document.getAnimations()) { animation.pause(); @@ -66,8 +70,10 @@ async function toggleAndHold(page: Page, index: number, ms: number) { ); } +type Rgb = [number, number, number]; + /** The darkest pixel within 3 px of `y` at `x`. */ -async function darkestNear(page: Page, x: number, y: number) { +async function darkestNear(page: Page, x: number, y: number): Promise { const shot = await page.screenshot({ clip: {x, y: y - 3, width: 1, height: 7}, scale: 'css', @@ -81,13 +87,18 @@ async function darkestNear(page: Page, x: number, y: number) { canvas.height = image.height; const context = canvas.getContext('2d'); if (context == null) { - return 255; + return [255, 255, 255]; } context.drawImage(image, 0, 0); const data = context.getImageData(0, 0, image.width, image.height).data; - let darkest = 255; + let darkest: [number, number, number] = [255, 255, 255]; for (let i = 0; i < data.length; i += 4) { - darkest = Math.min(darkest, data[i], data[i + 1], data[i + 2]); + if ( + data[i] + data[i + 1] + data[i + 2] < + darkest[0] + darkest[1] + darkest[2] + ) { + darkest = [data[i], data[i + 1], data[i + 2]]; + } } return darkest; }, shot.toString('base64')); @@ -109,6 +120,9 @@ async function rowBottom(page: Page, index: number) { }, index); } +const near = (a: Rgb, b: Rgb, tolerance: number) => + a.every((channel, i) => Math.abs(channel - b[i]) <= tolerance); + const columnWidths = async (page: Page) => page.evaluate(() => [...document.querySelectorAll('thead th')] @@ -125,24 +139,26 @@ test('mid-glide each divider moves with its row, pinned cells included', async ( page, }) => { await mount(page, 'gliding-rows'); - for (const [action, row] of [ - ['opening', 2], - ['closing', 1], - ] as const) { + for (const action of ['opening', 'closing'] as const) { expect(await toggleAndHold(page, 0, 90), action).toBeGreaterThan(0); - // The row after the panel, where it shows now (its glide included). - // Between two device pixels a 1 px line is antialiased, and a pinned - // (composited) cell can draw it fainter, so this asks for a line, not - // its full colour: a divider left behind leaves nothing here (255). - const {y, pinnedX, unpinnedX} = await rowBottom(page, row); - expect( - await darkestNear(page, pinnedX, y), - `${action}, pinned`, - ).toBeLessThan(254); - expect( - await darkestNear(page, unpinnedX, y), - `${action}, unpinned`, - ).toBeLessThan(254); + // Every row after the panel but the last (which has no divider), where + // it shows now (its glide included): a line under its pinned cell as + // under the rest of the row. A divider left at its static place leaves + // nothing here. + const rows = await page.evaluate( + () => + document.querySelectorAll('.astryx-table-scroll-wrapper tbody tr') + .length, + ); + for (let row = action === 'opening' ? 2 : 1; row < rows - 1; row++) { + const {y, pinnedX, unpinnedX} = await rowBottom(page, row); + const pinned = await darkestNear(page, pinnedX, y); + const unpinned = await darkestNear(page, unpinnedX, y); + expect(unpinned[0], `${action}, row ${row}`).toBeLessThan(248); + expect(near(pinned, unpinned, 8), `${action}, row ${row}, pinned`).toBe( + true, + ); + } await page.evaluate(() => { for (const animation of document.getAnimations()) { animation.finish(); @@ -152,6 +168,84 @@ test('mid-glide each divider moves with its row, pinned cells included', async ( } }); +test("a theme's divider and an app's override move with their rows, pinned cells included", async ({ + page, +}) => { + await mount(page, 'gliding-rows', '&args=themed:!true'); + expect(await toggleAndHold(page, 0, 90)).toBeGreaterThan(0); + // The story's red is on the tbody's third row, which is the first after + // the open panel (row 2); the next (row 3) draws the theme's blue. + for (const [row, isBlue] of [ + [2, false], + [3, true], + ] as const) { + const {y, pinnedX, unpinnedX} = await rowBottom(page, row); + for (const [where, x] of [ + ['pinned', pinnedX], + ['unpinned', unpinnedX], + ] as const) { + const [r, , b] = await darkestNear(page, x, y); + expect(isBlue ? b - r : r - b, `row ${row}, ${where}`).toBeGreaterThan( + 60, + ); + } + } +}); + +test('the chevron turns on the glide; a grid table and Reduce Motion change at once', async ({ + page, +}) => { + await mount(page, 'gliding-rows'); + await page.locator('[data-expansion-toggle]').first().click(); + const timing = await page + .locator('[data-expansion-toggle] > *') + .first() + .evaluate(glyph => + (glyph as HTMLElement).style.getPropertyValue( + 'transition-timing-function', + ), + ); + expect(timing.startsWith('linear(')).toBe(true); + + await mount(page, 'gliding-rows', '&args=grid:!true'); + expect(await toggleAndHold(page, 0, 90)).toBe(0); + + await page.emulateMedia({reducedMotion: 'reduce'}); + await mount(page, 'gliding-rows'); + expect(await toggleAndHold(page, 0, 90)).toBe(0); +}); + +test('under Reduce Motion a finger sees the rows fade in place, not move', async ({ + browser, +}) => { + const context = await browser.newContext({ + hasTouch: true, + isMobile: true, + reducedMotion: 'reduce', + viewport: {width: 390, height: 800}, + }); + const page = await context.newPage(); + await mount(page, 'gliding-rows'); + await toggleAndHold(page, 0, 0); + // The size-change module's Reduce Motion under a coarse pointer: a short + // fade at the new place (design:reduced-motion DR2), never a translate. + const moves = await page.evaluate( + () => + document + .getAnimations() + .filter(animation => !(animation instanceof CSSTransition)) + .flatMap( + animation => + (animation.effect as KeyframeEffect | null)?.getKeyframes() ?? [], + ) + .filter( + keyframe => keyframe.translate != null || keyframe.transform != null, + ).length, + ); + expect(moves).toBe(0); + await context.close(); +}); + test('the columns keep their widths through a glide', async ({page}) => { await mount(page, 'gliding-rows'); // A table its columns do not fill, so they share the spare width. @@ -185,5 +279,5 @@ test('an open panel draws the divider its row hands down', async ({page}) => { unpinnedX: Math.round(((panel?.left ?? 0) + (panel?.right ?? 0)) / 2), }; }); - expect(await darkestNear(page, unpinnedX, y)).toBeLessThan(245); + expect((await darkestNear(page, unpinnedX, y))[0]).toBeLessThan(245); }); diff --git a/packages/core/src/Table/glidingDivider.ts b/packages/core/src/Table/glidingDivider.ts index 7d3e0a647a357..123e429cf1eb3 100644 --- a/packages/core/src/Table/glidingDivider.ts +++ b/packages/core/src/Table/glidingDivider.ts @@ -107,6 +107,8 @@ export const glidingDividerStyles = stylex.create({ /** The batch that marked a row last: only that batch unmarks it. */ const markedBy = new WeakMap(); +/** A marked cell's own inline border color, given back when it is unmarked. */ +const inlineColorOf = new WeakMap(); /** * A row's own bottom border, for its stand-in while it moves; null for a row @@ -174,6 +176,12 @@ export async function markGlidingRows( row.style.setProperty(APP_DIVIDER_STYLE, edge.style); row.style.setProperty(APP_DIVIDER_COLOR, edge.color); row.setAttribute(GLIDING_ATTRIBUTE, ''); + // The table's last row can glide a pixel farther than the rows above + // it (its border collapses differently), so mid-glide it overlaps the + // row above. Each moving row is its own layer; the last one stays + // under the others so it cannot cover the divider above it. Both stay + // under a sticky header (z-index 3). + row.style.zIndex = row.nextElementSibling == null ? '0' : '1'; // The first cell that scrolls with the row draws the line; a pinned // cell is positioned (the line would be its width) and draws its own. let hasLine = false; @@ -182,6 +190,13 @@ export async function markGlidingRows( !hasLine && !cell.hasAttribute(PINNED_ATTRIBUTE); cell.setAttribute(CELL_ATTRIBUTE, isLine ? 'line' : ''); hasLine ||= isLine; + // Inline, so a theme's `table-cell` color or an app's stylesheet on + // the cells, which outrank the stylesheet's rule, cannot keep the + // line at the row's static place. + if (cell instanceof HTMLElement) { + inlineColorOf.set(cell, cell.style.borderBottomColor); + cell.style.borderBottomColor = 'transparent'; + } } } markedBy.set(row, batch); @@ -198,8 +213,13 @@ export async function markGlidingRows( } markedBy.delete(row); row.removeAttribute(GLIDING_ATTRIBUTE); + row.style.removeProperty('z-index'); for (const cell of row.children) { cell.removeAttribute(CELL_ATTRIBUTE); + if (cell instanceof HTMLElement && inlineColorOf.has(cell)) { + cell.style.borderBottomColor = inlineColorOf.get(cell) ?? ''; + inlineColorOf.delete(cell); + } } row.style.removeProperty(APP_DIVIDER_WIDTH); row.style.removeProperty(APP_DIVIDER_STYLE); diff --git a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx index b20f89255646e..c1844f0413b83 100644 --- a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx +++ b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx @@ -52,7 +52,13 @@ afterEach(() => { vi.restoreAllMocks(); }); -function ExpandableTable({withHandle}: {withHandle: boolean}) { +function ExpandableTable({ + withHandle, + dividers, +}: { + withHandle: boolean; + dividers?: 'rows' | 'grid'; +}) { const containerRef = useRef(null); const animatedLayout = useAnimatedLayout({containerRef}); const [expandedKeys, setExpandedKeys] = useState(() => new Set()); @@ -78,6 +84,7 @@ function ExpandableTable({withHandle}: {withHandle: boolean}) { data={rows} columns={columns} idKey="id" + dividers={dividers} plugins={{rowExpansion: expansion}} /> @@ -120,6 +127,15 @@ describe('useTableRowExpansion adopts animatedLayout (spec:AST-105 FR1, FR2)', ( ).toMatch(/^linear\(/); }); + it('changes the rows at once in a grid table, even with a handle (Table FR15)', async () => { + const user = userEvent.setup(); + render(); + stubGeometry(); + await user.click(screen.getAllByRole('button', {name: /expand/i})[0]); + await flush(); + expect(animated).toEqual([]); + }); + it('changes nothing without a handle', async () => { const user = userEvent.setup(); render(); diff --git a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx index 446b631365a9f..ad4cba5676c1c 100644 --- a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +++ b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx @@ -439,6 +439,26 @@ function ExpansionPanelCell({ ); } +/** + * Measures each expansion change for the glide, except in a grid table: its + * column lines are painted at the cells' static places too, and only row + * dividers have a moving stand-in, so a grid table's rows change at once + * (component:Table FR15). + */ +function ExpansionSnapshot({ + value, + onBeforeChange, +}: { + value: Set; + onBeforeChange: (previous: Set) => void; +}) { + const ctx = useTableContext(); + if (ctx?.dividers === 'grid') { + return null; + } + return ; +} + // ============================================================================= // Hook // ============================================================================= @@ -555,6 +575,19 @@ export function useTableRowExpansion>( }, [animatedLayout, expandedKeys, rowFor], ); + // 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 glyph of scrollWrapperRef.current?.querySelectorAll( + '[data-expansion-toggle] > *', + ) ?? []) { + applyLayoutChangeTransition(null, glyph); + } + }, [animatedLayout]); + useLayoutEffect(() => { const pendings = pendingLayoutRef.current; pendingLayoutRef.current = []; @@ -786,7 +819,7 @@ export function useTableRowExpansion>( ) : ( <> {props.beforeTable} - diff --git a/packages/core/src/Table/useTableRowExpansion.doc.mjs b/packages/core/src/Table/useTableRowExpansion.doc.mjs index 91cdaa92e0f8e..f687f8595718d 100644 --- a/packages/core/src/Table/useTableRowExpansion.doc.mjs +++ b/packages/core/src/Table/useTableRowExpansion.doc.mjs @@ -59,7 +59,7 @@ export const docs = { name: 'animatedLayout', type: 'AnimatedLayout', description: - "A handle from useAnimatedLayout. With it, opening or closing a panel glides the rows after it (and the content after the table) to their new places instead of jumping, the opening panel plays in on the same motion, and each row's divider moves with it. Under Reduce Motion the rows change at once. Without it, rows jump.", + "A handle from useAnimatedLayout. With it, opening or closing a panel glides the rows after it (and the content after the table) to their new places instead of jumping, the opening panel plays in on the same motion, and each row's divider moves with it. Under Reduce Motion, and in a table with dividers="grid" (its column lines do not move yet), the rows change at once. Without it, rows jump.", }, ], examples: [ @@ -131,6 +131,6 @@ export const docsDense = { hasRowClickExpansion: 'Toggle a row by clicking anywhere on it, not just the chevron; pointer-only, skips interactive cell content and text selections. Defaults to false.', animatedLayout: - 'Handle from useAnimatedLayout: opening or closing a panel glides the rows after it instead of jumping them. Reduce Motion: rows change at once.', + 'Handle from useAnimatedLayout: opening or closing a panel glides the rows after it instead of jumping them. Reduce Motion and dividers="grid": rows change at once.', }, }; From 5cf3af151681d02eeef5e0fcfa1da7b22b071bbd Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 05:21:41 -0700 Subject: [PATCH 46/58] docs(Table): the grid note in useTableRowExpansion's doc is valid JavaScript --- packages/core/src/Table/useTableRowExpansion.doc.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/Table/useTableRowExpansion.doc.mjs b/packages/core/src/Table/useTableRowExpansion.doc.mjs index f687f8595718d..451106ee68632 100644 --- a/packages/core/src/Table/useTableRowExpansion.doc.mjs +++ b/packages/core/src/Table/useTableRowExpansion.doc.mjs @@ -59,7 +59,7 @@ export const docs = { name: 'animatedLayout', type: 'AnimatedLayout', description: - "A handle from useAnimatedLayout. With it, opening or closing a panel glides the rows after it (and the content after the table) to their new places instead of jumping, the opening panel plays in on the same motion, and each row's divider moves with it. Under Reduce Motion, and in a table with dividers="grid" (its column lines do not move yet), the rows change at once. Without it, rows jump.", + "A handle from useAnimatedLayout. With it, opening or closing a panel glides the rows after it (and the content after the table) to their new places instead of jumping, the opening panel plays in on the same motion, and each row's divider moves with it. Under Reduce Motion, and in a table with dividers: grid (its column lines do not move yet), the rows change at once. Without it, rows jump.", }, ], examples: [ @@ -131,6 +131,6 @@ export const docsDense = { hasRowClickExpansion: 'Toggle a row by clicking anywhere on it, not just the chevron; pointer-only, skips interactive cell content and text selections. Defaults to false.', animatedLayout: - 'Handle from useAnimatedLayout: opening or closing a panel glides the rows after it instead of jumping them. Reduce Motion and dividers="grid": rows change at once.', + 'Handle from useAnimatedLayout: opening or closing a panel glides the rows after it instead of jumping them. Reduce Motion and dividers: grid: rows change at once.', }, }; From 8fbfe64ac6bd1b82a1618fc98f05690aa3658179 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 05:42:23 -0700 Subject: [PATCH 47/58] fix(core): a table with column lines (dividers "columns" too) changes at once, panel, rows and chevron Table FR15 (#8679) covers dividers="columns" as well as "grid": both paint column lines at the cells' static places. With a handle such a table changes as without one. jsdom test for both modes (no glide, no panel entrance, the chevron keeps the theme's turn) and the browser check for both, through the story's dividers control. --- .changeset/table-row-expansion-glide.md | 2 +- .../stories/TableRowExpansion.stories.tsx | 18 ++++++++----- .../TableRowGlide.a11y.browser.spec.ts | 8 +++--- .../useTableRowExpansion.glide.test.tsx | 27 ++++++++++++------- .../rowExpansion/useTableRowExpansion.tsx | 9 ++++--- .../src/Table/useTableRowExpansion.doc.mjs | 4 +-- 6 files changed, 42 insertions(+), 26 deletions(-) diff --git a/.changeset/table-row-expansion-glide.md b/.changeset/table-row-expansion-glide.md index ddbfdc4686294..abbe1470ab9da 100644 --- a/.changeset/table-row-expansion-glide.md +++ b/.changeset/table-row-expansion-glide.md @@ -2,4 +2,4 @@ '@astryxdesign/core': patch --- -Table: `useTableRowExpansion` takes an `animatedLayout` handle from `useAnimatedLayout`. With it, opening or closing a row's panel glides the rows after it to their new places instead of jumping, and each row's divider moves with its row. Under Reduce Motion, and in a table with `dividers="grid"`, the rows change at once. +Table: `useTableRowExpansion` takes an `animatedLayout` handle from `useAnimatedLayout`. With it, opening or closing a row's panel glides the rows after it to their new places instead of jumping, and each row's divider moves with its row. Under Reduce Motion, and in a table with column lines (`dividers="columns"` or `"grid"`), the rows change at once. diff --git a/apps/storybook/stories/TableRowExpansion.stories.tsx b/apps/storybook/stories/TableRowExpansion.stories.tsx index 510ab5dd0fa9d..aa6a8084289eb 100644 --- a/apps/storybook/stories/TableRowExpansion.stories.tsx +++ b/apps/storybook/stories/TableRowExpansion.stories.tsx @@ -178,13 +178,17 @@ const sevenOrders = manyOrders.slice(0, 7); * rows after it instead of jumping them, the panel plays in on the same * motion, and each row's divider moves with its row. The Customer column is * pinned and the table scrolls horizontally, so the pinned cells' own - * dividers move too. Under Reduce Motion, and with `dividers="grid"` (the - * `grid` control), the rows change at once. The `themed` control sets a blue - * theme border and an app's red border on the third row's cells. + * dividers move too. Under Reduce Motion, and with column lines (the + * `dividers` control at "columns" or "grid"), the rows change at once. The + * `themed` control sets a blue theme border and an app's red border on the + * tbody's third row. */ -export const GlidingRows: StoryObj<{themed?: boolean; grid?: boolean}> = { - args: {themed: false, grid: false}, - render: ({themed, grid}) => { +export const GlidingRows: StoryObj<{ + themed?: boolean; + dividers?: 'rows' | 'columns' | 'grid'; +}> = { + args: {themed: false, dividers: 'rows'}, + render: ({themed, dividers}) => { const containerRef = useRef(null); const animatedLayout = useAnimatedLayout({containerRef}); const [expandedKeys, setExpandedKeys] = useState>( @@ -214,7 +218,7 @@ export const GlidingRows: StoryObj<{themed?: boolean; grid?: boolean}> = {
    { await mount(page, 'gliding-rows'); @@ -207,8 +207,10 @@ test('the chevron turns on the glide; a grid table and Reduce Motion change at o ); expect(timing.startsWith('linear(')).toBe(true); - await mount(page, 'gliding-rows', '&args=grid:!true'); - expect(await toggleAndHold(page, 0, 90)).toBe(0); + for (const dividers of ['columns', 'grid']) { + await mount(page, 'gliding-rows', `&args=dividers:${dividers}`); + expect(await toggleAndHold(page, 0, 90), dividers).toBe(0); + } await page.emulateMedia({reducedMotion: 'reduce'}); await mount(page, 'gliding-rows'); diff --git a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx index c1844f0413b83..73006db78b17a 100644 --- a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx +++ b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx @@ -57,7 +57,7 @@ function ExpandableTable({ dividers, }: { withHandle: boolean; - dividers?: 'rows' | 'grid'; + dividers?: 'rows' | 'columns' | 'grid'; }) { const containerRef = useRef(null); const animatedLayout = useAnimatedLayout({containerRef}); @@ -127,14 +127,23 @@ describe('useTableRowExpansion adopts animatedLayout (spec:AST-105 FR1, FR2)', ( ).toMatch(/^linear\(/); }); - it('changes the rows at once in a grid table, even with a handle (Table FR15)', async () => { - const user = userEvent.setup(); - render(); - stubGeometry(); - await user.click(screen.getAllByRole('button', {name: /expand/i})[0]); - await flush(); - expect(animated).toEqual([]); - }); + it.each(['columns', 'grid'] as const)( + 'changes everything at once with %s dividers, even with a handle (Table FR15)', + async dividers => { + const user = userEvent.setup(); + render(); + stubGeometry(); + await user.click(screen.getAllByRole('button', {name: /expand/i})[0]); + await flush(); + // No glide, no panel entrance, and the chevron keeps the theme's turn. + expect(animated).toEqual([]); + expect( + document + .querySelector('[data-expansion-toggle] > *') + ?.style.getPropertyValue('transition-timing-function'), + ).toBe(''); + }, + ); it('changes nothing without a handle', async () => { const user = userEvent.setup(); diff --git a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx index ad4cba5676c1c..e9fca039da4b9 100644 --- a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +++ b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx @@ -440,9 +440,10 @@ function ExpansionPanelCell({ } /** - * Measures each expansion change for the glide, except in a grid table: its - * column lines are painted at the cells' static places too, and only row - * dividers have a moving stand-in, so a grid table's rows change at once + * Measures each expansion change for the glide, except in a table with + * column lines (`dividers` "columns" or "grid"): they are painted at the + * cells' static places too, and only row dividers have a moving stand-in, so + * such a table changes at once, panel, rows and chevron, as without a handle * (component:Table FR15). */ function ExpansionSnapshot({ @@ -453,7 +454,7 @@ function ExpansionSnapshot({ onBeforeChange: (previous: Set) => void; }) { const ctx = useTableContext(); - if (ctx?.dividers === 'grid') { + if (ctx?.dividers === 'grid' || ctx?.dividers === 'columns') { return null; } return ; diff --git a/packages/core/src/Table/useTableRowExpansion.doc.mjs b/packages/core/src/Table/useTableRowExpansion.doc.mjs index 451106ee68632..b5f40c425016c 100644 --- a/packages/core/src/Table/useTableRowExpansion.doc.mjs +++ b/packages/core/src/Table/useTableRowExpansion.doc.mjs @@ -59,7 +59,7 @@ export const docs = { name: 'animatedLayout', type: 'AnimatedLayout', description: - "A handle from useAnimatedLayout. With it, opening or closing a panel glides the rows after it (and the content after the table) to their new places instead of jumping, the opening panel plays in on the same motion, and each row's divider moves with it. Under Reduce Motion, and in a table with dividers: grid (its column lines do not move yet), the rows change at once. Without it, rows jump.", + "A handle from useAnimatedLayout. With it, opening or closing a panel glides the rows after it (and the content after the table) to their new places instead of jumping, the opening panel plays in on the same motion, and each row's divider moves with it. Under Reduce Motion, and in a table with column lines (dividers columns or grid, which do not move yet), the rows change at once. Without it, rows jump.", }, ], examples: [ @@ -131,6 +131,6 @@ export const docsDense = { hasRowClickExpansion: 'Toggle a row by clicking anywhere on it, not just the chevron; pointer-only, skips interactive cell content and text selections. Defaults to false.', animatedLayout: - 'Handle from useAnimatedLayout: opening or closing a panel glides the rows after it instead of jumping them. Reduce Motion and dividers: grid: rows change at once.', + 'Handle from useAnimatedLayout: opening or closing a panel glides the rows after it instead of jumping them. Under Reduce Motion or with column lines, rows change at once.', }, }; From aeb209a82ae28812b903b9c986aaa113c6945168 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 06:16:18 -0700 Subject: [PATCH 48/58] fix(core): pinned dividers follow a colour-scheme switch; a panel carries its row cells' width CodeBunny on #8654: - In a theme's system mode an operating-system light/dark switch re-resolves the colours with no commit, so a carried row border stayed in the old scheme under the pinned column. The pinned dividers are read again on a prefers-color-scheme change; browser test for the switch. - A detail panel's divider stayed at the token width when the row's cells set their own (3px): it carries that width now; unit test. - WebKit paints a translucent collapsed border twice, so a token divider shows darker across the row than its colour, while the pinned cell draws the token's true colour once. The three pixel comparisons against the token-coloured row are skipped in WebKit, with that reason; no browser detection in the component. --- .../stories/TableStickyColumns.stories.tsx | 14 ++++++-- .../TableStickyDividers.a11y.browser.spec.ts | 36 ++++++++++++++++++- .../useTableRowExpansion.test.tsx | 12 +++++++ .../stickyColumns/useTableStickyColumns.tsx | 10 ++++++ packages/core/src/Table/rowDivider.ts | 10 ++++-- 5 files changed, 76 insertions(+), 6 deletions(-) diff --git a/apps/storybook/stories/TableStickyColumns.stories.tsx b/apps/storybook/stories/TableStickyColumns.stories.tsx index 2d612eed0a004..84124a63bea52 100644 --- a/apps/storybook/stories/TableStickyColumns.stories.tsx +++ b/apps/storybook/stories/TableStickyColumns.stories.tsx @@ -181,8 +181,9 @@ export const PinnedDividers: Story = { * 3 px top border, which wins the collapse over the third row's bottom; "Row * borders" draws an app's 2 px blue border on the rows that turns red on * hover (`tr:hover`); "First row top" gives the first body row a 3 px top - * border, which wins over the header's bottom; "Add columns" widens the - * table inside the same wrapper. + * border, which wins over the header's bottom; "Token row borders" draws a + * 2 px row border in the theme's border token, which follows a light/dark + * switch; "Add columns" widens the table inside the same wrapper. */ export const PinnedDividersLive: Story = { render: () => { @@ -195,6 +196,7 @@ export const PinnedDividersLive: Story = { const [hasThickTop, setHasThickTop] = useState(false); const [hasRowBorders, setHasRowBorders] = useState(false); const [hasFirstRowTop, setHasFirstRowTop] = useState(false); + const [hasTokenRowBorders, setHasTokenRowBorders] = useState(false); const shown = hasMoreColumns ? columns : columns.filter(column => ['name', 'status'].includes(column.key)); @@ -207,6 +209,9 @@ export const PinnedDividersLive: Story = { {hasRowBorders && ( )} + {hasTokenRowBorders && ( + + )} {hasFirstRowTop && ( )} @@ -226,6 +231,11 @@ export const PinnedDividersLive: Story = { {' '} + {' '} diff --git a/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts b/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts index 4f4065670419e..4fc4aa882d1a3 100644 --- a/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts +++ b/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts @@ -115,6 +115,15 @@ async function dividerPixels( }; } +/** + * WebKit paints a translucent collapsed border twice, so a token divider + * shows darker across the row than its colour; a pinned cell draws the + * token's true colour once. The pixel comparisons against the row's own + * divider at the token colour hold in Chromium only. + */ +const WEBKIT_DOUBLE_PAINT = + 'WebKit paints translucent collapsed borders twice; the pinned divider draws the token once'; + const near = (a: Rgb, b: Rgb, tolerance = 6) => a.every((channel, i) => Math.abs(channel - b[i]) <= tolerance); /** Every pixel of one column matches the other's, top to bottom. */ @@ -128,7 +137,9 @@ const darkest = (column: Rgb[]): Rgb => test('the divider under a pinned cell matches its row, at rest and scrolled', async ({ page, + browserName, }) => { + test.skip(browserName === 'webkit', WEBKIT_DOUBLE_PAINT); await mount(page, 'pin-start'); for (const left of [0, 150]) { await scrollTo(page, left); @@ -147,7 +158,9 @@ test('the divider under a pinned cell matches its row, at rest and scrolled', as test('a pinned cell keeps its divider where the cell scrolled beneath it has none', async ({ page, + browserName, }) => { + test.skip(browserName === 'webkit', WEBKIT_DOUBLE_PAINT); await mount(page, 'pin-start'); await scrollTo(page, 150); const before = await dividerPixels(page, 1); @@ -244,7 +257,9 @@ test("an app's :hover rule on the cells reaches the pinned cell in CSS", async ( test('a pinned cell stops carrying a border once its own wins again', async ({ page, + browserName, }) => { + test.skip(browserName === 'webkit', WEBKIT_DOUBLE_PAINT); await mount(page, 'pinned-dividers-live'); await page.getByRole('button', {name: 'Add columns'}).click(); const highlight = page.getByRole('button', {name: 'Highlight'}); @@ -281,7 +296,7 @@ test('a row border recoloured by CSS state alone (tr:hover) reaches the pinned c }) => { await mount(page, 'pinned-dividers-live'); await page.getByRole('button', {name: 'Add columns'}).click(); - await page.getByRole('button', {name: 'Row borders'}).click(); + await page.getByRole('button', {name: 'Row borders', exact: true}).click(); for (const left of [0, 150]) { await scrollTo(page, left); await page @@ -313,3 +328,22 @@ test("the first body row's wider top border wins under the pinned header too", a expect(orange[0], `at ${left}px is orange`).toBeGreaterThan(orange[2] + 60); } }); + +test('a carried border follows an operating-system switch between light and dark', async ({ + page, +}) => { + await page.emulateMedia({colorScheme: 'light'}); + await mount(page, 'pinned-dividers-live'); + await page.getByRole('button', {name: 'Add columns'}).click(); + await page.getByRole('button', {name: 'Token row borders'}).click(); + await page.emulateMedia({colorScheme: 'dark'}); + for (const left of [0, 150]) { + await scrollTo(page, left); + await expect + .poll(async () => { + const {pinned, unpinned} = await dividerPixels(page, 1); + return sameColumn(pinned, unpinned, 12); + }, `at ${left}px`) + .toBe(true); + } +}); diff --git a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx index 2843c197140d7..b98e5987f6a37 100644 --- a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +++ b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx @@ -337,6 +337,18 @@ describe('useTableRowExpansion (detail panel)', () => { expect(cell.style.getPropertyValue('--_table-panel-divider-width')).toBe( 'initial', ); + // A width set on the row's cells is the divider's too. + for (const rowCell of row.cells) { + rowCell.style.borderBottomWidth = '3px'; + } + fireEvent.click(screen.getAllByRole('button', {name: /expand/i})[0]); + expect(cell.style.getPropertyValue('--_table-panel-divider-width')).toBe( + '3px', + ); + fireEvent.click(screen.getAllByRole('button', {name: /collapse/i})[1]); + for (const rowCell of row.cells) { + rowCell.style.borderBottomWidth = ''; + } // An app's wider border on the row itself wins; the next commit // (opening another row) reads it. row.style.borderBottom = '3px solid rgb(0, 0, 255)'; diff --git a/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx b/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx index e17981bb3b429..13fb913ff02e5 100644 --- a/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +++ b/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx @@ -486,6 +486,15 @@ export function useTableStickyColumns>( for (const type of stateEvents) { el.addEventListener(type, onStateChange, {passive: true}); } + // A theme in `system` mode re-resolves its colors when the operating + // system switches between light and dark, with no commit: a carried + // border is read again then. + const scheme = + typeof window.matchMedia === 'function' + ? window.matchMedia('(prefers-color-scheme: dark)') + : null; + const onSchemeChange = () => syncPinnedDividers(el); + scheme?.addEventListener('change', onSchemeChange); const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null; resizeObserver?.observe(el); @@ -502,6 +511,7 @@ export function useTableStickyColumns>( for (const type of stateEvents) { el.removeEventListener(type, onStateChange); } + scheme?.removeEventListener('change', onSchemeChange); cancelAnimationFrame(frame); resizeObserver?.disconnect(); }; diff --git a/packages/core/src/Table/rowDivider.ts b/packages/core/src/Table/rowDivider.ts index d16f629d72cf8..b1bdb84635b49 100644 --- a/packages/core/src/Table/rowDivider.ts +++ b/packages/core/src/Table/rowDivider.ts @@ -110,8 +110,9 @@ export function collapsedBottom(cell: HTMLTableCellElement): BottomEdge & { /** * The divider a detail panel takes from its row: the color and style of the * row's cells (a theme's `table-cell` styling or an app's own, which never - * reach the panel's cell), at the panel's own width unless the row's own - * border wins the collapse (an app styling the ``), as custom property + * reach the panel's cell), with their width where they set one, at the + * panel's own width otherwise, unless the row's own border wins the + * collapse (an app styling the ``), as custom property * values for the panel's cell. Every value is written, `initial` where the * panel keeps its own, so a panel nested in another never inherits that * one's. @@ -135,8 +136,11 @@ export function panelDividerFor(row: Element): { }; } const cellStyle = getComputedStyle(rowCell); + // The row's cells hand their divider down (their own width is 0); a width + // an app or a theme sets on them anyway is the divider's. + const cellWidth = parseFloat(cellStyle.borderBottomWidth) || 0; return { - width: 'initial', + width: cellWidth > 0 ? `${cellWidth}px` : 'initial', style: cellStyle.borderBottomStyle, color: cellStyle.borderBottomColor, }; From 60a01e8e590cc47793ef81cf641f7d6c2cd07cc2 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 06:19:23 -0700 Subject: [PATCH 49/58] fix(core): the closing hold adds to the wrapper's own margin; a panel opened mid-glide takes its row's divider after CodeBunny on #8656: - The closing hold replaced the scroll wrapper's stylesheet margin, so a Table at the end of a padded container (its bleed margin) ended 12 px off and then jumped. The hold now adds to the wrapper's own padding and margin, read with the measurement. Checked in real Chromium with a -12 px bleed: the content after the table glides to its place, largest step 8 px, no jump at the end. - A panel opened while its row was still gliding skipped the carry and kept the default divider. It now reads its row once the row's glide has let it go. - The chevron comment cites component:Table FR15. --- .../rowExpansion/useTableRowExpansion.tsx | 69 ++++++++++++++----- 1 file changed, 50 insertions(+), 19 deletions(-) diff --git a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx index e9fca039da4b9..1cd852edb0c9e 100644 --- a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +++ b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx @@ -400,28 +400,39 @@ function ExpansionPanelCell({ useLayoutEffect(() => { const cell = cellRef.current; const row = cell?.parentElement?.previousElementSibling; - // A row a glide is moving has transparent cells until it ends: the - // panel keeps what it carries. - if ( - cell == null || - row == null || - !hasRowDividers || - row.hasAttribute(GLIDING_ATTRIBUTE) - ) { + if (cell == null || row == null || !hasRowDividers) { return; } - const edge = panelDividerFor(row); - if (edge == null) { + const carry = () => { + const edge = panelDividerFor(row); + if (edge == null) { + return; + } + const set = (name: string, value: string) => { + if (cell.style.getPropertyValue(name) !== value) { + cell.style.setProperty(name, value); + } + }; + set('--_table-panel-divider-width', edge.width); + set('--_table-panel-divider-style', edge.style); + set('--_table-panel-divider-color', edge.color); + }; + if (!row.hasAttribute(GLIDING_ATTRIBUTE)) { + carry(); return; } - const set = (name: string, value: string) => { - if (cell.style.getPropertyValue(name) !== value) { - cell.style.setProperty(name, value); + // A row a glide is moving has transparent cells until it ends: the + // panel reads its row once the glide has let it go. + let frame = 0; + const carryWhenStill = () => { + if (row.hasAttribute(GLIDING_ATTRIBUTE)) { + frame = requestAnimationFrame(carryWhenStill); + return; } + carry(); }; - set('--_table-panel-divider-width', edge.width); - set('--_table-panel-divider-style', edge.style); - set('--_table-panel-divider-color', edge.color); + frame = requestAnimationFrame(carryWhenStill); + return () => cancelAnimationFrame(frame); }); return (
    >( // layout effect). const closedHeightRef = useRef(0); const heldHeightRef = useRef(0); + const holdBaseRef = useRef<{padding: number; margin: number} | null>(null); const rowFor = useCallback( (key: string) => scrollWrapperRef.current?.querySelector( @@ -560,8 +572,18 @@ export function useTableRowExpansion>( panel?.hasAttribute('data-expansion-panel') === true ) { closedHeightRef.current += panel.getBoundingClientRect().height; + // The wrapper's own bottom padding and margin (a container's + // bleed), which the hold adds to rather than replaces. + const wrapper = scrollWrapperRef.current; + if (wrapper != null && holdBaseRef.current == null) { + const style = getComputedStyle(wrapper); + holdBaseRef.current = { + padding: parseFloat(style.paddingBlockEnd) || 0, + margin: parseFloat(style.marginBlockEnd) || 0, + }; + } } - // The chevron turns on the glide's motion (`spec:AST-108` FR4a); + // The chevron turns on the glide's motion (component:Table FR15); // written after the measurement so the read finds the layout clean. applyLayoutChangeTransition( animatedLayout, @@ -654,6 +676,9 @@ export function useTableRowExpansion>( const wrapper = scrollWrapperRef.current; const held = moved.length > 0 ? closedHeightRef.current : 0; closedHeightRef.current = 0; + if (heldHeightRef.current === 0 && held <= 0.5) { + holdBaseRef.current = null; + } if (moved.length === 0) { return; } @@ -676,8 +701,14 @@ export function useTableRowExpansion>( return; } const hold = (height: number) => { - wrapper.style.paddingBlockEnd = height > 0 ? `${height}px` : ''; - wrapper.style.marginBlockEnd = height > 0 ? `${-height}px` : ''; + const base = holdBaseRef.current ?? {padding: 0, margin: 0}; + wrapper.style.paddingBlockEnd = + height > 0 ? `${base.padding + height}px` : ''; + wrapper.style.marginBlockEnd = + height > 0 ? `${base.margin - height}px` : ''; + if (height <= 0) { + holdBaseRef.current = null; + } }; heldHeightRef.current += held; hold(heldHeightRef.current); From c8c46bc6b8a19d2c250763fd85c40c5cbb65965f Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 07:13:17 -0700 Subject: [PATCH 50/58] fix(core): pinned and panel dividers follow a Theme mode switch too; the scheme tests run under a system-mode Theme CodeBunny on #8654: - A carried pinned border kept its old colour when an app toggled its Theme mode without re-rendering the hook's owner, and an open panel's divider kept its old colour after any colour-mode switch. Both now follow the Theme's colour mode (useTheme: a Theme's mode, or the operating system's under system), so they are read again after it; the scheme listener stays for a Theme nested below the hook's owner. - The operating-system switch test ran under the story decorator's explicit light Theme, so it proved nothing. The live story's table now sits in a system-mode Theme on its own surface, and a new story (PanelDividerFollowsScheme) does the same for an open panel; browser tests for both. The pixel comparison skips WebKit with the double-paint reason, like the others. Checked in real Chromium: the carried border and the panel divider switch from rgba(0,0,0,.08) to rgba(255,255,255,.1) with the OS; 12 browser checks pass, three runs. --- .../stories/TableRowExpansion.stories.tsx | 33 ++++++++++++++++++ .../stories/TableStickyColumns.stories.tsx | 23 +++++++++---- .../TableStickyDividers.a11y.browser.spec.ts | 34 +++++++++++++++++++ .../rowExpansion/useTableRowExpansion.tsx | 4 +++ .../stickyColumns/useTableStickyColumns.tsx | 6 ++++ 5 files changed, 93 insertions(+), 7 deletions(-) diff --git a/apps/storybook/stories/TableRowExpansion.stories.tsx b/apps/storybook/stories/TableRowExpansion.stories.tsx index 93be197c4fa8c..2c0e23f0b27ca 100644 --- a/apps/storybook/stories/TableRowExpansion.stories.tsx +++ b/apps/storybook/stories/TableRowExpansion.stories.tsx @@ -9,6 +9,8 @@ import { proportional, } from '@astryxdesign/core/Table'; import type {TableColumn} from '@astryxdesign/core/Table'; +import {Theme} from '@astryxdesign/core/theme'; +import {neutralTheme} from '@astryxdesign/theme-neutral'; import {VStack, HStack} from '@astryxdesign/core/Stack'; import {Text, Heading} from '@astryxdesign/core/Text'; import {Badge} from '@astryxdesign/core/Badge'; @@ -136,6 +138,37 @@ export const DetailPanel: Story = { }, }; +/** + * An open panel's divider is the one its row hands down, in the theme's + * colour, and follows a switch between light and dark: the table sits in a + * `system`-mode Theme, which follows the operating system. + */ +export const PanelDividerFollowsScheme: Story = { + render: () => { + const [expandedKeys, setExpandedKeys] = useState>( + () => new Set(['ord-1001']), + ); + const expansion = useTableRowExpansion({ + expandedKeys, + onToggle: key => setExpandedKeys(prev => toggleKey(prev, key)), + getRowKey: item => item.id, + renderExpanded: item => , + }); + return ( + +
    + + + + ); + }, +}; + /** * `getIsItemExpandable` restricts which rows can expand. Here only orders with * more than one line item are expandable; the rest show no chevron and no diff --git a/apps/storybook/stories/TableStickyColumns.stories.tsx b/apps/storybook/stories/TableStickyColumns.stories.tsx index 84124a63bea52..2499fc258d2c4 100644 --- a/apps/storybook/stories/TableStickyColumns.stories.tsx +++ b/apps/storybook/stories/TableStickyColumns.stories.tsx @@ -9,6 +9,8 @@ import { pixel, } from '@astryxdesign/core/Table'; import type {TableColumn} from '@astryxdesign/core/Table'; +import {Theme} from '@astryxdesign/core/theme'; +import {neutralTheme} from '@astryxdesign/theme-neutral'; // ============================================================================= // Sample Data — wide enough to require horizontal scroll @@ -183,7 +185,8 @@ export const PinnedDividers: Story = { * hover (`tr:hover`); "First row top" gives the first body row a 3 px top * border, which wins over the header's bottom; "Token row borders" draws a * 2 px row border in the theme's border token, which follows a light/dark - * switch; "Add columns" widens the table inside the same wrapper. + * switch (the table sits in a `system`-mode Theme); "Add columns" widens the + * table inside the same wrapper. */ export const PinnedDividersLive: Story = { render: () => { @@ -240,12 +243,18 @@ export const PinnedDividersLive: Story = { Add columns

    -
    + {/* A `system`-mode Theme: the table follows the operating system's + light or dark. */} + +
    +
    + + ); }, diff --git a/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts b/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts index 4fc4aa882d1a3..615af9706da6c 100644 --- a/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts +++ b/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts @@ -331,7 +331,10 @@ test("the first body row's wider top border wins under the pinned header too", a test('a carried border follows an operating-system switch between light and dark', async ({ page, + browserName, }) => { + test.skip(browserName === 'webkit', WEBKIT_DOUBLE_PAINT); + // The story's table sits in a `system`-mode Theme. await page.emulateMedia({colorScheme: 'light'}); await mount(page, 'pinned-dividers-live'); await page.getByRole('button', {name: 'Add columns'}).click(); @@ -347,3 +350,34 @@ test('a carried border follows an operating-system switch between light and dark .toBe(true); } }); + +test("an open panel's divider follows an operating-system switch between light and dark", async ({ + page, +}) => { + await page.emulateMedia({colorScheme: 'light'}); + await page.goto( + `${storybook.origin}/iframe.html?id=core-tablerowexpansion--panel-divider-follows-scheme&viewMode=story`, + ); + await expect(page.locator('tr[data-expansion-panel]')).toBeVisible(); + // The colour the panel's divider draws, against its row's cells' (the + // panel sits on its own surface, so its pixels differ from a row's). + const colours = async () => + page.evaluate(() => { + const panel = document.querySelector('tr[data-expansion-panel] > td'); + const row = + panel?.parentElement?.previousElementSibling?.lastElementChild; + return { + panel: panel == null ? '' : getComputedStyle(panel).borderBottomColor, + row: row == null ? '' : getComputedStyle(row).borderBottomColor, + }; + }); + const light = await colours(); + expect(light.panel).toBe(light.row); + await page.emulateMedia({colorScheme: 'dark'}); + await expect + .poll(async () => { + const dark = await colours(); + return dark.panel === dark.row && dark.panel !== light.panel; + }) + .toBe(true); +}); diff --git a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx index 0fe7532160459..02cd3d027241f 100644 --- a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +++ b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx @@ -30,6 +30,7 @@ import { } from '../../../theme/tokens.stylex'; import {tableRowMarker} from '../../table.stylex'; import {panelDividerFor} from '../../rowDivider'; +import {useTheme} from '../../../theme/useTheme'; import {Icon} from '../../../Icon'; import {VisuallyHidden} from '../../../VisuallyHidden'; import {resolveContextActions} from '../../tableContextMenu'; @@ -357,6 +358,9 @@ function ExpansionPanelCell({ const ctx = useTableContext(); const hasRowDividers = ctx?.dividers === 'rows' || ctx?.dividers === 'grid'; const cellRef = useRef(null); + // Re-rendered when the colour mode changes (a Theme's mode, or the + // operating system's under `system`), so the carried colour is read again. + useTheme(); // The panel carries its row's divider (a theme's `table-cell` border or an // app's own never reach this cell, which is not one of the row's cells). // Read after each commit; written only when it differs. diff --git a/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx b/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx index 13fb913ff02e5..d938647a78a52 100644 --- a/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +++ b/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx @@ -31,6 +31,7 @@ import type { } from '../../types'; import {resolveColumnFloorWidths} from '../../columnUtils'; import {collapsedBottom} from '../../rowDivider'; +import {useTheme} from '../../../theme/useTheme'; // ============================================================================= // Config @@ -517,6 +518,11 @@ export function useTableStickyColumns>( }; }, []); + // Re-rendered when the colour mode changes (a Theme's mode, or the + // operating system's under `system`), so a carried border is read again + // after it; a Theme nested below this hook's owner is covered by the + // scheme listener in attachScrollShadow. + useTheme(); // After each commit: a border that changed with the render (a row // highlighted on click) reaches the pinned divider, and the edge shadows // follow content that changed without a resize. From 35944bcaec94bd66900222f29632694c45ea952a Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 07:58:58 -0700 Subject: [PATCH 51/58] fix(core): pinned dividers follow a Theme switched between the hooks' owner and the table CodeBunny on #8654: a Theme between the component that calls the hooks and the Table, switching mode on its own state, re-renders neither the owner nor the plugin, so a carried border kept its old colour. The plugin now renders a small watcher inside the table (transformTableContext) that reads the Theme's colour mode there and re-reads the pinned dividers when it changes; it covers a Theme anywhere above the table and the operating system's switch under system, so the owner-level useTheme and the scheme listener go. The live story's Theme is switched by a control inside it (its own state); browser test for that switch. 13 browser checks pass in real Chromium. --- .../stories/TableStickyColumns.stories.tsx | 42 ++++++++++++----- .../TableStickyDividers.a11y.browser.spec.ts | 23 +++++++++ .../stickyColumns/useTableStickyColumns.tsx | 47 +++++++++++++------ 3 files changed, 85 insertions(+), 27 deletions(-) diff --git a/apps/storybook/stories/TableStickyColumns.stories.tsx b/apps/storybook/stories/TableStickyColumns.stories.tsx index 2499fc258d2c4..485ad22476783 100644 --- a/apps/storybook/stories/TableStickyColumns.stories.tsx +++ b/apps/storybook/stories/TableStickyColumns.stories.tsx @@ -175,6 +175,25 @@ export const PinnedDividers: Story = { }, }; +/** + * A `system`-mode Theme on its own surface, with a control that switches it + * to dark: its state lives here, so switching it re-renders only this and the + * table, not the story that calls the table's hooks. + */ +function ThemeAroundTable({children}: {children: React.ReactNode}) { + const [isDark, setIsDark] = useState(false); + return ( + +
    + + {children} +
    +
    + ); +} + /** * A border that changes after mount reaches the pinned cells, and the edge * shadows follow columns added without a resize. Hovering a row turns its @@ -243,18 +262,17 @@ export const PinnedDividersLive: Story = { Add columns

    - {/* A `system`-mode Theme: the table follows the operating system's - light or dark. */} - -
    -
    - - + {/* The table sits in its own Theme (`system` by default, so it + follows the operating system), switched by a control inside it, so + the switch does not re-render the component that calls the hooks. */} + +
    + ); }, diff --git a/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts b/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts index 615af9706da6c..24d586d8d0e09 100644 --- a/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts +++ b/packages/core/src/Table/__tests__/TableStickyDividers.a11y.browser.spec.ts @@ -381,3 +381,26 @@ test("an open panel's divider follows an operating-system switch between light a }) .toBe(true); }); + +test("a carried border follows a Theme switched between the hooks' owner and the table", async ({ + page, + browserName, +}) => { + test.skip(browserName === 'webkit', WEBKIT_DOUBLE_PAINT); + await page.emulateMedia({colorScheme: 'light'}); + await mount(page, 'pinned-dividers-live'); + await page.getByRole('button', {name: 'Add columns'}).click(); + await page.getByRole('button', {name: 'Token row borders'}).click(); + // The Theme around the table switches on its own state: the component + // that calls the hooks does not re-render. + await page.getByRole('button', {name: 'Theme dark'}).click(); + for (const left of [0, 150]) { + await scrollTo(page, left); + await expect + .poll(async () => { + const {pinned, unpinned} = await dividerPixels(page, 1); + return sameColumn(pinned, unpinned, 12); + }, `at ${left}px`) + .toBe(true); + } +}); diff --git a/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx b/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx index d938647a78a52..8505c61fd99cc 100644 --- a/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +++ b/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx @@ -19,6 +19,8 @@ import { useMemo, useRef, type CSSProperties, + type ReactNode, + type RefObject, } from 'react'; import * as stylex from '@stylexjs/stylex'; import {colorVars, durationVars, easeVars} from '../../../theme/tokens.stylex'; @@ -371,6 +373,28 @@ function syncPinnedDividers( } } +/** + * Rendered inside the table: when the colour mode around it changes (a + * Theme's mode, wherever that Theme sits above the table, or the operating + * system's under `system`), the colours resolve again with no commit of the + * hook's owner, so the pinned dividers' carried borders are read again. + */ +function PinnedDividerModeWatch({ + refreshRef, +}: { + refreshRef: RefObject<(() => void) | null>; +}) { + const {mode} = useTheme(); + const modeRef = useRef(mode); + useLayoutEffect(() => { + if (modeRef.current !== mode) { + modeRef.current = mode; + refreshRef.current?.(); + } + }, [mode, refreshRef]); + return null; +} + // Stable empty default so an unset start/end doesn't allocate a fresh array // (and bust the memo) on every render. const EMPTY: string[] = []; @@ -487,15 +511,6 @@ export function useTableStickyColumns>( for (const type of stateEvents) { el.addEventListener(type, onStateChange, {passive: true}); } - // A theme in `system` mode re-resolves its colors when the operating - // system switches between light and dark, with no commit: a carried - // border is read again then. - const scheme = - typeof window.matchMedia === 'function' - ? window.matchMedia('(prefers-color-scheme: dark)') - : null; - const onSchemeChange = () => syncPinnedDividers(el); - scheme?.addEventListener('change', onSchemeChange); const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null; resizeObserver?.observe(el); @@ -512,17 +527,11 @@ export function useTableStickyColumns>( for (const type of stateEvents) { el.removeEventListener(type, onStateChange); } - scheme?.removeEventListener('change', onSchemeChange); cancelAnimationFrame(frame); resizeObserver?.disconnect(); }; }, []); - // Re-rendered when the colour mode changes (a Theme's mode, or the - // operating system's under `system`), so a carried border is read again - // after it; a Theme nested below this hook's owner is covered by the - // scheme listener in attachScrollShadow. - useTheme(); // After each commit: a border that changed with the render (a row // highlighted on click) reaches the pinned divider, and the edge shadows // follow content that changed without a resize. @@ -532,6 +541,14 @@ export function useTableStickyColumns>( return useMemo( (): TablePlugin => ({ + transformTableContext(children: ReactNode): ReactNode { + return ( + <> + {children} + + + ); + }, transformHeaderCell( props: HeaderCellRenderProps, column: TableColumn, From 5cdda33609114598829f376ac879c0872315f793 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 08:00:47 -0700 Subject: [PATCH 52/58] fix(core): a chevron drops the glide's spring when its table turns to column lines CodeBunny on #8656: with the column-lines exception, a chevron toggled while the table had row dividers kept the 735 ms spring after dividers became "columns" or "grid", so it turned slowly while the rows changed at once. The snapshot that skips the glide there also puts the chevrons back on the theme's turn; test for both modes. --- .../useTableRowExpansion.glide.test.tsx | 21 +++++++++++++ .../rowExpansion/useTableRowExpansion.tsx | 31 ++++++++++++++----- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx index 73006db78b17a..aef7b5e3f0e25 100644 --- a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx +++ b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx @@ -145,6 +145,27 @@ describe('useTableRowExpansion adopts animatedLayout (spec:AST-105 FR1, FR2)', ( }, ); + it.each(['columns', 'grid'] as const)( + 'takes the spring off a chevron when the table turns to %s dividers', + async dividers => { + const user = userEvent.setup(); + const {rerender} = render(); + stubGeometry(); + await user.click(screen.getAllByRole('button', {name: /expand/i})[0]); + await flush(); + const glyph = document.querySelector( + '[data-expansion-toggle] > *', + ); + expect( + glyph?.style.getPropertyValue('transition-timing-function'), + ).toMatch(/^linear\(/); + rerender(); + expect(glyph?.style.getPropertyValue('transition-timing-function')).toBe( + '', + ); + }, + ); + it('changes nothing without a handle', async () => { const user = userEvent.setup(); render(); diff --git a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx index 7e1f404a2c1fa..1b8d314f970ed 100644 --- a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +++ b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx @@ -464,12 +464,22 @@ function ExpansionPanelCell({ function ExpansionSnapshot({ value, onBeforeChange, + onColumnLines, }: { value: Set; onBeforeChange: (previous: Set) => void; + /** The chevrons drop a spring an earlier glide set on them. */ + onColumnLines: () => void; }) { const ctx = useTableContext(); - if (ctx?.dividers === 'grid' || ctx?.dividers === 'columns') { + const hasColumnLines = + ctx?.dividers === 'grid' || ctx?.dividers === 'columns'; + useLayoutEffect(() => { + if (hasColumnLines) { + onColumnLines(); + } + }, [hasColumnLines, onColumnLines]); + if (hasColumnLines) { return null; } return ; @@ -606,12 +616,10 @@ export function useTableRowExpansion>( }, [animatedLayout, expandedKeys, rowFor], ); - // 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; - } + // The chevrons back on the theme's own turn: a spring a handle set stays + // only while the glide runs (`spec:AST-105` FR2), so it goes without a + // handle and in a table with column lines. + const resetChevrons = useCallback(() => { const table = scrollWrapperRef.current?.querySelector('table'); for (const glyph of table?.querySelectorAll( '[data-expansion-toggle] > *', @@ -621,7 +629,12 @@ export function useTableRowExpansion>( applyLayoutChangeTransition(null, glyph); } } - }, [animatedLayout]); + }, []); + useLayoutEffect(() => { + if (animatedLayout == null) { + resetChevrons(); + } + }, [animatedLayout, resetChevrons]); useLayoutEffect(() => { const pendings = pendingLayoutRef.current; @@ -866,6 +879,7 @@ export function useTableRowExpansion>( ), @@ -988,6 +1002,7 @@ export function useTableRowExpansion>( expansionColumn, animatedLayout, measureChanges, + resetChevrons, ], ); } From 3d2d64c3a89bebfd8c406fbe5c46b2eb85ce6fe6 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 08:55:43 -0700 Subject: [PATCH 53/58] fix(core): the scroll container attaches once with column resize composed; the divider width is a listed structural property CodeBunny on #8654: - useTableColumnResize wrapped the scroll container's ref in a fresh function every render, so with it composed (in either order) React re-attached the container each commit and the pinned dividers were read three times per commit. Column resize now keeps one merged ref per inner ref too, and the sticky plugin reads the pinned dividers after each commit only: not on attach, and not in the resize observer's first callback, which reports the size at observe time. Test: three re-renders, no new attach, in either plugin order (fails without the column-resize change). - --_table-row-divider-width joins derivedVarRegistry's structural list, with its reason; the theme test passes. 13 browser checks pass in real Chromium. --- .../columnResize/useTableColumnResize.tsx | 31 ++++++++--- .../useTableStickyColumns.test.tsx | 52 ++++++++++++++++++- .../stickyColumns/useTableStickyColumns.tsx | 26 ++++++++-- .../core/src/theme/derivedVarRegistry.test.ts | 4 ++ 4 files changed, 99 insertions(+), 14 deletions(-) diff --git a/packages/core/src/Table/plugins/columnResize/useTableColumnResize.tsx b/packages/core/src/Table/plugins/columnResize/useTableColumnResize.tsx index 996cc039d2cda..1fcdad6c8ea84 100644 --- a/packages/core/src/Table/plugins/columnResize/useTableColumnResize.tsx +++ b/packages/core/src/Table/plugins/columnResize/useTableColumnResize.tsx @@ -781,6 +781,13 @@ export function useTableColumnResize>( const dragStateRef = useRef(null); const isDraggingRef = useRef(false); + // The scroll container's merged ref, kept while the inner ref is the same, + // so React does not detach and re-attach it (and every plugin's listeners + // on it) on every render. + const mergedScrollWrapperRef = useRef<{ + inner: ScrollWrapperRenderProps['htmlProps']['ref'] | null; + merged: (node: HTMLDivElement | null) => void; + } | null>(null); const tableRef = useRef(null); const globalMinWidth = config.minWidth; @@ -941,14 +948,22 @@ export function useTableColumnResize>( ): ScrollWrapperRenderProps { // Compose with any ref a prior plugin (e.g. sticky columns, // virtualization) already set on the scroll container. - const existingRef = props.htmlProps.ref; - const mergedRef = (node: HTMLDivElement | null) => { - attachScrollGate(node); - if (typeof existingRef === 'function') { - existingRef(node); - } else if (existingRef != null) { - existingRef.current = node; - } + const existingRef = props.htmlProps.ref ?? null; + const cached = mergedScrollWrapperRef.current; + const mergedRef = + cached != null && cached.inner === existingRef + ? cached.merged + : (node: HTMLDivElement | null) => { + attachScrollGate(node); + if (typeof existingRef === 'function') { + existingRef(node); + } else if (existingRef != null) { + existingRef.current = node; + } + }; + mergedScrollWrapperRef.current = { + inner: existingRef, + merged: mergedRef, }; return { ...props, diff --git a/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.test.tsx b/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.test.tsx index eb6454ec7c038..ad888ddd14a82 100644 --- a/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.test.tsx +++ b/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.test.tsx @@ -13,7 +13,7 @@ * jsdom-visible, plugin-owned signal that a column is pinned and at what offset. */ -import {describe, it, expect} from 'vitest'; +import {afterEach, describe, it, expect, vi} from 'vitest'; import {render, screen} from '@testing-library/react'; import {Table} from '../../Table'; import {useTableStickyColumns} from './useTableStickyColumns'; @@ -281,4 +281,54 @@ describe('useTableStickyColumns', () => { expect(getHeaderCell('team').style.position).toBe(''); }); }); + + describe('the scroll container attaches once', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each([true, false])( + 'keeps one attach across renders with column resize composed (resize first: %s)', + resizeFirst => { + let observers = 0; + vi.stubGlobal( + 'ResizeObserver', + class { + constructor() { + observers += 1; + } + observe() {} + unobserve() {} + disconnect() {} + }, + ); + function Harness({tick}: {tick: number}) { + const sticky = useTableStickyColumns({startKeys: ['name']}); + const resize = useTableColumnResize({ + columns: columns as TableColumn>[], + }); + return ( +
    + ); + } + const {rerender} = render(); + const attached = observers; + rerender(); + rerender(); + rerender(); + // Three plain re-renders: no plugin's scroll container re-attaches. + expect(observers).toBe(attached); + }, + ); + }); }); diff --git a/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx b/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx index 8505c61fd99cc..19a2f7ec8b5b2 100644 --- a/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +++ b/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx @@ -470,6 +470,17 @@ export function useTableStickyColumns>( update(); syncPinnedDividers(el); }; + // The observer's first callback reports the size at observe time, which + // the after-commit read already covers: it updates the shadows only. + let hasObserved = false; + const onResize = () => { + if (hasObserved) { + resize(); + return; + } + hasObserved = true; + update(); + }; el.addEventListener('scroll', update, {passive: true}); // A row border can change by CSS state alone (`tr:hover`, // `:focus-within`): on pointer and focus changes, the rows they touch @@ -512,7 +523,9 @@ export function useTableStickyColumns>( el.addEventListener(type, onStateChange, {passive: true}); } const resizeObserver = - typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null; + typeof ResizeObserver !== 'undefined' + ? new ResizeObserver(onResize) + : null; resizeObserver?.observe(el); // The table too: columns added or widened change what scrolls without // resizing the wrapper. @@ -520,7 +533,9 @@ export function useTableStickyColumns>( if (table != null) { resizeObserver?.observe(table); } - resize(); + // The pinned dividers are read after the commit that attached this + // (the refresh layout effect below), not here as well. + update(); refreshRef.current = resize; detachRef.current = () => { el.removeEventListener('scroll', update); @@ -628,9 +643,10 @@ export function useTableStickyColumns>( } // Compose with any ref a prior plugin (e.g. virtualization) set on the // scroll container. One merged ref per inner ref, so React does not - // re-attach it on every render: the scroll listeners, the resize - // observer and the pinned dividers' read (syncPinnedDividers, on - // mount and on resize) run once, not once per render. + // re-attach it on every render while the plugins composed with this + // one keep theirs stable too (column resize does): the scroll + // listeners and the resize observer attach once, and the pinned + // dividers are read after each commit (the refresh effect). const existingRef = props.htmlProps.ref ?? null; const cached = mergedScrollWrapperRef.current; const mergedRef = diff --git a/packages/core/src/theme/derivedVarRegistry.test.ts b/packages/core/src/theme/derivedVarRegistry.test.ts index 5c012ed4630e3..83d00323f22fc 100644 --- a/packages/core/src/theme/derivedVarRegistry.test.ts +++ b/packages/core/src/theme/derivedVarRegistry.test.ts @@ -86,6 +86,10 @@ const STRUCTURAL_VARS = new Set([ // replays on pinned cells. Structural/runtime, not themeable design tokens. '--table-sticky-background', '--table-row-overlay', + // The width a cell's row divider draws (0 on the last row), which a pinned + // cell's or a detail panel's own divider reads (Table/rowDivider.ts). + // Private runtime plumbing, never authored by a theme. + '--_table-row-divider-width', '--separator-display', '--astryx-section-padding', // Private counterpart of the public token above: one ancestor Section's From 40f08df09d59a76782ba9bf1d2f4ba7c9cd398f8 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 08:56:41 -0700 Subject: [PATCH 54/58] test(Table): the long table's no-handle glide check waits long enough for 2,000 rows --- .../Table/__tests__/TableRowGlide.a11y.browser.spec.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/core/src/Table/__tests__/TableRowGlide.a11y.browser.spec.ts b/packages/core/src/Table/__tests__/TableRowGlide.a11y.browser.spec.ts index 9d07966d8f3ee..86de21271b21c 100644 --- a/packages/core/src/Table/__tests__/TableRowGlide.a11y.browser.spec.ts +++ b/packages/core/src/Table/__tests__/TableRowGlide.a11y.browser.spec.ts @@ -30,11 +30,13 @@ test.afterAll(async () => { await storybook?.close(); }); -async function mount(page: Page, story: string, args = '') { +async function mount(page: Page, story: string, args = '', timeout = 5000) { await page.goto( `${storybook.origin}/iframe.html?id=core-tablerowexpansion--${story}&viewMode=story${args}`, ); - await expect(page.locator('[data-expansion-toggle]').first()).toBeVisible(); + await expect(page.locator('[data-expansion-toggle]').first()).toBeVisible({ + timeout, + }); } /** @@ -131,7 +133,8 @@ const columnWidths = async (page: Page) => ); test('without a handle the rows change at once', async ({page}) => { - await mount(page, 'gliding-rows-long-table', '&args=glide:!false'); + // 2,000 rows: a slow runner takes more than the default 5 s to render it. + await mount(page, 'gliding-rows-long-table', '&args=glide:!false', 30_000); expect(await toggleAndHold(page, 1, 90)).toBe(0); }); From 007f4142037845183ec25c1b800337799e6c702d Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 13:10:47 -0700 Subject: [PATCH 55/58] 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 6b1bb99c52580217a4593fec0afa092f9bbc1e50 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 23:40:25 -0700 Subject: [PATCH 56/58] fix(core): an app's border on a gliding row itself moves with the row, pinned cells included CodeBunny on #8656 (Table FR14's row-border case): - The table kept painting a row's own border (an app's tr rule) at the row's static place while the line drew the carried copy, so the divider showed twice. The row's own bottom colour now goes transparent with the marks and comes back with them. - A pinned cell's own carry (0px none for a row winner) outranked the values the glide set on the row, so the moving divider stopped short of the pinned cells. The glide carries the row's divider in names no cell sets (--_table-row-glide-*), which the pinned divider reads while it glides. GlidingRows gains rowBorder (an app's 3 px green border on a tr); a browser case checks the moving line pinned and unpinned, and nothing at the static place. It fails on the previous head. derivedVarRegistry lists the glide names. --- .../stories/TableRowExpansion.stories.tsx | 11 ++++-- .../TableRowGlide.a11y.browser.spec.ts | 27 +++++++++++++ packages/core/src/Table/glidingDivider.ts | 38 ++++++++++++------- .../useTableRowExpansion.glide.test.tsx | 4 +- .../stickyColumns/useTableStickyColumns.tsx | 19 +++++++--- .../core/src/theme/derivedVarRegistry.test.ts | 5 +++ 6 files changed, 81 insertions(+), 23 deletions(-) diff --git a/apps/storybook/stories/TableRowExpansion.stories.tsx b/apps/storybook/stories/TableRowExpansion.stories.tsx index c45bacc782e50..d8e623c6178d6 100644 --- a/apps/storybook/stories/TableRowExpansion.stories.tsx +++ b/apps/storybook/stories/TableRowExpansion.stories.tsx @@ -222,14 +222,16 @@ const sevenOrders = manyOrders.slice(0, 7); * dividers move too. Under Reduce Motion, and with column lines (the * `dividers` control at "columns" or "grid"), the rows change at once. The * `themed` control sets a blue theme border and an app's red border on the - * tbody's third row. + * tbody's third row; `rowBorder` sets an app's 3 px green border on the + * fourth row itself (`tr`), which glides with the row too. */ export const GlidingRows: StoryObj<{ themed?: boolean; + rowBorder?: boolean; dividers?: 'rows' | 'columns' | 'grid'; }> = { - args: {themed: false, dividers: 'rows'}, - render: ({themed, dividers}) => { + args: {themed: false, rowBorder: false, dividers: 'rows'}, + render: ({themed, rowBorder, dividers}) => { const containerRef = useRef(null); const animatedLayout = useAnimatedLayout({containerRef}); const [expandedKeys, setExpandedKeys] = useState>( @@ -256,6 +258,9 @@ export const GlidingRows: StoryObj<{ {themed && ( )} + {rowBorder && ( + + )}
  • { + await mount(page, 'gliding-rows', '&args=rowBorder:!true'); + expect(await toggleAndHold(page, 0, 90)).toBeGreaterThan(0); + // The green border is on the tbody's fourth row, which glides down. + const {y, pinnedX, unpinnedX} = await rowBottom(page, 3); + for (const [where, x] of [ + ['pinned', pinnedX], + ['unpinned', unpinnedX], + ] as const) { + const [r, g] = await darkestNear(page, x, y); + expect(g - r, `moving line, ${where}`).toBeGreaterThan(60); + } + // Where the row lands, the line is not drawn yet. + const travel = await page.evaluate(() => { + const row = document.querySelectorAll( + '.astryx-table-scroll-wrapper tbody tr', + )[3] as HTMLElement; + const parts = getComputedStyle(row).translate.split(' '); + return Number.parseFloat(parts[1] ?? '0') || 0; + }); + expect(Math.abs(travel)).toBeGreaterThan(4); + const [r, g] = await darkestNear(page, unpinnedX, Math.round(y - travel)); + expect(g - r, 'static place').toBeLessThan(30); +}); + test('the chevron turns on the glide; column lines and Reduce Motion change at once', async ({ page, }) => { diff --git a/packages/core/src/Table/glidingDivider.ts b/packages/core/src/Table/glidingDivider.ts index 123e429cf1eb3..0362653f19111 100644 --- a/packages/core/src/Table/glidingDivider.ts +++ b/packages/core/src/Table/glidingDivider.ts @@ -40,10 +40,13 @@ const CELL_LINE = '[data-astryx-cell-gliding="line"]'; /** On a pinned (sticky) cell: plugins/stickyColumns. */ const PINNED_ATTRIBUTE = 'data-astryx-table-pinned'; -/** A row's own divider, carried for the glide (or a pinned cell's). */ -export const APP_DIVIDER_WIDTH = '--_table-row-divider-app-width'; -export const APP_DIVIDER_STYLE = '--_table-row-divider-app-style'; -export const APP_DIVIDER_COLOR = '--_table-row-divider-app-color'; +/** + * A row's divider, carried on the row for its glide. Names no cell sets, so + * a pinned cell's own carry (plugins/stickyColumns) cannot shadow them. + */ +export const GLIDE_DIVIDER_WIDTH = '--_table-row-glide-width'; +export const GLIDE_DIVIDER_STYLE = '--_table-row-glide-style'; +export const GLIDE_DIVIDER_COLOR = '--_table-row-glide-color'; export const glidingDividerStyles = stylex.create({ /** @@ -69,15 +72,15 @@ export const glidingDividerStyles = stylex.create({ height: {default: null, [CELL_LINE]: 0}, borderBottomWidth: { default: null, - [CELL_LINE]: `var(${APP_DIVIDER_WIDTH}, 0px)`, + [CELL_LINE]: `var(${GLIDE_DIVIDER_WIDTH}, 0px)`, }, borderBottomStyle: { default: null, - [CELL_LINE]: `var(${APP_DIVIDER_STYLE}, solid)`, + [CELL_LINE]: `var(${GLIDE_DIVIDER_STYLE}, solid)`, }, borderBottomColor: { default: null, - [CELL_LINE]: `var(${APP_DIVIDER_COLOR}, ${colorVars['--color-border']})`, + [CELL_LINE]: `var(${GLIDE_DIVIDER_COLOR}, ${colorVars['--color-border']})`, }, pointerEvents: {default: null, [CELL_LINE]: 'none'}, }, @@ -172,10 +175,15 @@ export async function markGlidingRows( for (const row of rows.keys()) { const edge = edges.get(row); if (edge != null && !row.hasAttribute(GLIDING_ATTRIBUTE)) { - row.style.setProperty(APP_DIVIDER_WIDTH, `${edge.width}px`); - row.style.setProperty(APP_DIVIDER_STYLE, edge.style); - row.style.setProperty(APP_DIVIDER_COLOR, edge.color); + row.style.setProperty(GLIDE_DIVIDER_WIDTH, `${edge.width}px`); + row.style.setProperty(GLIDE_DIVIDER_STYLE, edge.style); + row.style.setProperty(GLIDE_DIVIDER_COLOR, edge.color); row.setAttribute(GLIDING_ATTRIBUTE, ''); + // An app's border on the row itself (a winning `tr` border) is painted + // by the table at the row's static place: it goes transparent too, and + // the line carries it. + inlineColorOf.set(row, row.style.borderBottomColor); + row.style.borderBottomColor = 'transparent'; // The table's last row can glide a pixel farther than the rows above // it (its border collapses differently), so mid-glide it overlaps the // row above. Each moving row is its own layer; the last one stays @@ -214,6 +222,10 @@ export async function markGlidingRows( markedBy.delete(row); row.removeAttribute(GLIDING_ATTRIBUTE); row.style.removeProperty('z-index'); + if (inlineColorOf.has(row)) { + row.style.borderBottomColor = inlineColorOf.get(row) ?? ''; + inlineColorOf.delete(row); + } for (const cell of row.children) { cell.removeAttribute(CELL_ATTRIBUTE); if (cell instanceof HTMLElement && inlineColorOf.has(cell)) { @@ -221,8 +233,8 @@ export async function markGlidingRows( inlineColorOf.delete(cell); } } - row.style.removeProperty(APP_DIVIDER_WIDTH); - row.style.removeProperty(APP_DIVIDER_STYLE); - row.style.removeProperty(APP_DIVIDER_COLOR); + row.style.removeProperty(GLIDE_DIVIDER_WIDTH); + row.style.removeProperty(GLIDE_DIVIDER_STYLE); + row.style.removeProperty(GLIDE_DIVIDER_COLOR); } } diff --git a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx index aef7b5e3f0e25..7fcd16875fd8d 100644 --- a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx +++ b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx @@ -256,7 +256,7 @@ describe('markGlidingRows', () => { rows.map(row => row.hasAttribute('data-astryx-row-gliding')), ).toEqual([false, false, true, true, false]); expect( - rows[2].style.getPropertyValue('--_table-row-divider-app-width'), + rows[2].style.getPropertyValue('--_table-row-glide-width'), ).toBe('0px'); // The first cell that scrolls draws the line; the pinned one draws its // own; the nested table's cell is not marked. @@ -272,7 +272,7 @@ describe('markGlidingRows', () => { false, ); expect( - rows[2].style.getPropertyValue('--_table-row-divider-app-width'), + rows[2].style.getPropertyValue('--_table-row-glide-width'), ).toBe(''); expect(cellMarks(rows[2])).toEqual([null, null, null]); body.remove(); diff --git a/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx b/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx index d9b99f6eeeba7..e800cc3b17347 100644 --- a/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +++ b/packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx @@ -198,6 +198,9 @@ const PINNED_DIVIDER_STYLE = '--_table-row-divider-app-style'; const PINNED_DIVIDER_COLOR = '--_table-row-divider-app-color'; const PINNED_BASE = `var(--table-sticky-background, ${colorVars['--color-background-card']})`; const DIVIDER_WIDTH = `var(${PINNED_DIVIDER_WIDTH}, var(--_table-row-divider-width, 0px))`; +// The row's divider, carried on the row while it glides (glidingDivider.ts): +// it outranks the cell's own carry, which it would otherwise inherit under. +const GLIDE_WIDTH = `var(--_table-row-glide-width, ${DIVIDER_WIDTH})`; const stickyStyles = stylex.create({ cell: { @@ -219,22 +222,28 @@ const stickyStyles = stylex.create({ // The divider: the layer reaches the bottom of the cell's border area, // paints the pinned base there (hiding the table's static border and any // scrolled cell's border), then the divider over it. - marginBottom: `calc(-1 * ${DIVIDER_WIDTH})`, - borderBottomWidth: DIVIDER_WIDTH, + marginBottom: { + default: `calc(-1 * ${DIVIDER_WIDTH})`, + [CELL_GLIDING]: `calc(-1 * ${GLIDE_WIDTH})`, + }, + borderBottomWidth: {default: DIVIDER_WIDTH, [CELL_GLIDING]: GLIDE_WIDTH}, // While its row glides the cell's own border is transparent: the // divider is the row's, carried for the glide (glidingDivider.ts). borderBottomStyle: { default: 'inherit', [CARRYING]: `var(${PINNED_DIVIDER_STYLE}, solid)`, - [CELL_GLIDING]: `var(${PINNED_DIVIDER_STYLE}, solid)`, + [CELL_GLIDING]: 'var(--_table-row-glide-style, solid)', }, borderBottomColor: { default: 'inherit', [CARRYING]: `var(${PINNED_DIVIDER_COLOR}, ${colorVars['--color-border']})`, - [CELL_GLIDING]: `var(${PINNED_DIVIDER_COLOR}, ${colorVars['--color-border']})`, + [CELL_GLIDING]: `var(--_table-row-glide-color, ${colorVars['--color-border']})`, }, backgroundImage: `linear-gradient(${PINNED_BASE}, ${PINNED_BASE})`, - backgroundSize: `100% ${DIVIDER_WIDTH}`, + backgroundSize: { + default: `100% ${DIVIDER_WIDTH}`, + [CELL_GLIDING]: `100% ${GLIDE_WIDTH}`, + }, backgroundPosition: 'bottom', backgroundRepeat: 'no-repeat', backgroundOrigin: 'border-box', diff --git a/packages/core/src/theme/derivedVarRegistry.test.ts b/packages/core/src/theme/derivedVarRegistry.test.ts index cb8dbfed97f62..b0b5846cf1cd6 100644 --- a/packages/core/src/theme/derivedVarRegistry.test.ts +++ b/packages/core/src/theme/derivedVarRegistry.test.ts @@ -90,6 +90,11 @@ const STRUCTURAL_VARS = new Set([ // cell's or a detail panel's own divider reads (Table/rowDivider.ts). // Private runtime plumbing, never authored by a theme. '--_table-row-divider-width', + // A gliding row's divider, carried on the row for the glide + // (Table/glidingDivider.ts). Private runtime plumbing. + '--_table-row-glide-width', + '--_table-row-glide-style', + '--_table-row-glide-color', '--separator-display', '--astryx-section-padding', // Private counterpart of the public token above: one ancestor Section's From 3ecdc48f03b9f85469c0472e5117488e46040ab0 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Wed, 28 Oct 2026 00:25:38 -0700 Subject: [PATCH 57/58] fix(core): a glide hides every edge the divider can come from; a row's own border's line sits where it rests CodeBunny on #8656 (Table FR14): - The moving line carries whichever edge wins the collapse, but only the row's and its cells' bottoms were hidden, so a tbody's bottom border or a top border on the next row's cells stayed at the static place and the divider showed twice. A glide now hides every edge the collapse can take the divider from (the row's bottom, the row below's and its cells' tops, and at a section edge the row groups' borders) and gives them back with the marks. - A row's own border is drawn below its box, so the line sat its width above where the border rests and stepped at the pinned edge. The line is offset by the row's own border width. GlidingRows gains edgeBorder (tbody, top). Browser cases: the tr line is level across the pinned and scrolling cells (within 1.5 px), and a tbody or next-row-top divider is drawn once while it glides; all three fail on the previous head. --- .../stories/TableRowExpansion.stories.tsx | 15 ++- .../TableRowGlide.a11y.browser.spec.ts | 64 +++++++++++ packages/core/src/Table/glidingDivider.ts | 101 ++++++++++++++++-- .../core/src/theme/derivedVarRegistry.test.ts | 1 + 4 files changed, 169 insertions(+), 12 deletions(-) diff --git a/apps/storybook/stories/TableRowExpansion.stories.tsx b/apps/storybook/stories/TableRowExpansion.stories.tsx index d8e623c6178d6..0c61b63837c3a 100644 --- a/apps/storybook/stories/TableRowExpansion.stories.tsx +++ b/apps/storybook/stories/TableRowExpansion.stories.tsx @@ -223,15 +223,18 @@ const sevenOrders = manyOrders.slice(0, 7); * `dividers` control at "columns" or "grid"), the rows change at once. The * `themed` control sets a blue theme border and an app's red border on the * tbody's third row; `rowBorder` sets an app's 3 px green border on the - * fourth row itself (`tr`), which glides with the row too. + * fourth row itself (`tr`), which glides with the row too; `edgeBorder` + * sets one on the tbody's bottom (`tbody`) or as the top of the fifth order's + * cells (`top`), each of which wins the collapse and glides with its row. */ export const GlidingRows: StoryObj<{ themed?: boolean; rowBorder?: boolean; + edgeBorder?: 'none' | 'tbody' | 'top'; dividers?: 'rows' | 'columns' | 'grid'; }> = { - args: {themed: false, rowBorder: false, dividers: 'rows'}, - render: ({themed, rowBorder, dividers}) => { + args: {themed: false, rowBorder: false, edgeBorder: 'none', dividers: 'rows'}, + render: ({themed, rowBorder, edgeBorder, dividers}) => { const containerRef = useRef(null); const animatedLayout = useAnimatedLayout({containerRef}); const [expandedKeys, setExpandedKeys] = useState>( @@ -261,6 +264,12 @@ export const GlidingRows: StoryObj<{ {rowBorder && ( )} + {edgeBorder === 'tbody' && ( + + )} + {edgeBorder === 'top' && ( + + )}
    { + const shot = await page.screenshot({ + clip: {x, y: 0, width: 1, height: 720}, + scale: 'css', + }); + return page.evaluate(async b64 => { + const image = new Image(); + image.src = `data:image/png;base64,${b64}`; + await image.decode(); + const canvas = document.createElement('canvas'); + canvas.width = image.width; + canvas.height = image.height; + const context = canvas.getContext('2d'); + if (context == null) { + return []; + } + context.drawImage(image, 0, 0); + const data = context.getImageData(0, 0, 1, image.height).data; + const rows: number[] = []; + for (let y = 0; y < image.height; y += 1) { + const [r, g, b] = [data[y * 4], data[y * 4 + 1], data[y * 4 + 2]]; + if (g - r > 60 && g - b > 30) { + rows.push(y); + } + } + return rows; + }, shot.toString('base64')); +} + +test("a row's own border glides level across the pinned and scrolling cells", async ({ + page, +}) => { + await mount(page, 'gliding-rows', '&args=rowBorder:!true'); + expect(await toggleAndHold(page, 0, 90)).toBeGreaterThan(0); + const {pinnedX, unpinnedX} = await rowBottom(page, 3); + const pinned = await greenRowsAt(page, pinnedX); + const unpinned = await greenRowsAt(page, unpinnedX); + expect(pinned.length).toBeGreaterThan(0); + expect(unpinned.length).toBeGreaterThan(0); + // One line, at one height on both sides of the pinned edge. + expect(Math.abs(Math.min(...pinned) - Math.min(...unpinned))).toBeLessThan( + 1.5, + ); + expect(Math.abs(Math.max(...pinned) - Math.max(...unpinned))).toBeLessThan( + 1.5, + ); +}); + +for (const edge of ['tbody', 'top'] as const) { + test(`a divider the collapse takes from ${edge === 'tbody' ? "the body's bottom border" : "the next row's top border"} is drawn once while it glides`, async ({ + page, + }) => { + await mount(page, 'gliding-rows', `&args=edgeBorder:${edge}`); + const {unpinnedX} = await rowBottom(page, 0); + expect((await greenRowsAt(page, unpinnedX)).length).toBeGreaterThan(0); + expect(await toggleAndHold(page, 0, 90)).toBeGreaterThan(0); + const rows = await greenRowsAt(page, unpinnedX); + // One 3 px line, moving: none left at the static place. + expect(rows.length).toBeGreaterThan(0); + expect(rows.length).toBeLessThanOrEqual(4); + }); +} + test('the chevron turns on the glide; column lines and Reduce Motion change at once', async ({ page, }) => { diff --git a/packages/core/src/Table/glidingDivider.ts b/packages/core/src/Table/glidingDivider.ts index 0362653f19111..ba9cfda75bf7c 100644 --- a/packages/core/src/Table/glidingDivider.ts +++ b/packages/core/src/Table/glidingDivider.ts @@ -68,7 +68,12 @@ export const glidingDividerStyles = stylex.create({ content: {default: null, [CELL_LINE]: '""'}, position: {default: null, [CELL_LINE]: 'absolute'}, insetInline: {default: null, [CELL_LINE]: 0}, - bottom: {default: null, [CELL_LINE]: 0}, + // A row's own border is drawn below its box: the line sits that much + // lower, where the border rests. + bottom: { + default: null, + [CELL_LINE]: 'calc(-1 * var(--_table-row-glide-offset, 0px))', + }, height: {default: null, [CELL_LINE]: 0}, borderBottomWidth: { default: null, @@ -110,6 +115,16 @@ export const glidingDividerStyles = stylex.create({ /** The batch that marked a row last: only that batch unmarks it. */ const markedBy = new WeakMap(); +/** + * A marked element's own inline border colors (bottom, or top for the row + * below and its cells), given back when it is unmarked. + */ +const inlineTopOf = new WeakMap(); +/** The edge sources a row hid when it was marked, given back at unmark. */ +const sourcesOf = new WeakMap< + Element, + {bottoms: HTMLElement[]; tops: HTMLElement[]} +>(); /** A marked cell's own inline border color, given back when it is unmarked. */ const inlineColorOf = new WeakMap(); @@ -118,6 +133,40 @@ const inlineColorOf = new WeakMap(); * already carrying one. Read where the glide reads the layout (`animate`'s * `beforeMove`), before anything starts moving. */ +/** + * The elements whose borders the collapse can take a row's divider from + * (`rowDivider.ts` collapsedBottom): the row's own bottom, the row below's + * top and its cells' tops, and at a section edge the row groups' borders. + */ +function edgeSourcesOf(row: HTMLElement): { + bottoms: HTMLElement[]; + tops: HTMLElement[]; +} { + const bottoms: HTMLElement[] = [row]; + const tops: HTMLElement[] = []; + const group = row.parentElement; + const next = + row.nextElementSibling ?? + group?.nextElementSibling?.firstElementChild ?? + null; + if (next instanceof HTMLElement) { + tops.push(next); + for (const cell of next.children) { + if (cell instanceof HTMLElement) { + tops.push(cell); + } + } + } + if (row.nextElementSibling == null && group instanceof HTMLElement) { + bottoms.push(group); + const nextGroup = group.nextElementSibling; + if (nextGroup instanceof HTMLElement) { + tops.push(nextGroup); + } + } + return {bottoms, tops}; +} + export function rowEdge(row: Element): BottomEdge | null { if (row.hasAttribute(GLIDING_ATTRIBUTE) || row.lastElementChild == null) { return null; @@ -179,11 +228,34 @@ export async function markGlidingRows( row.style.setProperty(GLIDE_DIVIDER_STYLE, edge.style); row.style.setProperty(GLIDE_DIVIDER_COLOR, edge.color); row.setAttribute(GLIDING_ATTRIBUTE, ''); - // An app's border on the row itself (a winning `tr` border) is painted - // by the table at the row's static place: it goes transparent too, and - // the line carries it. - inlineColorOf.set(row, row.style.borderBottomColor); - row.style.borderBottomColor = 'transparent'; + // Every edge the collapse could take the divider from is painted by the + // table at the row's static place: the row's own border, the row + // below's top and its cells' tops, and at a section edge the row + // groups' borders. They go transparent too, and the line carries the + // winner. + const sources = edgeSourcesOf(row); + sourcesOf.set(row, sources); + for (const element of sources.bottoms) { + if (!inlineColorOf.has(element)) { + inlineColorOf.set(element, element.style.borderBottomColor); + element.style.borderBottomColor = 'transparent'; + } + } + for (const element of sources.tops) { + if (!inlineTopOf.has(element)) { + inlineTopOf.set(element, element.style.borderTopColor); + element.style.borderTopColor = 'transparent'; + } + } + const ownWidth = Number.parseFloat( + getComputedStyle(row).borderBottomWidth, + ); + // (Read before any write of this batch's next row: the row's own + // width does not change with its color.) + row.style.setProperty( + '--_table-row-glide-offset', + `${Number.isFinite(ownWidth) ? ownWidth : 0}px`, + ); // The table's last row can glide a pixel farther than the rows above // it (its border collapses differently), so mid-glide it overlaps the // row above. Each moving row is its own layer; the last one stays @@ -222,10 +294,21 @@ export async function markGlidingRows( markedBy.delete(row); row.removeAttribute(GLIDING_ATTRIBUTE); row.style.removeProperty('z-index'); - if (inlineColorOf.has(row)) { - row.style.borderBottomColor = inlineColorOf.get(row) ?? ''; - inlineColorOf.delete(row); + const {bottoms, tops} = sourcesOf.get(row) ?? {bottoms: [], tops: []}; + sourcesOf.delete(row); + for (const element of bottoms) { + if (inlineColorOf.has(element)) { + element.style.borderBottomColor = inlineColorOf.get(element) ?? ''; + inlineColorOf.delete(element); + } + } + for (const element of tops) { + if (inlineTopOf.has(element)) { + element.style.borderTopColor = inlineTopOf.get(element) ?? ''; + inlineTopOf.delete(element); + } } + row.style.removeProperty('--_table-row-glide-offset'); for (const cell of row.children) { cell.removeAttribute(CELL_ATTRIBUTE); if (cell instanceof HTMLElement && inlineColorOf.has(cell)) { diff --git a/packages/core/src/theme/derivedVarRegistry.test.ts b/packages/core/src/theme/derivedVarRegistry.test.ts index b0b5846cf1cd6..74cd3ff656e61 100644 --- a/packages/core/src/theme/derivedVarRegistry.test.ts +++ b/packages/core/src/theme/derivedVarRegistry.test.ts @@ -95,6 +95,7 @@ const STRUCTURAL_VARS = new Set([ '--_table-row-glide-width', '--_table-row-glide-style', '--_table-row-glide-color', + '--_table-row-glide-offset', '--separator-display', '--astryx-section-padding', // Private counterpart of the public token above: one ancestor Section's From eeacd267b385c224d2273a06ec41fe6f28afc703 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Wed, 28 Oct 2026 01:22:08 -0700 Subject: [PATCH 58/58] fix(core): a gliding row's line sits where its divider rests at any width CodeBunny on #8656 (Table FR14): - For a divider wider than 1 px (a theme's 2 px, an app's 3 px tr border) the moving line sat a pixel above the pinned copy and above where the border rests, so it stepped at the pinned edge and dropped as the glide ended. When it marks a row, the glide reads where the collapsed edge rests (a cell's padding-box bottom, from the whole pixel the table paints it on) against the row's padding box the line hangs from, and offsets the line by that. - The browser level checks now fail on a 1 px step (exact rows of pixels), with a 2 px theme divider case beside the 3 px tr one; both fail on the previous head. GlidingRows gains wideBorder, and its rowBorder targets the fourth order by key, so the border stays on one row as a panel opens. - The doc entry and Changeset say what Reduce Motion does: rows fade in place on touch and change at once with a mouse. --- .changeset/table-row-expansion-glide.md | 2 +- .../stories/TableRowExpansion.stories.tsx | 21 ++++++--- .../TableRowGlide.a11y.browser.spec.ts | 45 ++++++++++--------- packages/core/src/Table/glidingDivider.ts | 29 ++++++++---- .../src/Table/useTableRowExpansion.doc.mjs | 4 +- 5 files changed, 64 insertions(+), 37 deletions(-) diff --git a/.changeset/table-row-expansion-glide.md b/.changeset/table-row-expansion-glide.md index 215361186325e..a602be810a05b 100644 --- a/.changeset/table-row-expansion-glide.md +++ b/.changeset/table-row-expansion-glide.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[feat] Table: `useTableRowExpansion` takes an `animatedLayout` handle from `useAnimatedLayout`. With it, opening or closing a row's panel glides the rows after it to their new places instead of jumping, and each row's divider moves with its row. Under Reduce Motion, and in a table with column lines (`dividers="columns"` or `"grid"`), the rows change at once. +[feat] Table: `useTableRowExpansion` takes an `animatedLayout` handle from `useAnimatedLayout`. With it, opening or closing a row's panel glides the rows after it to their new places instead of jumping, and each row's divider moves with its row. Under Reduce Motion the rows fade in place on a touch screen and change at once with a mouse; in a table with column lines (`dividers="columns"` or `"grid"`) they change at once. @cixzhang diff --git a/apps/storybook/stories/TableRowExpansion.stories.tsx b/apps/storybook/stories/TableRowExpansion.stories.tsx index 0c61b63837c3a..d7bf28e6449df 100644 --- a/apps/storybook/stories/TableRowExpansion.stories.tsx +++ b/apps/storybook/stories/TableRowExpansion.stories.tsx @@ -223,18 +223,26 @@ const sevenOrders = manyOrders.slice(0, 7); * `dividers` control at "columns" or "grid"), the rows change at once. The * `themed` control sets a blue theme border and an app's red border on the * tbody's third row; `rowBorder` sets an app's 3 px green border on the - * fourth row itself (`tr`), which glides with the row too; `edgeBorder` + * fourth order's row itself (`tr`), which glides with the row too; `edgeBorder` * sets one on the tbody's bottom (`tbody`) or as the top of the fifth order's - * cells (`top`), each of which wins the collapse and glides with its row. + * cells (`top`), each of which wins the collapse and glides with its row; + * `wideBorder` sets a theme's 2 px green divider on every row. */ export const GlidingRows: StoryObj<{ themed?: boolean; rowBorder?: boolean; + wideBorder?: boolean; edgeBorder?: 'none' | 'tbody' | 'top'; dividers?: 'rows' | 'columns' | 'grid'; }> = { - args: {themed: false, rowBorder: false, edgeBorder: 'none', dividers: 'rows'}, - render: ({themed, rowBorder, edgeBorder, dividers}) => { + args: { + themed: false, + rowBorder: false, + wideBorder: false, + edgeBorder: 'none', + dividers: 'rows', + }, + render: ({themed, rowBorder, wideBorder, edgeBorder, dividers}) => { const containerRef = useRef(null); const animatedLayout = useAnimatedLayout({containerRef}); const [expandedKeys, setExpandedKeys] = useState>( @@ -255,6 +263,9 @@ export const GlidingRows: StoryObj<{ const style: React.CSSProperties & Record = { maxWidth: 640, ...(themed === true ? {'--color-border': 'rgb(37, 99, 235)'} : {}), + ...(wideBorder === true + ? {'--color-border': 'rgb(22, 163, 74)', '--border-width': '2px'} + : {}), }; return (
    @@ -262,7 +273,7 @@ export const GlidingRows: StoryObj<{ )} {rowBorder && ( - + )} {edgeBorder === 'tbody' && ( diff --git a/packages/core/src/Table/__tests__/TableRowGlide.a11y.browser.spec.ts b/packages/core/src/Table/__tests__/TableRowGlide.a11y.browser.spec.ts index 4be7905b0e450..91eaa09694e66 100644 --- a/packages/core/src/Table/__tests__/TableRowGlide.a11y.browser.spec.ts +++ b/packages/core/src/Table/__tests__/TableRowGlide.a11y.browser.spec.ts @@ -200,8 +200,9 @@ test("an app's border on a row itself moves with the row, pinned cells included, }) => { await mount(page, 'gliding-rows', '&args=rowBorder:!true'); expect(await toggleAndHold(page, 0, 90)).toBeGreaterThan(0); - // The green border is on the tbody's fourth row, which glides down. - const {y, pinnedX, unpinnedX} = await rowBottom(page, 3); + // The green border is on the fourth order's row, which glides down: the + // open panel sits above it, so it is the tbody's fifth row now. + const {y, pinnedX, unpinnedX} = await rowBottom(page, 4); for (const [where, x] of [ ['pinned', pinnedX], ['unpinned', unpinnedX], @@ -213,7 +214,7 @@ test("an app's border on a row itself moves with the row, pinned cells included, const travel = await page.evaluate(() => { const row = document.querySelectorAll( '.astryx-table-scroll-wrapper tbody tr', - )[3] as HTMLElement; + )[4] as HTMLElement; const parts = getComputedStyle(row).translate.split(' '); return Number.parseFloat(parts[1] ?? '0') || 0; }); @@ -252,24 +253,26 @@ async function greenRowsAt(page: Page, x: number): Promise { }, shot.toString('base64')); } -test("a row's own border glides level across the pinned and scrolling cells", async ({ - page, -}) => { - await mount(page, 'gliding-rows', '&args=rowBorder:!true'); - expect(await toggleAndHold(page, 0, 90)).toBeGreaterThan(0); - const {pinnedX, unpinnedX} = await rowBottom(page, 3); - const pinned = await greenRowsAt(page, pinnedX); - const unpinned = await greenRowsAt(page, unpinnedX); - expect(pinned.length).toBeGreaterThan(0); - expect(unpinned.length).toBeGreaterThan(0); - // One line, at one height on both sides of the pinned edge. - expect(Math.abs(Math.min(...pinned) - Math.min(...unpinned))).toBeLessThan( - 1.5, - ); - expect(Math.abs(Math.max(...pinned) - Math.max(...unpinned))).toBeLessThan( - 1.5, - ); -}); +for (const [name, args] of [ + ["a row's own 3 px border", '&args=rowBorder:!true'], + ["a theme's 2 px divider", '&args=wideBorder:!true'], +] as const) { + test(`${name} glides level across the pinned and scrolling cells`, async ({ + page, + }) => { + await mount(page, 'gliding-rows', args); + expect(await toggleAndHold(page, 0, 90)).toBeGreaterThan(0); + const {pinnedX, unpinnedX} = await rowBottom(page, 4); + const pinned = await greenRowsAt(page, pinnedX); + const unpinned = await greenRowsAt(page, unpinnedX); + expect(pinned.length).toBeGreaterThan(0); + expect(unpinned.length).toBeGreaterThan(0); + // One line, at one height on both sides of the pinned edge: not even a + // 1 px step. + expect(Math.min(...pinned)).toBe(Math.min(...unpinned)); + expect(Math.max(...pinned)).toBe(Math.max(...unpinned)); + }); +} for (const edge of ['tbody', 'top'] as const) { test(`a divider the collapse takes from ${edge === 'tbody' ? "the body's bottom border" : "the next row's top border"} is drawn once while it glides`, async ({ diff --git a/packages/core/src/Table/glidingDivider.ts b/packages/core/src/Table/glidingDivider.ts index ba9cfda75bf7c..e1d071508bc38 100644 --- a/packages/core/src/Table/glidingDivider.ts +++ b/packages/core/src/Table/glidingDivider.ts @@ -68,8 +68,8 @@ export const glidingDividerStyles = stylex.create({ content: {default: null, [CELL_LINE]: '""'}, position: {default: null, [CELL_LINE]: 'absolute'}, insetInline: {default: null, [CELL_LINE]: 0}, - // A row's own border is drawn below its box: the line sits that much - // lower, where the border rests. + // Where the collapsed edge rests below the row's padding box (a wide + // border, or the row's own): measured when the row is marked. bottom: { default: null, [CELL_LINE]: 'calc(-1 * var(--_table-row-glide-offset, 0px))', @@ -221,6 +221,24 @@ export async function markGlidingRows( return; } const batch = Symbol('glide'); + // Where each row's divider rests, read before any write. The line hangs + // from the row's padding box (its containing block), which a row's own + // border shortens; the collapsed edge starts at a cell's padding-box + // bottom, which a wide border puts below it. The table paints that edge + // from the whole pixel at or above it, so the line starts there too. + const offsets = new Map(); + for (const row of rows.keys()) { + const edge = edges.get(row); + const cell = row.lastElementChild; + if (edge == null || !(cell instanceof HTMLElement)) { + continue; + } + const cellEdge = + cell.getBoundingClientRect().top + cell.clientTop + cell.clientHeight; + const rowPaddingEdge = + row.getBoundingClientRect().top + row.clientTop + row.clientHeight; + offsets.set(row, Math.floor(cellEdge) + edge.width - rowPaddingEdge); + } for (const row of rows.keys()) { const edge = edges.get(row); if (edge != null && !row.hasAttribute(GLIDING_ATTRIBUTE)) { @@ -247,14 +265,9 @@ export async function markGlidingRows( element.style.borderTopColor = 'transparent'; } } - const ownWidth = Number.parseFloat( - getComputedStyle(row).borderBottomWidth, - ); - // (Read before any write of this batch's next row: the row's own - // width does not change with its color.) row.style.setProperty( '--_table-row-glide-offset', - `${Number.isFinite(ownWidth) ? ownWidth : 0}px`, + `${offsets.get(row) ?? 0}px`, ); // The table's last row can glide a pixel farther than the rows above // it (its border collapses differently), so mid-glide it overlaps the diff --git a/packages/core/src/Table/useTableRowExpansion.doc.mjs b/packages/core/src/Table/useTableRowExpansion.doc.mjs index b5f40c425016c..1c2d753dbe813 100644 --- a/packages/core/src/Table/useTableRowExpansion.doc.mjs +++ b/packages/core/src/Table/useTableRowExpansion.doc.mjs @@ -59,7 +59,7 @@ export const docs = { name: 'animatedLayout', type: 'AnimatedLayout', description: - "A handle from useAnimatedLayout. With it, opening or closing a panel glides the rows after it (and the content after the table) to their new places instead of jumping, the opening panel plays in on the same motion, and each row's divider moves with it. Under Reduce Motion, and in a table with column lines (dividers columns or grid, which do not move yet), the rows change at once. Without it, rows jump.", + "A handle from useAnimatedLayout. With it, opening or closing a panel glides the rows after it (and the content after the table) to their new places instead of jumping, the opening panel plays in on the same motion, and each row's divider moves with it. Under Reduce Motion the rows fade in place on a touch screen and change at once with a mouse; in a table with column lines (dividers columns or grid, which do not move yet) they change at once. Without it, rows jump.", }, ], examples: [ @@ -131,6 +131,6 @@ export const docsDense = { hasRowClickExpansion: 'Toggle a row by clicking anywhere on it, not just the chevron; pointer-only, skips interactive cell content and text selections. Defaults to false.', animatedLayout: - 'Handle from useAnimatedLayout: opening or closing a panel glides the rows after it instead of jumping them. Under Reduce Motion or with column lines, rows change at once.', + 'Handle from useAnimatedLayout: opening or closing a panel glides the rows after it instead of jumping them. Under Reduce Motion they fade in place on touch and change at once with a mouse; with column lines they change at once.', }, };