From e66701a8197eae76138ba6501e58afc5ef0b6fd5 Mon Sep 17 00:00:00 2001 From: Rob Hannay <609062+RobHannay@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:04:26 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20Tree=20expand=20and=20collapse=20?= =?UTF-8?q?animations=20=E2=99=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retain outgoing rows separately from the semantic collection, publish measured row heights, and cover interruption, independent exits, loaders, StrictMode and drag indicators. Co-authored-by: Rook --- .../dev/s2-docs/pages/react-aria/Tree.mdx | 40 +- .../react-aria-components/src/Collection.tsx | 23 +- packages/react-aria-components/src/Tree.tsx | 298 ++++++++++- .../stories/Tree.stories.tsx | 60 +++ .../react-aria-components/test/Tree.test.tsx | 188 +++++++ .../test/TreeAnimations.browser.test.tsx | 488 ++++++++++++++++++ .../exports/private/utils/animation.ts | 2 +- packages/react-aria/src/utils/animation.ts | 2 +- starters/docs/src/Tree.css | 15 +- starters/tailwind/src/Tree.tsx | 2 +- 10 files changed, 1091 insertions(+), 27 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..1735db27bbe 100644 --- a/packages/dev/s2-docs/pages/react-aria/Tree.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Tree.mdx @@ -367,6 +367,43 @@ 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: var(--tree-item-height, auto); + overflow: clip; + transition: height 200ms, padding 200ms, opacity 200ms; + + &[data-entering], + &[data-exiting] { + padding-block: 0; + opacity: 0; + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + } +} +``` + +`height: auto` can't be animated, so while a row is entering or exiting the Tree sets +`--tree-item-height` to the animation's target height (zero when collapsing, the measured CSS height +when expanding), and back to `auto` once the animation finishes. Resting size changes are tracked with +`ResizeObserver`. This is only measured when the row declares a transition on `height` or `all`. +A row can't shrink below its own padding or minimum height, so animate those alongside the height. + +Expand/collapse animations are not supported in a [virtualized](Virtualizer) Tree. Virtualized rows +may unmount before an animation completes, so expansion and collapse remain immediate there. +Load-more indicators also disappear immediately when their parent collapses. + ## 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. @@ -481,7 +518,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 target CSS height in pixels while expanding or collapsing, and auto otherwise. Set when height or all is used in the CSS transition." }} /> ### TreeItemContent diff --git a/packages/react-aria-components/src/Collection.tsx b/packages/react-aria-components/src/Collection.tsx index ecc435879fe..d6ecdf57839 100644 --- a/packages/react-aria-components/src/Collection.tsx +++ b/packages/react-aria-components/src/Collection.tsx @@ -171,7 +171,7 @@ export interface CollectionBranchProps { /** The parent node of the items to render. */ parent: Node; /** A function that renders a drop indicator between items. */ - renderDropIndicator?: (target: ItemDropTarget) => ReactNode; + renderDropIndicator?: (target: ItemDropTarget, source?: Node) => ReactNode; } export interface CollectionRootProps extends HTMLAttributes { @@ -182,7 +182,7 @@ export interface CollectionRootProps extends HTMLAttributes { /** A ref to the scroll container for the collection. */ scrollRef?: RefObject; /** A function that renders a drop indicator between items. */ - renderDropIndicator?: (target: ItemDropTarget) => ReactNode; + renderDropIndicator?: (target: ItemDropTarget, source?: Node) => ReactNode; } export interface CollectionRenderer { @@ -210,7 +210,7 @@ export const DefaultCollectionRenderer: CollectionRenderer = { function useCollectionRender( collection: ICollection>, parent: Node | null, - renderDropIndicator?: (target: ItemDropTarget) => ReactNode + renderDropIndicator?: (target: ItemDropTarget, source?: Node) => ReactNode ) { return useCachedChildren({ items: parent ? collection.getChildren!(parent.key) : collection, @@ -229,7 +229,7 @@ function useCollectionRender( return ( <> - {renderDropIndicator({type: 'item', key: node.key, dropPosition: 'before'})} + {renderDropIndicator({type: 'item', key: node.key, dropPosition: 'before'}, node)} {rendered} {renderAfterDropIndicators(collection, node, renderDropIndicator)} @@ -241,7 +241,7 @@ function useCollectionRender( export function renderAfterDropIndicators( collection: ICollection>, node: Node, - renderDropIndicator: (target: ItemDropTarget) => ReactNode + renderDropIndicator: (target: ItemDropTarget, source?: Node) => ReactNode ): ReactNode { let key = node.key; let keyAfter = collection.getKeyAfter(key); @@ -269,11 +269,14 @@ export function renderAfterDropIndicators( (current.parentKey !== nextItemInFlattenedCollection.parentKey && nextItemInFlattenedCollection.level < current.level)) ) { - let indicator = renderDropIndicator({ - type: 'item', - key: current.key, - dropPosition: 'after' - }); + let indicator = renderDropIndicator( + { + type: 'item', + key: current.key, + dropPosition: 'after' + }, + node + ); if (isValidElement(indicator)) { afterIndicators.push(cloneElement(indicator, {key: `${current.key}-after`})); } diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index c2a05801f2b..0e911280f64 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -55,6 +55,7 @@ import { forwardRefType, GlobalDOMAttributes, HoverEvents, + ItemDropTarget, Key, LinkDOMProps, MultipleSelection, @@ -99,6 +100,7 @@ import React, { forwardRef, JSX, 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,32 @@ 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 {useResizeObserver} from 'react-aria/private/utils/useResizeObserver'; import {useVisuallyHidden} from 'react-aria/VisuallyHidden'; +const emptyKeySet: Set = new Set(); + +interface TreeAnimationContextValue { + exitingKeys: Set; + enteringKeys: Set; + renderedCollection: TreeCollection; + onExitComplete: (key: Key) => void; +} + +const TreeAnimationContext = createContext(null); + class TreeCollection extends BaseCollection { private expandedKeys: Set = new Set(); + private renderedExpandedKeys: Set = new Set(); + private exitingKeys: Set = emptyKeySet; withExpandedKeys(lastExpandedKeys: Set, expandedKeys: Set) { let collection = this.clone(); collection.expandedKeys = expandedKeys; + collection.renderedExpandedKeys = expandedKeys; // 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 @@ -136,6 +155,36 @@ class TreeCollection extends BaseCollection { return collection; } + // Only the renderer sees retained rows. Behavioral consumers keep the semantic collection. + withExitingKeys(exitingKeys: Set) { + if (exitingKeys.size === 0) { + return this; + } + + let collection = this.clone(); + collection.expandedKeys = this.expandedKeys; + collection.renderedExpandedKeys = new Set(this.expandedKeys); + collection.exitingKeys = exitingKeys; + for (let key of exitingKeys) { + let parentKey = this.getItem(key)?.parentKey; + while (parentKey != null) { + collection.renderedExpandedKeys.add(parentKey); + parentKey = this.getItem(parentKey)?.parentKey; + } + } + TreeCollection.cloneAncestorSections(exitingKeys, emptyKeySet, collection); + collection.frozen = this.frozen; + return collection; + } + + private isRendered(node: Node) { + return ( + this.exitingKeys.size === 0 || + this.exitingKeys.has(node.key) || + isRowVisible(this, node.key, this.expandedKeys) + ); + } + // diff lastExpandedKeys and expandedKeys so we only clone what has changed private static cloneAncestorSections( keys: Iterable, @@ -163,13 +212,15 @@ class TreeCollection extends BaseCollection { let node: Node | null = firstKey != null ? this.getItem(firstKey) : null; while (node) { - yield node as Node; + if (this.isRendered(node)) { + yield node as Node; + } if (node.type === 'section') { node = node.nextKey != null ? this.getItem(node.nextKey) : null; } 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 +248,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; } @@ -257,9 +312,11 @@ class TreeCollection extends BaseCollection { if (parent && parent.type === 'section' && node) { // Stop once either the node is null or the node is the parent's sibling while (node && node.key !== parent.nextKey) { - yield self.getItem(node.key)!; + if (self.isRendered(node)) { + yield self.getItem(node.key)!; + } // This will include content nodes which we skip in ListLayout - let key = self.getKeyAfter(node.key); + let key = self.getKeyAfterInternal(node.key, self.renderedExpandedKeys); node = key != null ? (self.getItem(key)! as CollectionNode) : null; } } else { @@ -445,17 +502,65 @@ function TreeInner({props, collection, treeRef: ref}: TreeInnerProps) { let [lastCollection, setLastCollection] = useState(collection); let [lastExpandedKeys, setLastExpandedKeys] = useState(expandedKeys); + let [exitingKeys, setExitingKeys] = useState(emptyKeySet); + let [enteringKeys, setEnteringKeys] = useState(emptyKeySet); let [flattenedCollection, setFlattenedCollection] = useState(() => collection.withExpandedKeys(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)); + let expandedKeysChanged = !areSetsEqual(lastExpandedKeys, expandedKeys); + if (expandedKeysChanged || collection !== lastCollection) { + let nextCollection = collection.withExpandedKeys(lastExpandedKeys, expandedKeys); + let previousVisible = getVisibleItemKeys(flattenedCollection); + let nextVisible = getVisibleItemKeys(nextCollection); + let nextExiting = new Set(); + let nextEntering = new Set(); + // Virtualized rows may never mount (or unmount before finishing), so they cannot own exits. + if (!isVirtualized) { + for (let key of new Set([...exitingKeys, ...previousVisible])) { + if (collection.getItem(key) && !nextVisible.has(key)) { + nextExiting.add(key); + } + } + if (expandedKeysChanged) { + nextEntering = new Set( + [...nextVisible].filter(key => !previousVisible.has(key) && !exitingKeys.has(key)) + ); + } + } + setFlattenedCollection(nextCollection); setLastCollection(collection); setLastExpandedKeys(expandedKeys); + setExitingKeys(nextExiting); + setEnteringKeys(nextEntering); } + let onExitComplete = useCallback((key: Key) => { + setExitingKeys(keys => { + if (!keys.has(key)) { + return keys; + } + let next = new Set(keys); + next.delete(key); + return next; + }); + }, []); + + useLayoutEffect(() => { + if (enteringKeys.size > 0) { + setEnteringKeys(emptyKeySet); + } + }, [enteringKeys]); + + let renderedCollection = useMemo( + () => flattenedCollection.withExitingKeys(exitingKeys), + [flattenedCollection, exitingKeys] + ); + let animationContextValue = useMemo( + () => ({exitingKeys, enteringKeys, renderedCollection, onExitComplete}), + [exitingKeys, enteringKeys, renderedCollection, onExitComplete] + ); + let state = useTreeState({ ...props, selectionMode, @@ -608,6 +713,15 @@ function TreeInner({props, collection, treeRef: ref}: TreeInnerProps) { } let DOMProps = filterDOMProps(props, {global: true}); + let renderDropIndicator = useRenderDropIndicator(dragAndDropHooks, dropState); + let renderVisibleDropIndicator = useCallback( + (target: ItemDropTarget, source?: Node) => { + return isRowVisible(state.collection, source?.key ?? target.key, expandedKeys) + ? renderDropIndicator?.(target) + : null; + }, + [state.collection, expandedKeys, renderDropIndicator] + ); return ( <> @@ -635,20 +749,21 @@ function TreeInner({props, collection, treeRef: ref}: TreeInnerProps) { {hasDropHooks && } @@ -686,6 +801,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 +929,18 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent( props.hasChildItems || [...state.collection.getChildren!(item.key)]?.length > 1; let level = rowProps['aria-level'] || 1; + let {exitingKeys, enteringKeys, onExitComplete} = useContext(TreeAnimationContext)!; + let isExiting = exitingKeys.has(item.key); + let [didEnterViaExpansion] = useState(() => enteringKeys.has(item.key)); + let [isAnimationReady, setAnimationReady] = useState(!didEnterViaExpansion); + let isEntering = useEnterAnimation(ref, didEnterViaExpansion && isAnimationReady && !isExiting); + useTreeItemHeight(ref, isAnimationReady, setAnimationReady, isEntering, 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 +996,8 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent( selectionMode, selectionBehavior, isFocusVisibleWithin, + isEntering, + isExiting, state, id: item.key, allowsDragging: !!dragState, @@ -868,6 +1012,8 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent( hasChildItems, level, isFocusVisibleWithin, + isEntering, + isExiting, state, item.key, dragState, @@ -942,7 +1088,7 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent( return ( <> - {dropIndicator && !dropIndicator.isHidden && ( + {dropIndicator && !dropIndicator.isHidden && !isExiting && (
(props: TreeSectionProps, ref: ForwardedRef, item: Node) => { let state = useContext(TreeStateContext)!; + let {renderedCollection} = useContext(TreeAnimationContext)!; let {CollectionBranch} = useContext(CollectionRendererContext); let headingRef = useRef(null); ref = useObjectRef(ref); @@ -1299,7 +1450,7 @@ export const TreeSection = /*#__PURE__*/ createBranchComponent( [GridListHeaderContext, {...rowProps, ref: headingRef}], [GridListHeaderInnerContext, {...rowHeaderProps}] ]}> - + ); @@ -1316,6 +1467,95 @@ export const TreeHeader = (props: TreeHeaderProps): ReactNode => { ); }; +function useTreeItemHeight( + ref: RefObject, + isReady: boolean, + setReady: (ready: boolean) => void, + isEntering: boolean, + isExiting: boolean +) { + let restingHeight = useRef(null); + let isSized = useRef(false); + let [hasHeightTransition, setHasHeightTransition] = useState(false); + + // Observe only the resting geometry. Initial entry is staged before applying animation styles, + // so measuring never needs to remove selectors or cancel the consumer's keyframes. + useResizeObserver({ + ref: hasHeightTransition ? ref : undefined, + box: 'border-box', + onResize() { + if (ref.current && !isEntering && !isExiting && !isSized.current) { + restingHeight.current = window.getComputedStyle(ref.current).height; + } + } + }); + + useLayoutEffect(() => { + let element = ref.current; + if (!element || typeof element.getAnimations !== 'function') { + setReady(true); + return; + } + + let style = window.getComputedStyle(element); + let durations = style.transitionDuration.split(','); + let delays = style.transitionDelay.split(','); + let transitionsHeight = style.transitionProperty + .split(',') + .some( + (property, index) => + ['height', 'all'].includes(property.trim()) && + (parseFloat(durations[index % durations.length]) > 0 || + parseFloat(delays[index % delays.length]) > 0) + ); + setHasHeightTransition(transitionsHeight); + if (!transitionsHeight) { + element.style.removeProperty('--tree-item-height'); + isSized.current = false; + setReady(true); + return; + } + + if (!isSized.current && (!isReady || (!isEntering && !isExiting))) { + restingHeight.current = style.height; + } + if (!isReady) { + if (!isSized.current) { + element.style.setProperty('--tree-item-height', '0px'); + isSized.current = true; + } + setReady(true); + return; + } + if (!isExiting && !isSized.current) { + return; + } + if (isExiting && !isSized.current) { + element.style.setProperty('--tree-item-height', restingHeight.current || style.height); + window.getComputedStyle(element).height; + } + + element.style.setProperty( + '--tree-item-height', + isExiting ? '0px' : restingHeight.current || 'auto' + ); + isSized.current = true; + if (!isEntering && !isExiting) { + let canceled = false; + Promise.allSettled(element.getAnimations().map(a => a.finished)).then(() => { + if (!canceled) { + element.style.setProperty('--tree-item-height', 'auto'); + isSized.current = false; + restingHeight.current = window.getComputedStyle(element).height; + } + }); + return () => { + canceled = true; + }; + } + }, [ref, isReady, setReady, isEntering, isExiting]); +} + function areSetsEqual(a: Set, b: Set) { if (a.size !== b.size) { return false; @@ -1328,3 +1568,37 @@ function areSetsEqual(a: Set, b: Set) { } return true; } + +function getVisibleItemKeys(collection: TreeCollection) { + let keys = new Set(); + let key = collection.getFirstKey(); + while (key != null) { + if (collection.getItem(key)?.type === 'item') { + keys.add(key); + } + key = collection.getKeyAfter(key); + } + return keys; +} + +function isRowVisible( + collection: Pick, 'getItem'>, + 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; +} diff --git a/packages/react-aria-components/stories/Tree.stories.tsx b/packages/react-aria-components/stories/Tree.stories.tsx index 8acea296766..a9847de73d2 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..2f3b0e46773 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -3130,6 +3130,194 @@ 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); + }); + + it('should not duplicate ancestor drop indicators while descendants exit', async () => { + mockAnimations(); + function DraggableAnimatedTree({expandedKeys}: {expandedKeys: string[]}) { + let {dragAndDropHooks} = useDragAndDrop({ + getItems: keys => [...keys].map(key => ({'text/plain': String(key)})), + onReorder: () => {} + }); + return ( + {}}> + + + Source + + + + + + Root + + + + Child + + + + + ); + } + let {getByRole, getAllByRole, rerender} = render( + + ); + act(() => getByRole('button', {name: 'Drag Source'}).focus()); + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + rerender(); + act(() => jest.runAllTimers()); + expect(rowElements().filter(row => row.hasAttribute('data-exiting'))).toHaveLength(1); + expect(getAllByRole('button', {name: 'Insert after Root'})).toHaveLength(1); + await finishAnimations(); + expect(getAllByRole('button', {name: 'Insert after Root'})).toHaveLength(1); + await user.keyboard('{Escape}'); + act(() => jest.runAllTimers()); + }); + }); }); 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..3fc5a15bf85 --- /dev/null +++ b/packages/react-aria-components/test/TreeAnimations.browser.test.tsx @@ -0,0 +1,488 @@ +/* + * 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. + */ + +import {afterEach, beforeEach, expect, it} from 'vitest'; +import {Button} from '../src/Button'; +import {createRoot, Root} from 'react-dom/client'; +import {ListLayout} from 'react-stately/useVirtualizerState'; +import React from 'react'; +import {Tree, TreeItem, TreeItemContent, TreeLoadMoreItem, TreeSection} from '../src/Tree'; +import {Virtualizer} from '../src/Virtualizer'; + +const DURATION = 5000; +const LINE_HEIGHT = 30; +const PADDING = 5; +const ROW_HEIGHT = LINE_HEIGHT + PADDING * 2; + +const css = ` +.animated-tree-item { + display: block; + box-sizing: border-box; + overflow: clip; + height: var(--tree-item-height, auto); + 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; +} +`; + +function AnimatedTree({ + expandedKeys, + children +}: { + expandedKeys: string[]; + children?: React.ReactNode; +}) { + return ( + {}}> + + + + Root + + {children ?? ( + <> + + Child 1 + + + Child 2 + + + )} + + + ); +} + +let container: HTMLDivElement; +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; +async function waitFor(condition: () => boolean, description: string) { + await expect.poll(condition, {message: description}).toBe(true); + // Establish a before-change style before the next expansion/collapse. + 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'); + 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 waitFor(() => rows().length === 3, 'three rows render while expanded'); + + root.render(); + await waitFor( + () => exitingRows().length === 2 && exitingRows().every(isAnimating), + 'both children are held in the DOM and animating out' + ); + + let exiting = exitingRows(); + expect(exiting.every(row => 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'); + expect(exiting.every(row => row.hasAttribute('inert'))).toBe(true); + + 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 waitFor(() => rows().length === 3, 'three rows render while expanded'); + + root.render(); + await waitFor( + () => exitingRows().length === 2 && exitingRows().every(isAnimating), + 'both children are held in the DOM and animating out' + ); + + let children = exitingRows(); + seekToMiddle(children); + let middleHeights = children.map(height); + expect(middleHeights.every(h => h > 0 && h < ROW_HEIGHT)).toBe(true); + root.render(); + await waitFor(() => exitingRows().length === 0, 'the exiting state is cleared'); + + expect(rows()).toHaveLength(3); + expect(container.querySelectorAll('[inert]')).toHaveLength(0); + expect(rows().slice(1)).toEqual(children); + expect(children.every(isAnimating)).toBe(true); + + await finishAnimations(rows()); + await expect.poll(() => rows().slice(1).map(height)).toEqual([ROW_HEIGHT, ROW_HEIGHT]); +}); + +it('animates rows in when they are revealed by an expansion', async () => { + root.render(); + await waitFor(() => rows().length === 1, 'only the root renders while collapsed'); + + root.render(); + await waitFor( + () => rows().length === 3 && rows().slice(1).every(isAnimating), + 'the revealed children are animating in' + ); + + 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); + + await finishAnimations(rows()); + await expect.poll(() => rows().slice(1).map(height)).toEqual([ROW_HEIGHT, ROW_HEIGHT]); +}); + +it('releases siblings independently without restoring a finished row', async () => { + root.render(); + await waitFor(() => rows().length === 3, 'expanded rows mount'); + root.render(); + await waitFor( + () => exitingRows().length === 2 && exitingRows().every(isAnimating), + 'children exit' + ); + let [first, second] = exitingRows(); + seekToMiddle([first, second]); + await finishAnimations([first]); + await expect.poll(() => first.isConnected).toBe(false); + expect(second).toHaveAttribute('data-exiting'); + expect(second).toHaveAttribute('inert'); + expect(height(second)).toBeGreaterThan(0); + await finishAnimations([second]); + await expect.poll(() => rows().length).toBe(1); +}); + +it('retains a grandchild after its parent has finished exiting', async () => { + let children = ( + + + Branch + + + Leaf + + + ); + root.render({children}); + await waitFor(() => rows().length === 3, 'nested rows mount'); + root.render({children}); + await waitFor( + () => exitingRows().length === 2 && exitingRows().every(isAnimating), + 'nested rows exit' + ); + let [branch, leaf] = exitingRows(); + seekToMiddle([branch, leaf]); + await finishAnimations([branch]); + await expect.poll(() => branch.isConnected).toBe(false); + expect(leaf.isConnected).toBe(true); + expect(leaf).toHaveAttribute('inert'); + await finishAnimations([leaf]); + await expect.poll(() => rows().length).toBe(1); +}); + +it('removes loaders immediately and does not reveal newly added collapsed children', async () => { + let children = ( + <> + + Child + + {}}> + Loading + + + ); + root.render({children}); + await waitFor(() => rows().length === 2, 'children mount'); + expect(container.textContent).toContain('Loading'); + root.render({children}); + await waitFor( + () => exitingRows().length === 1 && exitingRows().every(isAnimating), + 'child exits' + ); + let [child] = exitingRows(); + seekToMiddle([child]); + expect(container.textContent).not.toContain('Loading'); + expect(container.querySelector('[data-testid="loadMoreSentinel"]')).toBeNull(); + root.render( + + {children} + + New + + + ); + await waitFor(() => exitingRows().length === 1, 'existing child remains exiting'); + expect(container.textContent).not.toContain('New'); + await finishAnimations([child]); + await expect.poll(() => rows().length).toBe(1); +}); + +it('releases independently animated rows inside a section', async () => { + let tree = (expandedKeys: string[]) => ( + {}}> + + + + Root + + + A + + + B + + + + + ); + root.render(tree(['root'])); + await waitFor(() => rows().length === 3, 'section rows mount'); + root.render(tree([])); + await waitFor( + () => exitingRows().length === 2 && exitingRows().every(isAnimating), + 'section rows exit' + ); + let [a, b] = exitingRows(); + seekToMiddle([a, b]); + await finishAnimations([a]); + await expect.poll(() => a.isConnected).toBe(false); + expect(b).toHaveAttribute('inert'); + await finishAnimations([b]); + await expect.poll(() => rows().length).toBe(1); +}); + +it('refreshes intrinsic height after content resizes', async () => { + root.render(); + await waitFor(() => rows().length === 3, 'expanded rows mount'); + let children = rows().slice(1); + for (let child of children) { + child.style.lineHeight = '60px'; + } + await waitFor(() => children.every(row => height(row) === 70), 'content resizes'); + root.render(); + await waitFor( + () => exitingRows().length === 2 && exitingRows().every(isAnimating), + 'children exit' + ); + seekToMiddle(children); + expect(children.map(height)).toEqual([35, 35]); + root.render(); + await waitFor( + () => exitingRows().length === 0 && children.every(isAnimating), + 'collapse reverses' + ); + await finishAnimations(children); + await expect.poll(() => children.map(height)).toEqual([70, 70]); +}); + +it('restores content-driven sizing when an entering transition is canceled', async () => { + root.render(); + await waitFor(() => rows().length === 1, 'root mounts'); + root.render(); + await waitFor(() => rows().length === 3 && rows().slice(1).every(isAnimating), 'children enter'); + let children = rows().slice(1); + for (let child of children) { + child.style.transition = 'none'; + child.getAnimations(); + } + await expect + .poll(() => children.map(row => row.style.getPropertyValue('--tree-item-height'))) + .toEqual(['auto', 'auto']); + for (let child of children) { + child.style.lineHeight = '60px'; + } + await expect.poll(() => children.map(height)).toEqual([70, 70]); +}); + +it('measures the CSS height box rather than assuming border-box sizing', async () => { + style.textContent += '.animated-tree-item { box-sizing: content-box; border-block: 2px solid; }'; + root.render(); + await waitFor(() => rows().length === 1, 'root mounts'); + root.render(); + await waitFor(() => rows().length === 3 && rows().slice(1).every(isAnimating), 'children enter'); + let children = rows().slice(1); + expect(children.map(row => row.style.getPropertyValue('--tree-item-height'))).toEqual([ + '30px', + '30px' + ]); + await finishAnimations(children); + await expect.poll(() => children.map(height)).toEqual([44, 44]); +}); + +it('does not cancel entering keyframes while measuring a height transition', async () => { + style.textContent += ` + @keyframes tree-fade { from { opacity: 0; } to { opacity: 1; } } + .animated-tree-item[data-entering] { animation: tree-fade ${DURATION}ms linear; } + `; + root.render(); + await waitFor(() => rows().length === 1, 'root mounts'); + root.render(); + await waitFor( + () => + rows().length === 3 && + rows() + .slice(1) + .every(row => row.hasAttribute('data-entering') && isAnimating(row)), + 'entering keyframes run' + ); + let children = rows().slice(1); + seekToMiddle(children); + expect(children.map(row => Number(window.getComputedStyle(row).opacity))).toEqual([0.5, 0.5]); + expect(children.every(row => height(row) > 0)).toBe(true); + await finishAnimations(children); + await waitFor( + () => children.every(row => !row.hasAttribute('data-entering') && isAnimating(row)), + 'height transitions follow the keyframes' + ); + await finishAnimations(children); + await expect.poll(() => children.map(height)).toEqual([ROW_HEIGHT, ROW_HEIGHT]); +}); + +it('collapses virtualized branches without waiting for unmounted rows', async () => { + style.textContent += '[role="treegrid"] { height: 80px; width: 300px; overflow: auto; }'; + let tree = (expandedKeys: string[]) => ( + + + {Array.from({length: 100}, (_, i) => ( + + Child {i} + + ))} + + + ); + root.render(tree(['root'])); + await waitFor(() => rows().length > 1, 'virtualized children mount'); + expect(rows().length).toBeLessThan(101); + root.render(tree([])); + await expect.poll(() => rows().length).toBe(1); + expect(exitingRows()).toHaveLength(0); + expect(container.querySelector('[role="treegrid"]')!.scrollHeight).toBe(80); +}); + +it('does not clear an inert attribute supplied by the consumer', async () => { + root.render( + + + Child + + + ); + await waitFor(() => rows().length === 2, 'inert child mounts'); + expect(rows()[1]).toHaveAttribute('inert'); +}); + +it('does not retain rows when transitions are disabled for reduced motion', async () => { + style.textContent += '.animated-tree-item { transition: none; }'; + root.render(); + await waitFor(() => rows().length === 3, 'expanded rows mount'); + root.render(); + await expect.poll(() => rows().length).toBe(1); + root.render(); + await expect.poll(() => rows().slice(1).map(height)).toEqual([ROW_HEIGHT, ROW_HEIGHT]); + expect(rows().some(isAnimating)).toBe(false); +}); + +it('prepares intrinsic sizing only once when StrictMode replays effects', async () => { + root.render( + + + + ); + await waitFor(() => rows().length === 1, 'root mounts in StrictMode'); + root.render( + + + + ); + await waitFor(() => rows().length === 3 && rows().slice(1).every(isAnimating), 'children enter'); + let children = rows().slice(1); + expect(children.map(row => row.style.getPropertyValue('--tree-item-height'))).toEqual([ + '40px', + '40px' + ]); + seekToMiddle(children); + expect(children.map(height)).toEqual([20, 20]); + await finishAnimations(children); + await expect.poll(() => children.map(height)).toEqual([ROW_HEIGHT, ROW_HEIGHT]); +}); + +it('observes padding changes as well as content changes', async () => { + root.render(); + await waitFor(() => rows().length === 3, 'children mount'); + style.textContent += + '.animated-tree-item:not([data-entering]):not([data-exiting]) { padding-block: 15px; }'; + let children = rows().slice(1); + await waitFor(() => children.every(isAnimating), 'padding transitions start'); + await finishAnimations(children); + await waitFor( + () => children.every(row => height(row) === 60), + 'padding increases the resting height' + ); + root.render(); + await waitFor( + () => exitingRows().length === 2 && exitingRows().every(isAnimating), + 'children exit' + ); + seekToMiddle(children); + expect(children.map(height)).toEqual([30, 30]); +}); 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..b451a19c99c 100644 --- a/starters/docs/src/Tree.css +++ b/starters/docs/src/Tree.css @@ -69,6 +69,8 @@ align-items: center; gap: var(--spacing-2); min-height: var(--spacing-8); + 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; --padding: var(--spacing-4); @@ -79,11 +81,22 @@ 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); + &[data-entering], + &[data-exiting] { + 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..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 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-(--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: