import { computed, reactive, ref, watch } from 'vue' import type { FdsIconName } from '../FdsIcon/types' import type { TreeStateOptions as BaseTreeStateOptions, FdsTreeNode, FdsTreeNodeArray, FdsTreeNodeItem } from './types' import { getTitleFromProperties } from './utils' interface TreeStateOptions extends BaseTreeStateOptions { searchInputTriggerLength?: number nodes?: FdsTreeNodeArray } const useTreeState = (options: TreeStateOptions = {}) => { /** Default the trigger length to 1 when not provided */ const triggerLength = options?.searchInputTriggerLength ?? 1 const nodeIndex = new Map() const subtreeNodeIdsIndex = new Map() const selectedNodeObjectIds = reactive>(new Set()) const parentOrChildrenSelectedCache = new Map() const parentAndAllChildrenSelectedCache = new Map() const parentOnlySelectedCache = new Map() const nodeIndeterminateByIdCache = new Map() /** * The set of selected nodes */ const selectedNodes = reactive>(new Set()) /** * The array of selected node objects */ const selectedNodeObjects = reactive([]) /** * The set of expanded nodes */ const expandedNodes = reactive>(new Set()) /** * The search term for filtering nodes */ const searchTerm = ref('') /** * If no results are found for the search term */ const isEmptySearchResult = ref(false) const selectionVersion = ref(0) const clearDerivedSelectionCaches = () => { parentOrChildrenSelectedCache.clear() parentAndAllChildrenSelectedCache.clear() parentOnlySelectedCache.clear() nodeIndeterminateByIdCache.clear() } const bumpSelectionVersion = () => { selectionVersion.value += 1 } const indexNodes = (nodes: FdsTreeNodeArray) => { for (const rootNode of nodes) { const stack: FdsTreeNode[] = [rootNode] while (stack.length > 0) { const node = stack.pop()! nodeIndex.set(node.nodeId, node) if (node.children && node.children.length > 0) { for (const child of node.children) stack.push(child) } } } } const getSubtreeNodeIds = (node: FdsTreeNode): FdsTreeNode['nodeId'][] => { const cached = subtreeNodeIdsIndex.get(node.nodeId) if (cached) return cached const ids: FdsTreeNode['nodeId'][] = [] const stack: FdsTreeNode[] = [node] while (stack.length > 0) { const current = stack.pop()! ids.push(current.nodeId) if (current.children && current.children.length > 0) { for (const child of current.children) stack.push(child) } } subtreeNodeIdsIndex.set(node.nodeId, ids) return ids } const addNodesToSelection = (nodes: FdsTreeNode[]) => { let hasChanges = false const nodesToAppend: FdsTreeNode[] = [] for (const node of nodes) { if (selectedNodes.has(node.nodeId)) continue hasChanges = true selectedNodes.add(node.nodeId) selectedNodeObjectIds.add(node.nodeId) nodesToAppend.push(node) } if (nodesToAppend.length > 0) selectedNodeObjects.push(...nodesToAppend) if (hasChanges) bumpSelectionVersion() } const removeNodeIdsFromSelection = (nodeIds: Set) => { if (nodeIds.size === 0) return let hasChanges = false const filteredNodes: FdsTreeNode[] = [] nodeIds.forEach((nodeId) => { if (selectedNodes.delete(nodeId)) hasChanges = true selectedNodeObjectIds.delete(nodeId) }) if (hasChanges) { for (const node of selectedNodeObjects) { if (!nodeIds.has(node.nodeId)) filteredNodes.push(node) } selectedNodeObjects.splice(0, selectedNodeObjects.length, ...filteredNodes) bumpSelectionVersion() } } indexNodes(Array.isArray(options.nodes) ? options.nodes : []) // Watch for search term changes to update empty result flag watch(searchTerm, (newTerm) => { if (!newTerm.trim() || newTerm.length < triggerLength) { isEmptySearchResult.value = false } else if (options.nodes && options.nodes.length > 0) { // Check if there are any results for the new search term // The tree structure has a root node with children, so we need to check the root's children const rootNode = options.nodes[0] const nodesToCheck = rootNode?.children || [] const filtered = filterNodesRecursive(nodesToCheck, newTerm, ['title', 'nodeId']) isEmptySearchResult.value = filtered.length === 0 } }) /** * Clears all selected nodes. Can be used to reset the selections to the initial state. */ const clearAllSelectedNodes = () => { const hadSelection = selectedNodes.size > 0 selectedNodes.clear() selectedNodeObjectIds.clear() selectedNodeObjects.length = 0 clearDerivedSelectionCaches() if (hadSelection) bumpSelectionVersion() } /** * Collapses all expanded nodes. Can be used to reset the tree to the initial state. */ const collapseAllExpandedNodes = () => expandedNodes.clear() /** * Returns true if the node is selected */ const isNodeSelected = (nodeId: FdsTreeNode['nodeId']) => selectedNodes.has(nodeId) /** * Returns true if the node is expanded */ const isNodeExpanded = (nodeId: FdsTreeNode['nodeId']) => expandedNodes.has(nodeId) /** * Expands the specific node */ const expandNode = (nodeId: FdsTreeNode['nodeId']) => expandedNodes.add(nodeId) /** * Collapses the specific node */ const collapseNode = (nodeId: FdsTreeNode['nodeId']) => expandedNodes.delete(nodeId) /** * Selects the specific node and adds it to the selectedNodeObjects array. * Used to access the node object and it's underlying data */ const injectNode = (nodeObject: FdsTreeNode) => { // Prevent duplicates if (selectedNodes.has(nodeObject.nodeId)) return selectedNodes.add(nodeObject.nodeId) if (!selectedNodeObjectIds.has(nodeObject.nodeId)) { selectedNodeObjects.push(nodeObject) selectedNodeObjectIds.add(nodeObject.nodeId) bumpSelectionVersion() } } /** * Expands all children of the specific node */ const expandAllChildren = (node: FdsTreeNode) => { expandNode(node.nodeId) if (node.children && node.children.length > 0) { node.children.forEach(expandAllChildren) } } /** * Selects this node and recursively selects all children nodes */ const selectAllChildren = (node: FdsTreeNode) => { const subtreeIds = getSubtreeNodeIds(node) const nodesToAdd: FdsTreeNode[] = [] for (const id of subtreeIds) { if (selectedNodes.has(id)) continue const subtreeNode = nodeIndex.get(id) if (subtreeNode) nodesToAdd.push(subtreeNode) } addNodesToSelection(nodesToAdd) // Only expand children if the option is enabled if (options.expandChildrenOnParentCheck) { expandAllChildren(node) } } /** * Selects this node and recursively selects all children nodes */ const selectNodeAndAllChildren = (nodeId: FdsTreeNode['nodeId']) => { const node = nodeIndex.get(nodeId) if (!node) return selectAllChildren(node) clearDerivedSelectionCaches() } /** * Deselects the specific node */ const deselectNode = (nodeId: FdsTreeNode['nodeId']) => { const hadSelection = selectedNodes.delete(nodeId) selectedNodeObjectIds.delete(nodeId) const index = selectedNodeObjects.findIndex((node) => node.nodeId === nodeId) if (index > -1) { selectedNodeObjects.splice(index, 1) } clearDerivedSelectionCaches() if (hadSelection) bumpSelectionVersion() } /** * Deselects this node and recursively deselects all children nodes */ const deselectNodeAndAllChildren = (node: FdsTreeNode) => { removeNodeIdsFromSelection(new Set(getSubtreeNodeIds(node))) } /** * Deselects only the children of the specific node */ const deselectChildrenOnly = (nodeId: FdsTreeNode['nodeId']) => { const node = nodeIndex.get(nodeId) if (!node) return // Ensure parent remains selected addNodesToSelection([node]) const childNodeIds = new Set() if (node.children) { for (const child of node.children) { getSubtreeNodeIds(child).forEach((id) => childNodeIds.add(id)) } } removeNodeIdsFromSelection(childNodeIds) clearDerivedSelectionCaches() } /** * Finds a node by ID */ const findNodeObjectById = (nodeId: string): FdsTreeNodeItem | null => nodeIndex.get(nodeId) ?? null /** * Checks if a node has children */ const hasChildrenNodes = (nodes: FdsTreeNodeArray) => nodes.length > 0 /** * Checks if the absolute top/root node has children. */ const absoluteTopNodeHasChildren = (): boolean => { const rootNode = Array.isArray(options.nodes) && options.nodes.length > 0 ? options.nodes[0] : null return Array.isArray(rootNode?.children) && rootNode.children.length > 0 } /** * Checks if a parent or it's children are selected */ const isParentOrChildrenSelected = (nodeId: FdsTreeNode['nodeId']): boolean => { const cached = parentOrChildrenSelectedCache.get(nodeId) if (cached !== undefined) return cached const node = findNodeObjectById(nodeId) if (!node || !hasChildrenNodes(node.children || [])) return false const value = selectedNodes.has(node.nodeId) || isAnyChildSelected(node.children || []) parentOrChildrenSelectedCache.set(nodeId, value) return value } /** * Checks if a parent or it's children are selected */ const isParentAndAllChildrenSelected = (nodeId: FdsTreeNode['nodeId']): boolean => { const cached = parentAndAllChildrenSelectedCache.get(nodeId) if (cached !== undefined) return cached const node = findNodeObjectById(nodeId) if (!node) return false // Parent must be selected and all children const value = selectedNodes.has(node.nodeId) && isEveryChildSelected(node) parentAndAllChildrenSelectedCache.set(nodeId, value) return value } /** * Checks if a parent or it's children are selected */ const isParentOnlySelected = (nodeId: FdsTreeNode['nodeId']): boolean => { const cached = parentOnlySelectedCache.get(nodeId) if (cached !== undefined) return cached const node = findNodeObjectById(nodeId) if (!node || !hasChildrenNodes(node.children || [])) return false const value = !isAnyChildSelected(node.children || []) && selectedNodes.has(node.nodeId) parentOnlySelectedCache.set(nodeId, value) return value } /** * Returns true if ALL children of a node are selected */ const isEveryChildSelected = (node: FdsTreeNode): boolean => { if (!node.children || node.children.length === 0) return true return node.children.every((child) => selectedNodes.has(child.nodeId) && isEveryChildSelected(child)) } /** * Checks if any child of a node is selected */ const isAnyChildSelected = (nodes: FdsTreeNodeArray): boolean => { if (!nodes || nodes.length === 0) return false return nodes.some( (child) => selectedNodes.has(child.nodeId) || (child.children && isAnyChildSelected(child.children)), ) } /** * Checks if a node is indeterminate (partially selected) */ const isNodeIndeterminate = (nodes: FdsTreeNodeArray, parentNodeId?: string): boolean => { if (!hasChildrenNodes(nodes)) return false // Check each child's state const childStates = nodes.map((node) => ({ isSelected: selectedNodes.has(node.nodeId), isIndeterminate: node.children && node.children.length > 0 ? isNodeIndeterminate(node.children, node.nodeId) : false, })) const hasSelected = childStates.some((child: { isSelected: boolean; isIndeterminate: boolean }) => child.isSelected) const hasUnselected = childStates.some( (child: { isSelected: boolean; isIndeterminate: boolean }) => !child.isSelected && !child.isIndeterminate, ) const hasIndeterminate = childStates.some( (child: { isSelected: boolean; isIndeterminate: boolean }) => child.isIndeterminate, ) // If parent is selected, never show indeterminate if (parentNodeId && selectedNodes.has(parentNodeId)) return false // Standard behavior: some selected + some unselected, OR any child is indeterminate if (!options.showIndeterminateOnlyOnChildrenSelection) { return (hasSelected && hasUnselected) || hasIndeterminate } // Special behavior: any child selected OR any child indeterminate return hasSelected || hasIndeterminate } const isNodeIndeterminateById = (nodeId: FdsTreeNode['nodeId']): boolean => { const cached = nodeIndeterminateByIdCache.get(nodeId) if (cached !== undefined) return cached const node = findNodeObjectById(nodeId) if (!node) return false const value = isNodeIndeterminate(node.children || [], nodeId) nodeIndeterminateByIdCache.set(nodeId, value) return value } /** * Toggles the selection state of the specific node */ const toggleSelectNode = (nodeId: string, title?: string, data?: Record) => { let node = findNodeObjectById(nodeId) if (!node && nodeId) { node = { nodeId, title, children: [], data, } nodeIndex.set(nodeId, node) subtreeNodeIdsIndex.set(nodeId, [nodeId]) } if (!node) return if (selectedNodes.has(nodeId)) { deselectNodeAndAllChildren(node) } else { selectAllChildren(node) if (selectedNodes.has(nodeId) && !expandedNodes.has(nodeId) && options.expandChildrenOnParentCheck) { expandNode(nodeId) } } clearDerivedSelectionCaches() } /** * Toggles the expansion state of the specific node */ const toggleExpandNode = (nodeId: FdsTreeNode['nodeId']) => { if (isNodeExpanded(nodeId)) { collapseNode(nodeId) } else { expandNode(nodeId) } } /** * Gets the icons for expanded and collapsed nodes */ const getNodeIcon = (nodeId: FdsTreeNode['nodeId'], expandIcon: FdsIconName, collapseIcon: FdsIconName) => isNodeExpanded(nodeId) ? collapseIcon : expandIcon /** * Checks if a node matches the search term by searching through specified properties */ const nodeMatchesSearch = (node: FdsTreeNode, term: string, searchParams?: string[]): boolean => { if (!term.trim()) return true if (!searchParams || !Array.isArray(searchParams)) return false const normalizeForSearch = (value: unknown): string => { const s = String(value ?? '') return s .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/\s+/g, ' ') .trim() } const searchLower = normalizeForSearch(term) const getValueByPath = (obj: unknown, path: string): unknown => { if (!obj) return undefined // If data is an array, check each element if (Array.isArray(obj)) { for (const item of obj) { const v = getValueByPath(item as Record, path) if (v !== undefined && v !== null) return v } return undefined } if (typeof obj !== 'object') return undefined const segments = path.split('.') let current: unknown = obj as Record for (const seg of segments) { if (current && typeof current === 'object' && seg in (current as Record)) { current = (current as Record)[seg] } else { return undefined } } return current } for (const rawParam of searchParams) { const param = rawParam.startsWith('data.') ? rawParam.slice(5) : rawParam if (param === 'title') { const renderedTitle = getTitleFromProperties(node, options?.titleTemplate) const titleToMatch = normalizeForSearch(renderedTitle || node.title) if (titleToMatch.includes(searchLower)) return true } if (param === 'nodeId' && normalizeForSearch(node.nodeId).includes(searchLower)) return true const dataSource = node.data as unknown if (Array.isArray(dataSource)) { for (const item of dataSource) { const v = getValueByPath(item, param) if (v !== undefined && v !== null) { try { if (normalizeForSearch(v).includes(searchLower)) return true } catch {} } } } else { const value = getValueByPath(dataSource, param) if (value !== undefined && value !== null) { try { if (normalizeForSearch(value).includes(searchLower)) return true } catch {} } } } return false } /** * Internal recursive filtering function */ const filterNodesRecursive = (nodes: FdsTreeNodeArray, term: string, searchParams?: string[]): FdsTreeNodeArray => { if (!term.trim()) return nodes const filtered: FdsTreeNodeArray = [] for (const node of nodes) { const nodeMatches = nodeMatchesSearch(node, term, searchParams) const filteredChildren = node.children ? filterNodesRecursive(node.children, term, searchParams) : [] // Include node if it matches OR has matching children if (nodeMatches || filteredChildren.length > 0) { filtered.push({ ...node, children: filteredChildren.length > 0 ? filteredChildren : node.children, }) } } return filtered } /** * Counts how many nodes match the search term across the provided subtree. */ const countMatchingNodesRecursive = (nodes: FdsTreeNodeArray, term: string, searchParams?: string[]): number => { if (!Array.isArray(nodes) || nodes.length === 0) return 0 if (!term.trim()) return nodes.length let count = 0 for (const node of nodes) { if (nodeMatchesSearch(node, term, searchParams)) count += 1 if (node.children && node.children.length > 0) { count += countMatchingNodesRecursive(node.children, term, searchParams) } } return count } /** * Counts all nodes in the provided subtree. */ const countAllNodesRecursive = (nodes: FdsTreeNodeArray): number => { if (!Array.isArray(nodes) || nodes.length === 0) return 0 let count = 0 for (const node of nodes) { count += 1 if (node.children && node.children.length > 0) { count += countAllNodesRecursive(node.children) } } return count } /** * Filters nodes based on search term, including children that match */ const filterNodes = (nodes: FdsTreeNodeArray, term: string, searchParams?: string[]): FdsTreeNodeArray => { if (!term.trim() || term.length < triggerLength) { return nodes } const filtered = filterNodesRecursive(nodes, term, searchParams) return filtered } /** * Sets the search term */ const setSearchTerm = (term: string) => { searchTerm.value = term if (!term.trim() || term.length < triggerLength) { isEmptySearchResult.value = false } } /** * Clears the search term */ const clearSearch = () => { searchTerm.value = '' isEmptySearchResult.value = false } /** * Reactive total count of nodes matching the current search term. * If there is no term, returns total nodes under the root's children. */ const filteredMatchCount = computed(() => { const t = String(searchTerm.value || '').trim() const rootNode = Array.isArray(options.nodes) && options.nodes.length > 0 ? options.nodes[0] : null const nodesToCheck = rootNode?.children || [] if (!rootNode) return 0 if (!t || t.length < triggerLength) { return countAllNodesRecursive(nodesToCheck) } return countMatchingNodesRecursive(nodesToCheck, t, ['title', 'nodeId']) }) /** * Reactive total node count under the root's children. */ const totalNodeCount = computed(() => { const rootNode = Array.isArray(options.nodes) && options.nodes.length > 0 ? options.nodes[0] : null const nodesToCheck = rootNode?.children || [] if (!rootNode) return 0 return countAllNodesRecursive(nodesToCheck) }) /** * Returns HTML string with hits from search term wrapped in . */ const highlightText = (text: string | undefined, term: string | undefined): string => { const source = String(text ?? '') const t = String(term ?? '').trim() if (!t) return source try { const re = new RegExp(t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'ig') return source.replace(re, '$&') } catch { return source } } return { triggerLength, clearSearch, collapseAllExpandedNodes, collapseNode, deselectNodeAndAllChildren, deselectChildrenOnly, deselectNode, expandAllChildren, expandedNodes, expandNode, filterNodes, getNodeIcon, injectNode, isNodeExpanded, isNodeIndeterminate, isNodeIndeterminateById, isNodeSelected, isParentOrChildrenSelected, isParentAndAllChildrenSelected, absoluteTopNodeHasChildren, highlightText, searchTerm, filteredMatchCount, totalNodeCount, selectionVersion, selectAllChildren, selectedNodeObjects, selectedNodes, setSearchTerm, toggleExpandNode, toggleSelectNode, isParentOnlySelected, selectNodeAndAllChildren, clearAllSelectedNodes, isEmptySearchResult, } } export default useTreeState