@spaethtech/svelte-ui 0.21.1-dev.126.3149ff9 → 0.21.1-dev.128.59d3487

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/MIGRATION.md CHANGED
@@ -8,6 +8,16 @@ upgrade are documented here, each with a dated heading and a concrete before/aft
8
8
 
9
9
  ## The `*2` native-first variants (transitional)
10
10
 
11
+ > ### ⚠️ EXPERIMENTAL — proof-of-concept, NOT ready for use
12
+ >
13
+ > The `*2` components (`Select2`, `Disclosure2`, `Accordion2`, `DatePicker2`) are a **proof-of-concept
14
+ > under active evaluation in a single test bed (php-svelte) only**. They ship on the **`@dev`** channel
15
+ > so that test bed can exercise them — **do not adopt them in any other project yet.** The API, the
16
+ > behaviour, and even whether each one survives are still in flux (e.g. reset semantics are still being
17
+ > settled). Treat them as unstable: no compatibility promise, and they may change or be removed without
18
+ > a migration note while in this phase. When one is actually ready, this banner comes off and the swap
19
+ > is announced here. Until then: **look, test in php-svelte, don't build on them.**
20
+
11
21
  Several components are getting a **native-first, no-JS / SSR-correct** rebuild so SSR apps (e.g. a
12
22
  PHP-rendered front end) render and submit them with **no client JavaScript**. Rather than change the
13
23
  existing components in place and risk the apps that already depend on them, each rebuild ships
@@ -2,7 +2,9 @@
2
2
  /**
3
3
  * Tree — a hierarchical, expandable tree view (ARIA `tree`). Data-driven nodes; expand/collapse +
4
4
  * single-select with roving-tabindex keyboard nav (↑/↓ move, →/← expand/collapse, Enter/Space select).
5
- * Shared `variant`/`size` axes, `--ui-*` tokens. See Tree.spec.md.
5
+ * Shared `variant`/`size` axes, `--ui-*` tokens. Supports LAZY children (a `hasChildren` hint +
6
+ * `loadChildren`/`onexpand`), a per-node `oncontextmenu`/`data-key` for right-click menus, and
7
+ * `expandOnSelect={false}` to keep selection and expansion separate. See Tree.spec.md.
6
8
  */
7
9
  -->
8
10
  <script lang="ts" module>
@@ -14,22 +16,33 @@
14
16
  icon?: Snippet;
15
17
  children?: TreeNode[];
16
18
  disabled?: boolean;
19
+ /** Lazy-tree hint: `true` = has children (show the chevron) even before they're loaded; `false` =
20
+ * definitely a leaf (no chevron); `undefined` = inferred from `children`. */
21
+ hasChildren?: boolean;
17
22
  };
18
23
  </script>
19
24
 
20
25
  <script lang="ts">
21
26
  import IconChevronRight from "~icons/mdi/chevron-right";
27
+ import IconLoading from "~icons/mdi/loading";
22
28
  import type { HTMLAttributes } from "svelte/elements";
23
29
  import type { Variant } from "../../types/variants.js";
24
30
  import { variantToken } from "../../types/variants.js";
25
31
  import type { Size } from "../../types/sizes.js";
26
32
  import { responsiveClasses, type Responsive } from "../../types/responsive.js";
33
+ import { uiI18n } from "../../i18n.js";
34
+
35
+ const i18n = uiI18n();
27
36
 
