Skip to content

Commit

Permalink
feature(react-tree): introduces navigationMode property (#33658)
Browse files Browse the repository at this point in the history
  • Loading branch information
bsunderhus authored Jan 21, 2025
1 parent b987de6 commit 246f5b3
Show file tree
Hide file tree
Showing 21 changed files with 398 additions and 71 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feature: introduces navigationMode property",
"packageName": "@fluentui/react-tree",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export type FlatTreeItemProps = TreeItemProps & {

// @public (undocumented)
export type FlatTreeProps = ComponentProps<TreeSlots> & {
navigationMode?: 'tree' | 'treegrid';
appearance?: 'subtle' | 'subtle-alpha' | 'transparent';
size?: 'small' | 'medium';
openItems?: Iterable<TreeItemValue>;
Expand Down Expand Up @@ -160,6 +161,7 @@ export type TreeContextValue = {
checkedItems: ImmutableMap<TreeItemValue, 'mixed' | boolean>;
requestTreeResponse(request: TreeItemRequest): void;
forceUpdateRovingTabIndex?(): void;
navigationMode?: 'tree' | 'treegrid';
};

// @public (undocumented)
Expand Down Expand Up @@ -331,6 +333,9 @@ export type TreeNavigationData_unstable = {
// @public (undocumented)
export type TreeNavigationEvent_unstable = TreeNavigationData_unstable['event'];

// @public (undocumented)
export type TreeNavigationMode = 'tree' | 'treegrid';

// @public (undocumented)
export type TreeOpenChangeData = {
open: boolean;
Expand Down Expand Up @@ -366,6 +371,7 @@ export type TreeOpenChangeEvent = TreeOpenChangeData['event'];

// @public (undocumented)
export type TreeProps = ComponentProps<TreeSlots> & {
navigationMode?: TreeNavigationMode;
appearance?: 'subtle' | 'subtle-alpha' | 'transparent';
size?: 'small' | 'medium';
openItems?: Iterable<TreeItemValue>;
Expand Down
1 change: 1 addition & 0 deletions packages/react-components/react-tree/library/src/Tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type {
TreeSelectionValue,
TreeSlots,
TreeState,
TreeNavigationMode,
} from './components/Tree/index';
export {
Tree,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,25 @@ describe('FlatTree', () => {
cy.document().realPress('Tab');
cy.get('#action').should('be.focused');
});
describe('navigationMode="treegrid"', () => {
it('should focus on actions/treeitem when pressing right/left arrow', () => {
mount(
<TreeTest openItems={['item1']} navigationMode="treegrid" id="tree" aria-label="Tree">
<TreeItem itemType="branch" value="item1" data-testid="item1">
<TreeItemLayout actions={<Button id="action">action</Button>}>level 1, item 1</TreeItemLayout>
<Tree>
<TreeItem itemType="leaf" value="item1__item1" data-testid="item1__item1">
<TreeItemLayout>level 2, item 1</TreeItemLayout>
</TreeItem>
</Tree>
</TreeItem>
</TreeTest>,
);
cy.get('[data-testid="item1"]').focus().realPress('{rightarrow}');
cy.get('#action').should('be.focused').realPress('{leftarrow}');
cy.get('[data-testid="item1"]').should('be.focused');
});
});
it('should not expand/collapse item on actions Enter/Space key', () => {
mount(
<TreeTest id="tree" aria-label="Tree">
Expand Down Expand Up @@ -250,25 +269,50 @@ describe('FlatTree', () => {
cy.get('[data-testid="item2"]').should('be.focused');
cy.focused().realPress('Tab').should('not.exist');
});
it('should move with Left/Right keys', () => {
mount(<TreeTest defaultOpenItems={['item2', 'item2__item1']} />);
cy.get('[data-testid="item1"]').focus().realPress('{downarrow}');
cy.get('[data-testid="item2"]').should('be.focused').realPress('{rightarrow}');
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{rightarrow}');
cy.get('[data-testid="item2__item1__item1"]').should('be.focused').realPress('{leftarrow}');
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{leftarrow}').realPress('{leftarrow}');
cy.get('[data-testid="item2"]').should('be.focused');
});
it('should not move with Alt + Left/Right keys', () => {
mount(<TreeTest defaultOpenItems={['item2', 'item2__item1']} />);
cy.get('[data-testid="item1"]').focus().realPress('{downarrow}');
cy.get('[data-testid="item2"]').should('be.focused').realPress(['Alt', '{rightarrow}']);
cy.get('[data-testid="item2"]').should('be.focused').realPress('{rightarrow}');
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{rightarrow}');
cy.get('[data-testid="item2__item1__item1"]').should('be.focused').realPress(['Alt', '{leftarrow}']);
cy.get('[data-testid="item2__item1__item1"]').should('be.focused').realPress('{leftarrow}');
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{leftarrow}').realPress('{leftarrow}');
cy.get('[data-testid="item2"]').should('be.focused');
describe('navigationMode="treegrid"', () => {
it('should move with Up/Down keys', () => {
mount(
<TreeTest openItems={['item1']} navigationMode="treegrid" id="tree" aria-label="Tree">
<TreeItem itemType="branch" value="item1" data-testid="item1">
<TreeItemLayout>level 1, item 1</TreeItemLayout>
<Tree>
<TreeItem itemType="leaf" value="item1__item1" data-testid="item1__item1">
<TreeItemLayout actions={<Button id="action">action</Button>}>level 2, item 1</TreeItemLayout>
</TreeItem>
<TreeItem itemType="leaf" value="item1__item2" data-testid="item1__item2">
<TreeItemLayout>level 2, item 2</TreeItemLayout>
</TreeItem>
</Tree>
</TreeItem>
</TreeTest>,
);
cy.get('[data-testid="item1__item1"]').focus().realPress('{rightarrow}');
cy.get('#action').should('be.focused').realPress('{uparrow}');
cy.get('[data-testid="item1"]').should('be.focused');
cy.get('[data-testid="item1__item1"]').focus().realPress('{rightarrow}');
cy.get('#action').should('be.focused').realPress('{downarrow}');
cy.get('[data-testid="item1__item2"]').should('be.focused');
});
it('should move with Left keys', () => {
mount(<TreeTest navigationMode="treegrid" defaultOpenItems={['item2', 'item2__item1']} />);
cy.get('[data-testid="item1"]').focus().realPress('{downarrow}');
cy.get('[data-testid="item2"]').should('be.focused').realPress('{downarrow}');
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{downarrow}');
cy.get('[data-testid="item2__item1__item1"]').should('be.focused').realPress('{leftarrow}');
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{leftarrow}').realPress('{leftarrow}');
cy.get('[data-testid="item2"]').should('be.focused');
});

it('should not move with Alt + Left keys', () => {
mount(<TreeTest navigationMode="treegrid" defaultOpenItems={['item2', 'item2__item1']} />);
cy.get('[data-testid="item1"]').focus().realPress('{downarrow}');
cy.get('[data-testid="item2"]').should('be.focused').realPress('{downarrow}');
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{downarrow}');
cy.get('[data-testid="item2__item1__item1"]').should('be.focused').realPress(['Alt', '{leftarrow}']);
cy.get('[data-testid="item2__item1__item1"]').should('be.focused').realPress('{leftarrow}');
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{leftarrow}').realPress('{leftarrow}');
cy.get('[data-testid="item2"]').should('be.focused');
});
});
it('should move to last item with End key', () => {
mount(<TreeTest defaultOpenItems={['item1', 'item2', 'item2__item1']} />);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ export type FlatTreeContextValues = {
};

export type FlatTreeProps = ComponentProps<TreeSlots> & {
/**
* Indicates how navigation between a treeitem and its actions work
* - 'tree' (default): The default navigation, pressing right arrow key navigates inward the first inner children of a branch treeitem
* - 'treegrid': Pressing right arrow key navigate towards the actions of a treeitem
* @default 'tree'
*/
navigationMode?: 'tree' | 'treegrid';
/**
* A tree item can have various appearances:
* - 'subtle' (default): The default tree item styles.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const useFlatTree_unstable: (props: FlatTreeProps, ref: React.Ref<HTMLEle
};

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

return Object.assign(
useRootTree(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const useFlatTreeContextValues_unstable = (state: FlatTreeState): FlatTre
treeType,
checkedItems,
selectionMode,
navigationMode,
appearance,
size,
requestTreeResponse,
Expand All @@ -25,6 +26,7 @@ export const useFlatTreeContextValues_unstable = (state: FlatTreeState): FlatTre
appearance,
checkedItems,
selectionMode,
navigationMode,
contextType,
level,
requestTreeResponse,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,25 @@ describe('Tree', () => {
cy.document().realPress('Tab');
cy.get('#action').should('be.focused');
});
describe('navigationMode="treegrid"', () => {
it('should focus on actions/treeitem when pressing right/left arrow', () => {
mount(
<TreeTest openItems={['item1']} navigationMode="treegrid" id="tree" aria-label="Tree">
<TreeItem itemType="branch" value="item1" data-testid="item1">
<TreeItemLayout actions={<Button id="action">action</Button>}>level 1, item 1</TreeItemLayout>
<Tree>
<TreeItem itemType="leaf" value="item1__item1" data-testid="item1__item1">
<TreeItemLayout>level 2, item 1</TreeItemLayout>
</TreeItem>
</Tree>
</TreeItem>
</TreeTest>,
);
cy.get('[data-testid="item1"]').focus().realPress('{rightarrow}');
cy.get('#action').should('be.focused').realPress('{leftarrow}');
cy.get('[data-testid="item1"]').should('be.focused');
});
});
it('should not expand/collapse item on actions Enter/Space key', () => {
mount(
<TreeTest id="tree" aria-label="Tree">
Expand Down Expand Up @@ -231,25 +250,49 @@ describe('Tree', () => {
cy.get('[data-testid="item2"]').should('be.focused');
cy.focused().realPress('Tab').should('not.exist');
});
it('should move with Left/Right keys', () => {
mount(<TreeTest defaultOpenItems={['item2', 'item2__item1']} />);
cy.get('[data-testid="item1"]').focus().realPress('{downarrow}');
cy.get('[data-testid="item2"]').should('be.focused').realPress('{rightarrow}');
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{rightarrow}');
cy.get('[data-testid="item2__item1__item1"]').should('be.focused').realPress('{leftarrow}');
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{leftarrow}').realPress('{leftarrow}');
cy.get('[data-testid="item2"]').should('be.focused');
});
it('should not move with Alt + Left/Right keys', () => {
mount(<TreeTest defaultOpenItems={['item2', 'item2__item1']} />);
cy.get('[data-testid="item1"]').focus().realPress('{downarrow}');
cy.get('[data-testid="item2"]').should('be.focused').realPress(['Alt', '{rightarrow}']);
cy.get('[data-testid="item2"]').should('be.focused').realPress('{rightarrow}');
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{rightarrow}');
cy.get('[data-testid="item2__item1__item1"]').should('be.focused').realPress(['Alt', '{leftarrow}']);
cy.get('[data-testid="item2__item1__item1"]').should('be.focused').realPress('{leftarrow}');
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{leftarrow}').realPress('{leftarrow}');
cy.get('[data-testid="item2"]').should('be.focused');
describe('navigationMode="treegrid"', () => {
it('should move with Up/Down keys', () => {
mount(
<TreeTest openItems={['item1']} navigationMode="treegrid" id="tree" aria-label="Tree">
<TreeItem itemType="branch" value="item1" data-testid="item1">
<TreeItemLayout>level 1, item 1</TreeItemLayout>
<Tree>
<TreeItem itemType="leaf" value="item1__item1" data-testid="item1__item1">
<TreeItemLayout actions={<Button id="action">action</Button>}>level 2, item 1</TreeItemLayout>
</TreeItem>
<TreeItem itemType="leaf" value="item1__item2" data-testid="item1__item2">
<TreeItemLayout>level 2, item 2</TreeItemLayout>
</TreeItem>
</Tree>
</TreeItem>
</TreeTest>,
);
cy.get('[data-testid="item1__item1"]').focus().realPress('{rightarrow}');
cy.get('#action').should('be.focused').realPress('{uparrow}');
cy.get('[data-testid="item1"]').should('be.focused');
cy.get('[data-testid="item1__item1"]').focus().realPress('{rightarrow}');
cy.get('#action').should('be.focused').realPress('{downarrow}');
cy.get('[data-testid="item1__item2"]').should('be.focused');
});
it('should move with Left keys', () => {
mount(<TreeTest navigationMode="treegrid" defaultOpenItems={['item2', 'item2__item1']} />);
cy.get('[data-testid="item1"]').focus().realPress('{downarrow}');
cy.get('[data-testid="item2"]').should('be.focused').realPress('{downarrow}');
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{downarrow}');
cy.get('[data-testid="item2__item1__item1"]').should('be.focused').realPress('{leftarrow}');
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{leftarrow}').realPress('{leftarrow}');
cy.get('[data-testid="item2"]').should('be.focused');
});
it('should not move with Alt + Left keys', () => {
mount(<TreeTest navigationMode="treegrid" defaultOpenItems={['item2', 'item2__item1']} />);
cy.get('[data-testid="item1"]').focus().realPress('{downarrow}');
cy.get('[data-testid="item2"]').should('be.focused').realPress('{downarrow}');
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{downarrow}');
cy.get('[data-testid="item2__item1__item1"]').should('be.focused').realPress(['Alt', '{leftarrow}']);
cy.get('[data-testid="item2__item1__item1"]').should('be.focused').realPress('{leftarrow}');
cy.get('[data-testid="item2__item1"]').should('be.focused').realPress('{leftarrow}').realPress('{leftarrow}');
cy.get('[data-testid="item2"]').should('be.focused');
});
});
it('should move to last item with End key', () => {
mount(<TreeTest defaultOpenItems={['item1', 'item2', 'item2__item1']} />);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,16 @@ export type TreeContextValues = {
tree: TreeContextValue | SubtreeContextValue;
};

export type TreeNavigationMode = 'tree' | 'treegrid';

export type TreeProps = ComponentProps<TreeSlots> & {
/**
* Indicates how navigation between a treeitem and its actions work
* - 'tree' (default): The default navigation, pressing right arrow key navigates inward the first inner children of a branch treeitem
* - 'treegrid': Pressing right arrow key navigate towards the actions of a treeitem
* @default 'tree'
*/
navigationMode?: TreeNavigationMode;
/**
* A tree item can have various appearances:
* - 'subtle' (default): The default tree item styles.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type {
TreeSelectionValue,
TreeSlots,
TreeState,
TreeNavigationMode,
} from './Tree.types';
export { useTree_unstable } from './useTree';
export { useTreeContextValues_unstable } from './useTreeContextValues';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ function useNestedRootTree(props: TreeProps, ref: React.Ref<HTMLElement>): TreeS

const [openItems, setOpenItems] = useControllableOpenItems(props);
const checkedItems = useNestedCheckedItems(props);
const navigation = useTreeNavigation();
const navigation = useTreeNavigation(props.navigationMode);

return Object.assign(
useRootTree(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export function useTreeContextValues_unstable(state: TreeState): TreeContextValu
treeType,
checkedItems,
selectionMode,
navigationMode,
appearance,
size,
requestTreeResponse,
Expand All @@ -29,6 +30,7 @@ export function useTreeContextValues_unstable(state: TreeState): TreeContextValu
appearance,
checkedItems,
selectionMode,
navigationMode,
contextType,
level,
requestTreeResponse,
Expand Down
Loading

0 comments on commit 246f5b3

Please sign in to comment.