@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.
- package/README.md +1 -1
- package/dist/components/AccordionItem.svelte +1 -1
- package/dist/components/Autocomplete.svelte +191 -125
- package/dist/components/Autocomplete.svelte.d.ts +29 -8
- package/dist/components/Button.svelte +26 -4
- package/dist/components/Card.svelte +61 -3
- package/dist/components/Card.svelte.d.ts +24 -2
- package/dist/components/Checkbox.svelte +174 -59
- package/dist/components/Checkbox.svelte.d.ts +20 -3
- package/dist/components/CheckboxGroup.svelte +162 -0
- package/dist/components/CheckboxGroup.svelte.d.ts +51 -0
- package/dist/components/Collapsible.svelte +142 -0
- package/dist/components/Collapsible.svelte.d.ts +32 -0
- package/dist/components/CopyButton.svelte +126 -0
- package/dist/components/CopyButton.svelte.d.ts +14 -0
- package/dist/components/DatePicker.svelte +48 -6
- package/dist/components/DateTimePicker.svelte +337 -0
- package/dist/components/DateTimePicker.svelte.d.ts +26 -0
- package/dist/components/DescriptionList.svelte +78 -0
- package/dist/components/DescriptionList.svelte.d.ts +34 -0
- package/dist/components/Drawer.svelte +15 -4
- package/dist/components/Field.svelte +104 -0
- package/dist/components/Field.svelte.d.ts +46 -0
- package/dist/components/FileInput.svelte +5 -2
- package/dist/components/FormMessage.svelte +85 -0
- package/dist/components/FormMessage.svelte.d.ts +11 -0
- package/dist/components/Input.svelte +1 -1
- package/dist/components/Label.svelte +7 -1
- package/dist/components/Label.svelte.d.ts +6 -0
- package/dist/components/Modal.svelte +25 -8
- package/dist/components/MultiSelect.svelte +199 -109
- package/dist/components/MultiSelect.svelte.d.ts +22 -9
- package/dist/components/NumberInput.svelte +8 -4
- package/dist/components/PageHeader.svelte +37 -4
- package/dist/components/PageHeader.svelte.d.ts +15 -0
- package/dist/components/PageShell.svelte +85 -0
- package/dist/components/PageShell.svelte.d.ts +38 -0
- package/dist/components/Pagination.svelte +58 -17
- package/dist/components/Panel.svelte +101 -0
- package/dist/components/Panel.svelte.d.ts +39 -0
- package/dist/components/PasswordInput.svelte +139 -0
- package/dist/components/PasswordInput.svelte.d.ts +29 -0
- package/dist/components/Radio.svelte +152 -32
- package/dist/components/Radio.svelte.d.ts +16 -1
- package/dist/components/RadioGroup.svelte +118 -71
- package/dist/components/RadioGroup.svelte.d.ts +39 -9
- package/dist/components/SectionHeading.svelte +39 -0
- package/dist/components/SectionHeading.svelte.d.ts +21 -0
- package/dist/components/SegmentedControl.svelte +194 -0
- package/dist/components/SegmentedControl.svelte.d.ts +55 -0
- package/dist/components/Select.svelte +471 -46
- package/dist/components/Select.svelte.d.ts +95 -6
- package/dist/components/SidebarNav.svelte +259 -0
- package/dist/components/SidebarNav.svelte.d.ts +17 -0
- package/dist/components/Stat.svelte +53 -2
- package/dist/components/Stat.svelte.d.ts +31 -0
- package/dist/components/Textarea.svelte +1 -1
- package/dist/components/TimePicker.svelte +480 -0
- package/dist/components/TimePicker.svelte.d.ts +23 -0
- package/dist/components/Toaster.svelte +9 -2
- package/dist/components/Toggle.svelte +5 -1
- package/dist/components/Toggle.svelte.d.ts +2 -0
- package/dist/components/Toolbar.svelte +39 -0
- package/dist/components/Toolbar.svelte.d.ts +26 -0
- package/dist/components/Tooltip.svelte +48 -12
- package/dist/components/TreeView.svelte +339 -0
- package/dist/components/TreeView.svelte.d.ts +37 -0
- package/dist/components/dialog/Dialog.svelte +15 -58
- package/dist/components/dialog/dialog-manager.svelte.d.ts +2 -2
- package/dist/components/dialog/dialog-manager.svelte.js +21 -21
- package/dist/index.d.ts +25 -1
- package/dist/index.js +20 -1
- package/dist/internal/calendar.d.ts +119 -0
- package/dist/internal/calendar.js +225 -0
- package/dist/internal/choice.d.ts +136 -0
- package/dist/internal/choice.js +179 -0
- package/dist/internal/field.d.ts +31 -0
- package/dist/internal/field.js +42 -1
- package/dist/internal/filter.d.ts +80 -0
- package/dist/internal/filter.js +80 -0
- package/dist/internal/layout.d.ts +119 -0
- package/dist/internal/layout.js +132 -0
- package/dist/internal/listbox.svelte.d.ts +77 -0
- package/dist/internal/listbox.svelte.js +438 -0
- package/dist/internal/nav-expansion.svelte.d.ts +36 -0
- package/dist/internal/nav-expansion.svelte.js +144 -0
- package/dist/internal/nav-tree.d.ts +68 -0
- package/dist/internal/nav-tree.js +102 -0
- package/dist/internal/overlay.d.ts +25 -0
- package/dist/internal/overlay.js +92 -0
- package/dist/internal/panel.d.ts +100 -0
- package/dist/internal/panel.js +109 -0
- package/dist/internal/rollup.d.ts +52 -0
- package/dist/internal/rollup.js +67 -0
- package/dist/internal/time.d.ts +103 -0
- package/dist/internal/time.js +166 -0
- package/dist/internal/tree.d.ts +86 -0
- package/dist/internal/tree.js +111 -0
- package/dist/styles/theme.css +66 -25
- package/package.json +4 -2
- 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
|
-
|
|
31
|
-
|
|
62
|
+
onfocusin={() => (visible = true)}
|
|
63
|
+
onfocusout={() => (visible = false)}
|
|
64
|
+
{onkeydown}
|
|
32
65
|
>
|
|
33
66
|
{@render children()}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
export
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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.
|