From 0ffbbe8a1b5a7a88eef9a76ca86a6cad8fd4853a Mon Sep 17 00:00:00 2001 From: Flavien DELANGLE Date: Wed, 23 Oct 2024 09:34:51 +0200 Subject: [PATCH] [TreeView] Automatic parents and children selection (#14899) --- docs/data/tree-view/datasets/employees.ts | 40 ++++++ .../ParentChildrenSelectionRelationship.js | 98 ------------- .../ParentChildrenSelectionRelationship.tsx | 106 -------------- ...tChildrenSelectionRelationship.tsx.preview | 9 -- .../selection/SelectionPropagation.js | 59 ++++++++ .../selection/SelectionPropagation.tsx | 60 ++++++++ .../rich-tree-view/selection/selection.md | 37 +++-- .../x/api/tree-view/rich-tree-view-pro.json | 4 + .../pages/x/api/tree-view/rich-tree-view.json | 4 + .../x/api/tree-view/simple-tree-view.json | 4 + docs/pages/x/api/tree-view/tree-view.json | 4 + .../rich-tree-view-pro.json | 3 + .../rich-tree-view/rich-tree-view.json | 3 + .../simple-tree-view/simple-tree-view.json | 3 + .../tree-view/tree-view/tree-view.json | 3 + .../src/RichTreeViewPro/RichTreeViewPro.tsx | 20 +++ .../src/RichTreeView/RichTreeView.tsx | 20 +++ .../src/SimpleTreeView/SimpleTreeView.tsx | 20 +++ .../x-tree-view/src/TreeItem/TreeItem.tsx | 10 +- .../src/TreeItem/TreeItemContent.tsx | 19 +-- .../src/TreeItem2/TreeItem2.test.tsx | 7 +- .../x-tree-view/src/TreeItem2/TreeItem2.tsx | 2 +- .../x-tree-view/src/TreeView/TreeView.tsx | 20 +++ .../src/internals/models/itemPlugin.ts | 9 +- .../useTreeViewSelection.itemPlugin.ts | 115 +++++++++++++++ .../useTreeViewSelection.test.tsx | 122 ++++++++++++++++ .../useTreeViewSelection.ts | 76 +++++++--- .../useTreeViewSelection.types.ts | 38 ++++- .../useTreeViewSelection.utils.ts | 131 +++++++++++++++++- packages/x-tree-view/src/models/items.ts | 5 + .../src/useTreeItem2/useTreeItem2.ts | 39 ++---- .../src/useTreeItem2/useTreeItem2.types.ts | 10 +- scripts/x-tree-view-pro.exports.json | 1 + scripts/x-tree-view.exports.json | 1 + test/utils/tree-view/fakeContextValue.ts | 1 + 35 files changed, 802 insertions(+), 301 deletions(-) create mode 100644 docs/data/tree-view/datasets/employees.ts delete mode 100644 docs/data/tree-view/rich-tree-view/selection/ParentChildrenSelectionRelationship.js delete mode 100644 docs/data/tree-view/rich-tree-view/selection/ParentChildrenSelectionRelationship.tsx delete mode 100644 docs/data/tree-view/rich-tree-view/selection/ParentChildrenSelectionRelationship.tsx.preview create mode 100644 docs/data/tree-view/rich-tree-view/selection/SelectionPropagation.js create mode 100644 docs/data/tree-view/rich-tree-view/selection/SelectionPropagation.tsx create mode 100644 packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.itemPlugin.ts diff --git a/docs/data/tree-view/datasets/employees.ts b/docs/data/tree-view/datasets/employees.ts new file mode 100644 index 0000000000000..2af62416f36e8 --- /dev/null +++ b/docs/data/tree-view/datasets/employees.ts @@ -0,0 +1,40 @@ +import { TreeViewBaseItem } from '@mui/x-tree-view/models'; + +export const EMPLOYEES_DATASET: TreeViewBaseItem[] = [ + { + id: '0', + label: 'Sarah', + }, + { + id: '1', + label: 'Thomas', + children: [ + { id: '2', label: 'Robert' }, + { id: '3', label: 'Karen' }, + { id: '4', label: 'Nancy' }, + { id: '5', label: 'Daniel' }, + { id: '6', label: 'Christopher' }, + { id: '7', label: 'Donald' }, + ], + }, + { + id: '8', + label: 'Mary', + children: [ + { + id: '9', + label: 'Jennifer', + children: [{ id: '10', label: 'Anna' }], + }, + { id: '11', label: 'Michael' }, + { + id: '12', + label: 'Linda', + children: [ + { id: '13', label: 'Elizabeth' }, + { id: '14', label: 'William' }, + ], + }, + ], + }, +]; diff --git a/docs/data/tree-view/rich-tree-view/selection/ParentChildrenSelectionRelationship.js b/docs/data/tree-view/rich-tree-view/selection/ParentChildrenSelectionRelationship.js deleted file mode 100644 index 8f9dd61e4df94..0000000000000 --- a/docs/data/tree-view/rich-tree-view/selection/ParentChildrenSelectionRelationship.js +++ /dev/null @@ -1,98 +0,0 @@ -import * as React from 'react'; -import Box from '@mui/material/Box'; -import { RichTreeView } from '@mui/x-tree-view/RichTreeView'; -import { useTreeViewApiRef } from '@mui/x-tree-view/hooks'; - -const MUI_X_PRODUCTS = [ - { - id: 'grid', - label: 'Data Grid', - children: [ - { id: 'grid-community', label: '@mui/x-data-grid' }, - { id: 'grid-pro', label: '@mui/x-data-grid-pro' }, - { id: 'grid-premium', label: '@mui/x-data-grid-premium' }, - ], - }, - { - id: 'pickers', - label: 'Date and Time Pickers', - children: [ - { id: 'pickers-community', label: '@mui/x-date-pickers' }, - { id: 'pickers-pro', label: '@mui/x-date-pickers-pro' }, - ], - }, - { - id: 'charts', - label: 'Charts', - children: [{ id: 'charts-community', label: '@mui/x-charts' }], - }, - { - id: 'tree-view', - label: 'Tree View', - children: [{ id: 'tree-view-community', label: '@mui/x-tree-view' }], - }, -]; - -function getItemDescendantsIds(item) { - const ids = []; - item.children?.forEach((child) => { - ids.push(child.id); - ids.push(...getItemDescendantsIds(child)); - }); - - return ids; -} - -export default function ParentChildrenSelectionRelationship() { - const [selectedItems, setSelectedItems] = React.useState([]); - const toggledItemRef = React.useRef({}); - const apiRef = useTreeViewApiRef(); - - const handleItemSelectionToggle = (event, itemId, isSelected) => { - toggledItemRef.current[itemId] = isSelected; - }; - - const handleSelectedItemsChange = (event, newSelectedItems) => { - setSelectedItems(newSelectedItems); - - // Select / unselect the children of the toggled item - const itemsToSelect = []; - const itemsToUnSelect = {}; - Object.entries(toggledItemRef.current).forEach(([itemId, isSelected]) => { - const item = apiRef.current.getItem(itemId); - if (isSelected) { - itemsToSelect.push(...getItemDescendantsIds(item)); - } else { - getItemDescendantsIds(item).forEach((descendantId) => { - itemsToUnSelect[descendantId] = true; - }); - } - }); - - const newSelectedItemsWithChildren = Array.from( - new Set( - [...newSelectedItems, ...itemsToSelect].filter( - (itemId) => !itemsToUnSelect[itemId], - ), - ), - ); - - setSelectedItems(newSelectedItemsWithChildren); - - toggledItemRef.current = {}; - }; - - return ( - - - - ); -} diff --git a/docs/data/tree-view/rich-tree-view/selection/ParentChildrenSelectionRelationship.tsx b/docs/data/tree-view/rich-tree-view/selection/ParentChildrenSelectionRelationship.tsx deleted file mode 100644 index e45bdbb4fb3c3..0000000000000 --- a/docs/data/tree-view/rich-tree-view/selection/ParentChildrenSelectionRelationship.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import * as React from 'react'; -import Box from '@mui/material/Box'; -import { RichTreeView } from '@mui/x-tree-view/RichTreeView'; -import { useTreeViewApiRef } from '@mui/x-tree-view/hooks'; -import { TreeViewBaseItem } from '@mui/x-tree-view/models'; - -const MUI_X_PRODUCTS: TreeViewBaseItem[] = [ - { - id: 'grid', - label: 'Data Grid', - children: [ - { id: 'grid-community', label: '@mui/x-data-grid' }, - { id: 'grid-pro', label: '@mui/x-data-grid-pro' }, - { id: 'grid-premium', label: '@mui/x-data-grid-premium' }, - ], - }, - { - id: 'pickers', - label: 'Date and Time Pickers', - children: [ - { id: 'pickers-community', label: '@mui/x-date-pickers' }, - { id: 'pickers-pro', label: '@mui/x-date-pickers-pro' }, - ], - }, - { - id: 'charts', - label: 'Charts', - children: [{ id: 'charts-community', label: '@mui/x-charts' }], - }, - { - id: 'tree-view', - label: 'Tree View', - children: [{ id: 'tree-view-community', label: '@mui/x-tree-view' }], - }, -]; - -function getItemDescendantsIds(item: TreeViewBaseItem) { - const ids: string[] = []; - item.children?.forEach((child) => { - ids.push(child.id); - ids.push(...getItemDescendantsIds(child)); - }); - - return ids; -} - -export default function ParentChildrenSelectionRelationship() { - const [selectedItems, setSelectedItems] = React.useState([]); - const toggledItemRef = React.useRef<{ [itemId: string]: boolean }>({}); - const apiRef = useTreeViewApiRef(); - - const handleItemSelectionToggle = ( - event: React.SyntheticEvent, - itemId: string, - isSelected: boolean, - ) => { - toggledItemRef.current[itemId] = isSelected; - }; - - const handleSelectedItemsChange = ( - event: React.SyntheticEvent, - newSelectedItems: string[], - ) => { - setSelectedItems(newSelectedItems); - - // Select / unselect the children of the toggled item - const itemsToSelect: string[] = []; - const itemsToUnSelect: { [itemId: string]: boolean } = {}; - Object.entries(toggledItemRef.current).forEach(([itemId, isSelected]) => { - const item = apiRef.current!.getItem(itemId); - if (isSelected) { - itemsToSelect.push(...getItemDescendantsIds(item)); - } else { - getItemDescendantsIds(item).forEach((descendantId) => { - itemsToUnSelect[descendantId] = true; - }); - } - }); - - const newSelectedItemsWithChildren = Array.from( - new Set( - [...newSelectedItems, ...itemsToSelect].filter( - (itemId) => !itemsToUnSelect[itemId], - ), - ), - ); - - setSelectedItems(newSelectedItemsWithChildren); - - toggledItemRef.current = {}; - }; - - return ( - - - - ); -} diff --git a/docs/data/tree-view/rich-tree-view/selection/ParentChildrenSelectionRelationship.tsx.preview b/docs/data/tree-view/rich-tree-view/selection/ParentChildrenSelectionRelationship.tsx.preview deleted file mode 100644 index 6fa38db9c231b..0000000000000 --- a/docs/data/tree-view/rich-tree-view/selection/ParentChildrenSelectionRelationship.tsx.preview +++ /dev/null @@ -1,9 +0,0 @@ - \ No newline at end of file diff --git a/docs/data/tree-view/rich-tree-view/selection/SelectionPropagation.js b/docs/data/tree-view/rich-tree-view/selection/SelectionPropagation.js new file mode 100644 index 0000000000000..2c54a087f326b --- /dev/null +++ b/docs/data/tree-view/rich-tree-view/selection/SelectionPropagation.js @@ -0,0 +1,59 @@ +import * as React from 'react'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import Stack from '@mui/material/Stack'; +import Box from '@mui/material/Box'; +import { RichTreeView } from '@mui/x-tree-view/RichTreeView'; + +import { EMPLOYEES_DATASET } from '../../datasets/employees'; + +export default function SelectionPropagation() { + const [selectionPropagation, setSelectionPropagation] = React.useState({ + parents: true, + descendants: true, + }); + + return ( +
+ + + setSelectionPropagation((prev) => ({ + ...prev, + descendants: event.target.checked, + })) + } + /> + } + label="Auto select descendants" + /> + + setSelectionPropagation((prev) => ({ + ...prev, + parents: event.target.checked, + })) + } + /> + } + label="Auto select parents" + /> + + + + +
+ ); +} diff --git a/docs/data/tree-view/rich-tree-view/selection/SelectionPropagation.tsx b/docs/data/tree-view/rich-tree-view/selection/SelectionPropagation.tsx new file mode 100644 index 0000000000000..c41780b8fb9cc --- /dev/null +++ b/docs/data/tree-view/rich-tree-view/selection/SelectionPropagation.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import Stack from '@mui/material/Stack'; +import Box from '@mui/material/Box'; +import { RichTreeView } from '@mui/x-tree-view/RichTreeView'; +import { TreeViewSelectionPropagation } from '@mui/x-tree-view/models'; +import { EMPLOYEES_DATASET } from '../../datasets/employees'; + +export default function SelectionPropagation() { + const [selectionPropagation, setSelectionPropagation] = + React.useState({ + parents: true, + descendants: true, + }); + + return ( +
+ + + setSelectionPropagation((prev) => ({ + ...prev, + descendants: event.target.checked, + })) + } + /> + } + label="Auto select descendants" + /> + + setSelectionPropagation((prev) => ({ + ...prev, + parents: event.target.checked, + })) + } + /> + } + label="Auto select parents" + /> + + + + +
+ ); +} diff --git a/docs/data/tree-view/rich-tree-view/selection/selection.md b/docs/data/tree-view/rich-tree-view/selection/selection.md index 2175d7ad39787..6012a68e78d60 100644 --- a/docs/data/tree-view/rich-tree-view/selection/selection.md +++ b/docs/data/tree-view/rich-tree-view/selection/selection.md @@ -75,25 +75,36 @@ Use the `onItemSelectionToggle` prop if you want to react to an item selection c {{"demo": "TrackItemSelectionToggle.js"}} -## Parent / children selection relationship +## Automatic parents and children selection -Automatically select an item when all of its children are selected and automatically select all children when the parent is selected. +By default, selecting a parent item does not select its children. You can override this behavior using the `selectionPropagation` prop. -:::warning -This feature isn't implemented yet. It's coming. +Here's how it's structured: -👍 Upvote [issue #4821](https://github.com/mui/mui-x/issues/4821) if you want to see it land faster. +```ts +type TreeViewSelectionPropagation = { + descendants?: boolean; // default: false + parents?: boolean; // default: false +}; +``` -Don't hesitate to leave a comment on the same issue to influence what gets built. -Especially if you already have a use case for this component, -or if you are facing a pain point with your current solution. -::: +When `selectionPropagation.descendants` is set to `true`. + +- Selecting a parent selects all its descendants automatically. +- Deselecting a parent deselects all its descendants automatically. -If you cannot wait for the official implementation, -you can create your own custom solution using the `selectedItems`, -`onSelectedItemsChange` and `onItemSelectionToggle` props: +When `selectionPropagation.parents` is set to `true`. -{{"demo": "ParentChildrenSelectionRelationship.js"}} +- Selecting all the descendants of a parent selects the parent automatically. +- Deselecting a descendant of a selected parent deselects the parent automatically. + +The example below demonstrates the usage of the `selectionPropagation` prop. + +{{"demo": "SelectionPropagation.js", "defaultCodeOpen": false}} + +:::warning +This feature only works when multi selection is enabled using `props.multiSelect`. +::: ## Imperative API 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 fb4b208b9f1de..79efa85605209 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 @@ -134,6 +134,10 @@ } }, "selectedItems": { "type": { "name": "any" } }, + "selectionPropagation": { + "type": { "name": "shape", "description": "{ descendants?: bool, parents?: bool }" }, + "default": "{ parents: false, descendants: false }" + }, "slotProps": { "type": { "name": "object" }, "default": "{}" }, "slots": { "type": { "name": "object" }, 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 98191cec2fc07..3265653e606e3 100644 --- a/docs/pages/x/api/tree-view/rich-tree-view.json +++ b/docs/pages/x/api/tree-view/rich-tree-view.json @@ -109,6 +109,10 @@ } }, "selectedItems": { "type": { "name": "any" } }, + "selectionPropagation": { + "type": { "name": "shape", "description": "{ descendants?: bool, parents?: bool }" }, + "default": "{ parents: false, descendants: false }" + }, "slotProps": { "type": { "name": "object" }, "default": "{}" }, "slots": { "type": { "name": "object" }, diff --git a/docs/pages/x/api/tree-view/simple-tree-view.json b/docs/pages/x/api/tree-view/simple-tree-view.json index b3507fdce74d7..bcf5153e9cb5e 100644 --- a/docs/pages/x/api/tree-view/simple-tree-view.json +++ b/docs/pages/x/api/tree-view/simple-tree-view.json @@ -73,6 +73,10 @@ } }, "selectedItems": { "type": { "name": "any" } }, + "selectionPropagation": { + "type": { "name": "shape", "description": "{ descendants?: bool, parents?: bool }" }, + "default": "{ parents: false, descendants: false }" + }, "slotProps": { "type": { "name": "object" } }, "slots": { "type": { "name": "object" }, "additionalInfo": { "slotsApi": true } }, "sx": { diff --git a/docs/pages/x/api/tree-view/tree-view.json b/docs/pages/x/api/tree-view/tree-view.json index dc8fc5e8013fe..c9f0691c2b7b6 100644 --- a/docs/pages/x/api/tree-view/tree-view.json +++ b/docs/pages/x/api/tree-view/tree-view.json @@ -73,6 +73,10 @@ } }, "selectedItems": { "type": { "name": "any" } }, + "selectionPropagation": { + "type": { "name": "shape", "description": "{ descendants?: bool, parents?: bool }" }, + "default": "{ parents: false, descendants: false }" + }, "slotProps": { "type": { "name": "object" } }, "slots": { "type": { "name": "object" }, "additionalInfo": { "slotsApi": true } }, "sx": { 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 d7edf9c18d6d7..b43dd9784328a 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 @@ -137,6 +137,9 @@ "selectedItems": { "description": "Selected item ids. (Controlled) When multiSelect is true this takes an array of strings; when false (default) a string." }, + "selectionPropagation": { + "description": "When selectionPropagation.descendants is set to true.
- Selecting a parent selects all its descendants automatically. - Deselecting a parent deselects all its descendants automatically.
When selectionPropagation.parents is set to true.
- Selecting all the descendants of a parent selects the parent automatically. - Deselecting a descendant of a selected parent deselects the parent automatically.
Only works when multiSelect is true. On the <SimpleTreeView />, only the expanded items are considered (since the collapsed item are not passed to the Tree View component at all)" + }, "slotProps": { "description": "The props used for each component slot." }, "slots": { "description": "Overridable component slots." }, "sx": { 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 feba556e087cb..a2fdbd15c255a 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 @@ -108,6 +108,9 @@ "selectedItems": { "description": "Selected item ids. (Controlled) When multiSelect is true this takes an array of strings; when false (default) a string." }, + "selectionPropagation": { + "description": "When selectionPropagation.descendants is set to true.
- Selecting a parent selects all its descendants automatically. - Deselecting a parent deselects all its descendants automatically.
When selectionPropagation.parents is set to true.
- Selecting all the descendants of a parent selects the parent automatically. - Deselecting a descendant of a selected parent deselects the parent automatically.
Only works when multiSelect is true. On the <SimpleTreeView />, only the expanded items are considered (since the collapsed item are not passed to the Tree View component at all)" + }, "slotProps": { "description": "The props used for each component slot." }, "slots": { "description": "Overridable component slots." }, "sx": { diff --git a/docs/translations/api-docs/tree-view/simple-tree-view/simple-tree-view.json b/docs/translations/api-docs/tree-view/simple-tree-view/simple-tree-view.json index 1d9142ee23a96..86afc50c65dbd 100644 --- a/docs/translations/api-docs/tree-view/simple-tree-view/simple-tree-view.json +++ b/docs/translations/api-docs/tree-view/simple-tree-view/simple-tree-view.json @@ -84,6 +84,9 @@ "selectedItems": { "description": "Selected item ids. (Controlled) When multiSelect is true this takes an array of strings; when false (default) a string." }, + "selectionPropagation": { + "description": "When selectionPropagation.descendants is set to true.
- Selecting a parent selects all its descendants automatically. - Deselecting a parent deselects all its descendants automatically.
When selectionPropagation.parents is set to true.
- Selecting all the descendants of a parent selects the parent automatically. - Deselecting a descendant of a selected parent deselects the parent automatically.
Only works when multiSelect is true. On the <SimpleTreeView />, only the expanded items are considered (since the collapsed item are not passed to the Tree View component at all)" + }, "slotProps": { "description": "The props used for each component slot." }, "slots": { "description": "Overridable component slots." }, "sx": { diff --git a/docs/translations/api-docs/tree-view/tree-view/tree-view.json b/docs/translations/api-docs/tree-view/tree-view/tree-view.json index 069ba6b9dbc67..7558c542dc419 100644 --- a/docs/translations/api-docs/tree-view/tree-view/tree-view.json +++ b/docs/translations/api-docs/tree-view/tree-view/tree-view.json @@ -84,6 +84,9 @@ "selectedItems": { "description": "Selected item ids. (Controlled) When multiSelect is true this takes an array of strings; when false (default) a string." }, + "selectionPropagation": { + "description": "When selectionPropagation.descendants is set to true.
- Selecting a parent selects all its descendants automatically. - Deselecting a parent deselects all its descendants automatically.
When selectionPropagation.parents is set to true.
- Selecting all the descendants of a parent selects the parent automatically. - Deselecting a descendant of a selected parent deselects the parent automatically.
Only works when multiSelect is true. On the <SimpleTreeView />, only the expanded items are considered (since the collapsed item are not passed to the Tree View component at all)" + }, "slotProps": { "description": "The props used for each component slot." }, "slots": { "description": "Overridable component slots." }, "sx": { diff --git a/packages/x-tree-view-pro/src/RichTreeViewPro/RichTreeViewPro.tsx b/packages/x-tree-view-pro/src/RichTreeViewPro/RichTreeViewPro.tsx index 126919c04ba50..d1cf77859bda1 100644 --- a/packages/x-tree-view-pro/src/RichTreeViewPro/RichTreeViewPro.tsx +++ b/packages/x-tree-view-pro/src/RichTreeViewPro/RichTreeViewPro.tsx @@ -315,6 +315,26 @@ RichTreeViewPro.propTypes = { * When `multiSelect` is true this takes an array of strings; when false (default) a string. */ selectedItems: PropTypes.any, + /** + * When `selectionPropagation.descendants` is set to `true`. + * + * - Selecting a parent selects all its descendants automatically. + * - Deselecting a parent deselects all its descendants automatically. + * + * When `selectionPropagation.parents` is set to `true`. + * + * - Selecting all the descendants of a parent selects the parent automatically. + * - Deselecting a descendant of a selected parent deselects the parent automatically. + * + * Only works when `multiSelect` is `true`. + * On the , only the expanded items are considered (since the collapsed item are not passed to the Tree View component at all) + * + * @default { parents: false, descendants: false } + */ + selectionPropagation: PropTypes.shape({ + descendants: PropTypes.bool, + parents: PropTypes.bool, + }), /** * The props used for each component slot. * @default {} diff --git a/packages/x-tree-view/src/RichTreeView/RichTreeView.tsx b/packages/x-tree-view/src/RichTreeView/RichTreeView.tsx index cf5ff069ef5b8..ccaccc5bcc78b 100644 --- a/packages/x-tree-view/src/RichTreeView/RichTreeView.tsx +++ b/packages/x-tree-view/src/RichTreeView/RichTreeView.tsx @@ -275,6 +275,26 @@ RichTreeView.propTypes = { * When `multiSelect` is true this takes an array of strings; when false (default) a string. */ selectedItems: PropTypes.any, + /** + * When `selectionPropagation.descendants` is set to `true`. + * + * - Selecting a parent selects all its descendants automatically. + * - Deselecting a parent deselects all its descendants automatically. + * + * When `selectionPropagation.parents` is set to `true`. + * + * - Selecting all the descendants of a parent selects the parent automatically. + * - Deselecting a descendant of a selected parent deselects the parent automatically. + * + * Only works when `multiSelect` is `true`. + * On the , only the expanded items are considered (since the collapsed item are not passed to the Tree View component at all) + * + * @default { parents: false, descendants: false } + */ + selectionPropagation: PropTypes.shape({ + descendants: PropTypes.bool, + parents: PropTypes.bool, + }), /** * The props used for each component slot. * @default {} diff --git a/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.tsx b/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.tsx index ff7ae43139351..5eb1c66c9cbbd 100644 --- a/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.tsx +++ b/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.tsx @@ -230,6 +230,26 @@ SimpleTreeView.propTypes = { * When `multiSelect` is true this takes an array of strings; when false (default) a string. */ selectedItems: PropTypes.any, + /** + * When `selectionPropagation.descendants` is set to `true`. + * + * - Selecting a parent selects all its descendants automatically. + * - Deselecting a parent deselects all its descendants automatically. + * + * When `selectionPropagation.parents` is set to `true`. + * + * - Selecting all the descendants of a parent selects the parent automatically. + * - Deselecting a descendant of a selected parent deselects the parent automatically. + * + * Only works when `multiSelect` is `true`. + * On the , only the expanded items are considered (since the collapsed item are not passed to the Tree View component at all) + * + * @default { parents: false, descendants: false } + */ + selectionPropagation: PropTypes.shape({ + descendants: PropTypes.bool, + parents: PropTypes.bool, + }), /** * The props used for each component slot. */ diff --git a/packages/x-tree-view/src/TreeItem/TreeItem.tsx b/packages/x-tree-view/src/TreeItem/TreeItem.tsx index 4204ae102656d..a53f1c1fd87bb 100644 --- a/packages/x-tree-view/src/TreeItem/TreeItem.tsx +++ b/packages/x-tree-view/src/TreeItem/TreeItem.tsx @@ -235,6 +235,7 @@ export const TreeItem = React.forwardRef(function TreeItem( handleExpansion, handleCancelItemLabelEditing, handleSaveItemLabel, + handleCheckboxSelection, } = useTreeItemState(itemId); if (process.env.NODE_ENV !== 'production') { @@ -413,7 +414,8 @@ export const TreeItem = React.forwardRef(function TreeItem( > = { rootRefObject, contentRefObject, - interactions: { handleSaveItemLabel, handleCancelItemLabelEditing }, + interactions: { handleSaveItemLabel, handleCancelItemLabelEditing, handleCheckboxSelection }, + status: { selected, disabled }, }; const enhancedRootProps = @@ -436,6 +438,11 @@ export const TreeItem = React.forwardRef(function TreeItem( ...sharedPropsEnhancerParams, externalEventHandlers: {}, }) ?? {}; + const { visible: isCheckboxVisible, ...enhancedCheckboxProps } = + propsEnhancers.checkbox?.({ + ...sharedPropsEnhancerParams, + externalEventHandlers: {}, + }) ?? {}; return ( @@ -495,6 +502,7 @@ export const TreeItem = React.forwardRef(function TreeItem( {...((enhancedLabelInputProps as any).value == null ? {} : { labelInputProps: enhancedLabelInputProps })} + {...(isCheckboxVisible ? { checkboxProps: enhancedCheckboxProps } : {})} ref={handleContentRef} /> {children && ( diff --git a/packages/x-tree-view/src/TreeItem/TreeItemContent.tsx b/packages/x-tree-view/src/TreeItem/TreeItemContent.tsx index 15a0d73496799..cbbcaad73ae4e 100644 --- a/packages/x-tree-view/src/TreeItem/TreeItemContent.tsx +++ b/packages/x-tree-view/src/TreeItem/TreeItemContent.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; -import Checkbox from '@mui/material/Checkbox'; +import Checkbox, { CheckboxProps } from '@mui/material/Checkbox'; import { useTreeItemState } from './useTreeItemState'; import { TreeItem2DragAndDropOverlay, @@ -61,6 +61,7 @@ export interface TreeItemContentProps extends React.HTMLAttributes displayIcon?: React.ReactNode; dragAndDropOverlayProps?: TreeItem2DragAndDropOverlayProps; labelInputProps?: TreeItem2LabelInputProps; + checkboxProps?: CheckboxProps & { visible?: boolean }; } export type TreeItemContentClassKey = keyof NonNullable; @@ -84,6 +85,7 @@ const TreeItemContent = React.forwardRef(function TreeItemContent( onMouseDown, dragAndDropOverlayProps, labelInputProps, + checkboxProps, ...other } = props; @@ -94,11 +96,9 @@ const TreeItemContent = React.forwardRef(function TreeItemContent( focused, editing, editable, - disableSelection, checkboxSelection, handleExpansion, handleSelection, - handleCheckboxSelection, handleContentClick, preventSelection, expansionTrigger, @@ -164,17 +164,9 @@ const TreeItemContent = React.forwardRef(function TreeItemContent( ref={ref} >
{icon}
- {checkboxSelection && ( - + {checkboxProps && ( + )} - {editing ? ( ) : ( @@ -193,6 +185,7 @@ TreeItemContent.propTypes = { // | These PropTypes are generated from the TypeScript type definitions | // | To update them edit the TypeScript types and run "pnpm proptypes" | // ---------------------------------------------------------------------- + checkboxProps: PropTypes.object, /** * Override or extend the styles applied to the component. */ diff --git a/packages/x-tree-view/src/TreeItem2/TreeItem2.test.tsx b/packages/x-tree-view/src/TreeItem2/TreeItem2.test.tsx index 524b3aca824fc..36317d592c2c0 100644 --- a/packages/x-tree-view/src/TreeItem2/TreeItem2.test.tsx +++ b/packages/x-tree-view/src/TreeItem2/TreeItem2.test.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { createRenderer } from '@mui/internal-test-utils'; +import { SimpleTreeView } from '@mui/x-tree-view/SimpleTreeView'; import { TreeItem2 } from '@mui/x-tree-view/TreeItem2'; import { treeItemClasses as classes } from '@mui/x-tree-view/TreeItem'; import { TreeViewContext } from '@mui/x-tree-view/internals/TreeViewProvider/TreeViewContext'; @@ -26,11 +27,9 @@ describe('', () => { describeSlotsConformance({ render, getElement: ({ props, slotName }) => ( - + - +
), slots: { label: { className: classes.label }, diff --git a/packages/x-tree-view/src/TreeItem2/TreeItem2.tsx b/packages/x-tree-view/src/TreeItem2/TreeItem2.tsx index 86a398dee5d4f..4046bed32731d 100644 --- a/packages/x-tree-view/src/TreeItem2/TreeItem2.tsx +++ b/packages/x-tree-view/src/TreeItem2/TreeItem2.tsx @@ -170,7 +170,7 @@ export const TreeItem2GroupTransition = styled(Collapse, { export const TreeItem2Checkbox = styled( React.forwardRef( - (props: CheckboxProps & { visible: boolean }, ref: React.Ref) => { + (props: CheckboxProps & { visible?: boolean }, ref: React.Ref) => { const { visible, ...other } = props; if (!visible) { return null; diff --git a/packages/x-tree-view/src/TreeView/TreeView.tsx b/packages/x-tree-view/src/TreeView/TreeView.tsx index efe8947e4c943..06be3ecba63ab 100644 --- a/packages/x-tree-view/src/TreeView/TreeView.tsx +++ b/packages/x-tree-view/src/TreeView/TreeView.tsx @@ -217,6 +217,26 @@ TreeView.propTypes = { * When `multiSelect` is true this takes an array of strings; when false (default) a string. */ selectedItems: PropTypes.any, + /** + * When `selectionPropagation.descendants` is set to `true`. + * + * - Selecting a parent selects all its descendants automatically. + * - Deselecting a parent deselects all its descendants automatically. + * + * When `selectionPropagation.parents` is set to `true`. + * + * - Selecting all the descendants of a parent selects the parent automatically. + * - Deselecting a descendant of a selected parent deselects the parent automatically. + * + * Only works when `multiSelect` is `true`. + * On the , only the expanded items are considered (since the collapsed item are not passed to the Tree View component at all) + * + * @default { parents: false, descendants: false } + */ + selectionPropagation: PropTypes.shape({ + descendants: PropTypes.bool, + parents: PropTypes.bool, + }), /** * The props used for each component slot. */ diff --git a/packages/x-tree-view/src/internals/models/itemPlugin.ts b/packages/x-tree-view/src/internals/models/itemPlugin.ts index c0858f84d9604..017fa155f28fb 100644 --- a/packages/x-tree-view/src/internals/models/itemPlugin.ts +++ b/packages/x-tree-view/src/internals/models/itemPlugin.ts @@ -1,10 +1,12 @@ import * as React from 'react'; import { EventHandlers } from '@mui/utils'; import type { + UseTreeItem2CheckboxSlotOwnProps, UseTreeItem2ContentSlotOwnProps, UseTreeItem2DragAndDropOverlaySlotOwnProps, UseTreeItem2LabelInputSlotOwnProps, UseTreeItem2RootSlotOwnProps, + UseTreeItem2Status, } from '../../useTreeItem2'; import type { UseTreeItem2Interactions } from '../../hooks/useTreeItem2Utils/useTreeItem2Utils'; @@ -12,11 +14,13 @@ export interface TreeViewItemPluginSlotPropsEnhancerParams { rootRefObject: React.MutableRefObject; contentRefObject: React.MutableRefObject; externalEventHandlers: EventHandlers; - // TODO v9: Remove "Pick" once the old TreeItem is removed. + // TODO v8: Remove "Pick" once the old TreeItem is removed. interactions: Pick< UseTreeItem2Interactions, - 'handleSaveItemLabel' | 'handleCancelItemLabelEditing' + 'handleSaveItemLabel' | 'handleCancelItemLabelEditing' | 'handleCheckboxSelection' >; + // TODO v8: Remove "Pick" once the old TreeItem is removed. + status: Pick; } type TreeViewItemPluginSlotPropsEnhancer = ( @@ -28,6 +32,7 @@ export interface TreeViewItemPluginSlotPropsEnhancers { content?: TreeViewItemPluginSlotPropsEnhancer; dragAndDropOverlay?: TreeViewItemPluginSlotPropsEnhancer; labelInput?: TreeViewItemPluginSlotPropsEnhancer; + checkbox?: TreeViewItemPluginSlotPropsEnhancer; } export interface TreeViewItemPluginResponse { 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 new file mode 100644 index 0000000000000..b5e8dfed978d7 --- /dev/null +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.itemPlugin.ts @@ -0,0 +1,115 @@ +import * as React from 'react'; +import type { TreeItem2Props } from '../../../TreeItem2'; +import type { TreeItemProps } from '../../../TreeItem'; +import { + TreeViewItemId, + TreeViewSelectionPropagation, + TreeViewCancellableEvent, +} from '../../../models'; +import { useTreeViewContext } from '../../TreeViewProvider'; +import { TreeViewInstance, TreeViewItemPlugin } from '../../models'; +import { + UseTreeItem2CheckboxSlotPropsFromSelection, + UseTreeViewSelectionSignature, +} from './useTreeViewSelection.types'; +import { UseTreeViewItemsSignature } from '../useTreeViewItems'; + +function getCheckboxStatus({ + itemId, + instance, + selectionPropagation, + selected, +}: { + itemId: TreeViewItemId; + instance: TreeViewInstance<[UseTreeViewItemsSignature, UseTreeViewSelectionSignature]>; + selectionPropagation: TreeViewSelectionPropagation; + selected: boolean; +}) { + if (selected) { + return { + indeterminate: false, + checked: true, + }; + } + + const children = instance.getItemOrderedChildrenIds(itemId); + if (children.length === 0) { + return { + indeterminate: false, + checked: false, + }; + } + + let hasSelectedDescendant = false; + let hasUnSelectedDescendant = false; + + const traverseDescendants = (itemToTraverseId: TreeViewItemId) => { + if (itemToTraverseId !== itemId) { + if (instance.isItemSelected(itemToTraverseId)) { + hasSelectedDescendant = true; + } else { + hasUnSelectedDescendant = true; + } + } + + instance.getItemOrderedChildrenIds(itemToTraverseId).forEach(traverseDescendants); + }; + + traverseDescendants(itemId); + + return { + indeterminate: + (hasSelectedDescendant && hasUnSelectedDescendant) || (!hasUnSelectedDescendant && !selected), + checked: selectionPropagation.parents ? hasSelectedDescendant : selected, + }; +} + +export const useTreeViewSelectionItemPlugin: TreeViewItemPlugin = ({ + props, +}) => { + const { itemId } = props; + + const { + instance, + selection: { disableSelection, checkboxSelection, selectionPropagation }, + } = useTreeViewContext<[UseTreeViewItemsSignature, UseTreeViewSelectionSignature]>(); + return { + propsEnhancers: { + checkbox: ({ + externalEventHandlers, + interactions, + status, + }): UseTreeItem2CheckboxSlotPropsFromSelection => { + const handleChange = ( + event: React.ChangeEvent & TreeViewCancellableEvent, + ) => { + externalEventHandlers.onChange?.(event); + if (event.defaultMuiPrevented) { + return; + } + + if (disableSelection || status.disabled) { + return; + } + + interactions.handleCheckboxSelection(event); + }; + + const checkboxStatus = getCheckboxStatus({ + instance, + itemId, + selectionPropagation, + selected: status.selected, + }); + + return { + visible: checkboxSelection, + disabled: disableSelection || status.disabled, + tabIndex: -1, + onChange: handleChange, + ...checkboxStatus, + }; + }, + }, + }; +}; diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.test.tsx b/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.test.tsx index 448178c94601e..c9cc2f20d28ca 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.test.tsx +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.test.tsx @@ -689,6 +689,128 @@ describeTreeView<[UseTreeViewSelectionSignature, UseTreeViewExpansionSignature]> fireEvent.click(view.getItemCheckboxInput('3'), { shiftKey: true }); expect(view.getSelectedTreeItems()).to.deep.equal(['1', '3']); }); + + it('should not select the parent when selecting all the children', () => { + const view = render({ + multiSelect: true, + checkboxSelection: true, + items: [{ id: '1', children: [{ id: '1.1' }, { id: '1.2' }] }, { id: '2' }], + defaultSelectedItems: ['1.2'], + defaultExpandedItems: ['1'], + }); + + fireEvent.click(view.getItemCheckboxInput('1.1')); + expect(view.getSelectedTreeItems()).to.deep.equal(['1.1', '1.2']); + }); + + it('should set the parent checkbox as indeterminate when some children are selected but the parent is not', () => { + const view = render({ + multiSelect: true, + checkboxSelection: true, + items: [{ id: '1', children: [{ id: '1.1' }, { id: '1.2' }] }, { id: '2' }], + defaultSelectedItems: ['1.1'], + defaultExpandedItems: ['1'], + }); + + expect(view.getItemCheckboxInput('1').dataset.indeterminate).to.equal('true'); + }); + + it('should not set the parent checkbox as indeterminate when no child is selected and the parent is not either', () => { + const view = render({ + multiSelect: true, + checkboxSelection: true, + items: [{ id: '1', children: [{ id: '1.1' }, { id: '1.2' }] }, { id: '2' }], + defaultExpandedItems: ['1'], + }); + + expect(view.getItemCheckboxInput('1').dataset.indeterminate).to.equal('false'); + }); + }); + + describe('multi selection with selectionPropagation.descendants = true', () => { + it('should select all the children when selecting a parent', () => { + const view = render({ + multiSelect: true, + checkboxSelection: true, + items: [{ id: '1', children: [{ id: '1.1' }, { id: '1.2' }] }], + defaultExpandedItems: ['1'], + selectionPropagation: { descendants: true }, + }); + + fireEvent.click(view.getItemCheckboxInput('1')); + expect(view.getSelectedTreeItems()).to.deep.equal(['1', '1.1', '1.2']); + }); + + it('should deselect all the children when deselecting a parent', () => { + const view = render({ + multiSelect: true, + checkboxSelection: true, + items: [{ id: '1', children: [{ id: '1.1' }, { id: '1.2' }] }], + defaultSelectedItems: ['1', '1.1', '1.2'], + defaultExpandedItems: ['1'], + selectionPropagation: { descendants: true }, + }); + + fireEvent.click(view.getItemCheckboxInput('1')); + expect(view.getSelectedTreeItems()).to.deep.equal([]); + }); + + it('should not select the parent when selecting all the children', () => { + const view = render({ + multiSelect: true, + checkboxSelection: true, + items: [{ id: '1', children: [{ id: '1.1' }, { id: '1.2' }] }], + defaultSelectedItems: ['1.2'], + defaultExpandedItems: ['1'], + selectionPropagation: { descendants: true }, + }); + + fireEvent.click(view.getItemCheckboxInput('1.1')); + expect(view.getSelectedTreeItems()).to.deep.equal(['1.1', '1.2']); + }); + + it('should not unselect the parent when unselecting a children', () => { + const view = render({ + multiSelect: true, + checkboxSelection: true, + items: [{ id: '1', children: [{ id: '1.1' }, { id: '1.2' }] }], + defaultSelectedItems: ['1', '1.1', '1.2'], + defaultExpandedItems: ['1'], + selectionPropagation: { descendants: true }, + }); + + fireEvent.click(view.getItemCheckboxInput('1.1')); + expect(view.getSelectedTreeItems()).to.deep.equal(['1', '1.2']); + }); + }); + + describe('multi selection with selectionPropagation.parents = true', () => { + it('should select all the parents when selecting a child', () => { + const view = render({ + multiSelect: true, + checkboxSelection: true, + items: [{ id: '1', children: [{ id: '1.1', children: [{ id: '1.1.1' }] }] }], + defaultExpandedItems: ['1', '1.1'], + selectionPropagation: { parents: true }, + }); + + fireEvent.click(view.getItemCheckboxInput('1.1.1')); + expect(view.getSelectedTreeItems()).to.deep.equal(['1', '1.1', '1.1.1']); + }); + + it('should deselect all the parents when deselecting a child ', () => { + const view = render({ + multiSelect: true, + checkboxSelection: true, + items: [{ id: '1', children: [{ id: '1.1', children: [{ id: '1.1.1' }] }] }], + defaultSelectedItems: ['1', '1.1', '1.1.1'], + defaultExpandedItems: ['1', '1.1'], + selectionPropagation: { parents: true }, + }); + + fireEvent.click(view.getItemCheckboxInput('1.1.1')); + expect(view.getSelectedTreeItems()).to.deep.equal([]); + }); }); }); 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 c24fad0b6bbe5..e2225d707777b 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.ts @@ -12,7 +12,13 @@ import { UseTreeViewSelectionInstance, UseTreeViewSelectionSignature, } from './useTreeViewSelection.types'; -import { convertSelectedItemsToArray, getLookupFromArray } from './useTreeViewSelection.utils'; +import { + convertSelectedItemsToArray, + propagateSelection, + getAddedAndRemovedItems, + getLookupFromArray, +} from './useTreeViewSelection.utils'; +import { useTreeViewSelectionItemPlugin } from './useTreeViewSelection.itemPlugin'; export const useTreeViewSelection: TreeViewPlugin = ({ instance, @@ -37,39 +43,58 @@ export const useTreeViewSelection: TreeViewPlugin const setSelectedItems = ( event: React.SyntheticEvent, - newSelectedItems: typeof params.defaultSelectedItems, + newModel: typeof params.defaultSelectedItems, + additionalItemsToPropagate?: TreeViewItemId[], ) => { + let cleanModel: typeof newModel; + + if ( + params.multiSelect && + (params.selectionPropagation.descendants || params.selectionPropagation.parents) + ) { + cleanModel = propagateSelection({ + instance, + selectionPropagation: params.selectionPropagation, + newModel: newModel as string[], + oldModel: models.selectedItems.value as string[], + additionalItemsToPropagate, + }); + } else { + cleanModel = newModel; + } + if (params.onItemSelectionToggle) { if (params.multiSelect) { - const addedItems = (newSelectedItems as string[]).filter( - (itemId) => !instance.isItemSelected(itemId), - ); - const removedItems = (models.selectedItems.value as string[]).filter( - (itemId) => !(newSelectedItems as string[]).includes(itemId), - ); - - addedItems.forEach((itemId) => { - params.onItemSelectionToggle!(event, itemId, true); + const changes = getAddedAndRemovedItems({ + instance, + newModel: cleanModel as string[], + oldModel: models.selectedItems.value as string[], }); - removedItems.forEach((itemId) => { - params.onItemSelectionToggle!(event, itemId, false); - }); - } else if (newSelectedItems !== models.selectedItems.value) { + if (params.onItemSelectionToggle) { + changes.added.forEach((itemId) => { + params.onItemSelectionToggle!(event, itemId, true); + }); + + changes.removed.forEach((itemId) => { + params.onItemSelectionToggle!(event, itemId, false); + }); + } + } else if (params.onItemSelectionToggle && cleanModel !== models.selectedItems.value) { if (models.selectedItems.value != null) { params.onItemSelectionToggle(event, models.selectedItems.value as string, false); } - if (newSelectedItems != null) { - params.onItemSelectionToggle(event, newSelectedItems as string, true); + if (cleanModel != null) { + params.onItemSelectionToggle(event, cleanModel as string, true); } } } if (params.onSelectedItemsChange) { - params.onSelectedItemsChange(event, newSelectedItems); + params.onSelectedItemsChange(event, cleanModel); } - models.selectedItems.setControlledValue(newSelectedItems); + models.selectedItems.setControlledValue(cleanModel); }; const isItemSelected = (itemId: string) => selectedItemsMap.has(itemId); @@ -107,7 +132,13 @@ export const useTreeViewSelection: TreeViewPlugin } } - setSelectedItems(event, newSelected); + setSelectedItems( + event, + newSelected, + // If shouldBeSelected === instance.isItemSelect(itemId), we still want to propagate the select. + // This is useful when the element is in an indeterminate state. + [itemId], + ); lastSelectedItem.current = itemId; lastSelectedRange.current = {}; }; @@ -213,11 +244,14 @@ export const useTreeViewSelection: TreeViewPlugin multiSelect: params.multiSelect, checkboxSelection: params.checkboxSelection, disableSelection: params.disableSelection, + selectionPropagation: params.selectionPropagation, }, }, }; }; +useTreeViewSelection.itemPlugin = useTreeViewSelectionItemPlugin; + useTreeViewSelection.models = { selectedItems: { getDefaultValue: (params) => params.defaultSelectedItems, @@ -233,6 +267,7 @@ useTreeViewSelection.getDefaultizedParams = ({ params }) => ({ checkboxSelection: params.checkboxSelection ?? false, defaultSelectedItems: params.defaultSelectedItems ?? (params.multiSelect ? DEFAULT_SELECTED_ITEMS : null), + selectionPropagation: params.selectionPropagation ?? {}, }); useTreeViewSelection.params = { @@ -243,4 +278,5 @@ useTreeViewSelection.params = { selectedItems: true, onSelectedItemsChange: true, onItemSelectionToggle: true, + selectionPropagation: true, }; 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 d70f440eec262..bb573c827b220 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 @@ -2,6 +2,7 @@ import * as React from 'react'; import type { DefaultizedProps, TreeViewPluginSignature } from '../../models'; import { UseTreeViewItemsSignature } from '../useTreeViewItems'; import { UseTreeViewExpansionSignature } from '../useTreeViewExpansion'; +import { TreeViewSelectionPropagation, TreeViewCancellableEventHandler } from '../../../models'; export interface UseTreeViewSelectionPublicAPI { /** @@ -94,6 +95,23 @@ export interface UseTreeViewSelectionParameters, only the expanded items are considered (since the collapsed item are not passed to the Tree View component at all) + * + * @default { parents: false, descendants: false } + */ + selectionPropagation?: TreeViewSelectionPropagation; /** * Callback fired when Tree Items are selected/deselected. * @param {React.SyntheticEvent} event The DOM event that triggered the change. @@ -119,13 +137,17 @@ export interface UseTreeViewSelectionParameters = DefaultizedProps< UseTreeViewSelectionParameters, - 'disableSelection' | 'defaultSelectedItems' | 'multiSelect' | 'checkboxSelection' + | 'disableSelection' + | 'defaultSelectedItems' + | 'multiSelect' + | 'checkboxSelection' + | 'selectionPropagation' >; interface UseTreeViewSelectionContextValue { selection: Pick< UseTreeViewSelectionDefaultizedParameters, - 'multiSelect' | 'checkboxSelection' | 'disableSelection' + 'multiSelect' | 'checkboxSelection' | 'disableSelection' | 'selectionPropagation' >; } @@ -142,3 +164,15 @@ export type UseTreeViewSelectionSignature = TreeViewPluginSignature<{ UseTreeViewItemsSignature, ]; }>; + +export interface UseTreeItem2CheckboxSlotPropsFromSelection { + visible?: boolean; + checked?: boolean; + disabled?: boolean; + tabIndex?: -1; + onChange?: TreeViewCancellableEventHandler>; +} + +declare module '@mui/x-tree-view/useTreeItem2' { + interface UseTreeItem2CheckboxSlotOwnProps extends UseTreeItem2CheckboxSlotPropsFromSelection {} +} 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 bb022e13338c6..29913562a4b69 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,3 +1,8 @@ +import { TreeViewItemId, TreeViewSelectionPropagation } from '../../../models'; +import { TreeViewInstance } from '../../models'; +import { UseTreeViewItemsSignature } from '../useTreeViewItems'; +import { UseTreeViewSelectionSignature } from './useTreeViewSelection.types'; + /** * Transform the `selectedItems` model to be an array if it was a string or null. * @param {string[] | string | null} model The raw model. @@ -16,9 +21,133 @@ export const convertSelectedItemsToArray = (model: string[] | string | null): st }; export const getLookupFromArray = (array: string[]) => { - const lookup: { [itemId: string]: boolean } = {}; + const lookup: { [itemId: string]: true } = {}; array.forEach((itemId) => { lookup[itemId] = true; }); return lookup; }; + +export const getAddedAndRemovedItems = ({ + instance, + oldModel, + newModel, +}: { + instance: TreeViewInstance<[UseTreeViewSelectionSignature]>; + oldModel: TreeViewItemId[]; + newModel: TreeViewItemId[]; +}) => { + const newModelLookup = getLookupFromArray(newModel); + + return { + added: newModel.filter((itemId) => !instance.isItemSelected(itemId)), + removed: oldModel.filter((itemId) => !newModelLookup[itemId]), + }; +}; + +export const propagateSelection = ({ + instance, + selectionPropagation, + newModel, + oldModel, + additionalItemsToPropagate, +}: { + instance: TreeViewInstance<[UseTreeViewItemsSignature, UseTreeViewSelectionSignature]>; + selectionPropagation: TreeViewSelectionPropagation; + newModel: TreeViewItemId[]; + oldModel: TreeViewItemId[]; + additionalItemsToPropagate?: TreeViewItemId[]; +}): string[] => { + if (!selectionPropagation.descendants && !selectionPropagation.parents) { + return newModel; + } + + let shouldRegenerateModel = false; + const newModelLookup = getLookupFromArray(newModel); + + const changes = getAddedAndRemovedItems({ + instance, + newModel, + oldModel, + }); + + additionalItemsToPropagate?.forEach((itemId) => { + if (newModelLookup[itemId]) { + if (!changes.added.includes(itemId)) { + changes.added.push(itemId); + } + } else if (!changes.removed.includes(itemId)) { + changes.removed.push(itemId); + } + }); + + changes.added.forEach((addedItemId) => { + if (selectionPropagation.descendants) { + const selectDescendants = (itemId: TreeViewItemId) => { + if (itemId !== addedItemId) { + shouldRegenerateModel = true; + newModelLookup[itemId] = true; + } + + instance.getItemOrderedChildrenIds(itemId).forEach(selectDescendants); + }; + + selectDescendants(addedItemId); + } + + if (selectionPropagation.parents) { + const checkAllDescendantsSelected = (itemId: TreeViewItemId): boolean => { + if (!newModelLookup[itemId]) { + return false; + } + + const children = instance.getItemOrderedChildrenIds(itemId); + return children.every(checkAllDescendantsSelected); + }; + + const selectParents = (itemId: TreeViewItemId) => { + const parentId = instance.getItemMeta(itemId).parentId; + if (parentId == null) { + return; + } + + const siblings = instance.getItemOrderedChildrenIds(parentId); + if (siblings.every(checkAllDescendantsSelected)) { + shouldRegenerateModel = true; + newModelLookup[parentId] = true; + selectParents(parentId); + } + }; + selectParents(addedItemId); + } + }); + + changes.removed.forEach((removedItemId) => { + if (selectionPropagation.parents) { + let parentId = instance.getItemMeta(removedItemId).parentId; + while (parentId != null) { + if (newModelLookup[parentId]) { + shouldRegenerateModel = true; + delete newModelLookup[parentId]; + } + + parentId = instance.getItemMeta(parentId).parentId; + } + } + + if (selectionPropagation.descendants) { + const deSelectDescendants = (itemId: TreeViewItemId) => { + if (itemId !== removedItemId) { + shouldRegenerateModel = true; + delete newModelLookup[itemId]; + } + + instance.getItemOrderedChildrenIds(itemId).forEach(deSelectDescendants); + }; + + deSelectDescendants(removedItemId); + } + }); + + return shouldRegenerateModel ? Object.keys(newModelLookup) : newModel; +}; diff --git a/packages/x-tree-view/src/models/items.ts b/packages/x-tree-view/src/models/items.ts index f1ef54da13b9c..0d15e359d2d67 100644 --- a/packages/x-tree-view/src/models/items.ts +++ b/packages/x-tree-view/src/models/items.ts @@ -10,3 +10,8 @@ export type TreeViewItemsReorderingAction = | 'reorder-below' | 'make-child' | 'move-to-parent'; + +export interface TreeViewSelectionPropagation { + descendants?: boolean; + parents?: boolean; +} diff --git a/packages/x-tree-view/src/useTreeItem2/useTreeItem2.ts b/packages/x-tree-view/src/useTreeItem2/useTreeItem2.ts index 6eb05172543f9..db8934d6300e9 100644 --- a/packages/x-tree-view/src/useTreeItem2/useTreeItem2.ts +++ b/packages/x-tree-view/src/useTreeItem2/useTreeItem2.ts @@ -59,7 +59,7 @@ export const useTreeItem2 = < const sharedPropsEnhancerParams: Omit< TreeViewItemPluginSlotPropsEnhancerParams, 'externalEventHandlers' - > = { rootRefObject, contentRefObject, interactions }; + > = { rootRefObject, contentRefObject, interactions, status }; const createRootHandleFocus = (otherHandlers: EventHandlers) => @@ -158,21 +158,6 @@ export const useTreeItem2 = < } }; - const createCheckboxHandleChange = - (otherHandlers: EventHandlers) => - (event: React.ChangeEvent & TreeViewCancellableEvent) => { - otherHandlers.onChange?.(event); - if (event.defaultMuiPrevented) { - return; - } - - if (disableSelection || status.disabled) { - return; - } - - interactions.handleCheckboxSelection(event); - }; - const createIconContainerHandleClick = (otherHandlers: EventHandlers) => (event: React.MouseEvent & TreeViewCancellableEvent) => { otherHandlers.onClick?.(event); @@ -268,15 +253,21 @@ export const useTreeItem2 = < ): UseTreeItem2CheckboxSlotProps => { const externalEventHandlers = extractEventHandlers(externalProps); - return { + const props = { ...externalEventHandlers, - visible: checkboxSelection, ref: checkboxRef, - checked: status.selected, - disabled: disableSelection || status.disabled, - tabIndex: -1, ...externalProps, - onChange: createCheckboxHandleChange(externalEventHandlers), + }; + + const enhancedCheckboxProps = + propsEnhancers.checkbox?.({ + ...sharedPropsEnhancerParams, + externalEventHandlers, + }) ?? {}; + + return { + ...props, + ...enhancedCheckboxProps, }; }; @@ -308,10 +299,8 @@ export const useTreeItem2 = < const enhancedLabelInputProps = propsEnhancers.labelInput?.({ - rootRefObject, - contentRefObject, + ...sharedPropsEnhancerParams, externalEventHandlers, - interactions, }) ?? {}; return { diff --git a/packages/x-tree-view/src/useTreeItem2/useTreeItem2.types.ts b/packages/x-tree-view/src/useTreeItem2/useTreeItem2.types.ts index 3d711b079230f..d154c9d037efb 100644 --- a/packages/x-tree-view/src/useTreeItem2/useTreeItem2.types.ts +++ b/packages/x-tree-view/src/useTreeItem2/useTreeItem2.types.ts @@ -97,15 +97,13 @@ export interface UseTreeItem2LabelInputSlotOwnProps {} export type UseTreeItem2LabelInputSlotProps = ExternalProps & UseTreeItem2LabelInputSlotOwnProps; -export interface UseTreeItem2CheckboxSlotOwnProps { - visible: boolean; - checked: boolean; - onChange: TreeViewCancellableEventHandler>; - disabled: boolean; +export interface UseTreeItem2CheckboxSlotPropsFromUseTreeItem { ref: React.RefObject; - tabIndex: -1; } +export interface UseTreeItem2CheckboxSlotOwnProps + extends UseTreeItem2CheckboxSlotPropsFromUseTreeItem {} + export type UseTreeItem2CheckboxSlotProps = ExternalProps & UseTreeItem2CheckboxSlotOwnProps; diff --git a/scripts/x-tree-view-pro.exports.json b/scripts/x-tree-view-pro.exports.json index decec771aa7fd..16fcc0d632504 100644 --- a/scripts/x-tree-view-pro.exports.json +++ b/scripts/x-tree-view-pro.exports.json @@ -61,6 +61,7 @@ { "name": "TreeViewItemId", "kind": "TypeAlias" }, { "name": "TreeViewItemsReorderingAction", "kind": "TypeAlias" }, { "name": "TreeViewProps", "kind": "Interface" }, + { "name": "TreeViewSelectionPropagation", "kind": "Interface" }, { "name": "TreeViewSlotProps", "kind": "Interface" }, { "name": "TreeViewSlots", "kind": "Interface" }, { "name": "unstable_resetCleanupTracking", "kind": "Variable" }, diff --git a/scripts/x-tree-view.exports.json b/scripts/x-tree-view.exports.json index 67797b594ce19..00c255bc05f51 100644 --- a/scripts/x-tree-view.exports.json +++ b/scripts/x-tree-view.exports.json @@ -65,6 +65,7 @@ { "name": "TreeViewItemId", "kind": "TypeAlias" }, { "name": "TreeViewItemsReorderingAction", "kind": "TypeAlias" }, { "name": "TreeViewProps", "kind": "Interface" }, + { "name": "TreeViewSelectionPropagation", "kind": "Interface" }, { "name": "TreeViewSlotProps", "kind": "Interface" }, { "name": "TreeViewSlots", "kind": "Interface" }, { "name": "unstable_resetCleanupTracking", "kind": "Variable" }, diff --git a/test/utils/tree-view/fakeContextValue.ts b/test/utils/tree-view/fakeContextValue.ts index eabcda86ac5d4..649fb121c4b2a 100644 --- a/test/utils/tree-view/fakeContextValue.ts +++ b/test/utils/tree-view/fakeContextValue.ts @@ -41,6 +41,7 @@ export const getFakeContextValue = ( multiSelect: false, checkboxSelection: features.checkboxSelection ?? false, disableSelection: false, + selectionPropagation: {}, }, treeId: 'mui-tree-view-1', rootRef: {