Repository navigation
feat(core): useAnimatedLayout, and Collapsible and TreeList glide the content after them (spec:AST-105) - #8568
Conversation
…t 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.
…ollapsible 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.
…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.
…+ Add tag wraps and its followers move
…tion by pointer, visible-part rule for the changed element, top-layer and hidden skipped, chevron on the spring)
- 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.
… 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.
… 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.
…ed 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.
spec:AST-105's Coverage row: an element already moving keeps its running motion and adds the new move. The new glide now samples the sum of the running glide's remaining curve and the new move into one replace-composited translate, so the element keeps its speed through the change instead of restarting from rest. Every animation of a change starts on the document timeline's current time, so a replaced glide moves in the very frame it is replaced and the changed element and its followers share one start. Tests: the carried speed (jsdom) and, in a real browser, no jump and no lost speed across the change.
…e morph once Core has it, its own compositor translate until then
…st adopt it in their own pull request The module, the hook, their tests and docs, and the INV12 record line stay here. Collapsible's and TreeList's adoption (their animatedLayout prop, docs and records), the CollapsibleGroup measurement, the adopter tests and the Storybook story move to a stacked pull request, so each lands and reverts on its own. The changeset now describes the hook alone.
Restores the adoption from the module pull request onto it: the animatedLayout prop on Collapsible and TreeList (docs and records), the CollapsibleGroup measuring every item a toggle changes, the adopter tests, and the Storybook story. The story keeps only adopting components and plain following content; its demos that drove the internal reporting functions directly are gone.
…motion spec:AST-105 FR4: the changed element plays its own animation when it exits too. holdLayoutExit keeps a closing element on screen but out of flow (absolutely positioned where it is), so the content after it glides up at once, and conceals it upward from its bottom edge on springs.settle (a fade under Reduce Motion with a coarse pointer, nothing with a fine one); the component hides it when the exit finishes, and releaseLayoutExit puts it back, including when it opens again mid-exit. The throttled-tree browser case now reads its animations in the same task, so a slow round trip can't outlast the glide.
…'s exit spec:AST-105 FR4: the changed element animates when it exits too. A measured close keeps the Collapsible's content (or the TreeList row's child group) rendered, inert and hidden from assistive technology, while holdLayoutExit holds it out of flow and conceals it on springs.settle; the rows after it glide up at once, and the content hides (or the group unmounts) when the exit ends. Opening again mid-exit ends it. Rows of an exiting group leave the tree's keyboard order at once. An unmeasured close, no handle, or an exit that lands at once hides it in the same commit.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
PR Analysis Report
|
…es with its row; the exit stays in flow - This pull request is now the internal module only: the public hook, its docs and the chevron's transition move to the adoption pull request, so nothing public appears before a component can take the handle. - A changed element that an earlier change in the same frame displaced moves with its row, by its total change (AST-105 FR3), while it plays its own entrance or exit; an element not rendered before the change has no old place and does not move. - The exit keeps the element in flow where it is and gives up its space with a negative end margin as large as its box, so it scrolls and clips with its container and keeps its width (no offset-parent positioning). - Browser proofs: the sticky row uses a stuck header after the change, the flex row a wrapped item and a row-direction neighbor whose rectangle does not change; new cases for the displaced changed element and the exit in a scrolled area.
There was a problem hiding this comment.
Approval is waiting on an engineering owner's spec decision, and one small fix is needed whatever that decision is. The proposed component:Collapsible FR10 and component:TreeList FR8 add a clause that spec:AST-105 does not state, that with a handle the disclosure chevron turns on the shared 735 ms spring instead of the theme's turn; an engineering owner records approval of this exact head by commenting /approve-spec 8338da941dd1b8187ac6f6b4ff3c590025824e49 on this pull request (the author may do so as an engineering owner), or asks for the clause to be dropped. Either way, please drop the AST-108 FR4a citation from FR10 and FR8, or cite it as spec:AST-108 FR4a once #8613 is on main; everything else matches AST-105, and the note about being stacked on #8482 can go now that it has merged.
[Automated review]
|
/approve-spec 8338da9 |
…ared 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.
There was a problem hiding this comment.
Approval is waiting on an engineering owner's spec decision, and one fix is needed whatever that decision is. With an animatedLayout handle, the proposed component:Collapsible FR10 and the new TreeList row make the disclosure chevron turn on the shared 735 ms spring instead of the theme's turn, which spec:AST-105 does not state; the /approve-spec recorded on the previous head no longer applies, so an engineering owner approves the clause by commenting /approve-spec <full SHA> on the head that will merge (/approve-spec 007f4142037845183ec25c1b800337799e6c702d for this one) or asks for it to be dropped, and the author may do so as an engineering owner. Separately, main now gives component:TreeList FR8 to an expanding group's own motion (#8691), so this branch conflicts in TreeList.spec.md and CI has not run on it: please merge main, renumber the animatedLayout row (with its Verification row and the browser spec's FR8 references), and reword "Without one, expanding and collapsing are unchanged" so it agrees with the new FR8.
[Automated review]
| | 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 `<li>` 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 | |
There was a problem hiding this comment.
main now gives FR8 in this record to an expanding group's own motion (#8691), so this row conflicts with it and the branch cannot merge as is. After merging main, give this row its own number (FR9) or fold it into main's FR8 handle clause, update the Verification row and the FR8 references in animatedLayoutAdopters.interaction.browser.spec.ts, and reword "Without one, expanding and collapsing are unchanged", which contradicts the new FR8's no-handle slide under a coarse pointer.
…dopters cite AST-108 FR4a main gave component:TreeList FR8 to an expanding group's own motion (#8691). The animatedLayout row becomes FR9 (its Verification row and the interaction spec's references too), and 'without one' now defers to FR8. AST-108 is on main, so Collapsible FR10 and TreeList FR9 cite its FR4a (coordinated animations share one clock) for the chevron moving on the same motion as the glide.
|
/approve-spec 36c6fd8 |
There was a problem hiding this comment.
Approval is recommended: the new useAnimatedLayout hook, the animatedLayout prop and the glide match spec:AST-105, the chevron's shared spring matches spec:AST-108 FR4a, and the new Collapsible FR10 and TreeList FR9 rows carry an engineering owner's approval for this head. The adopters' browser spec passes, and without a handle the existing Collapsible and TreeList stories render exactly as before. Some CI jobs (unit, Storybook build and interaction proofs) had not reported yet, so let them finish before merging; the description still calls the TreeList row FR8 and the browser spec two tests, which you may want to refresh.
[Automated review]
… the TreeList expansion change TreeList.tsx and TreeListItem.tsx take meta#8568's code as merged. The touch slide re-applies on top: the tree renders a row's children through a function the row calls while it shows them, the row plays its own slide only while the tree holds no animated-layout handle (with one, the handle plays the group's motion under every pointer, component:TreeList FR8), the chevron's coarse-pointer transition gives way to the spring only without a handle, and a collapsed group still leaving, by either exit, carries data-tree-exiting and inert, so one keyboard selector skips it.
…List Main's meta#8568 (useAnimatedLayout and the TreeList glide) conflicts with the tree expansion change this PR carries through meta#8106, in TreeList.tsx and TreeListItem.tsx. Resolved as meta#8088's own merge of that main resolves the same conflict (6801c11, on the same file versions): the row renders its children through renderChildren, plays its touch slide only without an animated-layout handle (useTreeExpansionMotion's isEnabled), and a group collapsing under a handle is marked as exiting and inert. Nothing of this PR's own changes.
…t and the route lock - TreeList.tsx and TreeListItem.tsx: main's meta#8568 (useAnimatedLayout and the TreeList glide) against the tree expansion change carried through meta#8106. Resolved as meta#8088's own merge of that main resolves the same conflict (6801c11): the row renders its children through renderChildren, plays its touch slide only without an animated-layout handle (useTreeExpansionMotion's isEnabled), and a group collapsing under a handle is marked as exiting and inert; this branch's revert (dividers stay drawn on press, no Core selection hold) stays applied. - internal/shadcn-registry/routes.lock.json: regenerated by the docsite's generate-data.mjs (UPDATE_SHADCN_ROUTE_LOCK=1); it lists both hook-create-reorderable and main's hook-use-animated-layout.
User need
When a section opens or closes inside a scroll container or app panel, the content after it jumps to its new place, and closing content vanishes at once. People lose their place in a list, with a mouse and on a phone alike. Collapsible and TreeList are the components whose size changes most often.
Problem and solution fit
useAnimatedLayout({containerRef})hook returns one opaque handle per area; Collapsible and TreeList take it as a newanimatedLayoutprop, and report each open and close through it. The hook ships here, with the components that take it, so it is never public without a use.Current specification
spec:AST-105(current) FR1–FR5: one handle per area; a component without one keeps its behavior; the content after a change glides; the changed element animates entering and exiting on the one shared motion role; every pointer, with fine-pointer evidence on each adopting pull request.component:CollapsibleFR10 andcomponent:TreeListFR8 (added here) record the prop.Public delta and caller burden
useAnimatedLayout({containerRef})from@astryxdesign/core/hooks, returningAnimatedLayout, an opaque handle with no public members. Its documentation carriesspec:AST-105FR7's text.animatedLayout?: AnimatedLayouton Collapsible and TreeList. Without it, both render and behave exactly as before.containerRef.useAnimatedLayout.Design and alternatives
isOpen, a group's controlledvalue, orisExpandedchanged initems(spec:AST-105FR1). The measurement runs in the commit itself, before React changes the DOM, through a small internalLayoutChangeSnapshot(getSnapshotBeforeUpdate). It runs only for a render that commits and only once under StrictMode, and render stays pure. In a single-mode group, each item measures its own change, so the items between glide too.icontheming target). With a handle, its CSS transition takes the shared spring.Evidence
Desktop (mouse, fine pointer), real Chrome 156, from the Storybook story Core/Animated layout › Before and after. Animations were slowed 10× to capture frames at the times shown. Each image shows Open then Close; each has a row without the handle and a row with it.
Collapsible: without the handle, the content below jumps at 0 ms either way. With it, the content after glides while the section reveals, and on close the section conceals while the content after glides up.
TreeList: without the handle, README.md and the content below jump at 0 ms either way. With it, they glide while the child rows reveal, and on collapse the child rows conceal while README.md glides up.
Scope
Testing
animatedLayout.test.tsadds the chevron'slinear()transition (with a handle, cleared without one, immediate under Reduce Motion).animatedLayoutAdopters.interaction.browser.spec.ts(real Chromium, against the builtCore/Animated layoutstory through a ScrollableArea ref; 2 of 2 pass, three repeats): Collapsible opening and closing, and a TreeList row expanding and collapsing. The content after each glides, the closing content exits inert until it hides, and the area's scroll doesn't move.animatedLayoutAdopters.test.tsx(13 tests):isOpen, a group's controlledvalue, andisExpandedchanged initemsall glide;pnpm -F @astryxdesign/core typecheck, eslint,pnpm check:repo,pnpm check:knowledge. Motion, Collapsible, TreeList and hooks tests: 1185 pass.Writing rubric results
Records touched:
component:Collapsible(Public concepts row, FR10, Verification row) andcomponent:TreeList(Public concepts row, FR8, Verification row).spec:AST-105FR1 and FR2; the Verification cells name the test files.