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.

526 lines (510 loc) 19.3 kB
"use strict"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { View, StyleSheet, TouchableOpacity, Platform } from "react-native"; import { FlashList } from "@shopify/flash-list"; import { useTreeViewStore } from "../store/treeView.store.js"; import { getFilteredTreeData, getFlattenedTreeData, getCheckboxValue, getInnerMostChildrenIdsInTree, handleToggleExpand, toggleCheckboxes } from "../helpers/index.js"; import { CheckboxView } from "./CheckboxView.js"; import { CustomExpandCollapseIcon } from "./CustomExpandCollapseIcon.js"; import { DragOverlay } from "./DragOverlay.js"; import { defaultIndentationMultiplier, defaultItemHeight, listHeaderFooterPadding } from "../constants/treeView.constants.js"; import { useShallow } from "zustand/react/shallow"; import { typedMemo } from "../utils/typedMemo.js"; import { useScrollToNode } from "../hooks/useScrollToNode.js"; import { useDragDrop } from "../hooks/useDragDrop.js"; import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; const NodeList = typedMemo(_NodeList); export default NodeList; function _NodeList(props) { const { storeId, scrollToNodeHandlerRef, initialScrollNodeID, treeFlashListProps, checkBoxViewStyleProps, indentationMultiplier, CheckboxComponent, ExpandCollapseIconComponent, ExpandCollapseTouchableComponent, CustomNodeRowComponent, dragAndDrop } = props; const { enabled: _dragEnabled, onDragStart, onDragEnd, onDragCancel, longPressDuration = 400, autoScrollThreshold = 60, autoScrollSpeed = 1.0, dragOverlayOffset = -2, overlayYCorrection, autoExpandDelay = 800, autoExpand = true, magneticSnap = true, customizations: dragDropCustomizations, canDrop: canDropCallback, maxDepth, canNodeHaveChildren, canDrag, autoScrollToDroppedNode } = dragAndDrop ?? {}; // When the dragAndDrop prop is provided, drag is enabled by default on native // (iOS/Android). On web it defaults OFF because the PanResponder-based drag is // still a work in progress there - consumers can opt in with `enabled: true`. const dragEnabled = dragAndDrop ? _dragEnabled ?? Platform.OS !== "web" : false; const { expanded, initialTreeViewData, updateInnerMostChildrenIds, searchKeys, searchText } = useTreeViewStore(storeId)(useShallow(state => ({ expanded: state.expanded, initialTreeViewData: state.initialTreeViewData, updateInnerMostChildrenIds: state.updateInnerMostChildrenIds, searchKeys: state.searchKeys, searchText: state.searchText }))); const flashListRef = useRef(null); const containerRef = useRef(null); const measuredItemHeightRef = useRef(0); const contentHeightRef = useRef(0); // Measured row heights keyed by stable node id (NOT flattened index, which is // reused by different nodes across expand/collapse/filter/reorder). Used for // accurate drop targeting with variable-height rows when the whole list is // rendered. Keying by id means a stale entry can never satisfy the // "all current rows measured" gate in useDragDrop. const itemHeightsRef = useRef(new Map()); const handleItemLayout = useCallback((id, height) => { if (height > 0) { itemHeightsRef.current.set(id, height); // First measured height seeds the uniform fallback used while // virtualization keeps some rows unmeasured. if (measuredItemHeightRef.current === 0) { measuredItemHeightRef.current = height; } } }, []); // Measured heights of removed nodes would otherwise accumulate forever on // dynamic trees; clear on structural change and let rows re-measure on layout. useEffect(() => { itemHeightsRef.current.clear(); }, [initialTreeViewData]); const [initialScrollIndex, setInitialScrollIndex] = useState(-1); // First we filter the tree as per the search term and keys const filteredTree = useMemo(() => getFilteredTreeData(initialTreeViewData, searchText.trim().toLowerCase(), searchKeys), [initialTreeViewData, searchText, searchKeys]); // Then we flatten the tree to make it "render-compatible" in a "flat" list const flattenedFilteredNodes = useMemo(() => getFlattenedTreeData(filteredTree, expanded), [filteredTree, expanded]); useScrollToNode({ storeId, scrollToNodeHandlerRef, flashListRef, flattenedFilteredNodes, setInitialScrollIndex, initialScrollNodeID }); // And update the innermost children id -> required to un/select filtered tree useEffect(() => { const updatedInnerMostChildrenIds = getInnerMostChildrenIdsInTree(filteredTree); updateInnerMostChildrenIds(updatedInnerMostChildrenIds); }, [filteredTree, updateInnerMostChildrenIds]); const effectiveIndentationMultiplier = indentationMultiplier ?? defaultIndentationMultiplier; // --- Drag and drop --- const { panResponder, overlayY, overlayX, isDragging, draggedNode, handleNodeTouchStart, handleNodeTouchEnd, handleScroll: dragHandleScroll, containerHeightRef } = useDragDrop({ storeId, flattenedNodes: flattenedFilteredNodes, flashListRef, containerRef, dragEnabled, onDragStart, onDragEnd, onDragCancel, longPressDuration, autoScrollThreshold, autoScrollSpeed, measuredItemHeightRef, contentHeightRef, itemHeightsRef, dragOverlayOffset, overlayYCorrection, autoExpandDelay, autoExpand, magneticSnap, indentationMultiplier: effectiveIndentationMultiplier, canDrop: canDropCallback, maxDepth, canNodeHaveChildren, canDrag, scrollToNodeHandlerRef, autoScrollToDroppedNode }); // The hook owns scroll-offset bookkeeping (single-writer during drag, // long-press cancellation); this wrapper just forwards to the user's onScroll. const handleScroll = useCallback(event => { dragHandleScroll(event); treeFlashListProps?.onScroll?.(event); }, [dragHandleScroll, treeFlashListProps]); // Track total content height so auto-scroll during drag can clamp to the // scrollable range. const handleContentSizeChange = useCallback((width, height) => { contentHeightRef.current = height; treeFlashListProps?.onContentSizeChange?.(width, height); }, [contentHeightRef, treeFlashListProps]); // Keep the container height fresh on resize (orientation change, keyboard, split // view) so auto-scroll edge detection and clamping don't go stale mid-session. const handleContainerLayout = useCallback(e => { containerHeightRef.current = e.nativeEvent.layout.height; }, [containerHeightRef]); const nodeRenderer = useCallback(({ item, index }) => { return /*#__PURE__*/_jsx(Node, { storeId: storeId, node: item, level: item.level || 0, checkBoxViewStyleProps: checkBoxViewStyleProps, indentationMultiplier: indentationMultiplier, CheckboxComponent: CheckboxComponent, ExpandCollapseIconComponent: ExpandCollapseIconComponent, ExpandCollapseTouchableComponent: ExpandCollapseTouchableComponent, CustomNodeRowComponent: CustomNodeRowComponent // Index only matters for drag touch bookkeeping; keep it stable // when drag is off so memoized nodes skip index-shift re-renders , nodeIndex: dragEnabled ? index : undefined, dragEnabled: dragEnabled, isDragging: isDragging, onNodeTouchStart: dragEnabled ? handleNodeTouchStart : undefined, onNodeTouchEnd: dragEnabled ? handleNodeTouchEnd : undefined, onItemLayout: dragEnabled ? handleItemLayout : undefined, dragDropCustomizations: dragDropCustomizations }); }, [storeId, CheckboxComponent, ExpandCollapseIconComponent, ExpandCollapseTouchableComponent, CustomNodeRowComponent, checkBoxViewStyleProps, indentationMultiplier, dragEnabled, isDragging, handleNodeTouchStart, handleNodeTouchEnd, dragDropCustomizations, handleItemLayout]); // Extract FlashList props but exclude onScroll / onContentSizeChange (we provide // our own combined handlers that still forward to the user's callbacks) // eslint-disable-next-line @typescript-eslint/no-unused-vars const { onScroll: _userOnScroll, onContentSizeChange: _userOnContentSizeChange, ...restFlashListProps } = treeFlashListProps ?? {}; const flashListElement = /*#__PURE__*/_jsx(FlashList, { ref: flashListRef // estimatedItemSize is used by FlashList v1; v2 auto-measures and ignores // it. Consumers can override via treeFlashListProps (spread below). , estimatedItemSize: defaultItemHeight, initialScrollIndex: initialScrollIndex, removeClippedSubviews: true, keyboardShouldPersistTaps: "handled", drawDistance: 50, ListHeaderComponent: /*#__PURE__*/_jsx(HeaderFooterView, {}), ListFooterComponent: /*#__PURE__*/_jsx(HeaderFooterView, {}), ...restFlashListProps, onScroll: handleScroll, onContentSizeChange: handleContentSizeChange, scrollEnabled: isDragging ? false : restFlashListProps?.scrollEnabled ?? true, data: flattenedFilteredNodes, renderItem: nodeRenderer }); return /*#__PURE__*/_jsx(_Fragment, { children: dragEnabled ? /*#__PURE__*/_jsxs(View, { ref: containerRef, style: styles.dragContainer, onLayout: handleContainerLayout, ...panResponder.panHandlers, children: [flashListElement, isDragging && draggedNode && /*#__PURE__*/_jsx(DragOverlay, { storeId: storeId, overlayY: overlayY, overlayX: overlayX, node: draggedNode /* Constant for the whole drag: the level shift toward the drop target is expressed via the overlayX translate, not a re-render (which caused visible indent flicker). */, level: /* istanbul ignore next -- level is always set by flattenTree */draggedNode.level ?? 0, indentationMultiplier: effectiveIndentationMultiplier, CheckboxComponent: CheckboxComponent, ExpandCollapseIconComponent: ExpandCollapseIconComponent, CustomNodeRowComponent: CustomNodeRowComponent, checkBoxViewStyleProps: checkBoxViewStyleProps, dragDropCustomizations: dragDropCustomizations })] }) : flashListElement }); } ; function HeaderFooterView() { return /*#__PURE__*/_jsx(View, { style: { padding: listHeaderFooterPadding } }); } const Node = typedMemo(_Node); function _Node(props) { const { storeId, node, level, checkBoxViewStyleProps, indentationMultiplier = defaultIndentationMultiplier, ExpandCollapseIconComponent = CustomExpandCollapseIcon, CheckboxComponent = CheckboxView, ExpandCollapseTouchableComponent = TouchableOpacity, CustomNodeRowComponent, nodeIndex = 0, dragEnabled, isDragging: isDraggingGlobal, onNodeTouchStart, onNodeTouchEnd, onItemLayout, dragDropCustomizations } = props; const { isExpanded, value, isBeingDragged, isDragInvalid, isDropTarget, nodeDropPosition, nodeDropLevel } = useTreeViewStore(storeId)(useShallow(state => ({ isExpanded: state.expanded.has(node.id), value: getCheckboxValue(state.checked.has(node.id), state.indeterminate.has(node.id)), isBeingDragged: state.draggedNodeId === node.id, isDragInvalid: state.invalidDragTargetIds.has(node.id), isDropTarget: state.dropTargetNodeId === node.id, nodeDropPosition: state.dropTargetNodeId === node.id ? state.dropPosition : null, nodeDropLevel: state.dropTargetNodeId === node.id ? state.dropLevel : null }))); // Track when this node was dragged so we can swallow the onPress/onCheck // that fires when the user lifts their finger after a long-press-initiated drag. // The flag is set during render (synchronous) and cleared on the next touch start. // It is also cleared via effect when dragging ends, to prevent stale `true` // values surviving FlashList recycling (where refs persist across items). const wasDraggedRef = useRef(false); if (isDraggingGlobal && isBeingDragged) { wasDraggedRef.current = true; } useEffect(() => { if (!isDraggingGlobal) { wasDraggedRef.current = false; } }, [isDraggingGlobal]); const _onToggleExpand = useCallback(() => { if (wasDraggedRef.current) return; handleToggleExpand(storeId, node.id); }, [storeId, node.id]); const _onCheck = useCallback(() => { if (wasDraggedRef.current) return; toggleCheckboxes(storeId, [node.id]); }, [storeId, node.id]); const handleTouchStart = useCallback(e => { wasDraggedRef.current = false; /* istanbul ignore next -- touch handlers are only attached when dragEnabled, and NodeList always passes both together */ if (!onNodeTouchStart) return; const { pageY, locationY } = e.nativeEvent; onNodeTouchStart(node.id, pageY, locationY, nodeIndex); }, [node.id, nodeIndex, onNodeTouchStart]); const handleTouchEnd = useCallback(() => { onNodeTouchEnd?.(); }, [onNodeTouchEnd]); // Determine opacity for drag state (separate values for dragged node vs invalid targets). // When CustomNodeRowComponent is used, hand off all visual control // (including drag opacity) to the custom component - it receives // isDraggedNode / isInvalidDropTarget / isDragging props. const draggedOpacity = dragDropCustomizations?.draggedNodeOpacity ?? 0.3; const invalidOpacity = dragDropCustomizations?.invalidTargetOpacity ?? 0.3; /* istanbul ignore next -- invalidOpacity side: invalid targets are the dragged node's own descendants, which drag start force-collapses out of the rendered list, so a built-in row never renders as an invalid target */ const nodeOpacity = CustomNodeRowComponent ? 1.0 : isDraggingGlobal ? isBeingDragged ? draggedOpacity : isDragInvalid ? invalidOpacity : 1.0 : 1.0; const handleLayout = useCallback(e => { onItemLayout?.(node.id, e.nativeEvent.layout.height); }, [onItemLayout, node.id]); const touchHandlers = dragEnabled ? { onTouchStart: handleTouchStart, onTouchEnd: handleTouchEnd, onTouchCancel: handleTouchEnd } : undefined; const CustomDropIndicator = dragDropCustomizations?.CustomDropIndicatorComponent; const indicatorLevel = nodeDropLevel ?? level; const dropIndicator = isDropTarget && nodeDropPosition ? CustomDropIndicator ? /*#__PURE__*/_jsx(CustomDropIndicator, { position: nodeDropPosition, level: indicatorLevel, indentationMultiplier: indentationMultiplier }) : /*#__PURE__*/_jsx(NodeDropIndicator, { position: nodeDropPosition, level: indicatorLevel, indentationMultiplier: indentationMultiplier, styleProps: dragDropCustomizations?.dropIndicatorStyleProps }) : null; if (!CustomNodeRowComponent) { return /*#__PURE__*/_jsxs(View, { testID: `node_row_${node.id}`, ...touchHandlers, onLayout: onItemLayout ? handleLayout : undefined, style: [styles.nodeCheckboxAndArrowRow, { paddingStart: level * indentationMultiplier }, { opacity: nodeOpacity }, dropIndicator ? styles.nodeOverflowVisible : undefined], children: [dropIndicator, /*#__PURE__*/_jsx(CheckboxComponent, { text: node.name, onValueChange: _onCheck, value: value, testID: `${node.id}`, ...checkBoxViewStyleProps }), node.children?.length ? /*#__PURE__*/_jsx(ExpandCollapseTouchableComponent, { testID: `expandable_arrow_${node.id}`, style: styles.nodeExpandableArrowTouchable, onPress: _onToggleExpand, children: /*#__PURE__*/_jsx(ExpandCollapseIconComponent, { isExpanded: isExpanded }) }) : null] }); } else { return /*#__PURE__*/_jsxs(View, { onLayout: onItemLayout ? handleLayout : undefined, style: [{ opacity: nodeOpacity }, dropIndicator ? styles.nodeOverflowVisible : undefined], children: [dropIndicator, /*#__PURE__*/_jsx(CustomNodeRowComponent, { node: node, level: level, checkedValue: value, isExpanded: isExpanded, onCheck: _onCheck, onExpand: _onToggleExpand, isInvalidDropTarget: isDragInvalid, isDropTarget: isDropTarget, dropPosition: nodeDropPosition ?? undefined, isDragging: isDraggingGlobal, isDraggedNode: isBeingDragged, dragHandleProps: touchHandlers })] }); } } ; function NodeDropIndicator({ position, level, indentationMultiplier, styleProps }) { const lineColor = styleProps?.lineColor ?? "#0078FF"; const lineThickness = styleProps?.lineThickness ?? 3; const circleSize = styleProps?.circleSize ?? 10; const highlightColor = styleProps?.highlightColor ?? "rgba(0, 120, 255, 0.15)"; const highlightBorderColor = styleProps?.highlightBorderColor ?? "rgba(0, 120, 255, 0.5)"; const highlightBorderWidth = styleProps?.highlightBorderWidth ?? 2; const highlightBorderRadius = styleProps?.highlightBorderRadius ?? 4; // Indent the line to match the node's nesting level so users can // visually distinguish drops at different tree depths. const leftOffset = level * indentationMultiplier; if (position === "inside") { return /*#__PURE__*/_jsx(View, { pointerEvents: "none", style: [styles.dropHighlight, { left: leftOffset, backgroundColor: highlightColor, borderColor: highlightBorderColor, borderWidth: highlightBorderWidth, borderRadius: highlightBorderRadius }] }); } // Ensure the circle isn't clipped at shallow indent levels const safeLeftOffset = Math.max(leftOffset, circleSize / 2); return /*#__PURE__*/_jsxs(View, { pointerEvents: "none", style: [styles.dropLineContainer, { height: lineThickness, left: safeLeftOffset }, position === "above" ? styles.dropLineTop : styles.dropLineBottom], children: [/*#__PURE__*/_jsx(View, { style: { width: circleSize, height: circleSize, borderRadius: circleSize / 2, backgroundColor: lineColor, marginLeft: -(circleSize / 2) } }), /*#__PURE__*/_jsx(View, { style: [styles.dropLine, { height: lineThickness, backgroundColor: lineColor }] })] }); } const styles = StyleSheet.create({ nodeExpandableArrowTouchable: { flex: 1 }, nodeCheckboxAndArrowRow: { flex: 1, flexDirection: "row", alignItems: "center", minWidth: "100%" }, dragContainer: { flex: 1 }, // Drop indicator styles (rendered by each node) dropHighlight: { position: "absolute", top: 0, bottom: 0, left: 0, right: 0, backgroundColor: "rgba(0, 120, 255, 0.15)", borderWidth: 2, borderColor: "rgba(0, 120, 255, 0.5)", borderRadius: 4, zIndex: 10 }, dropLineContainer: { position: "absolute", left: 0, right: 0, flexDirection: "row", alignItems: "center", height: 3, zIndex: 10, overflow: "visible" }, dropLineTop: { top: 0 }, dropLineBottom: { bottom: 0 }, dropLine: { flex: 1, height: 3, backgroundColor: "#0078FF" }, nodeOverflowVisible: { overflow: "visible" } }); //# sourceMappingURL=NodeList.js.map