diff --git a/change/@fluentui-react-tree-155e7f7c-e00d-49c0-8d71-35b3383dd6a3.json b/change/@fluentui-react-tree-155e7f7c-e00d-49c0-8d71-35b3383dd6a3.json new file mode 100644 index 00000000000000..09c10814a6ddb0 --- /dev/null +++ b/change/@fluentui-react-tree-155e7f7c-e00d-49c0-8d71-35b3383dd6a3.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "bugfix: makes value property on TreeItem less generic to simplify internals", + "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 8af9807ef23e8d..a4bca41869bc60 100644 --- a/packages/react-components/react-tree/etc/react-tree.api.md +++ b/packages/react-components/react-tree/etc/react-tree.api.md @@ -27,44 +27,43 @@ import { ProviderProps } from 'react'; import * as React_2 from 'react'; import type { Slot } from '@fluentui/react-utilities'; import type { SlotClassNames } from '@fluentui/react-utilities'; -import { SlotRenderFunction } from '@fluentui/react-utilities'; // @public -export const flattenTree_unstable: >(items: NestedTreeItem[]) => FlattenedTreeItem[]; +export const flattenTree_unstable: (items: NestedTreeItem[]) => FlattenedTreeItem[]; // @public -export type FlatTree = FlatTreeItemProps> = { - getTreeProps(): FlatTreeProps; - navigate(data: TreeNavigationData_unstable): void; - getNextNavigableItem(visibleItems: FlatTreeItem[], data: TreeNavigationData_unstable): FlatTreeItem | undefined; +export type FlatTree = { + getTreeProps(): FlatTreeProps; + navigate(data: TreeNavigationData_unstable): void; + getNextNavigableItem(visibleItems: FlatTreeItem[], data: TreeNavigationData_unstable): FlatTreeItem | undefined; + getElementFromItem(item: FlatTreeItem): HTMLElement | null; items(): IterableIterator>; }; // @public -export type FlatTreeItem = FlatTreeItemProps> = { +export type FlatTreeItem = { index: number; level: number; childrenSize: number; - value: Props['value']; - parentValue: Props['parentValue']; - ref: React_2.RefObject; - getTreeItemProps(): Required> & Omit; + value: TreeItemValue; + parentValue: TreeItemValue | undefined; + getTreeItemProps(): Required> & Omit; }; // @public (undocumented) -export type FlatTreeItemProps = Omit, 'itemType'> & Partial, 'itemType'>> & { - value: Value; - parentValue?: Value; +export type FlatTreeItemProps = Omit & Partial> & { + value: TreeItemValue; + parentValue?: TreeItemValue; }; // @public (undocumented) -export type FlatTreeProps = Required, 'openItems' | 'onOpenChange' | 'onNavigation_unstable'>> & { +export type FlatTreeProps = Required> & { ref: React_2.Ref; - openItems: ImmutableSet; + openItems: ImmutableSet; }; // @public (undocumented) -export type NestedTreeItem> = Omit & { +export type NestedTreeItem = Omit & { subtree?: NestedTreeItem[]; }; @@ -84,22 +83,7 @@ export const renderTreeItemLayout_unstable: (state: TreeItemLayoutState) => JSX. export const renderTreeItemPersonaLayout_unstable: (state: TreeItemPersonaLayoutState, contextValues: TreeItemPersonaLayoutContextValues) => JSX.Element; // @public -export const Tree: React_2.ForwardRefExoticComponent & Omit<{ - as?: "div" | undefined; -} & Pick, HTMLDivElement>, "key" | keyof React_2.HTMLAttributes> & { - ref?: ((instance: HTMLDivElement | null) => void) | React_2.RefObject | null | undefined; -} & { - children?: React_2.ReactNode | SlotRenderFunction, HTMLDivElement>, "key" | keyof React_2.HTMLAttributes> & { - ref?: ((instance: HTMLDivElement | null) => void) | React_2.RefObject | null | undefined; - }>; -}, "ref"> & { - appearance?: "transparent" | "subtle" | "subtle-alpha" | undefined; - size?: "small" | "medium" | undefined; - openItems?: Iterable | undefined; - defaultOpenItems?: Iterable | undefined; - onOpenChange?(event: React_2.KeyboardEvent | React_2.MouseEvent, data: TreeOpenChangeData): void; - onNavigation_unstable?(event: React_2.KeyboardEvent | React_2.MouseEvent, data: TreeNavigationData_unstable): void; -} & React_2.RefAttributes> & ((props: TreeProps) => JSX.Element); +export const Tree: ForwardRefComponent; // @public (undocumented) export const treeClassNames: SlotClassNames; @@ -110,24 +94,11 @@ export type TreeContextValue = { appearance: 'subtle' | 'subtle-alpha' | 'transparent'; size: 'small' | 'medium'; openItems: ImmutableSet; - requestTreeResponse(request: TreeItemRequest): void; + requestTreeResponse(request: TreeItemRequest): void; }; // @public -export const TreeItem: React_2.ForwardRefExoticComponent, "root"> & Omit<{ - as?: "div" | undefined; -} & Pick, HTMLDivElement>, "key" | keyof React_2.HTMLAttributes> & { - ref?: ((instance: HTMLDivElement | null) => void) | React_2.RefObject | null | undefined; -} & { - children?: React_2.ReactNode | SlotRenderFunction, HTMLDivElement>, "key" | keyof React_2.HTMLAttributes> & { - ref?: ((instance: HTMLDivElement | null) => void) | React_2.RefObject | null | undefined; - }>; -} & { - style?: TreeItemCSSProperties | undefined; -}, "ref"> & { - value?: string | undefined; - itemType: TreeItemType; -} & React_2.RefAttributes> & ((props: TreeItemProps) => JSX.Element); +export const TreeItem: ForwardRefComponent; // @public export const TreeItemAside: ForwardRefComponent; @@ -204,9 +175,9 @@ export type TreeItemPersonaLayoutState = ComponentState = ComponentProps> & { - value?: Value; +export type TreeItemProps = ComponentProps> & { itemType: TreeItemType; + value?: TreeItemValue; }; // @public (undocumented) @@ -226,9 +197,9 @@ export type TreeItemState = ComponentState & TreeItemContextValue }; // @public (undocumented) -export type TreeNavigationData_unstable = { - value: Value; +export type TreeNavigationData_unstable = { target: HTMLElement; + value: string; } & ({ event: React_2.MouseEvent; type: 'Click'; @@ -259,9 +230,9 @@ export type TreeNavigationData_unstable = { export type TreeNavigationEvent_unstable = TreeNavigationData_unstable['event']; // @public (undocumented) -export type TreeOpenChangeData = { +export type TreeOpenChangeData = { open: boolean; - value: Value; + value: string; } & ({ event: React_2.MouseEvent; target: HTMLElement; @@ -288,13 +259,13 @@ export type TreeOpenChangeData = { export type TreeOpenChangeEvent = TreeOpenChangeData['event']; // @public (undocumented) -export type TreeProps = ComponentProps & { +export type TreeProps = ComponentProps & { appearance?: 'subtle' | 'subtle-alpha' | 'transparent'; size?: 'small' | 'medium'; - openItems?: Iterable; - defaultOpenItems?: Iterable; - onOpenChange?(event: TreeOpenChangeEvent, data: TreeOpenChangeData): void; - onNavigation_unstable?(event: TreeNavigationEvent_unstable, data: TreeNavigationData_unstable): void; + openItems?: Iterable; + defaultOpenItems?: Iterable; + onOpenChange?(event: TreeOpenChangeEvent, data: TreeOpenChangeData): void; + onNavigation_unstable?(event: TreeNavigationEvent_unstable, data: TreeNavigationData_unstable): void; }; // @public (undocumented) @@ -311,7 +282,7 @@ export type TreeState = ComponentState & TreeContextValue & { }; // @public -export function useFlatTree_unstable = FlatTreeItemProps>(flatTreeItemProps: Props[], options?: FlatTreeOptions): FlatTree; +export function useFlatTree_unstable(flatTreeItemProps: Props[], options?: FlatTreeOptions): FlatTree; // @public export const useTree_unstable: (props: TreeProps, ref: React_2.Ref) => TreeState; @@ -323,7 +294,7 @@ export const useTreeContext_unstable: (selector: ContextSelector(props: TreeItemProps, ref: React_2.Ref): TreeItemState; +export function useTreeItem_unstable(props: TreeItemProps, ref: React_2.Ref): TreeItemState; // @public export const useTreeItemAside_unstable: (props: TreeItemAsideProps, ref: React_2.Ref) => TreeItemAsideState; diff --git a/packages/react-components/react-tree/src/components/Tree/Tree.tsx b/packages/react-components/react-tree/src/components/Tree/Tree.tsx index ee9016afd76077..b70762ce1ba5b8 100644 --- a/packages/react-components/react-tree/src/components/Tree/Tree.tsx +++ b/packages/react-components/react-tree/src/components/Tree/Tree.tsx @@ -14,11 +14,11 @@ import { useTreeContextValues_unstable } from './useTreeContextValues'; * an item representing a folder can be expanded to reveal the contents of the folder, * which may be files, folders, or both. */ -export const Tree = React.forwardRef((props, ref) => { +export const Tree: ForwardRefComponent = React.forwardRef((props, ref) => { const state = useTree_unstable(props, ref); useTreeStyles_unstable(state); const contextValues = useTreeContextValues_unstable(state); return renderTree_unstable(state, contextValues); -}) as ForwardRefComponent & ((props: TreeProps) => JSX.Element); +}); Tree.displayName = 'Tree'; diff --git a/packages/react-components/react-tree/src/components/Tree/Tree.types.ts b/packages/react-components/react-tree/src/components/Tree/Tree.types.ts index 2335b03c1da6f7..9e666f7159ce84 100644 --- a/packages/react-components/react-tree/src/components/Tree/Tree.types.ts +++ b/packages/react-components/react-tree/src/components/Tree/Tree.types.ts @@ -8,7 +8,7 @@ export type TreeSlots = { }; // eslint-disable-next-line @typescript-eslint/naming-convention -export type TreeNavigationData_unstable = { value: Value; target: HTMLElement } & ( +export type TreeNavigationData_unstable = { target: HTMLElement; value: string } & ( | { event: React.MouseEvent; type: 'Click' } | { event: React.KeyboardEvent; type: 'TypeAhead' } | { event: React.KeyboardEvent; type: typeof ArrowRight } @@ -22,7 +22,7 @@ export type TreeNavigationData_unstable = { value: Value; target // eslint-disable-next-line @typescript-eslint/naming-convention export type TreeNavigationEvent_unstable = TreeNavigationData_unstable['event']; -export type TreeOpenChangeData = { open: boolean; value: Value } & ( +export type TreeOpenChangeData = { open: boolean; value: string } & ( | { event: React.MouseEvent; target: HTMLElement; @@ -56,7 +56,7 @@ export type TreeContextValues = { tree: TreeContextValue; }; -export type TreeProps = ComponentProps & { +export type TreeProps = ComponentProps & { /** * A tree item can have various appearances: * - 'subtle' (default): The default tree item styles. @@ -75,13 +75,13 @@ export type TreeProps = ComponentProps & { * Controls the state of the open tree items. * These property is ignored for subtrees. */ - openItems?: Iterable; + openItems?: Iterable; /** * This refers to a list of ids of opened tree items. * Default value for the uncontrolled state of open tree items. * These property is ignored for subtrees. */ - defaultOpenItems?: Iterable; + defaultOpenItems?: Iterable; /** * Callback fired when the component changes value from open state. * These property is ignored for subtrees. @@ -90,7 +90,7 @@ export type TreeProps = ComponentProps & { * @param data - A data object with relevant information, * such as open value and type of interaction that created the event. */ - onOpenChange?(event: TreeOpenChangeEvent, data: TreeOpenChangeData): void; + onOpenChange?(event: TreeOpenChangeEvent, data: TreeOpenChangeData): void; /** * Callback fired when navigation happens inside the component. @@ -102,7 +102,7 @@ export type TreeProps = ComponentProps & { * @param data - A data object with relevant information, */ // eslint-disable-next-line @typescript-eslint/naming-convention - onNavigation_unstable?(event: TreeNavigationEvent_unstable, data: TreeNavigationData_unstable): void; + onNavigation_unstable?(event: TreeNavigationEvent_unstable, data: TreeNavigationData_unstable): void; }; /** diff --git a/packages/react-components/react-tree/src/components/Tree/useRootTree.ts b/packages/react-components/react-tree/src/components/Tree/useRootTree.ts index f2003a9dfe3d0e..6153dc9da69a81 100644 --- a/packages/react-components/react-tree/src/components/Tree/useRootTree.ts +++ b/packages/react-components/react-tree/src/components/Tree/useRootTree.ts @@ -12,7 +12,7 @@ import { TreeItemRequest } from '../../contexts/index'; * @param props - props from this instance of Tree * @param ref - reference to root HTMLElement of Tree */ -export function useRootTree(props: TreeProps, ref: React.Ref): TreeState { +export function useRootTree(props: TreeProps, ref: React.Ref): TreeState { warnIfNoProperPropsRootTree(props); const { appearance = 'subtle', size = 'medium' } = props; @@ -20,7 +20,7 @@ export function useRootTree(props: TreeProps, ref: React. const [openItems, updateOpenItems] = useOpenItemsState(props); const [navigate, navigationRef] = useNestedTreeNavigation(); - const requestOpenChange = (data: TreeOpenChangeData) => { + const requestOpenChange = (data: TreeOpenChangeData) => { props.onOpenChange?.(data.event, data); if (data.event.isDefaultPrevented()) { return; @@ -28,7 +28,7 @@ export function useRootTree(props: TreeProps, ref: React. return updateOpenItems(data); }; - const requestNavigation = (data: TreeNavigationData_unstable) => { + const requestNavigation = (data: TreeNavigationData_unstable) => { props.onNavigation_unstable?.(data.event, data); if (data.event.isDefaultPrevented()) { return; @@ -44,7 +44,7 @@ export function useRootTree(props: TreeProps, ref: React. value, itemType, type, - }: Extract, { type: 'Click' | 'ExpandIconClick' }>) => { + }: Extract) => { ReactDOM.unstable_batchedUpdates(() => { requestOpenChange({ event, @@ -59,10 +59,10 @@ export function useRootTree(props: TreeProps, ref: React. const handleTreeItemKeyDown = ({ event, - value, type, + value, itemType, - }: Exclude, { type: 'Click' | 'ExpandIconClick' }>) => { + }: Exclude) => { const open = openItems.has(value); switch (type) { case treeDataTypes.ArrowRight: @@ -107,14 +107,14 @@ export function useRootTree(props: TreeProps, ref: React. } }; - const requestTreeResponse = useEventCallback((request: TreeItemRequest) => { + const requestTreeResponse = useEventCallback((request: TreeItemRequest) => { switch (request.event.type) { case 'click': // casting is required here as we're narrowing down the event to only click events - return handleTreeItemClick(request as Extract, { type: 'Click' | 'ExpandIconClick' }>); + return handleTreeItemClick(request as Extract); case 'keydown': // casting is required here as we're narrowing down the event to only keyboard events - return handleTreeItemKeyDown(request as Exclude, { type: 'Click' | 'ExpandIconClick' }>); + return handleTreeItemKeyDown(request as Exclude); } }); diff --git a/packages/react-components/react-tree/src/components/TreeItem/TreeItem.tsx b/packages/react-components/react-tree/src/components/TreeItem/TreeItem.tsx index 9d4e8ac7df285f..d40525ddd63c14 100644 --- a/packages/react-components/react-tree/src/components/TreeItem/TreeItem.tsx +++ b/packages/react-components/react-tree/src/components/TreeItem/TreeItem.tsx @@ -18,12 +18,12 @@ import { useTreeItemContextValues_unstable } from './useTreeItemContextValues'; * When a TreeItem has nested child subtree, an expand/collapse control is displayed, * allowing the user to show or hide the children. */ -export const TreeItem = React.forwardRef((props, ref) => { +export const TreeItem: ForwardRefComponent = React.forwardRef((props, ref) => { const state = useTreeItem_unstable(props, ref); useTreeItemStyles_unstable(state); const contextValues = useTreeItemContextValues_unstable(state); return renderTreeItem_unstable(state, contextValues); -}) as ForwardRefComponent & ((props: TreeItemProps) => JSX.Element); +}); TreeItem.displayName = 'TreeItem'; 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 af95402b76b90f..4bad27fdf576c5 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 @@ -11,14 +11,16 @@ export type TreeItemSlots = { root: Slot & { style?: TreeItemCSSProperties }>>; }; +export type TreeItemValue = string | number; + export type TreeItemContextValues = { treeItem: TreeItemContextValue }; /** * TreeItem Props */ -export type TreeItemProps = ComponentProps> & { - value?: Value; +export type TreeItemProps = ComponentProps> & { itemType: TreeItemType; + value?: TreeItemValue; }; /** diff --git a/packages/react-components/react-tree/src/components/TreeItem/__snapshots__/TreeItem.test.tsx.snap b/packages/react-components/react-tree/src/components/TreeItem/__snapshots__/TreeItem.test.tsx.snap index 29c14287ba4c8a..fe2c476b6d7ef6 100644 --- a/packages/react-components/react-tree/src/components/TreeItem/__snapshots__/TreeItem.test.tsx.snap +++ b/packages/react-components/react-tree/src/components/TreeItem/__snapshots__/TreeItem.test.tsx.snap @@ -5,7 +5,7 @@ exports[`TreeItem renders a default state 1`] = `
diff --git a/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.ts b/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.ts index 1f2ab2f33441d0..c8e8595166d494 100644 --- a/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.ts +++ b/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.ts @@ -5,6 +5,7 @@ import { elementContains } from '@fluentui/react-portal'; import type { TreeItemProps, TreeItemState } from './TreeItem.types'; import { useTreeContext_unstable } from '../../contexts/index'; import { treeDataTypes } from '../../utils/tokens'; +import { dataTreeItemValueAttrName } from '../../utils/getTreeItemValueFromElement'; /** * Create the state required to render TreeItem. @@ -15,23 +16,12 @@ import { treeDataTypes } from '../../utils/tokens'; * @param props - props from this instance of TreeItem * @param ref - reference to root HTMLElement of TreeItem */ -export function useTreeItem_unstable( - props: TreeItemProps, - ref: React.Ref, -): TreeItemState { +export function useTreeItem_unstable(props: TreeItemProps, ref: React.Ref): TreeItemState { const contextLevel = useTreeContext_unstable(ctx => ctx.level); - const id = useId('fui-TreeItem-', props.id); + const value = useId('fuiTreeItemValue-', props.value?.toString()); - const { - onClick, - onKeyDown, - as = 'div', - value = id, - itemType = 'leaf', - 'aria-level': level = contextLevel, - ...rest - } = props; + const { onClick, onKeyDown, as = 'div', itemType = 'leaf', 'aria-level': level = contextLevel, ...rest } = props; const requestTreeResponse = useTreeContext_unstable(ctx => ctx.requestTreeResponse); @@ -87,12 +77,12 @@ export function useTreeItem_unstable( case treeDataTypes.ArrowDown: case treeDataTypes.ArrowLeft: case treeDataTypes.ArrowRight: - return requestTreeResponse({ event, itemType, value, type: event.key }); + return requestTreeResponse({ event, value, itemType, type: event.key }); } const isTypeAheadCharacter = event.key.length === 1 && event.key.match(/\w/) && !event.altKey && !event.ctrlKey && !event.metaKey; if (isTypeAheadCharacter) { - requestTreeResponse({ event, itemType, value, type: treeDataTypes.TypeAhead }); + requestTreeResponse({ event, value, itemType, type: treeDataTypes.TypeAhead }); } }); @@ -137,11 +127,11 @@ export function useTreeItem_unstable( root: getNativeElementProps(as, { tabIndex: -1, ...rest, - id, ref, + role: 'treeitem', 'aria-level': level, + [dataTreeItemValueAttrName]: value, 'aria-expanded': itemType === 'branch' ? open : undefined, - role: 'treeitem', onClick: handleClick, onKeyDown: handleKeyDown, onMouseOver: handleActionsVisible, diff --git a/packages/react-components/react-tree/src/contexts/treeContext.ts b/packages/react-components/react-tree/src/contexts/treeContext.ts index 4fe0b02b58b8d3..4caa766876c0f2 100644 --- a/packages/react-components/react-tree/src/contexts/treeContext.ts +++ b/packages/react-components/react-tree/src/contexts/treeContext.ts @@ -11,12 +11,12 @@ export type TreeContextValue = { /** * requests root Tree component to respond to some tree item event, */ - requestTreeResponse(request: TreeItemRequest): void; + requestTreeResponse(request: TreeItemRequest): void; }; -export type TreeItemRequest = { itemType: TreeItemType } & ( - | OmitWithoutExpanding, 'open' | 'target'> - | OmitWithoutExpanding, 'target'> +export type TreeItemRequest = { itemType: TreeItemType } & ( + | OmitWithoutExpanding + | OmitWithoutExpanding ); // 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/contexts/treeItemContext.ts b/packages/react-components/react-tree/src/contexts/treeItemContext.ts index 3ad7a1dd223570..6cff7d49c82362 100644 --- a/packages/react-components/react-tree/src/contexts/treeItemContext.ts +++ b/packages/react-components/react-tree/src/contexts/treeItemContext.ts @@ -9,13 +9,13 @@ export type TreeItemContextValue = { expandIconRef: React.Ref; layoutRef: React.Ref; subtreeRef: React.Ref; - value: unknown; itemType: TreeItemType; + value: string; open: boolean; }; const defaultContextValue: TreeItemContextValue = { - value: undefined, + value: '', isActionsVisible: false, isAsideVisible: true, actionsRef: React.createRef(), diff --git a/packages/react-components/react-tree/src/hooks/useFlatTree.ts b/packages/react-components/react-tree/src/hooks/useFlatTree.ts index 3eb714fe9f60d0..078a1890c1ce17 100644 --- a/packages/react-components/react-tree/src/hooks/useFlatTree.ts +++ b/packages/react-components/react-tree/src/hooks/useFlatTree.ts @@ -1,4 +1,4 @@ -import { useEventCallback } from '@fluentui/react-utilities'; +import { useEventCallback, useMergedRefs } from '@fluentui/react-utilities'; import * as React from 'react'; import { createFlatTreeItems, VisibleFlatTreeItemGenerator } from '../utils/createFlatTreeItems'; import { treeDataTypes } from '../utils/tokens'; @@ -11,40 +11,33 @@ import type { TreeOpenChangeEvent, TreeProps, } from '../Tree'; -import type { TreeItemProps } from '../TreeItem'; +import type { TreeItemProps, TreeItemValue } from '../TreeItem'; import { ImmutableSet } from '../utils/ImmutableSet'; +import { dataTreeItemValueAttrName } from '../utils/getTreeItemValueFromElement'; -export type FlatTreeItemProps = Omit, 'itemType'> & - Partial, 'itemType'>> & { - value: Value; - parentValue?: Value; +export type FlatTreeItemProps = Omit & + Partial> & { + value: TreeItemValue; + parentValue?: TreeItemValue; }; /** * The item that is returned by `useFlatTree`, it represents a wrapper around the properties provided to * `useFlatTree` but with extra information that might be useful on flat tree scenarios */ -export type FlatTreeItem = FlatTreeItemProps> = { +export type FlatTreeItem = { index: number; level: number; childrenSize: number; - value: Props['value']; - parentValue: Props['parentValue']; - /** - * A reference to the element that will render the `TreeItem`, - * this is necessary for nodes with parents (to ensure child to parent navigation), - * if a node has no parent then this reference will be null. - */ - ref: React.RefObject; + value: TreeItemValue; + parentValue: TreeItemValue | undefined; getTreeItemProps(): Required> & - Omit; + Omit; }; -export type FlatTreeProps = Required< - Pick, 'openItems' | 'onOpenChange' | 'onNavigation_unstable'> -> & { +export type FlatTreeProps = Required> & { ref: React.Ref; - openItems: ImmutableSet; + openItems: ImmutableSet; }; /** @@ -57,13 +50,13 @@ export type FlatTreeProps = Required< * * On simple scenarios it is advised to simply use a nested structure instead. */ -export type FlatTree = FlatTreeItemProps> = { +export type FlatTree = { /** * returns the properties required for the Tree component to work properly. * That includes: * `openItems`, `onOpenChange`, `onNavigation_unstable` and `ref` */ - getTreeProps(): FlatTreeProps; + getTreeProps(): FlatTreeProps; /** * internal method used to react to an `onNavigation` event. * This method ensures proper navigation on keyboard and mouse interaction. @@ -77,7 +70,7 @@ export type FlatTree = FlatTreeItemProp * event.preventDefault(); * const nextItem = tree.getNextNavigableItem(data); * // scroll to item using virtualization scroll mechanism - * if (nextItem && document.getElementById(nextItem.id)) { + * if (nextItem && tree.getElementFromItem(nextItem)) { * listRef.current.scrollToItem(nextItem.index); * } * // wait for scrolling to happen and then invoke navigate method @@ -87,7 +80,7 @@ export type FlatTree = FlatTreeItemProp * }; *``` */ - navigate(data: TreeNavigationData_unstable): void; + navigate(data: TreeNavigationData_unstable): void; /** * returns next item to be focused on a navigation. * This method is provided to decouple the element that needs to be focused from @@ -97,18 +90,19 @@ export type FlatTree = FlatTreeItemProp */ getNextNavigableItem( visibleItems: FlatTreeItem[], - data: TreeNavigationData_unstable, + data: TreeNavigationData_unstable, ): FlatTreeItem | undefined; + /** + * similar to getElementById but for FlatTreeItems + */ + getElementFromItem(item: FlatTreeItem): HTMLElement | null; /** * an iterable containing all visually available flat tree items */ items(): IterableIterator>; }; -type FlatTreeOptions = FlatTreeItemProps> = Pick< - TreeProps, - 'openItems' | 'defaultOpenItems' | 'onOpenChange' | 'onNavigation_unstable' ->; +type FlatTreeOptions = Pick; /** * this hook provides FlatTree API to manage all required mechanisms to convert a list of items into renderable TreeItems @@ -122,15 +116,16 @@ type FlatTreeOptions = FlatTreeItemProp * @param flatTreeItemProps - a list of tree items * @param options - in case control over the internal openItems is required */ -export function useFlatTree_unstable = FlatTreeItemProps>( +export function useFlatTree_unstable( flatTreeItemProps: Props[], - options: FlatTreeOptions = {}, + options: FlatTreeOptions = {}, ): FlatTree { - const [openItems, updateOpenItems] = useOpenItemsState(options); + const [openItems, updateOpenItems] = useOpenItemsState(options); const flatTreeItems = React.useMemo(() => createFlatTreeItems(flatTreeItemProps), [flatTreeItemProps]); const [navigate, navigationRef] = useFlatTreeNavigation(flatTreeItems); + const treeRef = React.useRef(null); - const handleOpenChange = useEventCallback((event: TreeOpenChangeEvent, data: TreeOpenChangeData) => { + const handleOpenChange = useEventCallback((event: TreeOpenChangeEvent, data: TreeOpenChangeData) => { options.onOpenChange?.(event, data); if (!event.isDefaultPrevented()) { updateOpenItems(data); @@ -139,7 +134,7 @@ export function useFlatTree_unstable = }); const handleNavigation = useEventCallback( - (event: TreeNavigationEvent_unstable, data: TreeNavigationData_unstable) => { + (event: TreeNavigationEvent_unstable, data: TreeNavigationData_unstable) => { options.onNavigation_unstable?.(event, data); if (!event.isDefaultPrevented()) { navigate(data); @@ -149,7 +144,7 @@ export function useFlatTree_unstable = ); const getNextNavigableItem = useEventCallback( - (visibleItems: FlatTreeItem[], data: TreeNavigationData_unstable) => { + (visibleItems: FlatTreeItem[], data: TreeNavigationData_unstable) => { const item = flatTreeItems.get(data.value); if (item) { switch (data.type) { @@ -172,9 +167,15 @@ export function useFlatTree_unstable = }, ); + const getElementFromItem = React.useCallback((item: FlatTreeItem) => { + return treeRef.current?.querySelector(`[${dataTreeItemValueAttrName}="${item.value}"]`) as HTMLElement | null; + }, []); + + const ref = useMergedRefs(treeRef, navigationRef as React.Ref); + const getTreeProps = React.useCallback( () => ({ - ref: navigationRef as React.Ref, + ref, openItems, onOpenChange: handleOpenChange, // eslint-disable-next-line @typescript-eslint/naming-convention @@ -190,7 +191,7 @@ export function useFlatTree_unstable = ); return React.useMemo( - () => ({ navigate, getTreeProps, getNextNavigableItem, items }), - [navigate, getTreeProps, getNextNavigableItem, items], + () => ({ navigate, getTreeProps, getNextNavigableItem, getElementFromItem, items }), + [navigate, getTreeProps, getNextNavigableItem, getElementFromItem, items], ); } diff --git a/packages/react-components/react-tree/src/hooks/useFlatTreeNavigation.ts b/packages/react-components/react-tree/src/hooks/useFlatTreeNavigation.ts index 68c5479e478e46..9c62019d85fd20 100644 --- a/packages/react-components/react-tree/src/hooks/useFlatTreeNavigation.ts +++ b/packages/react-components/react-tree/src/hooks/useFlatTreeNavigation.ts @@ -8,15 +8,16 @@ import { treeItemFilter } from '../utils/treeItemFilter'; import { HTMLElementWalker, useHTMLElementWalkerRef } from './useHTMLElementWalker'; import { useRovingTabIndex } from './useRovingTabIndexes'; import { FlatTreeItemProps } from './useFlatTree'; +import { dataTreeItemValueAttrName, getTreeItemValueFromElement } from '../utils/getTreeItemValueFromElement'; -export function useFlatTreeNavigation = FlatTreeItemProps>( +export function useFlatTreeNavigation( flatTreeItems: FlatTreeItems, ) { const { targetDocument } = useFluent_unstable(); const [treeItemWalkerRef, treeItemWalkerRootRef] = useHTMLElementWalkerRef(treeItemFilter); const [{ rove }, rovingRootRef] = useRovingTabIndex(treeItemFilter); - function getNextElement(data: TreeNavigationData_unstable) { + function getNextElement(data: TreeNavigationData_unstable) { if (!targetDocument || !treeItemWalkerRef.current) { return null; } @@ -28,7 +29,7 @@ export function useFlatTreeNavigation = treeItemWalker.currentElement = data.target; return nextTypeAheadElement(treeItemWalker, data.event.key); case treeDataTypes.ArrowLeft: - return parentElement(flatTreeItems, data.value); + return parentElement(flatTreeItems, data.target, treeItemWalker); case treeDataTypes.ArrowRight: treeItemWalker.currentElement = data.target; return firstChild(data.target, treeItemWalker); @@ -46,7 +47,7 @@ export function useFlatTreeNavigation = return treeItemWalker.previousElement(); } } - const navigate = useEventCallback((data: TreeNavigationData_unstable) => { + const navigate = useEventCallback((data: TreeNavigationData_unstable) => { const nextElement = getNextElement(data); if (nextElement) { rove(nextElement); @@ -69,11 +70,18 @@ function firstChild(target: HTMLElement, treeWalker: HTMLElementWalker): HTMLEle return null; } -function parentElement(flatTreeItems: FlatTreeItems>, value: unknown) { +function parentElement( + flatTreeItems: FlatTreeItems, + target: HTMLElement, + treeWalker: HTMLElementWalker, +) { + const value = getTreeItemValueFromElement(target); + if (value === null) { + return null; + } const flatTreeItem = flatTreeItems.get(value); if (flatTreeItem?.parentValue) { - const parentItem = flatTreeItems.get(flatTreeItem.parentValue); - return parentItem?.ref.current ?? null; + return treeWalker.root.querySelector(`[${dataTreeItemValueAttrName}="${flatTreeItem.parentValue}"]`); } return null; } diff --git a/packages/react-components/react-tree/src/hooks/useNestedTreeNavigation.ts b/packages/react-components/react-tree/src/hooks/useNestedTreeNavigation.ts index 167f262e1859dc..4d25d88a33a8ac 100644 --- a/packages/react-components/react-tree/src/hooks/useNestedTreeNavigation.ts +++ b/packages/react-components/react-tree/src/hooks/useNestedTreeNavigation.ts @@ -10,7 +10,7 @@ export function useNestedTreeNavigation() { const [{ rove }, rovingRootRef] = useRovingTabIndex(treeItemFilter); const [walkerRef, rootRef] = useHTMLElementWalkerRef(treeItemFilter); - const getNextElement = (data: TreeNavigationData_unstable) => { + const getNextElement = (data: TreeNavigationData_unstable) => { if (!walkerRef.current) { return; } @@ -41,7 +41,7 @@ export function useNestedTreeNavigation() { return treeItemWalker.previousElement(); } }; - function navigate(data: TreeNavigationData_unstable) { + function navigate(data: TreeNavigationData_unstable) { const nextElement = getNextElement(data); if (nextElement) { rove(nextElement); diff --git a/packages/react-components/react-tree/src/hooks/useOpenItemsState.ts b/packages/react-components/react-tree/src/hooks/useOpenItemsState.ts index 1fe50eccb2e101..24c60f24a0ed94 100644 --- a/packages/react-components/react-tree/src/hooks/useOpenItemsState.ts +++ b/packages/react-components/react-tree/src/hooks/useOpenItemsState.ts @@ -3,22 +3,22 @@ import * as React from 'react'; import { createImmutableSet, emptyImmutableSet, ImmutableSet } from '../utils/ImmutableSet'; import type { TreeOpenChangeData, TreeProps } from '../Tree'; -export function useOpenItemsState(props: Pick, 'openItems' | 'defaultOpenItems'>) { +export function useOpenItemsState(props: Pick) { const [openItems, setOpenItems] = useControllableState({ state: React.useMemo(() => props.openItems && createImmutableSet(props.openItems), [props.openItems]), defaultState: props.defaultOpenItems && (() => createImmutableSet(props.defaultOpenItems)), initialState: emptyImmutableSet, }); - const updateOpenItems = useEventCallback((data: TreeOpenChangeData) => + const updateOpenItems = useEventCallback((data: TreeOpenChangeData) => setOpenItems(currentOpenItems => createNextOpenItems(data, currentOpenItems)), ); return [openItems, updateOpenItems] as const; } -function createNextOpenItems( - data: TreeOpenChangeData, - previousOpenItems: ImmutableSet, -): ImmutableSet { +function createNextOpenItems(data: TreeOpenChangeData, previousOpenItems: ImmutableSet): ImmutableSet { + if (data.value === null) { + return previousOpenItems; + } const previousOpenItemsHasId = previousOpenItems.has(data.value); if (data.open ? previousOpenItemsHasId : !previousOpenItemsHasId) { return previousOpenItems; diff --git a/packages/react-components/react-tree/src/testing/flattenTreeFromElement.ts b/packages/react-components/react-tree/src/testing/flattenTreeFromElement.ts index 9b386bac26a037..a17a62aa5adf94 100644 --- a/packages/react-components/react-tree/src/testing/flattenTreeFromElement.ts +++ b/packages/react-components/react-tree/src/testing/flattenTreeFromElement.ts @@ -6,26 +6,26 @@ let count = 1; /** * @internal */ -export const flattenTreeFromElement = ( +export const flattenTreeFromElement = ( root: React.ReactElement<{ - children?: React.ReactElement> | React.ReactElement>[]; + children?: React.ReactElement | React.ReactElement[]; }>, - parent?: FlatTreeItemProps, + parent?: FlatTreeItemProps, level = 1, -): FlatTreeItemProps[] => { - const children = React.Children.toArray(root.props.children) as React.ReactElement>[]; - return children.reduce[]>((acc, curr, index) => { +): FlatTreeItemProps[] => { + const children = React.Children.toArray(root.props.children) as React.ReactElement[]; + return children.reduce((acc, curr, index) => { const childrenArray = React.Children.toArray(curr.props.children); const subtree = (childrenArray.length === 3 ? childrenArray[2] : childrenArray[1]) as typeof root | undefined; const [content] = childrenArray; const actions = (childrenArray.length === 3 ? childrenArray[1] : undefined) as React.ReactNode | undefined; const id = curr.props.id ?? `fui-FlatTreeItem-${count++}`; - const flatTreeItem: FlatTreeItemProps = { + const flatTreeItem: FlatTreeItemProps = { 'aria-level': level, 'aria-posinset': index + 1, 'aria-setsize': children.length, parentValue: parent?.value, - value: curr.props.value ?? (id as unknown as Value), + value: curr.props.value ?? id, ...curr.props, children: actions ? [content, actions] : content, }; diff --git a/packages/react-components/react-tree/src/utils/createFlatTreeItems.ts b/packages/react-components/react-tree/src/utils/createFlatTreeItems.ts index ea7424a2c6ef87..ab57390de414f8 100644 --- a/packages/react-components/react-tree/src/utils/createFlatTreeItems.ts +++ b/packages/react-components/react-tree/src/utils/createFlatTreeItems.ts @@ -1,15 +1,15 @@ import type { ImmutableSet } from './ImmutableSet'; import type { FlatTreeItem, FlatTreeItemProps } from '../hooks/useFlatTree'; -import * as React from 'react'; +import { TreeItemValue } from '../TreeItem'; /** * @internal */ -export type FlatTreeItems> = { +export type FlatTreeItems = { size: number; root: FlatTreeItem; - get(key: Props['value']): FlatTreeItem | undefined; - set(key: Props['value'], value: FlatTreeItem): void; + get(key: TreeItemValue): FlatTreeItem | undefined; + set(key: TreeItemValue, value: FlatTreeItem): void; getByIndex(index: number): FlatTreeItem; }; @@ -17,12 +17,10 @@ export type FlatTreeItems> = { * creates a list of flat tree items * and provides a map to access each item by id */ -export function createFlatTreeItems>( - flatTreeItemProps: Props[], -): FlatTreeItems { +export function createFlatTreeItems(flatTreeItemProps: Props[]): FlatTreeItems { const root = createFlatTreeRootItem(); - const itemsPerValue = new Map>>([[root.value, root]]); - const items: FlatTreeItem>[] = []; + const itemsPerValue = new Map>([[root.value, root]]); + const items: FlatTreeItem[] = []; for (let index = 0; index < flatTreeItemProps.length; index++) { const { parentValue = flatTreeRootId, ...treeItemProps } = flatTreeItemProps[index]; @@ -33,7 +31,7 @@ export function createFlatTreeItems>( if (process.env.NODE_ENV === 'development') { // eslint-disable-next-line no-console console.error( - `useFlatTree: item ${flatTreeItemProps[index].id} is wrongly positioned, did you properly ordered provided item props? make sure provided items are organized`, + `useFlatTree: item ${flatTreeItemProps[index].value} is wrongly positioned, did you properly ordered provided item props? make sure provided items are organized`, ); } break; @@ -43,9 +41,8 @@ export function createFlatTreeItems>( (treeItemProps.value === undefined || nextItemProps?.parentValue !== treeItemProps.value ? 'leaf' : 'branch'); const currentLevel = (currentParent.level ?? 0) + 1; const currentChildrenSize = ++currentParent.childrenSize; - const ref = React.createRef(); - const flatTreeItem: FlatTreeItem> = { + const flatTreeItem: FlatTreeItem = { value: treeItemProps.value, getTreeItemProps: () => ({ ...treeItemProps, @@ -53,10 +50,7 @@ export function createFlatTreeItems>( 'aria-posinset': currentChildrenSize, 'aria-setsize': currentParent.childrenSize, itemType, - // a reference to every parent element is necessary to ensure navigation - ref: flatTreeItem.childrenSize > 0 ? ref : undefined, }), - ref, level: currentLevel, parentValue, childrenSize: 0, @@ -66,7 +60,7 @@ export function createFlatTreeItems>( items.push(flatTreeItem); } - const flatTreeItems: FlatTreeItems> = { + const flatTreeItems: FlatTreeItems = { root, size: items.length, getByIndex: index => items[index], @@ -81,15 +75,21 @@ export const flatTreeRootId = '__fuiFlatTreeRoot'; function createFlatTreeRootItem(): FlatTreeItem { return { - ref: { current: null }, - value: flatTreeRootId, parentValue: undefined, + value: flatTreeRootId, getTreeItemProps: () => { if (process.env.NODE_ENV !== 'production') { // eslint-disable-next-line no-console console.error('useFlatTree: internal error, trying to access treeitem props from invalid root element'); } - return { value: flatTreeRootId, 'aria-setsize': -1, 'aria-level': -1, 'aria-posinset': -1, itemType: 'branch' }; + return { + id: flatTreeRootId, + value: flatTreeRootId, + 'aria-setsize': -1, + 'aria-level': -1, + 'aria-posinset': -1, + itemType: 'branch', + }; }, childrenSize: 0, get index() { @@ -104,8 +104,8 @@ function createFlatTreeRootItem(): FlatTreeItem { } // eslint-disable-next-line @typescript-eslint/naming-convention -export function* VisibleFlatTreeItemGenerator>( - openItems: ImmutableSet, +export function* VisibleFlatTreeItemGenerator( + openItems: ImmutableSet, flatTreeItems: FlatTreeItems, ) { for (let index = 0, visibleIndex = 0; index < flatTreeItems.size; index++) { @@ -121,9 +121,9 @@ export function* VisibleFlatTreeItemGenerator>, - openItems: ImmutableSet, - flatTreeItems: FlatTreeItems>, + item: FlatTreeItem, + openItems: ImmutableSet, + flatTreeItems: FlatTreeItems, ) { if (item.level === 1) { return true; diff --git a/packages/react-components/react-tree/src/utils/flattenTree.ts b/packages/react-components/react-tree/src/utils/flattenTree.ts index 5d9eeb1122fbb2..88999c2e953bb3 100644 --- a/packages/react-components/react-tree/src/utils/flattenTree.ts +++ b/packages/react-components/react-tree/src/utils/flattenTree.ts @@ -1,17 +1,16 @@ import { FlatTreeItemProps } from '../hooks/useFlatTree'; import { TreeItemProps } from '../TreeItem'; -export type NestedTreeItem> = Omit & { +export type NestedTreeItem = Omit & { subtree?: NestedTreeItem[]; }; -export type FlattenedTreeItem> = FlatTreeItemProps> & - Props; +export type FlattenedTreeItem = FlatTreeItemProps & Props; let count = 1; -function flattenTreeRecursive>( +function flattenTreeRecursive( items: NestedTreeItem[], - parent?: FlatTreeItemProps & Props, + parent?: FlatTreeItemProps & Props, level = 1, ): FlattenedTreeItem[] { return items.reduce[]>((acc, { subtree, ...item }, index) => { @@ -73,6 +72,6 @@ function flattenTreeRecursive>( * ``` */ // eslint-disable-next-line @typescript-eslint/naming-convention -export const flattenTree_unstable = >( +export const flattenTree_unstable = ( items: NestedTreeItem[], ): FlattenedTreeItem[] => flattenTreeRecursive(items); diff --git a/packages/react-components/react-tree/src/utils/getTreeItemValueFromElement.ts b/packages/react-components/react-tree/src/utils/getTreeItemValueFromElement.ts new file mode 100644 index 00000000000000..d5ead44bb79632 --- /dev/null +++ b/packages/react-components/react-tree/src/utils/getTreeItemValueFromElement.ts @@ -0,0 +1,5 @@ +export const dataTreeItemValueAttrName = 'data-fui-tree-item-value'; + +export const getTreeItemValueFromElement = (element: HTMLElement) => { + return element.getAttribute(dataTreeItemValueAttrName); +}; diff --git a/packages/react-components/react-tree/stories/A_Tree/TreeControllingOpenAndClose.stories.tsx b/packages/react-components/react-tree/stories/A_Tree/TreeControllingOpenAndClose.stories.tsx index 4b8790cdcfc6c2..305ab8af92b10e 100644 --- a/packages/react-components/react-tree/stories/A_Tree/TreeControllingOpenAndClose.stories.tsx +++ b/packages/react-components/react-tree/stories/A_Tree/TreeControllingOpenAndClose.stories.tsx @@ -4,7 +4,7 @@ import story from './TreeControllingOpenAndClose.md'; export const OpenItemsControlled = () => { const [openItems, setOpenItems] = React.useState([]); - const handleOpenChange = (event: TreeOpenChangeEvent, data: TreeOpenChangeData) => { + const handleOpenChange = (event: TreeOpenChangeEvent, data: TreeOpenChangeData) => { setOpenItems(curr => (data.open ? [...curr, data.value] : curr.filter(value => value !== data.value))); }; return ( diff --git a/packages/react-components/react-tree/stories/B_TreeItem/TreeItemExpandIcon.stories.tsx b/packages/react-components/react-tree/stories/B_TreeItem/TreeItemExpandIcon.stories.tsx index 81da60520f0a56..36f35f5f9b6f4b 100644 --- a/packages/react-components/react-tree/stories/B_TreeItem/TreeItemExpandIcon.stories.tsx +++ b/packages/react-components/react-tree/stories/B_TreeItem/TreeItemExpandIcon.stories.tsx @@ -6,7 +6,7 @@ import story from './TreeItemExpandIcon.md'; export const ExpandIcon = () => { const [openItems, setOpenItems] = React.useState([]); - const handleOpenChange = (event: TreeOpenChangeEvent, data: TreeOpenChangeData) => { + const handleOpenChange = (event: TreeOpenChangeEvent, data: TreeOpenChangeData) => { setOpenItems(curr => (data.open ? [...curr, data.value] : curr.filter(value => value !== data.value))); }; return ( diff --git a/packages/react-components/react-tree/stories/D_flatTree/TreeItemAddRemove.stories.tsx b/packages/react-components/react-tree/stories/D_flatTree/TreeItemAddRemove.stories.tsx index 4a1e3cb215475f..92793a3aaba793 100644 --- a/packages/react-components/react-tree/stories/D_flatTree/TreeItemAddRemove.stories.tsx +++ b/packages/react-components/react-tree/stories/D_flatTree/TreeItemAddRemove.stories.tsx @@ -40,7 +40,7 @@ export const AddRemoveTreeItem = () => { const addFlatTreeItem = (subtreeIndex: number) => setTrees(currentTrees => { const lastItem = currentTrees[subtreeIndex][currentTrees[subtreeIndex].length - 1]; - const newItemValue = `${subtreeIndex + 1}-${Number(lastItem.value.slice(2)) + 1}`; + const newItemValue = `${subtreeIndex + 1}-${Number(lastItem.value.toString().slice(2)) + 1}`; const nextSubTree: ItemProps[] = [ ...currentTrees[subtreeIndex], { @@ -83,7 +83,7 @@ export const AddRemoveTreeItem = () => { return ( {Array.from(flatTree.items(), item => { - const isUndeletable = item.level === 1 || item.value.endsWith('-btn'); + const isUndeletable = item.level === 1 || item.value.toString().endsWith('-btn'); const { content, ...treeItemProps } = item.getTreeItemProps(); return ( @@ -93,7 +93,7 @@ export const AddRemoveTreeItem = () => {