UNPKG

@mui/lab

Version:
982 lines (809 loc) 26.2 kB
import _extends from "@babel/runtime/helpers/esm/extends"; import _objectWithoutPropertiesLoose from "@babel/runtime/helpers/esm/objectWithoutPropertiesLoose"; const _excluded = ["children", "className", "defaultCollapseIcon", "defaultEndIcon", "defaultExpanded", "defaultExpandIcon", "defaultParentIcon", "defaultSelected", "disabledItemsFocusable", "disableSelection", "expanded", "id", "multiSelect", "onBlur", "onFocus", "onKeyDown", "onNodeFocus", "onNodeSelect", "onNodeToggle", "selected"]; import * as React from 'react'; import clsx from 'clsx'; import PropTypes from 'prop-types'; import { styled, useTheme, useThemeProps } from '@mui/material/styles'; import { unstable_composeClasses as composeClasses } from '@mui/base'; import { useControlled, useForkRef, ownerDocument, unstable_useId as useId } from '@mui/material/utils'; import TreeViewContext from './TreeViewContext'; import { DescendantProvider } from './descendants'; import { getTreeViewUtilityClass } from './treeViewClasses'; import { jsx as _jsx } from "react/jsx-runtime"; const useUtilityClasses = ownerState => { const { classes } = ownerState; const slots = { root: ['root'] }; return composeClasses(slots, getTreeViewUtilityClass, classes); }; const TreeViewRoot = styled('ul', { name: 'MuiTreeView', slot: 'Root', overridesResolver: (props, styles) => styles.root })({ padding: 0, margin: 0, listStyle: 'none', outline: 0 }); function isPrintableCharacter(string) { return string && string.length === 1 && string.match(/\S/); } function findNextFirstChar(firstChars, startIndex, char) { for (let i = startIndex; i < firstChars.length; i += 1) { if (char === firstChars[i]) { return i; } } return -1; } function noopSelection() { return false; } const defaultDefaultExpanded = []; const defaultDefaultSelected = []; const TreeView = /*#__PURE__*/React.forwardRef(function TreeView(inProps, ref) { const props = useThemeProps({ props: inProps, name: 'MuiTreeView' }); const { children, className, defaultCollapseIcon, defaultEndIcon, defaultExpanded = defaultDefaultExpanded, defaultExpandIcon, defaultParentIcon, defaultSelected = defaultDefaultSelected, disabledItemsFocusable = false, disableSelection = false, expanded: expandedProp, id: idProp, multiSelect = false, onBlur, onFocus, onKeyDown, onNodeFocus, onNodeSelect, onNodeToggle, selected: selectedProp } = props, other = _objectWithoutPropertiesLoose(props, _excluded); const theme = useTheme(); const isRtl = theme.direction === 'rtl'; const ownerState = _extends({}, props, { defaultExpanded, defaultSelected, disabledItemsFocusable, disableSelection, multiSelect }); const classes = useUtilityClasses(ownerState); const treeId = useId(idProp); const treeRef = React.useRef(null); const handleRef = useForkRef(treeRef, ref); const [focusedNodeId, setFocusedNodeId] = React.useState(null); const nodeMap = React.useRef({}); const firstCharMap = React.useRef({}); const [expanded, setExpandedState] = useControlled({ controlled: expandedProp, default: defaultExpanded, name: 'TreeView', state: 'expanded' }); const [selected, setSelectedState] = useControlled({ controlled: selectedProp, default: defaultSelected, name: 'TreeView', state: 'selected' }); /* * Status Helpers */ const isExpanded = React.useCallback(id => Array.isArray(expanded) ? expanded.indexOf(id) !== -1 : false, [expanded]); const isExpandable = React.useCallback(id => nodeMap.current[id] && nodeMap.current[id].expandable, []); const isSelected = React.useCallback(id => Array.isArray(selected) ? selected.indexOf(id) !== -1 : selected === id, [selected]); const isDisabled = React.useCallback(id => { let node = nodeMap.current[id]; // This can be called before the node has been added to the node map. if (!node) { return false; } if (node.disabled) { return true; } while (node.parentId != null) { node = nodeMap.current[node.parentId]; if (node.disabled) { return true; } } return false; }, []); const isFocused = id => focusedNodeId === id; /* * Child Helpers */ // Using Object.keys -> .map to mimic Object.values we should replace with Object.values() once we stop IE11 support. const getChildrenIds = id => Object.keys(nodeMap.current).map(key => { return nodeMap.current[key]; }).filter(node => node.parentId === id).sort((a, b) => a.index - b.index).map(child => child.id); const getNavigableChildrenIds = id => { let childrenIds = getChildrenIds(id); if (!disabledItemsFocusable) { childrenIds = childrenIds.filter(node => !isDisabled(node)); } return childrenIds; }; /* * Node Helpers */ const getNextNode = id => { // If expanded get first child if (isExpanded(id) && getNavigableChildrenIds(id).length > 0) { return getNavigableChildrenIds(id)[0]; } let node = nodeMap.current[id]; while (node != null) { // Try to get next sibling const siblings = getNavigableChildrenIds(node.parentId); const nextSibling = siblings[siblings.indexOf(node.id) + 1]; if (nextSibling) { return nextSibling; } // If the sibling does not exist, go up a level to the parent and try again. node = nodeMap.current[node.parentId]; } return null; }; const getPreviousNode = id => { const node = nodeMap.current[id]; const siblings = getNavigableChildrenIds(node.parentId); const nodeIndex = siblings.indexOf(id); if (nodeIndex === 0) { return node.parentId; } let currentNode = siblings[nodeIndex - 1]; while (isExpanded(currentNode) && getNavigableChildrenIds(currentNode).length > 0) { currentNode = getNavigableChildrenIds(currentNode).pop(); } return currentNode; }; const getLastNode = () => { let lastNode = getNavigableChildrenIds(null).pop(); while (isExpanded(lastNode)) { lastNode = getNavigableChildrenIds(lastNode).pop(); } return lastNode; }; const getFirstNode = () => getNavigableChildrenIds(null)[0]; const getParent = id => nodeMap.current[id].parentId; /** * This is used to determine the start and end of a selection range so * we can get the nodes between the two border nodes. * * It finds the nodes' common ancestor using * a naive implementation of a lowest common ancestor algorithm * (https://en.wikipedia.org/wiki/Lowest_common_ancestor). * Then compares the ancestor's 2 children that are ancestors of nodeA and NodeB * so we can compare their indexes to work out which node comes first in a depth first search. * (https://en.wikipedia.org/wiki/Depth-first_search) * * Another way to put it is which node is shallower in a trémaux tree * https://en.wikipedia.org/wiki/Tr%C3%A9maux_tree */ const findOrderInTremauxTree = (nodeAId, nodeBId) => { if (nodeAId === nodeBId) { return [nodeAId, nodeBId]; } const nodeA = nodeMap.current[nodeAId]; const nodeB = nodeMap.current[nodeBId]; if (nodeA.parentId === nodeB.id || nodeB.parentId === nodeA.id) { return nodeB.parentId === nodeA.id ? [nodeA.id, nodeB.id] : [nodeB.id, nodeA.id]; } const aFamily = [nodeA.id]; const bFamily = [nodeB.id]; let aAncestor = nodeA.parentId; let bAncestor = nodeB.parentId; let aAncestorIsCommon = bFamily.indexOf(aAncestor) !== -1; let bAncestorIsCommon = aFamily.indexOf(bAncestor) !== -1; let continueA = true; let continueB = true; while (!bAncestorIsCommon && !aAncestorIsCommon) { if (continueA) { aFamily.push(aAncestor); aAncestorIsCommon = bFamily.indexOf(aAncestor) !== -1; continueA = aAncestor !== null; if (!aAncestorIsCommon && continueA) { aAncestor = nodeMap.current[aAncestor].parentId; } } if (continueB && !aAncestorIsCommon) { bFamily.push(bAncestor); bAncestorIsCommon = aFamily.indexOf(bAncestor) !== -1; continueB = bAncestor !== null; if (!bAncestorIsCommon && continueB) { bAncestor = nodeMap.current[bAncestor].parentId; } } } const commonAncestor = aAncestorIsCommon ? aAncestor : bAncestor; const ancestorFamily = getChildrenIds(commonAncestor); const aSide = aFamily[aFamily.indexOf(commonAncestor) - 1]; const bSide = bFamily[bFamily.indexOf(commonAncestor) - 1]; return ancestorFamily.indexOf(aSide) < ancestorFamily.indexOf(bSide) ? [nodeAId, nodeBId] : [nodeBId, nodeAId]; }; const getNodesInRange = (nodeA, nodeB) => { const [first, last] = findOrderInTremauxTree(nodeA, nodeB); const nodes = [first]; let current = first; while (current !== last) { current = getNextNode(current); nodes.push(current); } return nodes; }; /* * Focus Helpers */ const focus = (event, id) => { if (id) { setFocusedNodeId(id); if (onNodeFocus) { onNodeFocus(event, id); } } }; const focusNextNode = (event, id) => focus(event, getNextNode(id)); const focusPreviousNode = (event, id) => focus(event, getPreviousNode(id)); const focusFirstNode = event => focus(event, getFirstNode()); const focusLastNode = event => focus(event, getLastNode()); const focusByFirstCharacter = (event, 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.parentId ? isExpanded(map.parentId) : true; const shouldBeSkipped = disabledItemsFocusable ? false : isDisabled(nodeId); if (visible && !shouldBeSkipped) { firstCharIds.push(nodeId); firstChars.push(firstChar); } }); // Get start index for search based on position of currentItem start = firstCharIds.indexOf(id) + 1; if (start >= firstCharIds.length) { start = 0; } // 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); } // If match was found... if (index > -1) { focus(event, firstCharIds[index]); } }; /* * Expansion Helpers */ const toggleExpansion = (event, value = focusedNodeId) => { let newExpanded; if (expanded.indexOf(value) !== -1) { newExpanded = expanded.filter(id => id !== value); } else { newExpanded = [value].concat(expanded); } if (onNodeToggle) { onNodeToggle(event, newExpanded); } setExpandedState(newExpanded); }; const expandAllSiblings = (event, id) => { const map = nodeMap.current[id]; const siblings = getChildrenIds(map.parentId); const diff = siblings.filter(child => isExpandable(child) && !isExpanded(child)); const newExpanded = expanded.concat(diff); if (diff.length > 0) { setExpandedState(newExpanded); if (onNodeToggle) { onNodeToggle(event, newExpanded); } } }; /* * Selection Helpers */ const lastSelectedNode = React.useRef(null); const lastSelectionWasRange = React.useRef(false); const currentRangeSelection = React.useRef([]); const handleRangeArrowSelect = (event, nodes) => { let base = selected.slice(); 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 (onNodeSelect) { onNodeSelect(event, base); } setSelectedState(base); }; const handleRangeSelect = (event, nodes) => { let base = selected.slice(); const { start, end } = nodes; // If last selection was a range selection ignore nodes that were selected. if (lastSelectionWasRange.current) { base = base.filter(id => currentRangeSelection.current.indexOf(id) === -1); } let range = getNodesInRange(start, end); range = range.filter(node => !isDisabled(node)); currentRangeSelection.current = range; let newSelected = base.concat(range); newSelected = newSelected.filter((id, i) => newSelected.indexOf(id) === i); if (onNodeSelect) { onNodeSelect(event, newSelected); } setSelectedState(newSelected); }; const handleMultipleSelect = (event, value) => { let newSelected; if (selected.indexOf(value) !== -1) { newSelected = selected.filter(id => id !== value); } else { newSelected = [value].concat(selected); } if (onNodeSelect) { onNodeSelect(event, newSelected); } setSelectedState(newSelected); }; const handleSingleSelect = (event, value) => { const newSelected = multiSelect ? [value] : value; if (onNodeSelect) { onNodeSelect(event, newSelected); } setSelectedState(newSelected); }; 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 = []; return true; } return false; }; const selectRange = (event, nodes, stacked = false) => { const { start = lastSelectedNode.current, end, current } = nodes; if (stacked) { handleRangeArrowSelect(event, { start, next: end, current }); } else if (start != null && end != null) { handleRangeSelect(event, { start, end }); } lastSelectionWasRange.current = true; }; 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) => { if (!isDisabled(getNextNode(id))) { selectRange(event, { end: getNextNode(id), current: id }, true); } }; const selectPreviousNode = (event, id) => { if (!isDisabled(getPreviousNode(id))) { selectRange(event, { end: getPreviousNode(id), current: id }, true); } }; const selectAllNodes = event => { selectRange(event, { start: getFirstNode(), end: getLastNode() }); }; /* * Mapping Helpers */ const registerNode = React.useCallback(node => { const { id, index, parentId, expandable, idAttribute, disabled } = node; nodeMap.current[id] = { id, index, parentId, expandable, idAttribute, disabled }; }, []); const unregisterNode = React.useCallback(id => { const newMap = _extends({}, nodeMap.current); delete newMap[id]; nodeMap.current = newMap; setFocusedNodeId(oldFocusedNodeId => { if (oldFocusedNodeId === id && treeRef.current === ownerDocument(treeRef.current).activeElement) { return getChildrenIds(null)[0]; } return oldFocusedNodeId; }); }, []); const mapFirstChar = React.useCallback((id, firstChar) => { firstCharMap.current[id] = firstChar; }, []); const unMapFirstChar = React.useCallback(id => { const newMap = _extends({}, firstCharMap.current); delete newMap[id]; firstCharMap.current = newMap; }, []); /** * Event handlers and Navigation */ const handleNextArrow = event => { if (isExpandable(focusedNodeId)) { if (isExpanded(focusedNodeId)) { focusNextNode(event, focusedNodeId); } else if (!isDisabled(focusedNodeId)) { toggleExpansion(event); } } return true; }; const handlePreviousArrow = event => { if (isExpanded(focusedNodeId) && !isDisabled(focusedNodeId)) { toggleExpansion(event, focusedNodeId); return true; } const parent = getParent(focusedNodeId); if (parent) { focus(event, parent); return true; } return false; }; const handleKeyDown = event => { let flag = false; const key = event.key; // If the tree is empty there will be no focused node if (event.altKey || event.currentTarget !== event.target || !focusedNodeId) { return; } const ctrlPressed = event.ctrlKey || event.metaKey; switch (key) { case ' ': if (!disableSelection && !isDisabled(focusedNodeId)) { if (multiSelect && event.shiftKey) { selectRange(event, { end: focusedNodeId }); flag = true; } else if (multiSelect) { flag = selectNode(event, focusedNodeId, true); } else { flag = selectNode(event, focusedNodeId); } } event.stopPropagation(); break; case 'Enter': if (!isDisabled(focusedNodeId)) { if (isExpandable(focusedNodeId)) { toggleExpansion(event); flag = true; } else if (multiSelect) { flag = selectNode(event, focusedNodeId, true); } else { flag = selectNode(event, focusedNodeId); } } event.stopPropagation(); break; case 'ArrowDown': if (multiSelect && event.shiftKey && !disableSelection) { selectNextNode(event, focusedNodeId); } focusNextNode(event, focusedNodeId); flag = true; break; case 'ArrowUp': if (multiSelect && event.shiftKey && !disableSelection) { selectPreviousNode(event, focusedNodeId); } focusPreviousNode(event, focusedNodeId); flag = true; break; case 'ArrowRight': if (isRtl) { flag = handlePreviousArrow(event); } else { flag = handleNextArrow(event); } break; case 'ArrowLeft': if (isRtl) { flag = handleNextArrow(event); } else { flag = handlePreviousArrow(event); } break; case 'Home': if (multiSelect && ctrlPressed && event.shiftKey && !disableSelection && !isDisabled(focusedNodeId)) { rangeSelectToFirst(event, focusedNodeId); } focusFirstNode(event); flag = true; break; case 'End': if (multiSelect && ctrlPressed && event.shiftKey && !disableSelection && !isDisabled(focusedNodeId)) { rangeSelectToLast(event, focusedNodeId); } focusLastNode(event); flag = true; break; default: if (key === '*') { expandAllSiblings(event, focusedNodeId); flag = true; } else if (multiSelect && ctrlPressed && key.toLowerCase() === 'a' && !disableSelection) { selectAllNodes(event); flag = true; } else if (!ctrlPressed && !event.shiftKey && isPrintableCharacter(key)) { focusByFirstCharacter(event, focusedNodeId, key); flag = true; } } if (flag) { event.preventDefault(); event.stopPropagation(); } if (onKeyDown) { onKeyDown(event); } }; const handleFocus = event => { // if the event bubbled (which is React specific) we don't want to steal focus if (event.target === event.currentTarget) { const firstSelected = Array.isArray(selected) ? selected[0] : selected; focus(event, firstSelected || getNavigableChildrenIds(null)[0]); } if (onFocus) { onFocus(event); } }; const handleBlur = event => { setFocusedNodeId(null); if (onBlur) { onBlur(event); } }; const activeDescendant = nodeMap.current[focusedNodeId] ? nodeMap.current[focusedNodeId].idAttribute : null; return /*#__PURE__*/_jsx(TreeViewContext.Provider, { value: { icons: { defaultCollapseIcon, defaultExpandIcon, defaultParentIcon, defaultEndIcon }, focus, toggleExpansion, isExpanded, isExpandable, isFocused, isSelected, isDisabled, selectNode: disableSelection ? noopSelection : selectNode, selectRange: disableSelection ? noopSelection : selectRange, multiSelect, disabledItemsFocusable, mapFirstChar, unMapFirstChar, registerNode, unregisterNode, treeId }, children: /*#__PURE__*/_jsx(DescendantProvider, { children: /*#__PURE__*/_jsx(TreeViewRoot, _extends({ role: "tree", id: treeId, "aria-activedescendant": activeDescendant, "aria-multiselectable": multiSelect, className: clsx(classes.root, className), ref: handleRef, tabIndex: 0, onKeyDown: handleKeyDown, onFocus: handleFocus, onBlur: handleBlur, ownerState: ownerState }, other, { children: children })) }) }); }); process.env.NODE_ENV !== "production" ? TreeView.propTypes /* remove-proptypes */ = { // ----------------------------- Warning -------------------------------- // | These PropTypes are generated from the TypeScript type definitions | // | To update them edit the d.ts file and run "yarn proptypes" | // ---------------------------------------------------------------------- /** * The content of the component. */ children: PropTypes.node, /** * Override or extend the styles applied to the component. */ classes: PropTypes.object, /** * @ignore */ className: PropTypes.string, /** * The default icon used to collapse the node. */ defaultCollapseIcon: PropTypes.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. */ defaultEndIcon: PropTypes.node, /** * Expanded node ids. (Uncontrolled) * @default [] */ defaultExpanded: PropTypes.arrayOf(PropTypes.string), /** * The default icon used to expand the node. */ defaultExpandIcon: PropTypes.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. */ defaultParentIcon: PropTypes.node, /** * Selected node ids. (Uncontrolled) * When `multiSelect` is true this takes an array of strings; when false (default) a string. * @default [] */ defaultSelected: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.string), PropTypes.string]), /** * If `true`, will allow focus on disabled items. * @default false */ disabledItemsFocusable: PropTypes.bool, /** * If `true` selection is disabled. * @default false */ disableSelection: PropTypes.bool, /** * Expanded node ids. (Controlled) */ expanded: PropTypes.arrayOf(PropTypes.string), /** * This prop is used to help implement the accessibility logic. * If you don't provide this prop. It falls back to a randomly generated id. */ id: PropTypes.string, /** * If true `ctrl` and `shift` will trigger multiselect. * @default false */ multiSelect: PropTypes.bool, /** * @ignore */ onBlur: PropTypes.func, /** * @ignore */ onFocus: PropTypes.func, /** * @ignore */ onKeyDown: PropTypes.func, /** * Callback fired when tree items are focused. * * @param {React.SyntheticEvent} event The event source of the callback **Warning**: This is a generic event not a focus event. * @param {string} value of the focused node. */ onNodeFocus: PropTypes.func, /** * Callback fired when tree items are selected/unselected. * * @param {React.SyntheticEvent} event The event source of the callback * @param {string[] | string} nodeIds Ids 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. * * @param {React.SyntheticEvent} event The event source of the callback. * @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]), /** * The system prop that allows defining system overrides as well as additional CSS styles. */ sx: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), PropTypes.func, PropTypes.object]) } : void 0; export default TreeView;