@lyeve-labs/ui-kit 0.8.4 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/components/AccordionItem.svelte +1 -1
  2. package/dist/components/Alert.svelte +42 -15
  3. package/dist/components/Alert.svelte.d.ts +2 -2
  4. package/dist/components/Autocomplete.svelte +32 -12
  5. package/dist/components/Avatar.svelte +4 -8
  6. package/dist/components/Badge.svelte +4 -4
  7. package/dist/components/Badge.svelte.d.ts +2 -2
  8. package/dist/components/Banner.svelte +10 -9
  9. package/dist/components/Banner.svelte.d.ts +2 -2
  10. package/dist/components/Breadcrumb.svelte +4 -1
  11. package/dist/components/Button.svelte +4 -2
  12. package/dist/components/Button.svelte.d.ts +1 -0
  13. package/dist/components/Card.svelte +1 -1
  14. package/dist/components/Checkbox.svelte +7 -4
  15. package/dist/components/Checkbox.svelte.d.ts +1 -0
  16. package/dist/components/DatePicker.svelte +19 -11
  17. package/dist/components/Drawer.svelte +13 -2
  18. package/dist/components/Dropdown.svelte +1 -1
  19. package/dist/components/FileInput.svelte +14 -5
  20. package/dist/components/Indicator.svelte +3 -3
  21. package/dist/components/Indicator.svelte.d.ts +2 -2
  22. package/dist/components/Input.svelte +17 -8
  23. package/dist/components/Modal.svelte +11 -2
  24. package/dist/components/MultiSelect.svelte +33 -12
  25. package/dist/components/NumberInput.svelte +68 -13
  26. package/dist/components/NumberInput.svelte.d.ts +3 -0
  27. package/dist/components/PageHeader.svelte +3 -1
  28. package/dist/components/Pagination.svelte +1 -1
  29. package/dist/components/Progress.svelte +4 -3
  30. package/dist/components/Progress.svelte.d.ts +2 -2
  31. package/dist/components/Radio.svelte +6 -3
  32. package/dist/components/Radio.svelte.d.ts +1 -0
  33. package/dist/components/RadioGroup.svelte +6 -5
  34. package/dist/components/SearchInput.svelte +64 -49
  35. package/dist/components/SearchInput.svelte.d.ts +3 -0
  36. package/dist/components/Select.svelte +29 -12
  37. package/dist/components/Select.svelte.d.ts +2 -0
  38. package/dist/components/StepIndicator.svelte +7 -5
  39. package/dist/components/Table.svelte +3 -1
  40. package/dist/components/Tabs.svelte +1 -1
  41. package/dist/components/Tag.svelte +3 -3
  42. package/dist/components/Tag.svelte.d.ts +2 -2
  43. package/dist/components/Textarea.svelte +28 -16
  44. package/dist/components/Textarea.svelte.d.ts +1 -0
  45. package/dist/components/ThemeToggle.svelte +1 -1
  46. package/dist/components/Toaster.svelte +34 -12
  47. package/dist/components/Toggle.svelte +6 -3
  48. package/dist/components/Toggle.svelte.d.ts +1 -0
  49. package/dist/components/dialog/Dialog.svelte +1 -1
  50. package/dist/index.d.ts +1 -1
  51. package/dist/index.js +3 -1
  52. package/dist/internal/field.d.ts +48 -0
  53. package/dist/internal/field.js +62 -0
  54. package/dist/internal/tone.d.ts +27 -0
  55. package/dist/internal/tone.js +29 -0
  56. package/dist/stores/toast.svelte.d.ts +6 -1
  57. package/dist/stores/toast.svelte.js +1 -1
  58. package/dist/styles/theme.css +30 -4
  59. package/dist/utils/broken-image.d.ts +21 -0
  60. package/dist/utils/broken-image.js +40 -0
  61. package/package.json +7 -6
  62. package/src/lib/styles/theme.css +30 -4
@@ -1,4 +1,14 @@
1
1
  <script lang="ts">
