From 83f9cb0960dd96eb7dc2314c1adf187bb872490d Mon Sep 17 00:00:00 2001 From: Rob Hannay <609062+RobHannay@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:47:20 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20Add=20expand/collapse=20animation?= =?UTF-8?q?=20support=20to=20Tree=20=E2=99=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rook --- .../dev/s2-docs/pages/react-aria/Tree.mdx | 31 +++ packages/react-aria-components/src/Tree.tsx | 258 +++++++++++++++++- .../stories/Tree.stories.tsx | 60 ++++ .../react-aria-components/test/Tree.test.tsx | 140 ++++++++++ .../test/TreeAnimations.browser.test.tsx | 144 ++++++++++ .../exports/private/utils/animation.ts | 2 +- packages/react-aria/src/utils/animation.ts | 2 +- starters/docs/src/Tree.css | 18 +- starters/tailwind/src/Tree.tsx | 2 +- 9 files changed, 646 insertions(+), 11 deletions(-) create mode 100644 packages/react-aria-components/test/TreeAnimations.browser.test.tsx diff --git a/packages/dev/s2-docs/pages/react-aria/Tree.mdx b/packages/dev/s2-docs/pages/react-aria/Tree.mdx index 590a4ce1c26..63945878df9 100644 --- a/packages/dev/s2-docs/pages/react-aria/Tree.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Tree.mdx @@ -367,6 +367,37 @@ import {TextField} from 'vanilla-starter/TextField'; ``` +## Animation + +Rows revealed by expanding a parent are marked with the `isEntering` state, and rows hidden by collapsing a +parent stay mounted with the `isExiting` state until their animations finish. Use the corresponding +`data-entering` and `data-exiting` selectors to animate rows in and out. + +Expansion state itself is never delayed: `aria-expanded` updates immediately, and exiting rows are `inert`, +so they are excluded from keyboard navigation and hidden from assistive technology while they animate away. + +```css render=false +.react-aria-TreeItem { + height: 30px; + overflow: clip; + transition: height 200ms, opacity 200ms; + + &[data-entering], + &[data-exiting] { + height: 0; + opacity: 0; + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + } +} +``` + +Animating a row's height requires a definite height to animate from, so give rows a fixed height as above. +In a [virtualized](Virtualizer) Tree, the layout does not re-measure rows while they animate; animate +`opacity` or `transform` instead of `height` there. + ## Drag and drop Tree supports drag and drop interactions when the `dragAndDropHooks` prop is provided using the hook. Users can drop data on the list as a whole, on individual items, insert new items between existing ones, or reorder items. React Aria supports drag and drop via mouse, touch, keyboard, and screen reader interactions. See the [drag and drop guide](dnd?component=Tree) to learn more. diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 85e47c5aaa0..c2cd20075b4 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -98,7 +98,9 @@ import React, { ForwardedRef, forwardRef, JSX, + MutableRefObject, ReactNode, + useCallback, useContext, useEffect, useMemo, @@ -109,6 +111,7 @@ import {SelectionIndicatorContext} from './SelectionIndicator'; import {SharedElementTransition} from './SharedElementTransition'; import {TreeDropTargetDelegate} from './TreeDropTargetDelegate'; import {TreeState, useTreeState} from 'react-stately/useTreeState'; +import {useAnimation, useEnterAnimation} from 'react-aria/private/utils/animation'; import {useCachedChildren} from 'react-aria/private/collections/useCachedChildren'; import {useCollator} from 'react-aria/useCollator'; import {useControlledState} from 'react-stately/useControlledState'; @@ -116,16 +119,43 @@ import {useFocusRing} from 'react-aria/useFocusRing'; import {useGridListSection, useGridListSelectionCheckbox} from 'react-aria/useGridList'; import {useHover} from 'react-aria/useHover'; import {useId} from 'react-aria/useId'; +import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; import {useLocale} from 'react-aria/I18nProvider'; import {useObjectRef} from 'react-aria/useObjectRef'; import {useVisuallyHidden} from 'react-aria/VisuallyHidden'; +const emptyKeySet: Set = new Set(); + +interface TreeAnimationContextValue { + /** Keys of rows that have been collapsed but are still mounted while they animate out. */ + exitingKeys: Set; + /** + * Keys of rows that were just revealed by an expansion. Held in a ref rather than state because + * rows read it once as they mount, which happens in the same commit that expands their parent. + */ + enteringKeysRef: MutableRefObject>; + /** Called by a row once its exit animation has finished, to release it from the collection. */ + onExitComplete: (key: Key) => void; +} + +const TreeAnimationContext = createContext(null); + class TreeCollection extends BaseCollection { private expandedKeys: Set = new Set(); - - withExpandedKeys(lastExpandedKeys: Set, expandedKeys: Set) { + // Superset of expandedKeys used only when producing the rendered rows. It additionally contains the + // ancestors of rows that are animating out, so those rows stay mounted after their parent collapses. + // Navigation, selection and the a11y tree deliberately continue to use expandedKeys, so a collapse is + // reflected immediately and exiting rows are unreachable while they animate. + private renderedExpandedKeys: Set = new Set(); + + withExpandedKeys( + lastExpandedKeys: Set, + expandedKeys: Set, + renderedExpandedKeys: Set = expandedKeys + ) { let collection = this.clone(); collection.expandedKeys = expandedKeys; + collection.renderedExpandedKeys = renderedExpandedKeys; // Clone ancestor section nodes so React knows to re-render since the same item won't cause a new render but a clone creating a new object with the same value will // Without this change, the items won't expand and collapse when virtualized inside a section @@ -169,7 +199,7 @@ class TreeCollection extends BaseCollection { } else { // This will include both item and content nodes // We handle the content nodes in useCollectionRenderer and ListLayout - let key = this.getKeyAfter(node.key); + let key = this.getKeyAfterInternal(node.key, this.renderedExpandedKeys); node = key != null ? this.getItem(key) : null; } } @@ -197,12 +227,16 @@ class TreeCollection extends BaseCollection { } getKeyAfter(key: Key) { + return this.getKeyAfterInternal(key, this.expandedKeys); + } + + private getKeyAfterInternal(key: Key, expandedKeys: Set) { let node = this.getItem(key) as CollectionNode; if (!node) { return null; } - if ((this.expandedKeys.has(node.key) || node.type !== 'item') && node.firstChildKey != null) { + if ((expandedKeys.has(node.key) || node.type !== 'item') && node.firstChildKey != null) { return node.firstChildKey; } @@ -259,7 +293,9 @@ class TreeCollection extends BaseCollection { while (node && node.key !== parent.nextKey) { yield self.getItem(node.key)!; // This will include content nodes which we skip in ListLayout - let key = self.getKeyAfter(node.key); + // Sections render their rows via CollectionBranch rather than the collection iterator, so this + // walks the rendered set to keep exiting rows mounted inside a section as well. + let key = self.getKeyAfterInternal(node.key, self.renderedExpandedKeys); node = key != null ? (self.getItem(key)! as CollectionNode) : null; } } else { @@ -445,17 +481,72 @@ function TreeInner({props, collection, treeRef: ref}: TreeInnerProps) { let [lastCollection, setLastCollection] = useState(collection); let [lastExpandedKeys, setLastExpandedKeys] = useState(expandedKeys); + let [exitingKeys, setExitingKeys] = useState(emptyKeySet); + let [lastExitingKeys, setLastExitingKeys] = useState(exitingKeys); + let enteringKeysRef = useRef(emptyKeySet); let [flattenedCollection, setFlattenedCollection] = useState(() => collection.withExpandedKeys(lastExpandedKeys, expandedKeys) ); + // Rows are removed from the collection as soon as their parent collapses, which gives them no chance to + // animate out. Instead, keep them in the rendered part of the collection until their animations finish. + // Keyboard navigation, selection and aria-expanded continue to use expandedKeys, so the collapse is + // reflected immediately and only the DOM lags behind. + let expandedKeysChanged = !areSetsEqual(lastExpandedKeys, expandedKeys); + let nextExitingKeys = exitingKeys; + if (expandedKeysChanged) { + nextExitingKeys = getExitingKeys(collection, lastExpandedKeys, expandedKeys, exitingKeys); + // Written during render alongside the collection so rows can read it as they mount in this same + // commit. Same trade-off as the previous-size ref in TabPanels. + // oxlint-disable-next-line react/react-compiler, rsp-rules/pure-render + enteringKeysRef.current = getEnteringKeys(collection, lastExpandedKeys, expandedKeys); + } + // if the lastExpandedKeys is not the same as the currentExpandedKeys or the collection has changed, then run this - if (!areSetsEqual(lastExpandedKeys, expandedKeys) || collection !== lastCollection) { - setFlattenedCollection(collection.withExpandedKeys(lastExpandedKeys, expandedKeys)); + if ( + expandedKeysChanged || + collection !== lastCollection || + !areSetsEqual(lastExitingKeys, nextExitingKeys) + ) { + setFlattenedCollection( + collection.withExpandedKeys( + lastExpandedKeys, + expandedKeys, + withExitingAncestors(collection, expandedKeys, nextExitingKeys) + ) + ); setLastCollection(collection); setLastExpandedKeys(expandedKeys); + setLastExitingKeys(nextExitingKeys); + if (nextExitingKeys !== exitingKeys) { + setExitingKeys(nextExitingKeys); + } } + let onExitComplete = useCallback((key: Key) => { + setExitingKeys(keys => { + if (!keys.has(key)) { + return keys; + } + let next = new Set(keys); + next.delete(key); + return next; + }); + }, []); + + // Entering keys are only meaningful for the commit that revealed them. Clearing afterwards stops a row + // that mounts later (e.g. scrolled into view in a virtualized tree) from replaying the enter animation. + useLayoutEffect(() => { + if (enteringKeysRef.current.size > 0) { + enteringKeysRef.current = emptyKeySet; + } + }); + + let animationContextValue = useMemo( + () => ({exitingKeys: nextExitingKeys, enteringKeysRef, onExitComplete}), + [nextExitingKeys, onExitComplete] + ); + let state = useTreeState({ ...props, selectionMode, @@ -635,6 +726,7 @@ function TreeInner({props, collection, treeRef: ref}: TreeInnerProps) { @@ -686,6 +778,21 @@ export interface TreeItemRenderProps extends ItemRenderProps { * @selector [data-focus-visible-within] */ isFocusVisibleWithin: boolean; + /** + * Whether the tree item is currently entering, after its parent was expanded. Use this to apply + * animations. + * + * @selector [data-entering] + */ + isEntering: boolean; + /** + * Whether the tree item is currently exiting, after its parent was collapsed. The row remains in + * the DOM until its animations complete, but is inert and excluded from keyboard navigation. Use + * this to apply animations. + * + * @selector [data-exiting] + */ + isExiting: boolean; /** The state of the tree. */ state: TreeState; /** The unique id of the tree row. */ @@ -799,6 +906,18 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent( props.hasChildItems || [...state.collection.getChildren!(item.key)]?.length > 1; let level = rowProps['aria-level'] || 1; + let {exitingKeys, enteringKeysRef, onExitComplete} = useContext(TreeAnimationContext)!; + let isExiting = exitingKeys.has(item.key); + // Whether this row appeared because its parent was expanded, as opposed to being present on mount. + // Captured once so that defaultExpandedKeys doesn't animate the initial rows in. + let [didEnterViaExpansion] = useState(() => enteringKeysRef.current.has(item.key)); + let isEntering = useEnterAnimation(ref, didEnterViaExpansion) && !isExiting; + useAnimation( + ref, + isExiting, + useCallback(() => onExitComplete(item.key), [onExitComplete, item.key]) + ); + let {hoverProps, isHovered} = useHover({ // because of https://bugs.webkit.org/show_bug.cgi?id=214609, supporting hover styles when a item is ONLY isDraggable // results in hover styles sticking around after a reorder/drop operation... @@ -854,6 +973,8 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent( selectionMode, selectionBehavior, isFocusVisibleWithin, + isEntering, + isExiting, state, id: item.key, allowsDragging: !!dragState, @@ -868,6 +989,8 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent( hasChildItems, level, isFocusVisibleWithin, + isEntering, + isExiting, state, item.key, dragState, @@ -991,6 +1114,12 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent( data-focused={states.isFocused || undefined} data-focus-visible={isFocusVisible || undefined} data-pressed={states.isPressed || undefined} + data-entering={isEntering || undefined} + data-exiting={isExiting || undefined} + // Exiting rows are only still here so they can animate out. Keep them from being focused, + // clicked or announced while they do. + // @ts-ignore - compatibility with React < 19 + inert={inertValue(isExiting)} data-selection-mode={ state.selectionManager.selectionMode === 'none' ? undefined @@ -1328,3 +1457,118 @@ function areSetsEqual(a: Set, b: Set) { } return true; } + +/** Adds every row that would be rendered beneath `key` for the given expanded set. */ +function addRenderedDescendants( + collection: BaseCollection, + key: Key, + expandedKeys: Set, + keys: Set +) { + let node = collection.getItem(key) as CollectionNode | null; + let childKey = node?.firstChildKey ?? null; + while (childKey != null) { + let child = collection.getItem(childKey) as CollectionNode | null; + if (!child) { + break; + } + + // Content nodes render inside their parent row rather than as rows of their own. + if (child.type !== 'content') { + keys.add(child.key); + if (child.type !== 'item' || expandedKeys.has(child.key)) { + addRenderedDescendants(collection, child.key, expandedKeys, keys); + } + } + + childKey = child.nextKey; + } +} + +/** + * Whether every ancestor of `key` is expanded, i.e. the row is part of the collapsed-aware + * collection. + */ +function isRowVisible(collection: BaseCollection, key: Key, expandedKeys: Set) { + let parentKey = collection.getItem(key)?.parentKey ?? null; + while (parentKey != null) { + let parent = collection.getItem(parentKey); + if (!parent) { + return false; + } + + if (parent.type === 'item' && !expandedKeys.has(parent.key)) { + return false; + } + + parentKey = parent.parentKey ?? null; + } + + return true; +} + +/** + * The rows that should keep rendering after a collapse. Rows revealed again by an interrupted + * collapse, and rows that have left the collection entirely, are dropped so they don't linger. + */ +function getExitingKeys( + collection: BaseCollection, + lastExpandedKeys: Set, + expandedKeys: Set, + exitingKeys: Set +): Set { + let next = new Set(exitingKeys); + for (let key of lastExpandedKeys) { + if (!expandedKeys.has(key) && collection.getItem(key)) { + addRenderedDescendants(collection, key, lastExpandedKeys, next); + } + } + + for (let key of next) { + if (!collection.getItem(key) || isRowVisible(collection, key, expandedKeys)) { + next.delete(key); + } + } + + return next.size === 0 ? emptyKeySet : next; +} + +/** The rows that a just-applied expansion revealed. */ +function getEnteringKeys( + collection: BaseCollection, + lastExpandedKeys: Set, + expandedKeys: Set +): Set { + let keys = new Set(); + for (let key of expandedKeys) { + if (!lastExpandedKeys.has(key) && collection.getItem(key)) { + addRenderedDescendants(collection, key, expandedKeys, keys); + } + } + + return keys.size === 0 ? emptyKeySet : keys; +} + +/** + * Expanded keys widened with the parents of every exiting row, so the render traversal can still + * reach them while they animate. Returns `expandedKeys` untouched when nothing is exiting. + */ +function withExitingAncestors( + collection: BaseCollection, + expandedKeys: Set, + exitingKeys: Set +): Set { + if (exitingKeys.size === 0) { + return expandedKeys; + } + + let keys = new Set(expandedKeys); + for (let key of exitingKeys) { + let parentKey = collection.getItem(key)?.parentKey; + if (parentKey != null) { + keys.add(parentKey); + } + } + + return keys; +} diff --git a/packages/react-aria-components/stories/Tree.stories.tsx b/packages/react-aria-components/stories/Tree.stories.tsx index 8acea296766..6b747b361a6 100644 --- a/packages/react-aria-components/stories/Tree.stories.tsx +++ b/packages/react-aria-components/stories/Tree.stories.tsx @@ -2045,3 +2045,63 @@ export const VirtualizedTreeInShadowDOMStory: StoryObj): JSX.Element => ( + <> + + + + Photos + + + + + Projects-1A + + + + Projects-2 + + + Projects-3 + + + + +); + +export const AnimatedTree: StoryObj = { + render: args => , + args: { + selectionMode: 'none', + selectionBehavior: 'toggle', + disabledBehavior: 'selection' + }, + name: 'Animated expand/collapse' +}; diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index c0087e78fd9..69be008e536 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -3130,6 +3130,146 @@ describe('Tree', () => { }); }); }); + + describe('expand/collapse animations', () => { + let resolveAnimations: () => void; + let animation: {finished: Promise}; + + let mockAnimations = () => { + animation = { + finished: new Promise(resolve => { + resolveAnimations = resolve; + }) + }; + // useEnterAnimation checks `animation instanceof CSSTransition`, which JSDOM doesn't define. + // @ts-ignore + window.CSSTransition = window.CSSTransition ?? class CSSTransition {}; + Element.prototype.getAnimations = jest.fn().mockImplementation(() => [animation]); + }; + + let finishAnimations = async () => { + await act(async () => { + resolveAnimations(); + await animation.finished; + }); + }; + + let rowElements = () => + Array.from(document.querySelectorAll('.react-aria-TreeItem')); + + afterEach(() => { + // @ts-ignore + delete Element.prototype.getAnimations; + }); + + it('should remove collapsed rows immediately when nothing is animating', async () => { + // No getAnimations (the JSDOM default) means there is nothing to wait for, so collapsing behaves + // exactly as it did before: the rows are gone by the time the collapse has been committed. + let {getAllByRole} = render(); + expect(rowElements()).toHaveLength(6); + + await user.click(within(getAllByRole('row')[1]).getAllByRole('button')[0]); + + expect(rowElements()).toHaveLength(2); + expect(document.querySelectorAll('[data-exiting]')).toHaveLength(0); + }); + + it('should keep collapsed rows mounted and inert until their animations finish', async () => { + mockAnimations(); + let {getAllByRole} = render(); + let rows = getAllByRole('row'); + expect(rows).toHaveLength(6); + + await user.click(within(rows[1]).getAllByRole('button')[0]); + + // The four descendants of "projects" are still in the DOM, but marked as exiting and inert. + let exiting = rowElements().filter(row => row.hasAttribute('data-exiting')); + expect(exiting).toHaveLength(4); + expect(exiting.map(row => row.textContent)).toEqual( + expect.arrayContaining([expect.stringContaining('Projects-1A')]) + ); + for (let row of exiting) { + expect(row).toHaveAttribute('inert'); + } + + await finishAnimations(); + + expect(rowElements()).toHaveLength(2); + expect(document.querySelectorAll('[data-exiting]')).toHaveLength(0); + }); + + it('should collapse semantically before the animation finishes', async () => { + mockAnimations(); + let {getAllByRole} = render(); + let rows = getAllByRole('row'); + + await user.click(within(rows[1]).getAllByRole('button')[0]); + + // aria-expanded must not lie while the subtree animates away. + expect(rows[1]).toHaveAttribute('aria-expanded', 'false'); + expect(rows[1]).not.toHaveAttribute('data-expanded'); + expect(onExpandedChange).toHaveBeenCalledTimes(1); + expect(new Set(onExpandedChange.mock.calls[0][0])).toEqual(new Set(['projects-1'])); + }); + + it('should exclude exiting rows from keyboard navigation', async () => { + mockAnimations(); + let {getAllByRole} = render(); + let rows = getAllByRole('row'); + + await user.click(within(rows[1]).getAllByRole('button')[0]); + expect(rowElements().filter(row => row.hasAttribute('data-exiting'))).toHaveLength(4); + + act(() => rows[0].focus()); + expect(document.activeElement).toBe(rows[0]); + + // "projects" is the last navigable row even though its descendants are still rendered. + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(rows[1]); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(rows[1]); + await user.keyboard('{End}'); + expect(document.activeElement).toBe(rows[1]); + }); + + it('should clear the exiting state when a collapse is interrupted by re-expanding', async () => { + mockAnimations(); + let {getAllByRole} = render(); + let rows = getAllByRole('row'); + let chevron = within(rows[1]).getAllByRole('button')[0]; + + await user.click(chevron); + expect(rowElements().filter(row => row.hasAttribute('data-exiting'))).toHaveLength(4); + + await user.click(chevron); + expect(rowElements()).toHaveLength(6); + expect(document.querySelectorAll('[data-exiting]')).toHaveLength(0); + expect(document.querySelectorAll('[inert]')).toHaveLength(0); + + // The now-stale animation completing must not remove the restored rows. + await finishAnimations(); + expect(rowElements()).toHaveLength(6); + }); + + it('should mark rows as entering when revealed by an expansion, but not on mount', async () => { + mockAnimations(); + let {getAllByRole} = render( + + ); + // defaultExpandedKeys must not animate the initial rows in. + expect(document.querySelectorAll('[data-entering]')).toHaveLength(0); + + let rows = getAllByRole('row'); + await user.click(within(rows[2]).getAllByRole('button')[0]); + + let entering = rowElements().filter(row => row.hasAttribute('data-entering')); + expect(entering).toHaveLength(1); + expect(entering[0].textContent).toContain('Projects-1A'); + + await finishAnimations(); + expect(document.querySelectorAll('[data-entering]')).toHaveLength(0); + }); + }); }); AriaTreeTests({ diff --git a/packages/react-aria-components/test/TreeAnimations.browser.test.tsx b/packages/react-aria-components/test/TreeAnimations.browser.test.tsx new file mode 100644 index 00000000000..abef53ebb53 --- /dev/null +++ b/packages/react-aria-components/test/TreeAnimations.browser.test.tsx @@ -0,0 +1,144 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +// JSDOM has no getAnimations, so the exit hold can only be verified end to end in a real browser. + +import {afterEach, beforeEach, expect, it} from 'vitest'; +import {Button} from '../src/Button'; +import {createRoot, Root} from 'react-dom/client'; +import React from 'react'; +import {Tree, TreeItem, TreeItemContent} from '../src/Tree'; + +const DURATION = 150; +const ROW_HEIGHT = 30; + +const css = ` +.animated-tree-item { + display: block; + box-sizing: border-box; + height: ${ROW_HEIGHT}px; + overflow: clip; + transition: height ${DURATION}ms linear, opacity ${DURATION}ms linear; +} + +.animated-tree-item[data-entering], +.animated-tree-item[data-exiting] { + height: 0; + opacity: 0; +} +`; + +function AnimatedTree({expandedKeys}: {expandedKeys: string[]}) { + return ( + {}}> + + + + Root + + + Child 1 + + + Child 2 + + + + ); +} + +let container: HTMLDivElement; +let style: HTMLStyleElement; +let root: Root; + +let rows = () => Array.from(container.querySelectorAll('.animated-tree-item')); +let wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +// The collection renders over two passes, and a cold browser start can take a while to get there. +let waitForRows = async (count: number) => { + for (let i = 0; i < 100 && rows().length !== count; i++) { + await wait(20); + } + expect(rows()).toHaveLength(count); +}; + +beforeEach(() => { + style = document.createElement('style'); + style.textContent = css; + document.head.appendChild(style); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + root.unmount(); + container.remove(); + style.remove(); +}); + +it('keeps collapsed rows mounted until their exit transition finishes', async () => { + root.render(); + await waitForRows(3); + + root.render(); + await wait(0); + + // The rows are still mounted and animating, but the tree already reports itself as collapsed. + let exiting = rows().filter(row => row.hasAttribute('data-exiting')); + expect(exiting).toHaveLength(2); + expect(exiting.every(row => row.getAnimations().length > 0)).toBe(true); + expect(rows()[0]).toHaveAttribute('aria-expanded', 'false'); + expect(exiting.every(row => row.hasAttribute('inert'))).toBe(true); + + // Halfway through, they should be partway collapsed rather than gone. + await wait(DURATION / 2); + let height = exiting[0].getBoundingClientRect().height; + expect(height).toBeGreaterThan(0); + expect(height).toBeLessThan(ROW_HEIGHT); + + await wait(DURATION); + expect(rows()).toHaveLength(1); +}); + +it('restores rows when a collapse is interrupted by re-expanding', async () => { + root.render(); + await waitForRows(3); + + root.render(); + await wait(DURATION / 2); + expect(rows().filter(row => row.hasAttribute('data-exiting'))).toHaveLength(2); + + root.render(); + await wait(DURATION * 2); + + expect(rows()).toHaveLength(3); + expect(container.querySelectorAll('[data-exiting]')).toHaveLength(0); + expect(rows()[1].getBoundingClientRect().height).toBeCloseTo(ROW_HEIGHT, 0); +}); + +it('animates rows in when they are revealed by an expansion', async () => { + root.render(); + await waitForRows(1); + + root.render(); + await wait(DURATION / 2); + + // Mid-transition the new rows exist but have not reached their full height yet. + expect(rows()).toHaveLength(3); + let height = rows()[1].getBoundingClientRect().height; + expect(height).toBeGreaterThan(0); + expect(height).toBeLessThan(ROW_HEIGHT); + + await wait(DURATION); + expect(rows()[1].getBoundingClientRect().height).toBeCloseTo(ROW_HEIGHT, 0); +}); diff --git a/packages/react-aria/exports/private/utils/animation.ts b/packages/react-aria/exports/private/utils/animation.ts index fcb37101d2a..acccb0661cb 100644 --- a/packages/react-aria/exports/private/utils/animation.ts +++ b/packages/react-aria/exports/private/utils/animation.ts @@ -1 +1 @@ -export {useEnterAnimation, useExitAnimation} from '../../../src/utils/animation'; +export {useAnimation, useEnterAnimation, useExitAnimation} from '../../../src/utils/animation'; diff --git a/packages/react-aria/src/utils/animation.ts b/packages/react-aria/src/utils/animation.ts index 226d88cd34e..57ae38791a2 100644 --- a/packages/react-aria/src/utils/animation.ts +++ b/packages/react-aria/src/utils/animation.ts @@ -80,7 +80,7 @@ export function useExitAnimation(ref: RefObject, isOpen: boo return isExiting; } -function useAnimation( +export function useAnimation( ref: RefObject, isActive: boolean, onEnd: () => void diff --git a/starters/docs/src/Tree.css b/starters/docs/src/Tree.css index 7c73af4f445..40352702556 100644 --- a/starters/docs/src/Tree.css +++ b/starters/docs/src/Tree.css @@ -69,6 +69,9 @@ align-items: center; gap: var(--spacing-2); min-height: var(--spacing-8); + /* A definite height lets rows collapse to zero as they animate out. */ + height: var(--spacing-8); + overflow: clip; padding: var(--spacing-1) var(--spacing-1) var(--spacing-1) var(--spacing-2); box-sizing: border-box; --padding: var(--spacing-4); @@ -79,11 +82,24 @@ font: var(--font-size) system-ui; position: relative; transform: translateZ(0); - transition-property: background, color, border-radius; + transition-property: background, color, border-radius, height, min-height, padding, opacity; transition-duration: 200ms; -webkit-tap-highlight-color: transparent; --chevron-width: var(--spacing-5); + /* Rows stay mounted while their parent expands or collapses, so they can animate in and out. */ + &[data-entering], + &[data-exiting] { + height: 0; + min-height: 0; + padding-block: 0; + opacity: 0; + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + } + &[data-has-child-items] { --chevron-width: 0px; } diff --git a/starters/tailwind/src/Tree.tsx b/starters/tailwind/src/Tree.tsx index 9a824087788..500fc9435ec 100644 --- a/starters/tailwind/src/Tree.tsx +++ b/starters/tailwind/src/Tree.tsx @@ -15,7 +15,7 @@ import {composeTailwindRenderProps, focusRing} from './utils'; const itemStyles = tv({ extend: focusRing, - base: 'relative font-sans flex group gap-3 cursor-default select-none py-1 px-3 text-sm text-neutral-900 dark:text-neutral-200 bg-white dark:bg-neutral-900 border-t dark:border-t-neutral-700 border-transparent first:border-t-0 -outline-offset-2 first:rounded-t-lg last:rounded-b-lg', + base: 'relative font-sans flex group gap-3 cursor-default select-none py-1 px-3 h-10 overflow-clip text-sm text-neutral-900 dark:text-neutral-200 bg-white dark:bg-neutral-900 border-t dark:border-t-neutral-700 border-transparent first:border-t-0 -outline-offset-2 first:rounded-t-lg last:rounded-b-lg motion-safe:transition-[height,padding,opacity] motion-safe:duration-200 entering:h-0 entering:py-0 entering:opacity-0 exiting:h-0 exiting:py-0 exiting:opacity-0', variants: { isSelected: { false: From 3a4908a0a37e94b72293d0e5cc46f84f6d552d58 Mon Sep 17 00:00:00 2001 From: Rob Hannay <609062+RobHannay@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:55:54 +0000 Subject: [PATCH 2/4] =?UTF-8?q?test:=20Make=20Tree=20animation=20browser?= =?UTF-8?q?=20tests=20deterministic=20=E2=99=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../test/TreeAnimations.browser.test.tsx | 117 ++++++++++++------ 1 file changed, 80 insertions(+), 37 deletions(-) diff --git a/packages/react-aria-components/test/TreeAnimations.browser.test.tsx b/packages/react-aria-components/test/TreeAnimations.browser.test.tsx index abef53ebb53..7f9c4c3cbe1 100644 --- a/packages/react-aria-components/test/TreeAnimations.browser.test.tsx +++ b/packages/react-aria-components/test/TreeAnimations.browser.test.tsx @@ -11,6 +11,11 @@ */ // JSDOM has no getAnimations, so the exit hold can only be verified end to end in a real browser. +// +// Nothing here is timed against the wall clock. A concurrent root commits asynchronously and CI +// machines are slow, so the transition is deliberately far longer than any plausible scheduling +// delay, every observation polls for the state it is about to assert, and animations are finished +// explicitly rather than waited out. import {afterEach, beforeEach, expect, it} from 'vitest'; import {Button} from '../src/Button'; @@ -18,7 +23,7 @@ import {createRoot, Root} from 'react-dom/client'; import React from 'react'; import {Tree, TreeItem, TreeItemContent} from '../src/Tree'; -const DURATION = 150; +const DURATION = 5000; const ROW_HEIGHT = 30; const css = ` @@ -27,13 +32,12 @@ const css = ` box-sizing: border-box; height: ${ROW_HEIGHT}px; overflow: clip; - transition: height ${DURATION}ms linear, opacity ${DURATION}ms linear; + transition: height ${DURATION}ms linear; } .animated-tree-item[data-entering], .animated-tree-item[data-exiting] { height: 0; - opacity: 0; } `; @@ -61,15 +65,45 @@ let style: HTMLStyleElement; let root: Root; let rows = () => Array.from(container.querySelectorAll('.animated-tree-item')); +let exitingRows = () => rows().filter(row => row.hasAttribute('data-exiting')); +let isAnimating = (row: HTMLElement) => row.getAnimations().length > 0; +let height = (row: HTMLElement) => row.getBoundingClientRect().height; let wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); -// The collection renders over two passes, and a cold browser start can take a while to get there. -let waitForRows = async (count: number) => { - for (let i = 0; i < 100 && rows().length !== count; i++) { +/** + * Waits for a state and then for the browser to actually render it. A transition is only generated + * when the property has a before-change style from a completed style pass, so mutating the tree in + * the same frame the rows first appeared would silently produce no animation at all. + */ +async function waitFor(condition: () => boolean, description: string) { + for (let i = 0; i < 250 && !condition(); i++) { await wait(20); } - expect(rows()).toHaveLength(count); -}; + + expect(condition(), description).toBe(true); + await new Promise(resolve => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ); +} + +/** Seeks to the midpoint so the intermediate state can be asserted without racing the clock. */ +function seekToMiddle(elements: HTMLElement[]) { + for (let animation of elements.flatMap(el => el.getAnimations())) { + animation.pause(); + animation.currentTime = DURATION / 2; + } +} + +/** Jumps the elements' animations to their end rather than waiting out DURATION. */ +async function finishAnimations(elements: HTMLElement[]) { + let animations = elements.flatMap(el => el.getAnimations()); + for (let animation of animations) { + animation.play(); + animation.finish(); + } + + await Promise.all(animations.map(a => a.finished.catch(() => {}))); +} beforeEach(() => { style = document.createElement('style'); @@ -88,57 +122,66 @@ afterEach(() => { it('keeps collapsed rows mounted until their exit transition finishes', async () => { root.render(); - await waitForRows(3); + await waitFor(() => rows().length === 3, 'three rows render while expanded'); root.render(); - await wait(0); + await waitFor( + () => exitingRows().length === 2 && exitingRows().every(isAnimating), + 'both children are held in the DOM and animating out' + ); - // The rows are still mounted and animating, but the tree already reports itself as collapsed. - let exiting = rows().filter(row => row.hasAttribute('data-exiting')); - expect(exiting).toHaveLength(2); - expect(exiting.every(row => row.getAnimations().length > 0)).toBe(true); + // The rows are still mounted and collapsing, but the tree already reports itself collapsed. + let exiting = exitingRows(); + seekToMiddle(exiting); + expect(exiting.every(row => height(row) > 0 && height(row) < ROW_HEIGHT)).toBe(true); expect(rows()[0]).toHaveAttribute('aria-expanded', 'false'); expect(exiting.every(row => row.hasAttribute('inert'))).toBe(true); - // Halfway through, they should be partway collapsed rather than gone. - await wait(DURATION / 2); - let height = exiting[0].getBoundingClientRect().height; - expect(height).toBeGreaterThan(0); - expect(height).toBeLessThan(ROW_HEIGHT); - - await wait(DURATION); - expect(rows()).toHaveLength(1); + await finishAnimations(exiting); + await waitFor(() => rows().length === 1, 'the held rows are released once they finish animating'); }); it('restores rows when a collapse is interrupted by re-expanding', async () => { root.render(); - await waitForRows(3); + await waitFor(() => rows().length === 3, 'three rows render while expanded'); root.render(); - await wait(DURATION / 2); - expect(rows().filter(row => row.hasAttribute('data-exiting'))).toHaveLength(2); + await waitFor( + () => exitingRows().length === 2 && exitingRows().every(isAnimating), + 'both children are held in the DOM and animating out' + ); root.render(); - await wait(DURATION * 2); + await waitFor(() => exitingRows().length === 0, 'the exiting state is cleared'); expect(rows()).toHaveLength(3); - expect(container.querySelectorAll('[data-exiting]')).toHaveLength(0); - expect(rows()[1].getBoundingClientRect().height).toBeCloseTo(ROW_HEIGHT, 0); + expect(container.querySelectorAll('[inert]')).toHaveLength(0); + + await finishAnimations(rows()); + await waitFor( + () => rows().length === 3 && rows().every(row => height(row) === ROW_HEIGHT), + 'the restored rows animate back to full height' + ); }); it('animates rows in when they are revealed by an expansion', async () => { root.render(); - await waitForRows(1); + await waitFor(() => rows().length === 1, 'only the root renders while collapsed'); root.render(); - await wait(DURATION / 2); + await waitFor( + () => rows().length === 3 && rows().slice(1).every(isAnimating), + 'the revealed children are animating in' + ); - // Mid-transition the new rows exist but have not reached their full height yet. - expect(rows()).toHaveLength(3); - let height = rows()[1].getBoundingClientRect().height; - expect(height).toBeGreaterThan(0); - expect(height).toBeLessThan(ROW_HEIGHT); + // The new rows grow to their full height rather than appearing at it. + let revealed = rows().slice(1); + seekToMiddle(revealed); + expect(revealed.every(row => height(row) > 0 && height(row) < ROW_HEIGHT)).toBe(true); - await wait(DURATION); - expect(rows()[1].getBoundingClientRect().height).toBeCloseTo(ROW_HEIGHT, 0); + await finishAnimations(rows()); + await waitFor( + () => rows().every(row => height(row) === ROW_HEIGHT), + 'the revealed rows reach full height' + ); }); From 5b54a6927ffc0c4d739dd3b36aa2204b6b93926a Mon Sep 17 00:00:00 2001 From: Rob Hannay <609062+RobHannay@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:21:39 +0000 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20Publish=20--tree-item-height=20so?= =?UTF-8?q?=20rows=20animate=20without=20a=20fixed=20height=20=E2=99=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../dev/s2-docs/pages/react-aria/Tree.mdx | 15 +++-- packages/react-aria-components/src/Tree.tsx | 60 +++++++++++++++++++ .../stories/Tree.stories.tsx | 6 +- .../test/TreeAnimations.browser.test.tsx | 12 ++-- starters/docs/src/Tree.css | 9 +-- starters/tailwind/src/Tree.tsx | 2 +- 6 files changed, 85 insertions(+), 19 deletions(-) diff --git a/packages/dev/s2-docs/pages/react-aria/Tree.mdx b/packages/dev/s2-docs/pages/react-aria/Tree.mdx index 63945878df9..6412f012b6a 100644 --- a/packages/dev/s2-docs/pages/react-aria/Tree.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Tree.mdx @@ -378,13 +378,13 @@ so they are excluded from keyboard navigation and hidden from assistive technolo ```css render=false .react-aria-TreeItem { - height: 30px; + height: var(--tree-item-height, auto); overflow: clip; - transition: height 200ms, opacity 200ms; + transition: height 200ms, padding 200ms, opacity 200ms; &[data-entering], &[data-exiting] { - height: 0; + padding-block: 0; opacity: 0; } @@ -394,7 +394,11 @@ so they are excluded from keyboard navigation and hidden from assistive technolo } ``` -Animating a row's height requires a definite height to animate from, so give rows a fixed height as above. +`height: auto` can't be animated, so while a row is entering or exiting the Tree sets +`--tree-item-height` to its measured height in pixels, and back to `auto` once the animation finishes. +This is only measured when the row declares a transition on `height`. A row can't shrink below its own +padding, so animate that alongside the height. + In a [virtualized](Virtualizer) Tree, the layout does not re-measure rows while they animate; animate `opacity` or `transform` instead of `height` there. @@ -512,7 +516,8 @@ function Example() { links={docs.links} showDescription cssVariables={{ - '--tree-item-level': "The depth of the item within the tree. Useful to calculate indentation." + '--tree-item-level': "The depth of the item within the tree. Useful to calculate indentation.", + '--tree-item-height': "The item's intrinsic height in pixels while it is entering or exiting, and auto otherwise. Useful for animations. Set when height, block-size, or all is used in the CSS transition." }} /> ### TreeItemContent diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index c2cd20075b4..0843d0ce07f 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -912,6 +912,7 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent( // Captured once so that defaultExpandedKeys doesn't animate the initial rows in. let [didEnterViaExpansion] = useState(() => enteringKeysRef.current.has(item.key)); let isEntering = useEnterAnimation(ref, didEnterViaExpansion) && !isExiting; + useTreeItemHeight(ref, isEntering, isExiting); useAnimation( ref, isExiting, @@ -1445,6 +1446,65 @@ export const TreeHeader = (props: TreeHeaderProps): ReactNode => { ); }; +/** + * Publishes a row's intrinsic height as `--tree-item-height` while it animates in or out, so CSS + * can interpolate between zero and a real height. `height: auto` isn't animatable, and requiring a + * fixed row height would rule out rows that size to their content, so the value is measured here — + * the same polyfill `useDisclosure` applies to its panel. + * + * Only runs when the row actually declares a height transition, since measuring forces a layout. + */ +function useTreeItemHeight( + ref: RefObject, + isEntering: boolean, + isExiting: boolean +) { + let hasHeightTransition = useRef(null); + let isSized = useRef(false); + + useLayoutEffect(() => { + let element = ref.current; + if (!element || typeof element.getAnimations !== 'function') { + return; + } + + if (hasHeightTransition.current == null) { + hasHeightTransition.current = /height|block-size|all/.test( + window.getComputedStyle(element).transition + ); + } + + // `isSized` keeps this running for one more pass after an interrupted collapse, which leaves the row + // holding a pixel height it needs to grow back out of. + if (!hasHeightTransition.current || (!isEntering && !isExiting && !isSized.current)) { + return; + } + + let height = element.scrollHeight + 'px'; + // An interrupted collapse animates from wherever it got to, so it doesn't get a starting value. + let from = isExiting ? height : isEntering ? '0px' : null; + if (from != null) { + element.style.setProperty('--tree-item-height', from); + + // Force style re-calculation to trigger animations. + window.getComputedStyle(element).height; + } + + element.style.setProperty('--tree-item-height', isExiting ? '0px' : height); + isSized.current = true; + + if (!isExiting) { + // After the animations complete, switch back to auto so the row can resize with its content. + Promise.all(element.getAnimations().map(a => a.finished)) + .then(() => { + element.style.setProperty('--tree-item-height', 'auto'); + isSized.current = false; + }) + .catch(() => {}); + } + }, [ref, isEntering, isExiting]); +} + function areSetsEqual(a: Set, b: Set) { if (a.size !== b.size) { return false; diff --git a/packages/react-aria-components/stories/Tree.stories.tsx b/packages/react-aria-components/stories/Tree.stories.tsx index 6b747b361a6..a9847de73d2 100644 --- a/packages/react-aria-components/stories/Tree.stories.tsx +++ b/packages/react-aria-components/stories/Tree.stories.tsx @@ -2049,14 +2049,14 @@ export const VirtualizedTreeInShadowDOMStory: StoryObj row.style.getPropertyValue('--tree-item-height') !== '')).toBe(true); seekToMiddle(exiting); expect(exiting.every(row => height(row) > 0 && height(row) < ROW_HEIGHT)).toBe(true); expect(rows()[0]).toHaveAttribute('aria-expanded', 'false'); diff --git a/starters/docs/src/Tree.css b/starters/docs/src/Tree.css index 40352702556..3075815cfa4 100644 --- a/starters/docs/src/Tree.css +++ b/starters/docs/src/Tree.css @@ -69,8 +69,8 @@ align-items: center; gap: var(--spacing-2); min-height: var(--spacing-8); - /* A definite height lets rows collapse to zero as they animate out. */ - height: var(--spacing-8); + /* Set to the row's intrinsic height while it animates in or out, and auto otherwise. */ + height: var(--tree-item-height, auto); overflow: clip; padding: var(--spacing-1) var(--spacing-1) var(--spacing-1) var(--spacing-2); box-sizing: border-box; @@ -87,10 +87,11 @@ -webkit-tap-highlight-color: transparent; --chevron-width: var(--spacing-5); - /* Rows stay mounted while their parent expands or collapses, so they can animate in and out. */ + /* Rows stay mounted while their parent expands or collapses, so they can animate in and out. A row + can't shrink below its own padding, so that has to go with the height or it pops out rather than + closing. */ &[data-entering], &[data-exiting] { - height: 0; min-height: 0; padding-block: 0; opacity: 0; diff --git a/starters/tailwind/src/Tree.tsx b/starters/tailwind/src/Tree.tsx index 500fc9435ec..1bbaf8b9c42 100644 --- a/starters/tailwind/src/Tree.tsx +++ b/starters/tailwind/src/Tree.tsx @@ -15,7 +15,7 @@ import {composeTailwindRenderProps, focusRing} from './utils'; const itemStyles = tv({ extend: focusRing, - base: 'relative font-sans flex group gap-3 cursor-default select-none py-1 px-3 h-10 overflow-clip text-sm text-neutral-900 dark:text-neutral-200 bg-white dark:bg-neutral-900 border-t dark:border-t-neutral-700 border-transparent first:border-t-0 -outline-offset-2 first:rounded-t-lg last:rounded-b-lg motion-safe:transition-[height,padding,opacity] motion-safe:duration-200 entering:h-0 entering:py-0 entering:opacity-0 exiting:h-0 exiting:py-0 exiting:opacity-0', + base: 'relative font-sans flex group gap-3 cursor-default select-none py-1 px-3 h-(--tree-item-height,auto) overflow-clip text-sm text-neutral-900 dark:text-neutral-200 bg-white dark:bg-neutral-900 border-t dark:border-t-neutral-700 border-transparent first:border-t-0 -outline-offset-2 first:rounded-t-lg last:rounded-b-lg motion-safe:transition-[height,padding,opacity] motion-safe:duration-200 entering:py-0 entering:opacity-0 exiting:py-0 exiting:opacity-0', variants: { isSelected: { false: From eab7d30960b36575a597c1e4a7e8a759f4f5c3af Mon Sep 17 00:00:00 2001 From: Rob Hannay <609062+RobHannay@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:38:34 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20Measure=20a=20tree=20row's=20height?= =?UTF-8?q?=20while=20it=20isn't=20animating=20=E2=99=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/react-aria-components/src/Tree.tsx | 65 ++++++++++++++++++- .../test/TreeAnimations.browser.test.tsx | 29 +++++++-- 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 0843d0ce07f..5ad15eee198 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -1446,6 +1446,53 @@ export const TreeHeader = (props: TreeHeaderProps): ReactNode => { ); }; +/** + * The row's height as if it weren't animating. The entering and exiting states commonly override + * the padding — a row can't shrink below it — and any height already applied here would clamp the + * result, so both are lifted for the duration of the read. Transitions are suppressed alongside + * them, or lifting an override would just start it animating and the read would return where it had + * got to rather than where it was heading. This runs inside a layout effect, so nothing is painted + * in between. + * + * Suppressing transitions cancels any that are in flight, so this must only be called while the row + * is at rest. + */ +function measureRestingHeight(element: HTMLElement) { + let entering = element.getAttribute('data-entering'); + let exiting = element.getAttribute('data-exiting'); + let height = element.style.getPropertyValue('--tree-item-height'); + let transition = element.style.transition; + + element.style.transition = 'none'; + if (entering != null) { + element.removeAttribute('data-entering'); + } + if (exiting != null) { + element.removeAttribute('data-exiting'); + } + if (height) { + element.style.removeProperty('--tree-item-height'); + } + + let restingHeight = element.offsetHeight; + + if (entering != null) { + element.setAttribute('data-entering', entering); + } + if (exiting != null) { + element.setAttribute('data-exiting', exiting); + } + if (height) { + element.style.setProperty('--tree-item-height', height); + } + + // Settle the restored styles before transitions come back, so none of this animates. + element.offsetHeight; + element.style.transition = transition; + + return restingHeight; +} + /** * Publishes a row's intrinsic height as `--tree-item-height` while it animates in or out, so CSS * can interpolate between zero and a real height. `height: auto` isn't animatable, and requiring a @@ -1460,6 +1507,7 @@ function useTreeItemHeight( isExiting: boolean ) { let hasHeightTransition = useRef(null); + let restingHeight = useRef(null); let isSized = useRef(false); useLayoutEffect(() => { @@ -1474,13 +1522,26 @@ function useTreeItemHeight( ); } + if (!hasHeightTransition.current) { + return; + } + // `isSized` keeps this running for one more pass after an interrupted collapse, which leaves the row // holding a pixel height it needs to grow back out of. - if (!hasHeightTransition.current || (!isEntering && !isExiting && !isSized.current)) { + let isAtRest = !isEntering && !isExiting && !isSized.current; + + // At rest nothing is in flight, so this is the only point the row can be measured honestly. The first + // pass of an entering row also qualifies: it has only just mounted, so its styles are its initial ones + // and no transition has started from them yet. + if (isAtRest || restingHeight.current == null) { + restingHeight.current = measureRestingHeight(element); + } + + if (isAtRest) { return; } - let height = element.scrollHeight + 'px'; + let height = restingHeight.current + 'px'; // An interrupted collapse animates from wherever it got to, so it doesn't get a starting value. let from = isExiting ? height : isEntering ? '0px' : null; if (from != null) { diff --git a/packages/react-aria-components/test/TreeAnimations.browser.test.tsx b/packages/react-aria-components/test/TreeAnimations.browser.test.tsx index f82ddedfcb8..af2494a6326 100644 --- a/packages/react-aria-components/test/TreeAnimations.browser.test.tsx +++ b/packages/react-aria-components/test/TreeAnimations.browser.test.tsx @@ -24,18 +24,28 @@ import React from 'react'; import {Tree, TreeItem, TreeItemContent} from '../src/Tree'; const DURATION = 5000; -const ROW_HEIGHT = 30; - -// Exercises the --tree-item-height polyfill rather than a hard-coded height: the row sizes to its -// content, and the Tree publishes that height so it can be animated to and from zero. +const LINE_HEIGHT = 30; +const PADDING = 5; +const ROW_HEIGHT = LINE_HEIGHT + PADDING * 2; + +// Exercises the --tree-item-height polyfill rather than a hard-coded height: the row sizes to its content, +// and the Tree publishes that height so it can be animated to and from zero. The padding is deliberate — a +// row can't shrink below it, so it has to animate too, which in turn means a naive measurement taken while +// the row is entering or exiting reads the wrong height. const css = ` .animated-tree-item { display: block; box-sizing: border-box; overflow: clip; height: var(--tree-item-height, auto); - line-height: ${ROW_HEIGHT}px; - transition: height ${DURATION}ms linear; + line-height: ${LINE_HEIGHT}px; + padding-block: ${PADDING}px; + transition: height ${DURATION}ms linear, padding ${DURATION}ms linear; +} + +.animated-tree-item[data-entering], +.animated-tree-item[data-exiting] { + padding-block: 0; } `; @@ -174,8 +184,13 @@ it('animates rows in when they are revealed by an expansion', async () => { 'the revealed children are animating in' ); - // The new rows grow to their full height rather than appearing at it. + // The published height must be the row's resting height. Measuring it naively reads the row mid-animation + // — with its padding already zeroed — and lands short, leaving the row to jump the difference at the end. let revealed = rows().slice(1); + expect(revealed.map(row => row.style.getPropertyValue('--tree-item-height'))).toEqual( + revealed.map(() => `${ROW_HEIGHT}px`) + ); + seekToMiddle(revealed); expect(revealed.every(row => height(row) > 0 && height(row) < ROW_HEIGHT)).toBe(true);