@lyeve-labs/ui-kit 0.33.1 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,7 +28,7 @@ No config file, no theme provider, no setup ceremony.
28
28
 
29
29
  ## What's in the box
30
30
 
31
- - **71 components:** buttons, inputs, modals, drawers, tabs, tables, toasts, the works.
31
+ - **72 components:** buttons, inputs, modals, drawers, tabs, tables, toasts, the works.
32
32
  - **Two themes:** Soft Dark (default) and Soft Light, switched by a single `data-theme` attribute on `<html>`.
33
33
  - **One CSS file:** `@lyeve-labs/ui-kit/styles.css` declares every token. The rest is just Tailwind.
34
34
  - **Svelte 5 native:** built on runes and snippets, fully typed end-to-end.
@@ -37,13 +37,13 @@ No config file, no theme provider, no setup ceremony.
37
37
  ## Component list
38
38
 
39
39
  <details>
40
- <summary>71 components, organized by purpose</summary>
40
+ <summary>72 components, organized by purpose</summary>
41
41
 
42
42
  **Layout and structure**
43
43
  Card, Panel, AppShell, AuthShell, PageShell, PageHeader, SectionHeading, Divider, Accordion, AccordionItem, Collapsible, Table, DescriptionList, Toolbar, TreeView
44
44
 
45
45
  **Forms and inputs**
46
- Button, ButtonGroup, Input, PasswordInput, CopyField, Textarea, NumberInput, SearchInput, FileInput, Label, Field, FormMessage, SegmentedControl, Select, MultiSelect, Autocomplete, DatePicker, TimePicker, DateTimePicker, Checkbox, CheckboxGroup, Radio, RadioGroup, Toggle
46
+ Button, ButtonGroup, Input, PasswordInput, CopyField, Textarea, NumberInput, SearchInput, FileInput, Label, Field, FormMessage, SegmentedControl, FilterChips, Select, MultiSelect, Autocomplete, DatePicker, TimePicker, DateTimePicker, Checkbox, CheckboxGroup, Radio, RadioGroup, Toggle
47
47
 
48
48
  **Navigation**
49
49
  Breadcrumb, Tabs, Pagination, StepIndicator, Dropdown, SidebarNav, AccountMenu
