From 5022b3a79efde13869e4cbd4552d25dc94a8e146 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Fri, 28 Jul 2023 12:59:46 +0000 Subject: [PATCH 1/2] feat: implements nested tree selection --- ...-6759724f-a9fe-45df-81be-ec6cb8f2fe98.json | 7 + .../react-tree/etc/react-tree.api.md | 16 ++- .../src/components/FlatTree/FlatTree.cy.tsx | 122 +++++++++++++++++- .../src/components/FlatTree/FlatTree.tsx | 6 +- .../src/components/FlatTree/index.ts | 6 +- .../src/components/FlatTree/renderFlatTree.ts | 5 + .../useFlatControllableCheckedItems.ts | 45 +++++-- .../FlatTree/useFlatTreeContextValues.ts | 5 + .../FlatTree/useHeadlessFlatTree.ts | 20 ++- .../src/components/Tree/Tree.cy.tsx | 122 +++++++++++++++++- .../Tree/useControllableCheckedItems.ts | 106 --------------- .../Tree/useNestedControllableCheckedItems.ts | 112 ++++++++++++++++ .../react-tree/src/components/Tree/useTree.ts | 61 +++++---- .../src/utils/getTreeItemValueFromElement.ts | 4 +- .../A_Tree/TreeMultiSelection.stories.tsx | 54 ++++++++ .../A_Tree/TreeSingleSelection.stories.tsx | 39 ++++++ .../stories/A_Tree/index.stories.tsx | 2 + 17 files changed, 555 insertions(+), 177 deletions(-) create mode 100644 change/@fluentui-react-tree-6759724f-a9fe-45df-81be-ec6cb8f2fe98.json create mode 100644 packages/react-components/react-tree/src/components/FlatTree/renderFlatTree.ts create mode 100644 packages/react-components/react-tree/src/components/FlatTree/useFlatTreeContextValues.ts delete mode 100644 packages/react-components/react-tree/src/components/Tree/useControllableCheckedItems.ts create mode 100644 packages/react-components/react-tree/src/components/Tree/useNestedControllableCheckedItems.ts create mode 100644 packages/react-components/react-tree/stories/A_Tree/TreeMultiSelection.stories.tsx create mode 100644 packages/react-components/react-tree/stories/A_Tree/TreeSingleSelection.stories.tsx 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..f4c7e8f31f42c3 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,13 @@ export type HeadlessFlatTreeItem = Head export type HeadlessFlatTreeItemProps = HeadlessTreeItemProps; // @public (undocumented) -export type HeadlessFlatTreeOptions = Pick & Pick; +export type HeadlessFlatTreeOptions = Pick & Pick; // @public (undocumented) -const renderTree_unstable: (state: TreeState, contextValues: TreeContextValues) => JSX.Element; -export { renderTree_unstable as renderFlatTree_unstable } -export { renderTree_unstable } +export const renderFlatTree_unstable: (state: TreeState, contextValues: TreeContextValues) => JSX.Element; + +// @public (undocumented) +export const renderTree_unstable: (state: TreeState, contextValues: TreeContextValues) => JSX.Element; // @public export const renderTreeItem_unstable: (state: TreeItemState, contextValues: TreeItemContextValues) => JSX.Element; @@ -331,6 +332,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 +348,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..077aeac6fd52a1 100644 --- a/packages/react-components/react-tree/src/components/FlatTree/useFlatControllableCheckedItems.ts +++ b/packages/react-components/react-tree/src/components/FlatTree/useFlatControllableCheckedItems.ts @@ -5,26 +5,31 @@ 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 { 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..d29c0172b6bd99 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,7 @@ export type HeadlessFlatTreeOptions = Pick< FlatTreeProps, 'onOpenChange' | 'onNavigation_unstable' | 'selectionMode' | 'onCheckedChange' > & - Pick; + Pick; /** * this hook provides FlatTree API to manage all required mechanisms to convert a list of items into renderable TreeItems @@ -115,7 +115,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 +130,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..e57c19b58b976a 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 @@ -127,6 +130,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', () => { @@ -154,7 +163,7 @@ describe(TreeTest.displayName!, () => { }); it('should focus on actions when pressing tab key', () => { mount( - + action}>level 1, item 1 @@ -173,7 +182,7 @@ describe(TreeTest.displayName!, () => { }); it('should not expand/collapse item on actions Enter/Space key', () => { mount( - + action}>level 1, item 1 @@ -199,7 +208,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'); @@ -235,5 +244,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/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..8159eac26551fc --- /dev/null +++ b/packages/react-components/react-tree/src/components/Tree/useNestedControllableCheckedItems.ts @@ -0,0 +1,112 @@ +import { useControllableState } from '@fluentui/react-utilities'; +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'; +import { getTreeItemValueFromElement } from '../../utils/getTreeItemValueFromElement'; +import { HTMLElementWalker } from '../../utils/createHTMLElementWalker'; + +export function useNestedControllableCheckedItems(props: Pick) { + return useControllableState({ + initialState: ImmutableMap.empty, + state: React.useMemo(() => props.checkedItems && createCheckedItems(props.checkedItems), [props.checkedItems]), + defaultState: () => createCheckedItems(props.defaultCheckedItems), + }); +} + +export function createNextNestedCheckedItems( + data: TreeCheckedChangeData, + previousCheckedItems: ImmutableMap, + walker: HTMLElementWalker, +): ImmutableMap { + if (data.selectionMode === 'single') { + return ImmutableMap.create([[data.value, data.checked]]); + } + const nextCheckedItems = new Map(previousCheckedItems); + walker.currentElement = data.target; + for (const descendant of GenerateDescendants(walker.currentElement, walker)) { + const descendantValue = getTreeItemValueFromElement(descendant); + if (!descendantValue) { + continue; + } + nextCheckedItems.set(descendantValue, data.checked); + } + nextCheckedItems.set(data.value, data.checked); + + let isAncestorsMixed = false; + let previousParent = walker.currentElement; + for (const parent of GenerateAncestors(walker.currentElement, walker)) { + const parentValue = getTreeItemValueFromElement(parent); + if (!parentValue) { + continue; + } + // if one parent is mixed, all ancestors are mixed + if (isAncestorsMixed) { + nextCheckedItems.set(parentValue, 'mixed'); + continue; + } + let checkedChildrenSize = 0; + let childrenSize = 0; + for (const child of GenerateChildren(parent, walker)) { + const childValue = getTreeItemValueFromElement(child); + if (!childValue) { + continue; + } + if (child !== previousParent) { + const ariaChecked = child.getAttribute('aria-checked') as 'true' | 'false' | null; + const currentChecked = !ariaChecked ? 'mixed' : ariaChecked === 'true'; + nextCheckedItems.set(childValue, currentChecked); + } + if (nextCheckedItems.get(childValue) === data.checked) { + checkedChildrenSize++; + } + childrenSize++; + } + if (checkedChildrenSize === childrenSize) { + nextCheckedItems.set(parentValue, data.checked); + } else { + // if one parent is mixed, all ancestors are mixed + isAncestorsMixed = true; + nextCheckedItems.set(parentValue, 'mixed'); + } + previousParent = parent; + } + return ImmutableMap.dangerouslyCreate_unstable(nextCheckedItems); +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +function* GenerateChildren(target: HTMLElement, walker: HTMLElementWalker) { + const root = walker.currentElement; + walker.currentElement = target; + let nextChild = walker.firstChild(); + while (nextChild) { + yield nextChild; + nextChild = walker.nextSibling(); + } + walker.currentElement = root; +} +// eslint-disable-next-line @typescript-eslint/naming-convention +function* GenerateDescendants(target: HTMLElement, walker: HTMLElementWalker): Generator { + const root = walker.currentElement; + walker.currentElement = target; + let nextChild = walker.firstChild(); + while (nextChild) { + yield nextChild; + yield* GenerateDescendants(nextChild, walker); + nextChild = walker.nextSibling(); + } + walker.currentElement = root; +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +function* GenerateAncestors(target: HTMLElement, walker: HTMLElementWalker) { + const root = walker.currentElement; + walker.currentElement = target; + let nextParent = walker.parentElement(); + while (nextParent) { + yield nextParent; + nextParent = walker.parentElement(); + } + walker.currentElement = root; +} 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..5e291eeedbcbfe 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, useNestedControllableCheckedItems } 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, setCheckedItems] = useNestedControllableCheckedItems(props); const { navigate, initialize } = useTreeNavigation(); const walkerRef = React.useRef(); const initializeWalker = React.useCallback( @@ -35,12 +43,20 @@ 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, walkerRef.current); + props.onCheckedChange?.(event, { ...data, checkedItems: nextCheckedItems.dangerouslyGetInternalMap_unstable() }); + setCheckedItems(nextCheckedItems); + } }); const handleNavigation = useEventCallback( (event: TreeNavigationEvent_unstable, data: TreeNavigationData_unstable) => { @@ -51,21 +67,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/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; }; diff --git a/packages/react-components/react-tree/stories/A_Tree/TreeMultiSelection.stories.tsx b/packages/react-components/react-tree/stories/A_Tree/TreeMultiSelection.stories.tsx new file mode 100644 index 00000000000000..fdd1b14abc6089 --- /dev/null +++ b/packages/react-components/react-tree/stories/A_Tree/TreeMultiSelection.stories.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; +import { Tree, TreeItem, TreeItemLayout } from '@fluentui/react-tree'; + +export const MultiSelection = () => ( + + + level 1, item 1 + + + level 2, item 1 + + + level 2, item 2 + + + level 2, item 3 + + + + + level 1, item 2 + + + level 2, item 1 + + + level 3, item 1 + + + level 3, item 2 + + + level 3, item 3 + + + + + level 2, item 2 + + + level 3, item 1 + + + level 3, item 2 + + + level 3, item 3 + + + + + + +); diff --git a/packages/react-components/react-tree/stories/A_Tree/TreeSingleSelection.stories.tsx b/packages/react-components/react-tree/stories/A_Tree/TreeSingleSelection.stories.tsx new file mode 100644 index 00000000000000..5f592ce954d22e --- /dev/null +++ b/packages/react-components/react-tree/stories/A_Tree/TreeSingleSelection.stories.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import { Tree, TreeItem, TreeItemLayout } from '@fluentui/react-tree'; + +export const SingleSelection = () => { + return ( + + + level 1, item 1 + + + level 2, item 1 + + + level 2, item 2 + + + level 2, item 3 + + + + + level 1, item 2 + + + level 2, item 1 + + + level 3, item 1 + + + + + + + level 1, item 3 + + + ); +}; diff --git a/packages/react-components/react-tree/stories/A_Tree/index.stories.tsx b/packages/react-components/react-tree/stories/A_Tree/index.stories.tsx index 23315dbb92ded1..c639ea02890e87 100644 --- a/packages/react-components/react-tree/stories/A_Tree/index.stories.tsx +++ b/packages/react-components/react-tree/stories/A_Tree/index.stories.tsx @@ -8,6 +8,8 @@ export { DefaultOpenTrees } from './TreeDefaultOpenTrees.stories'; export { Appearance } from './TreeAppearance.stories'; export { Size } from './TreeSize.stories'; export { OpenItemsControlled } from './TreeControllingOpenAndClose.stories'; +export { SingleSelection } from './TreeSingleSelection.stories'; +export { MultiSelection } from './TreeMultiSelection.stories'; export default { title: 'Preview Components/Tree/Tree', From c571da5b4dbefc637b39f1d601664d924b66d324 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Wed, 2 Aug 2023 07:56:58 +0000 Subject: [PATCH 2/2] feat: removes uncontrolled nested selection --- .../react-tree/etc/react-tree.api.md | 7 +- .../useFlatControllableCheckedItems.ts | 4 +- .../FlatTree/useHeadlessFlatTree.ts | 4 +- .../src/components/Tree/Tree.cy.tsx | 107 ------------------ .../src/components/Tree/Tree.types.ts | 5 - .../Tree/useNestedControllableCheckedItems.ts | 100 +--------------- .../react-tree/src/components/Tree/useTree.ts | 12 +- .../src/components/TreeItem/useTreeItem.tsx | 10 +- .../TreeItemLayout/TreeItemLayout.test.tsx | 1 + .../TreeItemLayout/useTreeItemLayout.tsx | 2 +- .../TreeItemPersonaLayout.test.tsx | 1 + .../src/contexts/treeItemContext.ts | 4 +- .../A_Tree/TreeMultiSelection.stories.tsx | 54 --------- .../A_Tree/TreeSingleSelection.stories.tsx | 39 ------- .../stories/A_Tree/index.stories.tsx | 2 - 15 files changed, 28 insertions(+), 324 deletions(-) delete mode 100644 packages/react-components/react-tree/stories/A_Tree/TreeMultiSelection.stories.tsx delete mode 100644 packages/react-components/react-tree/stories/A_Tree/TreeSingleSelection.stories.tsx 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 f4c7e8f31f42c3..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,7 +79,9 @@ 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; @@ -154,7 +156,7 @@ export type TreeItemContextValue = { itemType: TreeItemType; value: TreeItemValue; open: boolean; - checked?: TreeSelectionValue; + checked: TreeSelectionValue; }; // @public @@ -305,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; }; 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 077aeac6fd52a1..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,7 +4,7 @@ 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( @@ -68,7 +68,7 @@ export function createNextFlatCheckedItems( } function initializeCheckedItems( - props: Pick, + props: Pick, headlessTree: HeadlessTree, ) { if (!props.selectionMode) { 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 d29c0172b6bd99..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 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 e57c19b58b976a..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 @@ -130,12 +130,6 @@ describe('Tree', () => { 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', () => { @@ -244,106 +238,5 @@ describe('Tree', () => { 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/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/useNestedControllableCheckedItems.ts b/packages/react-components/react-tree/src/components/Tree/useNestedControllableCheckedItems.ts index 8159eac26551fc..9c1a2c646f368c 100644 --- a/packages/react-components/react-tree/src/components/Tree/useNestedControllableCheckedItems.ts +++ b/packages/react-components/react-tree/src/components/Tree/useNestedControllableCheckedItems.ts @@ -1,112 +1,22 @@ -import { useControllableState } from '@fluentui/react-utilities'; 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'; -import { getTreeItemValueFromElement } from '../../utils/getTreeItemValueFromElement'; -import { HTMLElementWalker } from '../../utils/createHTMLElementWalker'; -export function useNestedControllableCheckedItems(props: Pick) { - return useControllableState({ - initialState: ImmutableMap.empty, - state: React.useMemo(() => props.checkedItems && createCheckedItems(props.checkedItems), [props.checkedItems]), - defaultState: () => createCheckedItems(props.defaultCheckedItems), - }); +export function useNestedCheckedItems(props: Pick) { + return React.useMemo(() => createCheckedItems(props.checkedItems), [props.checkedItems]); } export function createNextNestedCheckedItems( data: TreeCheckedChangeData, previousCheckedItems: ImmutableMap, - walker: HTMLElementWalker, ): ImmutableMap { if (data.selectionMode === 'single') { return ImmutableMap.create([[data.value, data.checked]]); } - const nextCheckedItems = new Map(previousCheckedItems); - walker.currentElement = data.target; - for (const descendant of GenerateDescendants(walker.currentElement, walker)) { - const descendantValue = getTreeItemValueFromElement(descendant); - if (!descendantValue) { - continue; - } - nextCheckedItems.set(descendantValue, data.checked); + if (data.selectionMode === 'multiselect') { + return previousCheckedItems.set(data.value, data.checked); } - nextCheckedItems.set(data.value, data.checked); - - let isAncestorsMixed = false; - let previousParent = walker.currentElement; - for (const parent of GenerateAncestors(walker.currentElement, walker)) { - const parentValue = getTreeItemValueFromElement(parent); - if (!parentValue) { - continue; - } - // if one parent is mixed, all ancestors are mixed - if (isAncestorsMixed) { - nextCheckedItems.set(parentValue, 'mixed'); - continue; - } - let checkedChildrenSize = 0; - let childrenSize = 0; - for (const child of GenerateChildren(parent, walker)) { - const childValue = getTreeItemValueFromElement(child); - if (!childValue) { - continue; - } - if (child !== previousParent) { - const ariaChecked = child.getAttribute('aria-checked') as 'true' | 'false' | null; - const currentChecked = !ariaChecked ? 'mixed' : ariaChecked === 'true'; - nextCheckedItems.set(childValue, currentChecked); - } - if (nextCheckedItems.get(childValue) === data.checked) { - checkedChildrenSize++; - } - childrenSize++; - } - if (checkedChildrenSize === childrenSize) { - nextCheckedItems.set(parentValue, data.checked); - } else { - // if one parent is mixed, all ancestors are mixed - isAncestorsMixed = true; - nextCheckedItems.set(parentValue, 'mixed'); - } - previousParent = parent; - } - return ImmutableMap.dangerouslyCreate_unstable(nextCheckedItems); -} - -// eslint-disable-next-line @typescript-eslint/naming-convention -function* GenerateChildren(target: HTMLElement, walker: HTMLElementWalker) { - const root = walker.currentElement; - walker.currentElement = target; - let nextChild = walker.firstChild(); - while (nextChild) { - yield nextChild; - nextChild = walker.nextSibling(); - } - walker.currentElement = root; -} -// eslint-disable-next-line @typescript-eslint/naming-convention -function* GenerateDescendants(target: HTMLElement, walker: HTMLElementWalker): Generator { - const root = walker.currentElement; - walker.currentElement = target; - let nextChild = walker.firstChild(); - while (nextChild) { - yield nextChild; - yield* GenerateDescendants(nextChild, walker); - nextChild = walker.nextSibling(); - } - walker.currentElement = root; -} - -// eslint-disable-next-line @typescript-eslint/naming-convention -function* GenerateAncestors(target: HTMLElement, walker: HTMLElementWalker) { - const root = walker.currentElement; - walker.currentElement = target; - let nextParent = walker.parentElement(); - while (nextParent) { - yield nextParent; - nextParent = walker.parentElement(); - } - walker.currentElement = root; + 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 5e291eeedbcbfe..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,7 +11,7 @@ import type { TreeState, } from './Tree.types'; import { createNextOpenItems, useControllableOpenItems } from '../../hooks/useControllableOpenItems'; -import { createNextNestedCheckedItems, useNestedControllableCheckedItems } from './useNestedControllableCheckedItems'; +import { createNextNestedCheckedItems, useNestedCheckedItems } from './useNestedControllableCheckedItems'; import { useTreeContext_unstable } from '../../contexts/treeContext'; import { useRootTree } from '../../hooks/useRootTree'; import { useSubtree } from '../../hooks/useSubtree'; @@ -29,7 +29,7 @@ export const useTree_unstable = (props: TreeProps, ref: React.Ref): function useNestedRootTree(props: TreeProps, ref: React.Ref): TreeState { const [openItems, setOpenItems] = useControllableOpenItems(props); - const [checkedItems, setCheckedItems] = useNestedControllableCheckedItems(props); + const checkedItems = useNestedCheckedItems(props); const { navigate, initialize } = useTreeNavigation(); const walkerRef = React.useRef(); const initializeWalker = React.useCallback( @@ -53,9 +53,11 @@ function useNestedRootTree(props: TreeProps, ref: React.Ref): TreeS const handleCheckedChange = useEventCallback((event: TreeCheckedChangeEvent, data: TreeCheckedChangeData) => { if (walkerRef.current) { - const nextCheckedItems = createNextNestedCheckedItems(data, checkedItems, walkerRef.current); - props.onCheckedChange?.(event, { ...data, checkedItems: nextCheckedItems.dangerouslyGetInternalMap_unstable() }); - setCheckedItems(nextCheckedItems); + const nextCheckedItems = createNextNestedCheckedItems(data, checkedItems); + props.onCheckedChange?.(event, { + ...data, + checkedItems: nextCheckedItems.dangerouslyGetInternalMap_unstable(), + }); } }); const handleNavigation = useEventCallback( 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/stories/A_Tree/TreeMultiSelection.stories.tsx b/packages/react-components/react-tree/stories/A_Tree/TreeMultiSelection.stories.tsx deleted file mode 100644 index fdd1b14abc6089..00000000000000 --- a/packages/react-components/react-tree/stories/A_Tree/TreeMultiSelection.stories.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import * as React from 'react'; -import { Tree, TreeItem, TreeItemLayout } from '@fluentui/react-tree'; - -export const MultiSelection = () => ( - - - level 1, item 1 - - - level 2, item 1 - - - level 2, item 2 - - - level 2, item 3 - - - - - level 1, item 2 - - - level 2, item 1 - - - level 3, item 1 - - - level 3, item 2 - - - level 3, item 3 - - - - - level 2, item 2 - - - level 3, item 1 - - - level 3, item 2 - - - level 3, item 3 - - - - - - -); diff --git a/packages/react-components/react-tree/stories/A_Tree/TreeSingleSelection.stories.tsx b/packages/react-components/react-tree/stories/A_Tree/TreeSingleSelection.stories.tsx deleted file mode 100644 index 5f592ce954d22e..00000000000000 --- a/packages/react-components/react-tree/stories/A_Tree/TreeSingleSelection.stories.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import * as React from 'react'; -import { Tree, TreeItem, TreeItemLayout } from '@fluentui/react-tree'; - -export const SingleSelection = () => { - return ( - - - level 1, item 1 - - - level 2, item 1 - - - level 2, item 2 - - - level 2, item 3 - - - - - level 1, item 2 - - - level 2, item 1 - - - level 3, item 1 - - - - - - - level 1, item 3 - - - ); -}; diff --git a/packages/react-components/react-tree/stories/A_Tree/index.stories.tsx b/packages/react-components/react-tree/stories/A_Tree/index.stories.tsx index c639ea02890e87..23315dbb92ded1 100644 --- a/packages/react-components/react-tree/stories/A_Tree/index.stories.tsx +++ b/packages/react-components/react-tree/stories/A_Tree/index.stories.tsx @@ -8,8 +8,6 @@ export { DefaultOpenTrees } from './TreeDefaultOpenTrees.stories'; export { Appearance } from './TreeAppearance.stories'; export { Size } from './TreeSize.stories'; export { OpenItemsControlled } from './TreeControllingOpenAndClose.stories'; -export { SingleSelection } from './TreeSingleSelection.stories'; -export { MultiSelection } from './TreeMultiSelection.stories'; export default { title: 'Preview Components/Tree/Tree',