Skip to content

Commit 5a476aa

Browse files
committed
feat(tree-view): add filterTreeNodes utilities
Closes #2281 Add three new filtering utilities for TreeView: - `filterTreeNodes`: generic predicate-based filtering - `filterTreeById`: filter by node ID(s) - `filterTreeByText`: case-insensitive text search
1 parent 9b86b04 commit 5a476aa

File tree

6 files changed

+630
-0
lines changed

6 files changed

+630
-0
lines changed

src/index.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,4 +166,9 @@ export { default as SideNavMenu } from "./UIShell/SideNavMenu.svelte";
166166
export { default as SideNavMenuItem } from "./UIShell/SideNavMenuItem.svelte";
167167
export { default as SkipToContent } from "./UIShell/SkipToContent.svelte";
168168
export { default as UnorderedList } from "./UnorderedList/UnorderedList.svelte";
169+
export {
170+
filterTreeById,
171+
filterTreeByText,
172+
filterTreeNodes,
173+
} from "./utils/filterTreeNodes";
169174
export { toHierarchy } from "./utils/toHierarchy";

src/utils/filterTreeNodes.d.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
type NodeLike = {
2+
id: string | number;
3+
text?: string;
4+
nodes?: NodeLike[];
5+
[key: string]: any;
6+
};
7+
8+
type FilterOptions = {
9+
/**
10+
* Include all descendants of matching nodes
11+
* @default false
12+
*/
13+
includeChildren?: boolean;
14+
/**
15+
* Include all ancestors of matching nodes
16+
* @default true
17+
*/
18+
includeAncestors?: boolean;
19+
};
20+
21+
/**
22+
* Filter tree nodes by a predicate function.
23+
* Returns a new tree containing only matching nodes and their ancestors.
24+
*
25+
* @example
26+
* const tree = [{ id: 1, text: "Root", nodes: [{ id: 2, text: "Child" }] }];
27+
* const filtered = filterTreeNodes(tree, (node) => node.id === 2);
28+
* // Result: [{ id: 1, text: "Root", nodes: [{ id: 2, text: "Child" }] }]
29+
*/
30+
export function filterTreeNodes<T extends NodeLike>(
31+
tree: T[],
32+
predicate: (node: T) => boolean,
33+
options?: FilterOptions,
34+
): T[];
35+
36+
/**
37+
* Filter tree nodes by node ID
38+
*
39+
* @example
40+
* const tree = [{ id: 1, text: "Root", nodes: [{ id: 2, text: "Child" }] }];
41+
* const filtered = filterTreeById(tree, 2);
42+
* // Result: [{ id: 1, text: "Root", nodes: [{ id: 2, text: "Child" }] }]
43+
*/
44+
export function filterTreeById<T extends NodeLike>(
45+
tree: T[],
46+
id: string | number | (string | number)[],
47+
options?: FilterOptions,
48+
): T[];
49+
50+
/**
51+
* Filter tree nodes by text/name (case-insensitive substring match)
52+
*
53+
* @example
54+
* const tree = [{ id: 1, text: "Root", nodes: [{ id: 2, text: "Child Node" }] }];
55+
* const filtered = filterTreeByText(tree, "child");
56+
* // Result: [{ id: 1, text: "Root", nodes: [{ id: 2, text: "Child Node" }] }]
57+
*/
58+
export function filterTreeByText<T extends NodeLike>(
59+
tree: T[],
60+
text: string,
61+
options?: FilterOptions,
62+
): T[];
63+
64+
export default filterTreeNodes;

