Skip to content
Merged
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 @@ -148,10 +148,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

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 @@ -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(<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 @@ -1961,7 +1999,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
Loading