@lyeve-labs/ui-kit 0.11.2 → 0.13.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 (101) hide show
  1. package/README.md +1 -1
  2. package/dist/components/AccordionItem.svelte +1 -1
  3. package/dist/components/Autocomplete.svelte +191 -125
  4. package/dist/components/Autocomplete.svelte.d.ts +29 -8
  5. package/dist/components/Button.svelte +26 -4
  6. package/dist/components/Card.svelte +61 -3
  7. package/dist/components/Card.svelte.d.ts +24 -2
  8. package/dist/components/Checkbox.svelte +174 -59
  9. package/dist/components/Checkbox.svelte.d.ts +20 -3
  10. package/dist/components/CheckboxGroup.svelte +162 -0
  11. package/dist/components/CheckboxGroup.svelte.d.ts +51 -0
  12. package/dist/components/Collapsible.svelte +142 -0
  13. package/dist/components/Collapsible.svelte.d.ts +32 -0
  14. package/dist/components/CopyButton.svelte +126 -0
  15. package/dist/components/CopyButton.svelte.d.ts +14 -0
  16. package/dist/components/DatePicker.svelte +48 -6
  17. package/dist/components/DateTimePicker.svelte +337 -0
  18. package/dist/components/DateTimePicker.svelte.d.ts +26 -0
  19. package/dist/components/DescriptionList.svelte +78 -0
  20. package/dist/components/DescriptionList.svelte.d.ts +34 -0
  21. package/dist/components/Drawer.svelte +15 -4
  22. package/dist/components/Field.svelte +104 -0
  23. package/dist/components/Field.svelte.d.ts +46 -0
  24. package/dist/components/FileInput.svelte +5 -2
  25. package/dist/components/FormMessage.svelte +85 -0
  26. package/dist/components/FormMessage.svelte.d.ts +11 -0
  27. package/dist/components/Input.svelte +1 -1
  28. package/dist/components/Label.svelte +7 -1
  29. package/dist/components/Label.svelte.d.ts +6 -0
  30. package/dist/components/Modal.svelte +25 -8
  31. package/dist/components/MultiSelect.svelte +199 -109
  32. package/dist/components/MultiSelect.svelte.d.ts +22 -9
  33. package/dist/components/NumberInput.svelte +8 -4
  34. package/dist/components/PageHeader.svelte +37 -4
  35. package/dist/components/PageHeader.svelte.d.ts +15 -0
  36. package/dist/components/PageShell.svelte +85 -0
  37. package/dist/components/PageShell.svelte.d.ts +38 -0
  38. package/dist/components/Pagination.svelte +58 -17
  39. package/dist/components/Panel.svelte +101 -0
  40. package/dist/components/Panel.svelte.d.ts +39 -0
  41. package/dist/components/PasswordInput.svelte +139 -0
  42. package/dist/components/PasswordInput.svelte.d.ts +29 -0
  43. package/dist/components/Radio.svelte +152 -32
  44. package/dist/components/Radio.svelte.d.ts +16 -1
  45. package/dist/components/RadioGroup.svelte +118 -71
  46. package/dist/components/RadioGroup.svelte.d.ts +39 -9
  47. package/dist/components/SectionHeading.svelte +39 -0
  48. package/dist/components/SectionHeading.svelte.d.ts +21 -0
  49. package/dist/components/SegmentedControl.svelte +194 -0
  50. package/dist/components/SegmentedControl.svelte.d.ts +55 -0
  51. package/dist/components/Select.svelte +471 -46
  52. package/dist/components/Select.svelte.d.ts +95 -6
  53. package/dist/components/SidebarNav.svelte +259 -0
  54. package/dist/components/SidebarNav.svelte.d.ts +17 -0
  55. package/dist/components/Stat.svelte +53 -2
  56. package/dist/components/Stat.svelte.d.ts +31 -0
  57. package/dist/components/Textarea.svelte +1 -1
  58. package/dist/components/TimePicker.svelte +480 -0
  59. package/dist/components/TimePicker.svelte.d.ts +23 -0
  60. package/dist/components/Toaster.svelte +9 -2
  61. package/dist/components/Toggle.svelte +5 -1
  62. package/dist/components/Toggle.svelte.d.ts +2 -0
  63. package/dist/components/Toolbar.svelte +39 -0
  64. package/dist/components/Toolbar.svelte.d.ts +26 -0
  65. package/dist/components/Tooltip.svelte +48 -12
  66. package/dist/components/TreeView.svelte +339 -0
  67. package/dist/components/TreeView.svelte.d.ts +37 -0
  68. package/dist/components/dialog/Dialog.svelte +15 -58
  69. package/dist/components/dialog/dialog-manager.svelte.d.ts +2 -2
  70. package/dist/components/dialog/dialog-manager.svelte.js +21 -21
  71. package/dist/index.d.ts +25 -1
  72. package/dist/index.js +20 -1
  73. package/dist/internal/calendar.d.ts +119 -0
  74. package/dist/internal/calendar.js +225 -0
  75. package/dist/internal/choice.d.ts +136 -0
  76. package/dist/internal/choice.js +179 -0
  77. package/dist/internal/field.d.ts +31 -0
  78. package/dist/internal/field.js +42 -1
  79. package/dist/internal/filter.d.ts +80 -0
  80. package/dist/internal/filter.js +80 -0
  81. package/dist/internal/layout.d.ts +119 -0
  82. package/dist/internal/layout.js +132 -0
  83. package/dist/internal/listbox.svelte.d.ts +77 -0
  84. package/dist/internal/listbox.svelte.js +438 -0
  85. package/dist/internal/nav-expansion.svelte.d.ts +36 -0
  86. package/dist/internal/nav-expansion.svelte.js +144 -0
  87. package/dist/internal/nav-tree.d.ts +68 -0
  88. package/dist/internal/nav-tree.js +102 -0
  89. package/dist/internal/overlay.d.ts +25 -0
  90. package/dist/internal/overlay.js +92 -0
  91. package/dist/internal/panel.d.ts +100 -0
  92. package/dist/internal/panel.js +109 -0
  93. package/dist/internal/rollup.d.ts +52 -0
  94. package/dist/internal/rollup.js +67 -0
  95. package/dist/internal/time.d.ts +103 -0
  96. package/dist/internal/time.js +166 -0
  97. package/dist/internal/tree.d.ts +86 -0
  98. package/dist/internal/tree.js +111 -0
  99. package/dist/styles/theme.css +66 -25
  100. package/package.json +4 -2
  101. package/src/lib/styles/theme.css +66 -25
