From 08bd72ece5103881d66d809bb4bf443c6bb7f679 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Wed, 26 Jul 2023 13:27:45 +0000 Subject: [PATCH] chore: improves internal headless signature --- ...-b70a6941-a063-49d6-a196-4dbd7abfd90c.json | 7 + .../react-tree/etc/react-tree.api.md | 2 +- .../FlatTree/useHeadlessFlatTree.ts | 16 +- .../src/contexts/treeItemContext.ts | 3 +- .../src/hooks/useRovingTabIndexes.ts | 4 - .../src/utils/createHeadlessTree.ts | 224 ++++++++++++------ 6 files changed, 167 insertions(+), 89 deletions(-) create mode 100644 change/@fluentui-react-tree-b70a6941-a063-49d6-a196-4dbd7abfd90c.json diff --git a/change/@fluentui-react-tree-b70a6941-a063-49d6-a196-4dbd7abfd90c.json b/change/@fluentui-react-tree-b70a6941-a063-49d6-a196-4dbd7abfd90c.json new file mode 100644 index 00000000000000..2cf95314afd330 --- /dev/null +++ b/change/@fluentui-react-tree-b70a6941-a063-49d6-a196-4dbd7abfd90c.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "chore: improves internal headless signature", + "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 5ad74baae6fc02..9d65dc26c4a87b 100644 --- a/packages/react-components/react-tree/etc/react-tree.api.md +++ b/packages/react-components/react-tree/etc/react-tree.api.md @@ -332,7 +332,7 @@ export const useFlatTree_unstable: (props: FlatTreeProps, ref: React_2.Ref TreeState; // @public -export function useHeadlessFlatTree_unstable(flatTreeItemProps: Props[], options?: HeadlessFlatTreeOptions): HeadlessFlatTree; +export function useHeadlessFlatTree_unstable(props: Props[], options?: HeadlessFlatTreeOptions): HeadlessFlatTree; // @public (undocumented) export const useTree_unstable: (props: TreeProps, ref: React_2.Ref) => TreeState; 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 d646efeae8ae7f..1cd0f2bbae210f 100644 --- a/packages/react-components/react-tree/src/components/FlatTree/useHeadlessFlatTree.ts +++ b/packages/react-components/react-tree/src/components/FlatTree/useHeadlessFlatTree.ts @@ -104,17 +104,17 @@ export type HeadlessFlatTreeOptions = Pick< * It should be used on cases where more complex interactions with a Tree is required. * On simple scenarios it is advised to simply use a nested structure instead. * - * @param flatTreeItemProps - a list of tree items + * @param props - a list of tree items * @param options - in case control over the internal openItems is required */ export function useHeadlessFlatTree_unstable( - flatTreeItemProps: Props[], + props: Props[], options: HeadlessFlatTreeOptions = {}, ): HeadlessFlatTree { - const flatTreeItems = React.useMemo(() => createHeadlessTree(flatTreeItemProps), [flatTreeItemProps]); + const headlessTree = React.useMemo(() => createHeadlessTree(props), [props]); const [openItems, setOpenItems] = useControllableOpenItems(options); const [checkedItems, setCheckedItems] = useFlatControllableCheckedItems(options); - const [navigate, navigationRef] = useFlatTreeNavigation(flatTreeItems); + const [navigate, navigationRef] = useFlatTreeNavigation(headlessTree); const treeRef = React.useRef(null); const handleOpenChange = useEventCallback((event: TreeOpenChangeEvent, data: TreeOpenChangeData) => { options.onOpenChange?.(event, data); @@ -123,7 +123,7 @@ export function useHeadlessFlatTree_unstable { options.onCheckedChange?.(event, data); - setCheckedItems(createNextFlatCheckedItems(data, checkedItems, flatTreeItems)); + setCheckedItems(createNextFlatCheckedItems(data, checkedItems, headlessTree)); }); const handleNavigation = useEventCallback( @@ -135,13 +135,13 @@ export function useHeadlessFlatTree_unstable[], data: TreeNavigationData_unstable) => { - const item = flatTreeItems.get(data.value); + const item = headlessTree.get(data.value); if (item) { switch (data.type) { case treeDataTypes.TypeAhead: return item; case treeDataTypes.ArrowLeft: - return flatTreeItems.get(item.parentValue!); + return headlessTree.get(item.parentValue!); case treeDataTypes.ArrowRight: return visibleItems[item.index + 1]; case treeDataTypes.End: @@ -178,7 +178,7 @@ export function useHeadlessFlatTree_unstable flatTreeItems.visibleItems(openItems), [openItems, flatTreeItems]); + const items = React.useCallback(() => headlessTree.visibleItems(openItems), [openItems, headlessTree]); return React.useMemo>( () => ({ navigate, getTreeProps, getNextNavigableItem, getElementFromItem, items }), diff --git a/packages/react-components/react-tree/src/contexts/treeItemContext.ts b/packages/react-components/react-tree/src/contexts/treeItemContext.ts index f1ca331d24d7e1..9e9aa07143a9eb 100644 --- a/packages/react-components/react-tree/src/contexts/treeItemContext.ts +++ b/packages/react-components/react-tree/src/contexts/treeItemContext.ts @@ -1,6 +1,7 @@ import * as React from 'react'; import { Context, ContextSelector, createContext, useContextSelector } from '@fluentui/react-context-selector'; import type { TreeItemType, TreeItemValue } from '../TreeItem'; +import { virtualTreeRootId } from '../utils/createHeadlessTree'; export type TreeItemContextValue = { isActionsVisible: boolean; @@ -16,7 +17,7 @@ export type TreeItemContextValue = { }; const defaultContextValue: TreeItemContextValue = { - value: '', + value: virtualTreeRootId, selectionRef: React.createRef(), layoutRef: React.createRef(), subtreeRef: React.createRef(), diff --git a/packages/react-components/react-tree/src/hooks/useRovingTabIndexes.ts b/packages/react-components/react-tree/src/hooks/useRovingTabIndexes.ts index fb8c6aee930f0b..dfa3b4f295f9bd 100644 --- a/packages/react-components/react-tree/src/hooks/useRovingTabIndexes.ts +++ b/packages/react-components/react-tree/src/hooks/useRovingTabIndexes.ts @@ -28,10 +28,6 @@ export function useRovingTabIndex(filter?: HTMLElementFilter) { walker.currentElement = walker.root; tabbableChild ??= walker.firstChild(); if (!tabbableChild) { - if (process.env.NODE_ENV === 'development') { - // eslint-disable-next-line no-console - console.warn('useRovingTabIndexes: internal error, no tabbable element was found'); - } return; } tabbableChild.tabIndex = 0; diff --git a/packages/react-components/react-tree/src/utils/createHeadlessTree.ts b/packages/react-components/react-tree/src/utils/createHeadlessTree.ts index f84654ccc56b91..e18ad358357f1a 100644 --- a/packages/react-components/react-tree/src/utils/createHeadlessTree.ts +++ b/packages/react-components/react-tree/src/utils/createHeadlessTree.ts @@ -12,11 +12,13 @@ export type HeadlessTreeItemProps = Omit & * `createHeadlessTree` but with extra information that might be useful on virtual tree scenarios */ export type HeadlessTreeItem = { - index: number; level: number; + index: number; + position: number; childrenValues: TreeItemValue[]; value: TreeItemValue; parentValue: TreeItemValue | undefined; + itemType: TreeItemType; getTreeItemProps(): Required> & Omit; }; @@ -25,15 +27,62 @@ export type HeadlessTreeItem = { * @internal */ export type HeadlessTree = { - size: number; + /** + * the number of items in the virtual tree + */ + readonly size: number; + /** + * the root item of the virtual tree + */ root: HeadlessTreeItem; - get(key: TreeItemValue): HeadlessTreeItem | undefined; - getParent(key: TreeItemValue): HeadlessTreeItem; - getByIndex(index: number): HeadlessTreeItem; - subtree(key: TreeItemValue): IterableIterator>; - children(key: TreeItemValue): IterableIterator>; + /** + * method to get a virtual tree item by its value + * @param key the key of the item to get + */ + get(value: TreeItemValue): HeadlessTreeItem | undefined; + /** + * method to check if a virtual tree item exists by its value + * @param value the value of the item to check if exists + */ + has(value: TreeItemValue): boolean; + /** + * method to add a new virtual tree item to the virtual tree + * @param props the props of the item to add + */ + add(props: Props): void; + /** + * method to remove a virtual tree item from the virtual tree. + * When an item is removed: + * 1. all its children are also removed + * 2. all its siblings are repositioned + * @param value the value of the item to remove + */ + // remove(value: TreeItemValue): void; + /** + * method to get the parent of a virtual tree item by its value + * @param value the value of the item to get the parent from + */ + getParent(value: TreeItemValue): HeadlessTreeItem; + /** + * method to get the subtree of a virtual tree item by its value + * @param value the value of the item to get the subtree from + */ + subtree(value: TreeItemValue): IterableIterator>; + /** + * method to get the children of a virtual tree item by its value + * @param value the value of the item to get the children from + */ + children(value: TreeItemValue): IterableIterator>; + /** + * method to get the visible items of a virtual tree + * @param openItems the open items of the tree + */ visibleItems(openItems: ImmutableSet): IterableIterator>; - ancestors(key: TreeItemValue): IterableIterator>; + /** + * method to get the ancestors of a virtual tree item by its value + * @param value the value of the item to get the ancestors from + */ + ancestors(value: TreeItemValue): IterableIterator>; }; /** @@ -41,63 +90,83 @@ export type HeadlessTree = { * and provides a map to access each item by id */ export function createHeadlessTree( - virtualTreeItemProps: Props[], + initialProps: Props[] = [], ): HeadlessTree { const root = createHeadlessTreeRootItem(); const itemsPerValue = new Map>([[root.value, root]]); - const items: HeadlessTreeItem[] = []; - - for (let index = 0; index < virtualTreeItemProps.length; index++) { - const { parentValue = virtualTreeRootId, ...treeItemProps } = virtualTreeItemProps[index]; - - const nextItemProps: Props | undefined = virtualTreeItemProps[index + 1]; - const currentParent = itemsPerValue.get(parentValue); - if (!currentParent) { - if (process.env.NODE_ENV === 'development') { - // eslint-disable-next-line no-console - console.error( - `useHeadlessTree: item ${virtualTreeItemProps[index].value} is wrongly positioned, did you properly ordered provided item props? make sure provided items are organized`, - ); - } - break; - } - const itemType = - treeItemProps.itemType ?? - (treeItemProps.value === undefined || nextItemProps?.parentValue !== treeItemProps.value ? 'leaf' : 'branch'); - const currentLevel = (currentParent.level ?? 0) + 1; - const virtualTreeItem: HeadlessTreeItem = { - value: treeItemProps.value, - getTreeItemProps: () => ({ - ...treeItemProps, - 'aria-level': currentLevel, - 'aria-posinset': currentChildrenSize, - 'aria-setsize': currentParent.childrenValues.length, - itemType, - }), - level: currentLevel, - parentValue, - childrenValues: [], - index: -1, - }; - const currentChildrenSize = currentParent.childrenValues.push(virtualTreeItem.value); - itemsPerValue.set(virtualTreeItem.value, virtualTreeItem); - items.push(virtualTreeItem); - } - - const virtualTreeItems: HeadlessTree = { + const headlessTree: HeadlessTree = { root, - size: items.length, - getByIndex: index => items[index], + get size() { + return itemsPerValue.size; + }, getParent: key => itemsPerValue.get(itemsPerValue.get(key)?.parentValue ?? root.value) ?? root, get: key => itemsPerValue.get(key), - subtree: key => HeadlessTreeSubtreeGenerator(key, virtualTreeItems), - children: key => HeadlessTreeChildrenGenerator(key, virtualTreeItems), - ancestors: key => HeadlessTreeAncestorsGenerator(key, virtualTreeItems), - visibleItems: openItems => HeadlessTreeVisibleItemsGenerator(openItems, virtualTreeItems), + has: key => itemsPerValue.has(key), + add(props) { + const { parentValue = virtualTreeRootId, ...propsWithoutParentValue } = props; + const parentItem = itemsPerValue.get(parentValue); + if (!parentItem) { + if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line no-console + console.error( + `HeadlessTree: item ${props.value} is wrongly positioned, did you properly ordered provided item props? make sure provided items are organized, parents should come before children`, + ); + } + return; + } + parentItem.itemType = 'branch'; + + const item: HeadlessTreeItem = { + value: props.value, + getTreeItemProps: () => ({ + ...propsWithoutParentValue, + 'aria-level': item.level, + 'aria-posinset': item.position, + 'aria-setsize': parentItem.childrenValues.length, + itemType: item.itemType, + }), + itemType: 'leaf', + level: parentItem.level + 1, + parentValue, + childrenValues: [], + index: -1, + position: parentItem.childrenValues.push(props.value), + }; + itemsPerValue.set(item.value, item); + }, + // TODO: eventually it would be nice to have this method exported for the user to modify + // the internal state of the virtual tree + // remove(value) { + // const itemToBeRemoved = itemsPerValue.get(value); + // if (!itemToBeRemoved) { + // return; + // } + // const parentItem = headlessTree.getParent(value); + // parentItem.childrenValues.splice(itemToBeRemoved.position, 1); + // itemsPerValue.delete(value); + // if (parentItem.childrenValues.length === 0) { + // parentItem.itemType = 'leaf'; + // } + // for (let index = itemToBeRemoved.position; index < parentItem.childrenValues.length; index++) { + // const child = itemsPerValue.get(parentItem.childrenValues[index]); + // if (child) { + // child.position = index + 1; + // } + // } + // for (const descendant of HeadlessTreeSubtreeGenerator(value, headlessTree)) { + // itemsPerValue.delete(descendant.value); + // } + // }, + subtree: key => HeadlessTreeSubtreeGenerator(key, headlessTree), + children: key => HeadlessTreeChildrenGenerator(key, headlessTree), + ancestors: key => HeadlessTreeAncestorsGenerator(key, headlessTree), + visibleItems: openItems => HeadlessTreeVisibleItemsGenerator(openItems, headlessTree), }; - return virtualTreeItems as HeadlessTree; + initialProps.forEach(headlessTree.add); + + return headlessTree as HeadlessTree; } export const virtualTreeRootId = '__fuiHeadlessTreeRoot'; @@ -106,10 +175,11 @@ function createHeadlessTreeRootItem(): HeadlessTreeItem { return { parentValue: undefined, value: virtualTreeRootId, + itemType: 'branch', getTreeItemProps: () => { if (process.env.NODE_ENV !== 'production') { // eslint-disable-next-line no-console - console.error('useHeadlessTree: internal error, trying to access treeitem props from invalid root element'); + console.error('HeadlessTree: internal error, trying to access treeitem props from invalid root element'); } return { id: virtualTreeRootId, @@ -124,7 +194,14 @@ function createHeadlessTreeRootItem(): HeadlessTreeItem { get index() { if (process.env.NODE_ENV !== 'production') { // eslint-disable-next-line no-console - console.error('useHeadlessTree: internal error, trying to access treeitem props from invalid root element'); + console.error('HeadlessTree: internal error, trying to access treeitem props from invalid root element'); + } + return -1; + }, + get position() { + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.error('HeadlessTree: internal error, trying to access treeitem props from invalid root element'); } return -1; }, @@ -140,17 +217,17 @@ function createHeadlessTreeRootItem(): HeadlessTreeItem { function* HeadlessTreeSubtreeGenerator( key: TreeItemValue, virtualTreeItems: HeadlessTree, -) { +): Generator, void, void> { const item = virtualTreeItems.get(key); if (!item || item.childrenValues.length === 0) { - return []; + return; } - let counter = item.childrenValues.length; - let index = item.index; - while (counter > 0) { - const children = virtualTreeItems.getByIndex(++index); - yield children; - counter += children.childrenValues.length - 1; + for (const childValue of item.childrenValues) { + const child = virtualTreeItems.get(childValue)!; + yield child; + if (child.childrenValues.length > 0) { + yield* HeadlessTreeSubtreeGenerator(childValue, virtualTreeItems); + } } } @@ -162,7 +239,7 @@ function* HeadlessTreeSubtreeGenerator( function* HeadlessTreeChildrenGenerator( key: TreeItemValue, virtualTreeItems: HeadlessTree, -) { +): Generator, void, void> { const item = virtualTreeItems.get(key); if (!item || item.childrenValues.length === 0) { return; @@ -180,7 +257,7 @@ function* HeadlessTreeChildrenGenerator( function* HeadlessTreeAncestorsGenerator( key: TreeItemValue, virtualTreeItems: HeadlessTree, -) { +): Generator, void, void> { let parent = virtualTreeItems.getParent(key); while (parent !== virtualTreeItems.root) { yield parent; @@ -196,15 +273,12 @@ function* HeadlessTreeAncestorsGenerator( function* HeadlessTreeVisibleItemsGenerator( openItems: ImmutableSet, virtualTreeItems: HeadlessTree, -) { - for (let index = 0, visibleIndex = 0; index < virtualTreeItems.size; index++) { - const item = virtualTreeItems.getByIndex(index) as HeadlessTreeItem; +): Generator, void, void> { + let index = 0; + for (const item of HeadlessTreeSubtreeGenerator(virtualTreeItems.root.value, virtualTreeItems)) { if (isItemVisible(item, openItems, virtualTreeItems)) { - item.index = visibleIndex++; + item.index = index++; yield item; - } else { - // Jump the amount of children the current item has, since those items will also be hidden - index += item.childrenValues.length; } } }