From 246f5b3ed047531f7a329ad8ca7e5693cd3838a9 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Tue, 21 Jan 2025 14:59:17 +0100 Subject: [PATCH] feature(react-tree): introduces navigationMode property (#33658) --- ...-69773403-5633-414b-866a-f20aedf4bc0c.json | 7 ++ .../react-tree/library/etc/react-tree.api.md | 6 + .../react-tree/library/src/Tree.ts | 1 + .../src/components/FlatTree/FlatTree.cy.tsx | 82 +++++++++--- .../src/components/FlatTree/FlatTree.types.ts | 7 ++ .../src/components/FlatTree/useFlatTree.ts | 2 +- .../FlatTree/useFlatTreeContextValues.ts | 2 + .../library/src/components/Tree/Tree.cy.tsx | 81 +++++++++--- .../library/src/components/Tree/Tree.types.ts | 9 ++ .../library/src/components/Tree/index.ts | 1 + .../library/src/components/Tree/useTree.ts | 2 +- .../components/Tree/useTreeContextValues.ts | 2 + .../src/components/TreeItem/useTreeItem.tsx | 90 +++++++++---- .../TreeItemLayout/useTreeItemLayout.tsx | 4 +- .../library/src/contexts/treeContext.ts | 4 + .../src/hooks/useFlatTreeNavigation.ts | 22 +++- .../library/src/hooks/useRootTree.ts | 1 + .../library/src/hooks/useTreeNavigation.ts | 26 +++- .../react-tree/library/src/index.ts | 1 + .../TreeNavigationModeTreeGrid.stories.tsx | 118 ++++++++++++++++++ .../stories/src/Tree/index.stories.tsx | 1 + 21 files changed, 398 insertions(+), 71 deletions(-) create mode 100644 change/@fluentui-react-tree-69773403-5633-414b-866a-f20aedf4bc0c.json create mode 100644 packages/react-components/react-tree/stories/src/Tree/TreeNavigationModeTreeGrid.stories.tsx diff --git a/change/@fluentui-react-tree-69773403-5633-414b-866a-f20aedf4bc0c.json b/change/@fluentui-react-tree-69773403-5633-414b-866a-f20aedf4bc0c.json new file mode 100644 index 00000000000000..299a1892740844 --- /dev/null +++ b/change/@fluentui-react-tree-69773403-5633-414b-866a-f20aedf4bc0c.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feature: introduces navigationMode property", + "packageName": "@fluentui/react-tree", + "email": "bernardo.sunderhus@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-tree/library/etc/react-tree.api.md b/packages/react-components/react-tree/library/etc/react-tree.api.md index 419d5b136e4352..210a53b0625064 100644 --- a/packages/react-components/react-tree/library/etc/react-tree.api.md +++ b/packages/react-components/react-tree/library/etc/react-tree.api.md @@ -62,6 +62,7 @@ export type FlatTreeItemProps = TreeItemProps & { // @public (undocumented) export type FlatTreeProps = ComponentProps & { + navigationMode?: 'tree' | 'treegrid'; appearance?: 'subtle' | 'subtle-alpha' | 'transparent'; size?: 'small' | 'medium'; openItems?: Iterable; @@ -160,6 +161,7 @@ export type TreeContextValue = { checkedItems: ImmutableMap; requestTreeResponse(request: TreeItemRequest): void; forceUpdateRovingTabIndex?(): void; + navigationMode?: 'tree' | 'treegrid'; }; // @public (undocumented) @@ -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; @@ -366,6 +371,7 @@ export type TreeOpenChangeEvent = TreeOpenChangeData['event']; // @public (undocumented) export type TreeProps = ComponentProps & { + navigationMode?: TreeNavigationMode; appearance?: 'subtle' | 'subtle-alpha' | 'transparent'; size?: 'small' | 'medium'; openItems?: Iterable; diff --git a/packages/react-components/react-tree/library/src/Tree.ts b/packages/react-components/react-tree/library/src/Tree.ts index f6591a88e83ac3..a4bd46d2b4b4a8 100644 --- a/packages/react-components/react-tree/library/src/Tree.ts +++ b/packages/react-components/react-tree/library/src/Tree.ts @@ -10,6 +10,7 @@ export type { TreeSelectionValue, TreeSlots, TreeState, + TreeNavigationMode, } from './components/Tree/index'; export { Tree, diff --git a/packages/react-components/react-tree/library/src/components/FlatTree/FlatTree.cy.tsx b/packages/react-components/react-tree/library/src/components/FlatTree/FlatTree.cy.tsx index 820f0ff2e38779..50d5822afc05f8 100644 --- a/packages/react-components/react-tree/library/src/components/FlatTree/FlatTree.cy.tsx +++ b/packages/react-components/react-tree/library/src/components/FlatTree/FlatTree.cy.tsx @@ -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( + + + action}>level 1, item 1 + + + level 2, item 1 + + + + , + ); + 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( @@ -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(); - 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(); - 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( + + + level 1, item 1 + + + action}>level 2, item 1 + + + level 2, item 2 + + + + , + ); + 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(); + 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(); + 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(); diff --git a/packages/react-components/react-tree/library/src/components/FlatTree/FlatTree.types.ts b/packages/react-components/react-tree/library/src/components/FlatTree/FlatTree.types.ts index 56e8b8a0312181..51ca908f5c16a4 100644 --- a/packages/react-components/react-tree/library/src/components/FlatTree/FlatTree.types.ts +++ b/packages/react-components/react-tree/library/src/components/FlatTree/FlatTree.types.ts @@ -19,6 +19,13 @@ export type FlatTreeContextValues = { }; export type FlatTreeProps = ComponentProps & { + /** + * 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. diff --git a/packages/react-components/react-tree/library/src/components/FlatTree/useFlatTree.ts b/packages/react-components/react-tree/library/src/components/FlatTree/useFlatTree.ts index 12d019abab8748..5e7610a3193a3c 100644 --- a/packages/react-components/react-tree/library/src/components/FlatTree/useFlatTree.ts +++ b/packages/react-components/react-tree/library/src/components/FlatTree/useFlatTree.ts @@ -22,7 +22,7 @@ export const useFlatTree_unstable: (props: FlatTreeProps, ref: React.Ref): FlatTreeState { - const navigation = useFlatTreeNavigation(); + const navigation = useFlatTreeNavigation(props.navigationMode); return Object.assign( useRootTree( diff --git a/packages/react-components/react-tree/library/src/components/FlatTree/useFlatTreeContextValues.ts b/packages/react-components/react-tree/library/src/components/FlatTree/useFlatTreeContextValues.ts index a06d65e2ec568d..9c30a476081ed7 100644 --- a/packages/react-components/react-tree/library/src/components/FlatTree/useFlatTreeContextValues.ts +++ b/packages/react-components/react-tree/library/src/components/FlatTree/useFlatTreeContextValues.ts @@ -9,6 +9,7 @@ export const useFlatTreeContextValues_unstable = (state: FlatTreeState): FlatTre treeType, checkedItems, selectionMode, + navigationMode, appearance, size, requestTreeResponse, @@ -25,6 +26,7 @@ export const useFlatTreeContextValues_unstable = (state: FlatTreeState): FlatTre appearance, checkedItems, selectionMode, + navigationMode, contextType, level, requestTreeResponse, diff --git a/packages/react-components/react-tree/library/src/components/Tree/Tree.cy.tsx b/packages/react-components/react-tree/library/src/components/Tree/Tree.cy.tsx index 0413eafba5eb90..87ae49eedd53dd 100644 --- a/packages/react-components/react-tree/library/src/components/Tree/Tree.cy.tsx +++ b/packages/react-components/react-tree/library/src/components/Tree/Tree.cy.tsx @@ -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( + + + action}>level 1, item 1 + + + level 2, item 1 + + + + , + ); + 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( @@ -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(); - 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(); - 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( + + + level 1, item 1 + + + action}>level 2, item 1 + + + level 2, item 2 + + + + , + ); + 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(); + 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(); + 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(); diff --git a/packages/react-components/react-tree/library/src/components/Tree/Tree.types.ts b/packages/react-components/react-tree/library/src/components/Tree/Tree.types.ts index 959ba074dcad9b..92f6f8824ee01a 100644 --- a/packages/react-components/react-tree/library/src/components/Tree/Tree.types.ts +++ b/packages/react-components/react-tree/library/src/components/Tree/Tree.types.ts @@ -91,7 +91,16 @@ export type TreeContextValues = { tree: TreeContextValue | SubtreeContextValue; }; +export type TreeNavigationMode = 'tree' | 'treegrid'; + export type TreeProps = ComponentProps & { + /** + * 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. diff --git a/packages/react-components/react-tree/library/src/components/Tree/index.ts b/packages/react-components/react-tree/library/src/components/Tree/index.ts index c49dc6c59cd400..6d35eba2306cad 100644 --- a/packages/react-components/react-tree/library/src/components/Tree/index.ts +++ b/packages/react-components/react-tree/library/src/components/Tree/index.ts @@ -11,6 +11,7 @@ export type { TreeSelectionValue, TreeSlots, TreeState, + TreeNavigationMode, } from './Tree.types'; export { useTree_unstable } from './useTree'; export { useTreeContextValues_unstable } from './useTreeContextValues'; diff --git a/packages/react-components/react-tree/library/src/components/Tree/useTree.ts b/packages/react-components/react-tree/library/src/components/Tree/useTree.ts index 233b7fdf32628b..625dd6d5fe299a 100644 --- a/packages/react-components/react-tree/library/src/components/Tree/useTree.ts +++ b/packages/react-components/react-tree/library/src/components/Tree/useTree.ts @@ -26,7 +26,7 @@ function useNestedRootTree(props: TreeProps, ref: React.Ref): TreeS const [openItems, setOpenItems] = useControllableOpenItems(props); const checkedItems = useNestedCheckedItems(props); - const navigation = useTreeNavigation(); + const navigation = useTreeNavigation(props.navigationMode); return Object.assign( useRootTree( diff --git a/packages/react-components/react-tree/library/src/components/Tree/useTreeContextValues.ts b/packages/react-components/react-tree/library/src/components/Tree/useTreeContextValues.ts index fdfa26728522c6..4431b8be3909f8 100644 --- a/packages/react-components/react-tree/library/src/components/Tree/useTreeContextValues.ts +++ b/packages/react-components/react-tree/library/src/components/Tree/useTreeContextValues.ts @@ -13,6 +13,7 @@ export function useTreeContextValues_unstable(state: TreeState): TreeContextValu treeType, checkedItems, selectionMode, + navigationMode, appearance, size, requestTreeResponse, @@ -29,6 +30,7 @@ export function useTreeContextValues_unstable(state: TreeState): TreeContextValu appearance, checkedItems, selectionMode, + navigationMode, contextType, level, requestTreeResponse, diff --git a/packages/react-components/react-tree/library/src/components/TreeItem/useTreeItem.tsx b/packages/react-components/react-tree/library/src/components/TreeItem/useTreeItem.tsx index 0b01ccce4a61c0..bc6c4400e0c306 100644 --- a/packages/react-components/react-tree/library/src/components/TreeItem/useTreeItem.tsx +++ b/packages/react-components/react-tree/library/src/components/TreeItem/useTreeItem.tsx @@ -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'; @@ -38,6 +39,7 @@ export function useTreeItem_unstable(props: TreeItemProps, ref: React.Ref 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); @@ -147,25 +149,36 @@ export function useTreeItem_unstable(props: TreeItemProps, ref: React.Ref) => { onKeyDown?.(event); - // Ignore keyboard events that do not originate from the current tree item. - if (event.isDefaultPrevented() || event.currentTarget !== event.target) { + if (event.isDefaultPrevented() || !treeItemRef.current) { return; } + const isEventFromTreeItem = event.currentTarget === event.target; + const isEventFromActions = actionsRef.current && actionsRef.current.contains(event.target as Node); + switch (event.key) { - case Space: + case Space: { + if (!isEventFromTreeItem) { + return; + } if (selectionMode !== 'none') { selectionRef.current?.click(); // Prevents the page from scrolling down when the spacebar is pressed event.preventDefault(); } return; + } case treeDataTypes.Enter: { + if (!isEventFromTreeItem) { + return; + } return event.currentTarget.click(); } case treeDataTypes.End: case treeDataTypes.Home: - case treeDataTypes.ArrowUp: - case treeDataTypes.ArrowDown: + case treeDataTypes.ArrowUp: { + if (!isEventFromTreeItem && !isEventFromActions) { + return; + } return requestTreeResponse({ requestType: 'navigate', event, @@ -175,40 +188,63 @@ export function useTreeItem_unstable(props: TreeItemProps, ref: React.Ref ctx.layoutRef); const selectionMode = useTreeContext_unstable(ctx => ctx.selectionMode); + const navigationMode = useTreeContext_unstable(ctx => ctx.navigationMode ?? 'tree'); const [isActionsVisibleFromProps, onActionVisibilityChange]: [ TreeItemLayoutActionSlotProps['visible'], @@ -134,7 +135,7 @@ export const useTreeItemLayout_unstable = ( if (expandIcon) { expandIcon.ref = expandIconRefs; } - const arrowNavigationProps = useArrowNavigationGroup({ circular: true, axis: 'horizontal' }); + const arrowNavigationProps = useArrowNavigationGroup({ circular: navigationMode === 'tree', axis: 'horizontal' }); const actions = isActionsVisible ? slot.optional(props.actions, { defaultProps: { ...arrowNavigationProps, role: 'toolbar' }, @@ -143,6 +144,7 @@ export const useTreeItemLayout_unstable = ( : undefined; delete actions?.visible; delete actions?.onVisibilityChange; + const actionsRefs = useMergedRefs(actions?.ref, actionsRef, actionsRefInternal); const handleActionsBlur = useEventCallback((event: React.FocusEvent) => { if (isResolvedShorthand(props.actions)) { diff --git a/packages/react-components/react-tree/library/src/contexts/treeContext.ts b/packages/react-components/react-tree/library/src/contexts/treeContext.ts index 721a33fb67b3d5..e81a4868f0c5d4 100644 --- a/packages/react-components/react-tree/library/src/contexts/treeContext.ts +++ b/packages/react-components/react-tree/library/src/contexts/treeContext.ts @@ -21,6 +21,9 @@ export type TreeContextValue = { // FIXME: this is only marked as optional to avoid breaking changes // it should always be provided internally forceUpdateRovingTabIndex?(): void; + // FIXME: this is only marked as optional to avoid breaking changes + // it should always be provided internally + navigationMode?: 'tree' | 'treegrid'; }; export type TreeItemRequest = { itemType: TreeItemType } & ( @@ -43,6 +46,7 @@ const defaultTreeContextValue: TreeContextValue = { forceUpdateRovingTabIndex: noop, appearance: 'subtle', size: 'medium', + navigationMode: 'tree', }; function noop() { diff --git a/packages/react-components/react-tree/library/src/hooks/useFlatTreeNavigation.ts b/packages/react-components/react-tree/library/src/hooks/useFlatTreeNavigation.ts index cf1cbaf262e092..7c40d566b825ad 100644 --- a/packages/react-components/react-tree/library/src/hooks/useFlatTreeNavigation.ts +++ b/packages/react-components/react-tree/library/src/hooks/useFlatTreeNavigation.ts @@ -8,12 +8,16 @@ import { TreeItemValue } from '../TreeItem'; import { dataTreeItemValueAttrName } from '../utils/getTreeItemValueFromElement'; import * as React from 'react'; import { useHTMLElementWalkerRef } from './useHTMLElementWalkerRef'; +import { TreeNavigationMode } from '../components/Tree/Tree.types'; +import { useFocusFinders } from '@fluentui/react-tabster'; +import { treeItemLayoutClassNames } from '../TreeItemLayout'; -export function useFlatTreeNavigation() { +export function useFlatTreeNavigation(navigationMode: TreeNavigationMode = 'tree') { 'use no memo'; const { walkerRef, rootRef: walkerRootRef } = useHTMLElementWalkerRef(); const { rove, forceUpdate: forceUpdateRovingTabIndex, initialize: initializeRovingTabIndex } = useRovingTabIndex(); + const { findFirstFocusable } = useFocusFinders(); const rootRefCallback: React.RefCallback = React.useCallback( root => { @@ -35,6 +39,10 @@ export function useFlatTreeNavigation() { walkerRef.current.currentElement = data.target; return nextTypeAheadElement(walkerRef.current, data.event.key); case treeDataTypes.ArrowLeft: { + const actions = queryActions(data.target); + if (navigationMode === 'treegrid' && actions?.contains(data.target.ownerDocument.activeElement)) { + return data.target; + } const nextElement = parentElement(data.parentValue, walkerRef.current); if (!nextElement && process.env.NODE_ENV !== 'production') { // eslint-disable-next-line no-console @@ -50,6 +58,13 @@ export function useFlatTreeNavigation() { return nextElement; } case treeDataTypes.ArrowRight: { + if (navigationMode === 'treegrid') { + const actions = queryActions(data.target); + if (actions) { + findFirstFocusable(actions)?.focus(); + } + return null; + } walkerRef.current.currentElement = data.target; const nextElement = firstChild(data.target, walkerRef.current); if (!nextElement && process.env.NODE_ENV !== 'production') { @@ -114,3 +129,8 @@ function parentElement(parentValue: TreeItemValue | undefined, treeWalker: HTMLE } return treeWalker.root.querySelector(`[${dataTreeItemValueAttrName}="${parentValue}"]`); } + +const queryActions = (target: HTMLElement) => + target.querySelector( + `:scope > .${treeItemLayoutClassNames.root} > .${treeItemLayoutClassNames.actions}`, + ); diff --git a/packages/react-components/react-tree/library/src/hooks/useRootTree.ts b/packages/react-components/react-tree/library/src/hooks/useRootTree.ts index f127e698d88e29..f7714274176ce1 100644 --- a/packages/react-components/react-tree/library/src/hooks/useRootTree.ts +++ b/packages/react-components/react-tree/library/src/hooks/useRootTree.ts @@ -88,6 +88,7 @@ export function useRootTree( }, contextType: 'root', selectionMode, + navigationMode: props.navigationMode ?? 'tree', open: true, appearance, size, diff --git a/packages/react-components/react-tree/library/src/hooks/useTreeNavigation.ts b/packages/react-components/react-tree/library/src/hooks/useTreeNavigation.ts index 93e85bfbef4473..c6f82d19a7afc0 100644 --- a/packages/react-components/react-tree/library/src/hooks/useTreeNavigation.ts +++ b/packages/react-components/react-tree/library/src/hooks/useTreeNavigation.ts @@ -1,4 +1,4 @@ -import { TreeNavigationData_unstable } from '../components/Tree/Tree.types'; +import { TreeNavigationData_unstable, TreeNavigationMode } from '../components/Tree/Tree.types'; import { nextTypeAheadElement } from '../utils/nextTypeAheadElement'; import { treeDataTypes } from '../utils/tokens'; import { useRovingTabIndex } from './useRovingTabIndexes'; @@ -6,14 +6,17 @@ import { HTMLElementWalker } from '../utils/createHTMLElementWalker'; import * as React from 'react'; import { useHTMLElementWalkerRef } from './useHTMLElementWalkerRef'; import { useMergedRefs } from '@fluentui/react-utilities'; +import { treeItemLayoutClassNames } from '../TreeItemLayout'; +import { useFocusFinders } from '@fluentui/react-tabster'; /** * @internal */ -export function useTreeNavigation() { +export function useTreeNavigation(navigationMode: TreeNavigationMode = 'tree') { 'use no memo'; const { rove, initialize: initializeRovingTabIndex, forceUpdate: forceUpdateRovingTabIndex } = useRovingTabIndex(); + const { findFirstFocusable } = useFocusFinders(); const { walkerRef, rootRef: walkerRootRef } = useHTMLElementWalkerRef(); const rootRefCallback: React.RefCallback = React.useCallback( @@ -35,10 +38,22 @@ export function useTreeNavigation() { case treeDataTypes.TypeAhead: walkerRef.current.currentElement = data.target; return nextTypeAheadElement(walkerRef.current, data.event.key); - case treeDataTypes.ArrowLeft: + case treeDataTypes.ArrowLeft: { + const actions = queryActions(data.target); + if (navigationMode === 'treegrid' && actions?.contains(data.target.ownerDocument.activeElement)) { + return data.target; + } walkerRef.current.currentElement = data.target; return walkerRef.current.parentElement(); + } case treeDataTypes.ArrowRight: + if (navigationMode === 'treegrid') { + const actions = queryActions(data.target); + if (actions) { + findFirstFocusable(actions)?.focus(); + } + return null; + } walkerRef.current.currentElement = data.target; return walkerRef.current.firstChild(); case treeDataTypes.End: @@ -76,3 +91,8 @@ function lastChildRecursive(walker: HTMLElementWalker) { } return lastElement; } + +const queryActions = (target: HTMLElement) => + target.querySelector( + `:scope > .${treeItemLayoutClassNames.root} > .${treeItemLayoutClassNames.actions}`, + ); diff --git a/packages/react-components/react-tree/library/src/index.ts b/packages/react-components/react-tree/library/src/index.ts index bb0478843dcf80..eba48f9ef7c7bb 100644 --- a/packages/react-components/react-tree/library/src/index.ts +++ b/packages/react-components/react-tree/library/src/index.ts @@ -19,6 +19,7 @@ export type { TreeCheckedChangeEvent, TreeNavigationData_unstable, TreeNavigationEvent_unstable, + TreeNavigationMode, } from './Tree'; export { diff --git a/packages/react-components/react-tree/stories/src/Tree/TreeNavigationModeTreeGrid.stories.tsx b/packages/react-components/react-tree/stories/src/Tree/TreeNavigationModeTreeGrid.stories.tsx new file mode 100644 index 00000000000000..bbbae19d853bbe --- /dev/null +++ b/packages/react-components/react-tree/stories/src/Tree/TreeNavigationModeTreeGrid.stories.tsx @@ -0,0 +1,118 @@ +import * as React from 'react'; +import { Tree, TreeItem, TreeItemLayout, TreeItemProps } from '@fluentui/react-components'; +import { Edit20Regular, MoreHorizontal20Regular } from '@fluentui/react-icons'; +import { + Button, + Menu, + MenuItem, + MenuList, + MenuPopover, + MenuTrigger, + useRestoreFocusTarget, +} from '@fluentui/react-components'; + +type CustomTreeItemProps = TreeItemProps; + +const CustomTreeItem = ({ children, ...props }: CustomTreeItemProps) => { + const focusTargetAttribute = useRestoreFocusTarget(); + const [layoutChildren, subtree] = React.Children.toArray(children); + + // same items to be used between contextmenu and actions + const commonMenuItems = ( + <> + New + New Window + Open File + Open Folder + + ); + + return ( + + + + + + + } + > + {layoutChildren} + + {subtree} + + + + + Edit + {commonMenuItems} + + + + ); +}; + +export const NavigationModeTreeGrid = () => { + return ( + + + item 1 + + + item 1-1 + + item 1-1-1 + item 1-1-2 + item 1-1-3 + + + item 1-2 + item 1-3 + + + + item 2 + + + item 2-1 + + item 2-1-1 + + + + + item 3 + + item 3-1 + item 3-2 + item 3-3 + + + + + + ); +}; + +NavigationModeTreeGrid.parameters = { + docs: { + description: { + story: ` +If \`navigationMode\` is set to \`treegrid\`, the navigation pattern changes to allow navigation between tree items and their actions. + +1. If the treeitem is a branch and it's not expanded, pressing right arrow key will expand the treeitem. +2. If the treeitem is a branch and it's expanded, pressing right arrow key will navigate towards the actions of the treeitem. +3. If focused in the actions, pressing left arrow key will navigate back to the treeitem. +`, + }, + }, +}; diff --git a/packages/react-components/react-tree/stories/src/Tree/index.stories.tsx b/packages/react-components/react-tree/stories/src/Tree/index.stories.tsx index ac5aa99c24248b..cfbc1e6fd3973e 100644 --- a/packages/react-components/react-tree/stories/src/Tree/index.stories.tsx +++ b/packages/react-components/react-tree/stories/src/Tree/index.stories.tsx @@ -20,6 +20,7 @@ export { ExpandIcon } from './TreeExpandIcon.stories'; export { IconBeforeAndAfter } from './TreeIconBeforeAndAfter.stories'; export { Aside } from './TreeAside.stories'; export { Actions } from './TreeActions.stories'; +export { NavigationModeTreeGrid } from './TreeNavigationModeTreeGrid.stories'; // FUNCTIONAL EXAMPLES export { DefaultOpen } from './TreeDefaultOpen.stories';