@@ -253,7 +253,7 @@ This repo is a single-purpose component library. Nothing but `src/lib/`.
253
253
  ```
254
254
  src/
255
255
  └── lib/ # → published as @lyeve-labs/ui-kit
256
- ├── components/ # 71 .svelte files
256
+ ├── components/ # 72 .svelte files
257
257
  ├── stores/ # toast.svelte.ts
258
258
  ├── styles/ # theme.css (the one stylesheet)
259
259
  ├── utils/ # cn.ts, theme.ts
@@ -0,0 +1,86 @@
1
+ <script lang="ts" module>
2
+ import type { Component } from 'svelte';
3
+
4
+ /** One chip. */
5
+ export interface FilterChip<T extends string = string> {
6
+ /** The chosen value, and the key the chip is rendered under. */
7
+ value: T;
8
+ /** What the reader sees. */
9
+ label: string;
10
+ /** How many rows this chip leaves on screen, drawn small beside the label. */
11
+ count?: number;
12
+ /** A lucide component before the label. */
13
+ icon?: Component<{ size?: number; class?: string }>;
14
+ }
15
+ </script>
16
+
17
+ <script lang="ts" generics="T extends string">
18
+ /**
19
+ * One filter out of many: a row of chips that wraps.
20
+ *
21
+ * A SegmentedControl holds two to four short options in one strip. A
22
+ * catalog filtered by a dozen categories does not fit one: the strip either
23
+ * scrolls, hiding categories past the edge, or squeezes each label onto two
24
+ * lines inside its segment. Chips wrap instead, so every category is in
25
+ * sight at every width. One is always pressed.
26
+ */
27
+ import { cn } from '../utils/cn.js';
28
+ import { HIT_AREA } from '../internal/touch.js';
29
+
30
+ interface Props {
31
+ /** The pressed chip's value. */
32
+ value: T;
33
+ options: FilterChip<T>[];
34
+ /** Names the group for assistive technology. The chips name themselves. */
35
+ label: string;
36
+ /** Read after the count, so "3" is heard as "3 on sale". */
37
+ countLabel?: string;
38
+ class?: string;
39
+ onchange?: (value: T) => void;
40
+ }
41
+
42
+ let {
43
+ value = $bindable(),
44
+ options,
45
+ label,
46
+ countLabel = '',
47
+ class: klass = '',
48
+ onchange = undefined,
49
+ }: Props = $props();
50
+
51
+ function pick(next: T): void {
52
+ if (next === value) return;
53
+ value = next;
54
+ onchange?.(next);
55
+ }
56
+ </script>
57
+
58
+ <div class={cn('flex flex-wrap gap-1.5', klass)} role="group" aria-label={label}>
59
+ {#each options as chip (chip.value)}
60
+ {@const pressed = chip.value === value}
61
+ <button
62
+ type="button"
63
+ aria-pressed={pressed}
64
+ class={cn(
65
+ HIT_AREA,
66
+ 'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors',
67
+ 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand',
68
+ pressed
69
+ ? 'border-brand bg-brand/10 text-fg'
70
+ : 'border-line text-muted hover:border-line-strong hover:text-fg',
71
+ )}
72
+ onclick={() => pick(chip.value)}
73
+ >
74
+ {#if chip.icon}
75
+ {@const Icon = chip.icon}
76
+ <Icon size={12} class="shrink-0" />
77
+ {/if}
78
+ {chip.label}
79
+ {#if chip.count !== undefined}
80
+ <span class="font-mono text-[10px] text-faint"
81
+ >{chip.count}{#if countLabel}<span class="sr-only"> {countLabel}</span>{/if}</span
82
+ >
83
+ {/if}
84
+ </button>
85
+ {/each}
86
+ </div>
@@ -0,0 +1,49 @@
1
+ import type { Component } from 'svelte';
2
+ /** One chip. */
3
+ export interface FilterChip<T extends string = string> {
4
+ /** The chosen value, and the key the chip is rendered under. */
5
+ value: T;
6
+ /** What the reader sees. */
7
+ label: string;
8
+ /** How many rows this chip leaves on screen, drawn small beside the label. */
9
+ count?: number;
10
+ /** A lucide component before the label. */
11
+ icon?: Component<{
12
+ size?: number;
13
+ class?: string;
14
+ }>;
15
+ }
16
+ declare function $$render<T extends string>(): {
17
+ props: {
18
+ /** The pressed chip's value. */
19
+ value: T;
20
+ options: FilterChip<T>[];
21
+ /** Names the group for assistive technology. The chips name themselves. */
22
+ label: string;
23
+ /** Read after the count, so "3" is heard as "3 on sale". */
24
+ countLabel?: string;
25
+ class?: string;
26
+ onchange?: (value: T) => void;
27
+ };
28
+ exports: {};
29
+ bindings: "value";
30
+ slots: {};
31
+ events: {};
32
+ };
33
+ declare class __sveltets_Render<T extends string> {
34
+ props(): ReturnType<typeof $$render<T>>['props'];
35
+ events(): ReturnType<typeof $$render<T>>['events'];
36
+ slots(): ReturnType<typeof $$render<T>>['slots'];
37
+ bindings(): "value";
38
+ exports(): {};
39
+ }
40
+ interface $$IsomorphicComponent {
41
+ new <T extends string>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
42
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
43
+ } & ReturnType<__sveltets_Render<T>['exports']>;
44
+ <T extends string>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
45
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
46
+ }
47
+ declare const FilterChips: $$IsomorphicComponent;
48
+ type FilterChips<T extends string> = InstanceType<typeof FilterChips<T>>;
49
+ export default FilterChips;
@@ -15,6 +15,7 @@
15
15
  import { X } from '@lucide/svelte';
16
16
  import Badge from './Badge.svelte';
17
17
  import Button from './Button.svelte';
18
+ import FilterChips from './FilterChips.svelte';
18
19
  import SearchInput from './SearchInput.svelte';
19
20
  import Spinner from './Spinner.svelte';
20
21
  import * as motion from '../motion.js';
@@ -161,7 +162,11 @@
161
162
  );
162
163
 
163
164
  const total = $derived(
164
- quoted ? (period === 'annual' ? (quoted.annual_total_cents ?? null) : quoted.total_cents) : null,
165
+ quoted
166
+ ? period === 'annual'
167
+ ? (quoted.annual_total_cents ?? null)
168
+ : quoted.total_cents
169
+ : null,
165
170
  );
166
171
 
167
172
  const ladder = $derived(
@@ -229,26 +234,12 @@
229
234
  {#if categories.length > 2}
230
235
  <!-- Chips that wrap rather than a strip that scrolls: every category is
231
236
  in sight at every width, and none hides past the edge. -->
232
- <div class="flex flex-wrap gap-1.5" role="group" aria-label="Category">
233
- {#each categories as c (c.value)}
234
- <button
235
- type="button"
236
- aria-pressed={category === c.value}
237
- class={cn(
238
- HIT_AREA,
239
- 'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors',
240
- 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand',
241
- category === c.value
242
- ? 'border-brand bg-brand/10 text-fg'
243
- : 'border-line text-muted hover:border-line-strong hover:text-fg',
244
- )}
245
- onclick={() => (category = c.value)}
246
- >
247
- {c.label}
248
- <span class="font-mono text-[10px] text-faint">{c.count}<span class="sr-only"> on sale</span></span>
249
- </button>
250
- {/each}
251
- </div>
237
+ <FilterChips
238
+ label="Category"
239
+ countLabel="on sale"
240
+ options={categories}
241
+ bind:value={category}
242
+ />
252
243
  {/if}
253
244
 
254
245
  {#if shown.length === 0}
@@ -278,7 +269,9 @@
278
269
  aria-describedby="{id}-price {id}-blurb{tagged ? ` ${id}-tags` : ''}"
279
270
  onchange={() => toggle(plugin.sku)}
280
271
  />
281
- <span class={cn(choiceWrap('card', on, owned), CHOICE_FOCUS, 'h-full flex-col gap-2 p-4')}>
272
+ <span
273
+ class={cn(choiceWrap('card', on, owned), CHOICE_FOCUS, 'h-full flex-col gap-2 p-4')}
274
+ >
282
275
  <span class="flex w-full items-start justify-between gap-3">
283
276
  <span class="flex min-w-0 items-start gap-2.5">
284
277
  <span
@@ -289,15 +282,29 @@
289
282
  aria-hidden="true"
290
283
  >
291
284
  {#if on}
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>
285
+ <svg
286
+ viewBox="0 0 10 8"
287
+ class="size-2.5"
288
+ fill="none"
289
+ stroke="currentColor"
290
+ stroke-width="1.5"
291
+ stroke-linecap="round"
292
+ stroke-linejoin="round"><path d={CHOICE_MARK.check} /></svg
293
+ >
293
294
  {/if}
294
295
  </span>
295
296
  <!-- A one-word name longer than the space beside the price breaks
296
297
  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>
298
+ <span
299
+ id="{id}-name"
300
+ class="min-w-0 text-sm font-semibold leading-snug text-fg hyphens-auto [overflow-wrap:anywhere]"
301
+ >{plugin.name}</span
302
+ >
298
303
  </span>
299
304
  <span id="{id}-price" class="shrink-0 font-mono text-sm font-semibold text-fg">
300
- {formatCents(plugin.price_cents, currency)}<span class="text-xs font-normal text-muted">/mo</span>
305
+ {formatCents(plugin.price_cents, currency)}<span
306
+ class="text-xs font-normal text-muted">/mo</span
307
+ >
301
308
  </span>
302
309
  </span>
303
310
  <span id="{id}-blurb" class="text-xs leading-relaxed text-muted">
@@ -305,7 +312,8 @@
305
312
  </span>
306
313
  {#if tagged}
307
314
  <span id="{id}-tags" class="mt-auto flex flex-wrap gap-1.5 pt-1">
308
- {#if plugin.category}<Badge tone="neutral" size="sm">{plugin.category}</Badge>{/if}
315
+ {#if plugin.category}<Badge tone="neutral" size="sm">{plugin.category}</Badge
316
+ >{/if}
309
317
  {#if blurb.beta}<Badge tone="violet" size="sm">Beta</Badge>{/if}
310
318
  {#if owned}<Badge tone="success" size="sm">Owned</Badge>{/if}
311
319
  </span>
@@ -325,8 +333,11 @@
325
333
  class="flex items-center justify-between gap-3 rounded-xl border border-brand bg-surface px-4 py-3 text-sm shadow-lg transition-colors hover:bg-surface-2"
326
334
  >
327
335
  <span class="text-fg">
328
- <span class="font-semibold">{count}</span> picked{#if total !== null}<span class="text-muted">
329
- · </span><span class="font-mono">{formatCents(total, currency)}{perLabel}</span>{/if}
336
+ <span class="font-semibold">{count}</span> picked{#if total !== null}<span
337
+ class="text-muted"
338
+ >
339
+ ·
340
+ </span><span class="font-mono">{formatCents(total, currency)}{perLabel}</span>{/if}
330
341
  </span>
331
342
  <span class="font-semibold text-brand">Review</span>
332
343
  </a>
@@ -395,7 +406,9 @@
395
406
  <span
396
407
  class={cn(
397
408
  'absolute top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 transition-colors',
398
- ladder.count >= r.plugins ? 'border-brand bg-brand' : 'border-line-strong bg-surface',
409
+ ladder.count >= r.plugins
410
+ ? 'border-brand bg-brand'
411
+ : 'border-line-strong bg-surface',
399
412
  )}
400
413
  style:left="{(r.plugins / ladderTop) * 100}%"
401
414
  ></span>
@@ -403,7 +416,10 @@
403
416
  </div>
404
417
  <div class="relative mt-2 h-4 text-[10px] text-muted">
405
418
  {#each rungs as r (r.plugins)}
406
- <span class="absolute -translate-x-1/2 whitespace-nowrap font-mono" style:left="{Math.min((r.plugins / ladderTop) * 100, 92)}%">
419
+ <span
420
+ class="absolute -translate-x-1/2 whitespace-nowrap font-mono"
421
+ style:left="{Math.min((r.plugins / ladderTop) * 100, 92)}%"
422
+ >
407
423
  {r.plugins}+ · {r.discount_pct}%
408
424
  </span>
409
425
  {/each}
@@ -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
@@ -37,6 +37,8 @@ export { default as Label } from './components/Label.svelte';
37
37
  export { default as Field } from './components/Field.svelte';
38
38
  export { default as FormMessage } from './components/FormMessage.svelte';
39
39
  export { default as SegmentedControl } from './components/SegmentedControl.svelte';
40
+ export { default as FilterChips } from './components/FilterChips.svelte';
41
+ export type { FilterChip } from './components/FilterChips.svelte';
40
42
  export { default as Select } from './components/Select.svelte';
41
43
  export { default as MultiSelect } from './components/MultiSelect.svelte';
42
44
  export { default as Autocomplete } from './components/Autocomplete.svelte';
@@ -50,7 +52,7 @@ export { default as RadioGroup } from './components/RadioGroup.svelte';
50
52
  export { default as Toggle } from './components/Toggle.svelte';
51
53
  export type { ChoiceSize, ChoiceVariant, ChoiceOrientation } from './internal/choice.js';
52
54
  export type { ChoiceOption } from './components/CheckboxGroup.svelte';
53
- export type { SelectOption, SelectChangeEvent } from './components/Select.svelte';
55
+ export type { SelectOption } from './components/Select.svelte';
54
56
  export type { ListOption } from './components/Autocomplete.svelte';
55
57
  export type { FilterFn, FilterInput, FilterContext } from './internal/filter.js';
56
58
  export { default as Breadcrumb } from './components/Breadcrumb.svelte';
@@ -98,4 +100,4 @@ export { cn, type ClassValue } from './utils/cn.js';
98
100
  export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, watchThemePreference, toggleTheme, watchSystemTheme, THEME_PREFERENCES, type Theme, type ThemePreference, } from './utils/theme.js';
99
101
  export * as motion from './motion.js';
100
102
  export type { Rung as MotionRung, Curve as MotionCurve } from './motion.js';
101
- export declare const VERSION = "0.33.1";
103
+ export declare const VERSION = "0.35.0";
package/dist/index.js CHANGED
@@ -38,6 +38,7 @@ export { default as Label } from './components/Label.svelte';
38
38
  export { default as Field } from './components/Field.svelte';
39
39
  export { default as FormMessage } from './components/FormMessage.svelte';
40
40
  export { default as SegmentedControl } from './components/SegmentedControl.svelte';
41
+ export { default as FilterChips } from './components/FilterChips.svelte';
41
42
  export { default as Select } from './components/Select.svelte';
42
43
  export { default as MultiSelect } from './components/MultiSelect.svelte';
43
44
  export { default as Autocomplete } from './components/Autocomplete.svelte';
@@ -107,4 +108,4 @@ export * as motion from './motion.js';
107
108
  // ── Version ────────────────────────────────────────────────────────────────
108
109
  // Generated from package.json by `pnpm version:sync`. Bump package.json, never
109
110
  // this line. The build and the test suite fail when the two disagree.
110
- export const VERSION = '0.33.1';
111
+ export const VERSION = '0.35.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyeve-labs/ui-kit",
3
- "version": "0.33.1",
3
+ "version": "0.35.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>",