Skip to content

Commit

Permalink
[TreeView] Add controlled API to TreeView (#18165)
Browse files Browse the repository at this point in the history
  • Loading branch information
joshwooding authored and oliviertassinari committed Nov 9, 2019
1 parent 2a165b2 commit ba1f645
Show file tree
Hide file tree
Showing 8 changed files with 248 additions and 57 deletions.
5 changes: 3 additions & 2 deletions docs/pages/api/tree-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
47 changes: 47 additions & 0 deletions docs/src/pages/components/tree-view/ControlledTreeView.js
Original file line number Diff line number Diff line change
@@ -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>
);
}
47 changes: 47 additions & 0 deletions docs/src/pages/components/tree-view/ControlledTreeView.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
6 changes: 6 additions & 0 deletions docs/src/pages/components/tree-view/tree-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}
Expand Down
33 changes: 18 additions & 15 deletions packages/material-ui-lab/src/TreeItem/TreeItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,43 +125,46 @@ const TreeItem = React.forwardRef(function TreeItem(props, ref) {
}

if (expandable) {
toggle(nodeId);
toggle(event, nodeId);
}

if (onClick) {
onClick(event);
}
};

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;
}
if (event.shift) {
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();
Expand All @@ -179,7 +182,7 @@ const TreeItem = React.forwardRef(function TreeItem(props, ref) {
if (expanded) {
focusNextNode(nodeId);
} else {
toggle();
toggle(event);
}
}
flag = true;
Expand All @@ -197,7 +200,7 @@ const TreeItem = React.forwardRef(function TreeItem(props, ref) {
break;
default:
if (isPrintableCharacter(key)) {
printableCharacter();
flag = printableCharacter(event, key);
}
}
}
Expand Down
14 changes: 9 additions & 5 deletions packages/material-ui-lab/src/TreeView/TreeView.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export interface TreeViewProps
*/
defaultEndIcon?: React.ReactNode;
/**
* Expanded node ids.
* Expanded node ids. (Uncontrolled)
*/
defaultExpanded?: string[];
/**
Expand All @@ -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';
Expand Down
100 changes: 67 additions & 33 deletions packages/material-ui-lab/src/TreeView/TreeView.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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];
Expand Down Expand Up @@ -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];

Expand All @@ -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;
Expand Down Expand Up @@ -345,7 +375,7 @@ TreeView.propTypes = {
*/
defaultEndIcon: PropTypes.node,
/**
* Expanded node ids.
* Expanded node ids. (Uncontrolled)
*/
defaultExpanded: PropTypes.arrayOf(PropTypes.string),
/**
Expand All @@ -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,
};
Expand Down
Loading

0 comments on commit ba1f645

Please sign in to comment.