@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
- <span id="{id}-name" class="text-sm font-semibold leading-snug text-fg">{plugin.name}</span>
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, 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.
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 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.
30
+ * A select: a listbox the kit draws, never the browser's own.
52
31
  *
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.
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
- * Listbox mode owns nothing of its own behavior. The open state, the active
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 this control cannot drift away from the other lists in
65
- * the library the way the four hand-rolled ones drifted from each other.
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 { Snippet } from 'svelte';
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 listbox pick reaches the caller without a callback. */
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
- /** Native mode only. Its signature is frozen. See SelectChangeEvent. */
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
- /** Listbox mode. Adds a search field inside the panel. */
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 in listbox mode. Receives the selected row and the open state. */
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. In native mode it renders as a leading empty row. */
90
+ /** Shown when nothing is selected. */
121
91
  placeholder?: string;
122
- /** Value-shaped and additive, so the event signature above stays frozen. Fires in both modes. */
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. Native mode never filters, so it never narrows. */
168
- const rows = $derived(listboxMode ? applyFilter(options, query, filter) : options);
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 both modes group identically: an
186
- * optgroup nests, so a native select cannot show one group in two places
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
- function nativeChange(event: SelectChangeEvent): void {
230
- value = event.currentTarget.value;
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
- {#if listboxMode}
356
- <div class="relative" use:anchor>
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
- <button
372
- bind:this={triggerEl}
373
- type="button"
374
- role="combobox"
375
- aria-expanded={box.open}
376
- id={fieldId}
377
- {disabled}
378
- onclick={() => box.toggle()}
379
- onkeydown={(event) => {
380
- box.onkeydown(event);
381
- }}
382
- aria-invalid={error ? 'true' : undefined}
383
- aria-required={required ? 'true' : undefined}
384
- aria-describedby={describedBy(fieldId, error, hint)}
385
- {...box.triggerAttrs}
386
- class="{CONTROL_BASE} {controlBorder(!!error)} flex cursor-pointer items-center gap-2 pe-8
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
- {#if trigger}
390
- {@render trigger({ selected, open: box.open })}
391
- {:else if selected}
392
- {#if selected.icon}
393
- {@const Icon = selected.icon}
394
- <Icon size={16} class="shrink-0" />
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
- </button>
401
- {@render chevron(box.open)}
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
- <input type="hidden" {name} {disabled} value={value ?? ''} />
340
+ <input type="hidden" {name} {disabled} value={value ?? ''} />
410
341
 
411
- {#if box.open}
412
- <div use:placePanel transition:motion.popover|global class="{PANEL_SURFACE} w-full">
413
- {#if searchable}
414
- <div class="border-b border-line p-2">
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
- <input
423
- bind:this={searchEl}
424
- type="text"
425
- role="combobox"
426
- aria-expanded="true"
427
- value={query}
428
- oninput={search}
429
- onkeydown={(event) => {
430
- box.onkeydown(event);
431
- }}
432
- placeholder="Search"
433
- aria-label={label ? `Search ${label}` : 'Search options'}
434
- {...box.triggerAttrs}
435
- class="w-full rounded-md border border-line-strong bg-surface-2 px-2.5 py-1.5
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
- </div>
464
- {/if}
465
- </div>
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
- <optgroup label={block.group}>
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
- <option value={row.option.value} disabled={row.option.disabled}>
490
- {row.option.label}
491
- </option>
380
+ {@render optionRow(row)}
492
381
  {/each}
493
- </optgroup>
382
+ </div>
494
383
  {:else}
495
384
  {#each block.rows as row (row.option.value)}
496
- <option value={row.option.value} disabled={row.option.disabled}>
497
- {row.option.label}
498
- </option>
385
+ {@render optionRow(row)}
499
386
  {/each}
500
387
  {/if}
501
388
  {/each}
502
- {/if}
503
- </select>
504
- {@render chevron(false)}
505
- </div>
506
- {/if}
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, 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.
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 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.
28
+ * A select: a listbox the kit draws, never the browser's own.
51
29
  *
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.
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
- * Listbox mode owns nothing of its own behavior. The open state, the active
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 this control cannot drift away from the other lists in
64
- * the library the way the four hand-rolled ones drifted from each other.
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 { Snippet } from 'svelte';
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 listbox pick reaches the caller without a callback. */
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
- /** Native mode only. Its signature is frozen. See SelectChangeEvent. */
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
- /** Listbox mode. Adds a search field inside the panel. */
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 in listbox mode. Receives the selected row and the open state. */
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. In native mode it renders as a leading empty row. */
71
+ /** Shown when nothing is selected. */
103
72
  placeholder?: string;
104
- /** Value-shaped and additive, so the event signature above stays frozen. Fires in both modes. */
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 select, not a spinbutton. */
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 onMeridiemChange(e: Event & { currentTarget: HTMLSelectElement }): void {
381
- const next = e.currentTarget.value === 'PM' ? 'PM' : 'AM';
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
- // Up and down still pick an option, which is what a select does. Left and
391
- // right belong to the field: they walk the segments.
392
- if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
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 select, because two values is exactly what a select is for: it keeps
450
- the native option list, the native keyboard and the native
451
- announcement instead of a button pretending to be one. -->
452
- <select
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
- onchange={onMeridiemChange}
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
- <option value="AM">AM</option>
465
- <option value="PM">PM</option>
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, SelectChangeEvent } from './components/Select.svelte';
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.33.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.33.0';
110
+ export const VERSION = '0.34.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyeve-labs/ui-kit",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "A clean, accessible, palette-aware Svelte 5 component library. The design system behind LyEve.",
5
5
  "license": "MIT",
6
6
  "author": "LyEve Labs <hello@lyeve.com>",