+
+## Enable label editing
+
+You can use the `isItemEditable` prop to enable editing.
+If set to `true`, this prop will enable label editing on all items:
+
+{{"demo": "LabelEditingAllItems.js"}}
+
+:::success
+If an item is editable, the editing state can be toggled by double clicking on it, or by pressing Enter on the keyboard when the item is in focus.
+
+Once an item is in editing state, the value of the label can be edited. Pressing Enter again or bluring the item will save the new value. Pressing Esc will cancel the action and restore the item to its original state.
+
+:::
+
+## Limit editing to some items
+
+If you pass a method to `isItemEditable`, only the items for which the method returns `true` will be editable:
+
+{{"demo": "LabelEditingSomeItems.js"}}
+
+### Limit editing to leaves
+
+You can limit the editing to just the leaves of the tree.
+
+{{"demo": "EditLeaves.js"}}
+
+## Track item label change
+
+Use the `onItemLabelChange` prop to trigger an action when the label of an item changes.
+
+{{"demo": "EditingCallback.js"}}
+
+## Change the default behavior
+
+By default, blurring the tree item saves the new value if there is one.
+To modify this behavior, use the `slotProps` of the `TreeItem2`.
+
+{{"demo": "CustomBehavior.js"}}
+
+## Validation
+
+You can override the event handlers of the `labelInput` and implement a custom validation logic using the interaction methods from `useTreeItem2Utils`.
+
+{{"demo": "Validation.js"}}
+
+## Enable editing using only icons
+
+The demo below shows how to entirely override the editing behavior, and implement it using icons.
+
+{{"demo": "EditWithIcons.js"}}
+
+## Create a custom labelInput
+
+The demo below shows how to use a different component in the `labelInput` slot.
+
+{{"demo": "CustomLabelInput.js"}}
+
+## Imperative API
+
+:::success
+To use the `apiRef` object, you need to initialize it using the `useTreeViewApiRef` hook as follows:
+
+```tsx
+const apiRef = useTreeViewApiRef();
+
+return ;
+```
+
+When your component first renders, `apiRef` will be `undefined`.
+After this initial render, `apiRef` holds methods to interact imperatively with the Tree View.
+:::
+
+### Change the label of an item
+
+Use the `setItemExpansion` API method to change the expansion of an item.
+
+```ts
+apiRef.current.updateItemLabel(
+ // The id of the item to to update
+ itemId,
+ // The new label of the item.
+ newLabel,
+);
+```
+
+{{"demo": "ApiMethodUpdateItemLabel.js"}}
diff --git a/docs/data/tree-view/rich-tree-view/editing/employees.ts b/docs/data/tree-view/rich-tree-view/editing/employees.ts
new file mode 100644
index 000000000000..5e1e16f8a079
--- /dev/null
+++ b/docs/data/tree-view/rich-tree-view/editing/employees.ts
@@ -0,0 +1,37 @@
+import { TreeViewBaseItem } from '@mui/x-tree-view/models';
+
+export type Employee = {
+ editable?: boolean;
+ id: string;
+ firstName: string;
+ lastName: string;
+};
+
+export const EMPLOYEES: TreeViewBaseItem[] = [
+ {
+ id: '1',
+ firstName: 'Jane',
+ lastName: 'Doe',
+ editable: true,
+ children: [
+ { id: '1.1', firstName: 'Elena', lastName: 'Kim', editable: true },
+ { id: '1.2', firstName: 'Noah', lastName: 'Rodriguez', editable: true },
+ { id: '1.3', firstName: 'Maya', lastName: 'Patel', editable: true },
+ ],
+ },
+ {
+ id: '2',
+ firstName: 'Liam',
+ lastName: 'Clarke',
+ editable: true,
+ children: [
+ {
+ id: '2.1',
+ firstName: 'Ethan',
+ lastName: 'Lee',
+ editable: true,
+ },
+ { id: '2.2', firstName: 'Ava', lastName: 'Jones', editable: true },
+ ],
+ },
+];
diff --git a/docs/data/tree-view/rich-tree-view/editing/products.ts b/docs/data/tree-view/rich-tree-view/editing/products.ts
new file mode 100644
index 000000000000..97929673353e
--- /dev/null
+++ b/docs/data/tree-view/rich-tree-view/editing/products.ts
@@ -0,0 +1,37 @@
+import { TreeViewBaseItem } from '@mui/x-tree-view/models';
+
+export 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' }],
+ },
+];
diff --git a/docs/pages/x/api/charts/line-series-type.json b/docs/pages/x/api/charts/line-series-type.json
index 8043690cf449..61608bfce9c6 100644
--- a/docs/pages/x/api/charts/line-series-type.json
+++ b/docs/pages/x/api/charts/line-series-type.json
@@ -4,6 +4,7 @@
"properties": {
"type": { "type": { "description": "'line'" }, "required": true },
"area": { "type": { "description": "boolean" } },
+ "baseline": { "type": { "description": "number | 'min' | 'max'" }, "default": "0" },
"color": { "type": { "description": "string" } },
"connectNulls": { "type": { "description": "boolean" }, "default": "false" },
"curve": { "type": { "description": "CurveType" } },
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 03bea300243d..7cd100636eb8 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
@@ -3,7 +3,7 @@
"apiRef": {
"type": {
"name": "shape",
- "description": "{ current?: { focusItem: func, getItem: func, getItemDOMElement: func, getItemOrderedChildrenIds: func, getItemTree: func, selectItem: func, setItemExpansion: func } }"
+ "description": "{ current?: { focusItem: func, getItem: func, getItemDOMElement: func, getItemOrderedChildrenIds: func, getItemTree: func, selectItem: func, setItemExpansion: func, updateItemLabel: func } }"
}
},
"canMoveItemToNewPosition": {
@@ -31,7 +31,7 @@
"experimentalFeatures": {
"type": {
"name": "shape",
- "description": "{ indentationAtItemLevel?: bool, itemsReordering?: bool }"
+ "description": "{ indentationAtItemLevel?: bool, itemsReordering?: bool, labelEditing?: bool }"
}
},
"getItemId": {
@@ -61,6 +61,7 @@
"returned": "boolean"
}
},
+ "isItemEditable": { "type": { "name": "union", "description": "func | bool" } },
"isItemReorderable": {
"type": { "name": "func" },
"default": "() => true",
@@ -104,6 +105,13 @@
"describedArgs": ["event", "itemId"]
}
},
+ "onItemLabelChange": {
+ "type": { "name": "func" },
+ "signature": {
+ "type": "function(itemId: TreeViewItemId, newLabel: string) => void",
+ "describedArgs": ["itemId", "newLabel"]
+ }
+ },
"onItemPositionChange": {
"type": { "name": "func" },
"signature": {
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 7857c5c370ba..53335f98fc5e 100644
--- a/docs/pages/x/api/tree-view/rich-tree-view.json
+++ b/docs/pages/x/api/tree-view/rich-tree-view.json
@@ -3,7 +3,7 @@
"apiRef": {
"type": {
"name": "shape",
- "description": "{ current?: { focusItem: func, getItem: func, getItemDOMElement: func, getItemOrderedChildrenIds: func, getItemTree: func, selectItem: func, setItemExpansion: func } }"
+ "description": "{ current?: { focusItem: func, getItem: func, getItemDOMElement: func, getItemOrderedChildrenIds: func, getItemTree: func, selectItem: func, setItemExpansion: func, updateItemLabel: func } }"
}
},
"checkboxSelection": { "type": { "name": "bool" }, "default": "false" },
@@ -21,7 +21,10 @@
"default": "'content'"
},
"experimentalFeatures": {
- "type": { "name": "shape", "description": "{ indentationAtItemLevel?: bool }" }
+ "type": {
+ "name": "shape",
+ "description": "{ indentationAtItemLevel?: bool, labelEditing?: bool }"
+ }
},
"getItemId": {
"type": { "name": "func" },
@@ -50,6 +53,7 @@
"returned": "boolean"
}
},
+ "isItemEditable": { "type": { "name": "union", "description": "func | bool" } },
"itemChildrenIndentation": {
"type": { "name": "union", "description": "number | string" },
"default": "12px"
@@ -83,6 +87,13 @@
"describedArgs": ["event", "itemId"]
}
},
+ "onItemLabelChange": {
+ "type": { "name": "func" },
+ "signature": {
+ "type": "function(itemId: TreeViewItemId, newLabel: string) => void",
+ "describedArgs": ["itemId", "newLabel"]
+ }
+ },
"onItemSelectionToggle": {
"type": { "name": "func" },
"signature": {
diff --git a/docs/pages/x/api/tree-view/tree-item-2.json b/docs/pages/x/api/tree-view/tree-item-2.json
index 64dd97521004..547383a665af 100644
--- a/docs/pages/x/api/tree-view/tree-item-2.json
+++ b/docs/pages/x/api/tree-view/tree-item-2.json
@@ -59,6 +59,12 @@
"default": "TreeItem2Label",
"class": "MuiTreeItem2-label"
},
+ {
+ "name": "labelInput",
+ "description": "The component that renders the input to edit the label when the item is editable and is currently being edited.",
+ "default": "TreeItem2LabelInput",
+ "class": "MuiTreeItem2-labelInput"
+ },
{
"name": "dragAndDropOverlay",
"description": "The component that renders the overlay when an item reordering is ongoing.\nWarning: This slot is only useful when using the `RichTreeViewPro` component.",
@@ -81,6 +87,18 @@
"description": "State class applied to the element when disabled.",
"isGlobal": true
},
+ {
+ "key": "editable",
+ "className": "MuiTreeItem2-editable",
+ "description": "Styles applied to the content of the items that are editable.",
+ "isGlobal": false
+ },
+ {
+ "key": "editing",
+ "className": "MuiTreeItem2-editing",
+ "description": "Styles applied to the content element when editing is enabled.",
+ "isGlobal": false
+ },
{
"key": "expanded",
"className": "Mui-expanded",
diff --git a/docs/pages/x/api/tree-view/tree-item.json b/docs/pages/x/api/tree-view/tree-item.json
index 2a7c1b9c0032..7873eb960165 100644
--- a/docs/pages/x/api/tree-view/tree-item.json
+++ b/docs/pages/x/api/tree-view/tree-item.json
@@ -73,6 +73,18 @@
"description": "Styles applied to the drag and drop overlay.",
"isGlobal": false
},
+ {
+ "key": "editable",
+ "className": "MuiTreeItem-editable",
+ "description": "Styles applied to the content of the items that are editable.",
+ "isGlobal": false
+ },
+ {
+ "key": "editing",
+ "className": "MuiTreeItem-editing",
+ "description": "Styles applied to the content element when editing is enabled.",
+ "isGlobal": false
+ },
{
"key": "expanded",
"className": "Mui-expanded",
@@ -97,6 +109,12 @@
"description": "Styles applied to the label element.",
"isGlobal": false
},
+ {
+ "key": "labelInput",
+ "className": "MuiTreeItem-labelInput",
+ "description": "Styles applied to the input element that is visible when editing is enabled.",
+ "isGlobal": false
+ },
{
"key": "root",
"className": "MuiTreeItem-root",
diff --git a/docs/pages/x/react-tree-view/rich-tree-view/editing.js b/docs/pages/x/react-tree-view/rich-tree-view/editing.js
new file mode 100644
index 000000000000..73aff62cb36f
--- /dev/null
+++ b/docs/pages/x/react-tree-view/rich-tree-view/editing.js
@@ -0,0 +1,7 @@
+import * as React from 'react';
+import MarkdownDocs from 'docs/src/modules/components/MarkdownDocs';
+import * as pageProps from 'docsx/data/tree-view/rich-tree-view/editing/editing.md?muiMarkdown';
+
+export default function Page() {
+ return ;
+}
diff --git a/docs/translations/api-docs/charts/line-series-type.json b/docs/translations/api-docs/charts/line-series-type.json
index ff576209272d..824efbcd4acb 100644
--- a/docs/translations/api-docs/charts/line-series-type.json
+++ b/docs/translations/api-docs/charts/line-series-type.json
@@ -3,6 +3,9 @@
"propertiesDescriptions": {
"type": { "description": "" },
"area": { "description": "" },
+ "baseline": {
+ "description": "
The value of the line at the base of the series area.
- 'min' the area will fill the space under the line. - 'max' the area will fill the space above the line. - number the area will fill the space between this value and the line
\n"
+ },
"color": { "description": "" },
"connectNulls": {
"description": "If true, line and area connect points separated by null values."
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 bcd6e7bdb905..3a33b96c08bd 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
@@ -8,7 +8,7 @@
"description": "Used to determine if a given item can move to some new position.",
"typeDescriptions": {
"params": "The params describing the item re-ordering.",
- "params.itemId": "The id of the item to check.",
+ "params.itemId": "The id of the item that is being moved to a new position.",
"params.oldPosition": "The old position of the item.",
"params.newPosition": "The new position of the item.",
"boolean": "true if the item can move to the new position."
@@ -55,6 +55,9 @@
"boolean": "true if the item should be disabled."
}
},
+ "isItemEditable": {
+ "description": "Determines if a given item is editable or not. Make sure to also enable the labelEditing experimental feature: <RichTreeViewPro experimentalFeatures={{ labelEditing: true }} />. By default, the items are not editable."
+ },
"isItemReorderable": {
"description": "Used to determine if a given item can be reordered.",
"typeDescriptions": {
@@ -100,6 +103,13 @@
"itemId": "The id of the focused item."
}
},
+ "onItemLabelChange": {
+ "description": "Callback fired when the label of an item changes.",
+ "typeDescriptions": {
+ "itemId": "The id of the item that was edited.",
+ "newLabel": "The new label of the items."
+ }
+ },
"onItemPositionChange": {
"description": "Callback fired when a tree item is moved in the tree.",
"typeDescriptions": {
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 3b94a5f52442..3ba5993e5187 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
@@ -45,6 +45,9 @@
"boolean": "true if the item should be disabled."
}
},
+ "isItemEditable": {
+ "description": "Determines if a given item is editable or not. Make sure to also enable the labelEditing experimental feature: <RichTreeViewPro experimentalFeatures={{ labelEditing: true }} />. By default, the items are not editable."
+ },
"itemChildrenIndentation": {
"description": "Horizontal indentation between an item and its children. Examples: 24, "24px", "2rem", "2em"."
},
@@ -80,6 +83,13 @@
"itemId": "The id of the focused item."
}
},
+ "onItemLabelChange": {
+ "description": "Callback fired when the label of an item changes.",
+ "typeDescriptions": {
+ "itemId": "The id of the item that was edited.",
+ "newLabel": "The new label of the items."
+ }
+ },
"onItemSelectionToggle": {
"description": "Callback fired when a tree item is selected or deselected.",
"typeDescriptions": {
diff --git a/docs/translations/api-docs/tree-view/tree-item-2/tree-item-2.json b/docs/translations/api-docs/tree-view/tree-item-2/tree-item-2.json
index 6a5f8d2ae832..1ec2e89e98d8 100644
--- a/docs/translations/api-docs/tree-view/tree-item-2/tree-item-2.json
+++ b/docs/translations/api-docs/tree-view/tree-item-2/tree-item-2.json
@@ -23,6 +23,15 @@
"nodeName": "the element",
"conditions": "disabled"
},
+ "editable": {
+ "description": "Styles applied to {{nodeName}}.",
+ "nodeName": "the content of the items that are editable"
+ },
+ "editing": {
+ "description": "Styles applied to {{nodeName}} when {{conditions}}.",
+ "nodeName": "the content element",
+ "conditions": "editing is enabled"
+ },
"expanded": {
"description": "State class applied to {{nodeName}} when {{conditions}}.",
"nodeName": "the content element",
@@ -50,6 +59,7 @@
"icon": "The icon to display next to the tree item's label.",
"iconContainer": "The component that renders the icon.",
"label": "The component that renders the item label.",
+ "labelInput": "The component that renders the input to edit the label when the item is editable and is currently being edited.",
"root": "The component that renders the root."
}
}
diff --git a/docs/translations/api-docs/tree-view/tree-item/tree-item.json b/docs/translations/api-docs/tree-view/tree-item/tree-item.json
index 596c008d70f1..f0febee14167 100644
--- a/docs/translations/api-docs/tree-view/tree-item/tree-item.json
+++ b/docs/translations/api-docs/tree-view/tree-item/tree-item.json
@@ -41,6 +41,15 @@
"description": "Styles applied to {{nodeName}}.",
"nodeName": "the drag and drop overlay"
},
+ "editable": {
+ "description": "Styles applied to {{nodeName}}.",
+ "nodeName": "the content of the items that are editable"
+ },
+ "editing": {
+ "description": "Styles applied to {{nodeName}} when {{conditions}}.",
+ "nodeName": "the content element",
+ "conditions": "editing is enabled"
+ },
"expanded": {
"description": "State class applied to {{nodeName}} when {{conditions}}.",
"nodeName": "the content element",
@@ -56,6 +65,11 @@
"nodeName": "the tree item icon"
},
"label": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the label element" },
+ "labelInput": {
+ "description": "Styles applied to {{nodeName}} when {{conditions}}.",
+ "nodeName": "the input element that is visible",
+ "conditions": "editing is enabled"
+ },
"root": { "description": "Styles applied to the root element." },
"selected": {
"description": "State class applied to {{nodeName}} when {{conditions}}.",
diff --git a/netlify.toml b/netlify.toml
index 25bf422e747a..4d663716bd27 100644
--- a/netlify.toml
+++ b/netlify.toml
@@ -10,7 +10,6 @@
[build.environment]
NODE_VERSION = "18"
NODE_OPTIONS = "--max_old_space_size=4096"
- PNPM_FLAGS = "--shamefully-hoist"
[[plugins]]
package = "./packages/netlify-plugin-cache-docs"
diff --git a/packages/x-charts/src/LineChart/AreaPlot.tsx b/packages/x-charts/src/LineChart/AreaPlot.tsx
index 79412e5211e6..01956e3b847b 100644
--- a/packages/x-charts/src/LineChart/AreaPlot.tsx
+++ b/packages/x-charts/src/LineChart/AreaPlot.tsx
@@ -59,6 +59,7 @@ const useAggregatedData = () => {
stackedData,
data,
connectNulls,
+ baseline,
} = series[seriesId];
const xAxisId = xAxisIdProp ?? xAxisKey;
@@ -97,6 +98,16 @@ const useAggregatedData = () => {
.x((d) => xScale(d.x))
.defined((_, i) => connectNulls || data[i] != null)
.y0((d) => {
+ if (typeof baseline === 'number') {
+ return yScale(baseline)!;
+ }
+ if (baseline === 'max') {
+ return yScale.range()[1];
+ }
+ if (baseline === 'min') {
+ return yScale.range()[0];
+ }
+
const value = d.y && yScale(d.y[0])!;
if (Number.isNaN(value)) {
return yScale.range()[0];
diff --git a/packages/x-charts/src/LineChart/extremums.ts b/packages/x-charts/src/LineChart/extremums.ts
index e913bc31c26b..e0fdcfac51c0 100644
--- a/packages/x-charts/src/LineChart/extremums.ts
+++ b/packages/x-charts/src/LineChart/extremums.ts
@@ -43,8 +43,11 @@ export const getExtremumY: ExtremumGetter<'line'> = (params) => {
const { area, stackedData } = series[seriesId];
const isArea = area !== undefined;
+ // Since this series is not used to display an area, we do not consider the base (the d[0]).
const getValues: GetValuesTypes =
- isArea && axis.scaleType !== 'log' ? (d) => d : (d) => [d[1], d[1]]; // Since this series is not used to display an area, we do not consider the base (the d[0]).
+ isArea && axis.scaleType !== 'log' && typeof series[seriesId].baseline !== 'string'
+ ? (d) => d
+ : (d) => [d[1], d[1]];
const seriesExtremums = getSeriesExtremums(getValues, stackedData);
diff --git a/packages/x-charts/src/models/seriesType/line.ts b/packages/x-charts/src/models/seriesType/line.ts
index 5e4a6f481da9..744749d66833 100644
--- a/packages/x-charts/src/models/seriesType/line.ts
+++ b/packages/x-charts/src/models/seriesType/line.ts
@@ -82,6 +82,16 @@ export interface LineSeriesType
* @default 'none'
*/
stackOffset?: StackOffsetType;
+ /**
+ * The value of the line at the base of the series area.
+ *
+ * - `'min'` the area will fill the space **under** the line.
+ * - `'max'` the area will fill the space **above** the line.
+ * - `number` the area will fill the space between this value and the line
+ *
+ * @default 0
+ */
+ baseline?: number | 'min' | 'max';
}
/**
diff --git a/packages/x-date-pickers/src/DateCalendar/tests/DateCalendar.test.tsx b/packages/x-date-pickers/src/DateCalendar/tests/DateCalendar.test.tsx
index 5d4ff9b31eed..fb52644ced5b 100644
--- a/packages/x-date-pickers/src/DateCalendar/tests/DateCalendar.test.tsx
+++ b/packages/x-date-pickers/src/DateCalendar/tests/DateCalendar.test.tsx
@@ -247,7 +247,9 @@ describe('', () => {
userEvent.mousePress(screen.getByRole('gridcell', { name: '2' }));
expect(onChange.callCount).to.equal(1);
- expect(onChange.lastCall.firstArg).toEqualDateTime(adapterToUse.date('2018-01-02T11:11:11'));
+ expect(onChange.lastCall.firstArg).toEqualDateTime(
+ adapterToUse.date('2018-01-02T11:11:11.111'),
+ );
});
it('should complete weeks when showDaysOutsideCurrentMonth=true', () => {
diff --git a/packages/x-date-pickers/src/internals/utils/date-utils.test.ts b/packages/x-date-pickers/src/internals/utils/date-utils.test.ts
index 6f7e2c855e30..a9d642959c4e 100644
--- a/packages/x-date-pickers/src/internals/utils/date-utils.test.ts
+++ b/packages/x-date-pickers/src/internals/utils/date-utils.test.ts
@@ -32,9 +32,9 @@ describe('findClosestEnabledDate', () => {
disableFuture: false,
disablePast: false,
timezone: 'default',
- })!;
+ });
- expect(adapterToUse.isSameDay(result, adapterToUse.date('2000-01-01'))).to.equal(true);
+ expect(result).toEqualDateTime(adapterToUse.date('2000-01-01'));
});
it('should return next 18th going from 10th', () => {
@@ -47,9 +47,9 @@ describe('findClosestEnabledDate', () => {
disableFuture: false,
disablePast: false,
timezone: 'default',
- })!;
+ });
- expect(adapterToUse.isSameDay(result, adapterToUse.date('2018-08-18'))).to.equal(true);
+ expect(result).toEqualDateTime(adapterToUse.date('2018-08-18'));
});
it('should return previous 18th going from 1st', () => {
@@ -62,9 +62,9 @@ describe('findClosestEnabledDate', () => {
disableFuture: false,
disablePast: false,
timezone: 'default',
- })!;
+ });
- expect(adapterToUse.isSameDay(result, adapterToUse.date('2018-07-18'))).to.equal(true);
+ expect(result).toEqualDateTime(adapterToUse.date('2018-07-18'));
});
it('should return future 18th if disablePast', () => {
@@ -78,7 +78,7 @@ describe('findClosestEnabledDate', () => {
disableFuture: false,
disablePast: true,
timezone: 'default',
- })!;
+ });
expect(adapterToUse.isBefore(result, today)).to.equal(false);
expect(adapterToUse.isBefore(result, adapterToUse.addDays(today, 31))).to.equal(true);
@@ -95,9 +95,9 @@ describe('findClosestEnabledDate', () => {
disableFuture: true,
disablePast: true,
timezone: 'default',
- })!;
+ });
- expect(adapterToUse.isSameDay(result, today)).to.equal(true);
+ expect(result).toEqualDateTime(today);
});
it('should return now with given time part if disablePast and now is valid', () => {
@@ -113,13 +113,13 @@ describe('findClosestEnabledDate', () => {
disableFuture: false,
disablePast: true,
timezone: 'default',
- })!;
+ });
expect(result).toEqualDateTime(adapterToUse.addDays(tryDate, 1));
clock.reset();
});
- it('should fallback to today if disablePast+disableFuture and now is invalid', () => {
+ it('should return `null` when disablePast+disableFuture and now is invalid', () => {
const today = adapterToUse.date();
const result = findClosestEnabledDate({
date: adapterToUse.date('2000-01-01'),
@@ -132,7 +132,7 @@ describe('findClosestEnabledDate', () => {
timezone: 'default',
});
- expect(adapterToUse.isEqual(result, adapterToUse.date()));
+ expect(result).to.equal(null);
});
it('should return minDate if it is after the date and valid', () => {
@@ -145,9 +145,9 @@ describe('findClosestEnabledDate', () => {
disableFuture: false,
disablePast: false,
timezone: 'default',
- })!;
+ });
- expect(adapterToUse.isSameDay(result, adapterToUse.date('2018-08-18'))).to.equal(true);
+ expect(result).toEqualDateTime(adapterToUse.date('2018-08-18'));
});
it('should return next 18th after minDate', () => {
@@ -160,9 +160,27 @@ describe('findClosestEnabledDate', () => {
disableFuture: false,
disablePast: false,
timezone: 'default',
- })!;
+ });
+
+ expect(result).toEqualDateTime(adapterToUse.date('2018-08-18'));
+ });
+
+ it('should keep the time of the `date` when `disablePast`', () => {
+ const clock = useFakeTimers({ now: new Date('2000-01-02T11:12:13.123Z') });
+
+ const result = findClosestEnabledDate({
+ date: adapterToUse.date('2000-01-01T11:12:13.550Z'),
+ minDate: adapterToUse.date('1900-01-01'),
+ maxDate: adapterToUse.date('2100-01-01'),
+ utils: adapterToUse,
+ isDateDisabled: () => false,
+ disableFuture: false,
+ disablePast: true,
+ timezone: 'default',
+ });
- expect(adapterToUse.isSameDay(result, adapterToUse.date('2018-08-18'))).to.equal(true);
+ expect(result).toEqualDateTime(adapterToUse.date('2000-01-02T11:12:13.550Z'));
+ clock.reset();
});
it('should return maxDate if it is before the date and valid', () => {
@@ -175,9 +193,9 @@ describe('findClosestEnabledDate', () => {
disableFuture: false,
disablePast: false,
timezone: 'default',
- })!;
+ });
- expect(adapterToUse.isSameDay(result, adapterToUse.date('2018-07-18'))).to.equal(true);
+ expect(result).toEqualDateTime(adapterToUse.date('2018-07-18'));
});
it('should return previous 18th before maxDate', () => {
@@ -190,9 +208,9 @@ describe('findClosestEnabledDate', () => {
disableFuture: false,
disablePast: false,
timezone: 'default',
- })!;
+ });
- expect(adapterToUse.isSameDay(result, adapterToUse.date('2018-07-18'))).to.equal(true);
+ expect(result).toEqualDateTime(adapterToUse.date('2018-07-18'));
});
it('should return null if minDate is after maxDate', () => {
@@ -205,7 +223,7 @@ describe('findClosestEnabledDate', () => {
disableFuture: false,
disablePast: false,
timezone: 'default',
- })!;
+ });
expect(result).to.equal(null);
});
diff --git a/packages/x-date-pickers/src/internals/utils/date-utils.ts b/packages/x-date-pickers/src/internals/utils/date-utils.ts
index 5f04069743a4..781ca434fba6 100644
--- a/packages/x-date-pickers/src/internals/utils/date-utils.ts
+++ b/packages/x-date-pickers/src/internals/utils/date-utils.ts
@@ -17,6 +17,7 @@ export const mergeDateAndTime = (
mergedDate = utils.setHours(mergedDate, utils.getHours(timeParam));
mergedDate = utils.setMinutes(mergedDate, utils.getMinutes(timeParam));
mergedDate = utils.setSeconds(mergedDate, utils.getSeconds(timeParam));
+ mergedDate = utils.setMilliseconds(mergedDate, utils.getMilliseconds(timeParam));
return mergedDate;
};
diff --git a/packages/x-tree-view-pro/src/RichTreeViewPro/RichTreeViewPro.plugins.ts b/packages/x-tree-view-pro/src/RichTreeViewPro/RichTreeViewPro.plugins.ts
index 70dbf423afe4..7e7adee15189 100644
--- a/packages/x-tree-view-pro/src/RichTreeViewPro/RichTreeViewPro.plugins.ts
+++ b/packages/x-tree-view-pro/src/RichTreeViewPro/RichTreeViewPro.plugins.ts
@@ -13,6 +13,8 @@ import {
ConvertPluginsIntoSignatures,
MergeSignaturesProperty,
TreeViewCorePluginParameters,
+ useTreeViewLabel,
+ UseTreeViewLabelParameters,
} from '@mui/x-tree-view/internals';
import {
useTreeViewItemsReordering,
@@ -26,6 +28,7 @@ export const RICH_TREE_VIEW_PRO_PLUGINS = [
useTreeViewFocus,
useTreeViewKeyboardNavigation,
useTreeViewIcons,
+ useTreeViewLabel,
useTreeViewItemsReordering,
] as const;
@@ -51,4 +54,5 @@ export interface RichTreeViewProPluginParameters,
UseTreeViewIconsParameters,
+ UseTreeViewLabelParameters,
UseTreeViewItemsReorderingParameters {}
diff --git a/packages/x-tree-view-pro/src/RichTreeViewPro/RichTreeViewPro.tsx b/packages/x-tree-view-pro/src/RichTreeViewPro/RichTreeViewPro.tsx
index e71cc8ed6534..c08210e09782 100644
--- a/packages/x-tree-view-pro/src/RichTreeViewPro/RichTreeViewPro.tsx
+++ b/packages/x-tree-view-pro/src/RichTreeViewPro/RichTreeViewPro.tsx
@@ -190,12 +190,13 @@ RichTreeViewPro.propTypes = {
getItemTree: PropTypes.func.isRequired,
selectItem: PropTypes.func.isRequired,
setItemExpansion: PropTypes.func.isRequired,
+ updateItemLabel: PropTypes.func.isRequired,
}),
}),
/**
* Used to determine if a given item can move to some new position.
* @param {object} params The params describing the item re-ordering.
- * @param {string} params.itemId The id of the item to check.
+ * @param {string} params.itemId The id of the item that is being moved to a new position.
* @param {TreeViewItemReorderPosition} params.oldPosition The old position of the item.
* @param {TreeViewItemReorderPosition} params.newPosition The new position of the item.
* @returns {boolean} `true` if the item can move to the new position.
@@ -251,6 +252,7 @@ RichTreeViewPro.propTypes = {
experimentalFeatures: PropTypes.shape({
indentationAtItemLevel: PropTypes.bool,
itemsReordering: PropTypes.bool,
+ labelEditing: PropTypes.bool,
}),
/**
* Used to determine the id of a given item.
@@ -282,6 +284,16 @@ RichTreeViewPro.propTypes = {
* @returns {boolean} `true` if the item should be disabled.
*/
isItemDisabled: PropTypes.func,
+ /**
+ * Determines if a given item is editable or not.
+ * Make sure to also enable the `labelEditing` experimental feature:
+ * ``.
+ * By default, the items are not editable.
+ * @template R
+ * @param {R} item The item to check.
+ * @returns {boolean} `true` if the item is editable.
+ */
+ isItemEditable: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
/**
* Used to determine if a given item can be reordered.
* @param {string} itemId The id of the item to check.
@@ -333,6 +345,12 @@ RichTreeViewPro.propTypes = {
* @param {string} itemId The id of the focused item.
*/
onItemFocus: PropTypes.func,
+ /**
+ * Callback fired when the label of an item changes.
+ * @param {TreeViewItemId} itemId The id of the item that was edited.
+ * @param {string} newLabel The new label of the items.
+ */
+ onItemLabelChange: PropTypes.func,
/**
* Callback fired when a tree item is moved in the tree.
* @param {object} params The params describing the item re-ordering.
diff --git a/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.test.tsx b/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.test.tsx
index b316969dbc1f..9e01b6229860 100644
--- a/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.test.tsx
+++ b/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.test.tsx
@@ -180,6 +180,23 @@ describeTreeView<
});
describe('canMoveItemToNewPosition prop', () => {
+ it('should call canMoveItemToNewPosition with the correct parameters', () => {
+ const canMoveItemToNewPosition = spy();
+ const response = render({
+ experimentalFeatures: { indentationAtItemLevel: true, itemsReordering: true },
+ items: [{ id: '1' }, { id: '2' }, { id: '3' }],
+ itemsReordering: true,
+ canMoveItemToNewPosition,
+ });
+
+ dragEvents.fullDragSequence(response.getItemRoot('1'), response.getItemContent('2'));
+ expect(canMoveItemToNewPosition.lastCall.firstArg).to.deep.equal({
+ itemId: '1',
+ oldPosition: { parentId: null, index: 0 },
+ newPosition: { parentId: null, index: 1 },
+ });
+ });
+
it('should not allow to drop an item when canMoveItemToNewPosition returns false', () => {
const response = render({
experimentalFeatures: { indentationAtItemLevel: true, itemsReordering: true },
diff --git a/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.ts b/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.ts
index 91843a7b6dc4..a19c0c491e0e 100644
--- a/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.ts
+++ b/packages/x-tree-view-pro/src/internals/plugins/useTreeViewItemsReordering/useTreeViewItemsReordering.ts
@@ -55,18 +55,19 @@ export const useTreeViewItemsReordering: TreeViewPlugin {
- if (!state.itemsReordering) {
+ const itemsReordering = state.itemsReordering;
+ if (!itemsReordering) {
throw new Error('There is no ongoing reordering.');
}
- if (itemId === state.itemsReordering.draggedItemId) {
+ if (itemId === itemsReordering.draggedItemId) {
return {};
}
const canMoveItemToNewPosition = params.canMoveItemToNewPosition;
const targetItemMeta = instance.getItemMeta(itemId);
const targetItemIndex = instance.getItemIndex(targetItemMeta.id);
- const draggedItemMeta = instance.getItemMeta(state.itemsReordering.draggedItemId);
+ const draggedItemMeta = instance.getItemMeta(itemsReordering.draggedItemId);
const draggedItemIndex = instance.getItemIndex(draggedItemMeta.id);
const oldPosition: TreeViewItemReorderPosition = {
@@ -84,7 +85,7 @@ export const useTreeViewItemsReordering: TreeViewPlugin,
- UseTreeViewIconsParameters {}
+ UseTreeViewIconsParameters,
+ UseTreeViewLabelParameters {}
diff --git a/packages/x-tree-view/src/RichTreeView/RichTreeView.tsx b/packages/x-tree-view/src/RichTreeView/RichTreeView.tsx
index 6eb825d49fa1..5c46c54794d1 100644
--- a/packages/x-tree-view/src/RichTreeView/RichTreeView.tsx
+++ b/packages/x-tree-view/src/RichTreeView/RichTreeView.tsx
@@ -154,6 +154,7 @@ RichTreeView.propTypes = {
getItemTree: PropTypes.func.isRequired,
selectItem: PropTypes.func.isRequired,
setItemExpansion: PropTypes.func.isRequired,
+ updateItemLabel: PropTypes.func.isRequired,
}),
}),
/**
@@ -205,6 +206,7 @@ RichTreeView.propTypes = {
*/
experimentalFeatures: PropTypes.shape({
indentationAtItemLevel: PropTypes.bool,
+ labelEditing: PropTypes.bool,
}),
/**
* Used to determine the id of a given item.
@@ -236,6 +238,16 @@ RichTreeView.propTypes = {
* @returns {boolean} `true` if the item should be disabled.
*/
isItemDisabled: PropTypes.func,
+ /**
+ * Determines if a given item is editable or not.
+ * Make sure to also enable the `labelEditing` experimental feature:
+ * ``.
+ * By default, the items are not editable.
+ * @template R
+ * @param {R} item The item to check.
+ * @returns {boolean} `true` if the item is editable.
+ */
+ isItemEditable: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
/**
* Horizontal indentation between an item and its children.
* Examples: 24, "24px", "2rem", "2em".
@@ -273,6 +285,12 @@ RichTreeView.propTypes = {
* @param {string} itemId The id of the focused item.
*/
onItemFocus: PropTypes.func,
+ /**
+ * Callback fired when the label of an item changes.
+ * @param {TreeViewItemId} itemId The id of the item that was edited.
+ * @param {string} newLabel The new label of the items.
+ */
+ onItemLabelChange: PropTypes.func,
/**
* Callback fired when a tree item is selected or deselected.
* @param {React.SyntheticEvent} event The DOM event that triggered the change.
diff --git a/packages/x-tree-view/src/TreeItem/TreeItem.tsx b/packages/x-tree-view/src/TreeItem/TreeItem.tsx
index c66a4fde6a30..2be1daaa83f5 100644
--- a/packages/x-tree-view/src/TreeItem/TreeItem.tsx
+++ b/packages/x-tree-view/src/TreeItem/TreeItem.tsx
@@ -26,6 +26,7 @@ import { TreeViewCollapseIcon, TreeViewExpandIcon } from '../icons';
import { TreeItem2Provider } from '../TreeItem2Provider';
import { TreeViewItemDepthContext } from '../internals/TreeViewItemDepthContext';
import { useTreeItemState } from './useTreeItemState';
+import { isTargetInDescendants } from '../internals/utils/tree';
const useThemeProps = createUseThemeProps('MuiTreeItem');
@@ -42,6 +43,9 @@ const useUtilityClasses = (ownerState: TreeItemOwnerState) => {
iconContainer: ['iconContainer'],
checkbox: ['checkbox'],
label: ['label'],
+ labelInput: ['labelInput'],
+ editing: ['editing'],
+ editable: ['editable'],
groupTransition: ['groupTransition'],
};
@@ -217,7 +221,8 @@ export const TreeItem = React.forwardRef(function TreeItem(
...other
} = props;
- const { expanded, focused, selected, disabled, handleExpansion } = useTreeItemState(itemId);
+ const { expanded, focused, selected, disabled, editing, handleExpansion } =
+ useTreeItemState(itemId);
const { contentRef, rootRef, propsEnhancers } = runItemPlugins(props);
const rootRefObject = React.useRef(null);
@@ -343,11 +348,27 @@ export const TreeItem = React.forwardRef(function TreeItem(
function handleBlur(event: React.FocusEvent) {
onBlur?.(event);
+ if (
+ editing ||
+ // we can exit the editing state by clicking outside the input (within the tree item) or by pressing Enter or Escape -> we don't want to remove the focused item from the state in these cases
+ // we can also exit the editing state by clicking on the root itself -> want to remove the focused item from the state in this case
+ (event.relatedTarget &&
+ isTargetInDescendants(event.relatedTarget as HTMLElement, rootRefObject.current) &&
+ ((event.target &&
+ (event.target as HTMLElement)?.dataset?.element === 'labelInput' &&
+ isTargetInDescendants(event.target as HTMLElement, rootRefObject.current)) ||
+ (event.relatedTarget as HTMLElement)?.dataset?.element === 'labelInput'))
+ ) {
+ return;
+ }
instance.removeFocusedItem();
}
const handleKeyDown = (event: React.KeyboardEvent) => {
onKeyDown?.(event);
+ if ((event.target as HTMLElement)?.dataset?.element === 'labelInput') {
+ return;
+ }
instance.handleItemKeyDown(event, itemId);
};
@@ -372,6 +393,12 @@ export const TreeItem = React.forwardRef(function TreeItem(
contentRefObject,
externalEventHandlers: {},
}) ?? {};
+ const enhancedLabelInputProps =
+ propsEnhancers.labelInput?.({
+ rootRefObject,
+ contentRefObject,
+ externalEventHandlers: {},
+ }) ?? {};
return (
@@ -408,8 +435,11 @@ export const TreeItem = React.forwardRef(function TreeItem(
selected: classes.selected,
focused: classes.focused,
disabled: classes.disabled,
+ editable: classes.editable,
+ editing: classes.editing,
iconContainer: classes.iconContainer,
label: classes.label,
+ labelInput: classes.labelInput,
checkbox: classes.checkbox,
}}
label={label}
@@ -425,6 +455,9 @@ export const TreeItem = React.forwardRef(function TreeItem(
{...((enhancedDragAndDropOverlayProps as any).action == null
? {}
: { dragAndDropOverlayProps: enhancedDragAndDropOverlayProps })}
+ {...((enhancedLabelInputProps as any).value == null
+ ? {}
+ : { labelInputProps: enhancedLabelInputProps })}
ref={handleContentRef}
/>
{children && (
diff --git a/packages/x-tree-view/src/TreeItem/TreeItemContent.tsx b/packages/x-tree-view/src/TreeItem/TreeItemContent.tsx
index 686d7d9c88a5..2c62bd29c452 100644
--- a/packages/x-tree-view/src/TreeItem/TreeItemContent.tsx
+++ b/packages/x-tree-view/src/TreeItem/TreeItemContent.tsx
@@ -7,6 +7,8 @@ import {
TreeItem2DragAndDropOverlay,
TreeItem2DragAndDropOverlayProps,
} from '../TreeItem2DragAndDropOverlay';
+import { TreeItem2LabelInput, TreeItem2LabelInputProps } from '../TreeItem2LabelInput';
+import { MuiCancellableEvent } from '../internals/models';
export interface TreeItemContentProps extends React.HTMLAttributes {
className?: string;
@@ -30,6 +32,12 @@ export interface TreeItemContentProps extends React.HTMLAttributes
label: string;
/** Styles applied to the checkbox element. */
checkbox: string;
+ /** Styles applied to the input element that is visible when editing is enabled. */
+ labelInput: string;
+ /** Styles applied to the content element when editing is enabled. */
+ editing: string;
+ /** Styles applied to the content of the items that are editable. */
+ editable: string;
};
/**
* The tree item label.
@@ -52,6 +60,7 @@ export interface TreeItemContentProps extends React.HTMLAttributes
*/
displayIcon?: React.ReactNode;
dragAndDropOverlayProps?: TreeItem2DragAndDropOverlayProps;
+ labelInputProps?: TreeItem2LabelInputProps;
}
export type TreeItemContentClassKey = keyof NonNullable;
@@ -74,6 +83,7 @@ const TreeItemContent = React.forwardRef(function TreeItemContent(
onClick,
onMouseDown,
dragAndDropOverlayProps,
+ labelInputProps,
...other
} = props;
@@ -82,6 +92,8 @@ const TreeItemContent = React.forwardRef(function TreeItemContent(
expanded,
selected,
focused,
+ editing,
+ editable,
disableSelection,
checkboxSelection,
handleExpansion,
@@ -90,6 +102,9 @@ const TreeItemContent = React.forwardRef(function TreeItemContent(
handleContentClick,
preventSelection,
expansionTrigger,
+ toggleItemEditing,
+ handleSaveItemLabel,
+ handleCancelItemLabelEditing,
} = useTreeItemState(itemId);
const icon = iconProp || expansionIcon || displayIcon;
@@ -123,6 +138,39 @@ const TreeItemContent = React.forwardRef(function TreeItemContent(
}
};
+ const handleLabelDoubleClick = (event: React.MouseEvent & MuiCancellableEvent) => {
+ if (event.defaultMuiPrevented) {
+ return;
+ }
+ toggleItemEditing();
+ };
+ const handleLabelInputBlur = (
+ event: React.FocusEvent & MuiCancellableEvent,
+ ) => {
+ if (event.defaultMuiPrevented) {
+ return;
+ }
+
+ if (event.target.value) {
+ handleSaveItemLabel(event, event.target.value);
+ }
+ };
+
+ const handleLabelInputKeydown = (
+ event: React.KeyboardEvent & MuiCancellableEvent,
+ ) => {
+ if (event.defaultMuiPrevented) {
+ return;
+ }
+
+ const target = event.target as HTMLInputElement;
+ if (event.key === 'Enter' && target.value) {
+ handleSaveItemLabel(event, target.value);
+ } else if (event.key === 'Escape') {
+ handleCancelItemLabelEditing(event);
+ }
+ };
+
return (
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions -- Key event is handled by the TreeView */
)}
-
{label}
+ {editing ? (
+
+ ) : (
+
+ {label}
+
+ )}
+
{dragAndDropOverlayProps && }
);
@@ -189,6 +251,7 @@ TreeItemContent.propTypes = {
* The tree item label.
*/
label: PropTypes.node,
+ labelInputProps: PropTypes.object,
} as any;
export { TreeItemContent };
diff --git a/packages/x-tree-view/src/TreeItem/treeItemClasses.ts b/packages/x-tree-view/src/TreeItem/treeItemClasses.ts
index a8ac533418c6..6b0efd7279ce 100644
--- a/packages/x-tree-view/src/TreeItem/treeItemClasses.ts
+++ b/packages/x-tree-view/src/TreeItem/treeItemClasses.ts
@@ -22,6 +22,12 @@ export interface TreeItemClasses {
label: string;
/** Styles applied to the checkbox element. */
checkbox: string;
+ /** Styles applied to the input element that is visible when editing is enabled. */
+ labelInput: string;
+ /** Styles applied to the content element when editing is enabled. */
+ editing: string;
+ /** Styles applied to the content of the items that are editable. */
+ editable: string;
/** Styles applied to the drag and drop overlay. */
dragAndDropOverlay: string;
}
@@ -43,5 +49,8 @@ export const treeItemClasses: TreeItemClasses = generateUtilityClasses('MuiTreeI
'iconContainer',
'label',
'checkbox',
+ 'labelInput',
+ 'editable',
+ 'editing',
'dragAndDropOverlay',
]);
diff --git a/packages/x-tree-view/src/TreeItem/useTreeItemState.ts b/packages/x-tree-view/src/TreeItem/useTreeItemState.ts
index 7f41a5ad7dcc..a236983664ee 100644
--- a/packages/x-tree-view/src/TreeItem/useTreeItemState.ts
+++ b/packages/x-tree-view/src/TreeItem/useTreeItemState.ts
@@ -1,9 +1,12 @@
import * as React from 'react';
+import { MuiCancellableEvent } from '../internals/models/MuiCancellableEvent';
import { useTreeViewContext } from '../internals/TreeViewProvider';
import { UseTreeViewSelectionSignature } from '../internals/plugins/useTreeViewSelection';
import { UseTreeViewExpansionSignature } from '../internals/plugins/useTreeViewExpansion';
import { UseTreeViewFocusSignature } from '../internals/plugins/useTreeViewFocus';
import { UseTreeViewItemsSignature } from '../internals/plugins/useTreeViewItems';
+import { UseTreeViewLabelSignature, useTreeViewLabel } from '../internals/plugins/useTreeViewLabel';
+import { hasPlugin } from '../internals/utils/plugins';
type UseTreeItemStateMinimalPlugins = readonly [
UseTreeViewSelectionSignature,
@@ -12,7 +15,7 @@ type UseTreeItemStateMinimalPlugins = readonly [
UseTreeViewItemsSignature,
];
-type UseTreeItemStateOptionalPlugins = readonly [];
+type UseTreeItemStateOptionalPlugins = readonly [UseTreeViewLabelSignature];
export function useTreeItemState(itemId: string) {
const {
@@ -27,6 +30,8 @@ export function useTreeItemState(itemId: string) {
const focused = instance.isItemFocused(itemId);
const selected = instance.isItemSelected(itemId);
const disabled = instance.isItemDisabled(itemId);
+ const editing = instance?.isItemBeingEdited ? instance?.isItemBeingEdited(itemId) : false;
+ const editable = instance.isItemEditable ? instance.isItemEditable(itemId) : false;
const handleExpansion = (event: React.MouseEvent) => {
if (!disabled) {
@@ -87,11 +92,56 @@ export function useTreeItemState(itemId: string) {
}
};
+ const toggleItemEditing = () => {
+ if (!hasPlugin(instance, useTreeViewLabel)) {
+ return;
+ }
+ if (instance.isItemEditable(itemId)) {
+ if (instance.isItemBeingEdited(itemId)) {
+ instance.setEditedItemId(null);
+ } else {
+ instance.setEditedItemId(itemId);
+ }
+ }
+ };
+
+ const handleSaveItemLabel = (
+ event: React.SyntheticEvent & MuiCancellableEvent,
+ label: string,
+ ) => {
+ if (!hasPlugin(instance, useTreeViewLabel)) {
+ return;
+ }
+
+ // As a side effect of `instance.focusItem` called here and in `handleCancelItemLabelEditing` the `labelInput` is blurred
+ // The `onBlur` event is triggered, which calls `handleSaveItemLabel` again.
+ // To avoid creating an unwanted behavior we need to check if the item is being edited before calling `updateItemLabel`
+ // using `instance.isItemBeingEditedRef` instead of `instance.isItemBeingEdited` since the state is not yet updated in this point
+ if (instance.isItemBeingEditedRef(itemId)) {
+ instance.updateItemLabel(itemId, label);
+ toggleItemEditing();
+ instance.focusItem(event, itemId);
+ }
+ };
+
+ const handleCancelItemLabelEditing = (event: React.SyntheticEvent) => {
+ if (!hasPlugin(instance, useTreeViewLabel)) {
+ return;
+ }
+
+ if (instance.isItemBeingEditedRef(itemId)) {
+ toggleItemEditing();
+ instance.focusItem(event, itemId);
+ }
+ };
+
return {
disabled,
expanded,
selected,
focused,
+ editable,
+ editing,
disableSelection,
checkboxSelection,
handleExpansion,
@@ -100,5 +150,8 @@ export function useTreeItemState(itemId: string) {
handleContentClick: onItemClick,
preventSelection,
expansionTrigger,
+ toggleItemEditing,
+ handleSaveItemLabel,
+ handleCancelItemLabelEditing,
};
}
diff --git a/packages/x-tree-view/src/TreeItem2/TreeItem2.tsx b/packages/x-tree-view/src/TreeItem2/TreeItem2.tsx
index a1f72bfc089d..c792574ce229 100644
--- a/packages/x-tree-view/src/TreeItem2/TreeItem2.tsx
+++ b/packages/x-tree-view/src/TreeItem2/TreeItem2.tsx
@@ -13,12 +13,14 @@ import { TreeItem2Props, TreeItem2OwnerState } from './TreeItem2.types';
import {
unstable_useTreeItem2 as useTreeItem2,
UseTreeItem2ContentSlotOwnProps,
+ UseTreeItem2LabelSlotOwnProps,
UseTreeItem2Status,
} from '../useTreeItem2';
import { getTreeItemUtilityClass } from '../TreeItem';
import { TreeItem2Icon } from '../TreeItem2Icon';
import { TreeItem2DragAndDropOverlay } from '../TreeItem2DragAndDropOverlay';
import { TreeItem2Provider } from '../TreeItem2Provider';
+import { TreeItem2LabelInput } from '../TreeItem2LabelInput';
const useThemeProps = createUseThemeProps('MuiTreeItem2');
@@ -115,13 +117,23 @@ export const TreeItem2Label = styled('div', {
name: 'MuiTreeItem2',
slot: 'Label',
overridesResolver: (props, styles) => styles.label,
-})(({ theme }) => ({
+ shouldForwardProp: (prop) => shouldForwardProp(prop) && prop !== 'editable',
+})<{ editable?: boolean }>(({ theme }) => ({
width: '100%',
boxSizing: 'border-box', // prevent width + padding to overflow
// fixes overflow - see https://github.com/mui/material-ui/issues/27372
minWidth: 0,
position: 'relative',
+ overflow: 'hidden',
...theme.typography.body1,
+ variants: [
+ {
+ props: ({ editable }: UseTreeItem2LabelSlotOwnProps) => editable,
+ style: {
+ paddingLeft: '2px',
+ },
+ },
+ ],
}));
export const TreeItem2IconContainer = styled('div', {
@@ -182,6 +194,8 @@ const useUtilityClasses = (ownerState: TreeItem2OwnerState) => {
root: ['root'],
content: ['content'],
expanded: ['expanded'],
+ editing: ['editing'],
+ editable: ['editable'],
selected: ['selected'],
focused: ['focused'],
disabled: ['disabled'],
@@ -189,6 +203,7 @@ const useUtilityClasses = (ownerState: TreeItem2OwnerState) => {
checkbox: ['checkbox'],
label: ['label'],
groupTransition: ['groupTransition'],
+ labelInput: ['labelInput'],
dragAndDropOverlay: ['dragAndDropOverlay'],
};
@@ -224,6 +239,7 @@ export const TreeItem2 = React.forwardRef(function TreeItem2(
getCheckboxProps,
getLabelProps,
getGroupTransitionProps,
+ getLabelInputProps,
getDragAndDropOverlayProps,
status,
} = useTreeItem2({
@@ -265,8 +281,11 @@ export const TreeItem2 = React.forwardRef(function TreeItem2(
[classes.selected]: status.selected,
[classes.focused]: status.focused,
[classes.disabled]: status.disabled,
+ [classes.editing]: status.editing,
+ [classes.editable]: status.editable,
}),
});
+
const IconContainer: React.ElementType = slots.iconContainer ?? TreeItem2IconContainer;
const iconContainerProps = useSlotProps({
elementType: IconContainer,
@@ -303,6 +322,15 @@ export const TreeItem2 = React.forwardRef(function TreeItem2(
className: classes.groupTransition,
});
+ const LabelInput: React.ElementType = slots.labelInput ?? TreeItem2LabelInput;
+ const labelInputProps = useSlotProps({
+ elementType: LabelInput,
+ getSlotProps: getLabelInputProps,
+ externalSlotProps: slotProps.labelInput,
+ ownerState: {},
+ className: classes.labelInput,
+ });
+
const DragAndDropOverlay: React.ElementType | undefined =
slots.dragAndDropOverlay ?? TreeItem2DragAndDropOverlay;
const dragAndDropOverlayProps = useSlotProps({
@@ -321,7 +349,7 @@ export const TreeItem2 = React.forwardRef(function TreeItem2(
-
+ {status.editing ? : }
{children && }
diff --git a/packages/x-tree-view/src/TreeItem2/TreeItem2.types.ts b/packages/x-tree-view/src/TreeItem2/TreeItem2.types.ts
index a82f1ec7b75f..192e62a92716 100644
--- a/packages/x-tree-view/src/TreeItem2/TreeItem2.types.ts
+++ b/packages/x-tree-view/src/TreeItem2/TreeItem2.types.ts
@@ -37,6 +37,11 @@ export interface TreeItem2Slots extends TreeItem2IconSlots {
* @default TreeItem2Label
*/
label?: React.ElementType;
+ /**
+ * The component that renders the input to edit the label when the item is editable and is currently being edited.
+ * @default TreeItem2LabelInput
+ */
+ labelInput?: React.ElementType;
/**
* The component that renders the overlay when an item reordering is ongoing.
* Warning: This slot is only useful when using the `RichTreeViewPro` component.
@@ -52,6 +57,7 @@ export interface TreeItem2SlotProps extends TreeItem2IconSlotProps {
iconContainer?: SlotComponentProps<'div', {}, {}>;
checkbox?: SlotComponentProps<'button', {}, {}>;
label?: SlotComponentProps<'div', {}, {}>;
+ labelInput?: SlotComponentProps<'input', {}, {}>;
dragAndDropOverlay?: SlotComponentProps<'div', {}, {}>;
}
diff --git a/packages/x-tree-view/src/TreeItem2Icon/TreeItem2Icon.tsx b/packages/x-tree-view/src/TreeItem2Icon/TreeItem2Icon.tsx
index b07e292a9aaa..7c5c20b56b4e 100644
--- a/packages/x-tree-view/src/TreeItem2Icon/TreeItem2Icon.tsx
+++ b/packages/x-tree-view/src/TreeItem2Icon/TreeItem2Icon.tsx
@@ -71,6 +71,8 @@ TreeItem2Icon.propTypes = {
slots: PropTypes.object,
status: PropTypes.shape({
disabled: PropTypes.bool.isRequired,
+ editable: PropTypes.bool.isRequired,
+ editing: PropTypes.bool.isRequired,
expandable: PropTypes.bool.isRequired,
expanded: PropTypes.bool.isRequired,
focused: PropTypes.bool.isRequired,
diff --git a/packages/x-tree-view/src/TreeItem2LabelInput/TreeItem2LabelInput.tsx b/packages/x-tree-view/src/TreeItem2LabelInput/TreeItem2LabelInput.tsx
new file mode 100644
index 000000000000..f12f822d8407
--- /dev/null
+++ b/packages/x-tree-view/src/TreeItem2LabelInput/TreeItem2LabelInput.tsx
@@ -0,0 +1,20 @@
+import { styled } from '../internals/zero-styled';
+
+const TreeItem2LabelInput = styled('input', {
+ name: 'MuiTreeItem2',
+ slot: 'LabelInput',
+ overridesResolver: (props, styles) => styles.labelInput,
+})(({ theme }) => ({
+ ...theme.typography.body1,
+ width: '100%',
+ backgroundColor: theme.palette.background.paper,
+ borderRadius: theme.shape.borderRadius,
+ border: 'none',
+ padding: '0 2px',
+ boxSizing: 'border-box',
+ '&:focus': {
+ outline: `1px solid ${theme.palette.primary.main}`,
+ },
+}));
+
+export { TreeItem2LabelInput };
diff --git a/packages/x-tree-view/src/TreeItem2LabelInput/TreeItem2LabelInput.types.ts b/packages/x-tree-view/src/TreeItem2LabelInput/TreeItem2LabelInput.types.ts
new file mode 100644
index 000000000000..03e2414054e9
--- /dev/null
+++ b/packages/x-tree-view/src/TreeItem2LabelInput/TreeItem2LabelInput.types.ts
@@ -0,0 +1,8 @@
+export interface TreeItem2LabelInputProps extends React.InputHTMLAttributes {
+ value?: string;
+ onChange?: React.ChangeEventHandler;
+ /**
+ * Used to determine if the target of keydown or blur events is the input and prevent the event from propagating to the root.
+ */
+ 'data-element'?: 'labelInput';
+}
diff --git a/packages/x-tree-view/src/TreeItem2LabelInput/index.ts b/packages/x-tree-view/src/TreeItem2LabelInput/index.ts
new file mode 100644
index 000000000000..c779e21303b1
--- /dev/null
+++ b/packages/x-tree-view/src/TreeItem2LabelInput/index.ts
@@ -0,0 +1,2 @@
+export { TreeItem2LabelInput } from './TreeItem2LabelInput';
+export type { TreeItem2LabelInputProps } from './TreeItem2LabelInput.types';
diff --git a/packages/x-tree-view/src/hooks/useTreeItem2Utils/useTreeItem2Utils.tsx b/packages/x-tree-view/src/hooks/useTreeItem2Utils/useTreeItem2Utils.tsx
index b7b3b99bf501..fb2d994d28c6 100644
--- a/packages/x-tree-view/src/hooks/useTreeItem2Utils/useTreeItem2Utils.tsx
+++ b/packages/x-tree-view/src/hooks/useTreeItem2Utils/useTreeItem2Utils.tsx
@@ -1,15 +1,24 @@
import * as React from 'react';
+import { MuiCancellableEvent } from '../../internals/models/MuiCancellableEvent';
import { useTreeViewContext } from '../../internals/TreeViewProvider';
import { UseTreeViewSelectionSignature } from '../../internals/plugins/useTreeViewSelection';
import { UseTreeViewExpansionSignature } from '../../internals/plugins/useTreeViewExpansion';
import { UseTreeViewItemsSignature } from '../../internals/plugins/useTreeViewItems';
import { UseTreeViewFocusSignature } from '../../internals/plugins/useTreeViewFocus';
+import {
+ UseTreeViewLabelSignature,
+ useTreeViewLabel,
+} from '../../internals/plugins/useTreeViewLabel';
import type { UseTreeItem2Status } from '../../useTreeItem2';
+import { hasPlugin } from '../../internals/utils/plugins';
interface UseTreeItem2Interactions {
handleExpansion: (event: React.MouseEvent) => void;
handleSelection: (event: React.MouseEvent) => void;
handleCheckboxSelection: (event: React.ChangeEvent) => void;
+ toggleItemEditing: () => void;
+ handleSaveItemLabel: (event: React.SyntheticEvent, label: string) => void;
+ handleCancelItemLabelEditing: (event: React.SyntheticEvent) => void;
}
interface UseTreeItem2UtilsReturnValue {
@@ -37,7 +46,8 @@ type UseTreeItem2UtilsMinimalPlugins = readonly [
/**
* Plugins that `useTreeItem2Utils` can use if they are present, but are not required.
*/
-export type UseTreeItem2UtilsOptionalPlugins = readonly [];
+
+export type UseTreeItem2UtilsOptionalPlugins = readonly [UseTreeViewLabelSignature];
export const useTreeItem2Utils = ({
itemId,
@@ -57,6 +67,8 @@ export const useTreeItem2Utils = ({
focused: instance.isItemFocused(itemId),
selected: instance.isItemSelected(itemId),
disabled: instance.isItemDisabled(itemId),
+ editing: instance?.isItemBeingEdited ? instance?.isItemBeingEdited(itemId) : false,
+ editable: instance.isItemEditable ? instance.isItemEditable(itemId) : false,
};
const handleExpansion = (event: React.MouseEvent) => {
@@ -112,10 +124,56 @@ export const useTreeItem2Utils = ({
}
};
+ const toggleItemEditing = () => {
+ if (!hasPlugin(instance, useTreeViewLabel)) {
+ return;
+ }
+ if (instance.isItemEditable(itemId)) {
+ if (instance.isItemBeingEdited(itemId)) {
+ instance.setEditedItemId(null);
+ } else {
+ instance.setEditedItemId(itemId);
+ }
+ }
+ };
+
+ const handleSaveItemLabel = (
+ event: React.SyntheticEvent & MuiCancellableEvent,
+ label: string,
+ ) => {
+ if (!hasPlugin(instance, useTreeViewLabel)) {
+ return;
+ }
+
+ // As a side effect of `instance.focusItem` called here and in `handleCancelItemLabelEditing` the `labelInput` is blurred
+ // The `onBlur` event is triggered, which calls `handleSaveItemLabel` again.
+ // To avoid creating an unwanted behavior we need to check if the item is being edited before calling `updateItemLabel`
+ // using `instance.isItemBeingEditedRef` instead of `instance.isItemBeingEdited` since the state is not yet updated in this point
+ if (instance.isItemBeingEditedRef(itemId)) {
+ instance.updateItemLabel(itemId, label);
+ toggleItemEditing();
+ instance.focusItem(event, itemId);
+ }
+ };
+
+ const handleCancelItemLabelEditing = (event: React.SyntheticEvent) => {
+ if (!hasPlugin(instance, useTreeViewLabel)) {
+ return;
+ }
+
+ if (instance.isItemBeingEditedRef(itemId)) {
+ toggleItemEditing();
+ instance.focusItem(event, itemId);
+ }
+ };
+
const interactions: UseTreeItem2Interactions = {
handleExpansion,
handleSelection,
handleCheckboxSelection,
+ toggleItemEditing,
+ handleSaveItemLabel,
+ handleCancelItemLabelEditing,
};
return { interactions, status };
diff --git a/packages/x-tree-view/src/internals/index.ts b/packages/x-tree-view/src/internals/index.ts
index a17a5f7a414d..fa8077673775 100644
--- a/packages/x-tree-view/src/internals/index.ts
+++ b/packages/x-tree-view/src/internals/index.ts
@@ -54,6 +54,11 @@ export type {
UseTreeViewItemsParameters,
UseTreeViewItemsState,
} from './plugins/useTreeViewItems';
+export { useTreeViewLabel } from './plugins/useTreeViewLabel';
+export type {
+ UseTreeViewLabelSignature,
+ UseTreeViewLabelParameters,
+} from './plugins/useTreeViewLabel';
export { useTreeViewJSXItems } from './plugins/useTreeViewJSXItems';
export type {
UseTreeViewJSXItemsSignature,
diff --git a/packages/x-tree-view/src/internals/models/itemPlugin.ts b/packages/x-tree-view/src/internals/models/itemPlugin.ts
index 2356e8deeb68..ad02c708dcff 100644
--- a/packages/x-tree-view/src/internals/models/itemPlugin.ts
+++ b/packages/x-tree-view/src/internals/models/itemPlugin.ts
@@ -3,6 +3,7 @@ import { EventHandlers } from '@mui/utils';
import type {
UseTreeItem2ContentSlotOwnProps,
UseTreeItem2DragAndDropOverlaySlotOwnProps,
+ UseTreeItem2LabelInputSlotOwnProps,
UseTreeItem2RootSlotOwnProps,
} from '../../useTreeItem2';
@@ -20,6 +21,7 @@ export interface TreeViewItemPluginSlotPropsEnhancers {
root?: TreeViewItemPluginSlotPropsEnhancer;
content?: TreeViewItemPluginSlotPropsEnhancer;
dragAndDropOverlay?: TreeViewItemPluginSlotPropsEnhancer;
+ labelInput?: TreeViewItemPluginSlotPropsEnhancer;
}
export interface TreeViewItemPluginResponse {
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 3fd669acf55f..b9dabd1bd1a2 100644
--- a/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.ts
+++ b/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.ts
@@ -88,8 +88,12 @@ export const useTreeViewExpansion: TreeViewPlugin
return params.expansionTrigger;
}
+ if (instance.isTreeViewEditable) {
+ return 'iconContainer';
+ }
+
return 'content';
- }, [params.expansionTrigger]);
+ }, [params.expansionTrigger, instance.isTreeViewEditable]);
return {
publicAPI: {
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 d471bcb19606..88460ccf2fee 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
@@ -2,6 +2,7 @@ import * as React from 'react';
import { DefaultizedProps, TreeViewPluginSignature } from '../../models';
import { UseTreeViewItemsSignature } from '../useTreeViewItems';
import { TreeViewItemId } from '../../../models';
+import { UseTreeViewLabelSignature } from '../useTreeViewLabel';
export interface UseTreeViewExpansionPublicAPI {
/**
@@ -96,4 +97,5 @@ export type UseTreeViewExpansionSignature = TreeViewPluginSignature<{
modelNames: 'expandedItems';
contextValue: UseTreeViewExpansionContextValue;
dependencies: [UseTreeViewItemsSignature];
+ optionalDependencies: [UseTreeViewLabelSignature];
}>;
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 62cc95cb0912..b16854a16cc3 100644
--- a/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts
+++ b/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts
@@ -71,6 +71,7 @@ export const useTreeViewFocus: TreeViewPlugin = ({
}
setFocusedItemId(itemId);
+
if (params.onItemFocus) {
params.onItemFocus(event, itemId);
}
@@ -93,6 +94,7 @@ export const useTreeViewFocus: TreeViewPlugin = ({
const itemElement = document.getElementById(
instance.getTreeItemIdAttribute(state.focusedItemId, itemMeta.idAttribute),
);
+
if (itemElement) {
itemElement.blur();
}
diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewKeyboardNavigation/useTreeViewKeyboardNavigation.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewKeyboardNavigation/useTreeViewKeyboardNavigation.ts
index 1e8c939257d1..c00321273754 100644
--- a/packages/x-tree-view/src/internals/plugins/useTreeViewKeyboardNavigation/useTreeViewKeyboardNavigation.ts
+++ b/packages/x-tree-view/src/internals/plugins/useTreeViewKeyboardNavigation/useTreeViewKeyboardNavigation.ts
@@ -13,6 +13,8 @@ import {
TreeViewFirstCharMap,
UseTreeViewKeyboardNavigationSignature,
} from './useTreeViewKeyboardNavigation.types';
+import { hasPlugin } from '../../utils/plugins';
+import { useTreeViewLabel } from '../useTreeViewLabel';
function isPrintableCharacter(string: string) {
return !!string && string.length === 1 && !!string.match(/\S/);
@@ -121,7 +123,13 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin<
// If the focused item has children, we expand it.
// If the focused item has no children, we select it.
case key === 'Enter': {
- if (canToggleItemExpansion(itemId)) {
+ if (
+ hasPlugin(instance, useTreeViewLabel) &&
+ instance.isItemEditable(itemId) &&
+ !instance.isItemBeingEdited(itemId)
+ ) {
+ instance.setEditedItemId(itemId);
+ } else if (canToggleItemExpansion(itemId)) {
instance.toggleItemExpansion(event, itemId);
event.preventDefault();
} else if (canToggleItemSelection(itemId)) {
diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewKeyboardNavigation/useTreeViewKeyboardNavigation.types.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewKeyboardNavigation/useTreeViewKeyboardNavigation.types.ts
index 9bab5a805ad6..921e874c9e80 100644
--- a/packages/x-tree-view/src/internals/plugins/useTreeViewKeyboardNavigation/useTreeViewKeyboardNavigation.types.ts
+++ b/packages/x-tree-view/src/internals/plugins/useTreeViewKeyboardNavigation/useTreeViewKeyboardNavigation.types.ts
@@ -5,6 +5,7 @@ import { UseTreeViewSelectionSignature } from '../useTreeViewSelection';
import { UseTreeViewFocusSignature } from '../useTreeViewFocus';
import { UseTreeViewExpansionSignature } from '../useTreeViewExpansion';
import { TreeViewItemId } from '../../../models';
+import { UseTreeViewLabelSignature } from '../useTreeViewLabel';
export interface UseTreeViewKeyboardNavigationInstance {
/**
@@ -34,6 +35,7 @@ export type UseTreeViewKeyboardNavigationSignature = TreeViewPluginSignature<{
UseTreeViewFocusSignature,
UseTreeViewExpansionSignature,
];
+ optionalDependencies: [UseTreeViewLabelSignature];
}>;
export type TreeViewFirstCharMap = { [itemId: string]: string };
diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/index.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/index.ts
new file mode 100644
index 000000000000..750ea6e25b8d
--- /dev/null
+++ b/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/index.ts
@@ -0,0 +1,5 @@
+export { useTreeViewLabel } from './useTreeViewLabel';
+export type {
+ UseTreeViewLabelSignature,
+ UseTreeViewLabelParameters,
+} from './useTreeViewLabel.types';
diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.itemPlugin.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.itemPlugin.ts
new file mode 100644
index 000000000000..96e143e02dbf
--- /dev/null
+++ b/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.itemPlugin.ts
@@ -0,0 +1,52 @@
+import * as React from 'react';
+import { useTreeViewContext } from '../../TreeViewProvider';
+import { TreeViewItemPlugin } from '../../models';
+import { UseTreeViewItemsSignature } from '../useTreeViewItems';
+import {
+ UseTreeItem2LabelInputSlotPropsFromItemsReordering,
+ UseTreeViewLabelSignature,
+} from './useTreeViewLabel.types';
+
+export const isAndroid = () => navigator.userAgent.toLowerCase().includes('android');
+
+export const useTreeViewLabelItemPlugin: TreeViewItemPlugin = ({ props }) => {
+ const { instance } = useTreeViewContext<[UseTreeViewItemsSignature, UseTreeViewLabelSignature]>();
+ const { label, itemId } = props;
+
+ const [labelInputValue, setLabelInputValue] = React.useState(label);
+
+ const isItemBeingEdited = instance.isItemBeingEdited(itemId);
+
+ React.useEffect(() => {
+ if (!isItemBeingEdited) {
+ setLabelInputValue(label);
+ }
+ }, [isItemBeingEdited, label]);
+
+ return {
+ propsEnhancers: {
+ labelInput: ({
+ externalEventHandlers,
+ }): UseTreeItem2LabelInputSlotPropsFromItemsReordering => {
+ const editable = instance.isItemEditable(itemId);
+
+ if (!editable) {
+ return {};
+ }
+
+ const handleInputChange = (event: React.ChangeEvent) => {
+ externalEventHandlers.onChange?.(event);
+ setLabelInputValue(event.target.value);
+ };
+
+ return {
+ value: labelInputValue ?? '',
+ 'data-element': 'labelInput',
+ onChange: handleInputChange,
+ autoFocus: true,
+ type: 'text',
+ };
+ },
+ },
+ };
+};
diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.test.tsx b/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.test.tsx
new file mode 100644
index 000000000000..05fe270d5248
--- /dev/null
+++ b/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.test.tsx
@@ -0,0 +1,244 @@
+import { expect } from 'chai';
+import { act, fireEvent } from '@mui/internal-test-utils';
+import { describeTreeView } from 'test/utils/tree-view/describeTreeView';
+import { UseTreeViewLabelSignature } from '@mui/x-tree-view/internals';
+
+describeTreeView<[UseTreeViewLabelSignature]>(
+ 'useTreeViewLabel plugin',
+ ({ render, treeViewComponentName }) => {
+ describe('interaction', () => {
+ describe('render labelInput when needed', () => {
+ it('should not render labelInput when double clicked if item is not editable', function test() {
+ // This test is not relevant for the TreeItem component or the SimpleTreeView.
+ if (treeViewComponentName.startsWith('SimpleTreeView')) {
+ this.skip();
+ }
+ const response = render({
+ experimentalFeatures: { labelEditing: true },
+ items: [{ id: '1', editable: false }],
+ isItemEditable: (item) => item.editable,
+ });
+ act(() => {
+ response.getItemRoot('1').focus();
+ });
+ fireEvent.doubleClick(response.getItemLabel('1'));
+
+ expect(response.getItemLabelInput('1')).to.equal(null);
+ });
+
+ it('should render labelInput when double clicked if item is editable', function test() {
+ // This test is not relevant for the TreeItem component or the SimpleTreeView.
+ if (treeViewComponentName.startsWith('SimpleTreeView')) {
+ this.skip();
+ }
+ const response = render({
+ experimentalFeatures: { labelEditing: true },
+ items: [{ id: '1', editable: true }],
+ isItemEditable: (item) => item.editable,
+ });
+ act(() => {
+ response.getItemRoot('1').focus();
+ });
+ fireEvent.doubleClick(response.getItemLabel('1'));
+
+ expect(response.getItemLabelInput('1')).not.to.equal(null);
+ });
+
+ it('should not render label when double clicked if item is editable', function test() {
+ // This test is not relevant for the TreeItem component or the SimpleTreeView.
+ if (treeViewComponentName.startsWith('SimpleTreeView')) {
+ this.skip();
+ }
+ const response = render({
+ experimentalFeatures: { labelEditing: true },
+ items: [{ id: '1', editable: true }],
+ isItemEditable: (item) => item.editable,
+ });
+ act(() => {
+ response.getItemRoot('1').focus();
+ });
+ fireEvent.doubleClick(response.getItemLabel('1'));
+
+ expect(response.getItemLabel('1')).to.equal(null);
+ });
+
+ it('should not render labelInput on Enter if item is not editable', function test() {
+ // This test is not relevant for the TreeItem component or the SimpleTreeView.
+ if (treeViewComponentName.startsWith('SimpleTreeView')) {
+ this.skip();
+ }
+ const response = render({
+ experimentalFeatures: { labelEditing: true },
+ items: [{ id: '1', editable: false }],
+ isItemEditable: (item) => item.editable,
+ });
+ act(() => {
+ response.getItemRoot('1').focus();
+ });
+ fireEvent.keyDown(response.getItemRoot('1'), { key: 'Enter' });
+
+ expect(response.getItemLabelInput('1')).to.equal(null);
+ expect(response.getItemLabel('1')).not.to.equal(null);
+ });
+
+ it('should render labelInput on Enter if item is editable', function test() {
+ // This test is not relevant for the TreeItem component or the SimpleTreeView.
+ if (treeViewComponentName.startsWith('SimpleTreeView')) {
+ this.skip();
+ }
+ const response = render({
+ experimentalFeatures: { labelEditing: true },
+ items: [{ id: '1', editable: true }],
+ isItemEditable: (item) => item.editable,
+ });
+ act(() => {
+ response.getItemRoot('1').focus();
+ });
+ fireEvent.keyDown(response.getItemRoot('1'), { key: 'Enter' });
+
+ expect(response.getItemLabelInput('1')).not.to.equal(null);
+ });
+
+ it('should unmount labelInput after save', function test() {
+ // This test is not relevant for the TreeItem component or the SimpleTreeView.
+ if (treeViewComponentName.startsWith('SimpleTreeView')) {
+ this.skip();
+ }
+ const response = render({
+ experimentalFeatures: { labelEditing: true },
+ items: [{ id: '1', label: 'test', editable: true }],
+ isItemEditable: (item) => item.editable,
+ });
+ act(() => {
+ response.getItemRoot('1').focus();
+ });
+ fireEvent.doubleClick(response.getItemLabel('1'));
+ fireEvent.keyDown(response.getItemLabelInput('1'), { key: 'Enter' });
+
+ expect(response.getItemLabelInput('1')).to.equal(null);
+ expect(response.getItemLabel('1')).not.to.equal(null);
+ });
+
+ it('should unmount labelInput after cancel', function test() {
+ // This test is not relevant for the TreeItem component or the SimpleTreeView.
+ if (treeViewComponentName.startsWith('SimpleTreeView')) {
+ this.skip();
+ }
+ const response = render({
+ experimentalFeatures: { labelEditing: true },
+ items: [{ id: '1', label: 'test', editable: true }],
+ isItemEditable: (item) => item.editable,
+ });
+ act(() => {
+ response.getItemRoot('1').focus();
+ });
+ fireEvent.doubleClick(response.getItemLabel('1'));
+ fireEvent.keyDown(response.getItemLabelInput('1'), { key: 'Esc' });
+
+ expect(response.getItemLabelInput('1')).to.equal(null);
+ expect(response.getItemLabel('1')).not.to.equal(null);
+ });
+ });
+
+ describe('labelInput value', () => {
+ it('should equal label value on first render', function test() {
+ // This test is not relevant for the TreeItem component or the SimpleTreeView.
+ if (treeViewComponentName.startsWith('SimpleTreeView')) {
+ this.skip();
+ }
+ const response = render({
+ experimentalFeatures: { labelEditing: true },
+ items: [{ id: '1', label: 'test', editable: true }],
+ isItemEditable: (item) => item.editable,
+ });
+ act(() => {
+ response.getItemRoot('1').focus();
+ });
+ fireEvent.doubleClick(response.getItemLabel('1'));
+
+ expect(response.getItemLabelInput('1').value).to.equal('test');
+ });
+
+ it('should save new value on Enter', function test() {
+ // This test is not relevant for the TreeItem component or the SimpleTreeView.
+ if (treeViewComponentName.startsWith('SimpleTreeView')) {
+ this.skip();
+ }
+ const response = render({
+ experimentalFeatures: { labelEditing: true },
+ items: [{ id: '1', label: 'test', editable: true }],
+ isItemEditable: (item) => item.editable,
+ });
+ act(() => {
+ response.getItemRoot('1').focus();
+ });
+ fireEvent.doubleClick(response.getItemLabel('1'));
+ fireEvent.change(response.getItemLabelInput('1'), { target: { value: 'new value' } });
+ fireEvent.keyDown(response.getItemLabelInput('1'), { key: 'Enter' });
+
+ expect(response.getItemLabel('1').textContent).to.equal('new value');
+ });
+
+ it('should hold new value on render after save', function test() {
+ // This test is not relevant for the TreeItem component or the SimpleTreeView.
+ if (treeViewComponentName.startsWith('SimpleTreeView')) {
+ this.skip();
+ }
+ const response = render({
+ experimentalFeatures: { labelEditing: true },
+ items: [{ id: '1', label: 'test', editable: true }],
+ isItemEditable: (item) => item.editable,
+ });
+ act(() => {
+ response.getItemRoot('1').focus();
+ });
+ fireEvent.doubleClick(response.getItemLabel('1'));
+ fireEvent.change(response.getItemLabelInput('1'), { target: { value: 'new value' } });
+ fireEvent.keyDown(response.getItemLabelInput('1'), { key: 'Enter' });
+ fireEvent.doubleClick(response.getItemLabel('1'));
+
+ expect(response.getItemLabelInput('1').value).to.equal('new value');
+ });
+
+ it('should hold initial value on render after cancel', function test() {
+ // This test is not relevant for the TreeItem component or the SimpleTreeView.
+ if (treeViewComponentName.startsWith('SimpleTreeView')) {
+ this.skip();
+ }
+ const response = render({
+ experimentalFeatures: { labelEditing: true },
+ items: [{ id: '1', label: 'test', editable: true }],
+ isItemEditable: (item) => item.editable,
+ });
+ act(() => {
+ response.getItemRoot('1').focus();
+ });
+ fireEvent.doubleClick(response.getItemLabel('1'));
+ fireEvent.change(response.getItemLabelInput('1'), { target: { value: 'new value' } });
+ fireEvent.keyDown(response.getItemLabelInput('1'), { key: 'Esc' });
+ expect(response.getItemLabel('1').textContent).to.equal('test');
+
+ fireEvent.doubleClick(response.getItemLabel('1'));
+ expect(response.getItemLabelInput('1').value).to.equal('test');
+ });
+ });
+ });
+ describe('updateItemLabel api method', () => {
+ it('should change the label value', function test() {
+ // This test is not relevant for the TreeItem component or the SimpleTreeView.
+ if (treeViewComponentName.startsWith('SimpleTreeView')) {
+ this.skip();
+ }
+ const response = render({
+ items: [{ id: '1', label: 'test' }],
+ });
+
+ act(() => {
+ response.apiRef.current.updateItemLabel('1', 'new value');
+ });
+
+ expect(response.getItemLabel('1').textContent).to.equal('new value');
+ });
+ });
+ },
+);
diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.ts
new file mode 100644
index 000000000000..72c692b40fe5
--- /dev/null
+++ b/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.ts
@@ -0,0 +1,105 @@
+import * as React from 'react';
+import { warnOnce } from '../../utils/warning';
+import { TreeViewPlugin } from '../../models';
+import { TreeViewItemId } from '../../../models';
+import { UseTreeViewLabelSignature } from './useTreeViewLabel.types';
+import { useTreeViewLabelItemPlugin } from './useTreeViewLabel.itemPlugin';
+
+export const useTreeViewLabel: TreeViewPlugin = ({
+ instance,
+ state,
+ setState,
+ params,
+ experimentalFeatures,
+}) => {
+ if (process.env.NODE_ENV !== 'production') {
+ if (params.isItemEditable && !experimentalFeatures?.labelEditing) {
+ warnOnce([
+ 'MUI X: The label editing feature requires the `labelEditing` experimental feature to be enabled.',
+ 'You can do it by passing `experimentalFeatures={{ labelEditing: true}}` to the `RichTreeViewPro` component.',
+ 'Check the documentation for more details: https://mui.com/x/react-tree-view/rich-tree-view/editing/',
+ ]);
+ }
+ }
+ const editedItemRef = React.useRef(state.editedItemId);
+
+ const isItemBeingEditedRef = (itemId: TreeViewItemId) => editedItemRef.current === itemId;
+
+ const setEditedItemId = (editedItemId: TreeViewItemId | null) => {
+ setState((prevState) => ({ ...prevState, editedItemId }));
+ editedItemRef.current = editedItemId;
+ };
+
+ const isItemBeingEdited = (itemId: TreeViewItemId) => itemId === state.editedItemId;
+
+ const isTreeViewEditable = Boolean(params.isItemEditable) && !!experimentalFeatures.labelEditing;
+
+ const isItemEditable = (itemId: TreeViewItemId): boolean => {
+ if (itemId == null || !isTreeViewEditable) {
+ return false;
+ }
+ const item = instance.getItem(itemId);
+
+ if (!item) {
+ return false;
+ }
+ return typeof params.isItemEditable === 'function'
+ ? params.isItemEditable(item)
+ : Boolean(params.isItemEditable);
+ };
+
+ const updateItemLabel = (itemId: TreeViewItemId, label: string) => {
+ if (!label) {
+ throw new Error(
+ [
+ 'MUI X: The Tree View component requires all items to have a `label` property.',
+ 'The label of an item cannot be empty.',
+ itemId,
+ ].join('\n'),
+ );
+ }
+ setState((prevState) => {
+ const item = prevState.items.itemMetaMap[itemId];
+ if (item.label !== label) {
+ return {
+ ...prevState,
+ items: {
+ ...prevState.items,
+ itemMetaMap: { ...prevState.items.itemMetaMap, [itemId]: { ...item, label } },
+ },
+ };
+ }
+
+ return prevState;
+ });
+
+ if (params.onItemLabelChange) {
+ params.onItemLabelChange(itemId, label);
+ }
+ };
+
+ return {
+ instance: {
+ setEditedItemId,
+ isItemBeingEdited,
+ updateItemLabel,
+ isItemEditable,
+ isTreeViewEditable,
+ isItemBeingEditedRef,
+ },
+ publicAPI: {
+ updateItemLabel,
+ },
+ };
+};
+
+useTreeViewLabel.itemPlugin = useTreeViewLabelItemPlugin;
+
+useTreeViewLabel.getInitialState = () => ({
+ editedItemId: null,
+});
+
+useTreeViewLabel.params = {
+ onItemLabelChange: true,
+ isItemEditable: true,
+};
diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.types.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.types.ts
new file mode 100644
index 000000000000..37a348a2da61
--- /dev/null
+++ b/packages/x-tree-view/src/internals/plugins/useTreeViewLabel/useTreeViewLabel.types.ts
@@ -0,0 +1,80 @@
+import { TreeViewPluginSignature } from '../../models';
+import { TreeViewItemId } from '../../../models';
+import { UseTreeViewItemsSignature } from '../useTreeViewItems';
+import { TreeItem2LabelInputProps } from '../../../TreeItem2LabelInput';
+
+export interface UseTreeViewLabelPublicAPI {
+ /**
+ * Used to update the label of an item.
+ * @param {TreeViewItemId} itemId The id of the item to update the label of.
+ * @param {string} newLabel The new label of the item.
+ */
+ updateItemLabel: (itemId: TreeViewItemId, newLabel: string) => void;
+}
+
+export interface UseTreeViewLabelInstance extends UseTreeViewLabelPublicAPI {
+ /**
+ * Updates which item is currently being edited.
+ * @param {TreeViewItemId} itemId The id of the item that is currently being edited.
+ * @returns {void}.
+ */
+ setEditedItemId: (itemId: TreeViewItemId | null) => void;
+ /**
+ * Checks if an item is being edited or not.
+ * @param {TreeViewItemId} itemId The id of the item to check.
+ * @returns {boolean}.
+ */
+ isItemBeingEdited: (itemId: TreeViewItemId) => boolean;
+ /**
+ * Checks if an item is being edited or not.
+ * Purely internal use, used to avoid unnecessarily calling `updateItemLabel` twice.
+ * @param {TreeViewItemId} itemId The id of the item to check.
+ * @returns {boolean}.
+ */
+ isItemBeingEditedRef: (itemId: TreeViewItemId) => boolean;
+ /**
+ * Determines if a given item is editable.
+ * @param {TreeViewItemId} itemId The id of the item to check.
+ * @returns {boolean} `true` if the item is editable.
+ */
+ isItemEditable: (itemId: TreeViewItemId) => boolean;
+ /**
+ * Set to `true` if the tree view is editable.
+ */
+ isTreeViewEditable: boolean;
+}
+
+export interface UseTreeViewLabelParameters {
+ /**
+ * Callback fired when the label of an item changes.
+ * @param {TreeViewItemId} itemId The id of the item that was edited.
+ * @param {string} newLabel The new label of the items.
+ */
+ onItemLabelChange?: (itemId: TreeViewItemId, newLabel: string) => void;
+ /**
+ * Determines if a given item is editable or not.
+ * Make sure to also enable the `labelEditing` experimental feature:
+ * ``.
+ * By default, the items are not editable.
+ * @template R
+ * @param {R} item The item to check.
+ * @returns {boolean} `true` if the item is editable.
+ */
+ isItemEditable?: boolean | ((item: R) => boolean);
+}
+
+export interface UseTreeViewLabelState {
+ editedItemId: string | null;
+}
+
+export type UseTreeViewLabelSignature = TreeViewPluginSignature<{
+ params: UseTreeViewLabelParameters;
+ defaultizedParams: UseTreeViewLabelParameters;
+ publicAPI: UseTreeViewLabelPublicAPI;
+ instance: UseTreeViewLabelInstance;
+ state: UseTreeViewLabelState;
+ experimentalFeatures: 'labelEditing';
+ dependencies: [UseTreeViewItemsSignature];
+}>;
+export interface UseTreeItem2LabelInputSlotPropsFromItemsReordering
+ extends TreeItem2LabelInputProps {}
diff --git a/packages/x-tree-view/src/useTreeItem2/index.ts b/packages/x-tree-view/src/useTreeItem2/index.ts
index f99a0f545223..1305bca8626e 100644
--- a/packages/x-tree-view/src/useTreeItem2/index.ts
+++ b/packages/x-tree-view/src/useTreeItem2/index.ts
@@ -5,6 +5,7 @@ export type {
UseTreeItem2Status,
UseTreeItem2RootSlotOwnProps,
UseTreeItem2ContentSlotOwnProps,
+ UseTreeItem2LabelInputSlotOwnProps,
UseTreeItem2LabelSlotOwnProps,
UseTreeItem2IconContainerSlotOwnProps,
UseTreeItem2GroupTransitionSlotOwnProps,
diff --git a/packages/x-tree-view/src/useTreeItem2/useTreeItem2.ts b/packages/x-tree-view/src/useTreeItem2/useTreeItem2.ts
index 64639815422f..e9424c9d28f1 100644
--- a/packages/x-tree-view/src/useTreeItem2/useTreeItem2.ts
+++ b/packages/x-tree-view/src/useTreeItem2/useTreeItem2.ts
@@ -11,6 +11,7 @@ import {
UseTreeItem2LabelSlotProps,
UseTreeItemIconContainerSlotProps,
UseTreeItem2CheckboxSlotProps,
+ UseTreeItem2LabelInputSlotProps,
UseTreeItem2MinimalPlugins,
UseTreeItem2OptionalPlugins,
UseTreeItem2DragAndDropOverlaySlotProps,
@@ -21,6 +22,7 @@ import { useTreeViewContext } from '../internals/TreeViewProvider';
import { MuiCancellableEvent } from '../internals/models';
import { useTreeItem2Utils } from '../hooks/useTreeItem2Utils';
import { TreeViewItemDepthContext } from '../internals/TreeViewItemDepthContext';
+import { isTargetInDescendants } from '../internals/utils/tree';
export const useTreeItem2 = <
TSignatures extends UseTreeItem2MinimalPlugins = UseTreeItem2MinimalPlugins,
@@ -48,6 +50,7 @@ export const useTreeItem2 = <
const handleRootRef = useForkRef(rootRef, pluginRootRef, rootRefObject)!;
const handleContentRef = useForkRef(contentRef, contentRefObject)!;
const checkboxRef = React.useRef(null);
+ const rootTabIndex = instance.canItemBeTabbed(itemId) ? 0 : -1;
const createRootHandleFocus =
(otherHandlers: EventHandlers) =>
@@ -71,6 +74,25 @@ export const useTreeItem2 = <
return;
}
+ const rootElement = instance.getItemDOMElement(itemId);
+
+ // Don't blur the root when switching to editing mode
+ // the input that triggers the root blur can be either the relatedTarget (when entering editing state) or the target (when exiting editing state)
+ // when we enter the editing state, we focus the input -> we don't want to remove the focused item from the state
+ if (
+ status.editing ||
+ // we can exit the editing state by clicking outside the input (within the tree item) or by pressing Enter or Escape -> we don't want to remove the focused item from the state in these cases
+ // we can also exit the editing state by clicking on the root itself -> want to remove the focused item from the state in this case
+ (event.relatedTarget &&
+ isTargetInDescendants(event.relatedTarget as HTMLElement, rootElement) &&
+ ((event.target &&
+ (event.target as HTMLElement)?.dataset?.element === 'labelInput' &&
+ isTargetInDescendants(event.target as HTMLElement, rootElement)) ||
+ (event.relatedTarget as HTMLElement)?.dataset?.element === 'labelInput'))
+ ) {
+ return;
+ }
+
instance.removeFocusedItem();
};
@@ -78,13 +100,25 @@ export const useTreeItem2 = <
(otherHandlers: EventHandlers) =>
(event: React.KeyboardEvent & MuiCancellableEvent) => {
otherHandlers.onKeyDown?.(event);
- if (event.defaultMuiPrevented) {
+ if (
+ event.defaultMuiPrevented ||
+ (event.target as HTMLElement)?.dataset?.element === 'labelInput'
+ ) {
return;
}
instance.handleItemKeyDown(event, itemId);
};
+ const createLabelHandleDoubleClick =
+ (otherHandlers: EventHandlers) => (event: React.MouseEvent & MuiCancellableEvent) => {
+ otherHandlers.onDoubleClick?.(event);
+ if (event.defaultMuiPrevented) {
+ return;
+ }
+ interactions.toggleItemEditing();
+ };
+
const createContentHandleClick =
(otherHandlers: EventHandlers) => (event: React.MouseEvent & MuiCancellableEvent) => {
otherHandlers.onClick?.(event);
@@ -93,7 +127,6 @@ export const useTreeItem2 = <
if (event.defaultMuiPrevented || checkboxRef.current?.contains(event.target as HTMLElement)) {
return;
}
-
if (expansionTrigger === 'content') {
interactions.handleExpansion(event);
}
@@ -131,6 +164,35 @@ export const useTreeItem2 = <
interactions.handleCheckboxSelection(event);
};
+ const createInputHandleKeydown =
+ (otherHandlers: EventHandlers) =>
+ (event: React.KeyboardEvent & MuiCancellableEvent) => {
+ otherHandlers.onKeyDown?.(event);
+ if (event.defaultMuiPrevented) {
+ return;
+ }
+ const target = event.target as HTMLInputElement;
+
+ if (event.key === 'Enter' && target.value) {
+ interactions.handleSaveItemLabel(event, target.value);
+ } else if (event.key === 'Escape') {
+ interactions.handleCancelItemLabelEditing(event);
+ }
+ };
+
+ const createInputHandleBlur =
+ (otherHandlers: EventHandlers) =>
+ (event: React.FocusEvent & MuiCancellableEvent) => {
+ otherHandlers.onBlur?.(event);
+ if (event.defaultMuiPrevented) {
+ return;
+ }
+
+ if (event.target.value) {
+ interactions.handleSaveItemLabel(event, event.target.value);
+ }
+ };
+
const createIconContainerHandleClick =
(otherHandlers: EventHandlers) => (event: React.MouseEvent & MuiCancellableEvent) => {
otherHandlers.onClick?.(event);
@@ -167,7 +229,7 @@ export const useTreeItem2 = <
...externalEventHandlers,
ref: handleRootRef,
role: 'treeitem',
- tabIndex: instance.canItemBeTabbed(itemId) ? 0 : -1,
+ tabIndex: rootTabIndex,
id: idAttribute,
'aria-expanded': status.expandable ? status.expanded : undefined,
'aria-selected': ariaSelected,
@@ -245,11 +307,39 @@ export const useTreeItem2 = <
...extractEventHandlers(externalProps),
};
- return {
+ const props: UseTreeItem2LabelSlotProps = {
...externalEventHandlers,
children: label,
...externalProps,
+ onDoubleClick: createLabelHandleDoubleClick(externalEventHandlers),
+ };
+
+ if (instance.isTreeViewEditable) {
+ props.editable = status.editable;
+ }
+
+ return props;
+ };
+
+ const getLabelInputProps = = {}>(
+ externalProps: ExternalProps = {} as ExternalProps,
+ ): UseTreeItem2LabelInputSlotProps => {
+ const externalEventHandlers = extractEventHandlers(externalProps);
+
+ const props = {
+ ...externalEventHandlers,
+ ...externalProps,
+ onKeyDown: createInputHandleKeydown(externalEventHandlers),
+ onBlur: createInputHandleBlur(externalEventHandlers),
};
+
+ const enhancedlabelInputProps =
+ propsEnhancers.labelInput?.({ rootRefObject, contentRefObject, externalEventHandlers }) ?? {};
+
+ return {
+ ...props,
+ ...enhancedlabelInputProps,
+ } as UseTreeItem2LabelInputSlotProps;
};
const getIconContainerProps = = {}>(
@@ -313,6 +403,7 @@ export const useTreeItem2 = <
getIconContainerProps,
getCheckboxProps,
getLabelProps,
+ getLabelInputProps,
getDragAndDropOverlayProps,
rootRef: handleRootRef,
status,
diff --git a/packages/x-tree-view/src/useTreeItem2/useTreeItem2.types.ts b/packages/x-tree-view/src/useTreeItem2/useTreeItem2.types.ts
index 42173dbc4f57..e59f98ab285a 100644
--- a/packages/x-tree-view/src/useTreeItem2/useTreeItem2.types.ts
+++ b/packages/x-tree-view/src/useTreeItem2/useTreeItem2.types.ts
@@ -5,6 +5,7 @@ import { UseTreeViewSelectionSignature } from '../internals/plugins/useTreeViewS
import { UseTreeViewItemsSignature } from '../internals/plugins/useTreeViewItems';
import { UseTreeViewFocusSignature } from '../internals/plugins/useTreeViewFocus';
import { UseTreeViewKeyboardNavigationSignature } from '../internals/plugins/useTreeViewKeyboardNavigation';
+import { UseTreeViewLabelSignature } from '../internals/plugins/useTreeViewLabel';
import { UseTreeViewExpansionSignature } from '../internals/plugins/useTreeViewExpansion';
export interface UseTreeItem2Parameters {
@@ -81,11 +82,24 @@ export type UseTreeItemIconContainerSlotProps = ExternalProp
export interface UseTreeItem2LabelSlotOwnProps {
children: React.ReactNode;
+ onDoubleClick: MuiCancellableEventHandler;
+ /**
+ * Only defined when the `isItemEditable` experimental feature is enabled.
+ */
+ editable?: boolean;
}
export type UseTreeItem2LabelSlotProps = ExternalProps &
UseTreeItem2LabelSlotOwnProps;
+export type UseTreeItem2LabelInputSlotOwnProps = {
+ onBlur: MuiCancellableEventHandler>;
+ onKeyDown: MuiCancellableEventHandler>;
+};
+
+export type UseTreeItem2LabelInputSlotProps = ExternalProps &
+ UseTreeItem2LabelInputSlotOwnProps;
+
export interface UseTreeItem2CheckboxSlotOwnProps {
visible: boolean;
checked: boolean;
@@ -124,6 +138,8 @@ export interface UseTreeItem2Status {
focused: boolean;
selected: boolean;
disabled: boolean;
+ editing: boolean;
+ editable: boolean;
}
export interface UseTreeItem2ReturnValue<
@@ -132,48 +148,56 @@ export interface UseTreeItem2ReturnValue<
> {
/**
* Resolver for the root slot's props.
- * @param {ExternalProps} externalProps Additional props for the root slot
- * @returns {UseTreeItem2RootSlotProps} Props that should be spread on the root slot
+ * @param {ExternalProps} externalProps Additional props for the root slot.
+ * @returns {UseTreeItem2RootSlotProps} Props that should be spread on the root slot.
*/
getRootProps: = {}>(
externalProps?: ExternalProps,
) => UseTreeItem2RootSlotProps;
/**
* Resolver for the content slot's props.
- * @param {ExternalProps} externalProps Additional props for the content slot
- * @returns {UseTreeItem2ContentSlotProps} Props that should be spread on the content slot
+ * @param {ExternalProps} externalProps Additional props for the content slot.
+ * @returns {UseTreeItem2ContentSlotProps} Props that should be spread on the content slot.
*/
getContentProps: = {}>(
externalProps?: ExternalProps,
) => UseTreeItem2ContentSlotProps;
/**
* Resolver for the label slot's props.
- * @param {ExternalProps} externalProps Additional props for the label slot
- * @returns {UseTreeItem2LabelSlotProps} Props that should be spread on the label slot
+ * @param {ExternalProps} externalProps Additional props for the label slot.
+ * @returns {UseTreeItem2LabelSlotProps} Props that should be spread on the label slot.
*/
getLabelProps: = {}>(
externalProps?: ExternalProps,
) => UseTreeItem2LabelSlotProps;
+ /**
+ * Resolver for the labelInput slot's props.
+ * @param {ExternalProps} externalProps Additional props for the labelInput slot.
+ * @returns {UseTreeItem2LabelInputSlotProps} Props that should be spread on the labelInput slot.
+ */
+ getLabelInputProps: = {}>(
+ externalProps?: ExternalProps,
+ ) => UseTreeItem2LabelInputSlotProps;
/**
* Resolver for the checkbox slot's props.
- * @param {ExternalProps} externalProps Additional props for the checkbox slot
- * @returns {UseTreeItem2CheckboxSlotProps} Props that should be spread on the checkbox slot
+ * @param {ExternalProps} externalProps Additional props for the checkbox slot.
+ * @returns {UseTreeItem2CheckboxSlotProps} Props that should be spread on the checkbox slot.
*/
getCheckboxProps: = {}>(
externalProps?: ExternalProps,
) => UseTreeItem2CheckboxSlotProps;
/**
* Resolver for the iconContainer slot's props.
- * @param {ExternalProps} externalProps Additional props for the iconContainer slot
- * @returns {UseTreeItemIconContainerSlotProps} Props that should be spread on the iconContainer slot
+ * @param {ExternalProps} externalProps Additional props for the iconContainer slot.
+ * @returns {UseTreeItemIconContainerSlotProps} Props that should be spread on the iconContainer slot.
*/
getIconContainerProps: = {}>(
externalProps?: ExternalProps,
) => UseTreeItemIconContainerSlotProps;
/**
* Resolver for the GroupTransition slot's props.
- * @param {ExternalProps} externalProps Additional props for the GroupTransition slot
- * @returns {UseTreeItem2GroupTransitionSlotProps} Props that should be spread on the GroupTransition slot
+ * @param {ExternalProps} externalProps Additional props for the GroupTransition slot.
+ * @returns {UseTreeItem2GroupTransitionSlotProps} Props that should be spread on the GroupTransition slot.
*/
getGroupTransitionProps: = {}>(
externalProps?: ExternalProps,
@@ -181,8 +205,8 @@ export interface UseTreeItem2ReturnValue<
/**
* Resolver for the DragAndDropOverlay slot's props.
* Warning: This slot is only useful when using the `RichTreeViewPro` component.
- * @param {ExternalProps} externalProps Additional props for the DragAndDropOverlay slot
- * @returns {UseTreeItem2DragAndDropOverlaySlotProps} Props that should be spread on the DragAndDropOverlay slot
+ * @param {ExternalProps} externalProps Additional props for the DragAndDropOverlay slot.
+ * @returns {UseTreeItem2DragAndDropOverlaySlotProps} Props that should be spread on the DragAndDropOverlay slot.
*/
getDragAndDropOverlayProps: = {}>(
externalProps?: ExternalProps,
@@ -210,6 +234,7 @@ export type UseTreeItem2MinimalPlugins = readonly [
UseTreeViewItemsSignature,
UseTreeViewFocusSignature,
UseTreeViewKeyboardNavigationSignature,
+ UseTreeViewLabelSignature,
];
/**
diff --git a/scripts/x-tree-view-pro.exports.json b/scripts/x-tree-view-pro.exports.json
index 86649b37c5eb..2a6017e62600 100644
--- a/scripts/x-tree-view-pro.exports.json
+++ b/scripts/x-tree-view-pro.exports.json
@@ -67,6 +67,7 @@
{ "name": "UseTreeItem2DragAndDropOverlaySlotOwnProps", "kind": "Interface" },
{ "name": "UseTreeItem2GroupTransitionSlotOwnProps", "kind": "Interface" },
{ "name": "UseTreeItem2IconContainerSlotOwnProps", "kind": "Interface" },
+ { "name": "UseTreeItem2LabelInputSlotOwnProps", "kind": "TypeAlias" },
{ "name": "UseTreeItem2LabelSlotOwnProps", "kind": "Interface" },
{ "name": "UseTreeItem2Parameters", "kind": "Interface" },
{ "name": "UseTreeItem2ReturnValue", "kind": "Interface" },
diff --git a/scripts/x-tree-view.exports.json b/scripts/x-tree-view.exports.json
index 046c45f75a40..447ddcf3a83c 100644
--- a/scripts/x-tree-view.exports.json
+++ b/scripts/x-tree-view.exports.json
@@ -71,6 +71,7 @@
{ "name": "UseTreeItem2DragAndDropOverlaySlotOwnProps", "kind": "Interface" },
{ "name": "UseTreeItem2GroupTransitionSlotOwnProps", "kind": "Interface" },
{ "name": "UseTreeItem2IconContainerSlotOwnProps", "kind": "Interface" },
+ { "name": "UseTreeItem2LabelInputSlotOwnProps", "kind": "TypeAlias" },
{ "name": "UseTreeItem2LabelSlotOwnProps", "kind": "Interface" },
{ "name": "UseTreeItem2Parameters", "kind": "Interface" },
{ "name": "UseTreeItem2ReturnValue", "kind": "Interface" },
diff --git a/test/utils/tree-view/describeTreeView/describeTreeView.tsx b/test/utils/tree-view/describeTreeView/describeTreeView.tsx
index 8b61afb3e962..ce3368a259da 100644
--- a/test/utils/tree-view/describeTreeView/describeTreeView.tsx
+++ b/test/utils/tree-view/describeTreeView/describeTreeView.tsx
@@ -69,6 +69,9 @@ const innerDescribeTreeView =
const getItemCheckbox = (id: string) =>
getItemRoot(id).querySelector(`.${treeItemClasses.checkbox}`)!;
+ const getItemLabelInput = (id: string) =>
+ getItemRoot(id).querySelector(`.${treeItemClasses.labelInput}`)!;
+
const getItemCheckboxInput = (id: string) =>
getItemCheckbox(id).querySelector(`input`)!;
@@ -101,6 +104,7 @@ const innerDescribeTreeView =
isItemExpanded,
isItemSelected,
getSelectedTreeItems,
+ getItemLabelInput,
getItemIdTree,
};
};
diff --git a/test/utils/tree-view/describeTreeView/describeTreeView.types.ts b/test/utils/tree-view/describeTreeView/describeTreeView.types.ts
index d9e17fe48cde..b470f885df76 100644
--- a/test/utils/tree-view/describeTreeView/describeTreeView.types.ts
+++ b/test/utils/tree-view/describeTreeView/describeTreeView.types.ts
@@ -47,6 +47,12 @@ export interface DescribeTreeViewRendererUtils {
* @returns {HTMLElement} `content` slot of the item with the given id.
*/
getItemContent: (id: string) => HTMLElement;
+ /**
+ * Returns the `labelInput` slot of the item with the given id.
+ * @param {string} id The id of the item to retrieve.
+ * @returns {HTMLElement} `labelInput` slot of the item with the given id.
+ */
+ getItemLabelInput: (id: string) => HTMLInputElement;
/**
* Returns the `checkbox` slot of the item with the given id.
* @param {string} id The id of the item to retrieve.