28
37
  let {
29
38
  items,
30
39
  expanded = $bindable([]),
31
40
  selected = $bindable(""),
32
41
  onselect,
42
+ onexpand,
43
+ oncontextmenu,
44
+ loadChildren,
45
+ expandOnSelect = true,
33
46
  variant = "primary",
34
47
  size = "md",
35
48
  class: cls = "",
@@ -41,12 +54,25 @@
41
54
  /** Key of the selected node (bindable). */
42
55
  selected?: string;
43
56
  onselect?: (node: TreeNode, key: string) => void;
57
+ /** Fired when a node is expanded — use with a consumer-managed `items` to load children yourself
58
+ * (or use `loadChildren` to let the Tree own the fetch + spinner). */
59
+ onexpand?: (node: TreeNode, key: string) => void;
60
+ /** Right-click on a row. The event is NOT prevented, so a wrapping `ContextMenu` still opens;
61
+ * each row also carries `data-key` for consumers that read the target instead. */
62
+ oncontextmenu?: (event: MouseEvent, node: TreeNode, key: string) => void;
63
+ /** Lazily load a node's children. Called once when an unloaded node (`hasChildren: true`, no
64
+ * `children`) is first expanded: the Tree shows a spinner row, caches the resolved children, and
65
+ * shows an inline error row on rejection. */
66
+ loadChildren?: (node: TreeNode, key: string) => Promise<TreeNode[]>;
67
+ /** `true` (default): selecting a parent also toggles it (current behaviour). `false`: select and
68
+ * expand are separate — expand only via the chevron or →/←, as desktop explorers do. */
69
+ expandOnSelect?: boolean;
44
70
  variant?: Variant;
45
71
  size?: Responsive<Size>;
46
72
  class?: string;
47
- // `style` omitted — rows compute their own inline style (indent + active tint). `onselect` omitted
48
- // too: it collides with the DOM `onselect` in HTMLAttributes but has this component's own signature.
49
- } & Omit<HTMLAttributes<HTMLElement>, "class" | "style" | "onselect"> = $props();
73
+ // `style`/`onselect`/`oncontextmenu` omitted from the DOM attrs: rows compute their own style, and
74
+ // the latter two have this component's own (node-aware) signatures.
75
+ } & Omit<HTMLAttributes<HTMLElement>, "class" | "style" | "onselect" | "oncontextmenu"> = $props();
50
76
 
51
77
  const token = $derived(variantToken[variant]);
52
78
  const rowH: Record<Size, string> = { sm: "h-7 text-xs", md: "h-8 text-sm", lg: "h-10 text-base" };
@@ -56,37 +82,95 @@
56
82
  lg: "[&_svg]:w-5 [&_svg]:h-5",
57
83
  };
58
84
 
59
- type Flat = { key: string; node: TreeNode; level: number; hasChildren: boolean; open: boolean };
60
- // Flattened list of VISIBLE nodes (children shown only under expanded parents) — drives rendering,
61
- // roving tabindex, and arrow-key navigation.
85
+ // Lazily-loaded children + per-node load state, keyed by node key. Kept internal so `items` stays
86
+ // immutable for the consumer; `loadChildren`'s result is cached here and merged into the flat view.
87
+ let loadedChildren = $state<Record<string, TreeNode[]>>({});
88
+ let loadingKeys = $state<Record<string, boolean>>({});
89
+ let loadErrors = $state<Record<string, string>>({});
90
+
91
+ const childrenOf = (node: TreeNode, key: string): TreeNode[] | undefined =>
92
+ node.children ?? loadedChildren[key];
93
+ const nodeHasChildren = (node: TreeNode, key: string): boolean =>
94
+ node.hasChildren ?? (childrenOf(node, key)?.length ?? 0) > 0;
95
+
96
+ type Flat = {
97
+ key: string;
98
+ node: TreeNode;
99
+ level: number;
100
+ hasChildren: boolean;
101
+ open: boolean;
102
+ /** A synthetic, non-interactive row for a lazy node's load state. */
103
+ status?: "loading" | "error";
104
+ message?: string;
105
+ };
106
+ // Flattened list of VISIBLE rows (children shown only under expanded parents), plus synthetic
107
+ // loading/error rows for lazy nodes. Drives rendering; `navFlat` (real nodes only) drives keyboard nav.
62
108
  const flat = $derived.by<Flat[]>(() => {
63
109
  const out: Flat[] = [];
64
110
  const walk = (nodes: TreeNode[], level: number, prefix: string) => {
65
111
  nodes.forEach((node, i) => {
66
112
  const key = node.id ?? `${prefix}${i}`;
67
- const hasChildren = !!node.children?.length;
113
+ const hasChildren = nodeHasChildren(node, key);
68
114
  const open = expanded.includes(key);
69
115
  out.push({ key, node, level, hasChildren, open });
70
- if (hasChildren && open) walk(node.children!, level + 1, `${key}>`);
116
+ if (hasChildren && open) {
117
+ const kids = childrenOf(node, key);
118
+ if (kids && kids.length) walk(kids, level + 1, `${key}>`);
119
+ else if (loadingKeys[key])
120
+ out.push({ key: `${key}::loading`, node, level: level + 1, hasChildren: false, open: false, status: "loading" });
121
+ else if (loadErrors[key])
122
+ out.push({ key: `${key}::error`, node, level: level + 1, hasChildren: false, open: false, status: "error", message: loadErrors[key] });
123
+ }
71
124
  });
72
125
  };
73
126
  walk(items, 0, "");
74
127
  return out;
75
128
  });
