From 4cb6333beaa6305888a037e89884b4bc146d4877 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Fri, 30 Jan 2026 09:25:16 +0100 Subject: [PATCH] feat: Allow returning to treeitem parent when collapsing a non-collapsible item --- .../gridlist/src/useGridListItem.ts | 20 ++++++++-- .../@react-stately/tree/src/useTreeState.ts | 39 ++++++++++++++---- packages/react-aria-components/docs/Tree.mdx | 20 ++++++++++ packages/react-aria-components/src/Tree.tsx | 7 +++- .../stories/Tree.stories.tsx | 9 +++++ .../react-aria-components/test/Tree.test.tsx | 40 ++++++++++++++++++- 6 files changed, 122 insertions(+), 13 deletions(-) diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts index 6a1bd8e27c1..d979a6c9e0a 100644 --- a/packages/@react-aria/gridlist/src/useGridListItem.ts +++ b/packages/@react-aria/gridlist/src/useGridListItem.ts @@ -143,10 +143,22 @@ 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.shouldNavigateToCollapsibleParent && + !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..cebc5d49ebd 100644 --- a/packages/@react-stately/tree/src/useTreeState.ts +++ b/packages/@react-stately/tree/src/useTreeState.ts @@ -10,16 +10,33 @@ * 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 + disabledBehavior?: DisabledBehavior, + + /** Whether collapsing a non-collapsing item should navigate to its collapsible parent. */ + shouldNavigateToCollapsibleParent?: boolean } export interface TreeState { /** A collection of items in the tree. */ @@ -38,7 +55,13 @@ export interface TreeState { setExpandedKeys(keys: Set): void, /** A selection manager to read and update multiple selection state. */ - readonly selectionManager: SelectionManager + readonly selectionManager: SelectionManager, + + /** + * Whether collapsing a non-collapsing item should navigate to its collapsible parent. + * @default false + */ + shouldNavigateToCollapsibleParent?: boolean } /** @@ -47,7 +70,8 @@ export interface TreeState { */ export function useTreeState(props: TreeProps): TreeState { let { - onExpandedChange + onExpandedChange, + shouldNavigateToCollapsibleParent = false } = props; let [expandedKeys, setExpandedKeys] = useControlledState( @@ -81,7 +105,8 @@ export function useTreeState(props: TreeProps): TreeState` 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 + +By default, pressing the collapse key ( in LTR, in RTL) on an expanded item will collapse it. The key will do nothing on non-collapsible items. The same key is used to navigate between the actions within tree items. + +The `shouldNavigateToCollapsibleParent` prop enables a faster navigation behavior: when the collapse key is pressed on a leaf item or an already collapsed parent, focus moves to that item's parent. This helps users quickly navigate up the tree without needing to manually navigate to each parent item. But it has a trade-off: users can no longer use that key to cycle through actions on the current item. + +```tsx example + +``` + +With this prop enabled: +- Pressing collapse on a leaf item moves focus to its parent +- Pressing collapse on an expanded item collapses it +- Pressing collapse again on a collapsed item moves focus to its parent + ## 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/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 53a0794e324..a326b934ded 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -167,7 +167,12 @@ export interface TreeProps extends Omit, 'children'>, Multip */ disabledBehavior?: DisabledBehavior, /** The drag and drop hooks returned by `useDragAndDrop` used to enable drag and drop behavior for the Tree. */ - dragAndDropHooks?: DragAndDropHooks> + dragAndDropHooks?: DragAndDropHooks>, + /** + * Whether pressing the collapse key should navigate to the nearest collapsible parent. + * @default false + */ + shouldNavigateToCollapsibleParent?: boolean } diff --git a/packages/react-aria-components/stories/Tree.stories.tsx b/packages/react-aria-components/stories/Tree.stories.tsx index f086ed99b07..aab575a0525 100644 --- a/packages/react-aria-components/stories/Tree.stories.tsx +++ b/packages/react-aria-components/stories/Tree.stories.tsx @@ -457,6 +457,15 @@ export const WithActions: StoryObj = { name: 'Tree with actions' }; +export const NavToNearestCollapsibleParent: StoryObj = { + ...TreeExampleDynamic, + args: { + onAction: action('onAction'), + shouldNavigateToCollapsibleParent: true, + ...TreeExampleDynamic.args + } +}; + const WithLinksRender = (args: TreeProps): JSX.Element => { let treeData = useTreeData({ initialItems: rows, diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index 69636a8b7af..b4af5ee8a0e 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -840,6 +840,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(); @@ -1884,7 +1922,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);