diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts index d6280b13874..0b13820095e 100644 --- a/packages/@react-aria/gridlist/src/useGridListItem.ts +++ b/packages/@react-aria/gridlist/src/useGridListItem.ts @@ -144,10 +144,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..c6d5a893efd 100644 --- a/packages/react-aria-components/docs/Tree.mdx +++ b/packages/react-aria-components/docs/Tree.mdx @@ -702,6 +702,26 @@ 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 + +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/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index 69636a8b7af..f61122c11ff 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);