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
20 changes: 16 additions & 4 deletions packages/@react-aria/gridlist/src/useGridListItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,22 @@ 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.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;
}
}
}

Expand Down
39 changes: 32 additions & 7 deletions packages/@react-stately/tree/src/useTreeState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<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
disabledBehavior?: DisabledBehavior,

/** Whether collapsing a non-collapsing item should navigate to its collapsible parent. */
shouldNavigateToCollapsibleParent?: boolean
}
export interface TreeState<T> {
/** A collection of items in the tree. */
Expand All @@ -38,7 +55,13 @@ export interface TreeState<T> {
setExpandedKeys(keys: Set<Key>): 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
}

/**
Expand All @@ -47,7 +70,8 @@ export interface TreeState<T> {
*/
export function useTreeState<T extends object>(props: TreeProps<T>): TreeState<T> {
let {
onExpandedChange
onExpandedChange,
shouldNavigateToCollapsibleParent = false
} = props;

let [expandedKeys, setExpandedKeys] = useControlledState(
Expand Down Expand Up @@ -81,7 +105,8 @@ export function useTreeState<T extends object>(props: TreeProps<T>): TreeState<T
disabledKeys,
toggleKey: onToggle,
setExpandedKeys,
selectionManager: new SelectionManager(tree, selectionState)
selectionManager: new SelectionManager(tree, selectionState),
shouldNavigateToCollapsibleParent
};
}

Expand Down
20 changes: 20 additions & 0 deletions packages/react-aria-components/docs/Tree.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,26 @@ 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?


By default, pressing the collapse key (<Keyboard>←</Keyboard> in LTR, <Keyboard>→</Keyboard> 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.
Copy link
Member

Choose a reason for hiding this comment

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

This isn't really accurate, you can still use the arrow keys to cycle through actions, it's only if the focus is on the row, it won't wrap to the end of the actions. But if you're among the actions already, then it works just fine.


```tsx example
<FileTree
shouldNavigateToCollapsibleParent
selectionMode="single"
defaultExpandedKeys={['1', '2']}
defaultSelectedKeys={['5']}
/>
```

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
Comment on lines +720 to +722
Copy link
Member

Choose a reason for hiding this comment

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

what does "pressing" mean here? clicking on the arrow with your mouse, the keyboard arrow keys?
I don't really have a better suggestion yet, maybe we can just remove this section


## Disabled items

A `TreeItem` can be disabled with the `isDisabled` prop. This will disable all interactions on the item
Expand Down
7 changes: 6 additions & 1 deletion packages/react-aria-components/src/Tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,12 @@ export interface TreeProps<T> extends Omit<AriaTreeProps<T>, 'children'>, Multip
*/
disabledBehavior?: DisabledBehavior,
/** The drag and drop hooks returned by `useDragAndDrop` used to enable drag and drop behavior for the Tree. */
dragAndDropHooks?: DragAndDropHooks<NoInfer<T>>
dragAndDropHooks?: DragAndDropHooks<NoInfer<T>>,
/**
* Whether pressing the collapse key should navigate to the nearest collapsible parent.
* @default false
*/
shouldNavigateToCollapsibleParent?: boolean
}


Expand Down
9 changes: 9 additions & 0 deletions packages/react-aria-components/stories/Tree.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,15 @@ export const WithActions: StoryObj<typeof TreeExampleDynamicRender> = {
name: 'Tree with actions'
};

export const NavToNearestCollapsibleParent: StoryObj<typeof TreeExampleDynamicRender> = {
...TreeExampleDynamic,
args: {
onAction: action('onAction'),
shouldNavigateToCollapsibleParent: true,
...TreeExampleDynamic.args
}
};

const WithLinksRender = <T extends object>(args: TreeProps<T>): JSX.Element => {
let treeData = useTreeData<any>({
initialItems: rows,
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 treeProps={{shouldNavigateToCollapsibleParent: true}} />);
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