@momentum-ui/react-collaboration
Version:
Cisco Momentum UI Framework for React Collaboration Applications
469 lines (414 loc) • 12.8 kB
text/typescript
import React, { MutableRefObject, useContext } from 'react';
import {
TreeIdNodeMap,
TreeNodeRecord,
TreeContextValue,
TreeNavKeyCodes,
TreeNode,
TreeNodeId,
TreeRoot,
TreeNodeAction,
} from './Tree.types';
import { NODE_ID_ATTRIBUTE_NAME } from '../TreeNodeBase/TreeNodeBase.constants';
import { DEFAULTS } from './Tree.constants';
import { ItemSelection } from '../../hooks/useItemSelected';
export const TreeContext = React.createContext<TreeContextValue>(null);
/**
* Get the tree context value.
* It throws an error if the context is not provided.
*/
export const useTreeContext = (): TreeContextValue => {
const value = useContext(TreeContext);
if (!value) {
// eslint-disable-next-line no-console
console.error('useTreeContext hook used without TreeContext!');
}
return value;
};
/**
* Get the root node id of the tree which is represented as a map.
*
* @param tree
*/
export const getTreeRootId = (tree: TreeIdNodeMap): TreeNodeId | undefined => {
return Array.from(tree.values()).find((node) => !node.parent)?.id;
};
/**
* Check if the tree is empty.
*
* Works with both Map and recursive Object tree representation.
*
* @param tree
*/
export const isEmptyTree = (tree: unknown): boolean => {
if (!tree) return true;
if (tree instanceof Map && tree.size !== 0) return false;
if (tree instanceof Object && tree['id']) return false;
return true;
};
/**
* Find the next active tree node based on the current active node
* @param tree
* @param activeNodeId
* @internal
*/
const findNextTreeNode = (tree: TreeIdNodeMap, activeNodeId: TreeNodeId): TreeNodeAction => {
let current = tree.get(activeNodeId);
// Step into an open node
if (!current.isLeaf && current.isOpen) {
return { action: 'move', nextNodeId: current.children[0] };
}
const loopCheck = new Set<TreeNodeId>();
// Otherwise, find the next sibling
// eslint-disable-next-line no-constant-condition
while (true) {
const parent = tree.get(current.parent);
const pos = current.index + 1;
// Reached the last node of the tree
if (!parent) {
return { action: 'noop', nodeId: activeNodeId };
} else if (parent.children[pos]) {
return { action: 'move', nextNodeId: parent.children[pos] };
} else {
// If we are at the end of the parent's children, move up one level
current = parent;
if (loopCheck.has(current.id)) {
// eslint-disable-next-line no-console
console.error('Infinite loop detected in the tree navigation.');
return { action: 'move', nextNodeId: current.id };
} else {
loopCheck.add(current.id);
}
}
}
};
/**
* Find the previous active tree node based on the current active node
*
* @param tree
* @param excludeRootNode
* @param activeNodeId
* @internal
*/
const findPreviousTreeNode = (
tree: TreeIdNodeMap,
excludeRootNode: boolean,
activeNodeId: TreeNodeId
): TreeNodeAction => {
const current = tree.get(activeNodeId);
// Already in the root
if (!current.parent) return { action: 'noop', nodeId: activeNodeId };
// Exclude root
if (current.index === 0 && excludeRootNode && current.parent === getTreeRootId(tree))
return { action: 'noop', nodeId: activeNodeId };
// Move one level up
if (current.index === 0) return { action: 'move', nextNodeId: current.parent };
// Find the previous sibling
let next = tree.get(tree.get(current.parent).children[current.index - 1]);
const loopCheck = new Set<TreeNodeId>(activeNodeId);
for (let counter = 0; next; counter++) {
if (next.isLeaf || !next.isOpen) {
return { action: 'move', nextNodeId: next.id };
}
// Last child of the open node
next = tree.get(next.children[next.children.length - 1]);
if (loopCheck.has(next.id)) {
// eslint-disable-next-line no-console
console.error('Infinite loop detected in the tree navigation.');
return { action: 'move', nextNodeId: next.id };
} else {
loopCheck.add(next.id);
}
}
};
/**
* Open or find the next node based on the current active node
*
* @param tree
* @param activeNodeId
* @internal
*/
const openNextNode = (tree: TreeIdNodeMap, activeNodeId: TreeNodeId): TreeNodeAction => {
const current = tree.get(activeNodeId);
if (!current.isLeaf) {
if (!current.isOpen) {
// Open it if it's closed
return { action: 'open', nodeId: activeNodeId };
} else {
// Move to the first child if it's open
return { action: 'move', nextNodeId: current.children[0] };
}
}
// Otherwise, do nothing
return { action: 'noop', nodeId: activeNodeId };
};
/**
* Close or find the next node based on the current active node
*
* @param tree
* @param activeNodeId
* @param excludeRoot
* @internal
*/
const closeNextNode = (
tree: TreeIdNodeMap,
activeNodeId: TreeNodeId,
excludeRoot: boolean
): TreeNodeAction => {
const current = tree.get(activeNodeId);
// Close the node if it's open and not a leaf
if (current.isOpen && !current.isLeaf) {
return { action: 'close', nodeId: activeNodeId };
}
// Do nothing if it's the root
if (!current.parent || (excludeRoot && current.parent === getTreeRootId(tree))) {
return { action: 'noop', nodeId: activeNodeId };
}
// Move up one level if it's closed
if (current.parent) {
return { action: 'move', nextNodeId: current.parent };
}
// Otherwise, do nothing
return { action: 'noop', nodeId: activeNodeId };
};
/**
* Traverse the tree and convert it to a map between the node id and the node
* It also adds additional information to the node like the parent, the level and the index
*
* @param tree
*/
export const convertNestedTree2MappedTree = (tree: TreeRoot): TreeIdNodeMap => {
const map: TreeIdNodeMap = new Map();
if (isEmptyTree(tree)) {
return map;
}
const idSet = new Set<TreeNodeId>();
const rootNode = {
node: tree as TreeNode,
parentId: undefined,
level: 0,
index: 0,
isHidden: false,
};
const nodeStack: Array<typeof rootNode> = [rootNode];
while (nodeStack.length) {
const { node: parentNode, parentId, level, index, isHidden } = nodeStack.pop();
if (idSet.has(parentNode.id)) {
// eslint-disable-next-line no-console
console.error(`Duplicate node id ("${parentNode.id.toString()}") found and skipped.`);
continue;
} else {
idSet.add(parentNode.id);
}
const children = Array.from(new Set(parentNode.children.map((n) => n.id)));
const isOpen = parentNode.isOpenByDefault ?? true;
map.set(parentNode.id, {
id: parentNode.id,
isOpen,
level,
index,
children,
isHidden,
parent: parentId,
isLeaf: !children.length,
});
parentNode.children?.forEach?.((node, index) =>
nodeStack.push({
node,
index,
level: level + 1,
parentId: parentNode.id,
isHidden: isHidden || !isOpen,
})
);
}
return map;
};
/**
* Set the next active tree node based on the key code.
*
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/treeview/ WCAG Tree Pattern}
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/treeview/examples/treeview-1a/ WCAG Directory Tree example}
*
* @param tree
* @param nodeId Current active tree node descriptor
* @param keyCode Arrow key code
* @param excludeRoot
*/
export const getNextActiveNode = (
tree: TreeIdNodeMap,
nodeId: TreeNodeId,
keyCode: TreeNavKeyCodes,
excludeRoot = true
): TreeNodeAction => {
if (!tree.get(nodeId)) {
console.warn(`Tree node not found for id: "${nodeId}".`);
return { action: 'noop', nodeId };
}
switch (keyCode) {
case 'ArrowUp':
return findPreviousTreeNode(tree, excludeRoot, nodeId);
case 'ArrowDown':
return findNextTreeNode(tree, nodeId);
case 'ArrowRight':
return openNextNode(tree, nodeId);
case 'ArrowLeft':
return closeNextNode(tree, nodeId, excludeRoot);
default:
return { action: 'noop', nodeId };
}
};
/**
* Toggle the open/close state of the tree node.
*
* It also updates the hidden state of all children based on the parent's open state.
* And it returns the updated tree without changing the original tree.
*
* @param id
* @param prevTree
* @param isOpen
* @internal
*/
export const toggleTreeNodeRecord = (
id: TreeNodeId,
prevTree: TreeIdNodeMap,
isOpen: boolean
): TreeIdNodeMap => {
const newTree = new Map(prevTree.entries());
const current = prevTree.get(id);
if (current.isOpen === isOpen) {
return prevTree;
}
// Set the new isOpen value if it is provided, otherwise toggle it
newTree.set(id, { ...current, isOpen });
// Update the hidden state of all children
mapTree(
newTree,
(node, tree) => {
const parent = tree.get(node.parent);
newTree.set(node.id, { ...node, isHidden: !parent.isOpen || parent.isHidden });
},
{ rootNodeId: id }
);
return newTree;
};
/**
* Map each tree node to a new value by calling the callback function.
*
* It uses Depth First Search (DFS) with Preorder traverse algorithm.
*
* @param tree The tree to traverse
* @param cb Callback function to process each tree node
* @param options
* @param [options.rootNodeId=undefined] The root node id of the tree
* @param [options.excludeRootNode=true] Include the root node in the result
*/
export const mapTree = <T>(
tree: TreeIdNodeMap,
cb: (node: TreeNodeRecord, tree: TreeIdNodeMap) => T,
options?: {
rootNodeId?: TreeNodeId;
excludeRootNode?: boolean;
}
): Array<T> => {
// Empty tree, do nothing
if (tree.size === 0) return;
// Get the root node id
const rootNodeId = options?.rootNodeId ?? getTreeRootId(tree);
if (!tree.has(rootNodeId)) {
// eslint-disable-next-line no-console
console.error(`Tree root node is not found for id: "${rootNodeId.toString()}".`);
return [];
}
const excludeRoot = options?.excludeRootNode ?? true;
const result: Array<T> = [];
const idStack: Array<TreeNodeId> = [rootNodeId];
while (idStack.length) {
const nodeId = idStack.shift();
const node = tree.get(nodeId);
if (!(excludeRoot && nodeId === rootNodeId)) {
result.push(cb(node, tree));
}
idStack.unshift(...node.children);
}
return result;
};
/**
* Check if the active node is visible in the tree.
*
* @param treeRef DOM reference of the tree
* @param activeNodeId The id of the active node
*/
export const isActiveNodeInDOM = (
treeRef: MutableRefObject<HTMLDivElement>,
activeNodeId: TreeNodeId
): boolean => {
return !!treeRef.current.querySelector(`[${NODE_ID_ATTRIBUTE_NAME}="${activeNodeId}"]`);
};
/**
* Get the initial active node id in the tree.
*
* If selection mode is single and there is only one shown and selected item, it returns the selected item.
* Otherwise, it returns the first shown node in the tree.
*
* @param tree
* @param excludeTreeRoot
* @param itemSelection
*/
export const getInitialActiveNode = (
tree: TreeIdNodeMap,
excludeTreeRoot: boolean,
itemSelection: ItemSelection<string>
): TreeNodeId => {
if (
itemSelection.selectionMode === 'single' &&
itemSelection.selectedItems.length === 1 &&
tree.has(itemSelection.selectedItems[0])
) {
return itemSelection.selectedItems[0];
}
const rootId = getTreeRootId(tree);
if (rootId) {
const treeNode = tree.get(rootId);
if (excludeTreeRoot && treeNode.isOpen && treeNode.children[0]) {
return treeNode.children[0];
}
if (!excludeTreeRoot) {
return rootId;
}
}
return undefined;
};
/**
* Get the DOM id of the tree node.
*
* Node id prefixed with a constant to ensure the id really used only once in the DOM
* @param id
*/
export const getNodeDOMId = (id: TreeNodeId): string => {
return `${DEFAULTS.NODE_ID_PREFIX}-${id}`;
};
/**
* Migrate states between old and new trees
*
* `isOpen` state used from the old tree if the node available otherwise falls back to the new tree's node value.
* `isHidden` also updated based on the merged `isOpen` state.
*
* @remarks
* This function modify the `newTree` parameter
*
* @param oldTree
* @param newTree
*/
export const migrateTreeState = (oldTree: TreeIdNodeMap, newTree: TreeIdNodeMap): void => {
const rootId = getTreeRootId(newTree);
if (!rootId) return;
const nodeStack = [{ id: rootId, isHidden: false }];
while (nodeStack.length) {
const { id, isHidden } = nodeStack.pop();
const node = newTree.get(id);
const isOpen = oldTree.get(id)?.isOpen ?? node.isOpen;
newTree.set(node.id, { ...node, isOpen, isHidden });
nodeStack.push(...node.children.map((id) => ({ id, isHidden: node.isHidden || !isOpen })));
}
};