@delightstack/components 0.1.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.
Files changed (195) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -0
  3. package/SKILL.md +149 -0
  4. package/bin/agents.js +63 -0
  5. package/dist/actions/Alert.svelte +202 -0
  6. package/dist/actions/Alert.svelte.d.ts +36 -0
  7. package/dist/actions/Alert.svelte.d.ts.map +1 -0
  8. package/dist/actions/Button.svelte +1450 -0
  9. package/dist/actions/Button.svelte.d.ts +56 -0
  10. package/dist/actions/Button.svelte.d.ts.map +1 -0
  11. package/dist/actions/ButtonGroup.svelte +111 -0
  12. package/dist/actions/ButtonGroup.svelte.d.ts +41 -0
  13. package/dist/actions/ButtonGroup.svelte.d.ts.map +1 -0
  14. package/dist/actions/CommandPalette.svelte +939 -0
  15. package/dist/actions/CommandPalette.svelte.d.ts +37 -0
  16. package/dist/actions/CommandPalette.svelte.d.ts.map +1 -0
  17. package/dist/actions/ContextMenu.svelte +138 -0
  18. package/dist/actions/ContextMenu.svelte.d.ts +54 -0
  19. package/dist/actions/ContextMenu.svelte.d.ts.map +1 -0
  20. package/dist/actions/Modal.svelte +474 -0
  21. package/dist/actions/Modal.svelte.d.ts +28 -0
  22. package/dist/actions/Modal.svelte.d.ts.map +1 -0
  23. package/dist/actions/Popover.svelte +1214 -0
  24. package/dist/actions/Popover.svelte.d.ts +31 -0
  25. package/dist/actions/Popover.svelte.d.ts.map +1 -0
  26. package/dist/actions/Portal.svelte +80 -0
  27. package/dist/actions/Portal.svelte.d.ts +17 -0
  28. package/dist/actions/Portal.svelte.d.ts.map +1 -0
  29. package/dist/actions/ThemeToggle.svelte +345 -0
  30. package/dist/actions/ThemeToggle.svelte.d.ts +15 -0
  31. package/dist/actions/ThemeToggle.svelte.d.ts.map +1 -0
  32. package/dist/actions/index.d.ts +13 -0
  33. package/dist/actions/index.d.ts.map +1 -0
  34. package/dist/actions/index.js +10 -0
  35. package/dist/actions/scrollbar.d.ts +48 -0
  36. package/dist/actions/scrollbar.d.ts.map +1 -0
  37. package/dist/actions/scrollbar.js +404 -0
  38. package/dist/display/Accordion.svelte +586 -0
  39. package/dist/display/Accordion.svelte.d.ts +41 -0
  40. package/dist/display/Accordion.svelte.d.ts.map +1 -0
  41. package/dist/display/Avatar.svelte +527 -0
  42. package/dist/display/Avatar.svelte.d.ts +22 -0
  43. package/dist/display/Avatar.svelte.d.ts.map +1 -0
  44. package/dist/display/AvatarGroup.svelte +298 -0
  45. package/dist/display/AvatarGroup.svelte.d.ts +31 -0
  46. package/dist/display/AvatarGroup.svelte.d.ts.map +1 -0
  47. package/dist/display/Calendar.svelte +1366 -0
  48. package/dist/display/Calendar.svelte.d.ts +58 -0
  49. package/dist/display/Calendar.svelte.d.ts.map +1 -0
  50. package/dist/display/Chart.svelte +1426 -0
  51. package/dist/display/Chart.svelte.d.ts +35 -0
  52. package/dist/display/Chart.svelte.d.ts.map +1 -0
  53. package/dist/display/Code.svelte +780 -0
  54. package/dist/display/Code.svelte.d.ts +19 -0
  55. package/dist/display/Code.svelte.d.ts.map +1 -0
  56. package/dist/display/Comparison.svelte +686 -0
  57. package/dist/display/Comparison.svelte.d.ts +22 -0
  58. package/dist/display/Comparison.svelte.d.ts.map +1 -0
  59. package/dist/display/Counter.svelte +285 -0
  60. package/dist/display/Counter.svelte.d.ts +21 -0
  61. package/dist/display/Counter.svelte.d.ts.map +1 -0
  62. package/dist/display/Expand.svelte +48 -0
  63. package/dist/display/Expand.svelte.d.ts +9 -0
  64. package/dist/display/Expand.svelte.d.ts.map +1 -0
  65. package/dist/display/List.svelte +294 -0
  66. package/dist/display/List.svelte.d.ts +40 -0
  67. package/dist/display/List.svelte.d.ts.map +1 -0
  68. package/dist/display/ListContextReset.svelte +19 -0
  69. package/dist/display/ListContextReset.svelte.d.ts +7 -0
  70. package/dist/display/ListContextReset.svelte.d.ts.map +1 -0
  71. package/dist/display/ListItem.svelte +834 -0
  72. package/dist/display/ListItem.svelte.d.ts +22 -0
  73. package/dist/display/ListItem.svelte.d.ts.map +1 -0
  74. package/dist/display/QR.svelte +1193 -0
  75. package/dist/display/QR.svelte.d.ts +23 -0
  76. package/dist/display/QR.svelte.d.ts.map +1 -0
  77. package/dist/display/SplitPane.svelte +744 -0
  78. package/dist/display/SplitPane.svelte.d.ts +25 -0
  79. package/dist/display/SplitPane.svelte.d.ts.map +1 -0
  80. package/dist/display/Stat.svelte +439 -0
  81. package/dist/display/Stat.svelte.d.ts +24 -0
  82. package/dist/display/Stat.svelte.d.ts.map +1 -0
  83. package/dist/display/Table.svelte +4654 -0
  84. package/dist/display/Table.svelte.d.ts +249 -0
  85. package/dist/display/Table.svelte.d.ts.map +1 -0
  86. package/dist/display/TableCellEditor.svelte +935 -0
  87. package/dist/display/TableCellEditor.svelte.d.ts +58 -0
  88. package/dist/display/TableCellEditor.svelte.d.ts.map +1 -0
  89. package/dist/display/Timeline.svelte +1258 -0
  90. package/dist/display/Timeline.svelte.d.ts +43 -0
  91. package/dist/display/Timeline.svelte.d.ts.map +1 -0
  92. package/dist/display/Tree.svelte +1740 -0
  93. package/dist/display/Tree.svelte.d.ts +74 -0
  94. package/dist/display/Tree.svelte.d.ts.map +1 -0
  95. package/dist/display/Typewriter.svelte +338 -0
  96. package/dist/display/Typewriter.svelte.d.ts +22 -0
  97. package/dist/display/Typewriter.svelte.d.ts.map +1 -0
  98. package/dist/display/index.d.ts +24 -0
  99. package/dist/display/index.d.ts.map +1 -0
  100. package/dist/display/index.js +18 -0
  101. package/dist/feedback/Callout.svelte +529 -0
  102. package/dist/feedback/Callout.svelte.d.ts +24 -0
  103. package/dist/feedback/Callout.svelte.d.ts.map +1 -0
  104. package/dist/feedback/Confetti.svelte +631 -0
  105. package/dist/feedback/Confetti.svelte.d.ts +90 -0
  106. package/dist/feedback/Confetti.svelte.d.ts.map +1 -0
  107. package/dist/feedback/Progress.svelte +382 -0
  108. package/dist/feedback/Progress.svelte.d.ts +25 -0
  109. package/dist/feedback/Progress.svelte.d.ts.map +1 -0
  110. package/dist/feedback/Toast.svelte +967 -0
  111. package/dist/feedback/Toast.svelte.d.ts +54 -0
  112. package/dist/feedback/Toast.svelte.d.ts.map +1 -0
  113. package/dist/feedback/index.d.ts +7 -0
  114. package/dist/feedback/index.d.ts.map +1 -0
  115. package/dist/feedback/index.js +4 -0
  116. package/dist/form/Checkbox.svelte +449 -0
  117. package/dist/form/Checkbox.svelte.d.ts +27 -0
  118. package/dist/form/Checkbox.svelte.d.ts.map +1 -0
  119. package/dist/form/Fieldset.svelte +410 -0
  120. package/dist/form/Fieldset.svelte.d.ts +22 -0
  121. package/dist/form/Fieldset.svelte.d.ts.map +1 -0
  122. package/dist/form/FileUpload.svelte +934 -0
  123. package/dist/form/FileUpload.svelte.d.ts +41 -0
  124. package/dist/form/FileUpload.svelte.d.ts.map +1 -0
  125. package/dist/form/Form.svelte +530 -0
  126. package/dist/form/Form.svelte.d.ts +120 -0
  127. package/dist/form/Form.svelte.d.ts.map +1 -0
  128. package/dist/form/Input.svelte +2858 -0
  129. package/dist/form/Input.svelte.d.ts +66 -0
  130. package/dist/form/Input.svelte.d.ts.map +1 -0
  131. package/dist/form/Radio.svelte +507 -0
  132. package/dist/form/Radio.svelte.d.ts +39 -0
  133. package/dist/form/Radio.svelte.d.ts.map +1 -0
  134. package/dist/form/Range.svelte +912 -0
  135. package/dist/form/Range.svelte.d.ts +33 -0
  136. package/dist/form/Range.svelte.d.ts.map +1 -0
  137. package/dist/form/Rating.svelte +429 -0
  138. package/dist/form/Rating.svelte.d.ts +28 -0
  139. package/dist/form/Rating.svelte.d.ts.map +1 -0
  140. package/dist/form/Select.svelte +1933 -0
  141. package/dist/form/Select.svelte.d.ts +54 -0
  142. package/dist/form/Select.svelte.d.ts.map +1 -0
  143. package/dist/form/Toggle.svelte +645 -0
  144. package/dist/form/Toggle.svelte.d.ts +50 -0
  145. package/dist/form/Toggle.svelte.d.ts.map +1 -0
  146. package/dist/form/index.d.ts +15 -0
  147. package/dist/form/index.d.ts.map +1 -0
  148. package/dist/form/index.js +10 -0
  149. package/dist/index.d.ts +7 -0
  150. package/dist/index.d.ts.map +1 -0
  151. package/dist/index.js +6 -0
  152. package/dist/layout/README.md +172 -0
  153. package/dist/media/Carousel.svelte +2424 -0
  154. package/dist/media/Carousel.svelte.d.ts +47 -0
  155. package/dist/media/Carousel.svelte.d.ts.map +1 -0
  156. package/dist/media/Gallery.svelte +2881 -0
  157. package/dist/media/Gallery.svelte.d.ts +82 -0
  158. package/dist/media/Gallery.svelte.d.ts.map +1 -0
  159. package/dist/media/Image.svelte +389 -0
  160. package/dist/media/Image.svelte.d.ts +33 -0
  161. package/dist/media/Image.svelte.d.ts.map +1 -0
  162. package/dist/media/PDF.svelte +1793 -0
  163. package/dist/media/PDF.svelte.d.ts +44 -0
  164. package/dist/media/PDF.svelte.d.ts.map +1 -0
  165. package/dist/media/Panorama.svelte +1391 -0
  166. package/dist/media/Panorama.svelte.d.ts +47 -0
  167. package/dist/media/Panorama.svelte.d.ts.map +1 -0
  168. package/dist/media/Video.svelte +2501 -0
  169. package/dist/media/Video.svelte.d.ts +58 -0
  170. package/dist/media/Video.svelte.d.ts.map +1 -0
  171. package/dist/media/carousel.d.ts +211 -0
  172. package/dist/media/carousel.d.ts.map +1 -0
  173. package/dist/media/carousel.js +408 -0
  174. package/dist/media/index.d.ts +11 -0
  175. package/dist/media/index.d.ts.map +1 -0
  176. package/dist/media/index.js +5 -0
  177. package/dist/navigation/BottomSheet.svelte +636 -0
  178. package/dist/navigation/BottomSheet.svelte.d.ts +27 -0
  179. package/dist/navigation/BottomSheet.svelte.d.ts.map +1 -0
  180. package/dist/navigation/Breadcrumbs.svelte +611 -0
  181. package/dist/navigation/Breadcrumbs.svelte.d.ts +28 -0
  182. package/dist/navigation/Breadcrumbs.svelte.d.ts.map +1 -0
  183. package/dist/navigation/Pagination.svelte +641 -0
  184. package/dist/navigation/Pagination.svelte.d.ts +27 -0
  185. package/dist/navigation/Pagination.svelte.d.ts.map +1 -0
  186. package/dist/navigation/Steps.svelte +965 -0
  187. package/dist/navigation/Steps.svelte.d.ts +43 -0
  188. package/dist/navigation/Steps.svelte.d.ts.map +1 -0
  189. package/dist/navigation/Tabs.svelte +698 -0
  190. package/dist/navigation/Tabs.svelte.d.ts +41 -0
  191. package/dist/navigation/Tabs.svelte.d.ts.map +1 -0
  192. package/dist/navigation/index.d.ts +8 -0
  193. package/dist/navigation/index.d.ts.map +1 -0
  194. package/dist/navigation/index.js +5 -0
  195. package/package.json +139 -0