129
+ const navFlat = $derived(flat.filter((f) => !f.status));
76
130
 
77
131
  let rowRefs = $state<Record<string, HTMLElement | undefined>>({});
78
132
  let activeKey = $state("");
79
133
  const tabKey = $derived(
80
- flat.some((f) => f.key === activeKey) ? activeKey : (flat.find((f) => !f.node.disabled)?.key ?? ""),
134
+ navFlat.some((f) => f.key === activeKey)
135
+ ? activeKey
136
+ : (navFlat.find((f) => !f.node.disabled)?.key ?? ""),
81
137
  );
82
138
 
83
- const toggle = (key: string) =>
84
- (expanded = expanded.includes(key) ? expanded.filter((k) => k !== key) : [...expanded, key]);
139
+ function maybeLoad(f: Flat) {
140
+ if (!loadChildren) return;
141
+ const kids = childrenOf(f.node, f.key);
142
+ if ((kids && kids.length) || loadingKeys[f.key] || loadedChildren[f.key]) return;
143
+ loadingKeys = { ...loadingKeys, [f.key]: true };
144
+ const { [f.key]: _drop, ...restErrors } = loadErrors;
145
+ loadErrors = restErrors;
146
+ loadChildren(f.node, f.key)
147
+ .then((res) => {
148
+ loadedChildren = { ...loadedChildren, [f.key]: res };
149
+ })
150
+ .catch((err) => {
151
+ loadErrors = { ...loadErrors, [f.key]: err instanceof Error ? err.message : String(err) };
152
+ })
153
+ .finally(() => {
154
+ const { [f.key]: _done, ...restLoading } = loadingKeys;
155
+ loadingKeys = restLoading;
156
+ });
157
+ }
158
+
159
+ function setOpen(f: Flat, open: boolean) {
160
+ if (open === f.open) return;
161
+ expanded = open ? [...expanded, f.key] : expanded.filter((k) => k !== f.key);
162
+ if (open) {
163
+ onexpand?.(f.node, f.key);
164
+ maybeLoad(f);
165
+ }
166
+ }
167
+ const toggleOpen = (f: Flat) => setOpen(f, !f.open);
168
+
85
169
  function selectNode(f: Flat) {
86
170
  if (f.node.disabled) return;
87
171
  selected = f.key;
88
172
  onselect?.(f.node, f.key);
89
- if (f.hasChildren) toggle(f.key);
173
+ if (f.hasChildren && expandOnSelect) toggleOpen(f);
90
174
  }
91
175
  const focusKey = (key: string) => {
92
176
  activeKey = key;
@@ -94,42 +178,42 @@
94
178
  };
95
179
 