src/utils/filterTreeNodes.js

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// @ts-check
2+
/**
3+
* Filter tree nodes by a predicate function.
4+
* Returns a new tree containing only matching nodes and their ancestors.
5+
*
6+
* @typedef {Object} TreeNode
7+
* @property {string | number} id - Unique identifier for the node
8+
* @property {string} [text] - Optional text/name for the node
9+
* @property {TreeNode[]} [nodes] - Optional array of child nodes
10+
* @property {Record<string, any>} [additionalProperties] - Any additional properties
11+
*
12+
* @typedef {Object} FilterOptions
13+
* @property {boolean} [includeChildren=false] - Include all descendants of matching nodes
14+
* @property {boolean} [includeAncestors=true] - Include all ancestors of matching nodes
15+
*
16+
* @param {TreeNode[]} tree - Hierarchical tree structure to filter
17+
* @param {function(TreeNode): boolean} predicate - Function to test each node
18+
* @param {FilterOptions} [options] - Filtering options
19+
* @returns {TreeNode[]} Filtered tree structure
20+
*/
21+
export function filterTreeNodes(
22+
tree,
23+
predicate,
24+
options = { includeChildren: false, includeAncestors: true },
25+
) {
26+
const { includeChildren = false, includeAncestors = true } = options;
27+
28+
/**
29+
* Deep clone a node and all its children
30+
* @param {TreeNode} node
31+
* @returns {TreeNode}
32+
*/
33+
function cloneNode(node) {
34+
const cloned = { ...node };
35+
if (Array.isArray(node.nodes)) {
36+
cloned.nodes = node.nodes.map(cloneNode);
37+
}
38+
return cloned;
39+
}
40+
41+
/**
42+
* Recursively filter tree nodes
43+
* @param {TreeNode} node
44+
* @returns {{ node: TreeNode | null, hasMatch: boolean }}
45+
*/
46+
function filterNode(node) {
47+
const matches = predicate(node);
48+
49+
// Process children first
50+
const filteredChildren = [];
51+
let childHasMatch = false;
52+
53+
if (Array.isArray(node.nodes)) {
54+
for (const child of node.nodes) {
55+
const result = filterNode(child);
56+
if (result.node) {
57+
filteredChildren.push(result.node);
58+
childHasMatch = true;
59+
}
60+
}
61+
}
62+
63+
// If this node matches and we include children, use all original children
64+
if (matches && includeChildren) {
65+
return { node: cloneNode(node), hasMatch: true };
66+
}
67+
68+
// Include this node if:
69+
// 1. It matches the predicate, OR
70+
// 2. includeAncestors is true AND a descendant matches
71+
if (matches || (includeAncestors && childHasMatch)) {
72+
const newNode = { ...node };
73+
if (filteredChildren.length > 0) {
74+
newNode.nodes = filteredChildren;
75+
} else if (matches && !includeChildren) {
76+
// If node matches but has no matching children, remove nodes array
77+
newNode.nodes = undefined;
78+
} else {
79+
// Remove empty nodes array when just including ancestors
80+
newNode.nodes = undefined;
81+
}
82+
return { node: newNode, hasMatch: true };
83+
}
84+
85+
return { node: null, hasMatch: false };
86+
}
87+
88+
const result = [];
89+
for (const node of tree) {
90+
const filtered = filterNode(node);
91+
if (filtered.node) {
92+
result.push(filtered.node);
93+
}
94+
}
95+
96+
return result;
97+
}
98+
99+
/**
100+
* Filter tree nodes by node ID
101+
* @param {TreeNode[]} tree - Hierarchical tree structure to filter
102+
* @param {string | number | (string | number)[]} id - Single ID or array of IDs to match
103+
* @param {FilterOptions} [options] - Filtering options
104+
* @returns {TreeNode[]} Filtered tree structure
105+
*/
106+
export function filterTreeById(tree, id, options) {
107+
const ids = Array.isArray(id) ? new Set(id) : new Set([id]);
108+
return filterTreeNodes(tree, (node) => ids.has(node.id), options);
109+
}
110+
111+
/**
112+
* Filter tree nodes by text/name (case-insensitive substring match)
113+
* @param {TreeNode[]} tree - Hierarchical tree structure to filter
114+
* @param {string} text - Text to search for (case-insensitive)
115+
* @param {FilterOptions} [options] - Filtering options
116+
* @returns {TreeNode[]} Filtered tree structure
117+
*/
118+
export function filterTreeByText(tree, text, options) {
119+
const searchText = text.toLowerCase();
120+
return filterTreeNodes(
121+
tree,
122+
(node) => {
123+
const nodeText = node.text || "";
124+
return (
125+
typeof nodeText === "string" &&
126+
nodeText.toLowerCase().includes(searchText)
127+
);
128+
},
129+
options,
130+
);
131+
}
132+
133+
export default filterTreeNodes;

0 commit comments

Comments
 (0)