@@ -13,6 +13,9 @@
13
13
  let { text, position = 'top', class: cls = '', children }: Props = $props();
14
14
 
15
15
  let visible = $state(false);
16
+ let wrapper = $state<HTMLElement>();
17
+
18
+ const id = $props.id();
16
19
 
17
20
  const pos: Record<Position, string> = {
18
21
  top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
@@ -20,25 +23,58 @@
20
23
  left: 'right-full top-1/2 -translate-y-1/2 mr-2',
21
24
  right: 'left-full top-1/2 -translate-y-1/2 ml-2',
22
25
  };
26
+
27
+ /**
28
+ * `role="tooltip"` on its own is inert: assistive technology reads a tooltip
29
+ * only because the element it describes points at it. The trigger arrives as
30
+ * a snippet, so the component cannot put the attribute on it at compile time
31
+ * and wires it to the first focusable descendant instead. Without this the
32
+ * text was visible to a sighted mouse user and to nobody else.
33
+ */
34
+ $effect(() => {
35
+ const trigger = wrapper?.querySelector<HTMLElement>(
36
+ 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])',
37
+ );
38
+ if (!trigger) return;
39
+ trigger.setAttribute('aria-describedby', id);
40
+ return () => trigger.removeAttribute('aria-describedby');
41
+ });
42
+
43
+ function onkeydown(e: KeyboardEvent) {
44
+ // SC 1.4.13 requires content shown on hover or focus to be dismissible
45
+ // without moving the pointer or the focus.
46
+ if (e.key === 'Escape' && visible) {
47
+ e.stopPropagation();
48
+ visible = false;
49
+ }
50
+ }
23
51
  </script>
24
52
 
53
+ <!-- focusin and focusout, not focus and blur. focus and blur do not bubble, so
54
+ bound to this wrapper they never fired for the trigger inside it and the
55
+ tooltip was unreachable by keyboard. -->
25
56
  <!-- svelte-ignore a11y_no_static_element_interactions -->
26
57
  <span
58
+ bind:this={wrapper}
27
59
  class="relative inline-flex {cls}"
