UNPKG

react-native-tree-multi-select

Version:

Super-fast, customizable tree view component for React Native with drag-and-drop reordering, multi-selection, and search filtering.

291 lines (278 loc) 12.8 kB
"use strict"; import { forwardRef, startTransition, useCallback, useEffect, useId, useImperativeHandle, useMemo, useRef } from "react"; import NodeList from "./components/NodeList.js"; import { selectAll, selectAllFiltered, unselectAll, unselectAllFiltered, initializeNodeMaps, expandAll, collapseAll, toggleCheckboxes, expandNodes, collapseNodes, moveTreeNode, applyMoveToStore, findNodePosition, findNodePositionFromMaps, getSubtreeDepthFromMap, getNodeDepthFromParentMap } from "./helpers/index.js"; import { deleteTreeViewStore, getTreeViewStore, useTreeViewStore } from "./store/treeView.store.js"; import usePreviousState from "./utils/usePreviousState.js"; import { useShallow } from "zustand/react/shallow"; import useDeepCompareEffect from "./utils/useDeepCompareEffect.js"; import { typedMemo } from "./utils/typedMemo.js"; import { scrollMovedNodeIntoView } from "./hooks/useScrollToNode.js"; import { fastIsEqual } from "fast-is-equal"; import { jsx as _jsx } from "react/jsx-runtime"; function _innerTreeView(props, ref) { const { data, onCheck, onExpand, selectionPropagation, preselectedIds = [], preExpandedIds = [], initialScrollNodeID, treeFlashListProps, checkBoxViewStyleProps, indentationMultiplier, CheckboxComponent, ExpandCollapseIconComponent, ExpandCollapseTouchableComponent, CustomNodeRowComponent, dragAndDrop } = props; const onDragEnd = dragAndDrop?.onDragEnd; const storeId = useId(); const { expanded, updateExpanded, initialTreeViewData, updateInitialTreeViewData, searchText, updateSearchText, updateSearchKeys, checked, indeterminate, setSelectionPropagation, cleanUpTreeViewStore, draggedNodeId } = useTreeViewStore(storeId)(useShallow(state => ({ expanded: state.expanded, updateExpanded: state.updateExpanded, initialTreeViewData: state.initialTreeViewData, updateInitialTreeViewData: state.updateInitialTreeViewData, searchText: state.searchText, updateSearchText: state.updateSearchText, updateSearchKeys: state.updateSearchKeys, checked: state.checked, indeterminate: state.indeterminate, setSelectionPropagation: state.setSelectionPropagation, cleanUpTreeViewStore: state.cleanUpTreeViewStore, draggedNodeId: state.draggedNodeId }))); useImperativeHandle(ref, () => ({ selectAll: () => selectAll(storeId), unselectAll: () => unselectAll(storeId), selectAllFiltered: () => selectAllFiltered(storeId), unselectAllFiltered: () => unselectAllFiltered(storeId), expandAll: () => expandAll(storeId), collapseAll: () => collapseAll(storeId), expandNodes: ids => expandNodes(storeId, ids), collapseNodes: ids => collapseNodes(storeId, ids), selectNodes: ids => selectNodes(ids), unselectNodes: ids => unselectNodes(ids), setSearchText, scrollToNodeID, getChildToParentMap, getTreeData, moveNode })); const scrollToNodeHandlerRef = useRef(null); const prevSearchText = usePreviousState(searchText); const internalDataRef = useRef(null); // Holds a `data` prop change that arrived mid-drag; applied once the drag ends so // the destructive reinit never swaps the node maps out from under an active drag. const pendingDataRef = useRef(null); // Wrap onDragEnd to capture the post-move tree before calling the consumer's // callback. The reordered tree lives in the store (the event only carries the // lightweight move delta); snapshotting it here lets a controlled consumer feed // an equal tree back into `data` and skip re-initialization. const wrappedOnDragEnd = useCallback(event => { internalDataRef.current = getTreeViewStore(storeId).getState().initialTreeViewData; // A `data` change deferred during this drag predates the move that just // committed; applying it would silently undo the drop after onDragEnd // already told the consumer it happened. Discard it - a controlled // consumer reacts to onDragEnd with fresh data anyway. pendingDataRef.current = null; onDragEnd?.(event); }, [onDragEnd, storeId]); // Reinitialize the store from a tree. Held in a ref so the value stays stable // (no dep churn) while always capturing the latest props/store actions. const applyDataRef = useRef(/* istanbul ignore next -- placeholder, overwritten on the next line */() => {}); applyDataRef.current = nextData => { // If data matches what was set internally from a drag-drop, skip reinitialize if (internalDataRef.current !== null && fastIsEqual(nextData, internalDataRef.current)) { internalDataRef.current = null; return; } internalDataRef.current = null; cleanUpTreeViewStore(); updateInitialTreeViewData(nextData); if (selectionPropagation) setSelectionPropagation(selectionPropagation); initializeNodeMaps(storeId, nextData); // Check any pre-selected nodes toggleCheckboxes(storeId, preselectedIds, true); // Expand pre-expanded nodes expandNodes(storeId, [...preExpandedIds, ...(initialScrollNodeID ? [initialScrollNodeID] : [])]); }; useDeepCompareEffect(() => { // A reinit while a drag is in flight would replace nodeMap/childToParentMap // under the drag's feet and corrupt the commit. Defer it until the drag ends. if (getTreeViewStore(storeId).getState().draggedNodeId !== null) { pendingDataRef.current = data; return; } applyDataRef.current(data); }, [data]); // Apply any data change that was deferred during a drag, once the drag ends. useEffect(() => { if (draggedNodeId === null && pendingDataRef.current !== null) { const pending = pendingDataRef.current; pendingDataRef.current = null; applyDataRef.current(pending); } }, [draggedNodeId]); function selectNodes(ids) { toggleCheckboxes(storeId, ids, true); } function unselectNodes(ids) { toggleCheckboxes(storeId, ids, false); } function setSearchText(text, keys = ["name"]) { updateSearchText(text); updateSearchKeys(keys); } function scrollToNodeID(params) { scrollToNodeHandlerRef.current?.scrollToNodeID(params); } function getChildToParentMap() { const treeViewStore = getTreeViewStore(storeId); return treeViewStore.getState().childToParentMap; } function getTreeData() { return getTreeViewStore(storeId).getState().initialTreeViewData; } function moveNode(nodeId, targetNodeId, position, options) { const store = getTreeViewStore(storeId); const { initialTreeViewData: currentData, nodeMap, childToParentMap } = store.getState(); // A programmatic move during an in-flight drag would reinitialize // nodeMap/childToParentMap under the drag's feet and corrupt the pending // commit (same guard as the deferred data-prop reinit above). if (store.getState().draggedNodeId !== null) { /* istanbul ignore else -- __DEV__ is always true in jest */ if (__DEV__) { console.warn("[react-native-tree-multi-select] moveNode() ignored: a drag is in progress."); } return null; } // Validation rules (canDrop / maxDepth / canNodeHaveChildren) live on the // dragAndDrop prop, so `validate` only has rules to enforce when that prop is // configured. Warn in dev if the caller asked to validate but nothing can. if (__DEV__ && options?.validate && !(dragAndDrop?.canDrop || dragAndDrop?.maxDepth !== undefined || dragAndDrop?.canNodeHaveChildren)) { console.warn("[react-native-tree-multi-select] moveNode({ validate: true }) was called, " + "but no validation rules are configured. canDrop / maxDepth / " + "canNodeHaveChildren are read from the `dragAndDrop` prop; without them the " + "move proceeds unvalidated."); } // Optional validation mirrors the interactive drag constraints so a // programmatic move can't silently build a tree the drag UI would reject. if (options?.validate && dragAndDrop) { const draggedNode = nodeMap.get(nodeId); const targetNode = nodeMap.get(targetNodeId); if (!draggedNode || !targetNode) return null; if (position === "inside" && dragAndDrop.canNodeHaveChildren && !dragAndDrop.canNodeHaveChildren(targetNode)) return null; if (dragAndDrop.canDrop && !dragAndDrop.canDrop(draggedNode, targetNode, position)) return null; if (dragAndDrop.maxDepth !== undefined) { const targetLevel = getNodeDepthFromParentMap(childToParentMap, targetNodeId); const subtreeDepth = getSubtreeDepthFromMap(nodeMap, nodeId); const baseLevel = position === "inside" ? targetLevel + 1 : targetLevel; if (baseLevel + subtreeDepth > dragAndDrop.maxDepth) return null; } } // The maps still describe the pre-move tree here, so the O(depth) lookup applies. const previousPosition = findNodePositionFromMaps(currentData, nodeMap, childToParentMap, nodeId); const newData = moveTreeNode(currentData, nodeId, targetNodeId, position); // moveTreeNode returns the original array reference on a no-op / invalid move // (same node, dropping into own descendant, or node/target not found). if (newData === currentData) return null; // Same commit pipeline as the interactive drag path. applyMoveToStore(storeId, newData, nodeId, targetNodeId, position); internalDataRef.current = newData; // Optionally scroll the moved node into view (the interactive drag does this // automatically; programmatic moves opt in). Deferred so the expand/render settles. if (options?.scrollToNode) { scrollMovedNodeIntoView(scrollToNodeHandlerRef, nodeId, options.scrollToNode); } const newPosition = findNodePosition(newData, nodeId); /* istanbul ignore next -- positions always resolve for a just-committed move; the ?? fallbacks are type-level guards */ return { draggedNodeId: nodeId, targetNodeId, position, previousParentId: previousPosition?.parentId ?? null, previousIndex: previousPosition?.index ?? -1, newParentId: newPosition?.parentId ?? null, newIndex: newPosition?.index ?? -1 }; } const getIds = useCallback(node => { if (!node.children || node.children.length === 0) { return [node.id]; } else { return [node.id, ...node.children.flatMap(item => getIds(item))]; } }, []); useEffect(() => { onCheck?.(Array.from(checked), Array.from(indeterminate)); }, [onCheck, checked, indeterminate]); useEffect(() => { onExpand?.(Array.from(expanded)); }, [onExpand, expanded]); useEffect(() => { if (searchText) { startTransition(() => { updateExpanded(new Set(initialTreeViewData.flatMap(item => getIds(item)))); }); } else if (prevSearchText && prevSearchText !== "") { /* Collapse all nodes only if previous search query was non-empty: this is done to prevent node collapse on first render if preExpandedIds is provided */ startTransition(() => { updateExpanded(new Set()); }); } }, [getIds, initialTreeViewData, prevSearchText, searchText, updateExpanded]); useEffect(() => { return () => { cleanUpTreeViewStore(); deleteTreeViewStore(storeId); }; }, [cleanUpTreeViewStore, storeId]); // Consumers routinely pass `dragAndDrop` as an inline object literal, so its // identity changes every render even when nothing differs. Stabilize by deep // equality (callbacks compare by reference) so NodeList's memo - and with it // the drag overlay - isn't churned by unrelated re-renders mid-drag. const stableDragAndDropRef = useRef(dragAndDrop); if (!fastIsEqual(stableDragAndDropRef.current, dragAndDrop)) { stableDragAndDropRef.current = dragAndDrop; } const stableDragAndDrop = stableDragAndDropRef.current; const dragAndDropWithWrappedEnd = useMemo(() => stableDragAndDrop && { ...stableDragAndDrop, onDragEnd: wrappedOnDragEnd }, [stableDragAndDrop, wrappedOnDragEnd]); return /*#__PURE__*/_jsx(NodeList, { storeId: storeId, scrollToNodeHandlerRef: scrollToNodeHandlerRef, initialScrollNodeID: initialScrollNodeID, treeFlashListProps: treeFlashListProps, checkBoxViewStyleProps: checkBoxViewStyleProps, indentationMultiplier: indentationMultiplier, CheckboxComponent: CheckboxComponent, ExpandCollapseIconComponent: ExpandCollapseIconComponent, ExpandCollapseTouchableComponent: ExpandCollapseTouchableComponent, CustomNodeRowComponent: CustomNodeRowComponent, dragAndDrop: dragAndDropWithWrappedEnd }); } const _TreeView = /*#__PURE__*/forwardRef(_innerTreeView); export const TreeView = typedMemo(_TreeView); //# sourceMappingURL=TreeView.js.map