diff --git a/docs/pages/api/tree-item.md b/docs/pages/api/tree-item.md index 5240fb00e650e1..325ed695b2032f 100644 --- a/docs/pages/api/tree-item.md +++ b/docs/pages/api/tree-item.md @@ -48,6 +48,7 @@ Any other props supplied will be provided to the root element (native element). |:-----|:-------------|:------------| | root | .MuiTreeItem-root | Styles applied to the root element. | expanded | .Mui-expanded | Pseudo-class applied to the root element when expanded. +| selected | .Mui-selected | Pseudo-class applied to the root element when selected. | group | .MuiTreeItem-group | Styles applied to the `role="group"` element. | content | .MuiTreeItem-content | Styles applied to the tree node content. | iconContainer | .MuiTreeItem-iconContainer | Styles applied to the tree node icon and collapse/expand icon. diff --git a/docs/pages/api/tree-view.md b/docs/pages/api/tree-view.md index 056f48ce199866..26680e298d4a91 100644 --- a/docs/pages/api/tree-view.md +++ b/docs/pages/api/tree-view.md @@ -31,8 +31,13 @@ You can learn more about the difference by [reading this guide](/guides/minimizi | defaultExpanded | Array<string> | [] | 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. | +| defaultSelected | Array<string>
| string
| [] | Selected node ids. (Uncontrolled) When `multiSelect` is true this takes an array of strings; when false (default) a string. | +| disableSelection | bool | false | If `true` selection is disabled. | | expanded | Array<string> | | Expanded node ids. (Controlled) | +| multiSelect | bool | false | If true `ctrl` and `shift` will trigger multiselect. | +| onNodeSelect | func | | Callback fired when tree items are selected/unselected.

**Signature:**
`function(event: object, value: undefined) => void`
*event:* The event source of the callback
*value:* of the selected nodes. When `multiSelect` is true this is an array of strings; when false (default) a string. | | 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. | +| selected | Array<string>
| string
| | Selected node ids. (Controlled) When `multiSelect` is true this takes an array of strings; when false (default) a string. | 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 index 7c4f262724516e..b83820173de086 100644 --- a/docs/src/pages/components/tree-view/ControlledTreeView.js +++ b/docs/src/pages/components/tree-view/ControlledTreeView.js @@ -16,9 +16,14 @@ const useStyles = makeStyles({ export default function ControlledTreeView() { const classes = useStyles(); const [expanded, setExpanded] = React.useState([]); + const [selected, setSelected] = React.useState([]); - const handleChange = (event, nodes) => { - setExpanded(nodes); + const handleToggle = (event, nodeIds) => { + setExpanded(nodeIds); + }; + + const handleSelect = (event, nodeIds) => { + setSelected(nodeIds); }; return ( @@ -27,7 +32,9 @@ export default function ControlledTreeView() { defaultCollapseIcon={} defaultExpandIcon={} expanded={expanded} - onNodeToggle={handleChange} + selected={selected} + onNodeToggle={handleToggle} + onNodeSelect={handleSelect} > diff --git a/docs/src/pages/components/tree-view/ControlledTreeView.tsx b/docs/src/pages/components/tree-view/ControlledTreeView.tsx index b928cb60fe5c7f..461e74a89b5148 100644 --- a/docs/src/pages/components/tree-view/ControlledTreeView.tsx +++ b/docs/src/pages/components/tree-view/ControlledTreeView.tsx @@ -16,9 +16,14 @@ const useStyles = makeStyles({ export default function ControlledTreeView() { const classes = useStyles(); const [expanded, setExpanded] = React.useState([]); + const [selected, setSelected] = React.useState([]); - const handleChange = (event: React.ChangeEvent<{}>, nodes: string[]) => { - setExpanded(nodes); + const handleToggle = (event: React.ChangeEvent<{}>, nodeIds: string[]) => { + setExpanded(nodeIds); + }; + + const handleSelect = (event: React.ChangeEvent<{}>, nodeIds: string[]) => { + setSelected(nodeIds); }; return ( @@ -27,7 +32,9 @@ export default function ControlledTreeView() { defaultCollapseIcon={} defaultExpandIcon={} expanded={expanded} - onNodeToggle={handleChange} + selected={selected} + onNodeToggle={handleToggle} + onNodeSelect={handleSelect} > diff --git a/docs/src/pages/components/tree-view/CustomizedTreeView.js b/docs/src/pages/components/tree-view/CustomizedTreeView.js index cc933bbe824a27..82c5a6a7fdb389 100644 --- a/docs/src/pages/components/tree-view/CustomizedTreeView.js +++ b/docs/src/pages/components/tree-view/CustomizedTreeView.js @@ -9,7 +9,7 @@ import { useSpring, animated } from 'react-spring/web.cjs'; // web.cjs is requir function MinusSquare(props) { return ( - + {/* tslint:disable-next-line: max-line-length */} @@ -18,7 +18,7 @@ function MinusSquare(props) { function PlusSquare(props) { return ( - + {/* tslint:disable-next-line: max-line-length */} @@ -27,7 +27,7 @@ function PlusSquare(props) { function CloseSquare(props) { return ( - + {/* tslint:disable-next-line: max-line-length */} @@ -61,8 +61,8 @@ const StyledTreeItem = withStyles(theme => ({ }, }, group: { - marginLeft: 12, - paddingLeft: 12, + marginLeft: 7, + paddingLeft: 18, borderLeft: `1px dashed ${fade(theme.palette.text.primary, 0.4)}`, }, }))(props => ); diff --git a/docs/src/pages/components/tree-view/CustomizedTreeView.tsx b/docs/src/pages/components/tree-view/CustomizedTreeView.tsx index e828c971bcef25..839eb971e70172 100644 --- a/docs/src/pages/components/tree-view/CustomizedTreeView.tsx +++ b/docs/src/pages/components/tree-view/CustomizedTreeView.tsx @@ -9,7 +9,7 @@ import { TransitionProps } from '@material-ui/core/transitions'; function MinusSquare(props: SvgIconProps) { return ( - + {/* tslint:disable-next-line: max-line-length */} @@ -18,7 +18,7 @@ function MinusSquare(props: SvgIconProps) { function PlusSquare(props: SvgIconProps) { return ( - + {/* tslint:disable-next-line: max-line-length */} @@ -27,7 +27,7 @@ function PlusSquare(props: SvgIconProps) { function CloseSquare(props: SvgIconProps) { return ( - + {/* tslint:disable-next-line: max-line-length */} @@ -55,8 +55,8 @@ const StyledTreeItem = withStyles((theme: Theme) => }, }, group: { - marginLeft: 12, - paddingLeft: 12, + marginLeft: 7, + paddingLeft: 18, borderLeft: `1px dashed ${fade(theme.palette.text.primary, 0.4)}`, }, }), diff --git a/docs/src/pages/components/tree-view/FileSystemNavigator.js b/docs/src/pages/components/tree-view/FileSystemNavigator.js index b1bd7452a84fcf..ea87bcf3197333 100644 --- a/docs/src/pages/components/tree-view/FileSystemNavigator.js +++ b/docs/src/pages/components/tree-view/FileSystemNavigator.js @@ -7,7 +7,7 @@ import TreeItem from '@material-ui/lab/TreeItem'; const useStyles = makeStyles({ root: { - height: 216, + height: 240, flexGrow: 1, maxWidth: 400, }, @@ -28,6 +28,7 @@ export default function FileSystemNavigator() { + diff --git a/docs/src/pages/components/tree-view/FileSystemNavigator.tsx b/docs/src/pages/components/tree-view/FileSystemNavigator.tsx index b1bd7452a84fcf..ea87bcf3197333 100644 --- a/docs/src/pages/components/tree-view/FileSystemNavigator.tsx +++ b/docs/src/pages/components/tree-view/FileSystemNavigator.tsx @@ -7,7 +7,7 @@ import TreeItem from '@material-ui/lab/TreeItem'; const useStyles = makeStyles({ root: { - height: 216, + height: 240, flexGrow: 1, maxWidth: 400, }, @@ -28,6 +28,7 @@ export default function FileSystemNavigator() { + diff --git a/docs/src/pages/components/tree-view/GmailTreeView.js b/docs/src/pages/components/tree-view/GmailTreeView.js index 2fb09dcf808593..219cd86df796b9 100644 --- a/docs/src/pages/components/tree-view/GmailTreeView.js +++ b/docs/src/pages/components/tree-view/GmailTreeView.js @@ -17,10 +17,16 @@ import ArrowRightIcon from '@material-ui/icons/ArrowRight'; const useTreeItemStyles = makeStyles(theme => ({ root: { color: theme.palette.text.secondary, - '&:focus > $content': { + '&:hover > $content': { + backgroundColor: theme.palette.action.hover, + }, + '&:focus > $content, &$selected > $content': { backgroundColor: `var(--tree-view-bg-color, ${theme.palette.grey[400]})`, color: 'var(--tree-view-color)', }, + '&:focus > $content $label, &:hover > $content $label, &$selected > $content $label': { + backgroundColor: 'transparent', + }, }, content: { color: theme.palette.text.secondary, @@ -39,6 +45,7 @@ const useTreeItemStyles = makeStyles(theme => ({ }, }, expanded: {}, + selected: {}, label: { fontWeight: 'inherit', color: 'inherit', @@ -82,6 +89,7 @@ function StyledTreeItem(props) { root: classes.root, content: classes.content, expanded: classes.expanded, + selected: classes.selected, group: classes.group, label: classes.label, }} diff --git a/docs/src/pages/components/tree-view/GmailTreeView.tsx b/docs/src/pages/components/tree-view/GmailTreeView.tsx index c4392c99d834ef..f1e4ca54b83f27 100644 --- a/docs/src/pages/components/tree-view/GmailTreeView.tsx +++ b/docs/src/pages/components/tree-view/GmailTreeView.tsx @@ -33,10 +33,16 @@ const useTreeItemStyles = makeStyles((theme: Theme) => createStyles({ root: { color: theme.palette.text.secondary, - '&:focus > $content': { + '&:hover > $content': { + backgroundColor: theme.palette.action.hover, + }, + '&:focus > $content, &$selected > $content': { backgroundColor: `var(--tree-view-bg-color, ${theme.palette.grey[400]})`, color: 'var(--tree-view-color)', }, + '&:focus > $content $label, &:hover > $content $label, &$selected > $content $label': { + backgroundColor: 'transparent', + }, }, content: { color: theme.palette.text.secondary, @@ -55,6 +61,7 @@ const useTreeItemStyles = makeStyles((theme: Theme) => }, }, expanded: {}, + selected: {}, label: { fontWeight: 'inherit', color: 'inherit', @@ -99,6 +106,7 @@ function StyledTreeItem(props: StyledTreeItemProps) { root: classes.root, content: classes.content, expanded: classes.expanded, + selected: classes.selected, group: classes.group, label: classes.label, }} diff --git a/docs/src/pages/components/tree-view/MultiSelectTreeView.js b/docs/src/pages/components/tree-view/MultiSelectTreeView.js new file mode 100644 index 00000000000000..5b539bbed6b111 --- /dev/null +++ b/docs/src/pages/components/tree-view/MultiSelectTreeView.js @@ -0,0 +1,41 @@ +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 MultiSelectTreeView() { + const classes = useStyles(); + + return ( + } + defaultExpandIcon={} + multiSelect + > + + + + + + + + + + + + + + + ); +} diff --git a/docs/src/pages/components/tree-view/MultiSelectTreeView.tsx b/docs/src/pages/components/tree-view/MultiSelectTreeView.tsx new file mode 100644 index 00000000000000..5b539bbed6b111 --- /dev/null +++ b/docs/src/pages/components/tree-view/MultiSelectTreeView.tsx @@ -0,0 +1,41 @@ +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 MultiSelectTreeView() { + const classes = useStyles(); + + return ( + } + defaultExpandIcon={} + multiSelect + > + + + + + + + + + + + + + + + ); +} diff --git a/docs/src/pages/components/tree-view/tree-view.md b/docs/src/pages/components/tree-view/tree-view.md index 80f2c5113bb499..aa1b2e9ec29b2a 100644 --- a/docs/src/pages/components/tree-view/tree-view.md +++ b/docs/src/pages/components/tree-view/tree-view.md @@ -9,9 +9,17 @@ components: TreeView, TreeItem Tree views can be used to represent a file system navigator displaying folders and files, an item representing a folder can be expanded to reveal the contents of the folder, which may be files, folders, or both. +## Basic tree view + {{"demo": "pages/components/tree-view/FileSystemNavigator.js"}} -## Controlled +## Multi selection + +Tree views also support multi selection. + +{{"demo": "pages/components/tree-view/MultiSelectTreeView.js"}} + +### Controlled tree view The tree view also offers a controlled API. diff --git a/packages/material-ui-lab/src/TreeItem/TreeItem.d.ts b/packages/material-ui-lab/src/TreeItem/TreeItem.d.ts index d559dde0464c00..a61986ee5f7e87 100644 --- a/packages/material-ui-lab/src/TreeItem/TreeItem.d.ts +++ b/packages/material-ui-lab/src/TreeItem/TreeItem.d.ts @@ -42,6 +42,7 @@ export interface TreeItemProps export type TreeItemClassKey = | 'root' | 'expanded' + | 'selected' | 'group' | 'content' | 'iconContainer' diff --git a/packages/material-ui-lab/src/TreeItem/TreeItem.js b/packages/material-ui-lab/src/TreeItem/TreeItem.js index 178e718c8b084e..8ed8550a561d98 100644 --- a/packages/material-ui-lab/src/TreeItem/TreeItem.js +++ b/packages/material-ui-lab/src/TreeItem/TreeItem.js @@ -4,7 +4,7 @@ import clsx from 'clsx'; import PropTypes from 'prop-types'; import Typography from '@material-ui/core/Typography'; import Collapse from '@material-ui/core/Collapse'; -import { withStyles, useTheme } from '@material-ui/core/styles'; +import { fade, withStyles, useTheme } from '@material-ui/core/styles'; import { useForkRef } from '@material-ui/core/utils'; import TreeViewContext from '../TreeView/TreeViewContext'; @@ -16,17 +16,32 @@ export const styles = theme => ({ padding: 0, outline: 0, WebkitTapHighlightColor: 'transparent', - '&:focus > $content': { - backgroundColor: theme.palette.grey[400], + '&:focus > $content $label': { + backgroundColor: theme.palette.action.hover, + }, + '&$selected > $content $label': { + backgroundColor: fade(theme.palette.primary.main, theme.palette.action.selectedOpacity), + }, + '&$selected > $content $label:hover, &$selected:focus > $content $label': { + backgroundColor: fade( + theme.palette.primary.main, + theme.palette.action.selectedOpacity + theme.palette.action.hoverOpacity, + ), + // Reset on touch devices, it doesn't add specificity + '@media (hover: none)': { + backgroundColor: 'transparent', + }, }, }, /* Pseudo-class applied to the root element when expanded. */ expanded: {}, + /* Pseudo-class applied to the root element when selected. */ + selected: {}, /* Styles applied to the `role="group"` element. */ group: { margin: 0, padding: 0, - marginLeft: 26, + marginLeft: 17, }, /* Styles applied to the tree node content. */ content: { @@ -34,20 +49,30 @@ export const styles = theme => ({ display: 'flex', alignItems: 'center', cursor: 'pointer', - '&:hover': { - backgroundColor: theme.palette.action.hover, - }, }, /* Styles applied to the tree node icon and collapse/expand icon. */ iconContainer: { - marginRight: 2, - width: 24, + marginRight: 4, + width: 15, display: 'flex', + flexShrink: 0, justifyContent: 'center', + '& svg': { + fontSize: 18, + }, }, /* Styles applied to the label element. */ label: { width: '100%', + paddingLeft: 4, + position: 'relative', + '&:hover': { + backgroundColor: theme.palette.action.hover, + // Reset on touch devices, it doesn't add specificity + '@media (hover: none)': { + backgroundColor: 'transparent', + }, + }, }, }); @@ -69,28 +94,39 @@ const TreeItem = React.forwardRef(function TreeItem(props, ref) { onClick, onFocus, onKeyDown, + onMouseDown, TransitionComponent = Collapse, TransitionProps, ...other } = props; const { - expandAllSiblings, + icons: contextIcons, focus, focusFirstNode, focusLastNode, focusNextNode, focusPreviousNode, - handleFirstChars, - handleLeftArrow, - addNodeToNodeMap, - removeNodeFromNodeMap, - icons: contextIcons, + focusByFirstCharacter, + selectNode, + selectRange, + selectNextNode, + selectPreviousNode, + rangeSelectToFirst, + rangeSelectToLast, + selectAllNodes, + expandAllSiblings, + toggleExpansion, isExpanded, isFocused, + isSelected, isTabbable, - setFocusByFirstCharacter, - toggle, + multiSelect, + selectionDisabled, + getParent, + mapFirstChar, + addNodeToNodeMap, + removeNodeFromNodeMap, } = React.useContext(TreeViewContext); const nodeRef = React.useRef(null); @@ -103,6 +139,7 @@ const TreeItem = React.forwardRef(function TreeItem(props, ref) { const expanded = isExpanded ? isExpanded(nodeId) : false; const focused = isFocused ? isFocused(nodeId) : false; const tabbable = isTabbable ? isTabbable(nodeId) : false; + const selected = isSelected ? isSelected(nodeId) : false; const icons = contextIcons || {}; const theme = useTheme(); @@ -127,8 +164,23 @@ const TreeItem = React.forwardRef(function TreeItem(props, ref) { focus(nodeId); } - if (expandable) { - toggle(event, nodeId); + 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))) { + toggleExpansion(event, nodeId); + } + + if (!selectionDisabled) { + if (multiple) { + if (event.shiftKey) { + selectRange(event, { end: nodeId }); + } else { + selectNode(event, nodeId, true); + } + } else { + selectNode(event, nodeId); + } } if (onClick) { @@ -136,14 +188,19 @@ const TreeItem = React.forwardRef(function TreeItem(props, ref) { } }; - const printableCharacter = (event, key) => { - if (key === '*') { - expandAllSiblings(event, nodeId); - return true; + const handleMouseDown = event => { + if (event.shiftKey || event.ctrlKey || event.metaKey) { + event.preventDefault(); + } + + if (onMouseDown) { + onMouseDown(event); } + }; + const printableCharacter = (event, key) => { if (isPrintableCharacter(key)) { - setFocusByFirstCharacter(nodeId, key); + focusByFirstCharacter(nodeId, key); return true; } return false; @@ -154,75 +211,110 @@ const TreeItem = React.forwardRef(function TreeItem(props, ref) { if (expanded) { focusNextNode(nodeId); } else { - toggle(event); + toggleExpansion(event); } } + return true; }; const handlePreviousArrow = event => { - handleLeftArrow(nodeId, event); + if (expanded) { + toggleExpansion(event, nodeId); + return true; + } + + const parent = getParent(nodeId); + if (parent) { + focus(parent); + return true; + } + return false; }; const handleKeyDown = event => { let flag = false; const key = event.key; - if (event.altKey || event.ctrlKey || event.metaKey || event.currentTarget !== event.target) { + if (event.altKey || event.currentTarget !== event.target) { return; } - if (event.shift) { - if (key === ' ' || key === 'Enter') { - event.stopPropagation(); - } else if (isPrintableCharacter(key)) { - flag = printableCharacter(event, key); - } - } else { - switch (key) { - case 'Enter': - case ' ': - if (nodeRef.current === event.currentTarget && expandable) { - toggle(event); - flag = true; + + const ctrlPressed = event.ctrlKey || event.metaKey; + + switch (key) { + case ' ': + if (nodeRef.current === event.currentTarget) { + if (multiSelect && event.shiftKey) { + selectRange(event, { end: nodeId }); + } else if (multiSelect) { + selectNode(event, nodeId, true); + } else { + selectNode(event, nodeId); } - event.stopPropagation(); - break; - case 'ArrowDown': - focusNextNode(nodeId); + flag = true; - break; - case 'ArrowUp': - focusPreviousNode(nodeId); + } + event.stopPropagation(); + break; + case 'Enter': + if (nodeRef.current === event.currentTarget && expandable) { + toggleExpansion(event); flag = true; - break; - case 'ArrowRight': - if (theme.direction === 'rtl') { - handlePreviousArrow(event); - } else { - handleNextArrow(event); - flag = true; - } - break; - case 'ArrowLeft': - if (theme.direction === 'rtl') { - handleNextArrow(event); - flag = true; - } else { - handlePreviousArrow(event); - } - break; - case 'Home': - focusFirstNode(); + } + event.stopPropagation(); + break; + case 'ArrowDown': + if (multiSelect && event.shiftKey) { + selectNextNode(event, nodeId); + } + focusNextNode(nodeId); + flag = true; + break; + case 'ArrowUp': + if (multiSelect && event.shiftKey) { + selectPreviousNode(event, nodeId); + } + focusPreviousNode(nodeId); + flag = true; + break; + case 'ArrowRight': + if (theme.direction === 'rtl') { + flag = handlePreviousArrow(event); + } else { + flag = handleNextArrow(event); + } + break; + case 'ArrowLeft': + if (theme.direction === 'rtl') { + flag = handleNextArrow(event); + } else { + flag = handlePreviousArrow(event); + } + break; + case 'Home': + if (multiSelect && ctrlPressed && event.shiftKey) { + rangeSelectToFirst(event, nodeId); + } + focusFirstNode(); + flag = true; + break; + case 'End': + if (multiSelect && ctrlPressed && event.shiftKey) { + rangeSelectToLast(event, nodeId); + } + focusLastNode(); + flag = true; + break; + default: + if (key === '*') { + expandAllSiblings(event, nodeId); flag = true; - break; - case 'End': - focusLastNode(); + } else if (multiSelect && ctrlPressed && key.toLowerCase() === 'a') { + selectAllNodes(event); flag = true; - break; - default: - if (isPrintableCharacter(key)) { - flag = printableCharacter(event, key); - } - } + } else if (isPrintableCharacter(key)) { + flag = printableCharacter(event, key); + } } if (flag) { @@ -262,10 +354,10 @@ const TreeItem = React.forwardRef(function TreeItem(props, ref) { }, [nodeId, removeNodeFromNodeMap]); React.useEffect(() => { - if (handleFirstChars && label) { - handleFirstChars(nodeId, contentRef.current.textContent.substring(0, 1).toLowerCase()); + if (mapFirstChar && label) { + mapFirstChar(nodeId, contentRef.current.textContent.substring(0, 1).toLowerCase()); } - }, [handleFirstChars, nodeId, label]); + }, [mapFirstChar, nodeId, label]); React.useEffect(() => { if (focused) { @@ -277,17 +369,24 @@ const TreeItem = React.forwardRef(function TreeItem(props, ref) {
  • -
    - {icon ?
    {icon}
    : null} +
    +
    {icon}
    {label} @@ -362,6 +461,10 @@ TreeItem.propTypes = { * @ignore */ onKeyDown: PropTypes.func, + /** + * @ignore + */ + onMouseDown: PropTypes.func, /** * The component used for the transition. * [Follow this guide](/components/transitions/#transitioncomponent-prop) to learn more about the requirements for this component. diff --git a/packages/material-ui-lab/src/TreeItem/TreeItem.test.js b/packages/material-ui-lab/src/TreeItem/TreeItem.test.js index 990251d34708a4..8ecebf9d5f309e 100644 --- a/packages/material-ui-lab/src/TreeItem/TreeItem.test.js +++ b/packages/material-ui-lab/src/TreeItem/TreeItem.test.js @@ -151,13 +151,13 @@ describe('', () => { describe('Accessibility', () => { it('should have the role `treeitem`', () => { - const { getByRole } = render( + const { getByTestId } = render( - + , ); - expect(getByRole('treeitem')).to.be.ok; + expect(getByTestId('test')).to.have.attribute('role', 'treeitem'); }); it('should add the role `group` to a component containing children', () => { @@ -172,38 +172,72 @@ describe('', () => { expect(getByRole('group')).to.contain(getByText('test2')); }); - it('should have the attribute `aria-expanded=false` if collapsed', () => { - const { getByTestId } = render( - - - - - , - ); + describe('aria-expanded', () => { + it('should have the attribute `aria-expanded=false` if collapsed', () => { + const { getByTestId } = render( + + + + + , + ); - expect(getByTestId('test')).to.have.attribute('aria-expanded', 'false'); - }); + expect(getByTestId('test')).to.have.attribute('aria-expanded', 'false'); + }); - it('should have the attribute `aria-expanded=true` if expanded', () => { - const { container } = render( - - - - - , - ); + it('should have the attribute `aria-expanded=true` if expanded', () => { + const { getByTestId } = render( + + + + + , + ); + + expect(getByTestId('test')).to.have.attribute('aria-expanded', 'true'); + }); + + it('should not have the attribute `aria-expanded` if no children are present', () => { + const { getByTestId } = render( + + + , + ); - expect(container.querySelector('[aria-expanded=true]')).to.be.ok; + expect(getByTestId('test')).to.not.have.attribute('aria-expanded'); + }); }); - it('should not have the attribute `aria-expanded` if no children are present', () => { - const { container } = render( - - - , - ); + describe('aria-selected', () => { + it('should have the attribute `aria-selected=false` if not selected', () => { + const { getByTestId } = render( + + + , + ); - expect(container.querySelector('[aria-expanded]')).to.not.be.ok; + expect(getByTestId('test')).to.have.attribute('aria-selected', 'false'); + }); + + it('should have the attribute `aria-selected=true` if selected', () => { + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('test')).to.have.attribute('aria-selected', 'true'); + }); + + it('should not have the attribute `aria-selected` if disableSelection is true', () => { + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('test')).to.not.have.attribute('aria-selected'); + }); }); describe('when a tree receives focus', () => { @@ -255,395 +289,681 @@ describe('', () => { }); }); - describe('right arrow interaction', () => { - it('should open the node and not move the focus if focus is on a closed node', () => { - const { getByTestId } = render( - - - - - , - ); - - expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false'); - getByTestId('one').focus(); - fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' }); - expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); - expect(getByTestId('one')).to.have.focus; + describe('Navigation', () => { + describe('right arrow interaction', () => { + it('should open the node and not move the focus if focus is on a closed node', () => { + const { getByTestId } = render( + + + + + , + ); + + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false'); + getByTestId('one').focus(); + fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' }); + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); + expect(getByTestId('one')).to.have.focus; + }); + + it('should move focus to the first child if focus is on an open node', () => { + const { getByTestId } = render( + + + + + , + ); + + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); + getByTestId('one').focus(); + fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' }); + expect(getByTestId('two')).to.have.focus; + }); + + it('should do nothing if focus is on an end node', () => { + const { getByTestId, getByText } = render( + + + + + , + ); + + fireEvent.click(getByText('two')); + expect(getByTestId('two')).to.have.focus; + fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' }); + expect(getByTestId('two')).to.have.focus; + }); }); - it('should move focus to the first child if focus is on an open node', () => { - const { getByTestId } = render( - - - - - , - ); + describe('left arrow interaction', () => { + it('should close the node if focus is on an open node', () => { + const { getByTestId, getByText } = render( + + + + + , + ); + + fireEvent.click(getByText('one')); + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); + getByTestId('one').focus(); + fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' }); + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false'); + expect(getByTestId('one')).to.have.focus; + }); + + it("should move focus to the node's parent node if focus is on a child node that is an end node", () => { + const { getByTestId, getByText } = render( + + + + + , + ); + + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); + fireEvent.click(getByText('two')); + fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' }); + expect(getByTestId('one')).to.have.focus; + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); + }); + + it("should move focus to the node's parent node if focus is on a child node that is closed", () => { + const { getByTestId, getByText } = render( + + + + + + + , + ); + + fireEvent.click(getByText('one')); + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); + // move focus to node two + fireEvent.click(getByText('two')); + fireEvent.click(getByText('two')); + expect(getByTestId('two')).to.have.attribute('aria-expanded', 'false'); + fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' }); + expect(getByTestId('one')).to.have.focus; + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); + }); + + it('should do nothing if focus is on a root node that is closed', () => { + const { getByTestId } = render( + + + + + , + ); - expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); - getByTestId('one').focus(); - fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' }); - expect(getByTestId('two')).to.have.focus; + getByTestId('one').focus(); + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false'); + fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' }); + expect(getByTestId('one')).to.have.focus; + }); + + it('should do nothing if focus is on a root node that is an end node', () => { + const { getByTestId } = render( + + + , + ); + + getByTestId('one').focus(); + fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' }); + expect(getByTestId('one')).to.have.focus; + }); }); - it('should do nothing if focus is on an end node', () => { - const { getByTestId, getByText } = render( - - + describe('down arrow interaction', () => { + it('moves focus to a sibling node', () => { + const { getByTestId } = render( + + - - , - ); - - fireEvent.click(getByText('two')); - expect(getByTestId('two')).to.have.focus; - fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' }); - expect(getByTestId('two')).to.have.focus; + , + ); + + getByTestId('one').focus(); + fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' }); + expect(getByTestId('two')).to.have.focus; + }); + + it('moves focus to a child node', () => { + const { getByTestId } = render( + + + + + , + ); + + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); + getByTestId('one').focus(); + fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' }); + expect(getByTestId('two')).to.have.focus; + }); + + it("moves focus to a parent's sibling", () => { + const { getByTestId, getByText } = render( + + + + + + , + ); + + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); + fireEvent.click(getByText('two')); + expect(getByTestId('two')).to.have.focus; + fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' }); + expect(getByTestId('three')).to.have.focus; + }); }); - }); - describe('left arrow interaction', () => { - it('should close the node if focus is on an open node', () => { - const { getByTestId, getByText } = render( - - - - - , - ); - - fireEvent.click(getByText('one')); - expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); - getByTestId('one').focus(); - fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' }); - expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false'); - expect(getByTestId('one')).to.have.focus; + describe('up arrow interaction', () => { + it('moves focus to a sibling node', () => { + const { getByTestId, getByText } = render( + + + + , + ); + + fireEvent.click(getByText('two')); + expect(getByTestId('two')).to.have.focus; + fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' }); + expect(getByTestId('one')).to.have.focus; + }); + + it('moves focus to a parent', () => { + const { getByTestId, getByText } = render( + + + + + , + ); + + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); + fireEvent.click(getByText('two')); + expect(getByTestId('two')).to.have.focus; + fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' }); + expect(getByTestId('one')).to.have.focus; + }); + + it("moves focus to a sibling's child", () => { + const { getByTestId, getByText } = render( + + + + + + , + ); + + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); + fireEvent.click(getByText('three')); + expect(getByTestId('three')).to.have.focus; + fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' }); + expect(getByTestId('two')).to.have.focus; + }); }); - it("should move focus to the node's parent node if focus is on a child node that is an end node", () => { - const { getByTestId, getByText } = render( - - + describe('home key interaction', () => { + it('moves focus to the first node in the tree', () => { + const { getByTestId, getByText } = render( + + - - , - ); - - expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); - fireEvent.click(getByText('two')); - fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' }); - expect(getByTestId('one')).to.have.focus; - expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); + + + , + ); + + fireEvent.click(getByText('four')); + expect(getByTestId('four')).to.have.focus; + fireEvent.keyDown(document.activeElement, { key: 'Home' }); + expect(getByTestId('one')).to.have.focus; + }); }); - it("should move focus to the node's parent node if focus is on a child node that is closed", () => { - const { getByTestId, getByText } = render( - - - - + describe('end key interaction', () => { + it('moves focus to the last node in the tree without expanded items', () => { + const { getByTestId } = render( + + + + + + , + ); + + getByTestId('one').focus(); + expect(getByTestId('one')).to.have.focus; + fireEvent.keyDown(document.activeElement, { key: 'End' }); + expect(getByTestId('four')).to.have.focus; + }); + + it('moves focus to the last node in the tree with expanded items', () => { + const { getByTestId } = render( + + + + + + + + - - , - ); - - fireEvent.click(getByText('one')); - expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); - // move focus to node two - fireEvent.click(getByText('two')); - fireEvent.click(getByText('two')); - expect(getByTestId('two')).to.have.attribute('aria-expanded', 'false'); - fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' }); - expect(getByTestId('one')).to.have.focus; - expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); - }); - - it('should do nothing if focus is on a root node that is closed', () => { - const { getByTestId } = render( - - - - - , - ); - - getByTestId('one').focus(); - expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false'); - fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' }); - expect(getByTestId('one')).to.have.focus; + , + ); + + getByTestId('one').focus(); + expect(getByTestId('one')).to.have.focus; + fireEvent.keyDown(document.activeElement, { key: 'End' }); + expect(getByTestId('six')).to.have.focus; + }); }); - it('should do nothing if focus is on a root node that is an end node', () => { - const { getByTestId } = render( - - - , - ); + describe('type-ahead functionality', () => { + it('moves focus to the next node with a name that starts with the typed character', () => { + const { getByTestId } = render( + + + two} data-testid="two" /> + + + , + ); - getByTestId('one').focus(); - fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' }); - expect(getByTestId('one')).to.have.focus; - }); - }); + getByTestId('one').focus(); + expect(getByTestId('one')).to.have.focus; + fireEvent.keyDown(document.activeElement, { key: 't' }); + expect(getByTestId('two')).to.have.focus; - describe('down arrow interaction', () => { - it('moves focus to a non-nested sibling node', () => { - const { getByTestId } = render( - - - - , - ); + fireEvent.keyDown(document.activeElement, { key: 'f' }); + expect(getByTestId('four')).to.have.focus; - getByTestId('one').focus(); - fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' }); - expect(getByTestId('two')).to.have.focus; - }); + fireEvent.keyDown(document.activeElement, { key: 'o' }); + expect(getByTestId('one')).to.have.focus; + }); - it('moves focus to a nested node', () => { - const { getByTestId } = render( - - + it('moves focus to the next node with the same starting character', () => { + const { getByTestId } = render( + + - - , - ); + + + , + ); - expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); - getByTestId('one').focus(); - fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' }); - expect(getByTestId('two')).to.have.focus; - }); + getByTestId('one').focus(); + expect(getByTestId('one')).to.have.focus; + fireEvent.keyDown(document.activeElement, { key: 't' }); + expect(getByTestId('two')).to.have.focus; - it("moves focus to a parent's sibling", () => { - const { getByTestId, getByText } = render( - - - - - - , - ); + fireEvent.keyDown(document.activeElement, { key: 't' }); + expect(getByTestId('three')).to.have.focus; - expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); - fireEvent.click(getByText('two')); - expect(getByTestId('two')).to.have.focus; - fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' }); - expect(getByTestId('three')).to.have.focus; + fireEvent.keyDown(document.activeElement, { key: 't' }); + expect(getByTestId('two')).to.have.focus; + }); }); - }); - - describe('up arrow interaction', () => { - it('moves focus to a non-nested sibling node', () => { - const { getByTestId, getByText } = render( - - - - , - ); - fireEvent.click(getByText('two')); - expect(getByTestId('two')).to.have.focus; - fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' }); - expect(getByTestId('one')).to.have.focus; + describe('asterisk key interaction', () => { + it('expands all siblings that are at the same level as the current node', () => { + const { getByTestId } = render( + + + + + + + + + + + + + , + ); + + getByTestId('one').focus(); + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false'); + expect(getByTestId('three')).to.have.attribute('aria-expanded', 'false'); + expect(getByTestId('five')).to.have.attribute('aria-expanded', 'false'); + fireEvent.keyDown(document.activeElement, { key: '*' }); + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); + expect(getByTestId('three')).to.have.attribute('aria-expanded', 'true'); + expect(getByTestId('five')).to.have.attribute('aria-expanded', 'true'); + expect(getByTestId('six')).to.have.attribute('aria-expanded', 'false'); + }); }); + }); - it('moves focus to a parent', () => { - const { getByTestId, getByText } = render( - - - - - , - ); + describe('Expansion', () => { + describe('enter key interaction', () => { + it('expands a node with children', () => { + const { getByTestId } = render( + + + + + , + ); - expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); - fireEvent.click(getByText('two')); - expect(getByTestId('two')).to.have.focus; - fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' }); - expect(getByTestId('one')).to.have.focus; - }); + getByTestId('one').focus(); + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false'); + fireEvent.keyDown(document.activeElement, { key: 'Enter' }); + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); + }); - it("moves focus to a sibling's child", () => { - const { getByTestId, getByText } = render( - - - - - - , - ); - - expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); - fireEvent.click(getByText('three')); - expect(getByTestId('three')).to.have.focus; - fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' }); - expect(getByTestId('two')).to.have.focus; + it('collapses a node with children', () => { + const { getByTestId, getByText } = render( + + + + + , + ); + + fireEvent.click(getByText('one')); + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); + fireEvent.keyDown(document.activeElement, { key: 'Enter' }); + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false'); + }); }); }); - describe('home key interaction', () => { - it('moves focus to the first node in the tree', () => { - const { getByTestId, getByText } = render( - - - - - - , - ); - - fireEvent.click(getByText('four')); - expect(getByTestId('four')).to.have.focus; - fireEvent.keyDown(document.activeElement, { key: 'Home' }); - expect(getByTestId('one')).to.have.focus; + describe('Single Selection', () => { + describe('keyboard', () => { + it('selects a node', () => { + const { getByTestId } = render( + + + , + ); + + getByTestId('one').focus(); + expect(getByTestId('one')).to.have.attribute('aria-selected', 'false'); + fireEvent.keyDown(document.activeElement, { key: ' ' }); + expect(getByTestId('one')).to.have.attribute('aria-selected', 'true'); + }); }); - }); - - describe('end key interaction', () => { - it('moves focus to the last node in the tree without expanded items', () => { - const { getByTestId } = render( - - - - - - , - ); - getByTestId('one').focus(); - expect(getByTestId('one')).to.have.focus; - fireEvent.keyDown(document.activeElement, { key: 'End' }); - expect(getByTestId('four')).to.have.focus; + describe('mouse', () => { + it('selects a node', () => { + const { getByText, getByTestId } = render( + + + , + ); + + expect(getByTestId('one')).to.have.attribute('aria-selected', 'false'); + fireEvent.click(getByText('one')); + expect(getByTestId('one')).to.have.attribute('aria-selected', 'true'); + }); }); + }); - it('moves focus to the last node in the tree with expanded items', () => { - const { getByTestId } = render( - - - - - + describe('Multi Selection', () => { + describe('range selection', () => { + specify('keyboard arrow', () => { + const { getByTestId, getByText, container } = render( + + + + + + + , + ); + + fireEvent.click(getByText('three')); + expect(getByTestId('three')).to.have.attribute('aria-selected', 'true'); + fireEvent.keyDown(document.activeElement, { key: 'ArrowDown', shiftKey: true }); + expect(getByTestId('four')).to.have.focus; + expect(container.querySelectorAll('[aria-selected=true]').length).to.equal(2); + fireEvent.keyDown(document.activeElement, { key: 'ArrowDown', shiftKey: true }); + expect(getByTestId('three')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('four')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('five')).to.have.attribute('aria-selected', 'true'); + expect(container.querySelectorAll('[aria-selected=true]').length).to.equal(3); + fireEvent.keyDown(document.activeElement, { key: 'ArrowUp', shiftKey: true }); + expect(getByTestId('four')).to.have.focus; + expect(container.querySelectorAll('[aria-selected=true]').length).to.equal(2); + fireEvent.keyDown(document.activeElement, { key: 'ArrowUp', shiftKey: true }); + expect(container.querySelectorAll('[aria-selected=true]').length).to.equal(1); + fireEvent.keyDown(document.activeElement, { key: 'ArrowUp', shiftKey: true }); + expect(container.querySelectorAll('[aria-selected=true]').length).to.equal(2); + fireEvent.keyDown(document.activeElement, { key: 'ArrowUp', shiftKey: true }); + expect(getByTestId('one')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('two')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('three')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('four')).to.have.attribute('aria-selected', 'false'); + expect(getByTestId('five')).to.have.attribute('aria-selected', 'false'); + expect(container.querySelectorAll('[aria-selected=true]').length).to.equal(3); + }); + + specify('keyboard arrow merge', () => { + const { getByTestId, getByText, container } = render( + + + + + + + + , + ); + + fireEvent.click(getByText('three')); + expect(getByTestId('three')).to.have.attribute('aria-selected', 'true'); + fireEvent.keyDown(document.activeElement, { key: 'ArrowUp', shiftKey: true }); + fireEvent.click(getByText('six'), { ctrlKey: true }); + fireEvent.keyDown(document.activeElement, { key: 'ArrowUp', shiftKey: true }); + fireEvent.keyDown(document.activeElement, { key: 'ArrowUp', shiftKey: true }); + fireEvent.keyDown(document.activeElement, { key: 'ArrowUp', shiftKey: true }); + fireEvent.keyDown(document.activeElement, { key: 'ArrowUp', shiftKey: true }); + expect(container.querySelectorAll('[aria-selected=true]').length).to.equal(5); + fireEvent.keyDown(document.activeElement, { key: 'ArrowDown', shiftKey: true }); + fireEvent.keyDown(document.activeElement, { key: 'ArrowDown', shiftKey: true }); + expect(container.querySelectorAll('[aria-selected=true]').length).to.equal(3); + }); + + specify('keyboard space', () => { + const { getByTestId, getByText } = render( + + + + + + + - - , - ); - - getByTestId('one').focus(); - expect(getByTestId('one')).to.have.focus; - fireEvent.keyDown(document.activeElement, { key: 'End' }); - expect(getByTestId('six')).to.have.focus; + + + , + ); + + fireEvent.click(getByText('five')); + for (let i = 0; i < 5; i += 1) { + fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' }); + } + fireEvent.keyDown(document.activeElement, { key: ' ', shiftKey: true }); + expect(getByTestId('five')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('six')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('seven')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('eight')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('nine')).to.have.attribute('aria-selected', 'true'); + for (let i = 0; i < 9; i += 1) { + fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' }); + } + fireEvent.keyDown(document.activeElement, { key: ' ', shiftKey: true }); + expect(getByTestId('one')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('two')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('three')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('four')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('five')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('six')).to.have.attribute('aria-selected', 'false'); + expect(getByTestId('seven')).to.have.attribute('aria-selected', 'false'); + expect(getByTestId('eight')).to.have.attribute('aria-selected', 'false'); + expect(getByTestId('nine')).to.have.attribute('aria-selected', 'false'); + }); + + specify('keyboard home and end', () => { + const { getByTestId } = render( + + + + + + + + + + + + + , + ); + + getByTestId('five').focus(); + fireEvent.keyDown(document.activeElement, { key: 'End', shiftKey: true, ctrlKey: true }); + expect(getByTestId('five')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('six')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('seven')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('eight')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('nine')).to.have.attribute('aria-selected', 'true'); + fireEvent.keyDown(document.activeElement, { key: 'Home', shiftKey: true, ctrlKey: true }); + expect(getByTestId('one')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('two')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('three')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('four')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('five')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('six')).to.have.attribute('aria-selected', 'false'); + expect(getByTestId('seven')).to.have.attribute('aria-selected', 'false'); + expect(getByTestId('eight')).to.have.attribute('aria-selected', 'false'); + expect(getByTestId('nine')).to.have.attribute('aria-selected', 'false'); + }); + + specify('mouse', () => { + const { getByTestId, getByText } = render( + + + + + + + + + + + + + , + ); + + fireEvent.click(getByText('five')); + fireEvent.click(getByText('nine'), { shiftKey: true }); + expect(getByTestId('five')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('six')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('seven')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('eight')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('nine')).to.have.attribute('aria-selected', 'true'); + fireEvent.click(getByText('one'), { shiftKey: true }); + expect(getByTestId('one')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('two')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('three')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('four')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('five')).to.have.attribute('aria-selected', 'true'); + }); }); - }); - describe('enter key interaction', () => { - it('expands a node with children', () => { - const { getByTestId } = render( - - + describe('multi selection', () => { + specify('keyboard', () => { + const { getByTestId } = render( + + - - , - ); - - getByTestId('one').focus(); - expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false'); - fireEvent.keyDown(document.activeElement, { key: 'Enter' }); - expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); - }); - - it('collapses a node with children', () => { - const { getByTestId, getByText } = render( - - + , + ); + + getByTestId('one').focus(); + expect(getByTestId('one')).to.have.attribute('aria-selected', 'false'); + expect(getByTestId('two')).to.have.attribute('aria-selected', 'false'); + fireEvent.keyDown(document.activeElement, { key: ' ' }); + expect(getByTestId('one')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('two')).to.have.attribute('aria-selected', 'false'); + getByTestId('two').focus(); + fireEvent.keyDown(document.activeElement, { key: ' ', ctrlKey: true }); + expect(getByTestId('one')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('two')).to.have.attribute('aria-selected', 'true'); + }); + + specify('mouse using ctrl', () => { + const { getByTestId, getByText } = render( + + - - , - ); - - fireEvent.click(getByText('one')); - expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); - fireEvent.keyDown(document.activeElement, { key: 'Enter' }); - expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false'); - }); - }); - - describe('type-ahead functionality', () => { - it('moves focus to the next node with a name that starts with the typed character', () => { - const { getByTestId } = render( - - - two} data-testid="two" /> - - - , - ); - - getByTestId('one').focus(); - expect(getByTestId('one')).to.have.focus; - fireEvent.keyDown(document.activeElement, { key: 't' }); - expect(getByTestId('two')).to.have.focus; - - fireEvent.keyDown(document.activeElement, { key: 'f' }); - expect(getByTestId('four')).to.have.focus; - - fireEvent.keyDown(document.activeElement, { key: 'o' }); - expect(getByTestId('one')).to.have.focus; + , + ); + + expect(getByTestId('one')).to.have.attribute('aria-selected', 'false'); + expect(getByTestId('two')).to.have.attribute('aria-selected', 'false'); + fireEvent.click(getByText('one')); + expect(getByTestId('one')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('two')).to.have.attribute('aria-selected', 'false'); + fireEvent.click(getByText('two'), { ctrlKey: true }); + expect(getByTestId('one')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('two')).to.have.attribute('aria-selected', 'true'); + }); + + specify('mouse using meta', () => { + const { getByTestId, getByText } = render( + + + + , + ); + + expect(getByTestId('one')).to.have.attribute('aria-selected', 'false'); + expect(getByTestId('two')).to.have.attribute('aria-selected', 'false'); + fireEvent.click(getByText('one')); + expect(getByTestId('one')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('two')).to.have.attribute('aria-selected', 'false'); + fireEvent.click(getByText('two'), { metaKey: true }); + expect(getByTestId('one')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('two')).to.have.attribute('aria-selected', 'true'); + }); }); - it('moves focus to the next node with the same starting character', () => { - const { getByTestId } = render( - + specify('ctrl + a selects all', () => { + const { getByTestId, container } = render( + + , ); getByTestId('one').focus(); - expect(getByTestId('one')).to.have.focus; - fireEvent.keyDown(document.activeElement, { key: 't' }); - expect(getByTestId('two')).to.have.focus; - - fireEvent.keyDown(document.activeElement, { key: 't' }); - expect(getByTestId('three')).to.have.focus; - - fireEvent.keyDown(document.activeElement, { key: 't' }); - expect(getByTestId('two')).to.have.focus; - }); - }); - - describe('asterisk key interaction', () => { - it('expands all siblings that are at the same level as the current node', () => { - const { getByTestId } = render( - - - - - - - - - - - - - , - ); - - getByTestId('one').focus(); - expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false'); - expect(getByTestId('three')).to.have.attribute('aria-expanded', 'false'); - expect(getByTestId('five')).to.have.attribute('aria-expanded', 'false'); - fireEvent.keyDown(document.activeElement, { key: '*' }); - expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); - expect(getByTestId('three')).to.have.attribute('aria-expanded', 'true'); - expect(getByTestId('five')).to.have.attribute('aria-expanded', 'true'); - expect(getByTestId('six')).to.have.attribute('aria-expanded', 'false'); + fireEvent.keyDown(document.activeElement, { key: 'a', ctrlKey: true }); + expect(container.querySelectorAll('[aria-selected=true]').length).to.equal(5); }); }); }); diff --git a/packages/material-ui-lab/src/TreeView/TreeView.d.ts b/packages/material-ui-lab/src/TreeView/TreeView.d.ts index 5298492a583a77..1bf19b26ce36e6 100644 --- a/packages/material-ui-lab/src/TreeView/TreeView.d.ts +++ b/packages/material-ui-lab/src/TreeView/TreeView.d.ts @@ -1,7 +1,7 @@ import * as React from 'react'; import { StandardProps } from '@material-ui/core'; -export interface TreeViewProps +export interface TreeViewPropsBase extends StandardProps, TreeViewClassKey> { /** * The default icon used to collapse the node. @@ -25,6 +25,10 @@ export interface TreeViewProps * parent nodes and can be overridden by the TreeItem `icon` prop. */ defaultParentIcon?: React.ReactNode; + /** + * If `true` selection is disabled. + */ + disableSelection?: boolean; /** * Expanded node ids. (Controlled) */ @@ -38,6 +42,58 @@ export interface TreeViewProps onNodeToggle?: (event: React.ChangeEvent<{}>, nodeIds: string[]) => void; } +export interface MultiSelectTreeViewProps extends TreeViewPropsBase { + /** + * Selected node ids. (Uncontrolled) + * When `multiSelect` is true this takes an array of strings; when false (default) a string. + */ + defaultSelected?: string[]; + /** + * Selected node ids. (Controlled) + * When `multiSelect` is true this takes an array of strings; when false (default) a string. + */ + selected?: string[]; + /** + * If true `ctrl` and `shift` will trigger multiselect. + */ + multiSelect?: true; + /** + * Callback fired when tree items are selected/unselected. + * + * @param {object} event The event source of the callback + * @param {(array|string)} value of the selected nodes. When `multiSelect` is true + * this is an array of strings; when false (default) a string. + */ + onNodeSelect?: (event: React.ChangeEvent<{}>, nodeIds: string[]) => void; +} + +export interface SingleSelectTreeViewProps extends TreeViewPropsBase { + /** + * Selected node ids. (Uncontrolled) + * When `multiSelect` is true this takes an array of strings; when false (default) a string. + */ + defaultSelected?: string; + /** + * Selected node ids. (Controlled) + * When `multiSelect` is true this takes an array of strings; when false (default) a string. + */ + selected?: string; + /** + * If true `ctrl` and `shift` will trigger multiselect. + */ + multiSelect?: false; + /** + * Callback fired when tree items are selected/unselected. + * + * @param {object} event The event source of the callback + * @param {(array|string)} value of the selected nodes. When `multiSelect` is true + * this is an array of strings; when false (default) a string. + */ + onNodeSelect?: (event: React.ChangeEvent<{}>, nodeIds: string) => void; +} + +export type TreeViewProps = SingleSelectTreeViewProps | MultiSelectTreeViewProps; + export type TreeViewClassKey = 'root'; export default function TreeView(props: TreeViewProps): JSX.Element; diff --git a/packages/material-ui-lab/src/TreeView/TreeView.js b/packages/material-ui-lab/src/TreeView/TreeView.js index 138315d5ce71ec..6e5b2d591ae250 100644 --- a/packages/material-ui-lab/src/TreeView/TreeView.js +++ b/packages/material-ui-lab/src/TreeView/TreeView.js @@ -24,7 +24,17 @@ function arrayDiff(arr1, arr2) { return false; } +const findNextFirstChar = (firstChars, startIndex, char) => { + for (let i = startIndex; i < firstChars.length; i += 1) { + if (char === firstChars[i]) { + return i; + } + } + return -1; +}; + const defaultExpandedDefault = []; +const defaultSelectedDefault = []; const TreeView = React.forwardRef(function TreeView(props, ref) { const { @@ -36,145 +46,151 @@ const TreeView = React.forwardRef(function TreeView(props, ref) { defaultExpanded = defaultExpandedDefault, defaultExpandIcon, defaultParentIcon, + defaultSelected = defaultSelectedDefault, + disableSelection = false, + multiSelect = false, expanded: expandedProp, + onNodeSelect, onNodeToggle, + selected: selectedProp, ...other } = props; - const [tabable, setTabable] = React.useState(null); + const [tabbable, setTabbable] = React.useState(null); const [focused, setFocused] = React.useState(null); - const firstNode = React.useRef(null); const nodeMap = React.useRef({}); const firstCharMap = React.useRef({}); + const visibleNodes = React.useRef([]); - const [expandedState, setExpandedState] = useControlled({ + const [expanded, setExpandedState] = useControlled({ controlled: expandedProp, default: defaultExpanded, name: 'TreeView', }); - const expanded = expandedState || defaultExpandedDefault; + const [selected, setSelectedState] = useControlled({ + controlled: selectedProp, + default: defaultSelected, + name: 'TreeView', + }); - const prevChildIds = React.useRef([]); - React.useEffect(() => { - const childIds = React.Children.map(children, child => child.props.nodeId) || []; - if (arrayDiff(prevChildIds.current, childIds)) { - nodeMap.current[-1] = { parent: null, children: childIds }; + /* + * Status Helpers + */ + const isExpanded = React.useCallback( + id => (Array.isArray(expanded) ? expanded.indexOf(id) !== -1 : false), + [expanded], + ); - childIds.forEach((id, index) => { - if (index === 0) { - firstNode.current = id; - setTabable(id); - } - nodeMap.current[id] = { parent: null }; - }); - prevChildIds.current = childIds; - } - }, [children]); + const isSelected = React.useCallback( + id => (Array.isArray(selected) ? selected.indexOf(id) !== -1 : selected === id), + [selected], + ); - const isExpanded = React.useCallback(id => expanded.indexOf(id) !== -1, [expanded]); - const isTabbable = id => tabable === id; + const isTabbable = id => tabbable === id; const isFocused = id => focused === id; - const getLastNode = React.useCallback( - id => { - const map = nodeMap.current[id]; - if (isExpanded(id) && map.children && map.children.length > 0) { - return getLastNode(map.children[map.children.length - 1]); - } - return id; - }, - [isExpanded], - ); + /* + * Node Helpers + */ - const focus = id => { - if (id) { - setTabable(id); + const getNextNode = id => { + const nodeIndex = visibleNodes.current.indexOf(id); + if (nodeIndex !== -1 && nodeIndex + 1 < visibleNodes.current.length) { + return visibleNodes.current[nodeIndex + 1]; } - setFocused(id); + return null; }; - const getNextNode = (id, end) => { - const map = nodeMap.current[id]; - const parent = nodeMap.current[map.parent]; - - if (!end) { - if (isExpanded(id)) { - return map.children[0]; - } - } - if (parent) { - const nodeIndex = parent.children.indexOf(id); - const nextIndex = nodeIndex + 1; - if (parent.children.length > nextIndex) { - return parent.children[nextIndex]; - } - return getNextNode(parent.id, true); - } - const topLevelNodes = nodeMap.current[-1].children; - const topLevelNodeIndex = topLevelNodes.indexOf(id); - if (topLevelNodeIndex !== -1 && topLevelNodeIndex !== topLevelNodes.length - 1) { - return topLevelNodes[topLevelNodeIndex + 1]; + const getPreviousNode = id => { + const nodeIndex = visibleNodes.current.indexOf(id); + if (nodeIndex !== -1 && nodeIndex - 1 >= 0) { + return visibleNodes.current[nodeIndex - 1]; } - return null; }; - const getPreviousNode = id => { - const map = nodeMap.current[id]; - const parent = nodeMap.current[map.parent]; - - if (parent) { - const nodeIndex = parent.children.indexOf(id); - if (nodeIndex !== 0) { - const nextIndex = nodeIndex - 1; - return getLastNode(parent.children[nextIndex]); - } - return parent.id; - } - const topLevelNodes = nodeMap.current[-1].children; - const topLevelNodeIndex = topLevelNodes.indexOf(id); - if (topLevelNodeIndex > 0) { - return getLastNode(topLevelNodes[topLevelNodeIndex - 1]); - } + const getLastNode = () => visibleNodes.current[visibleNodes.current.length - 1]; + const getFirstNode = () => visibleNodes.current[0]; + const getParent = id => nodeMap.current[id].parent; - return null; + const getNodesInRange = (a, b) => { + const aIndex = visibleNodes.current.indexOf(a); + const bIndex = visibleNodes.current.indexOf(b); + const start = Math.min(aIndex, bIndex); + const end = Math.max(aIndex, bIndex); + return visibleNodes.current.slice(start, end + 1); }; - const focusNextNode = id => { - const nextNode = getNextNode(id); - if (nextNode) { - focus(nextNode); + /* + * Focus Helpers + */ + + const focus = id => { + if (id) { + setTabbable(id); + setFocused(id); } }; - const focusPreviousNode = id => { - const previousNode = getPreviousNode(id); - if (previousNode) { - focus(previousNode); + + const focusNextNode = id => focus(getNextNode(id)); + const focusPreviousNode = id => focus(getPreviousNode(id)); + const focusFirstNode = () => focus(getFirstNode()); + const focusLastNode = () => focus(getLastNode()); + + const focusByFirstCharacter = (id, char) => { + let start; + let index; + const lowercaseChar = char.toLowerCase(); + + const firstCharIds = []; + const firstChars = []; + // This really only works since the ids are strings + Object.keys(firstCharMap.current).forEach(nodeId => { + const firstChar = firstCharMap.current[nodeId]; + const map = nodeMap.current[nodeId]; + const visible = map.parent ? isExpanded(map.parent) : true; + + if (visible) { + firstCharIds.push(nodeId); + firstChars.push(firstChar); + } + }); + + // Get start index for search based on position of currentItem + start = firstCharIds.indexOf(id) + 1; + if (start === nodeMap.current.length) { + start = 0; } - }; - const focusFirstNode = () => { - if (firstNode.current) { - focus(firstNode.current); + + // Check remaining slots in the menu + index = findNextFirstChar(firstChars, start, lowercaseChar); + + // If not found in remaining slots, check from beginning + if (index === -1) { + index = findNextFirstChar(firstChars, 0, lowercaseChar); } - }; - const focusLastNode = () => { - const topLevelNodes = nodeMap.current[-1].children; - const lastNode = getLastNode(topLevelNodes[topLevelNodes.length - 1]); - focus(lastNode); + // If match was found... + if (index > -1) { + focus(firstCharIds[index]); + } }; - const toggle = (event, value = focused) => { + /* + * Expansion Helpers + */ + + const toggleExpansion = (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) { + setTabbable(oldTabbable => { + const map = nodeMap.current[oldTabbable]; + if (oldTabbable && (map && map.parent ? map.parent.id : null) === value) { return value; } - return oldTabable; + return oldTabbable; }); } else { newExpanded = [value, ...expanded]; @@ -207,75 +223,172 @@ const TreeView = React.forwardRef(function TreeView(props, ref) { } }; - const handleLeftArrow = (id, event) => { - let flag = false; - if (isExpanded(id)) { - toggle(event, id); - flag = true; - } else { - const parent = nodeMap.current[id].parent; - if (parent) { - focus(parent); - flag = true; + /* + * Selection Helpers + */ + + const lastSelectedNode = React.useRef(null); + const lastSelectionWasRange = React.useRef(false); + const currentRangeSelection = React.useRef([]); + + const handleRangeArrowSelect = (event, nodes) => { + let base = selected; + const { start, next, current } = nodes; + + if (!next || !current) { + return; + } + + if (currentRangeSelection.current.indexOf(current) === -1) { + currentRangeSelection.current = []; + } + + if (lastSelectionWasRange.current) { + if (currentRangeSelection.current.indexOf(next) !== -1) { + base = base.filter(id => id === start || id !== current); + currentRangeSelection.current = currentRangeSelection.current.filter( + id => id === start || id !== current, + ); + } else { + base.push(next); + currentRangeSelection.current.push(next); } + } else { + base.push(next); + currentRangeSelection.current.push(current, next); } - if (flag && event) { - event.preventDefault(); - event.stopPropagation(); + if (onNodeSelect) { + onNodeSelect(event, base); } + + setSelectedState(base); }; - const getIndexFirstChars = (firstChars, startIndex, char) => { - for (let i = startIndex; i < firstChars.length; i += 1) { - if (char === firstChars[i]) { - return i; - } + const handleRangeSelect = (event, nodes) => { + let base = selected; + const { start, end } = nodes; + // If last selection was a range selection ignore nodes that were selected. + if (lastSelectionWasRange.current) { + base = selected.filter(id => currentRangeSelection.current.indexOf(id) === -1); + } + + const range = getNodesInRange(start, end); + currentRangeSelection.current = range; + let newSelected = base.concat(range); + newSelected = newSelected.filter((id, i) => newSelected.indexOf(id) === i); + + if (onNodeSelect) { + onNodeSelect(event, newSelected); } - return -1; + + setSelectedState(newSelected); }; - const setFocusByFirstCharacter = (id, char) => { - let start; - let index; - const lowercaseChar = char.toLowerCase(); + const handleMultipleSelect = (event, value) => { + let newSelected = []; + if (selected.indexOf(value) !== -1) { + newSelected = selected.filter(id => id !== value); + } else { + newSelected = [value, ...selected]; + } - const firstCharIds = []; - const firstChars = []; - // This really only works since the ids are strings - Object.entries(firstCharMap.current).forEach(([nodeId, firstChar]) => { - const map = nodeMap.current[nodeId]; - const visible = map.parent ? isExpanded(map.parent) : true; + if (onNodeSelect) { + onNodeSelect(event, newSelected); + } - if (visible) { - firstCharIds.push(nodeId); - firstChars.push(firstChar); - } - }); + setSelectedState(newSelected); + }; - // Get start index for search based on position of currentItem - start = firstCharIds.indexOf(id) + 1; - if (start === nodeMap.current.length) { - start = 0; + const handleSingleSelect = (event, value) => { + const newSelected = multiSelect ? [value] : value; + + if (onNodeSelect) { + onNodeSelect(event, newSelected); } - // Check remaining slots in the menu - index = getIndexFirstChars(firstChars, start, lowercaseChar); + setSelectedState(newSelected); + }; - // If not found in remaining slots, check from beginning - if (index === -1) { - index = getIndexFirstChars(firstChars, 0, lowercaseChar); + const selectNode = (event, id, multiple = false) => { + if (id) { + if (multiple) { + handleMultipleSelect(event, id); + } else { + handleSingleSelect(event, id); + } + lastSelectedNode.current = id; + lastSelectionWasRange.current = false; + currentRangeSelection.current = []; + } + }; + + const selectRange = (event, nodes, stacked = false) => { + const { start = lastSelectedNode.current, end, current } = nodes; + if (stacked) { + handleRangeArrowSelect(event, { start, next: end, current }); + } else { + handleRangeSelect(event, { start, end }); } + lastSelectionWasRange.current = true; + }; - // If match was found... - if (index > -1) { - focus(firstCharIds[index]); + const rangeSelectToFirst = (event, id) => { + if (!lastSelectedNode.current) { + lastSelectedNode.current = id; } + + const start = lastSelectionWasRange.current ? lastSelectedNode.current : id; + + selectRange(event, { + start, + end: getFirstNode(), + }); }; + const rangeSelectToLast = (event, id) => { + if (!lastSelectedNode.current) { + lastSelectedNode.current = id; + } + + const start = lastSelectionWasRange.current ? lastSelectedNode.current : id; + + selectRange(event, { + start, + end: getLastNode(), + }); + }; + + const selectNextNode = (event, id) => + selectRange( + event, + { + end: getNextNode(id), + current: id, + }, + true, + ); + + const selectPreviousNode = (event, id) => + selectRange( + event, + { + end: getPreviousNode(id), + current: id, + }, + true, + ); + + const selectAllNodes = event => selectRange(event, { start: getFirstNode(), end: getLastNode() }); + + /* + * Mapping Helpers + */ + const addNodeToNodeMap = (id, childrenIds) => { const currentMap = nodeMap.current[id]; nodeMap.current[id] = { ...currentMap, children: childrenIds, id }; + childrenIds.forEach(childId => { const currentChildMap = nodeMap.current[childId]; nodeMap.current[childId] = { ...currentChildMap, parent: id, id: childId }; @@ -297,32 +410,86 @@ const TreeView = React.forwardRef(function TreeView(props, ref) { } }; - const handleFirstChars = (id, firstChar) => { + const mapFirstChar = (id, firstChar) => { firstCharMap.current[id] = firstChar; }; + const prevChildIds = React.useRef([]); + const [childrenCalculated, setChildrenCalculated] = React.useState(false); + React.useEffect(() => { + const childIds = React.Children.map(children, child => child.props.nodeId) || []; + if (arrayDiff(prevChildIds.current, childIds)) { + nodeMap.current[-1] = { parent: null, children: childIds }; + + childIds.forEach((id, index) => { + if (index === 0) { + setTabbable(id); + } + nodeMap.current[id] = { parent: null }; + }); + visibleNodes.current = nodeMap.current[-1].children; + prevChildIds.current = childIds; + setChildrenCalculated(true); + } + }, [children]); + + React.useEffect(() => { + const buildVisible = nodes => { + let list = []; + for (let i = 0; i < nodes.length; i += 1) { + const item = nodes[i]; + list.push(item); + const childs = nodeMap.current[item].children; + if (isExpanded(item) && childs) { + list = list.concat(buildVisible(childs)); + } + } + return list; + }; + + if (childrenCalculated) { + visibleNodes.current = buildVisible(nodeMap.current[-1].children); + } + }, [expanded, childrenCalculated, isExpanded]); + return ( -
      +
        {children}
      @@ -369,10 +536,31 @@ TreeView.propTypes = { * parent nodes and can be overridden by the TreeItem `icon` prop. */ defaultParentIcon: PropTypes.node, + /** + * Selected node ids. (Uncontrolled) + * When `multiSelect` is true this takes an array of strings; when false (default) a string. + */ + defaultSelected: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.string), PropTypes.string]), + /** + * If `true` selection is disabled. + */ + disableSelection: PropTypes.bool, /** * Expanded node ids. (Controlled) */ expanded: PropTypes.arrayOf(PropTypes.string), + /** + * If true `ctrl` and `shift` will trigger multiselect. + */ + multiSelect: PropTypes.bool, + /** + * Callback fired when tree items are selected/unselected. + * + * @param {object} event The event source of the callback + * @param {(array|string)} value of the selected nodes. When `multiSelect` is true + * this is an array of strings; when false (default) a string. + */ + onNodeSelect: PropTypes.func, /** * Callback fired when tree items are expanded/collapsed. * @@ -380,6 +568,11 @@ TreeView.propTypes = { * @param {array} nodeIds The ids of the expanded nodes. */ onNodeToggle: PropTypes.func, + /** + * Selected node ids. (Controlled) + * When `multiSelect` is true this takes an array of strings; when false (default) a string. + */ + selected: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.string), PropTypes.string]), }; export default withStyles(styles, { name: 'MuiTreeView' })(TreeView); diff --git a/packages/material-ui-lab/src/TreeView/TreeView.test.js b/packages/material-ui-lab/src/TreeView/TreeView.test.js index c3ee321a504ac7..ac98a5179bfe69 100644 --- a/packages/material-ui-lab/src/TreeView/TreeView.test.js +++ b/packages/material-ui-lab/src/TreeView/TreeView.test.js @@ -37,7 +37,7 @@ describe('', () => { consoleErrorMock.reset(); }); - it('should warn when switching from controlled to uncontrolled', () => { + it('should warn when switching from controlled to uncontrolled of the expanded prop', () => { const { setProps } = render( @@ -46,12 +46,25 @@ describe('', () => { setProps({ expanded: undefined }); expect(consoleErrorMock.args()[0][0]).to.include( - 'A component is changing a controlled TreeView to be uncontrolled.', + 'A component is changing a controlled TreeView to be uncontrolled', + ); + }); + + it('should warn when switching from controlled to uncontrolled of the selected prop', () => { + const { setProps } = render( + + + , + ); + + setProps({ selected: 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', () => { + it('should be able to be controlled with the expanded prop', () => { function MyComponent() { const [expandedState, setExpandedState] = React.useState([]); const handleNodeToggle = (event, nodes) => { @@ -77,6 +90,58 @@ describe('', () => { expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); }); + it('should be able to be controlled with the selected prop and singleSelect', () => { + function MyComponent() { + const [selectedState, setSelectedState] = React.useState(null); + const handleNodeSelect = (event, nodes) => { + setSelectedState(nodes); + }; + return ( + + + + + ); + } + + const { getByTestId, getByText } = render(); + + expect(getByTestId('one')).to.have.attribute('aria-selected', 'false'); + expect(getByTestId('two')).to.have.attribute('aria-selected', 'false'); + fireEvent.click(getByText('one')); + expect(getByTestId('one')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('two')).to.have.attribute('aria-selected', 'false'); + fireEvent.click(getByText('two')); + expect(getByTestId('one')).to.have.attribute('aria-selected', 'false'); + expect(getByTestId('two')).to.have.attribute('aria-selected', 'true'); + }); + + it('should be able to be controlled with the selected prop and multiSelect', () => { + function MyComponent() { + const [selectedState, setSelectedState] = React.useState([]); + const handleNodeSelect = (event, nodes) => { + setSelectedState(nodes); + }; + return ( + + + + + ); + } + + const { getByTestId, getByText } = render(); + + expect(getByTestId('one')).to.have.attribute('aria-selected', 'false'); + expect(getByTestId('two')).to.have.attribute('aria-selected', 'false'); + fireEvent.click(getByText('one')); + expect(getByTestId('one')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('two')).to.have.attribute('aria-selected', 'false'); + fireEvent.click(getByText('two'), { ctrlKey: true }); + expect(getByTestId('one')).to.have.attribute('aria-selected', 'true'); + expect(getByTestId('two')).to.have.attribute('aria-selected', 'true'); + }); + it('should not error when component state changes', () => { function MyComponent() { const [, setState] = React.useState(1); @@ -134,5 +199,17 @@ describe('', () => { expect(getByRole('tree')).to.be.ok; }); + + it('(TreeView) should have the attribute `aria-multiselectable=false if using single select`', () => { + const { getByRole } = render(); + + expect(getByRole('tree')).to.have.attribute('aria-multiselectable', 'false'); + }); + + it('(TreeView) should have the attribute `aria-multiselectable=true if using multi select`', () => { + const { getByRole } = render(); + + expect(getByRole('tree')).to.have.attribute('aria-multiselectable', 'true'); + }); }); });