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

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 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 @@ -366,6 +368,7 @@ export type TreeOpenChangeEvent = TreeOpenChangeData['event'];

// @public (undocumented)
export type TreeProps = ComponentProps<TreeSlots> & {
navigationMode?: 'tree' | 'treegrid';
appearance?: 'subtle' | 'subtle-alpha' | 'transparent';
size?: 'small' | 'medium';
openItems?: Iterable<TreeItemValue>;
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 @@ -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 @@ -92,6 +92,13 @@ export type TreeContextValues = {
};

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?: '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 @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
slot,
elementContains,
useMergedRefs,
isHTMLElement,
} from '@fluentui/react-utilities';
import type { TreeItemProps, TreeItemState, TreeItemValue } from './TreeItem.types';
import { Space } from '@fluentui/keyboard-keys';
Expand All @@ -20,6 +21,7 @@ import {
import { dataTreeItemValueAttrName } from '../../utils/getTreeItemValueFromElement';
import { useHasParentContext } from '@fluentui/react-context-selector';
import { treeClassNames } from '../../Tree';
import { useFocusFinders } from '@fluentui/react-tabster';

/**
* Create the state required to render TreeItem.
Expand All @@ -38,6 +40,7 @@ export function useTreeItem_unstable(props: TreeItemProps, ref: React.Ref<HTMLDi
warnIfNoProperPropsFlatTreeItem(props);
}
const requestTreeResponse = useTreeContext_unstable(ctx => ctx.requestTreeResponse);
const navigationMode = useTreeContext_unstable(ctx => ctx.navigationMode ?? 'tree');
const forceUpdateRovingTabIndex = useTreeContext_unstable(ctx => ctx.forceUpdateRovingTabIndex);
const { level: contextLevel } = useSubtreeContext_unstable();
const parentValue = useTreeItemContext_unstable(ctx => props.parentValue ?? ctx.value);
Expand Down Expand Up @@ -145,10 +148,57 @@ export function useTreeItem_unstable(props: TreeItemProps, ref: React.Ref<HTMLDi
});
});

const { findFirstFocusable } = useFocusFinders();

const handleTreeGridActionsKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!event.currentTarget.contains(event.target as Node) || !treeItemRef.current) {
return;
}
switch (event.key) {
case treeDataTypes.ArrowLeft: {
// TODO: this should be included in requestTreeResponse navigation signature
treeItemRef.current?.focus();
return;
}
bsunderhus marked this conversation as resolved.
Show resolved Hide resolved
case treeDataTypes.ArrowUp: {
requestTreeResponse({
requestType: 'navigate',
event,
value,
itemType,
parentValue: undefined,
type: event.key,
target: treeItemRef.current,
});
return;
}
case treeDataTypes.ArrowDown: {
if (!isHTMLElement(event.target) || event.target.hasAttribute('aria-haspopup')) {
return;
}
bsunderhus marked this conversation as resolved.
Show resolved Hide resolved
requestTreeResponse({
requestType: 'navigate',
event,
value,
itemType,
parentValue: undefined,
type: event.key,
target: treeItemRef.current,
});
bsunderhus marked this conversation as resolved.
Show resolved Hide resolved
}
}
};

const handleKeyDown = useEventCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event);
if (event.isDefaultPrevented()) {
return;
}
if (actionsRef.current && actionsRef.current.contains(event.target as Node)) {
handleTreeGridActionsKeyDown(event);
}
// Ignore keyboard events that do not originate from the current tree item.
if (event.isDefaultPrevented() || event.currentTarget !== event.target) {
if (event.currentTarget !== event.target) {
return;
}
switch (event.key) {
Expand Down Expand Up @@ -207,6 +257,30 @@ export function useTreeItem_unstable(props: TreeItemProps, ref: React.Ref<HTMLDi
if (event.altKey) {
return;
}
if (navigationMode === 'treegrid') {
// only navigate or open if the item is a branch
if (itemType === 'branch' && !open) {
const data = {
value,
event,
open: getNextOpen(),
type: event.key,
target: event.currentTarget,
} as const;
props.onOpenChange?.(event, data);
requestTreeResponse({
...data,
itemType,
requestType: 'open',
});
return;
}
if (actionsRef.current) {
const first = findFirstFocusable(actionsRef.current);
first?.focus();
}
return;
}
// do not navigate or open if the item is a leaf
if (itemType === 'leaf') {
return;
Expand Down
Loading
Loading