@r2digisolutions/components 0.6.10 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -85,6 +85,53 @@ Los componentes usan clases de Tailwind 4. En la app consumidora basta con impor
85
85
 
86
86
  Ese import registra tokens (`border-border`, `bg-surface`, …) y hace que Tailwind escanee los `.svelte` del paquete. Sin él, las utilidades pueden faltar o `--border` no estar definido.
87
87
 
88
+ ## Tema: modo + paleta
89
+
90
+ Hay dos ejes independientes en `<html>`:
91
+
92
+ | Atributo | Valores | Qué cambia |
93
+ | -------------- | ----------------------------- | ----------------- |
94
+ | `class="dark"` | presente / ausente | Claro / oscuro |
95
+ | `data-theme` | `slate` (defecto) · `neutral` | Familia de grises |
96
+
97
+ - **slate** — gris frío (hue 265, el look actual).
98
+ - **neutral** — croma 0, sin castaño azul. Misma familia que Tailwind `neutral` (`#171717`, `#fafafa`).
99
+
100
+ Para evitar FOUC, fíjalo en `app.html` antes de pintar:
101
+
102
+ ```html
103
+ <html lang="es" data-theme="neutral">
104
+ <head>
105
+ <script>
106
+ (function () {
107
+ var palette = localStorage.getItem('r2-theme-palette') || 'neutral';
108
+ document.documentElement.setAttribute('data-theme', palette);
109
+ if (
110
+ localStorage.theme === 'dark' ||
111
+ localStorage.getItem('r2-theme') === 'dark' ||
112
+ (!('theme' in localStorage) &&
113
+ !localStorage.getItem('r2-theme') &&
114
+ window.matchMedia('(prefers-color-scheme: dark)').matches)
115
+ ) {
116
+ document.documentElement.classList.add('dark');
117
+ }
118
+ })();
119
+ </script>
120
+ </head>
121
+ </html>
122
+ ```
123
+
124
+ En runtime (Svelte 5):
125
+
126
+ ```ts
127
+ import { themeStore } from '@r2digisolutions/components';
128
+
129
+ themeStore.set('dark');
130
+ themeStore.setPalette('neutral');
131
+ // Solo esta página, sin persistir:
132
+ themeStore.setPalette('slate', { persist: false });
133
+ ```
134
+
88
135
  ## Licencia
89
136
 
90
137
  MIT
@@ -111,12 +111,9 @@
111
111
  }
112
112
  </script>
113
113
 
