diff --git a/package.json b/package.json
index 4752cf10..3e0cc09c 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"@fontsource-variable/inter": "^5.2.5",
"@fontsource/dm-serif-display": "^5.2.5",
"@fontsource/jetbrains-mono": "^5.2.5",
+ "@headless-tree/core": "^1.0.1",
"@internationalized/date": "^3.8.0",
"@lucide/svelte": "^0.511.0",
"@tanstack/table-core": "^8.21.3",
@@ -33,6 +34,7 @@
"runed": "^0.28.0",
"svelte-sonner": "^0.3.28",
"svelte-tel-input": "^3.6.0",
+ "svelte-toolbelt": "^0.9.1",
"tailwind-merge": "^2.5.4",
"tailwind-variants": "^0.2.1",
"vaul-svelte": "1.0.0-next.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a2194edf..98e714b0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -29,6 +29,9 @@ importers:
'@fontsource/jetbrains-mono':
specifier: ^5.2.5
version: 5.2.5
+ '@headless-tree/core':
+ specifier: ^1.0.1
+ version: 1.0.1
'@internationalized/date':
specifier: ^3.8.0
version: 3.8.0
@@ -59,6 +62,9 @@ importers:
svelte-tel-input:
specifier: ^3.6.0
version: 3.6.0(svelte@5.30.2)
+ svelte-toolbelt:
+ specifier: ^0.9.1
+ version: 0.9.1(svelte@5.30.2)
tailwind-merge:
specifier: ^2.5.4
version: 2.6.0
@@ -824,6 +830,9 @@ packages:
'@fontsource/jetbrains-mono@5.2.5':
resolution: {integrity: sha512-TPZ9b/uq38RMdrlZZkl0RwN8Ju9JxuqMETrw76pUQFbGtE1QbwQaNsLlnUrACNNBNbd0NZRXiJJSkC8ajPgbew==}
+ '@headless-tree/core@1.0.1':
+ resolution: {integrity: sha512-HbkveX2B5jltHS+90uTgD1CrgTEexjszff4+PRB8onMnAFZJ/eYPGVHZF5sK2dui9dGmP5OwNYTP6r2YXGCe+w==}
+
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@@ -2773,6 +2782,12 @@ packages:
peerDependencies:
svelte: ^5.0.0
+ svelte-toolbelt@0.9.1:
+ resolution: {integrity: sha512-wBX6MtYw/kpht80j5zLpxJyR9soLizXPIAIWEVd9llAi17SR44ZdG291bldjB7r/K5duC0opDFcuhk2cA1hb8g==}
+ engines: {node: '>=18', pnpm: '>=8.7.0'}
+ peerDependencies:
+ svelte: ^5.30.2
+
svelte@5.30.2:
resolution: {integrity: sha512-zfGFEwwPeILToOxOqQyFq/vc8euXrX2XyoffkBNgn/k8D1nxbLt5+mNaqQBmZF/vVhBGmkY6VmNK18p9Gf0auQ==}
engines: {node: '>=18'}
@@ -3611,6 +3626,8 @@ snapshots:
'@fontsource/jetbrains-mono@5.2.5': {}
+ '@headless-tree/core@1.0.1': {}
+
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.6':
@@ -5605,6 +5622,13 @@ snapshots:
style-to-object: 1.0.8
svelte: 5.30.2
+ svelte-toolbelt@0.9.1(svelte@5.30.2):
+ dependencies:
+ clsx: 2.1.1
+ runed: 0.28.0(svelte@5.30.2)
+ style-to-object: 1.0.8
+ svelte: 5.30.2
+
svelte@5.30.2:
dependencies:
'@ampproject/remapping': 2.3.0
diff --git a/src/lib/assets/thumbs/trees-dark.png b/src/lib/assets/thumbs/trees-dark.png
new file mode 100644
index 00000000..bfeb36d3
Binary files /dev/null and b/src/lib/assets/thumbs/trees-dark.png differ
diff --git a/src/lib/assets/thumbs/trees.png b/src/lib/assets/thumbs/trees.png
new file mode 100644
index 00000000..2398684b
Binary files /dev/null and b/src/lib/assets/thumbs/trees.png differ
diff --git a/src/lib/componentRegistry.components.ts b/src/lib/componentRegistry.components.ts
index 828882ab..3be2fa2a 100644
--- a/src/lib/componentRegistry.components.ts
+++ b/src/lib/componentRegistry.components.ts
@@ -2,7 +2,7 @@
/**
* !!!!!!!!!!
* This file is auto-generated. Do not edit manually
- * Last generated at: 5/24/2025, 7:36:58 PM
+ * Last generated at: 5/25/2025, 7:58:07 PM
* To update, run: pnpm generate:registry --format
* @version 0.0.1
* !!!!!!!!!!
@@ -730,6 +730,24 @@ export const OUI_DIRECTORIES = {
todo: 0,
ready: 12
}
+ },
+ TREES: {
+ directory: 'trees',
+ name: 'Trees',
+ components: [
+ 'tree-01.svelte',
+ 'tree-02.svelte',
+ 'tree-03.svelte',
+ 'tree-04.svelte',
+ 'tree-05.svelte',
+ 'tree-06.svelte',
+ 'tree-07.svelte',
+ 'tree-08.svelte'
+ ],
+ status: {
+ todo: 0,
+ ready: 8
+ }
}
} as const;
export type OUIDirectories = typeof OUI_DIRECTORIES;
diff --git a/src/lib/componentRegistry.types.ts b/src/lib/componentRegistry.types.ts
index ed2a1277..2a04c091 100644
--- a/src/lib/componentRegistry.types.ts
+++ b/src/lib/componentRegistry.types.ts
@@ -2,7 +2,7 @@
/**
* !!!!!!!!!!
* This file is auto-generated. Do not edit manually
- * Last generated at: 5/24/2025, 7:36:58 PM
+ * Last generated at: 5/25/2025, 7:58:07 PM
* To update, run: pnpm generate:registry --format
* @version 0.0.1
* !!!!!!!!!!
@@ -110,6 +110,7 @@ export type OUITabsComponents = OUIComponentHelper<'TABS'>;
export type OUITextareasComponents = OUIComponentHelper<'TEXTAREAS'>;
export type OUITimelinesComponents = OUIComponentHelper<'TIMELINES'>;
export type OUITooltipsComponents = OUIComponentHelper<'TOOLTIPS'>;
+export type OUITreesComponents = OUIComponentHelper<'TREES'>;
// All Component Types
export type OUIComponent = Prettify<
@@ -136,6 +137,7 @@ export type OUIComponent = Prettify<
| OUITextareasComponents
| OUITimelinesComponents
| OUITooltipsComponents
+ | OUITreesComponents
>;
// Directory To Component
@@ -163,4 +165,5 @@ export type OUIDirectoryToComponent = Prettify<{
textareas: OUITextareasComponents;
timelines: OUITimelinesComponents;
tooltips: OUITooltipsComponents;
+ trees: OUITreesComponents;
}>;
diff --git a/src/lib/components/trees/tree-01.svelte b/src/lib/components/trees/tree-01.svelte
new file mode 100644
index 00000000..3fa8ccb2
--- /dev/null
+++ b/src/lib/components/trees/tree-01.svelte
@@ -0,0 +1,93 @@
+
+
+
+
+ {#each tree.current.getItems() as item (item.getId())}
+
+
+ {item.getItemData().name}
+
+
+ {/each}
+
+
+ Basic tree with no extra features ∙
+
+ Headless Tree
+
+ ∙
+
+ Svelte Integration
+
+
+
diff --git a/src/lib/components/trees/tree-02.svelte b/src/lib/components/trees/tree-02.svelte
new file mode 100644
index 00000000..260b5d4b
--- /dev/null
+++ b/src/lib/components/trees/tree-02.svelte
@@ -0,0 +1,93 @@
+
+
+
diff --git a/src/lib/components/trees/tree-03.svelte b/src/lib/components/trees/tree-03.svelte
new file mode 100644
index 00000000..41088b77
--- /dev/null
+++ b/src/lib/components/trees/tree-03.svelte
@@ -0,0 +1,110 @@
+
+
+
+
+
+ {#each tree.current.getItems() as item (item.getId())}
+
+
+
+ {#if item.isFolder()}
+ {#if item.isExpanded()}
+
+ {:else}
+
+ {/if}
+ {:else}
+
+ {/if}
+
+ {item.getItemName()}
+
+
+
+ {/each}
+
+
+
+ Basic tree with icons ∙
+
+ Headless Tree
+
+ ∙
+
+ Svelte Integration
+
+
+
diff --git a/src/lib/components/trees/tree-04.svelte b/src/lib/components/trees/tree-04.svelte
new file mode 100644
index 00000000..9b99edaa
--- /dev/null
+++ b/src/lib/components/trees/tree-04.svelte
@@ -0,0 +1,110 @@
+
+
+
+
+
+ {#each tree.current.getItems() as item (item.getId())}
+
+
+
+ {#if item.isFolder()}
+ {#if item.isExpanded()}
+
+ {:else}
+
+ {/if}
+ {:else}
+
+ {/if}
+
+ {item.getItemName()}
+
+
+
+ {/each}
+
+
+
+ Basic tree with caret icon on the right ∙
+
+ Headless Tree
+
+ ∙
+
+ Svelte Integration
+
+
+
diff --git a/src/lib/components/trees/tree-05.svelte b/src/lib/components/trees/tree-05.svelte
new file mode 100644
index 00000000..6eb9449a
--- /dev/null
+++ b/src/lib/components/trees/tree-05.svelte
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+ {#each tree.current.getItems() as item (item.getId())}
+
+
+
+ {#if item.isFolder()}
+ {#if item.isExpanded()}
+
+ {:else}
+
+ {/if}
+ {/if}
+
+ {item.getItemName()}
+
+
+
+ {/each}
+
+
+
+
+ Tree with multi-select and drag and drop ∙
+
+ Headless Tree
+
+ ∙
+
+ Svelte Integration
+
+
+
diff --git a/src/lib/components/trees/tree-06.svelte b/src/lib/components/trees/tree-06.svelte
new file mode 100644
index 00000000..a318a4f0
--- /dev/null
+++ b/src/lib/components/trees/tree-06.svelte
@@ -0,0 +1,125 @@
+
+
+
+
+
+ {#each tree.current.getItems() as item (item.getId())}
+
+ {#snippet child({ props })}
+
+
+ {#if item.isFolder()}
+ {#if item.isExpanded()}
+
+ {:else}
+
+ {/if}
+ {/if}
+
+ {#if tree.reactive(() => item.isRenaming())}
+ {@const { attacher, ...rest } = item.getRenameInputProps()}
+
+ {:else}
+ {item.getItemName()}
+ {/if}
+
+
+ {/snippet}
+
+ {/each}
+
+
+
+ Tree with renaming (press F2 to rename) ∙
+
+ Headless Tree
+
+ ∙
+
+ Svelte Integration
+
+
+
diff --git a/src/lib/components/trees/tree-07.svelte b/src/lib/components/trees/tree-07.svelte
new file mode 100644
index 00000000..e1c49248
--- /dev/null
+++ b/src/lib/components/trees/tree-07.svelte
@@ -0,0 +1,173 @@
+
+
+
+
+
+
+
+ {#each tree.current.getItems() as item (item.getId())}
+
+
+
+ {#if item.isFolder()}
+ {#if item.isExpanded()}
+
+ {:else}
+
+ {/if}
+ {/if}
+
+ {item.getItemName()}
+
+
+
+ {/each}
+
+
+
+ Tree with search highlight ∙
+
+ Headless Tree
+
+ ∙
+
+ Svelte Integration
+
+
+
diff --git a/src/lib/components/trees/tree-08.svelte b/src/lib/components/trees/tree-08.svelte
new file mode 100644
index 00000000..c094a6e7
--- /dev/null
+++ b/src/lib/components/trees/tree-08.svelte
@@ -0,0 +1,323 @@
+
+
+
+
+
+
+
+
+ {#if searchValue}
+
+ {/if}
+
+
+
+
+ {#if searchValue && filteredItems.length === 0}
+ No items found for "{searchValue}"
+ {:else}
+ {#each tree.current.getItems() as item (item.getId())}
+ {@const isVisible = shouldShowItem(item.getId())}
+
+
+
+ {#if item.isFolder()}
+ {#if item.isExpanded()}
+
+ {:else}
+
+ {/if}
+ {/if}
+
+ {item.getItemName()}
+
+
+
+ {/each}
+ {/if}
+
+
+
+
+ Tree with filtering ∙
+
+ Headless Tree
+
+ ∙
+
+ Svelte Integration
+
+
+
diff --git a/src/lib/components/ui/tree/index.ts b/src/lib/components/ui/tree/index.ts
new file mode 100644
index 00000000..c8d174ec
--- /dev/null
+++ b/src/lib/components/ui/tree/index.ts
@@ -0,0 +1,8 @@
+import TreeAssistiveTreeDescription from './tree-assistive-tree-description.svelte';
+import TreeDragLine from './tree-drag-line.svelte';
+import TreeItem from './tree-item.svelte';
+import TreeLabel from './tree-label.svelte';
+import Tree from './tree.svelte';
+import { useTree } from './use-tree.svelte';
+
+export { Tree, TreeAssistiveTreeDescription, TreeDragLine, TreeItem, TreeLabel, useTree };
diff --git a/src/lib/components/ui/tree/tree-assistive-tree-description.svelte b/src/lib/components/ui/tree/tree-assistive-tree-description.svelte
new file mode 100644
index 00000000..22fa44f3
--- /dev/null
+++ b/src/lib/components/ui/tree/tree-assistive-tree-description.svelte
@@ -0,0 +1,68 @@
+
+
+
+
+ {getLabel(state.dnd, state.assistiveDndState ?? AssistiveDndState.None, tree.getHotkeyPresets())}
+
diff --git a/src/lib/components/ui/tree/tree-context-provider.svelte b/src/lib/components/ui/tree/tree-context-provider.svelte
new file mode 100644
index 00000000..bccc7ce0
--- /dev/null
+++ b/src/lib/components/ui/tree/tree-context-provider.svelte
@@ -0,0 +1,15 @@
+
+
+{@render children()}
diff --git a/src/lib/components/ui/tree/tree-context.svelte.ts b/src/lib/components/ui/tree/tree-context.svelte.ts
new file mode 100644
index 00000000..c846f5e0
--- /dev/null
+++ b/src/lib/components/ui/tree/tree-context.svelte.ts
@@ -0,0 +1,23 @@
+import { Context } from 'runed';
+
+import type { ReactiveItemInstance, ReactiveTree } from './use-tree.svelte';
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export interface TreeContextValue {
+ currentItem?: ReactiveItemInstance;
+ indent: number;
+ tree?: ReactiveTree;
+}
+
+export const treeContext = new Context('tree:context');
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function useTreeContext() {
+ const context = treeContext.get();
+
+ if (!context) {
+ throw new Error('useTree must be used within a Tree');
+ }
+
+ return context as TreeContextValue;
+}
diff --git a/src/lib/components/ui/tree/tree-drag-line.svelte b/src/lib/components/ui/tree/tree-drag-line.svelte
new file mode 100644
index 00000000..b52397d7
--- /dev/null
+++ b/src/lib/components/ui/tree/tree-drag-line.svelte
@@ -0,0 +1,42 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/tree/tree-item.svelte b/src/lib/components/ui/tree/tree-item.svelte
new file mode 100644
index 00000000..d96cc597
--- /dev/null
+++ b/src/lib/components/ui/tree/tree-item.svelte
@@ -0,0 +1,74 @@
+
+
+
+
+ {#if child}
+ {@render child({ props: mergedProps })}
+ {:else}
+
+ {/if}
+
diff --git a/src/lib/components/ui/tree/tree-label.svelte b/src/lib/components/ui/tree/tree-label.svelte
new file mode 100644
index 00000000..c3712697
--- /dev/null
+++ b/src/lib/components/ui/tree/tree-label.svelte
@@ -0,0 +1,50 @@
+
+
+
+{#if item}
+
+ {#if item.isFolder()}
+
+ {/if}
+ {#if children}
+ {@render children?.()}
+ {:else}
+ {typeof item.getItemName === 'function' ? item.getItemName() : null}
+ {/if}
+
+{/if}
diff --git a/src/lib/components/ui/tree/tree.svelte b/src/lib/components/ui/tree/tree.svelte
new file mode 100644
index 00000000..ef9db84a
--- /dev/null
+++ b/src/lib/components/ui/tree/tree.svelte
@@ -0,0 +1,41 @@
+
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/tree/use-tree.svelte.ts b/src/lib/components/ui/tree/use-tree.svelte.ts
new file mode 100644
index 00000000..bd13618d
--- /dev/null
+++ b/src/lib/components/ui/tree/use-tree.svelte.ts
@@ -0,0 +1,190 @@
+/**
+ * This is a workaround to allow for behavior in Svelte.
+ * DO NOT USE THIS IN PRODUCTION.
+ * If you have a better solution, please submit a PR. Even better, submit a PR to https://github.com/lukasbach/headless-tree
+ * to give svelte a native way to handle this.
+ */
+
+import {
+ createTree,
+ type FeatureImplementation,
+ type ItemInstance,
+ type TreeConfig,
+ type TreeInstance
+} from '@headless-tree/core';
+import { tick } from 'svelte';
+import { createAttachmentKey } from 'svelte/attachments';
+import { createSubscriber } from 'svelte/reactivity';
+
+// This is a workaround to allow for behavior in Svelte.
+export const svelteFeatures: FeatureImplementation = {
+ itemInstance: {
+ getProps: ({ prev }) => {
+ const props = prev?.() ?? {};
+ return {
+ ...props,
+ [createAttachmentKey()]: (node: HTMLElement) => {
+ props.ref(node);
+ },
+ onblur: props.onBlur ? (e: FocusEvent) => props.onBlur(e) : undefined,
+ onclick: props.onClick ? (e: MouseEvent) => props.onClick(e) : undefined,
+ ondragend: props.onDragEnd ? (e: DragEvent) => props.onDragEnd(e) : undefined,
+ ondragleave: props.onDragLeave ? (e: DragEvent) => props.onDragLeave(e) : undefined,
+ ondragover: props.onDragOver ? (e: DragEvent) => props.onDragOver(e) : undefined,
+ ondragstart: props.onDragStart ? (e: DragEvent) => props.onDragStart(e) : undefined,
+ ondrop: props.onDrop ? (e: DragEvent) => props.onDrop(e) : undefined,
+ onfocus: props.onFocus ? (e: FocusEvent) => props.onFocus(e) : undefined
+ };
+ },
+ getRenameInputProps({ prev }) {
+ const { onBlur, onChange, ...props } = prev?.() ?? {};
+ return {
+ ...props,
+ [createAttachmentKey()]: (node: HTMLElement) => {
+ props.ref?.(node);
+ },
+ onblur: onBlur ? (e: FocusEvent) => onBlur(e) : undefined,
+ // Couldn't get onChange to work, so we're using oninput instead
+ oninput: onChange ? (e: Event) => onChange(e) : undefined
+ };
+ }
+ },
+
+ treeInstance: {
+ getContainerProps({ prev }, ...rest) {
+ const props = prev?.() ?? {};
+ return {
+ ...props,
+ ...rest,
+ [createAttachmentKey()]: (node: HTMLElement) => {
+ props.ref(node);
+ }
+ };
+ },
+ getSearchInputElementProps({ prev }, ...rest) {
+ const { onBlur, onChange, ...props } = prev?.() ?? {};
+ return {
+ ...props,
+ ...rest,
+
+ [createAttachmentKey()]: (node: HTMLElement) => {
+ props.ref?.(node);
+ },
+
+ onblur: onBlur ? (e: FocusEvent) => onBlur(e) : undefined,
+ // Couldn't get onChange to work, so we're using oninput instead
+ oninput: onChange ? (e: Event) => onChange(e) : undefined
+ };
+ }
+ }
+};
+
+// Create a reactive proxy for ItemInstance
+type ReactiveItemInstance = ItemInstance & {
+ [K in keyof ItemInstance]: ItemInstance[K] extends (...args: never[]) => unknown
+ ? (...args: Parameters[K]>) => ReturnType[K]>
+ : ItemInstance[K];
+};
+
+export class ReactiveTree {
+ #tree = $state.raw>()!;
+ #subscribe: () => void;
+ #itemProxies = new WeakMap, ReactiveItemInstance>();
+
+ constructor(config: TreeConfig) {
+ const configWithFeatures: TreeConfig = {
+ ...config,
+ features: [...(config.features ?? []), svelteFeatures]
+ };
+
+ this.#tree = createTree(configWithFeatures);
+
+ this.#subscribe = createSubscriber((update) => {
+ // Store the original methods
+ const originalSetState = this.#tree.setState;
+ const originalApplySubStateUpdate = this.#tree.applySubStateUpdate;
+ const originalGetItems = this.#tree.getItems;
+
+ this.#tree.setState = (state) => {
+ originalSetState.call(this.#tree, state);
+ tick().then(() => {
+ update();
+ });
+ };
+
+ // Override applySubStateUpdate to handle for example selection and drag-and-drop changes
+ this.#tree.applySubStateUpdate = ((
+ ...args: Parameters
+ ) => {
+ originalApplySubStateUpdate.apply(this.#tree, args);
+ tick().then(() => {
+ update();
+ });
+ }) as typeof originalApplySubStateUpdate;
+
+ this.#tree.getItems = (...args: Parameters) => {
+ const items = originalGetItems.apply(this.#tree, args);
+ return items.map((item) => this.#createReactiveItemProxy(item));
+ };
+ return () => {
+ this.#tree.setState = originalSetState;
+ this.#tree.applySubStateUpdate = originalApplySubStateUpdate;
+ this.#tree.getItems = originalGetItems;
+ };
+ });
+ }
+
+ get current(): TreeInstance {
+ this.#subscribe();
+ return this.#tree;
+ }
+
+ // Create a reactive proxy for an item instance
+ #createReactiveItemProxy(item: ItemInstance): ReactiveItemInstance {
+ if (this.#itemProxies.has(item)) {
+ return this.#itemProxies.get(item)!;
+ }
+
+ const proxy = new Proxy(item, {
+ get: (target, prop: string | symbol) => {
+ const value = target[prop as keyof ItemInstance];
+
+ // If it's a function, check if it should be reactive
+ // !TODO: this is a hack to make all methods reactive, we should only make the methods that are needed reactive
+ if (typeof value === 'function' && typeof prop === 'string') {
+ return (...args: unknown[]) => {
+ // Make the call reactive
+ this.#subscribe();
+ return (value as (...args: unknown[]) => unknown).apply(target, args);
+ };
+ }
+
+ // For non-reactive methods and properties, return as-is
+ return value;
+ }
+ }) as ReactiveItemInstance;
+
+ // Cache the proxy
+ this.#itemProxies.set(item, proxy);
+ return proxy;
+ }
+
+ // Simple reactive helper - call this with any item method to make it reactive
+ /**
+ * @param fn - The function to make reactive
+ * @returns The result of the function
+ * @example
+ * ```ts
+ * const isFolder = tree.reactive(() => item.isFolder());
+ * ```
+ */
+ reactive(fn: () => R): R {
+ this.#subscribe();
+ return fn();
+ }
+}
+
+export const useTree = (config: TreeConfig) => new ReactiveTree(config);
+
+// Export the reactive item instance type for use in components
+export type { ReactiveItemInstance };
diff --git a/vite.config.ts b/vite.config.ts
index d0a5b2d1..6a161159 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -13,6 +13,11 @@ export default defineConfig({
compiler: 'svelte'
})
],
+
+ ssr: {
+ //https://github.com/lukasbach/headless-tree/issues/104
+ noExternal: ['@headless-tree/core']
+ },
build: {
rollupOptions: {
onwarn(warning, warn) {