@lyeve-labs/ui-kit 0.12.1 → 0.13.1
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/dist/components/Autocomplete.svelte +191 -125
- package/dist/components/Autocomplete.svelte.d.ts +29 -8
- package/dist/components/Card.svelte +43 -2
- package/dist/components/Card.svelte.d.ts +24 -2
- package/dist/components/Checkbox.svelte +174 -63
- 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/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/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 +43 -7
- 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 -35
- 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 +264 -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/Toolbar.svelte +39 -0
- package/dist/components/Toolbar.svelte.d.ts +26 -0
- package/dist/components/TreeView.svelte +339 -0
- package/dist/components/TreeView.svelte.d.ts +37 -0
- 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 +39 -0
- package/dist/internal/field.js +48 -0
- 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/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 +18 -10
- package/package.json +4 -2
- package/src/lib/styles/theme.css +18 -10
|
@@ -1,5 +1,71 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Component } from 'svelte';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The event a native select hands its change handler.
|
|
6
|
+
*
|
|
7
|
+
* Frozen, and it stays frozen. Every call site in the estate passes an
|
|
8
|
+
* unannotated arrow whose parameter is contextually typed from this prop, and
|
|
9
|
+
* several of them read `e.currentTarget.value` or call
|
|
10
|
+
* `e.currentTarget.form.requestSubmit()`. Retyping the callback to take a
|
|
11
|
+
* plain value would fail all of them under strict mode at once, so the
|
|
12
|
+
* value-shaped callback is a second prop rather than a new spelling of this
|
|
13
|
+
* one.
|
|
14
|
+
*/
|
|
15
|
+
export type SelectChangeEvent = Event & { currentTarget: HTMLSelectElement };
|
|
16
|
+
|
|
17
|
+
/** One row of the option list, in either mode. */
|
|
18
|
+
export interface SelectOption {
|
|
19
|
+
/** The submitted value, and the key the row is rendered under. */
|
|
20
|
+
value: string;
|
|
21
|
+
/** What the user reads. */
|
|
22
|
+
label: string;
|
|
23
|
+
/** Blocks this row alone. A disabled control blocks every row. */
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Drawn before the label. Listbox mode only: a native option element holds
|
|
27
|
+
* text and nothing else, so native mode drops it rather than standing in a
|
|
28
|
+
* Unicode character that would render at whatever weight the reader's font
|
|
29
|
+
* gives it.
|
|
30
|
+
*/
|
|
31
|
+
icon?: Component<{ size?: number; class?: string }>;
|
|
32
|
+
/**
|
|
33
|
+
* Extra text the default matcher searches, so a machine name finds a row
|
|
34
|
+
* that displays under a friendlier one.
|
|
35
|
+
*/
|
|
36
|
+
keywords?: string[];
|
|
37
|
+
/**
|
|
38
|
+
* Groups rows under a heading. A group is a run of neighbouring rows that
|
|
39
|
+
* name it, in both modes, because optgroup nests and cannot describe an
|
|
40
|
+
* interleaved list either. A caller who interleaves two groups gets the
|
|
41
|
+
* heading twice, which is what the array says.
|
|
42
|
+
*/
|
|
43
|
+
group?: string;
|
|
44
|
+
}
|
|
45
|
+
</script>
|
|
46
|
+
|
|
1
47
|
<script lang="ts">
|
|
48
|
+
/**
|
|
49
|
+
* A select in four kinds: a native one, a native one driven by an array, a
|
|
50
|
+
* custom listbox that can search and carry icons, and a listbox behind a
|
|
51
|
+
* trigger of the caller's own.
|
|
52
|
+
*
|
|
53
|
+
* The native element is the default and stays the default. Most call sites
|
|
54
|
+
* sit inside a form and pass `name`, and a custom listbox is a button, which
|
|
55
|
+
* serializes nothing; one call site submits its form from the change event's
|
|
56
|
+
* `currentTarget.form`, which only a form-associated element carries. So the
|
|
57
|
+
* mode is never inferred, not from `options` and not from `searchable`: a
|
|
58
|
+
* page inside a form opts in to the listbox deliberately or keeps a real
|
|
59
|
+
* select.
|
|
60
|
+
*
|
|
61
|
+
* Listbox mode owns nothing of its own behaviour. The open state, the active
|
|
62
|
+
* row, the keyboard model and the dismissal come from internal/listbox, the
|
|
63
|
+
* matching from internal/filter and every class in the panel from
|
|
64
|
+
* internal/panel, so this control cannot drift away from the other lists in
|
|
65
|
+
* the library the way the four hand-rolled ones drifted from each other.
|
|
66
|
+
*/
|
|
2
67
|
import type { Snippet } from 'svelte';
|
|
68
|
+
import { applyFilter, type FilterInput } from '../internal/filter.js';
|
|
3
69
|
import {
|
|
4
70
|
CONTROL_BASE,
|
|
5
71
|
FIELD_ERROR,
|
|
@@ -9,11 +75,54 @@
|
|
|
9
75
|
controlBorder,
|
|
10
76
|
describedBy,
|
|
11
77
|
} from '../internal/field.js';
|
|
78
|
+
import { createListbox } from '../internal/listbox.svelte.js';
|
|
79
|
+
import {
|
|
80
|
+
PANEL_EMPTY,
|
|
81
|
+
PANEL_GROUP_LABEL,
|
|
82
|
+
PANEL_LIST,
|
|
83
|
+
PANEL_SURFACE,
|
|
84
|
+
panelOption,
|
|
85
|
+
} from '../internal/panel.js';
|
|
12
86
|
|
|
13
|
-
|
|
87
|
+
interface Props {
|
|
88
|
+
/** The selected value. Bindable, so a listbox pick reaches the caller without a callback. */
|
|
89
|
+
value?: string | null;
|
|
90
|
+
id?: string;
|
|
91
|
+
name?: string;
|
|
92
|
+
label?: string;
|
|
93
|
+
hint?: string;
|
|
94
|
+
required?: boolean;
|
|
95
|
+
disabled?: boolean;
|
|
96
|
+
error?: string;
|
|
97
|
+
class?: string;
|
|
98
|
+
/** Native mode only. Its signature is frozen; see SelectChangeEvent. */
|
|
99
|
+
onchange?: (e: SelectChangeEvent) => void;
|
|
100
|
+
/** Option elements, written by hand. Native mode only. */
|
|
101
|
+
children?: Snippet;
|
|
102
|
+
/**
|
|
103
|
+
* 'native' renders a real select. 'listbox' renders the custom panel.
|
|
104
|
+
* Never inferred: see the note above.
|
|
105
|
+
*/
|
|
106
|
+
mode?: 'native' | 'listbox';
|
|
107
|
+
/** The rows, as data. Renders as option elements in native mode. */
|
|
108
|
+
options?: SelectOption[];
|
|
109
|
+
/** Listbox mode. Adds a search field inside the panel. */
|
|
110
|
+
searchable?: boolean;
|
|
111
|
+
/**
|
|
112
|
+
* Replaces the default matcher, or false to switch local filtering off
|
|
113
|
+
* because the list arrived already narrowed, by a server query for instance.
|
|
114
|
+
*/
|
|
115
|
+
filter?: FilterInput<SelectOption>;
|
|
116
|
+
/** Fills the trigger in listbox mode. Receives the selected row and the open state. */
|
|
117
|
+
trigger?: Snippet<[{ selected: SelectOption | undefined; open: boolean }]>;
|
|
118
|
+
/** Shown when nothing is selected. In native mode it renders as a leading empty row. */
|
|
119
|
+
placeholder?: string;
|
|
120
|
+
/** Value-shaped and additive, so the event signature above stays frozen. Fires in both modes. */
|
|
121
|
+
onvaluechange?: (value: string) => void;
|
|
122
|
+
}
|
|
14
123
|
|
|
15
124
|
let {
|
|
16
|
-
value,
|
|
125
|
+
value = $bindable(null),
|
|
17
126
|
id,
|
|
18
127
|
name,
|
|
19
128
|
label,
|
|
@@ -24,63 +133,379 @@
|
|
|
24
133
|
class: cls = '',
|
|
25
134
|
onchange,
|
|
26
135
|
children,
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
error?: string;
|
|
36
|
-
class?: string;
|
|
37
|
-
onchange?: (e: SE) => void;
|
|
38
|
-
children?: Snippet;
|
|
39
|
-
} = $props();
|
|
136
|
+
mode = 'native',
|
|
137
|
+
options = [],
|
|
138
|
+
searchable = false,
|
|
139
|
+
filter,
|
|
140
|
+
trigger,
|
|
141
|
+
placeholder,
|
|
142
|
+
onvaluechange,
|
|
143
|
+
}: Props = $props();
|
|
40
144
|
|
|
41
|
-
|
|
42
|
-
|
|
145
|
+
/*
|
|
146
|
+
* $props.id() and not Math.random(): a random id differs between the server
|
|
147
|
+
* render and hydration, and both the label association and every idref the
|
|
148
|
+
* listbox emits are built from this one.
|
|
149
|
+
*
|
|
150
|
+
* The label-derived id stays for a control that has a label, because call
|
|
151
|
+
* sites point their own markup at it. The fallback used to be undefined,
|
|
152
|
+
* which left an error message with no id and the control with no
|
|
153
|
+
* aria-describedby, so an unlabelled field announced its value and never the
|
|
154
|
+
* reason it was rejected.
|
|
155
|
+
*/
|
|
156
|
+
const uid = $props.id();
|
|
157
|
+
const fieldId = $derived(id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : uid));
|
|
43
158
|
|
|
44
|
-
|
|
45
|
-
{#if label}
|
|
46
|
-
<label for={fieldId} class={FIELD_LABEL}>
|
|
47
|
-
{label}{#if required}<span class="text-danger ml-0.5" aria-label="required">*</span>{/if}
|
|
48
|
-
</label>
|
|
49
|
-
{/if}
|
|
159
|
+
const listboxMode = $derived(mode === 'listbox');
|
|
50
160
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
161
|
+
let query = $state('');
|
|
162
|
+
let triggerEl = $state<HTMLButtonElement | undefined>();
|
|
163
|
+
let searchEl = $state<HTMLInputElement | undefined>();
|
|
164
|
+
|
|
165
|
+
/** The rows the panel is showing. Native mode never filters, so it never narrows. */
|
|
166
|
+
const rows = $derived(listboxMode ? applyFilter(options, query, filter) : options);
|
|
167
|
+
const selected = $derived(options.find((option) => option.value === value));
|
|
168
|
+
|
|
169
|
+
interface Row {
|
|
170
|
+
option: SelectOption;
|
|
171
|
+
/** Position in `rows`, which is what the listbox indexes by. */
|
|
172
|
+
index: number;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface Block {
|
|
176
|
+
group: string | undefined;
|
|
177
|
+
rows: Row[];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Splits the list into runs of rows that share a group.
|
|
182
|
+
*
|
|
183
|
+
* Runs rather than a bucket per name, so both modes group identically: an
|
|
184
|
+
* optgroup nests, so a native select cannot show one group in two places
|
|
185
|
+
* either, and a caller whose array interleaves gets the same answer from both.
|
|
186
|
+
*/
|
|
187
|
+
function groupRuns(list: readonly SelectOption[]): Block[] {
|
|
188
|
+
const blocks: Block[] = [];
|
|
189
|
+
list.forEach((option, index) => {
|
|
190
|
+
const open = blocks[blocks.length - 1];
|
|
191
|
+
if (open !== undefined && open.group === option.group) open.rows.push({ option, index });
|
|
192
|
+
else blocks.push({ group: option.group, rows: [{ option, index }] });
|
|
193
|
+
});
|
|
194
|
+
return blocks;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const blocks = $derived(groupRuns(rows));
|
|
198
|
+
|
|
199
|
+
const box = createListbox<SelectOption>({
|
|
200
|
+
items: () => rows,
|
|
201
|
+
baseId: () => uid,
|
|
202
|
+
onSelect: (option) => choose(option),
|
|
203
|
+
// A search box makes letters query text, so the two typeaheads cannot both
|
|
204
|
+
// own them.
|
|
205
|
+
typeahead: () => !searchable,
|
|
206
|
+
// Left off, so the ends of the list are where a native select puts them.
|
|
207
|
+
onClose: () => {
|
|
208
|
+
query = '';
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const anchor = box.anchor;
|
|
213
|
+
const panel = box.panel;
|
|
214
|
+
|
|
215
|
+
function choose(option: SelectOption): void {
|
|
216
|
+
if (option.disabled === true) return;
|
|
217
|
+
value = option.value;
|
|
218
|
+
onvaluechange?.(option.value);
|
|
219
|
+
// The factory leaves the list open, because a multi-value control collects
|
|
220
|
+
// several picks in one pass. This one takes a single value.
|
|
221
|
+
box.close('select');
|
|
222
|
+
// The row that was clicked is about to be unmounted, so focus has somewhere
|
|
223
|
+
// to be returned to or it falls to the body.
|
|
224
|
+
triggerEl?.focus();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function nativeChange(event: SelectChangeEvent): void {
|
|
228
|
+
value = event.currentTarget.value;
|
|
229
|
+
// Called synchronously and with the event untouched, so currentTarget is
|
|
230
|
+
// still the select and a call site can submit the form from it.
|
|
231
|
+
onchange?.(event);
|
|
232
|
+
onvaluechange?.(event.currentTarget.value);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function search(event: Event & { currentTarget: HTMLInputElement }): void {
|
|
236
|
+
query = event.currentTarget.value;
|
|
237
|
+
// The keyboard must not rest on a row the new query pushed out from under
|
|
238
|
+
// it: aria-activedescendant would name a different option than the one the
|
|
239
|
+
// ring is drawn on.
|
|
240
|
+
box.setActive(rows.findIndex((option) => option.disabled !== true));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
$effect(() => {
|
|
244
|
+
if (box.open && searchEl !== undefined) searchEl.focus();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
let warned = false;
|
|
248
|
+
|
|
249
|
+
/*
|
|
250
|
+
* A listbox-only prop passed to a native select is a caller mistake with
|
|
251
|
+
* three possible answers, and only one of them is safe.
|
|
252
|
+
*
|
|
253
|
+
* Throwing turns a cosmetic slip into a blank page in a form the user was
|
|
254
|
+
* halfway through. Upgrading the mode is worse than it looks: it swaps a
|
|
255
|
+
* form-associated element for a button, so the change event stops firing and
|
|
256
|
+
* the call sites that submit their form from it go quiet with no error
|
|
257
|
+
* anywhere. Warning leaves the page working as the native select it asked
|
|
258
|
+
* for and puts the mistake where the developer will read it. Once per
|
|
259
|
+
* instance, and from an effect, so a server render does not repeat it into
|
|
260
|
+
* the log on every request.
|
|
261
|
+
*/
|
|
262
|
+
$effect(() => {
|
|
263
|
+
if (listboxMode || warned) return;
|
|
264
|
+
const ignored = [searchable ? 'searchable' : '', trigger ? 'trigger' : ''].filter(
|
|
265
|
+
(part) => part !== '',
|
|
266
|
+
);
|
|
267
|
+
if (ignored.length === 0) return;
|
|
268
|
+
warned = true;
|
|
269
|
+
for (const prop of ignored) {
|
|
270
|
+
console.warn(`Select: the ${prop} prop applies to mode="listbox" and was ignored.`);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
</script>
|
|
274
|
+
|
|
275
|
+
{#snippet chevron(open: boolean)}
|
|
276
|
+
<span
|
|
277
|
+
class="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-faint"
|
|
278
|
+
aria-hidden="true"
|
|
279
|
+
>
|
|
280
|
+
<svg
|
|
281
|
+
width="12"
|
|
282
|
+
height="12"
|
|
283
|
+
viewBox="0 0 12 12"
|
|
284
|
+
fill="none"
|
|
285
|
+
class="transition-transform duration-150 {open ? 'rotate-180' : ''}"
|
|
68
286
|
>
|
|
69
|
-
<
|
|
287
|
+
<path
|
|
288
|
+
d="M2 4l4 4 4-4"
|
|
289
|
+
stroke="currentColor"
|
|
290
|
+
stroke-width="1.5"
|
|
291
|
+
stroke-linecap="round"
|
|
292
|
+
stroke-linejoin="round"
|
|
293
|
+
/>
|
|
294
|
+
</svg>
|
|
295
|
+
</span>
|
|
296
|
+
{/snippet}
|
|
297
|
+
|
|
298
|
+
{#snippet optionRow(row: Row)}
|
|
299
|
+
{@const option = row.option}
|
|
300
|
+
{@const isSelected = option.value === value}
|
|
301
|
+
<!--
|
|
302
|
+
role is stated as well as spread. The compiler checks aria-selected against
|
|
303
|
+
the role it can see in the source, and it cannot see into a spread, so
|
|
304
|
+
without this the row reads to it as a plain button carrying an attribute
|
|
305
|
+
buttons do not take. The spread sets the same value.
|
|
306
|
+
-->
|
|
307
|
+
<button
|
|
308
|
+
type="button"
|
|
309
|
+
role="option"
|
|
310
|
+
onclick={() => choose(option)}
|
|
311
|
+
onmouseenter={() => box.setActive(row.index)}
|
|
312
|
+
aria-selected={isSelected ? 'true' : 'false'}
|
|
313
|
+
{...box.optionAttrs(row.index)}
|
|
314
|
+
class={panelOption({
|
|
315
|
+
active: box.activeIndex === row.index,
|
|
316
|
+
selected: isSelected,
|
|
317
|
+
disabled: option.disabled === true,
|
|
318
|
+
})}
|
|
319
|
+
>
|
|
320
|
+
{#if option.icon}
|
|
321
|
+
{@const Icon = option.icon}
|
|
322
|
+
<Icon size={16} class="shrink-0" />
|
|
323
|
+
{/if}
|
|
324
|
+
<span class="min-w-0 truncate">{option.label}</span>
|
|
325
|
+
{#if isSelected}
|
|
326
|
+
<svg
|
|
327
|
+
width="12"
|
|
328
|
+
height="12"
|
|
329
|
+
viewBox="0 0 12 12"
|
|
330
|
+
fill="none"
|
|
331
|
+
class="ml-auto shrink-0"
|
|
332
|
+
aria-hidden="true"
|
|
333
|
+
>
|
|
70
334
|
<path
|
|
71
|
-
d="M2
|
|
335
|
+
d="M2 6.5l2.5 2.5L10 3"
|
|
72
336
|
stroke="currentColor"
|
|
73
337
|
stroke-width="1.5"
|
|
74
338
|
stroke-linecap="round"
|
|
75
339
|
stroke-linejoin="round"
|
|
76
340
|
/>
|
|
77
341
|
</svg>
|
|
78
|
-
|
|
79
|
-
</
|
|
342
|
+
{/if}
|
|
343
|
+
</button>
|
|
344
|
+
{/snippet}
|
|
345
|
+
|
|
346
|
+
<div class="{FIELD_WRAP} {cls}">
|
|
347
|
+
{#if label}
|
|
348
|
+
<label for={fieldId} class={FIELD_LABEL}>
|
|
349
|
+
{label}{#if required}<span class="text-danger ml-0.5" aria-hidden="true">*</span>{/if}
|
|
350
|
+
</label>
|
|
351
|
+
{/if}
|
|
352
|
+
|
|
353
|
+
{#if listboxMode}
|
|
354
|
+
<div class="relative" use:anchor>
|
|
355
|
+
<!--
|
|
356
|
+
The trigger is a button, so the snippet fills it rather than replacing
|
|
357
|
+
it: a caller-supplied element would have to carry the id, the label
|
|
358
|
+
association and four aria attributes itself, and a button nested inside
|
|
359
|
+
another button is not valid markup a browser will focus.
|
|
360
|
+
-->
|
|
361
|
+
<!--
|
|
362
|
+
role="combobox" is the select-only combobox pattern, and it is also
|
|
363
|
+
what carries aria-invalid and aria-required: a bare button takes
|
|
364
|
+
neither, so the control could not report its own validity. The role and
|
|
365
|
+
the expanded state are stated here as well as spread, because the
|
|
366
|
+
compiler checks both against the source it can read and it cannot read
|
|
367
|
+
a spread. The spread sets the same expanded value.
|
|
368
|
+
-->
|
|
369
|
+
<button
|
|
370
|
+
bind:this={triggerEl}
|
|
371
|
+
type="button"
|
|
372
|
+
role="combobox"
|
|
373
|
+
aria-expanded={box.open}
|
|
374
|
+
id={fieldId}
|
|
375
|
+
{disabled}
|
|
376
|
+
onclick={() => box.toggle()}
|
|
377
|
+
onkeydown={(event) => {
|
|
378
|
+
box.onkeydown(event);
|
|
379
|
+
}}
|
|
380
|
+
aria-invalid={error ? 'true' : undefined}
|
|
381
|
+
aria-required={required ? 'true' : undefined}
|
|
382
|
+
aria-describedby={describedBy(fieldId, error, hint)}
|
|
383
|
+
{...box.triggerAttrs}
|
|
384
|
+
class="{CONTROL_BASE} {controlBorder(!!error)} flex cursor-pointer items-center gap-2 pr-8
|
|
385
|
+
text-left"
|
|
386
|
+
>
|
|
387
|
+
{#if trigger}
|
|
388
|
+
{@render trigger({ selected, open: box.open })}
|
|
389
|
+
{:else if selected}
|
|
390
|
+
{#if selected.icon}
|
|
391
|
+
{@const Icon = selected.icon}
|
|
392
|
+
<Icon size={16} class="shrink-0" />
|
|
393
|
+
{/if}
|
|
394
|
+
<span class="min-w-0 truncate">{selected.label}</span>
|
|
395
|
+
{:else}
|
|
396
|
+
<span class="min-w-0 truncate text-faint">{placeholder ?? ''}</span>
|
|
397
|
+
{/if}
|
|
398
|
+
</button>
|
|
399
|
+
{@render chevron(box.open)}
|
|
400
|
+
|
|
401
|
+
<!--
|
|
402
|
+
The value the form actually submits. The visible control is a button,
|
|
403
|
+
which serializes nothing, and a hidden input is barred from constraint
|
|
404
|
+
validation, so `required` is announced through aria-required and
|
|
405
|
+
enforced by the caller rather than by the browser.
|
|
406
|
+
-->
|
|
407
|
+
<input type="hidden" {name} {disabled} value={value ?? ''} />
|
|
408
|
+
|
|
409
|
+
{#if box.open}
|
|
410
|
+
<div class="{PANEL_SURFACE} w-full">
|
|
411
|
+
{#if searchable}
|
|
412
|
+
<div class="border-b border-line p-2">
|
|
413
|
+
<!--
|
|
414
|
+
The second combobox over the same list, and it earns the role:
|
|
415
|
+
the trigger has to announce collapsed while it is closed, and
|
|
416
|
+
this box has to announce the active row while it holds focus.
|
|
417
|
+
It is rendered only while the panel is open, so its expanded
|
|
418
|
+
state is a constant.
|
|
419
|
+
-->
|
|
420
|
+
<input
|
|
421
|
+
bind:this={searchEl}
|
|
422
|
+
type="text"
|
|
423
|
+
role="combobox"
|
|
424
|
+
aria-expanded="true"
|
|
425
|
+
value={query}
|
|
426
|
+
oninput={search}
|
|
427
|
+
onkeydown={(event) => {
|
|
428
|
+
box.onkeydown(event);
|
|
429
|
+
}}
|
|
430
|
+
placeholder="Search"
|
|
431
|
+
aria-label={label ? `Search ${label}` : 'Search options'}
|
|
432
|
+
{...box.triggerAttrs}
|
|
433
|
+
class="w-full rounded-md border border-line-strong bg-surface-2 px-2.5 py-1.5
|
|
434
|
+
text-sm text-fg outline-none transition-colors duration-150
|
|
435
|
+
placeholder:text-faint focus:border-brand"
|
|
436
|
+
/>
|
|
437
|
+
</div>
|
|
438
|
+
{/if}
|
|
439
|
+
|
|
440
|
+
<div class={PANEL_LIST} use:panel {...box.listAttrs}>
|
|
441
|
+
{#each blocks as block, position (position)}
|
|
442
|
+
{#if block.group !== undefined}
|
|
443
|
+
<!-- Named once, on the group. The heading repeats it on screen. -->
|
|
444
|
+
<div role="group" aria-label={block.group}>
|
|
445
|
+
<div class={PANEL_GROUP_LABEL} aria-hidden="true">{block.group}</div>
|
|
446
|
+
{#each block.rows as row (row.option.value)}
|
|
447
|
+
{@render optionRow(row)}
|
|
448
|
+
{/each}
|
|
449
|
+
</div>
|
|
450
|
+
{:else}
|
|
451
|
+
{#each block.rows as row (row.option.value)}
|
|
452
|
+
{@render optionRow(row)}
|
|
453
|
+
{/each}
|
|
454
|
+
{/if}
|
|
455
|
+
{/each}
|
|
456
|
+
|
|
457
|
+
{#if rows.length === 0}
|
|
458
|
+
<p class={PANEL_EMPTY}>No matches</p>
|
|
459
|
+
{/if}
|
|
460
|
+
</div>
|
|
461
|
+
</div>
|
|
462
|
+
{/if}
|
|
463
|
+
</div>
|
|
464
|
+
{:else}
|
|
465
|
+
<div class="relative">
|
|
466
|
+
<select
|
|
467
|
+
id={fieldId}
|
|
468
|
+
{name}
|
|
469
|
+
{required}
|
|
470
|
+
{disabled}
|
|
471
|
+
value={value ?? ''}
|
|
472
|
+
onchange={nativeChange}
|
|
473
|
+
aria-invalid={error ? 'true' : undefined}
|
|
474
|
+
aria-describedby={describedBy(fieldId, error, hint)}
|
|
475
|
+
class="{CONTROL_BASE} {controlBorder(!!error)} cursor-pointer appearance-none pr-8"
|
|
476
|
+
>
|
|
477
|
+
{#if children}
|
|
478
|
+
{@render children()}
|
|
479
|
+
{:else}
|
|
480
|
+
{#if placeholder}
|
|
481
|
+
<option value="">{placeholder}</option>
|
|
482
|
+
{/if}
|
|
483
|
+
{#each blocks as block, position (position)}
|
|
484
|
+
{#if block.group !== undefined}
|
|
485
|
+
<optgroup label={block.group}>
|
|
486
|
+
{#each block.rows as row (row.option.value)}
|
|
487
|
+
<option value={row.option.value} disabled={row.option.disabled}>
|
|
488
|
+
{row.option.label}
|
|
489
|
+
</option>
|
|
490
|
+
{/each}
|
|
491
|
+
</optgroup>
|
|
492
|
+
{:else}
|
|
493
|
+
{#each block.rows as row (row.option.value)}
|
|
494
|
+
<option value={row.option.value} disabled={row.option.disabled}>
|
|
495
|
+
{row.option.label}
|
|
496
|
+
</option>
|
|
497
|
+
{/each}
|
|
498
|
+
{/if}
|
|
499
|
+
{/each}
|
|
500
|
+
{/if}
|
|
501
|
+
</select>
|
|
502
|
+
{@render chevron(false)}
|
|
503
|
+
</div>
|
|
504
|
+
{/if}
|
|
80
505
|
|
|
81
506
|
{#if error}
|
|
82
|
-
<p id={fieldId
|
|
507
|
+
<p id="{fieldId}-error" class={FIELD_ERROR}>{error}</p>
|
|
83
508
|
{:else if hint}
|
|
84
|
-
<p id={fieldId
|
|
509
|
+
<p id="{fieldId}-hint" class={FIELD_HINT}>{hint}</p>
|
|
85
510
|
{/if}
|
|
86
511
|
</div>
|
|
@@ -1,8 +1,72 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type { Component } from 'svelte';
|
|
2
|
+
/**
|
|
3
|
+
* The event a native select hands its change handler.
|
|
4
|
+
*
|
|
5
|
+
* Frozen, and it stays frozen. Every call site in the estate passes an
|
|
6
|
+
* unannotated arrow whose parameter is contextually typed from this prop, and
|
|
7
|
+
* several of them read `e.currentTarget.value` or call
|
|
8
|
+
* `e.currentTarget.form.requestSubmit()`. Retyping the callback to take a
|
|
9
|
+
* plain value would fail all of them under strict mode at once, so the
|
|
10
|
+
* value-shaped callback is a second prop rather than a new spelling of this
|
|
11
|
+
* one.
|
|
12
|
+
*/
|
|
13
|
+
export type SelectChangeEvent = Event & {
|
|
3
14
|
currentTarget: HTMLSelectElement;
|
|
4
15
|
};
|
|
5
|
-
|
|
16
|
+
/** One row of the option list, in either mode. */
|
|
17
|
+
export interface SelectOption {
|
|
18
|
+
/** The submitted value, and the key the row is rendered under. */
|
|
19
|
+
value: string;
|
|
20
|
+
/** What the user reads. */
|
|
21
|
+
label: string;
|
|
22
|
+
/** Blocks this row alone. A disabled control blocks every row. */
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Drawn before the label. Listbox mode only: a native option element holds
|
|
26
|
+
* text and nothing else, so native mode drops it rather than standing in a
|
|
27
|
+
* Unicode character that would render at whatever weight the reader's font
|
|
28
|
+
* gives it.
|
|
29
|
+
*/
|
|
30
|
+
icon?: Component<{
|
|
31
|
+
size?: number;
|
|
32
|
+
class?: string;
|
|
33
|
+
}>;
|
|
34
|
+
/**
|
|
35
|
+
* Extra text the default matcher searches, so a machine name finds a row
|
|
36
|
+
* that displays under a friendlier one.
|
|
37
|
+
*/
|
|
38
|
+
keywords?: string[];
|
|
39
|
+
/**
|
|
40
|
+
* Groups rows under a heading. A group is a run of neighbouring rows that
|
|
41
|
+
* name it, in both modes, because optgroup nests and cannot describe an
|
|
42
|
+
* interleaved list either. A caller who interleaves two groups gets the
|
|
43
|
+
* heading twice, which is what the array says.
|
|
44
|
+
*/
|
|
45
|
+
group?: string;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A select in four kinds: a native one, a native one driven by an array, a
|
|
49
|
+
* custom listbox that can search and carry icons, and a listbox behind a
|
|
50
|
+
* trigger of the caller's own.
|
|
51
|
+
*
|
|
52
|
+
* The native element is the default and stays the default. Most call sites
|
|
53
|
+
* sit inside a form and pass `name`, and a custom listbox is a button, which
|
|
54
|
+
* serializes nothing; one call site submits its form from the change event's
|
|
55
|
+
* `currentTarget.form`, which only a form-associated element carries. So the
|
|
56
|
+
* mode is never inferred, not from `options` and not from `searchable`: a
|
|
57
|
+
* page inside a form opts in to the listbox deliberately or keeps a real
|
|
58
|
+
* select.
|
|
59
|
+
*
|
|
60
|
+
* Listbox mode owns nothing of its own behaviour. The open state, the active
|
|
61
|
+
* row, the keyboard model and the dismissal come from internal/listbox, the
|
|
62
|
+
* matching from internal/filter and every class in the panel from
|
|
63
|
+
* internal/panel, so this control cannot drift away from the other lists in
|
|
64
|
+
* the library the way the four hand-rolled ones drifted from each other.
|
|
65
|
+
*/
|
|
66
|
+
import type { Snippet } from 'svelte';
|
|
67
|
+
import { type FilterInput } from '../internal/filter.js';
|
|
68
|
+
interface Props {
|
|
69
|
+
/** The selected value. Bindable, so a listbox pick reaches the caller without a callback. */
|
|
6
70
|
value?: string | null;
|
|
7
71
|
id?: string;
|
|
8
72
|
name?: string;
|
|
@@ -12,9 +76,34 @@ type $$ComponentProps = {
|
|
|
12
76
|
disabled?: boolean;
|
|
13
77
|
error?: string;
|
|
14
78
|
class?: string;
|
|
15
|
-
|
|
79
|
+
/** Native mode only. Its signature is frozen; see SelectChangeEvent. */
|
|
80
|
+
onchange?: (e: SelectChangeEvent) => void;
|
|
81
|
+
/** Option elements, written by hand. Native mode only. */
|
|
16
82
|
children?: Snippet;
|
|
17
|
-
|
|
18
|
-
|
|
83
|
+
/**
|
|
84
|
+
* 'native' renders a real select. 'listbox' renders the custom panel.
|
|
85
|
+
* Never inferred: see the note above.
|
|
86
|
+
*/
|
|
87
|
+
mode?: 'native' | 'listbox';
|
|
88
|
+
/** The rows, as data. Renders as option elements in native mode. */
|
|
89
|
+
options?: SelectOption[];
|
|
90
|
+
/** Listbox mode. Adds a search field inside the panel. */
|
|
91
|
+
searchable?: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Replaces the default matcher, or false to switch local filtering off
|
|
94
|
+
* because the list arrived already narrowed, by a server query for instance.
|
|
95
|
+
*/
|
|
96
|
+
filter?: FilterInput<SelectOption>;
|
|
97
|
+
/** Fills the trigger in listbox mode. Receives the selected row and the open state. */
|
|
98
|
+
trigger?: Snippet<[{
|
|
99
|
+
selected: SelectOption | undefined;
|
|
100
|
+
open: boolean;
|
|
101
|
+
}]>;
|
|
102
|
+
/** Shown when nothing is selected. In native mode it renders as a leading empty row. */
|
|
103
|
+
placeholder?: string;
|
|
104
|
+
/** Value-shaped and additive, so the event signature above stays frozen. Fires in both modes. */
|
|
105
|
+
onvaluechange?: (value: string) => void;
|
|
106
|
+
}
|
|
107
|
+
declare const Select: Component<Props, {}, "value">;
|
|
19
108
|
type Select = ReturnType<typeof Select>;
|
|
20
109
|
export default Select;
|