diff --git a/docs/src/pages/components/TreeView.svx b/docs/src/pages/components/TreeView.svx index 189cf78124..1a214360fe 100644 --- a/docs/src/pages/components/TreeView.svx +++ b/docs/src/pages/components/TreeView.svx @@ -113,3 +113,28 @@ Use `TreeView.showNode` to expand, select, and focus a specific node. Convert flat data to a hierarchical structure using the `toHierarchy` utility. + +## Filter by text with search input + +Combine a `Search` input with `filterTreeByText` to create an interactive searchable tree. Type to filter nodes by name (case-insensitive substring matching). + + + +## Filter utilities + +Three filtering functions are available: + + + filterTreeByText(tree, text, options): Case-insensitive substring search on node text + filterTreeById(tree, id, options): Filter by single ID or array of IDs + filterTreeNodes(tree, predicate, options): Custom filtering with predicate function + + +## Filter options + +All filter functions accept an optional `options` object: + + + includeAncestors (default: true): Include parent chain of matched nodes + includeChildren (default: false): Include all descendants of matched nodes + diff --git a/docs/src/pages/framed/TreeView/TreeViewFilter.svelte b/docs/src/pages/framed/TreeView/TreeViewFilter.svelte new file mode 100644 index 0000000000..9009b6115d --- /dev/null +++ b/docs/src/pages/framed/TreeView/TreeViewFilter.svelte @@ -0,0 +1,127 @@ + + + + +{#if filteredNodes.length > 0} + +{:else} +
+ No matching nodes found for "{searchValue}" +
+{/if} diff --git a/src/index.js b/src/index.js index e28120a430..e7dad790e0 100644 --- a/src/index.js +++ b/src/index.js @@ -153,3 +153,8 @@ export { } from "./UIShell"; export { UnorderedList } from "./UnorderedList"; export { toHierarchy } from "./utils/toHierarchy"; +export { + filterTreeNodes, + filterTreeById, + filterTreeByText, +} from "./utils/filterTreeNodes"; diff --git a/src/utils/filterTreeNodes.d.ts b/src/utils/filterTreeNodes.d.ts new file mode 100644 index 0000000000..71925daf0f --- /dev/null +++ b/src/utils/filterTreeNodes.d.ts @@ -0,0 +1,64 @@ +type NodeLike = { + id: string | number; + text?: string; + nodes?: NodeLike[]; + [key: string]: any; +}; + +type FilterOptions = { + /** + * Include all descendants of matching nodes + * @default false + */ + includeChildren?: boolean; + /** + * Include all ancestors of matching nodes + * @default true + */ + includeAncestors?: boolean; +}; + +/** + * Filter tree nodes by a predicate function. + * Returns a new tree containing only matching nodes and their ancestors. + * + * @example + * const tree = [{ id: 1, text: "Root", nodes: [{ id: 2, text: "Child" }] }]; + * const filtered = filterTreeNodes(tree, (node) => node.id === 2); + * // Result: [{ id: 1, text: "Root", nodes: [{ id: 2, text: "Child" }] }] + */ +export function filterTreeNodes( + tree: T[], + predicate: (node: T) => boolean, + options?: FilterOptions, +): T[]; + +/** + * Filter tree nodes by node ID + * + * @example + * const tree = [{ id: 1, text: "Root", nodes: [{ id: 2, text: "Child" }] }]; + * const filtered = filterTreeById(tree, 2); + * // Result: [{ id: 1, text: "Root", nodes: [{ id: 2, text: "Child" }] }] + */ +export function filterTreeById( + tree: T[], + id: string | number | (string | number)[], + options?: FilterOptions, +): T[]; + +/** + * Filter tree nodes by text/name (case-insensitive substring match) + * + * @example + * const tree = [{ id: 1, text: "Root", nodes: [{ id: 2, text: "Child Node" }] }]; + * const filtered = filterTreeByText(tree, "child"); + * // Result: [{ id: 1, text: "Root", nodes: [{ id: 2, text: "Child Node" }] }] + */ +export function filterTreeByText( + tree: T[], + text: string, + options?: FilterOptions, +): T[]; + +export default filterTreeNodes; diff --git a/src/utils/filterTreeNodes.js b/src/utils/filterTreeNodes.js new file mode 100644 index 0000000000..0becf1932a --- /dev/null +++ b/src/utils/filterTreeNodes.js @@ -0,0 +1,133 @@ +// @ts-check +/** + * Filter tree nodes by a predicate function. + * Returns a new tree containing only matching nodes and their ancestors. + * + * @typedef {Object} TreeNode + * @property {string | number} id - Unique identifier for the node + * @property {string} [text] - Optional text/name for the node + * @property {TreeNode[]} [nodes] - Optional array of child nodes + * @property {Record} [additionalProperties] - Any additional properties + * + * @typedef {Object} FilterOptions + * @property {boolean} [includeChildren=false] - Include all descendants of matching nodes + * @property {boolean} [includeAncestors=true] - Include all ancestors of matching nodes + * + * @param {TreeNode[]} tree - Hierarchical tree structure to filter + * @param {function(TreeNode): boolean} predicate - Function to test each node + * @param {FilterOptions} [options] - Filtering options + * @returns {TreeNode[]} Filtered tree structure + */ +export function filterTreeNodes( + tree, + predicate, + options = { includeChildren: false, includeAncestors: true }, +) { + const { includeChildren = false, includeAncestors = true } = options; + + /** + * Deep clone a node and all its children + * @param {TreeNode} node + * @returns {TreeNode} + */ + function cloneNode(node) { + const cloned = { ...node }; + if (Array.isArray(node.nodes)) { + cloned.nodes = node.nodes.map(cloneNode); + } + return cloned; + } + + /** + * Recursively filter tree nodes + * @param {TreeNode} node + * @returns {{ node: TreeNode | null, hasMatch: boolean }} + */ + function filterNode(node) { + const matches = predicate(node); + + // Process children first + let filteredChildren = []; + let childHasMatch = false; + + if (Array.isArray(node.nodes)) { + for (const child of node.nodes) { + const result = filterNode(child); + if (result.node) { + filteredChildren.push(result.node); + childHasMatch = true; + } + } + } + + // If this node matches and we include children, use all original children + if (matches && includeChildren) { + return { node: cloneNode(node), hasMatch: true }; + } + + // Include this node if: + // 1. It matches the predicate, OR + // 2. includeAncestors is true AND a descendant matches + if (matches || (includeAncestors && childHasMatch)) { + const newNode = { ...node }; + if (filteredChildren.length > 0) { + newNode.nodes = filteredChildren; + } else if (matches && !includeChildren) { + // If node matches but has no matching children, remove nodes array + delete newNode.nodes; + } else { + // Remove empty nodes array when just including ancestors + delete newNode.nodes; + } + return { node: newNode, hasMatch: true }; + } + + return { node: null, hasMatch: false }; + } + + const result = []; + for (const node of tree) { + const filtered = filterNode(node); + if (filtered.node) { + result.push(filtered.node); + } + } + + return result; +} + +/** + * Filter tree nodes by node ID + * @param {TreeNode[]} tree - Hierarchical tree structure to filter + * @param {string | number | (string | number)[]} id - Single ID or array of IDs to match + * @param {FilterOptions} [options] - Filtering options + * @returns {TreeNode[]} Filtered tree structure + */ +export function filterTreeById(tree, id, options) { + const ids = Array.isArray(id) ? new Set(id) : new Set([id]); + return filterTreeNodes(tree, (node) => ids.has(node.id), options); +} + +/** + * Filter tree nodes by text/name (case-insensitive substring match) + * @param {TreeNode[]} tree - Hierarchical tree structure to filter + * @param {string} text - Text to search for (case-insensitive) + * @param {FilterOptions} [options] - Filtering options + * @returns {TreeNode[]} Filtered tree structure + */ +export function filterTreeByText(tree, text, options) { + const searchText = text.toLowerCase(); + return filterTreeNodes( + tree, + (node) => { + const nodeText = node.text || ""; + return ( + typeof nodeText === "string" && + nodeText.toLowerCase().includes(searchText) + ); + }, + options, + ); +} + +export default filterTreeNodes; diff --git a/tests/TreeView/filterTreeNodes.test.ts b/tests/TreeView/filterTreeNodes.test.ts new file mode 100644 index 0000000000..95c07d3eb6 --- /dev/null +++ b/tests/TreeView/filterTreeNodes.test.ts @@ -0,0 +1,361 @@ +import { + filterTreeNodes, + filterTreeById, + filterTreeByText, +} from "../../src/utils/filterTreeNodes"; + +describe("filterTreeNodes", () => { + const sampleTree = [ + { + id: 1, + text: "Documents", + nodes: [ + { + id: 2, + text: "Work", + nodes: [ + { id: 3, text: "Report.docx" }, + { id: 4, text: "Presentation.pptx" }, + ], + }, + { + id: 5, + text: "Personal", + nodes: [{ id: 6, text: "Resume.pdf" }], + }, + ], + }, + { + id: 7, + text: "Pictures", + nodes: [{ id: 8, text: "Vacation.jpg" }], + }, + ]; + + describe("filterTreeNodes with predicate", () => { + test("should filter by custom predicate and include ancestors", () => { + const result = filterTreeNodes(sampleTree, (node) => node.id === 3); + + expect(result).toEqual([ + { + id: 1, + text: "Documents", + nodes: [ + { + id: 2, + text: "Work", + nodes: [{ id: 3, text: "Report.docx" }], + }, + ], + }, + ]); + }); + + test("should filter multiple nodes at different levels", () => { + const result = filterTreeNodes( + sampleTree, + (node) => node.id === 3 || node.id === 8, + ); + + expect(result).toEqual([ + { + id: 1, + text: "Documents", + nodes: [ + { + id: 2, + text: "Work", + nodes: [{ id: 3, text: "Report.docx" }], + }, + ], + }, + { + id: 7, + text: "Pictures", + nodes: [{ id: 8, text: "Vacation.jpg" }], + }, + ]); + }); + + test("should return empty array when no matches", () => { + const result = filterTreeNodes(sampleTree, (node) => node.id === 999); + expect(result).toEqual([]); + }); + + test("should include all children when includeChildren is true", () => { + const result = filterTreeNodes(sampleTree, (node) => node.id === 2, { + includeChildren: true, + }); + + expect(result).toEqual([ + { + id: 1, + text: "Documents", + nodes: [ + { + id: 2, + text: "Work", + nodes: [ + { id: 3, text: "Report.docx" }, + { id: 4, text: "Presentation.pptx" }, + ], + }, + ], + }, + ]); + }); + + test("should exclude ancestors when includeAncestors is false", () => { + const result = filterTreeNodes(sampleTree, (node) => node.id === 3, { + includeAncestors: false, + }); + + expect(result).toEqual([]); + }); + + test("should match root nodes", () => { + const result = filterTreeNodes(sampleTree, (node) => node.id === 1); + + // When a root node matches, without includeChildren, + // it only includes the node itself (no children unless they also match) + expect(result).toEqual([ + { + id: 1, + text: "Documents", + }, + ]); + }); + + test("should preserve additional properties", () => { + const tree = [ + { + id: 1, + text: "Root", + custom: "value", + nodes: [{ id: 2, text: "Child", meta: { key: "data" } }], + }, + ]; + + const result = filterTreeNodes(tree, (node) => node.id === 2); + + expect(result).toEqual([ + { + id: 1, + text: "Root", + custom: "value", + nodes: [{ id: 2, text: "Child", meta: { key: "data" } }], + }, + ]); + }); + }); + + describe("filterTreeById", () => { + test("should filter by single ID", () => { + const result = filterTreeById(sampleTree, 6); + + expect(result).toEqual([ + { + id: 1, + text: "Documents", + nodes: [ + { + id: 5, + text: "Personal", + nodes: [{ id: 6, text: "Resume.pdf" }], + }, + ], + }, + ]); + }); + + test("should filter by array of IDs", () => { + const result = filterTreeById(sampleTree, [3, 4]); + + expect(result).toEqual([ + { + id: 1, + text: "Documents", + nodes: [ + { + id: 2, + text: "Work", + nodes: [ + { id: 3, text: "Report.docx" }, + { id: 4, text: "Presentation.pptx" }, + ], + }, + ], + }, + ]); + }); + + test("should work with includeChildren option", () => { + const result = filterTreeById(sampleTree, 1, { includeChildren: true }); + + // Only the first tree (id=1) matches, so we get just that tree with all children + expect(result).toEqual([sampleTree[0]]); + }); + + test("should handle string IDs", () => { + const tree = [ + { + id: "root", + text: "Root", + nodes: [{ id: "child", text: "Child" }], + }, + ]; + + const result = filterTreeById(tree, "child"); + + expect(result).toEqual([ + { + id: "root", + text: "Root", + nodes: [{ id: "child", text: "Child" }], + }, + ]); + }); + }); + + describe("filterTreeByText", () => { + test("should filter by text (case-insensitive)", () => { + const result = filterTreeByText(sampleTree, "work"); + + // Without includeChildren, only the matching node is included (and ancestors) + expect(result).toEqual([ + { + id: 1, + text: "Documents", + nodes: [ + { + id: 2, + text: "Work", + }, + ], + }, + ]); + }); + + test("should perform substring matching", () => { + const result = filterTreeByText(sampleTree, "res"); + + // "res" matches: "Presentation.pptx", "Personal", "Resume.pdf", and "Pictures" + expect(result).toEqual([ + { + id: 1, + text: "Documents", + nodes: [ + { + id: 2, + text: "Work", + nodes: [{ id: 4, text: "Presentation.pptx" }], + }, + { + id: 5, + text: "Personal", + nodes: [{ id: 6, text: "Resume.pdf" }], + }, + ], + }, + { + id: 7, + text: "Pictures", + }, + ]); + }); + + test("should handle empty search string", () => { + const result = filterTreeByText(sampleTree, ""); + + // Empty string matches all nodes with text + expect(result).toEqual(sampleTree); + }); + + test("should return empty array when no text matches", () => { + const result = filterTreeByText(sampleTree, "nonexistent"); + + expect(result).toEqual([]); + }); + + test("should work with includeChildren option", () => { + const result = filterTreeByText(sampleTree, "Documents", { + includeChildren: true, + }); + + // Only "Documents" node matches, so we get just the first tree with all children + expect(result).toEqual([sampleTree[0]]); + }); + + test("should handle nodes without text property", () => { + const tree = [ + { + id: 1, + nodes: [{ id: 2, text: "Has Text" }], + }, + ]; + + const result = filterTreeByText(tree, "Has"); + + expect(result).toEqual([ + { + id: 1, + nodes: [{ id: 2, text: "Has Text" }], + }, + ]); + }); + }); + + describe("edge cases", () => { + test("should handle empty tree", () => { + const result = filterTreeNodes([], (node) => true); + expect(result).toEqual([]); + }); + + test("should handle deeply nested structures", () => { + const deepTree = [ + { + id: 1, + text: "L1", + nodes: [ + { + id: 2, + text: "L2", + nodes: [ + { + id: 3, + text: "L3", + nodes: [ + { + id: 4, + text: "L4", + nodes: [{ id: 5, text: "L5" }], + }, + ], + }, + ], + }, + ], + }, + ]; + + const result = filterTreeById(deepTree, 5); + + expect(result).toEqual(deepTree); + }); + + test("should not mutate original tree", () => { + const original = [ + { + id: 1, + text: "Root", + nodes: [{ id: 2, text: "Child" }], + }, + ]; + const originalCopy = JSON.parse(JSON.stringify(original)); + + filterTreeById(original, 2); + + expect(original).toEqual(originalCopy); + }); + }); +}); diff --git a/types/index.d.ts b/types/index.d.ts index be215d974d..b4157169aa 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -167,3 +167,6 @@ export { default as HeaderGlobalAction } from "./UIShell/HeaderGlobalAction.svel export { default as HeaderSearch } from "./UIShell/HeaderSearch.svelte"; export { default as UnorderedList } from "./UnorderedList/UnorderedList.svelte"; export { default as toHierarchy } from "./utils/toHierarchy"; +export { default as filterTreeNodes } from "./utils/filterTreeNodes"; +export { default as filterTreeById } from "./utils/filterTreeNodes"; +export { default as filterTreeByText } from "./utils/filterTreeNodes"; diff --git a/types/utils/filterTreeNodes.d.ts b/types/utils/filterTreeNodes.d.ts new file mode 100644 index 0000000000..71925daf0f --- /dev/null +++ b/types/utils/filterTreeNodes.d.ts @@ -0,0 +1,64 @@ +type NodeLike = { + id: string | number; + text?: string; + nodes?: NodeLike[]; + [key: string]: any; +}; + +type FilterOptions = { + /** + * Include all descendants of matching nodes + * @default false + */ + includeChildren?: boolean; + /** + * Include all ancestors of matching nodes + * @default true + */ + includeAncestors?: boolean; +}; + +/** + * Filter tree nodes by a predicate function. + * Returns a new tree containing only matching nodes and their ancestors. + * + * @example + * const tree = [{ id: 1, text: "Root", nodes: [{ id: 2, text: "Child" }] }]; + * const filtered = filterTreeNodes(tree, (node) => node.id === 2); + * // Result: [{ id: 1, text: "Root", nodes: [{ id: 2, text: "Child" }] }] + */ +export function filterTreeNodes( + tree: T[], + predicate: (node: T) => boolean, + options?: FilterOptions, +): T[]; + +/** + * Filter tree nodes by node ID + * + * @example + * const tree = [{ id: 1, text: "Root", nodes: [{ id: 2, text: "Child" }] }]; + * const filtered = filterTreeById(tree, 2); + * // Result: [{ id: 1, text: "Root", nodes: [{ id: 2, text: "Child" }] }] + */ +export function filterTreeById( + tree: T[], + id: string | number | (string | number)[], + options?: FilterOptions, +): T[]; + +/** + * Filter tree nodes by text/name (case-insensitive substring match) + * + * @example + * const tree = [{ id: 1, text: "Root", nodes: [{ id: 2, text: "Child Node" }] }]; + * const filtered = filterTreeByText(tree, "child"); + * // Result: [{ id: 1, text: "Root", nodes: [{ id: 2, text: "Child Node" }] }] + */ +export function filterTreeByText( + tree: T[], + text: string, + options?: FilterOptions, +): T[]; + +export default filterTreeNodes;