28
60
  onmouseenter={() => (visible = true)}
29
61
  onmouseleave={() => (visible = false)}
30
- onfocus={() => (visible = true)}
31
- onblur={() => (visible = false)}
62
+ onfocusin={() => (visible = true)}
63
+ onfocusout={() => (visible = false)}
64
+ {onkeydown}
32
65
  >
33
66
  {@render children()}
34
- {#if visible}
35
- <span
36
- role="tooltip"
37
- class="pointer-events-none absolute {pos[position]} z-50
38
- whitespace-nowrap rounded-lg border border-line bg-surface-2
39
- px-2.5 py-1.5 text-xs text-fg shadow-xl"
40
- >
41
- {text}
42
- </span>
43
- {/if}
67
+ <!-- Always rendered, hidden by the `hidden` attribute rather than removed:
68
+ aria-describedby resolves the text of a hidden element, so the trigger
69
+ keeps a stable target. Left hoverable rather than pointer-events-none, so
70
+ moving onto it does not dismiss it, which SC 1.4.13 also requires. -->
71
+ <span
72
+ {id}
73
+ role="tooltip"
74
+ hidden={!visible}
75
+ class="absolute {pos[position]} z-50 max-w-[min(16rem,calc(100vw-2rem))]
76
+ rounded-lg border border-line bg-surface-2 px-2.5 py-1.5 text-xs text-fg shadow-xl"
77
+ >
78
+ {text}
79
+ </span>
44
80
  </span>
@@ -0,0 +1,339 @@
1
+ <script lang="ts" generics="T = unknown">
2
+ import { ChevronRight } from '@lucide/svelte';
3
+ import { CHOICE_MARK, choiceBox } from '../internal/choice.js';
4
+ import {
5
+ branchState,
6
+ flattenTree,
7
+ isBranch,
8
+ toggleSubtree,
9
+ type TreeNode,
10
+ type TriState,
11
+ } from '../internal/tree.js';
12
+
13
+ interface Props {
14
+ nodes: TreeNode<T>[];
15
+ /** Required. An unnamed tree announces as just "tree". */
16
+ label: string;
17
+ expanded?: string[];
18
+ selected?: string;
19
+ /** Checked LEAF ids only. A branch's state is always derived, never stored, so the two cannot disagree. */
20
+ checked?: string[];
21
+ checkable?: boolean;
22
+ class?: string;
23
+ onactivate?: (node: TreeNode<T>) => void;
24
+ oncheck?: (checked: string[]) => void;
25
+ }
26
+
27
+ let {
28
+ nodes,
29
+ label,
30
+ expanded = $bindable([]),
31
+ selected = $bindable(undefined),
32
+ checked = $bindable([]),
33
+ checkable = false,
34
+ class: klass = '',
35
+ onactivate,
36
+ oncheck,
37
+ }: Props = $props();
38
+
39
+ let rootEl: HTMLUListElement | undefined = $state();
40
+
41
+ /** The row the roving tabindex sits on. Undefined until the user lands somewhere. */
42
+ let activeId: string | undefined = $state(undefined);
43
+
44
+ const expandedSet = $derived(new Set(expanded));
45
+ const checkedSet = $derived(new Set(checked));
46
+
47
+ /**
48
+ * The visible rows, which is both the paint order and the arrow-key order.
49
+ * A collapsed branch contributes no children here, so ArrowDown cannot land
50
+ * on a row the user cannot see.
51
+ */
52
+ const rows = $derived(flattenTree(nodes, expandedSet));
53
+
54
+ /**
55
+ * Exactly one row is in the tab sequence, and the arrows move it.
56
+ *
57
+ * A tree is one stop, not one stop per row: a hundred-node tree with a
58
+ * tabbable row each is a hundred presses to get past. The fallback chain
59
+ * matters as much as the rule. The active row can be gone after a collapse
60
+ * or a data change, so the tabbable row falls back to the selected row and
61
+ * then to the first, and a tree with no row the keyboard can reach is a tree
62
+ * nobody can enter.
63
+ */
64
+ const tabbableId = $derived(
65
+ rows.find((r) => r.node.id === activeId)?.node.id ??
66
+ rows.find((r) => r.node.id === selected)?.node.id ??
67
+ rows[0]?.node.id,
68
+ );
69
+
70
+ /** Typeahead buffer. Not state: nothing renders it, and it clears on a timer. */
71
+ let typed = '';
72
+ let typedTimer: ReturnType<typeof setTimeout> | undefined;
73
+
74
+ $effect(() => () => clearTimeout(typedTimer));
75
+
76
+ function ariaChecked(state: TriState): 'true' | 'false' | 'mixed' {
77
+ if (state === 'all') return 'true';
78
+ return state === 'some' ? 'mixed' : 'false';
79
+ }
80
+
81
+ function rowPaint(isSelected: boolean, disabled: boolean): string {
82
+ // One utility per property in each branch. A resting `hover:bg-surface-2`
83
+ // that a selected row overrides with `bg-surface-2` resolves by the order
84
+ // Tailwind emits the two, not by the order they are written here.
85
+ if (disabled) return 'cursor-not-allowed opacity-50';
86
+ return isSelected
87
+ ? 'cursor-pointer bg-surface-2 font-medium text-brand'
88
+ : 'cursor-pointer hover:bg-surface-2';
89
+ }
90
+
91
+ function rowElement(id: string): HTMLElement | undefined {
92
+ if (!rootEl) return undefined;
93
+ return [...rootEl.querySelectorAll<HTMLElement>('[role="treeitem"]')].find(
94
+ (el) => el.dataset.treeId === id,
95
+ );
96
+ }
97
+
98
+ function focusRow(id: string | undefined): void {
99
+ if (id === undefined) return;
100
+ activeId = id;
101
+ rowElement(id)?.focus();
102
+ }
103
+
104
+ function setOpen(node: TreeNode<T>, open: boolean): void {
105
+ if (!isBranch(node)) return;
106
+ const next = new Set(expanded);
107
+ if (open) next.add(node.id);
108
+ else next.delete(node.id);
109
+ expanded = [...next];
110
+ }
111
+
112
+ function toggleOpen(node: TreeNode<T>): void {
113
+ setOpen(node, !expandedSet.has(node.id));
114
+ }
115
+
116
+ function activate(node: TreeNode<T>): void {
117
+ if (node.disabled) return;
118
+ selected = node.id;
119
+ onactivate?.(node);
120
+ }
121
+
122
+ function toggleCheck(node: TreeNode<T>): void {
123
+ if (!checkable || node.disabled) return;
124
+ const next = new Set(checked);
125
+ const { add, remove } = toggleSubtree(node, next);
126
+ // A branch with no enabled leaf has nothing to write, and reporting a
127
+ // change that did not happen is how a consumer ends up saving a list it was
128
+ // never given.
129
+ if (add.length === 0 && remove.length === 0) return;
130
+ for (const id of add) next.add(id);
131
+ for (const id of remove) next.delete(id);
132
+ checked = [...next];
133
+ oncheck?.(checked);
134
+ }
135
+
136
+ function typeahead(key: string, fromId: string): void {
137
+ clearTimeout(typedTimer);
138
+ typedTimer = setTimeout(() => {
139
+ typed = '';
140
+ }, 500);
141
+ typed += key.toLowerCase();
142
+
143
+ const start = rows.findIndex((r) => r.node.id === fromId);
144
+ // A single character looks past the current row, so pressing it again walks
145
+ // the matches. A longer buffer includes the current row, or refining a
146
+ // search on the row it already found would jump off it.
147
+ const from = typed.length === 1 ? start + 1 : start;
148
+ for (let step = 0; step < rows.length; step++) {
149
+ const candidate = rows[(from + step + rows.length) % rows.length];
150
+ if (candidate.node.label.toLowerCase().startsWith(typed)) {
151
+ focusRow(candidate.node.id);
152
+ return;
153
+ }
154
+ }
155
+ }
156
+
157
+ function onRowClick(e: MouseEvent, node: TreeNode<T>): void {
158
+ const target = e.target as Element | null;
159
+ // Every row is nested inside its ancestors, so a click on a child bubbles
160
+ // through all of them. A row answers only for a target that is its own.
161
+ if (!target || target.closest('[role="treeitem"]') !== e.currentTarget) return;
162
+
163
+ activeId = node.id;
164
+ if (target.closest('[data-tree-toggle]')) {
165
+ toggleOpen(node);
166
+ return;
167
+ }
168
+ if (checkable && target.closest('[data-tree-check]')) {
169
+ toggleCheck(node);
170
+ return;
171
+ }
172
+ // A branch that does not open when its row is clicked reads as broken, so
173
+ // the row body opens it as well as activating it.
174
+ if (isBranch(node)) toggleOpen(node);
175
+ activate(node);
176
+ }
177
+
178
+ function onRowKeydown(e: KeyboardEvent, node: TreeNode<T>, parentId: string | undefined): void {
179
+ // Keydown bubbles the same way a click does, and only the focused row is
180
+ // the target.
181
+ if (e.target !== e.currentTarget) return;
182
+
183
+ const index = rows.findIndex((r) => r.node.id === node.id);
184
+ const open = expandedSet.has(node.id);
185
+
186
+ switch (e.key) {
187
+ case 'ArrowDown':
188
+ e.preventDefault();
189
+ focusRow(rows[index + 1]?.node.id);
190
+ return;
191
+ case 'ArrowUp':
192
+ e.preventDefault();
193
+ focusRow(rows[index - 1]?.node.id);
194
+ return;
195
+ case 'Home':
196
+ e.preventDefault();
197
+ focusRow(rows[0]?.node.id);
198
+ return;
199
+ case 'End':
200
+ e.preventDefault();
201
+ focusRow(rows[rows.length - 1]?.node.id);
202
+ return;
203
+ case 'ArrowRight':
204
+ e.preventDefault();
205
+ // Opening and descending are two presses. The first press has to leave
206
+ // focus where it is, or a screen reader announces the child before its
207
+ // parent has said it is now open.
208
+ if (!isBranch(node)) return;
209
+ if (!open) setOpen(node, true);
210
+ else focusRow(node.children?.[0]?.id);
211
+ return;
212
+ case 'ArrowLeft':
213
+ e.preventDefault();
214
+ if (isBranch(node) && open) setOpen(node, false);
215
+ else focusRow(parentId);
216
+ return;
217
+ case 'Enter':
218
+ e.preventDefault();
219
+ activate(node);
220
+ return;
221
+ case ' ':
222
+ // Space never reaches the typeahead below. One key that both toggles a
223
+ // check and activates a row leaves the user unable to do either on
224
+ // purpose, so a checkable tree gives Space to the check and keeps
225
+ // Enter for activation.
226
+ e.preventDefault();
227
+ if (checkable) toggleCheck(node);
228
+ else activate(node);
229
+ return;
230
+ default:
231
+ if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
232
+ e.preventDefault();
233
+ typeahead(e.key, node.id);
234
+ }
235
+ }
236
+ }
237
+ </script>
238
+
239
+ {#snippet branch(list: TreeNode<T>[], depth: number, parentId: string | undefined)}
240
+ {#each list as node, i (node.id)}
241
+ {@const open = expandedSet.has(node.id)}
242
+ {@const state = branchState(node, checkedSet)}
243
+ <!--
244
+ The focus ring is stated once, on the element that holds the role and the
245
+ focus, and outside every selected and checked branch. A ring inside the
246
+ selected ternary disappears in the state it was meant to mark, and a
247
+ selected row already carries a brand text colour that a border-only focus
248
+ state would have nothing to add to. Inset, because a tree of any size
249
+ lives in a scrolling container that would crop an outset ring.
250
+ -->
251
+ <li
252
+ role="treeitem"
253
+ data-tree-id={node.id}
254
+ tabindex={node.id === tabbableId ? 0 : -1}
255
+ aria-level={depth + 1}
256
+ aria-setsize={list.length}
257
+ aria-posinset={i + 1}
258
+ aria-expanded={isBranch(node) ? open : undefined}
259
+ aria-selected={selected === node.id}
260
+ aria-checked={checkable ? ariaChecked(state) : undefined}
261
+ aria-disabled={node.disabled ? 'true' : undefined}
262
+ class="rounded-md outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
263
+ onclick={(e) => onRowClick(e, node)}
264
+ onkeydown={(e) => onRowKeydown(e, node, parentId)}
265
+ onfocus={() => (activeId = node.id)}
266
+ >
267
+ <!--
268
+ Indentation is a style attribute reading the spacing tokens. A class
269
+ built by interpolating the depth matches no candidate in Tailwind's
270
+ scan, so no rule is generated and every row draws flush left.
271
+ -->
272
+ <div
273
+ class="flex w-full items-center gap-2 rounded-md py-1.5 pr-2 text-sm transition-colors duration-150 {rowPaint(
274
+ selected === node.id,
275
+ !!node.disabled,
276
+ )}"
277
+ style="padding-left: calc(var(--spacing-inline) + {depth} * var(--spacing-stack))"
278
+ >
279
+ {#if isBranch(node)}
280
+ <span
281
+ data-tree-toggle
282
+ class="flex h-4 w-4 shrink-0 items-center justify-center text-faint"
283
+ >
284
+ <ChevronRight
285
+ size={14}
286
+ class="transition-transform duration-150 {open ? 'rotate-90' : ''}"
287
+ />
288
+ </span>
289
+ {:else}
290
+ <span class="h-4 w-4 shrink-0"></span>
291
+ {/if}
292
+
293
+ {#if checkable}
294
+ <!--
295
+ A painted box rather than an input: the treeitem itself carries
296
+ aria-checked, and a focusable control inside the row would put a
297
+ second tab stop in a widget whose whole point is that it has one.
298
+ -->
299
+ <span data-tree-check class="flex shrink-0 items-center">
300
+ <span class={choiceBox('checkbox', 'sm', state === 'all', state === 'some')}>
301
+ {#if state !== 'none'}
302
+ <svg width="10" height="8" viewBox="0 0 10 8" fill="none" aria-hidden="true">
303
+ <path
304
+ d={state === 'all' ? CHOICE_MARK.check : CHOICE_MARK.mixed}
305
+ stroke="currentColor"
306
+ stroke-width="1.5"
307
+ stroke-linecap="round"
308
+ stroke-linejoin="round"
309
+ />
310
+ </svg>
311
+ {/if}
312
+ </span>
313
+ </span>
314
+ {/if}
315
+
316
+ {#if node.icon}
317
+ {@const Icon = node.icon}
318
+ <Icon size={14} class="shrink-0 text-faint" />
319
+ {/if}
320
+
321
+ <span class="truncate">{node.label}</span>
322
+
323
+ {#if node.badge !== undefined}
324
+ <span class="ml-auto shrink-0 pl-2 text-xs text-faint">{node.badge}</span>
325
+ {/if}
326
+ </div>
327
+
328
+ {#if isBranch(node) && open}
329
+ <ul role="group">
330
+ {@render branch(node.children ?? [], depth + 1, node.id)}
331
+ </ul>
332
+ {/if}
333
+ </li>
334
+ {/each}
335
+ {/snippet}
336
+
337
+ <ul bind:this={rootEl} role="tree" aria-label={label} class="text-fg select-none {klass}">
338
+ {@render branch(nodes, 0, undefined)}
339
+ </ul>
@@ -0,0 +1,37 @@
1
+ import { type TreeNode } from '../internal/tree.js';
2
+ declare function $$render<T = unknown>(): {
3
+ props: {
4
+ nodes: TreeNode<T>[];
5
+ /** Required. An unnamed tree announces as just "tree". */
6
+ label: string;
7
+ expanded?: string[];
8
+ selected?: string;
9
+ /** Checked LEAF ids only. A branch's state is always derived, never stored, so the two cannot disagree. */
10
+ checked?: string[];
11
+ checkable?: boolean;
12
+ class?: string;
13
+ onactivate?: (node: TreeNode<T>) => void;
14
+ oncheck?: (checked: string[]) => void;
15
+ };
16
+ exports: {};
17
+ bindings: "expanded" | "selected" | "checked";
18
+ slots: {};
19
+ events: {};
20
+ };
21
+ declare class __sveltets_Render<T = unknown> {
22
+ props(): ReturnType<typeof $$render<T>>['props'];
23
+ events(): ReturnType<typeof $$render<T>>['events'];
24
+ slots(): ReturnType<typeof $$render<T>>['slots'];
25
+ bindings(): "expanded" | "selected" | "checked";
26
+ exports(): {};
27
+ }
28
+ interface $$IsomorphicComponent {
29
+ new <T = unknown>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
30
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
31
+ } & ReturnType<__sveltets_Render<T>['exports']>;
32
+ <T = unknown>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
33
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
34
+ }
35
+ declare const TreeView: $$IsomorphicComponent;
36
+ type TreeView<T = unknown> = InstanceType<typeof TreeView<T>>;
37
+ export default TreeView;
@@ -9,15 +9,14 @@
9
9
  * Not meant to be used directly - use openDialog() from dialog-manager.
10
10
  */
11
11
  import { X } from '@lucide/svelte';
12
- import { onMount, tick } from 'svelte';
12
+ import { onMount } from 'svelte';
13
13
  import type { Snippet } from 'svelte';
14
14
  import type { DialogEntry } from './types.js';
15
15
  import { sizeClass } from './types.js';
16
+ import { overlay } from '../../internal/overlay.js';
16
17
  import {
17
18
  closeDialog,
18
19
  dismissDialog,
19
- _lockBodyScroll,
20
- _unlockBodyScroll,
21
20
  } from './dialog-manager.svelte';
22
21
  import ConfirmDialog from './ConfirmDialog.svelte';
23
22
 
@@ -45,45 +44,18 @@
45
44
  // ──────────────────────────────────────────────────────
46
45
 
47
46
  let dialogEl = $state<HTMLDivElement>();
48
- let previousFocus = $state<HTMLElement | null>(null);
49
- let focusableSelector =
50
- 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
51
47
 
52
48
  // ──────────────────────────────────────────────────────
53
49
  // Lifecycle
54
50
  // ──────────────────────────────────────────────────────
55
51
 
52
+ // Focus entry, the Tab trap, the body scroll lock and focus restore all come
53
+ // from `use:overlay` on the panel below, which is the same implementation
54
+ // Modal and Drawer use. This is only the enter animation.
56
55
  onMount(() => {
57
- // Save previously focused element for restore on unmount
58
- const el = document.activeElement;
59
- previousFocus = el instanceof HTMLElement ? el : null;
60
-
61
- // Trigger enter animation on next frame
62
56
  requestAnimationFrame(() => {
63
57
  visible = true;
64
58
  });
65
-
66
- // Focus trap: focus first focusable element, or the dialog itself
67
- tick().then(() => {
68
- if (dialogEl) {
69
- const focusable = dialogEl.querySelectorAll(focusableSelector);
70
- if (focusable.length > 0) {
71
- (focusable[0] as HTMLElement).focus();
72
- } else {
73
- dialogEl.focus();
74
- }
75
- }
76
- });
77
-
78
- // Lock body scroll (counter-based: only unlock when no dialogs remain)
79
- _lockBodyScroll();
80
- const unlock = () => _unlockBodyScroll();
81
-
82
- return () => {
83
- unlock();
84
- // Restore focus
85
- previousFocus?.focus?.();
86
- };
87
59
  });
88
60
 
89
61
  // ──────────────────────────────────────────────────────
@@ -104,30 +76,6 @@
104
76
  handleDismiss();
105
77
  }
106
78
  }
107
-
108
- // Focus trap
109
- if (e.key === 'Tab' && dialogEl) {
110
- const focusable = dialogEl.querySelectorAll(focusableSelector);
111
- if (focusable.length === 0) {
112
- e.preventDefault();
113
- return;
114
- }
115
-
116
- const first = focusable[0] as HTMLElement;
117
- const last = focusable[focusable.length - 1] as HTMLElement;
118
-
119
- if (e.shiftKey) {
120
- if (document.activeElement === first) {
121
- e.preventDefault();
122
- last.focus();
123
- }
124
- } else {
125
- if (document.activeElement === last) {
126
- e.preventDefault();
127
- first.focus();
128
- }
129
- }
130
- }
131
79
  }
132
80
 
133
81
  async function handleDismiss(): Promise<void> {
@@ -145,7 +93,15 @@
145
93
  </script>
146
94
 
147
95
  <!-- svelte-ignore a11y_no_static_element_interactions -->
148
- <div class="fixed inset-0 z-[{zIndex}] flex items-center justify-center" role="presentation">
96
+ <!-- z-index is an inline style, not a `z-[...]` class. Tailwind scans source text
97
+ for complete class names, so a class built from a runtime value matches no
98
+ candidate and no rule is ever generated: every stacked dialog rendered at
99
+ `z-index: auto` and the stacking order came down to DOM order. -->
100
+ <div
101
+ class="fixed inset-0 flex items-center justify-center"
102
+ style="z-index: {zIndex}"
103
+ role="presentation"
104
+ >
149
105
  <!-- Backdrop -->
150
106
  <!-- svelte-ignore a11y_no_static_element_interactions -->
151
107
  <div
@@ -163,6 +119,7 @@
163
119
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
164
120
  <div
165
121
  bind:this={dialogEl}
122
+ use:overlay
166
123
  class="relative w-full {sizeClass(entry.options.size ?? 'md')} mx-4
167
124
  bg-surface border border-line rounded-xl shadow-2xl
168
125
  transition-all duration-200 ease-out
@@ -15,8 +15,8 @@
15
15
  * if (ok) { /* ... *\/ }
16
16
  */
17
17
  import type { DialogOptions, DialogEntry } from './types.js';
18
- export declare function _lockBodyScroll(): void;
19
- export declare function _unlockBodyScroll(): void;
18
+ import { lockBodyScroll, unlockBodyScroll } from '../../internal/overlay.js';
19
+ export { lockBodyScroll as _lockBodyScroll, unlockBodyScroll as _unlockBodyScroll };
20
20
  /** Readonly snapshot for components */
21
21
  export declare function getDialogStack(): readonly DialogEntry<any>[];
22
22
  /**
@@ -14,6 +14,7 @@
14
14
  * const ok = await confirm('Delete this item?', 'This action cannot be undone.');
15
15
  * if (ok) { /* ... *\/ }
16
16
  */
17
+ import { lockBodyScroll, unlockBodyScroll } from '../../internal/overlay.js';
17
18
  // ──────────────────────────────────────────────────────────
18
19
  // Module-level reactive state
19
20
  // ──────────────────────────────────────────────────────────
@@ -23,22 +24,13 @@ let stack = $state([]);
23
24
  // ──────────────────────────────────────────────────────────
24
25
  // Body scroll lock (counter-based - handles stacked dialogs)
25
26
  // ──────────────────────────────────────────────────────────
26
- let bodyLockCount = 0;
27
- export function _lockBodyScroll() {
28
- bodyLockCount++;
29
- if (typeof document !== 'undefined') {
30
- document.body.style.overflow = 'hidden';
31
- }
32
- }
33
- export function _unlockBodyScroll() {
34
- bodyLockCount--;
35
- if (bodyLockCount <= 0) {
36
- bodyLockCount = 0;
37
- if (typeof document !== 'undefined') {
38
- document.body.style.overflow = '';
39
- }
40
- }
41
- }
27
+ /*
28
+ * The count lives in internal/overlay.ts, which is also what Modal and Drawer
29
+ * lock through. Two counters meant a Dialog opened over a Drawer restored
30
+ * scrolling to the page as soon as either one closed, because each believed it
31
+ * held the only lock.
32
+ */
33
+ export { lockBodyScroll as _lockBodyScroll, unlockBodyScroll as _unlockBodyScroll };
42
34
  /** Readonly snapshot for components */
43
35
  export function getDialogStack() {
44
36
  return stack;
@@ -132,10 +124,9 @@ export function dismissDialog(id) {
132
124
  export function dismissAllDialogs() {
133
125
  const copy = [...stack];
134
126
  stack = [];
135
- bodyLockCount = 0;
136
- if (typeof document !== 'undefined') {
137
- document.body.style.overflow = '';
138
- }
127
+ // The manager never locks. Each overlay takes one lock on mount and releases
128
+ // it on unmount, so clearing the stack unmounts the dialogs and the count
129
+ // unwinds itself. Zeroing it here released locks a Modal or Drawer still held.
139
130
  for (const entry of copy) {
140
131
  entry.reject(new DOMException('All dialogs dismissed', 'AbortError'));
141
132
  }
@@ -150,7 +141,16 @@ export function confirm(title, message) {
150
141
  const id = `confirm-${++idCounter}`;
151
142
  const promise = openDialog({ id, size: 'sm', title });
152
143
  setDialogMeta(id, { confirmTitle: title, confirmMessage: message ?? '' });
153
- return promise;
144
+ // Cancelling a dialog dismisses it, and dismissal rejects. Callers write
145
+ // `if (await confirm(...))`, so a rejection on Cancel is an unhandled
146
+ // rejection on the ordinary path rather than an error anyone meant to
147
+ // handle. Cancel is an answer, not a failure: it resolves false, which is
148
+ // what this function has always documented.
149
+ return promise.catch((err) => {
150
+ if (err instanceof DOMException && err.name === 'AbortError')
151
+ return false;
152
+ throw err;
153
+ });
154
154
  }
155
155
  /**
156
156
  * Set metadata on a dialog entry after it's opened.