Repository navigation
feat(core): useAnimatedLayout, and Collapsible and TreeList glide the content after them (spec:AST-105) #8568
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
39 commits
Select commit
Hold shift + click to select a range
5b0abfb
feat(core): useAnimatedLayout, one module that glides the layout shif…
cixzhang b476220
feat(core): useAnimatedLayout({containerRef}) returns a handle that C…
cixzhang 52c08dc
fix(core): the layout glide corrects only a real ancestor scale, not …
cixzhang 7cfe5e0
chore(storybook): the Animated layout story's tags row is narrow, so …
cixzhang e2afc61
Merge 8ba53b45a4 (main, AST-105 current) into the animated layout draft
cixzhang dc023f3
wip(core): animated layout follows AST-105 (springs.settle, Reduce Mo…
cixzhang b60f171
feat(core): useAnimatedLayout follows AST-105
cixzhang ac58ce3
chore(storybook): the story's internal-reporting demos say they are s…
cixzhang 427f850
chore(docsite): the ShadCN route lock lists the useAnimatedLayout hoo…
cixzhang 1bccf04
fix(core): the animated layout reads springs.settle from the internal…
cixzhang b894315
test(core): the animated-layout browser proofs settle between changes…
cixzhang ea01d0c
fix(core): animated layout measures every grouped change, drops refus…
cixzhang 186526e
fix(core): a retargeted glide keeps its motion and adds the new move
cixzhang 5bcb797
docs(architecture): INV12 plays the glide through the shared rectangl…
cixzhang 7bad709
docs(architecture): rewrap INV12
cixzhang 3a5f9ae
refactor(core): useAnimatedLayout lands alone; Collapsible and TreeLi…
cixzhang 7e13143
feat(core): Collapsible and TreeList adopt useAnimatedLayout
cixzhang b3826b7
feat(core): the changed element's exit, held out of flow on the same …
cixzhang f8c2db7
Merge the module's exit (feat/animated-layout) into the adoption
cixzhang 044b865
feat(core): a Collapsible's or TreeList row's close plays the content…
cixzhang 52863fc
test(core): the exiting group's rows leave the tree's keyboard order
cixzhang 1c44863
chore(changeset): Collapsible and TreeList adopt animatedLayout
cixzhang a09ce74
fix(core): the internal module alone; a displaced changed element mov…
cixzhang 980ff13
Merge feat/animated-layout into the adoption; the public hook and the…
cixzhang 2f7cedb
fix(core): the exit frees exactly its space and is inert; elements th…
cixzhang f97811a
Merge feat/animated-layout (an exact, inert exit; app-moved followers…
cixzhang 52841d7
fix(core): the exit measures the content after it, holds only a measu…
cixzhang 86a458b
Merge feat/animated-layout (the exit measures the content after it an…
cixzhang bc32d66
fix(core): nested changes compose in any order; the exit finds the re…
cixzhang ac812d7
Merge feat/animated-layout (nested changes in any order; the exit's n…
cixzhang e4b19b0
fix(core): the exit's correction follows a scaled ancestor, and its m…
cixzhang 731af79
Merge feat/animated-layout (scale-corrected exit; measured out of flo…
cixzhang c08d59b
fix(core): Collapsible and TreeList report every change, whatever cau…
cixzhang 7c96972
test(core): the adopters' browser spec serves Storybook through a11y-…
cixzhang 7ca7a04
fix(core): a TreeList row collapsing with an expanded child moves wha…
cixzhang 5695f69
docs(core): Collapsible FR10 and TreeList FR8 cite AST-108 FR4a for t…
cixzhang 8338da9
Merge main (#8482 landed as 7a530cc) into the adopters
cixzhang 007f414
docs(spec): Collapsible FR10 and TreeList FR8 cite AST-105 for the sh…
cixzhang 36c6fd8
Merge main: TreeList's animatedLayout row is FR9 beside main's FR8; a…
cixzhang File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [feature] `useAnimatedLayout({containerRef})` returns a handle that Collapsible and TreeList take as their new `animatedLayout` prop (`spec:AST-105`). When one of them opens or closes, the content after it inside that container glides to its new place instead of jumping, the revealed content reveals, closing content conceals before it hides, and the chevron turns, all on one shared motion, under every pointer, a mouse included. Fixed and stuck sticky elements stay put, the glide never scrolls the container, and under Reduce Motion the change cross-fades on touch and happens at once with a mouse. A single-mode CollapsibleGroup glides every item it changes. Without the prop, nothing changes. | ||
|
|
||
| @cixzhang |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,269 @@ | ||
| // Copyright (c) Meta Platforms, Inc. and affiliates. | ||
|
|
||
| import {useRef, type ReactNode} from 'react'; | ||
| import type {Meta, StoryObj} from '@storybook/react'; | ||
| import {ScrollableArea} from '@astryxdesign/core/ScrollableArea'; | ||
| import {TreeList} from '@astryxdesign/core/TreeList'; | ||
| import type {TreeListItemData} from '@astryxdesign/core/TreeList'; | ||
| import {Collapsible} from '@astryxdesign/core/Collapsible'; | ||
| import {useAnimatedLayout} from '@astryxdesign/core/hooks'; | ||
|
|
||
| const meta: Meta = { | ||
| title: 'Core/Animated layout', | ||
| parameters: {layout: 'padded'}, | ||
| }; | ||
|
|
||
| export default meta; | ||
|
|
||
| type Story = StoryObj; | ||
|
|
||
| const card = { | ||
| padding: 12, | ||
| margin: '0 0 8px', | ||
| borderRadius: 12, | ||
| border: '1px solid var(--color-border)', | ||
| background: 'var(--color-background-surface)', | ||
| color: 'var(--color-text-primary)', | ||
| fontFamily: 'var(--font-family-body)', | ||
| fontSize: 14, | ||
| } as const; | ||
|
|
||
| const plain = { | ||
| ...card, | ||
| borderStyle: 'dashed', | ||
| background: 'var(--color-background-muted)', | ||
| } as const; | ||
|
|
||
| const label = { | ||
| margin: '0 0 6px', | ||
| fontSize: 12, | ||
| fontWeight: 600, | ||
| color: 'var(--color-text-secondary)', | ||
| fontFamily: 'var(--font-family-body)', | ||
| } as const; | ||
|
|
||
| const TREE: TreeListItemData[] = [ | ||
| { | ||
| id: 'photos', | ||
| label: 'Photos', | ||
| children: [ | ||
| {id: 'beach', label: 'Beach.jpg'}, | ||
| { | ||
| id: 'trips', | ||
| label: 'Trips', | ||
| children: [ | ||
| {id: 'snow', label: 'Snow.jpg'}, | ||
| {id: 'lake', label: 'Lake.jpg'}, | ||
| ], | ||
| }, | ||
| {id: 'party', label: 'Party.jpg'}, | ||
| ], | ||
| }, | ||
| { | ||
| id: 'docs', | ||
| label: 'Documents', | ||
| children: [ | ||
| {id: 'cv', label: 'Resume.pdf'}, | ||
| {id: 'tax', label: 'Taxes.pdf'}, | ||
| ], | ||
| }, | ||
| {id: 'notes', label: 'Notes.txt'}, | ||
| ]; | ||
|
|
||
| function Section({title, children}: {title: string; children: ReactNode}) { | ||
| return ( | ||
| <section style={{margin: '0 0 12px'}}> | ||
| <p style={label}>{title}</p> | ||
| {children} | ||
| </section> | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * The sticky header and the absolute badge stay put; the `display: contents` | ||
| * wrapper's children move on their own. | ||
| */ | ||
| function Demo() { | ||
| const containerRef = useRef<HTMLDivElement>(null); | ||
| const animatedLayout = useAnimatedLayout({containerRef}); | ||
| return ( | ||
| <div style={{maxWidth: 520}}> | ||
| <ScrollableArea | ||
| ref={containerRef} | ||
| height={560} | ||
| label="Animated layout demo"> | ||
| <div | ||
| style={{ | ||
| position: 'sticky', | ||
| top: 0, | ||
| zIndex: 2, | ||
| padding: '10px 12px', | ||
| fontWeight: 600, | ||
| background: 'var(--color-background-surface)', | ||
| borderBottom: '1px solid var(--color-border)', | ||
| fontFamily: 'var(--font-family-body)', | ||
| }}> | ||
| Sticky header (stays put) | ||
| </div> | ||
| <div style={{padding: 12}}> | ||
| <Section title="A nested tree (TreeList adopts the module)"> | ||
| <div style={card}> | ||
| <TreeList | ||
| aria-label="Files" | ||
| items={TREE} | ||
| animatedLayout={animatedLayout} | ||
| /> | ||
| </div> | ||
| </Section> | ||
| <Section title="A Collapsible (adopts the module)"> | ||
| <div style={card}> | ||
| <Collapsible | ||
| trigger="Shipping details" | ||
| defaultIsOpen={false} | ||
| animatedLayout={animatedLayout}> | ||
| <p style={{margin: '4px 0'}}>Ships in 2 business days.</p> | ||
| <p style={{margin: '4px 0'}}>Free returns for 30 days.</p> | ||
| <p style={{margin: '4px 0'}}>Tracking arrives by email.</p> | ||
| </Collapsible> | ||
| </div> | ||
| </Section> | ||
| <div style={{...plain, position: 'relative'}}> | ||
| A plain app div with an absolute badge (the badge rides with it) | ||
| <span | ||
| style={{ | ||
| position: 'absolute', | ||
| top: -8, | ||
| insetInlineEnd: -8, | ||
| padding: '2px 8px', | ||
| borderRadius: 9999, | ||
| background: 'var(--color-background-inverted)', | ||
| color: 'var(--color-background-body)', | ||
| fontSize: 12, | ||
| }}> | ||
| New | ||
| </span> | ||
| </div> | ||
| <div style={{display: 'contents'}}> | ||
| <div style={plain}>Inside a display: contents wrapper (1)</div> | ||
| <div style={plain}>Inside a display: contents wrapper (2)</div> | ||
| </div> | ||
| {Array.from({length: 8}, (_, i) => ( | ||
| <div key={i} style={plain}> | ||
| Plain app content after the changes ({i + 1}) | ||
| </div> | ||
| ))} | ||
| </div> | ||
| </ScrollableArea> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * The ScrollableArea is the container: `useAnimatedLayout({containerRef})` | ||
| * returns a handle, and each component that changes size takes it. | ||
| */ | ||
| export const AnimatedLayout: Story = { | ||
| name: 'Animated layout', | ||
| render: () => <Demo />, | ||
| }; | ||
|
|
||
| function PanelDemo() { | ||
| const containerRef = useRef<HTMLDivElement>(null); | ||
| const animatedLayout = useAnimatedLayout({containerRef}); | ||
| return ( | ||
| <div | ||
| ref={containerRef} | ||
| style={{ | ||
| maxWidth: 520, | ||
| padding: 12, | ||
| border: '2px dashed var(--color-border-emphasized)', | ||
| borderRadius: 16, | ||
| }}> | ||
| <div style={card}> | ||
| <Collapsible | ||
| trigger="Advanced settings" | ||
| defaultIsOpen={false} | ||
| animatedLayout={animatedLayout}> | ||
| <p style={{margin: '4px 0'}}>Cache size, retries and timeouts.</p> | ||
| <p style={{margin: '4px 0'}}>Changes apply on the next sync.</p> | ||
| </Collapsible> | ||
| </div> | ||
| <div style={plain}>Content after, in an app panel</div> | ||
| <div style={plain}>More content</div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| /** An app's own panel as the container. */ | ||
| export const AppPanel: Story = { | ||
| name: 'App panel', | ||
| render: () => <PanelDemo />, | ||
| }; | ||
|
|
||
| const COMPARE_TREE: TreeListItemData[] = [ | ||
| { | ||
| id: 'src', | ||
| label: 'src', | ||
| children: [ | ||
| {id: 'app', label: 'App.tsx'}, | ||
| {id: 'index', label: 'index.ts'}, | ||
| {id: 'styles', label: 'styles.css'}, | ||
| ], | ||
| }, | ||
| {id: 'readme', label: 'README.md'}, | ||
| ]; | ||
|
|
||
| function CompareColumn({ | ||
| title, | ||
| isAnimated, | ||
| }: { | ||
| title: string; | ||
| isAnimated: boolean; | ||
| }) { | ||
| const containerRef = useRef<HTMLDivElement>(null); | ||
| const handle = useAnimatedLayout({containerRef}); | ||
| const animatedLayout = isAnimated ? handle : undefined; | ||
| return ( | ||
| <div style={{flex: '1 1 0', minWidth: 0}}> | ||
| <div style={{fontWeight: 600, marginBottom: 8}}>{title}</div> | ||
| <div | ||
| ref={containerRef} | ||
| data-compare={isAnimated ? 'after' : 'before'} | ||
| style={{ | ||
| padding: 12, | ||
| border: '2px dashed var(--color-border-emphasized)', | ||
| borderRadius: 16, | ||
| }}> | ||
| <div style={card}> | ||
| <Collapsible | ||
| trigger="Advanced settings" | ||
| defaultIsOpen={false} | ||
| animatedLayout={animatedLayout}> | ||
| <p style={{margin: '4px 0'}}>Cache size, retries and timeouts.</p> | ||
| <p style={{margin: '4px 0'}}>Changes apply on the next sync.</p> | ||
| </Collapsible> | ||
| </div> | ||
| <div style={plain}>Content after the Collapsible</div> | ||
| <div style={card}> | ||
| <TreeList items={COMPARE_TREE} animatedLayout={animatedLayout} /> | ||
| </div> | ||
| <div style={plain}>Content after the tree</div> | ||
| <div style={plain}>More content</div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * 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: () => ( | ||
| <div style={{display: 'flex', gap: 24, maxWidth: 880}}> | ||
| <CompareColumn title="Without animatedLayout" isAnimated={false} /> | ||
| <CompareColumn title="With animatedLayout" isAnimated /> | ||
| </div> | ||
| ), | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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.', | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. An owner-set |
||
| }, | ||
| { | ||
| name: 'ref', | ||
| type: 'React.Ref<HTMLDivElement>', | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
An owner-set
isOpenlands at once even with the handle (animatedLayoutAdopters.test.tsx:221asserts it), so "opening and closing glides" overstates what a controlled caller gets.