Repository: mui/material-ui (TypeScript)
Date: 2020-04-20 18:22:41+00:00
Task type: Feature

Issue:
[TreeView] Expand/collapse node only when clicking on expand/collapse icon
- [x] I have searched the [issues](https://github.com/mui-org/material-ui/issues) of this repository and believe that this is not a duplicate.

## Summary 💡

When I click on a tree item that has children, the node expands, wherever I click on the line. While this behavior might be ok for most users, I would like to have the possibility to expand/collapse only when I click on the expand/collapse icon - not on the label.

## Motivation 🔦

In my project, I have a tree and I'd like to display a panel with information about a node when I click on it.
When my node does not have children, it is very easy to achieve this behavior. But when my node has children, it expends at the same time its information are displayed, which is not the behavior I want. So I lack a way to have a different behavior when I click on the label (display node info) and when I click on the expand/collapse icon (expand/collapse the node).


Issue discussion:
I have the same use case, it could be useful !
Here is a workaround.  

You need to import the [TreeViewContext](https://github.com/mui-org/material-ui/blob/master/packages/material-ui-lab/src/TreeView/TreeViewContext.js)

Prevent the label click from being [processed by the wrapped TreeItem](https://github.com/mui-org/material-ui/blob/63b76590391538a6789f76b8e29097cb7a24164c/packages/material-ui-lab/src/TreeItem/TreeItem.js#L385).

Use the [normal click handling code](https://github.com/mui-org/material-ui/blob/63b76590391538a6789f76b8e29097cb7a24164c/packages/material-ui-lab/src/TreeItem/TreeItem.js#L162) omitting the toggle expansion logic. 

```
function ExpandIconOnlyTreeItem(props:TreeItemProps){
  const {label,...other} = props;
  const context = useContext(TreeViewContext.default) as any;

  const focused = context.isFocused ? context.isFocused(props.nodeId) : false;
  const labelClicked = useCallback(
    (event:any) => {
      if (!focused) {
        context.focus(props.nodeId);
      }
  
      const multiple = context.multiSelect && (event.shiftKey || event.ctrlKey || event.metaKey);
  
      
  
      if (!context.selectionDisabled) {
        if (multiple) {
          if (event.shiftKey) {
            context.selectRange(event, { end: props.nodeId });
          } else {
            context.selectNode(event, props.nodeId, true);
          }
        } else {
          context.selectNode(event, props.nodeId);
        }
      }
      event.stopPropagation()
      if (props.onClick) {
        props.onClick(event);
      }
    }
  ,[]);
  
  return <TreeItem {...other} label={<span onClick={labelClicked}>{label}</span>}/>
}
```

@tonyhallett can you please specify how exactly you imported TreeViewContext in your workaround?

Also thanks for the commit, it'll be much more elegant to solve it by adding the iconClickExpandOnly prop to TreeView.
`const TreeViewContext = require('@material-ui/lab/esm/TreeView/TreeViewContext');`

Also as [mentioned](https://github.com/mui-org/material-ui/pull/20087#pullrequestreview-374568279) you can use expanded and onNodeToggle and check the event as I did in the pull request instead of using the private TreeViewContext.
https://github.com/mui-org/material-ui/blob/176bf440590df95cccae46768cb5c0475cc6746f/packages/material-ui-lab/src/TreeItem/TreeItem.js#L175
Could we have an option to expand when clicking the icon or the label, but collapsing only when clicking the icon?
> Could we have an option to expand when clicking the icon or the label, but collapsing only when clicking the icon?

We're probably going with a solution that enables checking in an onClick handler if the click came from the icon or label and then you can customize this behavior. Props-based approaches don't scale very well for one-off use cases.
> We're probably going with a solution that enables checking in an onClick handler if the click came from the icon or label and then you can customize this behavior. Props-based approaches don't scale very well for one-off use cases.

Fine for me. It would be great to have a mechanism to stop the propagation of the event. Like, in the onSelect event handler, a boolean that says whether the item will expand, whether it will be selected. That would give us complete flexibility, covering pretty much any desired behaviour, while keeping the props under control. 

(If that is exactly what you were planning to do... well, great :) )
@eps1lon I think I don't completely understand the approach you described.

I think you may have suggested that the fix is removing the `onClick={handleClick}` from the content and inserting `onClick = {() => handleClick('icon')}` in the icon and `onClick = {() => handleClick('label')}` in the label, and then using this as input to control the behavior.

I think you may also have suggested that the code would use the event argument on handleClick to know if the click was in the icon or in the label (maybe via `event.target`).

I'm trying to implement this feature and PR but this is my first time contributing to an open source project, so I could really use some guidance. Thanks.
I have create a React hook that can be used to get the desired behaviour, including that desired by @savissimo.

[npm useseparatetoggleclick](https://www.npmjs.com/package/useseparatetoggleclick).
[codesandbox demo](https://codesandbox.io/s/stupefied-tree-xhjxl)

@guicostaarantes - I can see where the confusion lies.  

> We're probably going with a solution that enables checking in an onClick handler if the click came from the icon or label and then you can customize this behavior.

> @eps1lon I think I don't completely understand the approach you described.

If you see the comment from @eps1lon on the pull request that I made
https://github.com/mui-org/material-ui/pull/20087#issuecomment-602176492

> I think we should rather use the approach we use with the other components: Add labelProps that are spread to the <Typography>{label}</Typography> and then you can intercept clicks that happen on the label.


By spreading props on Typography it will be possible to add an onClick handler which can be used in the same manner as my hook ( which added an onClick to the icon.  We only need the event information for all label clicks or all icon clicks ).

I will update my pull request accordingly tomorrow.  Even so, to me the hook seems to be a better solution.

I will also create another pull request tomorrow that determines if the click is from label or icon and surfaces this information to onNodeToggle as an additional argument - 'label'|'icon'|'keyboard'.

Then @eps1lon can choose to have either / both and possibly to include the hook.
[sandbox ](https://codesandbox.io/s/fragrant-cloud-erhwy) using the new onNodeToggle (#20609) to allow expansion only for icon ( or label ) click.  Hooks can be created that are similar ( and simpler ) to [npm useseparatetoggleclick](https://www.npmjs.com/package/useseparatetoggleclick).

Solution patch:
diff --git a/docs/pages/api-docs/tree-item.md b/docs/pages/api-docs/tree-item.md
--- a/docs/pages/api-docs/tree-item.md
+++ b/docs/pages/api-docs/tree-item.md
@@ -36,6 +36,8 @@ The `MuiTreeItem` name can be used for providing [default props](/customization/
 | <span class="prop-name">icon</span> | <span class="prop-type">node</span> |  | The icon to display next to the tree node's label. |
 | <span class="prop-name">label</span> | <span class="prop-type">node</span> |  | The tree node label. |
 | <span class="prop-name required">nodeId&nbsp;*</span> | <span class="prop-type">string</span> |  | The id of the node. |
+| <span class="prop-name">onIconClick</span> | <span class="prop-type">func</span> |  | `onClick` handler for the icon container. Call `event.preventDefault()` to prevent `onNodeToggle` from being called. |
+| <span class="prop-name">onLabelClick</span> | <span class="prop-type">func</span> |  | `onClick` handler for the label container. Call `event.preventDefault()` to prevent `onNodeToggle` from being called. |
 | <span class="prop-name">TransitionComponent</span> | <span class="prop-type">elementType</span> | <span class="prop-default">Collapse</span> | The component used for the transition. [Follow this guide](/components/transitions/#transitioncomponent-prop) to learn more about the requirements for this component. |
 | <span class="prop-name">TransitionProps</span> | <span class="prop-type">object</span> |  | Props applied to the [`Transition`](http://reactcommunity.org/react-transition-group/transition#Transition-props) element. |
 
diff --git a/packages/material-ui-lab/src/TreeItem/TreeItem.d.ts b/packages/material-ui-lab/src/TreeItem/TreeItem.d.ts
--- a/packages/material-ui-lab/src/TreeItem/TreeItem.d.ts
+++ b/packages/material-ui-lab/src/TreeItem/TreeItem.d.ts
@@ -20,10 +20,18 @@ export interface TreeItemProps
    * The icon to display next to the tree node's label.
    */
   icon?: React.ReactNode;
+  /**
+   * `onClick` handler for the icon container. Call `event.preventDefault()` to prevent `onNodeToggle` from being called.
+   */
+  onIconClick?: React.MouseEventHandler;
   /**
    * The tree node label.
    */
   label?: React.ReactNode;
+  /**
+   * `onClick` handler for the label container. Call `event.preventDefault()` to prevent `onNodeToggle` from being called.
+   */
+  onLabelClick?: React.MouseEventHandler;
   /**
    * The id of the node.
    */
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
@@ -92,6 +92,8 @@ const TreeItem = React.forwardRef(function TreeItem(props, ref) {
     label,
     nodeId,
     onClick,
+    onLabelClick,
+    onIconClick,
     onFocus,
     onKeyDown,
     onMouseDown,
@@ -166,7 +168,7 @@ const TreeItem = React.forwardRef(function TreeItem(props, ref) {
     const multiple = multiSelect && (event.shiftKey || event.ctrlKey || event.metaKey);
 
     // If already expanded and trying to toggle selection don't close
-    if (expandable && !(multiple && isExpanded(nodeId))) {
+    if (expandable && !event.defaultPrevented && !(multiple && isExpanded(nodeId))) {
       toggleExpansion(event, nodeId);
     }
 
@@ -386,8 +388,10 @@ const TreeItem = React.forwardRef(function TreeItem(props, ref) {
         onMouseDown={handleMouseDown}
         ref={contentRef}
       >
-        <div className={classes.iconContainer}>{icon}</div>
-        <Typography component="div" className={classes.label}>
+        <div onClick={onIconClick} className={classes.iconContainer}>
+          {icon}
+        </div>
+        <Typography onClick={onLabelClick} component="div" className={classes.label}>
           {label}
         </Typography>
       </div>
@@ -457,10 +461,18 @@ TreeItem.propTypes = {
    * @ignore
    */
   onFocus: PropTypes.func,
+  /**
+   * `onClick` handler for the icon container. Call `event.preventDefault()` to prevent `onNodeToggle` from being called.
+   */
+  onIconClick: PropTypes.func,
   /**
    * @ignore
    */
   onKeyDown: PropTypes.func,
+  /**
+   * `onClick` handler for the label container. Call `event.preventDefault()` to prevent `onNodeToggle` from being called.
+   */
+  onLabelClick: PropTypes.func,
   /**
    * @ignore
    */
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
@@ -219,10 +219,12 @@ const TreeView = React.forwardRef(function TreeView(props, ref) {
     }
     const newExpanded = [...expanded, ...diff];
 
-    setExpandedState(newExpanded);
+    if (diff.length > 0) {
+      setExpandedState(newExpanded);
 
-    if (onNodeToggle) {
-      onNodeToggle(event, newExpanded);
+      if (onNodeToggle) {
+        onNodeToggle(event, newExpanded);
+      }
     }
   };
 


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
@@ -196,7 +196,7 @@ describe('<TreeView />', () => {
   });
 
   describe('onNodeToggle', () => {
-    it('should be called when a parent node is clicked', () => {
+    it('should be called when a parent node label is clicked', () => {
       const handleNodeToggle = spy();
 
       const { getByText } = render(
@@ -212,6 +212,60 @@ describe('<TreeView />', () => {
       expect(handleNodeToggle.callCount).to.equal(1);
       expect(handleNodeToggle.args[0][1]).to.deep.equal(['1']);
     });
+
+    it('should not be called when a parent node label is clicked and onLabelClick preventDefault', () => {
+      const handleNodeToggle = spy();
+
+      const { getByText } = render(
+        <TreeView onNodeToggle={handleNodeToggle}>
+          <TreeItem onLabelClick={(event) => event.preventDefault()} nodeId="1" label="outer">
+            <TreeItem nodeId="2" label="inner" />
+          </TreeItem>
+        </TreeView>,
+      );
+
+      fireEvent.click(getByText('outer'));
+
+      expect(handleNodeToggle.callCount).to.equal(0);
+    });
+
+    it('should be called when a parent node icon is clicked', () => {
+      const handleNodeToggle = spy();
+
+      const { getByTestId } = render(
+        <TreeView onNodeToggle={handleNodeToggle}>
+          <TreeItem icon={<div data-testid="icon" />} nodeId="1" label="outer">
+            <TreeItem nodeId="2" label="inner" />
+          </TreeItem>
+        </TreeView>,
+      );
+
+      fireEvent.click(getByTestId('icon'));
+
+      expect(handleNodeToggle.callCount).to.equal(1);
+      expect(handleNodeToggle.args[0][1]).to.deep.equal(['1']);
+    });
+
+    it('should not be called when a parent node icon is clicked and onIconClick preventDefault', () => {
+      const handleNodeToggle = spy();
+
+      const { getByTestId } = render(
+        <TreeView onNodeToggle={handleNodeToggle}>
+          <TreeItem
+            onIconClick={(event) => event.preventDefault()}
+            icon={<div data-testid="icon" />}
+            nodeId="1"
+            label="outer"
+          >
+            <TreeItem nodeId="2" label="inner" />
+          </TreeItem>
+        </TreeView>,
+      );
+
+      fireEvent.click(getByTestId('icon'));
+
+      expect(handleNodeToggle.callCount).to.equal(0);
+    });
   });
 
   describe('Accessibility', () => {