114
- <div class={['flex flex-col gap-1.5 w-full', className]}>
114
+ <div class={['gap-1.5 flex w-full flex-col', className]}>
115
115
  {#if label}
116
- <label
117
- for={inputId}
118
- class="text-sm font-medium text-primary"
119
- >
116
+ <label for={inputId} class="text-sm font-medium text-primary">
120
117
  {label}
121
118
  {#if required}
122
119
  <span class="text-red-500 ml-0.5" aria-hidden="true">*</span>
@@ -126,51 +123,53 @@
126
123
 
127
124
  <div
128
125
  class={[
129
- 'flex items-center gap-2 w-full bg-surface-elevated border rounded-lg transition-all duration-200',
126
+ 'gap-2 rounded-lg flex w-full items-center border bg-transparent transition-all duration-200',
130
127
  paddingSizeClasses[size],
131
128
  wrapperSizeClasses[size],
132
129
  statusRingClasses[status],
133
- disabled && 'opacity-50 cursor-not-allowed bg-surface'
130
+ disabled ? 'bg-surface cursor-not-allowed opacity-50' : 'cursor-text'
134
131
  ]}
135
132
  >
136
- {#if leadIcon}
137
- <span class={['shrink-0 text-muted', statusIconClasses[status]]}>
138
- {@render leadIcon()}
139
- </span>
140
- {/if}
133
+ <label class="min-h-0 min-w-0 gap-2 flex flex-1 cursor-text items-center self-stretch">
134
+ {#if leadIcon}
135
+ <span class={['text-muted pointer-events-none shrink-0', statusIconClasses[status]]}>
136
+ {@render leadIcon()}
137
+ </span>
138
+ {/if}
141
139
 
142
- <input
143
- id={inputId}
144
- {name}
145
- {type}
146
- {placeholder}
147
- {disabled}
148
- {readonly}
149
- {required}
150
- {autofocus}
151
- {min}
152
- {max}
153
- {step}
154
- bind:value
155
- aria-describedby={helperText ? helperId : undefined}
156
- aria-invalid={status === 'error'}
157
- class={[
158
- 'min-w-0 flex-1 bg-transparent text-primary outline-none placeholder:text-muted disabled:cursor-not-allowed',
159
- type === 'search' &&
160
- '[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none'
161
- ]}
162
- {oninput}
163
- {onchange}
164
- {onfocus}
165
- {onblur}
166
- {onkeydown}
167
- />
140
+ <input
141
+ id={inputId}
142
+ {name}
143
+ {type}
144
+ {placeholder}
145
+ {disabled}
146
+ {readonly}
147
+ {required}
148
+ {autofocus}
149
+ {min}
150
+ {max}
151
+ {step}
152
+ bind:value
153
+ aria-describedby={helperText ? helperId : undefined}
154
+ aria-invalid={status === 'error'}
155
+ class={[
156
+ 'min-w-0 text-primary placeholder:text-muted h-full flex-1 self-stretch bg-transparent outline-none disabled:cursor-not-allowed',
157
+ type === 'search' &&
158
+ '[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none'
159
+ ]}
160
+ {oninput}
161
+ {onchange}
162
+ {onfocus}
163
+ {onblur}
164
+ {onkeydown}
165
+ />
166
+ </label>
168
167
 
169
168
  {#if hasClear}
170
169
  <button
171
170
  type="button"
172
171
  onclick={clearValue}
173
- class="shrink-0 text-muted hover:text-primary transition-colors duration-150 rounded"
172
+ class="text-muted hover:text-primary rounded shrink-0 transition-colors duration-150"
174
173
  aria-label="Clear input"
175
174
  >
176
175
  <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -178,16 +177,34 @@
178
177
  </svg>
179
178
  </button>
180
179
  {:else if trailIcon}
181
- <span class={['shrink-0 text-muted', statusIconClasses[status]]}>
180
+ <span class={['text-muted shrink-0', statusIconClasses[status]]}>
182
181
  {@render trailIcon()}
183
182
  </span>
184
183
  {:else if status === 'error'}
185
- <svg class="h-4 w-4 shrink-0 text-red-500" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
186
- <path fill-rule="evenodd" d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm-1 5a1 1 0 112 0v4a1 1 0 11-2 0V7zm1 8a1.5 1.5 0 100-3 1.5 1.5 0 000 3z" clip-rule="evenodd" />
184
+ <svg
185
+ class="h-4 w-4 text-red-500 shrink-0"
186
+ viewBox="0 0 24 24"
187
+ fill="currentColor"
188
+ aria-hidden="true"
189
+ >
190
+ <path
191
+ fill-rule="evenodd"
192
+ d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm-1 5a1 1 0 112 0v4a1 1 0 11-2 0V7zm1 8a1.5 1.5 0 100-3 1.5 1.5 0 000 3z"
193
+ clip-rule="evenodd"
194
+ />
187
195
  </svg>
188
196
  {:else if status === 'success'}
189
- <svg class="h-4 w-4 shrink-0 text-green-500" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
190
- <path fill-rule="evenodd" d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm4.707 7.293a1 1 0 00-1.414 0L10 14.586l-1.293-1.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l6-6a1 1 0 000-1.414z" clip-rule="evenodd" />
197
+ <svg
198
+ class="h-4 w-4 text-green-500 shrink-0"
199
+ viewBox="0 0 24 24"
200
+ fill="currentColor"
201
+ aria-hidden="true"
202
+ >
203
+ <path
204
+ fill-rule="evenodd"
205
+ d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm4.707 7.293a1 1 0 00-1.414 0L10 14.586l-1.293-1.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l6-6a1 1 0 000-1.414z"
206
+ clip-rule="evenodd"
207
+ />
191
208
  </svg>
192
209
  {/if}
193
210
  </div>
@@ -37,7 +37,7 @@
37
37
  end?: Snippet;
38
38
  /** Replaces default error + retry */
39
39
  errorSnippet?: Snippet;
40
- onloadmore?: () => void;
40
+ onloadmore?: () => void | Promise<void>;
41
41
  onretry?: () => void;
42
42
  }
43
43
 
@@ -67,41 +67,35 @@
67
67
  let locked = $state(false);
68
68
 
69
69
  const canAutoLoad = $derived(
70
- (mode === 'auto' || mode === 'both') &&
71
- !disabled &&
72
- !loading &&
73
- !error &&
74
- hasMore &&
75
- !locked
70
+ (mode === 'auto' || mode === 'both') && !disabled && !loading && !error && hasMore && !locked
76
71
  );
77
72
 
78
73
  const showManual = $derived(
79
- (mode === 'manual' || mode === 'both') && hasMore && !disabled && !error
74
+ (mode === 'manual' || mode === 'both') && hasMore && !disabled && !error && !loading && !locked
80
75
  );
81
76
 
82
- function requestMore() {
77
+ async function requestMore() {
83
78
  if (disabled || loading || !hasMore || locked || error) return;
84
79
  locked = true;
85
- onloadmore?.();
80
+ try {
81
+ await onloadmore?.();
82
+ } finally {
83
+ locked = false;
84
+ }
86
85
  }
87
86
 
88
87
  function retry() {
89
88
  if (loading) return;
90
- onretry?.() ?? onloadmore?.();
89
+ void (onretry?.() ?? requestMore());
91
90
  }
92
91
 
93
- // Unlock when parent finishes loading (or errors / ends).
94
- $effect(() => {
95
- if (!loading) locked = false;
96
- });
97
-
98
92
  $effect(() => {
99
93
  if (!sentinel || !canAutoLoad) return;
100
94
 
101
95
  const el = sentinel;
102
96
  const obs = new IntersectionObserver(
103
97
  (entries) => {
104
- if (entries.some((e) => e.isIntersecting)) requestMore();
98
+ if (entries.some((e) => e.isIntersecting)) void requestMore();
105
99
  },
106
100
  {
107
101
  root: scrollRoot ?? null,
@@ -114,10 +108,7 @@
114
108
  });
115
109
  </script>
116
110
 
117
- <div
118
- class={['w-full', className]}
119
- aria-busy={loading}
120
- >
111
+ <div class={['w-full', className]} aria-busy={loading}>
121
112
  {#if sentinelPos === 'start'}
122
113
  {@render footer()}
123
114
  {/if}
@@ -134,14 +125,14 @@
134
125
  {#snippet footer()}
135
126
  <div
136
127
  bind:this={sentinel}
137
- class="flex flex-col items-center justify-center gap-2 py-4"
128
+ class="gap-2 py-4 flex flex-col items-center justify-center"
138
129
  aria-live="polite"
139
130
  >
140
131
  {#if error}
141
132
  {#if errorSnippet}
142
133
  {@render errorSnippet()}
143
134
  {:else}
144
- <div class="flex flex-col items-center gap-2 px-3 text-center">
135
+ <div class="gap-2 px-3 flex flex-col items-center text-center">
145
136
  <p class="text-xs text-red-600 dark:text-red-400">{error}</p>
146
137
  <Button size="sm" variant="secondary" onclick={retry} disabled={loading}>
147
138
  {retryLabel}
@@ -152,7 +143,7 @@
152
143
  {#if loader}
153
144
  {@render loader()}
154
145
  {:else}
155
- <div class="flex items-center gap-2 text-muted">
146
+ <div class="gap-2 text-muted flex items-center">
156
147
  <Spinner size="sm" />
157
148
  <span class="text-xs">Loading…</span>
158
149
  </div>
@@ -167,7 +158,7 @@
167
158
  <Button
168
159
  size="sm"
169
160
  variant={mode === 'manual' ? 'secondary' : 'ghost'}
170
- onclick={requestMore}
161
+ onclick={() => void requestMore()}
171
162
  disabled={loading}
172
163
  >
173
164
  {loadMoreLabel}
@@ -29,7 +29,7 @@ interface InfiniteScrollProps {
29
29
  end?: Snippet;
30
30
  /** Replaces default error + retry */
31
31
  errorSnippet?: Snippet;
32
- onloadmore?: () => void;
32
+ onloadmore?: () => void | Promise<void>;
33
33
  onretry?: () => void;
34
34
  }
35
35
  declare const InfiniteScroll: import("svelte").Component<InfiniteScrollProps, {}, "">;
@@ -139,8 +139,7 @@
139
139
  const q = searchQuery.toLowerCase();
140
140
  return leafOptions.filter(
141
141
  (o) =>
142
- o.label.toLowerCase().includes(q) ||
143
- (o.breadcrumb?.toLowerCase().includes(q) ?? false)
142
+ o.label.toLowerCase().includes(q) || (o.breadcrumb?.toLowerCase().includes(q) ?? false)
144
143
  );
145
144
  }
146
145
  return currentLevel.options;
@@ -480,7 +479,7 @@
480
479
  popovertargetaction="toggle"
481
480
  onkeydown={handleTriggerKeydown}
482
481
  class={[
483
- 'gap-2 bg-surface-elevated rounded-lg flex w-full items-center justify-between border text-left transition-colors duration-150 outline-none select-none',
482
+ 'gap-2 rounded-lg flex w-full items-center justify-between border bg-transparent text-left transition-colors duration-150 outline-none select-none',
484
483
  sizeClasses[size],
485
484
  statusRingClasses[status],
486
485
  isOpen && 'border-brand-500 ring-brand-500/20 ring-2',
@@ -562,7 +561,7 @@
562
561
  <button
563
562
  type="button"
564
563
  onclick={goBack}
565
- class="gap-2 text-primary hover:bg-surface-overlay mx-1.5 mt-1.5 flex shrink-0 items-center rounded-lg px-2.5 py-2 text-left text-sm font-medium transition-colors"
564
+ class="gap-2 text-primary hover:bg-surface-overlay mx-1.5 mt-1.5 rounded-lg px-2.5 py-2 text-sm font-medium flex shrink-0 items-center text-left transition-colors"
566
565
  >
567
566
  <svg
568
567
  class="text-secondary h-4 w-4 shrink-0"
@@ -0,0 +1,26 @@
1
+ import type { StoryObj } from '@storybook/svelte';
2
+ declare const meta: {
3
+ title: string;
4
+ component: import("svelte").Component<{
5
+ showMeta?: boolean;
6
+ empty?: boolean;
7
+ }, {}, "">;
8
+ tags: string[];
9
+ argTypes: {
10
+ showMeta: {
11
+ control: "boolean";
12
+ };
13
+ empty: {
14
+ control: "boolean";
15
+ };
16
+ };
17
+ args: {
18
+ showMeta: false;
19
+ empty: false;
20
+ };
21
+ };
22
+ export default meta;
23
+ type Story = StoryObj<typeof meta>;
24
+ export declare const Default: Story;
25
+ export declare const WithOrder: Story;
26
+ export declare const Empty: Story;
@@ -0,0 +1,18 @@
1
+ import TreePanelStory from './TreePanelStory.svelte';
2
+ const meta = {
3
+ title: 'Molecules/TreePanel',
4
+ component: TreePanelStory,
5
+ tags: ['autodocs'],
6
+ argTypes: {
7
+ showMeta: { control: 'boolean' },
8
+ empty: { control: 'boolean' }
9
+ },
10
+ args: {
11
+ showMeta: false,
12
+ empty: false
13
+ }
14
+ };
15
+ export default meta;
16
+ export const Default = {};
17
+ export const WithOrder = { args: { showMeta: true } };
18
+ export const Empty = { args: { empty: true } };
@@ -0,0 +1,278 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import Card from '../Card/Card.svelte';
4
+ import IconButton from '../../atoms/IconButton/IconButton.svelte';
5
+ import IconBox from '../../atoms/IconBox/IconBox.svelte';
6
+
7
+ export interface TreePanelNode {
8
+ id: string;
9
+ label: string;
10
+ /** Emoji or short glyph shown instead of the default folder / branch icon. */
11
+ icon?: string | null;
12
+ /** Extra column value (sort order, count, …). */
13
+ meta?: string | number | null;
14
+ children?: TreePanelNode[];
15
+ }
16
+
17
+ interface TreePanelProps {
18
+ items?: TreePanelNode[];
19
+ collapsedIds?: string[];
20
+ emptyMessage?: string;
21
+ hierarchyLabel?: string;
22
+ metaLabel?: string;
23
+ actionsLabel?: string;
24
+ showMeta?: boolean;
25
+ addChildLabel?: string;
26
+ editLabel?: string;
27
+ deleteLabel?: string;
28
+ expandLabel?: string;
29
+ collapseLabel?: string;
30
+ onAddChild?: (node: TreePanelNode) => void;
31
+ onEdit?: (node: TreePanelNode) => void;
32
+ onDelete?: (node: TreePanelNode) => void;
33
+ /** Extra content after the label (badges, counts). */
34
+ meta?: Snippet<[TreePanelNode, number]>;
35
+ /** Replaces the default add / edit / delete buttons. */
36
+ trailing?: Snippet<[TreePanelNode, number]>;
37
+ leading?: Snippet<[TreePanelNode, number]>;
38
+ empty?: Snippet;
39
+ class?: string;
40
+ }
41
+
42
+ let {
43
+ items = [],
44
+ collapsedIds = $bindable([]),
45
+ emptyMessage = '',
46
+ hierarchyLabel = 'Hierarchy',
47
+ metaLabel = 'Order',
48
+ actionsLabel = 'Actions',
49
+ showMeta = false,
50
+ addChildLabel = 'Add child',
51
+ editLabel = 'Edit',
52
+ deleteLabel = 'Delete',
53
+ expandLabel = 'Expand',
54
+ collapseLabel = 'Collapse',
55
+ onAddChild,
56
+ onEdit,
57
+ onDelete,
58
+ meta,
59
+ trailing,
60
+ leading,
61
+ empty,
62
+ class: className = ''
63
+ }: TreePanelProps = $props();
64
+
65
+ const collapsed = $derived(new Set(collapsedIds));
66
+ const showActions = $derived(!!trailing || !!onAddChild || !!onEdit || !!onDelete);
67
+
68
+ function toggleCollapse(id: string) {
69
+ collapsedIds = collapsed.has(id)
70
+ ? collapsedIds.filter((item) => item !== id)
71
+ : [...collapsedIds, id];
72
+ }
73
+ </script>
74
+
75
+ <Card variant="default" padding="none" class={`rounded-3xl ${className}`.trim()}>
76
+ {#snippet header()}
77
+ <div
78
+ class="px-6 py-4 text-xs font-semibold tracking-wider text-secondary flex items-center uppercase"
79
+ >
80
+ <div class="min-w-0 flex-1">{hierarchyLabel}</div>
81
+ {#if showMeta}
82
+ <div class="mr-2 w-14 text-center">{metaLabel}</div>
83
+ {/if}
84
+ {#if showActions}
85
+ <div class="w-28 text-right">{actionsLabel}</div>
86
+ {/if}
87
+ </div>
88
+ {/snippet}
89
+
90
+ <div class="p-2">
91
+ {#if items.length === 0}
92
+ {#if empty}
93
+ {@render empty()}
94
+ {:else}
95
+ <p class="px-4 py-8 text-sm text-secondary text-center">{emptyMessage}</p>
96
+ {/if}
97
+ {/if}
98
+
99
+ {#snippet treeItem(node: TreePanelNode, level: number)}
100
+ {@const children = node.children ?? []}
101
+ {@const hasChildren = children.length > 0}
102
+ {@const isCollapsed = collapsed.has(node.id)}
103
+ <div
104
+ class="group rounded-xl p-2 hover:bg-surface-overlay flex items-center transition-colors"
105
+ >
106
+ <div class="min-w-0 flex flex-1 items-center" style:padding-left={`${level * 1.5}rem`}>
107
+ {#if hasChildren}
108
+ <IconButton
109
+ size="xs"
110
+ variant="ghost"
111
+ label={isCollapsed ? expandLabel : collapseLabel}
112
+ class={!isCollapsed ? 'mr-1 [&_svg]:rotate-90' : 'mr-1'}
113
+ onclick={() => toggleCollapse(node.id)}
114
+ >
115
+ <svg
116
+ class="transition-transform"
117
+ viewBox="0 0 24 24"
118
+ fill="none"
119
+ stroke="currentColor"
120
+ stroke-width="2"
121
+ stroke-linecap="round"
122
+ stroke-linejoin="round"
123
+ >
124
+ <path d="m9 18 6-6-6-6" />
125
+ </svg>
126
+ </IconButton>
127
+ {:else}
128
+ <span class="mr-1 h-7 w-7 flex shrink-0 items-center justify-center" aria-hidden="true">
129
+ <span class="h-1 w-1 bg-border-strong rounded-full"></span>
130
+ </span>
131
+ {/if}
132
+
133
+ {#if leading}
134
+ {@render leading(node, level)}
135
+ {:else if node.icon}
136
+ <span class="mr-2 text-lg shrink-0" aria-hidden="true">{node.icon}</span>
137
+ {:else if level === 0}
138
+ <IconBox tone="brand" size="sm" rounded="lg" class="mr-2">
139
+ <svg
140
+ viewBox="0 0 24 24"
141
+ fill="none"
142
+ stroke="currentColor"
143
+ stroke-width="2"
144
+ stroke-linecap="round"
145
+ stroke-linejoin="round"
146
+ >
147
+ <path
148
+ d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"
149
+ />
150
+ <path d="M2 10h20" />
151
+ </svg>
152
+ </IconBox>
153
+ {:else}
154
+ <svg
155
+ class="mr-2 h-4 w-4 text-secondary shrink-0"
156
+ viewBox="0 0 24 24"
157
+ fill="none"
158
+ stroke="currentColor"
159
+ stroke-width="2"
160
+ stroke-linecap="round"
161
+ stroke-linejoin="round"
162
+ aria-hidden="true"
163
+ >
164
+ <polyline points="15 10 20 15 15 20" />
165
+ <path d="M4 4v7a4 4 0 0 0 4 4h12" />
166
+ </svg>
167
+ {/if}
168
+
169
+ <span
170
+ class={[
171
+ 'font-medium truncate',
172
+ level === 0 ? 'text-base font-semibold text-primary' : 'text-sm text-secondary'
173
+ ]}
174
+ >
175
+ {node.label}
176
+ </span>
177
+ {#if meta}
178
+ <div class="ml-2 shrink-0">
179
+ {@render meta(node, level)}
180
+ </div>
181
+ {/if}
182
+ </div>
183
+
184
+ {#if showMeta}
185
+ <span class="mr-2 w-14 font-mono text-xs text-secondary text-center">
186
+ {node.meta ?? ''}
187
+ </span>
188
+ {/if}
189
+
190
+ {#if showActions}
191
+ <div
192
+ class="w-28 gap-0.5 sm:opacity-0 sm:group-hover:opacity-100 flex items-center justify-end opacity-100 transition-opacity"
193
+ >
194
+ {#if trailing}
195
+ {@render trailing(node, level)}
196
+ {:else}
197
+ {#if onAddChild}
198
+ <IconButton
199
+ size="xs"
200
+ variant="ghost"
201
+ label={addChildLabel}
202
+ class="text-secondary hover:bg-emerald-50 hover:text-emerald-600 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-400"
203
+ onclick={() => onAddChild(node)}
204
+ >
205
+ <svg
206
+ viewBox="0 0 24 24"
207
+ fill="none"
208
+ stroke="currentColor"
209
+ stroke-width="2"
210
+ stroke-linecap="round"
211
+ stroke-linejoin="round"
212
+ >
213
+ <path d="M5 12h14" />
214
+ <path d="M12 5v14" />
215
+ </svg>
216
+ </IconButton>
217
+ {/if}
218
+ {#if onEdit}
219
+ <IconButton
220
+ size="xs"
221
+ variant="ghost"
222
+ label={editLabel}
223
+ class="text-secondary hover:bg-sky-50 hover:text-sky-600 dark:hover:bg-sky-500/10 dark:hover:text-sky-400"
224
+ onclick={() => onEdit(node)}
225
+ >
226
+ <svg
227
+ viewBox="0 0 24 24"
228
+ fill="none"
229
+ stroke="currentColor"
230
+ stroke-width="2"
231
+ stroke-linecap="round"
232
+ stroke-linejoin="round"
233
+ >
234
+ <path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
235
+ <path d="m15 5 4 4" />
236
+ </svg>
237
+ </IconButton>
238
+ {/if}
239
+ {#if onDelete}
240
+ <IconButton
241
+ size="xs"
242
+ variant="ghost"
243
+ label={deleteLabel}
244
+ class="text-secondary hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-500/10 dark:hover:text-red-400"
245
+ onclick={() => onDelete(node)}
246
+ >
247
+ <svg
248
+ viewBox="0 0 24 24"
249
+ fill="none"
250
+ stroke="currentColor"
251
+ stroke-width="2"
252
+ stroke-linecap="round"
253
+ stroke-linejoin="round"
254
+ >
255
+ <path d="M3 6h18" />
256
+ <path d="M8 6V4h8v2" />
257
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
258
+ </svg>
259
+ </IconButton>
260
+ {/if}
261
+ {/if}
262
+ </div>
263
+ {/if}
264
+ </div>
265
+ {#if hasChildren && !isCollapsed}
266
+ {#each children as child (child.id)}
267
+ {@render treeItem(child, level + 1)}
268
+ {/each}
269
+ {/if}
270
+ {/snippet}
271
+
272
+ <div class="space-y-0.5">
273
+ {#each items as node (node.id)}
274
+ {@render treeItem(node, 0)}
275
+ {/each}
276
+ </div>
277
+ </div>
278
+ </Card>