Repository: mui/material-ui (TypeScript)
Date: 2019-11-04 00:01:20+00:00
Task type: Feature

Issue:
TreeView - Expand TreeItem manually
I'm wondering if there is a way to open TreeItem manually? If I have a node "Add item" with a child node "Items", is there a way to expand "Items" once a new item is added?

What is the recommended way of achieving what I want?

Here is a [Codesandbox](https://codesandbox.io/s/wizardly-dirac-73oll?fontsize=14) that describe the functionality I'm looking for.


Issue discussion:
@pimeh @flurmbo What do you think of allowing the *expanded* state to be controlled?
Right now, we have a `defaultExpanded` prop that supports the uncontrollable approach but nothing for the controlled way. A new `expanded`, and `onExpanded` props could solve this problem. 
@oliviertassinari I think that's much better. When I made my fix I wasn't sure how the API for manual control would look like for the TreeItem, but after looking at the Tooltip component I think it can be done analogously to that component's `open`, `onOpen`, `onClose` props
Cool, in our case, I hope that a two props are enough. I would imagine `onExpand`, but maybe we can find a better name 
> Callback function for when a tree item is expanded or collapsed
I think that would work if the callback is called with a boolean parameter indicating the collapsed/expanded state of the TreeItem. Wouldn't the parent component need that value to manually update the state of the `expanded` prop? But otherwise sounds good, you can call it `onToggleExpand` or something!
@flurmbo for the signature of the callback method, we can use the same as `onChange` in https://material-ui.com/api/expansion-panel/. The first argument would be the event, the second argument would be the whole expanded state. Maybe we can imagine a third argument to signal the node id that changed. My proposal is from a TreeView perspective. 

We might need something close for the TreeItem. 

Solution patch:
diff --git a/docs/pages/api/tree-view.md b/docs/pages/api/tree-view.md
--- 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
 | <span class="prop-name">classes</span> | <span class="prop-type">object</span> |  | Override or extend the styles applied to the component. See [CSS API](#css) below for more details. |
 | <span class="prop-name">defaultCollapseIcon</span> | <span class="prop-type">node</span> |  | The default icon used to collapse the node. |
 | <span class="prop-name">defaultEndIcon</span> | <span class="prop-type">node</span> |  | 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. |
-| <span class="prop-name">defaultExpanded</span> | <span class="prop-type">Array<string></span> | <span class="prop-default">[]</span> | Expanded node ids. |
+| <span class="prop-name">defaultExpanded</span> | <span class="prop-type">Array<string></span> | <span class="prop-default">[]</span> | Expanded node ids. (Uncontrolled) |
 | <span class="prop-name">defaultExpandIcon</span> | <span class="prop-type">node</span> |  | The default icon used to expand the node. |
 | <span class="prop-name">defaultParentIcon</span> | <span class="prop-type">node</span> |  | 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. |
-| <span class="prop-name">onNodeToggle</span> | <span class="prop-type">func</span> |  | Callback fired when a `TreeItem` is expanded/collapsed.<br><br>**Signature:**<br>`function(nodeId: string, expanded: boolean) => void`<br>*nodeId:* The id of the toggled node.<br>*expanded:* The node status - If `true` the node was expanded. If `false` the node was collapsed. |
+| <span class="prop-name">expanded</span> | <span class="prop-type">Array<string></span> |  | Expanded node ids. (Controlled) |
+| <span class="prop-name">onNodeToggle</span> | <span class="prop-type">func</span> |  | Callback fired when tree items are expanded/collapsed.<br><br>**Signature:**<br>`function(event: object, nodeIds: array) => void`<br>*event:* The event source of the callback<br>*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
--- /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 (
+    <TreeView
+      className={classes.root}
+      defaultCollapseIcon={<ExpandMoreIcon />}
+      defaultExpandIcon={<ChevronRightIcon />}
+      expanded={expanded}
+      onNodeToggle={handleChange}
+    >
+      <TreeItem nodeId="1" label="Applications">
+        <TreeItem nodeId="2" label="Calendar" />
+        <TreeItem nodeId="3" label="Chrome" />
+        <TreeItem nodeId="4" label="Webstorm" />
+      </TreeItem>
+      <TreeItem nodeId="5" label="Documents">
+        <TreeItem nodeId="6" label="Material-UI">
+          <TreeItem nodeId="7" label="src">
+            <TreeItem nodeId="8" label="index.js" />
+            <TreeItem nodeId="9" label="tree-view.js" />
+          </TreeItem>
+        </TreeItem>
+      </TreeItem>
+    </TreeView>
+  );
+}
diff --git a/docs/src/pages/components/tree-view/ControlledTreeView.tsx b/docs/src/pages/components/tree-view/ControlledTreeView.tsx
new file mode 100644
--- /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<string[]>([]);
+
+  const handleChange = (event: React.ChangeEvent<{}>, nodes: string[]) => {
+    setExpanded(nodes);
+  };
+
+  return (
+    <TreeView
+      className={classes.root}
+      defaultCollapseIcon={<ExpandMoreIcon />}
+      defaultExpandIcon={<ChevronRightIcon />}
+      expanded={expanded}
+      onNodeToggle={handleChange}
+    >
+      <TreeItem nodeId="1" label="Applications">
+        <TreeItem nodeId="2" label="Calendar" />
+        <TreeItem nodeId="3" label="Chrome" />
+        <TreeItem nodeId="4" label="Webstorm" />
+      </TreeItem>
+      <TreeItem nodeId="5" label="Documents">
+        <TreeItem nodeId="6" label="Material-UI">
+          <TreeItem nodeId="7" label="src">
+            <TreeItem nodeId="8" label="index.js" />
+            <TreeItem nodeId="9" label="tree-view.js" />
+          </TreeItem>
+        </TreeItem>
+      </TreeItem>
+    </TreeView>
+  );
+}
diff --git a/docs/src/pages/components/tree-view/tree-view.md b/docs/src/pages/components/tree-view/tree-view.md
--- 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
--- 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
--- 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
--- 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,
 };


Test patch:
diff --git a/packages/material-ui-lab/src/TreeView/TreeView.test.js b/packages/material-ui-lab/src/TreeView/TreeView.test.js
--- 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('<TreeView />', () => {
     after: () => mount.cleanUp(),
   }));
 
+  describe('warnings', () => {
+    beforeEach(() => {
+      consoleErrorMock.spy();
+    });
+
+    afterEach(() => {
+      consoleErrorMock.reset();
+    });
+
+    it('should warn when switching from controlled to uncontrolled', () => {
+      const { setProps } = render(
+        <TreeView expanded={[]}>
+          <TreeItem nodeId="1" label="one" />
+        </TreeView>,
+      );
+
+      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 (
+        <TreeView expanded={expandedState} onNodeToggle={handleNodeToggle}>
+          <TreeItem nodeId="1" label="one" data-testid="one">
+            <TreeItem nodeId="2" label="two" />
+          </TreeItem>
+        </TreeView>
+      );
+    }
+
+    const { getByTestId, getByText } = render(<MyComponent />);
+
+    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('<TreeView />', () => {
       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']);
     });
   });