@material-ui/lab
Version:
Material-UI Lab - Incubator for Material-UI React components.
677 lines (558 loc) • 17.9 kB
JavaScript
import _extends from "@babel/runtime/helpers/esm/extends";
import _objectWithoutPropertiesLoose from "@babel/runtime/helpers/esm/objectWithoutPropertiesLoose";
import * as React from 'react';
import clsx from 'clsx';
import PropTypes from 'prop-types';
import { withStyles } from '@material-ui/core/styles';
import { useControlled } from '@material-ui/core/utils';
import TreeViewContext from './TreeViewContext';
export const styles = {
/* Styles applied to the root element. */
root: {
padding: 0,
margin: 0,
listStyle: 'none'
}
};
function arrayDiff(arr1, arr2) {
if (arr1.length !== arr2.length) return true;
for (let i = 0; i < arr1.length; i += 1) {
if (arr1[i] !== arr2[i]) return true;
}
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 = /*#__PURE__*/React.forwardRef(function TreeView(props, ref) {
const {
children,
classes,
className,
defaultCollapseIcon,
defaultEndIcon,
defaultExpanded = defaultExpandedDefault,
defaultExpandIcon,
defaultParentIcon,
defaultSelected = defaultSelectedDefault,
disableSelection = false,
multiSelect = false,
expanded: expandedProp,
onNodeSelect,
onNodeToggle,
selected: selectedProp
} = props,
other = _objectWithoutPropertiesLoose(props, ["children", "classes", "className", "defaultCollapseIcon", "defaultEndIcon", "defaultExpanded", "defaultExpandIcon", "defaultParentIcon", "defaultSelected", "disableSelection", "multiSelect", "expanded", "onNodeSelect", "onNodeToggle", "selected"]);
const [tabbable, setTabbable] = React.useState(null);
const [focusedNodeId, setFocusedNodeId] = React.useState(null);
const nodeMap = React.useRef({});
const firstCharMap = React.useRef({});
const visibleNodes = 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 isSelected = React.useCallback(id => Array.isArray(selected) ? selected.indexOf(id) !== -1 : selected === id, [selected]);
const isTabbable = id => tabbable === id;
const isFocused = id => focusedNodeId === id;
/*
* Node Helpers
*/
const getNextNode = id => {
const nodeIndex = visibleNodes.current.indexOf(id);
if (nodeIndex !== -1 && nodeIndex + 1 < visibleNodes.current.length) {
return visibleNodes.current[nodeIndex + 1];
}
return null;
};
const getPreviousNode = id => {
const nodeIndex = visibleNodes.current.indexOf(id);
if (nodeIndex !== -1 && nodeIndex - 1 >= 0) {
return visibleNodes.current[nodeIndex - 1];
}
return null;
};
const getLastNode = () => visibleNodes.current[visibleNodes.current.length - 1];
const getFirstNode = () => visibleNodes.current[0];
const getParent = id => nodeMap.current[id].parent;
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);
};
/*
* Focus Helpers
*/
const focus = id => {
if (id) {
setTabbable(id);
setFocusedNodeId(id);
}
};
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;
} // 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(firstCharIds[index]);
}
};
/*
* Expansion Helpers
*/
const toggleExpansion = (event, value = focusedNodeId) => {
let newExpanded;
if (expanded.indexOf(value) !== -1) {
newExpanded = expanded.filter(id => id !== value);
setTabbable(oldTabbable => {
const map = nodeMap.current[oldTabbable];
if (oldTabbable && (map && map.parent ? map.parent.id : null) === value) {
return value;
}
return oldTabbable;
});
} else {
newExpanded = [value].concat(expanded);
}
if (onNodeToggle) {
onNodeToggle(event, newExpanded);
}
setExpandedState(newExpanded);
};
const expandAllSiblings = (event, id) => {
const map = nodeMap.current[id];
const parent = nodeMap.current[map.parent];
let diff;
if (parent) {
diff = parent.children.filter(child => !isExpanded(child));
} else {
const topLevelNodes = nodeMap.current[-1].children;
diff = topLevelNodes.filter(node => !isExpanded(node));
}
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;
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;
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);
}
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 {
handleRangeSelect(event, {
start,
end
});
}
lastSelectionWasRange.current = true;
return true;
};
const rangeSelectToFirst = (event, id) => {
if (!lastSelectedNode.current) {
lastSelectedNode.current = id;
}
const start = lastSelectionWasRange.current ? lastSelectedNode.current : id;
return selectRange(event, {
start,
end: getFirstNode()
});
};
const rangeSelectToLast = (event, id) => {
if (!lastSelectedNode.current) {
lastSelectedNode.current = id;
}
const start = lastSelectionWasRange.current ? lastSelectedNode.current : id;
return 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] = _extends({}, currentMap, {
children: childrenIds,
id
});
childrenIds.forEach(childId => {
const currentChildMap = nodeMap.current[childId];
nodeMap.current[childId] = _extends({}, currentChildMap, {
parent: id,
id: childId
});
});
};
const getNodesToRemove = React.useCallback(id => {
const map = nodeMap.current[id];
const nodes = [];
if (map) {
nodes.push(id);
if (map.children) {
nodes.concat(map.children);
map.children.forEach(node => {
nodes.concat(getNodesToRemove(node));
});
}
}
return nodes;
}, []);
const cleanUpFirstCharMap = React.useCallback(nodes => {
const newMap = _extends({}, firstCharMap.current);
nodes.forEach(node => {
if (newMap[node]) {
delete newMap[node];
}
});
firstCharMap.current = newMap;
}, []);
const removeNodeFromNodeMap = React.useCallback(id => {
const nodes = getNodesToRemove(id);
cleanUpFirstCharMap(nodes);
const newMap = _extends({}, nodeMap.current);
nodes.forEach(node => {
const map = newMap[node];
if (map) {
if (map.parent) {
const parentMap = newMap[map.parent];
if (parentMap && parentMap.children) {
const parentChildren = parentMap.children.filter(c => c !== node);
newMap[map.parent] = _extends({}, parentMap, {
children: parentChildren
});
}
}
delete newMap[node];
}
});
nodeMap.current = newMap;
setFocusedNodeId(oldFocusedNodeId => {
if (oldFocusedNodeId === id) {
return null;
}
return oldFocusedNodeId;
});
}, [getNodesToRemove, cleanUpFirstCharMap]);
const mapFirstChar = (id, firstChar) => {
firstCharMap.current[id] = firstChar;
};
const prevChildIds = React.useRef([]);
const [childrenCalculated, setChildrenCalculated] = React.useState(false);
React.useEffect(() => {
const childIds = [];
React.Children.forEach(children, child => {
if ( /*#__PURE__*/React.isValidElement(child) && child.props.nodeId) {
childIds.push(child.props.nodeId);
}
});
if (arrayDiff(prevChildIds.current, childIds)) {
nodeMap.current[-1] = {
parent: null,
children: childIds
};
childIds.forEach((id, index) => {
if (index === 0) {
setTabbable(id);
}
});
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, children]);
const noopSelection = () => {
return false;
};
return /*#__PURE__*/React.createElement(TreeViewContext.Provider, {
value: {
icons: {
defaultCollapseIcon,
defaultExpandIcon,
defaultParentIcon,
defaultEndIcon
},
focus,
focusFirstNode,
focusLastNode,
focusNextNode,
focusPreviousNode,
focusByFirstCharacter,
expandAllSiblings,
toggleExpansion,
isExpanded,
isFocused,
isSelected,
selectNode: disableSelection ? noopSelection : selectNode,
selectRange: disableSelection ? noopSelection : selectRange,
selectNextNode: disableSelection ? noopSelection : selectNextNode,
selectPreviousNode: disableSelection ? noopSelection : selectPreviousNode,
rangeSelectToFirst: disableSelection ? noopSelection : rangeSelectToFirst,
rangeSelectToLast: disableSelection ? noopSelection : rangeSelectToLast,
selectAllNodes: disableSelection ? noopSelection : selectAllNodes,
isTabbable,
multiSelect,
getParent,
mapFirstChar,
addNodeToNodeMap,
removeNodeFromNodeMap
}
}, /*#__PURE__*/React.createElement("ul", _extends({
role: "tree",
"aria-multiselectable": multiSelect,
className: clsx(classes.root, className),
ref: ref
}, other), children));
});
process.env.NODE_ENV !== "production" ? TreeView.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.
* See [CSS API](#css) below for more details.
*/
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)
*/
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.
*/
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.
*
* @param {object} 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])
} : void 0;
export default withStyles(styles, {
name: 'MuiTreeView'
})(TreeView);