96
180
  function onKeydown(e: KeyboardEvent) {
97
- const idx = flat.findIndex((f) => f.key === tabKey);
181
+ const idx = navFlat.findIndex((f) => f.key === tabKey);
98
182
  if (idx < 0) return;
99
- const f = flat[idx];
183
+ const f = navFlat[idx];
100
184
  switch (e.key) {
101
185
  case "ArrowDown":
102
186
  e.preventDefault();
103
- if (idx < flat.length - 1) focusKey(flat[idx + 1].key);
187
+ if (idx < navFlat.length - 1) focusKey(navFlat[idx + 1].key);
104
188
  break;
105
189
  case "ArrowUp":
106
190
  e.preventDefault();
107
- if (idx > 0) focusKey(flat[idx - 1].key);
191
+ if (idx > 0) focusKey(navFlat[idx - 1].key);
108
192
  break;
109
193
  case "ArrowRight":
110
194
  e.preventDefault();
111
- if (f.hasChildren && !f.open) toggle(f.key);
112
- else if (f.hasChildren && flat[idx + 1]) focusKey(flat[idx + 1].key);
195
+ if (f.hasChildren && !f.open) setOpen(f, true);
196
+ else if (f.hasChildren && navFlat[idx + 1]) focusKey(navFlat[idx + 1].key);
113
197
  break;
114
198
  case "ArrowLeft":
115
199
  e.preventDefault();
116
- if (f.hasChildren && f.open) toggle(f.key);
200
+ if (f.hasChildren && f.open) setOpen(f, false);
117
201
  else {
118
202
  // move to parent (previous node at a lower level)
119
203
  for (let j = idx - 1; j >= 0; j--)
120
- if (flat[j].level < f.level) {
121
- focusKey(flat[j].key);
204
+ if (navFlat[j].level < f.level) {
205
+ focusKey(navFlat[j].key);
122
206
  break;
123
207
  }
124
208
  }
125
209
  break;
126
210
  case "Home":
127
211
  e.preventDefault();
128
- focusKey(flat[0].key);
212
+ focusKey(navFlat[0].key);
129
213
  break;
130
214
  case "End":
131
215
  e.preventDefault();
132
- focusKey(flat[flat.length - 1].key);
216
+ focusKey(navFlat[navFlat.length - 1].key);
133
217
  break;
134
218
  case "Enter":
135
219
  case " ":
@@ -148,40 +232,71 @@
148
232
  onkeydown={onKeydown}
149
233
  >
150
234
  {#each flat as f (f.key)}
151
- {@const active = selected === f.key}
152
- <li role="none" class="list-none">
153
- <!-- svelte-ignore a11y_click_events_have_key_events -->
154
- <div
155
- bind:this={rowRefs[f.key]}
156
- role="treeitem"
157
- aria-level={f.level + 1}
158
- aria-selected={active}
159
- aria-expanded={f.hasChildren ? f.open : undefined}
160
- aria-disabled={f.node.disabled || undefined}
161
- tabindex={f.key === tabKey ? 0 : -1}
162
- onclick={() => selectNode(f)}
163
- onfocus={() => (activeKey = f.key)}
164
- style="padding-inline-start: {f.level * 1.25 + 0.5}rem; {active
165
- ? `background-color: color-mix(in srgb, var(${token}) 16%, transparent);`
166
- : ''}"
167
- class="group flex items-center gap-1.5 rounded-[var(--ui-border-radius)] pr-2 transition-colors {responsiveClasses(
168
- size,
169
- rowH,
170
- )} {f.node.disabled
171
- ? 'opacity-50 cursor-not-allowed'
172
- : 'cursor-pointer hover:[background-color:var(--ui-color-hover)]'} focus-visible:[outline:2px_solid_color-mix(in_srgb,var(--ui-color-text)_70%,transparent)] focus-visible:[outline-offset:-2px]"
173
- >
174
- {#if f.hasChildren}
175
- <span
176
- class="inline-flex shrink-0 items-center justify-center transition-transform duration-150 {f.open
177
- ? 'rotate-90'
178
- : ''}"><IconChevronRight /></span>
179
- {:else}
235
+ {#if f.status}
236
+ <!-- Synthetic lazy-load status row (non-interactive). -->
237
+ <li role="none" class="list-none">
238
+ <div
239
+ class="flex items-center gap-1.5 pr-2 {responsiveClasses(size, rowH)} [color:color-mix(in_srgb,var(--ui-color-text)_60%,transparent)]"
240
+ style="padding-inline-start: {f.level * 1.25 + 0.5}rem;"
241
+ >
180
242
  <span class="inline-block w-4 shrink-0"></span>
181
- {/if}
182
- {#if f.node.icon}<span class="inline-flex shrink-0">{@render f.node.icon()}</span>{/if}
183
- <span class="truncate [color:var(--ui-color-text)]">{f.node.label}</span>
184
- </div>
185
- </li>
243
+ {#if f.status === "loading"}
244
+ <span class="inline-flex shrink-0 animate-spin"><IconLoading /></span>
245
+ <span class="truncate">{i18n.messages.loading}</span>
246
+ {:else}
247
+ <span class="truncate [color:var(--ui-color-error)]">{f.message || i18n.messages.loadFailed}</span>
248
+ {/if}
249
+ </div>
250
+ </li>
251
+ {:else}
252
+ {@const active = selected === f.key}
253
+ <li role="none" class="list-none">
254
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
255
+ <div
256
+ bind:this={rowRefs[f.key]}
257
+ role="treeitem"
258
+ data-key={f.key}
259
+ aria-level={f.level + 1}
260
+ aria-selected={active}
261
+ aria-expanded={f.hasChildren ? f.open : undefined}
262
+ aria-disabled={f.node.disabled || undefined}
263
+ tabindex={f.key === tabKey ? 0 : -1}
264
+ onclick={() => selectNode(f)}
265
+ oncontextmenu={(e) => oncontextmenu?.(e, f.node, f.key)}
266
+ onfocus={() => (activeKey = f.key)}
267
+ style="padding-inline-start: {f.level * 1.25 + 0.5}rem; {active
268
+ ? `background-color: color-mix(in srgb, var(${token}) 16%, transparent);`
269
+ : ''}"
270
+ class="group flex items-center gap-1.5 rounded-[var(--ui-border-radius)] pr-2 transition-colors {responsiveClasses(
271
+ size,
272
+ rowH,
273
+ )} {f.node.disabled
274
+ ? 'opacity-50 cursor-not-allowed'
275
+ : 'cursor-pointer hover:[background-color:var(--ui-color-hover)]'} focus-visible:[outline:2px_solid_color-mix(in_srgb,var(--ui-color-text)_70%,transparent)] focus-visible:[outline-offset:-2px]"
276
+ >
277
+ {#if f.hasChildren && !expandOnSelect}
278
+ <!-- Select and expand are separate: the chevron is its own control (keyboard expand is
279
+ still →/←). A real <button> keeps it accessible without a roving tab stop. -->
280
+ <button
281
+ type="button"
282
+ tabindex="-1"
283
+ aria-label={f.open ? i18n.messages.collapse : i18n.messages.expand}
284
+ class="inline-flex shrink-0 cursor-pointer items-center justify-center rounded transition-transform duration-150 {f.open
285
+ ? 'rotate-90'
286
+ : ''}"
287
+ onclick={(e) => (e.stopPropagation(), toggleOpen(f))}><IconChevronRight /></button>
288
+ {:else if f.hasChildren}
289
+ <span
290
+ class="inline-flex shrink-0 items-center justify-center transition-transform duration-150 {f.open
291
+ ? 'rotate-90'
292
+ : ''}"><IconChevronRight /></span>
293
+ {:else}
294
+ <span class="inline-block w-4 shrink-0"></span>
295
+ {/if}
296
+ {#if f.node.icon}<span class="inline-flex shrink-0">{@render f.node.icon()}</span>{/if}
297
+ <span class="truncate [color:var(--ui-color-text)]">{f.node.label}</span>
298
+ </div>
299
+ </li>
300
+ {/if}
186
301
  {/each}
187
302
  </ul>
@@ -6,6 +6,9 @@ export type TreeNode = {
6
6
  icon?: Snippet;
7
7
  children?: TreeNode[];
8
8
  disabled?: boolean;
9
+ /** Lazy-tree hint: `true` = has children (show the chevron) even before they're loaded; `false` =
10
+ * definitely a leaf (no chevron); `undefined` = inferred from `children`. */
11
+ hasChildren?: boolean;
9
12
  };
10
13
  import type { HTMLAttributes } from "svelte/elements";
11
14
  import type { Variant } from "../../types/variants.js";
@@ -18,10 +21,23 @@ type $$ComponentProps = {
18
21
  /** Key of the selected node (bindable). */
19
22
  selected?: string;
20
23
  onselect?: (node: TreeNode, key: string) => void;
24
+ /** Fired when a node is expanded — use with a consumer-managed `items` to load children yourself
25
+ * (or use `loadChildren` to let the Tree own the fetch + spinner). */
26
+ onexpand?: (node: TreeNode, key: string) => void;
27
+ /** Right-click on a row. The event is NOT prevented, so a wrapping `ContextMenu` still opens;
28
+ * each row also carries `data-key` for consumers that read the target instead. */
29
+ oncontextmenu?: (event: MouseEvent, node: TreeNode, key: string) => void;
30
+ /** Lazily load a node's children. Called once when an unloaded node (`hasChildren: true`, no
31
+ * `children`) is first expanded: the Tree shows a spinner row, caches the resolved children, and
32
+ * shows an inline error row on rejection. */
33
+ loadChildren?: (node: TreeNode, key: string) => Promise<TreeNode[]>;
34
+ /** `true` (default): selecting a parent also toggles it (current behaviour). `false`: select and
35
+ * expand are separate — expand only via the chevron or →/←, as desktop explorers do. */
36
+ expandOnSelect?: boolean;
21
37
  variant?: Variant;
22
38
  size?: Responsive<Size>;
23
39
  class?: string;
24
- } & Omit<HTMLAttributes<HTMLElement>, "class" | "style" | "onselect">;
40
+ } & Omit<HTMLAttributes<HTMLElement>, "class" | "style" | "onselect" | "oncontextmenu">;
25
41
  declare const Tree: import("svelte").Component<$$ComponentProps, {}, "selected" | "expanded">;
