diff --git a/change/@fluentui-react-tree-6759724f-a9fe-45df-81be-ec6cb8f2fe98.json b/change/@fluentui-react-tree-6759724f-a9fe-45df-81be-ec6cb8f2fe98.json new file mode 100644 index 00000000000000..717989763b386b --- /dev/null +++ b/change/@fluentui-react-tree-6759724f-a9fe-45df-81be-ec6cb8f2fe98.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "feat: implements nested tree selection", + "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 312b199f750541..41d33e4ebdc201 100644 --- a/packages/react-components/react-tree/etc/react-tree.api.md +++ b/packages/react-components/react-tree/etc/react-tree.api.md @@ -79,12 +79,15 @@ export type HeadlessFlatTreeItem = Head export type HeadlessFlatTreeItemProps = HeadlessTreeItemProps; // @public (undocumented) -export type HeadlessFlatTreeOptions = Pick & Pick; +export type HeadlessFlatTreeOptions = Pick & Pick & { + defaultCheckedItems?: TreeProps['checkedItems']; +}; + +// @public (undocumented) +export const renderFlatTree_unstable: (state: TreeState, contextValues: TreeContextValues) => JSX.Element; // @public (undocumented) -const renderTree_unstable: (state: TreeState, contextValues: TreeContextValues) => JSX.Element; -export { renderTree_unstable as renderFlatTree_unstable } -export { renderTree_unstable } +export const renderTree_unstable: (state: TreeState, contextValues: TreeContextValues) => JSX.Element; // @public export const renderTreeItem_unstable: (state: TreeItemState, contextValues: TreeItemContextValues) => JSX.Element; @@ -153,7 +156,7 @@ export type TreeItemContextValue = { itemType: TreeItemType; value: TreeItemValue; open: boolean; - checked?: TreeSelectionValue; + checked: TreeSelectionValue; }; // @public @@ -304,7 +307,6 @@ export type TreeProps = ComponentProps & { onNavigation_unstable?(event: TreeNavigationEvent_unstable, data: TreeNavigationData_unstable): void; selectionMode?: SelectionMode_2; checkedItems?: Iterable; - defaultCheckedItems?: Iterable; onCheckedChange?(event: TreeCheckedChangeEvent, data: TreeCheckedChangeData): void; }; @@ -331,6 +333,9 @@ export { TreeState } // @public (undocumented) export const useFlatTree_unstable: (props: FlatTreeProps, ref: React_2.Ref) => TreeState; +// @public (undocumented) +export const useFlatTreeContextValues_unstable: (state: TreeState) => TreeContextValues; + // @public (undocumented) export const useFlatTreeStyles_unstable: (state: TreeState) => TreeState; @@ -344,9 +349,7 @@ export const useTree_unstable: (props: TreeProps, ref: React_2.Ref) export const useTreeContext_unstable: (selector: ContextSelector) => T; // @public (undocumented) -function useTreeContextValues_unstable(state: TreeState): TreeContextValues; -export { useTreeContextValues_unstable as useFlatTreeContextValues_unstable } -export { useTreeContextValues_unstable } +export function useTreeContextValues_unstable(state: TreeState): TreeContextValues; // @public export function useTreeItem_unstable(props: TreeItemProps, ref: React_2.Ref): TreeItemState; diff --git a/packages/react-components/react-tree/src/components/FlatTree/FlatTree.cy.tsx b/packages/react-components/react-tree/src/components/FlatTree/FlatTree.cy.tsx index 22fc40085f6fd9..dad6f5bc2851e3 100644 --- a/packages/react-components/react-tree/src/components/FlatTree/FlatTree.cy.tsx +++ b/packages/react-components/react-tree/src/components/FlatTree/FlatTree.cy.tsx @@ -1,3 +1,6 @@ +/// +/// + import * as React from 'react'; import { mount as mountBase } from '@cypress/react'; import { FluentProvider } from '@fluentui/react-provider'; @@ -60,7 +63,7 @@ const TreeTest: React.FC = props => { props, ); return ( - + {Array.from(flatTree.items(), item => ( ))} @@ -69,7 +72,7 @@ const TreeTest: React.FC = props => { }; TreeTest.displayName = 'FlatTree'; -describe(TreeTest.displayName!, () => { +describe('FlatTree', () => { it('should have all but first level items hidden', () => { mount(); cy.get('[data-testid="item1__item1"]').should('not.exist'); @@ -125,7 +128,7 @@ describe(TreeTest.displayName!, () => { }); it('should not expand/collapse item on actions click', () => { mount( - + action!}>level 1, item 1 @@ -141,6 +144,12 @@ describe(TreeTest.displayName!, () => { cy.get(`#action`).realClick(); cy.get('[data-testid="item1__item1"]').should('not.exist'); }); + it('should select item on selector click', () => { + mount(); + cy.get('[data-testid="item1"]').should('not.have.attr', 'aria-selected', 'true'); + cy.get(`[data-testid="item1"] .${treeItemLayoutClassNames.selector}`).realClick(); + cy.get('[data-testid="item1"]').should('have.attr', 'aria-selected', 'true'); + }); }); describe('Keyboard interactions', () => { it('should expand/collapse item on Enter key', () => { @@ -168,7 +177,7 @@ describe(TreeTest.displayName!, () => { }); it('should focus on actions when pressing tab key', () => { mount( - + action}>level 1, item 1 @@ -187,7 +196,7 @@ describe(TreeTest.displayName!, () => { }); it('should not expand/collapse item on actions Enter/Space key', () => { mount( - + action}>level 1, item 1 @@ -213,7 +222,7 @@ describe(TreeTest.displayName!, () => { cy.document().realPress('Tab'); cy.get('[data-testid="item1"]').should('be.focused'); }); - it('should focus out of baseTree when pressing tab key inside baseTree.', () => { + it('should focus out of tree when pressing tab key inside tree.', () => { mount(); cy.focused().should('not.exist'); cy.document().realPress('Tab'); @@ -249,5 +258,106 @@ describe(TreeTest.displayName!, () => { cy.get('[data-testid="item1"]').should('be.focused'); }); }); + it('should select item on Space key', () => { + mount(); + cy.get('[data-testid="item1"]').should('not.have.attr', 'aria-selected', 'true'); + cy.get(`[data-testid="item1"]`).focus().realPress('Space'); + cy.get('[data-testid="item1"]').should('have.attr', 'aria-selected', 'true'); + }); + }); + describe('single selection', () => { + it('should switch selection between items', () => { + mount(); + cy.get('[data-testid="item1"]').should('not.have.attr', 'aria-selected', 'true'); + cy.get('[data-testid="item2"]').should('not.have.attr', 'aria-selected', 'true'); + cy.get(`[data-testid="item1"]`).focus().realPress('Space'); + cy.get(`[data-testid="item2"]`).focus().realPress('Space'); + cy.get('[data-testid="item1"]').should('not.have.attr', 'aria-selected', 'true'); + cy.get('[data-testid="item2"]').should('have.attr', 'aria-selected', 'true'); + }); + it('should render with a default selected item', () => { + mount(); + cy.get('[data-testid="item1"]').should('have.attr', 'aria-selected', 'true'); + }); + it('should maintain selection when closing and reopening a branch', () => { + mount(); + cy.get('[data-testid="item1__item1"]').should('have.attr', 'aria-selected', 'true'); + cy.get('[data-testid="item1"]').focus().realPress('{enter}'); + cy.get('[data-testid="item1__item1"]').should('not.exist'); + cy.get('[data-testid="item1"]').focus().realPress('{enter}'); + cy.get('[data-testid="item1__item1"]').should('exist'); + cy.get('[data-testid="item1__item1"]').should('have.attr', 'aria-selected', 'true'); + }); + }); + describe('multiple selection', () => { + it('should select multiple items', () => { + mount(); + cy.get('[data-testid="item1"]').should('not.have.attr', 'aria-checked', 'true'); + cy.get('[data-testid="item2"]').should('not.have.attr', 'aria-checked', 'true'); + cy.get(`[data-testid="item1"]`).focus().realPress('Space'); + cy.get(`[data-testid="item2"]`).focus().realPress('Space'); + cy.get('[data-testid="item1"]').should('have.attr', 'aria-checked', 'true'); + cy.get('[data-testid="item2"]').should('have.attr', 'aria-checked', 'true'); + }); + it('should have multiple items default selected', () => { + mount(); + cy.get('[data-testid="item1"]').should('have.attr', 'aria-checked', 'true'); + cy.get('[data-testid="item2"]').should('have.attr', 'aria-checked', 'true'); + }); + it('should select all children when selecting a parent', () => { + mount(); + cy.get('[data-testid="item1"]').should('have.attr', 'aria-checked', 'true'); + cy.get('[data-testid="item1__item1"]').should('have.attr', 'aria-checked', 'true'); + cy.get('[data-testid="item1__item2"]').should('have.attr', 'aria-checked', 'true'); + cy.get('[data-testid="item1__item3"]').should('have.attr', 'aria-checked', 'true'); + }); + it('should deselect all children when deselecting a parent', () => { + mount(); + cy.get('[data-testid="item1"]').focus().realPress('Space'); + cy.get('[data-testid="item1"]').should('have.attr', 'aria-checked', 'false'); + cy.get('[data-testid="item1__item1"]').should('have.attr', 'aria-checked', 'false'); + cy.get('[data-testid="item1__item2"]').should('have.attr', 'aria-checked', 'false'); + cy.get('[data-testid="item1__item3"]').should('have.attr', 'aria-checked', 'false'); + }); + it('should deselect parent when deselecting all children', () => { + mount(); + cy.get('[data-testid="item1"]').should('have.attr', 'aria-checked', 'true'); + cy.get('[data-testid="item1__item1"]').focus().realPress('Space'); + cy.get('[data-testid="item1__item2"]').focus().realPress('Space'); + cy.get('[data-testid="item1__item3"]').focus().realPress('Space'); + cy.get('[data-testid="item1"]').should('have.attr', 'aria-checked', 'false'); + }); + it('should select parent when selecting all children', () => { + mount(); + cy.get('[data-testid="item1"]').should('have.attr', 'aria-checked', 'false'); + cy.get('[data-testid="item1__item1"]').focus().realPress('Space'); + cy.get('[data-testid="item1__item2"]').focus().realPress('Space'); + cy.get('[data-testid="item1__item3"]').focus().realPress('Space'); + cy.get('[data-testid="item1"]').should('have.attr', 'aria-checked', 'true'); + }); + it('parent should be indeterminate when selecting some children', () => { + mount(); + cy.get('[data-testid="item1"]').should('have.attr', 'aria-checked', 'false'); + cy.get('[data-testid="item1__item1"]').focus().realPress('Space'); + cy.get('[data-testid="item1__item2"]').focus().realPress('Space'); + cy.get('[data-testid="item1"]').should('not.have.attr', 'aria-checked', 'false'); + }); + it('should maintain selection when closing and reopening a branch', () => { + mount( + , + ); + cy.get('[data-testid="item1__item1"]').should('have.attr', 'aria-checked', 'true'); + cy.get('[data-testid="item1"]').focus().realPress('{enter}'); + cy.get('[data-testid="item1__item1"]').should('not.exist'); + cy.get('[data-testid="item1"]').focus().realPress('{enter}'); + cy.get('[data-testid="item1__item1"]').should('exist'); + cy.get('[data-testid="item1__item1"]').should('have.attr', 'aria-checked', 'true'); + }); + it('should change selection when selecting a closed branch', () => { + mount(); + cy.get('[data-testid="item1"]').should('have.attr', 'aria-checked', 'false'); + cy.get('[data-testid="item1"]').focus().realPress('Space').realPress('{enter}'); + cy.get('[data-testid="item1__item1"]').should('have.attr', 'aria-checked', 'true'); + }); }); }); diff --git a/packages/react-components/react-tree/src/components/FlatTree/FlatTree.tsx b/packages/react-components/react-tree/src/components/FlatTree/FlatTree.tsx index 0aad4662e9ca92..04e7cf7246e439 100644 --- a/packages/react-components/react-tree/src/components/FlatTree/FlatTree.tsx +++ b/packages/react-components/react-tree/src/components/FlatTree/FlatTree.tsx @@ -1,12 +1,10 @@ import * as React from 'react'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; import type { FlatTreeProps } from './FlatTree.types'; -import { - useTreeContextValues_unstable as useFlatTreeContextValues_unstable, - renderTree_unstable as renderFlatTree_unstable, -} from '../Tree/index'; import { useFlatTree_unstable } from './useFlatTree'; import { useFlatTreeStyles_unstable } from './useFlatTreeStyles.styles'; +import { useFlatTreeContextValues_unstable } from './useFlatTreeContextValues'; +import { renderFlatTree_unstable } from './renderFlatTree'; /** * FlatTree component - TODO: add more docs diff --git a/packages/react-components/react-tree/src/components/FlatTree/index.ts b/packages/react-components/react-tree/src/components/FlatTree/index.ts index 30ad76d7e65fc3..c6dd8973d6fcf0 100644 --- a/packages/react-components/react-tree/src/components/FlatTree/index.ts +++ b/packages/react-components/react-tree/src/components/FlatTree/index.ts @@ -1,9 +1,7 @@ export * from './FlatTree'; export * from './FlatTree.types'; -export { - renderTree_unstable as renderFlatTree_unstable, - useTreeContextValues_unstable as useFlatTreeContextValues_unstable, -} from '../Tree/index'; export * from './useHeadlessFlatTree'; export * from './useFlatTree'; export * from './useFlatTreeStyles.styles'; +export * from './useFlatTreeContextValues'; +export * from './renderFlatTree'; diff --git a/packages/react-components/react-tree/src/components/FlatTree/renderFlatTree.ts b/packages/react-components/react-tree/src/components/FlatTree/renderFlatTree.ts new file mode 100644 index 00000000000000..31612c99163eef --- /dev/null +++ b/packages/react-components/react-tree/src/components/FlatTree/renderFlatTree.ts @@ -0,0 +1,5 @@ +import { TreeContextValues, renderTree_unstable } from '../../Tree'; +import type { FlatTreeState } from './FlatTree.types'; + +export const renderFlatTree_unstable: (state: FlatTreeState, contextValues: TreeContextValues) => JSX.Element = + renderTree_unstable; diff --git a/packages/react-components/react-tree/src/components/FlatTree/useFlatControllableCheckedItems.ts b/packages/react-components/react-tree/src/components/FlatTree/useFlatControllableCheckedItems.ts index bbb28a1adf70ea..67834fdd4132ca 100644 --- a/packages/react-components/react-tree/src/components/FlatTree/useFlatControllableCheckedItems.ts +++ b/packages/react-components/react-tree/src/components/FlatTree/useFlatControllableCheckedItems.ts @@ -4,27 +4,32 @@ import { ImmutableMap } from '../../utils/ImmutableMap'; import * as React from 'react'; import type { HeadlessTree, HeadlessTreeItemProps } from '../../utils/createHeadlessTree'; import { createCheckedItems } from '../../utils/createCheckedItems'; -import type { TreeCheckedChangeData, TreeProps } from '../Tree/Tree.types'; +import type { TreeCheckedChangeData } from '../Tree/Tree.types'; +import { HeadlessFlatTreeOptions } from './useHeadlessFlatTree'; -export function useFlatControllableCheckedItems(props: Pick) { - const [checkedItems, setCheckedItems] = useControllableState({ +export function useFlatControllableCheckedItems( + props: Pick, + headlessTree: HeadlessTree, +) { + return useControllableState({ initialState: ImmutableMap.empty, - state: React.useMemo(() => props.checkedItems && createCheckedItems(props.checkedItems), [props.checkedItems]), - defaultState: () => createCheckedItems(props.defaultCheckedItems), + state: React.useMemo( + () => (props.selectionMode ? props.checkedItems && createCheckedItems(props.checkedItems) : undefined), + [props.checkedItems, props.selectionMode], + ), + defaultState: () => initializeCheckedItems(props, headlessTree), }); - - return [checkedItems, setCheckedItems] as const; } -export function createNextFlatCheckedItems( +export function createNextFlatCheckedItems( data: Pick, previousCheckedItems: ImmutableMap, - virtualTree: HeadlessTree, + headlessTree: HeadlessTree, ): ImmutableMap { if (data.selectionMode === 'single') { return ImmutableMap.create([[data.value, data.checked]]); } - const treeItem = virtualTree.get(data.value); + const treeItem = headlessTree.get(data.value); if (!treeItem) { if (process.env.NODE_ENV !== 'production') { // eslint-disable-next-line no-console @@ -33,20 +38,20 @@ export function createNextFlatCheckedItems( return previousCheckedItems; } const nextCheckedItems = new Map(previousCheckedItems); - for (const children of virtualTree.subtree(data.value)) { + for (const children of headlessTree.subtree(data.value)) { nextCheckedItems.set(children.value, data.checked); } nextCheckedItems.set(data.value, data.checked); let isAncestorsMixed = false; - for (const parent of virtualTree.ancestors(treeItem.value)) { + for (const parent of headlessTree.ancestors(treeItem.value)) { // if one parent is mixed, all ancestors are mixed if (isAncestorsMixed) { nextCheckedItems.set(parent.value, 'mixed'); continue; } const checkedChildren = []; - for (const child of virtualTree.children(parent.value)) { + for (const child of headlessTree.children(parent.value)) { if ((nextCheckedItems.get(child.value) ?? false) === data.checked) { checkedChildren.push(child); } @@ -61,3 +66,19 @@ export function createNextFlatCheckedItems( } return ImmutableMap.dangerouslyCreate_unstable(nextCheckedItems); } + +function initializeCheckedItems( + props: Pick, + headlessTree: HeadlessTree, +) { + if (!props.selectionMode) { + return ImmutableMap.empty; + } + let state = createCheckedItems(props.defaultCheckedItems); + if (props.selectionMode === 'multiselect') { + for (const [value, checked] of state) { + state = createNextFlatCheckedItems({ value, checked, selectionMode: props.selectionMode }, state, headlessTree); + } + } + return state; +} diff --git a/packages/react-components/react-tree/src/components/FlatTree/useFlatTreeContextValues.ts b/packages/react-components/react-tree/src/components/FlatTree/useFlatTreeContextValues.ts new file mode 100644 index 00000000000000..fe215c0357c5f3 --- /dev/null +++ b/packages/react-components/react-tree/src/components/FlatTree/useFlatTreeContextValues.ts @@ -0,0 +1,5 @@ +import { TreeContextValues, useTreeContextValues_unstable } from '../../Tree'; +import type { FlatTreeState } from './FlatTree.types'; + +export const useFlatTreeContextValues_unstable: (state: FlatTreeState) => TreeContextValues = + useTreeContextValues_unstable; 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 51b3fdecf9b993..72c5376c5fb929 100644 --- a/packages/react-components/react-tree/src/components/FlatTree/useHeadlessFlatTree.ts +++ b/packages/react-components/react-tree/src/components/FlatTree/useHeadlessFlatTree.ts @@ -95,7 +95,9 @@ export type HeadlessFlatTreeOptions = Pick< FlatTreeProps, 'onOpenChange' | 'onNavigation_unstable' | 'selectionMode' | 'onCheckedChange' > & - Pick; + Pick & { + defaultCheckedItems?: TreeProps['checkedItems']; + }; /** * this hook provides FlatTree API to manage all required mechanisms to convert a list of items into renderable TreeItems @@ -115,7 +117,7 @@ export function useHeadlessFlatTree_unstable { const headlessTree = React.useMemo(() => createHeadlessTree(props), [props]); const [openItems, setOpenItems] = useControllableOpenItems(options); - const [checkedItems, setCheckedItems] = useFlatControllableCheckedItems(options); + const [checkedItems, setCheckedItems] = useFlatControllableCheckedItems(options, headlessTree); const { initialize, navigate } = useFlatTreeNavigation(headlessTree); const walkerRef = React.useRef(); const initializeWalker = React.useCallback( @@ -130,13 +132,21 @@ export function useHeadlessFlatTree_unstable(null); const handleOpenChange = useEventCallback((event: TreeOpenChangeEvent, data: TreeOpenChangeData) => { - options.onOpenChange?.(event, data); - setOpenItems(createNextOpenItems(data, openItems)); + const nextOpenItems = createNextOpenItems(data, openItems); + options.onOpenChange?.(event, { + ...data, + openItems: nextOpenItems.dangerouslyGetInternalSet_unstable(), + }); + setOpenItems(nextOpenItems); }); const handleCheckedChange = useEventCallback((event: TreeCheckedChangeEvent, data: TreeCheckedChangeData) => { - options.onCheckedChange?.(event, data); - setCheckedItems(createNextFlatCheckedItems(data, checkedItems, headlessTree)); + const nextCheckedItems = createNextFlatCheckedItems(data, checkedItems, headlessTree); + options.onCheckedChange?.(event, { + ...data, + checkedItems: nextCheckedItems.dangerouslyGetInternalMap_unstable(), + }); + setCheckedItems(nextCheckedItems); }); const handleNavigation = useEventCallback( diff --git a/packages/react-components/react-tree/src/components/Tree/Tree.cy.tsx b/packages/react-components/react-tree/src/components/Tree/Tree.cy.tsx index cbd560b1de6b1e..4e4a539383849f 100644 --- a/packages/react-components/react-tree/src/components/Tree/Tree.cy.tsx +++ b/packages/react-components/react-tree/src/components/Tree/Tree.cy.tsx @@ -1,3 +1,6 @@ +/// +/// + import * as React from 'react'; import { mount as mountBase } from '@cypress/react'; import { FluentProvider } from '@fluentui/react-provider'; @@ -18,7 +21,7 @@ const mount = (element: JSX.Element) => { const TreeTest: React.FC = props => { return ( - + {props.children ?? ( <> @@ -55,7 +58,7 @@ const TreeTest: React.FC = props => { }; TreeTest.displayName = 'Tree'; -describe(TreeTest.displayName!, () => { +describe('Tree', () => { it('should have all but first level items hidden', () => { mount(); cy.get('[data-testid="item1__item1"]').should('not.exist'); @@ -111,7 +114,7 @@ describe(TreeTest.displayName!, () => { }); it('should not expand/collapse item on actions click', () => { mount( - + action!}>level 1, item 1 @@ -154,7 +157,7 @@ describe(TreeTest.displayName!, () => { }); it('should focus on actions when pressing tab key', () => { mount( - + action}>level 1, item 1 @@ -173,7 +176,7 @@ describe(TreeTest.displayName!, () => { }); it('should not expand/collapse item on actions Enter/Space key', () => { mount( - + action}>level 1, item 1 @@ -199,7 +202,7 @@ describe(TreeTest.displayName!, () => { cy.document().realPress('Tab'); cy.get('[data-testid="item1"]').should('be.focused'); }); - it('should focus out of baseTree when pressing tab key inside baseTree.', () => { + it('should focus out of tree when pressing tab key inside tree.', () => { mount(); cy.focused().should('not.exist'); cy.document().realPress('Tab'); 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 8783a4bdeb6b47..242f3ef9f63bab 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 @@ -129,11 +129,6 @@ export type TreeProps = ComponentProps & { * These property is ignored for subtrees. */ checkedItems?: Iterable; - /** - * This refers to a list of ids of default checked items, or a list of tuples of ids and checked state. - * These property is ignored for subtrees. - */ - defaultCheckedItems?: Iterable; /** * Callback fired when the component changes value from checked state. * These property is ignored for subtrees. diff --git a/packages/react-components/react-tree/src/components/Tree/useControllableCheckedItems.ts b/packages/react-components/react-tree/src/components/Tree/useControllableCheckedItems.ts deleted file mode 100644 index 81f539570c33d9..00000000000000 --- a/packages/react-components/react-tree/src/components/Tree/useControllableCheckedItems.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { useControllableState } from '@fluentui/react-utilities'; -import * as React from 'react'; -import type { TreeProps } from './Tree.types'; -import { ImmutableMap } from '../../utils/ImmutableMap'; -import { createCheckedItems } from '../../utils/createCheckedItems'; - -export type ControllableCheckedItemsOptions = { - checkedItems?: TreeProps['checkedItems']; - defaultCheckedItems?: TreeProps['checkedItems']; -}; - -export function useControllableCheckedItems(props: ControllableCheckedItemsOptions) { - return useControllableState({ - initialState: ImmutableMap.empty, - state: React.useMemo(() => props.checkedItems && createCheckedItems(props.checkedItems), [props.checkedItems]), - defaultState: () => createCheckedItems(props.defaultCheckedItems), - }); -} - -// export function useCheckedItemsState(props: Pick) { -// const [walkerRef, rootRef] = useHTMLElementWalkerRef(treeItemFilter); -// const selections = React.useMemo(() => initializeSelection(props.checkedItems ?? []), [props.checkedItems]); -// const defaultSelections = React.useMemo( -// () => initializeSelection(props.defaultCheckedItems ?? []), -// [props.defaultCheckedItems], -// ); -// const [checkedSelection, checkedSelectionManager] = useSelection({ -// selectionMode: props.selectionMode ?? 'single', -// selectedItems: selections.checkedSelection, -// defaultSelectedItems: defaultSelections.checkedSelection, -// }); -// const [mixedSelection, setMixedSelection] = useControllableState({ -// initialState: ImmutableSet.empty, -// defaultState: React.useMemo( -// () => ImmutableSet.create(defaultSelections.mixedSelection), -// [defaultSelections.mixedSelection], -// ), -// state: React.useMemo(() => ImmutableSet.create(selections.mixedSelection), [selections.mixedSelection]), -// }); - -// const updateCheckedItems = useEventCallback((data: TreeCheckedChangeData) => { -// if (props.selectionMode === 'single') { -// checkedSelectionManager.selectItem(data.value); -// return; -// } -// if (walkerRef.current === null) { -// return; -// } -// const nextSelectedState = !checkedSelectionManager.isSelected(data.value); - -// let treeItemValues = getAllSubTreeItemValues(data).add(data.value); - -// let mixedValues: ImmutableSet = ImmutableSet.empty; - -// walkerRef.current.currentElement = data.event.currentTarget; -// while (walkerRef.current.parentElement() !== null) { -// const descendants = Array.from( -// walkerRef.current.currentElement.querySelectorAll('[role="treeitem"]'), -// ).filter(item => item.getAttribute(dataTreeItemValueAttrName) !== data.value); -// const isAllSiblingsEqualSelectionState = descendants.every(item => { -// return ( -// (item.getAttribute('aria-selected') === 'true') === nextSelectedState || -// treeItemValues.has(item.getAttribute(dataTreeItemValueAttrName) as TreeItemValue) -// ); -// }); -// if (isAllSiblingsEqualSelectionState) { -// treeItemValues = treeItemValues.add( -// walkerRef.current.currentElement.getAttribute(dataTreeItemValueAttrName) as TreeItemValue, -// ); -// mixedValues = mixedValues.delete( -// walkerRef.current.currentElement.getAttribute(dataTreeItemValueAttrName) as TreeItemValue, -// ); -// } else { -// treeItemValues = treeItemValues -// .delete(walkerRef.current.currentElement.getAttribute(dataTreeItemValueAttrName) as TreeItemValue) -// .add(data.value); -// mixedValues = mixedValues.add( -// walkerRef.current.currentElement.getAttribute(dataTreeItemValueAttrName) as TreeItemValue, -// ); -// } -// } -// unstable_batchedUpdates(() => { -// nextSelectedState -// ? checkedSelectionManager.selectItems(treeItemValues) -// : checkedSelectionManager.deselectItems(treeItemValues); -// let nextMixedSelection = ImmutableSet.create(mixedSelection); -// for (const value of mixedValues) { -// nextMixedSelection = nextMixedSelection.add(value); -// } -// for (const value of treeItemValues) { -// nextMixedSelection = nextMixedSelection.delete(value); -// } -// setMixedSelection(nextMixedSelection); -// }); -// }); -// return [checkedSelection, mixedSelection, updateCheckedItems, rootRef] as const; -// } - -// function getAllSubTreeItemValues(data: TreeCheckedChangeData) { -// const subTreeItems = Array.from(data.event.currentTarget.querySelectorAll('[role="treeitem"]')); -// const values = new Set(); -// for (const item of subTreeItems) { -// values.add(item.getAttribute(dataTreeItemValueAttrName) as TreeItemValue); -// } -// return ImmutableSet.dangerouslyCreate(values); -// } diff --git a/packages/react-components/react-tree/src/components/Tree/useNestedControllableCheckedItems.ts b/packages/react-components/react-tree/src/components/Tree/useNestedControllableCheckedItems.ts new file mode 100644 index 00000000000000..9c1a2c646f368c --- /dev/null +++ b/packages/react-components/react-tree/src/components/Tree/useNestedControllableCheckedItems.ts @@ -0,0 +1,22 @@ +import * as React from 'react'; +import type { TreeCheckedChangeData, TreeProps } from './Tree.types'; +import { ImmutableMap } from '../../utils/ImmutableMap'; +import { createCheckedItems } from '../../utils/createCheckedItems'; +import { TreeItemValue } from '../TreeItem/TreeItem.types'; + +export function useNestedCheckedItems(props: Pick) { + return React.useMemo(() => createCheckedItems(props.checkedItems), [props.checkedItems]); +} + +export function createNextNestedCheckedItems( + data: TreeCheckedChangeData, + previousCheckedItems: ImmutableMap, +): ImmutableMap { + if (data.selectionMode === 'single') { + return ImmutableMap.create([[data.value, data.checked]]); + } + if (data.selectionMode === 'multiselect') { + return previousCheckedItems.set(data.value, data.checked); + } + return previousCheckedItems; +} 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 6c9906331e7e90..06fc907d411274 100644 --- a/packages/react-components/react-tree/src/components/Tree/useTree.ts +++ b/packages/react-components/react-tree/src/components/Tree/useTree.ts @@ -11,17 +11,25 @@ import type { TreeState, } from './Tree.types'; import { createNextOpenItems, useControllableOpenItems } from '../../hooks/useControllableOpenItems'; -import { useTreeNavigation } from './useTreeNavigation'; -import { useControllableCheckedItems } from './useControllableCheckedItems'; +import { createNextNestedCheckedItems, useNestedCheckedItems } from './useNestedControllableCheckedItems'; import { useTreeContext_unstable } from '../../contexts/treeContext'; import { useRootTree } from '../../hooks/useRootTree'; import { useSubtree } from '../../hooks/useSubtree'; import { HTMLElementWalker, createHTMLElementWalker } from '../../utils/createHTMLElementWalker'; import { treeItemFilter } from '../../utils/treeItemFilter'; +import { useTreeNavigation } from './useTreeNavigation'; export const useTree_unstable = (props: TreeProps, ref: React.Ref): TreeState => { + const isSubtree = useTreeContext_unstable(ctx => ctx.level > 0); + // as isSubTree 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 isSubtree ? useSubtree(props, ref) : useNestedRootTree(props, ref); +}; + +function useNestedRootTree(props: TreeProps, ref: React.Ref): TreeState { const [openItems, setOpenItems] = useControllableOpenItems(props); - const [checkedItems] = useControllableCheckedItems(props); + const checkedItems = useNestedCheckedItems(props); const { navigate, initialize } = useTreeNavigation(); const walkerRef = React.useRef(); const initializeWalker = React.useCallback( @@ -35,12 +43,22 @@ export const useTree_unstable = (props: TreeProps, ref: React.Ref): ); const handleOpenChange = useEventCallback((event: TreeOpenChangeEvent, data: TreeOpenChangeData) => { - props.onOpenChange?.(event, data); - setOpenItems(createNextOpenItems(data, openItems)); + const nextOpenItems = createNextOpenItems(data, openItems); + props.onOpenChange?.(event, { + ...data, + openItems: nextOpenItems.dangerouslyGetInternalSet_unstable(), + }); + setOpenItems(nextOpenItems); }); + const handleCheckedChange = useEventCallback((event: TreeCheckedChangeEvent, data: TreeCheckedChangeData) => { - props.onCheckedChange?.(event, data); - // TODO: implement next checked items for tree + if (walkerRef.current) { + const nextCheckedItems = createNextNestedCheckedItems(data, checkedItems); + props.onCheckedChange?.(event, { + ...data, + checkedItems: nextCheckedItems.dangerouslyGetInternalMap_unstable(), + }); + } }); const handleNavigation = useEventCallback( (event: TreeNavigationEvent_unstable, data: TreeNavigationData_unstable) => { @@ -51,21 +69,16 @@ export const useTree_unstable = (props: TreeProps, ref: React.Ref): }, ); - const baseProps = { - ...props, - openItems, - checkedItems, - onOpenChange: handleOpenChange, - // eslint-disable-next-line @typescript-eslint/naming-convention - onNavigation_unstable: handleNavigation, - onCheckedChange: handleCheckedChange, - }; - - const baseRef = useMergedRefs(ref, initializeWalker); - - const isSubtree = useTreeContext_unstable(ctx => ctx.level > 0); - // as isSubTree 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 isSubtree ? useSubtree(baseProps, baseRef) : useRootTree(baseProps, baseRef); -}; + return useRootTree( + { + ...props, + openItems, + checkedItems, + onOpenChange: handleOpenChange, + // eslint-disable-next-line @typescript-eslint/naming-convention + onNavigation_unstable: handleNavigation, + onCheckedChange: handleCheckedChange, + }, + useMergedRefs(ref, initializeWalker), + ); +} 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 af3b56d89a7140..d15434395c592c 100644 --- a/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.tsx +++ b/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.tsx @@ -3,7 +3,7 @@ import { getNativeElementProps, useId, useMergedRefs } from '@fluentui/react-uti import { useEventCallback } from '@fluentui/react-utilities'; import { elementContains } from '@fluentui/react-portal'; import type { TreeItemProps, TreeItemState } from './TreeItem.types'; -import { useTreeContext_unstable, useTreeItemContext_unstable } from '../../contexts/index'; +import { useTreeContext_unstable } from '../../contexts/index'; import { dataTreeItemValueAttrName } from '../../utils/getTreeItemValueFromElement'; import { Space } from '@fluentui/keyboard-keys'; import { treeDataTypes } from '../../utils/tokens'; @@ -43,13 +43,7 @@ export function useTreeItem_unstable(props: TreeItemProps, ref: React.Ref ctx.openItems.has(value)); const selectionMode = useTreeContext_unstable(ctx => ctx.selectionMode); - const parentChecked = useTreeItemContext_unstable(ctx => ctx.checked); - const checked = useTreeContext_unstable(ctx => { - if (selectionMode === 'multiselect' && typeof parentChecked === 'boolean') { - return parentChecked; - } - return ctx.checkedItems.get(value); - }); + const checked = useTreeContext_unstable(ctx => ctx.checkedItems.get(value) ?? false); const handleClick = useEventCallback((event: React.MouseEvent) => { onClick?.(event); diff --git a/packages/react-components/react-tree/src/components/TreeItemLayout/TreeItemLayout.test.tsx b/packages/react-components/react-tree/src/components/TreeItemLayout/TreeItemLayout.test.tsx index 6aaf890606d6bb..149f8c27284abf 100644 --- a/packages/react-components/react-tree/src/components/TreeItemLayout/TreeItemLayout.test.tsx +++ b/packages/react-components/react-tree/src/components/TreeItemLayout/TreeItemLayout.test.tsx @@ -17,6 +17,7 @@ const Wrapper: React.FC = ({ children }) => ( isAsideVisible: true, itemType: 'leaf', open: false, + checked: false, }} > {children} diff --git a/packages/react-components/react-tree/src/components/TreeItemLayout/useTreeItemLayout.tsx b/packages/react-components/react-tree/src/components/TreeItemLayout/useTreeItemLayout.tsx index 6c81bdc5165a0f..8877afccce6e16 100644 --- a/packages/react-components/react-tree/src/components/TreeItemLayout/useTreeItemLayout.tsx +++ b/packages/react-components/react-tree/src/components/TreeItemLayout/useTreeItemLayout.tsx @@ -35,7 +35,7 @@ export const useTreeItemLayout_unstable = ( const selectionRef = useTreeItemContext_unstable(ctx => ctx.selectionRef); const expandIconRef = useTreeItemContext_unstable(ctx => ctx.expandIconRef); const actionsRef = useTreeItemContext_unstable(ctx => ctx.actionsRef); - const checked = useTreeItemContext_unstable(ctx => ctx.checked ?? false); + const checked = useTreeItemContext_unstable(ctx => ctx.checked); const isBranch = useTreeItemContext_unstable(ctx => ctx.itemType === 'branch'); const expandIcon = resolveShorthand(props.expandIcon, { diff --git a/packages/react-components/react-tree/src/components/TreeItemPersonaLayout/TreeItemPersonaLayout.test.tsx b/packages/react-components/react-tree/src/components/TreeItemPersonaLayout/TreeItemPersonaLayout.test.tsx index 43cfb06e103a15..6c88f97e16281a 100644 --- a/packages/react-components/react-tree/src/components/TreeItemPersonaLayout/TreeItemPersonaLayout.test.tsx +++ b/packages/react-components/react-tree/src/components/TreeItemPersonaLayout/TreeItemPersonaLayout.test.tsx @@ -17,6 +17,7 @@ const Wrapper: React.FC = ({ children }) => ( isAsideVisible: true, itemType: 'leaf', open: false, + checked: false, }} > {children} diff --git a/packages/react-components/react-tree/src/contexts/treeItemContext.ts b/packages/react-components/react-tree/src/contexts/treeItemContext.ts index 8dc467e07980ca..58526e81a1f717 100644 --- a/packages/react-components/react-tree/src/contexts/treeItemContext.ts +++ b/packages/react-components/react-tree/src/contexts/treeItemContext.ts @@ -15,7 +15,7 @@ export type TreeItemContextValue = { itemType: TreeItemType; value: TreeItemValue; open: boolean; - checked?: TreeSelectionValue; + checked: TreeSelectionValue; }; const defaultContextValue: TreeItemContextValue = { @@ -29,7 +29,7 @@ const defaultContextValue: TreeItemContextValue = { isAsideVisible: false, itemType: 'leaf', open: false, - checked: undefined, + checked: false, }; export const TreeItemContext: Context = createContext< diff --git a/packages/react-components/react-tree/src/utils/getTreeItemValueFromElement.ts b/packages/react-components/react-tree/src/utils/getTreeItemValueFromElement.ts index d5ead44bb79632..08871d0b13d1af 100644 --- a/packages/react-components/react-tree/src/utils/getTreeItemValueFromElement.ts +++ b/packages/react-components/react-tree/src/utils/getTreeItemValueFromElement.ts @@ -1,5 +1,7 @@ +import type { TreeItemValue } from '../TreeItem'; + export const dataTreeItemValueAttrName = 'data-fui-tree-item-value'; export const getTreeItemValueFromElement = (element: HTMLElement) => { - return element.getAttribute(dataTreeItemValueAttrName); + return element.getAttribute(dataTreeItemValueAttrName) as TreeItemValue | null; };