Skip to content

Commit 246f5b3

Browse files
authored
feature(react-tree): introduces navigationMode property (#33658)
1 parent b987de6 commit 246f5b3

21 files changed

+398
-71
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "minor",
3+
"comment": "feature: introduces navigationMode property",
4+
"packageName": "@fluentui/react-tree",
5+
"email": "[email protected]",
6+
"dependentChangeType": "patch"
7+
}

packages/react-components/react-tree/library/etc/react-tree.api.md

+6
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export type FlatTreeItemProps = TreeItemProps & {
6262

6363
// @public (undocumented)
6464
export type FlatTreeProps = ComponentProps<TreeSlots> & {
65+
navigationMode?: 'tree' | 'treegrid';
6566
appearance?: 'subtle' | 'subtle-alpha' | 'transparent';
6667
size?: 'small' | 'medium';
6768
openItems?: Iterable<TreeItemValue>;
@@ -160,6 +161,7 @@ export type TreeContextValue = {
160161
checkedItems: ImmutableMap<TreeItemValue, 'mixed' | boolean>;
161162
requestTreeResponse(request: TreeItemRequest): void;
162163
forceUpdateRovingTabIndex?(): void;
164+
navigationMode?: 'tree' | 'treegrid';
163165
};
164166

165167
// @public (undocumented)
@@ -331,6 +333,9 @@ export type TreeNavigationData_unstable = {
331333
// @public (undocumented)
332334
export type TreeNavigationEvent_unstable = TreeNavigationData_unstable['event'];
333335

336+
// @public (undocumented)
337+
export type TreeNavigationMode = 'tree' | 'treegrid';
338+
334339
// @public (undocumented)
335340
export type TreeOpenChangeData = {
336341
open: boolean;
@@ -366,6 +371,7 @@ export type TreeOpenChangeEvent = TreeOpenChangeData['event'];
366371

367372
// @public (undocumented)
368373
export type TreeProps = ComponentProps<TreeSlots> & {
374+
navigationMode?: TreeNavigationMode;
369375
appearance?: 'subtle' | 'subtle-alpha' | 'transparent';
370376
size?: 'small' | 'medium';
371377
openItems?: Iterable<TreeItemValue>;

packages/react-components/react-tree/library/src/Tree.ts

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export type {
1010
TreeSelectionValue,
1111
TreeSlots,
1212
TreeState,
13+
TreeNavigationMode,
1314
} from './components/Tree/index';
1415
export {
1516
Tree,

packages/react-components/react-tree/library/src/components/FlatTree/FlatTree.cy.tsx

+63-19
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,25 @@ describe('FlatTree', () => {
207207
cy.document().realPress('Tab');
208208
cy.get('#action').should('be.focused');
209209
});
210+
describe('navigationMode="treegrid"', () => {
211+
it('should focus on actions/treeitem when pressing right/left arrow', () => {
212+
mount(
213+
<TreeTest openItems={['item1']} navigationMode="treegrid" id="tree" aria-label="Tree">
214+
<TreeItem itemType="branch" value="item1" data-testid="item1">
215+
<TreeItemLayout actions={<Button id="action">action</Button>}>level 1, item 1</TreeItemLayout>
216+
<Tree>
217+
<TreeItem itemType="leaf" value="item1__item1" data-testid="item1__item1">
218+
<TreeItemLayout>level 2, item 1</TreeItemLayout>
219+
</TreeItem>
220+
</Tree>
221+
</TreeItem>
222+
</TreeTest>,
223+
);
224+
cy.get('[data-testid="item1"]').focus().realPress('{rightarrow}');
225+
cy.get('#action').should('be.focused').realPress('{leftarrow}');
226+
cy.get('[data-testid="item1"]').should('be.focused');
227+
});
228+
});
210229
it('should not expand/collapse item on actions Enter/Space key', () => {
211230
mount(
212231
<TreeTest id="tree" aria-label="Tree">
@@ -250,25 +269,50 @@ describe('FlatTree', () => {
250269
cy.get('[data-testid="item2"]').should('be.focused');
251270
cy.focused().realPress('Tab').should('not.exist');
252271
});
253-
it('should move with Left/Right keys', () => {
254-
mount(<TreeTest defaultOpenItems={['item2', 'item2__item1']} />);
255-
cy.get('[data-testid="item1"]').focus().realPress('{downarrow}');
256-
cy.get('[data-testid="item2"]').should('be.focused').realPress('{rightarrow}');
257-
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{rightarrow}');
258-
cy.get('[data-testid="item2__item1__item1"]').should('be.focused').realPress('{leftarrow}');
259-
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{leftarrow}').realPress('{leftarrow}');
260-
cy.get('[data-testid="item2"]').should('be.focused');
261-
});
262-
it('should not move with Alt + Left/Right keys', () => {
263-
mount(<TreeTest defaultOpenItems={['item2', 'item2__item1']} />);
264-
cy.get('[data-testid="item1"]').focus().realPress('{downarrow}');
265-
cy.get('[data-testid="item2"]').should('be.focused').realPress(['Alt', '{rightarrow}']);
266-
cy.get('[data-testid="item2"]').should('be.focused').realPress('{rightarrow}');
267-
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{rightarrow}');
268-
cy.get('[data-testid="item2__item1__item1"]').should('be.focused').realPress(['Alt', '{leftarrow}']);
269-
cy.get('[data-testid="item2__item1__item1"]').should('be.focused').realPress('{leftarrow}');
270-
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{leftarrow}').realPress('{leftarrow}');
271-
cy.get('[data-testid="item2"]').should('be.focused');
272+
describe('navigationMode="treegrid"', () => {
273+
it('should move with Up/Down keys', () => {
274+
mount(
275+
<TreeTest openItems={['item1']} navigationMode="treegrid" id="tree" aria-label="Tree">
276+
<TreeItem itemType="branch" value="item1" data-testid="item1">
277+
<TreeItemLayout>level 1, item 1</TreeItemLayout>
278+
<Tree>
279+
<TreeItem itemType="leaf" value="item1__item1" data-testid="item1__item1">
280+
<TreeItemLayout actions={<Button id="action">action</Button>}>level 2, item 1</TreeItemLayout>
281+
</TreeItem>
282+
<TreeItem itemType="leaf" value="item1__item2" data-testid="item1__item2">
283+
<TreeItemLayout>level 2, item 2</TreeItemLayout>
284+
</TreeItem>
285+
</Tree>
286+
</TreeItem>
287+
</TreeTest>,
288+
);
289+
cy.get('[data-testid="item1__item1"]').focus().realPress('{rightarrow}');
290+
cy.get('#action').should('be.focused').realPress('{uparrow}');
291+
cy.get('[data-testid="item1"]').should('be.focused');
292+
cy.get('[data-testid="item1__item1"]').focus().realPress('{rightarrow}');
293+
cy.get('#action').should('be.focused').realPress('{downarrow}');
294+
cy.get('[data-testid="item1__item2"]').should('be.focused');
295+
});
296+
it('should move with Left keys', () => {
297+
mount(<TreeTest navigationMode="treegrid" defaultOpenItems={['item2', 'item2__item1']} />);
298+
cy.get('[data-testid="item1"]').focus().realPress('{downarrow}');
299+
cy.get('[data-testid="item2"]').should('be.focused').realPress('{downarrow}');
300+
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{downarrow}');
301+
cy.get('[data-testid="item2__item1__item1"]').should('be.focused').realPress('{leftarrow}');
302+
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{leftarrow}').realPress('{leftarrow}');
303+
cy.get('[data-testid="item2"]').should('be.focused');
304+
});
305+
306+
it('should not move with Alt + Left keys', () => {
307+
mount(<TreeTest navigationMode="treegrid" defaultOpenItems={['item2', 'item2__item1']} />);
308+
cy.get('[data-testid="item1"]').focus().realPress('{downarrow}');
309+
cy.get('[data-testid="item2"]').should('be.focused').realPress('{downarrow}');
310+
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{downarrow}');
311+
cy.get('[data-testid="item2__item1__item1"]').should('be.focused').realPress(['Alt', '{leftarrow}']);
312+
cy.get('[data-testid="item2__item1__item1"]').should('be.focused').realPress('{leftarrow}');
313+
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{leftarrow}').realPress('{leftarrow}');
314+
cy.get('[data-testid="item2"]').should('be.focused');
315+
});
272316
});
273317
it('should move to last item with End key', () => {
274318
mount(<TreeTest defaultOpenItems={['item1', 'item2', 'item2__item1']} />);

packages/react-components/react-tree/library/src/components/FlatTree/FlatTree.types.ts

+7
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ export type FlatTreeContextValues = {
1919
};
2020

2121
export type FlatTreeProps = ComponentProps<TreeSlots> & {
22+
/**
23+
* Indicates how navigation between a treeitem and its actions work
24+
* - 'tree' (default): The default navigation, pressing right arrow key navigates inward the first inner children of a branch treeitem
25+
* - 'treegrid': Pressing right arrow key navigate towards the actions of a treeitem
26+
* @default 'tree'
27+
*/
28+
navigationMode?: 'tree' | 'treegrid';
2229
/**
2330
* A tree item can have various appearances:
2431
* - 'subtle' (default): The default tree item styles.

packages/react-components/react-tree/library/src/components/FlatTree/useFlatTree.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export const useFlatTree_unstable: (props: FlatTreeProps, ref: React.Ref<HTMLEle
2222
};
2323

2424
function useRootFlatTree(props: FlatTreeProps, ref: React.Ref<HTMLElement>): FlatTreeState {
25-
const navigation = useFlatTreeNavigation();
25+
const navigation = useFlatTreeNavigation(props.navigationMode);
2626

2727
return Object.assign(
2828
useRootTree(

packages/react-components/react-tree/library/src/components/FlatTree/useFlatTreeContextValues.ts

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const useFlatTreeContextValues_unstable = (state: FlatTreeState): FlatTre
99
treeType,
1010
checkedItems,
1111
selectionMode,
12+
navigationMode,
1213
appearance,
1314
size,
1415
requestTreeResponse,
@@ -25,6 +26,7 @@ export const useFlatTreeContextValues_unstable = (state: FlatTreeState): FlatTre
2526
appearance,
2627
checkedItems,
2728
selectionMode,
29+
navigationMode,
2830
contextType,
2931
level,
3032
requestTreeResponse,

packages/react-components/react-tree/library/src/components/Tree/Tree.cy.tsx

+62-19
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,25 @@ describe('Tree', () => {
188188
cy.document().realPress('Tab');
189189
cy.get('#action').should('be.focused');
190190
});
191+
describe('navigationMode="treegrid"', () => {
192+
it('should focus on actions/treeitem when pressing right/left arrow', () => {
193+
mount(
194+
<TreeTest openItems={['item1']} navigationMode="treegrid" id="tree" aria-label="Tree">
195+
<TreeItem itemType="branch" value="item1" data-testid="item1">
196+
<TreeItemLayout actions={<Button id="action">action</Button>}>level 1, item 1</TreeItemLayout>
197+
<Tree>
198+
<TreeItem itemType="leaf" value="item1__item1" data-testid="item1__item1">
199+
<TreeItemLayout>level 2, item 1</TreeItemLayout>
200+
</TreeItem>
201+
</Tree>
202+
</TreeItem>
203+
</TreeTest>,
204+
);
205+
cy.get('[data-testid="item1"]').focus().realPress('{rightarrow}');
206+
cy.get('#action').should('be.focused').realPress('{leftarrow}');
207+
cy.get('[data-testid="item1"]').should('be.focused');
208+
});
209+
});
191210
it('should not expand/collapse item on actions Enter/Space key', () => {
192211
mount(
193212
<TreeTest id="tree" aria-label="Tree">
@@ -231,25 +250,49 @@ describe('Tree', () => {
231250
cy.get('[data-testid="item2"]').should('be.focused');
232251
cy.focused().realPress('Tab').should('not.exist');
233252
});
234-
it('should move with Left/Right keys', () => {
235-
mount(<TreeTest defaultOpenItems={['item2', 'item2__item1']} />);
236-
cy.get('[data-testid="item1"]').focus().realPress('{downarrow}');
237-
cy.get('[data-testid="item2"]').should('be.focused').realPress('{rightarrow}');
238-
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{rightarrow}');
239-
cy.get('[data-testid="item2__item1__item1"]').should('be.focused').realPress('{leftarrow}');
240-
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{leftarrow}').realPress('{leftarrow}');
241-
cy.get('[data-testid="item2"]').should('be.focused');
242-
});
243-
it('should not move with Alt + Left/Right keys', () => {
244-
mount(<TreeTest defaultOpenItems={['item2', 'item2__item1']} />);
245-
cy.get('[data-testid="item1"]').focus().realPress('{downarrow}');
246-
cy.get('[data-testid="item2"]').should('be.focused').realPress(['Alt', '{rightarrow}']);
247-
cy.get('[data-testid="item2"]').should('be.focused').realPress('{rightarrow}');
248-
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{rightarrow}');
249-
cy.get('[data-testid="item2__item1__item1"]').should('be.focused').realPress(['Alt', '{leftarrow}']);
250-
cy.get('[data-testid="item2__item1__item1"]').should('be.focused').realPress('{leftarrow}');
251-
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{leftarrow}').realPress('{leftarrow}');
252-
cy.get('[data-testid="item2"]').should('be.focused');
253+
describe('navigationMode="treegrid"', () => {
254+
it('should move with Up/Down keys', () => {
255+
mount(
256+
<TreeTest openItems={['item1']} navigationMode="treegrid" id="tree" aria-label="Tree">
257+
<TreeItem itemType="branch" value="item1" data-testid="item1">
258+
<TreeItemLayout>level 1, item 1</TreeItemLayout>
259+
<Tree>
260+
<TreeItem itemType="leaf" value="item1__item1" data-testid="item1__item1">
261+
<TreeItemLayout actions={<Button id="action">action</Button>}>level 2, item 1</TreeItemLayout>
262+
</TreeItem>
263+
<TreeItem itemType="leaf" value="item1__item2" data-testid="item1__item2">
264+
<TreeItemLayout>level 2, item 2</TreeItemLayout>
265+
</TreeItem>
266+
</Tree>
267+
</TreeItem>
268+
</TreeTest>,
269+
);
270+
cy.get('[data-testid="item1__item1"]').focus().realPress('{rightarrow}');
271+
cy.get('#action').should('be.focused').realPress('{uparrow}');
272+
cy.get('[data-testid="item1"]').should('be.focused');
273+
cy.get('[data-testid="item1__item1"]').focus().realPress('{rightarrow}');
274+
cy.get('#action').should('be.focused').realPress('{downarrow}');
275+
cy.get('[data-testid="item1__item2"]').should('be.focused');
276+
});
277+
it('should move with Left keys', () => {
278+
mount(<TreeTest navigationMode="treegrid" defaultOpenItems={['item2', 'item2__item1']} />);
279+
cy.get('[data-testid="item1"]').focus().realPress('{downarrow}');
280+
cy.get('[data-testid="item2"]').should('be.focused').realPress('{downarrow}');
281+
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{downarrow}');
282+
cy.get('[data-testid="item2__item1__item1"]').should('be.focused').realPress('{leftarrow}');
283+
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{leftarrow}').realPress('{leftarrow}');
284+
cy.get('[data-testid="item2"]').should('be.focused');
285+
});
286+
it('should not move with Alt + Left keys', () => {
287+
mount(<TreeTest navigationMode="treegrid" defaultOpenItems={['item2', 'item2__item1']} />);
288+
cy.get('[data-testid="item1"]').focus().realPress('{downarrow}');
289+
cy.get('[data-testid="item2"]').should('be.focused').realPress('{downarrow}');
290+
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{downarrow}');
291+
cy.get('[data-testid="item2__item1__item1"]').should('be.focused').realPress(['Alt', '{leftarrow}']);
292+
cy.get('[data-testid="item2__item1__item1"]').should('be.focused').realPress('{leftarrow}');
293+
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{leftarrow}').realPress('{leftarrow}');
294+
cy.get('[data-testid="item2"]').should('be.focused');
295+
});
253296
});
254297
it('should move to last item with End key', () => {
255298
mount(<TreeTest defaultOpenItems={['item1', 'item2', 'item2__item1']} />);

packages/react-components/react-tree/library/src/components/Tree/Tree.types.ts

+9
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,16 @@ export type TreeContextValues = {
9191
tree: TreeContextValue | SubtreeContextValue;
9292
};
9393

94+
export type TreeNavigationMode = 'tree' | 'treegrid';
95+
9496
export type TreeProps = ComponentProps<TreeSlots> & {
97+
/**
98+
* Indicates how navigation between a treeitem and its actions work
99+
* - 'tree' (default): The default navigation, pressing right arrow key navigates inward the first inner children of a branch treeitem
100+
* - 'treegrid': Pressing right arrow key navigate towards the actions of a treeitem
101+
* @default 'tree'
102+
*/
103+
navigationMode?: TreeNavigationMode;
95104
/**
96105
* A tree item can have various appearances:
97106
* - 'subtle' (default): The default tree item styles.

packages/react-components/react-tree/library/src/components/Tree/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type {
1111
TreeSelectionValue,
1212
TreeSlots,
1313
TreeState,
14+
TreeNavigationMode,
1415
} from './Tree.types';
1516
export { useTree_unstable } from './useTree';
1617
export { useTreeContextValues_unstable } from './useTreeContextValues';

packages/react-components/react-tree/library/src/components/Tree/useTree.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ function useNestedRootTree(props: TreeProps, ref: React.Ref<HTMLElement>): TreeS
2626

2727
const [openItems, setOpenItems] = useControllableOpenItems(props);
2828
const checkedItems = useNestedCheckedItems(props);
29-
const navigation = useTreeNavigation();
29+
const navigation = useTreeNavigation(props.navigationMode);
3030

3131
return Object.assign(
3232
useRootTree(

packages/react-components/react-tree/library/src/components/Tree/useTreeContextValues.ts

+2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export function useTreeContextValues_unstable(state: TreeState): TreeContextValu
1313
treeType,
1414
checkedItems,
1515
selectionMode,
16+
navigationMode,
1617
appearance,
1718
size,
1819
requestTreeResponse,
@@ -29,6 +30,7 @@ export function useTreeContextValues_unstable(state: TreeState): TreeContextValu
2930
appearance,
3031
checkedItems,
3132
selectionMode,
33+
navigationMode,
3234
contextType,
3335
level,
3436
requestTreeResponse,

0 commit comments

Comments
 (0)