From 4a2eea06643e245542376df8d7087edff459b91b Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Sat, 14 Mar 2026 07:06:20 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Speed=20up=20tree=20navigation=20by=20r?= =?UTF-8?q?eturning=20to=20parent=20when=20collapsing=E2=80=A6=20(#9547)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Allow returning to treeitem parent when collapsing a non-collapsible item * make navigation default behavior * docs: Rework copy for tree kb nav documentation --------- Co-authored-by: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> --- .../gridlist/src/useGridListItem.ts | 19 +++++++-- .../@react-stately/tree/src/useTreeState.ts | 20 ++++++++-- packages/react-aria-components/docs/Tree.mdx | 8 ++++ .../react-aria-components/test/Tree.test.tsx | 40 ++++++++++++++++++- 4 files changed, 79 insertions(+), 8 deletions(-) diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts index 04873c2cee0..1b398e9505f 100644 --- a/packages/@react-aria/gridlist/src/useGridListItem.ts +++ b/packages/@react-aria/gridlist/src/useGridListItem.ts @@ -148,10 +148,21 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt state.toggleKey(node.key); e.stopPropagation(); return; - } else if ((e.key === EXPANSION_KEYS['collapse'][direction]) && state.selectionManager.focusedKey === node.key && hasChildRows && state.expandedKeys.has(node.key)) { - state.toggleKey(node.key); - e.stopPropagation(); - return; + } else if ((e.key === EXPANSION_KEYS['collapse'][direction]) && state.selectionManager.focusedKey === node.key) { + // If item is collapsible, collapse it; else move to parent + if (hasChildRows && state.expandedKeys.has(node.key)) { + state.toggleKey(node.key); + e.stopPropagation(); + return; + } else if ( + !state.expandedKeys.has(node.key) && + node.parentKey + ) { + // Item is a leaf or already collapsed, move focus to parent + state.selectionManager.setFocusedKey(node.parentKey); + e.stopPropagation(); + return; + } } } diff --git a/packages/@react-stately/tree/src/useTreeState.ts b/packages/@react-stately/tree/src/useTreeState.ts index c454a13a9fe..eab0f368a79 100644 --- a/packages/@react-stately/tree/src/useTreeState.ts +++ b/packages/@react-stately/tree/src/useTreeState.ts @@ -10,14 +10,28 @@ * governing permissions and limitations under the License. */ -import {Collection, CollectionStateBase, DisabledBehavior, Expandable, Key, MultipleSelection, Node} from '@react-types/shared'; -import {SelectionManager, useMultipleSelectionState} from '@react-stately/selection'; +import { + Collection, + CollectionStateBase, + DisabledBehavior, + Expandable, + Key, + MultipleSelection, + Node +} from '@react-types/shared'; +import { + SelectionManager, + useMultipleSelectionState +} from '@react-stately/selection'; import {TreeCollection} from './TreeCollection'; import {useCallback, useEffect, useMemo} from 'react'; import {useCollection} from '@react-stately/collections'; import {useControlledState} from '@react-stately/utils'; -export interface TreeProps extends CollectionStateBase, Expandable, MultipleSelection { +export interface TreeProps + extends CollectionStateBase, + Expandable, + MultipleSelection { /** Whether `disabledKeys` applies to all interactions, or only selection. */ disabledBehavior?: DisabledBehavior } diff --git a/packages/react-aria-components/docs/Tree.mdx b/packages/react-aria-components/docs/Tree.mdx index 992f9d623bc..e87f87cb844 100644 --- a/packages/react-aria-components/docs/Tree.mdx +++ b/packages/react-aria-components/docs/Tree.mdx @@ -702,6 +702,14 @@ Tree items may also be links to another page or website. This can be achieved by The `` component works with frameworks and client side routers like [Next.js](https://nextjs.org/) and [React Router](https://reactrouter.com/en/main). As with other React Aria components that support links, this works via the component at the root of your app. See the [client side routing guide](routing.html) to learn how to set this up. +## Keyboard navigation + +Navigation within the tree and within individual item actions share two keyboard keys. + +The "expand" key ( in LTR, in RTL) expands a collapsed item, and the "collapse" key ( in LTR, in RTL) collapses an item, or navigates to its parent if the item is already collapsed. + +The same keys are used to navigate between the actions within tree items. When an item has actions and is not expandable, pressing the expand key navigates to the next action, and pressing the collapse key navigates to the previous action. When focus returns to the tree item itself, pressing the collapse key again collapses the item. + ## Disabled items A `TreeItem` can be disabled with the `isDisabled` prop. This will disable all interactions on the item diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index 9f5c483ffec..6346062331b 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -917,6 +917,44 @@ describe('Tree', () => { expect(rows[12]).toHaveAttribute('aria-label', 'Reports'); }); + it('should support collapse key to navigate to parent', async () => { + let {getAllByRole} = render(); + await user.tab(); + let rows = getAllByRole('row'); + expect(rows).toHaveLength(20); + expect(document.activeElement).toBe(rows[0]); + expect(document.activeElement).toHaveAttribute('data-expanded', 'true'); + + // Navigate down to Project 2B + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(rows[4]); + expect(document.activeElement).toHaveAttribute('aria-label', 'Project 2B'); + + // Collapse key on leaf node should move focus to parent (Projects) + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(rows[2]); + expect(document.activeElement).toHaveAttribute('aria-label', 'Project 2'); + expect(document.activeElement).toHaveAttribute('data-expanded', 'true'); + + // Collapse key on expanded parent should collapse it + await user.keyboard('{ArrowLeft}'); + // Projects should now be collapsed, so fewer rows visible + rows = getAllByRole('row'); + expect(rows.length).toBeLessThan(20); + expect(document.activeElement).toBe(rows[2]); + expect(document.activeElement).toHaveAttribute('aria-label', 'Project 2'); + expect(document.activeElement).not.toHaveAttribute('data-expanded'); + + // Collapse key again on now-collapsed parent should move to its parent + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(rows[0]); + expect(document.activeElement).toHaveAttribute('aria-label', 'Projects'); + }); + it('should navigate between visible rows when using Arrow Up/Down', async () => { let {getAllByRole} = render(); await user.tab(); @@ -1961,7 +1999,7 @@ describe('Tree', () => { let {getByRole} = render(); let gridListTester = testUtilUser.createTester('GridList', {root: getByRole('treegrid')}); await gridListTester.triggerRowAction({row: 1, interactionType}); - + expect(onAction).toHaveBeenCalledTimes(1); expect(onPressStart).toHaveBeenCalledTimes(1); expect(onPressEnd).toHaveBeenCalledTimes(1);