feat: Add expand/collapse animation support to Tree - #10378
Open
RobHannay wants to merge 4 commits into
Open
Conversation
Co-authored-by: Rook <rook@rook.is>
Poll for the state being asserted instead of sleeping for a fraction of the transition, seek animations rather than sampling them mid-flight, and finish them explicitly. Also wait a frame after reaching a state before mutating the tree, since a transition is only generated when the property has a before-change style from a completed style pass. Co-authored-by: Rook <rook@rook.is>
…ht ♜ height: auto isn't animatable, so a row could only animate open or closed if the consumer hard-coded a row height. Measure it instead and hand it to CSS as a variable, the same polyfill useDisclosure applies to its panel, gated on the row actually declaring a height transition. Co-authored-by: Rook <rook@rook.is>
Reading the row mid-animation returns whatever its transitioned properties have reached so far, not the values they're heading for. With the padding zeroed by the entering state, that lands short, and the row jumps the difference when the variable goes back to auto. Measure with the entering/exiting overrides lifted and transitions suppressed, and cache the result, since suppressing transitions is only safe while the row is at rest. Co-authored-by: Rook <rook@rook.is>
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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's no existing issue for this — happy to file one first if you'd prefer to discuss the approach before reviewing code. Related prior art: #7528 / #8867 (Disclosure) and #9077 (TabPanels).
Summary
Treehas no way to animate a subtree expanding or collapsing. Every other RAC component that hides content manages its own animation lifecycle — Modal, Popover and TabPanel hold the element in the DOM untilgetAnimations()settle; Disclosure keeps its panel mounted and defers thehiddenattribute. Tree can't do either, because collapsing removes descendants from the collection immediately and they unmount synchronously.This adds
isEntering/isExitingrender props anddata-entering/data-exitingselectors toTreeItem, so rows can animate in and out.Crucially, only the DOM lags — never the semantics.
expandedKeys,aria-expanded, keyboard navigation and selection all reflect the collapse immediately. Exiting rows areinert, so they're removed from the accessibility tree and can't be focused or clicked while they animate away. This was the sticking point that makes the userland workaround (holding a key inexpandedKeysfor the duration of the animation) unacceptable:aria-expandedderives directly fromstate.expandedKeys, so a row would announce itself as expanded to a screen reader for the whole collapse.How it works
Rendering and keyboard navigation are currently the same traversal, but only incidentally. Rendering iterates
TreeCollection[Symbol.iterator](Collection.tsx:216iterates the collection object itself); navigation, range selection and End go throughgetKeyAfter/getKeyBefore/getLastKey. They agree only because the iterator delegates togetKeyAfter.Splitting the two is the whole change:
TreeCollectiongains a privaterenderedExpandedKeys, a superset ofexpandedKeysthat also contains the parents of rows currently animating out.getKeyAfteris refactored into a private helper parameterised by the expanded set; the public key-stepping methods keep passingexpandedKeys, and only the iterator (plus the section branch ofgetChildren, which renders viaCollectionBranchrather than the iterator) passes the rendered set.TreeInnertracksexitingKeys, diffed offlastExpandedKeyswhen a collapse happens, and drops keys again if an interrupted collapse makes them visible.useAnimationprimitive and reports completion, releasing its key from the collection so it unmounts.Entering is cheaper — the rows are already in the collection, so there's no collection work. The entering keys live in a ref rather than state, because rows read them once as they mount, in the same commit that expands their parent (same reasoning as the snapshot scope in
SharedElementTransition). That also meansdefaultExpandedKeysdoesn't animate the initial rows in, without needing theisInitiallySelecteddanceTabPaneldoes.useAnimationis promoted from module-private to the existingreact-aria/private/utils/animationsubpath. It's reused rather than reimplemented so the interruption handling from #9772 (Promise.allSettled, cancellation flag,flushSync, JSDOM guard) applies here too.Cost when you aren't animating
None. If no animation is declared,
getAnimations()returns empty anduseAnimationcallsonEndsynchronously inside the layout effect, so the rows are released before paint — the collapse is indistinguishable from today's. There's a test pinning this.Sizing
height: autoisn't animatable, so a row could otherwise only animate open or closed if the consumer hard-coded a row height — which rules out rows that size to their content.TreeItemtherefore also publishes--tree-item-height, set to the row's measured height while it is entering or exiting and back toautoonce the animation settles. This is the same polyfilluseDisclosureapplies to its panel in #8867.It's gated the way
TabPanelsgates its measurement: the computedtransitionis probed once per row and the measurement is skipped entirely unless the row declares a transition onheight/block-size/all. Rows that animate onlyopacityortransformnever force a layout.One wrinkle worth flagging in review: a row can't shrink below its own padding, so the examples animate
padding-blockalongside the height. I couldn't see a way around that without dictating the row's box model.Known limitation
In a virtualized Tree,
ListLayoutdoesn't re-measure rows mid-animation, so a height animation won't reflow the rows below it. Animateopacity/transformthere instead. This is documented in the new Animation section of the Tree docs.✅ Pull Request Checklist:
📝 Test Instructions:
Storybook:
yarn start→ React Aria Components / Tree / Animated expand/collapse.Expand and collapse rows and check that:
Automated:
Six new JSDOM tests cover the exit hold, immediate semantic collapse, keyboard exclusion, interruption, the enter case, and the no-animation fast path. Because JSDOM has no
getAnimations, there are also three real-browser tests (TreeAnimations.browser.test.tsx) asserting the rows are genuinely mid-transition — those pass in Chromium, Firefox and WebKit.🧢 Your Project:
Metaview
Rook session · Slack thread · Requested by
Rob