Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/src/pages/components/TreeView.svx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<FileSource src="/framed/TreeView/TreeViewFlatArray" />

## 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).

<FileSource src="/framed/TreeView/TreeViewFilter" />

## Filter utilities

Three filtering functions are available:

<UnorderedList svx-ignore style="margin-bottom: var(--cds-spacing-08)">
<ListItem><strong>filterTreeByText(tree, text, options)</strong>: Case-insensitive substring search on node text</ListItem>
<ListItem><strong>filterTreeById(tree, id, options)</strong>: Filter by single ID or array of IDs</ListItem>
<ListItem><strong>filterTreeNodes(tree, predicate, options)</strong>: Custom filtering with predicate function</ListItem>
</UnorderedList>

## Filter options

All filter functions accept an optional `options` object:

<UnorderedList svx-ignore style="margin-bottom: var(--cds-spacing-08)">
<ListItem><strong>includeAncestors</strong> (default: true): Include parent chain of matched nodes</ListItem>
<ListItem><strong>includeChildren</strong> (default: false): Include all descendants of matched nodes</ListItem>
</UnorderedList>
127 changes: 127 additions & 0 deletions docs/src/pages/framed/TreeView/TreeViewFilter.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<script>
import { TreeView, Search, filterTreeByText } from "carbon-components-svelte";

const allNodes = [
{
id: "1",
text: "Documents",
nodes: [
{
id: "1-1",
text: "Work",
nodes: [
{ id: "1-1-1", text: "Report.docx" },
{ id: "1-1-2", text: "Presentation.pptx" },
{ id: "1-1-3", text: "Budget.xlsx" },
{ id: "1-1-4", text: "Meeting Notes.txt" },
],
},
{
id: "1-2",
text: "Personal",
nodes: [
{ id: "1-2-1", text: "Resume.pdf" },
{ id: "1-2-2", text: "Cover Letter.pdf" },
{ id: "1-2-3", text: "Portfolio.pdf" },
],
},
{
id: "1-3",
text: "Projects",
nodes: [
{
id: "1-3-1",
text: "Website",
nodes: [
{ id: "1-3-1-1", text: "index.html" },
{ id: "1-3-1-2", text: "styles.css" },
{ id: "1-3-1-3", text: "script.js" },
],
},
{
id: "1-3-2",
text: "App",
nodes: [
{ id: "1-3-2-1", text: "main.py" },
{ id: "1-3-2-2", text: "config.json" },
],
},
],
},
],
},
{
id: "2",
text: "Pictures",
nodes: [
{ id: "2-1", text: "Vacation.jpg" },
{ id: "2-2", text: "Family.jpg" },
{ id: "2-3", text: "Birthday.png" },
],
},
{
id: "3",
text: "Music",
nodes: [
{
id: "3-1",
text: "Rock",
nodes: [
{ id: "3-1-1", text: "Song1.mp3" },
{ id: "3-1-2", text: "Song2.mp3" },
],
},
{
id: "3-2",
text: "Jazz",
nodes: [
{ id: "3-2-1", text: "Song3.mp3" },
{ id: "3-2-2", text: "Song4.mp3" },
],
},
{
id: "3-3",
text: "Classical",
nodes: [{ id: "3-3-1", text: "Symphony.mp3" }],
},
],
},
];

let searchValue = "";
let expandedIds = [];

// Reactively filter nodes based on search input
$: filteredNodes =
searchValue.trim() === ""
? allNodes
: filterTreeByText(allNodes, searchValue);

// Auto-expand filtered nodes to show matches
$: if (searchValue.trim() !== "" && filteredNodes.length > 0) {
// Extract all node IDs from filtered tree for expansion
const extractIds = (nodes) => {
const ids = [];
for (const node of nodes) {
ids.push(node.id);
if (node.nodes) {
ids.push(...extractIds(node.nodes));
}
}
return ids;
};
expandedIds = extractIds(filteredNodes);
} else {
expandedIds = [];
}
</script>

<Search placeholder="Search tree nodes..." bind:value={searchValue} />

{#if filteredNodes.length > 0}
<TreeView labelText="File System" nodes={filteredNodes} {expandedIds} />
{:else}
<div style="padding: 1rem; color: var(--cds-text-secondary);">
No matching nodes found for "{searchValue}"
</div>
{/if}
5 changes: 5 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,8 @@ export {
} from "./UIShell";
export { UnorderedList } from "./UnorderedList";
export { toHierarchy } from "./utils/toHierarchy";
export {
filterTreeNodes,
filterTreeById,
filterTreeByText,
} from "./utils/filterTreeNodes";
64 changes: 64 additions & 0 deletions src/utils/filterTreeNodes.d.ts
Original file line number Diff line number Diff line change
@@ -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<T extends NodeLike>(
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<T extends NodeLike>(
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<T extends NodeLike>(
tree: T[],
text: string,
options?: FilterOptions,
): T[];

export default filterTreeNodes;
133 changes: 133 additions & 0 deletions src/utils/filterTreeNodes.js
Original file line number Diff line number Diff line change
@@ -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<string, any>} [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;
Loading
Loading