diff --git a/docs/pages/api/tree-view.md b/docs/pages/api/tree-view.md
index 9953f3a9867eb6..21798b7208fcfc 100644
--- a/docs/pages/api/tree-view.md
+++ b/docs/pages/api/tree-view.md
@@ -28,10 +28,11 @@ You can learn more about the difference by [reading this guide](/guides/minimizi
| classes | object | | Override or extend the styles applied to the component. See [CSS API](#css) below for more details. |
| defaultCollapseIcon | node | | The default icon used to collapse the node. |
| defaultEndIcon | node | | The default icon displayed next to a end node. This is applied to all tree nodes and can be overridden by the TreeItem `icon` prop. |
-| defaultExpanded | Array | [] | Expanded node ids. |
+| defaultExpanded | Array | [] | Expanded node ids. (Uncontrolled) |
| defaultExpandIcon | node | | The default icon used to expand the node. |
| defaultParentIcon | node | | The default icon displayed next to a parent node. This is applied to all parent nodes and can be overridden by the TreeItem `icon` prop. |
-| onNodeToggle | func | | Callback fired when a `TreeItem` is expanded/collapsed.
**Signature:**
`function(nodeId: string, expanded: boolean) => void`
*nodeId:* The id of the toggled node.
*expanded:* The node status - If `true` the node was expanded. If `false` the node was collapsed. |
+| expanded | Array | | Expanded node ids. (Controlled) |
+| onNodeToggle | func | | Callback fired when tree items are expanded/collapsed.
**Signature:**
`function(event: object, nodeIds: array) => void`
*event:* The event source of the callback
*nodeIds:* The ids of the expanded nodes. |
The `ref` is forwarded to the root element.
diff --git a/docs/src/pages/components/tree-view/ControlledTreeView.js b/docs/src/pages/components/tree-view/ControlledTreeView.js
new file mode 100644
index 00000000000000..7c4f262724516e
--- /dev/null
+++ b/docs/src/pages/components/tree-view/ControlledTreeView.js
@@ -0,0 +1,47 @@
+import React from 'react';
+import { makeStyles } from '@material-ui/core/styles';
+import TreeView from '@material-ui/lab/TreeView';
+import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
+import ChevronRightIcon from '@material-ui/icons/ChevronRight';
+import TreeItem from '@material-ui/lab/TreeItem';
+
+const useStyles = makeStyles({
+ root: {
+ height: 216,
+ flexGrow: 1,
+ maxWidth: 400,
+ },
+});
+
+export default function ControlledTreeView() {
+ const classes = useStyles();
+ const [expanded, setExpanded] = React.useState([]);
+
+ const handleChange = (event, nodes) => {
+ setExpanded(nodes);
+ };
+
+ return (
+ }
+ defaultExpandIcon={}
+ expanded={expanded}
+ onNodeToggle={handleChange}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/docs/src/pages/components/tree-view/ControlledTreeView.tsx b/docs/src/pages/components/tree-view/ControlledTreeView.tsx
new file mode 100644
index 00000000000000..b928cb60fe5c7f
--- /dev/null
+++ b/docs/src/pages/components/tree-view/ControlledTreeView.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { makeStyles } from '@material-ui/core/styles';
+import TreeView from '@material-ui/lab/TreeView';
+import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
+import ChevronRightIcon from '@material-ui/icons/ChevronRight';
+import TreeItem from '@material-ui/lab/TreeItem';
+
+const useStyles = makeStyles({
+ root: {
+ height: 216,
+ flexGrow: 1,
+ maxWidth: 400,
+ },
+});
+
+export default function ControlledTreeView() {
+ const classes = useStyles();
+ const [expanded, setExpanded] = React.useState([]);
+
+ const handleChange = (event: React.ChangeEvent<{}>, nodes: string[]) => {
+ setExpanded(nodes);
+ };
+
+ return (
+ }
+ defaultExpandIcon={}
+ expanded={expanded}
+ onNodeToggle={handleChange}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/docs/src/pages/components/tree-view/tree-view.md b/docs/src/pages/components/tree-view/tree-view.md
index 61cda16bda0c67..82caa75c465465 100644
--- a/docs/src/pages/components/tree-view/tree-view.md
+++ b/docs/src/pages/components/tree-view/tree-view.md
@@ -17,6 +17,12 @@ Tree views can be used to represent a file system navigator displaying folders a
{{"demo": "pages/components/tree-view/CustomizedTreeView.js"}}
+### Controlled
+
+The tree view also offers a controlled API.
+
+{{"demo": "pages/components/tree-view/ControlledTreeView.js"}}
+
### Gmail clone
{{"demo": "pages/components/tree-view/GmailTreeView.js"}}
diff --git a/packages/material-ui-lab/src/TreeItem/TreeItem.js b/packages/material-ui-lab/src/TreeItem/TreeItem.js
index baf7b5fd980fce..ae7be28aeadca7 100644
--- a/packages/material-ui-lab/src/TreeItem/TreeItem.js
+++ b/packages/material-ui-lab/src/TreeItem/TreeItem.js
@@ -125,7 +125,7 @@ const TreeItem = React.forwardRef(function TreeItem(props, ref) {
}
if (expandable) {
- toggle(nodeId);
+ toggle(event, nodeId);
}
if (onClick) {
@@ -133,20 +133,23 @@ const TreeItem = React.forwardRef(function TreeItem(props, ref) {
}
};
+ const printableCharacter = (event, key) => {
+ if (key === '*') {
+ expandAllSiblings(event, nodeId);
+ return true;
+ }
+
+ if (isPrintableCharacter(key)) {
+ setFocusByFirstCharacter(nodeId, key);
+ return true;
+ }
+ return false;
+ };
+
const handleKeyDown = event => {
let flag = false;
const key = event.key;
- const printableCharacter = () => {
- if (key === '*') {
- expandAllSiblings(nodeId);
- flag = true;
- } else if (isPrintableCharacter(key)) {
- setFocusByFirstCharacter(nodeId, key);
- flag = true;
- }
- };
-
if (event.altKey || event.ctrlKey || event.metaKey) {
return;
}
@@ -154,14 +157,14 @@ const TreeItem = React.forwardRef(function TreeItem(props, ref) {
if (key === ' ' || key === 'Enter') {
event.stopPropagation();
} else if (isPrintableCharacter(key)) {
- printableCharacter();
+ flag = printableCharacter(event, key);
}
} else {
switch (key) {
case 'Enter':
case ' ':
if (nodeRef.current === event.currentTarget && expandable) {
- toggle();
+ toggle(event);
flag = true;
}
event.stopPropagation();
@@ -179,7 +182,7 @@ const TreeItem = React.forwardRef(function TreeItem(props, ref) {
if (expanded) {
focusNextNode(nodeId);
} else {
- toggle();
+ toggle(event);
}
}
flag = true;
@@ -197,7 +200,7 @@ const TreeItem = React.forwardRef(function TreeItem(props, ref) {
break;
default:
if (isPrintableCharacter(key)) {
- printableCharacter();
+ flag = printableCharacter(event, key);
}
}
}
diff --git a/packages/material-ui-lab/src/TreeView/TreeView.d.ts b/packages/material-ui-lab/src/TreeView/TreeView.d.ts
index 580001425b0036..8c1bf405624ace 100644
--- a/packages/material-ui-lab/src/TreeView/TreeView.d.ts
+++ b/packages/material-ui-lab/src/TreeView/TreeView.d.ts
@@ -13,7 +13,7 @@ export interface TreeViewProps
*/
defaultEndIcon?: React.ReactNode;
/**
- * Expanded node ids.
+ * Expanded node ids. (Uncontrolled)
*/
defaultExpanded?: string[];
/**
@@ -26,12 +26,16 @@ export interface TreeViewProps
*/
defaultParentIcon?: React.ReactNode;
/**
- * Callback fired when a `TreeItem` is expanded/collapsed.
+ * Expanded node ids. (Controlled)
+ */
+ expanded?: string[];
+ /**
+ * Callback fired when tree items are expanded/collapsed.
*
- * @param {string} nodeId The id of the toggled node.
- * @param {boolean} expanded The node status - If `true` the node was expanded. If `false` the node was collapsed.
+ * @param {object} event The event source of the callback
+ * @param {array} nodeIds The ids of the expanded nodes.
*/
- onNodeToggle?: (nodeId: string, expanded: boolean) => void;
+ onNodeToggle?: (event: React.ChangeEvent<{}>, nodeIds: string[]) => void;
}
export type TreeViewClassKey = 'root';
diff --git a/packages/material-ui-lab/src/TreeView/TreeView.js b/packages/material-ui-lab/src/TreeView/TreeView.js
index 286224cc78726b..a4d4a4b797ec29 100644
--- a/packages/material-ui-lab/src/TreeView/TreeView.js
+++ b/packages/material-ui-lab/src/TreeView/TreeView.js
@@ -35,20 +35,39 @@ const TreeView = React.forwardRef(function TreeView(props, ref) {
defaultExpanded = defaultExpandedDefault,
defaultExpandIcon,
defaultParentIcon,
+ expanded: expandedProp,
onNodeToggle,
...other
} = props;
- const [expanded, setExpanded] = React.useState(defaultExpanded);
+ const [expandedState, setExpandedState] = React.useState(defaultExpanded);
const [tabable, setTabable] = React.useState(null);
const [focused, setFocused] = React.useState(null);
- const firstNode = React.useRef(null);
+ const firstNode = React.useRef(null);
const nodeMap = React.useRef({});
const firstCharMap = React.useRef({});
- const isExpanded = React.useCallback(id => expanded.indexOf(id) !== -1, [expanded]);
- const isTabable = id => tabable === id;
- const isFocused = id => focused === id;
+ const { current: isControlled } = React.useRef(expandedProp !== undefined);
+ const expanded = (isControlled ? expandedProp : expandedState) || [];
+
+ if (process.env.NODE_ENV !== 'production') {
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ React.useEffect(() => {
+ if (isControlled !== (expandedProp != null)) {
+ console.error(
+ [
+ `Material-UI: A component is changing ${
+ isControlled ? 'a ' : 'an un'
+ }controlled TreeView to be ${isControlled ? 'un' : ''}controlled.`,
+ 'Elements should not switch from uncontrolled to controlled (or vice versa).',
+ 'Decide between using a controlled or uncontrolled Select ' +
+ 'element for the lifetime of the component.',
+ 'More info: https://fb.me/react-controlled-components',
+ ].join('\n'),
+ );
+ }
+ }, [expandedProp, isControlled]);
+ }
const prevChildIds = React.useRef([]);
React.useEffect(() => {
@@ -67,6 +86,10 @@ const TreeView = React.forwardRef(function TreeView(props, ref) {
}
}, [children]);
+ const isExpanded = React.useCallback(id => expanded.indexOf(id) !== -1, [expanded]);
+ const isTabable = id => tabable === id;
+ const isFocused = id => focused === id;
+
const getLastNode = React.useCallback(
id => {
const map = nodeMap.current[id];
@@ -156,32 +179,31 @@ const TreeView = React.forwardRef(function TreeView(props, ref) {
focus(lastNode);
};
- const toggle = (value = focused) => {
- setExpanded(prevExpanded => {
- let newExpanded;
-
- if (prevExpanded.indexOf(value) !== -1) {
- newExpanded = prevExpanded.filter(id => id !== value);
- setTabable(oldTabable => {
- const map = nodeMap.current[oldTabable];
- if (oldTabable && (map && map.parent ? map.parent.id : null) === value) {
- return value;
- }
- return oldTabable;
- });
- } else {
- newExpanded = [value, ...prevExpanded];
- }
+ const toggle = (event, value = focused) => {
+ let newExpanded;
+ if (expanded.indexOf(value) !== -1) {
+ newExpanded = expanded.filter(id => id !== value);
+ setTabable(oldTabable => {
+ const map = nodeMap.current[oldTabable];
+ if (oldTabable && (map && map.parent ? map.parent.id : null) === value) {
+ return value;
+ }
+ return oldTabable;
+ });
+ } else {
+ newExpanded = [value, ...expanded];
+ }
- if (onNodeToggle) {
- onNodeToggle(value, newExpanded.indexOf(value) !== -1);
- }
+ if (onNodeToggle) {
+ onNodeToggle(event, newExpanded);
+ }
- return newExpanded;
- });
+ if (!isControlled) {
+ setExpandedState(newExpanded);
+ }
};
- const expandAllSiblings = id => {
+ const expandAllSiblings = (event, id) => {
const map = nodeMap.current[id];
const parent = nodeMap.current[map.parent];
@@ -192,13 +214,21 @@ const TreeView = React.forwardRef(function TreeView(props, ref) {
const topLevelNodes = nodeMap.current[-1].children;
diff = topLevelNodes.filter(node => !isExpanded(node));
}
- setExpanded(oldExpanded => [...oldExpanded, ...diff]);
+ const newExpanded = [...expanded, ...diff];
+
+ if (!isControlled) {
+ setExpandedState(newExpanded);
+ }
+
+ if (onNodeToggle) {
+ onNodeToggle(event, newExpanded);
+ }
};
const handleLeftArrow = (id, event) => {
let flag = false;
if (isExpanded(id)) {
- toggle(id);
+ toggle(event, id);
flag = true;
} else {
const parent = nodeMap.current[id].parent;
@@ -345,7 +375,7 @@ TreeView.propTypes = {
*/
defaultEndIcon: PropTypes.node,
/**
- * Expanded node ids.
+ * Expanded node ids. (Uncontrolled)
*/
defaultExpanded: PropTypes.arrayOf(PropTypes.string),
/**
@@ -358,10 +388,14 @@ TreeView.propTypes = {
*/
defaultParentIcon: PropTypes.node,
/**
- * Callback fired when a `TreeItem` is expanded/collapsed.
+ * Expanded node ids. (Controlled)
+ */
+ expanded: PropTypes.arrayOf(PropTypes.string),
+ /**
+ * Callback fired when tree items are expanded/collapsed.
*
- * @param {string} nodeId The id of the toggled node.
- * @param {boolean} expanded The node status - If `true` the node was expanded. If `false` the node was collapsed.
+ * @param {object} event The event source of the callback
+ * @param {array} nodeIds The ids of the expanded nodes.
*/
onNodeToggle: PropTypes.func,
};
diff --git a/packages/material-ui-lab/src/TreeView/TreeView.test.js b/packages/material-ui-lab/src/TreeView/TreeView.test.js
index e788328ad98b32..c3ee321a504ac7 100644
--- a/packages/material-ui-lab/src/TreeView/TreeView.test.js
+++ b/packages/material-ui-lab/src/TreeView/TreeView.test.js
@@ -4,6 +4,7 @@ import { spy } from 'sinon';
import { createClientRender, fireEvent } from 'test/utils/createClientRender';
import describeConformance from '@material-ui/core/test-utils/describeConformance';
import { createMount, getClasses } from '@material-ui/core/test-utils';
+import consoleErrorMock from 'test/utils/consoleErrorMock';
import TreeView from './TreeView';
import TreeItem from '../TreeItem';
@@ -27,6 +28,55 @@ describe('', () => {
after: () => mount.cleanUp(),
}));
+ describe('warnings', () => {
+ beforeEach(() => {
+ consoleErrorMock.spy();
+ });
+
+ afterEach(() => {
+ consoleErrorMock.reset();
+ });
+
+ it('should warn when switching from controlled to uncontrolled', () => {
+ const { setProps } = render(
+
+
+ ,
+ );
+
+ setProps({ expanded: undefined });
+ expect(consoleErrorMock.args()[0][0]).to.include(
+ 'A component is changing a controlled TreeView to be uncontrolled.',
+ );
+ });
+ });
+
+ it('should be able to be controlled', () => {
+ function MyComponent() {
+ const [expandedState, setExpandedState] = React.useState([]);
+ const handleNodeToggle = (event, nodes) => {
+ setExpandedState(nodes);
+ };
+ return (
+
+
+
+
+
+ );
+ }
+
+ const { getByTestId, getByText } = render();
+
+ expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false');
+ fireEvent.click(getByText('one'));
+ expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
+ fireEvent.click(getByText('one'));
+ expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false');
+ fireEvent.keyDown(document.activeElement, { key: '*' });
+ expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
+ });
+
it('should not error when component state changes', () => {
function MyComponent() {
const [, setState] = React.useState(1);
@@ -74,8 +124,7 @@ describe('', () => {
fireEvent.click(getByText('outer'));
expect(handleNodeToggle.callCount).to.equal(1);
- expect(handleNodeToggle.args[0][0]).to.equal('1');
- expect(handleNodeToggle.args[0][1]).to.equal(true);
+ expect(handleNodeToggle.args[0][1]).to.deep.equal(['1']);
});
});