diff --git a/packages/dev/s2-docs/pages/react-aria/Tree.mdx b/packages/dev/s2-docs/pages/react-aria/Tree.mdx index 590a4ce1c26..6412f012b6a 100644 --- a/packages/dev/s2-docs/pages/react-aria/Tree.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Tree.mdx @@ -367,6 +367,41 @@ 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 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. + ## 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 +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 85e47c5aaa0..5ad15eee198 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,19 @@ 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; + useTreeItemHeight(ref, 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 +974,8 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent( selectionMode, selectionBehavior, isFocusVisibleWithin, + isEntering, + isExiting, state, id: item.key, allowsDragging: !!dragState, @@ -868,6 +990,8 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent( hasChildItems, level, isFocusVisibleWithin, + isEntering, + isExiting, state, item.key, dragState, @@ -991,6 +1115,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 @@ -1316,6 +1446,126 @@ 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 + * 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 restingHeight = 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 + ); + } + + 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. + 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 = 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) { + 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; @@ -1328,3 +1578,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..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..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..af2494a6326 --- /dev/null +++ b/packages/react-aria-components/test/TreeAnimations.browser.test.tsx @@ -0,0 +1,202 @@ +/* + * 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. +// +// 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'; +import {createRoot, Root} from 'react-dom/client'; +import React from 'react'; +import {Tree, TreeItem, TreeItemContent} from '../src/Tree'; + +const DURATION = 5000; +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: ${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}: {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 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)); + +/** + * 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(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'); + 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' + ); + + // The rows are still mounted and collapsing, but the tree already reports itself collapsed. + let exiting = exitingRows(); + // The row sizes to its content, so it can only animate because the Tree published a height for it. + 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' + ); + + root.render(); + await waitFor(() => exitingRows().length === 0, 'the exiting state is cleared'); + + expect(rows()).toHaveLength(3); + 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 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' + ); + + // 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); + + await finishAnimations(rows()); + await waitFor( + () => rows().every(row => height(row) === ROW_HEIGHT), + 'the revealed rows reach full height' + ); +}); 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..3075815cfa4 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); + /* 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; --padding: var(--spacing-4); @@ -79,11 +82,25 @@ 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. 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] { + 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: