+ );
+}
+
+/**
+ * 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/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",
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..6ae976ea20bf6 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; `spec:AST-108` FR4a). 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 | `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 db4ba0d6eb55c..411a414f10fdf 100644
--- a/packages/core/src/Collapsible/Collapsible.tsx
+++ b/packages/core/src/Collapsible/Collapsible.tsx
@@ -27,7 +27,16 @@
* - /packages/cli/assets/templates/blocks/components/Collapsible/ (showcase blocks)
*/
-import {use, useId, type ReactNode} from 'react';
+import {
+ use,
+ useCallback,
+ useId,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from 'react';
import * as stylex from '@stylexjs/stylex';
import {
borderVars,
@@ -41,6 +50,15 @@ import {
} from '../theme/tokens.stylex';
import {useCollapsible} from './useCollapsible';
+import {LayoutChangeSnapshot} from '../motion/LayoutChangeSnapshot';
+import {
+ applyLayoutChangeTransition,
+ holdLayoutExit,
+ playLayoutChange,
+ releaseLayoutExit,
+ reportLayoutChange,
+ type AnimatedLayout,
+} from '../motion/animatedLayout';
import {CollapsibleGroupPresentationContext} from './CollapsibleGroupContext';
import type {CollapsibleChevronPosition} from './CollapsibleGroupContext';
import {Icon} from '../Icon';
@@ -242,6 +260,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.
@@ -317,6 +341,7 @@ export function Collapsible({
onOpenChange,
chevronPosition,
value,
+ animatedLayout,
ref,
xstyle,
className,
@@ -335,6 +360,95 @@ 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],
+ );
+ // 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,
+ triggerRef.current?.querySelector(':scope > .astryx-icon') ??
+ null,
+ );
+ 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
+ // 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) {
+ return;
+ }
+ wasOpenRef.current = isOpen;
+ const content = contentRef.current;
+ if (content == null) {
+ return;
+ }
+ // 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.
+ releaseLayoutExit(content);
+ if (isMeasured) {
+ playLayoutChange(animatedLayout, content);
+ }
+ return;
+ }
+ const isHeld =
+ isExiting &&
+ isMeasured &&
+ holdLayoutExit(animatedLayout, content, endExit);
+ if (isExiting && !isHeld) {
+ // 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]);
+ useLayoutEffect(() => {
+ if (!isOpen && !isExiting && contentRef.current != null) {
+ // Hidden again after its exit: drop the exit's inline values.
+ releaseLayoutExit(contentRef.current);
+ }
+ }, [isOpen, isExiting]);
+
// 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
@@ -399,6 +513,7 @@ export function Collapsible({
)}
{...props}>
+ {animatedLayout != null && (
+
+ )}
{presentation != null ? (
diff --git a/packages/core/src/TreeList/TreeList.doc.mjs b/packages/core/src/TreeList/TreeList.doc.mjs
index 7251c7c53fbca..2460d5fb4033d 100644
--- a/packages/core/src/TreeList/TreeList.doc.mjs
+++ b/packages/core/src/TreeList/TreeList.doc.mjs
@@ -118,6 +118,12 @@ export const docs = {
'Visual treatment of the hierarchy guide lines. lineGuides shows connector lines; noGuides hides them, keeping indentation. Orthogonal to density.',
default: "'lineGuides'",
},
+ {
+ name: 'animatedLayout',
+ type: 'AnimatedLayout',
+ description:
+ 'The handle from useAnimatedLayout({containerRef}). With it, expanding or collapsing a row glides the rows and content after it, up to that container, on one shared motion role instead of jumping, and the shown rows and the chevron move on the same motion. Without it, the content after the row takes its place at once.',
+ },
{
name: 'header',
type: 'ReactNode',
@@ -199,6 +205,12 @@ export const docsZh = {
'层级引导线的视觉呈现。lineGuides 显示连接线;noGuides 隐藏连接线并保留缩进。与 density 正交。',
default: "'lineGuides'",
},
+ {
+ name: 'animatedLayout',
+ type: 'AnimatedLayout',
+ description:
+ 'useAnimatedLayout({containerRef}) 返回的句柄。传入后,展开或收起一行时,该行之后直到该容器内的行和内容会以同一个共享动效角色平滑移动,而不是跳动;新显示的行和箭头也使用同一动效。不传时,该行之后的内容会立即就位。',
+ },
{
name: 'header',
type: 'ReactNode',
@@ -248,6 +260,7 @@ export const docsDense = {
items: 'Recursive tree item data w/ id, label, optional children + isExpanded, plus optional xstyle/className/style on its row.',
density: 'Spacing density for items.',
variant: 'Guide-line treatment: lineGuides shows connectors, noGuides hides them (indent kept). Orthogonal to density.',
+ animatedLayout: 'handle from useAnimatedLayout({containerRef}); expand/collapse glides the content after the row inside that container on one shared motion role. Omit: content after the row snaps.',
header: 'Header content, linked to tree via aria-labelledby.',
xstyle: 'StyleX styles for layout. Must be stylex.create() value.',
},
diff --git a/packages/core/src/TreeList/TreeList.spec.md b/packages/core/src/TreeList/TreeList.spec.md
index d92dafad123f9..29b94296425f5 100644
--- a/packages/core/src/TreeList/TreeList.spec.md
+++ b/packages/core/src/TreeList/TreeList.spec.md
@@ -91,9 +91,10 @@ the accepted API direction. Consumer syntax and release timing belong in
## Public concepts
-| Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
-| ---------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------------- | ------- | -------------------- | --------- | --------------------------------------------------------------- |
-| Item row styling | `className`, `style`, and/or `xstyle` | Applies caller-supplied classes, inline styles, or StyleX styles to one item's stable row box. | Every rendered item row | Absent | `component:TreeList` | Stable | TypeScript rejects unsupported values; supplied inputs compose. |
+| Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
+| ---------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------- | -------------------- | --------- | --------------------------------------------------------------- |
+| Item row styling | `className`, `style`, and/or `xstyle` | Applies caller-supplied classes, inline styles, or StyleX styles to one item's stable row box. | Every rendered item row | Absent | `component:TreeList` | Stable | TypeScript rejects unsupported values; supplied inputs compose. |
+| Animated layout | absent, a `useAnimatedLayout` handle | Whether expanding and collapsing a row glide the content after it, up to the handle's container (`spec:AST-105`). | Every tree | Absent: content after the row takes its place at once | `spec:AST-105` | Stable | TypeScript rejects unsupported values. |
The three mechanisms are one styling concept, not three separate feature tiers.
Supporting one does not justify omitting another from the durable contract.
@@ -110,6 +111,7 @@ Supporting one does not justify omitting another from the durable contract.
| 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 | Expanding or collapsing an Item with children MUST play its group's own motion: under a coarse primary pointer its rows slide out from under the parent as one block, clipped to the group's box, and back on collapse, a collapsing group's rows leaving the accessibility tree as the slide starts, while the Chevron turns, by transform only (`spec:AST-094` FR4–FR5); under a fine pointer the group appears and leaves at once. With an `animatedLayout` handle, the group and the rows after it follow `spec:AST-105`'s shared motion under every pointer (FR4, FR5); without one, the group's own slide stays touch-only and the rows after the group take their places at once (`spec:AST-105` FR2). | UIKit outline list (`UICollectionView`), iOS 26.2; `spec:AST-105` FR2 | Proposed; awaiting an engineering owner's approval |
+| FR9 | `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; `spec:AST-108` FR4a). Without one, expanding and collapsing follow FR8. The prop MUST NOT change tree semantics or keyboard behavior. | `spec:AST-105` FR1, FR2; `spec:AST-108` FR4a | Current behavior |
### Allowed variation
@@ -275,6 +277,7 @@ authorize or prohibit a future target.
| 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 | Real-browser frame evidence under a finger and a mouse | expand and collapse; a toggle mid-motion; reduced motion; rows after the group, with and without an `animatedLayout` handle | Rows inside the group that jump, travel under a fine pointer without a handle or under reduced motion, an animated property under a fine pointer with reduced motion, a fade under a mouse without a handle, collapsing rows still in the accessibility tree during the slide, layout or paint per frame, or rows after the group moving without a handle fails | `audit:TreeList/behavior` |
+| FR9 | `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 dca4724b7eff1..3bcaf8033f32f 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,16 @@ import type {
} from './TreeListTypes';
import {themeProps} from '../utils/themeProps';
import {useTreeFocusInternal} from '../hooks/useTreeFocus';
+import {LayoutChangeSnapshot} from '../motion/LayoutChangeSnapshot';
+import {
+ applyLayoutChangeTransition,
+ holdLayoutExit,
+ playLayoutChange,
+ releaseLayoutExit,
+ reportLayoutChange,
+ type AnimatedLayout,
+ type LayoutChange,
+} from '../motion/animatedLayout';
// =============================================================================
// Types
@@ -39,6 +57,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.
@@ -183,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',
@@ -196,6 +237,7 @@ export function TreeList({
'aria-labelledby': ariaLabelledby,
ref,
onKeyDown: onKeyDownProp,
+ animatedLayout,
...restProps
}: TreeListProps) {
const headerId = useId();
@@ -211,6 +253,145 @@ export function TreeList({
Map
>(() => new Map());
+ // 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.
+ const treeElementRef = useRef(null);
+ // 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) => {
+ // 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;
+ }
+ const next = new Set(prev);
+ next.delete(id);
+ return next;
+ });
+ }, []);
+ // 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;
+ /** Changed rows inside this one: they move and exit with it. */
+ nested: string[];
+ }[]
+ >([]);
+ const measureChanges = useCallback(
+ (previous: ReadonlySet) => {
+ const changed = [
+ ...[...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) {
+ 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(
+ animatedLayout,
+ row.querySelector('[data-tree-toggle] > .astryx-icon'),
+ );
+ const change = reportLayoutChange(animatedLayout, () => row);
+ change.measureBefore();
+ pendingLayoutRef.current.push({
+ 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 => {
@@ -259,6 +440,44 @@ export function TreeList({
return false;
}, []);
+ useLayoutEffect(() => {
+ 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;
+ }
+ // 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, 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.
+ endAll();
+ }
+ }
+ }, [expandedIds, animatedLayout, endExit]);
+
const {treeRef, handleKeyDown, handleFocus} =
useTreeFocusInternal({
onToggleExpand: handleToggle,
@@ -267,7 +486,12 @@ export function TreeList({
// Rows are named by their label (AR1); typeahead matches that label, not
// the chevron toggle, start content or description.
matchLabelledBy: true,
+ // Rows of a group still playing its collapse are gone already.
+ itemSelector: '[role="treeitem"]:not([data-tree-exiting] *)',
});
+ useLayoutEffect(() => {
+ treeElementRef.current = treeRef.current;
+ });
const handleRootKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -316,8 +540,9 @@ export function TreeList({
? [...ancestorsIsLast, isLast]
: ancestorsIsLast;
+ const isExiting = exitingIds.has(item.id);
const renderedChildren =
- isExpanded && hasChildren
+ (isExpanded || isExiting) && hasChildren
? renderItems(
item.children ?? [],
nestedLevel + 1,
@@ -347,6 +572,7 @@ export function TreeList({
isLast={isLast}
ancestorsIsLast={ancestorsIsLast}
isExpanded={isExpanded}
+ isExiting={isExiting && !isExpanded}
onToggle={handleToggle}
density={density}
variant={variant}
@@ -376,6 +602,12 @@ export function TreeList({
{header}
)}
+ {animatedLayout != null && (
+
+ )}
;
isExpanded: boolean;
+ /**
+ * The row collapsed and its child group is playing its exit: the group
+ * stays rendered (the exit makes it inert) until it ends, and its rows
+ * leave the tree's keyboard order at once.
+ */
+ isExiting?: boolean;
onToggle?: (id: string) => void;
density: TreeListDensity;
/**
@@ -397,6 +403,7 @@ export function TreeListItem({
isLast,
ancestorsIsLast,
isExpanded,
+ isExiting = false,
onToggle,
density,
variant,
@@ -703,8 +710,11 @@ export function TreeListItem({
)}
diff --git a/packages/core/src/hooks/index.ts b/packages/core/src/hooks/index.ts
index f465e2a473933..5ee93b5cb4840 100644
--- a/packages/core/src/hooks/index.ts
+++ b/packages/core/src/hooks/index.ts
@@ -86,6 +86,12 @@ 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.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/hooks/useAnimatedLayout.ts b/packages/core/src/hooks/useAnimatedLayout.ts
new file mode 100644
index 0000000000000..861a5600c0b6a
--- /dev/null
+++ b/packages/core/src/hooks/useAnimatedLayout.ts
@@ -0,0 +1,61 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+'use client';
+
+/**
+ * @file useAnimatedLayout.ts
+ * @input React (useMemo), motion/animatedLayout.ts
+ * @output Exports `useAnimatedLayout` and the opaque `AnimatedLayout` handle
+ * type
+ * @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, 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;
+}
+
+/**
+ * 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
+ * ```
+ * const containerRef = useRef(null);
+ * const animatedLayout = useAnimatedLayout({containerRef});
+ * return (
+ *
+ *
+ *
Notes
+ *
+ * );
+ * ```
+ */
+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.
+ return useMemo(
+ () => createAnimatedLayout(() => containerRef.current),
+ [containerRef],
+ );
+}
diff --git a/packages/core/src/motion/LayoutChangeSnapshot.tsx b/packages/core/src/motion/LayoutChangeSnapshot.tsx
new file mode 100644
index 0000000000000..f8e3f6c94eb30
--- /dev/null
+++ b/packages/core/src/motion/LayoutChangeSnapshot.tsx
@@ -0,0 +1,50 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file LayoutChangeSnapshot.tsx
+ * @input A value and a callback
+ * @output Exports LayoutChangeSnapshot: calls the callback in the commit that
+ * changes the value, before React changes the DOM
+ * @position Motion foundation, internal to core; how a component that takes
+ * an `animatedLayout` handle measures each change before it lands
+ * (`spec:AST-105` FR1), whatever caused it: a click, an owner-set prop, or
+ * a group's value
+ *
+ * `getSnapshotBeforeUpdate` runs in the commit, after render and before any
+ * DOM mutation of that commit, so the layout it reads is the old one. Unlike
+ * a read during render, it runs only for a render that commits, once (a
+ * StrictMode replay or a render React throws away never reaches it), and it
+ * keeps render pure (`architecture:react-component-runtime` INV1, INV3).
+ * Only a class component has it.
+ *
+ * SYNC: When modified, update Collapsible.tsx and TreeList.tsx.
+ */
+
+import {Component} from 'react';
+
+interface LayoutChangeSnapshotProps {
+ /** 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..bc6a9bd8c8781
--- /dev/null
+++ b/packages/core/src/motion/__tests__/animatedLayoutAdopters.interaction.browser.spec.ts
@@ -0,0 +1,217 @@
+// 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`
+ * FR9. 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 {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 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 => {
+ 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 FR9 (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);
+ 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);
+ 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);
+ });
+
+ 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/animatedLayout.boundary.test.ts b/packages/core/src/motion/animatedLayout.boundary.test.ts
index 9ac1d1947eafd..f02e6f0dd3681 100644
--- a/packages/core/src/motion/animatedLayout.boundary.test.ts
+++ b/packages/core/src/motion/animatedLayout.boundary.test.ts
@@ -124,8 +124,10 @@ describe('architecture:react-component-runtime INV12 source boundary', () => {
)
.map(path => relative(coreSrc, path))
.sort();
- // The hook and the components that adopt the handle join this list as
- // they adopt it.
- expect(users).toEqual([]);
+ expect(users).toEqual([
+ 'Collapsible/Collapsible.tsx',
+ 'TreeList/TreeList.tsx',
+ 'hooks/useAnimatedLayout.ts',
+ ]);
});
});
diff --git a/packages/core/src/motion/animatedLayout.test.ts b/packages/core/src/motion/animatedLayout.test.ts
index fe38c6fcff8c0..bddff357ba3c3 100644
--- a/packages/core/src/motion/animatedLayout.test.ts
+++ b/packages/core/src/motion/animatedLayout.test.ts
@@ -14,6 +14,7 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {
+ applyLayoutChangeTransition,
createAnimatedLayout,
holdLayoutExit,
playLayoutChange,
@@ -649,6 +650,42 @@ describe('two changes in one frame (spec:AST-105 FR3)', () => {
});
});
+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');
+ });
+});
+
describe('elements the app moves itself (spec:AST-105 Coverage)', () => {
it('leaves a follower with its own transform or translate to take its place at once', async () => {
const {area, changed, phase} = setUp(
diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts
index ed60b5b766aaf..947d726f3b571 100644
--- a/packages/core/src/motion/animatedLayout.ts
+++ b/packages/core/src/motion/animatedLayout.ts
@@ -6,8 +6,9 @@
* 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 `holdLayoutExit` /
- * `releaseLayoutExit`: the one module that produces the
+ * handle, `reportLayoutChange`, `playLayoutChange`, `holdLayoutExit` /
+ * `releaseLayoutExit` and `applyLayoutChangeTransition`: the one module
+ * that produces the
* size-change glide `spec:AST-105` defines
* (`architecture:react-component-runtime` INV12)
* @position Motion foundation, internal to core. A handle comes from the
@@ -36,7 +37,8 @@
* - A measurement no commit claims by the next frame is dropped.
* - The changed element plays its own entrance on the same spring, started in
* the same frame (`playLayoutChange`), or its exit: kept where it is while
- * it gives up its space and conceals (`holdLayoutExit`).
+ * it gives up its space and conceals (`holdLayoutExit`). A disclosure's
+ * indicator turns on the same motion (`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.
@@ -151,6 +153,18 @@ function sampleKeyframes(
return sampleOver(progress.durationMs, t => frame(progress.remaining(t)));
}
+/** 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;
}
@@ -959,6 +973,42 @@ export function releaseLayoutExit(element: HTMLElement): void {
}
}
+/**
+ * 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);
diff --git a/packages/core/src/motion/animatedLayoutAdopters.test.tsx b/packages/core/src/motion/animatedLayoutAdopters.test.tsx
new file mode 100644
index 0000000000000..8d2617c6a6ef1
--- /dev/null
+++ b/packages/core/src/motion/animatedLayoutAdopters.test.tsx
@@ -0,0 +1,500 @@
+// 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 {act, render, renderHook, screen} from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import {StrictMode, useRef, type ReactNode} from 'react';
+import {Collapsible} from '../Collapsible/Collapsible';
+import {CollapsibleGroup} from '../Collapsible/CollapsibleGroup';
+import {TreeList} from '../TreeList/TreeList';
+import type {TreeListItemData} from '../TreeList/TreeListTypes';
+import {
+ useAnimatedLayout,
+ type AnimatedLayout,
+} from '../hooks/useAnimatedLayout';
+
+let animated: Element[] = [];
+/** Each animation's finish listener, by the element it plays on. */
+let finishes = new Map void>();
+const originalAnimate = Element.prototype.animate;
+
+/** Records an animation on `target` and keeps its finish listener. */
+function recordAnimation(target: Element): Animation {
+ animated.push(target);
+ return {
+ cancel() {},
+ addEventListener(type: string, listener: () => void) {
+ if (type === 'finish') {
+ finishes.set(target, listener);
+ }
+ },
+ } as unknown as Animation;
+}
+
+beforeEach(() => {
+ animated = [];
+ finishes = new Map();
+ Element.prototype.animate = function animate(this: Element) {
+ return recordAnimation(this);
+ };
+});
+
+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('');
+ });
+});
+
+describe('Collapsible changes another component made (spec:AST-105 FR3)', () => {
+ it('glides the items between when a single-mode group closes one item to open another', async () => {
+ const user = userEvent.setup();
+ render(
+
+ {layout => (
+
+
+ Shipping details
+
+
+ Payment details
+
+
+ Warranty details
+
+
+ )}
+ ,
+ );
+ stubGeometry();
+ // The Payment item sits between the one that closes and the one that
+ // opens: it moves up when Shipping closes.
+ const payment = screen
+ .getByRole('button', {name: 'Payment'})
+ .closest('.astryx-collapsible');
+ expect(payment).not.toBeNull();
+ let reads = 0;
+ vi.spyOn(
+ payment as HTMLElement,
+ 'getBoundingClientRect',
+ ).mockImplementation(() => {
+ reads += 1;
+ return new DOMRect(0, reads === 1 ? 140 : 40, 400, 40);
+ });
+ await user.click(screen.getByRole('button', {name: 'Warranty'}));
+ await flush();
+ expect(animated).toContain(payment);
+ });
+
+ it('glides an owner-set isOpen too (spec:AST-105 FR1: each change is reported)', async () => {
+ const {rerender} = render(
+
+ {layout => (
+
+ Hidden content
+
+ )}
+ ,
+ );
+ const after = stubGeometry();
+ rerender(
+
+ {layout => (
+
+ Hidden content
+
+ )}
+ ,
+ );
+ 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([]);
+ });
+});
+
+describe("a close plays the content's exit (spec:AST-105 FR4)", () => {
+ it("keeps a closing Collapsible's content rendered, inert and out of flow until its exit ends", async () => {
+ const user = userEvent.setup();
+ render(
+
+ {layout => (
+
+ Shown content
+
+ )}
+ ,
+ );
+ stubGeometry();
+ const trigger = screen.getByRole('button', {name: 'Details'});
+ const content = document.getElementById(
+ trigger.getAttribute('aria-controls') ?? '',
+ ) as HTMLElement;
+ vi.spyOn(content, 'getBoundingClientRect').mockReturnValue(
+ new DOMRect(0, 40, 400, 40),
+ );
+ await user.click(trigger);
+ await flush();
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
+ // Still on screen for the exit, out of reach, giving up its space.
+ expect(getComputedStyle(content).display).not.toBe('none');
+ expect(content).toHaveAttribute('inert');
+ expect(content.style.getPropertyValue('margin-block-end')).not.toBe('');
+ expect(animated).toContain(screen.getByTestId('after'));
+ act(() => finishes.get(content)?.());
+ expect(getComputedStyle(content).display).toBe('none');
+ expect(content).not.toHaveAttribute('inert');
+ expect(content.style.getPropertyValue('margin-block-end')).toBe('');
+ });
+
+ it("keeps a collapsing TreeList row's child group until its exit ends, out of the keyboard order", async () => {
+ const user = userEvent.setup();
+ render(
+
+ {layout => (
+
+ )}
+ ,
+ );
+ stubGeometry();
+ const groupOf = () =>
+ document.querySelector(
+ '[data-tree-id="src"] > [role="group"]',
+ );
+ vi.spyOn(groupOf() as HTMLElement, 'getBoundingClientRect').mockReturnValue(
+ new DOMRect(0, 40, 400, 40),
+ );
+ await user.click(
+ document.querySelector('[data-tree-toggle]') ??
+ document.body,
+ );
+ await flush();
+ const group = groupOf();
+ expect(group).not.toBeNull();
+ expect(group).toHaveAttribute('inert');
+ expect(group?.style.getPropertyValue('margin-block-end')).not.toBe('');
+ // The tree's keyboard order (the rows its focus model reaches) has only
+ // the collapsed row.
+ expect(
+ document.querySelectorAll('[role="treeitem"]:not([data-tree-exiting] *)'),
+ ).toHaveLength(1);
+ act(() => finishes.get(group as HTMLElement)?.());
+ 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(
+
+ {layout => (
+
+ Shown content
+
+ )}
+ ,
+ );
+ const trigger = screen.getByRole('button', {name: 'Details'});
+ await user.click(trigger);
+ const content = document.getElementById(
+ trigger.getAttribute('aria-controls') ?? '',
+ ) as HTMLElement;
+ expect(getComputedStyle(content).display).toBe('none');
+ expect(content).not.toHaveAttribute('inert');
+ });
+});