@mui/lab
Version:
Laboratory for new MUI modules.
982 lines (809 loc) • 26.2 kB
JavaScript
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;