Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions packages/@react-aria/gridlist/src/useGridListItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,21 @@ export function useGridListItem<T>(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;
}
}
}

Expand Down
20 changes: 17 additions & 3 deletions packages/@react-stately/tree/src/useTreeState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> extends CollectionStateBase<T>, Expandable, MultipleSelection {
export interface TreeProps<T>
extends CollectionStateBase<T>,
Expandable,
MultipleSelection {
/** Whether `disabledKeys` applies to all interactions, or only selection. */
disabledBehavior?: DisabledBehavior
}
Expand Down
8 changes: 8 additions & 0 deletions packages/react-aria-components/docs/Tree.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,14 @@ Tree items may also be links to another page or website. This can be achieved by

The `<TreeItem>` 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 <TypeLink links={docs.links} type={docs.exports.RouterProvider} /> component at the root of your app. See the [client side routing guide](routing.html) to learn how to set this up.

## Keyboard navigation
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question to the team, do we want a prop for this, or should this just be the default for all trees?

Copy link
Member

@yihuiliao yihuiliao Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems reasonable to make it the default behavior. do we even need this section if that is the case?

edit: i guess it could be nice to set up the expectations of what the keyboard navigation is especially if it has changed

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm very happy to make it default!

I'll skip the prop and rework the doc to only mention that cycling through to the previous action uses the same keyboard shortcut as collapsing once all actions have been cycled through.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yihuiliao thanks for making the necessary changes! I was OOO for two weeks but intended to come back and finish the job :) I appreciate you helping to move the PR forward!


Navigation within the tree and within individual item actions share two keyboard keys.

The "expand" key (<Keyboard>→</Keyboard> in LTR, <Keyboard>←</Keyboard> in RTL) expands a collapsed item, and the "collapse" key (<Keyboard>←</Keyboard> in LTR, <Keyboard>→</Keyboard> 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
Expand Down
40 changes: 39 additions & 1 deletion packages/react-aria-components/test/Tree.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<DynamicTree />);
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(<DynamicTree />);
await user.tab();
Expand Down Expand Up @@ -1884,7 +1922,7 @@ describe('Tree', () => {
let {getByRole} = render(<StaticTree rowProps={{onAction, onPressStart, onPressEnd, onPress, onClick}} />);
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);
Expand Down