@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
@@ -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
- type SE = Event & { currentTarget: HTMLSelectElement };
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
- value?: string | null;
29
- id?: string;
30
- name?: string;
31
- label?: string;
32
- hint?: string;
33
- required?: boolean;
34
- disabled?: boolean;
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
- const fieldId = $derived(id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined));
42
- </script>
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
- <div class="{FIELD_WRAP} {cls}">
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
- <div class="relative">
52
- <select
53
- id={fieldId}
54
- {name}
55
- {required}
56
- {disabled}
57
- value={value ?? ''}
58
- {onchange}
59
- aria-invalid={error ? 'true' : undefined}
60
- aria-describedby={describedBy(fieldId, error, hint)}
61
- class="{CONTROL_BASE} {controlBorder(!!error)} appearance-none cursor-pointer pr-8"
62
- >
63
- {@render children?.()}
64
- </select>
65
- <span
66
- class="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-faint"
67
- aria-hidden="true"
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
- <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
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 4l4 4 4-4"
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
- </span>
79
- </div>
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 ? `${fieldId}-error` : undefined} class={FIELD_ERROR}>{error}</p>
507
+ <p id="{fieldId}-error" class={FIELD_ERROR}>{error}</p>
83
508
  {:else if hint}
84
- <p id={fieldId ? `${fieldId}-hint` : undefined} class={FIELD_HINT}>{hint}</p>
509
+ <p id="{fieldId}-hint" class={FIELD_HINT}>{hint}</p>
85
510
  {/if}
86
511
  </div>
@@ -1,8 +1,72 @@
1
- import type { Snippet } from 'svelte';
2
- type SE = Event & {
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
- type $$ComponentProps = {
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
- onchange?: (e: SE) => void;
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
- declare const Select: import("svelte").Component<$$ComponentProps, {}, "">;
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;