Skip to content

Commit 4a2eea0

Browse files
Sidnioulzyihuiliao
andauthored
feat: Speed up tree navigation by returning to parent when collapsing… (adobe#9547)
* feat: Allow returning to treeitem parent when collapsing a non-collapsible item * make navigation default behavior * docs: Rework copy for tree kb nav documentation --------- Co-authored-by: Yihui Liao <44729383+yihuiliao@users.noreply.github.com>
1 parent 87173ad commit 4a2eea0

File tree

4 files changed

+79
-8
lines changed

4 files changed

+79
-8
lines changed

packages/@react-aria/gridlist/src/useGridListItem.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,21 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
148148
state.toggleKey(node.key);
149149
e.stopPropagation();
150150
return;
151-
} else if ((e.key === EXPANSION_KEYS['collapse'][direction]) && state.selectionManager.focusedKey === node.key && hasChildRows && state.expandedKeys.has(node.key)) {
152-
state.toggleKey(node.key);
153-
e.stopPropagation();
154-
return;
151+
} else if ((e.key === EXPANSION_KEYS['collapse'][direction]) && state.selectionManager.focusedKey === node.key) {
152+
// If item is collapsible, collapse it; else move to parent
153+
if (hasChildRows && state.expandedKeys.has(node.key)) {
154+
state.toggleKey(node.key);
155+
e.stopPropagation();
156+
return;
157+
} else if (
158+
!state.expandedKeys.has(node.key) &&
159+
node.parentKey
160+
) {
161+
// Item is a leaf or already collapsed, move focus to parent
162+
state.selectionManager.setFocusedKey(node.parentKey);
163+
e.stopPropagation();
164+
return;
165+
}
155166
}
156167
}
157168

packages/@react-stately/tree/src/useTreeState.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,28 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {Collection, CollectionStateBase, DisabledBehavior, Expandable, Key, MultipleSelection, Node} from '@react-types/shared';
14-
import {SelectionManager, useMultipleSelectionState} from '@react-stately/selection';
13+
import {
14+
Collection,
15+
CollectionStateBase,
16+
DisabledBehavior,
17+
Expandable,
18+
Key,
19+
MultipleSelection,
20+
Node
21+
} from '@react-types/shared';
22+
import {
23+
SelectionManager,
24+
useMultipleSelectionState
25+
} from '@react-stately/selection';
1526
import {TreeCollection} from './TreeCollection';
1627
import {useCallback, useEffect, useMemo} from 'react';
1728
import {useCollection} from '@react-stately/collections';
1829
import {useControlledState} from '@react-stately/utils';
1930

20-
export interface TreeProps<T> extends CollectionStateBase<T>, Expandable, MultipleSelection {
31+
export interface TreeProps<T>
32+
extends CollectionStateBase<T>,
33+
Expandable,
34+
MultipleSelection {
2135
/** Whether `disabledKeys` applies to all interactions, or only selection. */
2236
disabledBehavior?: DisabledBehavior
2337
}

packages/react-aria-components/docs/Tree.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,14 @@ Tree items may also be links to another page or website. This can be achieved by
702702

703703
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.
704704

705+
## Keyboard navigation
706+
707+
Navigation within the tree and within individual item actions share two keyboard keys.
708+
709+
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.
710+
711+
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.
712+
705713
## Disabled items
706714

707715
A `TreeItem` can be disabled with the `isDisabled` prop. This will disable all interactions on the item

packages/react-aria-components/test/Tree.test.tsx

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -917,6 +917,44 @@ describe('Tree', () => {
917917
expect(rows[12]).toHaveAttribute('aria-label', 'Reports');
918918
});
919919

920+
it('should support collapse key to navigate to parent', async () => {
921+
let {getAllByRole} = render(<DynamicTree />);
922+
await user.tab();
923+
let rows = getAllByRole('row');
924+
expect(rows).toHaveLength(20);
925+
expect(document.activeElement).toBe(rows[0]);
926+
expect(document.activeElement).toHaveAttribute('data-expanded', 'true');
927+
928+
// Navigate down to Project 2B
929+
await user.keyboard('{ArrowDown}');
930+
await user.keyboard('{ArrowDown}');
931+
await user.keyboard('{ArrowRight}');
932+
await user.keyboard('{ArrowDown}');
933+
await user.keyboard('{ArrowDown}');
934+
expect(document.activeElement).toBe(rows[4]);
935+
expect(document.activeElement).toHaveAttribute('aria-label', 'Project 2B');
936+
937+
// Collapse key on leaf node should move focus to parent (Projects)
938+
await user.keyboard('{ArrowLeft}');
939+
expect(document.activeElement).toBe(rows[2]);
940+
expect(document.activeElement).toHaveAttribute('aria-label', 'Project 2');
941+
expect(document.activeElement).toHaveAttribute('data-expanded', 'true');
942+
943+
// Collapse key on expanded parent should collapse it
944+
await user.keyboard('{ArrowLeft}');
945+
// Projects should now be collapsed, so fewer rows visible
946+
rows = getAllByRole('row');
947+
expect(rows.length).toBeLessThan(20);
948+
expect(document.activeElement).toBe(rows[2]);
949+
expect(document.activeElement).toHaveAttribute('aria-label', 'Project 2');
950+
expect(document.activeElement).not.toHaveAttribute('data-expanded');
951+
952+
// Collapse key again on now-collapsed parent should move to its parent
953+
await user.keyboard('{ArrowLeft}');
954+
expect(document.activeElement).toBe(rows[0]);
955+
expect(document.activeElement).toHaveAttribute('aria-label', 'Projects');
956+
});
957+
920958
it('should navigate between visible rows when using Arrow Up/Down', async () => {
921959
let {getAllByRole} = render(<DynamicTree />);
922960
await user.tab();
@@ -1961,7 +1999,7 @@ describe('Tree', () => {
19611999
let {getByRole} = render(<StaticTree rowProps={{onAction, onPressStart, onPressEnd, onPress, onClick}} />);
19622000
let gridListTester = testUtilUser.createTester('GridList', {root: getByRole('treegrid')});
19632001
await gridListTester.triggerRowAction({row: 1, interactionType});
1964-
2002+
19652003
expect(onAction).toHaveBeenCalledTimes(1);
19662004
expect(onPressStart).toHaveBeenCalledTimes(1);
19672005
expect(onPressEnd).toHaveBeenCalledTimes(1);

0 commit comments

Comments
 (0)