@@ -0,0 +1,1740 @@
1
+ <script lang="ts" module>
2
+ export interface TreeNode {
3
+ /** Unique identifier for the node */
4
+ id: string;
5
+ /** Display text for the node */
6
+ label: string;
7
+ /** Icon component shown before the label */
8
+ icon?: import('svelte').Component;
9
+ /** Child nodes (presence makes this a branch node) */
10
+ children?: TreeNode[];
11
+ /** Whether this node is disabled (cannot be selected or expanded) */
12
+ disabled?: boolean;
13
+ /** Whether this node can be selected. When undefined, inherits from the tree's selectable prop. Set to false to make clicking expand/collapse instead. */
14
+ selectable?: boolean;
15
+ /** Whether this node can accept children via drag-and-drop (defaults to true if node has children array) */
16
+ allowChildren?: boolean;
17
+ /** Arbitrary user data attached to the node (passed back in selection/drop callbacks) */
18
+ data?: unknown;
19
+ }
20
+
21
+ export interface FlatTreeNode {
22
+ /** Unique identifier for the node */
23
+ id: string;
24
+ /** The `id` of the parent node, or `null` for root-level nodes */
25
+ parentId: string | null;
26
+ /** Display text for the node */
27
+ label: string;
28
+ /** Icon component shown before the label */
29
+ icon?: import('svelte').Component;
30
+ /** Whether this node is disabled (cannot be selected or expanded) */
31
+ disabled?: boolean;
32
+ /** Whether this node can be selected. When undefined, inherits from the tree's selectable prop. */
33
+ selectable?: boolean;
34
+ /** Arbitrary user data attached to the node (passed back in selection/drop callbacks) */
35
+ data?: unknown;
36
+ }
37
+ </script>
38
+
39
+ <script lang="ts">
40
+ import { setContext, type Snippet } from 'svelte';
41
+
42
+ const propId = $props.id();
43
+
44
+ let {
45
+ /** Tree data — nested TreeNode[] or flat FlatTreeNode[] */
46
+ data,
47
+
48
+ /** Selected node IDs, bindable */
49
+ selected = $bindable([]) as string[],
50
+
51
+ /** Expanded node IDs, bindable */
52
+ expanded = $bindable([]) as string[],
53
+
54
+ /** Enable node selection */
55
+ selectable = false,
56
+
57
+ /** Allow multiple selection */
58
+ multi_select = false,
59
+
60
+ /** Show checkboxes next to nodes */
61
+ checkboxes = false,
62
+
63
+ /** Show connecting lines between siblings */
64
+ show_lines = false,
65
+
66
+ /** Enable drag-and-drop reordering */
67
+ draggable = false,
68
+
69
+ /** Search/filter term */
70
+ filter = undefined as string | undefined,
71
+
72
+ /** Compact spacing */
73
+ dense = false,
74
+
75
+ /** Relaxed spacing */
76
+ comfortable = false,
77
+
78
+ /** Show loading skeleton */
79
+ skeleton = false,
80
+
81
+ /** Number of skeleton nodes */
82
+ skeleton_count = 5,
83
+
84
+ /** Skeleton nesting depth */
85
+ skeleton_depth = 2,
86
+
87
+ /** The ID of the element */
88
+ id = propId,
89
+
90
+ /** Specifies a custom class name */
91
+ class: class_name = '',
92
+
93
+ /** Lazy load children for a node */
94
+ load_children = undefined as ((node: TreeNode) => Promise<TreeNode[]>) | undefined,
95
+
96
+ /** Custom node content renderer */
97
+ node_content = undefined as Snippet<[{ node: TreeNode; level: number }]> | undefined,
98
+
99
+ /** Called when selection changes */
100
+ onselect = undefined as
101
+ | ((detail: { node: TreeNode; selected: string[] }) => void)
102
+ | undefined,
103
+
104
+ /** Called when a node is expanded/collapsed */
105
+ onexpand = undefined as
106
+ | ((detail: { node: TreeNode; expanded: boolean }) => void)
107
+ | undefined,
108
+
109
+ /** Called when a node is dropped onto a target */
110
+ ondrop = undefined as
111
+ | ((detail: {
112
+ node: TreeNode;
113
+ target: TreeNode;
114
+ position: 'before' | 'after' | 'inside';
115
+ }) => void)
116
+ | undefined,
117
+ } = $props();
118
+
119
+ /* ------------------------------------------------------------------ */
120
+ /* Detect flat vs nested data and build tree */
121
+ /* ------------------------------------------------------------------ */
122
+
123
+ function isFlat(d: TreeNode[] | FlatTreeNode[]): d is FlatTreeNode[] {
124
+ return d.length > 0 && 'parentId' in d[0];
125
+ }
126
+
127
+ function buildTreeFromFlat(flat: FlatTreeNode[]): TreeNode[] {
128
+ const map = new Map<string, TreeNode>();
129
+ const roots: TreeNode[] = [];
130
+
131
+ for (const item of flat) {
132
+ map.set(item.id, {
133
+ id: item.id,
134
+ label: item.label,
135
+ icon: item.icon,
136
+ disabled: item.disabled,
137
+ selectable: item.selectable,
138
+ data: item.data,
139
+ children: [],
140
+ });
141
+ }
142
+
143
+ for (const item of flat) {
144
+ const node = map.get(item.id)!;
145
+ if (item.parentId === null) {
146
+ roots.push(node);
147
+ } else {
148
+ const parent = map.get(item.parentId);
149
+ if (parent) {
150
+ parent.children!.push(node);
151
+ }
152
+ }
153
+ }
154
+
155
+ return roots;
156
+ }
157
+
158
+ const tree = $derived(isFlat(data) ? buildTreeFromFlat(data) : data);
159
+
160
+ /* ------------------------------------------------------------------ */
161
+ /* Node ID lookup map */
162
+ /* ------------------------------------------------------------------ */
163
+
164
+ function buildNodeMap(nodes: TreeNode[], map: Map<string, TreeNode>) {
165
+ for (const node of nodes) {
166
+ map.set(node.id, node);
167
+ if (node.children) buildNodeMap(node.children, map);
168
+ }
169
+ }
170
+
171
+ const node_map = $derived.by(() => {
172
+ const m = new Map<string, TreeNode>();
173
+ buildNodeMap(tree, m);
174
+ return m;
175
+ });
176
+
177
+ /* ------------------------------------------------------------------ */
178
+ /* Parent map */
179
+ /* ------------------------------------------------------------------ */
180
+
181
+ function buildParentMap(
182
+ nodes: TreeNode[],
183
+ parent: TreeNode | null,
184
+ map: Map<string, TreeNode | null>,
185
+ ) {
186
+ for (const node of nodes) {
187
+ map.set(node.id, parent);
188
+ if (node.children) buildParentMap(node.children, node, map);
189
+ }
190
+ }
191
+
192
+ const parent_map = $derived.by(() => {
193
+ const m = new Map<string, TreeNode | null>();
194
+ buildParentMap(tree, null, m);
195
+ return m;
196
+ });
197
+
198
+ /* ------------------------------------------------------------------ */
199
+ /* Filtering */
200
+ /* ------------------------------------------------------------------ */
201
+
202
+ function nodeMatchesFilter(node: TreeNode, term: string): boolean {
203
+ return node.label.toLowerCase().includes(term);
204
+ }
205
+
206
+ function subtreeMatchesFilter(node: TreeNode, term: string): boolean {
207
+ if (nodeMatchesFilter(node, term)) return true;
208
+ if (node.children) {
209
+ return node.children.some((c) => subtreeMatchesFilter(c, term));
210
+ }
211
+ return false;
212
+ }
213
+
214
+ const filter_term = $derived(filter?.toLowerCase().trim() ?? '');
215
+ const is_filtering = $derived(filter_term.length > 0);
216
+
217
+ /** Set of IDs that pass the filter (node itself or a descendant matches) */
218
+ const visible_ids = $derived.by(() => {
219
+ if (!is_filtering) return null;
220
+ const ids = new Set<string>();
221
+
222
+ function collect(node: TreeNode): boolean {
223
+ const matches = subtreeMatchesFilter(node, filter_term);
224
+ if (matches) {
225
+ ids.add(node.id);
226
+ // Also include all ancestors
227
+ let parent = parent_map.get(node.id) ?? null;
228
+ while (parent) {
229
+ ids.add(parent.id);
230
+ parent = parent_map.get(parent.id) ?? null;
231
+ }
232
+ // Include matching children too
233
+ if (node.children) {
234
+ for (const c of node.children) collect(c);
235
+ }
236
+ }
237
+ return matches;
238
+ }
239
+
240
+ for (const node of tree) collect(node);
241
+ return ids;
242
+ });
243
+
244
+ /** IDs of nodes whose children should be force-expanded during filter */
245
+ const filter_expanded_ids = $derived.by(() => {
246
+ if (!is_filtering || !visible_ids) return new Set<string>();
247
+ const ids = new Set<string>();
248
+ function walk(node: TreeNode) {
249
+ if (!visible_ids!.has(node.id)) return;
250
+ if (node.children && node.children.some((c) => visible_ids!.has(c.id))) {
251
+ ids.add(node.id);
252
+ }
253
+ if (node.children) {
254
+ for (const c of node.children) walk(c);
255
+ }
256
+ }
257
+ for (const n of tree) walk(n);
258
+ return ids;
259
+ });
260
+
261
+ function isNodeVisible(node: TreeNode): boolean {
262
+ if (!is_filtering || !visible_ids) return true;
263
+ return visible_ids.has(node.id);
264
+ }
265
+
266
+ /* ------------------------------------------------------------------ */
267
+ /* Expand / Collapse */
268
+ /* ------------------------------------------------------------------ */
269
+
270
+ /** Cache of lazily loaded children keyed by node ID */
271
+ let lazy_cache = $state(new Map<string, TreeNode[]>());
272
+ let loading_ids = $state(new Set<string>());
273
+
274
+ function isExpanded(node_id: string): boolean {
275
+ if (is_filtering && filter_expanded_ids.has(node_id)) return true;
276
+ return expanded.includes(node_id);
277
+ }
278
+
279
+ function hasChildren(node: TreeNode): boolean {
280
+ if (lazy_cache.has(node.id)) return true;
281
+ return !!node.children && node.children.length > 0;
282
+ }
283
+
284
+ function hasLoadableChildren(node: TreeNode): boolean {
285
+ return !!load_children && !node.children?.length && !lazy_cache.has(node.id);
286
+ }
287
+
288
+ function getVisibleChildren(node: TreeNode): TreeNode[] {
289
+ const cached = lazy_cache.get(node.id);
290
+ const children = cached ?? node.children ?? [];
291
+ if (!is_filtering) return children;
292
+ return children.filter((c) => isNodeVisible(c));
293
+ }
294
+
295
+ async function toggleExpand(node: TreeNode) {
296
+ if (node.disabled) return;
297
+ const was_expanded = isExpanded(node.id);
298
+
299
+ if (!was_expanded && hasLoadableChildren(node)) {
300
+ loading_ids.add(node.id);
301
+ loading_ids = new Set(loading_ids);
302
+ try {
303
+ const children = await load_children!(node);
304
+ lazy_cache.set(node.id, children);
305
+ lazy_cache = new Map(lazy_cache);
306
+ } finally {
307
+ loading_ids.delete(node.id);
308
+ loading_ids = new Set(loading_ids);
309
+ }
310
+ }
311
+
312
+ if (was_expanded) {
313
+ expanded = expanded.filter((id) => id !== node.id);
314
+ } else {
315
+ expanded = [...expanded, node.id];
316
+ }
317
+
318
+ onexpand?.({ node, expanded: !was_expanded });
319
+ }
320
+
321
+ /* ------------------------------------------------------------------ */
322
+ /* Selection */
323
+ /* ------------------------------------------------------------------ */
324
+
325
+ function getAllDescendantIds(node: TreeNode): string[] {
326
+ const ids: string[] = [];
327
+ if (node.children) {
328
+ for (const c of node.children) {
329
+ if (!c.disabled) {
330
+ ids.push(c.id);
331
+ ids.push(...getAllDescendantIds(c));
332
+ }
333
+ }
334
+ }
335
+ const cached = lazy_cache.get(node.id);
336
+ if (cached) {
337
+ for (const c of cached) {
338
+ if (!c.disabled) {
339
+ ids.push(c.id);
340
+ ids.push(...getAllDescendantIds(c));
341
+ }
342
+ }
343
+ }
344
+ return ids;
345
+ }
346
+
347
+ function getCheckState(node: TreeNode): 'checked' | 'unchecked' | 'indeterminate' {
348
+ const descendant_ids = getAllDescendantIds(node);
349
+ if (descendant_ids.length === 0) {
350
+ return selected.includes(node.id) ? 'checked' : 'unchecked';
351
+ }
352
+ const self_checked = selected.includes(node.id);
353
+ const all_checked =
354
+ descendant_ids.every((id) => selected.includes(id)) && self_checked;
355
+ const some_checked =
356
+ descendant_ids.some((id) => selected.includes(id)) || self_checked;
357
+ if (all_checked) return 'checked';
358
+ if (some_checked) return 'indeterminate';
359
+ return 'unchecked';
360
+ }
361
+
362
+ function isNodeSelectable(node: TreeNode): boolean {
363
+ if (node.selectable !== undefined) return node.selectable;
364
+ return selectable;
365
+ }
366
+
367
+ /* Per-node check animation flags, mirroring Checkbox.svelte's
368
+ 'check'/'uncheck' animation states (350ms elastic draw / 50ms retract) */
369
+ let check_anim = $state(new Map<string, 'check' | 'uncheck'>());
370
+ const check_anim_timeouts = new Map<string, ReturnType<typeof setTimeout>>();
371
+
372
+ function getAncestorIds(node: TreeNode): string[] {
373
+ const ids: string[] = [];
374
+ let parent = parent_map.get(node.id) ?? null;
375
+ while (parent) {
376
+ ids.push(parent.id);
377
+ parent = parent_map.get(parent.id) ?? null;
378
+ }
379
+ return ids;
380
+ }
381
+
382
+ /** Flag every affected node whose check state actually changed so its
383
+ * checkbox runs the same check/uncheck animation as Checkbox.svelte. */
384
+ function runCheckAnimations(
385
+ ids: string[],
386
+ before: Map<string, 'checked' | 'unchecked' | 'indeterminate'>,
387
+ ) {
388
+ const next = new Map(check_anim);
389
+ let changed = false;
390
+ for (const anim_id of ids) {
391
+ const n = node_map.get(anim_id);
392
+ if (!n) continue;
393
+ const prev = before.get(anim_id);
394
+ const now = getCheckState(n);
395
+ if (prev === now) continue;
396
+ let anim: 'check' | 'uncheck' | null = null;
397
+ if (now === 'checked') anim = 'check';
398
+ else if (prev === 'checked' && now === 'unchecked') anim = 'uncheck';
399
+ if (!anim) continue;
400
+ next.set(anim_id, anim);
401
+ changed = true;
402
+ const pending = check_anim_timeouts.get(anim_id);
403
+ if (pending) clearTimeout(pending);
404
+ check_anim_timeouts.set(
405
+ anim_id,
406
+ setTimeout(
407
+ () => {
408
+ check_anim.delete(anim_id);
409
+ check_anim = new Map(check_anim);
410
+ check_anim_timeouts.delete(anim_id);
411
+ },
412
+ anim === 'check' ? 350 : 50,
413
+ ),
414
+ );
415
+ }
416
+ if (changed) check_anim = next;
417
+ }
418
+
419
+ function selectNode(node: TreeNode, e?: MouseEvent | KeyboardEvent) {
420
+ if (!isNodeSelectable(node) || node.disabled) return;
421
+
422
+ if (checkboxes) {
423
+ const state = getCheckState(node);
424
+ const descendant_ids = getAllDescendantIds(node);
425
+ const all_ids = [node.id, ...descendant_ids];
426
+
427
+ // Capture before-states so changed nodes can animate afterwards
428
+ const affected_ids = [...all_ids, ...getAncestorIds(node)];
429
+ const before = new Map(
430
+ affected_ids
431
+ .filter((aid) => node_map.has(aid))
432
+ .map((aid) => [aid, getCheckState(node_map.get(aid)!)] as const),
433
+ );
434
+
435
+ if (state === 'checked') {
436
+ // Uncheck this node and all descendants
437
+ selected = selected.filter((id) => !all_ids.includes(id));
438
+ } else {
439
+ // Check this node and all descendants
440
+ const new_selected = new Set(selected);
441
+ for (const id of all_ids) new_selected.add(id);
442
+ selected = [...new_selected];
443
+ }
444
+
445
+ // Walk up: sync parent check states
446
+ syncParentCheckState(node);
447
+
448
+ runCheckAnimations(affected_ids, before);
449
+
450
+ onselect?.({ node, selected });
451
+ return;
452
+ }
453
+
454
+ if (multi_select && e && (e.ctrlKey || e.metaKey)) {
455
+ if (selected.includes(node.id)) {
456
+ selected = selected.filter((id) => id !== node.id);
457
+ } else {
458
+ selected = [...selected, node.id];
459
+ }
460
+ } else if (multi_select && e && e.shiftKey) {
461
+ // Range select based on visible order
462
+ const visible = getVisibleNodeOrder();
463
+ const last_selected = selected.length > 0 ? selected[selected.length - 1] : null;
464
+ if (last_selected) {
465
+ const from_idx = visible.indexOf(last_selected);
466
+ const to_idx = visible.indexOf(node.id);
467
+ if (from_idx !== -1 && to_idx !== -1) {
468
+ const start = Math.min(from_idx, to_idx);
469
+ const end = Math.max(from_idx, to_idx);
470
+ const range = visible.slice(start, end + 1);
471
+ const new_selected = new Set(selected);
472
+ for (const id of range) new_selected.add(id);
473
+ selected = [...new_selected];
474
+ } else {
475
+ selected = [node.id];
476
+ }
477
+ } else {
478
+ selected = [node.id];
479
+ }
480
+ } else {
481
+ selected = [node.id];
482
+ }
483
+
484
+ onselect?.({ node, selected });
485
+ }
486
+
487
+ function syncParentCheckState(node: TreeNode) {
488
+ let parent = parent_map.get(node.id) ?? null;
489
+ while (parent) {
490
+ if (parent.disabled) break;
491
+ const descendant_ids = getAllDescendantIds(parent);
492
+ const all_descendants_checked = descendant_ids.every((id) => selected.includes(id));
493
+
494
+ if (all_descendants_checked && !selected.includes(parent.id)) {
495
+ selected = [...selected, parent.id];
496
+ } else if (!all_descendants_checked && selected.includes(parent.id)) {
497
+ selected = selected.filter((id) => id !== parent!.id);
498
+ }
499
+
500
+ parent = parent_map.get(parent.id) ?? null;
501
+ }
502
+ }
503
+
504
+ /* ------------------------------------------------------------------ */
505
+ /* Visible node ordering (for keyboard navigation & shift-select) */
506
+ /* ------------------------------------------------------------------ */
507
+
508
+ function getVisibleNodeOrder(): string[] {
509
+ const order: string[] = [];
510
+ function walk(nodes: TreeNode[]) {
511
+ for (const node of nodes) {
512
+ if (!isNodeVisible(node)) continue;
513
+ order.push(node.id);
514
+ if (isExpanded(node.id)) {
515
+ const children = getVisibleChildren(node);
516
+ walk(children);
517
+ }
518
+ }
519
+ }
520
+ walk(tree);
521
+ return order;
522
+ }
523
+
524
+ /* ------------------------------------------------------------------ */
525
+ /* Adjacent-highlighted corner flattening */
526
+ /* ------------------------------------------------------------------ */
527
+
528
+ let hovered_id = $state<string | null>(null);
529
+
530
+ /** Selected + hovered nodes — any two adjacent highlighted nodes flatten their touching corners */
531
+ const highlighted_set = $derived.by(() => {
532
+ const s = new Set(selected);
533
+ if (hovered_id) s.add(hovered_id);
534
+ return s;
535
+ });
536
+
537
+ const adj_top = $derived.by(() => {
538
+ const s = new Set<string>();
539
+ const order = getVisibleNodeOrder();
540
+ for (let i = 1; i < order.length; i++) {
541
+ if (highlighted_set.has(order[i]) && highlighted_set.has(order[i - 1])) {
542
+ s.add(order[i]);
543
+ }
544
+ }
545
+ return s;
546
+ });
547
+
548
+ const adj_bottom = $derived.by(() => {
549
+ const s = new Set<string>();
550
+ const order = getVisibleNodeOrder();
551
+ for (let i = 0; i < order.length - 1; i++) {
552
+ if (highlighted_set.has(order[i]) && highlighted_set.has(order[i + 1])) {
553
+ s.add(order[i]);
554
+ }
555
+ }
556
+ return s;
557
+ });
558
+
559
+ /* ------------------------------------------------------------------ */
560
+ /* Focus management */
561
+ /* ------------------------------------------------------------------ */
562
+
563
+ let focused_id = $state<string | null>(null);
564
+ let keyboard_nav = $state(false);
565
+ /* True while the current focus was driven by a pointer press. Chromium
566
+ matches :focus-visible on a modifier-held click (ctrl/shift), which would
567
+ otherwise flash the container focus ring during multi-select — gate the
568
+ ring on this so it only shows for keyboard (Tab) focus. */
569
+ let pointer_focus = $state(false);
570
+ let tree_element: HTMLElement | undefined = $state(undefined);
571
+
572
+ function focusNode(node_id: string) {
573
+ focused_id = node_id;
574
+ // Scroll the focused element into view
575
+ if (tree_element) {
576
+ const el = tree_element.querySelector(`[data-node-id="${node_id}"]`) as HTMLElement;
577
+ el?.scrollIntoView({ block: 'nearest' });
578
+ }
579
+ }
580
+
581
+ /* ------------------------------------------------------------------ */
582
+ /* Keyboard navigation */
583
+ /* ------------------------------------------------------------------ */
584
+
585
+ function handleTreeKeyDown(e: KeyboardEvent) {
586
+ // A lone modifier press (holding Ctrl/Shift before a multi-select click)
587
+ // isn't keyboard navigation — leave the modality untouched, or the
588
+ // focus-visible ring would flash between pressing the modifier and the
589
+ // click landing.
590
+ if (e.key === 'Shift' || e.key === 'Control' || e.key === 'Alt' || e.key === 'Meta') {
591
+ return;
592
+ }
593
+ keyboard_nav = true;
594
+ pointer_focus = false;
595
+ const visible = getVisibleNodeOrder();
596
+ if (visible.length === 0) return;
597
+
598
+ const current_idx = focused_id ? visible.indexOf(focused_id) : -1;
599
+ const current_node = focused_id ? node_map.get(focused_id) : null;
600
+
601
+ switch (e.key) {
602
+ case 'ArrowDown': {
603
+ e.preventDefault();
604
+ const next = current_idx + 1;
605
+ if (next < visible.length) {
606
+ focusNode(visible[next]);
607
+ }
608
+ break;
609
+ }
610
+ case 'ArrowUp': {
611
+ e.preventDefault();
612
+ const prev = current_idx - 1;
613
+ if (prev >= 0) {
614
+ focusNode(visible[prev]);
615
+ }
616
+ break;
617
+ }
618
+ case 'ArrowRight': {
619
+ e.preventDefault();
620
+ if (!current_node) break;
621
+ if (
622
+ (hasChildren(current_node) || hasLoadableChildren(current_node)) &&
623
+ !isExpanded(current_node.id)
624
+ ) {
625
+ toggleExpand(current_node);
626
+ } else if (isExpanded(current_node.id)) {
627
+ const children = getVisibleChildren(current_node);
628
+ if (children.length > 0) {
629
+ focusNode(children[0].id);
630
+ }
631
+ }
632
+ break;
633
+ }
634
+ case 'ArrowLeft': {
635
+ e.preventDefault();
636
+ if (!current_node) break;
637
+ if (isExpanded(current_node.id)) {
638
+ toggleExpand(current_node);
639
+ } else {
640
+ const parent = parent_map.get(current_node.id);
641
+ if (parent) {
642
+ focusNode(parent.id);
643
+ }
644
+ }
645
+ break;
646
+ }
647
+ case 'Enter':
648
+ case ' ': {
649
+ e.preventDefault();
650
+ if (current_node) {
651
+ if (isNodeSelectable(current_node)) {
652
+ selectNode(current_node, e);
653
+ } else if (hasChildren(current_node) || hasLoadableChildren(current_node)) {
654
+ toggleExpand(current_node);
655
+ }
656
+ }
657
+ break;
658
+ }
659
+ case 'Home': {
660
+ e.preventDefault();
661
+ if (visible.length > 0) {
662
+ focusNode(visible[0]);
663
+ }
664
+ break;
665
+ }
666
+ case 'End': {
667
+ e.preventDefault();
668
+ if (visible.length > 0) {
669
+ focusNode(visible[visible.length - 1]);
670
+ }
671
+ break;
672
+ }
673
+ case '*': {
674
+ e.preventDefault();
675
+ // Expand all siblings of current node
676
+ if (current_node) {
677
+ const parent = parent_map.get(current_node.id);
678
+ const siblings = parent ? getVisibleChildren(parent) : tree;
679
+ const ids_to_expand = siblings
680
+ .filter(
681
+ (s: TreeNode) =>
682
+ (hasChildren(s) || hasLoadableChildren(s)) && !isExpanded(s.id),
683
+ )
684
+ .map((s: TreeNode) => s.id);
685
+ if (ids_to_expand.length > 0) {
686
+ expanded = [...expanded, ...ids_to_expand];
687
+ }
688
+ }
689
+ break;
690
+ }
691
+ }
692
+ }
693
+
694
+ /* ------------------------------------------------------------------ */
695
+ /* Drag-and-drop */
696
+ /* ------------------------------------------------------------------ */
697
+
698
+ let drag_node_id = $state<string | null>(null);
699
+ let drop_target_id = $state<string | null>(null);
700
+ let drop_position = $state<'before' | 'after' | 'inside'>('inside');
701
+
702
+ function handleDragStart(e: DragEvent, node: TreeNode) {
703
+ if (!draggable || node.disabled) {
704
+ e.preventDefault();
705
+ return;
706
+ }
707
+ e.stopPropagation();
708
+ drag_node_id = node.id;
709
+ if (e.dataTransfer) {
710
+ e.dataTransfer.effectAllowed = 'move';
711
+ e.dataTransfer.setData('text/plain', node.id);
712
+ }
713
+ }
714
+
715
+ function handleDragOver(e: DragEvent, node: TreeNode) {
716
+ if (!draggable || !drag_node_id || drag_node_id === node.id) return;
717
+
718
+ // Prevent dropping onto a descendant
719
+ if (isDescendant(drag_node_id, node.id)) return;
720
+
721
+ e.preventDefault();
722
+ if (e.dataTransfer) {
723
+ e.dataTransfer.dropEffect = 'move';
724
+ }
725
+
726
+ drop_target_id = node.id;
727
+
728
+ // Determine drop position based on mouse position within the element
729
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
730
+ const y = e.clientY - rect.top;
731
+ const height = rect.height;
732
+ const threshold = height / 4;
733
+
734
+ const can_have_children =
735
+ node.allowChildren !== undefined ? node.allowChildren : !!node.children;
736
+
737
+ if (y < threshold) {
738
+ drop_position = 'before';
739
+ } else if (y > height - threshold) {
740
+ drop_position = 'after';
741
+ } else if (can_have_children) {
742
+ drop_position = 'inside';
743
+ } else {
744
+ // Can't drop inside a leaf — snap to nearest edge
745
+ drop_position = y < height / 2 ? 'before' : 'after';
746
+ }
747
+ }
748
+
749
+ function handleDragLeave(e: DragEvent) {
750
+ // Only clear if we're leaving the node element, not entering a child
751
+ const related = e.relatedTarget as HTMLElement | null;
752
+ const current = e.currentTarget as HTMLElement;
753
+ if (related && current.contains(related)) return;
754
+ if (drop_target_id) {
755
+ const leaving_node = (e.currentTarget as HTMLElement).dataset.nodeId;
756
+ if (leaving_node === drop_target_id) {
757
+ drop_target_id = null;
758
+ }
759
+ }
760
+ }
761
+
762
+ function handleDrop(e: DragEvent, node: TreeNode) {
763
+ e.preventDefault();
764
+ if (!draggable || !drag_node_id || drag_node_id === node.id) return;
765
+ if (isDescendant(drag_node_id, node.id)) return;
766
+
767
+ const drag_node = node_map.get(drag_node_id);
768
+ if (drag_node) {
769
+ ondrop?.({
770
+ node: drag_node,
771
+ target: node,
772
+ position: drop_position,
773
+ });
774
+ }
775
+
776
+ drag_node_id = null;
777
+ drop_target_id = null;
778
+ }
779
+
780
+ function handleDragEnd() {
781
+ drag_node_id = null;
782
+ drop_target_id = null;
783
+ }
784
+
785
+ function isDescendant(ancestor_id: string, node_id: string): boolean {
786
+ const ancestor = node_map.get(ancestor_id);
787
+ if (!ancestor) return false;
788
+ const descendants = getAllDescendantIds(ancestor);
789
+ return descendants.includes(node_id);
790
+ }
791
+
792
+ /* ------------------------------------------------------------------ */
793
+ /* Filter text highlighting */
794
+ /* ------------------------------------------------------------------ */
795
+
796
+ function highlightMatch(
797
+ label: string,
798
+ term: string,
799
+ ): { text: string; bold: boolean }[] {
800
+ if (!term) return [{ text: label, bold: false }];
801
+ const lower = label.toLowerCase();
802
+ const idx = lower.indexOf(term);
803
+ if (idx === -1) return [{ text: label, bold: false }];
804
+ const parts: { text: string; bold: boolean }[] = [];
805
+ if (idx > 0) parts.push({ text: label.slice(0, idx), bold: false });
806
+ parts.push({ text: label.slice(idx, idx + term.length), bold: true });
807
+ if (idx + term.length < label.length) {
808
+ parts.push({ text: label.slice(idx + term.length), bold: false });
809
+ }
810
+ return parts;
811
+ }
812
+
813
+ /* ------------------------------------------------------------------ */
814
+ /* Context for potential sub-components */
815
+ /* ------------------------------------------------------------------ */
816
+
817
+ setContext('tree', {
818
+ get selectable() {
819
+ return selectable;
820
+ },
821
+ get checkboxes() {
822
+ return checkboxes;
823
+ },
824
+ get dense() {
825
+ return dense;
826
+ },
827
+ get comfortable() {
828
+ return comfortable;
829
+ },
830
+ });
831
+
832
+ /* ------------------------------------------------------------------ */
833
+ /* Skeleton helpers */
834
+ /* ------------------------------------------------------------------ */
835
+
836
+ function generateSkeletonNodes(
837
+ count: number,
838
+ depth: number,
839
+ ): { level: number; width: number }[] {
840
+ const nodes: { level: number; width: number }[] = [];
841
+ let current_level = 0;
842
+
843
+ for (let i = 0; i < count; i++) {
844
+ // Pseudo-random width between 40% and 80%
845
+ const width = 40 + ((i * 37 + 13) % 41);
846
+ nodes.push({ level: current_level, width });
847
+
848
+ // Vary the nesting level
849
+ if (i < count - 1) {
850
+ if (current_level < depth - 1 && i % 3 !== 2) {
851
+ current_level = Math.min(current_level + 1, depth - 1);
852
+ } else if (current_level > 0) {
853
+ current_level = Math.max(current_level - 1, 0);
854
+ }
855
+ }
856
+ }
857
+ return nodes;
858
+ }
859
+
860
+ const skeleton_nodes = $derived(generateSkeletonNodes(skeleton_count, skeleton_depth));
861
+ </script>
862
+
863
+ {#if skeleton}
864
+ <!-- Skeleton loading state -->
865
+ <div
866
+ class={['tree skeleton', class_name].filter(Boolean).join(' ')}
867
+ class:dense
868
+ class:comfortable
869
+ {id}
870
+ aria-hidden="true">
871
+ {#each skeleton_nodes as skel, i}
872
+ <div
873
+ class="skeleton-node"
874
+ style:padding-left="calc({skel.level} * var(--_indent))"
875
+ style:--shimmer-delay="{i * 120}ms">
876
+ <div class="skeleton-chevron"></div>
877
+ <div class="skeleton-bar" style:width="{skel.width}%"></div>
878
+ </div>
879
+ {/each}
880
+ </div>
881
+ {:else}
882
+ <!-- Tree -->
883
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
884
+ <ul
885
+ class={['tree', class_name].filter(Boolean).join(' ')}
886
+ class:dense
887
+ class:comfortable
888
+ class:show-lines={show_lines}
889
+ class:dragging={drag_node_id !== null}
890
+ class:pointer-focus={pointer_focus}
891
+ {id}
892
+ role="tree"
893
+ aria-activedescendant={focused_id ? `${id}-node-${focused_id}` : undefined}
894
+ aria-multiselectable={multi_select || undefined}
895
+ tabindex="0"
896
+ bind:this={tree_element}
897
+ onkeydown={handleTreeKeyDown}
898
+ onpointerdown={() => {
899
+ keyboard_nav = false;
900
+ pointer_focus = true;
901
+ }}
902
+ onfocusin={() => {
903
+ if (!focused_id) {
904
+ const visible = getVisibleNodeOrder();
905
+ if (visible.length > 0) focused_id = visible[0];
906
+ }
907
+ }}
908
+ onfocusout={(e) => {
909
+ if (!tree_element?.contains(e.relatedTarget as Node)) {
910
+ focused_id = null;
911
+ pointer_focus = false;
912
+ }
913
+ }}>
914
+ {#each tree as node, root_index (node.id)}
915
+ {@render treeNode(node, 1, root_index)}
916
+ {/each}
917
+ </ul>
918
+ {/if}
919
+
920
+ {#snippet treeNode(node: TreeNode, level: number, index: number)}
921
+ {#if isNodeVisible(node)}
922
+ {@const node_expanded = isExpanded(node.id)}
923
+ {@const children = getVisibleChildren(node)}
924
+ {@const has_kids = hasChildren(node) || hasLoadableChildren(node)}
925
+ {@const is_loading = loading_ids.has(node.id)}
926
+ {@const is_selected = selected.includes(node.id)}
927
+ {@const is_focused = focused_id === node.id}
928
+ {@const check_state = checkboxes ? getCheckState(node) : null}
929
+ {@const is_drag_target = drop_target_id === node.id}
930
+ <li
931
+ role="treeitem"
932
+ aria-expanded={has_kids ? node_expanded : undefined}
933
+ aria-selected={selectable ? is_selected : undefined}
934
+ aria-level={level}
935
+ aria-disabled={node.disabled || undefined}
936
+ id="{id}-node-{node.id}"
937
+ data-node-id={node.id}
938
+ class="node"
939
+ class:expanded={node_expanded}
940
+ class:selected={is_selected}
941
+ class:focused={is_focused && keyboard_nav}
942
+ class:disabled={node.disabled}
943
+ class:dragged={drag_node_id === node.id}
944
+ class:drop-before={is_drag_target && drop_position === 'before'}
945
+ class:drop-after={is_drag_target && drop_position === 'after'}
946
+ class:drop-inside={is_drag_target && drop_position === 'inside'}
947
+ style:--i={index}>
948
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
949
+ <div
950
+ class="row"
951
+ class:adj-top={adj_top.has(node.id)}
952
+ class:adj-bottom={adj_bottom.has(node.id)}
953
+ style:padding-left="calc({level - 1} * var(--_indent))"
954
+ onmouseenter={() => (hovered_id = node.id)}
955
+ onmouseleave={() => {
956
+ if (hovered_id === node.id) hovered_id = null;
957
+ }}
958
+ draggable={draggable && !node.disabled ? 'true' : undefined}
959
+ ondragstart={(e) => handleDragStart(e, node)}
960
+ ondragover={(e) => {
961
+ e.stopPropagation();
962
+ handleDragOver(e, node);
963
+ }}
964
+ ondragleave={handleDragLeave}
965
+ ondrop={(e) => {
966
+ e.stopPropagation();
967
+ handleDrop(e, node);
968
+ }}
969
+ ondragend={handleDragEnd}
970
+ onpointerdown={() => {
971
+ keyboard_nav = false;
972
+ }}
973
+ onclick={(e) => {
974
+ if (isNodeSelectable(node)) {
975
+ selectNode(node, e);
976
+ } else if (has_kids || hasLoadableChildren(node)) {
977
+ toggleExpand(node);
978
+ }
979
+ }}
980
+ onkeydown={(e) => {
981
+ if (e.key === 'Enter' || e.key === ' ') {
982
+ e.preventDefault();
983
+ e.stopPropagation();
984
+ if (isNodeSelectable(node)) selectNode(node, e);
985
+ else if (has_kids) toggleExpand(node);
986
+ }
987
+ }}
988
+ onfocusin={() => focusNode(node.id)}>
989
+ <!-- Chevron / Expand toggle -->
990
+ <button
991
+ class="chevron"
992
+ class:has-children={has_kids || hasLoadableChildren(node)}
993
+ tabindex={-1}
994
+ type="button"
995
+ aria-hidden="true"
996
+ onclick={(e) => {
997
+ e.stopPropagation();
998
+ if (has_kids || hasLoadableChildren(node)) toggleExpand(node);
999
+ }}>
1000
+ {#if is_loading}
1001
+ <svg class="spinner" width="16" height="16" viewBox="0 0 16 16" fill="none">
1002
+ <circle
1003
+ cx="8"
1004
+ cy="8"
1005
+ r="6"
1006
+ stroke="currentColor"
1007
+ stroke-width="1.5"
1008
+ stroke-dasharray="28"
1009
+ stroke-dashoffset="8"
1010
+ stroke-linecap="round" />
1011
+ </svg>
1012
+ {:else if has_kids || hasLoadableChildren(node)}
1013
+ <svg
1014
+ class="chevron-icon"
1015
+ class:rotated={node_expanded}
1016
+ width="16"
1017
+ height="16"
1018
+ viewBox="0 0 16 16"
1019
+ fill="none">
1020
+ <path
1021
+ d="M6 3L11 8L6 13"
1022
+ stroke="currentColor"
1023
+ stroke-width="1.5"
1024
+ stroke-linecap="round"
1025
+ stroke-linejoin="round" />
1026
+ </svg>
1027
+ {/if}
1028
+ </button>
1029
+
1030
+ <!-- Checkbox -->
1031
+ {#if checkboxes}
1032
+ <button
1033
+ class="checkbox"
1034
+ tabindex={-1}
1035
+ type="button"
1036
+ aria-hidden="true"
1037
+ onclick={(e) => {
1038
+ e.stopPropagation();
1039
+ selectNode(node);
1040
+ }}>
1041
+ <svg
1042
+ class="indicator"
1043
+ class:checked={check_state === 'checked'}
1044
+ class:indeterminate={check_state === 'indeterminate'}
1045
+ class:animating-check={check_anim.get(node.id) === 'check'}
1046
+ class:animating-uncheck={check_anim.get(node.id) === 'uncheck'}
1047
+ viewBox="0 0 24 24"
1048
+ width="18"
1049
+ height="18"
1050
+ fill="none">
1051
+ <rect
1052
+ class="box"
1053
+ x="2"
1054
+ y="2"
1055
+ width="20"
1056
+ height="20"
1057
+ rx="3"
1058
+ stroke-width="2" />
1059
+ {#if check_state === 'indeterminate'}
1060
+ <line
1061
+ class="dash"
1062
+ x1="7"
1063
+ y1="12"
1064
+ x2="17"
1065
+ y2="12"
1066
+ stroke-width="2.5"
1067
+ stroke-linecap="round" />
1068
+ {:else}
1069
+ <path
1070
+ class="mark"
1071
+ d="M6 12.5 L10 16.5 L18 8"
1072
+ stroke-width="2.5"
1073
+ stroke-linecap="round"
1074
+ stroke-linejoin="round" />
1075
+ {/if}
1076
+ </svg>
1077
+ </button>
1078
+ {/if}
1079
+
1080
+ <!-- Node content -->
1081
+ {#if node_content}
1082
+ {@render node_content({ node, level })}
1083
+ {:else}
1084
+ <span class="content">
1085
+ {#if node.icon}
1086
+ <span class="icon">
1087
+ <node.icon />
1088
+ </span>
1089
+ {/if}
1090
+ <span class="label">
1091
+ {#if is_filtering}
1092
+ {#each highlightMatch(node.label, filter_term) as part}
1093
+ {#if part.bold}<mark>{part.text}</mark>{:else}{part.text}{/if}
1094
+ {/each}
1095
+ {:else}
1096
+ {node.label}
1097
+ {/if}
1098
+ </span>
1099
+ </span>
1100
+ {/if}
1101
+ </div>
1102
+
1103
+ <!-- Children container with grid expand animation -->
1104
+ {#if has_kids && children.length > 0}
1105
+ <div class="children" class:show={node_expanded}>
1106
+ <ul role="group" style:--line-offset="calc({level - 1} * var(--_indent))">
1107
+ {#each children as child, child_index (child.id)}
1108
+ {@render treeNode(child, level + 1, child_index)}
1109
+ {/each}
1110
+ </ul>
1111
+ </div>
1112
+ {/if}
1113
+ </li>
1114
+ {/if}
1115
+ {/snippet}
1116
+
1117
+ <style>
1118
+ /* Registered so the active-path tint can ease out as an interpolated
1119
+ color (unsupported browsers degrade to a discrete switch) */
1120
+ @property --_tree-rail {
1121
+ syntax: '<color>';
1122
+ inherits: true;
1123
+ initial-value: transparent;
1124
+ }
1125
+
1126
+ /* ========== Tree Container ========== */
1127
+ .tree {
1128
+ /* Per-level indentation step — override with --tree-indent */
1129
+ --_indent: var(--tree-indent, 0.75rem);
1130
+ width: 100%;
1131
+ list-style: none;
1132
+ margin: 0;
1133
+ padding: 0;
1134
+ outline: none;
1135
+ color: light-dark(var(--color-text, #1a1a1a), var(--color-text, #f5f5f5));
1136
+ font-size: 0.875rem;
1137
+ -webkit-user-select: none;
1138
+ user-select: none;
1139
+
1140
+ /* Only surface the ring for keyboard (Tab) focus — :not(.pointer-focus)
1141
+ suppresses Chromium's modifier-held-click :focus-visible match so
1142
+ ctrl/shift multi-select clicks don't flash the container outline. */
1143
+ &:focus-visible:not(.pointer-focus) {
1144
+ box-shadow: inset 0 0 0 2px var(--color-action, #1976d2);
1145
+ border-radius: 8px;
1146
+ @supports (corner-shape: squircle) {
1147
+ corner-shape: squircle;
1148
+ border-radius: calc(8px * var(--squircle-ratio, 2));
1149
+ }
1150
+ }
1151
+ }
1152
+
1153
+ /* ========== Tree Node ========== */
1154
+ .node {
1155
+ list-style: none;
1156
+ position: relative;
1157
+ perspective: 100px;
1158
+ }
1159
+
1160
+ /* ========== Node Row ========== */
1161
+ .row {
1162
+ display: flex;
1163
+ align-items: center;
1164
+ gap: 0.125rem;
1165
+ padding: 0.25rem 0.5rem 0.25rem 0;
1166
+ cursor: pointer;
1167
+ border-radius: 8px;
1168
+ @supports (corner-shape: squircle) {
1169
+ corner-shape: squircle;
1170
+ border-radius: calc(8px * var(--squircle-ratio, 2));
1171
+ }
1172
+ position: relative;
1173
+ min-height: 1.75rem;
1174
+ transition:
1175
+ background-color 100ms ease,
1176
+ translate 200ms ease;
1177
+
1178
+ &:hover {
1179
+ background: light-dark(
1180
+ rgb(from var(--color-text, #000) r g b / 0.06),
1181
+ rgb(from var(--color-text, #fff) r g b / 0.08)
1182
+ );
1183
+ transition: translate 200ms ease;
1184
+ }
1185
+
1186
+ &:active {
1187
+ translate: 0px 4px clamp(-5px, calc(0.2em - 5px), -2px);
1188
+ }
1189
+ }
1190
+
1191
+ .node.disabled > .row:active {
1192
+ translate: none;
1193
+ }
1194
+
1195
+ .node.selected > .row {
1196
+ background: light-dark(
1197
+ rgb(from var(--color-action, #1976d2) r g b / 0.1),
1198
+ rgb(from var(--color-action, #5c9ce6) r g b / 0.15)
1199
+ );
1200
+ }
1201
+
1202
+ /* Flatten touching corners between visually adjacent highlighted nodes */
1203
+ .node.selected > .row.adj-bottom,
1204
+ .row:hover.adj-bottom {
1205
+ border-bottom-left-radius: 0;
1206
+ border-bottom-right-radius: 0;
1207
+ }
1208
+
1209
+ .node.selected > .row.adj-top,
1210
+ .row:hover.adj-top {
1211
+ border-top-left-radius: 0;
1212
+ border-top-right-radius: 0;
1213
+ }
1214
+
1215
+ .node.selected > .row:hover {
1216
+ background: light-dark(
1217
+ rgb(from var(--color-action, #1976d2) r g b / 0.15),
1218
+ rgb(from var(--color-action, #5c9ce6) r g b / 0.22)
1219
+ );
1220
+ transition: translate 200ms ease;
1221
+ }
1222
+
1223
+ .node.focused > .row {
1224
+ outline: 2px solid var(--color-action, #1976d2);
1225
+ outline-offset: -2px;
1226
+ border-radius: var(--radius-md, 4px);
1227
+ @supports (corner-shape: squircle) {
1228
+ corner-shape: squircle;
1229
+ border-radius: calc(var(--radius-md, 4px) * var(--squircle-ratio, 2));
1230
+ }
1231
+ }
1232
+
1233
+ .node.disabled > .row {
1234
+ opacity: 0.5;
1235
+ pointer-events: none;
1236
+ }
1237
+
1238
+ /* ========== Dense / Comfortable ========== */
1239
+ .tree.dense .row {
1240
+ padding-top: 0.0625rem;
1241
+ padding-bottom: 0.0625rem;
1242
+ min-height: 1.375rem;
1243
+ font-size: 0.8125rem;
1244
+ }
1245
+
1246
+ .tree.comfortable .row {
1247
+ padding-top: 0.5rem;
1248
+ padding-bottom: 0.5rem;
1249
+ min-height: 2.25rem;
1250
+ }
1251
+
1252
+ /* ========== Chevron Button ========== */
1253
+ .chevron {
1254
+ display: flex;
1255
+ align-items: center;
1256
+ justify-content: center;
1257
+ width: 1.25rem;
1258
+ height: 1.25rem;
1259
+ flex-shrink: 0;
1260
+ padding: 0;
1261
+ margin: 0;
1262
+ border: none;
1263
+ background: none;
1264
+ cursor: pointer;
1265
+ color: light-dark(var(--color-text-muted, #888), var(--color-text-muted, #999));
1266
+ border-radius: var(--radius-md, 4px);
1267
+ @supports (corner-shape: squircle) {
1268
+ corner-shape: squircle;
1269
+ border-radius: calc(var(--radius-md, 4px) * var(--squircle-ratio, 2));
1270
+ }
1271
+
1272
+ &:not(.has-children) {
1273
+ visibility: hidden;
1274
+ }
1275
+
1276
+ &:hover {
1277
+ background: light-dark(
1278
+ rgb(from var(--color-text, #000) r g b / 0.08),
1279
+ rgb(from var(--color-text, #fff) r g b / 0.1)
1280
+ );
1281
+ transition: none;
1282
+ }
1283
+ }
1284
+
1285
+ .chevron-icon {
1286
+ transition: transform 200ms ease;
1287
+
1288
+ &.rotated {
1289
+ transform: rotate(90deg);
1290
+ }
1291
+ }
1292
+
1293
+ /* ========== Spinner ==========
1294
+ * Combines a non-linear rotation with a dasharray sweep to mimic the
1295
+ * easing used by <Progress />. The rotation uses cubic-bezier for an
1296
+ * organic pace; the stroke offset/length animation gives the typical
1297
+ * "elastic chase" look found on Material-style indeterminate spinners. */
1298
+ .spinner {
1299
+ color: light-dark(var(--color-action, #1976d2), var(--color-action, #5c9ce6));
1300
+ animation: tree-spin 1.4s cubic-bezier(0.4, 0, 0.2, 1) infinite;
1301
+
1302
+ circle {
1303
+ transform-origin: center;
1304
+ animation: tree-spinner-dash 1.4s ease-in-out infinite;
1305
+ }
1306
+ }
1307
+
1308
+ @keyframes tree-spin {
1309
+ 0% {
1310
+ transform: rotate(0deg);
1311
+ }
1312
+ 100% {
1313
+ transform: rotate(360deg);
1314
+ }
1315
+ }
1316
+ @keyframes tree-spinner-dash {
1317
+ 0% {
1318
+ stroke-dasharray: 1, 38;
1319
+ stroke-dashoffset: 0;
1320
+ }
1321
+ 50% {
1322
+ stroke-dasharray: 22, 38;
1323
+ stroke-dashoffset: -9;
1324
+ }
1325
+ 100% {
1326
+ stroke-dasharray: 22, 38;
1327
+ stroke-dashoffset: -28;
1328
+ }
1329
+ }
1330
+
1331
+ /* ========== Checkbox ========== */
1332
+ .checkbox {
1333
+ display: flex;
1334
+ align-items: center;
1335
+ justify-content: center;
1336
+ flex-shrink: 0;
1337
+ width: 1.25rem;
1338
+ height: 1.25rem;
1339
+ padding: 0;
1340
+ margin: 0 0.125rem;
1341
+ border: none;
1342
+ background: none;
1343
+ cursor: pointer;
1344
+ color: light-dark(var(--color-text, #1a1a1a), var(--color-text, #f5f5f5));
1345
+ }
1346
+
1347
+ /* Same animation technique as form/Checkbox.svelte: the check path is
1348
+ always rendered and drawn via stroke-dashoffset; the animating-* classes
1349
+ add the elastic draw + box pulse on check and the fast retract on uncheck. */
1350
+ .indicator {
1351
+ flex-shrink: 0;
1352
+
1353
+ .box {
1354
+ stroke: light-dark(var(--color-text-muted, #999), var(--color-text-muted, #777));
1355
+ fill: transparent;
1356
+ transition:
1357
+ stroke 150ms ease,
1358
+ fill 150ms ease;
1359
+ }
1360
+
1361
+ .mark {
1362
+ stroke: transparent;
1363
+ fill: none;
1364
+ stroke-dasharray: 28;
1365
+ stroke-dashoffset: 28;
1366
+ transition:
1367
+ stroke-dashoffset 250ms ease,
1368
+ stroke 150ms ease;
1369
+ }
1370
+
1371
+ .dash {
1372
+ stroke: transparent;
1373
+ transition: stroke 150ms ease;
1374
+ }
1375
+
1376
+ &.checked {
1377
+ .box {
1378
+ stroke: var(--color-action, #1976d2);
1379
+ fill: var(--color-action, #1976d2);
1380
+ }
1381
+ .mark {
1382
+ stroke: var(--color-bg, #fff);
1383
+ stroke-dashoffset: 0;
1384
+ }
1385
+ }
1386
+
1387
+ &.indeterminate {
1388
+ .box {
1389
+ stroke: var(--color-action, #1976d2);
1390
+ fill: var(--color-action, #1976d2);
1391
+ }
1392
+ .dash {
1393
+ stroke: var(--color-bg, #fff);
1394
+ }
1395
+ }
1396
+
1397
+ /* Check-in: elastic checkmark draw with overshoot + scale pulse */
1398
+ &.animating-check {
1399
+ animation: box-pulse 350ms cubic-bezier(0.34, 1.56, 0.64, 1);
1400
+
1401
+ .mark {
1402
+ stroke-dashoffset: 0;
1403
+ transition: stroke-dashoffset 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
1404
+ }
1405
+ }
1406
+
1407
+ /* Uncheck: visible stroke retraction, box holds fill then fades */
1408
+ &.animating-uncheck {
1409
+ .mark {
1410
+ stroke: var(--color-bg, #fff);
1411
+ stroke-dashoffset: 28;
1412
+ transition: stroke-dashoffset 50ms cubic-bezier(0.4, 0, 0.2, 1);
1413
+ }
1414
+ .box {
1415
+ stroke: var(--color-action, #1976d2);
1416
+ fill: var(--color-action, #1976d2);
1417
+ }
1418
+ }
1419
+ }
1420
+
1421
+ @keyframes box-pulse {
1422
+ 0% {
1423
+ transform: scale(1);
1424
+ }
1425
+ 40% {
1426
+ transform: scale(1.1);
1427
+ }
1428
+ 100% {
1429
+ transform: scale(1);
1430
+ }
1431
+ }
1432
+
1433
+ /* ========== Node Content ========== */
1434
+ .content {
1435
+ display: flex;
1436
+ align-items: center;
1437
+ gap: 0.375rem;
1438
+ flex: 1;
1439
+ min-width: 0;
1440
+ overflow: hidden;
1441
+ }
1442
+
1443
+ .icon {
1444
+ display: flex;
1445
+ align-items: center;
1446
+ flex-shrink: 0;
1447
+ color: light-dark(var(--color-text-muted, #666), var(--color-text-muted, #aaa));
1448
+ }
1449
+
1450
+ .label {
1451
+ overflow: hidden;
1452
+ text-overflow: ellipsis;
1453
+ white-space: nowrap;
1454
+
1455
+ mark {
1456
+ background: light-dark(
1457
+ rgb(from var(--color-action, #1976d2) r g b / 0.2),
1458
+ rgb(from var(--color-action, #5c9ce6) r g b / 0.3)
1459
+ );
1460
+ color: inherit;
1461
+ border-radius: 2px;
1462
+ padding: 0 1px;
1463
+ }
1464
+ }
1465
+
1466
+ /* ========== Children Container (Expand Animation) ========== */
1467
+ .children {
1468
+ display: grid;
1469
+ grid-template-rows: min-content 0fr;
1470
+ transition:
1471
+ grid-template-rows 200ms ease,
1472
+ opacity 150ms;
1473
+ opacity: 0;
1474
+
1475
+ &::before {
1476
+ content: '';
1477
+ }
1478
+
1479
+ > :global(ul) {
1480
+ overflow: hidden;
1481
+ visibility: hidden;
1482
+ transition-behavior: allow-discrete;
1483
+ transition: visibility 0ms 200ms;
1484
+ list-style: none;
1485
+ margin: 0;
1486
+ padding: 0;
1487
+ }
1488
+
1489
+ &.show {
1490
+ grid-template-rows: min-content 1fr;
1491
+ opacity: 1;
1492
+
1493
+ > :global(ul) {
1494
+ visibility: visible;
1495
+ transition: visibility 0ms;
1496
+ }
1497
+ }
1498
+ }
1499
+
1500
+ /* ========== Connecting Lines ========== */
1501
+ /* Each group's rail color lives in --_tree-rail on its ul; the pseudo
1502
+ elements below just borrow it via var(). Every ul sets its own value,
1503
+ so a tinted group never leaks into nested groups. The transition here
1504
+ governs the OUT fade of the active-path tint (the IN snaps, below). */
1505
+ .tree.show-lines .children > :global(ul) {
1506
+ position: relative;
1507
+ --_tree-rail: light-dark(
1508
+ rgb(from var(--color-text, #000) r g b / 0.15),
1509
+ rgb(from var(--color-text, #fff) r g b / 0.2)
1510
+ );
1511
+ transition: --_tree-rail 200ms ease;
1512
+ }
1513
+
1514
+ /*
1515
+ * Guides are drawn per child: every node but the last carries a vertical
1516
+ * segment spanning its full height (subtree included), and the last
1517
+ * child carries an L running from its top to the vertical center of its
1518
+ * own row before curving toward the label — so the foot stays centered
1519
+ * on the text no matter how tall the row renders. clip-path reveals
1520
+ * each segment top-to-bottom with a per-row stagger (--i) on expand.
1521
+ */
1522
+ .tree.show-lines .node > .children > :global(ul > .node:not(:last-child))::before,
1523
+ .tree.show-lines .node > .children > :global(ul > .node:last-child > .row)::before {
1524
+ content: '';
1525
+ position: absolute;
1526
+ top: 0;
1527
+ left: calc(0.625rem + var(--line-offset, 0px));
1528
+ border-left: 1.5px solid var(--_tree-rail);
1529
+ pointer-events: none;
1530
+ clip-path: inset(0 0 100% 0);
1531
+ /* governs the retract on collapse */
1532
+ transition: clip-path 150ms ease;
1533
+ }
1534
+
1535
+ .tree.show-lines .node > .children > :global(ul > .node:not(:last-child))::before {
1536
+ bottom: 0;
1537
+ }
1538
+
1539
+ .tree.show-lines .node > .children > :global(ul > .node:last-child > .row)::before {
1540
+ height: 50%;
1541
+ width: 0.5rem;
1542
+ border-bottom: 1.5px solid var(--_tree-rail);
1543
+ border-bottom-left-radius: 0.375rem;
1544
+ }
1545
+
1546
+ /* Soften where a multi-row rail emerges from its parent row */
1547
+ .tree.show-lines
1548
+ .node
1549
+ > .children
1550
+ > :global(ul > .node:first-child:not(:last-child))::before {
1551
+ mask-image: linear-gradient(to bottom, transparent, #000 0.5rem);
1552
+ }
1553
+
1554
+ .tree.show-lines .node > .children.show > :global(ul > .node:not(:last-child))::before,
1555
+ .tree.show-lines
1556
+ .node
1557
+ > .children.show
1558
+ > :global(ul > .node:last-child > .row)::before {
1559
+ clip-path: inset(0 0 0 0);
1560
+ transition: clip-path 200ms ease-out calc(min(80ms + var(--i, 0) * 40ms, 400ms));
1561
+ }
1562
+
1563
+ /* Active path: tint the rail of the group containing the hovered,
1564
+ selected, or keyboard-focused row — snap in (transition: none here),
1565
+ ease out (the --_tree-rail transition on the base ul rule above) */
1566
+ .tree.show-lines .children > :global(ul:has(> .node > .row:hover)),
1567
+ .tree.show-lines .children > :global(ul:has(> .node.selected)),
1568
+ .tree.show-lines .children > :global(ul:has(> .node.focused)) {
1569
+ --_tree-rail: light-dark(
1570
+ rgb(from var(--color-action, #1976d2) r g b / 0.5),
1571
+ rgb(from var(--color-action, #5c9ce6) r g b / 0.55)
1572
+ );
1573
+ transition: none;
1574
+ }
1575
+
1576
+ /* ========== Drag-and-Drop Indicators ========== */
1577
+ .node.dragged {
1578
+ opacity: 0.4;
1579
+ }
1580
+
1581
+ /* Both drop indicators share ::after (they're mutually exclusive states)
1582
+ because ::before holds the connecting-line L on last children */
1583
+ .node.drop-before > .row::after,
1584
+ .node.drop-after > .row::after {
1585
+ content: '';
1586
+ position: absolute;
1587
+ left: 0;
1588
+ right: 0;
1589
+ height: 2px;
1590
+ background: var(--color-action, #1976d2);
1591
+ border-radius: 1px;
1592
+ z-index: 1;
1593
+ }
1594
+
1595
+ /* Drawn fully inside the row — straddling the boundary (top: -1px) lets
1596
+ group overflow clipping shave the bar to 1px on a group's edge rows */
1597
+ .node.drop-before > .row::after {
1598
+ top: 0;
1599
+ }
1600
+
1601
+ .node.drop-after > .row::after {
1602
+ bottom: 0;
1603
+ }
1604
+
1605
+ .node.drop-inside > .row {
1606
+ outline: 2px solid var(--color-action, #1976d2);
1607
+ outline-offset: -2px;
1608
+ border-radius: 8px;
1609
+ @supports (corner-shape: squircle) {
1610
+ corner-shape: squircle;
1611
+ border-radius: calc(8px * var(--squircle-ratio, 2));
1612
+ }
1613
+ background: light-dark(
1614
+ rgb(from var(--color-action, #1976d2) r g b / 0.08),
1615
+ rgb(from var(--color-action, #5c9ce6) r g b / 0.12)
1616
+ );
1617
+ }
1618
+
1619
+ .tree.dragging {
1620
+ cursor: grabbing;
1621
+ }
1622
+
1623
+ .tree.dragging .row {
1624
+ cursor: grabbing;
1625
+ }
1626
+
1627
+ /* ========== Skeleton ========== */
1628
+ .tree.skeleton {
1629
+ pointer-events: none;
1630
+ }
1631
+
1632
+ /* Mirrors .row metrics (incl. dense/comfortable) so each placeholder
1633
+ row is exactly the height of the real row it stands in for. */
1634
+ .skeleton-node {
1635
+ display: flex;
1636
+ align-items: center;
1637
+ gap: 0.125rem;
1638
+ padding: 0.25rem 0.5rem 0.25rem 0;
1639
+ min-height: 1.75rem;
1640
+ }
1641
+
1642
+ .tree.dense .skeleton-node {
1643
+ padding-top: 0.0625rem;
1644
+ padding-bottom: 0.0625rem;
1645
+ min-height: 1.375rem;
1646
+ font-size: 0.8125rem;
1647
+ }
1648
+
1649
+ .tree.comfortable .skeleton-node {
1650
+ padding-top: 0.5rem;
1651
+ padding-bottom: 0.5rem;
1652
+ min-height: 2.25rem;
1653
+ }
1654
+
1655
+ .skeleton-chevron,
1656
+ .skeleton-bar {
1657
+ position: relative;
1658
+ overflow: hidden;
1659
+ background: var(--skeleton-bg, rgb(from var(--color-text, #888) r g b / 0.1));
1660
+
1661
+ &::after {
1662
+ content: '';
1663
+ position: absolute;
1664
+ inset: 0;
1665
+ transform: translateX(-100%);
1666
+ background-image: linear-gradient(
1667
+ 105deg,
1668
+ transparent 25%,
1669
+ var(--skeleton-sheen, rgb(from var(--color-text, #888) r g b / 0.12)) 50%,
1670
+ transparent 75%
1671
+ );
1672
+ animation: delight-skeleton-shimmer var(--skeleton-duration, 2.4s) ease-in-out
1673
+ infinite;
1674
+ animation-delay: var(--shimmer-delay, 0s);
1675
+ }
1676
+ }
1677
+
1678
+ /* The real chevron button occupies a 1.25rem slot; the visible placeholder
1679
+ is the icon-sized square centered inside it. */
1680
+ .skeleton-chevron {
1681
+ width: 0.875rem;
1682
+ height: 0.875rem;
1683
+ margin: 0.1875rem;
1684
+ flex-shrink: 0;
1685
+ border-radius: var(--radius-sm, 2px);
1686
+ }
1687
+
1688
+ /* The bar is padded out to 1lh by its margins so the placeholder row grows
1689
+ with the inherited line-height exactly like the real label's line box. */
1690
+ .skeleton-bar {
1691
+ height: 0.7em;
1692
+ margin-block: calc((1lh - 0.7em) / 2);
1693
+ border-radius: var(--radius-full, 1e5px);
1694
+ }
1695
+
1696
+ @keyframes -global-delight-skeleton-shimmer {
1697
+ 0% {
1698
+ transform: translateX(-100%);
1699
+ }
1700
+ 55%,
1701
+ 100% {
1702
+ transform: translateX(100%);
1703
+ }
1704
+ }
1705
+
1706
+ @media (prefers-reduced-motion: reduce) {
1707
+ .skeleton-chevron::after,
1708
+ .skeleton-bar::after {
1709
+ animation: none;
1710
+ }
1711
+ .chevron-icon {
1712
+ transition: none;
1713
+ }
1714
+ .indicator {
1715
+ animation: none;
1716
+ .mark {
1717
+ transition: none;
1718
+ }
1719
+ }
1720
+ .children {
1721
+ transition: none;
1722
+ }
1723
+ .spinner {
1724
+ animation: none;
1725
+ }
1726
+ .tree.show-lines .node > .children > :global(ul > .node:not(:last-child))::before,
1727
+ .tree.show-lines .node > .children > :global(ul > .node:last-child > .row)::before,
1728
+ .tree.show-lines
1729
+ .node
1730
+ > .children.show
1731
+ > :global(ul > .node:not(:last-child))::before,
1732
+ .tree.show-lines
1733
+ .node
1734
+ > .children.show
1735
+ > :global(ul > .node:last-child > .row)::before {
1736
+ clip-path: none;
1737
+ transition: none;
1738
+ }
1739
+ }
1740
+ </style>