@spaethtech/svelte-ui 0.21.1-dev.127.89441ce → 0.21.1-dev.129.7debcd5

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/CLAUDE.md CHANGED
@@ -162,9 +162,21 @@ host port). Get its IP with `docker inspect svelte-ui-demo-1 --format '{{range .
162
162
  (`svelte-ui-caddy-1` proxies to it; `php-svelte.spaethtech.dev` → the host `:5173`). `curl` the route
163
163
  first to confirm it serves, then point a browser at it.
164
164
 
165
+ **For INTERACTION / HYDRATION testing, use the Caddy route `https://svelte-ui.spaethtech.dev/<route>`
166
+ (Playwright `ignoreHTTPSErrors: true`), NOT the container IP.** Hitting the container IP `:5173`
167
+ directly serves the SSR HTML but **intermittently fails to hydrate**: vite's HMR client tries
168
+ `ws://<ip>:443` (refused) and the route's dynamic client module then fails to fetch, so the page stays
169
+ SSR-only and **all client interactivity is dead** — clicks/keyboard/`bind:`/`oninput` do nothing, and a
170
+ Svelte `.fill()` won't propagate to bound state. This looks exactly like a component bug but isn't; it
171
+ bit this project's DatePicker2-reset and Tree-lazy verifications. The Caddy origin terminates TLS on
172
+ `:443`, so HMR + module imports load and the page hydrates reliably. Container IP is fine for
173
+ **SSR-only / visual** checks (screenshots of rendered markup); use Caddy whenever you click, type, or
174
+ assert post-hydration state. Confirm hydration first (e.g. toggle a known control and see state flip)
175
+ before trusting an interaction result — don't report "verified" from a page that never hydrated.
176
+
165
177
  Use the **`/playwright-cli`** workflow for browser-driven testing (navigation, snapshots/screenshots,
166
- console inspection, interaction). Point it at the **container IP** `:5173` and the relevant demo route
167
- (e.g. `/tabstrip`). The old Chrome DevTools MCP (`.mcp.json` + `chrome-devtools-mcp`) has been removed
178
+ console inspection, interaction). Point it at the **Caddy route** for interaction (container IP only for
179
+ SSR/visual) and the relevant demo route (e.g. `/tabstrip`). The old Chrome DevTools MCP (`.mcp.json` + `chrome-devtools-mcp`) has been removed
168
180
  in favor of this. **Browser-binary gotcha:** the Playwright in `node_modules` and the cached browser
169
181
  revision can mismatch (e.g. it wants `headless_shell-1181` but the cache has `chromium-1229`); pass
170
182
  `executablePath` to the installed `~/.cache/ms-playwright/chromium-*/chrome-linux64/chrome` instead of
@@ -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",
@@ -850,8 +850,17 @@ Hierarchical, expandable tree view (ARIA `tree`).
850
850
 
851
851
  - **Location**: `src/lib/components/Tree/Tree.svelte`
852
852
  - **Axes**: `variant` (selected-row tint), `size`
853
- - **Props**: `items` (`TreeNode[]` = `{ label, id?, icon?, children?, disabled? }`), `expanded`
854
- (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.
855
864
  - **Keyboard**: roving tabindex — ↑/↓ move, → expand/into child, ← collapse/to parent, Enter/Space
856
865
  select, Home/End
857
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.127.89441ce",
3
+ "version": "0.21.1-dev.129.7debcd5",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/spaethtech/svelte-ui.git"