2
+ import {
3
+ CONTROL_BASE,
4
+ FIELD_ERROR,
5
+ FIELD_HINT,
6
+ FIELD_LABEL,
7
+ FIELD_WRAP,
8
+ controlBorder,
9
+ describedBy,
10
+ } from '../internal/field.js';
11
+
2
12
  interface Props {
3
13
  value?: string;
4
14
  label?: string;
@@ -31,10 +41,10 @@
31
41
  const fieldId = $derived(id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined));
32
42
  </script>
33
43
 
34
- <div class="flex flex-col gap-1.5 {klass}">
44
+ <div class="{FIELD_WRAP} {klass}">
35
45
  {#if label}
36
- <label for={fieldId} class="text-sm font-medium text-fg">
37
- {label}{#if required}<span class="text-danger ml-0.5">*</span>{/if}
46
+ <label for={fieldId} class={FIELD_LABEL}>
47
+ {label}{#if required}<span class="text-danger ml-0.5" aria-label="required">*</span>{/if}
38
48
  </label>
39
49
  {/if}
40
50
 
@@ -46,15 +56,14 @@
46
56
  {required}
47
57
  bind:value
48
58
  aria-invalid={error ? 'true' : undefined}
49
- class="w-full rounded-lg bg-surface-2 border px-3 py-2 text-sm text-fg placeholder:text-faint
50
- transition-colors outline-none disabled:opacity-50 disabled:cursor-not-allowed
51
- {error ? 'border-danger focus:border-danger' : 'border-line focus:border-brand'}"
59
+ aria-describedby={describedBy(fieldId, error, hint)}
60
+ class="{CONTROL_BASE} {controlBorder(!!error)}"
52
61
  {...rest}
53
62
  />
54
63
 
55
64
  {#if error}
56
- <p class="text-xs text-danger">{error}</p>
65
+ <p id={fieldId ? `${fieldId}-error` : undefined} class={FIELD_ERROR}>{error}</p>
57
66
  {:else if hint}
58
- <p class="text-xs text-faint">{hint}</p>
67
+ <p id={fieldId ? `${fieldId}-hint` : undefined} class={FIELD_HINT}>{hint}</p>
59
68
  {/if}
60
69
  </div>
@@ -68,10 +68,19 @@
68
68
  <button
69
69
  type="button"
70
70
  onclick={close}
71
- class="text-faint hover:text-fg transition-colors text-xl leading-none -mt-1"
71
+ class="shrink-0 text-faint transition-colors duration-150 hover:text-fg"
72
72
  aria-label="Close"
73
73
  >
74
- ×
74
+ <svg
75
+ width="16"
76
+ height="16"
77
+ viewBox="0 0 24 24"
78
+ fill="none"
79
+ stroke="currentColor"
80
+ stroke-width="2"
81
+ stroke-linecap="round"
82
+ aria-hidden="true"><path d="M18 6L6 18M6 6l12 12" /></svg
83
+ >
75
84
  </button>
76
85
  </div>
77
86
  {/if}
@@ -1,4 +1,12 @@
1
1
  <script lang="ts">
2
+ import {
3
+ FIELD_ERROR,
4
+ FIELD_HINT,
5
+ FIELD_LABEL,
6
+ FIELD_WRAP,
7
+ controlBorder,
8
+ describedBy,
9
+ } from '../internal/field.js';
2
10
  // MultiSelect: pick several options, shown as removable chips, with an optional
3
11
  // search box. Binds to a string[] of selected values.
4
12
  interface Option {
@@ -85,10 +93,10 @@
85
93
  });
86
94
  </script>
87
95
 
88
- <div class="flex flex-col gap-1.5 {cls}" bind:this={containerEl}>
96
+ <div class="{FIELD_WRAP} {cls}" bind:this={containerEl}>
89
97
  {#if label}
90
- <label for={id} class="text-sm font-medium text-fg">
91
- {label}{#if required}<span class="text-danger ml-0.5">*</span>{/if}
98
+ <label for={id} class={FIELD_LABEL}>
99
+ {label}{#if required}<span class="text-danger ml-0.5" aria-label="required">*</span>{/if}
92
100
  </label>
93
101
  {/if}
94
102
 
@@ -108,11 +116,11 @@
108
116
  open ? (open = false) : open_();
109
117
  }
110
118
  }}
111
- class="w-full min-h-[2.375rem] flex flex-wrap items-center gap-1.5 rounded-lg border bg-surface-2
112
- px-2.5 py-1.5 text-left text-sm transition-colors outline-none cursor-pointer
113
- focus-visible:border-brand
119
+ aria-describedby={describedBy(id, error, hint)}
120
+ class="w-full min-h-control flex flex-wrap items-center gap-1.5 rounded-lg border bg-surface-2
121
+ px-2.5 py-1.5 text-left text-sm transition-colors duration-150 outline-none cursor-pointer
114
122
  {disabled ? 'opacity-50 cursor-not-allowed' : ''}
115
- {error ? 'border-danger focus:border-danger' : 'border-line focus:border-brand'}"
123
+ {controlBorder(!!error)}"
116
124
  >
117
125
  {#if selected.length === 0}
118
126
  <span class="text-faint px-0.5">{placeholder}</span>
@@ -129,8 +137,21 @@
129
137
  e.stopPropagation();
130
138
  remove(opt.value);
131
139
  }}
132
- class="hover:text-brand-light leading-none">×</button
140
+ class="transition-colors duration-150 hover:text-brand-light"
133
141
  >
142
+ <svg
143
+ width="11"
144
+ height="11"
145
+ viewBox="0 0 24 24"
146
+ fill="none"
147
+ stroke="currentColor"
148
+ stroke-width="2.5"
149
+ stroke-linecap="round"
150
+ aria-hidden="true"
151
+ >
152
+ <path d="M18 6L6 18M6 6l12 12" />
153
+ </svg>
154
+ </button>
134
155
  </span>
135
156
  {/each}
136
157
  {/if}
@@ -174,11 +195,11 @@
174
195
  aria-selected={isSelected(opt.value)}
175
196
  disabled={opt.disabled}
176
197
  onclick={() => toggle(opt)}
177
- class="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-fg transition-colors
198
+ class="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-fg transition-colors duration-150
178
199
  hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed"
179
200
  >
180
201
  <span
181
- class="flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors
202
+ class="flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors duration-150
182
203
  {isSelected(opt.value) ? 'bg-brand border-brand' : 'bg-surface-2 border-line'}"
183
204
  >
184
205
  {#if isSelected(opt.value)}
@@ -205,8 +226,8 @@
205
226
  </div>
206
227
 
207
228
  {#if error}
208
- <p class="text-xs text-danger">{error}</p>
229
+ <p id={id ? `${id}-error` : undefined} class={FIELD_ERROR}>{error}</p>
209
230
  {:else if hint}
210
- <p class="text-xs text-faint">{hint}</p>
231
+ <p id={id ? `${id}-hint` : undefined} class={FIELD_HINT}>{hint}</p>
211
232
  {/if}
212
233
  </div>
@@ -1,12 +1,23 @@
1
1
  <script lang="ts">
2
+ import {
3
+ FIELD_ERROR,
4
+ FIELD_HINT,
5
+ FIELD_LABEL,
6
+ FIELD_WRAP,
7
+ describedBy,
8
+ } from '../internal/field.js';
9
+
2
10
  interface Props {
3
11
  value?: number;
4
12
  min?: number;
5
13
  max?: number;
6
14
  step?: number;
7
15
  disabled?: boolean;
16
+ required?: boolean;
8
17
  id?: string;
9
18
  name?: string;
19
+ label?: string;
20
+ hint?: string;
10
21
  error?: string;
11
22
  class?: string;
12
23
  onchange?: (v: number) => void;
@@ -18,13 +29,18 @@
18
29
  max = undefined,
19
30
  step = 1,
20
31
  disabled = false,
32
+ required = false,
21
33
  id = undefined,
22
34
  name = undefined,
35
+ label = undefined,
36
+ hint = undefined,
23
37
  error = undefined,
24
38
  class: cls = '',
25
39
  onchange = undefined,
26
40
  }: Props = $props();
27
41
 
42
+ const fieldId = $derived(id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined));
43
+
28
44
  function dec() {
29
45
  const next = value - step;
30
46
  if (min !== undefined && next < min) return;
@@ -49,33 +65,57 @@
49
65
 
50
66
  let canDec = $derived(!(disabled || (min !== undefined && value <= min)));
51
67
  let canInc = $derived(!(disabled || (max !== undefined && value >= max)));
68
+
69
+ const step_ =
70
+ 'flex w-control shrink-0 items-center justify-center border-line bg-surface-2 text-muted ' +
71
+ 'transition-colors duration-150 hover:bg-line hover:text-fg ' +
72
+ 'disabled:opacity-50 disabled:cursor-not-allowed';
52
73
  </script>
53
74
 
54
- <div class="flex flex-col gap-1 {cls}">
55
- <div class="flex h-9">
75
+ <div class="{FIELD_WRAP} {cls}">
76
+ {#if label}
77
+ <label for={fieldId} class={FIELD_LABEL}>
78
+ {label}{#if required}<span class="text-danger ml-0.5" aria-label="required">*</span>{/if}
79
+ </label>
80
+ {/if}
81
+
82
+ <div class="flex h-control">
56
83
  <button
57
84
  type="button"
58
85
  onclick={dec}
59
86
  disabled={!canDec}
60
87
  aria-label="Decrease"
61
- class="flex w-9 shrink-0 items-center justify-center rounded-l-lg border border-r-0 border-line
62
- bg-surface-2 text-lg leading-none text-muted transition-colors
63
- hover:bg-line hover:text-fg
64
- disabled:cursor-not-allowed disabled:opacity-40">−</button
88
+ class="{step_} rounded-l-lg border border-r-0"
65
89
  >
90
+ <svg
91
+ width="14"
92
+ height="14"
93
+ viewBox="0 0 24 24"
94
+ fill="none"
95
+ stroke="currentColor"
96
+ stroke-width="2"
97
+ stroke-linecap="round"
98
+ aria-hidden="true"
99
+ >
100
+ <path d="M5 12h14" />
101
+ </svg>
102
+ </button>
66
103
 
67
104
  <input
68
105
  type="number"
69
- {id}
106
+ id={fieldId}
70
107
  {name}
71
108
  {min}
72
109
  {max}
73
110
  {step}
74
111
  {disabled}
112
+ {required}
75
113
  {value}
76
114
  oninput={handleInput}
115
+ aria-invalid={error ? 'true' : undefined}
116
+ aria-describedby={describedBy(fieldId, error, hint)}
77
117
  class="min-w-0 flex-1 border-y bg-surface-2 text-center text-sm text-fg
78
- focus:outline-none focus:border-brand transition-colors
118
+ outline-none transition-colors duration-150 focus:border-brand
79
119
  disabled:cursor-not-allowed disabled:opacity-50
80
120
  [appearance:textfield]
81
121
  [&::-webkit-inner-spin-button]:appearance-none
@@ -88,11 +128,26 @@
88
128
  onclick={inc}
89
129
  disabled={!canInc}
90
130
  aria-label="Increase"
91
- class="flex w-9 shrink-0 items-center justify-center rounded-r-lg border border-l-0 border-line
92
- bg-surface-2 text-lg leading-none text-muted transition-colors
93
- hover:bg-line hover:text-fg
94
- disabled:cursor-not-allowed disabled:opacity-40">+</button
131
+ class="{step_} rounded-r-lg border border-l-0"
95
132
  >
133
+ <svg
134
+ width="14"
135
+ height="14"
136
+ viewBox="0 0 24 24"
137
+ fill="none"
138
+ stroke="currentColor"
139
+ stroke-width="2"
140
+ stroke-linecap="round"
141
+ aria-hidden="true"
142
+ >
143
+ <path d="M12 5v14M5 12h14" />
144
+ </svg>
145
+ </button>
96
146
  </div>
97
- {#if error}<p class="text-xs text-danger">{error}</p>{/if}
147
+
148
+ {#if error}
149
+ <p id={fieldId ? `${fieldId}-error` : undefined} class={FIELD_ERROR}>{error}</p>
150
+ {:else if hint}
151
+ <p id={fieldId ? `${fieldId}-hint` : undefined} class={FIELD_HINT}>{hint}</p>
152
+ {/if}
98
153
  </div>
@@ -4,8 +4,11 @@ interface Props {
4
4
  max?: number;
5
5
  step?: number;
6
6
  disabled?: boolean;
7
+ required?: boolean;
7
8
  id?: string;
8
9
  name?: string;
10
+ label?: string;
11
+ hint?: string;
9
12
  error?: string;
10
13
  class?: string;
11
14
  onchange?: (v: number) => void;
@@ -13,7 +13,9 @@
13
13
 
14
14
  <header class="mb-8 flex flex-wrap items-start justify-between gap-4 {klass}">
15
15
  <div class="min-w-0">
16
- <h1 class="text-2xl font-bold text-fg">{title}</h1>
16
+ <!-- The app shells render their own h1 for the current route, so a heading
17
+ lookup by name matches two elements. This names the page's own title. -->
18
+ <h1 data-testid="page-title" class="text-2xl font-bold text-fg">{title}</h1>
17
19
  {#if description}<p class="mt-1 text-sm text-muted">{description}</p>{/if}
18
20
  </div>
19
21
  {#if actions}
@@ -34,7 +34,7 @@
34
34
  let nums = $derived(pageNumbers(safePage, totalPages));
35
35
 
36
36
  const btnBase =
37
- 'inline-flex items-center justify-center w-7 h-7 rounded text-xs font-medium transition-colors';
37
+ 'inline-flex items-center justify-center w-7 h-7 rounded text-xs font-medium transition-colors duration-150';
38
38
  </script>
39
39
 
40
40
  {#if totalPages > 1}
@@ -1,11 +1,11 @@
1
1
  <script lang="ts">
2
- type Tone = 'brand' | 'violet' | 'success' | 'warn' | 'danger';
2
+ import type { AccentTone } from '../internal/tone.js';
3
3
  type Size = 'xs' | 'sm' | 'md';
4
4
 
5
5
  interface Props {
6
6
  value: number;
7
7
  max?: number;
8
- tone?: Tone;
8
+ tone?: AccentTone;
9
9
  size?: Size;
10
10
  label?: string;
11
11
  showValue?: boolean;
@@ -30,7 +30,8 @@
30
30
  : 0,
31
31
  );
32
32
 
33
- const tones: Record<Tone, string> = {
33
+ const tones: Record<AccentTone, string> = {
34
+ neutral: 'bg-muted',
34
35
  brand: 'bg-brand',
35
36
  violet: 'bg-violet',
36
37
  success: 'bg-success',
@@ -1,9 +1,9 @@
1
- type Tone = 'brand' | 'violet' | 'success' | 'warn' | 'danger';
1
+ import type { AccentTone } from '../internal/tone.js';
2
2
  type Size = 'xs' | 'sm' | 'md';
3
3
  interface Props {
4
4
  value: number;
5
5
  max?: number;
6
- tone?: Tone;
6
+ tone?: AccentTone;
7
7
  size?: Size;
8
8
  label?: string;
9
9
  showValue?: boolean;
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { FIELD_HINT } from '../internal/field.js';
2
3
  interface Props {
3
4
  value: string;
4
5
  group?: string;
@@ -8,6 +9,7 @@
8
9
  disabled?: boolean;
9
10
  id?: string;
10
11
  name?: string;
12
+ class?: string;
11
13
  onchange?: (value: string) => void;
12
14
  }
13
15
 
@@ -20,6 +22,7 @@
20
22
  disabled = false,
21
23
  id = undefined,
22
24
  name = undefined,
25
+ class: cls = '',
23
26
  onchange = undefined,
24
27
  }: Props = $props();
25
28
 
@@ -31,7 +34,7 @@
31
34
 
32
35
  <label
33
36
  class="inline-flex cursor-pointer select-none items-start gap-2.5
34
- {disabled ? 'cursor-not-allowed opacity-50' : ''}"
37
+ {disabled ? 'cursor-not-allowed opacity-50' : ''} {cls}"
35
38
  >
36
39
  <span class="relative mt-0.5 flex shrink-0 items-center justify-center">
37
40
  <input
@@ -46,7 +49,7 @@
46
49
  class="peer sr-only"
47
50
  />
48
51
  <span
49
- class="flex h-4 w-4 items-center justify-center rounded-full border-2 transition-colors
52
+ class="flex h-4 w-4 items-center justify-center rounded-full border-2 transition-colors duration-150
50
53
  {group === value
51
54
  ? 'border-brand bg-surface-2'
52
55
  : 'border-line bg-surface-2 peer-focus-visible:border-brand'}"
@@ -59,7 +62,7 @@
59
62
  {#if label || hint}
60
63
  <span class="flex flex-col gap-0.5">
61
64
  {#if label}<span class="text-sm text-fg">{label}</span>{/if}
62
- {#if hint}<span class="text-xs text-faint">{hint}</span>{/if}
65
+ {#if hint}<span class={FIELD_HINT}>{hint}</span>{/if}
63
66
  </span>
64
67
  {/if}
65
68
  </label>
@@ -7,6 +7,7 @@ interface Props {
7
7
  disabled?: boolean;
8
8
  id?: string;
9
9
  name?: string;
10
+ class?: string;
10
11
  onchange?: (value: string) => void;
11
12
  }
12
13
  declare const Radio: import("svelte").Component<Props, {}, "group">;
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { FIELD_ERROR, FIELD_HINT, FIELD_LABEL } from '../internal/field.js';
2
3
  // RadioGroup renders a managed set of radio options bound to a single value.
3
4
  // Matches the visual of Radio.svelte; use this when you have a fixed option list.
4
5
  interface Option {
@@ -46,7 +47,7 @@
46
47
 
47
48
  <fieldset class="flex flex-col gap-2 {cls}" {disabled}>
48
49
  {#if label}
49
- <legend class="text-sm font-medium text-fg mb-0.5">
50
+ <legend class="{FIELD_LABEL} mb-0.5">
50
51
  {label}{#if required}<span class="text-danger ml-0.5">*</span>{/if}
51
52
  </legend>
52
53
  {/if}
@@ -70,7 +71,7 @@
70
71
  class="peer sr-only"
71
72
  />
72
73
  <span
73
- class="flex h-4 w-4 items-center justify-center rounded-full border-2 transition-colors
74
+ class="flex h-4 w-4 items-center justify-center rounded-full border-2 transition-colors duration-150
74
75
  {value === opt.value
75
76
  ? 'border-brand bg-surface-2'
76
77
  : 'border-line bg-surface-2 peer-focus-visible:border-brand'}"
@@ -82,15 +83,15 @@
82
83
  </span>
83
84
  <span class="flex flex-col gap-0.5">
84
85
  <span class="text-sm text-fg">{opt.label}</span>
85
- {#if opt.hint}<span class="text-xs text-faint">{opt.hint}</span>{/if}
86
+ {#if opt.hint}<span class={FIELD_HINT}>{opt.hint}</span>{/if}
86
87
  </span>
87
88
  </label>
88
89
  {/each}
89
90
  </div>
90
91
 
91
92
  {#if error}
92
- <p class="text-xs text-danger">{error}</p>
93
+ <p class={FIELD_ERROR}>{error}</p>
93
94
  {:else if hint}
94
- <p class="text-xs text-faint">{hint}</p>
95
+ <p class={FIELD_HINT}>{hint}</p>
95
96
  {/if}
96
97
  </fieldset>
@@ -1,20 +1,30 @@
1
1
  <script lang="ts">
2
+ import { CONTROL_BASE, FIELD_LABEL, FIELD_WRAP, controlBorder } from '../internal/field.js';
3
+
2
4
  interface Props {
3
5
  value?: string;
6
+ label?: string;
4
7
  placeholder?: string;
5
8
  disabled?: boolean;
9
+ id?: string;
10
+ name?: string;
6
11
  class?: string;
7
12
  oninput?: (value: string) => void;
8
13
  }
9
14
 
10
15
  let {
11
16
  value = $bindable(''),
17
+ label = undefined,
12
18
  placeholder = 'Search…',
13
19
  disabled = false,
20
+ id = undefined,
21
+ name = undefined,
14
22
  class: cls = '',
15
23
  oninput = undefined,
16
24
  }: Props = $props();
17
25
 
26
+ const fieldId = $derived(id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined));
27
+
18
28
  function handleInput(e: Event & { currentTarget: HTMLInputElement }) {
19
29
  value = e.currentTarget.value;
20
30
  oninput?.(value);
@@ -26,56 +36,61 @@
26
36
  }
27
37
  </script>
28
38
 
29
- <div class="relative {cls}">
30
- <svg
31
- width="15"
32
- height="15"
33
- viewBox="0 0 24 24"
34
- fill="none"
35
- stroke="currentColor"
36
- stroke-width="2"
37
- stroke-linecap="round"
38
- stroke-linejoin="round"
39
- class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-faint"
40
- aria-hidden="true"
41
- >
42
- <circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
43
- </svg>
44
-
45
- <input
46
- type="search"
47
- {placeholder}
48
- {disabled}
49
- {value}
50
- oninput={handleInput}
51
- class="w-full rounded-lg border border-line bg-surface-2
52
- py-2 pl-9 text-sm text-fg placeholder:text-faint
53
- focus:border-brand focus:outline-none transition-colors
54
- disabled:cursor-not-allowed disabled:opacity-50
55
- [&::-webkit-search-cancel-button]:appearance-none
56
- {value ? 'pr-8' : 'pr-3'}"
57
- />
39
+ <div class="{FIELD_WRAP} {cls}">
40
+ {#if label}
41
+ <label for={fieldId} class={FIELD_LABEL}>{label}</label>
42
+ {/if}
58
43
 
59
- {#if value}
60
- <button
61
- type="button"
62
- onclick={clear}
63
- aria-label="Clear search"
64
- class="absolute right-2.5 top-1/2 -translate-y-1/2 text-faint
65
- transition-colors hover:text-fg"
44
+ <div class="relative">
45
+ <svg
46
+ width="15"
47
+ height="15"
48
+ viewBox="0 0 24 24"
49
+ fill="none"
50
+ stroke="currentColor"
51
+ stroke-width="2"
52
+ stroke-linecap="round"
53
+ stroke-linejoin="round"
54
+ class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-faint"
55
+ aria-hidden="true"
66
56
  >
67
- <svg
68
- width="14"
69
- height="14"
70
- viewBox="0 0 24 24"
71
- fill="none"
72
- stroke="currentColor"
73
- stroke-width="2"
74
- stroke-linecap="round"
75
- aria-hidden="true"
57
+ <circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
58
+ </svg>
59
+
60
+ <input
61
+ type="search"
62
+ id={fieldId}
63
+ {name}
64
+ {placeholder}
65
+ {disabled}
66
+ {value}
67
+ oninput={handleInput}
68
+ class="{CONTROL_BASE} {controlBorder(false)} pl-9
69
+ [&::-webkit-search-cancel-button]:appearance-none
70
+ {value ? 'pr-8' : 'pr-3'}"
71
+ />
72
+
73
+ {#if value}
74
+ <button
75
+ type="button"
76
+ onclick={clear}
77
+ aria-label="Clear search"
78
+ class="absolute right-2.5 top-1/2 -translate-y-1/2 text-faint
79
+ transition-colors duration-150 hover:text-fg"
76
80
  >
77
- <path d="M18 6L6 18M6 6l12 12" />
78
- </svg>
79
- </button>
80
- {/if}
81
+ <svg
82
+ width="14"
83
+ height="14"
84
+ viewBox="0 0 24 24"
85
+ fill="none"
86
+ stroke="currentColor"
87
+ stroke-width="2"
88
+ stroke-linecap="round"
89
+ aria-hidden="true"
90
+ >
91
+ <path d="M18 6L6 18M6 6l12 12" />
92
+ </svg>
93
+ </button>
94
+ {/if}
95
+ </div>
81
96
  </div>
@@ -1,7 +1,10 @@
1
1
  interface Props {
2
2
  value?: string;
3
+ label?: string;
3
4
  placeholder?: string;
4
5
  disabled?: boolean;
6
+ id?: string;
7
+ name?: string;
5
8
  class?: string;
6
9
  oninput?: (value: string) => void;
7
10
  }