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']); }); });