Repository navigation
feat(core): SideNav's rail collapses by snap, clip and cross-fade; AppShell's content moves on its clock - #8723
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.
…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.
… chevron move here The module pull request is now internal only, so this one carries the public face with the components that take it: useAnimatedLayout, its doc page and export (and its docsite route), applyLayoutChangeTransition and its tests, and the boundary test's list of users (the hook, Collapsible and TreeList). The adopters follow the module's in-flow exit: their tests check the exit's end margin instead of absolute positioning. The changeset describes the hook and the two adopters together.
…e app moves are left alone - holdLayoutExit measures the layout after the element with it hidden and sets the end margin that puts that layout there, so a gap or collapsing margins come out exact and nothing jumps when the element hides; where no margin can free the space (a grid track and its gap) the exit lands at once instead. - The held element is inert while it conceals (no focus, no pointer, nothing read); the module sets and releases it. - A follower with its own transform, translate, rotate or scale, or with a running transform animation the module did not start, takes its final place at once, as AST-105's Coverage lists for elements the app moves. - Tests: the exact space with a gap (jsdom), the grid fallback, app-moved followers; real-browser cases for a flex gap and collapsing margins, and for app-moved followers.
… left alone) into the adoption The module now makes a held element inert, so Collapsible's content and TreeList's child group drop their own inert and aria-hidden while exiting (TreeList keeps the marker that takes the group's rows out of the keyboard order).
…red close, and restores what it replaced - giveUpSpace measures the first element after the closing one in the area (its next sibling, or an ancestor's below the area), not the parent's edge, which a collapsed negative margin never moves: a Collapsible-shaped close (the last child of a plain wrapper) now plays its exit. - holdLayoutExit holds only a close a change in the area measured, and its documentation asks for the call after animate() reports it measured. - releaseLayoutExit puts back the element's own inert and inline values. - AST-105's out-of-coverage list names a closing grid item whose track or gap changes when it hides (owner-approved): it hides at once, without its exit. - Tests: an unmeasured close holds nothing; prior values restored; a real- browser case for the plain-wrapper shape.
…d holds only a measured close) into the adoption
…al next box and checks what follows its container - The flush stops every running glide and reads every new place before starting any glide, and an element inside another moving element moves only by its own change: nested changes compose whichever is reported first (AST-105 FR3). - The exit's search for the next box enters a display: contents wrapper, as the follower walk does. - The exit checks the content after its container on both axes, so a flex-row item that sets its row's height is refused instead of leaving the content below to jump. - A refused exit puts back the element's own end margin and drops its change, so the content after it takes its final place at once. - AST-105's out-of-coverage line names both layouts no margin frees (owner- approved). - Tests: a refused hold keeps its own margin and glides nothing; real- browser cases for nested changes reported inner-first, a display: contents next box, and a tall flex-row item.
…ext box and container checks) into the adoption
There was a problem hiding this comment.
The rail motion matches the current SideNav and AppShell records, and from rest the rail's edge stays on the content's edge on every frame, but one case needs a fix before approval. When a rail the swap can't copy (a canvas that reads back blank, or more than 1,500 elements) is collapsed while an expand is still moving, the early return in SideNav.tsx skips the content's change report, so the expand's glide keeps carrying the page over the collapsed rail (its left edge at −29 px beside a 64 px rail) for about half a second. Please land the column's running glide in that branch too and add a test that presses the no-copy collapse during an expand.
[Automated review]
| if (snapshot != null && snapshot.swap == null && !wasCollapsed) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
When this collapse interrupts a running expand, returning here skips beforeRailChange(), and running?.endContent() above ends only the wrap swap and the cover, so the column keeps the expand's glide. In Chromium, with a blank canvas icon in the rail, a collapse pressed 120 ms into an expand put the content's left edge at −29 px beside the 64 px rail, sliding back over about 560 ms (component:SideNav FR6: "The main content moves on the same clock"). Landing the column's running glide in this branch too, with a test that presses the no-copy collapse during an expand, would close it.
…ng glide too The module gains landLayoutGlide (the element takes its layout place and a later change starts from there); AppShell lends it to SideNav as landContent, which a collapse that cannot copy the rail calls with its early return.
|
/approve-spec 266efdc |
There was a problem hiding this comment.
The rail and content motion matches the current SideNav and AppShell records and starts from the frame before the press, but two fixes are needed before approval:
- Under Reduce Motion with a coarse pointer, AppShell still reports the rail change to the animated-layout module, which fades the whole content column in from 0, so the page's surface blinks to the shell colour on the first frame; report the glide only for a spring motion and assert in the DR2 test that no ancestor of
mainanimates. packages/core/src/SideNav/railMotion.tscallscreateContextwithout a'use client'directive, which failspnpm lint(check:use-client); add it after the copyright header.
[Automated review]
| const change = reportLayoutChange(contentLayout, railRef); | ||
| change.measureBefore(); |
There was a problem hiding this comment.
Under Reduce Motion with a coarse pointer the module turns this reported change into a fade of every moved element (animatedLayout.ts:851-856), so the column that holds main, surface included, fades 0 → 1 beside the swap's own fades. In Chromium the first frame shows the content area at the shell colour (#ffffff → #f1f1f1; dark #262626 → #1b1b1b), and the new wrap peaks at 25% at 100 ms. Reporting the glide only when layoutMotionFor(main).kind === 'spring' would leave DR2's cross-fade to the two wraps over a steady surface.
| * SideNavRailMotion.test.tsx. | ||
| */ | ||
|
|
||
| import {createContext} from 'react'; |
There was a problem hiding this comment.
This module calls createContext at module scope, but it has no 'use client' directive, so scripts/check-use-client.mjs (part of pnpm lint) fails on this head with "SideNav/railMotion.ts: missing directive". Adding 'use client'; after the copyright header, as SideNavCollapseContext.ts does, clears it.
… is a client module - AppShell reports the column's glide only for a spring motion, so under Reduce Motion with a coarse pointer the two wraps cross-fade over a steady surface instead of the whole column fading in (design:reduced-motion DR2). - railMotion.ts calls createContext, so it carries 'use client'.
|
/approve-spec aabae5d |
There was a problem hiding this comment.
The rail and content motion matches the current SideNav and AppShell records, and both fixes from the last review hold: the content column no longer fades under Reduce Motion, and railMotion.ts is a client module. One case still needs a fix before approval: under Reduce Motion with a coarse pointer, an expand turns the 212 px strip the old content sat on from the surface colour to the frame colour in its first frame and keeps it there through the cross-fade, because the surface takes its new place at once while only the text cross-fades (AppShell.tsx). Let the old surface fade out with the old wrap there, for example by having the wrap's copy paint the surface it sat on, and add a Reduce Motion expand case that checks the first frame keeps the old surface.
[Automated review]
| // Only a spring glides the column; under Reduce Motion the two wraps | ||
| // cross-fade over a steady surface (`design:reduced-motion` DR2). | ||
| let change: LayoutChange | null = null; | ||
| if (motion.kind === 'spring') { |
There was a problem hiding this comment.
Under Reduce Motion with a coarse pointer this skips the glide, so the content surface takes its new place at once while only the two wraps cross-fade. On an expand that leaves the strip the old wrap sat on (x 48 to 260 px) unpainted: frame 0 turns it from #ffffff to #f1f1f1 and it stays so through the 200 ms fade, where FR17 asks for DR2's cross-fade ("a leaving element fades out where it is"). Letting the old wrap's copy paint the surface it sat on under DR2 (the swap's isOpaque, as the rail's copy does) would fade the strip instead, and a DR2 expand case that checks frame 0 would hold it.
…th the old wrap Nothing glides under Reduce Motion with a coarse pointer, so the old wrap's copy paints the content surface it sat on: the strip the narrower page leaves fades from the surface colour instead of turning bare at the first frame.
|
/approve-spec 95a7245 |
There was a problem hiding this comment.
This looks ready to approve: the rail's snap, clip and cross-fade and AppShell's sliding content match the current SideNav FR6 and AppShell FR17 records, the fixes requested on earlier revisions hold, and the settled state is pixel-identical to today's. Two transient details are worth a look before merging: icons below a section title appear at two heights while the rails cross-fade, and in the section variant no divider is drawn at the rail's moving edge. As a follow-up, the SideNav, AppShell and AST-105 records still describe this motion as not implemented.
[Automated review]
…dates base: a real conflict in the animated layout module # Conflicts: # packages/core/src/motion/animatedLayout.test.ts # packages/core/src/motion/animatedLayout.ts
…8723) into the List updates change # Conflicts: # packages/core/src/motion/animatedLayout.boundary.test.ts
useScrollableArea.ts conflicted: meta#8041 merged in a later form than the one this branch carried. That file takes this PR's version, which is meta#8041's merged code plus this PR's own changes (the shared mutation observers and the watch on a viewport's direct children). AppShell.tsx merged textually but declared mainRef twice: meta#8723 added an object ref for the content glide, and this PR's ref is the scroll measurement under fill. This PR's becomes mainScrollRef, and main's node goes to both through useMergedRefs.
Held for Cindy's inspection: do not merge. Based on main, which now carries the module's in-place swap (#8734,
spec:AST-105FR8).What changes for people
Collapsing or expanding SideNav's rail no longer snaps the whole shell. Layout snaps to the final width at once (nothing animates a width), the rail covers or reveals its labels with a clip and ends in its real collapsed state (icons only, exactly today's collapsed rail), and inside AppShell the page slides with it: its old line wrapping fades out over the new one as both slide. One motion, one clock. No new props: every AppShell and SideNav gets it.
Authority (current on main since #8720 merged):
component:SideNavFR6 / DEC-2,component:AppShellFR17,spec:AST-105FR2 (AppShell's content area holds its own handle), FR4 (one shared motion,springs.settle) and FR8 (the in-place swap's one transient copy),architecture:react-component-runtimeINV12,architecture:component-style-authoringINV7,design:reduced-motionDR2/DR3. The one shared clock isspec:AST-105FR4.Frames are real Chrome at quarter speed, labelled in real-time ms. Measured on every frame: frame 0 is exactly the state before the click (no expand jump, no line cut), nothing animates a width, nothing scrolls sideways (page or panel), and at rest the rail is 48 px with no labels and no copies left in the DOM.
390 px, elevated shell, outside AppShell, Reduce Motion, and an unchanged legacy shell
Fine-pointer before and after, from the Storybook story (
spec:AST-105FR5)The new "Rail collapse motion" stories, built from base and from this head, run in real Chrome with a mouse and paused at 0, 100, 200 and 700 ms. Before, the rail and the page snap. After, they move on one motion.
How
main's clip included. The content also swaps its old wrap for its new one through the module's in-place swap: a copy of the old wrap's visible part, beside the column, rides the column's actual glide frame by frame (so old and new share one left edge on every frame, and when the column lands silently out of view the copy lands with it) and fades out while the new wrap fades in. On an expand, the narrower column slides in from its old start, and the content surface (the elevated backdrop, ormain) reaches over the trailing gap as it closes. In right-to-left documents, the glide follows the inline-start edge.railMotion.ts, does only the rail's snap and clip:springs.settlethrough the module and starts on the same timeline time. Starting clips and translates apply before the first frame paints.aria-hidden, with no ids, form or radio group, and loading no media. They're removed when the motion ends, is interrupted, or the rail unmounts.Size (
architecture:component-style-authoringINV6)Gzip JavaScript each import loads eagerly, measured with CI's own
measureComponentImport(esbuild, minified, split), with base = main and head = this PR on #8734:AppShellSideNavMost of it is the animated-layout module and its swap (#8734), which AppShell and SideNav now load for the rail motion. Neither changes for an app that doesn't import them.
Tests
Failing first:
SideNavRailMotion.test.tsxhas 22 cases:Real Chrome, every frame: frame 0 is exactly the pre-click state, and the real content and the old wrap share one left edge on every frame (paused at 0/16/33/100/200 ms: 73 → 77 → 87 → 149 → 225 px expanding, 285 → 281 → 271 → 209 → 133 px collapsing, both in view and scrolled out of view). Nothing animates a width or scrolls sideways, the page radio stays checked, and at rest the rail is 48 px with no labels and no copies. The SideNav, AppShell, Layout, motion, Collapsible, TreeList, TopNav and MobileNav suites pass (1392 tests).
Size
Over the loop's size guide (product +331:
railMotion.ts191, AppShell 81, SideNav 54, the module doc 5); the shared swap itself is in #8734.