diff --git a/docs/src/pages/components/RecursiveList.svx b/docs/src/pages/components/RecursiveList.svx
index d5941bdafe..fba581d110 100644
--- a/docs/src/pages/components/RecursiveList.svx
+++ b/docs/src/pages/components/RecursiveList.svx
@@ -37,4 +37,11 @@ Set `type` to `"ordered"` to use the ordered list variant.
Set `type` to `"ordered-native"` to use the native styles for an ordered list.
-
\ No newline at end of file
+
+
+## Flat data structure
+
+If working with a flat data structure, use the `toHierarchy` utility
+to convert a flat data structure into a hierarchical array accepted by the `nodes` prop.
+
+
diff --git a/docs/src/pages/components/TreeView.svx b/docs/src/pages/components/TreeView.svx
index c7c97c92de..15e22015f6 100644
--- a/docs/src/pages/components/TreeView.svx
+++ b/docs/src/pages/components/TreeView.svx
@@ -107,3 +107,10 @@ Use the `TreeView.showNode` method to show a specific node.
If a matching node is found, it will be expanded, selected, and focused.
+
+## Flat data structure
+
+If working with a flat data structure, use the `toHierarchy` utility
+to convert a flat data structure into a hierarchical array accepted by the `nodes` prop.
+
+
diff --git a/docs/src/pages/framed/RecursiveList/RecursiveListFlatArray.svelte b/docs/src/pages/framed/RecursiveList/RecursiveListFlatArray.svelte
new file mode 100644
index 0000000000..356ffb45d1
--- /dev/null
+++ b/docs/src/pages/framed/RecursiveList/RecursiveListFlatArray.svelte
@@ -0,0 +1,20 @@
+
+
+ node.pid)} />
diff --git a/docs/src/pages/framed/TreeView/TreeViewFlatArray.svelte b/docs/src/pages/framed/TreeView/TreeViewFlatArray.svelte
new file mode 100644
index 0000000000..415aee4c3c
--- /dev/null
+++ b/docs/src/pages/framed/TreeView/TreeViewFlatArray.svelte
@@ -0,0 +1,28 @@
+
+
+ node.pid)}
+/>
diff --git a/src/TreeView/index.d.ts b/src/TreeView/index.d.ts
new file mode 100644
index 0000000000..59f96be0f3
--- /dev/null
+++ b/src/TreeView/index.d.ts
@@ -0,0 +1 @@
+export { default as TreeView } from "./TreeView.svelte";
diff --git a/src/index.js b/src/index.js
index ea1a84cf46..e28120a430 100644
--- a/src/index.js
+++ b/src/index.js
@@ -152,3 +152,4 @@ export {
HeaderSearch,
} from "./UIShell";
export { UnorderedList } from "./UnorderedList";
+export { toHierarchy } from "./utils/toHierarchy";
diff --git a/src/utils/toHierarchy.d.ts b/src/utils/toHierarchy.d.ts
new file mode 100644
index 0000000000..a805f8999c
--- /dev/null
+++ b/src/utils/toHierarchy.d.ts
@@ -0,0 +1,21 @@
+type NodeLike = {
+ id: string | number;
+ nodes?: NodeLike[];
+ [key: string]: any;
+};
+
+/** Create a hierarchical tree from a flat array. */
+export function toHierarchy<
+ T extends NodeLike,
+ K extends keyof Omit,
+>(
+ flatArray: T[] | readonly T[],
+ /**
+ * Function that returns the parent ID for a given node.
+ * @example
+ * toHierarchy(flatArray, (node) => node.parentId);
+ */
+ getParentId: (node: T) => T[K] | null,
+): (T & { nodes?: (T & { nodes?: T[] })[] })[];
+
+export default toHierarchy;
diff --git a/src/utils/toHierarchy.js b/src/utils/toHierarchy.js
new file mode 100644
index 0000000000..39f47b9eb7
--- /dev/null
+++ b/src/utils/toHierarchy.js
@@ -0,0 +1,49 @@
+// @ts-check
+/**
+ * Create a nested array from a flat array.
+ * @typedef {Object} NodeLike
+ * @property {string | number} id - Unique identifier for the node
+ * @property {NodeLike[]} [nodes] - Optional array of child nodes
+ * @property {Record} [additionalProperties] - Any additional properties
+ *
+ * @param {NodeLike[]} flatArray - Array of flat nodes to convert
+ * @param {function(NodeLike): (string|number|null)} getParentId - Function to get parent ID for a node
+ * @returns {NodeLike[]} Hierarchical tree structure
+ */
+export function toHierarchy(flatArray, getParentId) {
+ /** @type {NodeLike[]} */
+ const tree = [];
+ const childrenOf = new Map();
+ const itemsMap = new Map(flatArray.map((item) => [item.id, item]));
+
+ flatArray.forEach((item) => {
+ const parentId = getParentId(item);
+
+ // Only create nodes array if we have children.
+ const children = childrenOf.get(item.id);
+ if (children) {
+ item.nodes = children;
+ }
+
+ // Check if parentId exists using Map instead of array lookup.
+ const parentExists = parentId && itemsMap.has(parentId);
+
+ if (parentId && parentExists) {
+ if (!childrenOf.has(parentId)) {
+ childrenOf.set(parentId, []);
+ }
+ childrenOf.get(parentId).push(item);
+
+ const parent = itemsMap.get(parentId);
+ if (parent) {
+ parent.nodes = childrenOf.get(parentId);
+ }
+ } else {
+ tree.push(item);
+ }
+ });
+
+ return tree;
+}
+
+export default toHierarchy;
diff --git a/tests/App.test.svelte b/tests/App.test.svelte
index de04ad2fe1..96c876d1f2 100644
--- a/tests/App.test.svelte
+++ b/tests/App.test.svelte
@@ -1,6 +1,7 @@
+
+
diff --git a/tests/RecursiveList.test.svelte b/tests/RecursiveList/RecursiveList.test.svelte
similarity index 83%
rename from tests/RecursiveList.test.svelte
rename to tests/RecursiveList/RecursiveList.test.svelte
index 4aeda09427..cb1e9f980d 100644
--- a/tests/RecursiveList.test.svelte
+++ b/tests/RecursiveList/RecursiveList.test.svelte
@@ -14,18 +14,14 @@
{
text: "Item 2",
nodes: [
- {
- href: "https://svelte.dev/",
- },
+ { href: "https://svelte.dev/" },
{
href: "https://svelte.dev/",
text: "Link with custom text",
},
],
},
- {
- text: "Item 3",
- },
+ { text: "Item 3" },
];
diff --git a/tests/RecursiveList/RecursiveList.test.ts b/tests/RecursiveList/RecursiveList.test.ts
new file mode 100644
index 0000000000..1614c1545a
--- /dev/null
+++ b/tests/RecursiveList/RecursiveList.test.ts
@@ -0,0 +1,47 @@
+import { render, screen } from "@testing-library/svelte";
+import RecursiveListHierarchyTest from "./RecursiveList.hierarchy.test.svelte";
+import RecursiveListTest from "./RecursiveList.test.svelte";
+
+const testCases = [
+ { name: "RecursiveList", component: RecursiveListTest },
+ { name: "RecursiveList hierarchy", component: RecursiveListHierarchyTest },
+];
+
+describe.each(testCases)("$name", ({ component }) => {
+ it("renders all top-level items", () => {
+ render(component);
+
+ expect(screen.getByText("Item 1")).toBeInTheDocument();
+ expect(screen.getByText("Item 2")).toBeInTheDocument();
+ expect(screen.getByText("Item 3")).toBeInTheDocument();
+
+ expect(screen.getAllByRole("list")).toHaveLength(4);
+
+ // Nested items
+ expect(screen.getByText("Item 1a")).toBeInTheDocument();
+ });
+
+ it("renders HTML content", () => {
+ render(component);
+
+ const htmlContent = screen.getByText("HTML content");
+ expect(htmlContent.tagName).toBe("H5");
+ });
+
+ it("renders links correctly", () => {
+ render(component);
+
+ const links = screen.getAllByRole("link");
+ expect(links).toHaveLength(2);
+
+ // Link with custom text
+ const customLink = screen.getByText("Link with custom text");
+ expect(customLink).toHaveAttribute("href", "https://svelte.dev/");
+
+ // Plain link
+ const plainLink = links.find(
+ (link) => link.textContent === "https://svelte.dev/",
+ );
+ expect(plainLink).toHaveAttribute("href", "https://svelte.dev/");
+ });
+});
diff --git a/tests/TreeView/TreeView.hierarchy.test.svelte b/tests/TreeView/TreeView.hierarchy.test.svelte
new file mode 100644
index 0000000000..932298664c
--- /dev/null
+++ b/tests/TreeView/TreeView.hierarchy.test.svelte
@@ -0,0 +1,61 @@
+
+
+ console.log("select", detail)}
+ on:toggle={({ detail }) => console.log("toggle", detail)}
+ on:focus={({ detail }) => console.log("focus", detail)}
+ let:node
+>
+ {node.text}
+
+
+
+
diff --git a/tests/TreeView/TreeView.test.svelte b/tests/TreeView/TreeView.test.svelte
index fb89bb49fe..3887361674 100644
--- a/tests/TreeView/TreeView.test.svelte
+++ b/tests/TreeView/TreeView.test.svelte
@@ -50,18 +50,6 @@
];
$: console.log("selectedIds", selectedIds);
-
- /* $: if (treeview) {
- treeview.expandAll();
- treeview.expandNodes((node) => {
- return +node.id > 0;
- });
- treeview.collapseAll();
- treeview.collapseNodes((node) => {
- return node.disabled === true;
- });
- treeview.showNode(1);
- } */
{
+const testCases = [
+ { name: "TreeView", component: TreeView },
+ { name: "TreeView hierarchy", component: TreeViewHierarchy },
+];
+
+describe.each(testCases)("$name", ({ component }) => {
const getItemByName = (name: RegExp) => {
return screen.getByRole("treeitem", {
name,
@@ -30,7 +36,7 @@ describe("TreeView", () => {
it("can select a node", async () => {
const consoleLog = vi.spyOn(console, "log");
- render(TreeView);
+ render(component);
const firstItem = getItemByName(/AI \/ Machine learning/);
expect(firstItem).toBeInTheDocument();
@@ -49,7 +55,7 @@ describe("TreeView", () => {
});
it("can expand all nodes", async () => {
- render(TreeView);
+ render(component);
noExpandedItems();
@@ -60,7 +66,7 @@ describe("TreeView", () => {
});
it("can expand some nodes", async () => {
- render(TreeView);
+ render(component);
noExpandedItems();
diff --git a/tests/TreeView/toHierarchy.test.ts b/tests/TreeView/toHierarchy.test.ts
new file mode 100644
index 0000000000..66f946c190
--- /dev/null
+++ b/tests/TreeView/toHierarchy.test.ts
@@ -0,0 +1,105 @@
+import { toHierarchy } from "../../src/utils/toHierarchy";
+
+describe("toHierarchy", () => {
+ test("should create a flat hierarchy when no items have parents", () => {
+ const input = [
+ { id: 1, name: "Item 1" },
+ { id: 2, name: "Item 2", parentId: "invalid" },
+ ];
+ const result = toHierarchy(input, (item) => item.parentId);
+
+ expect(result).toEqual([
+ { id: 1, name: "Item 1" },
+ { id: 2, name: "Item 2", parentId: "invalid" },
+ ]);
+ });
+
+ test("should create a nested hierarchy with parent-child relationships", () => {
+ const input = [
+ { id: 1, name: "Parent" },
+ { id: 2, name: "Child", pid: 1, randomKey: "randomValue" },
+ { id: 3, name: "Grandchild", pid: 2 },
+ ];
+ const result = toHierarchy(input, (item) => item.pid);
+
+ expect(result).toEqual([
+ {
+ id: 1,
+ name: "Parent",
+ nodes: [
+ {
+ id: 2,
+ name: "Child",
+ pid: 1,
+ nodes: [
+ {
+ id: 3,
+ name: "Grandchild",
+ pid: 2,
+ },
+ ],
+ randomKey: "randomValue",
+ },
+ ],
+ },
+ ]);
+ });
+
+ test("should handle multiple root nodes with children", () => {
+ const input = [
+ { id: 1, name: "Root 1" },
+ { id: 2, name: "Root 2" },
+ { id: 3, name: "Child 1", pid: 1 },
+ { id: 4, name: "Child 2", pid: 2 },
+ ];
+ const result = toHierarchy(input, (item) => item.pid);
+
+ expect(result).toEqual([
+ {
+ id: 1,
+ name: "Root 1",
+ nodes: [
+ {
+ id: 3,
+ name: "Child 1",
+ pid: 1,
+ },
+ ],
+ },
+ {
+ id: 2,
+ name: "Root 2",
+ nodes: [
+ {
+ id: 4,
+ name: "Child 2",
+ pid: 2,
+ },
+ ],
+ },
+ ]);
+ });
+
+ test("should remove empty nodes arrays", () => {
+ const input = [
+ { id: 1, name: "Root" },
+ { id: 2, name: "Leaf", pid: 1 },
+ ];
+ const result = toHierarchy(input, (item) => item.pid);
+ expect(result).toEqual([
+ {
+ id: 1,
+ name: "Root",
+ nodes: [
+ {
+ id: 2,
+ name: "Leaf",
+ pid: 1,
+ },
+ ],
+ },
+ ]);
+
+ expect(result[0].nodes?.[0]).not.toHaveProperty("nodes");
+ });
+});
diff --git a/types/TreeView/index.d.ts b/types/TreeView/index.d.ts
new file mode 100644
index 0000000000..59f96be0f3
--- /dev/null
+++ b/types/TreeView/index.d.ts
@@ -0,0 +1 @@
+export { default as TreeView } from "./TreeView.svelte";
diff --git a/types/index.d.ts b/types/index.d.ts
index 1174ce26c1..be215d974d 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -166,3 +166,4 @@ export { default as SkipToContent } from "./UIShell/SkipToContent.svelte";
export { default as HeaderGlobalAction } from "./UIShell/HeaderGlobalAction.svelte";
export { default as HeaderSearch } from "./UIShell/HeaderSearch.svelte";
export { default as UnorderedList } from "./UnorderedList/UnorderedList.svelte";
+export { default as toHierarchy } from "./utils/toHierarchy";
diff --git a/types/utils/toHierarchy.d.ts b/types/utils/toHierarchy.d.ts
new file mode 100644
index 0000000000..a805f8999c
--- /dev/null
+++ b/types/utils/toHierarchy.d.ts
@@ -0,0 +1,21 @@
+type NodeLike = {
+ id: string | number;
+ nodes?: NodeLike[];
+ [key: string]: any;
+};
+
+/** Create a hierarchical tree from a flat array. */
+export function toHierarchy<
+ T extends NodeLike,
+ K extends keyof Omit,
+>(
+ flatArray: T[] | readonly T[],
+ /**
+ * Function that returns the parent ID for a given node.
+ * @example
+ * toHierarchy(flatArray, (node) => node.parentId);
+ */
+ getParentId: (node: T) => T[K] | null,
+): (T & { nodes?: (T & { nodes?: T[] })[] })[];
+
+export default toHierarchy;