diff --git a/.eslintrc.js b/.eslintrc.js index fac05b6d0e35d..6d7a7f1825f1e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -24,7 +24,7 @@ const ENABLE_REACT_COMPILER_PLUGIN_DATA_GRID = const ENABLE_REACT_COMPILER_PLUGIN_DATE_PICKERS = process.env.ENABLE_REACT_COMPILER_PLUGIN_DATE_PICKERS ?? false; const ENABLE_REACT_COMPILER_PLUGIN_TREE_VIEW = - process.env.ENABLE_REACT_COMPILER_PLUGIN_TREE_VIEW ?? false; + process.env.ENABLE_REACT_COMPILER_PLUGIN_TREE_VIEW ?? true; const isAnyReactCompilerPluginEnabled = ENABLE_REACT_COMPILER_PLUGIN || diff --git a/packages/x-tree-view/src/internals/models/plugin.ts b/packages/x-tree-view/src/internals/models/plugin.ts index 8265fa9bc8706..c0869a18a0438 100644 --- a/packages/x-tree-view/src/internals/models/plugin.ts +++ b/packages/x-tree-view/src/internals/models/plugin.ts @@ -1,6 +1,6 @@ import * as React from 'react'; import { EventHandlers } from '@mui/utils/types'; -import { TreeViewExperimentalFeatures, TreeViewInstance, TreeViewModel } from './treeView'; +import { TreeViewExperimentalFeatures, TreeViewInstance } from './treeView'; import type { MergeSignaturesProperty, OptionalIfEmpty } from './helpers'; import { TreeViewEventLookupElement } from './events'; import type { TreeViewCorePluginSignatures } from '../corePlugins'; @@ -14,20 +14,11 @@ export interface TreeViewPluginOptions; - models: TreeViewUsedModels; store: TreeViewUsedStore; rootRef: React.RefObject; plugins: TreeViewPlugin[]; } -type TreeViewModelsInitializer = { - [TControlled in keyof TSignature['models']]: { - getDefaultValue: ( - params: TSignature['defaultizedParams'], - ) => Exclude; - }; -}; - type TreeViewResponse = { getRootProps?: ( otherHandlers: TOther, @@ -48,7 +39,6 @@ export type TreeViewPluginSignature< contextValue?: {}; slots?: { [key in keyof T['slots']]: React.ElementType }; slotProps?: { [key in keyof T['slotProps']]: {} | (() => {}) }; - modelNames?: keyof T['defaultizedParams']; experimentalFeatures?: string; dependencies?: readonly TreeViewAnyPluginSignature[]; optionalDependencies?: readonly TreeViewAnyPluginSignature[]; @@ -64,13 +54,6 @@ export type TreeViewPluginSignature< contextValue: T extends { contextValue: {} } ? T['contextValue'] : {}; slots: T extends { slots: {} } ? T['slots'] : {}; slotProps: T extends { slotProps: {} } ? T['slotProps'] : {}; - models: T extends { defaultizedParams: {}; modelNames: keyof T['defaultizedParams'] } - ? { - [TControlled in T['modelNames']]-?: TreeViewModel< - Exclude - >; - } - : {}; experimentalFeatures: T extends { experimentalFeatures: string } ? { [key in T['experimentalFeatures']]?: boolean } : {}; @@ -92,7 +75,6 @@ export type TreeViewAnyPluginSignature = { contextValue: any; slots: any; slotProps: any; - models: any; experimentalFeatures: any; publicAPI: any; }; @@ -133,14 +115,6 @@ type TreeViewUsedExperimentalFeatures; -type RemoveSetValue>> = { - [K in keyof Models]: Omit; -}; - -export type TreeViewUsedModels = - TSignature['models'] & - RemoveSetValue, 'models'>>; - export type TreeViewUsedEvents = TSignature['events'] & MergeSignaturesProperty, 'events'>; @@ -161,7 +135,6 @@ export type TreeViewPlugin = { }) => TSignature['defaultizedParams']; getInitialState?: (params: TreeViewUsedDefaultizedParams) => TSignature['state']; getInitialCache?: () => TSignature['cache']; - models?: TreeViewModelsInitializer; params: Record; itemPlugin?: TreeViewItemPlugin; /** diff --git a/packages/x-tree-view/src/internals/models/treeView.ts b/packages/x-tree-view/src/internals/models/treeView.ts index b2c13afe8da23..f505a56ec408f 100644 --- a/packages/x-tree-view/src/internals/models/treeView.ts +++ b/packages/x-tree-view/src/internals/models/treeView.ts @@ -18,12 +18,6 @@ export interface TreeViewItemMeta { label?: string; } -export interface TreeViewModel { - name: string; - value: TValue; - setControlledValue: (value: TValue | ((prevValue: TValue) => TValue)) => void; -} - export type TreeViewInstance< TSignatures extends readonly TreeViewAnyPluginSignature[], TOptionalSignatures extends readonly TreeViewAnyPluginSignature[] = [], 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 index 1871b09fd45fb..8d666a9bc52ca 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.selectors.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.selectors.ts @@ -1,3 +1,4 @@ +import { TreeViewItemId } from '../../../models'; import { createSelector, TreeViewRootSelector } from '../../utils/selectors'; import { selectorItemMeta } from '../useTreeViewItems/useTreeViewItems.selectors'; import { UseTreeViewExpansionSignature } from './useTreeViewExpansion.types'; @@ -5,14 +6,38 @@ import { UseTreeViewExpansionSignature } from './useTreeViewExpansion.types'; const selectorExpansion: TreeViewRootSelector = (state) => state.expansion; +/** + * Get the expanded items. + * @param {TreeViewState<[UseTreeViewExpansionSignature]>} state The state of the tree view. + * @returns {TreeViewItemId[]} The expanded items. + */ +export const selectorExpandedItems = createSelector( + [selectorExpansion], + (expansionState) => expansionState.expandedItems, +); + +/** + * Get the expanded items as a Map. + * @param {TreeViewState<[UseTreeViewExpansionSignature]>} state The state of the tree view. + * @returns {TreeViewExpansionValue} The expanded items as a Map. + */ +export const selectorExpandedItemsMap = createSelector([selectorExpandedItems], (expandedItems) => { + const expandedItemsMap = new Map(); + expandedItems.forEach((id) => { + expandedItemsMap.set(id, true); + }); + + return expandedItemsMap; +}); + /** * 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), + [selectorExpandedItemsMap, (_, itemId: string) => itemId], + (expandedItemsMap, itemId) => expandedItemsMap.has(itemId), ); /** 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 a3b9723c42343..96e7dcec63625 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.ts @@ -4,28 +4,28 @@ 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, getExpansionTrigger } from './useTreeViewExpansion.utils'; +import { + selectorExpandedItems, + selectorIsItemExpandable, + selectorIsItemExpanded, +} from './useTreeViewExpansion.selectors'; +import { getExpansionTrigger } from './useTreeViewExpansion.utils'; import { selectorItemMeta, selectorItemOrderedChildrenIds, } from '../useTreeViewItems/useTreeViewItems.selectors'; +import { useAssertModelConsistency } from '../../utils/models'; export const useTreeViewExpansion: TreeViewPlugin = ({ instance, store, params, - models, }) => { - useEnhancedEffect(() => { - store.update((prevState) => ({ - ...prevState, - expansion: { - ...prevState.expansion, - expandedItemsMap: createExpandedItemsMap(models.expandedItems.value), - }, - })); - }, [store, models.expandedItems.value]); + useAssertModelConsistency({ + state: 'expandedItems', + controlled: params.expandedItems, + defaultValue: params.defaultExpandedItems, + }); useEnhancedEffect(() => { store.update((prevState) => { @@ -48,8 +48,13 @@ export const useTreeViewExpansion: TreeViewPlugin }, [store, params.isItemEditable, params.expansionTrigger]); const setExpandedItems = (event: React.SyntheticEvent, value: TreeViewItemId[]) => { + if (params.expandedItems === undefined) { + store.update((prevState) => ({ + ...prevState, + expansion: { ...prevState.expansion, expandedItems: value }, + })); + } params.onExpandedItemsChange?.(event, value); - models.expandedItems.setControlledValue(value); }; const toggleItemExpansion = useEventCallback( @@ -66,18 +71,19 @@ export const useTreeViewExpansion: TreeViewPlugin return; } - let newExpanded: string[]; + const oldModel = selectorExpandedItems(store.value); + let newModel: string[]; if (isExpanded) { - newExpanded = [itemId].concat(models.expandedItems.value); + newModel = [itemId].concat(oldModel); } else { - newExpanded = models.expandedItems.value.filter((id) => id !== itemId); + newModel = oldModel.filter((id) => id !== itemId); } if (params.onItemExpansionToggle) { params.onItemExpansionToggle(event, itemId, isExpanded); } - setExpandedItems(event, newExpanded); + setExpandedItems(event, newModel); }, ); @@ -93,8 +99,7 @@ export const useTreeViewExpansion: TreeViewPlugin (child) => selectorIsItemExpandable(store.value, child) && !selectorIsItemExpanded(store.value, child), ); - - const newExpanded = models.expandedItems.value.concat(diff); + const newModel = selectorExpandedItems(store.value).concat(diff); if (diff.length > 0) { if (params.onItemExpansionToggle) { @@ -103,7 +108,7 @@ export const useTreeViewExpansion: TreeViewPlugin }); } - setExpandedItems(event, newExpanded); + setExpandedItems(event, newModel); } }; @@ -119,12 +124,6 @@ export const useTreeViewExpansion: TreeViewPlugin }; }; -useTreeViewExpansion.models = { - expandedItems: { - getDefaultValue: (params) => params.defaultExpandedItems, - }, -}; - const DEFAULT_EXPANDED_ITEMS: string[] = []; useTreeViewExpansion.getDefaultizedParams = ({ params }) => ({ @@ -134,9 +133,8 @@ useTreeViewExpansion.getDefaultizedParams = ({ params }) => ({ useTreeViewExpansion.getInitialState = (params) => ({ expansion: { - expandedItemsMap: createExpandedItemsMap( + expandedItems: params.expandedItems === undefined ? params.defaultExpandedItems : params.expandedItems, - ), expansionTrigger: getExpansionTrigger(params), }, }); 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 a903fa645bfa3..6a6a7efe72053 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 @@ -74,7 +74,7 @@ export type UseTreeViewExpansionDefaultizedParameters = DefaultizedProps< export interface UseTreeViewExpansionState { expansion: { - expandedItemsMap: Map; + expandedItems: string[]; expansionTrigger: 'content' | 'iconContainer'; }; } @@ -84,7 +84,6 @@ export type UseTreeViewExpansionSignature = TreeViewPluginSignature<{ defaultizedParams: UseTreeViewExpansionDefaultizedParameters; instance: UseTreeViewExpansionInstance; publicAPI: UseTreeViewExpansionPublicAPI; - modelNames: 'expandedItems'; state: UseTreeViewExpansionState; 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 index 45ae73e5a6f82..8d120b501adbd 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.utils.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.utils.ts @@ -1,16 +1,6 @@ -import { TreeViewItemId } from '../../../models'; import { TreeViewUsedDefaultizedParams } from '../../models'; import { UseTreeViewExpansionSignature } from './useTreeViewExpansion.types'; -export const createExpandedItemsMap = (expandedItems: string[]) => { - const expandedItemsMap = new Map(); - expandedItems.forEach((id) => { - expandedItemsMap.set(id, true); - }); - - return expandedItemsMap; -}; - export const getExpansionTrigger = ({ isItemEditable, expansionTrigger, 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 index 4681cdc793d46..74bc5848b4d2e 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.selectors.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.selectors.ts @@ -1,5 +1,13 @@ import { UseTreeViewFocusSignature } from './useTreeViewFocus.types'; import { createSelector, TreeViewRootSelector } from '../../utils/selectors'; +import { selectorSelectionModelArray } from '../useTreeViewSelection/useTreeViewSelection.selectors'; +import { + selectorDisabledItemFocusable, + selectorItemMetaLookup, + selectorItemOrderedChildrenIds, +} from '../useTreeViewItems/useTreeViewItems.selectors'; +import { isItemDisabled } from '../useTreeViewItems/useTreeViewItems.utils'; +import { selectorExpandedItemsMap } from '../useTreeViewExpansion/useTreeViewExpansion.selectors'; const selectorTreeViewFocusState: TreeViewRootSelector = (state) => state.focus; @@ -12,8 +20,37 @@ const selectorTreeViewFocusState: TreeViewRootSelector focus.defaultFocusableItemId, + [ + selectorSelectionModelArray, + selectorExpandedItemsMap, + selectorItemMetaLookup, + selectorDisabledItemFocusable, + (state) => selectorItemOrderedChildrenIds(state, null), + ], + (selectedItems, expandedItemsMap, itemMetaLookup, disabledItemsFocusable, orderedRootItemIds) => { + const firstSelectedItem = selectedItems.find((itemId) => { + if (!disabledItemsFocusable && isItemDisabled(itemMetaLookup, itemId)) { + return false; + } + + const itemMeta = itemMetaLookup[itemId]; + return itemMeta && (itemMeta.parentId == null || expandedItemsMap.has(itemMeta.parentId)); + }); + + if (firstSelectedItem != null) { + return firstSelectedItem; + } + + const firstNavigableItem = orderedRootItemIds.find( + (itemId) => !disabledItemsFocusable || !isItemDisabled(itemMetaLookup, itemId), + ); + + if (firstNavigableItem != null) { + return firstNavigableItem; + } + + return null; + }, ); /** 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 0ed28f4a63b2f..5d1e0a95267d9 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts @@ -4,59 +4,19 @@ import { EventHandlers } from '@mui/utils/types'; import { TreeViewPlugin } from '../../models'; import { UseTreeViewFocusSignature } from './useTreeViewFocus.types'; import { useInstanceEventHandler } from '../../hooks/useInstanceEventHandler'; -import { getFirstNavigableItem } from '../../utils/tree'; import { TreeViewCancellableEvent } from '../../../models'; -import { convertSelectedItemsToArray } from '../useTreeViewSelection/useTreeViewSelection.utils'; import { selectorDefaultFocusableItemId, selectorFocusedItemId, } from './useTreeViewFocus.selectors'; import { selectorIsItemExpanded } from '../useTreeViewExpansion/useTreeViewExpansion.selectors'; -import { - selectorCanItemBeFocused, - selectorItemMeta, -} from '../useTreeViewItems/useTreeViewItems.selectors'; +import { selectorItemMeta } from '../useTreeViewItems/useTreeViewItems.selectors'; export const useTreeViewFocus: TreeViewPlugin = ({ instance, params, store, - models, }) => { - React.useEffect(() => { - 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; - } - - store.update((prevState) => { - if (defaultFocusableItemId === prevState.focus.defaultFocusableItemId) { - return prevState; - } - - return { - ...prevState, - focus: { - ...prevState.focus, - defaultFocusableItemId, - }, - }; - }); - }, [store, models.selectedItems.value]); - const setFocusedItemId = useEventCallback((itemId: string | null) => { store.update((prevState) => { const focusedItemId = selectorFocusedItemId(prevState); @@ -166,7 +126,7 @@ export const useTreeViewFocus: TreeViewPlugin = ({ }; useTreeViewFocus.getInitialState = () => ({ - focus: { focusedItemId: null, defaultFocusableItemId: null }, + focus: { focusedItemId: null }, }); useTreeViewFocus.params = { 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 105ad6a364691..7958b86992494 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 @@ -37,7 +37,6 @@ export type UseTreeViewFocusDefaultizedParameters = UseTreeViewFocusParameters; export interface UseTreeViewFocusState { focus: { focusedItemId: string | null; - defaultFocusableItemId: string | null; }; } 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 index 209e933a27a23..faa9c3518ac5b 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewItems/useTreeViewItems.selectors.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewItems/useTreeViewItems.selectors.ts @@ -2,7 +2,7 @@ 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'; +import { isItemDisabled, TREE_VIEW_ROOT_PARENT_ID } from './useTreeViewItems.utils'; const selectorTreeViewItemsState: TreeViewRootSelector = (state) => state.items; @@ -66,31 +66,7 @@ export const selectorItemMeta = createSelector( */ 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; - }, + isItemDisabled, ); /** @@ -134,13 +110,29 @@ export const selectorItemDepth = createSelector( (itemMeta) => itemMeta?.depth ?? 0, ); +/** + * Check if the disabled items are focusable. + * @param {TreeViewState<[UseTreeViewItemsSignature]>} state The state of the tree view. + * @returns {boolean} Whether the disabled items are focusable. + */ +export const selectorDisabledItemFocusable = createSelector( + [selectorTreeViewItemsState], + (itemsState) => itemsState.disabledItemsFocusable, +); + +/** + * Check if an item can be focused. + * @param {TreeViewState<[UseTreeViewItemsSignature]>} state The state of the tree view. + * @param {TreeViewItemId} itemId The id of the item to check. + * @returns {boolean} Whether the item can be focused. + */ export const selectorCanItemBeFocused = createSelector( - [selectorTreeViewItemsState, selectorIsItemDisabled], - (itemsState, isItemDisabled) => { - if (itemsState.disabledItemsFocusable) { + [selectorDisabledItemFocusable, selectorIsItemDisabled], + (disabledItemsFocusable, isDisabled) => { + if (disabledItemsFocusable) { return true; } - return !isItemDisabled; + return !isDisabled; }, ); diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewItems/useTreeViewItems.utils.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewItems/useTreeViewItems.utils.ts index 176d1b5eb37d5..55da45bff4dfb 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewItems/useTreeViewItems.utils.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewItems/useTreeViewItems.utils.ts @@ -1,3 +1,6 @@ +import { TreeViewItemId } from '../../../models'; +import { TreeViewItemMeta } from '../../models'; + export const TREE_VIEW_ROOT_PARENT_ID = '__TREE_VIEW_ROOT_PARENT_ID__'; export const buildSiblingIndexes = (siblings: string[]) => { @@ -8,3 +11,38 @@ export const buildSiblingIndexes = (siblings: string[]) => { return siblingsIndexLookup; }; + +/** + * Check if an item is disabled. + * This method should only be used in selectors that are checking if several items are disabled. + * Otherwise, use the `selectorIsItemDisabled` selector. + * @returns + */ +export const isItemDisabled = ( + itemMetaLookup: { [itemId: string]: TreeViewItemMeta }, + itemId: TreeViewItemId, +) => { + 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; +}; 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 index 1fb1f745658e3..1216ed3ef91a6 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.selectors.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.selectors.ts @@ -1,3 +1,4 @@ +import { TreeViewItemId } from '../../../models'; import { createSelector, TreeViewRootSelector } from '../../utils/selectors'; import { UseTreeViewSelectionSignature } from './useTreeViewSelection.types'; @@ -5,12 +6,55 @@ const selectorTreeViewSelectionState: TreeViewRootSelector state.selection; +/** + * Get the selected items. + * @param {TreeViewState<[UseTreeViewSelectionSignature]>} state The state of the tree view. + * @returns {TreeViewSelectionValue} The selected items. + */ +export const selectorSelectionModel = createSelector( + [selectorTreeViewSelectionState], + (selectionState) => selectionState.selectedItems, +); + +/** + * Get the selected items as an array. + * @param {TreeViewState<[UseTreeViewSelectionSignature]>} state The state of the tree view. + * @returns {TreeViewItemId[]} The selected items as an array. + */ +export const selectorSelectionModelArray = createSelector( + [selectorSelectionModel], + (selectedItems) => { + if (Array.isArray(selectedItems)) { + return selectedItems; + } + + if (selectedItems != null) { + return [selectedItems]; + } + + return []; + }, +); + +/** + * Get the selected items as a map. + * @param {TreeViewState<[UseTreeViewSelectionSignature]>} state The state of the tree view. + * @returns {Map} The selected items as a Map. + */ +const selectorSelectionModelMap = createSelector([selectorSelectionModelArray], (selectedItems) => { + const selectedItemsMap = new Map(); + selectedItems.forEach((id) => { + selectedItemsMap.set(id, true); + }); + return selectedItemsMap; +}); + /** * 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), + [selectorSelectionModelMap, (_, itemId: string) => itemId], + (selectedItemsMap, itemId) => 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 01b740645e875..75eb8c7184e36 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.ts @@ -1,5 +1,4 @@ import * as React from 'react'; -import useEnhancedEffect from '@mui/utils/useEnhancedEffect'; import { TreeViewPlugin } from '../../models'; import { TreeViewItemId } from '../../../models'; import { @@ -10,42 +9,43 @@ import { getNonDisabledItemsInRange, } from '../../utils/tree'; import { + TreeViewSelectionValue, UseTreeViewSelectionInstance, UseTreeViewSelectionParameters, UseTreeViewSelectionSignature, } from './useTreeViewSelection.types'; import { - convertSelectedItemsToArray, propagateSelection, getAddedAndRemovedItems, getLookupFromArray, - createSelectedItemsMap, } from './useTreeViewSelection.utils'; -import { selectorIsItemSelected } from './useTreeViewSelection.selectors'; +import { + selectorIsItemSelected, + selectorSelectionModel, + selectorSelectionModelArray, +} from './useTreeViewSelection.selectors'; import { useTreeViewSelectionItemPlugin } from './useTreeViewSelection.itemPlugin'; +import { useAssertModelConsistency } from '../../utils/models'; export const useTreeViewSelection: TreeViewPlugin = ({ store, params, - models, }) => { + useAssertModelConsistency({ + state: 'selectedItems', + controlled: params.selectedItems, + defaultValue: params.defaultSelectedItems, + }); + const lastSelectedItem = React.useRef(null); const lastSelectedRange = React.useRef<{ [itemId: string]: boolean }>({}); - useEnhancedEffect(() => { - store.update((prevState) => ({ - ...prevState, - selection: { - selectedItemsMap: createSelectedItemsMap(models.selectedItems.value), - }, - })); - }, [store, models.selectedItems.value]); - const setSelectedItems = ( event: React.SyntheticEvent, newModel: typeof params.defaultSelectedItems, additionalItemsToPropagate?: TreeViewItemId[], ) => { + const oldModel = selectorSelectionModel(store.value); let cleanModel: typeof newModel; if ( @@ -56,7 +56,7 @@ export const useTreeViewSelection: TreeViewPlugin store, selectionPropagation: params.selectionPropagation, newModel: newModel as string[], - oldModel: models.selectedItems.value as string[], + oldModel: oldModel as string[], additionalItemsToPropagate, }); } else { @@ -68,7 +68,7 @@ export const useTreeViewSelection: TreeViewPlugin const changes = getAddedAndRemovedItems({ store, newModel: cleanModel as string[], - oldModel: models.selectedItems.value as string[], + oldModel: oldModel as string[], }); if (params.onItemSelectionToggle) { @@ -80,9 +80,9 @@ export const useTreeViewSelection: TreeViewPlugin 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); + } else if (params.onItemSelectionToggle && cleanModel !== oldModel) { + if (oldModel != null) { + params.onItemSelectionToggle(event, oldModel as string, false); } if (cleanModel != null) { params.onItemSelectionToggle(event, cleanModel as string, true); @@ -90,11 +90,14 @@ export const useTreeViewSelection: TreeViewPlugin } } - if (params.onSelectedItemsChange) { - params.onSelectedItemsChange(event, cleanModel); + if (params.selectedItems === undefined) { + store.update((prevState) => ({ + ...prevState, + selection: { selectedItems: cleanModel }, + })); } - models.selectedItems.setControlledValue(cleanModel); + params.onSelectedItemsChange?.(event, cleanModel); }; const selectItem: UseTreeViewSelectionInstance['selectItem'] = ({ @@ -107,16 +110,16 @@ export const useTreeViewSelection: TreeViewPlugin return; } - let newSelected: typeof models.selectedItems.value; + let newModel: TreeViewSelectionValue; if (keepExistingSelection) { - const cleanSelectedItems = convertSelectedItemsToArray(models.selectedItems.value); + const oldModel = selectorSelectionModelArray(store.value); const isSelectedBefore = selectorIsItemSelected(store.value, itemId); if (isSelectedBefore && (shouldBeSelected === false || shouldBeSelected == null)) { - newSelected = cleanSelectedItems.filter((id) => id !== itemId); + newModel = oldModel.filter((id) => id !== itemId); } else if (!isSelectedBefore && (shouldBeSelected === true || shouldBeSelected == null)) { - newSelected = [itemId].concat(cleanSelectedItems); + newModel = [itemId].concat(oldModel); } else { - newSelected = cleanSelectedItems; + newModel = oldModel; } } else { // eslint-disable-next-line no-lonely-if @@ -124,15 +127,15 @@ export const useTreeViewSelection: TreeViewPlugin shouldBeSelected === false || (shouldBeSelected == null && selectorIsItemSelected(store.value, itemId)) ) { - newSelected = params.multiSelect ? [] : null; + newModel = params.multiSelect ? [] : null; } else { - newSelected = params.multiSelect ? [itemId] : itemId; + newModel = params.multiSelect ? [itemId] : itemId; } } setSelectedItems( event, - newSelected, + newModel, // If shouldBeSelected === selectorIsItemSelected(store, itemId), we still want to propagate the select. // This is useful when the element is in an indeterminate state. [itemId], @@ -146,7 +149,7 @@ export const useTreeViewSelection: TreeViewPlugin return; } - let newSelectedItems = convertSelectedItemsToArray(models.selectedItems.value).slice(); + let newSelectedItems = selectorSelectionModelArray(store.value).slice(); // If the last selection was a range selection, // remove the items that were part of the last range from the model @@ -199,7 +202,7 @@ export const useTreeViewSelection: TreeViewPlugin return; } - let newSelectedItems = convertSelectedItemsToArray(models.selectedItems.value).slice(); + let newSelectedItems = selectorSelectionModelArray(store.value).slice(); if (Object.keys(lastSelectedRange.current).length === 0) { newSelectedItems.push(nextItem); @@ -263,12 +266,6 @@ export const useTreeViewSelection: TreeViewPlugin useTreeViewSelection.itemPlugin = useTreeViewSelectionItemPlugin; -useTreeViewSelection.models = { - selectedItems: { - getDefaultValue: (params) => params.defaultSelectedItems, - }, -}; - const DEFAULT_SELECTED_ITEMS: string[] = []; const EMPTY_SELECTION_PROPAGATION: UseTreeViewSelectionParameters['selectionPropagation'] = @@ -285,17 +282,8 @@ useTreeViewSelection.getDefaultizedParams = ({ params }) => ({ useTreeViewSelection.getInitialState = (params) => ({ selection: { - selectedItemsMap: createSelectedItemsMap( - params.selectedItems === undefined ? params.defaultSelectedItems : params.selectedItems, - ), - }, -}); - -useTreeViewSelection.getInitialState = (params) => ({ - selection: { - selectedItemsMap: createSelectedItemsMap( + selectedItems: params.selectedItems === undefined ? params.defaultSelectedItems : params.selectedItems, - ), }, }); 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 573055dbe75c1..4f4489dee34f5 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 @@ -59,7 +59,7 @@ export interface UseTreeViewSelectionInstance extends UseTreeViewSelectionPublic ) => void; } -type TreeViewSelectionValue = Multiple extends true +export type TreeViewSelectionValue = Multiple extends true ? string[] : string | null; @@ -141,7 +141,7 @@ export type UseTreeViewSelectionDefaultizedParameters export interface UseTreeViewSelectionState { selection: { - selectedItemsMap: Map; + selectedItems: TreeViewSelectionValue; }; } @@ -158,7 +158,6 @@ export type UseTreeViewSelectionSignature = TreeViewPluginSignature<{ instance: UseTreeViewSelectionInstance; publicAPI: UseTreeViewSelectionPublicAPI; contextValue: UseTreeViewSelectionContextValue; - modelNames: 'selectedItems'; state: UseTreeViewSelectionState; dependencies: [ UseTreeViewItemsSignature, 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 ed99d6f9fa75e..7b31486ae278e 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 @@ -7,31 +7,6 @@ import { selectorItemParentId, } from '../useTreeViewItems/useTreeViewItems.selectors'; -/** - * Transform the `selectedItems` model to be an array if it was a string or null. - * @param {string[] | string | null} model The raw model. - * @returns {string[]} The converted model. - */ -export const convertSelectedItemsToArray = (model: string[] | string | null): string[] => { - if (Array.isArray(model)) { - return model; - } - - if (model != null) { - return [model]; - } - - 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) => { @@ -49,11 +24,14 @@ export const getAddedAndRemovedItems = ({ oldModel: TreeViewItemId[]; newModel: TreeViewItemId[]; }) => { - const newModelLookup = createSelectedItemsMap(newModel); + const newModelMap = new Map(); + newModel.forEach((id) => { + newModelMap.set(id, true); + }); return { added: newModel.filter((itemId) => !selectorIsItemSelected(store.value, itemId)), - removed: oldModel.filter((itemId) => !newModelLookup.has(itemId)), + removed: oldModel.filter((itemId) => !newModelMap.has(itemId)), }; }; diff --git a/packages/x-tree-view/src/internals/useTreeView/useTreeView.ts b/packages/x-tree-view/src/internals/useTreeView/useTreeView.ts index f13d024ea230f..22c8521145ab2 100644 --- a/packages/x-tree-view/src/internals/useTreeView/useTreeView.ts +++ b/packages/x-tree-view/src/internals/useTreeView/useTreeView.ts @@ -15,25 +15,28 @@ import { UseTreeViewReturnValue, UseTreeViewRootSlotProps, } from './useTreeView.types'; -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'; +function initializeInputApiRef(inputApiRef: React.RefObject) { + if (inputApiRef.current == null) { + inputApiRef.current = {} as T; + } + return inputApiRef.current; +} + export function useTreeViewApiInitialization( inputApiRef: React.RefObject | undefined, ): T { - const fallbackPublicApiRef = React.useRef({}) as React.RefObject; + const [fallbackPublicApi] = React.useState({}); if (inputApiRef) { - if (inputApiRef.current == null) { - inputApiRef.current = {} as T; - } - return inputApiRef.current; + return initializeInputApiRef(inputApiRef); } - return fallbackPublicApiRef.current; + return fallbackPublicApi as T; } let globalId: number = 0; @@ -64,7 +67,6 @@ export const useTreeView = < props, }); - const models = useTreeViewModels(plugins, pluginParams); const instanceRef = React.useRef({} as TreeViewInstance); const instance = instanceRef.current as TreeViewInstance; const publicAPI = useTreeViewApiInitialization>(apiRef); @@ -108,7 +110,6 @@ export const useTreeView = < slotProps, experimentalFeatures, rootRef: innerRootRef, - models, plugins, store: storeRef.current as TreeViewStore, }); @@ -152,6 +153,8 @@ export const useTreeView = < const contextValue = React.useMemo(() => { const copiedBaseContextValue = { ...baseContextValue }; return Object.assign(copiedBaseContextValue, ...pluginContextValues); + // TODO: Find a way to avoid this spread + // eslint-disable-next-line react-compiler/react-compiler // eslint-disable-next-line react-hooks/exhaustive-deps }, [baseContextValue, ...pluginContextValues]); diff --git a/packages/x-tree-view/src/internals/useTreeView/useTreeViewModels.ts b/packages/x-tree-view/src/internals/useTreeView/useTreeViewModels.ts deleted file mode 100644 index 7c30b4d90cbf4..0000000000000 --- a/packages/x-tree-view/src/internals/useTreeView/useTreeViewModels.ts +++ /dev/null @@ -1,109 +0,0 @@ -import * as React from 'react'; -import { - TreeViewAnyPluginSignature, - ConvertSignaturesIntoPlugins, - MergeSignaturesProperty, - TreeViewPlugin, -} from '../models'; -import { TreeViewCorePluginSignatures } from '../corePlugins'; - -/** - * Implements the same behavior as `useControlled` but for several models. - * The controlled models are never stored in the state, and the state is only updated if the model is not controlled. - */ -export const useTreeViewModels = ( - plugins: ConvertSignaturesIntoPlugins, - props: MergeSignaturesProperty, -) => { - type DefaultizedParams = MergeSignaturesProperty; - - const modelsRef = React.useRef<{ - [modelName: string]: { - getDefaultValue: (params: DefaultizedParams) => any; - isControlled: boolean; - }; - }>({}); - - const [modelsState, setModelsState] = React.useState<{ [modelName: string]: any }>(() => { - const initialState: { [modelName: string]: any } = {}; - - plugins.forEach((plugin: TreeViewPlugin) => { - if (plugin.models) { - Object.entries(plugin.models).forEach(([modelName, modelInitializer]) => { - modelsRef.current[modelName] = { - isControlled: props[modelName as keyof DefaultizedParams] !== undefined, - getDefaultValue: modelInitializer.getDefaultValue, - }; - initialState[modelName] = modelInitializer.getDefaultValue(props); - }); - } - }); - - return initialState; - }); - - const models = Object.fromEntries( - Object.entries(modelsRef.current).map(([modelName, model]) => { - const value = props[modelName as keyof DefaultizedParams] ?? modelsState[modelName]; - - return [ - modelName, - { - value, - setControlledValue: (newValue: any) => { - if (!model.isControlled) { - setModelsState((prevState) => ({ - ...prevState, - [modelName]: newValue, - })); - } - }, - }, - ]; - }), - ) as MergeSignaturesProperty; - - // We know that `modelsRef` do not vary across renders. - /* eslint-disable react-hooks/rules-of-hooks, react-hooks/exhaustive-deps */ - if (process.env.NODE_ENV !== 'production') { - Object.entries(modelsRef.current).forEach(([modelName, model]) => { - const controlled = props[modelName as keyof DefaultizedParams]; - const newDefaultValue = model.getDefaultValue(props); - - React.useEffect(() => { - if (model.isControlled !== (controlled !== undefined)) { - console.error( - [ - `MUI X: A component is changing the ${ - model.isControlled ? '' : 'un' - }controlled ${modelName} state of TreeView to be ${ - model.isControlled ? 'un' : '' - }controlled.`, - 'Elements should not switch from uncontrolled to controlled (or vice versa).', - `Decide between using a controlled or uncontrolled ${modelName} ` + - 'element for the lifetime of the component.', - "The nature of the state is determined during the first render. It's considered controlled if the value is not `undefined`.", - 'More info: https://fb.me/react-controlled-components', - ].join('\n'), - ); - } - }, [controlled]); - - const { current: defaultValue } = React.useRef(newDefaultValue); - - React.useEffect(() => { - if (!model.isControlled && defaultValue !== newDefaultValue) { - console.error( - [ - `MUI X: A component is changing the default ${modelName} state of an uncontrolled TreeView after being initialized. ` + - `To suppress this warning opt to use a controlled TreeView.`, - ].join('\n'), - ); - } - }, [JSON.stringify(newDefaultValue)]); - }); - } - /* eslint-enable react-hooks/rules-of-hooks, react-hooks/exhaustive-deps */ - - return models; -}; diff --git a/packages/x-tree-view/src/internals/utils/models.ts b/packages/x-tree-view/src/internals/utils/models.ts new file mode 100644 index 0000000000000..1af54ac771079 --- /dev/null +++ b/packages/x-tree-view/src/internals/utils/models.ts @@ -0,0 +1,43 @@ +import { warnOnce } from '@mui/x-internals/warning'; +import * as React from 'react'; + +function useAssertModelConsistencyOutsideOfProduction(parameters: { + state: string; + controlled: T | undefined; + defaultValue: T; +}) { + const { state, controlled, defaultValue } = parameters; + const [{ initialDefaultValue, isControlled }] = React.useState({ + initialDefaultValue: defaultValue, + isControlled: controlled !== undefined, + }); + + if (isControlled !== (controlled !== undefined)) { + warnOnce( + [ + `MUI X: A component is changing the ${ + isControlled ? '' : 'un' + }controlled ${state} state of a Tree View to be ${isControlled ? 'un' : ''}controlled.`, + 'Elements should not switch from uncontrolled to controlled (or vice versa).', + `Decide between using a controlled or uncontrolled ${state} ` + + 'element for the lifetime of the component.', + "The nature of the state is determined during the first render. It's considered controlled if the value is not `undefined`.", + 'More info: https://fb.me/react-controlled-components', + ], + 'error', + ); + } + + if (JSON.stringify(initialDefaultValue) !== JSON.stringify(defaultValue)) { + warnOnce( + [ + `MUI X: A component is changing the default ${state} state of an uncontrolled Tree View after being initialized. ` + + `To suppress this warning opt to use a controlled Chart.`, + ], + 'error', + ); + } +} + +export const useAssertModelConsistency = + process.env.NODE_ENV === 'production' ? () => {} : useAssertModelConsistencyOutsideOfProduction; diff --git a/packages/x-tree-view/src/useTreeItem/useTreeItem.test.tsx b/packages/x-tree-view/src/useTreeItem/useTreeItem.test.tsx index 2d09699c4b80c..837c04a258a57 100644 --- a/packages/x-tree-view/src/useTreeItem/useTreeItem.test.tsx +++ b/packages/x-tree-view/src/useTreeItem/useTreeItem.test.tsx @@ -101,6 +101,7 @@ describeTreeView<[UseTreeViewExpansionSignature, UseTreeViewIconsSignature]>( function ConditionallyMountedItem(props) { const [mounted, setMounted] = React.useState(true); if (props.itemId === '2') { + // eslint-disable-next-line react-compiler/react-compiler setActiveItemMounted = setMounted; } diff --git a/test/utils/tree-view/fakeContextValue.ts b/test/utils/tree-view/fakeContextValue.ts index 17316da4e5002..ca9d8c6a077c9 100644 --- a/test/utils/tree-view/fakeContextValue.ts +++ b/test/utils/tree-view/fakeContextValue.ts @@ -40,8 +40,8 @@ export const getFakeContextValue = ( itemOrderedChildrenIdsLookup: {}, itemChildrenIndexesLookup: {}, }, - expansion: { expandedItemsMap: new Map(), expansionTrigger: 'content' }, - selection: { selectedItemsMap: new Map() }, - focus: { focusedItemId: null, defaultFocusableItemId: null }, + expansion: { expandedItems: [], expansionTrigger: 'content' }, + selection: { selectedItems: null }, + focus: { focusedItemId: null }, }), });