@hitachivantara/uikit-react-core
Version:
Core React components for the NEXT Design System.
677 lines (676 loc) • 21.7 kB
JavaScript
import { jsx } from "react/jsx-runtime";
import { forwardRef, useRef, useState, useCallback, useMemo } from "react";
import { useDefaultProps } from "@hitachivantara/uikit-react-utils";
import { useControlled } from "../../hooks/useControlled.js";
import { useForkRef } from "../../hooks/useForkRef.js";
import { useUniqueId } from "../../hooks/useUniqueId.js";
import { DescendantProvider } from "../../TreeView/internals/DescendantProvider.js";
import { useClasses } from "./TreeView.styles.js";
import { staticClasses } from "./TreeView.styles.js";
import { TreeViewControlContext, TreeViewStateContext } from "./TreeViewContext.js";
function isPrintableCharacter(string) {
return string && string.length === 1 && string.match(/\S/);
}
function findNextFirstChar(firstChars, startIndex, char) {
return firstChars.slice(startIndex).findIndex((c) => c === char);
}
function noopSelection() {
return false;
}
const HvVerticalNavigationTreeView = forwardRef(function HvVerticalNavigationTreeView2(props, ref) {
const {
id: idProp,
className,
classes: classesProp,
mode = "treeview",
collapsible = false,
expanded: expandedProp,
defaultExpanded = [],
onToggle,
selectable = false,
multiSelect: multiSelectProp = false,
selected: selectedProp,
defaultSelected = [],
onChange,
disabledItemsFocusable = false,
onFocus,
onBlur,
onKeyDown,
children,
...others
} = useDefaultProps("HvVerticalNavigationTreeView", props);
const { classes, cx } = useClasses(classesProp);
const treeviewMode = mode === "treeview";
const multiSelect = selectable && multiSelectProp;
const treeId = useUniqueId(idProp);
const treeRef = useRef(null);
const handleRef = useForkRef(treeRef, ref);
const [expanded, setExpandedState] = useControlled(
expandedProp,
defaultExpanded
);
const [selected, setSelectedState] = useControlled(
selectedProp,
defaultSelected
);
const [focusedNodeId, setFocusedNodeId] = useState(null);
const nodeMap = useRef({});
const firstCharMap = useRef({});
const isExpanded = useCallback(
(id) => !collapsible || (Array.isArray(expanded) ? expanded.indexOf(id) !== -1 : false),
[collapsible, expanded]
);
const isExpandable = useCallback(
(id) => collapsible && nodeMap.current[id] && nodeMap.current[id].expandable,
[collapsible]
);
const isSelected = useCallback(
(id) => selectable && (Array.isArray(selected) ? selected.indexOf(id) !== -1 : selected === id),
[selectable, selected]
);
const isSelectable = useCallback(
(id) => selectable && nodeMap.current[id] && nodeMap.current[id].selectable,
[selectable]
);
const isDisabled = useCallback((id) => {
let node = nodeMap.current[id];
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 = useCallback(
(id) => focusedNodeId === id,
[focusedNodeId]
);
const isChildSelected = useCallback(
// the second part of the condition is to ensure that the id we're
// looking at is actually of a child (ie, there's at least one "-")
(id) => {
return Array().concat(selected).some((s) => s.startsWith(id) && s.includes("-"));
},
[selected]
);
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 = useCallback(
(id) => {
let childrenIds = getChildrenIds(id);
if (!disabledItemsFocusable) {
childrenIds = childrenIds.filter((node) => !isDisabled(node));
}
return childrenIds;
},
[disabledItemsFocusable, isDisabled]
);
const getNextNode = useCallback(
(id) => {
if (isExpanded(id) && getNavigableChildrenIds(id).length > 0) {
return getNavigableChildrenIds(id)[0];
}
let node = nodeMap.current[id];
while (node != null) {
const siblings = getNavigableChildrenIds(node.parentId);
const nextSibling = siblings[siblings.indexOf(node.id) + 1];
if (nextSibling) {
return nextSibling;
}
node = nodeMap.current[node.parentId];
}
return null;
},
[getNavigableChildrenIds, isExpanded]
);
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;
const findOrderInTremauxTree = useCallback(
(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 = useCallback(
(nodeA, nodeB) => {
if (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;
}
return [];
},
[findOrderInTremauxTree, getNextNode]
);
const focus = (event, id) => {
if (id) {
setFocusedNodeId(id);
if (nodeMap.current[id]?.onFocus) {
nodeMap.current[id].onFocus(event);
}
}
};
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 = [];
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);
}
});
start = firstCharIds.indexOf(id) + 1;
if (start >= firstCharIds.length) {
start = 0;
}
index = findNextFirstChar(firstChars, start, lowercaseChar);
if (index === -1) {
index = findNextFirstChar(firstChars, 0, lowercaseChar);
}
if (index > -1) {
focus(event, firstCharIds[index]);
}
};
const toggleExpansion = useCallback(
(event, value = focusedNodeId) => {
if (!value) return;
const newExpanded = expanded.includes(value) ? expanded.filter((id) => id !== value) : [value].concat(expanded);
onToggle?.(event, newExpanded);
setExpandedState(newExpanded);
},
[expanded, focusedNodeId, onToggle, setExpandedState]
);
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);
onToggle?.(event, newExpanded);
}
};
const lastSelectedNode = useRef(null);
const lastSelectionWasRange = useRef(false);
const currentRangeSelection = useRef([]);
const isDivElement = (element) => {
return element.nodeType === 1;
};
const handleRangeArrowSelect = useCallback(
(event, nodes) => {
let base = Array().concat(selected);
const { start, next, current } = nodes;
if (!next || !current) {
return;
}
if (isDivElement(current) && currentRangeSelection.current.indexOf(current) === -1) {
currentRangeSelection.current = [];
}
if (lastSelectionWasRange.current) {
if (isDivElement(next) && 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);
}
onChange?.(
event,
base,
base.map((id) => nodeMap.current[id]?.payload)
);
setSelectedState(base);
},
[onChange, selected, setSelectedState]
);
const handleRangeSelect = useCallback(
(event, nodes) => {
let base = Array().concat(selected);
const { start, end } = nodes;
if (lastSelectionWasRange.current) {
base = base.filter(
(id) => !currentRangeSelection.current.includes(id)
);
}
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
);
onChange?.(
event,
newSelected,
newSelected.map((id) => nodeMap.current[id]?.payload)
);
setSelectedState(newSelected);
},
[getNodesInRange, isDisabled, onChange, selected, setSelectedState]
);
const handleMultipleSelect = useCallback(
(event, value) => {
const newSelected = selected.includes(value) ? selected.filter((id) => id !== value) : [value].concat(selected);
onChange?.(
event,
newSelected,
newSelected.map((id) => nodeMap.current[id]?.payload)
);
setSelectedState(newSelected);
},
[onChange, selected, setSelectedState]
);
const handleSingleSelect = useCallback(
(event, value) => {
const newSelected = multiSelect ? [value] : value;
if (onChange) {
const nodeValue = nodeMap.current[newSelected]?.payload;
onChange(event, newSelected, multiSelect ? [nodeValue] : nodeValue);
}
setSelectedState(newSelected);
},
[multiSelect, onChange, setSelectedState]
);
const selectNode = useCallback(
(event, id, multiple = false) => {
if (id && isSelectable(id)) {
if (multiple) {
handleMultipleSelect(event, id);
} else {
handleSingleSelect(event, id);
}
lastSelectedNode.current = id;
lastSelectionWasRange.current = false;
currentRangeSelection.current = [];
return true;
}
return false;
},
[handleMultipleSelect, handleSingleSelect, isSelectable]
);
const selectRange = useCallback(
(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;
},
[handleRangeArrowSelect, handleRangeSelect]
);
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() });
};
const registerNode = useCallback((node) => {
const {
id,
index,
parentId,
expandable,
idAttribute,
disabled,
selectable: nodeSelectable,
onFocus: nodeOnFocus,
payload
} = node;
nodeMap.current[id] = {
id,
index,
parentId,
expandable,
idAttribute,
disabled,
selectable: nodeSelectable,
onFocus: nodeOnFocus,
payload
};
}, []);
const unregisterNode = useCallback((id) => {
const newMap = { ...nodeMap.current };
delete newMap[id];
nodeMap.current = newMap;
setFocusedNodeId((oldFocusedNodeId) => {
if (oldFocusedNodeId === id && treeRef.current && treeRef.current === (treeRef.current.ownerDocument || document).activeElement) {
return getChildrenIds(null)[0];
}
return oldFocusedNodeId;
});
}, []);
const mapFirstChar = useCallback((id, firstChar) => {
firstCharMap.current[id] = firstChar;
}, []);
const unMapFirstChar = useCallback((id) => {
const newMap = { ...firstCharMap.current };
delete newMap[id];
firstCharMap.current = newMap;
}, []);
const handleNextArrow = (event) => {
if (!focusedNodeId) return false;
if (isExpandable(focusedNodeId)) {
if (isExpanded(focusedNodeId)) {
focusNextNode(event, focusedNodeId);
} else if (!isDisabled(focusedNodeId)) {
toggleExpansion(event);
}
}
return true;
};
const handlePreviousArrow = (event) => {
if (!focusedNodeId) return false;
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;
if (event.altKey || event.currentTarget !== event.target || !focusedNodeId) {
return;
}
const ctrlPressed = event.ctrlKey || event.metaKey;
switch (key) {
case " ":
if (isSelectable(focusedNodeId) && !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 (isSelectable(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 "ArrowDown":
if (multiSelect && event.shiftKey) {
selectNextNode(event, focusedNodeId);
}
focusNextNode(event, focusedNodeId);
flag = true;
break;
case "ArrowUp":
if (multiSelect && event.shiftKey) {
selectPreviousNode(event, focusedNodeId);
}
focusPreviousNode(event, focusedNodeId);
flag = true;
break;
case "ArrowRight":
flag = handleNextArrow(event);
break;
case "ArrowLeft":
flag = handlePreviousArrow(event);
break;
case "Home":
if (multiSelect && ctrlPressed && event.shiftKey && !isDisabled(focusedNodeId)) {
rangeSelectToFirst(event, focusedNodeId);
}
focusFirstNode(event);
flag = true;
break;
case "End":
if (multiSelect && ctrlPressed && event.shiftKey && !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") {
selectAllNodes(event);
flag = true;
} else if (!ctrlPressed && !event.shiftKey && isPrintableCharacter(key)) {
focusByFirstCharacter(event, focusedNodeId, key);
flag = true;
}
}
if (flag) {
event.preventDefault();
event.stopPropagation();
}
onKeyDown?.(event);
};
const handleFocus = (event) => {
if (event.target === event.currentTarget) {
const firstSelected = Array.isArray(selected) ? selected[0] : selected;
focus(event, firstSelected || getNavigableChildrenIds(null)[0]);
}
onFocus?.(event);
};
const handleBlur = (event) => {
setFocusedNodeId(null);
onBlur?.(event);
};
const activeDescendant = focusedNodeId && nodeMap.current[focusedNodeId] ? nodeMap.current[focusedNodeId].idAttribute : null;
const treeControlContext = useMemo(
() => ({
treeId,
mode,
collapsible,
toggleExpansion,
multiSelect,
selectNode: selectable ? selectNode : noopSelection,
selectRange: selectable ? selectRange : noopSelection,
disabledItemsFocusable,
registerNode,
unregisterNode,
mapFirstChar,
unMapFirstChar,
focus
}),
[
registerNode,
unregisterNode,
mapFirstChar,
unMapFirstChar,
toggleExpansion,
selectable,
selectNode,
selectRange,
mode,
collapsible,
multiSelect,
disabledItemsFocusable,
treeId
]
);
const treeStateContext = useMemo(
() => ({
isExpanded,
isSelected,
isFocused,
isDisabled,
isChildSelected
}),
[isDisabled, isExpanded, isFocused, isSelected, isChildSelected]
);
return /* @__PURE__ */ jsx(TreeViewControlContext.Provider, { value: treeControlContext, children: /* @__PURE__ */ jsx(TreeViewStateContext.Provider, { value: treeStateContext, children: /* @__PURE__ */ jsx(DescendantProvider, { children: /* @__PURE__ */ jsx(
"ul",
{
ref: handleRef,
id: idProp,
className: cx(classes.root, className),
...treeviewMode && {
id: treeId,
role: "tree",
"aria-multiselectable": multiSelect,
"aria-activedescendant": activeDescendant,
tabIndex: 0,
onFocus: handleFocus,
onBlur: handleBlur,
onKeyDown: handleKeyDown
},
...others,
children
}
) }) }) });
});
export {
HvVerticalNavigationTreeView,
staticClasses as treeViewClasses
};