26
42
  type Tree = ReturnType<typeof Tree>;
27
43
  export default Tree;
package/dist/i18n.d.ts CHANGED
@@ -25,6 +25,9 @@ export type UiMessages = {
25
25
  next: Msg;
26
26
  first: Msg;
27
27
  last: Msg;
28
+ /** Tree: the per-row chevron toggle (accessible name), when select and expand are separate. */
29
+ expand: Msg;
30
+ collapse: Msg;
28
31
  pagination: Msg;
29
32
  pageNumber: Msg;
30
33
  perPage: Msg;
package/dist/i18n.js CHANGED
@@ -23,6 +23,8 @@ export const defaultUiMessages = {
23
23
  next: "Next",
24
24
  first: "First",
25
25
  last: "Last",
26
+ expand: "Expand",
27
+ collapse: "Collapse",
26
28
  pagination: "Pagination",
27
29
  pageNumber: "Page number",
28
30
  perPage: "per page",
@@ -93,6 +93,8 @@ Custom dropdown built on `Input` + `List`.
93
93
 
94
94
  ### Select2 (native-first)
95
95
 
96
+ > ⚠️ **Experimental — proof-of-concept, NOT ready for use.** Evaluated in the php-svelte test bed only, on the `@dev` channel; the API/behaviour are unstable and it may change or be removed without notice. Don't adopt it in a project yet. See the `*2` banner in `MIGRATION.md`.
97
+
96
98
  A **no-JS / SSR-correct** sibling of `Select` that renders a real `<select>` — it works with
97
99
  JavaScript disabled, submits in a plain `<form>` (its `name` carries the value, no hidden input), and
98
100
  shows the right option in SSR / before hydration. The **closed control is pixel-identical** to
@@ -313,6 +315,8 @@ independent. Propagates `size`/`variant`/`disabled` to children.
313
315
 
314
316
  ### Disclosure2 / Accordion2 (native-first)
315
317
 
318
+ > ⚠️ **Experimental — proof-of-concept, NOT ready for use.** Evaluated in the php-svelte test bed only, on the `@dev` channel; the API/behaviour are unstable and it may change or be removed without notice. Don't adopt it in a project yet. See the `*2` banner in `MIGRATION.md`.
319
+
316
320
  **No-JS / SSR-correct** siblings built on real `<details>/<summary>`: they expand, rotate the chevron,
317
321
  and (via `Accordion2`'s shared `<details name>`) enforce single-open **with no JavaScript**, correct
318
322
  in SSR. The `<summary>` wears `Button`'s ghost-header classes, so the header is **pixel-identical** to
@@ -420,6 +424,8 @@ snaps to canonical on blur.
420
424
 
421
425
  ### DatePicker2 (native-first)
422
426
 
427
+ > ⚠️ **Experimental — proof-of-concept, NOT ready for use.** Evaluated in the php-svelte test bed only, on the `@dev` channel; the API/behaviour are unstable and it may change or be removed without notice. Don't adopt it in a project yet. See the `*2` banner in `MIGRATION.md`.
428
+
423
429
  **No-JS / SSR-correct** date entry: reuses `Input` with a real `<input type="date">` (or
424
430
  `datetime-local` in `time` mode), so it works without JavaScript, pops the browser's native picker,
425
431
  and submits via `name`. The field **frame** is pixel-identical to `DatePicker` (same `Input`).
@@ -844,8 +850,17 @@ Hierarchical, expandable tree view (ARIA `tree`).
844
850
 
845
851
  - **Location**: `src/lib/components/Tree/Tree.svelte`
846
852
  - **Axes**: `variant` (selected-row tint), `size`
847
- - **Props**: `items` (`TreeNode[]` = `{ label, id?, icon?, children?, disabled? }`), `expanded`
848
- (bindable `string[]`), `selected` (bindable key), `onselect`, `class`
853
+ - **Props**: `items` (`TreeNode[]` = `{ label, id?, icon?, children?, disabled?, hasChildren? }`),
854
+ `expanded` (bindable `string[]`), `selected` (bindable key), `onselect`, `class`
855
+ - **Lazy children**: set `hasChildren: true` on a node whose `children` aren't loaded (it gets a
856
+ chevron). Provide **`loadChildren(node, key) => Promise<TreeNode[]>`** — it's called once on first
857
+ expand; the Tree shows a spinner row, caches the result (so `items` stays immutable), and shows an
858
+ inline error row (the rejection's message, else localized `loadFailed`) on failure. Or use
859
+ **`onexpand(node, key)`** to load into your own `items`.
860
+ - **Right-click**: **`oncontextmenu(event, node, key)`** per row (the event isn't prevented, so a
861
+ wrapping `ContextMenu` still opens); every `role="treeitem"` also carries **`data-key`**.
862
+ - **Select vs. expand**: **`expandOnSelect`** (default `true`) — set `false` so a row click only
863
+ selects and expansion is via the chevron (a real `<button>`) or →/←, as desktop explorers do.
849
864
  - **Keyboard**: roving tabindex — ↑/↓ move, → expand/into child, ← collapse/to parent, Enter/Space
850
865
  select, Home/End
851
866
  - **Accessibility**: `role="tree"`/`treeitem` with `aria-level`/`-expanded`/`-selected`/`-disabled`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaethtech/svelte-ui",
3
- "version": "0.21.1-dev.126.3149ff9",
3
+ "version": "0.21.1-dev.128.59d3487",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/spaethtech/svelte-ui.git"