@lyeve-labs/ui-kit 0.33.0 → 0.34.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.
|
@@ -292,7 +292,9 @@
|
|
|
292
292
|
<svg viewBox="0 0 10 8" class="size-2.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d={CHOICE_MARK.check} /></svg>
|
|
293
293
|
{/if}
|
|
294
294
|
</span>
|
|
295
|
-
|
|
295
|
+
<!-- A one-word name longer than the space beside the price breaks
|
|
296
|
+
with a hyphen rather than running under it. -->
|
|
297
|
+
<span id="{id}-name" class="min-w-0 text-sm font-semibold leading-snug text-fg hyphens-auto [overflow-wrap:anywhere]">{plugin.name}</span>
|
|
296
298
|
</span>
|
|
297
299
|
<span id="{id}-price" class="shrink-0 font-mono text-sm font-semibold text-fg">
|
|
298
300
|
{formatCents(plugin.price_cents, currency)}<span class="text-xs font-normal text-muted">/mo</span>
|
|
@@ -1,20 +1,7 @@
|
|
|
1
1
|
<script lang="ts" module>
|
|
2
2
|
import type { Component } from 'svelte';
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
* The event a native select hands its change handler.
|
|
6
|
-
*
|
|
7
|
-
* Frozen, and it stays frozen. Every call site in the consuming applications 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. */
|
|
4
|
+
/** One row of the option list. */
|
|
18
5
|
export interface SelectOption {
|
|
19
6
|
/** The submitted value, and the key the row is rendered under. */
|
|
20
7
|
value: string;
|
|
@@ -22,12 +9,7 @@
|
|
|
22
9
|
label: string;
|
|
23
10
|
/** Blocks this row alone. A disabled control blocks every row. */
|
|
24
11
|
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
|
-
*/
|
|
12
|
+
/** Drawn before the label. */
|
|
31
13
|
icon?: Component<{ size?: number; class?: string }>;
|
|
32
14
|
/**
|
|
33
15
|
* Extra text the default matcher searches, so a machine name finds a row
|
|
@@ -36,9 +18,8 @@
|
|
|
36
18
|
keywords?: string[];
|
|
37
19
|
/**
|
|
38
20
|
* Groups rows under a heading. A group is a run of neighboring rows that
|
|
39
|
-
* name it
|
|
40
|
-
*
|
|
41
|
-
* heading twice, which is what the array says.
|
|
21
|
+
* name it. A caller who interleaves two groups gets the heading twice,
|
|
22
|
+
* which is what the array says.
|
|
42
23
|
*/
|
|
43
24
|
group?: string;
|
|
44
25
|
}
|
|
@@ -46,25 +27,23 @@
|
|
|
46
27
|
|
|
47
28
|
<script lang="ts">
|
|
48
29
|
/**
|
|
49
|
-
* A select
|
|
50
|
-
* custom listbox that can search and carry icons, and a listbox behind a
|
|
51
|
-
* trigger of the caller's own.
|
|
30
|
+
* A select: a listbox the kit draws, never the browser's own.
|
|
52
31
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
32
|
+
* It used to default to a native select, and the native list is the one
|
|
33
|
+
* surface no stylesheet reaches: it opened white on a dark page, in the
|
|
34
|
+
* operating system's font and highlight color, in the middle of a checkout.
|
|
35
|
+
* So there is one mode. The trigger is a button and the value travels in a
|
|
36
|
+
* hidden input, so a form posts `name` exactly as a select would. A page
|
|
37
|
+
* that submitted from the change event uses `onvaluechange`, which fires
|
|
38
|
+
* after the hidden input holds the new value.
|
|
60
39
|
*
|
|
61
|
-
*
|
|
40
|
+
* The control owns nothing of its own behavior. The open state, the active
|
|
62
41
|
* row, the keyboard model and the dismissal come from internal/listbox, the
|
|
63
42
|
* matching from internal/filter and every class in the panel from
|
|
64
|
-
* internal/panel, so
|
|
65
|
-
*
|
|
43
|
+
* internal/panel, so it cannot drift away from the other lists in the
|
|
44
|
+
* library the way the four hand-rolled ones drifted from each other.
|
|
66
45
|
*/
|
|
67
|
-
import type
|
|
46
|
+
import { tick, type Snippet } from 'svelte';
|
|
68
47
|
import { applyFilter, type FilterInput } from '../internal/filter.js';
|
|
69
48
|
import {
|
|
70
49
|
CONTROL_BASE,
|
|
@@ -87,7 +66,7 @@
|
|
|
87
66
|
import * as motion from '../motion.js';
|
|
88
67
|
|
|
89
68
|
interface Props {
|
|
90
|
-
/** The selected value. Bindable, so a
|
|
69
|
+
/** The selected value. Bindable, so a pick reaches the caller without a callback. */
|
|
91
70
|
value?: string | null;
|
|
92
71
|
id?: string;
|
|
93
72
|
name?: string;
|
|
@@ -97,29 +76,20 @@
|
|
|
97
76
|
disabled?: boolean;
|
|
98
77
|
error?: string;
|
|
99
78
|
class?: string;
|
|
100
|
-
/**
|
|
101
|
-
onchange?: (e: SelectChangeEvent) => void;
|
|
102
|
-
/** Option elements, written by hand. Native mode only. */
|
|
103
|
-
children?: Snippet;
|
|
104
|
-
/**
|
|
105
|
-
* 'native' renders a real select. 'listbox' renders the custom panel.
|
|
106
|
-
* Never inferred: see the note above.
|
|
107
|
-
*/
|
|
108
|
-
mode?: 'native' | 'listbox';
|
|
109
|
-
/** The rows, as data. Renders as option elements in native mode. */
|
|
79
|
+
/** The rows, as data. */
|
|
110
80
|
options?: SelectOption[];
|
|
111
|
-
/**
|
|
81
|
+
/** Adds a search field inside the panel. */
|
|
112
82
|
searchable?: boolean;
|
|
113
83
|
/**
|
|
114
84
|
* Replaces the default matcher, or false to switch local filtering off
|
|
115
85
|
* because the list arrived already narrowed, by a server query for instance.
|
|
116
86
|
*/
|
|
117
87
|
filter?: FilterInput<SelectOption>;
|
|
118
|
-
/** Fills the trigger
|
|
88
|
+
/** Fills the trigger. Receives the selected row and the open state. */
|
|
119
89
|
trigger?: Snippet<[{ selected: SelectOption | undefined; open: boolean }]>;
|
|
120
|
-
/** Shown when nothing is selected.
|
|
90
|
+
/** Shown when nothing is selected. */
|
|
121
91
|
placeholder?: string;
|
|
122
|
-
/**
|
|
92
|
+
/** Fires with the new value once the hidden input holds it, so the form can be submitted from here. */
|
|
123
93
|
onvaluechange?: (value: string) => void;
|
|
124
94
|
}
|
|
125
95
|
|
|
@@ -133,9 +103,6 @@
|
|
|
133
103
|
disabled = false,
|
|
134
104
|
error,
|
|
135
105
|
class: cls = '',
|
|
136
|
-
onchange,
|
|
137
|
-
children,
|
|
138
|
-
mode = 'native',
|
|
139
106
|
options = [],
|
|
140
107
|
searchable = false,
|
|
141
108
|
filter,
|
|
@@ -158,14 +125,12 @@
|
|
|
158
125
|
const uid = $props.id();
|
|
159
126
|
const fieldId = $derived(id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : uid));
|
|
160
127
|
|
|
161
|
-
const listboxMode = $derived(mode === 'listbox');
|
|
162
|
-
|
|
163
128
|
let query = $state('');
|
|
164
129
|
let triggerEl = $state<HTMLButtonElement | undefined>();
|
|
165
130
|
let searchEl = $state<HTMLInputElement | undefined>();
|
|
166
131
|
|
|
167
|
-
/** The rows the panel is showing.
|
|
168
|
-
const rows = $derived(
|
|
132
|
+
/** The rows the panel is showing. */
|
|
133
|
+
const rows = $derived(applyFilter(options, query, filter));
|
|
169
134
|
const selected = $derived(options.find((option) => option.value === value));
|
|
170
135
|
|
|
171
136
|
interface Row {
|
|
@@ -182,9 +147,8 @@
|
|
|
182
147
|
/**
|
|
183
148
|
* Splits the list into runs of rows that share a group.
|
|
184
149
|
*
|
|
185
|
-
* Runs rather than a bucket per name, so
|
|
186
|
-
*
|
|
187
|
-
* either, and a caller whose array interleaves gets the same answer from both.
|
|
150
|
+
* Runs rather than a bucket per name, so a caller whose array interleaves two
|
|
151
|
+
* groups sees the order it wrote.
|
|
188
152
|
*/
|
|
189
153
|
function groupRuns(list: readonly SelectOption[]): Block[] {
|
|
190
154
|
const blocks: Block[] = [];
|
|
@@ -214,24 +178,19 @@
|
|
|
214
178
|
const anchor = box.anchor;
|
|
215
179
|
const panel = box.panel;
|
|
216
180
|
|
|
217
|
-
function choose(option: SelectOption): void {
|
|
181
|
+
async function choose(option: SelectOption): Promise<void> {
|
|
218
182
|
if (option.disabled === true) return;
|
|
219
183
|
value = option.value;
|
|
220
|
-
onvaluechange?.(option.value);
|
|
221
184
|
// The factory leaves the list open, because a multi-value control collects
|
|
222
185
|
// several picks in one pass. This one takes a single value.
|
|
223
186
|
box.close('select');
|
|
224
187
|
// The row that was clicked is about to be unmounted, so focus has somewhere
|
|
225
188
|
// to be returned to or it falls to the body.
|
|
226
189
|
triggerEl?.focus();
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
// Called synchronously and with the event untouched, so currentTarget is
|
|
232
|
-
// still the select and a call site can submit the form from it.
|
|
233
|
-
onchange?.(event);
|
|
234
|
-
onvaluechange?.(event.currentTarget.value);
|
|
190
|
+
// After the hidden input has been written, so a caller that submits the
|
|
191
|
+
// form from here posts the value just picked and not the one before it.
|
|
192
|
+
await tick();
|
|
193
|
+
onvaluechange?.(option.value);
|
|
235
194
|
}
|
|
236
195
|
|
|
237
196
|
function search(event: Event & { currentTarget: HTMLInputElement }): void {
|
|
@@ -245,33 +204,6 @@
|
|
|
245
204
|
$effect(() => {
|
|
246
205
|
if (box.open && searchEl !== undefined) searchEl.focus();
|
|
247
206
|
});
|
|
248
|
-
|
|
249
|
-
let warned = false;
|
|
250
|
-
|
|
251
|
-
/*
|
|
252
|
-
* A listbox-only prop passed to a native select is a caller mistake with
|
|
253
|
-
* three possible answers, and only one of them is safe.
|
|
254
|
-
*
|
|
255
|
-
* Throwing turns a cosmetic slip into a blank page in a form the user was
|
|
256
|
-
* halfway through. Upgrading the mode is worse than it looks: it swaps a
|
|
257
|
-
* form-associated element for a button, so the change event stops firing and
|
|
258
|
-
* the call sites that submit their form from it go quiet with no error
|
|
259
|
-
* anywhere. Warning leaves the page working as the native select it asked
|
|
260
|
-
* for and puts the mistake where the developer will read it. Once per
|
|
261
|
-
* instance, and from an effect, so a server render does not repeat it into
|
|
262
|
-
* the log on every request.
|
|
263
|
-
*/
|
|
264
|
-
$effect(() => {
|
|
265
|
-
if (listboxMode || warned) return;
|
|
266
|
-
const ignored = [searchable ? 'searchable' : '', trigger ? 'trigger' : ''].filter(
|
|
267
|
-
(part) => part !== '',
|
|
268
|
-
);
|
|
269
|
-
if (ignored.length === 0) return;
|
|
270
|
-
warned = true;
|
|
271
|
-
for (const prop of ignored) {
|
|
272
|
-
console.warn(`Select: the ${prop} prop applies to mode="listbox" and was ignored.`);
|
|
273
|
-
}
|
|
274
|
-
});
|
|
275
207
|
</script>
|
|
276
208
|
|
|
277
209
|
{#snippet chevron(open: boolean)}
|
|
@@ -352,15 +284,14 @@
|
|
|
352
284
|
</label>
|
|
353
285
|
{/if}
|
|
354
286
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
<!--
|
|
287
|
+
<div class="relative" use:anchor>
|
|
288
|
+
<!--
|
|
358
289
|
The trigger is a button, so the snippet fills it rather than replacing
|
|
359
290
|
it: a caller-supplied element would have to carry the id, the label
|
|
360
291
|
association and four aria attributes itself, and a button nested inside
|
|
361
292
|
another button is not valid markup a browser will focus.
|
|
362
293
|
-->
|
|
363
|
-
|
|
294
|
+
<!--
|
|
364
295
|
role="combobox" is the select-only combobox pattern, and it is also
|
|
365
296
|
what carries aria-invalid and aria-required: a bare button takes
|
|
366
297
|
neither, so the control could not report its own validity. The role and
|
|
@@ -368,142 +299,101 @@
|
|
|
368
299
|
compiler checks both against the source it can read and it cannot read
|
|
369
300
|
a spread. The spread sets the same expanded value.
|
|
370
301
|
-->
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
302
|
+
<button
|
|
303
|
+
bind:this={triggerEl}
|
|
304
|
+
type="button"
|
|
305
|
+
role="combobox"
|
|
306
|
+
aria-expanded={box.open}
|
|
307
|
+
id={fieldId}
|
|
308
|
+
{disabled}
|
|
309
|
+
onclick={() => box.toggle()}
|
|
310
|
+
onkeydown={(event) => {
|
|
311
|
+
box.onkeydown(event);
|
|
312
|
+
}}
|
|
313
|
+
aria-invalid={error ? 'true' : undefined}
|
|
314
|
+
aria-required={required ? 'true' : undefined}
|
|
315
|
+
aria-describedby={describedBy(fieldId, error, hint)}
|
|
316
|
+
{...box.triggerAttrs}
|
|
317
|
+
class="{CONTROL_BASE} {controlBorder(!!error)} flex cursor-pointer items-center gap-2 pe-8
|
|
387
318
|
text-start"
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
{/if}
|
|
396
|
-
<span class="min-w-0 truncate">{selected.label}</span>
|
|
397
|
-
{:else}
|
|
398
|
-
<span class="min-w-0 truncate text-faint">{placeholder ?? ''}</span>
|
|
319
|
+
>
|
|
320
|
+
{#if trigger}
|
|
321
|
+
{@render trigger({ selected, open: box.open })}
|
|
322
|
+
{:else if selected}
|
|
323
|
+
{#if selected.icon}
|
|
324
|
+
{@const Icon = selected.icon}
|
|
325
|
+
<Icon size={16} class="shrink-0" />
|
|
399
326
|
{/if}
|
|
400
|
-
|
|
401
|
-
{
|
|
327
|
+
<span class="min-w-0 truncate">{selected.label}</span>
|
|
328
|
+
{:else}
|
|
329
|
+
<span class="min-w-0 truncate text-faint">{placeholder ?? ''}</span>
|
|
330
|
+
{/if}
|
|
331
|
+
</button>
|
|
332
|
+
{@render chevron(box.open)}
|
|
402
333
|
|
|
403
|
-
|
|
334
|
+
<!--
|
|
404
335
|
The value the form actually submits. The visible control is a button,
|
|
405
336
|
which serializes nothing, and a hidden input is barred from constraint
|
|
406
337
|
validation, so `required` is announced through aria-required and
|
|
407
338
|
enforced by the caller rather than by the browser.
|
|
408
339
|
-->
|
|
409
|
-
|
|
340
|
+
<input type="hidden" {name} {disabled} value={value ?? ''} />
|
|
410
341
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
342
|
+
{#if box.open}
|
|
343
|
+
<div use:placePanel transition:motion.popover|global class="{PANEL_SURFACE} w-full">
|
|
344
|
+
{#if searchable}
|
|
345
|
+
<div class="border-b border-line p-2">
|
|
346
|
+
<!--
|
|
416
347
|
The second combobox over the same list, and it earns the role:
|
|
417
348
|
the trigger has to announce collapsed while it is closed, and
|
|
418
349
|
this box has to announce the active row while it holds focus.
|
|
419
350
|
It is rendered only while the panel is open, so its expanded
|
|
420
351
|
state is a constant.
|
|
421
352
|
-->
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
353
|
+
<input
|
|
354
|
+
bind:this={searchEl}
|
|
355
|
+
type="text"
|
|
356
|
+
role="combobox"
|
|
357
|
+
aria-expanded="true"
|
|
358
|
+
value={query}
|
|
359
|
+
oninput={search}
|
|
360
|
+
onkeydown={(event) => {
|
|
361
|
+
box.onkeydown(event);
|
|
362
|
+
}}
|
|
363
|
+
placeholder="Search"
|
|
364
|
+
aria-label={label ? `Search ${label}` : 'Search options'}
|
|
365
|
+
{...box.triggerAttrs}
|
|
366
|
+
class="w-full rounded-md border border-line-strong bg-surface-2 px-2.5 py-1.5
|
|
436
367
|
text-sm text-fg outline-none transition-colors
|
|
437
368
|
placeholder:text-faint focus:border-brand"
|
|
438
|
-
|
|
439
|
-
</div>
|
|
440
|
-
{/if}
|
|
441
|
-
|
|
442
|
-
<div class={PANEL_LIST} data-panel-list use:panel {...box.listAttrs}>
|
|
443
|
-
{#each blocks as block, position (position)}
|
|
444
|
-
{#if block.group !== undefined}
|
|
445
|
-
<!-- Named once, on the group. The heading repeats it on screen. -->
|
|
446
|
-
<div role="group" aria-label={block.group}>
|
|
447
|
-
<div class={PANEL_GROUP_LABEL} aria-hidden="true">{block.group}</div>
|
|
448
|
-
{#each block.rows as row (row.option.value)}
|
|
449
|
-
{@render optionRow(row)}
|
|
450
|
-
{/each}
|
|
451
|
-
</div>
|
|
452
|
-
{:else}
|
|
453
|
-
{#each block.rows as row (row.option.value)}
|
|
454
|
-
{@render optionRow(row)}
|
|
455
|
-
{/each}
|
|
456
|
-
{/if}
|
|
457
|
-
{/each}
|
|
458
|
-
|
|
459
|
-
{#if rows.length === 0}
|
|
460
|
-
<p class={PANEL_EMPTY}>No matches</p>
|
|
461
|
-
{/if}
|
|
369
|
+
/>
|
|
462
370
|
</div>
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
{:else}
|
|
467
|
-
<div class="relative">
|
|
468
|
-
<select
|
|
469
|
-
id={fieldId}
|
|
470
|
-
{name}
|
|
471
|
-
{required}
|
|
472
|
-
{disabled}
|
|
473
|
-
value={value ?? ''}
|
|
474
|
-
onchange={nativeChange}
|
|
475
|
-
aria-invalid={error ? 'true' : undefined}
|
|
476
|
-
aria-describedby={describedBy(fieldId, error, hint)}
|
|
477
|
-
class="{CONTROL_BASE} {controlBorder(!!error)} cursor-pointer appearance-none pe-8"
|
|
478
|
-
>
|
|
479
|
-
{#if children}
|
|
480
|
-
{@render children()}
|
|
481
|
-
{:else}
|
|
482
|
-
{#if placeholder}
|
|
483
|
-
<option value="">{placeholder}</option>
|
|
484
|
-
{/if}
|
|
371
|
+
{/if}
|
|
372
|
+
|
|
373
|
+
<div class={PANEL_LIST} data-panel-list use:panel {...box.listAttrs}>
|
|
485
374
|
{#each blocks as block, position (position)}
|
|
486
375
|
{#if block.group !== undefined}
|
|
487
|
-
|
|
376
|
+
<!-- Named once, on the group. The heading repeats it on screen. -->
|
|
377
|
+
<div role="group" aria-label={block.group}>
|
|
378
|
+
<div class={PANEL_GROUP_LABEL} aria-hidden="true">{block.group}</div>
|
|
488
379
|
{#each block.rows as row (row.option.value)}
|
|
489
|
-
|
|
490
|
-
{row.option.label}
|
|
491
|
-
</option>
|
|
380
|
+
{@render optionRow(row)}
|
|
492
381
|
{/each}
|
|
493
|
-
</
|
|
382
|
+
</div>
|
|
494
383
|
{:else}
|
|
495
384
|
{#each block.rows as row (row.option.value)}
|
|
496
|
-
|
|
497
|
-
{row.option.label}
|
|
498
|
-
</option>
|
|
385
|
+
{@render optionRow(row)}
|
|
499
386
|
{/each}
|
|
500
387
|
{/if}
|
|
501
388
|
{/each}
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
389
|
+
|
|
390
|
+
{#if rows.length === 0}
|
|
391
|
+
<p class={PANEL_EMPTY}>No matches</p>
|
|
392
|
+
{/if}
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
{/if}
|
|
396
|
+
</div>
|
|
507
397
|
|
|
508
398
|
{#if error}
|
|
509
399
|
<p id="{fieldId}-error" class={FIELD_ERROR}>{error}</p>
|
|
@@ -1,19 +1,5 @@
|
|
|
1
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 consuming applications 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 & {
|
|
14
|
-
currentTarget: HTMLSelectElement;
|
|
15
|
-
};
|
|
16
|
-
/** One row of the option list, in either mode. */
|
|
2
|
+
/** One row of the option list. */
|
|
17
3
|
export interface SelectOption {
|
|
18
4
|
/** The submitted value, and the key the row is rendered under. */
|
|
19
5
|
value: string;
|
|
@@ -21,12 +7,7 @@ export interface SelectOption {
|
|
|
21
7
|
label: string;
|
|
22
8
|
/** Blocks this row alone. A disabled control blocks every row. */
|
|
23
9
|
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
|
-
*/
|
|
10
|
+
/** Drawn before the label. */
|
|
30
11
|
icon?: Component<{
|
|
31
12
|
size?: number;
|
|
32
13
|
class?: string;
|
|
@@ -38,35 +19,32 @@ export interface SelectOption {
|
|
|
38
19
|
keywords?: string[];
|
|
39
20
|
/**
|
|
40
21
|
* Groups rows under a heading. A group is a run of neighboring rows that
|
|
41
|
-
* name it
|
|
42
|
-
*
|
|
43
|
-
* heading twice, which is what the array says.
|
|
22
|
+
* name it. A caller who interleaves two groups gets the heading twice,
|
|
23
|
+
* which is what the array says.
|
|
44
24
|
*/
|
|
45
25
|
group?: string;
|
|
46
26
|
}
|
|
47
27
|
/**
|
|
48
|
-
* A select
|
|
49
|
-
* custom listbox that can search and carry icons, and a listbox behind a
|
|
50
|
-
* trigger of the caller's own.
|
|
28
|
+
* A select: a listbox the kit draws, never the browser's own.
|
|
51
29
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
30
|
+
* It used to default to a native select, and the native list is the one
|
|
31
|
+
* surface no stylesheet reaches: it opened white on a dark page, in the
|
|
32
|
+
* operating system's font and highlight color, in the middle of a checkout.
|
|
33
|
+
* So there is one mode. The trigger is a button and the value travels in a
|
|
34
|
+
* hidden input, so a form posts `name` exactly as a select would. A page
|
|
35
|
+
* that submitted from the change event uses `onvaluechange`, which fires
|
|
36
|
+
* after the hidden input holds the new value.
|
|
59
37
|
*
|
|
60
|
-
*
|
|
38
|
+
* The control owns nothing of its own behavior. The open state, the active
|
|
61
39
|
* row, the keyboard model and the dismissal come from internal/listbox, the
|
|
62
40
|
* matching from internal/filter and every class in the panel from
|
|
63
|
-
* internal/panel, so
|
|
64
|
-
*
|
|
41
|
+
* internal/panel, so it cannot drift away from the other lists in the
|
|
42
|
+
* library the way the four hand-rolled ones drifted from each other.
|
|
65
43
|
*/
|
|
66
|
-
import type
|
|
44
|
+
import { type Snippet } from 'svelte';
|
|
67
45
|
import { type FilterInput } from '../internal/filter.js';
|
|
68
46
|
interface Props {
|
|
69
|
-
/** The selected value. Bindable, so a
|
|
47
|
+
/** The selected value. Bindable, so a pick reaches the caller without a callback. */
|
|
70
48
|
value?: string | null;
|
|
71
49
|
id?: string;
|
|
72
50
|
name?: string;
|
|
@@ -76,32 +54,23 @@ interface Props {
|
|
|
76
54
|
disabled?: boolean;
|
|
77
55
|
error?: string;
|
|
78
56
|
class?: string;
|
|
79
|
-
/**
|
|
80
|
-
onchange?: (e: SelectChangeEvent) => void;
|
|
81
|
-
/** Option elements, written by hand. Native mode only. */
|
|
82
|
-
children?: Snippet;
|
|
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. */
|
|
57
|
+
/** The rows, as data. */
|
|
89
58
|
options?: SelectOption[];
|
|
90
|
-
/**
|
|
59
|
+
/** Adds a search field inside the panel. */
|
|
91
60
|
searchable?: boolean;
|
|
92
61
|
/**
|
|
93
62
|
* Replaces the default matcher, or false to switch local filtering off
|
|
94
63
|
* because the list arrived already narrowed, by a server query for instance.
|
|
95
64
|
*/
|
|
96
65
|
filter?: FilterInput<SelectOption>;
|
|
97
|
-
/** Fills the trigger
|
|
66
|
+
/** Fills the trigger. Receives the selected row and the open state. */
|
|
98
67
|
trigger?: Snippet<[{
|
|
99
68
|
selected: SelectOption | undefined;
|
|
100
69
|
open: boolean;
|
|
101
70
|
}]>;
|
|
102
|
-
/** Shown when nothing is selected.
|
|
71
|
+
/** Shown when nothing is selected. */
|
|
103
72
|
placeholder?: string;
|
|
104
|
-
/**
|
|
73
|
+
/** Fires with the new value once the hidden input holds it, so the form can be submitted from here. */
|
|
105
74
|
onvaluechange?: (value: string) => void;
|
|
106
75
|
}
|
|
107
76
|
declare const Select: Component<Props, {}, "value">;
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
* the first attempt already lives.
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
|
-
/** A segment the user types digits into. The meridiem is a
|
|
46
|
+
/** A segment the user types digits into. The meridiem is a two-value spinbutton. */
|
|
47
47
|
type DigitSegment = Exclude<TimeSegment, 'meridiem'>;
|
|
48
48
|
|
|
49
49
|
interface Props {
|
|
@@ -377,8 +377,8 @@
|
|
|
377
377
|
commit();
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
-
function
|
|
381
|
-
|
|
380
|
+
function setMeridiem(next: 'AM' | 'PM'): void {
|
|
381
|
+
if (disabled) return;
|
|
382
382
|
meridiemPref = next;
|
|
383
383
|
if (hour !== null && to12Hour(hour).meridiem !== next) {
|
|
384
384
|
hour = stepSegment({ h: hour, mi: 0, s: 0 }, 'meridiem', 1, 1).h;
|
|
@@ -387,11 +387,20 @@
|
|
|
387
387
|
}
|
|
388
388
|
|
|
389
389
|
function onMeridiemKeydown(e: KeyboardEvent): void {
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
|
|
390
|
+
// Left and right belong to the field: they walk the segments. Up and down
|
|
391
|
+
// step the value as they do on every other segment, and the first letter
|
|
392
|
+
// picks it outright.
|
|
393
|
+
const key = e.key.toLowerCase();
|
|
394
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
|
395
|
+
focusSegment('meridiem', e.key === 'ArrowLeft' ? -1 : 1);
|
|
396
|
+
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
|
397
|
+
setMeridiem(meridiem === 'AM' ? 'PM' : 'AM');
|
|
398
|
+
} else if (key === 'a' || key === 'p') {
|
|
399
|
+
setMeridiem(key === 'a' ? 'AM' : 'PM');
|
|
400
|
+
} else {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
393
403
|
e.preventDefault();
|
|
394
|
-
focusSegment('meridiem', e.key === 'ArrowLeft' ? -1 : 1);
|
|
395
404
|
}
|
|
396
405
|
</script>
|
|
397
406
|
|
|
@@ -446,24 +455,28 @@
|
|
|
446
455
|
{/each}
|
|
447
456
|
|
|
448
457
|
{#if hour12}
|
|
449
|
-
<!-- A
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
<
|
|
458
|
+
<!-- A spinbutton like the segments beside it, not a select: a native
|
|
459
|
+
select opens a list the browser draws, which no theme reaches. A
|
|
460
|
+
click flips it, so two values never need a list at all. -->
|
|
461
|
+
<button
|
|
462
|
+
type="button"
|
|
463
|
+
role="spinbutton"
|
|
453
464
|
data-segment="meridiem"
|
|
454
465
|
id="{fieldId}-meridiem"
|
|
455
466
|
{disabled}
|
|
456
|
-
value={meridiem}
|
|
457
467
|
aria-label="AM or PM"
|
|
468
|
+
aria-valuenow={meridiem === 'AM' ? 0 : 1}
|
|
469
|
+
aria-valuemin={0}
|
|
470
|
+
aria-valuemax={1}
|
|
471
|
+
aria-valuetext={meridiem}
|
|
458
472
|
aria-invalid={error ? 'true' : undefined}
|
|
459
473
|
aria-required={required ? 'true' : undefined}
|
|
460
|
-
|
|
474
|
+
onclick={() => setMeridiem(meridiem === 'AM' ? 'PM' : 'AM')}
|
|
461
475
|
onkeydown={onMeridiemKeydown}
|
|
462
|
-
class="{CONTROL_SEGMENT} ms-1"
|
|
476
|
+
class="{CONTROL_SEGMENT} ms-1 w-8 cursor-pointer"
|
|
463
477
|
>
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
</select>
|
|
478
|
+
{meridiem}
|
|
479
|
+
</button>
|
|
467
480
|
{/if}
|
|
468
481
|
</div>
|
|
469
482
|
|
package/dist/index.d.ts
CHANGED
|
@@ -50,7 +50,7 @@ export { default as RadioGroup } from './components/RadioGroup.svelte';
|
|
|
50
50
|
export { default as Toggle } from './components/Toggle.svelte';
|
|
51
51
|
export type { ChoiceSize, ChoiceVariant, ChoiceOrientation } from './internal/choice.js';
|
|
52
52
|
export type { ChoiceOption } from './components/CheckboxGroup.svelte';
|
|
53
|
-
export type { SelectOption
|
|
53
|
+
export type { SelectOption } from './components/Select.svelte';
|
|
54
54
|
export type { ListOption } from './components/Autocomplete.svelte';
|
|
55
55
|
export type { FilterFn, FilterInput, FilterContext } from './internal/filter.js';
|
|
56
56
|
export { default as Breadcrumb } from './components/Breadcrumb.svelte';
|
|
@@ -98,4 +98,4 @@ export { cn, type ClassValue } from './utils/cn.js';
|
|
|
98
98
|
export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, watchThemePreference, toggleTheme, watchSystemTheme, THEME_PREFERENCES, type Theme, type ThemePreference, } from './utils/theme.js';
|
|
99
99
|
export * as motion from './motion.js';
|
|
100
100
|
export type { Rung as MotionRung, Curve as MotionCurve } from './motion.js';
|
|
101
|
-
export declare const VERSION = "0.
|
|
101
|
+
export declare const VERSION = "0.34.0";
|
package/dist/index.js
CHANGED
|
@@ -107,4 +107,4 @@ export * as motion from './motion.js';
|
|
|
107
107
|
// ── Version ────────────────────────────────────────────────────────────────
|
|
108
108
|
// Generated from package.json by `pnpm version:sync`. Bump package.json, never
|
|
109
109
|
// this line. The build and the test suite fail when the two disagree.
|
|
110
|
-
export const VERSION = '0.
|
|
110
|
+
export const VERSION = '0.34.0';
|
package/package.json
CHANGED