From a628154d1c287821a4dbc033bff377359ce6ca67 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Tue, 11 Jul 2023 18:46:52 +0000 Subject: [PATCH 01/10] feat: implements selection --- ...-322a0d03-169c-4ca6-9fba-65de83877ac2.json | 7 ++ .../react-tree/etc/react-tree.api.md | 15 ++- .../react-components/react-tree/package.json | 2 + .../src/components/Tree/Tree.test.tsx | 3 +- .../src/components/Tree/Tree.types.ts | 59 ++++++++- .../src/components/Tree/useRootTree.ts | 65 +++++++++- .../src/components/Tree/useSubtree.ts | 4 + .../components/Tree/useTreeContextValues.ts | 6 +- .../src/components/TreeItem/TreeItem.types.ts | 10 ++ .../src/components/TreeItem/useTreeItem.tsx | 46 ++++++- .../TreeItem/useTreeItemContextValues.ts | 6 +- .../TreeItemLayout/TreeItemLayout.types.ts | 4 +- .../TreeItemLayout/renderTreeItemLayout.tsx | 1 + .../TreeItemLayout/useTreeItemLayout.ts | 11 +- .../useTreeItemLayoutStyles.styles.ts | 24 ++-- .../react-tree/src/contexts/treeContext.ts | 9 +- .../src/contexts/treeItemSlotsContext.ts | 7 +- .../react-tree/src/hooks/index.ts | 2 + .../src/hooks/useFlatCheckedItemsState.ts | 36 ++++++ .../react-tree/src/hooks/useFlatTree.ts | 32 ++++- .../src/hooks/useNestedCheckedItemsState.ts | 118 ++++++++++++++++++ .../react-tree/src/utils/ImmutableMap.ts | 69 ++++++++++ .../react-tree/src/utils/tokens.ts | 5 +- .../A_Tree/TreeMultipleSelection.stories.tsx | 46 +++++++ .../A_Tree/TreeSingleSelection.stories.tsx | 40 ++++++ .../stories/A_Tree/index.stories.tsx | 2 + 26 files changed, 590 insertions(+), 39 deletions(-) create mode 100644 change/@fluentui-react-tree-322a0d03-169c-4ca6-9fba-65de83877ac2.json create mode 100644 packages/react-components/react-tree/src/hooks/useFlatCheckedItemsState.ts create mode 100644 packages/react-components/react-tree/src/hooks/useNestedCheckedItemsState.ts create mode 100644 packages/react-components/react-tree/src/utils/ImmutableMap.ts create mode 100644 packages/react-components/react-tree/stories/A_Tree/TreeMultipleSelection.stories.tsx create mode 100644 packages/react-components/react-tree/stories/A_Tree/TreeSingleSelection.stories.tsx diff --git a/change/@fluentui-react-tree-322a0d03-169c-4ca6-9fba-65de83877ac2.json b/change/@fluentui-react-tree-322a0d03-169c-4ca6-9fba-65de83877ac2.json new file mode 100644 index 00000000000000..8e94d29db49f54 --- /dev/null +++ b/change/@fluentui-react-tree-322a0d03-169c-4ca6-9fba-65de83877ac2.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "feat: implements 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 8580af0363e4fb..f515c60f474e7a 100644 --- a/packages/react-components/react-tree/etc/react-tree.api.md +++ b/packages/react-components/react-tree/etc/react-tree.api.md @@ -13,6 +13,8 @@ import type { ArrowUp } from '@fluentui/keyboard-keys'; import type { AvatarContextValue } from '@fluentui/react-avatar'; import type { AvatarSize } from '@fluentui/react-avatar'; import { ButtonContextValue } from '@fluentui/react-button'; +import type { Checkbox } from '@fluentui/react-checkbox'; +import { CheckboxProps } from '@fluentui/react-checkbox'; import type { ComponentProps } from '@fluentui/react-utilities'; import type { ComponentState } from '@fluentui/react-utilities'; import { ContextSelector } from '@fluentui/react-context-selector'; @@ -24,7 +26,10 @@ import type { ForwardRefComponent } from '@fluentui/react-utilities'; import type { Home } from '@fluentui/keyboard-keys'; import { Provider } from 'react'; import { ProviderProps } from 'react'; +import type { Radio } from '@fluentui/react-radio'; +import { RadioProps } from '@fluentui/react-radio'; import * as React_2 from 'react'; +import { SelectionMode as SelectionMode_2 } from '@fluentui/react-utilities'; import type { Slot } from '@fluentui/react-utilities'; import type { SlotClassNames } from '@fluentui/react-utilities'; @@ -57,7 +62,7 @@ export type FlatTreeItemProps = Omit & Part }; // @public (undocumented) -export type FlatTreeProps = Required> & { +export type FlatTreeProps = Required> & { ref: React_2.Ref; openItems: ImmutableSet; }; @@ -89,9 +94,11 @@ export const treeClassNames: SlotClassNames; // @public (undocumented) export type TreeContextValue = { level: number; + selectionMode: 'none' | SelectionMode_2; appearance: 'subtle' | 'subtle-alpha' | 'transparent'; size: 'small' | 'medium'; openItems: ImmutableSet; + checkedItems: ImmutableMap; requestTreeResponse(request: TreeItemRequest): void; }; @@ -168,6 +175,8 @@ export type TreeItemSlots = { actions?: Slot & { visible?: boolean; }>>; + checkboxIndicator?: Slot; + radioIndicator?: Slot; }; // @public @@ -246,6 +255,10 @@ export type TreeProps = ComponentProps & { defaultOpenItems?: Iterable; onOpenChange?(event: TreeOpenChangeEvent, data: TreeOpenChangeData): void; onNavigation_unstable?(event: TreeNavigationEvent_unstable, data: TreeNavigationData_unstable): void; + selectionMode?: SelectionMode_2; + checkedItems?: Iterable; + defaultCheckedItems?: Iterable; + onCheckedChange?(event: TreeCheckedChangeEvent, data: TreeCheckedChangeData): void; }; // @public (undocumented) diff --git a/packages/react-components/react-tree/package.json b/packages/react-components/react-tree/package.json index d91ebcd042bfe5..52a943fd2d65e0 100644 --- a/packages/react-components/react-tree/package.json +++ b/packages/react-components/react-tree/package.json @@ -40,9 +40,11 @@ "@fluentui/react-aria": "^9.3.26", "@fluentui/react-avatar": "^9.5.12", "@fluentui/react-button": "^9.3.23", + "@fluentui/react-checkbox": "^9.1.24", "@fluentui/react-context-selector": "^9.1.26", "@fluentui/react-icons": "^2.0.203", "@fluentui/react-portal": "^9.3.1", + "@fluentui/react-radio": "^9.1.24", "@fluentui/react-shared-contexts": "^9.6.0", "@fluentui/react-tabster": "^9.10.0", "@fluentui/react-theme": "^9.1.9", diff --git a/packages/react-components/react-tree/src/components/Tree/Tree.test.tsx b/packages/react-components/react-tree/src/components/Tree/Tree.test.tsx index 0d01a78d0ee37f..5faefcfeb23986 100644 --- a/packages/react-components/react-tree/src/components/Tree/Tree.test.tsx +++ b/packages/react-components/react-tree/src/components/Tree/Tree.test.tsx @@ -2,9 +2,10 @@ import * as React from 'react'; import { render } from '@testing-library/react'; import { Tree } from './Tree'; import { isConformant } from '../../testing/isConformant'; +import { TreeProps } from './index'; describe('Tree', () => { - isConformant({ + isConformant({ Component: Tree, displayName: 'Tree', disabledTests: ['consistent-callback-args'], 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 01a6cc7298d145..42fc5aa1c3ba4a 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 @@ -1,9 +1,15 @@ import type * as React from 'react'; -import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import type { ComponentProps, ComponentState, SelectionMode, Slot } from '@fluentui/react-utilities'; import type { TreeContextValue } from '../../contexts/treeContext'; import type { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, End, Enter, Home } from '@fluentui/keyboard-keys'; import type { TreeItemValue } from '../TreeItem/TreeItem.types'; +import { CheckboxProps } from '@fluentui/react-checkbox'; +import { RadioProps } from '@fluentui/react-radio'; import { ImmutableSet } from '../../utils/ImmutableSet'; +import { ImmutableMap } from '../../utils/ImmutableMap'; + +export type MultiSelectValue = NonNullable; +export type SingleSelectValue = NonNullable; export type TreeSlots = { root: Slot<'div'>; @@ -39,6 +45,26 @@ export type TreeOpenChangeData = { export type TreeOpenChangeEvent = TreeOpenChangeData['event']; +export type TreeCheckedChangeData = { + value: TreeItemValue; + target: HTMLElement; + event: React.ChangeEvent; + type: 'Change'; +} & ( + | { + selectionMode: 'multiselect'; + checked: MultiSelectValue; + checkedItems: ImmutableMap; + } + | { + selectionMode: 'single'; + checked: SingleSelectValue; + checkedItems: ImmutableMap; + } +); + +export type TreeCheckedChangeEvent = TreeCheckedChangeData['event']; + export type TreeContextValues = { tree: TreeContextValue; }; @@ -90,6 +116,37 @@ export type TreeProps = ComponentProps & { */ // eslint-disable-next-line @typescript-eslint/naming-convention onNavigation_unstable?(event: TreeNavigationEvent_unstable, data: TreeNavigationData_unstable): void; + + /** + * This refers to the selection mode of the tree. + * - undefined: No selection can be done. + * - 'single': Only one tree item can be selected, radio buttons are rendered. + * - 'multiselect': Multiple tree items can be selected, checkboxes are rendered. + * + * @default undefined + */ + selectionMode?: SelectionMode; + /** + * This refers to a list of ids of checked tree items, or a list of tuples of ids and checked state. + * Controls the state of the checked tree items. + * These property is ignored for subtrees. + */ + checkedItems?: Iterable; + /** + * This refers to a list of ids of checked tree items, or a list of tuples of ids and checked state. + * Default value for the uncontrolled state of checked tree items. + * These property is ignored for subtrees. + */ + defaultCheckedItems?: Iterable; + /** + * Callback fired when the component changes value from checked state. + * These property is ignored for subtrees. + * + * @param event - a React's Synthetic event + * @param data - A data object with relevant information, + * such as checked value and type of interaction that created the event. + */ + onCheckedChange?(event: TreeCheckedChangeEvent, data: TreeCheckedChangeData): void; }; /** diff --git a/packages/react-components/react-tree/src/components/Tree/useRootTree.ts b/packages/react-components/react-tree/src/components/Tree/useRootTree.ts index 0a5636516bb563..103ff6399e63b2 100644 --- a/packages/react-components/react-tree/src/components/Tree/useRootTree.ts +++ b/packages/react-components/react-tree/src/components/Tree/useRootTree.ts @@ -1,10 +1,19 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; -import { getNativeElementProps, useEventCallback, useMergedRefs } from '@fluentui/react-utilities'; -import type { TreeOpenChangeData, TreeProps, TreeState, TreeNavigationData_unstable } from './Tree.types'; -import { createNextOpenItems, useControllableOpenItems, useNestedTreeNavigation } from '../../hooks'; +import { SelectionMode, getNativeElementProps, useEventCallback, useMergedRefs } from '@fluentui/react-utilities'; +import { + TreeOpenChangeData, + TreeProps, + TreeState, + TreeNavigationData_unstable, + TreeCheckedChangeData, +} from './Tree.types'; +import { useNestedControllableCheckedItems, useControllableOpenItems, useNestedTreeNavigation } from '../../hooks'; import { treeDataTypes } from '../../utils/tokens'; import { TreeItemRequest } from '../../contexts'; +import { ImmutableSet } from '../../utils/ImmutableSet'; +import { TreeItemValue } from '../TreeItem/TreeItem.types'; +import { ImmutableMap } from '../../utils/ImmutableMap'; /** * Create the state required to render the root level Tree. @@ -15,10 +24,11 @@ import { TreeItemRequest } from '../../contexts'; export function useRootTree(props: TreeProps, ref: React.Ref): TreeState { warnIfNoProperPropsRootTree(props); - const { appearance = 'subtle', size = 'medium' } = props; + const { appearance = 'subtle', size = 'medium', selectionMode = 'none' } = props; const [openItems, setOpenItems] = useControllableOpenItems(props); + const [checkedItems, setCheckedItems] = useNestedControllableCheckedItems(props); const [navigate, navigationRef] = useNestedTreeNavigation(); const requestOpenChange = (data: Omit) => { @@ -30,6 +40,18 @@ export function useRootTree(props: TreeProps, ref: React.Ref): Tree return setOpenItems(nextOpenItems); }; + const requestCheckedChange = (data: Omit) => { + const nextCheckedItems = createNextCheckedItems(data, checkedItems); + props.onCheckedChange?.(data.event, { + ...data, + checkedItems: nextCheckedItems, + } as TreeCheckedChangeData); + if (data.event.isDefaultPrevented()) { + return; + } + return setCheckedItems(nextCheckedItems); + }; + const requestNavigation = (data: TreeNavigationData_unstable) => { props.onNavigation_unstable?.(data.event, data); if (data.event.isDefaultPrevented()) { @@ -77,6 +99,14 @@ export function useRootTree(props: TreeProps, ref: React.Ref): Tree case treeDataTypes.ArrowDown: case treeDataTypes.TypeAhead: return requestNavigation({ ...request, target: request.event.currentTarget }); + case treeDataTypes.Change: { + const previousCheckedValue = checkedItems.get(request.value); + return requestCheckedChange({ + ...request, + selectionMode: selectionMode as SelectionMode, + checked: previousCheckedValue === 'mixed' ? true : !previousCheckedValue, + }); + } } }); @@ -84,15 +114,18 @@ export function useRootTree(props: TreeProps, ref: React.Ref): Tree components: { root: 'div', }, + selectionMode, open: true, appearance, size, level: 1, openItems, + checkedItems, requestTreeResponse, root: getNativeElementProps('div', { ref: useMergedRefs(navigationRef, ref), role: 'tree', + 'aria-multiselectable': selectionMode === 'multiselect', ...props, }), }; @@ -106,3 +139,27 @@ function warnIfNoProperPropsRootTree(props: Pick, + previousOpenItems: ImmutableSet, +): ImmutableSet { + if (data.value === null) { + return previousOpenItems; + } + const previousOpenItemsHasId = previousOpenItems.has(data.value); + if (data.open ? previousOpenItemsHasId : !previousOpenItemsHasId) { + return previousOpenItems; + } + const nextOpenItems = ImmutableSet.create(previousOpenItems); + return data.open ? nextOpenItems.add(data.value) : nextOpenItems.delete(data.value); +} + +function createNextCheckedItems( + data: Pick, + previousCheckedItems: ImmutableMap, +): ImmutableMap { + // eslint-disable-next-line no-console + console.warn('useTree: createNextCheckedItems not implemented yet'); + return previousCheckedItems; +} diff --git a/packages/react-components/react-tree/src/components/Tree/useSubtree.ts b/packages/react-components/react-tree/src/components/Tree/useSubtree.ts index 8cc9eb7a7240f8..0d72557c09314a 100644 --- a/packages/react-components/react-tree/src/components/Tree/useSubtree.ts +++ b/packages/react-components/react-tree/src/components/Tree/useSubtree.ts @@ -18,7 +18,9 @@ export function useSubtree(props: TreeProps, ref: React.Ref): TreeS const { appearance = contextAppearance ?? 'subtle', size = contextSize ?? 'medium' } = props; const parentLevel = useTreeContext_unstable(ctx => ctx.level); + const selectionMode = useTreeContext_unstable(ctx => ctx.selectionMode); const openItems = useTreeContext_unstable(ctx => ctx.openItems); + const checkedItems = useTreeContext_unstable(ctx => ctx.checkedItems); const requestTreeResponse = useTreeContext_unstable(ctx => ctx.requestTreeResponse); const open = openItems.has(value); @@ -30,6 +32,7 @@ export function useSubtree(props: TreeProps, ref: React.Ref): TreeS }, appearance, size, + selectionMode, level: parentLevel + 1, root: getNativeElementProps('div', { ref: useMergedRefs(ref, subtreeRef), @@ -37,6 +40,7 @@ export function useSubtree(props: TreeProps, ref: React.Ref): TreeS ...props, }), openItems, + checkedItems, requestTreeResponse, }; } diff --git a/packages/react-components/react-tree/src/components/Tree/useTreeContextValues.ts b/packages/react-components/react-tree/src/components/Tree/useTreeContextValues.ts index 075cfc35589cba..8f0c09ca6e0907 100644 --- a/packages/react-components/react-tree/src/components/Tree/useTreeContextValues.ts +++ b/packages/react-components/react-tree/src/components/Tree/useTreeContextValues.ts @@ -2,16 +2,18 @@ import { TreeContextValue } from '../../contexts'; import type { TreeContextValues, TreeState } from './Tree.types'; export function useTreeContextValues_unstable(state: TreeState): TreeContextValues { - const { openItems, level, appearance, size, requestTreeResponse } = state; + const { openItems, checkedItems, selectionMode, level, appearance, size, requestTreeResponse } = state; /** * This context is created with "@fluentui/react-context-selector", * there is no sense to memoize it */ const tree: TreeContextValue = { - appearance, size, level, openItems, + appearance, + checkedItems, + selectionMode, requestTreeResponse, }; diff --git a/packages/react-components/react-tree/src/components/TreeItem/TreeItem.types.ts b/packages/react-components/react-tree/src/components/TreeItem/TreeItem.types.ts index d3ddf018fffc11..ab68cc4000f9c9 100644 --- a/packages/react-components/react-tree/src/components/TreeItem/TreeItem.types.ts +++ b/packages/react-components/react-tree/src/components/TreeItem/TreeItem.types.ts @@ -3,6 +3,8 @@ import type { TreeItemContextValue } from '../../contexts'; import { treeItemLevelToken } from '../../utils/tokens'; import * as React from 'react'; import { TreeItemSlotsContextValue } from '../../contexts/treeItemSlotsContext'; +import type { Checkbox } from '@fluentui/react-checkbox'; +import type { Radio } from '@fluentui/react-radio'; export type TreeItemCSSProperties = React.CSSProperties & { [treeItemLevelToken]?: string | number }; @@ -30,6 +32,14 @@ export type TreeItemSlots = { } > >; + /** + * Selection indicator if selection type is checkbox + */ + checkboxIndicator?: Slot; + /** + * Selection indicator if selection type is radio + */ + radioIndicator?: Slot; }; export type TreeItemInternalSlot = Pick; 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 1f1389a83194ed..7f0eee1fcac5e2 100644 --- a/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.tsx +++ b/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.tsx @@ -11,9 +11,10 @@ import { useEventCallback } from '@fluentui/react-utilities'; import { elementContains } from '@fluentui/react-portal'; import type { TreeItemProps, TreeItemSlots, TreeItemState } from './TreeItem.types'; import { useTreeContext_unstable } from '../../contexts/index'; -import { treeDataTypes } from '../../utils/tokens'; import { dataTreeItemValueAttrName } from '../../utils/getTreeItemValueFromElement'; import { TreeItemChevron } from '../TreeItemChevron'; +import { Space } from '@fluentui/keyboard-keys'; +import { treeDataTypes } from '../../utils/tokens'; /** * Create the state required to render TreeItem. @@ -60,12 +61,15 @@ export function useTreeItem_unstable(props: TreeItemProps, ref: React.Ref ctx.openItems.has(value)); - const actionsRef = React.useRef(null); const expandIconRef = React.useRef(null); const layoutRef = React.useRef(null); const subtreeRef = React.useRef(null); + const selectionRef = React.useRef(null); + + const open = useTreeContext_unstable(ctx => ctx.openItems.has(value)); + const checked = useTreeContext_unstable(ctx => ctx.checkedItems.get(value) ?? false); + const selectionMode = useTreeContext_unstable(ctx => ctx.selectionMode); const actionsRefs = useMergedRefs( isResolvedShorthand(actions) ? actions.ref : undefined, @@ -87,6 +91,10 @@ export function useTreeItem_unstable(props: TreeItemProps, ref: React.Ref) => { + if (event.isDefaultPrevented()) { + return; + } + const isEventFromSubtree = subtreeRef.current && elementContains(subtreeRef.current, event.target as Node); + if (isEventFromSubtree) { + return; + } + requestTreeResponse({ event, value, itemType, type: 'Change', target: event.currentTarget }); + }); + const isBranch = itemType === 'branch'; const actionsSlot = React.useMemo( @@ -189,6 +214,7 @@ export function useTreeItem_unstable(props: TreeItemProps, ref: React.Ref ({ actions, aside, expandIcon }), - [actions, aside, expandIcon], + () => ({ actions, aside, expandIcon, selector }), + [actions, aside, expandIcon, selector], ); return { treeItem, treeItemSlots }; diff --git a/packages/react-components/react-tree/src/components/TreeItemLayout/TreeItemLayout.types.ts b/packages/react-components/react-tree/src/components/TreeItemLayout/TreeItemLayout.types.ts index 60f2354e3a3cad..1f60e97abe7a6f 100644 --- a/packages/react-components/react-tree/src/components/TreeItemLayout/TreeItemLayout.types.ts +++ b/packages/react-components/react-tree/src/components/TreeItemLayout/TreeItemLayout.types.ts @@ -1,6 +1,6 @@ import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; import { ButtonContextValue } from '@fluentui/react-button'; -import { TreeItemSlots } from '../TreeItem/TreeItem.types'; +import { TreeItemSlotsContextValue } from '../../contexts/treeItemSlotsContext'; export type TreeItemLayoutSlots = { root: Slot<'div'>; @@ -18,7 +18,7 @@ export type TreeItemLayoutSlots = { iconAfter?: Slot<'div'>; }; -export type TreeItemLayoutInternalSlots = TreeItemLayoutSlots & Pick; +export type TreeItemLayoutInternalSlots = TreeItemLayoutSlots & TreeItemSlotsContextValue; /** * TreeItemLayout Props diff --git a/packages/react-components/react-tree/src/components/TreeItemLayout/renderTreeItemLayout.tsx b/packages/react-components/react-tree/src/components/TreeItemLayout/renderTreeItemLayout.tsx index 9a1330fa15f518..86b6793d97aced 100644 --- a/packages/react-components/react-tree/src/components/TreeItemLayout/renderTreeItemLayout.tsx +++ b/packages/react-components/react-tree/src/components/TreeItemLayout/renderTreeItemLayout.tsx @@ -15,6 +15,7 @@ export const renderTreeItemLayout_unstable = (state: TreeItemLayoutState) => { return ( {slots.expandIcon && } + {slots.selector && } {slots.iconBefore && } {slotProps.root.children} {slots.iconAfter && } diff --git a/packages/react-components/react-tree/src/components/TreeItemLayout/useTreeItemLayout.ts b/packages/react-components/react-tree/src/components/TreeItemLayout/useTreeItemLayout.ts index 05b6c1fb125569..8700d9adcdd0ce 100644 --- a/packages/react-components/react-tree/src/components/TreeItemLayout/useTreeItemLayout.ts +++ b/packages/react-components/react-tree/src/components/TreeItemLayout/useTreeItemLayout.ts @@ -1,8 +1,9 @@ import * as React from 'react'; import { getNativeElementProps, resolveShorthand, useMergedRefs } from '@fluentui/react-utilities'; +import { useTreeItemContext_unstable, useTreeContext_unstable, useTreeItemSlotsContext_unstable } from '../../contexts'; import type { TreeItemLayoutProps, TreeItemLayoutState } from './TreeItemLayout.types'; -import { useTreeItemContext_unstable } from '../../contexts/treeItemContext'; -import { useTreeItemSlotsContext_unstable } from '../../contexts/treeItemSlotsContext'; +import { Checkbox, CheckboxProps } from '@fluentui/react-checkbox'; +import { Radio, RadioProps } from '@fluentui/react-radio'; /** * Create the state required to render TreeItemLayout. @@ -19,9 +20,10 @@ export const useTreeItemLayout_unstable = ( ): TreeItemLayoutState => { const { content, iconAfter, iconBefore, as = 'span' } = props; - const { actions, aside, expandIcon } = useTreeItemSlotsContext_unstable(); + const { actions, aside, expandIcon, selector } = useTreeItemSlotsContext_unstable(); const layoutRef = useTreeItemContext_unstable(ctx => ctx.layoutRef); + const selectionMode = useTreeContext_unstable(ctx => ctx.selectionMode); return { components: { @@ -32,6 +34,8 @@ export const useTreeItemLayout_unstable = ( iconAfter: 'div', actions: 'div', aside: 'div', + // Casting here to a union between checkbox and radio + selector: (selectionMode === 'multiselect' ? Checkbox : Radio) as React.ElementType, }, buttonContextValue: { size: 'small' }, root: getNativeElementProps(as, { ...props, ref: useMergedRefs(ref, layoutRef) }), @@ -41,5 +45,6 @@ export const useTreeItemLayout_unstable = ( aside: resolveShorthand(aside), actions: resolveShorthand(actions), expandIcon: resolveShorthand(expandIcon), + selector: resolveShorthand(selector), }; }; diff --git a/packages/react-components/react-tree/src/components/TreeItemLayout/useTreeItemLayoutStyles.styles.ts b/packages/react-components/react-tree/src/components/TreeItemLayout/useTreeItemLayoutStyles.styles.ts index db5c82d0319c5f..782e9c461c0a08 100644 --- a/packages/react-components/react-tree/src/components/TreeItemLayout/useTreeItemLayoutStyles.styles.ts +++ b/packages/react-components/react-tree/src/components/TreeItemLayout/useTreeItemLayoutStyles.styles.ts @@ -14,6 +14,7 @@ export const treeItemLayoutClassNames: SlotClassNames { - const { content, iconAfter, iconBefore, expandIcon, root } = state; + const { content, iconAfter, iconBefore, expandIcon, root, aside, actions, selector } = state; const rootStyles = useRootStyles(); const actionsStyles = useActionsStyles(); const asideStyles = useAsideStyles(); @@ -219,23 +220,22 @@ export const useTreeItemLayoutStyles_unstable = (state: TreeItemLayoutState): Tr ); } - if (state.actions) { - state.actions.className = mergeClasses( - treeItemLayoutClassNames.actions, - actionsStyles.base, - state.actions.className, - ); + if (actions) { + actions.className = mergeClasses(treeItemLayoutClassNames.actions, actionsStyles.base, actions.className); } - if (state.aside) { - state.aside.className = mergeClasses(treeItemLayoutClassNames.aside, asideStyles.base, state.aside.className); + if (aside) { + aside.className = mergeClasses(treeItemLayoutClassNames.aside, asideStyles.base, aside.className); } - if (state.expandIcon) { - state.expandIcon.className = mergeClasses( + if (expandIcon) { + expandIcon.className = mergeClasses( treeItemLayoutClassNames.expandIcon, expandIconStyles.base, - state.expandIcon.className, + expandIcon.className, ); } + if (selector) { + selector.className = mergeClasses(treeItemLayoutClassNames.selector, selector.className); + } return state; }; diff --git a/packages/react-components/react-tree/src/contexts/treeContext.ts b/packages/react-components/react-tree/src/contexts/treeContext.ts index 11741a0a3553f9..87fa40ae600782 100644 --- a/packages/react-components/react-tree/src/contexts/treeContext.ts +++ b/packages/react-components/react-tree/src/contexts/treeContext.ts @@ -1,13 +1,17 @@ import { Context, ContextSelector, createContext, useContextSelector } from '@fluentui/react-context-selector'; -import { TreeNavigationData_unstable, TreeOpenChangeData } from '../Tree'; +import { TreeCheckedChangeData, TreeNavigationData_unstable, TreeOpenChangeData } from '../Tree'; import { TreeItemType, TreeItemValue } from '../TreeItem'; +import { SelectionMode } from '@fluentui/react-utilities'; import { ImmutableSet } from '../utils/ImmutableSet'; +import { ImmutableMap } from '../utils/ImmutableMap'; export type TreeContextValue = { level: number; + selectionMode: 'none' | SelectionMode; appearance: 'subtle' | 'subtle-alpha' | 'transparent'; size: 'small' | 'medium'; openItems: ImmutableSet; + checkedItems: ImmutableMap; /** * requests root Tree component to respond to some tree item event, */ @@ -17,6 +21,7 @@ export type TreeContextValue = { export type TreeItemRequest = { itemType: TreeItemType } & ( | OmitWithoutExpanding | TreeNavigationData_unstable + | OmitWithoutExpanding ); // helper type that avoids the expansion of unions while inferring it, should work exactly the same as Omit @@ -24,7 +29,9 @@ type OmitWithoutExpanding = P extends unk const defaultContextValue: TreeContextValue = { level: 0, + selectionMode: 'none', openItems: ImmutableSet.empty, + checkedItems: ImmutableMap.empty, requestTreeResponse: noop, appearance: 'subtle', size: 'medium', diff --git a/packages/react-components/react-tree/src/contexts/treeItemSlotsContext.ts b/packages/react-components/react-tree/src/contexts/treeItemSlotsContext.ts index 8b01c05e36c46f..aba0fc0613fef1 100644 --- a/packages/react-components/react-tree/src/contexts/treeItemSlotsContext.ts +++ b/packages/react-components/react-tree/src/contexts/treeItemSlotsContext.ts @@ -1,7 +1,12 @@ import * as React from 'react'; import type { TreeItemSlots } from '../TreeItem'; +import type { Slot } from '@fluentui/react-utilities'; +import type { Checkbox } from '@fluentui/react-checkbox'; +import type { Radio } from '@fluentui/react-radio'; -export type TreeItemSlotsContextValue = Pick; +export type TreeItemSlotsContextValue = Pick & { + selector?: Slot | Slot; +}; const defaultContextValue: TreeItemSlotsContextValue = { actions: undefined, diff --git a/packages/react-components/react-tree/src/hooks/index.ts b/packages/react-components/react-tree/src/hooks/index.ts index 16257766327212..38230e448f4437 100644 --- a/packages/react-components/react-tree/src/hooks/index.ts +++ b/packages/react-components/react-tree/src/hooks/index.ts @@ -1,3 +1,5 @@ export * from './useFlatTree'; export * from './useNestedTreeNavigation'; export * from './useControllableOpenItems'; +export * from './useNestedCheckedItemsState'; +export * from './useFlatCheckedItemsState'; diff --git a/packages/react-components/react-tree/src/hooks/useFlatCheckedItemsState.ts b/packages/react-components/react-tree/src/hooks/useFlatCheckedItemsState.ts new file mode 100644 index 00000000000000..bbd85c9c91c5cf --- /dev/null +++ b/packages/react-components/react-tree/src/hooks/useFlatCheckedItemsState.ts @@ -0,0 +1,36 @@ +import { useControllableState, useEventCallback } from '@fluentui/react-utilities'; +import * as React from 'react'; +import type { TreeCheckedChangeData, TreeProps } from '../Tree'; +import { TreeItemValue } from '../TreeItem'; +import { ImmutableMap } from '../utils/ImmutableMap'; + +function initializeMap(iterable?: Iterable) { + const map = new Map(); + if (iterable === undefined) { + return ImmutableMap.empty; + } + for (const item of iterable) { + if (Array.isArray(item)) { + map.set(item[0], item[1]); + } else { + map.set(item, true); + } + } + return ImmutableMap.dangerouslyCreate(map); +} + +export function useFlatCheckedItemsState( + props: Pick, +) { + const [checkedItems] = useControllableState({ + initialState: ImmutableMap.empty, + state: React.useMemo(() => initializeMap(props.checkedItems), [props.checkedItems]), + defaultState: () => initializeMap(props.defaultCheckedItems), + }); + // TODO: implements updateCheckedItems for nested tree + const updateCheckedItems = useEventCallback((data: TreeCheckedChangeData) => { + // eslint-disable-next-line no-console + console.warn('useTree: updateCheckedItems not implemented yet'); + }); + return [checkedItems, updateCheckedItems] as const; +} diff --git a/packages/react-components/react-tree/src/hooks/useFlatTree.ts b/packages/react-components/react-tree/src/hooks/useFlatTree.ts index a032366827355d..cf6825a82a268b 100644 --- a/packages/react-components/react-tree/src/hooks/useFlatTree.ts +++ b/packages/react-components/react-tree/src/hooks/useFlatTree.ts @@ -5,6 +5,8 @@ import { treeDataTypes } from '../utils/tokens'; import { useFlatTreeNavigation } from './useFlatTreeNavigation'; import { useControllableOpenItems } from './useControllableOpenItems'; import type { + TreeCheckedChangeData, + TreeCheckedChangeEvent, TreeNavigationData_unstable, TreeNavigationEvent_unstable, TreeOpenChangeData, @@ -14,6 +16,7 @@ import type { import type { TreeItemProps, TreeItemValue } from '../TreeItem'; import { dataTreeItemValueAttrName } from '../utils/getTreeItemValueFromElement'; import { ImmutableSet } from '../utils/ImmutableSet'; +import { useNestedControllableCheckedItems } from './useNestedCheckedItemsState'; export type FlatTreeItemProps = Omit & Partial> & { @@ -35,7 +38,9 @@ export type FlatTreeItem = Omit; }; -export type FlatTreeProps = Required> & { +export type FlatTreeProps = Required< + Pick +> & { ref: React.Ref; openItems: ImmutableSet; }; @@ -102,7 +107,17 @@ export type FlatTree = { items(): IterableIterator>; }; -type FlatTreeOptions = Pick; +type FlatTreeOptions = Pick< + TreeProps, + | 'openItems' + | 'defaultOpenItems' + | 'onOpenChange' + | 'onNavigation_unstable' + | 'selectionMode' + | 'checkedItems' + | 'defaultCheckedItems' + | 'onCheckedChange' +>; /** * this hook provides FlatTree API to manage all required mechanisms to convert a list of items into renderable TreeItems @@ -122,6 +137,7 @@ export function useFlatTree_unstable { const flatTreeItems = React.useMemo(() => createFlatTreeItems(flatTreeItemProps), [flatTreeItemProps]); const [openItems, setOpenItems] = useControllableOpenItems(options); + const [checkedItems, setCheckedItems] = useNestedControllableCheckedItems(options); const [navigate, navigationRef] = useFlatTreeNavigation(flatTreeItems); const treeRef = React.useRef(null); @@ -133,6 +149,14 @@ export function useFlatTree_unstable { + options.onCheckedChange?.(event, data); + if (!event.isDefaultPrevented()) { + setCheckedItems(data.checkedItems); + } + event.preventDefault(); + }); + const handleNavigation = useEventCallback( (event: TreeNavigationEvent_unstable, data: TreeNavigationData_unstable) => { options.onNavigation_unstable?.(event, data); @@ -177,12 +201,14 @@ export function useFlatTree_unstable ({ ref, openItems, + checkedItems, onOpenChange: handleOpenChange, + onCheckedChange: handleCheckedChange, // eslint-disable-next-line @typescript-eslint/naming-convention onNavigation_unstable: handleNavigation, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [openItems], + [openItems, checkedItems], ); const items = React.useCallback( diff --git a/packages/react-components/react-tree/src/hooks/useNestedCheckedItemsState.ts b/packages/react-components/react-tree/src/hooks/useNestedCheckedItemsState.ts new file mode 100644 index 00000000000000..5387ba50958b0b --- /dev/null +++ b/packages/react-components/react-tree/src/hooks/useNestedCheckedItemsState.ts @@ -0,0 +1,118 @@ +import { useControllableState } from '@fluentui/react-utilities'; +import * as React from 'react'; +import type { TreeProps } from '../Tree'; +import { TreeItemValue } from '../TreeItem'; +import { ImmutableMap } from '../utils/ImmutableMap'; + +function initializeMap(iterable?: Iterable) { + const map = new Map(); + if (iterable === undefined) { + return ImmutableMap.empty; + } + for (const item of iterable) { + if (Array.isArray(item)) { + map.set(item[0], item[1]); + } else { + map.set(item, true); + } + } + return ImmutableMap.dangerouslyCreate(map); +} + +export function useNestedControllableCheckedItems( + props: Pick, +) { + return useControllableState({ + initialState: ImmutableMap.empty, + state: React.useMemo(() => initializeMap(props.checkedItems), [props.checkedItems]), + defaultState: () => initializeMap(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/utils/ImmutableMap.ts b/packages/react-components/react-tree/src/utils/ImmutableMap.ts new file mode 100644 index 00000000000000..58fc9b26a3207f --- /dev/null +++ b/packages/react-components/react-tree/src/utils/ImmutableMap.ts @@ -0,0 +1,69 @@ +export interface ImmutableMap { + clear(): ImmutableMap; + delete(key: Key): ImmutableMap; + /** + * Returns a specified element from the Map object. If the value that is associated to the provided key is an object, then you will get a reference to that object and any change made to that object will effectively modify it inside the Map. + * @returns Returns the element associated with the specified key. If no element is associated with the specified key, undefined is returned. + */ + get(key: Key): Value | undefined; + /** + * @returns boolean indicating whether an element with the specified key exists or not. + */ + has(key: Key): boolean; + /** + * Adds a new element with a specified key and value to the Map. If an element with the same key already exists, the element will be updated. + */ + set(key: Key, value: Value): ImmutableMap; + /** + * @returns the number of elements in the Map. + */ + readonly size: number; + /** Iterates over entries in the Map. */ + [Symbol.iterator](): IterableIterator<[Key, Value]>; + /** + * @internal + * Exposes the internal map used to store values. + * This is an internal API and should not be used directly. + */ + dangerousGetInternalMap(): Map; +} + +const emptyImmutableMap = createImmutableMap(); + +/** + * properly creates an ImmutableMap instance from an iterable + */ +function createImmutableMap(iterable?: Iterable<[Key, Value]>): ImmutableMap { + const internalMap = new Map(iterable); + return dangerouslyCreateImmutableMap(internalMap); +} +/** + * Avoid using *dangerouslyCreateImmutableMap*, since this method will expose internally used set, use createImmutableMap instead, + * @param internalMap - a set that is used internally to store values. + */ +function dangerouslyCreateImmutableMap(internalMap: Map): ImmutableMap { + return { + size: internalMap.size, + set: (key, value) => { + const nextSet = new Map(internalMap); + nextSet.set(key, value); + return dangerouslyCreateImmutableMap(nextSet); + }, + get: key => internalMap.get(key), + clear: () => emptyImmutableMap, + delete(value) { + const nextSet = new Map(internalMap); + nextSet.delete(value); + return dangerouslyCreateImmutableMap(nextSet); + }, + has: value => internalMap.has(value), + [Symbol.iterator]: () => internalMap[Symbol.iterator](), + dangerousGetInternalMap: () => internalMap, + }; +} + +export const ImmutableMap = { + empty: emptyImmutableMap, + create: createImmutableMap, + dangerouslyCreate: dangerouslyCreateImmutableMap, +}; diff --git a/packages/react-components/react-tree/src/utils/tokens.ts b/packages/react-components/react-tree/src/utils/tokens.ts index 42b5212d078234..e836bab0a51729 100644 --- a/packages/react-components/react-tree/src/utils/tokens.ts +++ b/packages/react-components/react-tree/src/utils/tokens.ts @@ -1,5 +1,5 @@ import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, End, Home, Enter } from '@fluentui/keyboard-keys'; -import { TreeNavigationData_unstable, TreeOpenChangeData } from '../Tree'; +import { TreeCheckedChangeData, TreeNavigationData_unstable, TreeOpenChangeData } from '../Tree'; export const treeItemLevelToken = '--fluent-TreeItem--level' as const; @@ -9,7 +9,7 @@ export const treeAvatarSize = { } as const; export const treeDataTypes: { - readonly [K in TreeOpenChangeData['type'] | TreeNavigationData_unstable['type']]: K; + readonly [K in TreeOpenChangeData['type'] | TreeNavigationData_unstable['type'] | TreeCheckedChangeData['type']]: K; } = { ArrowLeft, ArrowRight, @@ -21,4 +21,5 @@ export const treeDataTypes: { ArrowUp, ArrowDown, TypeAhead: 'TypeAhead', + Change: 'Change', }; diff --git a/packages/react-components/react-tree/stories/A_Tree/TreeMultipleSelection.stories.tsx b/packages/react-components/react-tree/stories/A_Tree/TreeMultipleSelection.stories.tsx new file mode 100644 index 00000000000000..de413a49a80457 --- /dev/null +++ b/packages/react-components/react-tree/stories/A_Tree/TreeMultipleSelection.stories.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { Tree, TreeItem, TreeItemLayout } from '@fluentui/react-tree'; +import { Image20Regular } from '@fluentui/react-icons'; + +export const MultipleSelection = () => { + 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 3, item 2 + + + + + }>level 2, item 2 + + + + + }>level 1, 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..e4a4a2868d3d6a --- /dev/null +++ b/packages/react-components/react-tree/stories/A_Tree/TreeSingleSelection.stories.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { Tree, TreeItem, TreeItemLayout } from '@fluentui/react-tree'; +import { Image20Regular } from '@fluentui/react-icons'; + +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..b7f8f7f7cee0c3 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 { MultipleSelection } from './TreeMultipleSelection.stories'; export default { title: 'Preview Components/Tree/Tree', From 34276b5947c731f5d8d88083ad2fa3db2b9b82d5 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Wed, 12 Jul 2023 04:13:01 +0000 Subject: [PATCH 02/10] chore: adds unstable suffix to immutable interfaces --- .../react-tree/src/hooks/useFlatCheckedItemsState.ts | 2 +- .../react-tree/src/hooks/useNestedCheckedItemsState.ts | 2 +- .../react-tree/src/utils/ImmutableMap.ts | 9 ++++++--- .../react-tree/src/utils/ImmutableSet.ts | 7 ++++--- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/react-components/react-tree/src/hooks/useFlatCheckedItemsState.ts b/packages/react-components/react-tree/src/hooks/useFlatCheckedItemsState.ts index bbd85c9c91c5cf..7fda04ac1d4251 100644 --- a/packages/react-components/react-tree/src/hooks/useFlatCheckedItemsState.ts +++ b/packages/react-components/react-tree/src/hooks/useFlatCheckedItemsState.ts @@ -16,7 +16,7 @@ function initializeMap(iterable?: Iterable { * Exposes the internal map used to store values. * This is an internal API and should not be used directly. */ - dangerousGetInternalMap(): Map; + // eslint-disable-next-line @typescript-eslint/naming-convention + dangerouslyGetInternalMap_unstable(): Map; } const emptyImmutableMap = createImmutableMap(); @@ -58,12 +59,14 @@ function dangerouslyCreateImmutableMap(internalMap: Map) }, has: value => internalMap.has(value), [Symbol.iterator]: () => internalMap[Symbol.iterator](), - dangerousGetInternalMap: () => internalMap, + // eslint-disable-next-line @typescript-eslint/naming-convention + dangerouslyGetInternalMap_unstable: () => internalMap, }; } export const ImmutableMap = { empty: emptyImmutableMap, create: createImmutableMap, - dangerouslyCreate: dangerouslyCreateImmutableMap, + // eslint-disable-next-line @typescript-eslint/naming-convention + dangerouslyCreate_unstable: dangerouslyCreateImmutableMap, }; diff --git a/packages/react-components/react-tree/src/utils/ImmutableSet.ts b/packages/react-components/react-tree/src/utils/ImmutableSet.ts index 3e11c23b6a2d5c..4bfe89a4232ad5 100644 --- a/packages/react-components/react-tree/src/utils/ImmutableSet.ts +++ b/packages/react-components/react-tree/src/utils/ImmutableSet.ts @@ -28,7 +28,7 @@ export interface ImmutableSet { * This is an internal API and should not be used directly. */ // eslint-disable-next-line @typescript-eslint/naming-convention - dangerousGetInternalSet_unstable(): Set; + dangerouslyGetInternalSet_unstable(): Set; } const emptyImmutableSet = createImmutableSet(); @@ -60,7 +60,7 @@ function dangerouslyCreateImmutableSet(internalSet: Set): Immutabl return internalSet[Symbol.iterator](); }, // eslint-disable-next-line @typescript-eslint/naming-convention - dangerousGetInternalSet_unstable: () => internalSet, + dangerouslyGetInternalSet_unstable: () => internalSet, }; } @@ -75,5 +75,6 @@ function createImmutableSet(iterable?: Iterable): ImmutableSet Date: Thu, 13 Jul 2023 12:33:58 +0000 Subject: [PATCH 03/10] chore: begins implementation of createNextFlatCheckedItems --- .../react-tree/etc/react-tree.api.md | 7 +- .../src/components/Tree/Tree.types.ts | 5 +- .../src/components/Tree/useRootTree.ts | 6 +- .../react-tree/src/hooks/index.ts | 4 +- .../src/hooks/useFlatCheckedItemsState.ts | 36 ---------- .../hooks/useFlatControllableCheckedItems.ts | 68 +++++++++++++++++++ .../react-tree/src/hooks/useFlatTree.ts | 13 ++-- ...s => useNestedControllableCheckedItems.ts} | 0 .../react-components/react-tree/src/index.ts | 1 + .../TreeMultipleSelection.stories.tsx | 66 ++++++++++++++++++ .../stories/D_flatTree/index.stories.tsx | 1 + 11 files changed, 155 insertions(+), 52 deletions(-) delete mode 100644 packages/react-components/react-tree/src/hooks/useFlatCheckedItemsState.ts create mode 100644 packages/react-components/react-tree/src/hooks/useFlatControllableCheckedItems.ts rename packages/react-components/react-tree/src/hooks/{useNestedCheckedItemsState.ts => useNestedControllableCheckedItems.ts} (100%) create mode 100644 packages/react-components/react-tree/stories/D_flatTree/TreeMultipleSelection.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 f515c60f474e7a..da5354ce8544e8 100644 --- a/packages/react-components/react-tree/etc/react-tree.api.md +++ b/packages/react-components/react-tree/etc/react-tree.api.md @@ -256,14 +256,17 @@ export type TreeProps = ComponentProps & { onOpenChange?(event: TreeOpenChangeEvent, data: TreeOpenChangeData): void; onNavigation_unstable?(event: TreeNavigationEvent_unstable, data: TreeNavigationData_unstable): void; selectionMode?: SelectionMode_2; - checkedItems?: Iterable; - defaultCheckedItems?: Iterable; + checkedItems?: Iterable; + defaultCheckedItems?: Iterable; onCheckedChange?(event: TreeCheckedChangeEvent, data: TreeCheckedChangeData): void; }; // @public (undocumented) export const TreeProvider: Provider & FC>; +// @public (undocumented) +export type TreeSelectionValue = MultiSelectValue | SingleSelectValue; + // @public (undocumented) export type TreeSlots = { root: Slot<'div'>; 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 42fc5aa1c3ba4a..a10702d5a01653 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 @@ -10,6 +10,7 @@ import { ImmutableMap } from '../../utils/ImmutableMap'; export type MultiSelectValue = NonNullable; export type SingleSelectValue = NonNullable; +export type TreeSelectionValue = MultiSelectValue | SingleSelectValue; export type TreeSlots = { root: Slot<'div'>; @@ -131,13 +132,13 @@ export type TreeProps = ComponentProps & { * Controls the state of the checked tree items. * These property is ignored for subtrees. */ - checkedItems?: Iterable; + checkedItems?: Iterable; /** * This refers to a list of ids of checked tree items, or a list of tuples of ids and checked state. * Default value for the uncontrolled state of checked tree items. * These property is ignored for subtrees. */ - defaultCheckedItems?: Iterable; + 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/useRootTree.ts b/packages/react-components/react-tree/src/components/Tree/useRootTree.ts index 103ff6399e63b2..16fbeb9536641d 100644 --- a/packages/react-components/react-tree/src/components/Tree/useRootTree.ts +++ b/packages/react-components/react-tree/src/components/Tree/useRootTree.ts @@ -8,7 +8,7 @@ import { TreeNavigationData_unstable, TreeCheckedChangeData, } from './Tree.types'; -import { useNestedControllableCheckedItems, useControllableOpenItems, useNestedTreeNavigation } from '../../hooks'; +import { useControllableOpenItems, useNestedTreeNavigation, useNestedControllableCheckedItems } from '../../hooks'; import { treeDataTypes } from '../../utils/tokens'; import { TreeItemRequest } from '../../contexts'; import { ImmutableSet } from '../../utils/ImmutableSet'; @@ -156,10 +156,8 @@ function createNextOpenItems( } function createNextCheckedItems( - data: Pick, + data: Pick, previousCheckedItems: ImmutableMap, ): ImmutableMap { - // eslint-disable-next-line no-console - console.warn('useTree: createNextCheckedItems not implemented yet'); return previousCheckedItems; } diff --git a/packages/react-components/react-tree/src/hooks/index.ts b/packages/react-components/react-tree/src/hooks/index.ts index 38230e448f4437..68cb5841cf40ab 100644 --- a/packages/react-components/react-tree/src/hooks/index.ts +++ b/packages/react-components/react-tree/src/hooks/index.ts @@ -1,5 +1,5 @@ export * from './useFlatTree'; export * from './useNestedTreeNavigation'; export * from './useControllableOpenItems'; -export * from './useNestedCheckedItemsState'; -export * from './useFlatCheckedItemsState'; +export * from './useNestedControllableCheckedItems'; +export * from './useFlatControllableCheckedItems'; diff --git a/packages/react-components/react-tree/src/hooks/useFlatCheckedItemsState.ts b/packages/react-components/react-tree/src/hooks/useFlatCheckedItemsState.ts deleted file mode 100644 index 7fda04ac1d4251..00000000000000 --- a/packages/react-components/react-tree/src/hooks/useFlatCheckedItemsState.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useControllableState, useEventCallback } from '@fluentui/react-utilities'; -import * as React from 'react'; -import type { TreeCheckedChangeData, TreeProps } from '../Tree'; -import { TreeItemValue } from '../TreeItem'; -import { ImmutableMap } from '../utils/ImmutableMap'; - -function initializeMap(iterable?: Iterable) { - const map = new Map(); - if (iterable === undefined) { - return ImmutableMap.empty; - } - for (const item of iterable) { - if (Array.isArray(item)) { - map.set(item[0], item[1]); - } else { - map.set(item, true); - } - } - return ImmutableMap.dangerouslyCreate_unstable(map); -} - -export function useFlatCheckedItemsState( - props: Pick, -) { - const [checkedItems] = useControllableState({ - initialState: ImmutableMap.empty, - state: React.useMemo(() => initializeMap(props.checkedItems), [props.checkedItems]), - defaultState: () => initializeMap(props.defaultCheckedItems), - }); - // TODO: implements updateCheckedItems for nested tree - const updateCheckedItems = useEventCallback((data: TreeCheckedChangeData) => { - // eslint-disable-next-line no-console - console.warn('useTree: updateCheckedItems not implemented yet'); - }); - return [checkedItems, updateCheckedItems] as const; -} diff --git a/packages/react-components/react-tree/src/hooks/useFlatControllableCheckedItems.ts b/packages/react-components/react-tree/src/hooks/useFlatControllableCheckedItems.ts new file mode 100644 index 00000000000000..057e531f4caf8b --- /dev/null +++ b/packages/react-components/react-tree/src/hooks/useFlatControllableCheckedItems.ts @@ -0,0 +1,68 @@ +import { useControllableState } from '@fluentui/react-utilities'; +import type { TreeCheckedChangeData, TreeProps } from '../Tree'; +import { TreeItemValue } from '../TreeItem'; +import { ImmutableMap } from '../utils/ImmutableMap'; +import * as React from 'react'; + +function initializeMap(iterable?: Iterable) { + const map = new Map(); + if (iterable === undefined) { + return ImmutableMap.empty; + } + for (const item of iterable) { + if (Array.isArray(item)) { + map.set(item[0], item[1]); + } else { + map.set(item, true); + } + } + return ImmutableMap.dangerouslyCreate_unstable(map); +} + +export function useFlatControllableCheckedItems( + props: Pick, +) { + const [checkedItems, setCheckedItems] = useControllableState({ + initialState: ImmutableMap.empty, + state: React.useMemo( + () => (props.checkedItems ? initializeMap(props.checkedItems) : undefined), + [props.checkedItems], + ), + defaultState: () => initializeMap(props.defaultCheckedItems), + }); + + return [checkedItems, setCheckedItems] as const; +} + +export function createNextFlatCheckedItems( + data: Pick, + previousCheckedItems: ImmutableMap, +): ImmutableMap { + // eslint-disable-next-line no-console + console.warn('useTree: createNextFlatCheckedItems not implemented yet'); + + const nextCheckedItems = new Map(previousCheckedItems.dangerouslyGetInternalMap_unstable()); // create mutable copy of previous items + + const itemId = data.value; + + if (data.selectionMode === 'multiselect') { + const isChecked = data.checked; + + if (isChecked === true || isChecked === 'mixed') { + nextCheckedItems.set(itemId, isChecked); + } else { + nextCheckedItems.delete(itemId); + } + } else if (data.selectionMode === 'single') { + const isChecked = data.checked; + + if (isChecked === true) { + nextCheckedItems.clear(); + nextCheckedItems.set(itemId, true); + } else { + nextCheckedItems.delete(itemId); + } + } + const immutable = ImmutableMap.create(nextCheckedItems); + return immutable; +} diff --git a/packages/react-components/react-tree/src/hooks/useFlatTree.ts b/packages/react-components/react-tree/src/hooks/useFlatTree.ts index cf6825a82a268b..4863e169ec0a10 100644 --- a/packages/react-components/react-tree/src/hooks/useFlatTree.ts +++ b/packages/react-components/react-tree/src/hooks/useFlatTree.ts @@ -16,7 +16,7 @@ import type { import type { TreeItemProps, TreeItemValue } from '../TreeItem'; import { dataTreeItemValueAttrName } from '../utils/getTreeItemValueFromElement'; import { ImmutableSet } from '../utils/ImmutableSet'; -import { useNestedControllableCheckedItems } from './useNestedCheckedItemsState'; +import { createNextFlatCheckedItems, useFlatControllableCheckedItems } from './useFlatControllableCheckedItems'; export type FlatTreeItemProps = Omit & Partial> & { @@ -107,7 +107,7 @@ export type FlatTree = { items(): IterableIterator>; }; -type FlatTreeOptions = Pick< +export type FlatTreeOptions = Pick< TreeProps, | 'openItems' | 'defaultOpenItems' @@ -137,10 +137,9 @@ export function useFlatTree_unstable { const flatTreeItems = React.useMemo(() => createFlatTreeItems(flatTreeItemProps), [flatTreeItemProps]); const [openItems, setOpenItems] = useControllableOpenItems(options); - const [checkedItems, setCheckedItems] = useNestedControllableCheckedItems(options); + const [checkedItems, setCheckedItems] = useFlatControllableCheckedItems(options); const [navigate, navigationRef] = useFlatTreeNavigation(flatTreeItems); const treeRef = React.useRef(null); - const handleOpenChange = useEventCallback((event: TreeOpenChangeEvent, data: TreeOpenChangeData) => { options.onOpenChange?.(event, data); if (!event.isDefaultPrevented()) { @@ -150,9 +149,10 @@ export function useFlatTree_unstable { - options.onCheckedChange?.(event, data); + const nextFlatCheckedItems = createNextFlatCheckedItems(data, checkedItems); + options.onCheckedChange?.(event, { ...data, checkedItems: nextFlatCheckedItems } as TreeCheckedChangeData); if (!event.isDefaultPrevented()) { - setCheckedItems(data.checkedItems); + setCheckedItems(nextFlatCheckedItems); } event.preventDefault(); }); @@ -201,6 +201,7 @@ export function useFlatTree_unstable ({ ref, openItems, + selectionMode: options.selectionMode, checkedItems, onOpenChange: handleOpenChange, onCheckedChange: handleCheckedChange, diff --git a/packages/react-components/react-tree/src/hooks/useNestedCheckedItemsState.ts b/packages/react-components/react-tree/src/hooks/useNestedControllableCheckedItems.ts similarity index 100% rename from packages/react-components/react-tree/src/hooks/useNestedCheckedItemsState.ts rename to packages/react-components/react-tree/src/hooks/useNestedControllableCheckedItems.ts diff --git a/packages/react-components/react-tree/src/index.ts b/packages/react-components/react-tree/src/index.ts index 78183a3a262542..a08fcf754bc38a 100644 --- a/packages/react-components/react-tree/src/index.ts +++ b/packages/react-components/react-tree/src/index.ts @@ -10,6 +10,7 @@ export type { TreeProps, TreeState, TreeSlots, + TreeSelectionValue, TreeOpenChangeData, TreeOpenChangeEvent, TreeNavigationEvent_unstable, diff --git a/packages/react-components/react-tree/stories/D_flatTree/TreeMultipleSelection.stories.tsx b/packages/react-components/react-tree/stories/D_flatTree/TreeMultipleSelection.stories.tsx new file mode 100644 index 00000000000000..efa7f6d9d2a1a8 --- /dev/null +++ b/packages/react-components/react-tree/stories/D_flatTree/TreeMultipleSelection.stories.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import { + Tree, + TreeItem, + TreeItemLayout, + useFlatTree_unstable, + FlatTreeItemProps, + TreeItemValue, + TreeSelectionValue, +} from '@fluentui/react-tree'; +import story from './useFlatTree.md'; + +type Item = FlatTreeItemProps & { content: string }; + +const defaultItems: Item[] = [ + { value: '1', content: 'Level 1, item 1' }, + { value: '1-1', parentValue: '1', content: 'Level 2, item 1' }, + { value: '1-2', parentValue: '1', content: 'Level 2, item 2' }, + { value: '1-3', parentValue: '1', content: 'Level 2, item 3' }, + { value: '2', content: 'Level 1, item 2' }, + { value: '2-1', parentValue: '2', content: 'Level 2, item 1' }, + { value: '2-1-1', parentValue: '2-1', content: 'Level 3, item 1' }, + { value: '2-2', parentValue: '2', content: 'Level 2, item 2' }, + { value: '2-2-1', parentValue: '2-2', content: 'Level 3, item 1' }, + { value: '2-2-2', parentValue: '2-2', content: 'Level 3, item 2' }, + { value: '2-2-3', parentValue: '2-2', content: 'Level 3, item 3' }, + { value: '3', content: 'Level 1, item 3' }, +]; + +export const TreeMultipleSelection = () => { + const flatTree = useFlatTree_unstable(defaultItems, { + defaultOpenItems: ['1', '2'], + selectionMode: 'multiselect', + // defaultCheckedItems: new Map([ + // ['1-1', true], + // ['2-1', true], + // ]), + checkedItems: new Map([ + ['1-1', true], + ['2-1', 'mixed' as const], + ]), + }); + + console.log(flatTree.getTreeProps().checkedItems); + + return ( + + {Array.from(flatTree.items(), flatTreeItem => { + const { content, ...treeItemProps } = flatTreeItem.getTreeItemProps(); + return ( + + {content} + + ); + })} + + ); +}; + +TreeMultipleSelection.parameters = { + docs: { + description: { + story, + }, + }, +}; diff --git a/packages/react-components/react-tree/stories/D_flatTree/index.stories.tsx b/packages/react-components/react-tree/stories/D_flatTree/index.stories.tsx index c538b33ca0d81f..cd6d18d5d55e4a 100644 --- a/packages/react-components/react-tree/stories/D_flatTree/index.stories.tsx +++ b/packages/react-components/react-tree/stories/D_flatTree/index.stories.tsx @@ -7,6 +7,7 @@ export { Virtualization } from './Virtualization.stories'; export { AddRemoveTreeItem } from './TreeItemAddRemove.stories'; export { LazyLoading } from './TreeLazyLoading.stories'; export { InfiniteScrolling } from './TreeInfiniteScrolling.stories'; +export { TreeMultipleSelection } from './TreeMultipleSelection.stories'; export default { title: 'Preview Components/Tree/flatTree', From 07d2cdd55b948f520ce40ac5aef6cbfee2fa5999 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Thu, 13 Jul 2023 13:51:56 +0000 Subject: [PATCH 04/10] chore: implements selection in flat scenario --- .../src/components/Tree/useRootTree.ts | 3 + .../hooks/useFlatControllableCheckedItems.ts | 59 ++++++++++--------- .../react-tree/src/hooks/useFlatTree.ts | 2 +- .../useNestedControllableCheckedItems.ts | 2 +- .../src/utils/createFlatTreeItems.ts | 16 +++++ .../TreeMultipleSelection.stories.tsx | 22 ++----- 6 files changed, 59 insertions(+), 45 deletions(-) diff --git a/packages/react-components/react-tree/src/components/Tree/useRootTree.ts b/packages/react-components/react-tree/src/components/Tree/useRootTree.ts index 16fbeb9536641d..5805792ed16d88 100644 --- a/packages/react-components/react-tree/src/components/Tree/useRootTree.ts +++ b/packages/react-components/react-tree/src/components/Tree/useRootTree.ts @@ -159,5 +159,8 @@ function createNextCheckedItems( data: Pick, previousCheckedItems: ImmutableMap, ): ImmutableMap { + if (data.selectionMode === 'single') { + return ImmutableMap.create([[data.value, data.checked]]); + } return previousCheckedItems; } diff --git a/packages/react-components/react-tree/src/hooks/useFlatControllableCheckedItems.ts b/packages/react-components/react-tree/src/hooks/useFlatControllableCheckedItems.ts index 057e531f4caf8b..74eebcc21bef7e 100644 --- a/packages/react-components/react-tree/src/hooks/useFlatControllableCheckedItems.ts +++ b/packages/react-components/react-tree/src/hooks/useFlatControllableCheckedItems.ts @@ -3,6 +3,8 @@ import type { TreeCheckedChangeData, TreeProps } from '../Tree'; import { TreeItemValue } from '../TreeItem'; import { ImmutableMap } from '../utils/ImmutableMap'; import * as React from 'react'; +import type { FlatTreeItem, FlatTreeItemProps } from './useFlatTree'; +import { flatTreeRootId, type FlatTreeItems } from '../utils/createFlatTreeItems'; function initializeMap(iterable?: Iterable) { const map = new Map(); @@ -24,45 +26,48 @@ export function useFlatControllableCheckedItems( ) { const [checkedItems, setCheckedItems] = useControllableState({ initialState: ImmutableMap.empty, - state: React.useMemo( - () => (props.checkedItems ? initializeMap(props.checkedItems) : undefined), - [props.checkedItems], - ), + state: React.useMemo(() => props.checkedItems && initializeMap(props.checkedItems), [props.checkedItems]), defaultState: () => initializeMap(props.defaultCheckedItems), }); return [checkedItems, setCheckedItems] as const; } -export function createNextFlatCheckedItems( +export function createNextFlatCheckedItems( data: Pick, previousCheckedItems: ImmutableMap, + flatTreeItems: FlatTreeItems, ): ImmutableMap { - // eslint-disable-next-line no-console - console.warn('useTree: createNextFlatCheckedItems not implemented yet'); - - const nextCheckedItems = new Map(previousCheckedItems.dangerouslyGetInternalMap_unstable()); // create mutable copy of previous items - - const itemId = data.value; - - if (data.selectionMode === 'multiselect') { - const isChecked = data.checked; + if (data.selectionMode === 'single') { + return ImmutableMap.create([[data.value, data.checked]]); + } + const treeItem = flatTreeItems.get(data.value); + if (!treeItem) { + // eslint-disable-next-line no-console + console.error(`useFlatTree: tree item ${data.value} not found`); + return previousCheckedItems; + } + const nextCheckedItems = new Map(previousCheckedItems); + for (const children of flatTreeItems.getSubtree(data.value)) { + nextCheckedItems.set(children.value, data.checked); + } + nextCheckedItems.set(data.value, data.checked); - if (isChecked === true || isChecked === 'mixed') { - nextCheckedItems.set(itemId, isChecked); - } else { - nextCheckedItems.delete(itemId); + let parent: FlatTreeItem | undefined = treeItem; + while ((parent = flatTreeItems.get(parent?.parentValue!))) { + if (parent.value === flatTreeRootId) { + break; } - } else if (data.selectionMode === 'single') { - const isChecked = data.checked; - - if (isChecked === true) { - nextCheckedItems.clear(); - nextCheckedItems.set(itemId, true); + const subtree = flatTreeItems.getSubtree(parent.value); + const checkedChildren = subtree.filter(item => { + return (nextCheckedItems.get(item.value) ?? false) === data.checked; + }); + if (checkedChildren.length === subtree.length) { + nextCheckedItems.set(parent.value, data.checked); } else { - nextCheckedItems.delete(itemId); + nextCheckedItems.set(parent.value, 'mixed'); } } - const immutable = ImmutableMap.create(nextCheckedItems); - return immutable; + + return ImmutableMap.dangerouslyCreate_unstable(nextCheckedItems); } diff --git a/packages/react-components/react-tree/src/hooks/useFlatTree.ts b/packages/react-components/react-tree/src/hooks/useFlatTree.ts index 4863e169ec0a10..16742e5b77a619 100644 --- a/packages/react-components/react-tree/src/hooks/useFlatTree.ts +++ b/packages/react-components/react-tree/src/hooks/useFlatTree.ts @@ -149,7 +149,7 @@ export function useFlatTree_unstable { - const nextFlatCheckedItems = createNextFlatCheckedItems(data, checkedItems); + const nextFlatCheckedItems = createNextFlatCheckedItems(data, checkedItems, flatTreeItems); options.onCheckedChange?.(event, { ...data, checkedItems: nextFlatCheckedItems } as TreeCheckedChangeData); if (!event.isDefaultPrevented()) { setCheckedItems(nextFlatCheckedItems); diff --git a/packages/react-components/react-tree/src/hooks/useNestedControllableCheckedItems.ts b/packages/react-components/react-tree/src/hooks/useNestedControllableCheckedItems.ts index 364d2ba8532bc6..4d768a13c81c0f 100644 --- a/packages/react-components/react-tree/src/hooks/useNestedControllableCheckedItems.ts +++ b/packages/react-components/react-tree/src/hooks/useNestedControllableCheckedItems.ts @@ -24,7 +24,7 @@ export function useNestedControllableCheckedItems( ) { return useControllableState({ initialState: ImmutableMap.empty, - state: React.useMemo(() => initializeMap(props.checkedItems), [props.checkedItems]), + state: React.useMemo(() => props.checkedItems && initializeMap(props.checkedItems), [props.checkedItems]), defaultState: () => initializeMap(props.defaultCheckedItems), }); } diff --git a/packages/react-components/react-tree/src/utils/createFlatTreeItems.ts b/packages/react-components/react-tree/src/utils/createFlatTreeItems.ts index ab8fde5691fb99..b5e38ccc4eac19 100644 --- a/packages/react-components/react-tree/src/utils/createFlatTreeItems.ts +++ b/packages/react-components/react-tree/src/utils/createFlatTreeItems.ts @@ -9,6 +9,7 @@ export type FlatTreeItems = { size: number; root: FlatTreeItem; get(key: TreeItemValue): FlatTreeItem | undefined; + getSubtree(key: TreeItemValue): FlatTreeItem[]; set(key: TreeItemValue, value: FlatTreeItem): void; getByIndex(index: number): FlatTreeItem; }; @@ -64,6 +65,21 @@ export function createFlatTreeItems(flatTreeIte root, size: items.length, getByIndex: index => items[index], + getSubtree: key => { + const item = itemsPerValue.get(key); + if (!item || item.childrenSize === 0) { + return []; + } + let counter = item.childrenSize; + let index = item.index; + const subtree: FlatTreeItem[] = []; + while (counter > 0) { + const children = items[++index]; + subtree.push(children); + counter += children.childrenSize - 1; + } + return subtree; + }, get: key => itemsPerValue.get(key), set: (key, value) => itemsPerValue.set(key, value), }; diff --git a/packages/react-components/react-tree/stories/D_flatTree/TreeMultipleSelection.stories.tsx b/packages/react-components/react-tree/stories/D_flatTree/TreeMultipleSelection.stories.tsx index efa7f6d9d2a1a8..5de59fff888a9a 100644 --- a/packages/react-components/react-tree/stories/D_flatTree/TreeMultipleSelection.stories.tsx +++ b/packages/react-components/react-tree/stories/D_flatTree/TreeMultipleSelection.stories.tsx @@ -1,13 +1,5 @@ import * as React from 'react'; -import { - Tree, - TreeItem, - TreeItemLayout, - useFlatTree_unstable, - FlatTreeItemProps, - TreeItemValue, - TreeSelectionValue, -} from '@fluentui/react-tree'; +import { Tree, TreeItem, TreeItemLayout, useFlatTree_unstable, FlatTreeItemProps } from '@fluentui/react-tree'; import story from './useFlatTree.md'; type Item = FlatTreeItemProps & { content: string }; @@ -29,20 +21,18 @@ const defaultItems: Item[] = [ export const TreeMultipleSelection = () => { const flatTree = useFlatTree_unstable(defaultItems, { - defaultOpenItems: ['1', '2'], + defaultOpenItems: ['1', '2', '2-1', '2-2'], selectionMode: 'multiselect', // defaultCheckedItems: new Map([ // ['1-1', true], // ['2-1', true], // ]), - checkedItems: new Map([ - ['1-1', true], - ['2-1', 'mixed' as const], - ]), + // checkedItems: new Map([ + // ['1-1', true], + // ['2-1', 'mixed'], + // ]), }); - console.log(flatTree.getTreeProps().checkedItems); - return ( {Array.from(flatTree.items(), flatTreeItem => { From 95bacea7a020c910033efde2c7abac4313c1029e Mon Sep 17 00:00:00 2001 From: petdud Date: Thu, 13 Jul 2023 16:24:44 +0200 Subject: [PATCH 05/10] tree selection stories for single and multi select --- .../stories/D_flatTree/TreeMultiSelection.md | 1 + ...ies.tsx => TreeMultiSelection.stories.tsx} | 14 ++---- .../stories/D_flatTree/TreeSingleSelection.md | 1 + .../TreeSingleSelection.stories.tsx | 44 +++++++++++++++++++ .../stories/D_flatTree/index.stories.tsx | 3 +- 5 files changed, 51 insertions(+), 12 deletions(-) create mode 100644 packages/react-components/react-tree/stories/D_flatTree/TreeMultiSelection.md rename packages/react-components/react-tree/stories/D_flatTree/{TreeMultipleSelection.stories.tsx => TreeMultiSelection.stories.tsx} (82%) create mode 100644 packages/react-components/react-tree/stories/D_flatTree/TreeSingleSelection.md create mode 100644 packages/react-components/react-tree/stories/D_flatTree/TreeSingleSelection.stories.tsx diff --git a/packages/react-components/react-tree/stories/D_flatTree/TreeMultiSelection.md b/packages/react-components/react-tree/stories/D_flatTree/TreeMultiSelection.md new file mode 100644 index 00000000000000..ccd184d97bb3cf --- /dev/null +++ b/packages/react-components/react-tree/stories/D_flatTree/TreeMultiSelection.md @@ -0,0 +1 @@ +In this multi-selection `Tree` example, the `TreeItem` and `useFlatTree_unstable` hook build upon the same principles as the single selection example but enable the selection of multiple items at once. This is achieved by setting the `selectionMode` prop to `multiselect`, enhancing user interactivity when multiple choices are required. Optional props `defaultCheckedItems` and `checkedItems` can further refine selection control. diff --git a/packages/react-components/react-tree/stories/D_flatTree/TreeMultipleSelection.stories.tsx b/packages/react-components/react-tree/stories/D_flatTree/TreeMultiSelection.stories.tsx similarity index 82% rename from packages/react-components/react-tree/stories/D_flatTree/TreeMultipleSelection.stories.tsx rename to packages/react-components/react-tree/stories/D_flatTree/TreeMultiSelection.stories.tsx index 5de59fff888a9a..ed9b6e67cb89ea 100644 --- a/packages/react-components/react-tree/stories/D_flatTree/TreeMultipleSelection.stories.tsx +++ b/packages/react-components/react-tree/stories/D_flatTree/TreeMultiSelection.stories.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { Tree, TreeItem, TreeItemLayout, useFlatTree_unstable, FlatTreeItemProps } from '@fluentui/react-tree'; -import story from './useFlatTree.md'; +import story from './TreeMultiSelection.md'; type Item = FlatTreeItemProps & { content: string }; @@ -19,18 +19,10 @@ const defaultItems: Item[] = [ { value: '3', content: 'Level 1, item 3' }, ]; -export const TreeMultipleSelection = () => { +export const TreeMultiSelection = () => { const flatTree = useFlatTree_unstable(defaultItems, { defaultOpenItems: ['1', '2', '2-1', '2-2'], selectionMode: 'multiselect', - // defaultCheckedItems: new Map([ - // ['1-1', true], - // ['2-1', true], - // ]), - // checkedItems: new Map([ - // ['1-1', true], - // ['2-1', 'mixed'], - // ]), }); return ( @@ -47,7 +39,7 @@ export const TreeMultipleSelection = () => { ); }; -TreeMultipleSelection.parameters = { +TreeMultiSelection.parameters = { docs: { description: { story, diff --git a/packages/react-components/react-tree/stories/D_flatTree/TreeSingleSelection.md b/packages/react-components/react-tree/stories/D_flatTree/TreeSingleSelection.md new file mode 100644 index 00000000000000..ffd20d7e5bfc02 --- /dev/null +++ b/packages/react-components/react-tree/stories/D_flatTree/TreeSingleSelection.md @@ -0,0 +1 @@ +In this single selection `Tree` example, the `TreeItem` and `useFlatTree_unstable` hook convert a flat array of items into a hierarchical tree. The `selectionMode` prop, set to `single`, activates selection functionality, allowing only one item to be active at a time. Optional props such as `defaultCheckedItems` and `checkedItems` offer extended control over the selection process. The tree also supports specifying items to be open upon initial render, ensuring a user-friendly navigation experience. diff --git a/packages/react-components/react-tree/stories/D_flatTree/TreeSingleSelection.stories.tsx b/packages/react-components/react-tree/stories/D_flatTree/TreeSingleSelection.stories.tsx new file mode 100644 index 00000000000000..3910aafd9ff6d7 --- /dev/null +++ b/packages/react-components/react-tree/stories/D_flatTree/TreeSingleSelection.stories.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { Tree, TreeItem, TreeItemLayout, useFlatTree_unstable, FlatTreeItemProps } from '@fluentui/react-tree'; +import story from './TreeSingleSelection.md'; + +type Item = FlatTreeItemProps & { content: string }; + +const defaultItems: Item[] = [ + { value: '1', content: 'Level 1, item 1' }, + { value: '1-1', parentValue: '1', content: 'Level 2, item 1' }, + { value: '1-2', parentValue: '1', content: 'Level 2, item 2' }, + { value: '2', content: 'Level 1, item 2' }, + { value: '2-1', parentValue: '2', content: 'Level 2, item 1' }, + { value: '2-1-1', parentValue: '2-1', content: 'Level 3, item 1' }, + { value: '2-2', parentValue: '2', content: 'Level 2, item 2' }, + { value: '3', content: 'Level 1, item 3' }, +]; + +export const TreeSingleSelection = () => { + const flatTree = useFlatTree_unstable(defaultItems, { + defaultOpenItems: ['1', '2', '2-1'], + selectionMode: 'single', + }); + + return ( + + {Array.from(flatTree.items(), flatTreeItem => { + const { content, ...treeItemProps } = flatTreeItem.getTreeItemProps(); + return ( + + {content} + + ); + })} + + ); +}; + +TreeSingleSelection.parameters = { + docs: { + description: { + story, + }, + }, +}; diff --git a/packages/react-components/react-tree/stories/D_flatTree/index.stories.tsx b/packages/react-components/react-tree/stories/D_flatTree/index.stories.tsx index cd6d18d5d55e4a..d6e26df61a7354 100644 --- a/packages/react-components/react-tree/stories/D_flatTree/index.stories.tsx +++ b/packages/react-components/react-tree/stories/D_flatTree/index.stories.tsx @@ -7,7 +7,8 @@ export { Virtualization } from './Virtualization.stories'; export { AddRemoveTreeItem } from './TreeItemAddRemove.stories'; export { LazyLoading } from './TreeLazyLoading.stories'; export { InfiniteScrolling } from './TreeInfiniteScrolling.stories'; -export { TreeMultipleSelection } from './TreeMultipleSelection.stories'; +export { TreeSingleSelection } from './TreeSingleSelection.stories'; +export { TreeMultiSelection } from './TreeMultiSelection.stories'; export default { title: 'Preview Components/Tree/flatTree', From 699b3531249cc1353fc995f66ceb02f67db71a8d Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Thu, 13 Jul 2023 15:27:10 +0000 Subject: [PATCH 06/10] chore: adds short circuit to improve performance on selection --- .../src/hooks/useFlatControllableCheckedItems.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/react-components/react-tree/src/hooks/useFlatControllableCheckedItems.ts b/packages/react-components/react-tree/src/hooks/useFlatControllableCheckedItems.ts index 74eebcc21bef7e..a6397484a63fcc 100644 --- a/packages/react-components/react-tree/src/hooks/useFlatControllableCheckedItems.ts +++ b/packages/react-components/react-tree/src/hooks/useFlatControllableCheckedItems.ts @@ -54,10 +54,15 @@ export function createNextFlatCheckedItems | undefined = treeItem; + let isAncestorsMixed = false; while ((parent = flatTreeItems.get(parent?.parentValue!))) { if (parent.value === flatTreeRootId) { break; } + if (isAncestorsMixed) { + nextCheckedItems.set(parent.value, 'mixed'); + continue; + } const subtree = flatTreeItems.getSubtree(parent.value); const checkedChildren = subtree.filter(item => { return (nextCheckedItems.get(item.value) ?? false) === data.checked; @@ -65,6 +70,8 @@ export function createNextFlatCheckedItems Date: Thu, 13 Jul 2023 15:29:31 +0000 Subject: [PATCH 07/10] chore: removes checkedItem from TreeCheckedChangeData + exports TreeCheckedChangeData and TreeCheckedChangeEvent --- .../react-tree/etc/react-tree.api.md | 17 +++++++++++++++++ .../src/components/Tree/Tree.types.ts | 3 --- .../src/components/Tree/useRootTree.ts | 7 ++----- .../react-tree/src/hooks/useFlatTree.ts | 2 +- .../react-components/react-tree/src/index.ts | 2 ++ 5 files changed, 22 insertions(+), 9 deletions(-) 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 da5354ce8544e8..4ba2bf95824ef6 100644 --- a/packages/react-components/react-tree/etc/react-tree.api.md +++ b/packages/react-components/react-tree/etc/react-tree.api.md @@ -88,6 +88,23 @@ export const renderTreeItemPersonaLayout_unstable: (state: TreeItemPersonaLayout // @public export const Tree: ForwardRefComponent; +// @public (undocumented) +export type TreeCheckedChangeData = { + value: TreeItemValue; + target: HTMLElement; + event: React_2.ChangeEvent; + type: 'Change'; +} & ({ + selectionMode: 'multiselect'; + checked: MultiSelectValue; +} | { + selectionMode: 'single'; + checked: SingleSelectValue; +}); + +// @public (undocumented) +export type TreeCheckedChangeEvent = TreeCheckedChangeData['event']; + // @public (undocumented) export const treeClassNames: SlotClassNames; 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 a10702d5a01653..614e7b4b93d458 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 @@ -6,7 +6,6 @@ import type { TreeItemValue } from '../TreeItem/TreeItem.types'; import { CheckboxProps } from '@fluentui/react-checkbox'; import { RadioProps } from '@fluentui/react-radio'; import { ImmutableSet } from '../../utils/ImmutableSet'; -import { ImmutableMap } from '../../utils/ImmutableMap'; export type MultiSelectValue = NonNullable; export type SingleSelectValue = NonNullable; @@ -55,12 +54,10 @@ export type TreeCheckedChangeData = { | { selectionMode: 'multiselect'; checked: MultiSelectValue; - checkedItems: ImmutableMap; } | { selectionMode: 'single'; checked: SingleSelectValue; - checkedItems: ImmutableMap; } ); diff --git a/packages/react-components/react-tree/src/components/Tree/useRootTree.ts b/packages/react-components/react-tree/src/components/Tree/useRootTree.ts index 5805792ed16d88..2700f35365a905 100644 --- a/packages/react-components/react-tree/src/components/Tree/useRootTree.ts +++ b/packages/react-components/react-tree/src/components/Tree/useRootTree.ts @@ -40,12 +40,9 @@ export function useRootTree(props: TreeProps, ref: React.Ref): Tree return setOpenItems(nextOpenItems); }; - const requestCheckedChange = (data: Omit) => { + const requestCheckedChange = (data: TreeCheckedChangeData) => { const nextCheckedItems = createNextCheckedItems(data, checkedItems); - props.onCheckedChange?.(data.event, { - ...data, - checkedItems: nextCheckedItems, - } as TreeCheckedChangeData); + props.onCheckedChange?.(data.event, data); if (data.event.isDefaultPrevented()) { return; } diff --git a/packages/react-components/react-tree/src/hooks/useFlatTree.ts b/packages/react-components/react-tree/src/hooks/useFlatTree.ts index 16742e5b77a619..9453036fd139cf 100644 --- a/packages/react-components/react-tree/src/hooks/useFlatTree.ts +++ b/packages/react-components/react-tree/src/hooks/useFlatTree.ts @@ -150,7 +150,7 @@ export function useFlatTree_unstable { const nextFlatCheckedItems = createNextFlatCheckedItems(data, checkedItems, flatTreeItems); - options.onCheckedChange?.(event, { ...data, checkedItems: nextFlatCheckedItems } as TreeCheckedChangeData); + options.onCheckedChange?.(event, data); if (!event.isDefaultPrevented()) { setCheckedItems(nextFlatCheckedItems); } diff --git a/packages/react-components/react-tree/src/index.ts b/packages/react-components/react-tree/src/index.ts index a08fcf754bc38a..4ee54c095cc65d 100644 --- a/packages/react-components/react-tree/src/index.ts +++ b/packages/react-components/react-tree/src/index.ts @@ -13,6 +13,8 @@ export type { TreeSelectionValue, TreeOpenChangeData, TreeOpenChangeEvent, + TreeCheckedChangeData, + TreeCheckedChangeEvent, TreeNavigationEvent_unstable, TreeNavigationData_unstable, } from './Tree'; From 4763b188247e28ba25f4d3f59814eb1d495c0f47 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Mon, 17 Jul 2023 10:42:55 +0000 Subject: [PATCH 08/10] chore: uses aria-checked for multiselect --- .../react-tree/src/components/Tree/useRootTree.ts | 2 +- .../src/components/TreeItem/useTreeItem.tsx | 3 ++- .../TreeItemLayout/TreeItemLayout.test.tsx | 2 +- .../src/hooks/useFlatControllableCheckedItems.ts | 12 +++++------- .../react-tree/src/utils/createFlatTreeItems.ts | 2 ++ 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/react-components/react-tree/src/components/Tree/useRootTree.ts b/packages/react-components/react-tree/src/components/Tree/useRootTree.ts index 2700f35365a905..a520f04ce12242 100644 --- a/packages/react-components/react-tree/src/components/Tree/useRootTree.ts +++ b/packages/react-components/react-tree/src/components/Tree/useRootTree.ts @@ -122,7 +122,7 @@ export function useRootTree(props: TreeProps, ref: React.Ref): Tree root: getNativeElementProps('div', { ref: useMergedRefs(navigationRef, ref), role: 'tree', - 'aria-multiselectable': selectionMode === 'multiselect', + 'aria-multiselectable': selectionMode === 'multiselect' ? true : undefined, ...props, }), }; 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 7f0eee1fcac5e2..b7955d530cc859 100644 --- a/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.tsx +++ b/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.tsx @@ -214,7 +214,8 @@ export function useTreeItem_unstable(props: TreeItemProps, ref: React.Ref ( - + {props.children} ); diff --git a/packages/react-components/react-tree/src/hooks/useFlatControllableCheckedItems.ts b/packages/react-components/react-tree/src/hooks/useFlatControllableCheckedItems.ts index a6397484a63fcc..df3a4f4a222ab8 100644 --- a/packages/react-components/react-tree/src/hooks/useFlatControllableCheckedItems.ts +++ b/packages/react-components/react-tree/src/hooks/useFlatControllableCheckedItems.ts @@ -3,8 +3,8 @@ import type { TreeCheckedChangeData, TreeProps } from '../Tree'; import { TreeItemValue } from '../TreeItem'; import { ImmutableMap } from '../utils/ImmutableMap'; import * as React from 'react'; -import type { FlatTreeItem, FlatTreeItemProps } from './useFlatTree'; -import { flatTreeRootId, type FlatTreeItems } from '../utils/createFlatTreeItems'; +import type { FlatTreeItemProps } from './useFlatTree'; +import type { FlatTreeItems } from '../utils/createFlatTreeItems'; function initializeMap(iterable?: Iterable) { const map = new Map(); @@ -53,12 +53,9 @@ export function createNextFlatCheckedItems | undefined = treeItem; let isAncestorsMixed = false; - while ((parent = flatTreeItems.get(parent?.parentValue!))) { - if (parent.value === flatTreeRootId) { - break; - } + let parent = flatTreeItems.getParent(treeItem.value); + while (parent !== flatTreeItems.root) { if (isAncestorsMixed) { nextCheckedItems.set(parent.value, 'mixed'); continue; @@ -74,6 +71,7 @@ export function createNextFlatCheckedItems = { size: number; root: FlatTreeItem; get(key: TreeItemValue): FlatTreeItem | undefined; + getParent(key: TreeItemValue): FlatTreeItem; getSubtree(key: TreeItemValue): FlatTreeItem[]; set(key: TreeItemValue, value: FlatTreeItem): void; getByIndex(index: number): FlatTreeItem; @@ -80,6 +81,7 @@ export function createFlatTreeItems(flatTreeIte } return subtree; }, + getParent: key => itemsPerValue.get(itemsPerValue.get(key)?.parentValue ?? root.value) ?? root, get: key => itemsPerValue.get(key), set: (key, value) => itemsPerValue.set(key, value), }; From ae57e97847668dbc8bb56a1374ca59cf88c06e7a Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Wed, 19 Jul 2023 20:11:38 +0000 Subject: [PATCH 09/10] chore: creates generator to properly navigate through the flat tree --- .../react-tree/etc/react-tree.api.md | 3 +- .../src/components/Tree/Tree.types.ts | 2 - .../src/components/Tree/useRootTree.ts | 10 +- .../react-tree/src/contexts/treeContext.ts | 4 +- .../hooks/useFlatControllableCheckedItems.ts | 28 +++--- .../react-tree/src/hooks/useFlatTree.ts | 13 +-- .../src/utils/createFlatTreeItems.ts | 98 ++++++++++++++----- 7 files changed, 101 insertions(+), 57 deletions(-) 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 4ba2bf95824ef6..889a1f29df995e 100644 --- a/packages/react-components/react-tree/etc/react-tree.api.md +++ b/packages/react-components/react-tree/etc/react-tree.api.md @@ -49,7 +49,7 @@ export type FlatTree = { export type FlatTreeItem = { index: number; level: number; - childrenSize: number; + childrenValues: TreeItemValue[]; value: TreeItemValue; parentValue: TreeItemValue | undefined; getTreeItemProps(): Required> & Omit; @@ -243,7 +243,6 @@ export type TreeOpenChangeData = { open: boolean; value: TreeItemValue; target: HTMLElement; - openItems: ImmutableSet; } & ({ event: React_2.MouseEvent; type: 'ExpandIconClick'; 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 614e7b4b93d458..47c8517e7e8f1a 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 @@ -5,7 +5,6 @@ import type { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, End, Enter, Home } from import type { TreeItemValue } from '../TreeItem/TreeItem.types'; import { CheckboxProps } from '@fluentui/react-checkbox'; import { RadioProps } from '@fluentui/react-radio'; -import { ImmutableSet } from '../../utils/ImmutableSet'; export type MultiSelectValue = NonNullable; export type SingleSelectValue = NonNullable; @@ -34,7 +33,6 @@ export type TreeOpenChangeData = { open: boolean; value: TreeItemValue; target: HTMLElement; - openItems: ImmutableSet; } & ( | { event: React.MouseEvent; type: 'ExpandIconClick' } | { event: React.MouseEvent; type: 'Click' } diff --git a/packages/react-components/react-tree/src/components/Tree/useRootTree.ts b/packages/react-components/react-tree/src/components/Tree/useRootTree.ts index a520f04ce12242..254ac9b86d573b 100644 --- a/packages/react-components/react-tree/src/components/Tree/useRootTree.ts +++ b/packages/react-components/react-tree/src/components/Tree/useRootTree.ts @@ -31,22 +31,20 @@ export function useRootTree(props: TreeProps, ref: React.Ref): Tree const [checkedItems, setCheckedItems] = useNestedControllableCheckedItems(props); const [navigate, navigationRef] = useNestedTreeNavigation(); - const requestOpenChange = (data: Omit) => { - const nextOpenItems = createNextOpenItems(data, openItems); - props.onOpenChange?.(data.event, { ...data, openItems: nextOpenItems } as TreeOpenChangeData); + const requestOpenChange = (data: TreeOpenChangeData) => { + props.onOpenChange?.(data.event, data); if (data.event.isDefaultPrevented()) { return; } - return setOpenItems(nextOpenItems); + return setOpenItems(createNextOpenItems(data, openItems)); }; const requestCheckedChange = (data: TreeCheckedChangeData) => { - const nextCheckedItems = createNextCheckedItems(data, checkedItems); props.onCheckedChange?.(data.event, data); if (data.event.isDefaultPrevented()) { return; } - return setCheckedItems(nextCheckedItems); + return setCheckedItems(createNextCheckedItems(data, checkedItems)); }; const requestNavigation = (data: TreeNavigationData_unstable) => { diff --git a/packages/react-components/react-tree/src/contexts/treeContext.ts b/packages/react-components/react-tree/src/contexts/treeContext.ts index 87fa40ae600782..e5b5d8b62a079c 100644 --- a/packages/react-components/react-tree/src/contexts/treeContext.ts +++ b/packages/react-components/react-tree/src/contexts/treeContext.ts @@ -19,9 +19,9 @@ export type TreeContextValue = { }; export type TreeItemRequest = { itemType: TreeItemType } & ( - | OmitWithoutExpanding + | OmitWithoutExpanding | TreeNavigationData_unstable - | OmitWithoutExpanding + | OmitWithoutExpanding ); // helper type that avoids the expansion of unions while inferring it, should work exactly the same as Omit diff --git a/packages/react-components/react-tree/src/hooks/useFlatControllableCheckedItems.ts b/packages/react-components/react-tree/src/hooks/useFlatControllableCheckedItems.ts index df3a4f4a222ab8..15572507c45e5f 100644 --- a/packages/react-components/react-tree/src/hooks/useFlatControllableCheckedItems.ts +++ b/packages/react-components/react-tree/src/hooks/useFlatControllableCheckedItems.ts @@ -4,7 +4,7 @@ import { TreeItemValue } from '../TreeItem'; import { ImmutableMap } from '../utils/ImmutableMap'; import * as React from 'react'; import type { FlatTreeItemProps } from './useFlatTree'; -import type { FlatTreeItems } from '../utils/createFlatTreeItems'; +import { FlatTreeItems } from '../utils/createFlatTreeItems'; function initializeMap(iterable?: Iterable) { const map = new Map(); @@ -43,36 +43,38 @@ export function createNextFlatCheckedItems { - return (nextCheckedItems.get(item.value) ?? false) === data.checked; - }); - if (checkedChildren.length === subtree.length) { + const checkedChildren = []; + for (const child of flatTreeItems.children(parent.value)) { + if ((nextCheckedItems.get(child.value) ?? false) === data.checked) { + checkedChildren.push(child); + } + } + if (checkedChildren.length === parent.childrenValues.length) { nextCheckedItems.set(parent.value, data.checked); } else { // if one parent is mixed, all ancestors are mixed isAncestorsMixed = true; nextCheckedItems.set(parent.value, 'mixed'); } - parent = flatTreeItems.getParent(parent.value); } - return ImmutableMap.dangerouslyCreate_unstable(nextCheckedItems); } diff --git a/packages/react-components/react-tree/src/hooks/useFlatTree.ts b/packages/react-components/react-tree/src/hooks/useFlatTree.ts index 9453036fd139cf..b3a74a8411ddd1 100644 --- a/packages/react-components/react-tree/src/hooks/useFlatTree.ts +++ b/packages/react-components/react-tree/src/hooks/useFlatTree.ts @@ -1,9 +1,9 @@ import { useEventCallback, useMergedRefs } from '@fluentui/react-utilities'; import * as React from 'react'; -import { createFlatTreeItems, VisibleFlatTreeItemGenerator } from '../utils/createFlatTreeItems'; +import { createFlatTreeItems } from '../utils/createFlatTreeItems'; import { treeDataTypes } from '../utils/tokens'; import { useFlatTreeNavigation } from './useFlatTreeNavigation'; -import { useControllableOpenItems } from './useControllableOpenItems'; +import { createNextOpenItems, useControllableOpenItems } from './useControllableOpenItems'; import type { TreeCheckedChangeData, TreeCheckedChangeEvent, @@ -31,7 +31,7 @@ export type FlatTreeItemProps = Omit & export type FlatTreeItem = { index: number; level: number; - childrenSize: number; + childrenValues: TreeItemValue[]; value: TreeItemValue; parentValue: TreeItemValue | undefined; getTreeItemProps(): Required> & @@ -143,7 +143,7 @@ export function useFlatTree_unstable { options.onOpenChange?.(event, data); if (!event.isDefaultPrevented()) { - setOpenItems(data.openItems); + setOpenItems(createNextOpenItems(data, openItems)); } event.preventDefault(); }); @@ -212,10 +212,7 @@ export function useFlatTree_unstable VisibleFlatTreeItemGenerator(openItems, flatTreeItems), - [openItems, flatTreeItems], - ); + const items = React.useCallback(() => flatTreeItems.visibleItems(openItems), [openItems, flatTreeItems]); return React.useMemo( () => ({ navigate, getTreeProps, getNextNavigableItem, getElementFromItem, items }), diff --git a/packages/react-components/react-tree/src/utils/createFlatTreeItems.ts b/packages/react-components/react-tree/src/utils/createFlatTreeItems.ts index be97d5cd2d94db..1e832051290eed 100644 --- a/packages/react-components/react-tree/src/utils/createFlatTreeItems.ts +++ b/packages/react-components/react-tree/src/utils/createFlatTreeItems.ts @@ -10,9 +10,11 @@ export type FlatTreeItems = { root: FlatTreeItem; get(key: TreeItemValue): FlatTreeItem | undefined; getParent(key: TreeItemValue): FlatTreeItem; - getSubtree(key: TreeItemValue): FlatTreeItem[]; - set(key: TreeItemValue, value: FlatTreeItem): void; getByIndex(index: number): FlatTreeItem; + subtree(key: TreeItemValue): IterableIterator>; + children(key: TreeItemValue): IterableIterator>; + visibleItems(openItems: ImmutableSet): IterableIterator>; + ancestors(key: TreeItemValue): IterableIterator>; }; /** @@ -42,7 +44,6 @@ export function createFlatTreeItems(flatTreeIte treeItemProps.itemType ?? (treeItemProps.value === undefined || nextItemProps?.parentValue !== treeItemProps.value ? 'leaf' : 'branch'); const currentLevel = (currentParent.level ?? 0) + 1; - const currentChildrenSize = ++currentParent.childrenSize; const flatTreeItem: FlatTreeItem = { value: treeItemProps.value, @@ -50,14 +51,15 @@ export function createFlatTreeItems(flatTreeIte ...treeItemProps, 'aria-level': currentLevel, 'aria-posinset': currentChildrenSize, - 'aria-setsize': currentParent.childrenSize, + 'aria-setsize': currentParent.childrenValues.length, itemType, }), level: currentLevel, parentValue, - childrenSize: 0, + childrenValues: [], index: -1, }; + const currentChildrenSize = currentParent.childrenValues.push(flatTreeItem.value); itemsPerValue.set(flatTreeItem.value, flatTreeItem); items.push(flatTreeItem); } @@ -66,24 +68,12 @@ export function createFlatTreeItems(flatTreeIte root, size: items.length, getByIndex: index => items[index], - getSubtree: key => { - const item = itemsPerValue.get(key); - if (!item || item.childrenSize === 0) { - return []; - } - let counter = item.childrenSize; - let index = item.index; - const subtree: FlatTreeItem[] = []; - while (counter > 0) { - const children = items[++index]; - subtree.push(children); - counter += children.childrenSize - 1; - } - return subtree; - }, getParent: key => itemsPerValue.get(itemsPerValue.get(key)?.parentValue ?? root.value) ?? root, get: key => itemsPerValue.get(key), - set: (key, value) => itemsPerValue.set(key, value), + subtree: key => FlatTreeSubtreeGenerator(key, flatTreeItems), + children: key => FlatTreeChildrenGenerator(key, flatTreeItems), + ancestors: key => FlatTreeAncestorsGenerator(key, flatTreeItems), + visibleItems: openItems => VisibleFlatTreeItemGenerator(openItems, flatTreeItems), }; return flatTreeItems as FlatTreeItems; @@ -109,7 +99,7 @@ function createFlatTreeRootItem(): FlatTreeItem { itemType: 'branch', }; }, - childrenSize: 0, + childrenValues: [], get index() { if (process.env.NODE_ENV !== 'production') { // eslint-disable-next-line no-console @@ -121,8 +111,68 @@ function createFlatTreeRootItem(): FlatTreeItem { }; } +/** + * Generator that returns all subtree of a given flat tree item + * @param key the key of the item to get the subtree from + */ +// eslint-disable-next-line @typescript-eslint/naming-convention +function* FlatTreeSubtreeGenerator( + key: TreeItemValue, + flatTreeItems: FlatTreeItems, +) { + const item = flatTreeItems.get(key); + if (!item || item.childrenValues.length === 0) { + return []; + } + let counter = item.childrenValues.length; + let index = item.index; + while (counter > 0) { + const children = flatTreeItems.getByIndex(++index); + yield children; + counter += children.childrenValues.length - 1; + } +} + +/** + * Generator that returns all children of a given flat tree item + * @param key the key of the item to get the children from + */ +// eslint-disable-next-line @typescript-eslint/naming-convention +function* FlatTreeChildrenGenerator( + key: TreeItemValue, + flatTreeItems: FlatTreeItems, +) { + const item = flatTreeItems.get(key); + if (!item || item.childrenValues.length === 0) { + return; + } + for (const childValue of item.childrenValues) { + yield flatTreeItems.get(childValue)!; + } +} + +/** + * Generator that returns all ancestors of a given flat tree item + * @param key the key of the item to get the children from + */ +// eslint-disable-next-line @typescript-eslint/naming-convention +function* FlatTreeAncestorsGenerator( + key: TreeItemValue, + flatTreeItems: FlatTreeItems, +) { + let parent = flatTreeItems.getParent(key); + while (parent !== flatTreeItems.root) { + yield parent; + parent = flatTreeItems.getParent(parent.value); + } +} + +/** + * Generator that returns all visible items of a given flat tree + * @param openItems the open items of the tree + */ // eslint-disable-next-line @typescript-eslint/naming-convention -export function* VisibleFlatTreeItemGenerator( +function* VisibleFlatTreeItemGenerator( openItems: ImmutableSet, flatTreeItems: FlatTreeItems, ) { @@ -133,7 +183,7 @@ export function* VisibleFlatTreeItemGenerator( yield item; } else { // Jump the amount of children the current item has, since those items will also be hidden - index += item.childrenSize; + index += item.childrenValues.length; } } } From 80bafa90592565447797e7b8d2e1211636abcc31 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Thu, 20 Jul 2023 11:28:32 +0000 Subject: [PATCH 10/10] chore: stops change event prevention --- .../src/components/Tree/useRootTree.ts | 44 +++++-------------- .../react-tree/src/hooks/useFlatTree.ts | 6 +-- 2 files changed, 11 insertions(+), 39 deletions(-) diff --git a/packages/react-components/react-tree/src/components/Tree/useRootTree.ts b/packages/react-components/react-tree/src/components/Tree/useRootTree.ts index 254ac9b86d573b..99e20656efb397 100644 --- a/packages/react-components/react-tree/src/components/Tree/useRootTree.ts +++ b/packages/react-components/react-tree/src/components/Tree/useRootTree.ts @@ -8,12 +8,14 @@ import { TreeNavigationData_unstable, TreeCheckedChangeData, } from './Tree.types'; -import { useControllableOpenItems, useNestedTreeNavigation, useNestedControllableCheckedItems } from '../../hooks'; +import { + useControllableOpenItems, + useNestedTreeNavigation, + useNestedControllableCheckedItems, + createNextOpenItems, +} from '../../hooks'; import { treeDataTypes } from '../../utils/tokens'; import { TreeItemRequest } from '../../contexts'; -import { ImmutableSet } from '../../utils/ImmutableSet'; -import { TreeItemValue } from '../TreeItem/TreeItem.types'; -import { ImmutableMap } from '../../utils/ImmutableMap'; /** * Create the state required to render the root level Tree. @@ -28,7 +30,7 @@ export function useRootTree(props: TreeProps, ref: React.Ref): Tree const [openItems, setOpenItems] = useControllableOpenItems(props); - const [checkedItems, setCheckedItems] = useNestedControllableCheckedItems(props); + const [checkedItems] = useNestedControllableCheckedItems(props); const [navigate, navigationRef] = useNestedTreeNavigation(); const requestOpenChange = (data: TreeOpenChangeData) => { @@ -41,10 +43,9 @@ export function useRootTree(props: TreeProps, ref: React.Ref): Tree const requestCheckedChange = (data: TreeCheckedChangeData) => { props.onCheckedChange?.(data.event, data); - if (data.event.isDefaultPrevented()) { - return; - } - return setCheckedItems(createNextCheckedItems(data, checkedItems)); + // TODO: + // we should implement the logic for nested tree selection + // return setCheckedItems(checkedItems); }; const requestNavigation = (data: TreeNavigationData_unstable) => { @@ -134,28 +135,3 @@ function warnIfNoProperPropsRootTree(props: Pick, - previousOpenItems: ImmutableSet, -): ImmutableSet { - if (data.value === null) { - return previousOpenItems; - } - const previousOpenItemsHasId = previousOpenItems.has(data.value); - if (data.open ? previousOpenItemsHasId : !previousOpenItemsHasId) { - return previousOpenItems; - } - const nextOpenItems = ImmutableSet.create(previousOpenItems); - return data.open ? nextOpenItems.add(data.value) : nextOpenItems.delete(data.value); -} - -function createNextCheckedItems( - data: Pick, - previousCheckedItems: ImmutableMap, -): ImmutableMap { - if (data.selectionMode === 'single') { - return ImmutableMap.create([[data.value, data.checked]]); - } - return previousCheckedItems; -} diff --git a/packages/react-components/react-tree/src/hooks/useFlatTree.ts b/packages/react-components/react-tree/src/hooks/useFlatTree.ts index b3a74a8411ddd1..af88c85c9bb6fb 100644 --- a/packages/react-components/react-tree/src/hooks/useFlatTree.ts +++ b/packages/react-components/react-tree/src/hooks/useFlatTree.ts @@ -149,12 +149,8 @@ export function useFlatTree_unstable { - const nextFlatCheckedItems = createNextFlatCheckedItems(data, checkedItems, flatTreeItems); options.onCheckedChange?.(event, data); - if (!event.isDefaultPrevented()) { - setCheckedItems(nextFlatCheckedItems); - } - event.preventDefault(); + setCheckedItems(createNextFlatCheckedItems(data, checkedItems, flatTreeItems)); }); const handleNavigation = useEventCallback(