From a4c00f5af87094409ad326ac4078ce0c42523645 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Fri, 18 Apr 2025 10:45:22 +0000 Subject: [PATCH 1/3] Teams selection mode --- .../library/src/components/Tree/Tree.types.ts | 4 +- .../library/src/components/Tree/useTree.ts | 1 + .../src/components/TreeItem/useTreeItem.tsx | 16 ++- .../library/src/hooks/useTreeNavigation.ts | 17 ++- .../stories/src/Tree/TreeDefault.stories.tsx | 113 +++++++++++++----- 5 files changed, 114 insertions(+), 37 deletions(-) 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 92f6f8824ee01..abf3667251424 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 @@ -3,7 +3,7 @@ import type { PresenceMotionSlotProps } from '@fluentui/react-motion'; import type { ComponentProps, ComponentState, SelectionMode, Slot } from '@fluentui/react-utilities'; import type { TreeContextValue, SubtreeContextValue } from '../../contexts'; import type { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, End, Enter, Home } from '@fluentui/keyboard-keys'; -import type { TreeItemValue } from '../TreeItem/TreeItem.types'; +import type { TreeItemType, TreeItemValue } from '../TreeItem/TreeItem.types'; import { CheckboxProps } from '@fluentui/react-checkbox'; import { RadioProps } from '@fluentui/react-radio'; @@ -146,6 +146,8 @@ export type TreeProps = ComponentProps & { */ onNavigation?(event: TreeNavigationEvent_unstable, data: TreeNavigationDataParam): void; + onNavigationIn?(event: React.SyntheticEvent, data: { value: TreeItemValue; itemType: TreeItemType }): void; + /** * This refers to the selection mode of the tree. * - undefined: No selection can be done. 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 625dd6d5fe299..9f8f701119aab 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 @@ -47,6 +47,7 @@ function useNestedRootTree(props: TreeProps, ref: React.Ref): TreeS if (!event.isDefaultPrevented()) { navigation.navigate(data, { preventScroll: data.isScrollPrevented(), + onNavigateIn: props.onNavigationIn, }); } }), 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 a458bb60a70dd..a26b05d6fa148 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 @@ -68,6 +68,20 @@ export function useTreeItem_unstable(props: TreeItemProps, ref: React.Ref(null); const treeItemRef = React.useRef(null); + const treeItemPropertiesRef = React.useCallback( + (el: HTMLElement | null) => { + if (el) { + // TODO use a symbol to hide this from public API + // @ts-expect-error + el._treeItem = { + value, + itemType, + }; + } + }, + [value, itemType], + ); + if (process.env.NODE_ENV !== 'production') { // This is acceptable since the NODE_ENV will not change during runtime @@ -331,7 +345,7 @@ export function useTreeItem_unstable(props: TreeItemProps, ref: React.Ref { + const [checked, setChecked] = React.useState(['maxMustermann']); + const onCheckedChange: TreeProps['onCheckedChange'] = (e, data) => { + setChecked([data.value as string]); + }; + + const onNavigationIn: TreeProps['onNavigationIn'] = (e, data) => { + console.log(data); + if (data.itemType === 'leaf') { + setChecked([data.value as string]); + } + }; + + // This should be built into an enhanced tree item + const onClick = (e: React.MouseEvent) => { + const treeItem = e.nativeEvent.composedPath().find(el => (el as HTMLElement).getAttribute('role') === 'treeitem'); + // @ts-expect-error + const value = treeItem._treeItem?.value; + // @ts-expect-error + const itemType = treeItem._treeItem?.itemType; + if (value && itemType === 'leaf') { + setChecked([value]); + } + }; + return ( - - - level 1, item 1 - - - level 2, item 1 - - - level 2, item 2 - - - level 2, item 3 - - - - - level 1, item 2 - - - level 2, item 1 - - - level 3, item 1 - - - - - - - level 1, item 3 - - + <> +
Selected: {checked[0]}
+ + + Favorites + + + Max Mustermann + + + John Doe + + + Pierre Dupont + + + + + Chats + + + Jan Novak + + + Jane Doe + + + Erika Mustermann + + + + + Teams and Channels + + + General + + + Eng planning + + + PM discussion + + + + + ); }; From 75d07c5d4e14bf60eb0ae6cf73bab38d7c8f7c11 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Fri, 18 Apr 2025 10:53:02 +0000 Subject: [PATCH 2/3] select item imperatively --- .../stories/src/Tree/TreeDefault.stories.tsx | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/react-components/react-tree/stories/src/Tree/TreeDefault.stories.tsx b/packages/react-components/react-tree/stories/src/Tree/TreeDefault.stories.tsx index 0fb704a700cb7..b2734f3032219 100644 --- a/packages/react-components/react-tree/stories/src/Tree/TreeDefault.stories.tsx +++ b/packages/react-components/react-tree/stories/src/Tree/TreeDefault.stories.tsx @@ -1,8 +1,21 @@ import * as React from 'react'; -import { Tree, TreeItem, TreeItemLayout, TreeProps } from '@fluentui/react-components'; +import { Tree, TreeItem, TreeItemLayout, TreeProps, Select, Button } from '@fluentui/react-components'; + +const values = [ + 'maxMustermann', + 'johnDoe', + 'pierreDupont', + 'janNovak', + 'janeDoe', + 'erikaMustermann', + 'general', + 'engPlanning', + 'pmDiscussion', +]; export const Default = () => { const [checked, setChecked] = React.useState(['maxMustermann']); + const [itemToSelect, setItemToSelect] = React.useState('maxMustermann'); const onCheckedChange: TreeProps['onCheckedChange'] = (e, data) => { setChecked([data.value as string]); }; @@ -29,6 +42,14 @@ export const Default = () => { return ( <>
Selected: {checked[0]}
+
+ + +
Date: Fri, 18 Apr 2025 11:26:29 +0000 Subject: [PATCH 3/3] handle focus for imperative select --- .../library/src/components/Tree/Tree.types.ts | 6 +++++ .../library/src/components/Tree/useTree.ts | 11 +++++++++ .../library/src/hooks/useTreeNavigation.ts | 24 ++++++++++++++++++- .../stories/src/Tree/TreeDefault.stories.tsx | 8 +++++++ 4 files changed, 48 insertions(+), 1 deletion(-) 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 abf3667251424..77be380a3201d 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 @@ -172,8 +172,14 @@ export type TreeProps = ComponentProps & { * such as checked value and type of interaction that created the event. */ onCheckedChange?(event: TreeCheckedChangeEvent, data: TreeCheckedChangeData): void; + + imperativeRef?: React.RefObject; }; +interface TreeImperativeRef { + focus(value: TreeItemValue): void; +} + /** * State used in rendering Tree */ 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 9f8f701119aab..3f6c1a33754cd 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 @@ -10,6 +10,7 @@ import { useTreeNavigation } from '../../hooks/useTreeNavigation'; import { useTreeContext_unstable } from '../../contexts/treeContext'; import { ImmutableSet } from '../../utils/ImmutableSet'; import { ImmutableMap } from '../../utils/ImmutableMap'; +import { TreeItemValue } from '../TreeItem/TreeItem.types'; export const useTree_unstable = (props: TreeProps, ref: React.Ref): TreeState => { 'use no memo'; @@ -28,6 +29,16 @@ function useNestedRootTree(props: TreeProps, ref: React.Ref): TreeS const checkedItems = useNestedCheckedItems(props); const navigation = useTreeNavigation(props.navigationMode); + React.useImperativeHandle( + props.imperativeRef, + () => ({ + focus: (value: TreeItemValue) => { + navigation.focusOnItem(value); + }, + }), + [navigation], + ); + return Object.assign( useRootTree( { 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 2db39b9c58820..ae1915b63513d 100644 --- a/packages/react-components/react-tree/library/src/hooks/useTreeNavigation.ts +++ b/packages/react-components/react-tree/library/src/hooks/useTreeNavigation.ts @@ -8,7 +8,7 @@ import { useHTMLElementWalkerRef } from './useHTMLElementWalkerRef'; import { useMergedRefs } from '@fluentui/react-utilities'; import { treeItemLayoutClassNames } from '../TreeItemLayout'; import { useFocusFinders } from '@fluentui/react-tabster'; -import { TreeItemProps, TreeItemType, TreeItemValue } from '../TreeItem'; +import { TreeItemType, TreeItemValue } from '../TreeItem'; /** * @internal @@ -89,10 +89,32 @@ export function useTreeNavigation(navigationMode: TreeNavigationMode = 'tree') { rove(nextElement, focusOptions); } } + + function focusOnItem(value: TreeItemValue) { + if (!walkerRef.current) { + return; + } + const walker = walkerRef.current; + walker.currentElement = walker.root; + let cur = walker.firstChild(); + while (cur) { + // @ts-expect-error + if (cur._treeItem?.value === value) { + break; + } + + cur = walker.nextElement(); + } + + if (cur) { + rove(cur); + } + } return { navigate, treeRef: useMergedRefs(walkerRootRef, rootRefCallback) as React.RefCallback, forceUpdateRovingTabIndex, + focusOnItem, } as const; } diff --git a/packages/react-components/react-tree/stories/src/Tree/TreeDefault.stories.tsx b/packages/react-components/react-tree/stories/src/Tree/TreeDefault.stories.tsx index b2734f3032219..388034368eb69 100644 --- a/packages/react-components/react-tree/stories/src/Tree/TreeDefault.stories.tsx +++ b/packages/react-components/react-tree/stories/src/Tree/TreeDefault.stories.tsx @@ -39,6 +39,13 @@ export const Default = () => { } }; + const imperativeRef: NonNullable = React.useRef(null); + + const selectedItem = checked[0]; + React.useEffect(() => { + imperativeRef.current?.focus(selectedItem); + }, [selectedItem]); + return ( <>
Selected: {checked[0]}
@@ -51,6 +58,7 @@ export const Default = () => {