UNPKG

17.9 kBJavaScriptView Raw
1import _extends from "@babel/runtime/helpers/esm/extends";
2import _objectWithoutPropertiesLoose from "@babel/runtime/helpers/esm/objectWithoutPropertiesLoose";
3import * as React from 'react';
4import clsx from 'clsx';
5import PropTypes from 'prop-types';
6import { withStyles } from '@material-ui/core/styles';
7import { useControlled } from '@material-ui/core/utils';
8import TreeViewContext from './TreeViewContext';
9export const styles = {
10 /* Styles applied to the root element. */
11 root: {
12 padding: 0,
13 margin: 0,
14 listStyle: 'none'
15 }
16};
17
18function arrayDiff(arr1, arr2) {
19 if (arr1.length !== arr2.length) return true;
20
21 for (let i = 0; i < arr1.length; i += 1) {
22 if (arr1[i] !== arr2[i]) return true;
23 }
24
25 return false;
26}
27
28const findNextFirstChar = (firstChars, startIndex, char) => {
29 for (let i = startIndex; i < firstChars.length; i += 1) {
30 if (char === firstChars[i]) {
31 return i;
32 }
33 }
34
35 return -1;
36};
37
38const defaultExpandedDefault = [];
39const defaultSelectedDefault = [];
40const TreeView = /*#__PURE__*/React.forwardRef(function TreeView(props, ref) {
41 const {
42 children,
43 classes,
44 className,
45 defaultCollapseIcon,
46 defaultEndIcon,
47 defaultExpanded = defaultExpandedDefault,
48 defaultExpandIcon,
49 defaultParentIcon,
50 defaultSelected = defaultSelectedDefault,
51 disableSelection = false,
52 multiSelect = false,
53 expanded: expandedProp,
54 onNodeSelect,
55 onNodeToggle,
56 selected: selectedProp
57 } = props,
58 other = _objectWithoutPropertiesLoose(props, ["children", "classes", "className", "defaultCollapseIcon", "defaultEndIcon", "defaultExpanded", "defaultExpandIcon", "defaultParentIcon", "defaultSelected", "disableSelection", "multiSelect", "expanded", "onNodeSelect", "onNodeToggle", "selected"]);
59
60 const [tabbable, setTabbable] = React.useState(null);
61 const [focusedNodeId, setFocusedNodeId] = React.useState(null);
62 const nodeMap = React.useRef({});
63 const firstCharMap = React.useRef({});
64 const visibleNodes = React.useRef([]);
65 const [expanded, setExpandedState] = useControlled({
66 controlled: expandedProp,
67 default: defaultExpanded,
68 name: 'TreeView',
69 state: 'expanded'
70 });
71 const [selected, setSelectedState] = useControlled({
72 controlled: selectedProp,
73 default: defaultSelected,
74 name: 'TreeView',
75 state: 'selected'
76 });
77 /*
78 * Status Helpers
79 */
80
81 const isExpanded = React.useCallback(id => Array.isArray(expanded) ? expanded.indexOf(id) !== -1 : false, [expanded]);
82 const isSelected = React.useCallback(id => Array.isArray(selected) ? selected.indexOf(id) !== -1 : selected === id, [selected]);
83
84 const isTabbable = id => tabbable === id;
85
86 const isFocused = id => focusedNodeId === id;
87 /*
88 * Node Helpers
89 */
90
91
92 const getNextNode = id => {
93 const nodeIndex = visibleNodes.current.indexOf(id);
94
95 if (nodeIndex !== -1 && nodeIndex + 1 < visibleNodes.current.length) {
96 return visibleNodes.current[nodeIndex + 1];
97 }
98
99 return null;
100 };
101
102 const getPreviousNode = id => {
103 const nodeIndex = visibleNodes.current.indexOf(id);
104
105 if (nodeIndex !== -1 && nodeIndex - 1 >= 0) {
106 return visibleNodes.current[nodeIndex - 1];
107 }
108
109 return null;
110 };
111
112 const getLastNode = () => visibleNodes.current[visibleNodes.current.length - 1];
113
114 const getFirstNode = () => visibleNodes.current[0];
115
116 const getParent = id => nodeMap.current[id].parent;
117
118 const getNodesInRange = (a, b) => {
119 const aIndex = visibleNodes.current.indexOf(a);
120 const bIndex = visibleNodes.current.indexOf(b);
121 const start = Math.min(aIndex, bIndex);
122 const end = Math.max(aIndex, bIndex);
123 return visibleNodes.current.slice(start, end + 1);
124 };
125 /*
126 * Focus Helpers
127 */
128
129
130 const focus = id => {
131 if (id) {
132 setTabbable(id);
133 setFocusedNodeId(id);
134 }
135 };
136
137 const focusNextNode = id => focus(getNextNode(id));
138
139 const focusPreviousNode = id => focus(getPreviousNode(id));
140
141 const focusFirstNode = () => focus(getFirstNode());
142
143 const focusLastNode = () => focus(getLastNode());
144
145 const focusByFirstCharacter = (id, char) => {
146 let start;
147 let index;
148 const lowercaseChar = char.toLowerCase();
149 const firstCharIds = [];
150 const firstChars = []; // This really only works since the ids are strings
151
152 Object.keys(firstCharMap.current).forEach(nodeId => {
153 const firstChar = firstCharMap.current[nodeId];
154 const map = nodeMap.current[nodeId];
155 const visible = map.parent ? isExpanded(map.parent) : true;
156
157 if (visible) {
158 firstCharIds.push(nodeId);
159 firstChars.push(firstChar);
160 }
161 }); // Get start index for search based on position of currentItem
162
163 start = firstCharIds.indexOf(id) + 1;
164
165 if (start === nodeMap.current.length) {
166 start = 0;
167 } // Check remaining slots in the menu
168
169
170 index = findNextFirstChar(firstChars, start, lowercaseChar); // If not found in remaining slots, check from beginning
171
172 if (index === -1) {
173 index = findNextFirstChar(firstChars, 0, lowercaseChar);
174 } // If match was found...
175
176
177 if (index > -1) {
178 focus(firstCharIds[index]);
179 }
180 };
181 /*
182 * Expansion Helpers
183 */
184
185
186 const toggleExpansion = (event, value = focusedNodeId) => {
187 let newExpanded;
188
189 if (expanded.indexOf(value) !== -1) {
190 newExpanded = expanded.filter(id => id !== value);
191 setTabbable(oldTabbable => {
192 const map = nodeMap.current[oldTabbable];
193
194 if (oldTabbable && (map && map.parent ? map.parent.id : null) === value) {
195 return value;
196 }
197
198 return oldTabbable;
199 });
200 } else {
201 newExpanded = [value].concat(expanded);
202 }
203
204 if (onNodeToggle) {
205 onNodeToggle(event, newExpanded);
206 }
207
208 setExpandedState(newExpanded);
209 };
210
211 const expandAllSiblings = (event, id) => {
212 const map = nodeMap.current[id];
213 const parent = nodeMap.current[map.parent];
214 let diff;
215
216 if (parent) {
217 diff = parent.children.filter(child => !isExpanded(child));
218 } else {
219 const topLevelNodes = nodeMap.current[-1].children;
220 diff = topLevelNodes.filter(node => !isExpanded(node));
221 }
222
223 const newExpanded = expanded.concat(diff);
224
225 if (diff.length > 0) {
226 setExpandedState(newExpanded);
227
228 if (onNodeToggle) {
229 onNodeToggle(event, newExpanded);
230 }
231 }
232 };
233 /*
234 * Selection Helpers
235 */
236
237
238 const lastSelectedNode = React.useRef(null);
239 const lastSelectionWasRange = React.useRef(false);
240 const currentRangeSelection = React.useRef([]);
241
242 const handleRangeArrowSelect = (event, nodes) => {
243 let base = selected;
244 const {
245 start,
246 next,
247 current
248 } = nodes;
249
250 if (!next || !current) {
251 return;
252 }
253
254 if (currentRangeSelection.current.indexOf(current) === -1) {
255 currentRangeSelection.current = [];
256 }
257
258 if (lastSelectionWasRange.current) {
259 if (currentRangeSelection.current.indexOf(next) !== -1) {
260 base = base.filter(id => id === start || id !== current);
261 currentRangeSelection.current = currentRangeSelection.current.filter(id => id === start || id !== current);
262 } else {
263 base.push(next);
264 currentRangeSelection.current.push(next);
265 }
266 } else {
267 base.push(next);
268 currentRangeSelection.current.push(current, next);
269 }
270
271 if (onNodeSelect) {
272 onNodeSelect(event, base);
273 }
274
275 setSelectedState(base);
276 };
277
278 const handleRangeSelect = (event, nodes) => {
279 let base = selected;
280 const {
281 start,
282 end
283 } = nodes; // If last selection was a range selection ignore nodes that were selected.
284
285 if (lastSelectionWasRange.current) {
286 base = selected.filter(id => currentRangeSelection.current.indexOf(id) === -1);
287 }
288
289 const range = getNodesInRange(start, end);
290 currentRangeSelection.current = range;
291 let newSelected = base.concat(range);
292 newSelected = newSelected.filter((id, i) => newSelected.indexOf(id) === i);
293
294 if (onNodeSelect) {
295 onNodeSelect(event, newSelected);
296 }
297
298 setSelectedState(newSelected);
299 };
300
301 const handleMultipleSelect = (event, value) => {
302 let newSelected = [];
303
304 if (selected.indexOf(value) !== -1) {
305 newSelected = selected.filter(id => id !== value);
306 } else {
307 newSelected = [value].concat(selected);
308 }
309
310 if (onNodeSelect) {
311 onNodeSelect(event, newSelected);
312 }
313
314 setSelectedState(newSelected);
315 };
316
317 const handleSingleSelect = (event, value) => {
318 const newSelected = multiSelect ? [value] : value;
319
320 if (onNodeSelect) {
321 onNodeSelect(event, newSelected);
322 }
323
324 setSelectedState(newSelected);
325 };
326
327 const selectNode = (event, id, multiple = false) => {
328 if (id) {
329 if (multiple) {
330 handleMultipleSelect(event, id);
331 } else {
332 handleSingleSelect(event, id);
333 }
334
335 lastSelectedNode.current = id;
336 lastSelectionWasRange.current = false;
337 currentRangeSelection.current = [];
338 return true;
339 }
340
341 return false;
342 };
343
344 const selectRange = (event, nodes, stacked = false) => {
345 const {
346 start = lastSelectedNode.current,
347 end,
348 current
349 } = nodes;
350
351 if (stacked) {
352 handleRangeArrowSelect(event, {
353 start,
354 next: end,
355 current
356 });
357 } else {
358 handleRangeSelect(event, {
359 start,
360 end
361 });
362 }
363
364 lastSelectionWasRange.current = true;
365 return true;
366 };
367
368 const rangeSelectToFirst = (event, id) => {
369 if (!lastSelectedNode.current) {
370 lastSelectedNode.current = id;
371 }
372
373 const start = lastSelectionWasRange.current ? lastSelectedNode.current : id;
374 return selectRange(event, {
375 start,
376 end: getFirstNode()
377 });
378 };
379
380 const rangeSelectToLast = (event, id) => {
381 if (!lastSelectedNode.current) {
382 lastSelectedNode.current = id;
383 }
384
385 const start = lastSelectionWasRange.current ? lastSelectedNode.current : id;
386 return selectRange(event, {
387 start,
388 end: getLastNode()
389 });
390 };
391
392 const selectNextNode = (event, id) => selectRange(event, {
393 end: getNextNode(id),
394 current: id
395 }, true);
396
397 const selectPreviousNode = (event, id) => selectRange(event, {
398 end: getPreviousNode(id),
399 current: id
400 }, true);
401
402 const selectAllNodes = event => selectRange(event, {
403 start: getFirstNode(),
404 end: getLastNode()
405 });
406 /*
407 * Mapping Helpers
408 */
409
410
411 const addNodeToNodeMap = (id, childrenIds) => {
412 const currentMap = nodeMap.current[id];
413 nodeMap.current[id] = _extends({}, currentMap, {
414 children: childrenIds,
415 id
416 });
417 childrenIds.forEach(childId => {
418 const currentChildMap = nodeMap.current[childId];
419 nodeMap.current[childId] = _extends({}, currentChildMap, {
420 parent: id,
421 id: childId
422 });
423 });
424 };
425
426 const getNodesToRemove = React.useCallback(id => {
427 const map = nodeMap.current[id];
428 const nodes = [];
429
430 if (map) {
431 nodes.push(id);
432
433 if (map.children) {
434 nodes.concat(map.children);
435 map.children.forEach(node => {
436 nodes.concat(getNodesToRemove(node));
437 });
438 }
439 }
440
441 return nodes;
442 }, []);
443 const cleanUpFirstCharMap = React.useCallback(nodes => {
444 const newMap = _extends({}, firstCharMap.current);
445
446 nodes.forEach(node => {
447 if (newMap[node]) {
448 delete newMap[node];
449 }
450 });
451 firstCharMap.current = newMap;
452 }, []);
453 const removeNodeFromNodeMap = React.useCallback(id => {
454 const nodes = getNodesToRemove(id);
455 cleanUpFirstCharMap(nodes);
456
457 const newMap = _extends({}, nodeMap.current);
458
459 nodes.forEach(node => {
460 const map = newMap[node];
461
462 if (map) {
463 if (map.parent) {
464 const parentMap = newMap[map.parent];
465
466 if (parentMap && parentMap.children) {
467 const parentChildren = parentMap.children.filter(c => c !== node);
468 newMap[map.parent] = _extends({}, parentMap, {
469 children: parentChildren
470 });
471 }
472 }
473
474 delete newMap[node];
475 }
476 });
477 nodeMap.current = newMap;
478 setFocusedNodeId(oldFocusedNodeId => {
479 if (oldFocusedNodeId === id) {
480 return null;
481 }
482
483 return oldFocusedNodeId;
484 });
485 }, [getNodesToRemove, cleanUpFirstCharMap]);
486
487 const mapFirstChar = (id, firstChar) => {
488 firstCharMap.current[id] = firstChar;
489 };
490
491 const prevChildIds = React.useRef([]);
492 const [childrenCalculated, setChildrenCalculated] = React.useState(false);
493 React.useEffect(() => {
494 const childIds = [];
495 React.Children.forEach(children, child => {
496 if ( /*#__PURE__*/React.isValidElement(child) && child.props.nodeId) {
497 childIds.push(child.props.nodeId);
498 }
499 });
500
501 if (arrayDiff(prevChildIds.current, childIds)) {
502 nodeMap.current[-1] = {
503 parent: null,
504 children: childIds
505 };
506 childIds.forEach((id, index) => {
507 if (index === 0) {
508 setTabbable(id);
509 }
510 });
511 visibleNodes.current = nodeMap.current[-1].children;
512 prevChildIds.current = childIds;
513 setChildrenCalculated(true);
514 }
515 }, [children]);
516 React.useEffect(() => {
517 const buildVisible = nodes => {
518 let list = [];
519
520 for (let i = 0; i < nodes.length; i += 1) {
521 const item = nodes[i];
522 list.push(item);
523 const childs = nodeMap.current[item].children;
524
525 if (isExpanded(item) && childs) {
526 list = list.concat(buildVisible(childs));
527 }
528 }
529
530 return list;
531 };
532
533 if (childrenCalculated) {
534 visibleNodes.current = buildVisible(nodeMap.current[-1].children);
535 }
536 }, [expanded, childrenCalculated, isExpanded, children]);
537
538 const noopSelection = () => {
539 return false;
540 };
541
542 return /*#__PURE__*/React.createElement(TreeViewContext.Provider, {
543 value: {
544 icons: {
545 defaultCollapseIcon,
546 defaultExpandIcon,
547 defaultParentIcon,
548 defaultEndIcon
549 },
550 focus,
551 focusFirstNode,
552 focusLastNode,
553 focusNextNode,
554 focusPreviousNode,
555 focusByFirstCharacter,
556 expandAllSiblings,
557 toggleExpansion,
558 isExpanded,
559 isFocused,
560 isSelected,
561 selectNode: disableSelection ? noopSelection : selectNode,
562 selectRange: disableSelection ? noopSelection : selectRange,
563 selectNextNode: disableSelection ? noopSelection : selectNextNode,
564 selectPreviousNode: disableSelection ? noopSelection : selectPreviousNode,
565 rangeSelectToFirst: disableSelection ? noopSelection : rangeSelectToFirst,
566 rangeSelectToLast: disableSelection ? noopSelection : rangeSelectToLast,
567 selectAllNodes: disableSelection ? noopSelection : selectAllNodes,
568 isTabbable,
569 multiSelect,
570 getParent,
571 mapFirstChar,
572 addNodeToNodeMap,
573 removeNodeFromNodeMap
574 }
575 }, /*#__PURE__*/React.createElement("ul", _extends({
576 role: "tree",
577 "aria-multiselectable": multiSelect,
578 className: clsx(classes.root, className),
579 ref: ref
580 }, other), children));
581});
582process.env.NODE_ENV !== "production" ? TreeView.propTypes = {
583 // ----------------------------- Warning --------------------------------
584 // | These PropTypes are generated from the TypeScript type definitions |
585 // | To update them edit the d.ts file and run "yarn proptypes" |
586 // ----------------------------------------------------------------------
587
588 /**
589 * The content of the component.
590 */
591 children: PropTypes.node,
592
593 /**
594 * Override or extend the styles applied to the component.
595 * See [CSS API](#css) below for more details.
596 */
597 classes: PropTypes.object,
598
599 /**
600 * @ignore
601 */
602 className: PropTypes.string,
603
604 /**
605 * The default icon used to collapse the node.
606 */
607 defaultCollapseIcon: PropTypes.node,
608
609 /**
610 * The default icon displayed next to a end node. This is applied to all
611 * tree nodes and can be overridden by the TreeItem `icon` prop.
612 */
613 defaultEndIcon: PropTypes.node,
614
615 /**
616 * Expanded node ids. (Uncontrolled)
617 */
618 defaultExpanded: PropTypes.arrayOf(PropTypes.string),
619
620 /**
621 * The default icon used to expand the node.
622 */
623 defaultExpandIcon: PropTypes.node,
624
625 /**
626 * The default icon displayed next to a parent node. This is applied to all
627 * parent nodes and can be overridden by the TreeItem `icon` prop.
628 */
629 defaultParentIcon: PropTypes.node,
630
631 /**
632 * Selected node ids. (Uncontrolled)
633 * When `multiSelect` is true this takes an array of strings; when false (default) a string.
634 */
635 defaultSelected: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.string), PropTypes.string]),
636
637 /**
638 * If `true` selection is disabled.
639 */
640 disableSelection: PropTypes.bool,
641
642 /**
643 * Expanded node ids. (Controlled)
644 */
645 expanded: PropTypes.arrayOf(PropTypes.string),
646
647 /**
648 * If true `ctrl` and `shift` will trigger multiselect.
649 */
650 multiSelect: PropTypes.bool,
651
652 /**
653 * Callback fired when tree items are selected/unselected.
654 *
655 * @param {object} event The event source of the callback
656 * @param {(array|string)} value of the selected nodes. When `multiSelect` is true
657 * this is an array of strings; when false (default) a string.
658 */
659 onNodeSelect: PropTypes.func,
660
661 /**
662 * Callback fired when tree items are expanded/collapsed.
663 *
664 * @param {object} event The event source of the callback.
665 * @param {array} nodeIds The ids of the expanded nodes.
666 */
667 onNodeToggle: PropTypes.func,
668
669 /**
670 * Selected node ids. (Controlled)
671 * When `multiSelect` is true this takes an array of strings; when false (default) a string.
672 */
673 selected: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.string), PropTypes.string])
674} : void 0;
675export default withStyles(styles, {
676 name: 'MuiTreeView'
677})(TreeView);
\No newline at end of file