Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature(react-tree): introduces navigationMode property #33658

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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
bsunderhus marked this conversation as resolved.
Show resolved Hide resolved
"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
Loading