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 @@ + + +
+
+ + {#each tree.current.getItems() as item (item.getId())} + + + + {/each} + +

+ Basic tree with vertical lines ∙ + + Headless Tree + + ∙ + + Svelte Integration + +

+
+
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) {