diff --git a/change/@fluentui-react-tree-a0c03a32-7116-4e69-9da0-e5d936c3f433.json b/change/@fluentui-react-tree-a0c03a32-7116-4e69-9da0-e5d936c3f433.json new file mode 100644 index 0000000000000..dbd2c16c0d9a5 --- /dev/null +++ b/change/@fluentui-react-tree-a0c03a32-7116-4e69-9da0-e5d936c3f433.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: FlatTree supports navigation without useHeadlessFlatTree", + "packageName": "@fluentui/react-tree", + "email": "bernardo.sunderhus@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-tree/etc/react-tree.api.md b/packages/react-components/react-tree/etc/react-tree.api.md index 52d3cf32717ab..9835deb7f7d7a 100644 --- a/packages/react-components/react-tree/etc/react-tree.api.md +++ b/packages/react-components/react-tree/etc/react-tree.api.md @@ -256,6 +256,7 @@ export type TreeItemProps = ComponentProps> & { value?: TreeItemValue; open?: boolean; onOpenChange?: (e: TreeItemOpenChangeEvent, data: TreeItemOpenChangeData) => void; + parentValue?: TreeItemValue; }; // @public (undocumented) @@ -281,6 +282,7 @@ export type TreeItemValue = string | number; export type TreeNavigationData_unstable = { target: HTMLElement; value: TreeItemValue; + parentValue: TreeItemValue | undefined; } & ({ event: React_2.MouseEvent; type: 'Click'; diff --git a/packages/react-components/react-tree/src/components/FlatTree/useFlatTree.ts b/packages/react-components/react-tree/src/components/FlatTree/useFlatTree.ts index b3818ec229a86..3229f4a32c819 100644 --- a/packages/react-components/react-tree/src/components/FlatTree/useFlatTree.ts +++ b/packages/react-components/react-tree/src/components/FlatTree/useFlatTree.ts @@ -1,10 +1,70 @@ import * as React from 'react'; import { useRootTree } from '../../hooks/useRootTree'; -import { FlatTreeProps, FlatTreeState } from './FlatTree.types'; +import { FlatTreeProps, FlatTreeSlots, FlatTreeState } from './FlatTree.types'; +import { useFlatTreeNavigation } from './useFlatTreeNavigation'; +import { HTMLElementWalker, createHTMLElementWalker } from '../../utils/createHTMLElementWalker'; +import { useFluent_unstable } from '@fluentui/react-shared-contexts'; +import { treeItemFilter } from '../../utils/treeItemFilter'; +import { ExtractSlotProps, slot, useEventCallback, useMergedRefs } from '@fluentui/react-utilities'; +import type { TreeNavigationData_unstable, TreeNavigationEvent_unstable } from '../Tree/Tree.types'; +import { useTreeContext_unstable } from '../../contexts/treeContext'; +import { useSubtree } from '../../hooks/useSubtree'; + +export const useFlatTree_unstable: (props: FlatTreeProps, ref: React.Ref) => FlatTreeState = ( + props, + ref, +) => { + const level = useTreeContext_unstable(ctx => ctx.level); + // as level is static, this doesn't break rule of hooks + // and if this becomes an issue later on, this can be easily converted + // eslint-disable-next-line react-hooks/rules-of-hooks + return level > 1 ? useSubFlatTree(props, ref) : useRootFlatTree(props, ref); +}; + +function useRootFlatTree(props: FlatTreeProps, ref: React.Ref): FlatTreeState { + const { navigate, initialize } = useFlatTreeNavigation(); + const walkerRef = React.useRef(); + const { targetDocument } = useFluent_unstable(); + + const initializeWalker = React.useCallback( + (root: HTMLElement | null) => { + if (root && targetDocument) { + walkerRef.current = createHTMLElementWalker(root, targetDocument, treeItemFilter); + initialize(walkerRef.current); + } + }, + [initialize, targetDocument], + ); + + const handleNavigation = useEventCallback( + (event: TreeNavigationEvent_unstable, data: TreeNavigationData_unstable) => { + props.onNavigation?.(event, data); + if (walkerRef.current && !event.isDefaultPrevented()) { + navigate(data, walkerRef.current); + } + }, + ); -export const useFlatTree_unstable = (props: FlatTreeProps, ref: React.Ref): FlatTreeState => { return { treeType: 'flat', - ...useRootTree(props, ref), + ...useRootTree({ ...props, onNavigation: handleNavigation }, useMergedRefs(ref, initializeWalker)), }; -}; +} + +function useSubFlatTree(props: FlatTreeProps, ref: React.Ref): FlatTreeState { + if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line no-console + console.error(/* #__DE-INDENT__ */ ` + @fluentui/react-tree [useFlatTree]: + Subtrees are not allowed in a FlatTree! + You cannot use a component inside of another component. + `); + } + return { + ...useSubtree(props, ref), + open: false, + treeType: 'flat', + components: { root: React.Fragment }, + root: slot.always>(undefined, { elementType: React.Fragment }), + }; +} diff --git a/packages/react-components/react-tree/src/components/FlatTree/useFlatTreeNavigation.ts b/packages/react-components/react-tree/src/components/FlatTree/useFlatTreeNavigation.ts index 3c7b7133cba63..2af0fada4d7ad 100644 --- a/packages/react-components/react-tree/src/components/FlatTree/useFlatTreeNavigation.ts +++ b/packages/react-components/react-tree/src/components/FlatTree/useFlatTreeNavigation.ts @@ -1,15 +1,15 @@ import { useFluent_unstable } from '@fluentui/react-shared-contexts'; import { useEventCallback } from '@fluentui/react-utilities'; import { TreeNavigationData_unstable } from '../../Tree'; -import { HeadlessTree, HeadlessTreeItemProps } from '../../utils/createHeadlessTree'; import { nextTypeAheadElement } from '../../utils/nextTypeAheadElement'; import { treeDataTypes } from '../../utils/tokens'; import { treeItemFilter } from '../../utils/treeItemFilter'; import { useRovingTabIndex } from '../../hooks/useRovingTabIndexes'; -import { dataTreeItemValueAttrName, getTreeItemValueFromElement } from '../../utils/getTreeItemValueFromElement'; import { HTMLElementWalker } from '../../utils/createHTMLElementWalker'; +import { TreeItemValue } from '../../TreeItem'; +import { dataTreeItemValueAttrName } from '../../utils/getTreeItemValueFromElement'; -export function useFlatTreeNavigation(virtualTree: HeadlessTree) { +export function useFlatTreeNavigation() { const { targetDocument } = useFluent_unstable(); const { rove, initialize } = useRovingTabIndex(treeItemFilter); @@ -23,11 +23,39 @@ export function useFlatTreeNavigation(virtu case treeDataTypes.TypeAhead: walker.currentElement = data.target; return nextTypeAheadElement(walker, data.event.key); - case treeDataTypes.ArrowLeft: - return parentElement(virtualTree, data.target, walker); - case treeDataTypes.ArrowRight: + case treeDataTypes.ArrowLeft: { + const nextElement = parentElement(data.parentValue, walker); + if (!nextElement && process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.warn( + /* #__DE-INDENT__ */ ` + @fluentui/react-tree [useFlatTreeNavigation]: + \'ArrowLeft\' navigation was not possible. + No parent element found for the current element: + `, + data.target, + ); + } + return nextElement; + } + case treeDataTypes.ArrowRight: { walker.currentElement = data.target; - return firstChild(data.target, walker); + const nextElement = firstChild(data.target, walker); + if (!nextElement && process.env.NODE_ENV !== 'production') { + const ariaLevel = Number(data.target.getAttribute('aria-level')); + // eslint-disable-next-line no-console + console.warn( + /* #__DE-INDENT__ */ ` + @fluentui/react-tree [useFlatTreeNavigation]: + \'ArrowRight\' navigation was not possible. + No element with "aria-posinset=1" and "aria-level=${ariaLevel + 1}" + was found after the current element! + `, + data.target, + ); + } + return nextElement; + } case treeDataTypes.End: walker.currentElement = walker.root; return walker.lastChild(); @@ -65,20 +93,9 @@ function firstChild(target: HTMLElement, treeWalker: HTMLElementWalker): HTMLEle return null; } -function parentElement( - virtualTreeItems: HeadlessTree, - target: HTMLElement, - treeWalker: HTMLElementWalker, -) { - const value = getTreeItemValueFromElement(target); - if (value === null) { +function parentElement(parentValue: TreeItemValue | undefined, treeWalker: HTMLElementWalker) { + if (parentValue === undefined) { return null; } - const virtualTreeItem = virtualTreeItems.get(value); - if (virtualTreeItem?.parentValue) { - return treeWalker.root.querySelector( - `[${dataTreeItemValueAttrName}="${virtualTreeItem.parentValue}"]`, - ); - } - return null; + return treeWalker.root.querySelector(`[${dataTreeItemValueAttrName}="${parentValue}"]`); } diff --git a/packages/react-components/react-tree/src/components/FlatTree/useHeadlessFlatTree.ts b/packages/react-components/react-tree/src/components/FlatTree/useHeadlessFlatTree.ts index 8c7bca625084d..62820df0f66df 100644 --- a/packages/react-components/react-tree/src/components/FlatTree/useHeadlessFlatTree.ts +++ b/packages/react-components/react-tree/src/components/FlatTree/useHeadlessFlatTree.ts @@ -13,7 +13,6 @@ import { TreeCheckedChangeData, TreeCheckedChangeEvent, TreeNavigationData_unstable, - TreeNavigationEvent_unstable, TreeOpenChangeData, TreeOpenChangeEvent, TreeProps, @@ -119,7 +118,7 @@ export function useHeadlessFlatTree_unstable createHeadlessTree(props), [props]); const [openItems, setOpenItems] = useControllableOpenItems(options); const [checkedItems, setCheckedItems] = useFlatControllableCheckedItems(options, headlessTree); - const { initialize, navigate } = useFlatTreeNavigation(headlessTree); + const { initialize, navigate } = useFlatTreeNavigation(); const { targetDocument } = useFluent_unstable(); const walkerRef = React.useRef(); const initializeWalker = React.useCallback( @@ -151,15 +150,6 @@ export function useHeadlessFlatTree_unstable { - options.onNavigation?.(event, data); - if (walkerRef.current) { - navigate(data, walkerRef.current); - } - }, - ); - const getNextNavigableItem = useEventCallback( (visibleItems: HeadlessTreeItem[], data: TreeNavigationData_unstable) => { const item = headlessTree.get(data.value); @@ -198,10 +188,10 @@ export function useHeadlessFlatTree_unstable headlessTree.visibleItems(openItems), [openItems, headlessTree]); @@ -221,3 +211,7 @@ export function useHeadlessFlatTree_unstable; type: 'Click' } | { event: React.KeyboardEvent; type: 'TypeAhead' } | { event: React.KeyboardEvent; type: typeof ArrowRight } diff --git a/packages/react-components/react-tree/src/components/Tree/useTree.ts b/packages/react-components/react-tree/src/components/Tree/useTree.ts index 4d31b885387ef..29584ce51e884 100644 --- a/packages/react-components/react-tree/src/components/Tree/useTree.ts +++ b/packages/react-components/react-tree/src/components/Tree/useTree.ts @@ -34,6 +34,7 @@ function useNestedRootTree(props: TreeProps, ref: React.Ref): TreeS const { navigate, initialize } = useTreeNavigation(); const walkerRef = React.useRef(); const { targetDocument } = useFluent_unstable(); + const initializeWalker = React.useCallback( (root: HTMLElement | null) => { if (root && targetDocument) { @@ -65,7 +66,7 @@ function useNestedRootTree(props: TreeProps, ref: React.Ref): TreeS const handleNavigation = useEventCallback( (event: TreeNavigationEvent_unstable, data: TreeNavigationData_unstable) => { props.onNavigation?.(event, data); - if (walkerRef.current) { + if (walkerRef.current && !event.isDefaultPrevented()) { navigate(data, walkerRef.current); } }, diff --git a/packages/react-components/react-tree/src/components/TreeItem/TreeItem.types.ts b/packages/react-components/react-tree/src/components/TreeItem/TreeItem.types.ts index 801b73a0928e9..38a53044062ec 100644 --- a/packages/react-components/react-tree/src/components/TreeItem/TreeItem.types.ts +++ b/packages/react-components/react-tree/src/components/TreeItem/TreeItem.types.ts @@ -55,6 +55,10 @@ export type TreeItemProps = ComponentProps> & { */ open?: boolean; onOpenChange?: (e: TreeItemOpenChangeEvent, data: TreeItemOpenChangeData) => void; + /** + * This property is inferred through context on a nested tree, and required for a flat tree. + */ + parentValue?: TreeItemValue; }; /** diff --git a/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.tsx b/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.tsx index 1d83b02955e8e..77664c9908fa7 100644 --- a/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.tsx +++ b/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.tsx @@ -5,7 +5,7 @@ import { elementContains } from '@fluentui/react-portal'; import type { TreeItemProps, TreeItemState } from './TreeItem.types'; import { Space } from '@fluentui/keyboard-keys'; import { treeDataTypes } from '../../utils/tokens'; -import { useTreeContext_unstable } from '../../contexts/index'; +import { useTreeContext_unstable, useTreeItemContext_unstable } from '../../contexts/index'; import { dataTreeItemValueAttrName } from '../../utils/getTreeItemValueFromElement'; /** @@ -24,6 +24,7 @@ export function useTreeItem_unstable(props: TreeItemProps, ref: React.Ref ctx.requestTreeResponse); const contextLevel = useTreeContext_unstable(ctx => ctx.level); + const parentValue = useTreeItemContext_unstable(ctx => props.parentValue ?? ctx.value); // note, if the value is not externally provided, // then selection and expansion will not work properly @@ -79,6 +80,14 @@ export function useTreeItem_unstable(props: TreeItemProps, ref: React.Ref) { +function warnIfNoProperPropsFlatTreeItem( + props: Pick, +) { if (process.env.NODE_ENV !== 'production') { if ( props['aria-posinset'] === undefined || props['aria-setsize'] === undefined || - props['aria-level'] === undefined + props['aria-level'] === undefined || + (props.parentValue === undefined && props['aria-level'] !== 1) ) { // eslint-disable-next-line no-console console.error(/** #__DE-INDENT__ */ ` @fluentui/react-tree [${useTreeItem_unstable.name}]: - A flat treeitem must have "aria-posinset", "aria-setsize", "aria-level" to ensure a11y and navigation. + A flat treeitem must have "aria-posinset", "aria-setsize", "aria-level" + and "parentValue" (if "aria-level" > 1) to ensure a11y and navigation. - "aria-posinset": the position of this treeitem in the current level of the tree. - "aria-setsize": the number of siblings in this level of the tree. - "aria-level": the current level of the treeitem. + - "parentValue": the "value" property of the parent item of this item. `); } } diff --git a/packages/react-components/react-tree/src/contexts/treeContext.ts b/packages/react-components/react-tree/src/contexts/treeContext.ts index e1d2c5826510d..dab2497d6bd1c 100644 --- a/packages/react-components/react-tree/src/contexts/treeContext.ts +++ b/packages/react-components/react-tree/src/contexts/treeContext.ts @@ -20,9 +20,9 @@ export type TreeContextValue = { }; export type TreeItemRequest = { itemType: TreeItemType } & ( - | OmitWithoutExpanding - | OmitWithoutExpanding - | OmitWithoutExpanding + | (OmitWithoutExpanding & { requestType: 'open' }) + | (TreeNavigationData_unstable & { requestType: 'navigate' }) + | (OmitWithoutExpanding & { requestType: 'selection' }) ); // helper type that avoids the expansion of unions while inferring it, should work exactly the same as Omit diff --git a/packages/react-components/react-tree/src/hooks/useRootTree.ts b/packages/react-components/react-tree/src/hooks/useRootTree.ts index 8fac7ac1fa257..b409a61a523ed 100644 --- a/packages/react-components/react-tree/src/hooks/useRootTree.ts +++ b/packages/react-components/react-tree/src/hooks/useRootTree.ts @@ -7,11 +7,11 @@ import type { TreeState, } from '../Tree'; import * as React from 'react'; -import * as ReactDOM from 'react-dom'; import { TreeItemRequest } from '../contexts/treeContext'; import { createOpenItems } from '../utils/createOpenItems'; import { createCheckedItems } from '../utils/createCheckedItems'; import { treeDataTypes } from '../utils/tokens'; +import { createNextOpenItems } from './useControllableOpenItems'; /** * Create the state required to render the root level tree. @@ -42,7 +42,13 @@ export function useRootTree( const openItems = React.useMemo(() => createOpenItems(props.openItems), [props.openItems]); const checkedItems = React.useMemo(() => createCheckedItems(props.checkedItems), [props.checkedItems]); - const requestOpenChange = (data: TreeOpenChangeData) => props.onOpenChange?.(data.event, data); + const requestOpenChange = (data: TreeOpenChangeData) => { + const nextOpenItems = createNextOpenItems(data, openItems); + props.onOpenChange?.(data.event, { + ...data, + openItems: nextOpenItems.dangerouslyGetInternalSet_unstable(), + }); + }; const requestCheckedChange = (data: TreeCheckedChangeData) => props.onCheckedChange?.(data.event, data); @@ -60,65 +66,21 @@ export function useRootTree( }; const requestTreeResponse = useEventCallback((request: TreeItemRequest) => { - switch (request.type) { - case treeDataTypes.Click: - case treeDataTypes.ExpandIconClick: { - return ReactDOM.unstable_batchedUpdates(() => { - requestOpenChange({ - ...request, - open: request.itemType === 'branch' && !openItems.has(request.value), - openItems: openItems.dangerouslyGetInternalSet_unstable(), - }); - requestNavigation({ ...request, type: treeDataTypes.Click }); - }); - } - case treeDataTypes.ArrowRight: { - if (request.itemType === 'leaf') { - return; - } - const open = openItems.has(request.value); - if (!open) { - return requestOpenChange({ - ...request, - open: true, - openItems: openItems.dangerouslyGetInternalSet_unstable(), - }); - } + switch (request.requestType) { + case 'navigate': return requestNavigation(request); - } - case treeDataTypes.Enter: { - const open = openItems.has(request.value); + case 'open': return requestOpenChange({ ...request, - open: request.itemType === 'branch' && !open, + open: request.itemType === 'branch' && !openItems.has(request.value), openItems: openItems.dangerouslyGetInternalSet_unstable(), }); - } - case treeDataTypes.ArrowLeft: { - const open = openItems.has(request.value); - if (open && request.itemType === 'branch') { - return requestOpenChange({ - ...request, - open: false, - type: treeDataTypes.ArrowLeft, - openItems: openItems.dangerouslyGetInternalSet_unstable(), - }); - } - return requestNavigation({ ...request, type: treeDataTypes.ArrowLeft }); - } - case treeDataTypes.End: - case treeDataTypes.Home: - case treeDataTypes.ArrowUp: - case treeDataTypes.ArrowDown: - case treeDataTypes.TypeAhead: - return requestNavigation({ ...request, target: request.event.currentTarget }); - case treeDataTypes.Change: { + case 'selection': return requestCheckedChange({ ...request, selectionMode: selectionMode as SelectionMode, checkedItems: checkedItems.dangerouslyGetInternalMap_unstable(), } as TreeCheckedChangeData); - } } }); diff --git a/packages/react-components/react-tree/src/utils/createHeadlessTree.ts b/packages/react-components/react-tree/src/utils/createHeadlessTree.ts index 79f796982f719..42c795cabeada 100644 --- a/packages/react-components/react-tree/src/utils/createHeadlessTree.ts +++ b/packages/react-components/react-tree/src/utils/createHeadlessTree.ts @@ -20,7 +20,7 @@ export type HeadlessTreeItem = { parentValue: TreeItemValue | undefined; itemType: TreeItemType; getTreeItemProps(): Required> & - Omit; + Props; }; /** @@ -122,6 +122,7 @@ export function createHeadlessTree( value: props.value, getTreeItemProps: () => ({ ...propsWithoutParentValue, + parentValue, 'aria-level': item.level, 'aria-posinset': item.position, 'aria-setsize': parentItem.childrenValues.length, @@ -187,6 +188,7 @@ function createHeadlessTreeRootItem(): HeadlessTreeItem { } return { id: headlessTreeRootId, + parentValue: undefined, value: headlessTreeRootId, 'aria-setsize': -1, 'aria-level': -1, diff --git a/packages/react-components/react-tree/stories/Tree/FlatTree.stories.tsx b/packages/react-components/react-tree/stories/Tree/FlatTree.stories.tsx index 45714acc1d746..5aa77554cce9e 100644 --- a/packages/react-components/react-tree/stories/Tree/FlatTree.stories.tsx +++ b/packages/react-components/react-tree/stories/Tree/FlatTree.stories.tsx @@ -2,131 +2,48 @@ import * as React from 'react'; import { FlatTree, FlatTreeItem, - // flattenTree_unstable, - // TreeItemProps, TreeItemLayout, - useHeadlessFlatTree_unstable, - HeadlessFlatTreeItemProps, + TreeItemValue, + TreeOpenChangeData, + TreeOpenChangeEvent, } from '@fluentui/react-components'; -import { - Button, - Menu, - MenuItem, - MenuList, - MenuPopover, - MenuTrigger, - useRestoreFocusTarget, -} from '@fluentui/react-components'; -import { Edit20Regular, MoreHorizontal20Regular } from '@fluentui/react-icons'; - -type FlatItem = HeadlessFlatTreeItemProps & { content: string }; - -const flatTreeItems: FlatItem[] = [ - { value: '1', content: 'Level 1, item 1' }, - { value: '1-1', parentValue: '1', content: 'Level 2, item 1' }, - { value: '1-2', parentValue: '1', content: 'Level 2, item 2' }, - { value: '1-3', parentValue: '1', content: 'Level 2, item 3' }, - { value: '2', content: 'Level 1, item 2' }, - { value: '2-1', parentValue: '2', content: 'Level 2, item 1' }, - { value: '2-1-1', parentValue: '2-1', content: 'Level 3, item 1' }, - { value: '2-1-1-1', parentValue: '2-1-1', content: 'Level 4, item 1' }, -]; - -// // EXAMPLE OF NESTED TREE ITEMS BEING FLATTEN BY `flattenTree`: -// type Item = TreeItemProps & { content: React.ReactNode }; - -// const nestedTreeItems = [ -// { -// value: '1', -// content: <>level 1, item 1, -// subtree: [ -// { -// value: '1-1', -// content: <>level 2, item 1, -// }, -// { -// value: '1-2', -// content: <>level 2, item 2, -// }, -// { -// value: '1-3', -// content: <>level 2, item 3, -// }, -// ], -// }, -// { -// value: '2', -// content: <>level 1, item 2, -// subtree: [ -// { -// value: '2-1', -// content: <>level 2, item 1, -// subtree: [ -// { -// value: '2-1-1', -// content: <>level 3, item 1, -// subtree: [ -// { -// value: '2-1-1-1', -// content: <>level 4, item 1, -// }, -// ], -// }, -// ], -// }, -// ], -// }, -// ]; - -// const flatTreeItems = flattenTree_unstable(nestedTreeItems); - -const ActionsExample = () => ( - <> -