From 142510ec23efa5eeafd956521ab83e5b0835efd1 Mon Sep 17 00:00:00 2001 From: mariana castro Date: Fri, 25 Sep 2026 17:08:32 -0300 Subject: [PATCH 1/5] fix: focus the closest visible row when the focused Tree/Table item is collapsed When the focused key pointed at an item under a collapsed ancestor (a selected item chosen by autoFocus or on Tab entry, or a focused row whose parent was collapsed programmatically), no row was rendered for it. DOM focus stayed on the collection, nothing in the collection was tabbable, and arrow keys navigated from the hidden item, skipping its ancestor. Tree and Table now move focus to the closest visible ancestor in that case, leaving the selection unchanged. Items that are being dragged are skipped so drag and drop keeps control of focus until the drop completes. The closestVisibleKey helper is shared with NavigationTree. Closes #10645 --- .../src/NavigationTree.tsx | 21 +- packages/react-aria-components/src/Table.tsx | 19 ++ packages/react-aria-components/src/Tree.tsx | 16 ++ packages/react-aria-components/src/utils.tsx | 29 +- .../react-aria-components/test/Tree.test.tsx | 265 ++++++++++++++++++ .../test/Treeble.test.js | 61 ++++ 6 files changed, 390 insertions(+), 21 deletions(-) diff --git a/packages/react-aria-components/src/NavigationTree.tsx b/packages/react-aria-components/src/NavigationTree.tsx index 443852272f9..fe17a430a51 100644 --- a/packages/react-aria-components/src/NavigationTree.tsx +++ b/packages/react-aria-components/src/NavigationTree.tsx @@ -13,6 +13,7 @@ import { ChildrenOrFunction, ClassNameOrFunction, + closestVisibleKey, ContextValue, dom, Provider, @@ -325,26 +326,6 @@ function findKeyForRoute(collection: Collection>, route: string): return null; } -// Walks up from `key` to the closest ancestor that is actually rendered (all ancestors expanded). -// Returns `key` unchanged when already visible. A collapsed ancestor hides everything beneath it, -// so the highest collapsed ancestor is the closest visible row. -function closestVisibleKey( - collection: Collection>, - expandedKeys: Set, - key: Key -): Key { - let target = key; - let node = collection.getItem(key); - while (node?.parentKey != null) { - let parent = collection.getItem(node.parentKey); - if (parent?.type === 'item' && !expandedKeys.has(node.parentKey)) { - target = node.parentKey; - } - node = parent; - } - return target; -} - // Moves the tree's focused key to the item matching selectedRoute. Runs when the route or the // collection changes; the shared syncedRouteRef dedupes across items so it fires once per change. function useRouteFocusSync({state}: {state: TreeState}): void { diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 08ddd8b0cdc..8c3d90a6dcf 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -18,6 +18,7 @@ import {ButtonContext} from './Button'; import {CheckboxContext, CheckboxFieldContext} from './Checkbox'; import { ClassNameOrFunction, + closestVisibleKey, ContextValue, DEFAULT_SLOT, dom, @@ -850,6 +851,24 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl isRootDropTarget = dropState.isDropTarget({type: 'root'}); } + // If the focused row is hidden inside a collapsed row, move focus to the closest visible + // ancestor row. Selection is unaffected. Rows being dragged are skipped so that focus can follow + // them once they are dropped. + useEffect(() => { + let node = + selectionState.focusedKey != null ? collection.getItem(selectionState.focusedKey) : null; + // A cell is visible whenever its row is. + let rowKey = node?.type === 'cell' ? node.parentKey : node?.key; + if (rowKey == null || dragState?.isDragging(rowKey)) { + return; + } + let visibleKey = closestVisibleKey(collection, expandedKeys, rowKey); + if (visibleKey !== rowKey) { + selectionState.setFocusedKey(visibleKey); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [collection, expandedKeys, selectionState.focusedKey]); + let {focusProps, isFocused, isFocusVisible} = useFocusRing(); let renderProps = useRenderProps({ ...props, diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index c2a05801f2b..9923acea906 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -24,6 +24,7 @@ import {CheckboxContext, CheckboxFieldContext} from './Checkbox'; import { ChildrenOrFunction, ClassNameOrFunction, + closestVisibleKey, ContextValue, DEFAULT_SLOT, dom, @@ -569,6 +570,21 @@ function TreeInner({props, collection, treeRef: ref}: TreeInnerProps) { isRootDropTarget = dropState.isDropTarget({type: 'root'}); } + // If the focused item is hidden inside a collapsed item, move focus to the closest visible + // ancestor. Selection is unaffected. Items being dragged are skipped so that focus can follow + // them once they are dropped. + useEffect(() => { + let focusedKey = state.selectionManager.focusedKey; + if (focusedKey == null || dragState?.isDragging(focusedKey)) { + return; + } + let visibleKey = closestVisibleKey(state.collection, expandedKeys, focusedKey); + if (visibleKey !== focusedKey) { + state.selectionManager.setFocusedKey(visibleKey); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.collection, expandedKeys, state.selectionManager.focusedKey]); + let isTreeDraggable = !!(hasDragHooks && !dragState?.isDisabled); let {focusProps, isFocused, isFocusVisible} = useFocusRing(); diff --git a/packages/react-aria-components/src/utils.tsx b/packages/react-aria-components/src/utils.tsx index 9c0ca0c142f..6d43db3c17e 100644 --- a/packages/react-aria-components/src/utils.tsx +++ b/packages/react-aria-components/src/utils.tsx @@ -10,7 +10,14 @@ * governing permissions and limitations under the License. */ -import {AriaLabelingProps, RefObject, DOMProps as SharedDOMProps} from '@react-types/shared'; +import { + AriaLabelingProps, + Collection, + Key, + Node, + RefObject, + DOMProps as SharedDOMProps +} from '@react-types/shared'; import {mergeProps} from 'react-aria/mergeProps'; import {mergeRefs} from 'react-aria/mergeRefs'; import React, { @@ -404,6 +411,26 @@ export function removeDataAttributes(props: T): T { return filteredProps; } +// Walks up from `key` to the closest ancestor that is actually rendered (all ancestors expanded). +// Returns `key` unchanged when already visible. A collapsed ancestor hides everything beneath it, +// so the highest collapsed ancestor is the closest visible row. +export function closestVisibleKey( + collection: Collection>, + expandedKeys: Set, + key: Key +): Key { + let target = key; + let node = collection.getItem(key); + while (node?.parentKey != null) { + let parent = collection.getItem(node.parentKey); + if (parent?.type === 'item' && !expandedKeys.has(node.parentKey)) { + target = node.parentKey; + } + node = parent; + } + return target; +} + // Override base type to change the default. export interface RACValidation { /** diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index c0087e78fd9..3d5a0a67ef0 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -1595,6 +1595,271 @@ describe('Tree', () => { }); }); + describe('focus on items inside collapsed items', () => { + let CollapsibleItem = props => ( + + + {({hasChildItems}) => ( + <> + {hasChildItems && } + {props.id} + + )} + + {props.children} + + ); + + let CollapsibleTree = props => ( + <> + + + + + + + + + + + + + ); + + let expectSingleTabStop = (tree: HTMLElement, row: HTMLElement) => { + expect(tree).toHaveAttribute('tabindex', '-1'); + for (let r of within(tree).getAllByRole('row')) { + expect(r).toHaveAttribute('tabindex', r === row ? '0' : '-1'); + } + }; + + it('should autoFocus the closest visible ancestor of a selected item inside a collapsed item', async () => { + let {getByRole, getAllByRole} = render( + + ); + let tree = getByRole('treegrid'); + let rows = getAllByRole('row'); + expect(rows.map(row => row.getAttribute('data-key'))).toEqual(['p1', 'p2', 'p3']); + expect(document.activeElement).toBe(rows[0]); + expectSingleTabStop(tree, rows[0]); + + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(rows[1]); + await user.keyboard('{ArrowUp}'); + expect(document.activeElement).toBe(rows[0]); + + // The hidden item stays selected. + await user.keyboard('{ArrowRight}'); + rows = getAllByRole('row'); + expect(rows[1]).toHaveAttribute('data-key', 'child'); + expect(rows[1]).toHaveAttribute('aria-selected', 'true'); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + + it('should focus the closest visible ancestor of a selected item when tabbing into the tree', async () => { + let {getByRole, getAllByRole, getByText} = render( + + ); + let tree = getByRole('treegrid'); + let rows = getAllByRole('row'); + + await user.tab(); + await user.tab(); + expect(document.activeElement).toBe(rows[0]); + expectSingleTabStop(tree, rows[0]); + + await user.tab(); + expect(document.activeElement).toBe(getByText('After')); + await user.tab({shift: true}); + expect(document.activeElement).toBe(rows[0]); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + + it('should focus the closest visible ancestor when several levels are collapsed', () => { + let {getByRole, getAllByRole} = render( + + ); + let rows = getAllByRole('row'); + expect(rows.map(row => row.getAttribute('data-key'))).toEqual(['p1', 'child', 'p2', 'p3']); + expect(document.activeElement).toBe(rows[1]); + expectSingleTabStop(getByRole('treegrid'), rows[1]); + }); + + it('should focus the tree when the closest visible ancestor is disabled', async () => { + let {getByRole, getAllByRole} = render( + + ); + let tree = getByRole('treegrid'); + expect(document.activeElement).toBe(tree); + expect(tree).toHaveAttribute('tabindex', '0'); + + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(getAllByRole('row')[1]); + }); + + it('should move focus to the closest visible ancestor when the focused item is collapsed externally', async () => { + function ControlledTree() { + let [expandedKeys, setExpandedKeys] = React.useState(new Set(['p1'])); + return ( + <> + + + + ); + } + + let {getByRole, getAllByRole, getByText} = render(); + let tree = getByRole('treegrid'); + + await user.tab(); + await user.tab(); + expect(document.activeElement).toBe(getAllByRole('row')[1]); + expect(document.activeElement).toHaveAttribute('data-key', 'child'); + + await user.click(getByText('Collapse all')); + let rows = getAllByRole('row'); + expect(rows.map(row => row.getAttribute('data-key'))).toEqual(['p1', 'p2', 'p3']); + expectSingleTabStop(tree, rows[0]); + + await user.tab({shift: true}); + await user.tab({shift: true}); + expect(document.activeElement).toBe(rows[0]); + + // The hidden item stays selected. + await user.keyboard('{ArrowRight}'); + expect(getAllByRole('row')[1]).toHaveAttribute('aria-selected', 'true'); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + + describe('virtualized', () => { + let clientWidth, clientHeight; + let items = Array.from({length: 30}, (_, i) => ({ + id: `item-${i}`, + childItems: i === 20 ? [{id: 'item-20-child', childItems: []}] : [] + })); + let renderItem = item => ( + + {renderItem} + + ); + + beforeAll(() => { + clientWidth = jest + .spyOn(window.HTMLElement.prototype, 'clientWidth', 'get') + .mockImplementation(() => 100); + clientHeight = jest + .spyOn(window.HTMLElement.prototype, 'clientHeight', 'get') + .mockImplementation(() => 100); + }); + + afterAll(function () { + clientWidth.mockReset(); + clientHeight.mockReset(); + }); + + it.each` + selectedKey | focusedKey + ${'item-25'} | ${'item-25'} + ${'item-20-child'} | ${'item-20'} + `( + 'should autoFocus $focusedKey when $selectedKey is selected and out of view', + ({selectedKey, focusedKey}) => { + let {getByRole} = render( + + + {renderItem} + + + ); + expect(document.activeElement).toHaveAttribute('data-key', focusedKey); + expect(getByRole('treegrid')).toHaveAttribute('tabindex', '-1'); + } + ); + }); + + it('should keep a visible tab stop when the drag source is collapsed during a keyboard drag', async () => { + function MovingTree() { + let tree = useTreeData({ + initialItems: rows, + getKey: item => item.id, + getChildren: item => item.childItems ?? [] + }); + let {dragAndDropHooks} = useDragAndDrop({ + getItems: keys => [...keys].map(key => ({'text/plain': String(key)})), + getAllowedDropOperations: () => ['move'], + onMove: () => {} + }); + let renderItem = item => ( + + + {({hasChildItems}) => ( + <> + + {hasChildItems && } + {item.value.name} + + )} + + {renderItem} + + ); + return ( + + {renderItem} + + ); + } + + let {getByRole, getAllByRole} = render(); + + await user.tab(); + await user.keyboard('{ArrowDown}{ArrowRight}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Drag Project 1'})); + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + for ( + let i = 0; + i < 8 && document.activeElement?.getAttribute('aria-label') !== 'Drop on Projects'; + i++ + ) { + await user.keyboard('{ArrowUp}'); + } + expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on Projects'); + + // Collapse the drag source's parent, then cancel the drag. + await user.keyboard('{ArrowLeft}'); + act(() => jest.runAllTimers()); + await user.keyboard('{Escape}'); + act(() => jest.runAllTimers()); + + let tree = getByRole('treegrid'); + let projects = getAllByRole('row').find(row => row.getAttribute('data-key') === 'projects')!; + expect(projects).toHaveAttribute('aria-expanded', 'false'); + expectSingleTabStop(tree, projects); + }); + }); + describe('empty state', () => { it('should allow the user to tab to the empty tree', async () => { let {getAllByRole, getByRole} = render( diff --git a/packages/react-aria-components/test/Treeble.test.js b/packages/react-aria-components/test/Treeble.test.js index 38c4eaf318f..d4a166012f6 100644 --- a/packages/react-aria-components/test/Treeble.test.js +++ b/packages/react-aria-components/test/Treeble.test.js @@ -559,6 +559,67 @@ describe('Treeble', () => { expect(document.activeElement).toBe(tester.getRows()[0]); }); + it('should focus the closest visible ancestor row when tabbing in with a selected row inside a collapsed row', async () => { + let onSelectionChange = jest.fn(); + let tree = render( + + ); + let tester = utils.createTester('Table', {root: tree.getByTestId('treeble')}); + let rows = tester.getRows(); + expect(rows).toHaveLength(4); + + await user.tab(); + expect(document.activeElement).toBe(rows[0]); + expect(rows[0]).toHaveAttribute('tabindex', '0'); + expect(tree.getByTestId('treeble')).toHaveAttribute('tabindex', '-1'); + + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(rows[1]); + + // The hidden row stays selected. + await user.keyboard('{ArrowUp}'); + await user.keyboard('{ArrowRight}'); + expect(tester.getRowHeaders()[1]).toHaveTextContent('Mario Kart'); + expect(tester.getRows()[1]).toHaveAttribute('aria-selected', 'true'); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + + it('should move focus to the closest visible ancestor row when a focused cell is collapsed externally', async () => { + function ControlledExample() { + let [expandedKeys, setExpandedKeys] = React.useState(new Set(['games'])); + return ( + <> + + + + ); + } + + let tree = render(); + let tester = utils.createTester('Table', {root: tree.getByTestId('treeble')}); + + await user.tab(); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(tester.getRowHeaders()[1]); + expect(tester.getRowHeaders()[1]).toHaveTextContent('Mario Kart'); + + await user.click(tree.getByText('Collapse all')); + let rows = tester.getRows(); + expect(rows).toHaveLength(4); + expect(rows[0]).toHaveAttribute('tabindex', '0'); + for (let row of rows.slice(1)) { + expect(row).toHaveAttribute('tabindex', '-1'); + } + + await user.tab({shift: true}); + expect(document.activeElement).toBe(rows[0]); + }); + it('supports selection', async () => { let onSelectionChange = jest.fn(); let tree = render( From fc7bd786ac92e49bc59c97b7262e6c2ce40a454a Mon Sep 17 00:00:00 2001 From: mariana castro Date: Fri, 25 Sep 2026 17:36:04 -0300 Subject: [PATCH 2/5] chore: remove unnecessary comments --- packages/react-aria-components/src/Table.tsx | 4 +--- packages/react-aria-components/src/Tree.tsx | 4 +--- packages/react-aria-components/src/utils.tsx | 4 +--- packages/react-aria-components/test/Tree.test.tsx | 3 --- packages/react-aria-components/test/Treeble.test.js | 1 - 5 files changed, 3 insertions(+), 13 deletions(-) diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 8c3d90a6dcf..fcb96bcc12c 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -851,9 +851,7 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl isRootDropTarget = dropState.isDropTarget({type: 'root'}); } - // If the focused row is hidden inside a collapsed row, move focus to the closest visible - // ancestor row. Selection is unaffected. Rows being dragged are skipped so that focus can follow - // them once they are dropped. + // Dragged rows keep focus so it can follow them when they are dropped. useEffect(() => { let node = selectionState.focusedKey != null ? collection.getItem(selectionState.focusedKey) : null; diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 9923acea906..cf61fee9957 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -570,9 +570,7 @@ function TreeInner({props, collection, treeRef: ref}: TreeInnerProps) { isRootDropTarget = dropState.isDropTarget({type: 'root'}); } - // If the focused item is hidden inside a collapsed item, move focus to the closest visible - // ancestor. Selection is unaffected. Items being dragged are skipped so that focus can follow - // them once they are dropped. + // Dragged items keep focus so it can follow them when they are dropped. useEffect(() => { let focusedKey = state.selectionManager.focusedKey; if (focusedKey == null || dragState?.isDragging(focusedKey)) { diff --git a/packages/react-aria-components/src/utils.tsx b/packages/react-aria-components/src/utils.tsx index 6d43db3c17e..c47fa3fd907 100644 --- a/packages/react-aria-components/src/utils.tsx +++ b/packages/react-aria-components/src/utils.tsx @@ -411,9 +411,7 @@ export function removeDataAttributes(props: T): T { return filteredProps; } -// Walks up from `key` to the closest ancestor that is actually rendered (all ancestors expanded). -// Returns `key` unchanged when already visible. A collapsed ancestor hides everything beneath it, -// so the highest collapsed ancestor is the closest visible row. +// A collapsed ancestor hides everything below it, so the highest one is the closest visible row. export function closestVisibleKey( collection: Collection>, expandedKeys: Set, diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index 3d5a0a67ef0..ae12f330266 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -1652,7 +1652,6 @@ describe('Tree', () => { await user.keyboard('{ArrowUp}'); expect(document.activeElement).toBe(rows[0]); - // The hidden item stays selected. await user.keyboard('{ArrowRight}'); rows = getAllByRole('row'); expect(rows[1]).toHaveAttribute('data-key', 'child'); @@ -1737,7 +1736,6 @@ describe('Tree', () => { await user.tab({shift: true}); expect(document.activeElement).toBe(rows[0]); - // The hidden item stays selected. await user.keyboard('{ArrowRight}'); expect(getAllByRole('row')[1]).toHaveAttribute('aria-selected', 'true'); expect(onSelectionChange).not.toHaveBeenCalled(); @@ -1847,7 +1845,6 @@ describe('Tree', () => { } expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on Projects'); - // Collapse the drag source's parent, then cancel the drag. await user.keyboard('{ArrowLeft}'); act(() => jest.runAllTimers()); await user.keyboard('{Escape}'); diff --git a/packages/react-aria-components/test/Treeble.test.js b/packages/react-aria-components/test/Treeble.test.js index d4a166012f6..3c328e954af 100644 --- a/packages/react-aria-components/test/Treeble.test.js +++ b/packages/react-aria-components/test/Treeble.test.js @@ -580,7 +580,6 @@ describe('Treeble', () => { await user.keyboard('{ArrowDown}'); expect(document.activeElement).toBe(rows[1]); - // The hidden row stays selected. await user.keyboard('{ArrowUp}'); await user.keyboard('{ArrowRight}'); expect(tester.getRowHeaders()[1]).toHaveTextContent('Mario Kart'); From eb0c8864fcb8d7f87e226902e627a782c0a1cf0c Mon Sep 17 00:00:00 2001 From: mariana castro Date: Fri, 25 Sep 2026 17:50:52 -0300 Subject: [PATCH 3/5] test: skip the keyboard drag collapse test on React 16 --- .../react-aria-components/test/Tree.test.tsx | 126 +++++++++--------- 1 file changed, 66 insertions(+), 60 deletions(-) diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index ae12f330266..9c41d66462e 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -1792,69 +1792,75 @@ describe('Tree', () => { ); }); - it('should keep a visible tab stop when the drag source is collapsed during a keyboard drag', async () => { - function MovingTree() { - let tree = useTreeData({ - initialItems: rows, - getKey: item => item.id, - getChildren: item => item.childItems ?? [] - }); - let {dragAndDropHooks} = useDragAndDrop({ - getItems: keys => [...keys].map(key => ({'text/plain': String(key)})), - getAllowedDropOperations: () => ['move'], - onMove: () => {} - }); - let renderItem = item => ( - - - {({hasChildItems}) => ( - <> - - {hasChildItems && } - {item.value.name} - - )} - - {renderItem} - - ); - return ( - - {renderItem} - - ); - } + // React 16 warns when the cancelled drag updates the unmounted drag source (#10599). + (parseInt(React.version, 10) >= 17 ? it : it.skip)( + 'should keep a visible tab stop when the drag source is collapsed during a keyboard drag', + async () => { + function MovingTree() { + let tree = useTreeData({ + initialItems: rows, + getKey: item => item.id, + getChildren: item => item.childItems ?? [] + }); + let {dragAndDropHooks} = useDragAndDrop({ + getItems: keys => [...keys].map(key => ({'text/plain': String(key)})), + getAllowedDropOperations: () => ['move'], + onMove: () => {} + }); + let renderItem = item => ( + + + {({hasChildItems}) => ( + <> + + {hasChildItems && } + {item.value.name} + + )} + + {renderItem} + + ); + return ( + + {renderItem} + + ); + } - let {getByRole, getAllByRole} = render(); + let {getByRole, getAllByRole} = render(); - await user.tab(); - await user.keyboard('{ArrowDown}{ArrowRight}'); - expect(document.activeElement).toBe(getByRole('button', {name: 'Drag Project 1'})); - await user.keyboard('{Enter}'); - act(() => jest.runAllTimers()); - for ( - let i = 0; - i < 8 && document.activeElement?.getAttribute('aria-label') !== 'Drop on Projects'; - i++ - ) { - await user.keyboard('{ArrowUp}'); + await user.tab(); + await user.keyboard('{ArrowDown}{ArrowRight}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Drag Project 1'})); + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + for ( + let i = 0; + i < 8 && document.activeElement?.getAttribute('aria-label') !== 'Drop on Projects'; + i++ + ) { + await user.keyboard('{ArrowUp}'); + } + expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on Projects'); + + await user.keyboard('{ArrowLeft}'); + act(() => jest.runAllTimers()); + await user.keyboard('{Escape}'); + act(() => jest.runAllTimers()); + + let tree = getByRole('treegrid'); + let projects = getAllByRole('row').find( + row => row.getAttribute('data-key') === 'projects' + )!; + expect(projects).toHaveAttribute('aria-expanded', 'false'); + expectSingleTabStop(tree, projects); } - expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on Projects'); - - await user.keyboard('{ArrowLeft}'); - act(() => jest.runAllTimers()); - await user.keyboard('{Escape}'); - act(() => jest.runAllTimers()); - - let tree = getByRole('treegrid'); - let projects = getAllByRole('row').find(row => row.getAttribute('data-key') === 'projects')!; - expect(projects).toHaveAttribute('aria-expanded', 'false'); - expectSingleTabStop(tree, projects); - }); + ); }); describe('empty state', () => { From 6dc3be993e8c19245190d21ccb96569409b80c5c Mon Sep 17 00:00:00 2001 From: mariana castro Date: Sun, 27 Sep 2026 08:28:04 -0300 Subject: [PATCH 4/5] test: cover moving the focused key to a hidden Tree item --- .../react-aria-components/test/Tree.test.tsx | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index 9c41d66462e..bd168d52009 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -37,7 +37,8 @@ import { TreeItem, TreeItemContent, TreeLoadMoreItem, - TreeSection + TreeSection, + TreeStateContext } from '../src/Tree'; import {User} from '@react-aria/test-utils'; import userEvent from '@testing-library/user-event'; @@ -1741,6 +1742,43 @@ describe('Tree', () => { expect(onSelectionChange).not.toHaveBeenCalled(); }); + it('should move focus to the closest visible ancestor when the focused key is set to a hidden item', async () => { + let state; + let StateReader = ({onState}) => { + let treeState = React.useContext(TreeStateContext); + React.useEffect(() => { + onState(treeState); + }); + return null; + }; + + let {getByRole, getAllByRole} = render( + + + + + + + p2 + (state = s)} /> + + + + ); + let tree = getByRole('treegrid'); + let rows = getAllByRole('row'); + + await user.tab(); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(rows[1]); + + act(() => state.selectionManager.setFocusedKey('child')); + rows = getAllByRole('row'); + expect(rows.map(row => row.getAttribute('data-key'))).toEqual(['p1', 'p2']); + expect(document.activeElement).toBe(rows[0]); + expectSingleTabStop(tree, rows[0]); + }); + describe('virtualized', () => { let clientWidth, clientHeight; let items = Array.from({length: 30}, (_, i) => ({ From c7e2c631846d9726f1084a26bf28f2a52fa17fa5 Mon Sep 17 00:00:00 2001 From: mariana castro Date: Tue, 29 Sep 2026 08:57:42 -0300 Subject: [PATCH 5/5] test: assert focus behavior instead of tabindex in collapsed-item tests --- .../react-aria-components/test/Tree.test.tsx | 67 ++++++++----------- .../test/Treeble.test.js | 6 -- 2 files changed, 29 insertions(+), 44 deletions(-) diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index bd168d52009..ed3c392dabf 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -1631,22 +1631,11 @@ describe('Tree', () => { ); - let expectSingleTabStop = (tree: HTMLElement, row: HTMLElement) => { - expect(tree).toHaveAttribute('tabindex', '-1'); - for (let r of within(tree).getAllByRole('row')) { - expect(r).toHaveAttribute('tabindex', r === row ? '0' : '-1'); - } - }; - it('should autoFocus the closest visible ancestor of a selected item inside a collapsed item', async () => { - let {getByRole, getAllByRole} = render( - - ); - let tree = getByRole('treegrid'); + let {getAllByRole} = render(); let rows = getAllByRole('row'); expect(rows.map(row => row.getAttribute('data-key'))).toEqual(['p1', 'p2', 'p3']); expect(document.activeElement).toBe(rows[0]); - expectSingleTabStop(tree, rows[0]); await user.keyboard('{ArrowDown}'); expect(document.activeElement).toBe(rows[1]); @@ -1661,16 +1650,12 @@ describe('Tree', () => { }); it('should focus the closest visible ancestor of a selected item when tabbing into the tree', async () => { - let {getByRole, getAllByRole, getByText} = render( - - ); - let tree = getByRole('treegrid'); + let {getAllByRole, getByText} = render(); let rows = getAllByRole('row'); await user.tab(); await user.tab(); expect(document.activeElement).toBe(rows[0]); - expectSingleTabStop(tree, rows[0]); await user.tab(); expect(document.activeElement).toBe(getByText('After')); @@ -1680,7 +1665,7 @@ describe('Tree', () => { }); it('should focus the closest visible ancestor when several levels are collapsed', () => { - let {getByRole, getAllByRole} = render( + let {getAllByRole} = render( { let rows = getAllByRole('row'); expect(rows.map(row => row.getAttribute('data-key'))).toEqual(['p1', 'child', 'p2', 'p3']); expect(document.activeElement).toBe(rows[1]); - expectSingleTabStop(getByRole('treegrid'), rows[1]); }); it('should focus the tree when the closest visible ancestor is disabled', async () => { - let {getByRole, getAllByRole} = render( + let {getByRole, getAllByRole, getByText} = render( ); let tree = getByRole('treegrid'); expect(document.activeElement).toBe(tree); - expect(tree).toHaveAttribute('tabindex', '0'); + + await user.tab(); + expect(document.activeElement).toBe(getByText('After')); + await user.tab({shift: true}); + expect(document.activeElement).toBe(tree); await user.keyboard('{ArrowDown}'); expect(document.activeElement).toBe(getAllByRole('row')[1]); @@ -1720,8 +1708,7 @@ describe('Tree', () => { ); } - let {getByRole, getAllByRole, getByText} = render(); - let tree = getByRole('treegrid'); + let {getAllByRole, getByText} = render(); await user.tab(); await user.tab(); @@ -1731,7 +1718,6 @@ describe('Tree', () => { await user.click(getByText('Collapse all')); let rows = getAllByRole('row'); expect(rows.map(row => row.getAttribute('data-key'))).toEqual(['p1', 'p2', 'p3']); - expectSingleTabStop(tree, rows[0]); await user.tab({shift: true}); await user.tab({shift: true}); @@ -1743,16 +1729,23 @@ describe('Tree', () => { }); it('should move focus to the closest visible ancestor when the focused key is set to a hidden item', async () => { - let state; - let StateReader = ({onState}) => { - let treeState = React.useContext(TreeStateContext); + let FocusItemAfterDelay = ({id}) => { + let state = React.useContext(TreeStateContext)!; + let [hasFocused, setHasFocused] = React.useState(false); React.useEffect(() => { - onState(treeState); - }); + if (hasFocused) { + return; + } + let timeout = setTimeout(() => { + state.selectionManager.setFocusedKey(id); + setHasFocused(true); + }, 1000); + return () => clearTimeout(timeout); + }, [hasFocused, id, state]); return null; }; - let {getByRole, getAllByRole} = render( + let {getAllByRole} = render( @@ -1760,23 +1753,21 @@ describe('Tree', () => { p2 - (state = s)} /> + ); - let tree = getByRole('treegrid'); let rows = getAllByRole('row'); await user.tab(); await user.keyboard('{ArrowDown}'); expect(document.activeElement).toBe(rows[1]); - act(() => state.selectionManager.setFocusedKey('child')); + act(() => jest.advanceTimersByTime(1000)); rows = getAllByRole('row'); expect(rows.map(row => row.getAttribute('data-key'))).toEqual(['p1', 'p2']); expect(document.activeElement).toBe(rows[0]); - expectSingleTabStop(tree, rows[0]); }); describe('virtualized', () => { @@ -1812,7 +1803,7 @@ describe('Tree', () => { `( 'should autoFocus $focusedKey when $selectedKey is selected and out of view', ({selectedKey, focusedKey}) => { - let {getByRole} = render( + render( { ); expect(document.activeElement).toHaveAttribute('data-key', focusedKey); - expect(getByRole('treegrid')).toHaveAttribute('tabindex', '-1'); } ); }); @@ -1891,12 +1881,13 @@ describe('Tree', () => { await user.keyboard('{Escape}'); act(() => jest.runAllTimers()); - let tree = getByRole('treegrid'); let projects = getAllByRole('row').find( row => row.getAttribute('data-key') === 'projects' )!; expect(projects).toHaveAttribute('aria-expanded', 'false'); - expectSingleTabStop(tree, projects); + + await user.tab(); + expect(document.activeElement).toBe(projects); } ); }); diff --git a/packages/react-aria-components/test/Treeble.test.js b/packages/react-aria-components/test/Treeble.test.js index 3c328e954af..b4b6c82c860 100644 --- a/packages/react-aria-components/test/Treeble.test.js +++ b/packages/react-aria-components/test/Treeble.test.js @@ -574,8 +574,6 @@ describe('Treeble', () => { await user.tab(); expect(document.activeElement).toBe(rows[0]); - expect(rows[0]).toHaveAttribute('tabindex', '0'); - expect(tree.getByTestId('treeble')).toHaveAttribute('tabindex', '-1'); await user.keyboard('{ArrowDown}'); expect(document.activeElement).toBe(rows[1]); @@ -610,10 +608,6 @@ describe('Treeble', () => { await user.click(tree.getByText('Collapse all')); let rows = tester.getRows(); expect(rows).toHaveLength(4); - expect(rows[0]).toHaveAttribute('tabindex', '0'); - for (let row of rows.slice(1)) { - expect(row).toHaveAttribute('tabindex', '-1'); - } await user.tab({shift: true}); expect(document.activeElement).toBe(rows[0]);