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 0000000000000..8e94d29db49f5 --- /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 8580af0363e4f..889a1f29df995 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'; @@ -44,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; @@ -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; }; @@ -83,15 +88,34 @@ 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; // @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 +192,8 @@ export type TreeItemSlots = { actions?: Slot & { visible?: boolean; }>>; + checkboxIndicator?: Slot; + radioIndicator?: Slot; }; // @public @@ -217,7 +243,6 @@ export type TreeOpenChangeData = { open: boolean; value: TreeItemValue; target: HTMLElement; - openItems: ImmutableSet; } & ({ event: React_2.MouseEvent; type: 'ExpandIconClick'; @@ -246,11 +271,18 @@ 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) 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/package.json b/packages/react-components/react-tree/package.json index d91ebcd042bfe..52a943fd2d65e 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 0d01a78d0ee37..5faefcfeb2398 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 01a6cc7298d14..47c8517e7e8f1 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,14 @@ 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 { ImmutableSet } from '../../utils/ImmutableSet'; +import { CheckboxProps } from '@fluentui/react-checkbox'; +import { RadioProps } from '@fluentui/react-radio'; + +export type MultiSelectValue = NonNullable; +export type SingleSelectValue = NonNullable; +export type TreeSelectionValue = MultiSelectValue | SingleSelectValue; export type TreeSlots = { root: Slot<'div'>; @@ -28,7 +33,6 @@ export type TreeOpenChangeData = { open: boolean; value: TreeItemValue; target: HTMLElement; - openItems: ImmutableSet; } & ( | { event: React.MouseEvent; type: 'ExpandIconClick' } | { event: React.MouseEvent; type: 'Click' } @@ -39,6 +43,24 @@ export type TreeOpenChangeData = { export type TreeOpenChangeEvent = TreeOpenChangeData['event']; +export type TreeCheckedChangeData = { + value: TreeItemValue; + target: HTMLElement; + event: React.ChangeEvent; + type: 'Change'; +} & ( + | { + selectionMode: 'multiselect'; + checked: MultiSelectValue; + } + | { + selectionMode: 'single'; + checked: SingleSelectValue; + } +); + +export type TreeCheckedChangeEvent = TreeCheckedChangeData['event']; + export type TreeContextValues = { tree: TreeContextValue; }; @@ -90,6 +112,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 0a5636516bb56..99e20656efb39 100644 --- a/packages/react-components/react-tree/src/components/Tree/useRootTree.ts +++ b/packages/react-components/react-tree/src/components/Tree/useRootTree.ts @@ -1,8 +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 { + useControllableOpenItems, + useNestedTreeNavigation, + useNestedControllableCheckedItems, + createNextOpenItems, +} from '../../hooks'; import { treeDataTypes } from '../../utils/tokens'; import { TreeItemRequest } from '../../contexts'; @@ -15,19 +26,26 @@ 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] = 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) => { + props.onCheckedChange?.(data.event, data); + // TODO: + // we should implement the logic for nested tree selection + // return setCheckedItems(checkedItems); }; const requestNavigation = (data: TreeNavigationData_unstable) => { @@ -77,6 +95,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 +110,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' ? true : undefined, ...props, }), }; 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 8cc9eb7a7240f..0d72557c09314 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 075cfc35589cb..8f0c09ca6e090 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 d3ddf018fffc1..ab68cc4000f9c 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 1f1389a83194e..b7955d530cc85 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,8 @@ 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.test.tsx b/packages/react-components/react-tree/src/components/TreeItemLayout/TreeItemLayout.test.tsx index 4bb9ab530ca6a..be6970d219678 100644 --- a/packages/react-components/react-tree/src/components/TreeItemLayout/TreeItemLayout.test.tsx +++ b/packages/react-components/react-tree/src/components/TreeItemLayout/TreeItemLayout.test.tsx @@ -5,7 +5,7 @@ import { isConformant } from '../../testing/isConformant'; import { TreeItemSlotsProvider } from '../../contexts/treeItemSlotsContext'; const Wrap: React.FC = props => ( - + {props.children} ); 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 60f2354e3a3ca..1f60e97abe7a6 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 9a1330fa15f51..86b6793d97ace 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 05b6c1fb12556..8700d9adcdd0c 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 db5c82d0319c5..782e9c461c0a0 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 11741a0a3553f..e5b5d8b62a079 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, */ @@ -15,8 +19,9 @@ export type TreeContextValue = { }; export type TreeItemRequest = { itemType: TreeItemType } & ( - | OmitWithoutExpanding + | 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 8b01c05e36c46..aba0fc0613fef 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 1625776632721..68cb5841cf40a 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 './useNestedControllableCheckedItems'; +export * from './useFlatControllableCheckedItems'; 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 0000000000000..15572507c45e5 --- /dev/null +++ b/packages/react-components/react-tree/src/hooks/useFlatControllableCheckedItems.ts @@ -0,0 +1,80 @@ +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'; +import type { FlatTreeItemProps } from './useFlatTree'; +import { FlatTreeItems } from '../utils/createFlatTreeItems'; + +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), [props.checkedItems]), + defaultState: () => initializeMap(props.defaultCheckedItems), + }); + + return [checkedItems, setCheckedItems] as const; +} + +export function createNextFlatCheckedItems( + data: Pick, + previousCheckedItems: ImmutableMap, + flatTreeItems: FlatTreeItems, +): ImmutableMap { + if (data.selectionMode === 'single') { + return ImmutableMap.create([[data.value, data.checked]]); + } + const treeItem = flatTreeItems.get(data.value); + if (!treeItem) { + if (process.env.NODE_ENV !== 'production') { + // 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.subtree(data.value)) { + nextCheckedItems.set(children.value, data.checked); + } + nextCheckedItems.set(data.value, data.checked); + + let isAncestorsMixed = false; + for (const parent of flatTreeItems.ancestors(treeItem.value)) { + // if one parent is mixed, all ancestors are mixed + if (isAncestorsMixed) { + nextCheckedItems.set(parent.value, 'mixed'); + continue; + } + const checkedChildren = []; + for (const child of 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'); + } + } + 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 a032366827355..af88c85c9bb6f 100644 --- a/packages/react-components/react-tree/src/hooks/useFlatTree.ts +++ b/packages/react-components/react-tree/src/hooks/useFlatTree.ts @@ -1,10 +1,12 @@ 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, 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 { createNextFlatCheckedItems, useFlatControllableCheckedItems } from './useFlatControllableCheckedItems'; export type FlatTreeItemProps = Omit & Partial> & { @@ -28,14 +31,16 @@ export type FlatTreeItemProps = Omit & export type FlatTreeItem = { index: number; level: number; - childrenSize: number; + childrenValues: TreeItemValue[]; value: TreeItemValue; parentValue: TreeItemValue | undefined; getTreeItemProps(): Required> & 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; +export 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,17 +137,22 @@ export function useFlatTree_unstable { const flatTreeItems = React.useMemo(() => createFlatTreeItems(flatTreeItemProps), [flatTreeItemProps]); const [openItems, setOpenItems] = useControllableOpenItems(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()) { - setOpenItems(data.openItems); + setOpenItems(createNextOpenItems(data, openItems)); } event.preventDefault(); }); + const handleCheckedChange = useEventCallback((event: TreeCheckedChangeEvent, data: TreeCheckedChangeData) => { + options.onCheckedChange?.(event, data); + setCheckedItems(createNextFlatCheckedItems(data, checkedItems, flatTreeItems)); + }); + const handleNavigation = useEventCallback( (event: TreeNavigationEvent_unstable, data: TreeNavigationData_unstable) => { options.onNavigation_unstable?.(event, data); @@ -177,18 +197,18 @@ export function useFlatTree_unstable ({ ref, openItems, + selectionMode: options.selectionMode, + 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( - () => 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/hooks/useNestedControllableCheckedItems.ts b/packages/react-components/react-tree/src/hooks/useNestedControllableCheckedItems.ts new file mode 100644 index 0000000000000..4d768a13c81c0 --- /dev/null +++ b/packages/react-components/react-tree/src/hooks/useNestedControllableCheckedItems.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_unstable(map); +} + +export function useNestedControllableCheckedItems( + props: Pick, +) { + return useControllableState({ + initialState: ImmutableMap.empty, + state: React.useMemo(() => props.checkedItems && 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/index.ts b/packages/react-components/react-tree/src/index.ts index 78183a3a26254..4ee54c095cc65 100644 --- a/packages/react-components/react-tree/src/index.ts +++ b/packages/react-components/react-tree/src/index.ts @@ -10,8 +10,11 @@ export type { TreeProps, TreeState, TreeSlots, + TreeSelectionValue, TreeOpenChangeData, TreeOpenChangeEvent, + TreeCheckedChangeData, + TreeCheckedChangeEvent, TreeNavigationEvent_unstable, TreeNavigationData_unstable, } from './Tree'; 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 0000000000000..2588baec9ef38 --- /dev/null +++ b/packages/react-components/react-tree/src/utils/ImmutableMap.ts @@ -0,0 +1,72 @@ +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. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + dangerouslyGetInternalMap_unstable(): 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](), + // eslint-disable-next-line @typescript-eslint/naming-convention + dangerouslyGetInternalMap_unstable: () => internalMap, + }; +} + +export const ImmutableMap = { + empty: emptyImmutableMap, + create: createImmutableMap, + // 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 3e11c23b6a2d5..4bfe89a4232ad 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 = { size: number; root: FlatTreeItem; get(key: TreeItemValue): FlatTreeItem | undefined; - set(key: TreeItemValue, value: FlatTreeItem): void; + getParent(key: TreeItemValue): FlatTreeItem; getByIndex(index: number): FlatTreeItem; + subtree(key: TreeItemValue): IterableIterator>; + children(key: TreeItemValue): IterableIterator>; + visibleItems(openItems: ImmutableSet): IterableIterator>; + ancestors(key: TreeItemValue): IterableIterator>; }; /** @@ -40,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, @@ -48,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); } @@ -64,8 +68,12 @@ export function createFlatTreeItems(flatTreeIte root, size: items.length, getByIndex: index => items[index], + 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; @@ -91,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 @@ -103,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, ) { @@ -115,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; } } } diff --git a/packages/react-components/react-tree/src/utils/tokens.ts b/packages/react-components/react-tree/src/utils/tokens.ts index 42b5212d07823..e836bab0a5172 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 0000000000000..de413a49a8045 --- /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 0000000000000..e4a4a2868d3d6 --- /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 23315dbb92ded..b7f8f7f7cee0c 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', 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 0000000000000..ccd184d97bb3c --- /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/TreeMultiSelection.stories.tsx b/packages/react-components/react-tree/stories/D_flatTree/TreeMultiSelection.stories.tsx new file mode 100644 index 0000000000000..ed9b6e67cb89e --- /dev/null +++ b/packages/react-components/react-tree/stories/D_flatTree/TreeMultiSelection.stories.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +import { Tree, TreeItem, TreeItemLayout, useFlatTree_unstable, FlatTreeItemProps } from '@fluentui/react-tree'; +import story from './TreeMultiSelection.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 TreeMultiSelection = () => { + const flatTree = useFlatTree_unstable(defaultItems, { + defaultOpenItems: ['1', '2', '2-1', '2-2'], + selectionMode: 'multiselect', + }); + + return ( + + {Array.from(flatTree.items(), flatTreeItem => { + const { content, ...treeItemProps } = flatTreeItem.getTreeItemProps(); + return ( + + {content} + + ); + })} + + ); +}; + +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 0000000000000..ffd20d7e5bfc0 --- /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 0000000000000..3910aafd9ff6d --- /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 c538b33ca0d81..d6e26df61a735 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,8 @@ export { Virtualization } from './Virtualization.stories'; export { AddRemoveTreeItem } from './TreeItemAddRemove.stories'; export { LazyLoading } from './TreeLazyLoading.stories'; export { InfiniteScrolling } from './TreeInfiniteScrolling.stories'; +export { TreeSingleSelection } from './TreeSingleSelection.stories'; +export { TreeMultiSelection } from './TreeMultiSelection.stories'; export default { title: 'Preview Components/Tree/flatTree',