From 17486e93b24e0db46207c4c959376dfa37498f3c Mon Sep 17 00:00:00 2001 From: Flavien DELANGLE Date: Fri, 15 Nov 2024 15:00:54 +0100 Subject: [PATCH] [TreeView] Do not re-render every Tree Item when the Rich Tree View re-renders (introduce selectors) (#14210) --- .../migration-tree-view-v7.md | 40 ++++ .../customization/FileExplorer.js | 23 +- .../customization/FileExplorer.tsx | 23 +- .../customization/HeadlessAPI.js | 3 +- .../customization/HeadlessAPI.tsx | 3 +- .../editing/CustomLabelInput.js | 11 +- .../editing/CustomLabelInput.tsx | 14 +- .../headless/LogExpandedItems.js | 21 +- .../headless/LogExpandedItems.tsx | 19 +- .../rich-tree-view/headless/headless.md | 2 +- .../rich-tree-view/ordering/FileExplorer.js | 30 +-- .../rich-tree-view/ordering/FileExplorer.tsx | 29 +-- .../ordering/OnlyReorderFromDragHandle.js | 3 +- .../ordering/OnlyReorderFromDragHandle.tsx | 3 +- .../customization/GmailTreeView.js | 3 +- .../customization/GmailTreeView.tsx | 3 +- .../customization/HeadlessAPI.js | 3 +- .../customization/HeadlessAPI.tsx | 3 +- .../CustomTreeItemDemo.js | 3 +- .../CustomTreeItemDemo.tsx | 3 +- .../HandleExpansionDemo.js | 3 +- .../HandleExpansionDemo.tsx | 3 +- .../tree-item-customization/LabelSlot.js | 9 +- .../tree-item-customization/LabelSlot.tsx | 9 +- .../tree-item-customization.md | 19 +- .../useTreeItemHookProperties.js | 3 +- .../useTreeItemHookProperties.tsx | 3 +- .../useTreeItemHookPublicAPI.js | 34 ++- .../useTreeItemHookPublicAPI.tsx | 40 +++- .../useTreeItemHookStatus.js | 3 +- .../useTreeItemHookStatus.tsx | 3 +- .../useTreeItemModelHook.js | 32 +++ .../useTreeItemModelHook.tsx | 41 ++++ .../useTreeItemModelHook.tsx.preview | 5 + .../x/api/tree-view/rich-tree-view-pro.json | 12 +- .../pages/x/api/tree-view/rich-tree-view.json | 12 +- docs/pages/x/api/tree-view/tree-item.json | 2 +- .../rich-tree-view-pro.json | 2 +- .../rich-tree-view/rich-tree-view.json | 2 +- packages/x-tree-view-pro/package.json | 5 +- .../src/RichTreeViewPro/RichTreeViewPro.tsx | 21 +- .../RichTreeViewPro/RichTreeViewPro.types.ts | 25 +-- .../useTreeViewItemsReordering.itemPlugin.ts | 38 ++-- .../useTreeViewItemsReordering.selectors.ts | 53 +++++ .../useTreeViewItemsReordering.test.tsx | 2 +- .../useTreeViewItemsReordering.ts | 76 ++++--- .../useTreeViewItemsReordering.types.ts | 2 +- .../useTreeViewItemsReordering.utils.ts | 39 ++-- packages/x-tree-view/package.json | 5 +- .../src/RichTreeView/RichTreeView.tsx | 11 +- .../src/RichTreeView/RichTreeView.types.ts | 23 +- .../x-tree-view/src/TreeItem/TreeItem.tsx | 5 +- .../src/TreeItemProvider/TreeItemProvider.tsx | 9 +- .../TreeItemProvider.types.ts | 1 + packages/x-tree-view/src/hooks/index.ts | 1 + .../x-tree-view/src/hooks/useTreeItemModel.ts | 12 ++ .../useTreeItemUtils/useTreeItemUtils.tsx | 55 +++-- .../TreeViewItemDepthContext.ts | 4 +- .../TreeViewChildrenItemProvider.tsx | 28 +-- .../TreeViewProvider/TreeViewProvider.tsx | 2 +- .../TreeViewProvider.types.ts | 6 +- .../components/RichTreeViewItems.tsx | 123 ++++++----- .../useTreeViewId/useTreeViewId.selectors.ts | 14 ++ .../useTreeViewId/useTreeViewId.ts | 24 +-- .../useTreeViewId/useTreeViewId.types.ts | 1 + .../src/internals/hooks/useSelector.ts | 27 +++ packages/x-tree-view/src/internals/index.ts | 15 ++ .../src/internals/models/itemPlugin.ts | 8 +- .../src/internals/models/plugin.ts | 26 ++- .../src/internals/models/treeView.ts | 10 + .../useTreeViewExpansion.selectors.ts | 26 +++ .../useTreeViewExpansion.ts | 79 ++++--- .../useTreeViewExpansion.types.ts | 21 +- .../useTreeViewExpansion.utils.ts | 10 + .../useTreeViewFocus.selectors.ts | 49 +++++ .../useTreeViewFocus.test.tsx | 4 +- .../useTreeViewFocus/useTreeViewFocus.ts | 136 +++++++----- .../useTreeViewFocus.types.ts | 20 +- .../useTreeViewIcons/useTreeViewIcons.ts | 19 +- .../plugins/useTreeViewItems/index.ts | 1 - .../useTreeViewItems.selectors.ts | 146 +++++++++++++ .../useTreeViewItems.test.tsx | 123 ++++++++++- .../useTreeViewItems/useTreeViewItems.tsx | 202 +++++++----------- .../useTreeViewItems.types.ts | 71 ++---- .../useTreeViewJSXItems.tsx | 58 +++-- .../useTreeViewKeyboardNavigation.ts | 54 +++-- .../useTreeViewLabel.itemPlugin.ts | 16 +- .../useTreeViewLabel.selectors.ts | 39 ++++ .../useTreeViewLabel/useTreeViewLabel.ts | 50 ++--- .../useTreeViewLabel.types.ts | 32 +-- .../useTreeViewSelection.itemPlugin.ts | 19 +- .../useTreeViewSelection.selectors.ts | 16 ++ .../useTreeViewSelection.ts | 99 ++++++--- .../useTreeViewSelection.types.ts | 13 +- .../useTreeViewSelection.utils.ts | 46 ++-- .../src/internals/useTreeView/useTreeView.ts | 56 +++-- .../useTreeView/useTreeView.types.ts | 2 - .../useTreeView/useTreeViewBuildContext.ts | 197 +++++++++-------- .../src/internals/utils/TreeViewStore.ts | 37 ++++ .../src/internals/utils/selectors.ts | 54 +++++ .../x-tree-view/src/internals/utils/tree.ts | 124 ++++++----- packages/x-tree-view/src/models/items.ts | 4 +- .../src/useTreeItem/useTreeItem.ts | 44 +++- .../src/useTreeItem/useTreeItem.types.ts | 10 + pnpm-lock.yaml | 32 +++ scripts/x-tree-view-pro.exports.json | 2 + scripts/x-tree-view.exports.json | 2 + .../describeTreeView/describeTreeView.tsx | 3 +- .../describeTreeView.types.ts | 5 +- test/utils/tree-view/fakeContextValue.ts | 37 ++-- 110 files changed, 2022 insertions(+), 1086 deletions(-) create mode 100644 docs/data/tree-view/tree-item-customization/useTreeItemModelHook.js create mode 100644 docs/data/tree-view/tree-item-customization/useTreeItemModelHook.tsx create mode 100644 docs/data/tree-view/tree-item-customization/useTreeItemModelHook.tsx.preview create mode 100644 packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.selectors.ts create mode 100644 packages/x-tree-view/src/hooks/useTreeItemModel.ts create mode 100644 packages/x-tree-view/src/internals/corePlugins/useTreeViewId/useTreeViewId.selectors.ts create mode 100644 packages/x-tree-view/src/internals/hooks/useSelector.ts create mode 100644 packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.selectors.ts create mode 100644 packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.utils.ts create mode 100644 packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.selectors.ts create mode 100644 packages/x-tree-view/src/internals/plugins/useTreeViewItems/useTreeViewItems.selectors.ts create mode 100644 packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.selectors.ts create mode 100644 packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.selectors.ts create mode 100644 packages/x-tree-view/src/internals/utils/TreeViewStore.ts create mode 100644 packages/x-tree-view/src/internals/utils/selectors.ts diff --git a/docs/data/migration/migration-tree-view-v7/migration-tree-view-v7.md b/docs/data/migration/migration-tree-view-v7/migration-tree-view-v7.md index ed0546fe4fa8..4420a0b036ae 100644 --- a/docs/data/migration/migration-tree-view-v7/migration-tree-view-v7.md +++ b/docs/data/migration/migration-tree-view-v7/migration-tree-view-v7.md @@ -226,6 +226,46 @@ All the new Tree Item-related components and utils (introduced in the previous m + } from '@mui/x-tree-view/TreeItemLabelInput'; ``` +## Stop using `publicAPI` methods in the render + +The Tree Items are now memoized to improve the performances of the Tree View components. +If you call a `publicAPI` method in the render of an item, it might not re-render and you might not have the new value. + +```ts +function CustomTreeItem(props) { + const { publicAPI } = useTreeItemUtils(); + + // Invalid + console.log(publicAPI.getItem(props.itemId)); + + // Valid + React.useEffect(() => { + console.log(publicAPI.getItem(props.itemId)); + }); + + // Valid + function handleItemClick() { + console.log(publicAPI.getItem(props.itemId)); + } +} +``` + +If you need to access the tree item model inside the render, you can use the new `useTreeItemModel` hook: + +```diff ++import { useTreeItemModel } from '@mui/x-tree-view/hooks'; + + function CustomTreeItem(props) { +- const { publicAPI } = useTreeItemUtils(); +- const item = publicAPI.getItem(props.itemId); ++ const item = useTreeItemModel(props.itemId); + } +``` + +:::success +If you were using `publicAPI` methods to access other information than the tree item model inside the render, please open an issue so that we can provide a way to do it. +::: + ## Apply the indentation on the item content instead of it's parent's group The indentation of nested Tree Items is now applied on the content of the element. diff --git a/docs/data/tree-view/rich-tree-view/customization/FileExplorer.js b/docs/data/tree-view/rich-tree-view/customization/FileExplorer.js index 304f82faaf74..cfc8d7fe5ccd 100644 --- a/docs/data/tree-view/rich-tree-view/customization/FileExplorer.js +++ b/docs/data/tree-view/rich-tree-view/customization/FileExplorer.js @@ -26,6 +26,7 @@ import { import { TreeItemIcon } from '@mui/x-tree-view/TreeItemIcon'; import { TreeItemProvider } from '@mui/x-tree-view/TreeItemProvider'; import { TreeItemDragAndDropOverlay } from '@mui/x-tree-view/TreeItemDragAndDropOverlay'; +import { useTreeItemModel } from '@mui/x-tree-view/hooks'; const ITEMS = [ { @@ -178,13 +179,6 @@ function CustomLabel({ icon: Icon, expandable, children, ...other }) { ); } -const isExpandable = (reactChildren) => { - if (Array.isArray(reactChildren)) { - return reactChildren.length > 0 && reactChildren.some(isExpandable); - } - return Boolean(reactChildren); -}; - const getIconFromFileType = (fileType) => { switch (fileType) { case 'image': @@ -210,6 +204,7 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem(props, ref) { const { id, itemId, label, disabled, children, ...other } = props; const { + getContextProviderProps, getRootProps, getContentProps, getIconContainerProps, @@ -218,20 +213,19 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem(props, ref) { getGroupTransitionProps, getDragAndDropOverlayProps, status, - publicAPI, } = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref }); - const item = publicAPI.getItem(itemId); - const expandable = isExpandable(children); + const item = useTreeItemModel(itemId); + let icon; - if (expandable) { + if (status.expandable) { icon = FolderRounded; } else if (item.fileType) { icon = getIconFromFileType(item.fileType); } return ( - + diff --git a/docs/data/tree-view/rich-tree-view/customization/FileExplorer.tsx b/docs/data/tree-view/rich-tree-view/customization/FileExplorer.tsx index 09291542de68..dd76a0417488 100644 --- a/docs/data/tree-view/rich-tree-view/customization/FileExplorer.tsx +++ b/docs/data/tree-view/rich-tree-view/customization/FileExplorer.tsx @@ -26,6 +26,7 @@ import { import { TreeItemIcon } from '@mui/x-tree-view/TreeItemIcon'; import { TreeItemProvider } from '@mui/x-tree-view/TreeItemProvider'; import { TreeItemDragAndDropOverlay } from '@mui/x-tree-view/TreeItemDragAndDropOverlay'; +import { useTreeItemModel } from '@mui/x-tree-view/hooks'; import { TreeViewBaseItem } from '@mui/x-tree-view/models'; type FileType = 'image' | 'pdf' | 'doc' | 'video' | 'folder' | 'pinned' | 'trash'; @@ -204,13 +205,6 @@ function CustomLabel({ ); } -const isExpandable = (reactChildren: React.ReactNode) => { - if (Array.isArray(reactChildren)) { - return reactChildren.length > 0 && reactChildren.some(isExpandable); - } - return Boolean(reactChildren); -}; - const getIconFromFileType = (fileType: FileType) => { switch (fileType) { case 'image': @@ -243,6 +237,7 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem( const { id, itemId, label, disabled, children, ...other } = props; const { + getContextProviderProps, getRootProps, getContentProps, getIconContainerProps, @@ -251,20 +246,19 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem( getGroupTransitionProps, getDragAndDropOverlayProps, status, - publicAPI, } = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref }); - const item = publicAPI.getItem(itemId); - const expandable = isExpandable(children); + const item = useTreeItemModel(itemId)!; + let icon; - if (expandable) { + if (status.expandable) { icon = FolderRounded; } else if (item.fileType) { icon = getIconFromFileType(item.fileType); } return ( - + diff --git a/docs/data/tree-view/rich-tree-view/customization/HeadlessAPI.js b/docs/data/tree-view/rich-tree-view/customization/HeadlessAPI.js index 172d7a1deaaf..a946acc455c8 100644 --- a/docs/data/tree-view/rich-tree-view/customization/HeadlessAPI.js +++ b/docs/data/tree-view/rich-tree-view/customization/HeadlessAPI.js @@ -40,6 +40,7 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem(props, ref) { const { id, itemId, label, disabled, children, ...other } = props; const { + getContextProviderProps, getRootProps, getContentProps, getIconContainerProps, @@ -51,7 +52,7 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem(props, ref) { } = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref }); return ( - + diff --git a/docs/data/tree-view/rich-tree-view/customization/HeadlessAPI.tsx b/docs/data/tree-view/rich-tree-view/customization/HeadlessAPI.tsx index bccd2390df60..3554fd4cc93e 100644 --- a/docs/data/tree-view/rich-tree-view/customization/HeadlessAPI.tsx +++ b/docs/data/tree-view/rich-tree-view/customization/HeadlessAPI.tsx @@ -47,6 +47,7 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem( const { id, itemId, label, disabled, children, ...other } = props; const { + getContextProviderProps, getRootProps, getContentProps, getIconContainerProps, @@ -58,7 +59,7 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem( } = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref }); return ( - + diff --git a/docs/data/tree-view/rich-tree-view/editing/CustomLabelInput.js b/docs/data/tree-view/rich-tree-view/editing/CustomLabelInput.js index 552f4de2ce1f..dfbfeec308cb 100644 --- a/docs/data/tree-view/rich-tree-view/editing/CustomLabelInput.js +++ b/docs/data/tree-view/rich-tree-view/editing/CustomLabelInput.js @@ -6,8 +6,8 @@ import IconButton from '@mui/material/IconButton'; import { RichTreeView } from '@mui/x-tree-view/RichTreeView'; import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; import { TreeItem, TreeItemLabel } from '@mui/x-tree-view/TreeItem'; -import { useTreeItem } from '@mui/x-tree-view/useTreeItem'; -import { useTreeItemUtils } from '@mui/x-tree-view/hooks'; + +import { useTreeItemUtils, useTreeItemModel } from '@mui/x-tree-view/hooks'; const StyledLabelInput = styled('input')(({ theme }) => ({ ...theme.typography.body1, @@ -69,9 +69,11 @@ function Label({ children, ...other }) { } const LabelInput = React.forwardRef(function LabelInput( - { item, handleCancelItemLabelEditing, handleSaveItemLabel, ...props }, + { itemId, handleCancelItemLabelEditing, handleSaveItemLabel, ...props }, ref, ) { + const item = useTreeItemModel(itemId); + const [initialNameValue, setInitialNameValue] = React.useState({ firstName: item.firstName, lastName: item.lastName, @@ -141,7 +143,6 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem(props, ref) { itemId: props.itemId, children: props.children, }); - const { publicAPI } = useTreeItem(props); const handleInputBlur = (event) => { event.defaultMuiPrevented = true; @@ -158,7 +159,7 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem(props, ref) { slots={{ label: Label, labelInput: LabelInput }} slotProps={{ labelInput: { - item: publicAPI.getItem(props.itemId), + itemId: props.itemId, onBlur: handleInputBlur, onKeyDown: handleInputKeyDown, handleCancelItemLabelEditing: interactions.handleCancelItemLabelEditing, diff --git a/docs/data/tree-view/rich-tree-view/editing/CustomLabelInput.tsx b/docs/data/tree-view/rich-tree-view/editing/CustomLabelInput.tsx index 8b4d1ad7c143..248f80009c8c 100644 --- a/docs/data/tree-view/rich-tree-view/editing/CustomLabelInput.tsx +++ b/docs/data/tree-view/rich-tree-view/editing/CustomLabelInput.tsx @@ -9,10 +9,9 @@ import { TreeItem, TreeItemLabel, TreeItemProps } from '@mui/x-tree-view/TreeIte import { UseTreeItemLabelInputSlotOwnProps, UseTreeItemLabelSlotOwnProps, - useTreeItem, } from '@mui/x-tree-view/useTreeItem'; -import { useTreeItemUtils } from '@mui/x-tree-view/hooks'; -import { TreeViewBaseItem } from '@mui/x-tree-view/models'; +import { useTreeItemUtils, useTreeItemModel } from '@mui/x-tree-view/hooks'; +import { TreeViewBaseItem, TreeViewItemId } from '@mui/x-tree-view/models'; const StyledLabelInput = styled('input')(({ theme }) => ({ ...theme.typography.body1, @@ -83,18 +82,20 @@ function Label({ children, ...other }: UseTreeItemLabelSlotOwnProps) { interface CustomLabelInputProps extends UseTreeItemLabelInputSlotOwnProps { handleCancelItemLabelEditing: (event: React.SyntheticEvent) => void; handleSaveItemLabel: (event: React.SyntheticEvent, label: string) => void; - item: TreeViewBaseItem; + itemId: TreeViewItemId; } const LabelInput = React.forwardRef(function LabelInput( { - item, + itemId, handleCancelItemLabelEditing, handleSaveItemLabel, ...props }: Omit, ref: React.Ref, ) { + const item = useTreeItemModel(itemId)!; + const [initialNameValue, setInitialNameValue] = React.useState({ firstName: item.firstName, lastName: item.lastName, @@ -167,7 +168,6 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem( itemId: props.itemId, children: props.children, }); - const { publicAPI } = useTreeItem(props); const handleInputBlur: UseTreeItemLabelInputSlotOwnProps['onBlur'] = (event) => { event.defaultMuiPrevented = true; @@ -186,7 +186,7 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem( slots={{ label: Label, labelInput: LabelInput }} slotProps={{ labelInput: { - item: publicAPI.getItem(props.itemId), + itemId: props.itemId, onBlur: handleInputBlur, onKeyDown: handleInputKeyDown, handleCancelItemLabelEditing: interactions.handleCancelItemLabelEditing, diff --git a/docs/data/tree-view/rich-tree-view/headless/LogExpandedItems.js b/docs/data/tree-view/rich-tree-view/headless/LogExpandedItems.js index 1b76b3354084..3d6077c4fdac 100644 --- a/docs/data/tree-view/rich-tree-view/headless/LogExpandedItems.js +++ b/docs/data/tree-view/rich-tree-view/headless/LogExpandedItems.js @@ -7,8 +7,11 @@ import { RichTreeViewRoot, RICH_TREE_VIEW_PLUGINS, } from '@mui/x-tree-view/RichTreeView'; -import { TreeItem } from '@mui/x-tree-view/TreeItem'; -import { useTreeView, TreeViewProvider } from '@mui/x-tree-view/internals'; +import { + useTreeView, + TreeViewProvider, + RichTreeViewItems, +} from '@mui/x-tree-view/internals'; const useTreeViewLogExpanded = ({ params, models }) => { const expandedStr = JSON.stringify(models.expandedItems.value); @@ -36,25 +39,15 @@ useTreeViewLogExpanded.params = { const TREE_VIEW_PLUGINS = [...RICH_TREE_VIEW_PLUGINS, useTreeViewLogExpanded]; function TreeView(props) { - const { getRootProps, contextValue, instance } = useTreeView({ + const { getRootProps, contextValue } = useTreeView({ plugins: TREE_VIEW_PLUGINS, props, }); - const itemsToRender = instance.getItemsToRender(); - - const renderItem = ({ children: itemChildren, ...itemProps }) => { - return ( - - {itemChildren?.map(renderItem)} - - ); - }; - return ( - {itemsToRender.map(renderItem)} + ); diff --git a/docs/data/tree-view/rich-tree-view/headless/LogExpandedItems.tsx b/docs/data/tree-view/rich-tree-view/headless/LogExpandedItems.tsx index 5c6942d61458..bab71f713478 100644 --- a/docs/data/tree-view/rich-tree-view/headless/LogExpandedItems.tsx +++ b/docs/data/tree-view/rich-tree-view/headless/LogExpandedItems.tsx @@ -11,7 +11,6 @@ import { RichTreeViewPluginSlots, RichTreeViewPluginSlotProps, } from '@mui/x-tree-view/RichTreeView'; -import { TreeItem } from '@mui/x-tree-view/TreeItem'; import { UseTreeViewExpansionSignature, TreeViewPlugin, @@ -19,6 +18,7 @@ import { useTreeView, TreeViewProvider, ConvertPluginsIntoSignatures, + RichTreeViewItems, } from '@mui/x-tree-view/internals'; interface TreeViewLogExpandedParameters { @@ -86,7 +86,7 @@ type TreeViewPluginSignatures = ConvertPluginsIntoSignatures< function TreeView( props: TreeViewProps, ) { - const { getRootProps, contextValue, instance } = useTreeView< + const { getRootProps, contextValue } = useTreeView< TreeViewPluginSignatures, typeof props >({ @@ -94,23 +94,10 @@ function TreeView( props, }); - const itemsToRender = instance.getItemsToRender(); - - const renderItem = ({ - children: itemChildren, - ...itemProps - }: ReturnType[number]) => { - return ( - - {itemChildren?.map(renderItem)} - - ); - }; - return ( - {itemsToRender.map(renderItem)} + ); diff --git a/docs/data/tree-view/rich-tree-view/headless/headless.md b/docs/data/tree-view/rich-tree-view/headless/headless.md index 21ccf76210a5..0cc8c75d50b6 100644 --- a/docs/data/tree-view/rich-tree-view/headless/headless.md +++ b/docs/data/tree-view/rich-tree-view/headless/headless.md @@ -122,7 +122,7 @@ const useCustomPlugin = ({ models }) => { models.expandedItems.setValue([]); // Check if an item is expanded - const isExpanded = instance.isNodeExpanded('some-item-id'); + const isExpanded = useSelector(selectorIsItemExpanded, 'some-item-id'); }; }; ``` diff --git a/docs/data/tree-view/rich-tree-view/ordering/FileExplorer.js b/docs/data/tree-view/rich-tree-view/ordering/FileExplorer.js index 0e7bf9686ca8..9951fed165c1 100644 --- a/docs/data/tree-view/rich-tree-view/ordering/FileExplorer.js +++ b/docs/data/tree-view/rich-tree-view/ordering/FileExplorer.js @@ -23,8 +23,7 @@ import { import { TreeItemIcon } from '@mui/x-tree-view/TreeItemIcon'; import { TreeItemProvider } from '@mui/x-tree-view/TreeItemProvider'; import { TreeItemDragAndDropOverlay } from '@mui/x-tree-view/TreeItemDragAndDropOverlay'; - -import { useTreeViewApiRef } from '@mui/x-tree-view/hooks'; +import { useTreeItemModel, useTreeViewApiRef } from '@mui/x-tree-view/hooks'; const ITEMS = [ { @@ -164,13 +163,6 @@ function CustomLabel({ icon: Icon, expandable, children, ...other }) { ); } -const isExpandable = (reactChildren) => { - if (Array.isArray(reactChildren)) { - return reactChildren.length > 0 && reactChildren.some(isExpandable); - } - return Boolean(reactChildren); -}; - const getIconFromFileType = (fileType) => { switch (fileType) { case 'image': @@ -194,6 +186,7 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem(props, ref) { const { id, itemId, label, disabled, children, ...other } = props; const { + getContextProviderProps, getRootProps, getContentProps, getIconContainerProps, @@ -202,15 +195,19 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem(props, ref) { getGroupTransitionProps, getDragAndDropOverlayProps, status, - publicAPI, } = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref }); - const item = publicAPI.getItem(itemId); - const expandable = isExpandable(children); - const icon = getIconFromFileType(item.fileType); + const item = useTreeItemModel(itemId); + + let icon; + if (status.expandable) { + icon = FolderRounded; + } else if (item.fileType) { + icon = getIconFromFileType(item.fileType); + } return ( - + diff --git a/docs/data/tree-view/rich-tree-view/ordering/FileExplorer.tsx b/docs/data/tree-view/rich-tree-view/ordering/FileExplorer.tsx index ff08427f4691..49d4013853f5 100644 --- a/docs/data/tree-view/rich-tree-view/ordering/FileExplorer.tsx +++ b/docs/data/tree-view/rich-tree-view/ordering/FileExplorer.tsx @@ -23,8 +23,8 @@ import { import { TreeItemIcon } from '@mui/x-tree-view/TreeItemIcon'; import { TreeItemProvider } from '@mui/x-tree-view/TreeItemProvider'; import { TreeItemDragAndDropOverlay } from '@mui/x-tree-view/TreeItemDragAndDropOverlay'; +import { useTreeItemModel, useTreeViewApiRef } from '@mui/x-tree-view/hooks'; import { TreeViewBaseItem } from '@mui/x-tree-view/models'; -import { useTreeViewApiRef } from '@mui/x-tree-view/hooks'; type FileType = 'image' | 'pdf' | 'doc' | 'video' | 'folder' | 'pinned' | 'trash'; @@ -189,13 +189,6 @@ function CustomLabel({ ); } -const isExpandable = (reactChildren: React.ReactNode) => { - if (Array.isArray(reactChildren)) { - return reactChildren.length > 0 && reactChildren.some(isExpandable); - } - return Boolean(reactChildren); -}; - const getIconFromFileType = (fileType: FileType) => { switch (fileType) { case 'image': @@ -226,6 +219,7 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem( const { id, itemId, label, disabled, children, ...other } = props; const { + getContextProviderProps, getRootProps, getContentProps, getIconContainerProps, @@ -234,15 +228,19 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem( getGroupTransitionProps, getDragAndDropOverlayProps, status, - publicAPI, } = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref }); - const item = publicAPI.getItem(itemId); - const expandable = isExpandable(children); - const icon = getIconFromFileType(item.fileType); + const item = useTreeItemModel(itemId)!; + + let icon; + if (status.expandable) { + icon = FolderRounded; + } else if (item.fileType) { + icon = getIconFromFileType(item.fileType); + } return ( - + diff --git a/docs/data/tree-view/rich-tree-view/ordering/OnlyReorderFromDragHandle.js b/docs/data/tree-view/rich-tree-view/ordering/OnlyReorderFromDragHandle.js index 6abae8742ed1..6d8122853552 100644 --- a/docs/data/tree-view/rich-tree-view/ordering/OnlyReorderFromDragHandle.js +++ b/docs/data/tree-view/rich-tree-view/ordering/OnlyReorderFromDragHandle.js @@ -50,6 +50,7 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem(props, ref) { const { id, itemId, label, disabled, children, ...other } = props; const { + getContextProviderProps, getRootProps, getContentProps, getIconContainerProps, @@ -73,7 +74,7 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem(props, ref) { }; return ( - + diff --git a/docs/data/tree-view/rich-tree-view/ordering/OnlyReorderFromDragHandle.tsx b/docs/data/tree-view/rich-tree-view/ordering/OnlyReorderFromDragHandle.tsx index bdf2378a6dbb..f2d38f4972cc 100644 --- a/docs/data/tree-view/rich-tree-view/ordering/OnlyReorderFromDragHandle.tsx +++ b/docs/data/tree-view/rich-tree-view/ordering/OnlyReorderFromDragHandle.tsx @@ -57,6 +57,7 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem( const { id, itemId, label, disabled, children, ...other } = props; const { + getContextProviderProps, getRootProps, getContentProps, getIconContainerProps, @@ -84,7 +85,7 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem( }; return ( - + diff --git a/docs/data/tree-view/simple-tree-view/customization/GmailTreeView.js b/docs/data/tree-view/simple-tree-view/customization/GmailTreeView.js index da5c5eebb7f4..c07504f8a92f 100644 --- a/docs/data/tree-view/simple-tree-view/customization/GmailTreeView.js +++ b/docs/data/tree-view/simple-tree-view/customization/GmailTreeView.js @@ -69,6 +69,7 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem(props, ref) { } = props; const { + getContextProviderProps, getRootProps, getContentProps, getIconContainerProps, @@ -84,7 +85,7 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem(props, ref) { }; return ( - + + + diff --git a/docs/data/tree-view/simple-tree-view/customization/HeadlessAPI.tsx b/docs/data/tree-view/simple-tree-view/customization/HeadlessAPI.tsx index 10f47fe2aa3d..67c04b2c4b92 100644 --- a/docs/data/tree-view/simple-tree-view/customization/HeadlessAPI.tsx +++ b/docs/data/tree-view/simple-tree-view/customization/HeadlessAPI.tsx @@ -25,6 +25,7 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem( const { id, itemId, label, disabled, children, ...other } = props; const { + getContextProviderProps, getRootProps, getContentProps, getIconContainerProps, @@ -35,7 +36,7 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem( } = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref }); return ( - + diff --git a/docs/data/tree-view/tree-item-customization/CustomTreeItemDemo.js b/docs/data/tree-view/tree-item-customization/CustomTreeItemDemo.js index 9a39125199f4..436d40ea821f 100644 --- a/docs/data/tree-view/tree-item-customization/CustomTreeItemDemo.js +++ b/docs/data/tree-view/tree-item-customization/CustomTreeItemDemo.js @@ -103,12 +103,13 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem(props, ref) { getGroupTransitionProps, getDragAndDropOverlayProps, getLabelInputProps, + getContextProviderProps, status, } = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref }); return ( - + - + + {status.expandable && ( + {status.expandable && ( , ) { - const { publicAPI } = useTreeItemUtils({ - itemId: props.itemId, - children: props.children, - }); - - const item = publicAPI.getItem(props.itemId); + const item = useTreeItemModel(props.itemId)!; return ( + diff --git a/docs/data/tree-view/tree-item-customization/useTreeItemHookProperties.tsx b/docs/data/tree-view/tree-item-customization/useTreeItemHookProperties.tsx index 8e49e71df547..0fc4072427ac 100644 --- a/docs/data/tree-view/tree-item-customization/useTreeItemHookProperties.tsx +++ b/docs/data/tree-view/tree-item-customization/useTreeItemHookProperties.tsx @@ -22,6 +22,7 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem( ref: React.Ref, ) { const { + getContextProviderProps, getRootProps, getContentProps, getIconContainerProps, @@ -34,7 +35,7 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem( } = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref }); return ( - + diff --git a/docs/data/tree-view/tree-item-customization/useTreeItemHookPublicAPI.js b/docs/data/tree-view/tree-item-customization/useTreeItemHookPublicAPI.js index 72886928944d..0c71983ff031 100644 --- a/docs/data/tree-view/tree-item-customization/useTreeItemHookPublicAPI.js +++ b/docs/data/tree-view/tree-item-customization/useTreeItemHookPublicAPI.js @@ -1,14 +1,14 @@ import * as React from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; -import Chip from '@mui/material/Chip'; +import Button from '@mui/material/Button'; import Stack from '@mui/material/Stack'; import { RichTreeView } from '@mui/x-tree-view/RichTreeView'; import { TreeItem } from '@mui/x-tree-view/TreeItem'; import { useTreeItem } from '@mui/x-tree-view/useTreeItem'; import { MUI_X_PRODUCTS } from './products'; -function CustomLabel({ children, className, numberOfChildren }) { +function CustomLabel({ children, className, selectFirstChildren }) { return ( {children} - - + {!!selectFirstChildren && ( + + )} ); } const CustomTreeItem = React.forwardRef(function CustomTreeItem(props, ref) { - const { publicAPI } = useTreeItem(props); + const { publicAPI, status } = useTreeItem(props); - const childrenNumber = publicAPI.getItemOrderedChildrenIds(props.itemId).length; + const selectFirstChildren = status.expanded + ? (event) => { + event.stopPropagation(); + const children = publicAPI.getItemOrderedChildrenIds(props.itemId); + if (children.length > 0) { + publicAPI.selectItem({ + event, + itemId: children[0], + shouldBeSelected: true, + }); + } + } + : undefined; return ( ); diff --git a/docs/data/tree-view/tree-item-customization/useTreeItemHookPublicAPI.tsx b/docs/data/tree-view/tree-item-customization/useTreeItemHookPublicAPI.tsx index 48bf738b2fc8..3698ce968f02 100644 --- a/docs/data/tree-view/tree-item-customization/useTreeItemHookPublicAPI.tsx +++ b/docs/data/tree-view/tree-item-customization/useTreeItemHookPublicAPI.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; -import Chip from '@mui/material/Chip'; +import Button from '@mui/material/Button'; import Stack from '@mui/material/Stack'; import { RichTreeView } from '@mui/x-tree-view/RichTreeView'; import { TreeItem, TreeItemProps } from '@mui/x-tree-view/TreeItem'; @@ -11,10 +11,14 @@ import { MUI_X_PRODUCTS } from './products'; interface CustomLabelProps { children: string; className: string; - numberOfChildren: number; + selectFirstChildren?: (event: React.MouseEvent) => void; } -function CustomLabel({ children, className, numberOfChildren }: CustomLabelProps) { +function CustomLabel({ + children, + className, + selectFirstChildren, +}: CustomLabelProps) { return ( {children} - - + {!!selectFirstChildren && ( + + )} ); } @@ -34,9 +46,21 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem( props: TreeItemProps, ref: React.Ref, ) { - const { publicAPI } = useTreeItem(props); + const { publicAPI, status } = useTreeItem(props); - const childrenNumber = publicAPI.getItemOrderedChildrenIds(props.itemId).length; + const selectFirstChildren = status.expanded + ? (event: React.MouseEvent) => { + event.stopPropagation(); + const children = publicAPI.getItemOrderedChildrenIds(props.itemId); + if (children.length > 0) { + publicAPI.selectItem({ + event, + itemId: children[0], + shouldBeSelected: true, + }); + } + } + : undefined; return ( ); diff --git a/docs/data/tree-view/tree-item-customization/useTreeItemHookStatus.js b/docs/data/tree-view/tree-item-customization/useTreeItemHookStatus.js index 5c6e53c47b14..13395175085d 100644 --- a/docs/data/tree-view/tree-item-customization/useTreeItemHookStatus.js +++ b/docs/data/tree-view/tree-item-customization/useTreeItemHookStatus.js @@ -88,6 +88,7 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem( ref, ) { const { + getContextProviderProps, getRootProps, getContentProps, getLabelProps, @@ -98,7 +99,7 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem( } = useTreeItem({ id, itemId, label, disabled, children, rootRef: ref }); return ( - + diff --git a/docs/data/tree-view/tree-item-customization/useTreeItemHookStatus.tsx b/docs/data/tree-view/tree-item-customization/useTreeItemHookStatus.tsx index d03d53ca63d0..783bf24064ca 100644 --- a/docs/data/tree-view/tree-item-customization/useTreeItemHookStatus.tsx +++ b/docs/data/tree-view/tree-item-customization/useTreeItemHookStatus.tsx @@ -91,6 +91,7 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem( ref: React.Ref, ) { const { + getContextProviderProps, getRootProps, getContentProps, getLabelProps, @@ -101,7 +102,7 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem( } = useTreeItem({ id, itemId, label, disabled, children, rootRef: ref }); return ( - + diff --git a/docs/data/tree-view/tree-item-customization/useTreeItemModelHook.js b/docs/data/tree-view/tree-item-customization/useTreeItemModelHook.js new file mode 100644 index 000000000000..eba242636def --- /dev/null +++ b/docs/data/tree-view/tree-item-customization/useTreeItemModelHook.js @@ -0,0 +1,32 @@ +import * as React from 'react'; +import Box from '@mui/material/Box'; +import { RichTreeView } from '@mui/x-tree-view/RichTreeView'; +import { TreeItem } from '@mui/x-tree-view/TreeItem'; +import { useTreeItemModel } from '@mui/x-tree-view/hooks'; +import { MUI_X_PRODUCTS } from './products'; + +const CustomTreeItem = React.forwardRef(function CustomTreeItem(props, ref) { + const item = useTreeItemModel(props.itemId); + + return ( + + ); +}); + +export default function useTreeItemModelHook() { + return ( + + + + ); +} diff --git a/docs/data/tree-view/tree-item-customization/useTreeItemModelHook.tsx b/docs/data/tree-view/tree-item-customization/useTreeItemModelHook.tsx new file mode 100644 index 000000000000..fedf73068fa7 --- /dev/null +++ b/docs/data/tree-view/tree-item-customization/useTreeItemModelHook.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import Box from '@mui/material/Box'; +import { RichTreeView } from '@mui/x-tree-view/RichTreeView'; +import { TreeItem, TreeItemProps } from '@mui/x-tree-view/TreeItem'; +import { useTreeItemModel } from '@mui/x-tree-view/hooks'; +import { MUI_X_PRODUCTS } from './products'; + +type TreeItemWithLabel = { + id: string; + label: string; + isHighlighted?: boolean; +}; + +const CustomTreeItem = React.forwardRef(function CustomTreeItem( + props: TreeItemProps, + ref: React.Ref, +) { + const item = useTreeItemModel(props.itemId)!; + + return ( + + ); +}); + +export default function useTreeItemModelHook() { + return ( + + + + ); +} diff --git a/docs/data/tree-view/tree-item-customization/useTreeItemModelHook.tsx.preview b/docs/data/tree-view/tree-item-customization/useTreeItemModelHook.tsx.preview new file mode 100644 index 000000000000..1333b932705f --- /dev/null +++ b/docs/data/tree-view/tree-item-customization/useTreeItemModelHook.tsx.preview @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/docs/pages/x/api/tree-view/rich-tree-view-pro.json b/docs/pages/x/api/tree-view/rich-tree-view-pro.json index 2fcdf49e993d..3456c4228964 100644 --- a/docs/pages/x/api/tree-view/rich-tree-view-pro.json +++ b/docs/pages/x/api/tree-view/rich-tree-view-pro.json @@ -161,12 +161,6 @@ "default": "RichTreeViewProRoot", "class": "MuiRichTreeViewPro-root" }, - { - "name": "item", - "description": "Custom component for the item.", - "default": "TreeItem.", - "class": null - }, { "name": "collapseIcon", "description": "The default icon used to collapse the item.", @@ -181,6 +175,12 @@ "name": "endIcon", "description": "The default icon displayed next to an end item.\nThis is applied to all Tree Items and can be overridden by the TreeItem `icon` slot prop.", "class": null + }, + { + "name": "item", + "description": "Custom component to render a Tree Item.", + "default": "TreeItem.", + "class": null } ], "classes": [], diff --git a/docs/pages/x/api/tree-view/rich-tree-view.json b/docs/pages/x/api/tree-view/rich-tree-view.json index c5ca125dfd5d..7b014b540d84 100644 --- a/docs/pages/x/api/tree-view/rich-tree-view.json +++ b/docs/pages/x/api/tree-view/rich-tree-view.json @@ -136,12 +136,6 @@ "default": "RichTreeViewRoot", "class": "MuiRichTreeView-root" }, - { - "name": "item", - "description": "Custom component for the item.", - "default": "TreeItem.", - "class": null - }, { "name": "collapseIcon", "description": "The default icon used to collapse the item.", @@ -156,6 +150,12 @@ "name": "endIcon", "description": "The default icon displayed next to an end item.\nThis is applied to all Tree Items and can be overridden by the TreeItem `icon` slot prop.", "class": null + }, + { + "name": "item", + "description": "Custom component to render a Tree Item.", + "default": "TreeItem.", + "class": null } ], "classes": [], diff --git a/docs/pages/x/api/tree-view/tree-item.json b/docs/pages/x/api/tree-view/tree-item.json index be0a273ec4c0..f1383007b5fe 100644 --- a/docs/pages/x/api/tree-view/tree-item.json +++ b/docs/pages/x/api/tree-view/tree-item.json @@ -1,7 +1,7 @@ { "props": { "itemId": { "type": { "name": "string" }, "required": true }, - "children": { "type": { "name": "node" } }, + "children": { "type": { "name": "any" } }, "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, "disabled": { "type": { "name": "bool" }, "default": "false" }, "id": { "type": { "name": "string" } }, diff --git a/docs/translations/api-docs/tree-view/rich-tree-view-pro/rich-tree-view-pro.json b/docs/translations/api-docs/tree-view/rich-tree-view-pro/rich-tree-view-pro.json index b43dd9784328..97cdea241f1b 100644 --- a/docs/translations/api-docs/tree-view/rich-tree-view-pro/rich-tree-view-pro.json +++ b/docs/translations/api-docs/tree-view/rich-tree-view-pro/rich-tree-view-pro.json @@ -151,7 +151,7 @@ "collapseIcon": "The default icon used to collapse the item.", "endIcon": "The default icon displayed next to an end item. This is applied to all Tree Items and can be overridden by the TreeItem icon slot prop.", "expandIcon": "The default icon used to expand the item.", - "item": "Custom component for the item.", + "item": "Custom component to render a Tree Item.", "root": "Element rendered at the root." } } diff --git a/docs/translations/api-docs/tree-view/rich-tree-view/rich-tree-view.json b/docs/translations/api-docs/tree-view/rich-tree-view/rich-tree-view.json index a2fdbd15c255..e0b8130a4faa 100644 --- a/docs/translations/api-docs/tree-view/rich-tree-view/rich-tree-view.json +++ b/docs/translations/api-docs/tree-view/rich-tree-view/rich-tree-view.json @@ -122,7 +122,7 @@ "collapseIcon": "The default icon used to collapse the item.", "endIcon": "The default icon displayed next to an end item. This is applied to all Tree Items and can be overridden by the TreeItem icon slot prop.", "expandIcon": "The default icon used to expand the item.", - "item": "Custom component for the item.", + "item": "Custom component to render a Tree Item.", "root": "Element rendered at the root." } } diff --git a/packages/x-tree-view-pro/package.json b/packages/x-tree-view-pro/package.json index 47c3fbf73f0b..86875321ef0d 100644 --- a/packages/x-tree-view-pro/package.json +++ b/packages/x-tree-view-pro/package.json @@ -51,7 +51,9 @@ "@types/react-transition-group": "^4.4.11", "clsx": "^2.1.1", "prop-types": "^15.8.1", - "react-transition-group": "^4.4.5" + "react-transition-group": "^4.4.5", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.0.0" }, "peerDependencies": { "@emotion/react": "^11.9.0", @@ -74,6 +76,7 @@ "@mui/material": "^5.16.7", "@mui/system": "^5.16.7", "@types/prop-types": "^15.7.13", + "@types/use-sync-external-store": "^0.0.6", "rimraf": "^6.0.1" }, "engines": { diff --git a/packages/x-tree-view-pro/src/RichTreeViewPro/RichTreeViewPro.tsx b/packages/x-tree-view-pro/src/RichTreeViewPro/RichTreeViewPro.tsx index 5f48c869de88..74be110c7958 100644 --- a/packages/x-tree-view-pro/src/RichTreeViewPro/RichTreeViewPro.tsx +++ b/packages/x-tree-view-pro/src/RichTreeViewPro/RichTreeViewPro.tsx @@ -75,14 +75,13 @@ const RichTreeViewPro = React.forwardRef(function RichTreeViewPro< } } - const { getRootProps, contextValue, instance } = useTreeView< - RichTreeViewProPluginSignatures, - typeof props - >({ - plugins: RICH_TREE_VIEW_PRO_PLUGINS, - rootRef: ref, - props, - }); + const { getRootProps, contextValue } = useTreeView( + { + plugins: RICH_TREE_VIEW_PRO_PLUGINS, + rootRef: ref, + props, + }, + ); const { slots, slotProps } = props; const classes = useUtilityClasses(props); @@ -99,11 +98,7 @@ const RichTreeViewPro = React.forwardRef(function RichTreeViewPro< return ( - + diff --git a/packages/x-tree-view-pro/src/RichTreeViewPro/RichTreeViewPro.types.ts b/packages/x-tree-view-pro/src/RichTreeViewPro/RichTreeViewPro.types.ts index ff865e8fe213..b2d4a02f071f 100644 --- a/packages/x-tree-view-pro/src/RichTreeViewPro/RichTreeViewPro.types.ts +++ b/packages/x-tree-view-pro/src/RichTreeViewPro/RichTreeViewPro.types.ts @@ -2,9 +2,12 @@ import * as React from 'react'; import { Theme } from '@mui/material/styles'; import { SxProps } from '@mui/system'; import { SlotComponentProps } from '@mui/utils'; -import { TreeItem, TreeItemProps } from '@mui/x-tree-view/TreeItem'; -import { TreeViewItemId } from '@mui/x-tree-view/models'; -import { TreeViewPublicAPI, TreeViewExperimentalFeatures } from '@mui/x-tree-view/internals'; +import { + TreeViewPublicAPI, + TreeViewExperimentalFeatures, + RichTreeViewItemsSlots, + RichTreeViewItemsSlotProps, +} from '@mui/x-tree-view/internals'; import { RichTreeViewProClasses } from './richTreeViewProClasses'; import { RichTreeViewProPluginParameters, @@ -13,28 +16,18 @@ import { RichTreeViewProPluginSignatures, } from './RichTreeViewPro.plugins'; -interface RichTreeViewItemProSlotOwnerState { - itemId: TreeViewItemId; - label: string; -} - -export interface RichTreeViewProSlots extends RichTreeViewProPluginSlots { +export interface RichTreeViewProSlots extends RichTreeViewProPluginSlots, RichTreeViewItemsSlots { /** * Element rendered at the root. * @default RichTreeViewProRoot */ root?: React.ElementType; - /** - * Custom component for the item. - * @default TreeItem. - */ - item?: React.JSXElementConstructor; } export interface RichTreeViewProSlotProps - extends RichTreeViewProPluginSlotProps { + extends RichTreeViewProPluginSlotProps, + RichTreeViewItemsSlotProps { root?: SlotComponentProps<'ul', {}, RichTreeViewProProps>; - item?: SlotComponentProps; } export type RichTreeViewProApiRef = React.MutableRefObject< diff --git a/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.itemPlugin.ts b/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.itemPlugin.ts index 999d7b2b3e3f..1906cef2d7ea 100644 --- a/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.itemPlugin.ts +++ b/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.itemPlugin.ts @@ -5,6 +5,7 @@ import { useTreeViewContext, UseTreeViewItemsSignature, isTargetInDescendants, + useSelector, } from '@mui/x-tree-view/internals'; import { UseTreeItemDragAndDropOverlaySlotPropsFromItemsReordering, @@ -13,16 +14,27 @@ import { TreeViewItemItemReorderingValidActions, UseTreeItemContentSlotPropsFromItemsReordering, } from './useTreeViewItemsReordering.types'; +import { + selectorItemsReorderingDraggedItemProperties, + selectorItemsReorderingIsValidTarget, +} from './useTreeViewItemsReordering.selectors'; export const isAndroid = () => navigator.userAgent.toLowerCase().includes('android'); export const useTreeViewItemsReorderingItemPlugin: TreeViewItemPlugin = ({ props }) => { - const { itemsReordering, instance } = + const { instance, store, itemsReordering } = useTreeViewContext<[UseTreeViewItemsSignature, UseTreeViewItemsReorderingSignature]>(); const { itemId } = props; const validActionsRef = React.useRef(null); + const draggedItemProperties = useSelector( + store, + selectorItemsReorderingDraggedItemProperties, + itemId, + ); + const isValidTarget = useSelector(store, selectorItemsReorderingIsValidTarget, itemId); + return { propsEnhancers: { root: ({ @@ -30,8 +42,10 @@ export const useTreeViewItemsReorderingItemPlugin: TreeViewItemPlugin = ({ props contentRefObject, externalEventHandlers, }): UseTreeItemRootSlotPropsFromItemsReordering => { - const draggable = instance.canItemBeDragged(itemId); - if (!draggable) { + if ( + !itemsReordering.enabled || + (itemsReordering.isItemReorderable && !itemsReordering.isItemReorderable(itemId)) + ) { return {}; } @@ -92,8 +106,7 @@ export const useTreeViewItemsReorderingItemPlugin: TreeViewItemPlugin = ({ props externalEventHandlers, contentRefObject, }): UseTreeItemContentSlotPropsFromItemsReordering => { - const currentDrag = itemsReordering.currentDrag; - if (!currentDrag || currentDrag.draggedItemId === itemId) { + if (!isValidTarget) { return {}; } @@ -131,20 +144,15 @@ export const useTreeViewItemsReorderingItemPlugin: TreeViewItemPlugin = ({ props }; }, dragAndDropOverlay: (): UseTreeItemDragAndDropOverlaySlotPropsFromItemsReordering => { - const currentDrag = itemsReordering.currentDrag; - if (!currentDrag || currentDrag.targetItemId !== itemId || currentDrag.action == null) { + if (!draggedItemProperties) { return {}; } - const targetDepth = - currentDrag.newPosition?.parentId == null - ? 0 - : // The depth is always defined because drag&drop is only usable with Rich Tree View components. - instance.getItemMeta(currentDrag.newPosition.parentId).depth! + 1; - return { - action: currentDrag.action, - style: { '--TreeView-targetDepth': targetDepth } as React.CSSProperties, + action: draggedItemProperties.action, + style: { + '--TreeView-targetDepth': draggedItemProperties.targetDepth, + } as React.CSSProperties, }; }, }, diff --git a/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.selectors.ts b/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.selectors.ts new file mode 100644 index 000000000000..8e59419f0259 --- /dev/null +++ b/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.selectors.ts @@ -0,0 +1,53 @@ +import { createSelector, TreeViewState, selectorItemMetaLookup } from '@mui/x-tree-view/internals'; +import { UseTreeViewItemsReorderingSignature } from './useTreeViewItemsReordering.types'; + +/** + * Get the items reordering state. + * @param {TreeViewState<[UseTreeViewItemsReorderingSignature]>} state The state of the tree view. + * @returns {TreeViewItemsReorderingState | null} The items reordering state. + */ +export const selectorItemsReordering = ( + state: TreeViewState<[UseTreeViewItemsReorderingSignature]>, +) => state.itemsReordering; + +/** + * Get the properties of the dragged item. + * @param {TreeViewState<[UseTreeViewItemsSignature, UseTreeViewItemsReorderingSignature]>} state The state of the tree view. + * @param {string} itemId The id of the item. + * @returns {TreeViewItemDraggedItemProperties | null} The properties of the dragged item if the current item is being dragged, `null` otherwise. + */ +export const selectorItemsReorderingDraggedItemProperties = createSelector( + [selectorItemsReordering, selectorItemMetaLookup, (_, itemId: string) => itemId], + (itemsReordering, itemMetaLookup, itemId) => { + if ( + !itemsReordering || + itemsReordering.targetItemId !== itemId || + itemsReordering.action == null + ) { + return null; + } + + const targetDepth = + itemsReordering.newPosition?.parentId == null + ? 0 + : // The depth is always defined because drag&drop is only usable with Rich Tree View components. + itemMetaLookup[itemId].depth! + 1; + + return { + newPosition: itemsReordering.newPosition, + action: itemsReordering.action, + targetDepth, + }; + }, +); + +/** + * Check if the current item is a valid target for the dragged item. + * @param {TreeViewState<[UseTreeViewItemsReorderingSignature]>} state The state of the tree view. + * @param {string} itemId The id of the item. + * @returns {boolean} `true` if the current item is a valid target for the dragged item, `false` otherwise. + */ +export const selectorItemsReorderingIsValidTarget = createSelector( + [selectorItemsReordering, (_, itemId: string) => itemId], + (itemsReordering, itemId) => itemsReordering && itemsReordering.draggedItemId !== itemId, +); diff --git a/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.test.tsx b/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.test.tsx index f414c28fa431..cfd604e7ac70 100644 --- a/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.test.tsx +++ b/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.test.tsx @@ -123,7 +123,7 @@ describeTreeView< fireEvent.keyDown(view.getItemRoot('2'), { key: 'Enter' }); expect(view.getItemIdTree()).to.deep.equal([ - { id: '1', children: [] }, + { id: '1' }, { id: '2', children: [{ id: '1.1' }] }, ]); }); diff --git a/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.ts b/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.ts index 45b8568da050..c096a1320ebd 100644 --- a/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.ts +++ b/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.ts @@ -1,5 +1,10 @@ import * as React from 'react'; -import { TreeViewPlugin } from '@mui/x-tree-view/internals'; +import { + TreeViewPlugin, + selectorItemIndex, + selectorItemMeta, + selectorItemOrderedChildrenIds, +} from '@mui/x-tree-view/internals'; import { warnOnce } from '@mui/x-internals/warning'; import { TreeViewItemsReorderingAction } from '@mui/x-tree-view/models'; import { @@ -14,12 +19,11 @@ import { moveItemInTree, } from './useTreeViewItemsReordering.utils'; import { useTreeViewItemsReorderingItemPlugin } from './useTreeViewItemsReordering.itemPlugin'; +import { selectorItemsReordering } from './useTreeViewItemsReordering.selectors'; export const useTreeViewItemsReordering: TreeViewPlugin = ({ params, - instance, - state, - setState, + store, }) => { const canItemBeDragged = React.useCallback( (itemId: string) => { @@ -39,7 +43,7 @@ export const useTreeViewItemsReordering: TreeViewPlugin { - const itemsReordering = state.itemsReordering; + const itemsReordering = selectorItemsReordering(store.value); if (!itemsReordering) { throw new Error('There is no ongoing reordering.'); } @@ -49,10 +53,10 @@ export const useTreeViewItemsReordering: TreeViewPlugin { - setState((prevState) => ({ + store.update((prevState) => ({ ...prevState, itemsReordering: { targetItemId: itemId, @@ -137,34 +141,35 @@ export const useTreeViewItemsReordering: TreeViewPlugin { - if (state.itemsReordering == null || state.itemsReordering.draggedItemId !== itemId) { + const itemsReordering = selectorItemsReordering(store.value); + if (itemsReordering == null || itemsReordering.draggedItemId !== itemId) { return; } if ( - state.itemsReordering.draggedItemId === state.itemsReordering.targetItemId || - state.itemsReordering.action == null || - state.itemsReordering.newPosition == null + itemsReordering.draggedItemId === itemsReordering.targetItemId || + itemsReordering.action == null || + itemsReordering.newPosition == null ) { - setState((prevState) => ({ ...prevState, itemsReordering: null })); + store.update((prevState) => ({ ...prevState, itemsReordering: null })); return; } - const draggedItemMeta = instance.getItemMeta(state.itemsReordering.draggedItemId); + const draggedItemMeta = selectorItemMeta(store.value, itemsReordering.draggedItemId)!; const oldPosition: TreeViewItemReorderPosition = { parentId: draggedItemMeta.parentId, - index: instance.getItemIndex(draggedItemMeta.id), + index: selectorItemIndex(store.value, draggedItemMeta.id), }; - const newPosition = state.itemsReordering.newPosition; + const newPosition = itemsReordering.newPosition; - setState((prevState) => ({ + store.update((prevState) => ({ ...prevState, itemsReordering: null, items: moveItemInTree({ @@ -182,23 +187,23 @@ export const useTreeViewItemsReordering: TreeViewPlugin( ({ itemId, validActions, targetHeight, cursorY, cursorX, contentElement }) => { - setState((prevState) => { + store.update((prevState) => { const prevSubState = prevState.itemsReordering; - if (prevSubState == null || isAncestor(instance, itemId, prevSubState.draggedItemId)) { + if (prevSubState == null || isAncestor(store, itemId, prevSubState.draggedItemId)) { return prevState; } const action = chooseActionToApply({ itemChildrenIndentation: params.itemChildrenIndentation, validActions, targetHeight, - targetDepth: prevState.items.itemMetaMap[itemId].depth!, + targetDepth: prevState.items.itemMetaLookup[itemId].depth!, cursorY, cursorX, contentElement, @@ -226,7 +231,17 @@ export const useTreeViewItemsReordering: TreeViewPlugin ({ + itemsReordering: { + enabled: params.itemsReordering, + isItemReorderable: params.isItemReorderable, + }, + }), + [params.itemsReordering, params.isItemReorderable], ); return { @@ -237,12 +252,7 @@ export const useTreeViewItemsReordering: TreeViewPlugin boolean) | undefined; }; } diff --git a/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.utils.ts b/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.utils.ts index bfd7f19a3ba8..171d961838bc 100644 --- a/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.utils.ts +++ b/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.utils.ts @@ -1,25 +1,26 @@ import { - TreeViewInstance, - UseTreeViewItemsSignature, + TreeViewUsedStore, UseTreeViewItemsState, buildSiblingIndexes, TREE_VIEW_ROOT_PARENT_ID, + selectorItemMeta, } from '@mui/x-tree-view/internals'; import { TreeViewItemId, TreeViewItemsReorderingAction } from '@mui/x-tree-view/models'; import { TreeViewItemItemReorderingValidActions, TreeViewItemReorderPosition, + UseTreeViewItemsReorderingSignature, } from './useTreeViewItemsReordering.types'; /** * Checks if the item with the id itemIdB is an ancestor of the item with the id itemIdA. */ export const isAncestor = ( - instance: TreeViewInstance<[UseTreeViewItemsSignature]>, + store: TreeViewUsedStore, itemIdA: string, itemIdB: string, ): boolean => { - const itemMetaA = instance.getItemMeta(itemIdA); + const itemMetaA = selectorItemMeta(store.value, itemIdA)!; if (itemMetaA.parentId === itemIdB) { return true; } @@ -28,7 +29,7 @@ export const isAncestor = ( return false; } - return isAncestor(instance, itemMetaA.parentId, itemIdB); + return isAncestor(store, itemMetaA.parentId, itemIdB); }; /** @@ -121,7 +122,7 @@ export const chooseActionToApply = ({ return action; }; -export const moveItemInTree = ({ +export const moveItemInTree = ({ itemToMoveId, oldPosition, newPosition, @@ -132,13 +133,13 @@ export const moveItemInTree = ({ newPosition: TreeViewItemReorderPosition; prevState: UseTreeViewItemsState['items']; }): UseTreeViewItemsState['items'] => { - const itemToMoveMeta = prevState.itemMetaMap[itemToMoveId]; + const itemToMoveMeta = prevState.itemMetaLookup[itemToMoveId]; const oldParentId = oldPosition.parentId ?? TREE_VIEW_ROOT_PARENT_ID; const newParentId = newPosition.parentId ?? TREE_VIEW_ROOT_PARENT_ID; // 1. Update the `itemOrderedChildrenIds`. - const itemOrderedChildrenIds = { ...prevState.itemOrderedChildrenIds }; + const itemOrderedChildrenIds = { ...prevState.itemOrderedChildrenIdsLookup }; if (oldParentId === newParentId) { const updatedChildren = [...itemOrderedChildrenIds[oldParentId]]; updatedChildren.splice(oldPosition.index, 1); @@ -155,27 +156,27 @@ export const moveItemInTree = ({ } // 2. Update the `itemChildrenIndexes` - const itemChildrenIndexes = { ...prevState.itemChildrenIndexes }; + const itemChildrenIndexes = { ...prevState.itemChildrenIndexesLookup }; itemChildrenIndexes[oldParentId] = buildSiblingIndexes(itemOrderedChildrenIds[oldParentId]); if (newParentId !== oldParentId) { itemChildrenIndexes[newParentId] = buildSiblingIndexes(itemOrderedChildrenIds[newParentId]); } - // 3. Update the `itemMetaMap` - const itemMetaMap = { ...prevState.itemMetaMap }; + // 3. Update the `itemMetaLookup` + const itemMetaLookup = { ...prevState.itemMetaLookup }; // 3.1 Update the `expandable` property of the old and the new parent if (oldParentId !== TREE_VIEW_ROOT_PARENT_ID && oldParentId !== newParentId) { - itemMetaMap[oldParentId].expandable = itemOrderedChildrenIds[oldParentId].length > 0; + itemMetaLookup[oldParentId].expandable = itemOrderedChildrenIds[oldParentId].length > 0; } if (newParentId !== TREE_VIEW_ROOT_PARENT_ID && newParentId !== oldParentId) { - itemMetaMap[newParentId].expandable = itemOrderedChildrenIds[newParentId].length > 0; + itemMetaLookup[newParentId].expandable = itemOrderedChildrenIds[newParentId].length > 0; } // 3.2 Update the `parentId` and `depth` properties of the item to move // The depth is always defined because drag&drop is only usable with Rich Tree View components. - const itemToMoveDepth = newPosition.parentId == null ? 0 : itemMetaMap[newParentId].depth! + 1; - itemMetaMap[itemToMoveId] = { + const itemToMoveDepth = newPosition.parentId == null ? 0 : itemMetaLookup[newParentId].depth! + 1; + itemMetaLookup[itemToMoveId] = { ...itemToMoveMeta, parentId: newPosition.parentId, depth: itemToMoveDepth, @@ -183,7 +184,7 @@ export const moveItemInTree = ({ // 3.3 Update the depth of all the children of the item to move const updateItemDepth = (itemId: string, depth: number) => { - itemMetaMap[itemId] = { ...itemMetaMap[itemId], depth }; + itemMetaLookup[itemId] = { ...itemMetaLookup[itemId], depth }; itemOrderedChildrenIds[itemId]?.forEach((childId) => updateItemDepth(childId, depth + 1)); }; itemOrderedChildrenIds[itemToMoveId]?.forEach((childId) => @@ -192,8 +193,8 @@ export const moveItemInTree = ({ return { ...prevState, - itemOrderedChildrenIds, - itemChildrenIndexes, - itemMetaMap, + itemOrderedChildrenIdsLookup: itemOrderedChildrenIds, + itemChildrenIndexesLookup: itemChildrenIndexes, + itemMetaLookup, }; }; diff --git a/packages/x-tree-view/package.json b/packages/x-tree-view/package.json index ddb626116fb5..8928631300a1 100644 --- a/packages/x-tree-view/package.json +++ b/packages/x-tree-view/package.json @@ -49,7 +49,9 @@ "@types/react-transition-group": "^4.4.11", "clsx": "^2.1.1", "prop-types": "^15.8.1", - "react-transition-group": "^4.4.5" + "react-transition-group": "^4.4.5", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.0.0" }, "peerDependencies": { "@emotion/react": "^11.9.0", @@ -72,6 +74,7 @@ "@mui/material": "^5.16.7", "@mui/system": "^5.16.7", "@types/prop-types": "^15.7.13", + "@types/use-sync-external-store": "^0.0.6", "rimraf": "^6.0.1" }, "engines": { diff --git a/packages/x-tree-view/src/RichTreeView/RichTreeView.tsx b/packages/x-tree-view/src/RichTreeView/RichTreeView.tsx index 158ac9ab8ea4..0a31937d2971 100644 --- a/packages/x-tree-view/src/RichTreeView/RichTreeView.tsx +++ b/packages/x-tree-view/src/RichTreeView/RichTreeView.tsx @@ -68,10 +68,7 @@ const RichTreeView = React.forwardRef(function RichTreeView< } } - const { getRootProps, contextValue, instance } = useTreeView< - RichTreeViewPluginSignatures, - typeof props - >({ + const { getRootProps, contextValue } = useTreeView({ plugins: RICH_TREE_VIEW_PLUGINS, rootRef: ref, props, @@ -92,11 +89,7 @@ const RichTreeView = React.forwardRef(function RichTreeView< return ( - + ); diff --git a/packages/x-tree-view/src/RichTreeView/RichTreeView.types.ts b/packages/x-tree-view/src/RichTreeView/RichTreeView.types.ts index c774183d9a6d..502a88050ec6 100644 --- a/packages/x-tree-view/src/RichTreeView/RichTreeView.types.ts +++ b/packages/x-tree-view/src/RichTreeView/RichTreeView.types.ts @@ -2,7 +2,6 @@ import * as React from 'react'; import { Theme } from '@mui/material/styles'; import { SxProps } from '@mui/system'; import { SlotComponentProps } from '@mui/utils'; -import { SlotComponentPropsFromProps } from '@mui/x-internals/types'; import { RichTreeViewClasses } from './richTreeViewClasses'; import { RichTreeViewPluginParameters, @@ -10,32 +9,24 @@ import { RichTreeViewPluginSlots, RichTreeViewPluginSignatures, } from './RichTreeView.plugins'; -import { TreeItemProps } from '../TreeItem'; -import { TreeViewItemId } from '../models'; import { TreeViewExperimentalFeatures, TreeViewPublicAPI } from '../internals/models'; +import { + RichTreeViewItemsSlotProps, + RichTreeViewItemsSlots, +} from '../internals/components/RichTreeViewItems'; -interface RichTreeViewItemSlotOwnerState { - itemId: TreeViewItemId; - label: string; -} - -export interface RichTreeViewSlots extends RichTreeViewPluginSlots { +export interface RichTreeViewSlots extends RichTreeViewPluginSlots, RichTreeViewItemsSlots { /** * Element rendered at the root. * @default RichTreeViewRoot */ root?: React.ElementType; - /** - * Custom component for the item. - * @default TreeItem. - */ - item?: React.JSXElementConstructor; } export interface RichTreeViewSlotProps - extends RichTreeViewPluginSlotProps { + extends RichTreeViewPluginSlotProps, + RichTreeViewItemsSlotProps { root?: SlotComponentProps<'ul', {}, RichTreeViewProps>; - item?: SlotComponentPropsFromProps; } export type RichTreeViewApiRef = React.MutableRefObject< diff --git a/packages/x-tree-view/src/TreeItem/TreeItem.tsx b/packages/x-tree-view/src/TreeItem/TreeItem.tsx index 3f10accdbe42..98ac33c3f024 100644 --- a/packages/x-tree-view/src/TreeItem/TreeItem.tsx +++ b/packages/x-tree-view/src/TreeItem/TreeItem.tsx @@ -220,6 +220,7 @@ export const TreeItem = React.forwardRef(function TreeItem( const { id, itemId, label, disabled, children, slots = {}, slotProps = {}, ...other } = props; const { + getContextProviderProps, getRootProps, getContentProps, getIconContainerProps, @@ -329,7 +330,7 @@ export const TreeItem = React.forwardRef(function TreeItem( }); return ( - + @@ -353,7 +354,7 @@ TreeItem.propTypes = { /** * The content of the component. */ - children: PropTypes.node, + children: PropTypes /* @typescript-to-proptypes-ignore */.any, /** * Override or extend the styles applied to the component. */ diff --git a/packages/x-tree-view/src/TreeItemProvider/TreeItemProvider.tsx b/packages/x-tree-view/src/TreeItemProvider/TreeItemProvider.tsx index e50cc7f9f81b..6994ee378e95 100644 --- a/packages/x-tree-view/src/TreeItemProvider/TreeItemProvider.tsx +++ b/packages/x-tree-view/src/TreeItemProvider/TreeItemProvider.tsx @@ -1,15 +1,17 @@ import PropTypes from 'prop-types'; import { TreeItemProviderProps } from './TreeItemProvider.types'; import { useTreeViewContext } from '../internals/TreeViewProvider'; +import { generateTreeItemIdAttribute } from '../internals/corePlugins/useTreeViewId/useTreeViewId.utils'; /** * @ignore - internal component. */ function TreeItemProvider(props: TreeItemProviderProps) { - const { children, itemId } = props; - const { wrapItem, instance } = useTreeViewContext<[]>(); + const { children, itemId, id } = props; + const { wrapItem, instance, treeId } = useTreeViewContext<[]>(); + const idAttribute = generateTreeItemIdAttribute({ itemId, treeId, id }); - return wrapItem({ children, itemId, instance }); + return wrapItem({ children, itemId, instance, idAttribute }); } TreeItemProvider.propTypes = { @@ -18,6 +20,7 @@ TreeItemProvider.propTypes = { // | To update them edit the TypeScript types and run "pnpm proptypes" | // ---------------------------------------------------------------------- children: PropTypes.node, + id: PropTypes.string, itemId: PropTypes.string.isRequired, } as any; diff --git a/packages/x-tree-view/src/TreeItemProvider/TreeItemProvider.types.ts b/packages/x-tree-view/src/TreeItemProvider/TreeItemProvider.types.ts index 5214935419c7..3357e56d27b8 100644 --- a/packages/x-tree-view/src/TreeItemProvider/TreeItemProvider.types.ts +++ b/packages/x-tree-view/src/TreeItemProvider/TreeItemProvider.types.ts @@ -4,4 +4,5 @@ import { TreeViewItemId } from '../models'; export interface TreeItemProviderProps { children: React.ReactNode; itemId: TreeViewItemId; + id: string | undefined; } diff --git a/packages/x-tree-view/src/hooks/index.ts b/packages/x-tree-view/src/hooks/index.ts index 5b9960c618bf..0149a071aef5 100644 --- a/packages/x-tree-view/src/hooks/index.ts +++ b/packages/x-tree-view/src/hooks/index.ts @@ -1,2 +1,3 @@ export { useTreeViewApiRef } from './useTreeViewApiRef'; export { useTreeItemUtils } from './useTreeItemUtils'; +export { useTreeItemModel } from './useTreeItemModel'; diff --git a/packages/x-tree-view/src/hooks/useTreeItemModel.ts b/packages/x-tree-view/src/hooks/useTreeItemModel.ts new file mode 100644 index 000000000000..696955b8d7c9 --- /dev/null +++ b/packages/x-tree-view/src/hooks/useTreeItemModel.ts @@ -0,0 +1,12 @@ +'use client'; +import { useTreeViewContext } from '../internals/TreeViewProvider'; +import { useSelector } from '../internals/hooks/useSelector'; +import { selectorItemModel } from '../internals/plugins/useTreeViewItems/useTreeViewItems.selectors'; +import { TreeViewBaseItem, TreeViewDefaultItemModelProperties, TreeViewItemId } from '../models'; + +export const useTreeItemModel = ( + itemId: TreeViewItemId, +) => { + const { store } = useTreeViewContext(); + return useSelector(store, selectorItemModel, itemId) as unknown as TreeViewBaseItem | null; +}; diff --git a/packages/x-tree-view/src/hooks/useTreeItemUtils/useTreeItemUtils.tsx b/packages/x-tree-view/src/hooks/useTreeItemUtils/useTreeItemUtils.tsx index ef03b9510dc3..623c80a30c05 100644 --- a/packages/x-tree-view/src/hooks/useTreeItemUtils/useTreeItemUtils.tsx +++ b/packages/x-tree-view/src/hooks/useTreeItemUtils/useTreeItemUtils.tsx @@ -13,6 +13,15 @@ import { import type { UseTreeItemStatus } from '../../useTreeItem'; import { hasPlugin } from '../../internals/utils/plugins'; import { TreeViewPublicAPI } from '../../internals/models'; +import { useSelector } from '../../internals/hooks/useSelector'; +import { selectorIsItemExpanded } from '../../internals/plugins/useTreeViewExpansion/useTreeViewExpansion.selectors'; +import { selectorIsItemFocused } from '../../internals/plugins/useTreeViewFocus/useTreeViewFocus.selectors'; +import { selectorIsItemDisabled } from '../../internals/plugins/useTreeViewItems/useTreeViewItems.selectors'; +import { selectorIsItemSelected } from '../../internals/plugins/useTreeViewSelection/useTreeViewSelection.selectors'; +import { + selectorIsItemBeingEdited, + selectorIsItemEditable, +} from '../../internals/plugins/useTreeViewLabel/useTreeViewLabel.selectors'; export interface UseTreeItemInteractions { handleExpansion: (event: React.MouseEvent) => void; @@ -51,7 +60,7 @@ interface UseTreeItemUtilsReturnValue< publicAPI: TreeViewPublicAPI; } -const isItemExpandable = (reactChildren: React.ReactNode) => { +export const isItemExpandable = (reactChildren: React.ReactNode) => { if (Array.isArray(reactChildren)) { return reactChildren.length > 0 && reactChildren.some(isItemExpandable); } @@ -66,22 +75,37 @@ export const useTreeItemUtils = < children, }: { itemId: string; - children: React.ReactNode; + children?: React.ReactNode; }): UseTreeItemUtilsReturnValue => { const { instance, + label, + store, selection: { multiSelect }, publicAPI, } = useTreeViewContext(); + const isExpanded = useSelector(store, selectorIsItemExpanded, itemId); + const isFocused = useSelector(store, selectorIsItemFocused, itemId); + const isSelected = useSelector(store, selectorIsItemSelected, itemId); + const isDisabled = useSelector(store, selectorIsItemDisabled, itemId); + const isEditing = useSelector(store, (state) => + label == null ? false : selectorIsItemBeingEdited(state, itemId), + ); + const isEditable = useSelector(store, (state) => + label == null + ? false + : selectorIsItemEditable(state, { itemId, isItemEditable: label.isItemEditable }), + ); + const status: UseTreeItemStatus = { expandable: isItemExpandable(children), - expanded: instance.isItemExpanded(itemId), - focused: instance.isItemFocused(itemId), - selected: instance.isItemSelected(itemId), - disabled: instance.isItemDisabled(itemId), - editing: instance?.isItemBeingEdited ? instance?.isItemBeingEdited(itemId) : false, - editable: instance.isItemEditable ? instance.isItemEditable(itemId) : false, + expanded: isExpanded, + focused: isFocused, + selected: isSelected, + disabled: isDisabled, + editing: isEditing, + editable: isEditable, }; const handleExpansion = (event: React.MouseEvent) => { @@ -96,7 +120,7 @@ export const useTreeItemUtils = < const multiple = multiSelect && (event.shiftKey || event.ctrlKey || event.metaKey); // If already expanded and trying to toggle selection don't close - if (status.expandable && !(multiple && instance.isItemExpanded(itemId))) { + if (status.expandable && !(multiple && selectorIsItemExpanded(store.value, itemId))) { instance.toggleItemExpansion(event, itemId); } }; @@ -141,8 +165,8 @@ export const useTreeItemUtils = < if (!hasPlugin(instance, useTreeViewLabel)) { return; } - if (instance.isItemEditable(itemId)) { - if (instance.isItemBeingEdited(itemId)) { + if (isEditable) { + if (isEditing) { instance.setEditedItemId(null); } else { instance.setEditedItemId(itemId); @@ -152,7 +176,7 @@ export const useTreeItemUtils = < const handleSaveItemLabel = ( event: React.SyntheticEvent & TreeViewCancellableEvent, - label: string, + newLabel: string, ) => { if (!hasPlugin(instance, useTreeViewLabel)) { return; @@ -161,9 +185,8 @@ export const useTreeItemUtils = < // As a side effect of `instance.focusItem` called here and in `handleCancelItemLabelEditing` the `labelInput` is blurred // The `onBlur` event is triggered, which calls `handleSaveItemLabel` again. // To avoid creating an unwanted behavior we need to check if the item is being edited before calling `updateItemLabel` - // using `instance.isItemBeingEditedRef` instead of `instance.isItemBeingEdited` since the state is not yet updated in this point - if (instance.isItemBeingEditedRef(itemId)) { - instance.updateItemLabel(itemId, label); + if (selectorIsItemBeingEdited(store.value, itemId)) { + instance.updateItemLabel(itemId, newLabel); toggleItemEditing(); instance.focusItem(event, itemId); } @@ -174,7 +197,7 @@ export const useTreeItemUtils = < return; } - if (instance.isItemBeingEditedRef(itemId)) { + if (selectorIsItemBeingEdited(store.value, itemId)) { toggleItemEditing(); instance.focusItem(event, itemId); } diff --git a/packages/x-tree-view/src/internals/TreeViewItemDepthContext/TreeViewItemDepthContext.ts b/packages/x-tree-view/src/internals/TreeViewItemDepthContext/TreeViewItemDepthContext.ts index e06f1da34038..ba31a7135222 100644 --- a/packages/x-tree-view/src/internals/TreeViewItemDepthContext/TreeViewItemDepthContext.ts +++ b/packages/x-tree-view/src/internals/TreeViewItemDepthContext/TreeViewItemDepthContext.ts @@ -1,8 +1,10 @@ import * as React from 'react'; import { TreeViewItemId } from '../../models'; +import { TreeViewState } from '../models'; +import type { UseTreeViewItemsSignature } from '../plugins/useTreeViewItems'; export const TreeViewItemDepthContext = React.createContext< - number | ((itemId: TreeViewItemId) => number) + ((state: TreeViewState<[UseTreeViewItemsSignature]>, itemId: TreeViewItemId) => number) | number >(() => -1); if (process.env.NODE_ENV !== 'production') { diff --git a/packages/x-tree-view/src/internals/TreeViewProvider/TreeViewChildrenItemProvider.tsx b/packages/x-tree-view/src/internals/TreeViewProvider/TreeViewChildrenItemProvider.tsx index 11d568f01469..956d468e00e6 100644 --- a/packages/x-tree-view/src/internals/TreeViewProvider/TreeViewChildrenItemProvider.tsx +++ b/packages/x-tree-view/src/internals/TreeViewProvider/TreeViewChildrenItemProvider.tsx @@ -4,7 +4,7 @@ import { useTreeViewContext } from './useTreeViewContext'; import { escapeOperandAttributeSelector } from '../utils/utils'; import type { UseTreeViewJSXItemsSignature } from '../plugins/useTreeViewJSXItems'; import type { UseTreeViewItemsSignature } from '../plugins/useTreeViewItems'; -import { generateTreeItemIdAttribute } from '../corePlugins/useTreeViewId/useTreeViewId.utils'; +import { selectorItemOrderedChildrenIds } from '../plugins/useTreeViewItems/useTreeViewItems.selectors'; export const TreeViewChildrenItemContext = React.createContext(null); @@ -14,14 +14,15 @@ if (process.env.NODE_ENV !== 'production') { } interface TreeViewChildrenItemProviderProps { - itemId?: string; + itemId: string | null; + idAttribute: string | null; children: React.ReactNode; } export function TreeViewChildrenItemProvider(props: TreeViewChildrenItemProviderProps) { - const { children, itemId = null } = props; + const { children, itemId = null, idAttribute } = props; - const { instance, treeId, rootRef } = + const { instance, store, rootRef } = useTreeViewContext<[UseTreeViewJSXItemsSignature, UseTreeViewItemsSignature]>(); const childrenIdAttrToIdRef = React.useRef>(new Map()); @@ -30,23 +31,8 @@ export function TreeViewChildrenItemProvider(props: TreeViewChildrenItemProvider return; } - let idAttr: string | null = null; - if (itemId == null) { - idAttr = rootRef.current.id; - } else { - // Undefined during 1st render - const itemMeta = instance.getItemMeta(itemId); - if (itemMeta !== undefined) { - idAttr = generateTreeItemIdAttribute({ itemId, treeId, id: itemMeta.idAttribute }); - } - } - - if (idAttr == null) { - return; - } - - const previousChildrenIds = instance.getItemOrderedChildrenIds(itemId ?? null) ?? []; - const escapedIdAttr = escapeOperandAttributeSelector(idAttr); + const previousChildrenIds = selectorItemOrderedChildrenIds(store.value, itemId ?? null) ?? []; + const escapedIdAttr = escapeOperandAttributeSelector(idAttribute ?? rootRef.current.id); const childrenElements = rootRef.current.querySelectorAll( `${itemId == null ? '' : `*[id="${escapedIdAttr}"] `}[role="treeitem"]:not(*[id="${escapedIdAttr}"] [role="treeitem"] [role="treeitem"])`, ); diff --git a/packages/x-tree-view/src/internals/TreeViewProvider/TreeViewProvider.tsx b/packages/x-tree-view/src/internals/TreeViewProvider/TreeViewProvider.tsx index 99b4813b2057..d85512736950 100644 --- a/packages/x-tree-view/src/internals/TreeViewProvider/TreeViewProvider.tsx +++ b/packages/x-tree-view/src/internals/TreeViewProvider/TreeViewProvider.tsx @@ -15,7 +15,7 @@ export function TreeViewProvider - {value.wrapRoot({ children, instance: value.instance })} + {value.wrapRoot({ children })} ); } diff --git a/packages/x-tree-view/src/internals/TreeViewProvider/TreeViewProvider.types.ts b/packages/x-tree-view/src/internals/TreeViewProvider/TreeViewProvider.types.ts index be08aeb5afe4..14a8d0df6b9b 100644 --- a/packages/x-tree-view/src/internals/TreeViewProvider/TreeViewProvider.types.ts +++ b/packages/x-tree-view/src/internals/TreeViewProvider/TreeViewProvider.types.ts @@ -8,8 +8,9 @@ import { TreeViewItemPluginResponse, TreeViewPublicAPI, } from '../models'; +import { TreeViewStore } from '../utils/TreeViewStore'; import { TreeViewCorePluginSignatures } from '../corePlugins'; -import type { TreeItemProps } from '../../TreeItem'; +import type { TreeItemProps } from '../../TreeItem/TreeItem.types'; export type TreeViewItemPluginsRunner = ( props: TreeItemProps, @@ -22,9 +23,10 @@ export type TreeViewContextValue< Partial> & { instance: TreeViewInstance; publicAPI: TreeViewPublicAPI; + store: TreeViewStore; rootRef: React.RefObject; wrapItem: TreeItemWrapper; - wrapRoot: TreeRootWrapper; + wrapRoot: TreeRootWrapper; runItemPlugins: TreeViewItemPluginsRunner; }; diff --git a/packages/x-tree-view/src/internals/components/RichTreeViewItems.tsx b/packages/x-tree-view/src/internals/components/RichTreeViewItems.tsx index af1c4abeb5f2..3e39f2fdb86f 100644 --- a/packages/x-tree-view/src/internals/components/RichTreeViewItems.tsx +++ b/packages/x-tree-view/src/internals/components/RichTreeViewItems.tsx @@ -1,9 +1,74 @@ import * as React from 'react'; import useSlotProps from '@mui/utils/useSlotProps'; import { SlotComponentProps } from '@mui/utils'; +import { fastObjectShallowCompare } from '@mui/x-internals/fastObjectShallowCompare'; import { TreeItem, TreeItemProps } from '../../TreeItem'; import { TreeViewItemId } from '../../models'; -import { TreeViewItemToRenderProps } from '../plugins/useTreeViewItems'; +import { useSelector } from '../hooks/useSelector'; +import { + selectorItemMeta, + selectorItemOrderedChildrenIds, +} from '../plugins/useTreeViewItems/useTreeViewItems.selectors'; +import { useTreeViewContext } from '../TreeViewProvider'; + +const RichTreeViewItemsContext = React.createContext< + ((itemId: TreeViewItemId) => React.ReactNode) | null +>(null); + +if (process.env.NODE_ENV !== 'production') { + RichTreeViewItemsContext.displayName = 'RichTreeViewItemsProvider'; +} + +const WrappedTreeItem = React.memo(function WrappedTreeItem({ + itemSlot, + itemSlotProps, + itemId, +}: WrappedTreeItemProps) { + const renderItemForRichTreeView = React.useContext(RichTreeViewItemsContext)!; + const { store } = useTreeViewContext(); + + const itemMeta = useSelector(store, selectorItemMeta, itemId); + const children = useSelector(store, selectorItemOrderedChildrenIds, itemId); + const Item = (itemSlot ?? TreeItem) as React.JSXElementConstructor; + + const { ownerState, ...itemProps } = useSlotProps({ + elementType: Item, + externalSlotProps: itemSlotProps, + additionalProps: { label: itemMeta?.label!, id: itemMeta?.idAttribute!, itemId }, + ownerState: { itemId, label: itemMeta?.label! }, + }); + + return {children?.map(renderItemForRichTreeView)}; +}, fastObjectShallowCompare); + +export function RichTreeViewItems(props: RichTreeViewItemsProps) { + const { slots, slotProps } = props; + const { store } = useTreeViewContext(); + + const itemSlot = slots?.item as React.JSXElementConstructor | undefined; + const itemSlotProps = slotProps?.item; + const items = useSelector(store, selectorItemOrderedChildrenIds, null); + + const renderItem = React.useCallback( + (itemId: TreeViewItemId) => { + return ( + + ); + }, + [itemSlot, itemSlotProps], + ); + + return ( + + {items.map(renderItem)} + + ); +} interface RichTreeViewItemsOwnerState { itemId: TreeViewItemId; @@ -12,7 +77,7 @@ interface RichTreeViewItemsOwnerState { export interface RichTreeViewItemsSlots { /** - * Custom component for the item. + * Custom component to render a Tree Item. * @default TreeItem. */ item?: React.JSXElementConstructor; @@ -23,7 +88,6 @@ export interface RichTreeViewItemsSlotProps { } export interface RichTreeViewItemsProps { - itemsToRender: TreeViewItemToRenderProps[]; /** * Overridable component slots. * @default {} @@ -36,54 +100,7 @@ export interface RichTreeViewItemsProps { slotProps?: RichTreeViewItemsSlotProps; } -function WrappedTreeItem({ - slots, - slotProps, - label, - id, - itemId, - itemsToRender, -}: Pick & - Pick & { - label: string; - isContentHidden?: boolean; - itemsToRender: TreeViewItemToRenderProps[] | undefined; - }) { - const Item = slots?.item ?? TreeItem; - const { ownerState, ...itemProps } = useSlotProps({ - elementType: Item, - externalSlotProps: slotProps?.item, - additionalProps: { itemId, id, label }, - ownerState: { itemId, label }, - }); - - const children = React.useMemo( - () => - itemsToRender ? ( - - ) : null, - [itemsToRender, slots, slotProps], - ); - - return {children}; -} - -export function RichTreeViewItems(props: RichTreeViewItemsProps) { - const { itemsToRender, slots, slotProps } = props; - - return ( - - {itemsToRender.map((item) => ( - - ))} - - ); +interface WrappedTreeItemProps extends Pick { + itemSlot: React.JSXElementConstructor | undefined; + itemSlotProps: SlotComponentProps | undefined; } diff --git a/packages/x-tree-view/src/internals/corePlugins/useTreeViewId/useTreeViewId.selectors.ts b/packages/x-tree-view/src/internals/corePlugins/useTreeViewId/useTreeViewId.selectors.ts new file mode 100644 index 000000000000..8b96ad677e08 --- /dev/null +++ b/packages/x-tree-view/src/internals/corePlugins/useTreeViewId/useTreeViewId.selectors.ts @@ -0,0 +1,14 @@ +import { createSelector, TreeViewRootSelector } from '../../utils/selectors'; +import { UseTreeViewIdSignature } from './useTreeViewId.types'; + +const selectorTreeViewIdState: TreeViewRootSelector = (state) => state.id; + +/** + * Get the id attribute of the tree view. + * @param {TreeViewState<[UseTreeViewIdSignature]>} state The state of the tree view. + * @returns {string} The id attribute of the tree view. + */ +export const selectorTreeViewId = createSelector( + selectorTreeViewIdState, + (idState) => idState.treeId, +); diff --git a/packages/x-tree-view/src/internals/corePlugins/useTreeViewId/useTreeViewId.ts b/packages/x-tree-view/src/internals/corePlugins/useTreeViewId/useTreeViewId.ts index bb059671285c..c6c0489eb589 100644 --- a/packages/x-tree-view/src/internals/corePlugins/useTreeViewId/useTreeViewId.ts +++ b/packages/x-tree-view/src/internals/corePlugins/useTreeViewId/useTreeViewId.ts @@ -1,16 +1,14 @@ import * as React from 'react'; import { TreeViewPlugin } from '../../models'; import { UseTreeViewIdSignature } from './useTreeViewId.types'; +import { useSelector } from '../../hooks/useSelector'; +import { selectorTreeViewId } from './useTreeViewId.selectors'; import { createTreeViewDefaultId } from './useTreeViewId.utils'; -export const useTreeViewId: TreeViewPlugin = ({ - params, - state, - setState, -}) => { +export const useTreeViewId: TreeViewPlugin = ({ params, store }) => { React.useEffect(() => { - setState((prevState) => { - if (prevState.id.treeId === params.id && prevState.id.treeId !== undefined) { + store.update((prevState) => { + if (params.id === prevState.id.providedTreeId && prevState.id.treeId !== undefined) { return prevState; } @@ -19,17 +17,17 @@ export const useTreeViewId: TreeViewPlugin = ({ id: { ...prevState.id, treeId: params.id ?? createTreeViewDefaultId() }, }; }); - }, [setState, params.id]); + }, [store, params.id]); - const treeId = params.id ?? state.id.treeId; + const treeId = useSelector(store, selectorTreeViewId); + + const pluginContextValue = React.useMemo(() => ({ treeId }), [treeId]); return { getRootProps: () => ({ id: treeId, }), - contextValue: { - treeId, - }, + contextValue: pluginContextValue, }; }; @@ -37,4 +35,4 @@ useTreeViewId.params = { id: true, }; -useTreeViewId.getInitialState = ({ id }) => ({ id: { treeId: id ?? undefined } }); +useTreeViewId.getInitialState = ({ id }) => ({ id: { treeId: undefined, providedTreeId: id } }); diff --git a/packages/x-tree-view/src/internals/corePlugins/useTreeViewId/useTreeViewId.types.ts b/packages/x-tree-view/src/internals/corePlugins/useTreeViewId/useTreeViewId.types.ts index 7baa508f7097..6704907447de 100644 --- a/packages/x-tree-view/src/internals/corePlugins/useTreeViewId/useTreeViewId.types.ts +++ b/packages/x-tree-view/src/internals/corePlugins/useTreeViewId/useTreeViewId.types.ts @@ -13,6 +13,7 @@ export type UseTreeViewIdDefaultizedParameters = UseTreeViewIdParameters; export interface UseTreeViewIdState { id: { treeId: string | undefined; + providedTreeId: string | undefined; }; } diff --git a/packages/x-tree-view/src/internals/hooks/useSelector.ts b/packages/x-tree-view/src/internals/hooks/useSelector.ts new file mode 100644 index 000000000000..014efd73b5b1 --- /dev/null +++ b/packages/x-tree-view/src/internals/hooks/useSelector.ts @@ -0,0 +1,27 @@ +import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector'; +import { TreeViewAnyPluginSignature, TreeViewState } from '../models'; +import { TreeViewStore } from '../utils/TreeViewStore'; +import { TreeViewSelector } from '../utils/selectors'; + +const defaultCompare = Object.is; + +export const useSelector = < + TSignatures extends readonly TreeViewAnyPluginSignature[], + TArgs, + TValue, +>( + store: TreeViewStore, + selector: TreeViewSelector, TArgs, TValue>, + args: TArgs = undefined as TArgs, + equals: (a: TValue, b: TValue) => boolean = defaultCompare, +): TValue => { + const selectorWithArgs = (state: TreeViewState) => selector(state, args); + + return useSyncExternalStoreWithSelector( + store.subscribe, + store.getSnapshot, + store.getSnapshot, + selectorWithArgs, + equals, + ); +}; diff --git a/packages/x-tree-view/src/internals/index.ts b/packages/x-tree-view/src/internals/index.ts index 68127e7c85e8..5020622452e0 100644 --- a/packages/x-tree-view/src/internals/index.ts +++ b/packages/x-tree-view/src/internals/index.ts @@ -2,8 +2,13 @@ export { useTreeView } from './useTreeView'; export { TreeViewProvider, useTreeViewContext } from './TreeViewProvider'; export { RichTreeViewItems } from './components/RichTreeViewItems'; +export type { + RichTreeViewItemsSlots, + RichTreeViewItemsSlotProps, +} from './components/RichTreeViewItems'; export { unstable_resetCleanupTracking } from './hooks/useInstanceEventHandler'; +export { useSelector } from './hooks/useSelector'; export type { TreeViewPlugin, @@ -11,10 +16,12 @@ export type { ConvertPluginsIntoSignatures, MergeSignaturesProperty, TreeViewPublicAPI, + TreeViewState, TreeViewExperimentalFeatures, TreeViewItemMeta, TreeViewInstance, TreeViewItemPlugin, + TreeViewUsedStore, } from './models'; // Core plugins @@ -48,6 +55,12 @@ export { buildSiblingIndexes, TREE_VIEW_ROOT_PARENT_ID, } from './plugins/useTreeViewItems'; +export { + selectorItemMetaLookup, + selectorItemMeta, + selectorItemIndex, + selectorItemOrderedChildrenIds, +} from './plugins/useTreeViewItems/useTreeViewItems.selectors'; export type { UseTreeViewItemsSignature, UseTreeViewItemsParameters, @@ -64,4 +77,6 @@ export type { UseTreeViewJSXItemsParameters, } from './plugins/useTreeViewJSXItems'; +export { createSelector } from './utils/selectors'; export { isTargetInDescendants } from './utils/tree'; +export { TreeViewStore } from './utils/TreeViewStore'; diff --git a/packages/x-tree-view/src/internals/models/itemPlugin.ts b/packages/x-tree-view/src/internals/models/itemPlugin.ts index 64a54e5cc549..c41e09a8c473 100644 --- a/packages/x-tree-view/src/internals/models/itemPlugin.ts +++ b/packages/x-tree-view/src/internals/models/itemPlugin.ts @@ -1,6 +1,5 @@ import * as React from 'react'; import { EventHandlers } from '@mui/utils'; -import type { TreeItemProps } from '../../TreeItem'; import type { UseTreeItemContentSlotOwnProps, UseTreeItemDragAndDropOverlaySlotOwnProps, @@ -10,6 +9,7 @@ import type { UseTreeItemStatus, } from '../../useTreeItem'; import type { UseTreeItemInteractions } from '../../hooks/useTreeItemUtils/useTreeItemUtils'; +import type { TreeItemProps } from '../../TreeItem/TreeItem.types'; export interface TreeViewItemPluginSlotPropsEnhancerParams { rootRefObject: React.MutableRefObject; @@ -50,11 +50,11 @@ export interface TreeViewItemPluginResponse { propsEnhancers?: TreeViewItemPluginSlotPropsEnhancers; } -export interface TreeViewItemPluginOptions +export interface TreeViewItemPluginOptions extends Omit { - props: TProps; + props: TreeItemProps; } export type TreeViewItemPlugin = ( - options: TreeViewItemPluginOptions, + options: TreeViewItemPluginOptions, ) => void | TreeViewItemPluginResponse; diff --git a/packages/x-tree-view/src/internals/models/plugin.ts b/packages/x-tree-view/src/internals/models/plugin.ts index df0edd46a7fb..6db17e872fa0 100644 --- a/packages/x-tree-view/src/internals/models/plugin.ts +++ b/packages/x-tree-view/src/internals/models/plugin.ts @@ -6,16 +6,16 @@ import { TreeViewEventLookupElement } from './events'; import type { TreeViewCorePluginSignatures } from '../corePlugins'; import { TreeViewItemPlugin } from './itemPlugin'; import { TreeViewItemId } from '../../models'; +import { TreeViewStore } from '../utils/TreeViewStore'; export interface TreeViewPluginOptions { instance: TreeViewUsedInstance; params: TreeViewUsedDefaultizedParams; - state: TreeViewUsedState; slots: TSignature['slots']; slotProps: TSignature['slotProps']; experimentalFeatures: TreeViewUsedExperimentalFeatures; models: TreeViewUsedModels; - setState: React.Dispatch>>; + store: TreeViewUsedStore; rootRef: React.RefObject; plugins: TreeViewPlugin[]; } @@ -44,6 +44,7 @@ export type TreeViewPluginSignature< publicAPI?: {}; events?: { [key in keyof T['events']]: TreeViewEventLookupElement }; state?: {}; + cache?: {}; contextValue?: {}; slots?: { [key in keyof T['slots']]: React.ElementType }; slotProps?: { [key in keyof T['slotProps']]: {} | (() => {}) }; @@ -59,6 +60,7 @@ export type TreeViewPluginSignature< publicAPI: T extends { publicAPI: {} } ? T['publicAPI'] : {}; events: T extends { events: {} } ? T['events'] : {}; state: T extends { state: {} } ? T['state'] : {}; + cache: T extends { cache: {} } ? T['cache'] : {}; contextValue: T extends { contextValue: {} } ? T['contextValue'] : {}; slots: T extends { slots: {} } ? T['slots'] : {}; slotProps: T extends { slotProps: {} } ? T['slotProps'] : {}; @@ -79,6 +81,7 @@ export type TreeViewPluginSignature< }; export type TreeViewAnyPluginSignature = { + cache: any; state: any; instance: any; params: any; @@ -120,11 +123,15 @@ export type TreeViewUsedInstance $$signature: TSignature; }; -type TreeViewUsedState = - PluginPropertyWithDependencies; +export type TreeViewUsedStore = TreeViewStore< + [TSignature, ...TSignature['dependencies']] +>; type TreeViewUsedExperimentalFeatures = - TreeViewExperimentalFeatures<[TSignature, ...TSignature['dependencies']]>; + TreeViewExperimentalFeatures< + [TSignature, ...TSignature['dependencies']], + TSignature['optionalDependencies'] + >; type RemoveSetValue>> = { [K in keyof Models]: Omit; @@ -141,12 +148,10 @@ export type TreeItemWrapper; + idAttribute: string; }) => React.ReactNode; -export type TreeRootWrapper = (params: { - children: React.ReactNode; - instance: TreeViewInstance; -}) => React.ReactNode; +export type TreeRootWrapper = (params: { children: React.ReactNode }) => React.ReactNode; export type TreeViewPlugin = { (options: TreeViewPluginOptions): TreeViewResponse; @@ -155,6 +160,7 @@ export type TreeViewPlugin = { experimentalFeatures: TreeViewUsedExperimentalFeatures; }) => TSignature['defaultizedParams']; getInitialState?: (params: TreeViewUsedDefaultizedParams) => TSignature['state']; + getInitialCache?: () => TSignature['cache']; models?: TreeViewModelsInitializer; params: Record; itemPlugin?: TreeViewItemPlugin; @@ -169,5 +175,5 @@ export type TreeViewPlugin = { * @param {{ children: React.ReactNode; }} params The params of the root. * @returns {React.ReactNode} The wrapped root. */ - wrapRoot?: TreeRootWrapper<[TSignature, ...TSignature['dependencies']]>; + wrapRoot?: TreeRootWrapper; }; diff --git a/packages/x-tree-view/src/internals/models/treeView.ts b/packages/x-tree-view/src/internals/models/treeView.ts index df139e952924..b2c13afe8da2 100644 --- a/packages/x-tree-view/src/internals/models/treeView.ts +++ b/packages/x-tree-view/src/internals/models/treeView.ts @@ -40,3 +40,13 @@ export type TreeViewExperimentalFeatures< TSignatures extends readonly TreeViewAnyPluginSignature[], TOptionalSignatures extends readonly TreeViewAnyPluginSignature[] = [], > = MergeSignaturesProperty<[...TSignatures, ...TOptionalSignatures], 'experimentalFeatures'>; + +export type TreeViewStateCacheKey = { id: number }; + +export type TreeViewState< + TSignatures extends readonly TreeViewAnyPluginSignature[], + TOptionalSignatures extends readonly TreeViewAnyPluginSignature[] = [], +> = MergeSignaturesProperty<[...TreeViewCorePluginSignatures, ...TSignatures], 'state'> & + Partial> & { + cacheKey: TreeViewStateCacheKey; + }; diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.selectors.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.selectors.ts new file mode 100644 index 000000000000..07523fbba84b --- /dev/null +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.selectors.ts @@ -0,0 +1,26 @@ +import { createSelector, TreeViewRootSelector } from '../../utils/selectors'; +import { selectorItemMeta } from '../useTreeViewItems/useTreeViewItems.selectors'; +import { UseTreeViewExpansionSignature } from './useTreeViewExpansion.types'; + +const selectorExpansion: TreeViewRootSelector = (state) => + state.expansion; + +/** + * Check if an item is expanded. + * @param {TreeViewState<[UseTreeViewExpansionSignature]>} state The state of the tree view. + * @returns {boolean} `true` if the item is expanded, `false` otherwise. + */ +export const selectorIsItemExpanded = createSelector( + [selectorExpansion, (_, itemId: string) => itemId], + (expansionState, itemId) => expansionState.expandedItemsMap.has(itemId), +); + +/** + * Check if an item is expandable. + * @param {TreeViewState<[UseTreeViewItemsSignature]>} state The state of the tree view. + * @returns {boolean} `true` if the item is expandable, `false` otherwise. + */ +export const selectorIsItemExpandable = createSelector( + [selectorItemMeta], + (itemMeta) => itemMeta?.expandable ?? false, +); diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.ts index d01813aeab71..985dca5daac1 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.ts @@ -1,48 +1,49 @@ import * as React from 'react'; import useEventCallback from '@mui/utils/useEventCallback'; +import useEnhancedEffect from '@mui/utils/useEnhancedEffect'; import { TreeViewPlugin } from '../../models'; import { UseTreeViewExpansionSignature } from './useTreeViewExpansion.types'; import { TreeViewItemId } from '../../../models'; +import { selectorIsItemExpandable, selectorIsItemExpanded } from './useTreeViewExpansion.selectors'; +import { createExpandedItemsMap } from './useTreeViewExpansion.utils'; +import { + selectorItemMeta, + selectorItemOrderedChildrenIds, +} from '../useTreeViewItems/useTreeViewItems.selectors'; export const useTreeViewExpansion: TreeViewPlugin = ({ instance, + store, params, models, + experimentalFeatures, }) => { - const expandedItemsMap = React.useMemo(() => { - const temp = new Map(); - models.expandedItems.value.forEach((id) => { - temp.set(id, true); - }); + const isTreeViewEditable = Boolean(params.isItemEditable) && !!experimentalFeatures.labelEditing; - return temp; - }, [models.expandedItems.value]); + useEnhancedEffect(() => { + store.update((prevState) => ({ + ...prevState, + expansion: { + expandedItemsMap: createExpandedItemsMap(models.expandedItems.value), + }, + })); + }, [store, models.expandedItems.value]); const setExpandedItems = (event: React.SyntheticEvent, value: TreeViewItemId[]) => { params.onExpandedItemsChange?.(event, value); models.expandedItems.setControlledValue(value); }; - const isItemExpanded = React.useCallback( - (itemId: string) => expandedItemsMap.has(itemId), - [expandedItemsMap], - ); - - const isItemExpandable = React.useCallback( - (itemId: string) => !!instance.getItemMeta(itemId)?.expandable, - [instance], - ); - const toggleItemExpansion = useEventCallback( (event: React.SyntheticEvent, itemId: TreeViewItemId) => { - const isExpandedBefore = instance.isItemExpanded(itemId); + const isExpandedBefore = selectorIsItemExpanded(store.value, itemId); instance.setItemExpansion(event, itemId, !isExpandedBefore); }, ); const setItemExpansion = useEventCallback( (event: React.SyntheticEvent, itemId: TreeViewItemId, isExpanded: boolean) => { - const isExpandedBefore = instance.isItemExpanded(itemId); + const isExpandedBefore = selectorIsItemExpanded(store.value, itemId); if (isExpandedBefore === isExpanded) { return; } @@ -63,11 +64,16 @@ export const useTreeViewExpansion: TreeViewPlugin ); const expandAllSiblings = (event: React.KeyboardEvent, itemId: TreeViewItemId) => { - const itemMeta = instance.getItemMeta(itemId); - const siblings = instance.getItemOrderedChildrenIds(itemMeta.parentId); + const itemMeta = selectorItemMeta(store.value, itemId); + if (itemMeta == null) { + return; + } + + const siblings = selectorItemOrderedChildrenIds(store.value, itemMeta.parentId); const diff = siblings.filter( - (child) => instance.isItemExpandable(child) && !instance.isItemExpanded(child), + (child) => + selectorIsItemExpandable(store.value, child) && !selectorIsItemExpanded(store.value, child), ); const newExpanded = models.expandedItems.value.concat(diff); @@ -88,29 +94,32 @@ export const useTreeViewExpansion: TreeViewPlugin return params.expansionTrigger; } - if (instance.isTreeViewEditable) { + if (isTreeViewEditable) { return 'iconContainer'; } return 'content'; - }, [params.expansionTrigger, instance.isTreeViewEditable]); + }, [params.expansionTrigger, isTreeViewEditable]); + + const pluginContextValue = React.useMemo( + () => ({ + expansion: { + expansionTrigger, + }, + }), + [expansionTrigger], + ); return { publicAPI: { setItemExpansion, }, instance: { - isItemExpanded, - isItemExpandable, setItemExpansion, toggleItemExpansion, expandAllSiblings, }, - contextValue: { - expansion: { - expansionTrigger, - }, - }, + contextValue: pluginContextValue, }; }; @@ -127,6 +136,14 @@ useTreeViewExpansion.getDefaultizedParams = ({ params }) => ({ defaultExpandedItems: params.defaultExpandedItems ?? DEFAULT_EXPANDED_ITEMS, }); +useTreeViewExpansion.getInitialState = (params) => ({ + expansion: { + expandedItemsMap: createExpandedItemsMap( + params.expandedItems === undefined ? params.defaultExpandedItems : params.expandedItems, + ), + }, +}); + useTreeViewExpansion.params = { expandedItems: true, defaultExpandedItems: true, diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.types.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.types.ts index dacc10a2ecda..70a75a3cad27 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.types.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.types.ts @@ -16,20 +16,6 @@ export interface UseTreeViewExpansionPublicAPI { } export interface UseTreeViewExpansionInstance extends UseTreeViewExpansionPublicAPI { - /** - * Check if an item is expanded. - * @param {TreeViewItemId} itemId The id of the item to check. - * @returns {boolean} `true` if the item is expanded, `false` otherwise. - */ - isItemExpanded: (itemId: TreeViewItemId) => boolean; - /** - * Check if an item is expandable. - * Currently, an item is expandable if it has children. - * In the future, the user should be able to flag an item as expandable even if it has no loaded children to support children lazy loading. - * @param {TreeViewItemId} itemId The id of the item to check. - * @returns {boolean} `true` if the item can be expanded, `false` otherwise. - */ - isItemExpandable: (itemId: TreeViewItemId) => boolean; /** * Toggle the current expansion of an item. * If it is expanded, it will be collapsed, and vice versa. @@ -86,6 +72,12 @@ export type UseTreeViewExpansionDefaultizedParameters = DefaultizedProps< 'defaultExpandedItems' >; +export interface UseTreeViewExpansionState { + expansion: { + expandedItemsMap: Map; + }; +} + interface UseTreeViewExpansionContextValue { expansion: Pick; } @@ -96,6 +88,7 @@ export type UseTreeViewExpansionSignature = TreeViewPluginSignature<{ instance: UseTreeViewExpansionInstance; publicAPI: UseTreeViewExpansionPublicAPI; modelNames: 'expandedItems'; + state: UseTreeViewExpansionState; contextValue: UseTreeViewExpansionContextValue; dependencies: [UseTreeViewItemsSignature]; optionalDependencies: [UseTreeViewLabelSignature]; diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.utils.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.utils.ts new file mode 100644 index 000000000000..17303c07aeaa --- /dev/null +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.utils.ts @@ -0,0 +1,10 @@ +import { TreeViewItemId } from '../../../models'; + +export const createExpandedItemsMap = (expandedItems: string[]) => { + const expandedItemsMap = new Map(); + expandedItems.forEach((id) => { + expandedItemsMap.set(id, true); + }); + + return expandedItemsMap; +}; diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.selectors.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.selectors.ts new file mode 100644 index 000000000000..4681cdc793d4 --- /dev/null +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.selectors.ts @@ -0,0 +1,49 @@ +import { UseTreeViewFocusSignature } from './useTreeViewFocus.types'; +import { createSelector, TreeViewRootSelector } from '../../utils/selectors'; + +const selectorTreeViewFocusState: TreeViewRootSelector = (state) => + state.focus; + +/** + * Get the item that should be sequentially focusable (usually with the Tab key). + * At any point in time, there is a single item that can be sequentially focused in the Tree View. + * This item is the first selected item (that is both visible and navigable), if any, or the first navigable item if no item is selected. + * @param {TreeViewState<[UseTreeViewFocusSignature]>} state The state of the tree view. + * @returns {TreeViewItemId | null} The id of the item that should be sequentially focusable. + */ +export const selectorDefaultFocusableItemId = createSelector( + selectorTreeViewFocusState, + (focus) => focus.defaultFocusableItemId, +); + +/** + * Check if an item is the default focusable item. + * @param {TreeViewState<[UseTreeViewFocusSignature]>} state The state of the tree view. + * @param {TreeViewItemId} itemId The id of the item to check. + * @returns {boolean} `true` if the item is the default focusable item, `false` otherwise. + */ +export const selectorIsItemTheDefaultFocusableItem = createSelector( + [selectorDefaultFocusableItemId, (_, itemId: string) => itemId], + (defaultFocusableItemId, itemId) => defaultFocusableItemId === itemId, +); + +/** + * Get the id of the item that is currently focused. + * @param {TreeViewState<[UseTreeViewFocusSignature]>} state The state of the tree view. + * @returns {TreeViewItemId | null} The id of the item that is currently focused. + */ +export const selectorFocusedItemId = createSelector( + selectorTreeViewFocusState, + (focus) => focus.focusedItemId, +); + +/** + * Check if an item is focused. + * @param {TreeViewState<[UseTreeViewFocusSignature]>} state The state of the tree view. + * @param {TreeViewItemId} itemId The id of the item to check. + * @returns {boolean} `true` if the item is focused, `false` otherwise. + */ +export const selectorIsItemFocused = createSelector( + [selectorFocusedItemId, (_, itemId: string) => itemId], + (focusedItemId, itemId) => focusedItemId === itemId, +); diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.test.tsx b/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.test.tsx index 94cf0c21ea63..779061acf847 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.test.tsx +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.test.tsx @@ -76,7 +76,7 @@ describeTreeView< expect(view.getItemRoot('3').tabIndex).to.equal(-1); }); - it('should set tabIndex={0} on the first item if the selected item is not visible', () => { + it('should set tabIndex={0} on the first item if selected item is not visible', () => { const view = render({ items: [{ id: '1' }, { id: '2', children: [{ id: '2.1' }] }], selectedItems: '2.1', @@ -86,7 +86,7 @@ describeTreeView< expect(view.getItemRoot('2').tabIndex).to.equal(-1); }); - it('should set tabIndex={0} on the first item if the no selected item is visible', () => { + it('should set tabIndex={0} on the first item if no selected item is visible', () => { const view = render({ items: [{ id: '1' }, { id: '2', children: [{ id: '2.1' }, { id: '2.2' }] }], selectedItems: ['2.1', '2.2'], diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts index 5e86c40374bd..04eabd9802c0 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts @@ -1,67 +1,79 @@ import * as React from 'react'; import useEventCallback from '@mui/utils/useEventCallback'; +import useEnhancedEffect from '@mui/utils/useEnhancedEffect'; import { EventHandlers } from '@mui/utils'; -import ownerDocument from '@mui/utils/ownerDocument'; -import { TreeViewPlugin, TreeViewUsedInstance } from '../../models'; +import { TreeViewPlugin } from '../../models'; import { UseTreeViewFocusSignature } from './useTreeViewFocus.types'; import { useInstanceEventHandler } from '../../hooks/useInstanceEventHandler'; -import { getActiveElement } from '../../utils/utils'; import { getFirstNavigableItem } from '../../utils/tree'; import { TreeViewCancellableEvent } from '../../../models'; import { convertSelectedItemsToArray } from '../useTreeViewSelection/useTreeViewSelection.utils'; - -const useDefaultFocusableItemId = ( - instance: TreeViewUsedInstance, - selectedItems: string | string[] | null, -): string => { - let tabbableItemId = convertSelectedItemsToArray(selectedItems).find((itemId) => { - if (!instance.isItemNavigable(itemId)) { - return false; - } - - const itemMeta = instance.getItemMeta(itemId); - return itemMeta && (itemMeta.parentId == null || instance.isItemExpanded(itemMeta.parentId)); - }); - - if (tabbableItemId == null) { - tabbableItemId = getFirstNavigableItem(instance); - } - - return tabbableItemId; -}; +import { + selectorDefaultFocusableItemId, + selectorFocusedItemId, +} from './useTreeViewFocus.selectors'; +import { selectorIsItemExpanded } from '../useTreeViewExpansion/useTreeViewExpansion.selectors'; +import { + selectorCanItemBeFocused, + selectorItemMeta, +} from '../useTreeViewItems/useTreeViewItems.selectors'; export const useTreeViewFocus: TreeViewPlugin = ({ instance, params, - state, - setState, + store, models, - rootRef, }) => { - const defaultFocusableItemId = useDefaultFocusableItemId(instance, models.selectedItems.value); - - const setFocusedItemId = useEventCallback((itemId: React.SetStateAction) => { - const cleanItemId = typeof itemId === 'function' ? itemId(state.focusedItemId) : itemId; - if (state.focusedItemId !== cleanItemId) { - setState((prevState) => ({ ...prevState, focusedItemId: cleanItemId })); + useEnhancedEffect(() => { + let defaultFocusableItemId = convertSelectedItemsToArray(models.selectedItems.value).find( + (itemId) => { + if (!selectorCanItemBeFocused(store.value, itemId)) { + return false; + } + + const itemMeta = selectorItemMeta(store.value, itemId); + return ( + itemMeta && + (itemMeta.parentId == null || selectorIsItemExpanded(store.value, itemMeta.parentId)) + ); + }, + ); + + if (defaultFocusableItemId == null) { + defaultFocusableItemId = getFirstNavigableItem(store.value) ?? null; } - }); - const isTreeViewFocused = React.useCallback( - () => - !!rootRef.current && - rootRef.current.contains(getActiveElement(ownerDocument(rootRef.current))), - [rootRef], - ); + store.update((prevState) => { + if (defaultFocusableItemId === prevState.focus.defaultFocusableItemId) { + return prevState; + } - const isItemFocused = React.useCallback( - (itemId: string) => state.focusedItemId === itemId && isTreeViewFocused(), - [state.focusedItemId, isTreeViewFocused], - ); + return { + ...prevState, + focus: { + ...prevState.focus, + defaultFocusableItemId, + }, + }; + }); + }, [store, models.selectedItems.value]); + + const setFocusedItemId = useEventCallback((itemId: string | null) => { + const focusedItemId = selectorFocusedItemId(store.value); + if (focusedItemId !== itemId) { + store.update((prevState) => ({ + ...prevState, + focus: { ...prevState.focus, focusedItemId: itemId }, + })); + } + }); const isItemVisible = (itemId: string) => { - const itemMeta = instance.getItemMeta(itemId); - return itemMeta && (itemMeta.parentId == null || instance.isItemExpanded(itemMeta.parentId)); + const itemMeta = selectorItemMeta(store.value, itemId); + return ( + itemMeta && + (itemMeta.parentId == null || selectorIsItemExpanded(store.value, itemMeta.parentId)) + ); }; const innerFocusItem = (event: React.SyntheticEvent | null, itemId: string) => { @@ -85,13 +97,14 @@ export const useTreeViewFocus: TreeViewPlugin = ({ }); const removeFocusedItem = useEventCallback(() => { - if (state.focusedItemId == null) { + const focusedItemId = selectorFocusedItemId(store.value); + if (focusedItemId == null) { return; } - const itemMeta = instance.getItemMeta(state.focusedItemId); + const itemMeta = selectorItemMeta(store.value, focusedItemId); if (itemMeta) { - const itemElement = instance.getItemDOMElement(state.focusedItemId); + const itemElement = instance.getItemDOMElement(focusedItemId); if (itemElement) { itemElement.blur(); } @@ -100,10 +113,10 @@ export const useTreeViewFocus: TreeViewPlugin = ({ setFocusedItemId(null); }); - const canItemBeTabbed = (itemId: string) => itemId === defaultFocusableItemId; - useInstanceEventHandler(instance, 'removeItem', ({ id }) => { - if (state.focusedItemId === id) { + const focusedItemId = selectorFocusedItemId(store.value); + const defaultFocusableItemId = selectorDefaultFocusableItemId(store.value); + if (focusedItemId === id && defaultFocusableItemId != null) { innerFocusItem(null, defaultFocusableItemId); } }); @@ -117,28 +130,41 @@ export const useTreeViewFocus: TreeViewPlugin = ({ } // if the event bubbled (which is React specific) we don't want to steal focus - if (event.target === event.currentTarget) { + const defaultFocusableItemId = selectorDefaultFocusableItemId(store.value); + if (event.target === event.currentTarget && defaultFocusableItemId != null) { innerFocusItem(event, defaultFocusableItemId); } }; + const createRootHandleBlur = + (otherHandlers: EventHandlers) => + (event: React.FocusEvent & TreeViewCancellableEvent) => { + otherHandlers.onBlur?.(event); + if (event.defaultMuiPrevented) { + return; + } + + setFocusedItemId(null); + }; + return { getRootProps: (otherHandlers) => ({ onFocus: createRootHandleFocus(otherHandlers), + onBlur: createRootHandleBlur(otherHandlers), }), publicAPI: { focusItem, }, instance: { - isItemFocused, - canItemBeTabbed, focusItem, removeFocusedItem, }, }; }; -useTreeViewFocus.getInitialState = () => ({ focusedItemId: null }); +useTreeViewFocus.getInitialState = () => ({ + focus: { focusedItemId: null, defaultFocusableItemId: null }, +}); useTreeViewFocus.params = { onItemFocus: true, diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.types.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.types.ts index c84b650be8d5..105ad6a36469 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.types.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.types.ts @@ -3,7 +3,6 @@ import { TreeViewPluginSignature } from '../../models'; import type { UseTreeViewItemsSignature } from '../useTreeViewItems'; import type { UseTreeViewSelectionSignature } from '../useTreeViewSelection'; import { UseTreeViewExpansionSignature } from '../useTreeViewExpansion'; -import { TreeViewItemId } from '../../../models'; export interface UseTreeViewFocusPublicAPI { /** @@ -18,20 +17,6 @@ export interface UseTreeViewFocusPublicAPI { } export interface UseTreeViewFocusInstance extends UseTreeViewFocusPublicAPI { - /** - * Check if an item is the currently focused item. - * @param {TreeViewItemId} itemId The id of the item to check. - * @returns {boolean} `true` if the item is focused, `false` otherwise. - */ - isItemFocused: (itemId: TreeViewItemId) => boolean; - /** - * Check if an item should be sequentially focusable (usually with the Tab key). - * At any point in time, there is a single item that can be sequentially focused in the Tree View. - * This item is the first selected item (that is both visible and navigable), if any, or the first navigable item if no item is selected. - * @param {TreeViewItemId} itemId The id of the item to check. - * @returns {boolean} `true` if the item can be sequentially focusable, `false` otherwise. - */ - canItemBeTabbed: (itemId: TreeViewItemId) => boolean; /** * Remove the focus from the currently focused item (both from the internal state and the DOM). */ @@ -50,7 +35,10 @@ export interface UseTreeViewFocusParameters { export type UseTreeViewFocusDefaultizedParameters = UseTreeViewFocusParameters; export interface UseTreeViewFocusState { - focusedItemId: string | null; + focus: { + focusedItemId: string | null; + defaultFocusableItemId: string | null; + }; } export type UseTreeViewFocusSignature = TreeViewPluginSignature<{ diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewIcons/useTreeViewIcons.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewIcons/useTreeViewIcons.ts index 73b9d08db50e..4707a169ca72 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewIcons/useTreeViewIcons.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewIcons/useTreeViewIcons.ts @@ -1,3 +1,4 @@ +import * as React from 'react'; import { TreeViewPlugin } from '../../models'; import { UseTreeViewIconsSignature } from './useTreeViewIcons.types'; @@ -5,8 +6,8 @@ export const useTreeViewIcons: TreeViewPlugin = ({ slots, slotProps, }) => { - return { - contextValue: { + const pluginContextValue = React.useMemo( + () => ({ icons: { slots: { collapseIcon: slots.collapseIcon, @@ -19,7 +20,19 @@ export const useTreeViewIcons: TreeViewPlugin = ({ endIcon: slotProps.endIcon, }, }, - }, + }), + [ + slots.collapseIcon, + slots.expandIcon, + slots.endIcon, + slotProps.collapseIcon, + slotProps.expandIcon, + slotProps.endIcon, + ], + ); + + return { + contextValue: pluginContextValue, }; }; diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewItems/index.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewItems/index.ts index 2ab1e4963528..b98e02587536 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewItems/index.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewItems/index.ts @@ -4,6 +4,5 @@ export type { UseTreeViewItemsParameters, UseTreeViewItemsDefaultizedParameters, UseTreeViewItemsState, - TreeViewItemToRenderProps, } from './useTreeViewItems.types'; export { buildSiblingIndexes, TREE_VIEW_ROOT_PARENT_ID } from './useTreeViewItems.utils'; diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewItems/useTreeViewItems.selectors.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewItems/useTreeViewItems.selectors.ts new file mode 100644 index 000000000000..209e933a27a2 --- /dev/null +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewItems/useTreeViewItems.selectors.ts @@ -0,0 +1,146 @@ +import { TreeViewItemId } from '../../../models'; +import { TreeViewItemMeta } from '../../models'; +import { createSelector, TreeViewRootSelector } from '../../utils/selectors'; +import { UseTreeViewItemsSignature } from './useTreeViewItems.types'; +import { TREE_VIEW_ROOT_PARENT_ID } from './useTreeViewItems.utils'; + +const selectorTreeViewItemsState: TreeViewRootSelector = (state) => + state.items; + +/** + * Get the meta-information of all items. + * @param {TreeViewState<[UseTreeViewItemsSignature]>} state The state of the tree view. + * @returns {TreeViewItemMetaLookup} The meta-information of all items. + */ +export const selectorItemMetaLookup = createSelector( + selectorTreeViewItemsState, + (items) => items.itemMetaLookup, +); + +const EMPTY_CHILDREN: TreeViewItemId[] = []; + +/** + * Get the ordered children ids of a given item. + * @param {TreeViewState<[UseTreeViewItemsSignature]>} state The state of the tree view. + * @param {TreeViewItemId} itemId The id of the item to get the children of. + * @returns {TreeViewItemId[]} The ordered children ids of the item. + */ +export const selectorItemOrderedChildrenIds = createSelector( + [selectorTreeViewItemsState, (_, itemId: string | null) => itemId], + (itemsState, itemId) => + itemsState.itemOrderedChildrenIdsLookup[itemId ?? TREE_VIEW_ROOT_PARENT_ID] ?? EMPTY_CHILDREN, +); + +/** + * Get the model of an item. + * @param {TreeViewState<[UseTreeViewItemsSignature]>} state The state of the tree view. + * @param {TreeViewItemId} itemId The id of the item to get the model of. + * @returns {R} The model of the item. + */ +export const selectorItemModel = createSelector( + [selectorTreeViewItemsState, (_, itemId: string) => itemId], + (itemsState, itemId) => { + const a = itemsState.itemModelLookup[itemId]; + return a; + }, +); + +/** + * Get the meta-information of an item. + * Check the `TreeViewItemMeta` type for more information. + * @param {TreeViewState<[UseTreeViewItemsSignature]>} + * @param {TreeViewItemId} itemId The id of the item to get the meta-information of. + * @returns {TreeViewItemMeta | null} The meta-information of the item. + */ +export const selectorItemMeta = createSelector( + [selectorItemMetaLookup, (_, itemId: string | null) => itemId], + (itemMetaLookup, itemId) => + (itemMetaLookup[itemId ?? TREE_VIEW_ROOT_PARENT_ID] ?? null) as TreeViewItemMeta | null, +); + +/** + * Check if an item is disabled. + * @param {TreeViewState<[UseTreeViewItemsSignature]>} state The state of the tree view. + * @param {TreeViewItemId} itemId The id of the item to check. + * @returns {boolean} `true` if the item is disabled, `false` otherwise. + */ +export const selectorIsItemDisabled = createSelector( + [selectorItemMetaLookup, (_, itemId: string) => itemId], + (itemMetaLookup, itemId) => { + if (itemId == null) { + return false; + } + + let itemMeta = itemMetaLookup[itemId]; + + // This can be called before the item has been added to the item map. + if (!itemMeta) { + return false; + } + + if (itemMeta.disabled) { + return true; + } + + while (itemMeta.parentId != null) { + itemMeta = itemMetaLookup[itemMeta.parentId]; + if (itemMeta.disabled) { + return true; + } + } + + return false; + }, +); + +/** + * Get the index of an item in its parent's children. + * @param {TreeViewState<[UseTreeViewItemsSignature]>} state The state of the tree view. + * @param {TreeViewItemId} itemId The id of the item to get the index of. + * @returns {number} The index of the item in its parent's children. + */ +export const selectorItemIndex = createSelector( + [selectorTreeViewItemsState, selectorItemMeta], + (itemsState, itemMeta) => { + if (itemMeta == null) { + return -1; + } + + const parentIndexes = + itemsState.itemChildrenIndexesLookup[itemMeta.parentId ?? TREE_VIEW_ROOT_PARENT_ID]; + return parentIndexes[itemMeta.id]; + }, +); + +/** + * Get the id of the parent of an item. + * @param {TreeViewState<[UseTreeViewItemsSignature]>} state The state of the tree view. + * @param {TreeViewItemId} itemId The id of the item to get the parent id of. + * @returns {TreeViewItemId | null} The id of the parent of the item. + */ +export const selectorItemParentId = createSelector( + [selectorItemMeta], + (itemMeta) => itemMeta?.parentId ?? null, +); + +/** + * Get the depth of an item (items at the root level have a depth of 0). + * @param {TreeViewState<[UseTreeViewItemsSignature]>} state The state of the tree view. + * @param {TreeViewItemId} itemId The id of the item to get the depth of. + * @returns {number} The depth of the item. + */ +export const selectorItemDepth = createSelector( + [selectorItemMeta], + (itemMeta) => itemMeta?.depth ?? 0, +); + +export const selectorCanItemBeFocused = createSelector( + [selectorTreeViewItemsState, selectorIsItemDisabled], + (itemsState, isItemDisabled) => { + if (itemsState.disabledItemsFocusable) { + return true; + } + + return !isItemDisabled; + }, +); diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewItems/useTreeViewItems.test.tsx b/packages/x-tree-view/src/internals/plugins/useTreeViewItems/useTreeViewItems.test.tsx index 1fda8500521e..1256f01c37e0 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewItems/useTreeViewItems.test.tsx +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewItems/useTreeViewItems.test.tsx @@ -8,6 +8,7 @@ import { UseTreeViewItemsSignature, UseTreeViewSelectionSignature, } from '@mui/x-tree-view/internals'; +import { TreeItemLabel } from '@mui/x-tree-view/TreeItem'; describeTreeView< [UseTreeViewItemsSignature, UseTreeViewExpansionSignature, UseTreeViewSelectionSignature] @@ -22,16 +23,25 @@ describeTreeView< this.skip(); } - expect(() => - render({ items: [{ id: '1' }, { id: '1' }], withErrorBoundary: true }), - ).toErrorDev([ - ...(treeViewComponentName === 'SimpleTreeView' - ? ['Encountered two children with the same key'] - : []), - 'MUI X: The Tree View component requires all items to have a unique `id` property.', - 'MUI X: The Tree View component requires all items to have a unique `id` property.', - `The above error occurred in the component`, - ]); + if (treeViewComponentName === 'SimpleTreeView') { + expect(() => + render({ items: [{ id: '1' }, { id: '1' }], withErrorBoundary: true }), + ).toErrorDev([ + 'Encountered two children with the same key, `1`', + 'MUI X: The Tree View component requires all items to have a unique `id` property.', + 'MUI X: The Tree View component requires all items to have a unique `id` property.', + `The above error occurred in the component`, + `The above error occurred in the component`, + ]); + } else { + expect(() => + render({ items: [{ id: '1' }, { id: '1' }], withErrorBoundary: true }), + ).toErrorDev([ + 'MUI X: The Tree View component requires all items to have a unique `id` property.', + 'MUI X: The Tree View component requires all items to have a unique `id` property.', + `The above error occurred in the component`, + ]); + } }); it('should be able to use a custom id attribute', function test() { @@ -212,6 +222,99 @@ describeTreeView< }); }); + describe('Memoization (Rich Tree View only)', () => { + it('should not re-render any children when the Tree View re-renders (flat tree)', function test() { + if (!treeViewComponentName.startsWith('RichTreeView')) { + this.skip(); + } + + const spyLabel = spy((props) => ); + const view = render({ + items: Array.from({ length: 10 }, (_, i) => ({ id: i.toString() })), + slotProps: { item: { slots: { label: spyLabel } } }, + }); + + spyLabel.resetHistory(); + + view.setProps({ onClick: () => {} }); + + const renders = spyLabel.getCalls().map((call) => call.args[0].children); + expect(renders).to.deep.equal([]); + }); + + it('should not re-render every children when updating the state on an item (flat tree)', function test() { + if (!treeViewComponentName.startsWith('RichTreeView')) { + this.skip(); + } + + const spyLabel = spy((props) => ); + const view = render({ + items: Array.from({ length: 10 }, (_, i) => ({ id: i.toString() })), + selectedItems: [], + slotProps: { item: { slots: { label: spyLabel } } }, + }); + + spyLabel.resetHistory(); + + view.setProps({ selectedItems: ['1'] }); + + const renders = spyLabel.getCalls().map((call) => call.args[0].children); + + // 2 renders of the 1st item to remove to tabIndex={0} + // 2 renders of the selected item to change its visual state + expect(renders).to.deep.equal(['0', '0', '1', '1']); + }); + + it('should not re-render any children when the Tree View re-renders (nested tree)', function test() { + if (!treeViewComponentName.startsWith('RichTreeView')) { + this.skip(); + } + + const spyLabel = spy((props) => ); + const view = render({ + items: Array.from({ length: 5 }, (_, i) => ({ + id: i.toString(), + children: Array.from({ length: 5 }, (_el, j) => ({ id: `${i}.${j}` })), + })), + slotProps: { item: { slots: { label: spyLabel } } }, + }); + + spyLabel.resetHistory(); + + view.setProps({ onClick: () => {} }); + + const renders = spyLabel.getCalls().map((call) => call.args[0].children); + expect(renders).to.deep.equal([]); + }); + + it('should not re-render every children when updating the state on an item (nested tree)', function test() { + if (!treeViewComponentName.startsWith('RichTreeView')) { + this.skip(); + } + + const spyLabel = spy((props) => ); + const view = render({ + items: Array.from({ length: 5 }, (_, i) => ({ + id: i.toString(), + children: Array.from({ length: 5 }, (_el, j) => ({ id: `${i}.${j}` })), + })), + defaultExpandedItems: Array.from({ length: 5 }, (_, i) => i.toString()), + selectedItems: [], + slotProps: { item: { slots: { label: spyLabel } } }, + }); + + spyLabel.resetHistory(); + + view.setProps({ selectedItems: ['1'] }); + + const renders = spyLabel.getCalls().map((call) => call.args[0].children); + + // 2 renders of the 1st item to remove to tabIndex={0} + // 2 renders of the selected item to change its visual state + expect(renders).to.deep.equal(['0', '0', '1', '1']); + }); + }); + describe('API methods', () => { describe('getItem', () => { // This method is only usable with Rich Tree View components diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewItems/useTreeViewItems.tsx b/packages/x-tree-view/src/internals/plugins/useTreeViewItems/useTreeViewItems.tsx index 83dcd7421083..fe8bd453ab8b 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewItems/useTreeViewItems.tsx +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewItems/useTreeViewItems.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import useEventCallback from '@mui/utils/useEventCallback'; import { TreeViewPlugin } from '../../models'; import { UseTreeViewItemsSignature, @@ -6,27 +7,39 @@ import { UseTreeViewItemsState, } from './useTreeViewItems.types'; import { publishTreeViewEvent } from '../../utils/publishTreeViewEvent'; -import { TreeViewBaseItem, TreeViewItemId } from '../../../models'; +import { + TreeViewBaseItem, + TreeViewDefaultItemModelProperties, + TreeViewItemId, +} from '../../../models'; import { buildSiblingIndexes, TREE_VIEW_ROOT_PARENT_ID } from './useTreeViewItems.utils'; import { TreeViewItemDepthContext } from '../../TreeViewItemDepthContext'; +import { + selectorItemMeta, + selectorItemOrderedChildrenIds, + selectorItemModel, + selectorItemDepth, +} from './useTreeViewItems.selectors'; +import { selectorTreeViewId } from '../../corePlugins/useTreeViewId/useTreeViewId.selectors'; import { generateTreeItemIdAttribute } from '../../corePlugins/useTreeViewId/useTreeViewId.utils'; -interface UpdateNodesStateParameters +interface UpdateItemsStateParameters extends Pick< UseTreeViewItemsDefaultizedParameters, - 'items' | 'isItemDisabled' | 'getItemLabel' | 'getItemId' + 'items' | 'isItemDisabled' | 'getItemLabel' | 'getItemId' | 'disabledItemsFocusable' > {} -type State = UseTreeViewItemsState['items']; +type State = UseTreeViewItemsState['items']; const updateItemsState = ({ + disabledItemsFocusable, items, isItemDisabled, getItemLabel, getItemId, -}: UpdateNodesStateParameters): State => { - const itemMetaMap: State['itemMetaMap'] = {}; - const itemMap: State['itemMap'] = {}; - const itemOrderedChildrenIds: State['itemOrderedChildrenIds'] = { +}: UpdateItemsStateParameters): State => { + const itemMetaLookup: State['itemMetaLookup'] = {}; + const itemModelLookup: State['itemModelLookup'] = {}; + const itemOrderedChildrenIdsLookup: State['itemOrderedChildrenIdsLookup'] = { [TREE_VIEW_ROOT_PARENT_ID]: [], }; @@ -44,7 +57,7 @@ const updateItemsState = ({ ); } - if (itemMetaMap[id] != null) { + if (itemMetaLookup[id] != null) { throw new Error( [ 'MUI X: The Tree View component requires all items to have a unique `id` property.', @@ -66,7 +79,7 @@ const updateItemsState = ({ ); } - itemMetaMap[id] = { + itemMetaLookup[id] = { id, label, parentId, @@ -76,120 +89,77 @@ const updateItemsState = ({ depth, }; - itemMap[id] = item; + itemModelLookup[id] = item; const parentIdWithDefault = parentId ?? TREE_VIEW_ROOT_PARENT_ID; - if (!itemOrderedChildrenIds[parentIdWithDefault]) { - itemOrderedChildrenIds[parentIdWithDefault] = []; + if (!itemOrderedChildrenIdsLookup[parentIdWithDefault]) { + itemOrderedChildrenIdsLookup[parentIdWithDefault] = []; } - itemOrderedChildrenIds[parentIdWithDefault].push(id); + itemOrderedChildrenIdsLookup[parentIdWithDefault].push(id); item.children?.forEach((child) => processItem(child, depth + 1, id)); }; items.forEach((item) => processItem(item, 0, null)); - const itemChildrenIndexes: State['itemChildrenIndexes'] = {}; - Object.keys(itemOrderedChildrenIds).forEach((parentId) => { - itemChildrenIndexes[parentId] = buildSiblingIndexes(itemOrderedChildrenIds[parentId]); + const itemChildrenIndexesLookup: State['itemChildrenIndexesLookup'] = {}; + Object.keys(itemOrderedChildrenIdsLookup).forEach((parentId) => { + itemChildrenIndexesLookup[parentId] = buildSiblingIndexes( + itemOrderedChildrenIdsLookup[parentId], + ); }); return { - itemMetaMap, - itemMap, - itemOrderedChildrenIds, - itemChildrenIndexes, + disabledItemsFocusable, + itemMetaLookup, + itemModelLookup, + itemOrderedChildrenIdsLookup, + itemChildrenIndexesLookup, }; }; export const useTreeViewItems: TreeViewPlugin = ({ instance, params, - state, - setState, + store, }) => { - const getItemMeta = React.useCallback( - (itemId: string) => state.items.itemMetaMap[itemId], - [state.items.itemMetaMap], - ); - const getItem = React.useCallback( - (itemId: string) => state.items.itemMap[itemId], - [state.items.itemMap], + (itemId: string) => selectorItemModel(store.value, itemId), + [store], ); const getItemTree = React.useCallback(() => { - const getItemFromItemId = (id: TreeViewItemId): TreeViewBaseItem => { - const { children: oldChildren, ...item } = state.items.itemMap[id]; - const newChildren = state.items.itemOrderedChildrenIds[id]; - if (newChildren) { + const getItemFromItemId = (itemId: TreeViewItemId): TreeViewBaseItem => { + const item = selectorItemModel(store.value, itemId); + const newChildren = selectorItemOrderedChildrenIds(store.value, itemId); + if (newChildren.length > 0) { item.children = newChildren.map(getItemFromItemId); + } else { + delete item.children; } return item; }; - return state.items.itemOrderedChildrenIds[TREE_VIEW_ROOT_PARENT_ID].map(getItemFromItemId); - }, [state.items.itemMap, state.items.itemOrderedChildrenIds]); - - const isItemDisabled = React.useCallback( - (itemId: string | null): itemId is string => { - if (itemId == null) { - return false; - } - - let itemMeta = instance.getItemMeta(itemId); - - // This can be called before the item has been added to the item map. - if (!itemMeta) { - return false; - } - - if (itemMeta.disabled) { - return true; - } - - while (itemMeta.parentId != null) { - itemMeta = instance.getItemMeta(itemMeta.parentId); - if (itemMeta.disabled) { - return true; - } - } - - return false; - }, - [instance], - ); - - const getItemIndex = React.useCallback( - (itemId: string) => { - const parentId = instance.getItemMeta(itemId).parentId ?? TREE_VIEW_ROOT_PARENT_ID; - return state.items.itemChildrenIndexes[parentId][itemId]; - }, - [instance, state.items.itemChildrenIndexes], - ); + return selectorItemOrderedChildrenIds(store.value, null).map(getItemFromItemId); + }, [store]); const getItemOrderedChildrenIds = React.useCallback( - (itemId: string | null) => - state.items.itemOrderedChildrenIds[itemId ?? TREE_VIEW_ROOT_PARENT_ID] ?? [], - [state.items.itemOrderedChildrenIds], + (itemId: string | null) => selectorItemOrderedChildrenIds(store.value, itemId), + [store], ); const getItemDOMElement = (itemId: string) => { - const itemMeta = instance.getItemMeta(itemId); + const itemMeta = selectorItemMeta(store.value, itemId); if (itemMeta == null) { return null; } - return document.getElementById( - generateTreeItemIdAttribute({ treeId: state.id.treeId, itemId, id: itemMeta.idAttribute }), - ); - }; - - const isItemNavigable = (itemId: string) => { - if (params.disabledItemsFocusable) { - return true; - } - return !instance.isItemDisabled(itemId); + const idAttribute = generateTreeItemIdAttribute({ + treeId: selectorTreeViewId(store.value), + itemId, + id: itemMeta.idAttribute, + }); + return document.getElementById(idAttribute); }; const areItemUpdatesPreventedRef = React.useRef(false); @@ -204,16 +174,17 @@ export const useTreeViewItems: TreeViewPlugin = ({ return; } - setState((prevState) => { + store.update((prevState) => { const newState = updateItemsState({ + disabledItemsFocusable: params.disabledItemsFocusable, items: params.items, isItemDisabled: params.isItemDisabled, getItemId: params.getItemId, getItemLabel: params.getItemLabel, }); - Object.values(prevState.items.itemMetaMap).forEach((item) => { - if (!newState.itemMetaMap[item.id]) { + Object.values(prevState.items.itemMetaLookup).forEach((item) => { + if (!newState.itemMetaLookup[item.id]) { publishTreeViewEvent(instance, 'removeItem', { id: item.id }); } }); @@ -222,28 +193,29 @@ export const useTreeViewItems: TreeViewPlugin = ({ }); }, [ instance, - setState, + store, params.items, + params.disabledItemsFocusable, params.isItemDisabled, params.getItemId, params.getItemLabel, ]); - const getItemsToRender = () => { - const getPropsFromItemId = ( - id: TreeViewItemId, - ): ReturnType[number] => { - const item = state.items.itemMetaMap[id]; - return { - label: item.label!, - itemId: item.id, - id: item.idAttribute, - children: state.items.itemOrderedChildrenIds[id]?.map(getPropsFromItemId), - }; - }; + // Wrap `props.onItemClick` with `useEventCallback` to prevent unneeded context updates. + const handleItemClick = useEventCallback((event: React.MouseEvent, itemId: string) => { + if (params.onItemClick) { + params.onItemClick(event, itemId); + } + }); - return state.items.itemOrderedChildrenIds[TREE_VIEW_ROOT_PARENT_ID].map(getPropsFromItemId); - }; + const pluginContextValue = React.useMemo( + () => ({ + items: { + onItemClick: handleItemClick, + }, + }), + [handleItemClick], + ); return { getRootProps: () => ({ @@ -261,29 +233,17 @@ export const useTreeViewItems: TreeViewPlugin = ({ getItemOrderedChildrenIds, }, instance: { - getItemMeta, - getItem, - getItemTree, - getItemsToRender, - getItemIndex, getItemDOMElement, - getItemOrderedChildrenIds, - isItemDisabled, - isItemNavigable, preventItemUpdates, areItemUpdatesPrevented, }, - contextValue: { - items: { - onItemClick: params.onItemClick, - disabledItemsFocusable: params.disabledItemsFocusable, - }, - }, + contextValue: pluginContextValue, }; }; useTreeViewItems.getInitialState = (params) => ({ items: updateItemsState({ + disabledItemsFocusable: params.disabledItemsFocusable, items: params.items, isItemDisabled: params.isItemDisabled, getItemId: params.getItemId, @@ -297,9 +257,9 @@ useTreeViewItems.getDefaultizedParams = ({ params }) => ({ itemChildrenIndentation: params.itemChildrenIndentation ?? '12px', }); -useTreeViewItems.wrapRoot = ({ children, instance }) => { +useTreeViewItems.wrapRoot = ({ children }) => { return ( - instance.getItemMeta(itemId)?.depth ?? 0}> + {children} ); diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewItems/useTreeViewItems.types.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewItems/useTreeViewItems.types.ts index a27e8913c9bd..65bd886fa1ee 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewItems/useTreeViewItems.types.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewItems/useTreeViewItems.types.ts @@ -1,14 +1,11 @@ import * as React from 'react'; import { DefaultizedProps } from '@mui/x-internals/types'; import { TreeViewItemMeta, TreeViewPluginSignature } from '../../models'; -import { TreeViewBaseItem, TreeViewItemId } from '../../../models'; - -export interface TreeViewItemToRenderProps { - label: string; - itemId: string; - id: string | undefined; - children?: TreeViewItemToRenderProps[]; -} +import { + TreeViewBaseItem, + TreeViewDefaultItemModelProperties, + TreeViewItemId, +} from '../../../models'; export interface UseTreeViewItemsPublicAPI { /** @@ -34,46 +31,13 @@ export interface UseTreeViewItemsPublicAPI { getItemOrderedChildrenIds: (itemId: TreeViewItemId | null) => TreeViewItemId[]; /** * Get all the items in the same format as provided by `props.items`. - * @returns {TreeViewItemToRenderProps[]} The items in the tree. + * @returns {TreeViewBaseItem[]} The items in the tree. */ getItemTree: () => TreeViewBaseItem[]; } -export interface UseTreeViewItemsInstance extends UseTreeViewItemsPublicAPI { - /** - * Get the meta-information of an item. - * Check the `TreeViewItemMeta` type for more information. - * @param {TreeViewItemId} itemId The id of the item to get the meta-information of. - * @returns {TreeViewItemMeta} The meta-information of the item. - */ - getItemMeta: (itemId: TreeViewItemId) => TreeViewItemMeta; - /** - * Get the item that should be rendered. - * This method is only used on Rich Tree View components. - * Check the `TreeViewItemToRenderProps` type for more information. - * @returns {TreeViewItemToRenderProps[]} The items to render. - */ - getItemsToRender: () => TreeViewItemToRenderProps[]; - /** - * Check if a given item is disabled. - * An item is disabled if it was marked as disabled or if one of its ancestors is disabled. - * @param {TreeViewItemId} itemId The id of the item to check. - * @returns {boolean} `true` if the item is disabled, `false` otherwise. - */ - isItemDisabled: (itemId: TreeViewItemId) => boolean; - /** - * Check if a given item is navigable (i.e.: if it can be accessed through keyboard navigation). - * An item is navigable if it is not disabled or if the `disabledItemsFocusable` prop is `true`. - * @param {TreeViewItemId} itemId The id of the item to check. - * @returns {boolean} `true` if the item is navigable, `false` otherwise. - */ - isItemNavigable: (itemId: TreeViewItemId) => boolean; - /** - * Get the index of a given item in its parent's children list. - * @param {TreeViewItemId} itemId The id of the item to get the index of. - * @returns {number} The index of the item in its parent's children list. - */ - getItemIndex: (itemId: TreeViewItemId) => number; +export interface UseTreeViewItemsInstance + extends Pick, 'getItemDOMElement'> { /** * Freeze any future update to the state based on the `items` prop. * This is useful when `useTreeViewJSXItems` is used to avoid having conflicting sources of truth. @@ -146,15 +110,18 @@ interface UseTreeViewItemsEventLookup { export interface UseTreeViewItemsState { items: { - itemMetaMap: TreeViewItemMetaMap; - itemMap: TreeViewItemMap; - itemOrderedChildrenIds: { [parentItemId: string]: string[] }; - itemChildrenIndexes: { [parentItemId: string]: { [itemId: string]: number } }; + disabledItemsFocusable: boolean; + itemModelLookup: TreeViewItemModelLookup; + itemMetaLookup: TreeViewItemMetaLookup; + itemOrderedChildrenIdsLookup: { [parentItemId: string]: string[] }; + itemChildrenIndexesLookup: { [parentItemId: string]: { [itemId: string]: number } }; }; } interface UseTreeViewItemsContextValue { - items: Pick, 'disabledItemsFocusable' | 'onItemClick'>; + items: { + onItemClick: (event: React.MouseEvent, itemId: string) => void; + }; } export type UseTreeViewItemsSignature = TreeViewPluginSignature<{ @@ -163,10 +130,10 @@ export type UseTreeViewItemsSignature = TreeViewPluginSignature<{ instance: UseTreeViewItemsInstance; publicAPI: UseTreeViewItemsPublicAPI; events: UseTreeViewItemsEventLookup; - state: UseTreeViewItemsState; + state: UseTreeViewItemsState; contextValue: UseTreeViewItemsContextValue; }>; -export type TreeViewItemMetaMap = { [itemId: string]: TreeViewItemMeta }; +export type TreeViewItemMetaLookup = { [itemId: string]: TreeViewItemMeta }; -export type TreeViewItemMap = { [itemId: string]: R }; +export type TreeViewItemModelLookup = { [itemId: string]: TreeViewBaseItem }; diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewJSXItems/useTreeViewJSXItems.tsx b/packages/x-tree-view/src/internals/plugins/useTreeViewJSXItems/useTreeViewJSXItems.tsx index 764f22b72802..347080cea474 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewJSXItems/useTreeViewJSXItems.tsx +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewJSXItems/useTreeViewJSXItems.tsx @@ -16,16 +16,17 @@ import { } from '../useTreeViewItems/useTreeViewItems.utils'; import { TreeViewItemDepthContext } from '../../TreeViewItemDepthContext'; import { generateTreeItemIdAttribute } from '../../corePlugins/useTreeViewId/useTreeViewId.utils'; +import { isItemExpandable } from '../../../hooks/useTreeItemUtils/useTreeItemUtils'; export const useTreeViewJSXItems: TreeViewPlugin = ({ instance, - setState, + store, }) => { instance.preventItemUpdates(); const insertJSXItem = useEventCallback((item: TreeViewItemMeta) => { - setState((prevState) => { - if (prevState.items.itemMetaMap[item.id] != null) { + store.update((prevState) => { + if (prevState.items.itemMetaLookup[item.id] != null) { throw new Error( [ 'MUI X: The Tree View component requires all items to have a unique `id` property.', @@ -39,25 +40,28 @@ export const useTreeViewJSXItems: TreeViewPlugin = ...prevState, items: { ...prevState.items, - itemMetaMap: { ...prevState.items.itemMetaMap, [item.id]: item }, + itemMetaLookup: { ...prevState.items.itemMetaLookup, [item.id]: item }, // For Simple Tree View, we don't have a proper `item` object, so we create a very basic one. - itemMap: { ...prevState.items.itemMap, [item.id]: { id: item.id, label: item.label } }, + itemModelLookup: { + ...prevState.items.itemModelLookup, + [item.id]: { id: item.id, label: item.label ?? '' }, + }, }, }; }); return () => { - setState((prevState) => { - const newItemMetaMap = { ...prevState.items.itemMetaMap }; - const newItemMap = { ...prevState.items.itemMap }; - delete newItemMetaMap[item.id]; - delete newItemMap[item.id]; + store.update((prevState) => { + const newItemMetaLookup = { ...prevState.items.itemMetaLookup }; + const newItemModelLookup = { ...prevState.items.itemModelLookup }; + delete newItemMetaLookup[item.id]; + delete newItemModelLookup[item.id]; return { ...prevState, items: { ...prevState.items, - itemMetaMap: newItemMetaMap, - itemMap: newItemMap, + itemMetaLookup: newItemMetaLookup, + itemModelLookup: newItemModelLookup, }, }; }); @@ -68,16 +72,16 @@ export const useTreeViewJSXItems: TreeViewPlugin = const setJSXItemsOrderedChildrenIds = (parentId: string | null, orderedChildrenIds: string[]) => { const parentIdWithDefault = parentId ?? TREE_VIEW_ROOT_PARENT_ID; - setState((prevState) => ({ + store.update((prevState) => ({ ...prevState, items: { ...prevState.items, - itemOrderedChildrenIds: { - ...prevState.items.itemOrderedChildrenIds, + itemOrderedChildrenIdsLookup: { + ...prevState.items.itemOrderedChildrenIdsLookup, [parentIdWithDefault]: orderedChildrenIds, }, - itemChildrenIndexes: { - ...prevState.items.itemChildrenIndexes, + itemChildrenIndexesLookup: { + ...prevState.items.itemChildrenIndexesLookup, [parentIdWithDefault]: buildSiblingIndexes(orderedChildrenIds), }, }, @@ -108,15 +112,8 @@ export const useTreeViewJSXItems: TreeViewPlugin = }; }; -const isItemExpandable = (reactChildren: React.ReactNode) => { - if (Array.isArray(reactChildren)) { - return reactChildren.length > 0 && reactChildren.some(isItemExpandable); - } - return Boolean(reactChildren); -}; - const useTreeViewJSXItemsItemPlugin: TreeViewItemPlugin = ({ props, rootRef, contentRef }) => { - const { instance, treeId } = useTreeViewContext<[UseTreeViewJSXItemsSignature]>(); + const { instance, store, treeId } = useTreeViewContext<[UseTreeViewJSXItemsSignature]>(); const { children, disabled = false, label, itemId, id } = props; const parentContext = React.useContext(TreeViewChildrenItemContext); @@ -142,10 +139,11 @@ const useTreeViewJSXItemsItemPlugin: TreeViewItemPlugin = ({ props, rootRef, con return () => { unregisterChild(idAttribute); + unregisterChild(idAttribute); }; - }, [registerChild, unregisterChild, itemId, id, treeId]); + }, [store, instance, registerChild, unregisterChild, itemId, id, treeId]); - React.useEffect(() => { + useEnhancedEffect(() => { return instance.insertJSXItem({ id: itemId, idAttribute: id, @@ -173,12 +171,12 @@ const useTreeViewJSXItemsItemPlugin: TreeViewItemPlugin = ({ props, rootRef, con useTreeViewJSXItems.itemPlugin = useTreeViewJSXItemsItemPlugin; -useTreeViewJSXItems.wrapItem = ({ children, itemId }) => { +useTreeViewJSXItems.wrapItem = ({ children, itemId, idAttribute }) => { // eslint-disable-next-line react-hooks/rules-of-hooks const depthContext = React.useContext(TreeViewItemDepthContext); return ( - + {children} @@ -187,7 +185,7 @@ useTreeViewJSXItems.wrapItem = ({ children, itemId }) => { }; useTreeViewJSXItems.wrapRoot = ({ children }) => ( - + {children} ); diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewKeyboardNavigation/useTreeViewKeyboardNavigation.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewKeyboardNavigation/useTreeViewKeyboardNavigation.ts index e5f587f75571..76cd569b3b7b 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewKeyboardNavigation/useTreeViewKeyboardNavigation.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewKeyboardNavigation/useTreeViewKeyboardNavigation.ts @@ -16,6 +16,21 @@ import { } from './useTreeViewKeyboardNavigation.types'; import { hasPlugin } from '../../utils/plugins'; import { useTreeViewLabel } from '../useTreeViewLabel'; +import { useSelector } from '../../hooks/useSelector'; +import { + selectorItemMetaLookup, + selectorIsItemDisabled, + selectorItemParentId, +} from '../useTreeViewItems/useTreeViewItems.selectors'; +import { + selectorIsItemBeingEdited, + selectorIsItemEditable, +} from '../useTreeViewLabel/useTreeViewLabel.selectors'; +import { selectorIsItemSelected } from '../useTreeViewSelection/useTreeViewSelection.selectors'; +import { + selectorIsItemExpandable, + selectorIsItemExpanded, +} from '../useTreeViewExpansion/useTreeViewExpansion.selectors'; function isPrintableKey(string: string) { return !!string && string.length === 1 && !!string.match(/\S/); @@ -23,7 +38,7 @@ function isPrintableKey(string: string) { export const useTreeViewKeyboardNavigation: TreeViewPlugin< UseTreeViewKeyboardNavigationSignature -> = ({ instance, params, state }) => { +> = ({ instance, store, params }) => { const isRtl = useRtl(); const firstCharMap = React.useRef({}); @@ -33,6 +48,7 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< }, ); + const itemMetaLookup = useSelector(store, selectorItemMetaLookup); React.useEffect(() => { if (instance.areItemUpdatesPrevented()) { return; @@ -44,18 +60,18 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< newFirstCharMap[item.id] = item.label!.substring(0, 1).toLowerCase(); }; - Object.values(state.items.itemMetaMap).forEach(processItem); + Object.values(itemMetaLookup).forEach(processItem); firstCharMap.current = newFirstCharMap; - }, [state.items.itemMetaMap, params.getItemId, instance]); + }, [itemMetaLookup, params.getItemId, instance]); const getFirstMatchingItem = (itemId: string, query: string) => { const cleanQuery = query.toLowerCase(); const getNextItem = (itemIdToCheck: string) => { - const nextItemId = getNextNavigableItem(instance, itemIdToCheck); + const nextItemId = getNextNavigableItem(store.value, itemIdToCheck); // We reached the end of the tree, check from the beginning if (nextItemId === null) { - return getFirstNavigableItem(instance); + return getFirstNavigableItem(store.value); } return nextItemId; @@ -78,10 +94,12 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< }; const canToggleItemSelection = (itemId: string) => - !params.disableSelection && !instance.isItemDisabled(itemId); + !params.disableSelection && !selectorIsItemDisabled(store.value, itemId); const canToggleItemExpansion = (itemId: string) => { - return !instance.isItemDisabled(itemId) && instance.isItemExpandable(itemId); + return ( + !selectorIsItemDisabled(store.value, itemId) && selectorIsItemExpandable(store.value, itemId) + ); }; // ARIA specification: https://www.w3.org/WAI/ARIA/apg/patterns/treeview/#keyboardinteraction @@ -126,8 +144,8 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< case key === 'Enter': { if ( hasPlugin(instance, useTreeViewLabel) && - instance.isItemEditable(itemId) && - !instance.isItemBeingEdited(itemId) + selectorIsItemEditable(store.value, { itemId, isItemEditable: params.isItemEditable! }) && + !selectorIsItemBeingEdited(store.value, itemId) ) { instance.setEditedItemId(itemId); } else if (canToggleItemExpansion(itemId)) { @@ -137,7 +155,7 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< if (params.multiSelect) { event.preventDefault(); instance.selectItem({ event, itemId, keepExistingSelection: true }); - } else if (!instance.isItemSelected(itemId)) { + } else if (!selectorIsItemSelected(store.value, itemId)) { instance.selectItem({ event, itemId }); event.preventDefault(); } @@ -148,7 +166,7 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< // Focus the next focusable item case key === 'ArrowDown': { - const nextItem = getNextNavigableItem(instance, itemId); + const nextItem = getNextNavigableItem(store.value, itemId); if (nextItem) { event.preventDefault(); instance.focusItem(event, nextItem); @@ -165,7 +183,7 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< // Focuses the previous focusable item case key === 'ArrowUp': { - const previousItem = getPreviousNavigableItem(instance, itemId); + const previousItem = getPreviousNavigableItem(store.value, itemId); if (previousItem) { event.preventDefault(); instance.focusItem(event, previousItem); @@ -186,8 +204,8 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< if (ctrlPressed) { return; } - if (instance.isItemExpanded(itemId)) { - const nextItemId = getNextNavigableItem(instance, itemId); + if (selectorIsItemExpanded(store.value, itemId)) { + const nextItemId = getNextNavigableItem(store.value, itemId); if (nextItemId) { instance.focusItem(event, nextItemId); event.preventDefault(); @@ -206,11 +224,11 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< if (ctrlPressed) { return; } - if (canToggleItemExpansion(itemId) && instance.isItemExpanded(itemId)) { + if (canToggleItemExpansion(itemId) && selectorIsItemExpanded(store.value, itemId)) { instance.toggleItemExpansion(event, itemId); event.preventDefault(); } else { - const parent = instance.getItemMeta(itemId).parentId; + const parent = selectorItemParentId(store.value, itemId); if (parent) { instance.focusItem(event, parent); event.preventDefault(); @@ -227,7 +245,7 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< if (canToggleItemSelection(itemId) && params.multiSelect && ctrlPressed && event.shiftKey) { instance.selectRangeFromStartToItem(event, itemId); } else { - instance.focusItem(event, getFirstNavigableItem(instance)); + instance.focusItem(event, getFirstNavigableItem(store.value)); } event.preventDefault(); @@ -241,7 +259,7 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< if (canToggleItemSelection(itemId) && params.multiSelect && ctrlPressed && event.shiftKey) { instance.selectRangeFromItemToEnd(event, itemId); } else { - instance.focusItem(event, getLastNavigableItem(instance)); + instance.focusItem(event, getLastNavigableItem(store.value)); } event.preventDefault(); diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.itemPlugin.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.itemPlugin.ts index 7636ad9b39dd..f403056654ca 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.itemPlugin.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.itemPlugin.ts @@ -7,20 +7,26 @@ import { UseTreeItemLabelInputSlotPropsFromLabelEditing, UseTreeViewLabelSignature, } from './useTreeViewLabel.types'; +import { useSelector } from '../../hooks/useSelector'; +import { selectorIsItemBeingEdited, selectorIsItemEditable } from './useTreeViewLabel.selectors'; export const useTreeViewLabelItemPlugin: TreeViewItemPlugin = ({ props }) => { - const { instance } = useTreeViewContext<[UseTreeViewItemsSignature, UseTreeViewLabelSignature]>(); + const { + store, + label: { isItemEditable }, + } = useTreeViewContext<[UseTreeViewItemsSignature, UseTreeViewLabelSignature]>(); const { label, itemId } = props; const [labelInputValue, setLabelInputValue] = React.useState(label as string); - const isItemBeingEdited = instance.isItemBeingEdited(itemId); + const editable = useSelector(store, selectorIsItemEditable, { itemId, isItemEditable }); + const editing = useSelector(store, selectorIsItemBeingEdited, itemId); React.useEffect(() => { - if (!isItemBeingEdited) { + if (!editing) { setLabelInputValue(label as string); } - }, [isItemBeingEdited, label]); + }, [editing, label]); return { propsEnhancers: { @@ -28,8 +34,6 @@ export const useTreeViewLabelItemPlugin: TreeViewItemPlugin = ({ props }) => { externalEventHandlers, interactions, }): UseTreeItemLabelInputSlotPropsFromLabelEditing => { - const editable = instance.isItemEditable(itemId); - if (!editable) { return {}; } diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.selectors.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.selectors.ts new file mode 100644 index 000000000000..43c28f972606 --- /dev/null +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.selectors.ts @@ -0,0 +1,39 @@ +import { UseTreeViewLabelSignature } from './useTreeViewLabel.types'; +import { createSelector, TreeViewRootSelector } from '../../utils/selectors'; +import { selectorItemModel } from '../useTreeViewItems/useTreeViewItems.selectors'; + +const selectorTreeViewLabelState: TreeViewRootSelector = (state) => + state.label; + +/** + * Check if an item is editable. + * @param {TreeViewState<[UseTreeViewItemsSignature]>} state The state of the tree view. + * @param {object} params The parameters. + * @param {TreeViewItemId} params.itemId The id of the item to check. + * @param {((item: any) => boolean) | boolean} params.isItemEditable The function to determine if an item is editable. + * @returns {boolean} `true` if the item is editable, `false` otherwise. + */ +export const selectorIsItemEditable = createSelector( + [ + (_, args: { itemId: string; isItemEditable: ((item: any) => boolean) | boolean }) => args, + (state, args) => selectorItemModel(state, args.itemId), + ], + (args, itemModel) => { + if (!itemModel || !args.isItemEditable) { + return false; + } + + return typeof args.isItemEditable === 'function' ? args.isItemEditable(itemModel) : true; + }, +); + +/** + * Check if an item is being edited. + * @param {TreeViewState<[UseTreeViewLabelSignature]>} state The state of the tree view. + * @param {TreeViewItemId} itemId The id of the item to check. + * @returns {boolean} `true` if the item is being edited, `false` otherwise. + */ +export const selectorIsItemBeingEdited = createSelector( + [selectorTreeViewLabelState, (_, itemId: string) => itemId], + (labelState, itemId) => labelState.editedItemId === itemId, +); diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.ts index 460b4667674c..51f124b08f85 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.ts @@ -5,37 +5,9 @@ import { TreeViewItemId } from '../../../models'; import { UseTreeViewLabelSignature } from './useTreeViewLabel.types'; import { useTreeViewLabelItemPlugin } from './useTreeViewLabel.itemPlugin'; -export const useTreeViewLabel: TreeViewPlugin = ({ - instance, - state, - setState, - params, -}) => { - const editedItemRef = React.useRef(state.editedItemId); - - const isItemBeingEditedRef = (itemId: TreeViewItemId) => editedItemRef.current === itemId; - +export const useTreeViewLabel: TreeViewPlugin = ({ store, params }) => { const setEditedItemId = (editedItemId: TreeViewItemId | null) => { - setState((prevState) => ({ ...prevState, editedItemId })); - editedItemRef.current = editedItemId; - }; - - const isItemBeingEdited = (itemId: TreeViewItemId) => itemId === state.editedItemId; - - const isTreeViewEditable = Boolean(params.isItemEditable); - - const isItemEditable = (itemId: TreeViewItemId): boolean => { - if (itemId == null || !isTreeViewEditable) { - return false; - } - const item = instance.getItem(itemId); - - if (!item) { - return false; - } - return typeof params.isItemEditable === 'function' - ? params.isItemEditable(item) - : Boolean(params.isItemEditable); + store.update((prevState) => ({ ...prevState, label: { editedItemId } })); }; const updateItemLabel = (itemId: TreeViewItemId, label: string) => { @@ -48,14 +20,14 @@ export const useTreeViewLabel: TreeViewPlugin = ({ ].join('\n'), ); } - setState((prevState) => { - const item = prevState.items.itemMetaMap[itemId]; + store.update((prevState) => { + const item = prevState.items.itemMetaLookup[itemId]; if (item.label !== label) { return { ...prevState, items: { ...prevState.items, - itemMetaMap: { ...prevState.items.itemMetaMap, [itemId]: { ...item, label } }, + itemMetaLookup: { ...prevState.items.itemMetaLookup, [itemId]: { ...item, label } }, }, }; } @@ -68,18 +40,20 @@ export const useTreeViewLabel: TreeViewPlugin = ({ } }; + const pluginContextValue = React.useMemo( + () => ({ label: { isItemEditable: params.isItemEditable } }), + [params.isItemEditable], + ); + return { instance: { setEditedItemId, - isItemBeingEdited, updateItemLabel, - isItemEditable, - isTreeViewEditable, - isItemBeingEditedRef, }, publicAPI: { updateItemLabel, }, + contextValue: pluginContextValue, }; }; @@ -104,7 +78,7 @@ useTreeViewLabel.getDefaultizedParams = ({ params, experimentalFeatures }) => { }; useTreeViewLabel.getInitialState = () => ({ - editedItemId: null, + label: { editedItemId: null }, }); useTreeViewLabel.params = { diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.types.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.types.ts index 71b2018f913a..19c293a13996 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.types.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.types.ts @@ -20,29 +20,6 @@ export interface UseTreeViewLabelInstance extends UseTreeViewLabelPublicAPI { * @returns {void}. */ setEditedItemId: (itemId: TreeViewItemId | null) => void; - /** - * Checks if an item is being edited or not. - * @param {TreeViewItemId} itemId The id of the item to check. - * @returns {boolean}. - */ - isItemBeingEdited: (itemId: TreeViewItemId) => boolean; - /** - * Checks if an item is being edited or not. - * Purely internal use, used to avoid unnecessarily calling `updateItemLabel` twice. - * @param {TreeViewItemId} itemId The id of the item to check. - * @returns {boolean}. - */ - isItemBeingEditedRef: (itemId: TreeViewItemId) => boolean; - /** - * Determines if a given item is editable. - * @param {TreeViewItemId} itemId The id of the item to check. - * @returns {boolean} `true` if the item is editable. - */ - isItemEditable: (itemId: TreeViewItemId) => boolean; - /** - * Set to `true` if the Tree View is editable. - */ - isTreeViewEditable: boolean; } export interface UseTreeViewLabelParameters { @@ -70,7 +47,13 @@ export type UseTreeViewLabelDefaultizedParameters = DefaultizedPro >; export interface UseTreeViewLabelState { - editedItemId: string | null; + label: { + editedItemId: string | null; + }; +} + +export interface UseTreeViewLabelContextValue { + label: Pick, 'isItemEditable'>; } export type UseTreeViewLabelSignature = TreeViewPluginSignature<{ @@ -79,6 +62,7 @@ export type UseTreeViewLabelSignature = TreeViewPluginSignature<{ publicAPI: UseTreeViewLabelPublicAPI; instance: UseTreeViewLabelInstance; state: UseTreeViewLabelState; + contextValue: UseTreeViewLabelContextValue; experimentalFeatures: 'labelEditing'; dependencies: [UseTreeViewItemsSignature]; }>; diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.itemPlugin.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.itemPlugin.ts index c2f7d4978cef..2a7d3f936441 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.itemPlugin.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.itemPlugin.ts @@ -5,21 +5,24 @@ import { TreeViewCancellableEvent, } from '../../../models'; import { useTreeViewContext } from '../../TreeViewProvider'; -import { TreeViewInstance, TreeViewItemPlugin } from '../../models'; +import { TreeViewItemPlugin } from '../../models'; import { UseTreeItemCheckboxSlotPropsFromSelection, UseTreeViewSelectionSignature, } from './useTreeViewSelection.types'; import { UseTreeViewItemsSignature } from '../useTreeViewItems'; +import { selectorItemOrderedChildrenIds } from '../useTreeViewItems/useTreeViewItems.selectors'; +import { selectorIsItemSelected } from './useTreeViewSelection.selectors'; +import { TreeViewStore } from '../../utils/TreeViewStore'; function getCheckboxStatus({ itemId, - instance, + store, selectionPropagation, selected, }: { itemId: TreeViewItemId; - instance: TreeViewInstance<[UseTreeViewItemsSignature, UseTreeViewSelectionSignature]>; + store: TreeViewStore<[UseTreeViewItemsSignature, UseTreeViewSelectionSignature]>; selectionPropagation: TreeViewSelectionPropagation; selected: boolean; }) { @@ -30,7 +33,7 @@ function getCheckboxStatus({ }; } - const children = instance.getItemOrderedChildrenIds(itemId); + const children = selectorItemOrderedChildrenIds(store.value, itemId); if (children.length === 0) { return { indeterminate: false, @@ -43,14 +46,14 @@ function getCheckboxStatus({ const traverseDescendants = (itemToTraverseId: TreeViewItemId) => { if (itemToTraverseId !== itemId) { - if (instance.isItemSelected(itemToTraverseId)) { + if (selectorIsItemSelected(store.value, itemToTraverseId)) { hasSelectedDescendant = true; } else { hasUnSelectedDescendant = true; } } - instance.getItemOrderedChildrenIds(itemToTraverseId).forEach(traverseDescendants); + selectorItemOrderedChildrenIds(store.value, itemToTraverseId).forEach(traverseDescendants); }; traverseDescendants(itemId); @@ -66,7 +69,7 @@ export const useTreeViewSelectionItemPlugin: TreeViewItemPlugin = ({ props }) => const { itemId } = props; const { - instance, + store, selection: { disableSelection, checkboxSelection, selectionPropagation }, } = useTreeViewContext<[UseTreeViewItemsSignature, UseTreeViewSelectionSignature]>(); return { @@ -92,7 +95,7 @@ export const useTreeViewSelectionItemPlugin: TreeViewItemPlugin = ({ props }) => }; const checkboxStatus = getCheckboxStatus({ - instance, + store, itemId, selectionPropagation, selected: status.selected, diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.selectors.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.selectors.ts new file mode 100644 index 000000000000..1fb1f745658e --- /dev/null +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.selectors.ts @@ -0,0 +1,16 @@ +import { createSelector, TreeViewRootSelector } from '../../utils/selectors'; +import { UseTreeViewSelectionSignature } from './useTreeViewSelection.types'; + +const selectorTreeViewSelectionState: TreeViewRootSelector = ( + state, +) => state.selection; + +/** + * Check if an item is selected. + * @param {TreeViewState<[UseTreeViewSelectionSignature]>} state The state of the tree view. + * @returns {boolean} `true` if the item is selected, `false` otherwise. + */ +export const selectorIsItemSelected = createSelector( + [selectorTreeViewSelectionState, (_, itemId: string) => itemId], + (selectionState, itemId) => selectionState.selectedItemsMap.has(itemId), +); diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.ts index e2225d707777..01b740645e87 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.ts @@ -1,4 +1,5 @@ import * as React from 'react'; +import useEnhancedEffect from '@mui/utils/useEnhancedEffect'; import { TreeViewPlugin } from '../../models'; import { TreeViewItemId } from '../../../models'; import { @@ -10,6 +11,7 @@ import { } from '../../utils/tree'; import { UseTreeViewSelectionInstance, + UseTreeViewSelectionParameters, UseTreeViewSelectionSignature, } from './useTreeViewSelection.types'; import { @@ -17,29 +19,27 @@ import { propagateSelection, getAddedAndRemovedItems, getLookupFromArray, + createSelectedItemsMap, } from './useTreeViewSelection.utils'; +import { selectorIsItemSelected } from './useTreeViewSelection.selectors'; import { useTreeViewSelectionItemPlugin } from './useTreeViewSelection.itemPlugin'; export const useTreeViewSelection: TreeViewPlugin = ({ - instance, + store, params, models, }) => { const lastSelectedItem = React.useRef(null); const lastSelectedRange = React.useRef<{ [itemId: string]: boolean }>({}); - const selectedItemsMap = React.useMemo(() => { - const temp = new Map(); - if (Array.isArray(models.selectedItems.value)) { - models.selectedItems.value.forEach((id) => { - temp.set(id, true); - }); - } else if (models.selectedItems.value != null) { - temp.set(models.selectedItems.value, true); - } - - return temp; - }, [models.selectedItems.value]); + useEnhancedEffect(() => { + store.update((prevState) => ({ + ...prevState, + selection: { + selectedItemsMap: createSelectedItemsMap(models.selectedItems.value), + }, + })); + }, [store, models.selectedItems.value]); const setSelectedItems = ( event: React.SyntheticEvent, @@ -53,7 +53,7 @@ export const useTreeViewSelection: TreeViewPlugin (params.selectionPropagation.descendants || params.selectionPropagation.parents) ) { cleanModel = propagateSelection({ - instance, + store, selectionPropagation: params.selectionPropagation, newModel: newModel as string[], oldModel: models.selectedItems.value as string[], @@ -66,7 +66,7 @@ export const useTreeViewSelection: TreeViewPlugin if (params.onItemSelectionToggle) { if (params.multiSelect) { const changes = getAddedAndRemovedItems({ - instance, + store, newModel: cleanModel as string[], oldModel: models.selectedItems.value as string[], }); @@ -97,8 +97,6 @@ export const useTreeViewSelection: TreeViewPlugin models.selectedItems.setControlledValue(cleanModel); }; - const isItemSelected = (itemId: string) => selectedItemsMap.has(itemId); - const selectItem: UseTreeViewSelectionInstance['selectItem'] = ({ event, itemId, @@ -112,7 +110,7 @@ export const useTreeViewSelection: TreeViewPlugin let newSelected: typeof models.selectedItems.value; if (keepExistingSelection) { const cleanSelectedItems = convertSelectedItemsToArray(models.selectedItems.value); - const isSelectedBefore = instance.isItemSelected(itemId); + const isSelectedBefore = selectorIsItemSelected(store.value, itemId); if (isSelectedBefore && (shouldBeSelected === false || shouldBeSelected == null)) { newSelected = cleanSelectedItems.filter((id) => id !== itemId); } else if (!isSelectedBefore && (shouldBeSelected === true || shouldBeSelected == null)) { @@ -124,7 +122,7 @@ export const useTreeViewSelection: TreeViewPlugin // eslint-disable-next-line no-lonely-if if ( shouldBeSelected === false || - (shouldBeSelected == null && instance.isItemSelected(itemId)) + (shouldBeSelected == null && selectorIsItemSelected(store.value, itemId)) ) { newSelected = params.multiSelect ? [] : null; } else { @@ -135,7 +133,7 @@ export const useTreeViewSelection: TreeViewPlugin setSelectedItems( event, newSelected, - // If shouldBeSelected === instance.isItemSelect(itemId), we still want to propagate the select. + // If shouldBeSelected === selectorIsItemSelected(store, itemId), we still want to propagate the select. // This is useful when the element is in an indeterminate state. [itemId], ); @@ -158,7 +156,7 @@ export const useTreeViewSelection: TreeViewPlugin // Add to the model the items that are part of the new range and not already part of the model. const selectedItemsLookup = getLookupFromArray(newSelectedItems); - const range = getNonDisabledItemsInRange(instance, start, end); + const range = getNonDisabledItemsInRange(store.value, start, end); const itemsToAddToModel = range.filter((id) => !selectedItemsLookup[id]); newSelectedItems = newSelectedItems.concat(itemsToAddToModel); @@ -168,17 +166,17 @@ export const useTreeViewSelection: TreeViewPlugin const expandSelectionRange = (event: React.SyntheticEvent, itemId: string) => { if (lastSelectedItem.current != null) { - const [start, end] = findOrderInTremauxTree(instance, itemId, lastSelectedItem.current); + const [start, end] = findOrderInTremauxTree(store.value, itemId, lastSelectedItem.current); selectRange(event, [start, end]); } }; const selectRangeFromStartToItem = (event: React.SyntheticEvent, itemId: string) => { - selectRange(event, [getFirstNavigableItem(instance), itemId]); + selectRange(event, [getFirstNavigableItem(store.value), itemId]); }; const selectRangeFromItemToEnd = (event: React.SyntheticEvent, itemId: string) => { - selectRange(event, [itemId, getLastNavigableItem(instance)]); + selectRange(event, [itemId, getLastNavigableItem(store.value)]); }; const selectAllNavigableItems = (event: React.SyntheticEvent) => { @@ -186,7 +184,7 @@ export const useTreeViewSelection: TreeViewPlugin return; } - const navigableItems = getAllNavigableItems(instance); + const navigableItems = getAllNavigableItems(store.value); setSelectedItems(event, navigableItems); lastSelectedRange.current = getLookupFromArray(navigableItems); @@ -223,6 +221,27 @@ export const useTreeViewSelection: TreeViewPlugin setSelectedItems(event, newSelectedItems); }; + const pluginContextValue = React.useMemo( + () => ({ + selection: { + multiSelect: params.multiSelect, + checkboxSelection: params.checkboxSelection, + disableSelection: params.disableSelection, + selectionPropagation: { + descendants: params.selectionPropagation.descendants, + parents: params.selectionPropagation.parents, + }, + }, + }), + [ + params.multiSelect, + params.checkboxSelection, + params.disableSelection, + params.selectionPropagation.descendants, + params.selectionPropagation.parents, + ], + ); + return { getRootProps: () => ({ 'aria-multiselectable': params.multiSelect, @@ -231,7 +250,6 @@ export const useTreeViewSelection: TreeViewPlugin selectItem, }, instance: { - isItemSelected, selectItem, selectAllNavigableItems, expandSelectionRange, @@ -239,14 +257,7 @@ export const useTreeViewSelection: TreeViewPlugin selectRangeFromItemToEnd, selectItemFromArrowNavigation, }, - contextValue: { - selection: { - multiSelect: params.multiSelect, - checkboxSelection: params.checkboxSelection, - disableSelection: params.disableSelection, - selectionPropagation: params.selectionPropagation, - }, - }, + contextValue: pluginContextValue, }; }; @@ -260,6 +271,8 @@ useTreeViewSelection.models = { const DEFAULT_SELECTED_ITEMS: string[] = []; +const EMPTY_SELECTION_PROPAGATION: UseTreeViewSelectionParameters['selectionPropagation'] = + {}; useTreeViewSelection.getDefaultizedParams = ({ params }) => ({ ...params, disableSelection: params.disableSelection ?? false, @@ -267,7 +280,23 @@ useTreeViewSelection.getDefaultizedParams = ({ params }) => ({ checkboxSelection: params.checkboxSelection ?? false, defaultSelectedItems: params.defaultSelectedItems ?? (params.multiSelect ? DEFAULT_SELECTED_ITEMS : null), - selectionPropagation: params.selectionPropagation ?? {}, + selectionPropagation: params.selectionPropagation ?? EMPTY_SELECTION_PROPAGATION, +}); + +useTreeViewSelection.getInitialState = (params) => ({ + selection: { + selectedItemsMap: createSelectedItemsMap( + params.selectedItems === undefined ? params.defaultSelectedItems : params.selectedItems, + ), + }, +}); + +useTreeViewSelection.getInitialState = (params) => ({ + selection: { + selectedItemsMap: createSelectedItemsMap( + params.selectedItems === undefined ? params.defaultSelectedItems : params.selectedItems, + ), + }, }); useTreeViewSelection.params = { diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.types.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.types.ts index 78f313320d04..573055dbe75c 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.types.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.types.ts @@ -23,12 +23,6 @@ export interface UseTreeViewSelectionPublicAPI { } export interface UseTreeViewSelectionInstance extends UseTreeViewSelectionPublicAPI { - /** - * Check if an item is selected. - * @param {TreeViewItemId} itemId The id of the item to check. - * @returns {boolean} `true` if the item is selected, `false` otherwise. - */ - isItemSelected: (itemId: string) => boolean; /** * Select all the navigable items in the tree. * @param {React.SyntheticEvent} event The DOM event that triggered the change. @@ -145,6 +139,12 @@ export type UseTreeViewSelectionDefaultizedParameters | 'selectionPropagation' >; +export interface UseTreeViewSelectionState { + selection: { + selectedItemsMap: Map; + }; +} + interface UseTreeViewSelectionContextValue { selection: Pick< UseTreeViewSelectionDefaultizedParameters, @@ -159,6 +159,7 @@ export type UseTreeViewSelectionSignature = TreeViewPluginSignature<{ publicAPI: UseTreeViewSelectionPublicAPI; contextValue: UseTreeViewSelectionContextValue; modelNames: 'selectedItems'; + state: UseTreeViewSelectionState; dependencies: [ UseTreeViewItemsSignature, UseTreeViewExpansionSignature, diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.utils.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.utils.ts index 29913562a4b6..ed99d6f9fa75 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.utils.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.utils.ts @@ -1,7 +1,11 @@ import { TreeViewItemId, TreeViewSelectionPropagation } from '../../../models'; -import { TreeViewInstance } from '../../models'; -import { UseTreeViewItemsSignature } from '../useTreeViewItems'; +import { TreeViewUsedStore } from '../../models'; import { UseTreeViewSelectionSignature } from './useTreeViewSelection.types'; +import { selectorIsItemSelected } from './useTreeViewSelection.selectors'; +import { + selectorItemOrderedChildrenIds, + selectorItemParentId, +} from '../useTreeViewItems/useTreeViewItems.selectors'; /** * Transform the `selectedItems` model to be an array if it was a string or null. @@ -20,6 +24,14 @@ export const convertSelectedItemsToArray = (model: string[] | string | null): st return []; }; +export const createSelectedItemsMap = (selectedItems: string | string[] | null) => { + const selectedItemsMap = new Map(); + convertSelectedItemsToArray(selectedItems).forEach((id) => { + selectedItemsMap.set(id, true); + }); + return selectedItemsMap; +}; + export const getLookupFromArray = (array: string[]) => { const lookup: { [itemId: string]: true } = {}; array.forEach((itemId) => { @@ -29,30 +41,30 @@ export const getLookupFromArray = (array: string[]) => { }; export const getAddedAndRemovedItems = ({ - instance, + store, oldModel, newModel, }: { - instance: TreeViewInstance<[UseTreeViewSelectionSignature]>; + store: TreeViewUsedStore; oldModel: TreeViewItemId[]; newModel: TreeViewItemId[]; }) => { - const newModelLookup = getLookupFromArray(newModel); + const newModelLookup = createSelectedItemsMap(newModel); return { - added: newModel.filter((itemId) => !instance.isItemSelected(itemId)), - removed: oldModel.filter((itemId) => !newModelLookup[itemId]), + added: newModel.filter((itemId) => !selectorIsItemSelected(store.value, itemId)), + removed: oldModel.filter((itemId) => !newModelLookup.has(itemId)), }; }; export const propagateSelection = ({ - instance, + store, selectionPropagation, newModel, oldModel, additionalItemsToPropagate, }: { - instance: TreeViewInstance<[UseTreeViewItemsSignature, UseTreeViewSelectionSignature]>; + store: TreeViewUsedStore; selectionPropagation: TreeViewSelectionPropagation; newModel: TreeViewItemId[]; oldModel: TreeViewItemId[]; @@ -66,7 +78,7 @@ export const propagateSelection = ({ const newModelLookup = getLookupFromArray(newModel); const changes = getAddedAndRemovedItems({ - instance, + store, newModel, oldModel, }); @@ -89,7 +101,7 @@ export const propagateSelection = ({ newModelLookup[itemId] = true; } - instance.getItemOrderedChildrenIds(itemId).forEach(selectDescendants); + selectorItemOrderedChildrenIds(store.value, itemId).forEach(selectDescendants); }; selectDescendants(addedItemId); @@ -101,17 +113,17 @@ export const propagateSelection = ({ return false; } - const children = instance.getItemOrderedChildrenIds(itemId); + const children = selectorItemOrderedChildrenIds(store.value, itemId); return children.every(checkAllDescendantsSelected); }; const selectParents = (itemId: TreeViewItemId) => { - const parentId = instance.getItemMeta(itemId).parentId; + const parentId = selectorItemParentId(store.value, itemId); if (parentId == null) { return; } - const siblings = instance.getItemOrderedChildrenIds(parentId); + const siblings = selectorItemOrderedChildrenIds(store.value, parentId); if (siblings.every(checkAllDescendantsSelected)) { shouldRegenerateModel = true; newModelLookup[parentId] = true; @@ -124,14 +136,14 @@ export const propagateSelection = ({ changes.removed.forEach((removedItemId) => { if (selectionPropagation.parents) { - let parentId = instance.getItemMeta(removedItemId).parentId; + let parentId = selectorItemParentId(store.value, removedItemId); while (parentId != null) { if (newModelLookup[parentId]) { shouldRegenerateModel = true; delete newModelLookup[parentId]; } - parentId = instance.getItemMeta(parentId).parentId; + parentId = selectorItemParentId(store.value, parentId); } } @@ -142,7 +154,7 @@ export const propagateSelection = ({ delete newModelLookup[itemId]; } - instance.getItemOrderedChildrenIds(itemId).forEach(deSelectDescendants); + selectorItemOrderedChildrenIds(store.value, itemId).forEach(deSelectDescendants); }; deSelectDescendants(removedItemId); diff --git a/packages/x-tree-view/src/internals/useTreeView/useTreeView.ts b/packages/x-tree-view/src/internals/useTreeView/useTreeView.ts index 09f41ff67af7..ea0f06caeeeb 100644 --- a/packages/x-tree-view/src/internals/useTreeView/useTreeView.ts +++ b/packages/x-tree-view/src/internals/useTreeView/useTreeView.ts @@ -5,9 +5,9 @@ import { TreeViewAnyPluginSignature, TreeViewInstance, TreeViewPlugin, - MergeSignaturesProperty, TreeViewPublicAPI, ConvertSignaturesIntoPlugins, + TreeViewState, } from '../models'; import { UseTreeViewBaseProps, @@ -19,6 +19,7 @@ import { useTreeViewModels } from './useTreeViewModels'; import { TREE_VIEW_CORE_PLUGINS, TreeViewCorePluginSignatures } from '../corePlugins'; import { extractPluginParamsFromProps } from './extractPluginParamsFromProps'; import { useTreeViewBuildContext } from './useTreeViewBuildContext'; +import { TreeViewStore } from '../utils/TreeViewStore'; export function useTreeViewApiInitialization( inputApiRef: React.MutableRefObject | undefined, @@ -35,6 +36,7 @@ export function useTreeViewApiInitialization( return fallbackPublicApiRef.current; } +let globalId: number = 0; export const useTreeView = < TSignatures extends readonly TreeViewAnyPluginSignature[], TProps extends Partial>, @@ -47,10 +49,14 @@ export const useTreeView = < ...TreeViewCorePluginSignatures, ...TSignatures, ]; - const plugins = [ - ...TREE_VIEW_CORE_PLUGINS, - ...inPlugins, - ] as unknown as ConvertSignaturesIntoPlugins; + const plugins = React.useMemo( + () => + [ + ...TREE_VIEW_CORE_PLUGINS, + ...inPlugins, + ] as unknown as ConvertSignaturesIntoPlugins, + [inPlugins], + ); const { pluginParams, forwardedProps, apiRef, experimentalFeatures, slots, slotProps } = extractPluginParamsFromProps({ @@ -65,27 +71,35 @@ export const useTreeView = < const innerRootRef: React.RefObject = React.useRef(null); const handleRootRef = useForkRef(innerRootRef, rootRef); - const contextValue = useTreeViewBuildContext({ - plugins, - instance, - publicAPI, - rootRef: innerRootRef, - }); + const storeRef = React.useRef | null>(null); + if (storeRef.current == null) { + globalId += 1; + const initialState = { + cacheKey: { id: globalId }, + } as TreeViewState; - const [state, setState] = React.useState(() => { - const temp = {} as MergeSignaturesProperty; plugins.forEach((plugin) => { if (plugin.getInitialState) { - Object.assign(temp, plugin.getInitialState(pluginParams)); + Object.assign(initialState, plugin.getInitialState(pluginParams)); } }); - return temp; + storeRef.current = new TreeViewStore(initialState); + } + + const baseContextValue = useTreeViewBuildContext({ + plugins, + instance, + publicAPI, + store: storeRef.current as TreeViewStore, + rootRef: innerRootRef, }); const rootPropsGetters: (( otherHandlers: TOther, ) => React.HTMLAttributes)[] = []; + + const pluginContextValues: any[] = []; const runPlugin = (plugin: TreeViewPlugin) => { const pluginResponse = plugin({ instance, @@ -93,11 +107,10 @@ export const useTreeView = < slots, slotProps, experimentalFeatures, - state, - setState, rootRef: innerRootRef, models, plugins, + store: storeRef.current as TreeViewStore, }); if (pluginResponse.getRootProps) { @@ -113,7 +126,7 @@ export const useTreeView = < } if (pluginResponse.contextValue) { - Object.assign(contextValue, pluginResponse.contextValue); + pluginContextValues.push(pluginResponse.contextValue); } }; @@ -136,10 +149,15 @@ export const useTreeView = < return rootProps; }; + const contextValue = React.useMemo(() => { + const copiedBaseContextValue = { ...baseContextValue }; + return Object.assign(copiedBaseContextValue, ...pluginContextValues); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [baseContextValue, ...pluginContextValues]); + return { getRootProps, rootRef: handleRootRef, contextValue, - instance, }; }; diff --git a/packages/x-tree-view/src/internals/useTreeView/useTreeView.types.ts b/packages/x-tree-view/src/internals/useTreeView/useTreeView.types.ts index d4ebb91b39c4..f45c98934a0c 100644 --- a/packages/x-tree-view/src/internals/useTreeView/useTreeView.types.ts +++ b/packages/x-tree-view/src/internals/useTreeView/useTreeView.types.ts @@ -5,7 +5,6 @@ import { TreeViewAnyPluginSignature, ConvertSignaturesIntoPlugins, MergeSignaturesProperty, - TreeViewInstance, TreeViewPublicAPI, TreeViewExperimentalFeatures, } from '../models'; @@ -40,5 +39,4 @@ export interface UseTreeViewReturnValue UseTreeViewRootSlotProps; rootRef: React.RefCallback | null; contextValue: TreeViewContextValue; - instance: TreeViewInstance; } diff --git a/packages/x-tree-view/src/internals/useTreeView/useTreeViewBuildContext.ts b/packages/x-tree-view/src/internals/useTreeView/useTreeViewBuildContext.ts index 288a308346ce..d8c75ccbe3f5 100644 --- a/packages/x-tree-view/src/internals/useTreeView/useTreeViewBuildContext.ts +++ b/packages/x-tree-view/src/internals/useTreeView/useTreeViewBuildContext.ts @@ -11,117 +11,140 @@ import { TreeViewItemPluginSlotPropsEnhancerParams, } from '../models'; import { TreeViewCorePluginSignatures } from '../corePlugins'; +import { TreeViewStore } from '../utils/TreeViewStore'; export const useTreeViewBuildContext = ({ plugins, instance, publicAPI, + store, rootRef, }: { plugins: ConvertSignaturesIntoPlugins; instance: TreeViewInstance; publicAPI: TreeViewPublicAPI; + store: TreeViewStore; rootRef: React.RefObject; }): TreeViewContextValue => { - const runItemPlugins: TreeViewItemPluginsRunner = (itemPluginProps) => { - let finalRootRef: React.RefCallback | null = null; - let finalContentRef: React.RefCallback | null = null; - const pluginPropEnhancers: TreeViewItemPluginSlotPropsEnhancers[] = []; - const pluginPropEnhancersNames: { [key in keyof TreeViewItemPluginSlotPropsEnhancers]?: true } = - {}; + const runItemPlugins = React.useCallback( + (itemPluginProps) => { + let finalRootRef: React.RefCallback | null = null; + let finalContentRef: React.RefCallback | null = null; + const pluginPropEnhancers: TreeViewItemPluginSlotPropsEnhancers[] = []; + const pluginPropEnhancersNames: { + [key in keyof TreeViewItemPluginSlotPropsEnhancers]?: true; + } = {}; - plugins.forEach((plugin) => { - if (!plugin.itemPlugin) { - return; - } + plugins.forEach((plugin) => { + if (!plugin.itemPlugin) { + return; + } - const itemPluginResponse = plugin.itemPlugin({ - props: itemPluginProps, - rootRef: finalRootRef, - contentRef: finalContentRef, - }); - if (itemPluginResponse?.rootRef) { - finalRootRef = itemPluginResponse.rootRef; - } - if (itemPluginResponse?.contentRef) { - finalContentRef = itemPluginResponse.contentRef; - } - if (itemPluginResponse?.propsEnhancers) { - pluginPropEnhancers.push(itemPluginResponse.propsEnhancers); - - // Prepare a list of all the slots which are enhanced by at least one plugin - Object.keys(itemPluginResponse.propsEnhancers).forEach((propsEnhancerName) => { - pluginPropEnhancersNames[ - propsEnhancerName as keyof TreeViewItemPluginSlotPropsEnhancers - ] = true; + const itemPluginResponse = plugin.itemPlugin({ + props: itemPluginProps, + rootRef: finalRootRef, + contentRef: finalContentRef, }); - } - }); + if (itemPluginResponse?.rootRef) { + finalRootRef = itemPluginResponse.rootRef; + } + if (itemPluginResponse?.contentRef) { + finalContentRef = itemPluginResponse.contentRef; + } + if (itemPluginResponse?.propsEnhancers) { + pluginPropEnhancers.push(itemPluginResponse.propsEnhancers); - const resolvePropsEnhancer = - (currentSlotName: keyof TreeViewItemPluginSlotPropsEnhancers) => - (currentSlotParams: TreeViewItemPluginSlotPropsEnhancerParams) => { - const enhancedProps = {}; - pluginPropEnhancers.forEach((propsEnhancersForCurrentPlugin) => { - const propsEnhancerForCurrentPluginAndSlot = - propsEnhancersForCurrentPlugin[currentSlotName]; - if (propsEnhancerForCurrentPluginAndSlot != null) { - Object.assign(enhancedProps, propsEnhancerForCurrentPluginAndSlot(currentSlotParams)); - } - }); + // Prepare a list of all the slots which are enhanced by at least one plugin + Object.keys(itemPluginResponse.propsEnhancers).forEach((propsEnhancerName) => { + pluginPropEnhancersNames[ + propsEnhancerName as keyof TreeViewItemPluginSlotPropsEnhancers + ] = true; + }); + } + }); - return enhancedProps; - }; + const resolvePropsEnhancer = + (currentSlotName: keyof TreeViewItemPluginSlotPropsEnhancers) => + (currentSlotParams: TreeViewItemPluginSlotPropsEnhancerParams) => { + const enhancedProps = {}; + pluginPropEnhancers.forEach((propsEnhancersForCurrentPlugin) => { + const propsEnhancerForCurrentPluginAndSlot = + propsEnhancersForCurrentPlugin[currentSlotName]; + if (propsEnhancerForCurrentPluginAndSlot != null) { + Object.assign(enhancedProps, propsEnhancerForCurrentPluginAndSlot(currentSlotParams)); + } + }); - const propsEnhancers = Object.fromEntries( - Object.keys(pluginPropEnhancersNames).map( - (propEnhancerName) => - [ - propEnhancerName, - resolvePropsEnhancer(propEnhancerName as keyof TreeViewItemPluginSlotPropsEnhancers), - ] as const, - ), - ); + return enhancedProps; + }; - return { - contentRef: finalContentRef, - rootRef: finalRootRef, - propsEnhancers, - }; - }; + const propsEnhancers = Object.fromEntries( + Object.keys(pluginPropEnhancersNames).map( + (propEnhancerName) => + [ + propEnhancerName, + resolvePropsEnhancer(propEnhancerName as keyof TreeViewItemPluginSlotPropsEnhancers), + ] as const, + ), + ); + + return { + contentRef: finalContentRef, + rootRef: finalRootRef, + propsEnhancers, + }; + }, + [plugins], + ); - const wrapItem: TreeItemWrapper = ({ itemId, children }) => { - let finalChildren: React.ReactNode = children; - // The wrappers are reversed to ensure that the first wrapper is the outermost one. - for (let i = plugins.length - 1; i >= 0; i -= 1) { - const plugin = plugins[i]; - if (plugin.wrapItem) { - finalChildren = plugin.wrapItem({ itemId, children: finalChildren, instance }); + const wrapItem = React.useCallback>( + ({ itemId, children, idAttribute }) => { + let finalChildren: React.ReactNode = children; + // The wrappers are reversed to ensure that the first wrapper is the outermost one. + for (let i = plugins.length - 1; i >= 0; i -= 1) { + const plugin = plugins[i]; + if (plugin.wrapItem) { + finalChildren = plugin.wrapItem({ + instance, + itemId, + children: finalChildren, + idAttribute, + }); + } } - } - return finalChildren; - }; + return finalChildren; + }, + [plugins, instance], + ); - const wrapRoot: TreeRootWrapper = ({ children }) => { - let finalChildren: React.ReactNode = children; - // The wrappers are reversed to ensure that the first wrapper is the outermost one. - for (let i = plugins.length - 1; i >= 0; i -= 1) { - const plugin = plugins[i]; - if (plugin.wrapRoot) { - finalChildren = plugin.wrapRoot({ children: finalChildren, instance }); + const wrapRoot = React.useCallback( + ({ children }) => { + let finalChildren: React.ReactNode = children; + // The wrappers are reversed to ensure that the first wrapper is the outermost one. + for (let i = plugins.length - 1; i >= 0; i -= 1) { + const plugin = plugins[i]; + if (plugin.wrapRoot) { + finalChildren = plugin.wrapRoot({ + children: finalChildren, + }); + } } - } - return finalChildren; - }; + return finalChildren; + }, + [plugins], + ); - return { - runItemPlugins, - wrapItem, - wrapRoot, - instance, - rootRef, - publicAPI, - } as TreeViewContextValue; + return React.useMemo(() => { + return { + runItemPlugins, + wrapItem, + wrapRoot, + instance, + publicAPI, + store, + rootRef, + } as TreeViewContextValue; + }, [runItemPlugins, wrapItem, wrapRoot, instance, publicAPI, store, rootRef]); }; diff --git a/packages/x-tree-view/src/internals/utils/TreeViewStore.ts b/packages/x-tree-view/src/internals/utils/TreeViewStore.ts new file mode 100644 index 000000000000..cf36a1bc6f8f --- /dev/null +++ b/packages/x-tree-view/src/internals/utils/TreeViewStore.ts @@ -0,0 +1,37 @@ +import type { TreeViewAnyPluginSignature, TreeViewState } from '../models'; + +type Listener = (value: T) => void; + +export type StoreUpdater = ( + prevState: TreeViewState, +) => TreeViewState; + +export class TreeViewStore { + public value: TreeViewState; + + private listeners: Set>>; + + constructor(value: TreeViewState) { + this.value = value; + this.listeners = new Set(); + } + + public subscribe = (fn: Listener>) => { + this.listeners.add(fn); + return () => { + this.listeners.delete(fn); + }; + }; + + public getSnapshot = () => { + return this.value; + }; + + public update = (updater: StoreUpdater) => { + const newState = updater(this.value); + if (newState !== this.value) { + this.value = newState; + this.listeners.forEach((l) => l(newState)); + } + }; +} diff --git a/packages/x-tree-view/src/internals/utils/selectors.ts b/packages/x-tree-view/src/internals/utils/selectors.ts new file mode 100644 index 000000000000..5c76cb78fc53 --- /dev/null +++ b/packages/x-tree-view/src/internals/utils/selectors.ts @@ -0,0 +1,54 @@ +import { lruMemoize, createSelectorCreator, CreateSelectorFunction } from 'reselect'; +import { TreeViewAnyPluginSignature, TreeViewState, TreeViewStateCacheKey } from '../models'; + +const reselectCreateSelector = createSelectorCreator({ + memoize: lruMemoize, + memoizeOptions: { + maxSize: 1, + equalityCheck: Object.is, + }, +}); + +const cache = new WeakMap< + TreeViewStateCacheKey, + Map, any> +>(); + +export type TreeViewRootSelector = < + TSignatures extends [TSignature], +>( + state: TreeViewState, +) => TSignature['state'][keyof TSignature['state']]; + +export type TreeViewSelector = (state: TState, args: TArgs) => TResult; + +/** + * Method wrapping reselect's createSelector to provide caching for tree view instances. + * + */ +export const createSelector = ((...createSelectorArgs: any) => { + const selector: TreeViewSelector, any, any> = (state, selectorArgs) => { + const cacheKey = state.cacheKey; + + // If there is no cache for the current tree view instance, create one. + let cacheForCurrentTreeViewInstance = cache.get(cacheKey); + if (!cacheForCurrentTreeViewInstance) { + cacheForCurrentTreeViewInstance = new Map(); + cache.set(cacheKey, cacheForCurrentTreeViewInstance); + } + + // If there is a cached selector, execute it. + const cachedSelector = cacheForCurrentTreeViewInstance.get(createSelectorArgs); + if (cachedSelector) { + return cachedSelector(state, selectorArgs); + } + + // Otherwise, create a new selector and cache it and execute it. + const fn = reselectCreateSelector(...createSelectorArgs); + cacheForCurrentTreeViewInstance.set(createSelectorArgs, fn); + + return fn(state, selectorArgs); + }; + + return selector; +}) as unknown as CreateSelectorFunction; diff --git a/packages/x-tree-view/src/internals/utils/tree.ts b/packages/x-tree-view/src/internals/utils/tree.ts index deacfe7bbb71..60ef4b3215fb 100644 --- a/packages/x-tree-view/src/internals/utils/tree.ts +++ b/packages/x-tree-view/src/internals/utils/tree.ts @@ -1,14 +1,26 @@ -import { TreeViewInstance } from '../models'; +import { TreeViewItemMeta, TreeViewState } from '../models'; import type { UseTreeViewExpansionSignature } from '../plugins/useTreeViewExpansion'; +import { + selectorIsItemExpandable, + selectorIsItemExpanded, +} from '../plugins/useTreeViewExpansion/useTreeViewExpansion.selectors'; import type { UseTreeViewItemsSignature } from '../plugins/useTreeViewItems'; +import { + selectorCanItemBeFocused, + selectorIsItemDisabled, + selectorItemIndex, + selectorItemMeta, + selectorItemOrderedChildrenIds, + selectorItemParentId, +} from '../plugins/useTreeViewItems/useTreeViewItems.selectors'; const getLastNavigableItemInArray = ( - instance: TreeViewInstance<[UseTreeViewItemsSignature]>, + state: TreeViewState<[UseTreeViewItemsSignature]>, items: string[], ) => { // Equivalent to Array.prototype.findLastIndex let itemIndex = items.length - 1; - while (itemIndex >= 0 && !instance.isItemNavigable(items[itemIndex])) { + while (itemIndex >= 0 && !selectorCanItemBeFocused(state, items[itemIndex])) { itemIndex -= 1; } @@ -20,12 +32,16 @@ const getLastNavigableItemInArray = ( }; export const getPreviousNavigableItem = ( - instance: TreeViewInstance<[UseTreeViewItemsSignature, UseTreeViewExpansionSignature]>, + state: TreeViewState<[UseTreeViewItemsSignature, UseTreeViewExpansionSignature]>, itemId: string, ): string | null => { - const itemMeta = instance.getItemMeta(itemId); - const siblings = instance.getItemOrderedChildrenIds(itemMeta.parentId); - const itemIndex = instance.getItemIndex(itemId); + const itemMeta = selectorItemMeta(state, itemId); + if (!itemMeta) { + return null; + } + + const siblings = selectorItemOrderedChildrenIds(state, itemMeta.parentId); + const itemIndex = selectorItemIndex(state, itemId); // TODO: What should we do if the parent is not navigable? if (itemIndex === 0) { @@ -35,7 +51,7 @@ export const getPreviousNavigableItem = ( // Finds the previous navigable sibling. let previousNavigableSiblingIndex = itemIndex - 1; while ( - !instance.isItemNavigable(siblings[previousNavigableSiblingIndex]) && + !selectorCanItemBeFocused(state, siblings[previousNavigableSiblingIndex]) && previousNavigableSiblingIndex >= 0 ) { previousNavigableSiblingIndex -= 1; @@ -48,73 +64,73 @@ export const getPreviousNavigableItem = ( } // Otherwise, we can try to go up a level and find the previous navigable item. - return getPreviousNavigableItem(instance, itemMeta.parentId); + return getPreviousNavigableItem(state, itemMeta.parentId); } // Finds the last navigable ancestor of the previous navigable sibling. let currentItemId: string = siblings[previousNavigableSiblingIndex]; let lastNavigableChild = getLastNavigableItemInArray( - instance, - instance.getItemOrderedChildrenIds(currentItemId), + state, + selectorItemOrderedChildrenIds(state, currentItemId), ); - while (instance.isItemExpanded(currentItemId) && lastNavigableChild != null) { + while (selectorIsItemExpanded(state, currentItemId) && lastNavigableChild != null) { currentItemId = lastNavigableChild; - lastNavigableChild = instance - .getItemOrderedChildrenIds(currentItemId) - .find(instance.isItemNavigable); + lastNavigableChild = selectorItemOrderedChildrenIds(state, currentItemId).find((childId) => + selectorCanItemBeFocused(state, childId), + ); } return currentItemId; }; export const getNextNavigableItem = ( - instance: TreeViewInstance<[UseTreeViewExpansionSignature, UseTreeViewItemsSignature]>, + state: TreeViewState<[UseTreeViewItemsSignature, UseTreeViewExpansionSignature]>, itemId: string, ) => { // If the item is expanded and has some navigable children, return the first of them. - if (instance.isItemExpanded(itemId)) { - const firstNavigableChild = instance - .getItemOrderedChildrenIds(itemId) - .find(instance.isItemNavigable); + if (selectorIsItemExpanded(state, itemId)) { + const firstNavigableChild = selectorItemOrderedChildrenIds(state, itemId).find((childId) => + selectorCanItemBeFocused(state, childId), + ); if (firstNavigableChild != null) { return firstNavigableChild; } } - let itemMeta = instance.getItemMeta(itemId); + let itemMeta = selectorItemMeta(state, itemId); while (itemMeta != null) { // Try to find the first navigable sibling after the current item. - const siblings = instance.getItemOrderedChildrenIds(itemMeta.parentId); - const currentItemIndex = instance.getItemIndex(itemMeta.id); + const siblings = selectorItemOrderedChildrenIds(state, itemMeta.parentId); + const currentItemIndex = selectorItemIndex(state, itemMeta.id); if (currentItemIndex < siblings.length - 1) { let nextItemIndex = currentItemIndex + 1; while ( - !instance.isItemNavigable(siblings[nextItemIndex]) && + !selectorCanItemBeFocused(state, siblings[nextItemIndex]) && nextItemIndex < siblings.length - 1 ) { nextItemIndex += 1; } - if (instance.isItemNavigable(siblings[nextItemIndex])) { + if (selectorCanItemBeFocused(state, siblings[nextItemIndex])) { return siblings[nextItemIndex]; } } // If the sibling does not exist, go up a level to the parent and try again. - itemMeta = instance.getItemMeta(itemMeta.parentId!); + itemMeta = selectorItemMeta(state, itemMeta.parentId!); } return null; }; export const getLastNavigableItem = ( - instance: TreeViewInstance<[UseTreeViewExpansionSignature, UseTreeViewItemsSignature]>, + state: TreeViewState<[UseTreeViewExpansionSignature, UseTreeViewItemsSignature]>, ) => { let itemId: string | null = null; - while (itemId == null || instance.isItemExpanded(itemId)) { - const children = instance.getItemOrderedChildrenIds(itemId); - const lastNavigableChild = getLastNavigableItemInArray(instance, children); + while (itemId == null || selectorIsItemExpanded(state, itemId)) { + const children = selectorItemOrderedChildrenIds(state, itemId); + const lastNavigableChild = getLastNavigableItemInArray(state, children); // The item has no navigable children. if (lastNavigableChild == null) { @@ -127,8 +143,12 @@ export const getLastNavigableItem = ( return itemId!; }; -export const getFirstNavigableItem = (instance: TreeViewInstance<[UseTreeViewItemsSignature]>) => - instance.getItemOrderedChildrenIds(null).find(instance.isItemNavigable)!; +export const getFirstNavigableItem = ( + state: TreeViewState<[UseTreeViewExpansionSignature, UseTreeViewItemsSignature]>, +) => + selectorItemOrderedChildrenIds(state, null).find((itemId) => + selectorCanItemBeFocused(state, itemId), + )!; /** * This is used to determine the start and end of a selection range so @@ -145,7 +165,7 @@ export const getFirstNavigableItem = (instance: TreeViewInstance<[UseTreeViewIte * https://en.wikipedia.org/wiki/Tr%C3%A9maux_tree */ export const findOrderInTremauxTree = ( - instance: TreeViewInstance<[UseTreeViewItemsSignature]>, + state: TreeViewState<[UseTreeViewExpansionSignature, UseTreeViewItemsSignature]>, itemAId: string, itemBId: string, ) => { @@ -153,8 +173,12 @@ export const findOrderInTremauxTree = ( return [itemAId, itemBId]; } - const itemMetaA = instance.getItemMeta(itemAId); - const itemMetaB = instance.getItemMeta(itemBId); + const itemMetaA = selectorItemMeta(state, itemAId); + const itemMetaB = selectorItemMeta(state, itemBId); + + if (!itemMetaA || !itemMetaB) { + return [itemAId, itemBId]; + } if (itemMetaA.parentId === itemMetaB.id || itemMetaB.parentId === itemMetaA.id) { return itemMetaB.parentId === itemMetaA.id @@ -180,7 +204,7 @@ export const findOrderInTremauxTree = ( aAncestorIsCommon = bFamily.indexOf(aAncestor) !== -1; continueA = aAncestor !== null; if (!aAncestorIsCommon && continueA) { - aAncestor = instance.getItemMeta(aAncestor!).parentId; + aAncestor = selectorItemParentId(state, aAncestor!); } } @@ -189,13 +213,13 @@ export const findOrderInTremauxTree = ( bAncestorIsCommon = aFamily.indexOf(bAncestor) !== -1; continueB = bAncestor !== null; if (!bAncestorIsCommon && continueB) { - bAncestor = instance.getItemMeta(bAncestor!).parentId; + bAncestor = selectorItemParentId(state, bAncestor!); } } } const commonAncestor = aAncestorIsCommon ? aAncestor : bAncestor; - const ancestorFamily = instance.getItemOrderedChildrenIds(commonAncestor); + const ancestorFamily = selectorItemOrderedChildrenIds(state, commonAncestor); const aSide = aFamily[aFamily.indexOf(commonAncestor) - 1]; const bSide = bFamily[bFamily.indexOf(commonAncestor) - 1]; @@ -206,40 +230,40 @@ export const findOrderInTremauxTree = ( }; export const getNonDisabledItemsInRange = ( - instance: TreeViewInstance<[UseTreeViewItemsSignature, UseTreeViewExpansionSignature]>, + state: TreeViewState<[UseTreeViewItemsSignature, UseTreeViewExpansionSignature]>, itemAId: string, itemBId: string, ) => { const getNextItem = (itemId: string) => { // If the item is expanded and has some children, return the first of them. - if (instance.isItemExpandable(itemId) && instance.isItemExpanded(itemId)) { - return instance.getItemOrderedChildrenIds(itemId)[0]; + if (selectorIsItemExpandable(state, itemId) && selectorIsItemExpanded(state, itemId)) { + return selectorItemOrderedChildrenIds(state, itemId)[0]; } - let itemMeta = instance.getItemMeta(itemId); + let itemMeta: TreeViewItemMeta | null = selectorItemMeta(state, itemId); while (itemMeta != null) { // Try to find the first navigable sibling after the current item. - const siblings = instance.getItemOrderedChildrenIds(itemMeta.parentId); - const currentItemIndex = instance.getItemIndex(itemMeta.id); + const siblings = selectorItemOrderedChildrenIds(state, itemMeta.parentId); + const currentItemIndex = selectorItemIndex(state, itemMeta.id); if (currentItemIndex < siblings.length - 1) { return siblings[currentItemIndex + 1]; } // If the item is the last of its siblings, go up a level to the parent and try again. - itemMeta = instance.getItemMeta(itemMeta.parentId!); + itemMeta = itemMeta.parentId ? selectorItemMeta(state, itemMeta.parentId) : null; } throw new Error('Invalid range'); }; - const [first, last] = findOrderInTremauxTree(instance, itemAId, itemBId); + const [first, last] = findOrderInTremauxTree(state, itemAId, itemBId); const items = [first]; let current = first; while (current !== last) { current = getNextItem(current); - if (!instance.isItemDisabled(current)) { + if (!selectorIsItemDisabled(state, current)) { items.push(current); } } @@ -248,13 +272,13 @@ export const getNonDisabledItemsInRange = ( }; export const getAllNavigableItems = ( - instance: TreeViewInstance<[UseTreeViewItemsSignature, UseTreeViewExpansionSignature]>, + state: TreeViewState<[UseTreeViewItemsSignature, UseTreeViewExpansionSignature]>, ) => { - let item: string | null = getFirstNavigableItem(instance); + let item: string | null = getFirstNavigableItem(state); const navigableItems: string[] = []; while (item != null) { navigableItems.push(item); - item = getNextNavigableItem(instance, item); + item = getNextNavigableItem(state, item); } return navigableItems; diff --git a/packages/x-tree-view/src/models/items.ts b/packages/x-tree-view/src/models/items.ts index 0d15e359d2d6..66bd474c6fb8 100644 --- a/packages/x-tree-view/src/models/items.ts +++ b/packages/x-tree-view/src/models/items.ts @@ -1,7 +1,9 @@ // TODO: Add support for number export type TreeViewItemId = string; -export type TreeViewBaseItem = R & { +export type TreeViewDefaultItemModelProperties = { id: string; label: string }; + +export type TreeViewBaseItem = R & { children?: TreeViewBaseItem[]; }; diff --git a/packages/x-tree-view/src/useTreeItem/useTreeItem.ts b/packages/x-tree-view/src/useTreeItem/useTreeItem.ts index cb5e07fbacaa..939973e40fa5 100644 --- a/packages/x-tree-view/src/useTreeItem/useTreeItem.ts +++ b/packages/x-tree-view/src/useTreeItem/useTreeItem.ts @@ -25,7 +25,10 @@ import { TreeViewItemPluginSlotPropsEnhancerParams } from '../internals/models'; import { useTreeItemUtils } from '../hooks/useTreeItemUtils'; import { TreeViewItemDepthContext } from '../internals/TreeViewItemDepthContext'; import { isTargetInDescendants } from '../internals/utils/tree'; +import { useSelector } from '../internals/hooks/useSelector'; +import { selectorIsItemTheDefaultFocusableItem } from '../internals/plugins/useTreeViewFocus/useTreeViewFocus.selectors'; import { generateTreeItemIdAttribute } from '../internals/corePlugins/useTreeViewId/useTreeViewId.utils'; +import { selectorCanItemBeFocused } from '../internals/plugins/useTreeViewItems/useTreeViewItems.selectors'; export const useTreeItem = < TSignatures extends UseTreeItemMinimalPlugins = UseTreeItemMinimalPlugins, @@ -35,15 +38,29 @@ export const useTreeItem = < ): UseTreeItemReturnValue => { const { runItemPlugins, - items: { onItemClick, disabledItemsFocusable }, + items: { onItemClick }, selection: { disableSelection, checkboxSelection }, expansion: { expansionTrigger }, + label: labelContext, treeId, instance, publicAPI, + store, } = useTreeViewContext(); const depthContext = React.useContext(TreeViewItemDepthContext); + const depth = useSelector( + store, + (...params) => { + if (typeof depthContext === 'function') { + return depthContext(...params); + } + + return depthContext; + }, + parameters.itemId, + ); + const { id, itemId, label, children, rootRef } = parameters; const { rootRef: pluginRootRef, contentRef, propsEnhancers } = runItemPlugins(parameters); @@ -55,7 +72,11 @@ export const useTreeItem = < const checkboxRef = React.useRef(null); const idAttribute = generateTreeItemIdAttribute({ itemId, treeId, id }); - const rootTabIndex = instance.canItemBeTabbed(itemId) ? 0 : -1; + const shouldBeAccessibleWithTab = useSelector( + store, + selectorIsItemTheDefaultFocusableItem, + itemId, + ); const sharedPropsEnhancerParams: Omit< TreeViewItemPluginSlotPropsEnhancerParams, @@ -70,8 +91,11 @@ export const useTreeItem = < return; } - const canBeFocused = !status.disabled || disabledItemsFocusable; - if (!status.focused && canBeFocused && event.currentTarget === event.target) { + if ( + !status.focused && + selectorCanItemBeFocused(store.value, itemId) && + event.currentTarget === event.target + ) { instance.focusItem(event, itemId); } }; @@ -132,7 +156,7 @@ export const useTreeItem = < const createContentHandleClick = (otherHandlers: EventHandlers) => (event: React.MouseEvent & TreeViewCancellableEvent) => { otherHandlers.onClick?.(event); - onItemClick?.(event, itemId); + onItemClick(event, itemId); if (event.defaultMuiPrevented || checkboxRef.current?.contains(event.target as HTMLElement)) { return; @@ -170,6 +194,8 @@ export const useTreeItem = < } }; + const getContextProviderProps = () => ({ itemId, id }); + const getRootProps = = {}>( externalProps: ExternalProps = {} as ExternalProps, ): UseTreeItemRootSlotProps => { @@ -195,7 +221,7 @@ export const useTreeItem = < ...externalEventHandlers, ref: handleRootRef, role: 'treeitem', - tabIndex: rootTabIndex, + tabIndex: shouldBeAccessibleWithTab ? 0 : -1, id: idAttribute, 'aria-expanded': status.expandable ? status.expanded : undefined, 'aria-selected': ariaSelected, @@ -203,8 +229,7 @@ export const useTreeItem = < ...externalProps, style: { ...(externalProps.style ?? {}), - '--TreeView-itemDepth': - typeof depthContext === 'function' ? depthContext(itemId) : depthContext, + '--TreeView-itemDepth': depth, } as React.CSSProperties, onFocus: createRootHandleFocus(externalEventHandlers), onBlur: createRootHandleBlur(externalEventHandlers), @@ -280,7 +305,7 @@ export const useTreeItem = < onDoubleClick: createLabelHandleDoubleClick(externalEventHandlers), }; - if (instance.isTreeViewEditable) { + if (labelContext?.isItemEditable) { props.editable = status.editable; } @@ -352,6 +377,7 @@ export const useTreeItem = < }; return { + getContextProviderProps, getRootProps, getContentProps, getGroupTransitionProps, diff --git a/packages/x-tree-view/src/useTreeItem/useTreeItem.types.ts b/packages/x-tree-view/src/useTreeItem/useTreeItem.types.ts index 0fae841967eb..fc4462713ffc 100644 --- a/packages/x-tree-view/src/useTreeItem/useTreeItem.types.ts +++ b/packages/x-tree-view/src/useTreeItem/useTreeItem.types.ts @@ -34,6 +34,11 @@ export interface UseTreeItemParameters { children?: React.ReactNode; } +export interface UseTreeItemContextProviderProps { + itemId: string; + id: string | undefined; +} + export interface UseTreeItemRootSlotPropsFromUseTreeItem { role: 'treeitem'; tabIndex: 0 | -1; @@ -127,6 +132,11 @@ export interface UseTreeItemReturnValue< TSignatures extends UseTreeItemMinimalPlugins, TOptionalSignatures extends UseTreeItemOptionalPlugins, > { + /** + * Resolver for the context provider's props. + * @returns {UseTreeItemContextProviderProps} Props that should be spread on the context provider slot. + */ + getContextProviderProps: () => UseTreeItemContextProviderProps; /** * Resolver for the root slot's props. * @param {ExternalProps} externalProps Additional props for the root slot. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63cac7483cb2..6ebba888a2f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1449,6 +1449,12 @@ importers: react-transition-group: specifier: ^4.4.5 version: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + reselect: + specifier: ^5.1.1 + version: 5.1.1 + use-sync-external-store: + specifier: ^1.0.0 + version: 1.2.2(react@18.3.1) devDependencies: '@mui/internal-test-utils': specifier: ^1.0.20 @@ -1462,6 +1468,9 @@ importers: '@types/prop-types': specifier: ^15.7.13 version: 15.7.13 + '@types/use-sync-external-store': + specifier: ^0.0.6 + version: 0.0.6 rimraf: specifier: ^6.0.1 version: 6.0.1 @@ -1508,6 +1517,12 @@ importers: react-transition-group: specifier: ^4.4.5 version: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + reselect: + specifier: ^5.1.1 + version: 5.1.1 + use-sync-external-store: + specifier: ^1.0.0 + version: 1.2.2(react@18.3.1) devDependencies: '@mui/internal-test-utils': specifier: ^1.0.20 @@ -1521,6 +1536,9 @@ importers: '@types/prop-types': specifier: ^15.7.13 version: 15.7.13 + '@types/use-sync-external-store': + specifier: ^0.0.6 + version: 0.0.6 rimraf: specifier: ^6.0.1 version: 6.0.1 @@ -4268,6 +4286,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/webpack-bundle-analyzer@4.7.0': resolution: {integrity: sha512-c5i2ThslSNSG8W891BRvOd/RoCjI2zwph8maD22b1adtSns20j+0azDDMCK06DiVrzTgnwiDl5Ntmu1YRJw8Sg==} @@ -9922,6 +9943,11 @@ packages: urlpattern-polyfill@8.0.2: resolution: {integrity: sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==} + use-sync-external-store@1.2.2: + resolution: {integrity: sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -13344,6 +13370,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/use-sync-external-store@0.0.6': {} + '@types/webpack-bundle-analyzer@4.7.0(@swc/core@1.7.35(@swc/helpers@0.5.5))(webpack-cli@5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.96.1))': dependencies: '@types/node': 20.17.6 @@ -20064,6 +20092,10 @@ snapshots: urlpattern-polyfill@8.0.2: {} + use-sync-external-store@1.2.2(react@18.3.1): + dependencies: + react: 18.3.1 + util-deprecate@1.0.2: {} util.inherits@1.0.3: {} diff --git a/scripts/x-tree-view-pro.exports.json b/scripts/x-tree-view-pro.exports.json index 66646f1d424a..926170d64994 100644 --- a/scripts/x-tree-view-pro.exports.json +++ b/scripts/x-tree-view-pro.exports.json @@ -47,6 +47,7 @@ { "name": "TreeViewCancellableEvent", "kind": "TypeAlias" }, { "name": "TreeViewCancellableEventHandler", "kind": "TypeAlias" }, { "name": "TreeViewCollapseIcon", "kind": "Variable" }, + { "name": "TreeViewDefaultItemModelProperties", "kind": "TypeAlias" }, { "name": "TreeViewExpandIcon", "kind": "Variable" }, { "name": "TreeViewItemId", "kind": "TypeAlias" }, { "name": "TreeViewItemsReorderingAction", "kind": "TypeAlias" }, @@ -60,6 +61,7 @@ { "name": "UseTreeItemIconContainerSlotOwnProps", "kind": "Interface" }, { "name": "UseTreeItemLabelInputSlotOwnProps", "kind": "Interface" }, { "name": "UseTreeItemLabelSlotOwnProps", "kind": "Interface" }, + { "name": "useTreeItemModel", "kind": "Variable" }, { "name": "UseTreeItemParameters", "kind": "Interface" }, { "name": "UseTreeItemReturnValue", "kind": "Interface" }, { "name": "UseTreeItemRootSlotOwnProps", "kind": "Interface" }, diff --git a/scripts/x-tree-view.exports.json b/scripts/x-tree-view.exports.json index 6daaee01de81..e72c296ae182 100644 --- a/scripts/x-tree-view.exports.json +++ b/scripts/x-tree-view.exports.json @@ -51,6 +51,7 @@ { "name": "TreeViewCancellableEvent", "kind": "TypeAlias" }, { "name": "TreeViewCancellableEventHandler", "kind": "TypeAlias" }, { "name": "TreeViewCollapseIcon", "kind": "Variable" }, + { "name": "TreeViewDefaultItemModelProperties", "kind": "TypeAlias" }, { "name": "TreeViewExpandIcon", "kind": "Variable" }, { "name": "TreeViewItemId", "kind": "TypeAlias" }, { "name": "TreeViewItemsReorderingAction", "kind": "TypeAlias" }, @@ -64,6 +65,7 @@ { "name": "UseTreeItemIconContainerSlotOwnProps", "kind": "Interface" }, { "name": "UseTreeItemLabelInputSlotOwnProps", "kind": "Interface" }, { "name": "UseTreeItemLabelSlotOwnProps", "kind": "Interface" }, + { "name": "useTreeItemModel", "kind": "Variable" }, { "name": "UseTreeItemParameters", "kind": "Interface" }, { "name": "UseTreeItemReturnValue", "kind": "Interface" }, { "name": "UseTreeItemRootSlotOwnProps", "kind": "Interface" }, diff --git a/test/utils/tree-view/describeTreeView/describeTreeView.tsx b/test/utils/tree-view/describeTreeView/describeTreeView.tsx index 4d7d28078f17..dc4c6c99a043 100644 --- a/test/utils/tree-view/describeTreeView/describeTreeView.tsx +++ b/test/utils/tree-view/describeTreeView/describeTreeView.tsx @@ -118,6 +118,7 @@ const innerDescribeTreeView = items: rawItems, withErrorBoundary, slotProps, + slots, ...other }) => { const items = rawItems as readonly DescribeTreeViewItem[]; @@ -127,7 +128,7 @@ const innerDescribeTreeView = diff --git a/test/utils/tree-view/describeTreeView/describeTreeView.types.ts b/test/utils/tree-view/describeTreeView/describeTreeView.types.ts index 20996106b13b..a51c1ec53c76 100644 --- a/test/utils/tree-view/describeTreeView/describeTreeView.types.ts +++ b/test/utils/tree-view/describeTreeView/describeTreeView.types.ts @@ -109,7 +109,10 @@ export interface DescribeTreeViewRendererReturnValue< * Passes new props to the Tree View. * @param {Partial>} props A subset of the props accepted by the Tree View. */ - setProps: (props: Partial>) => void; + setProps: ( + props: Partial> & + React.HTMLAttributes, + ) => void; /** * Passes new items to the Tree View. * @param {readyonly DescribeTreeViewItem[]} items The new items. diff --git a/test/utils/tree-view/fakeContextValue.ts b/test/utils/tree-view/fakeContextValue.ts index b64e3cb6759f..3898a7e7c242 100644 --- a/test/utils/tree-view/fakeContextValue.ts +++ b/test/utils/tree-view/fakeContextValue.ts @@ -1,27 +1,12 @@ import { TreeViewContextValue } from '@mui/x-tree-view/internals/TreeViewProvider'; import { SimpleTreeViewPluginSignatures } from '@mui/x-tree-view/SimpleTreeView/SimpleTreeView.plugins'; +import { TreeViewStore } from '@mui/x-tree-view/internals/utils/TreeViewStore'; export const getFakeContextValue = ( features: { checkboxSelection?: boolean } = {}, ): TreeViewContextValue => ({ - instance: { - isItemExpandable: () => false, - isItemExpanded: () => false, - isItemFocused: () => false, - isItemSelected: () => false, - isItemDisabled: (itemId: string | null): itemId is string => !!itemId, - mapFirstCharFromJSX: () => () => {}, - canItemBeTabbed: () => false, - } as any, - publicAPI: { - focusItem: () => {}, - getItem: () => ({}), - getItemOrderedChildrenIds: () => [], - setItemExpansion: () => {}, - getItemDOMElement: () => null, - selectItem: () => {}, - getItemTree: () => [], - }, + instance: {} as any, + publicAPI: {} as any, runItemPlugins: () => ({ rootRef: null, contentRef: null, @@ -30,7 +15,7 @@ export const getFakeContextValue = ( wrapItem: ({ children }) => children, wrapRoot: ({ children }) => children, items: { - disabledItemsFocusable: false, + onItemClick: () => {}, }, icons: { slots: {}, @@ -47,4 +32,18 @@ export const getFakeContextValue = ( current: null, }, expansion: { expansionTrigger: 'content' }, + store: new TreeViewStore({ + cacheKey: { id: 1 }, + id: { treeId: 'mui-tree-view-1', providedTreeId: undefined }, + items: { + disabledItemsFocusable: false, + itemMetaLookup: {}, + itemModelLookup: {}, + itemOrderedChildrenIdsLookup: {}, + itemChildrenIndexesLookup: {}, + }, + expansion: { expandedItemsMap: new Map() }, + selection: { selectedItemsMap: new Map() }, + focus: { focusedItemId: null, defaultFocusableItemId: null }, + }), });