@delightstack/components 0.1.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 (195) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -0
  3. package/SKILL.md +149 -0
  4. package/bin/agents.js +63 -0
  5. package/dist/actions/Alert.svelte +202 -0
  6. package/dist/actions/Alert.svelte.d.ts +36 -0
  7. package/dist/actions/Alert.svelte.d.ts.map +1 -0
  8. package/dist/actions/Button.svelte +1450 -0
  9. package/dist/actions/Button.svelte.d.ts +56 -0
  10. package/dist/actions/Button.svelte.d.ts.map +1 -0
  11. package/dist/actions/ButtonGroup.svelte +111 -0
  12. package/dist/actions/ButtonGroup.svelte.d.ts +41 -0
  13. package/dist/actions/ButtonGroup.svelte.d.ts.map +1 -0
  14. package/dist/actions/CommandPalette.svelte +939 -0
  15. package/dist/actions/CommandPalette.svelte.d.ts +37 -0
  16. package/dist/actions/CommandPalette.svelte.d.ts.map +1 -0
  17. package/dist/actions/ContextMenu.svelte +138 -0
  18. package/dist/actions/ContextMenu.svelte.d.ts +54 -0
  19. package/dist/actions/ContextMenu.svelte.d.ts.map +1 -0
  20. package/dist/actions/Modal.svelte +474 -0
  21. package/dist/actions/Modal.svelte.d.ts +28 -0
  22. package/dist/actions/Modal.svelte.d.ts.map +1 -0
  23. package/dist/actions/Popover.svelte +1214 -0
  24. package/dist/actions/Popover.svelte.d.ts +31 -0
  25. package/dist/actions/Popover.svelte.d.ts.map +1 -0
  26. package/dist/actions/Portal.svelte +80 -0
  27. package/dist/actions/Portal.svelte.d.ts +17 -0
  28. package/dist/actions/Portal.svelte.d.ts.map +1 -0
  29. package/dist/actions/ThemeToggle.svelte +345 -0
  30. package/dist/actions/ThemeToggle.svelte.d.ts +15 -0
  31. package/dist/actions/ThemeToggle.svelte.d.ts.map +1 -0
  32. package/dist/actions/index.d.ts +13 -0
  33. package/dist/actions/index.d.ts.map +1 -0
  34. package/dist/actions/index.js +10 -0
  35. package/dist/actions/scrollbar.d.ts +48 -0
  36. package/dist/actions/scrollbar.d.ts.map +1 -0
  37. package/dist/actions/scrollbar.js +404 -0
  38. package/dist/display/Accordion.svelte +586 -0
  39. package/dist/display/Accordion.svelte.d.ts +41 -0
  40. package/dist/display/Accordion.svelte.d.ts.map +1 -0
  41. package/dist/display/Avatar.svelte +527 -0
  42. package/dist/display/Avatar.svelte.d.ts +22 -0
  43. package/dist/display/Avatar.svelte.d.ts.map +1 -0
  44. package/dist/display/AvatarGroup.svelte +298 -0
  45. package/dist/display/AvatarGroup.svelte.d.ts +31 -0
  46. package/dist/display/AvatarGroup.svelte.d.ts.map +1 -0
  47. package/dist/display/Calendar.svelte +1366 -0
  48. package/dist/display/Calendar.svelte.d.ts +58 -0
  49. package/dist/display/Calendar.svelte.d.ts.map +1 -0
  50. package/dist/display/Chart.svelte +1426 -0
  51. package/dist/display/Chart.svelte.d.ts +35 -0
  52. package/dist/display/Chart.svelte.d.ts.map +1 -0
  53. package/dist/display/Code.svelte +780 -0
  54. package/dist/display/Code.svelte.d.ts +19 -0
  55. package/dist/display/Code.svelte.d.ts.map +1 -0
  56. package/dist/display/Comparison.svelte +686 -0
  57. package/dist/display/Comparison.svelte.d.ts +22 -0
  58. package/dist/display/Comparison.svelte.d.ts.map +1 -0
  59. package/dist/display/Counter.svelte +285 -0
  60. package/dist/display/Counter.svelte.d.ts +21 -0
  61. package/dist/display/Counter.svelte.d.ts.map +1 -0
  62. package/dist/display/Expand.svelte +48 -0
  63. package/dist/display/Expand.svelte.d.ts +9 -0
  64. package/dist/display/Expand.svelte.d.ts.map +1 -0
  65. package/dist/display/List.svelte +294 -0
  66. package/dist/display/List.svelte.d.ts +40 -0
  67. package/dist/display/List.svelte.d.ts.map +1 -0
  68. package/dist/display/ListContextReset.svelte +19 -0
  69. package/dist/display/ListContextReset.svelte.d.ts +7 -0
  70. package/dist/display/ListContextReset.svelte.d.ts.map +1 -0
  71. package/dist/display/ListItem.svelte +834 -0
  72. package/dist/display/ListItem.svelte.d.ts +22 -0
  73. package/dist/display/ListItem.svelte.d.ts.map +1 -0
  74. package/dist/display/QR.svelte +1193 -0
  75. package/dist/display/QR.svelte.d.ts +23 -0
  76. package/dist/display/QR.svelte.d.ts.map +1 -0
  77. package/dist/display/SplitPane.svelte +744 -0
  78. package/dist/display/SplitPane.svelte.d.ts +25 -0
  79. package/dist/display/SplitPane.svelte.d.ts.map +1 -0
  80. package/dist/display/Stat.svelte +439 -0
  81. package/dist/display/Stat.svelte.d.ts +24 -0
  82. package/dist/display/Stat.svelte.d.ts.map +1 -0
  83. package/dist/display/Table.svelte +4654 -0
  84. package/dist/display/Table.svelte.d.ts +249 -0
  85. package/dist/display/Table.svelte.d.ts.map +1 -0
  86. package/dist/display/TableCellEditor.svelte +935 -0
  87. package/dist/display/TableCellEditor.svelte.d.ts +58 -0
  88. package/dist/display/TableCellEditor.svelte.d.ts.map +1 -0
  89. package/dist/display/Timeline.svelte +1258 -0
  90. package/dist/display/Timeline.svelte.d.ts +43 -0
  91. package/dist/display/Timeline.svelte.d.ts.map +1 -0
  92. package/dist/display/Tree.svelte +1740 -0
  93. package/dist/display/Tree.svelte.d.ts +74 -0
  94. package/dist/display/Tree.svelte.d.ts.map +1 -0
  95. package/dist/display/Typewriter.svelte +338 -0
  96. package/dist/display/Typewriter.svelte.d.ts +22 -0
  97. package/dist/display/Typewriter.svelte.d.ts.map +1 -0
  98. package/dist/display/index.d.ts +24 -0
  99. package/dist/display/index.d.ts.map +1 -0
  100. package/dist/display/index.js +18 -0
  101. package/dist/feedback/Callout.svelte +529 -0
  102. package/dist/feedback/Callout.svelte.d.ts +24 -0
  103. package/dist/feedback/Callout.svelte.d.ts.map +1 -0
  104. package/dist/feedback/Confetti.svelte +631 -0
  105. package/dist/feedback/Confetti.svelte.d.ts +90 -0
  106. package/dist/feedback/Confetti.svelte.d.ts.map +1 -0
  107. package/dist/feedback/Progress.svelte +382 -0
  108. package/dist/feedback/Progress.svelte.d.ts +25 -0
  109. package/dist/feedback/Progress.svelte.d.ts.map +1 -0
  110. package/dist/feedback/Toast.svelte +967 -0
  111. package/dist/feedback/Toast.svelte.d.ts +54 -0
  112. package/dist/feedback/Toast.svelte.d.ts.map +1 -0
  113. package/dist/feedback/index.d.ts +7 -0
  114. package/dist/feedback/index.d.ts.map +1 -0
  115. package/dist/feedback/index.js +4 -0
  116. package/dist/form/Checkbox.svelte +449 -0
  117. package/dist/form/Checkbox.svelte.d.ts +27 -0
  118. package/dist/form/Checkbox.svelte.d.ts.map +1 -0
  119. package/dist/form/Fieldset.svelte +410 -0
  120. package/dist/form/Fieldset.svelte.d.ts +22 -0
  121. package/dist/form/Fieldset.svelte.d.ts.map +1 -0
  122. package/dist/form/FileUpload.svelte +934 -0
  123. package/dist/form/FileUpload.svelte.d.ts +41 -0
  124. package/dist/form/FileUpload.svelte.d.ts.map +1 -0
  125. package/dist/form/Form.svelte +530 -0
  126. package/dist/form/Form.svelte.d.ts +120 -0
  127. package/dist/form/Form.svelte.d.ts.map +1 -0
  128. package/dist/form/Input.svelte +2858 -0
  129. package/dist/form/Input.svelte.d.ts +66 -0
  130. package/dist/form/Input.svelte.d.ts.map +1 -0
  131. package/dist/form/Radio.svelte +507 -0
  132. package/dist/form/Radio.svelte.d.ts +39 -0
  133. package/dist/form/Radio.svelte.d.ts.map +1 -0
  134. package/dist/form/Range.svelte +912 -0
  135. package/dist/form/Range.svelte.d.ts +33 -0
  136. package/dist/form/Range.svelte.d.ts.map +1 -0
  137. package/dist/form/Rating.svelte +429 -0
  138. package/dist/form/Rating.svelte.d.ts +28 -0
  139. package/dist/form/Rating.svelte.d.ts.map +1 -0
  140. package/dist/form/Select.svelte +1933 -0
  141. package/dist/form/Select.svelte.d.ts +54 -0
  142. package/dist/form/Select.svelte.d.ts.map +1 -0
  143. package/dist/form/Toggle.svelte +645 -0
  144. package/dist/form/Toggle.svelte.d.ts +50 -0
  145. package/dist/form/Toggle.svelte.d.ts.map +1 -0
  146. package/dist/form/index.d.ts +15 -0
  147. package/dist/form/index.d.ts.map +1 -0
  148. package/dist/form/index.js +10 -0
  149. package/dist/index.d.ts +7 -0
  150. package/dist/index.d.ts.map +1 -0
  151. package/dist/index.js +6 -0
  152. package/dist/layout/README.md +172 -0
  153. package/dist/media/Carousel.svelte +2424 -0
  154. package/dist/media/Carousel.svelte.d.ts +47 -0
  155. package/dist/media/Carousel.svelte.d.ts.map +1 -0
  156. package/dist/media/Gallery.svelte +2881 -0
  157. package/dist/media/Gallery.svelte.d.ts +82 -0
  158. package/dist/media/Gallery.svelte.d.ts.map +1 -0
  159. package/dist/media/Image.svelte +389 -0
  160. package/dist/media/Image.svelte.d.ts +33 -0
  161. package/dist/media/Image.svelte.d.ts.map +1 -0
  162. package/dist/media/PDF.svelte +1793 -0
  163. package/dist/media/PDF.svelte.d.ts +44 -0
  164. package/dist/media/PDF.svelte.d.ts.map +1 -0
  165. package/dist/media/Panorama.svelte +1391 -0
  166. package/dist/media/Panorama.svelte.d.ts +47 -0
  167. package/dist/media/Panorama.svelte.d.ts.map +1 -0
  168. package/dist/media/Video.svelte +2501 -0
  169. package/dist/media/Video.svelte.d.ts +58 -0
  170. package/dist/media/Video.svelte.d.ts.map +1 -0
  171. package/dist/media/carousel.d.ts +211 -0
  172. package/dist/media/carousel.d.ts.map +1 -0
  173. package/dist/media/carousel.js +408 -0
  174. package/dist/media/index.d.ts +11 -0
  175. package/dist/media/index.d.ts.map +1 -0
  176. package/dist/media/index.js +5 -0
  177. package/dist/navigation/BottomSheet.svelte +636 -0
  178. package/dist/navigation/BottomSheet.svelte.d.ts +27 -0
  179. package/dist/navigation/BottomSheet.svelte.d.ts.map +1 -0
  180. package/dist/navigation/Breadcrumbs.svelte +611 -0
  181. package/dist/navigation/Breadcrumbs.svelte.d.ts +28 -0
  182. package/dist/navigation/Breadcrumbs.svelte.d.ts.map +1 -0
  183. package/dist/navigation/Pagination.svelte +641 -0
  184. package/dist/navigation/Pagination.svelte.d.ts +27 -0
  185. package/dist/navigation/Pagination.svelte.d.ts.map +1 -0
  186. package/dist/navigation/Steps.svelte +965 -0
  187. package/dist/navigation/Steps.svelte.d.ts +43 -0
  188. package/dist/navigation/Steps.svelte.d.ts.map +1 -0
  189. package/dist/navigation/Tabs.svelte +698 -0
  190. package/dist/navigation/Tabs.svelte.d.ts +41 -0
  191. package/dist/navigation/Tabs.svelte.d.ts.map +1 -0
  192. package/dist/navigation/index.d.ts +8 -0
  193. package/dist/navigation/index.d.ts.map +1 -0
  194. package/dist/navigation/index.js +5 -0
  195. package/package.json +139 -0
@@ -0,0 +1,1933 @@
1
+ <script lang="ts" module>
2
+ export interface SelectOption {
3
+ /** The value committed when this option is chosen */
4
+ value: unknown;
5
+ /** Display text for the option */
6
+ label: string;
7
+ /** Whether this option cannot be selected */
8
+ disabled?: boolean;
9
+ /** Secondary descriptive text shown under the label */
10
+ description?: string;
11
+ /** Group heading this option is listed under */
12
+ group?: string;
13
+ }
14
+ </script>
15
+
16
+ <script lang="ts">
17
+ import { tooltip, ripple } from '@delightstack/utilities';
18
+ import { getContext, type Snippet } from 'svelte';
19
+ import type { FormContext } from './Form.svelte';
20
+ import { scale } from 'svelte/transition';
21
+ import { flip } from 'svelte/animate';
22
+ import { backOut, quintOut } from 'svelte/easing';
23
+
24
+ const propId = $props.id();
25
+ let {
26
+ /** The current value (single) or values (multi) */
27
+ value = $bindable() as unknown,
28
+
29
+ /** The list of options to choose from */
30
+ options = [] as SelectOption[],
31
+
32
+ /** Whether multiple values can be selected */
33
+ multiple = false,
34
+
35
+ /** Whether the dropdown includes a search input */
36
+ searchable = false,
37
+
38
+ /** Whether the value can be cleared */
39
+ clearable = false,
40
+
41
+ /** Whether the user can create new options from the search query */
42
+ creatable = false,
43
+
44
+ /** Whether the component is in a loading state */
45
+ loading = false,
46
+
47
+ /** Whether the component is disabled */
48
+ disabled = false,
49
+
50
+ /**
51
+ * Placeholder text shown in the trigger when there is no value. Only
52
+ * visible when the label has floated to the top, or there is no label
53
+ * (when a label is present and no distinct placeholder is set, the
54
+ * label itself acts as the placeholder, legacy-style).
55
+ */
56
+ placeholder = undefined as string | undefined,
57
+
58
+ /** The label text above the trigger */
59
+ label = undefined as string | undefined,
60
+
61
+ /** An error message shown below the trigger */
62
+ error = undefined as string | undefined,
63
+
64
+ /** Parses & validates the value (e.g. a database table form field's `parse`).
65
+ * Throws an error whose message is shown below the trigger.
66
+ * Standalone, it runs when the dropdown closes (re-running on change
67
+ * while errored); inside a Form it is registered with the form instead. */
68
+ parse = undefined as ((value: unknown) => unknown) | undefined,
69
+
70
+ /** Description text shown below the trigger (hidden while an error shows) */
71
+ description = undefined as string | undefined,
72
+
73
+ /** Whether the field is required */
74
+ required = false,
75
+
76
+ /** Size preset */
77
+ size = '1' as '0' | '1' | '2' | '3',
78
+
79
+ /** Whether to show a skeleton loading state */
80
+ skeleton = false,
81
+
82
+ /** Tooltip message shown on hover */
83
+ tooltip: tooltip_message = undefined as string | undefined,
84
+
85
+ /** Whether the component uses dense spacing */
86
+ dense = false,
87
+
88
+ /** Whether the component uses comfortable spacing */
89
+ comfortable = false,
90
+
91
+ /** Paint a filled surface background behind the trigger (vs the default
92
+ * transparent/outlined look) */
93
+ filled = false,
94
+
95
+ /** The id of the select element */
96
+ id = propId,
97
+
98
+ /** The name attribute for hidden form input(s) */
99
+ name = undefined as string | undefined,
100
+
101
+ /** Custom class name */
102
+ class: class_name = '',
103
+
104
+ /** Called when the value changes */
105
+ onchange = undefined as ((detail: { value: unknown }) => void) | undefined,
106
+
107
+ /** Called when the search query changes (debounced 300ms) */
108
+ onsearch = undefined as ((detail: { query: string }) => void) | undefined,
109
+
110
+ /**
111
+ * Called when the user tries to create a new option. Return `false` to
112
+ * reject it; return the created `SelectOption` to have the Select select
113
+ * it immediately (otherwise the search text is selected as the value).
114
+ */
115
+ oncreate = undefined as
116
+ | ((detail: { value: string }) => boolean | void | SelectOption)
117
+ | undefined,
118
+
119
+ /** Called when the dropdown opens */
120
+ onopen = undefined as (() => void) | undefined,
121
+
122
+ /** Called when the dropdown closes */
123
+ onclose = undefined as (() => void) | undefined,
124
+
125
+ /** Custom snippet for rendering the selected value in the trigger */
126
+ render_value = undefined as Snippet<[SelectOption | SelectOption[]]> | undefined,
127
+
128
+ /** Custom snippet for rendering an option in the dropdown */
129
+ option: optionSnippet = undefined as Snippet<[SelectOption]> | undefined,
130
+ } = $props();
131
+
132
+ let open = $state(false);
133
+ let focused = $state(false);
134
+ let searchQuery = $state('');
135
+ let highlightedIndex = $state(-1);
136
+ let selectElement = $state<HTMLElement | undefined>(undefined);
137
+ let triggerElement = $state<HTMLElement | undefined>(undefined);
138
+ let searchInputElement = $state<HTMLInputElement | undefined>(undefined);
139
+ let dropdownElement = $state<HTMLElement | undefined>(undefined);
140
+ let searchDebounceTimer: ReturnType<typeof setTimeout> | undefined;
141
+ let typeAheadBuffer = $state('');
142
+ let typeAheadTimer: ReturnType<typeof setTimeout> | undefined;
143
+
144
+ // Whether the dropdown was flipped above the trigger, so it can expand
145
+ // from the edge nearest the control.
146
+ let dropdownAbove = $state(false);
147
+
148
+ /* ------------------------------------------------------------------ */
149
+ /* Form context integration */
150
+ /* ------------------------------------------------------------------ */
151
+
152
+ const form_ctx = getContext<FormContext | undefined>('form');
153
+
154
+ $effect(() => {
155
+ if (!form_ctx || !name) return;
156
+ if (triggerElement) form_ctx.register(name, triggerElement, parse);
157
+ return () => {
158
+ if (name) form_ctx.unregister(name);
159
+ };
160
+ });
161
+
162
+ /**
163
+ * Context-driven mode: inside a Form, with a name, and no value passed,
164
+ * the select mirrors the form data (e.g. an entity's draft) instead of a
165
+ * local binding — `<Select {...field.relationship} />` needs no bind:value.
166
+ */
167
+ const context_driven = !!(form_ctx && name && value === undefined);
168
+
169
+ $effect(() => {
170
+ if (!context_driven || !form_ctx || !name) return;
171
+ const ctx_value = form_ctx.getValue(name);
172
+ if (ctx_value !== value) value = ctx_value;
173
+ });
174
+
175
+ /** Error from running `parse` standalone. Inside a Form the form runs
176
+ * `parse` instead (it was registered above), so this never sets there. */
177
+ let parse_error = $state<string | undefined>(undefined);
178
+
179
+ function runParse() {
180
+ if (!parse || form_ctx) return;
181
+ try {
182
+ parse(value);
183
+ parse_error = undefined;
184
+ } catch (e) {
185
+ parse_error = e instanceof Error ? e.message : 'Invalid value';
186
+ }
187
+ }
188
+
189
+ /** Re-validate on change, but only while already errored — the error
190
+ * clears the moment the value is fixed without nagging beforehand. */
191
+ function reparseIfErrored() {
192
+ if (parse_error) runParse();
193
+ }
194
+
195
+ /** Error from the local prop, standalone parse, or form context */
196
+ const resolved_error = $derived.by(() => {
197
+ if (error !== undefined) return error;
198
+ if (parse_error) return parse_error;
199
+ if (form_ctx && name && form_ctx.errors[name]) return form_ctx.errors[name];
200
+ return undefined;
201
+ });
202
+
203
+ /** Commits a value change to the form context and local validation */
204
+ function commitToForm() {
205
+ if (form_ctx && name) {
206
+ form_ctx.setValue(name, value);
207
+ } else {
208
+ reparseIfErrored();
209
+ }
210
+ }
211
+
212
+ /* Per-size font from the shared --control-font-* tokens so Select lines up
213
+ at the same height as Input and Button for a given size. */
214
+ const sizeMap: Record<string, string> = {
215
+ '0': 'var(--control-font-0, 0.875rem)',
216
+ '1': 'var(--control-font-1, 1rem)',
217
+ '2': 'var(--control-font-2, 1.125rem)',
218
+ '3': 'var(--control-font-3, 1.25rem)',
219
+ };
220
+
221
+ /** The currently selected option(s) based on value */
222
+ const selectedOptions = $derived.by(() => {
223
+ if (multiple) {
224
+ const values = Array.isArray(value) ? value : [];
225
+ return options.filter((opt) => values.includes(opt.value));
226
+ }
227
+ return options.find((opt) => opt.value === value) ?? null;
228
+ });
229
+
230
+ /** Filtered options based on search query */
231
+ const filteredOptions = $derived.by(() => {
232
+ if (!searchable || !searchQuery.trim()) return options;
233
+ const q = searchQuery.toLowerCase().trim();
234
+ return options.filter((opt) => opt.label.toLowerCase().includes(q));
235
+ });
236
+
237
+ /** Group the filtered options by their group property */
238
+ const groupedOptions = $derived.by(() => {
239
+ const groups = new Map<string, SelectOption[]>();
240
+ const ungrouped: SelectOption[] = [];
241
+
242
+ for (const opt of filteredOptions) {
243
+ if (opt.group) {
244
+ const list = groups.get(opt.group);
245
+ if (list) {
246
+ list.push(opt);
247
+ } else {
248
+ groups.set(opt.group, [opt]);
249
+ }
250
+ } else {
251
+ ungrouped.push(opt);
252
+ }
253
+ }
254
+
255
+ return { groups, ungrouped };
256
+ });
257
+
258
+ /** Flat list of selectable (non-disabled) option indices for keyboard navigation */
259
+ const flatSelectableOptions = $derived.by(() => {
260
+ const result: SelectOption[] = [];
261
+ // Add ungrouped first, then grouped (in order)
262
+ for (const opt of groupedOptions.ungrouped) {
263
+ result.push(opt);
264
+ }
265
+ for (const [, opts] of groupedOptions.groups) {
266
+ for (const opt of opts) {
267
+ result.push(opt);
268
+ }
269
+ }
270
+ return result;
271
+ });
272
+
273
+ /** Whether to show "Create" option */
274
+ const showCreateOption = $derived(
275
+ creatable && searchQuery.trim() && filteredOptions.length === 0,
276
+ );
277
+
278
+ /** Whether no results exist */
279
+ const showEmpty = $derived(
280
+ filteredOptions.length === 0 && !showCreateOption && !loading,
281
+ );
282
+
283
+ /** Whether an option value is selected */
284
+ function isSelected(optValue: unknown): boolean {
285
+ if (multiple) {
286
+ return Array.isArray(value) && value.includes(optValue);
287
+ }
288
+ return value === optValue;
289
+ }
290
+
291
+ /** Select or toggle an option */
292
+ function selectOption(opt: SelectOption) {
293
+ if (opt.disabled) return;
294
+
295
+ if (multiple) {
296
+ const current = Array.isArray(value) ? [...value] : [];
297
+ const idx = current.indexOf(opt.value);
298
+ if (idx >= 0) {
299
+ current.splice(idx, 1);
300
+ } else {
301
+ current.push(opt.value);
302
+ }
303
+ value = current;
304
+ } else {
305
+ value = opt.value;
306
+ closeDropdown();
307
+ }
308
+ commitToForm();
309
+ onchange?.({ value });
310
+ }
311
+
312
+ /** Remove a value from multi-select */
313
+ function removeValue(optValue: unknown, e: Event) {
314
+ e.stopPropagation();
315
+ if (disabled) return;
316
+ if (!multiple || !Array.isArray(value)) return;
317
+ value = value.filter((v: unknown) => v !== optValue);
318
+ commitToForm();
319
+ onchange?.({ value });
320
+ }
321
+
322
+ /**
323
+ * Out transition for a chip. A plain `out:scale` keeps the leaving chip in
324
+ * the layout for the whole outro, so the surviving chips don't reflow into
325
+ * the gap until it finishes — `animate:flip` then measures no movement and
326
+ * they snap. Pinning the chip with `position: absolute` at its current spot
327
+ * pulls it out of flow immediately, so the others reflow now and flip slides
328
+ * them while this one scales + fades in place (same look as `out:scale`).
329
+ */
330
+ function chipOut(node: HTMLElement, { duration = 150 } = {}) {
331
+ const { offsetLeft, offsetTop, offsetWidth, offsetHeight } = node;
332
+ node.style.position = 'absolute';
333
+ node.style.left = `${offsetLeft}px`;
334
+ node.style.top = `${offsetTop}px`;
335
+ node.style.width = `${offsetWidth}px`;
336
+ node.style.height = `${offsetHeight}px`;
337
+ node.style.pointerEvents = 'none';
338
+ return {
339
+ duration,
340
+ easing: quintOut,
341
+ css: (t: number) => `opacity: ${t}; transform: scale(${0.6 + 0.4 * t});`,
342
+ };
343
+ }
344
+
345
+ /** Clear the value entirely */
346
+ function clearValue(e: Event) {
347
+ e.stopPropagation();
348
+ if (disabled) return;
349
+ value = multiple ? [] : undefined;
350
+ commitToForm();
351
+ onchange?.({ value });
352
+ }
353
+
354
+ /** Open the dropdown */
355
+ function openDropdown() {
356
+ if (disabled || open) return;
357
+ open = true;
358
+ highlightedIndex = -1;
359
+ searchQuery = '';
360
+ }
361
+
362
+ /** Close the dropdown */
363
+ function closeDropdown() {
364
+ if (!open) return;
365
+ open = false;
366
+ if (form_ctx && name) form_ctx.setTouched(name);
367
+ runParse();
368
+ }
369
+
370
+ /** Toggle the dropdown */
371
+ function toggleDropdown() {
372
+ if (open) {
373
+ closeDropdown();
374
+ } else {
375
+ openDropdown();
376
+ }
377
+ }
378
+
379
+ /** Handle search input changes */
380
+ function onSearchInput(e: Event) {
381
+ const target = e.target as HTMLInputElement;
382
+ searchQuery = target.value;
383
+ highlightedIndex = -1;
384
+
385
+ if (onsearch) {
386
+ clearTimeout(searchDebounceTimer);
387
+ searchDebounceTimer = setTimeout(() => {
388
+ onsearch?.({ query: searchQuery });
389
+ }, 300);
390
+ }
391
+ }
392
+
393
+ /** Select a value directly (single) or add it to the selection (multi). */
394
+ function selectByValue(optValue: unknown) {
395
+ if (multiple) {
396
+ const current = Array.isArray(value) ? [...value] : [];
397
+ if (!current.includes(optValue)) current.push(optValue);
398
+ value = current;
399
+ } else {
400
+ value = optValue;
401
+ }
402
+ commitToForm();
403
+ onchange?.({ value });
404
+ }
405
+
406
+ /** Handle creating a new option */
407
+ function handleCreate() {
408
+ const trimmed = searchQuery.trim();
409
+ if (!trimmed) return;
410
+ const result = oncreate?.({ value: trimmed });
411
+ if (result === false) return;
412
+ // Select the freshly created option so the user doesn't have to reopen
413
+ // the dropdown. `oncreate` may hand back the created option (with its
414
+ // real value); otherwise fall back to selecting the search text.
415
+ const createdValue =
416
+ result && typeof result === 'object' && 'value' in result
417
+ ? (result as SelectOption).value
418
+ : trimmed;
419
+ selectByValue(createdValue);
420
+ searchQuery = '';
421
+ if (!multiple) closeDropdown();
422
+ }
423
+
424
+ /** Scroll the highlighted option into view */
425
+ function scrollHighlightedIntoView() {
426
+ requestAnimationFrame(() => {
427
+ if (!dropdownElement) return;
428
+ const items = dropdownElement.querySelectorAll('[role="option"]');
429
+ const item = items[highlightedIndex];
430
+ if (item) {
431
+ item.scrollIntoView({ block: 'nearest', inline: 'nearest' });
432
+ }
433
+ });
434
+ }
435
+
436
+ /** Get the next non-disabled index in a given direction */
437
+ function getNextIndex(current: number, direction: 1 | -1): number {
438
+ const total = flatSelectableOptions.length;
439
+ if (total === 0) return -1;
440
+
441
+ let next = current;
442
+ for (let i = 0; i < total; i++) {
443
+ next = (((next + direction) % total) + total) % total;
444
+ if (!flatSelectableOptions[next].disabled) return next;
445
+ }
446
+ return -1;
447
+ }
448
+
449
+ /**
450
+ * Whether the closed trigger should cycle the value directly on arrow keys
451
+ * (native `<select>` feel). Only single, non-searchable selects — multi and
452
+ * searchable selects open the panel instead, where direct cycling makes no
453
+ * sense.
454
+ */
455
+ const arrowCyclesValue = $derived(!multiple && !searchable);
456
+
457
+ /**
458
+ * Move the single value to the next/prev selectable option without opening
459
+ * the panel. Clamps at the ends (no wrap), matching native selects. With no
460
+ * current value, the first arrow lands on the first/last option.
461
+ */
462
+ function cycleValue(direction: 1 | -1) {
463
+ const opts = flatSelectableOptions;
464
+ if (opts.length === 0) return;
465
+ const currentIdx = opts.findIndex((o) => o.value === value);
466
+ let nextIdx: number;
467
+ if (currentIdx === -1) {
468
+ nextIdx = direction === 1 ? 0 : opts.length - 1;
469
+ } else {
470
+ nextIdx = currentIdx + direction;
471
+ if (nextIdx < 0 || nextIdx >= opts.length) return;
472
+ }
473
+ value = opts[nextIdx].value;
474
+ commitToForm();
475
+ onchange?.({ value });
476
+ }
477
+
478
+ /** Handle keyboard navigation on the trigger */
479
+ function onTriggerKeyDown(e: KeyboardEvent) {
480
+ switch (e.key) {
481
+ case 'ArrowDown': {
482
+ e.preventDefault();
483
+ if (!open) {
484
+ if (arrowCyclesValue) {
485
+ cycleValue(1);
486
+ } else {
487
+ openDropdown();
488
+ highlightedIndex = getNextIndex(-1, 1);
489
+ }
490
+ } else {
491
+ highlightedIndex = getNextIndex(highlightedIndex, 1);
492
+ scrollHighlightedIntoView();
493
+ }
494
+ break;
495
+ }
496
+ case 'ArrowUp': {
497
+ e.preventDefault();
498
+ if (!open) {
499
+ if (arrowCyclesValue) {
500
+ cycleValue(-1);
501
+ } else {
502
+ openDropdown();
503
+ highlightedIndex = getNextIndex(flatSelectableOptions.length, -1);
504
+ }
505
+ } else {
506
+ highlightedIndex = getNextIndex(highlightedIndex, -1);
507
+ scrollHighlightedIntoView();
508
+ }
509
+ break;
510
+ }
511
+ case 'Enter': {
512
+ e.preventDefault();
513
+ if (!open) {
514
+ openDropdown();
515
+ } else if (
516
+ showCreateOption &&
517
+ (highlightedIndex === -1 || highlightedIndex >= flatSelectableOptions.length)
518
+ ) {
519
+ handleCreate();
520
+ } else if (
521
+ highlightedIndex >= 0 &&
522
+ highlightedIndex < flatSelectableOptions.length
523
+ ) {
524
+ selectOption(flatSelectableOptions[highlightedIndex]);
525
+ }
526
+ break;
527
+ }
528
+ case 'Escape': {
529
+ e.preventDefault();
530
+ closeDropdown();
531
+ triggerElement?.focus();
532
+ break;
533
+ }
534
+ case 'Home': {
535
+ if (open) {
536
+ e.preventDefault();
537
+ highlightedIndex = getNextIndex(-1, 1);
538
+ scrollHighlightedIntoView();
539
+ }
540
+ break;
541
+ }
542
+ case 'End': {
543
+ if (open) {
544
+ e.preventDefault();
545
+ highlightedIndex = getNextIndex(flatSelectableOptions.length, -1);
546
+ scrollHighlightedIntoView();
547
+ }
548
+ break;
549
+ }
550
+ default: {
551
+ // Type-ahead in non-searchable mode
552
+ if (
553
+ !searchable &&
554
+ !open &&
555
+ e.key.length === 1 &&
556
+ !e.ctrlKey &&
557
+ !e.metaKey &&
558
+ !e.altKey
559
+ ) {
560
+ clearTimeout(typeAheadTimer);
561
+ typeAheadBuffer += e.key.toLowerCase();
562
+ typeAheadTimer = setTimeout(() => {
563
+ typeAheadBuffer = '';
564
+ }, 500);
565
+
566
+ const match = options.find(
567
+ (opt) => !opt.disabled && opt.label.toLowerCase().startsWith(typeAheadBuffer),
568
+ );
569
+ if (match) {
570
+ if (multiple) {
571
+ // In multi-select type-ahead doesn't auto-select
572
+ } else {
573
+ value = match.value;
574
+ commitToForm();
575
+ onchange?.({ value });
576
+ }
577
+ }
578
+ }
579
+ break;
580
+ }
581
+ }
582
+ }
583
+
584
+ /** Handle keyboard navigation within the search input */
585
+ function onSearchKeyDown(e: KeyboardEvent) {
586
+ switch (e.key) {
587
+ case 'ArrowDown': {
588
+ e.preventDefault();
589
+ highlightedIndex = getNextIndex(highlightedIndex, 1);
590
+ scrollHighlightedIntoView();
591
+ break;
592
+ }
593
+ case 'ArrowUp': {
594
+ e.preventDefault();
595
+ highlightedIndex = getNextIndex(highlightedIndex, -1);
596
+ scrollHighlightedIntoView();
597
+ break;
598
+ }
599
+ case 'Enter': {
600
+ e.preventDefault();
601
+ if (
602
+ showCreateOption &&
603
+ (highlightedIndex === -1 || highlightedIndex >= flatSelectableOptions.length)
604
+ ) {
605
+ handleCreate();
606
+ } else if (
607
+ highlightedIndex >= 0 &&
608
+ highlightedIndex < flatSelectableOptions.length
609
+ ) {
610
+ selectOption(flatSelectableOptions[highlightedIndex]);
611
+ }
612
+ break;
613
+ }
614
+ case 'Escape': {
615
+ e.preventDefault();
616
+ closeDropdown();
617
+ triggerElement?.focus();
618
+ break;
619
+ }
620
+ case 'Home': {
621
+ e.preventDefault();
622
+ highlightedIndex = getNextIndex(-1, 1);
623
+ scrollHighlightedIntoView();
624
+ break;
625
+ }
626
+ case 'End': {
627
+ e.preventDefault();
628
+ highlightedIndex = getNextIndex(flatSelectableOptions.length, -1);
629
+ scrollHighlightedIntoView();
630
+ break;
631
+ }
632
+ }
633
+ }
634
+
635
+ /** Whether the trigger has a value to display */
636
+ const hasValue = $derived.by(() => {
637
+ if (multiple) {
638
+ return Array.isArray(value) && value.length > 0;
639
+ }
640
+ /* Treat empty string as "no value" so a `let value = $state('')`
641
+ binding leaves the label resting as the in-field placeholder (and
642
+ floating only on open/focus/selection), matching <Input>. */
643
+ return value !== undefined && value !== null && value !== '';
644
+ });
645
+
646
+ /** A distinct placeholder is one that differs from the label. */
647
+ const hasDistinctPlaceholder = $derived(!!placeholder && placeholder !== label);
648
+
649
+ /** Whether the floating label sits in its raised (notched) position. */
650
+ const labelFloated = $derived.by(() => {
651
+ if (!label) return false;
652
+ if (hasDistinctPlaceholder) return true;
653
+ if (open || focused) return true;
654
+ return hasValue;
655
+ });
656
+
657
+ /** Whether placeholder text should be shown inside the trigger. */
658
+ const showPlaceholder = $derived(
659
+ !hasValue && !!placeholder && (!label || hasDistinctPlaceholder),
660
+ );
661
+
662
+ /** A unique CSS anchor name, used for native anchor positioning. */
663
+ const anchorName = $derived(`--ds-select-${String(id).replace(/[^a-zA-Z0-9_-]/g, '')}`);
664
+
665
+ /** Run open/close side effects when the dropdown state changes. */
666
+ let previousOpen = false;
667
+ $effect(() => {
668
+ if (open && !previousOpen) {
669
+ onopen?.();
670
+ if (searchable) {
671
+ requestAnimationFrame(() => {
672
+ searchInputElement?.focus();
673
+ });
674
+ }
675
+ } else if (!open && previousOpen) {
676
+ searchQuery = '';
677
+ highlightedIndex = -1;
678
+ onclose?.();
679
+ }
680
+ previousOpen = open;
681
+ });
682
+
683
+ /* Mirror `open` onto the native popover element, and detect whether the
684
+ browser flipped it above the trigger so the panel expands from the edge
685
+ nearest the control. */
686
+ $effect(() => {
687
+ const el = dropdownElement;
688
+ if (!el) return;
689
+ const shown = el.matches(':popover-open');
690
+ if (open && !shown) {
691
+ try {
692
+ el.showPopover();
693
+ /* Measure synchronously — `showPopover()` has already placed the
694
+ popover (incl. any `flip-block` fallback), and reading layout
695
+ here keeps the result in the same frame as the open
696
+ transition, so the expand origin is correct from frame one. */
697
+ if (triggerElement) {
698
+ const t = triggerElement.getBoundingClientRect();
699
+ const d = el.getBoundingClientRect();
700
+ dropdownAbove = d.top < t.top;
701
+ }
702
+ } catch {
703
+ /* not connected yet */
704
+ }
705
+ } else if (!open && shown) {
706
+ try {
707
+ el.hidePopover();
708
+ } catch {
709
+ /* already hidden */
710
+ }
711
+ }
712
+ });
713
+
714
+ /* Close when a pointer goes down outside the component while open. */
715
+ $effect(() => {
716
+ if (!open) return;
717
+ function onDocPointerDown(e: PointerEvent) {
718
+ if (selectElement && !selectElement.contains(e.target as Node)) {
719
+ closeDropdown();
720
+ }
721
+ }
722
+ document.addEventListener('pointerdown', onDocPointerDown, true);
723
+ return () => document.removeEventListener('pointerdown', onDocPointerDown, true);
724
+ });
725
+
726
+ /** Get the flat index offset for options within a group */
727
+ function getFlatGroupIndex(groupName: string, indexInGroup: number): number {
728
+ let offset = 0;
729
+ for (const [name, opts] of groupedOptions.groups) {
730
+ if (name === groupName) return offset + indexInGroup;
731
+ offset += opts.length;
732
+ }
733
+ return offset + indexInGroup;
734
+ }
735
+ </script>
736
+
737
+ <div
738
+ class={['select', `size-${size}`, class_name].filter(Boolean).join(' ')}
739
+ class:dense
740
+ class:comfortable
741
+ class:filled
742
+ class:disabled
743
+ class:skeleton
744
+ class:open
745
+ class:has-label={!!label}
746
+ class:has-error={!!resolved_error}
747
+ bind:this={selectElement}
748
+ style:--select-font={sizeMap[size] ?? sizeMap['1']}
749
+ onfocusin={() => (focused = true)}
750
+ onfocusout={(e) => {
751
+ if (!selectElement?.contains(e.relatedTarget as Node)) focused = false;
752
+ }}
753
+ {@attach tooltip_message ? tooltip(tooltip_message) : () => {}}>
754
+ <!-- Trigger button -->
755
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
756
+ <div
757
+ bind:this={triggerElement}
758
+ {id}
759
+ class="trigger"
760
+ class:open
761
+ class:error={!!resolved_error}
762
+ class:disabled
763
+ role="combobox"
764
+ aria-expanded={open}
765
+ aria-haspopup="listbox"
766
+ aria-controls="{id}-listbox"
767
+ aria-disabled={disabled || undefined}
768
+ tabindex={disabled ? -1 : 0}
769
+ style:anchor-name={anchorName}
770
+ onclick={toggleDropdown}
771
+ onkeydown={onTriggerKeyDown}>
772
+ <div class="value">
773
+ {#if hasValue && render_value}
774
+ {@render render_value(selectedOptions as SelectOption | SelectOption[])}
775
+ {:else if multiple && Array.isArray(selectedOptions) && selectedOptions.length > 0}
776
+ {#each selectedOptions as opt (opt.value)}
777
+ <span
778
+ class="chip"
779
+ in:scale={{ duration: 200, start: 0.6, easing: backOut }}
780
+ out:chipOut={{ duration: 150 }}
781
+ animate:flip={{ duration: 150, easing: quintOut }}>
782
+ <span>{opt.label}</span>
783
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
784
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
785
+ <span class="chip-remove" onclick={(e) => removeValue(opt.value, e)}>
786
+ <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
787
+ <path
788
+ d="M18 6L6 18M6 6l12 12"
789
+ stroke="currentColor"
790
+ stroke-width="2"
791
+ stroke-linecap="round"
792
+ fill="none" />
793
+ </svg>
794
+ </span>
795
+ </span>
796
+ {/each}
797
+ {:else if !multiple && selectedOptions && !Array.isArray(selectedOptions)}
798
+ <span class="single-value">{selectedOptions.label}</span>
799
+ {:else if showPlaceholder}
800
+ <span class="placeholder">{placeholder}</span>
801
+ {/if}
802
+ </div>
803
+
804
+ <!-- Floating notched-outline label -->
805
+ {#if label}
806
+ <label class:floated={labelFloated} for={id}>
807
+ <span class="label-text">
808
+ {label}{#if required}<span class="required-mark" aria-hidden="true">
809
+ *
810
+ </span>{/if}
811
+ </span>
812
+ </label>
813
+ {/if}
814
+
815
+ {#if loading}
816
+ <span class="spinner" aria-hidden="true"></span>
817
+ {/if}
818
+
819
+ {#if clearable && hasValue && !disabled}
820
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
821
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
822
+ <span class="clear" onclick={clearValue} aria-label="Clear selection">
823
+ <svg viewBox="0 0 24 24" width="16" height="16">
824
+ <path
825
+ d="M18 6L6 18M6 6l12 12"
826
+ stroke="currentColor"
827
+ stroke-width="2"
828
+ stroke-linecap="round"
829
+ fill="none" />
830
+ </svg>
831
+ </span>
832
+ {/if}
833
+
834
+ <span class="chevron" class:open aria-hidden="true">
835
+ <svg viewBox="0 0 24 24" width="18" height="18">
836
+ <path
837
+ d="M6 9l6 6 6-6"
838
+ stroke="currentColor"
839
+ stroke-width="2"
840
+ stroke-linecap="round"
841
+ stroke-linejoin="round"
842
+ fill="none" />
843
+ </svg>
844
+ </span>
845
+
846
+ <!-- Skeleton shimmer overlay — its own element so the sweep can be
847
+ clipped to the trigger's corners without overflow:hidden on the
848
+ trigger (which would clip the floating label). -->
849
+ {#if skeleton}
850
+ <span class="skeleton-sweep" aria-hidden="true"></span>
851
+ {/if}
852
+ </div>
853
+
854
+ <!-- Dropdown — native popover, positioned with CSS anchor positioning -->
855
+ <div
856
+ class="dropdown"
857
+ class:above={dropdownAbove}
858
+ popover="manual"
859
+ bind:this={dropdownElement}
860
+ role="listbox"
861
+ id="{id}-listbox"
862
+ aria-multiselectable={multiple || undefined}
863
+ style:position-anchor={anchorName}>
864
+ {#if searchable}
865
+ <div class="search">
866
+ <input
867
+ bind:this={searchInputElement}
868
+ type="text"
869
+ placeholder="Search..."
870
+ value={searchQuery}
871
+ oninput={onSearchInput}
872
+ onkeydown={onSearchKeyDown}
873
+ aria-label="Search options"
874
+ autocomplete="off" />
875
+ </div>
876
+ {/if}
877
+
878
+ {#if loading}
879
+ <div class="empty">Loading...</div>
880
+ {:else}
881
+ <!-- Ungrouped options -->
882
+ {#each groupedOptions.ungrouped as opt, i (opt.value)}
883
+ {@const flatIndex = i}
884
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
885
+ <div
886
+ class="option"
887
+ class:selected={isSelected(opt.value)}
888
+ class:highlighted={highlightedIndex === flatIndex}
889
+ class:disabled={opt.disabled}
890
+ role="option"
891
+ tabindex="-1"
892
+ aria-selected={isSelected(opt.value)}
893
+ aria-disabled={opt.disabled || undefined}
894
+ onpointerdown={(e) => e.preventDefault()}
895
+ onclick={() => selectOption(opt)}
896
+ onpointerenter={() => {
897
+ if (!opt.disabled) highlightedIndex = flatIndex;
898
+ }}
899
+ {@attach ripple({ enabled: !opt.disabled, zIndex: 1 })}>
900
+ {#if multiple}
901
+ <span class="check" aria-hidden="true">
902
+ {#if isSelected(opt.value)}
903
+ <svg viewBox="0 0 24 24" width="16" height="16">
904
+ <path
905
+ d="M5 13l4 4L19 7"
906
+ stroke="currentColor"
907
+ stroke-width="2"
908
+ stroke-linecap="round"
909
+ stroke-linejoin="round"
910
+ fill="none" />
911
+ </svg>
912
+ {/if}
913
+ </span>
914
+ {/if}
915
+ {#if optionSnippet}
916
+ {@render optionSnippet(opt)}
917
+ {:else}
918
+ <span class="option-content">
919
+ <span class="option-label">{opt.label}</span>
920
+ {#if opt.description}
921
+ <span class="option-desc">{opt.description}</span>
922
+ {/if}
923
+ </span>
924
+ {/if}
925
+ {#if !multiple && isSelected(opt.value)}
926
+ <span class="check-single" aria-hidden="true">
927
+ <svg viewBox="0 0 24 24" width="16" height="16">
928
+ <path
929
+ d="M5 13l4 4L19 7"
930
+ stroke="currentColor"
931
+ stroke-width="2"
932
+ stroke-linecap="round"
933
+ stroke-linejoin="round"
934
+ fill="none" />
935
+ </svg>
936
+ </span>
937
+ {/if}
938
+ </div>
939
+ {/each}
940
+
941
+ <!-- Grouped options -->
942
+ {#each [...groupedOptions.groups] as [groupName, groupOpts] (groupName)}
943
+ <div class="group-label">{groupName}</div>
944
+ {#each groupOpts as opt, gi (opt.value)}
945
+ {@const flatIndex =
946
+ groupedOptions.ungrouped.length + getFlatGroupIndex(groupName, gi)}
947
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
948
+ <div
949
+ class="option"
950
+ class:selected={isSelected(opt.value)}
951
+ class:highlighted={highlightedIndex === flatIndex}
952
+ class:disabled={opt.disabled}
953
+ role="option"
954
+ tabindex="-1"
955
+ aria-selected={isSelected(opt.value)}
956
+ aria-disabled={opt.disabled || undefined}
957
+ onpointerdown={(e) => e.preventDefault()}
958
+ onclick={() => selectOption(opt)}
959
+ onpointerenter={() => {
960
+ if (!opt.disabled) highlightedIndex = flatIndex;
961
+ }}
962
+ {@attach ripple({ enabled: !opt.disabled, zIndex: 1 })}>
963
+ {#if multiple}
964
+ <span class="check" aria-hidden="true">
965
+ {#if isSelected(opt.value)}
966
+ <svg viewBox="0 0 24 24" width="16" height="16">
967
+ <path
968
+ d="M5 13l4 4L19 7"
969
+ stroke="currentColor"
970
+ stroke-width="2"
971
+ stroke-linecap="round"
972
+ stroke-linejoin="round"
973
+ fill="none" />
974
+ </svg>
975
+ {/if}
976
+ </span>
977
+ {/if}
978
+ {#if optionSnippet}
979
+ {@render optionSnippet(opt)}
980
+ {:else}
981
+ <span class="option-content">
982
+ <span class="option-label">{opt.label}</span>
983
+ {#if opt.description}
984
+ <span class="option-desc">{opt.description}</span>
985
+ {/if}
986
+ </span>
987
+ {/if}
988
+ {#if !multiple && isSelected(opt.value)}
989
+ <span class="check-single" aria-hidden="true">
990
+ <svg viewBox="0 0 24 24" width="16" height="16">
991
+ <path
992
+ d="M5 13l4 4L19 7"
993
+ stroke="currentColor"
994
+ stroke-width="2"
995
+ stroke-linecap="round"
996
+ stroke-linejoin="round"
997
+ fill="none" />
998
+ </svg>
999
+ </span>
1000
+ {/if}
1001
+ </div>
1002
+ {/each}
1003
+ {/each}
1004
+
1005
+ <!-- Creatable option -->
1006
+ {#if showCreateOption}
1007
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
1008
+ <div
1009
+ class="option create"
1010
+ class:highlighted={highlightedIndex === flatSelectableOptions.length ||
1011
+ highlightedIndex === -1}
1012
+ role="option"
1013
+ tabindex="-1"
1014
+ aria-selected={false}
1015
+ onpointerdown={(e) => e.preventDefault()}
1016
+ onclick={handleCreate}
1017
+ onpointerenter={() => {
1018
+ highlightedIndex = flatSelectableOptions.length;
1019
+ }}
1020
+ {@attach ripple()}>
1021
+ Create '{searchQuery.trim()}'
1022
+ </div>
1023
+ {/if}
1024
+
1025
+ <!-- Empty state -->
1026
+ {#if showEmpty}
1027
+ <div class="empty">No options</div>
1028
+ {/if}
1029
+ {/if}
1030
+ </div>
1031
+
1032
+ <!-- Hidden input(s) for form submission -->
1033
+ {#if name}
1034
+ {#if multiple && Array.isArray(value)}
1035
+ {#each value as v (v)}
1036
+ <input type="hidden" {name} value={v} />
1037
+ {/each}
1038
+ {:else if value !== undefined && value !== null}
1039
+ <input type="hidden" {name} {value} />
1040
+ {/if}
1041
+ {/if}
1042
+ </div>
1043
+
1044
+ <!-- Error message / description text -->
1045
+ {#if resolved_error}
1046
+ <span class="error-text">{resolved_error}</span>
1047
+ {:else if description}
1048
+ <span class="description-text">{description}</span>
1049
+ {/if}
1050
+
1051
+ <style>
1052
+ /* ================================================================== */
1053
+ /* ROOT */
1054
+ /* ================================================================== */
1055
+
1056
+ .select {
1057
+ --_font: var(--select-font, var(--control-font-1, 1rem));
1058
+ /* Height scales off the font so the whole control scales from one
1059
+ number. The ratio is the SHARED --control-height-ratio (tokens.css),
1060
+ so Input, Select and Button land on the same height. */
1061
+ --_height: calc(var(--_font) * var(--control-height-ratio, 3));
1062
+ --_radius: var(--radius-lg, 10px);
1063
+ --_border: var(--color-border, light-dark(hsl(0 0% 78%), hsl(0 0% 32%)));
1064
+ --_border-hover: var(--color-border-active, light-dark(hsl(0 0% 60%), hsl(0 0% 48%)));
1065
+ --_border-focus: var(--color-action, hsl(217 75% 52%));
1066
+ --_border-error: var(--color-error, light-dark(#ef6262, #b04343));
1067
+ --_bg: var(--color-surface, light-dark(#fff, hsl(0 0% 9%)));
1068
+ --_panel: var(--color-surface, light-dark(#fff, hsl(0 0% 13%)));
1069
+ --_panel-hover: var(--color-bg-active, light-dark(hsl(0 0% 95%), hsl(0 0% 18%)));
1070
+ /* Row highlight — the same 6% text-color tint ListItem uses for its
1071
+ hover/active fill (and its hairline separators), so this panel and the
1072
+ Input autocomplete panel light up identically. */
1073
+ --_option-hover: color-mix(
1074
+ in oklch,
1075
+ var(--color-text, light-dark(#000, #fff)) 6%,
1076
+ transparent
1077
+ );
1078
+ /* The persistent selected tint — an action-color wash, so the current
1079
+ selection is clearly visible at rest and reads as "selected" (matching
1080
+ the row's accent text/checkmark) rather than as a weak gray hover. */
1081
+ --_option-selected: color-mix(in oklch, var(--_border-focus) 10%, transparent);
1082
+ /* A hovered/highlighted selected row deepens the same wash, so pointing
1083
+ at the selection never makes it LESS prominent than its resting state. */
1084
+ --_option-selected-hover: color-mix(in oklch, var(--_border-focus) 16%, transparent);
1085
+ --_text: var(--color-text, inherit);
1086
+ --_text-muted: var(--color-text-muted, light-dark(hsl(0 0% 46%), hsl(0 0% 62%)));
1087
+ --_chip-bg: var(--color-action, hsl(217 75% 52%));
1088
+ --_chip-text: var(--color-action-text, #fff);
1089
+ --_duration: 150ms;
1090
+ --_ease: var(--ease-in-out, cubic-bezier(0.76, 0, 0.24, 1));
1091
+ --_ease-label: cubic-bezier(0, 0.54, 0.47, 1);
1092
+ /* Snappy ease-out for the dropdown's expand-in animation */
1093
+ --_ease-expand: cubic-bezier(0.16, 1, 0.3, 1);
1094
+ /* Back-out easing — overshoots the target so the chevron flip has a
1095
+ little bounce. */
1096
+ --_ease-back: var(--ease-spring, cubic-bezier(0.34, 1.56, 0.64, 1));
1097
+
1098
+ position: relative;
1099
+ width: 100%;
1100
+ font-size: var(--_font);
1101
+ text-align: left;
1102
+ }
1103
+
1104
+ .select.dense {
1105
+ --_height: calc(var(--_font) * var(--control-height-ratio-dense, 2.5));
1106
+ }
1107
+ .select.comfortable {
1108
+ --_height: calc(var(--_font) * var(--control-height-ratio-comfortable, 3.5));
1109
+ }
1110
+
1111
+ .select.disabled {
1112
+ opacity: 0.55;
1113
+ pointer-events: none;
1114
+ }
1115
+
1116
+ /* ================================================================== */
1117
+ /* SKELETON / LOADING */
1118
+ /* ================================================================== */
1119
+
1120
+ /* The skeleton renders the real trigger (non-interactive) with a soft
1121
+ sweeping shimmer — no layout shift when it resolves. */
1122
+ .select.skeleton {
1123
+ pointer-events: none;
1124
+ }
1125
+ .skeleton-sweep {
1126
+ position: absolute;
1127
+ inset: 0;
1128
+ z-index: 1;
1129
+ border-radius: inherit;
1130
+ @supports (corner-shape: squircle) {
1131
+ corner-shape: inherit;
1132
+ }
1133
+ overflow: hidden;
1134
+ pointer-events: none;
1135
+
1136
+ &::after {
1137
+ content: '';
1138
+ position: absolute;
1139
+ inset: 0;
1140
+ transform: translateX(-100%);
1141
+ background-image: linear-gradient(
1142
+ 105deg,
1143
+ transparent 25%,
1144
+ var(--skeleton-sheen, rgb(from var(--color-text, #888) r g b / 0.12)) 50%,
1145
+ transparent 75%
1146
+ );
1147
+ animation: delight-skeleton-shimmer var(--skeleton-duration, 2.4s) ease-in-out
1148
+ infinite;
1149
+ animation-delay: var(--shimmer-delay, 0s);
1150
+ }
1151
+ }
1152
+
1153
+ @keyframes -global-delight-skeleton-shimmer {
1154
+ 0% {
1155
+ transform: translateX(-100%);
1156
+ }
1157
+ 55%,
1158
+ 100% {
1159
+ transform: translateX(100%);
1160
+ }
1161
+ }
1162
+
1163
+ @media (prefers-reduced-motion: reduce) {
1164
+ .skeleton-sweep::after {
1165
+ animation: none;
1166
+ }
1167
+ }
1168
+
1169
+ /* ================================================================== */
1170
+ /* TRIGGER */
1171
+ /* ================================================================== */
1172
+
1173
+ .trigger {
1174
+ position: relative;
1175
+ display: flex;
1176
+ align-items: center;
1177
+ gap: 0.5em;
1178
+ box-sizing: border-box;
1179
+ min-height: var(--_height);
1180
+ /* No top margin: the bordered box IS the control's layout height, so a
1181
+ row of Input/Select/Button top-aligns. The floating label is
1182
+ absolutely positioned and straddles the top border out of flow — it
1183
+ overflows ~0.4em above the box without adding to the layout height.
1184
+ Vertical padding keeps wrapped chips off the rounded outline; the
1185
+ trigger grows past `min-height` when chips span multiple rows. */
1186
+ padding: 0.5em var(--control-pad-x, 1em);
1187
+ border-radius: var(--_radius);
1188
+ /* Squircle + a rounder radius. The notch shoulders (label ::before/::after)
1189
+ draw the top corners, so the radius is doubled like elsewhere but CAPPED at the
1190
+ label's left content offset (1em) — past that the corner would crowd the floated
1191
+ label. --_cr is the shared corner radius; the shoulders scale to it (height +
1192
+ floated width) so the squircle seam stays aligned with the side borders. */
1193
+ @supports (corner-shape: squircle) {
1194
+ --_cr: min(calc(var(--_radius) * var(--squircle-ratio, 2)), 1em);
1195
+ corner-shape: squircle;
1196
+ border-radius: var(--_cr);
1197
+ }
1198
+ /* Transparent (outlined) by default; the `filled` prop paints the surface. */
1199
+ background: transparent;
1200
+ cursor: pointer;
1201
+ width: 100%;
1202
+ font: inherit;
1203
+ font-size: var(--_font);
1204
+ color: var(--_text);
1205
+ text-align: left;
1206
+ outline: none;
1207
+ }
1208
+
1209
+ /* Filled variant — paint the trigger surface behind the control. */
1210
+ .select.filled .trigger {
1211
+ background: var(--_bg);
1212
+ }
1213
+
1214
+ .select.dense .trigger {
1215
+ padding: 0.4em var(--control-pad-x-dense, 0.75em);
1216
+ }
1217
+ .select.comfortable .trigger {
1218
+ padding: 0.6em var(--control-pad-x-comfortable, 1.25em);
1219
+ }
1220
+
1221
+ /* The outline is painted by a pseudo-element so the 1px -> 2px focus
1222
+ transition never nudges the trigger's contents. */
1223
+ .trigger::before {
1224
+ content: '';
1225
+ position: absolute;
1226
+ inset: 0;
1227
+ border: 1px solid var(--_border);
1228
+ border-radius: inherit;
1229
+ @supports (corner-shape: squircle) {
1230
+ corner-shape: inherit;
1231
+ }
1232
+ pointer-events: none;
1233
+ /* Width is NOT transitioned: the top edge (notch shoulders) thickens
1234
+ instantly on focus, so the sides/bottom must snap too or the box
1235
+ visibly thickens at two different rates. */
1236
+ transition: border-color var(--_duration) var(--_ease);
1237
+ }
1238
+
1239
+ /* With a label present, the label paints the top edge (the notch) */
1240
+ .select.has-label .trigger::before {
1241
+ border-top-color: transparent;
1242
+ }
1243
+
1244
+ .trigger:hover::before {
1245
+ border-color: var(--_border-hover);
1246
+ /* Snap the border color in on hover; the base rule eases it back out on leave. */
1247
+ transition: none;
1248
+ }
1249
+ .select.has-label .trigger:hover::before {
1250
+ border-top-color: transparent;
1251
+ }
1252
+
1253
+ .trigger.open::before,
1254
+ .trigger:focus-within::before {
1255
+ border-color: var(--_border-focus);
1256
+ border-width: 2px;
1257
+ /* Snap the border in on focus (matches the hover rule above); the base
1258
+ rule eases it back out on blur. Without this, keyboard-focus eased the
1259
+ color over --_duration while the width snapped — the same two-rate
1260
+ mismatch the notch fix removed, just on focus instead of hover. */
1261
+ transition: none;
1262
+ }
1263
+ .select.has-label .trigger.open::before,
1264
+ .select.has-label .trigger:focus-within::before {
1265
+ border-top-color: transparent;
1266
+ }
1267
+
1268
+ .select.has-error .trigger::before {
1269
+ border-color: var(--_border-error);
1270
+ }
1271
+ .select.has-error .trigger.open::before,
1272
+ .select.has-error .trigger:focus-within::before {
1273
+ border-color: var(--_border-error);
1274
+ }
1275
+ .select.has-error.has-label .trigger::before {
1276
+ border-top-color: transparent;
1277
+ }
1278
+
1279
+ /* ================================================================== */
1280
+ /* FLOATING LABEL (notched outline, legacy-style) */
1281
+ /* ================================================================== */
1282
+
1283
+ label {
1284
+ position: absolute;
1285
+ inset: 0 0 auto 0;
1286
+ display: flex;
1287
+ align-items: center;
1288
+ height: var(--_height);
1289
+ margin: 0;
1290
+ padding: 0;
1291
+ box-sizing: border-box;
1292
+ border-top: 1px solid var(--_border);
1293
+ /* Invisible counterweight to the top border: with border-box sizing the
1294
+ 1px top border alone would push the flex-centred resting text 0.5px
1295
+ below the trigger's true centre. */
1296
+ border-bottom: 1px solid transparent;
1297
+ border-radius: var(--_radius);
1298
+ @supports (corner-shape: squircle) {
1299
+ corner-shape: squircle;
1300
+ border-radius: var(--_cr);
1301
+ }
1302
+ color: var(--_text-muted);
1303
+ pointer-events: none;
1304
+ transition:
1305
+ border-color var(--_duration) var(--_ease),
1306
+ color var(--_duration) var(--_ease);
1307
+ }
1308
+
1309
+ /* Notch shoulders — short border runs either side of the label text */
1310
+ label::before,
1311
+ label::after {
1312
+ content: '';
1313
+ display: block;
1314
+ box-sizing: border-box;
1315
+ flex: 0 0 auto;
1316
+ align-self: flex-start;
1317
+ width: 0;
1318
+ min-width: 1em;
1319
+ height: var(--_radius);
1320
+ @supports (corner-shape: squircle) {
1321
+ height: var(--_cr);
1322
+ }
1323
+ border-top: 1px solid transparent;
1324
+ transition:
1325
+ border-color var(--_duration) var(--_ease),
1326
+ min-width 200ms var(--_ease-label);
1327
+ }
1328
+ label::before {
1329
+ /* End the left border run 0.3em before the text so the notch has a small
1330
+ gap on the left, matching the 0.3em the ::after leaves on the right.
1331
+ The text's own margin-left keeps it aligned with the trigger value. */
1332
+ min-width: 0.7em;
1333
+ border-top-left-radius: var(--_radius);
1334
+ @supports (corner-shape: squircle) {
1335
+ corner-shape: squircle;
1336
+ border-top-left-radius: var(--_cr);
1337
+ }
1338
+ }
1339
+ label::after {
1340
+ flex: 1 1 auto;
1341
+ min-width: 0.5em;
1342
+ margin-left: 0.3em;
1343
+ border-top-right-radius: var(--_radius);
1344
+ @supports (corner-shape: squircle) {
1345
+ corner-shape: squircle;
1346
+ border-top-right-radius: var(--_cr);
1347
+ /* Room for the bigger corner when a long label squeezes the shoulder,
1348
+ so the curve never gets scaled down (which would break the seam). */
1349
+ min-width: 1em;
1350
+ }
1351
+ }
1352
+
1353
+ .label-text {
1354
+ display: flex;
1355
+ align-items: center;
1356
+ max-width: 100%;
1357
+ /* Small gap from the left notch shoulder (mirrors the ::after gap on the
1358
+ right); the shoulder is shortened by the same amount so the text stays
1359
+ aligned with the trigger value. */
1360
+ margin-left: 0.3em;
1361
+ font-size: var(--_font);
1362
+ /* Roomier than 1 so the line box contains descenders (g, y, p): with
1363
+ line-height 1 the box is exactly the font size and overflow:hidden
1364
+ clips them. Half-leading is symmetric, so the glyph stays centred on
1365
+ the border when floated — nothing shifts. */
1366
+ line-height: 1.4;
1367
+ white-space: nowrap;
1368
+ overflow: hidden;
1369
+ text-overflow: ellipsis;
1370
+ transition:
1371
+ font-size 200ms var(--_ease-label),
1372
+ transform 200ms var(--_ease-label),
1373
+ margin-left 200ms var(--_ease-label);
1374
+ }
1375
+
1376
+ /* Floated: hide the label's own edge, light the notch shoulders, and
1377
+ glide the shrunken text up onto the outline. */
1378
+ label.floated {
1379
+ border-top-color: transparent;
1380
+ }
1381
+ label.floated::before,
1382
+ label.floated::after {
1383
+ border-top-color: var(--_border);
1384
+ }
1385
+ label.floated .label-text {
1386
+ font-size: calc(var(--_font) * 0.8);
1387
+ transform: translateY(calc(var(--_height) / -2));
1388
+ @supports (corner-shape: squircle) {
1389
+ /* The floated left shoulder is widened to the 1em label offset below, so
1390
+ drop the text's own gap to keep it landing at the same spot (the
1391
+ ::before min-width and this margin animate together → no horizontal shift). */
1392
+ margin-left: 0;
1393
+ }
1394
+ }
1395
+ /* Floated: widen the left shoulder to the label's 1em content offset so the
1396
+ (now larger, capped) squircle corner has room and its seam meets the side. */
1397
+ @supports (corner-shape: squircle) {
1398
+ label.floated::before {
1399
+ min-width: 1em;
1400
+ /* Trim the trailing 0.3em of the shoulder so the line stops short of
1401
+ the text — the same gap the ::after's margin leaves on the right.
1402
+ A squircle is dead flat over its last third, so only the straight
1403
+ tail of the corner falls in the trimmed region; the curve itself
1404
+ still reads complete. */
1405
+ mask-image: linear-gradient(to right, #000 calc(100% - 0.3em), #0000 0);
1406
+ }
1407
+ }
1408
+
1409
+ .trigger:hover label {
1410
+ border-top-color: var(--_border-hover);
1411
+ /* Snap the notch color in on hover; the base rule eases it back out on leave. */
1412
+ transition: color var(--_duration) var(--_ease);
1413
+ }
1414
+ .trigger:hover label.floated {
1415
+ border-top-color: transparent;
1416
+ }
1417
+ .trigger:hover label.floated::before,
1418
+ .trigger:hover label.floated::after {
1419
+ border-top-color: var(--_border-hover);
1420
+ /* Snap the notch color in on hover; the base rule eases it back out.
1421
+ Keep min-width animating — the label floats while hovered (opening is
1422
+ a click), so dropping it here would snap the shoulder mid-float. */
1423
+ transition: min-width 200ms var(--_ease-label);
1424
+ }
1425
+
1426
+ /* The label's own border-top stays 1px on focus/open — an open/focused label
1427
+ is always floated (its own border is then transparent), so thickening it
1428
+ here was invisible yet grew the label's content box, nudging the notch
1429
+ shoulders and centred text down ~1px. The focus emphasis comes from the
1430
+ notch shoulders (::before/::after) below, which thicken without moving. */
1431
+ .trigger.open label,
1432
+ .trigger:focus-within label {
1433
+ border-top-color: var(--_border-focus);
1434
+ color: var(--_border-focus);
1435
+ /* Snap the notch color in on focus (mirrors the hover rule); the text
1436
+ color still eases. The base rule eases both back out on blur. */
1437
+ transition: color var(--_duration) var(--_ease);
1438
+ }
1439
+ .trigger.open label.floated,
1440
+ .trigger:focus-within label.floated {
1441
+ border-top-color: transparent;
1442
+ }
1443
+ .trigger.open label::before,
1444
+ .trigger.open label::after,
1445
+ .trigger:focus-within label::before,
1446
+ .trigger:focus-within label::after {
1447
+ border-top-width: 2px;
1448
+ }
1449
+ .trigger.open label.floated::before,
1450
+ .trigger.open label.floated::after,
1451
+ .trigger:focus-within label.floated::before,
1452
+ .trigger:focus-within label.floated::after {
1453
+ border-top-color: var(--_border-focus);
1454
+ /* Snap the shoulder color in on focus (mirrors the hover rule); keep
1455
+ min-width animating so the notch still opens smoothly. */
1456
+ transition: min-width 200ms var(--_ease-label);
1457
+ }
1458
+
1459
+ .select.has-error label {
1460
+ border-top-color: var(--_border-error);
1461
+ color: var(--_border-error);
1462
+ }
1463
+ .select.has-error label.floated {
1464
+ border-top-color: transparent;
1465
+ }
1466
+ .select.has-error label.floated::before,
1467
+ .select.has-error label.floated::after {
1468
+ border-top-color: var(--_border-error);
1469
+ }
1470
+
1471
+ .required-mark {
1472
+ color: var(--_border-error);
1473
+ margin-left: 0.15em;
1474
+ }
1475
+
1476
+ /* ================================================================== */
1477
+ /* VALUE / CHIPS */
1478
+ /* ================================================================== */
1479
+
1480
+ .value {
1481
+ flex: 1;
1482
+ display: flex;
1483
+ flex-wrap: wrap;
1484
+ align-items: center;
1485
+ gap: 0.35em;
1486
+ min-width: 0;
1487
+ /* Kept visible so each chip's enlarged (overflowing) remove-button
1488
+ touch target isn't clipped. Single value / placeholder truncate
1489
+ themselves below. */
1490
+ overflow: visible;
1491
+ }
1492
+
1493
+ .single-value,
1494
+ .placeholder {
1495
+ flex: 1;
1496
+ min-width: 0;
1497
+ overflow: hidden;
1498
+ text-overflow: ellipsis;
1499
+ white-space: nowrap;
1500
+ }
1501
+
1502
+ .placeholder {
1503
+ color: var(--_text-muted);
1504
+ }
1505
+
1506
+ .chip {
1507
+ display: inline-flex;
1508
+ align-items: center;
1509
+ gap: 0.3em;
1510
+ padding: 0.2em 0.3em 0.2em 0.7em;
1511
+ border-radius: var(--radius-full, 999px);
1512
+ background: var(--_chip-bg);
1513
+ color: var(--_chip-text);
1514
+ font-size: 0.82em;
1515
+ max-width: 100%;
1516
+ line-height: 1.45;
1517
+ }
1518
+ .chip > span:first-child {
1519
+ overflow: hidden;
1520
+ text-overflow: ellipsis;
1521
+ white-space: nowrap;
1522
+ }
1523
+
1524
+ .chip-remove {
1525
+ position: relative;
1526
+ display: inline-flex;
1527
+ align-items: center;
1528
+ justify-content: center;
1529
+ width: 1.35em;
1530
+ height: 1.35em;
1531
+ flex-shrink: 0;
1532
+ border-radius: var(--radius-full, 999px);
1533
+ color: inherit;
1534
+ cursor: pointer;
1535
+ opacity: 0.75;
1536
+ transition:
1537
+ opacity var(--_duration) var(--_ease),
1538
+ background var(--_duration) var(--_ease);
1539
+ }
1540
+ /* Invisible hit area extending ~10px past the icon on every side so the
1541
+ button is easy to tap. The visible hover feedback stays the size of the
1542
+ element itself (above), not the touch target. */
1543
+ .chip-remove::before {
1544
+ content: '';
1545
+ position: absolute;
1546
+ inset: -10px;
1547
+ }
1548
+ .chip-remove:hover {
1549
+ opacity: 1;
1550
+ background: color-mix(in oklch, currentColor 22%, transparent);
1551
+ /* Snap the tint in on hover; keep the opacity reveal eased both ways. */
1552
+ transition: opacity var(--_duration) var(--_ease);
1553
+ }
1554
+
1555
+ /* Clear button */
1556
+ .clear {
1557
+ display: flex;
1558
+ align-items: center;
1559
+ justify-content: center;
1560
+ flex-shrink: 0;
1561
+ padding: 0.35em;
1562
+ border-radius: var(--radius-full, 999px);
1563
+ color: var(--_text-muted);
1564
+ cursor: pointer;
1565
+ opacity: 0.7;
1566
+ transition:
1567
+ opacity var(--_duration) var(--_ease),
1568
+ background var(--_duration) var(--_ease);
1569
+ }
1570
+ .clear:hover {
1571
+ opacity: 1;
1572
+ background: var(--_panel-hover);
1573
+ /* Snap the tint in on hover; keep the opacity reveal eased both ways. */
1574
+ transition: opacity var(--_duration) var(--_ease);
1575
+ }
1576
+
1577
+ /* Chevron */
1578
+ .chevron {
1579
+ display: flex;
1580
+ align-items: center;
1581
+ flex-shrink: 0;
1582
+ color: var(--_text-muted);
1583
+ transition:
1584
+ transform 300ms var(--_ease-back),
1585
+ color var(--_duration) var(--_ease);
1586
+ }
1587
+ .chevron.open {
1588
+ transform: rotate(180deg);
1589
+ }
1590
+ .trigger.open .chevron,
1591
+ .trigger:focus-within .chevron {
1592
+ color: var(--_border-focus);
1593
+ }
1594
+
1595
+ /* Spinner */
1596
+ .spinner {
1597
+ display: inline-block;
1598
+ width: 1.05em;
1599
+ height: 1.05em;
1600
+ border: 2px solid var(--_border);
1601
+ border-top-color: var(--_border-focus);
1602
+ border-radius: 50%;
1603
+ animation: spin 0.6s linear infinite;
1604
+ flex-shrink: 0;
1605
+ }
1606
+ @keyframes spin {
1607
+ to {
1608
+ transform: rotate(360deg);
1609
+ }
1610
+ }
1611
+
1612
+ /* ================================================================== */
1613
+ /* DROPDOWN (native popover, CSS anchor positioned) */
1614
+ /* ================================================================== */
1615
+
1616
+ /*
1617
+ * The dropdown is a native `popover` element — it renders in the top
1618
+ * layer (no clipping, no z-index juggling, no Portal) and is placed with
1619
+ * CSS anchor positioning relative to the trigger. `position-anchor` is set
1620
+ * inline to a per-instance anchor name.
1621
+ */
1622
+ .dropdown {
1623
+ position: fixed;
1624
+ top: anchor(bottom);
1625
+ bottom: auto;
1626
+ left: anchor(left);
1627
+ right: auto;
1628
+ width: anchor-size(width);
1629
+ margin: 0.4em 0 0 0;
1630
+ padding: 0.3em;
1631
+ box-sizing: border-box;
1632
+ max-height: 18em;
1633
+ overflow-y: auto;
1634
+ /* Border + shadow together: in light mode the shadow lifts the panel and
1635
+ the border is a faint edge; in dark mode --shadow-md is transparent, so
1636
+ the border is what separates the panel from the page. */
1637
+ border: 1px solid var(--_border);
1638
+ background: var(--_panel);
1639
+ color: var(--_text);
1640
+ border-radius: var(--radius-xl, 16px);
1641
+ /* Keep the native (baseline-styled) thumb clear of the rounded corners.
1642
+ scrollbar-width/scrollbar-color must NOT be set here — they disable the
1643
+ ::-webkit-scrollbar baseline styling in Chromium. */
1644
+ --scrollbar-track-inset: calc(var(--radius-xl, 16px) / 2);
1645
+ @supports (corner-shape: squircle) {
1646
+ corner-shape: squircle;
1647
+ border-radius: calc(var(--radius-xl, 16px) * var(--squircle-ratio, 2));
1648
+ --scrollbar-track-inset: calc(
1649
+ var(--radius-xl, 16px) * var(--squircle-ratio, 2) / 2
1650
+ );
1651
+ }
1652
+ overscroll-behavior: contain;
1653
+ box-shadow: var(--shadow-md, 0 8px 28px -8px rgb(0 0 0 / 0.3));
1654
+ /* Flip above the trigger when there is no room below */
1655
+ position-try-fallbacks: flip-block;
1656
+ /* Expand-in from the edge closest to the trigger — origin flips to
1657
+ `bottom` when the panel is placed above the control (`.above`). */
1658
+ transform-origin: center top;
1659
+ opacity: 1;
1660
+ transform: scaleY(1);
1661
+ transition:
1662
+ opacity 200ms var(--_ease-expand),
1663
+ transform 200ms var(--_ease-expand),
1664
+ display 200ms allow-discrete,
1665
+ overlay 200ms allow-discrete;
1666
+ }
1667
+ .dropdown.above {
1668
+ transform-origin: center bottom;
1669
+ }
1670
+ /* Collapsed state — drives both the open (@starting-style) and close
1671
+ transitions, so the panel expands/collapses toward the trigger. */
1672
+ .dropdown:not(:popover-open) {
1673
+ opacity: 0;
1674
+ transform: scaleY(0.6);
1675
+ }
1676
+ @starting-style {
1677
+ .dropdown:popover-open {
1678
+ opacity: 0;
1679
+ transform: scaleY(0.6);
1680
+ }
1681
+ }
1682
+
1683
+ /* Search */
1684
+ .search {
1685
+ padding: 0.25em 0.25em 0.4em;
1686
+ }
1687
+ .search input {
1688
+ width: 100%;
1689
+ padding: 0.6em 0.8em;
1690
+ border: 1px solid var(--_border);
1691
+ /* A larger radius than the default so it doesn't read as sharper than
1692
+ the surrounding popover. */
1693
+ border-radius: var(--radius-lg, 10px);
1694
+ @supports (corner-shape: squircle) {
1695
+ corner-shape: squircle;
1696
+ border-radius: calc(var(--radius-lg, 10px) * var(--squircle-ratio, 2));
1697
+ }
1698
+ background: var(--_bg);
1699
+ color: var(--_text);
1700
+ font: inherit;
1701
+ outline: none;
1702
+ box-shadow: none;
1703
+ transition: border-color var(--_duration) var(--_ease);
1704
+ }
1705
+ .search input:focus {
1706
+ border-color: var(--_border-focus);
1707
+ }
1708
+ .search input::placeholder {
1709
+ color: var(--_text-muted);
1710
+ }
1711
+
1712
+ /* Options */
1713
+ .option {
1714
+ position: relative;
1715
+ display: flex;
1716
+ align-items: center;
1717
+ gap: 0.6em;
1718
+ padding: 0.7em 0.85em;
1719
+ border-radius: var(--radius-md, 8px);
1720
+ @supports (corner-shape: squircle) {
1721
+ corner-shape: squircle;
1722
+ border-radius: calc(var(--radius-md, 8px) * var(--squircle-ratio, 2));
1723
+ }
1724
+ cursor: pointer;
1725
+ /* Self-contained perspective so the pressed dip recedes toward each
1726
+ * option's own center, not the center of the whole list. */
1727
+ transform-origin: center center;
1728
+ /* Durations match ListItem: the highlight eases out over 300ms; the
1729
+ corner merge (below) animates over 150ms. */
1730
+ transition:
1731
+ background 300ms ease,
1732
+ border-radius 150ms ease,
1733
+ transform 200ms ease;
1734
+ user-select: none;
1735
+ }
1736
+ .option:hover,
1737
+ .option.highlighted {
1738
+ background: var(--_option-hover);
1739
+ /* Snap the highlight in on hover/keyboard nav; the base rule eases it out. */
1740
+ transition:
1741
+ border-radius 150ms ease,
1742
+ transform 200ms ease;
1743
+ }
1744
+ /* Hairline between consecutive rows, matching ListItem's separator (same
1745
+ 6% text tint, same 1rem inset). Group labels carry their own stronger
1746
+ rule, so only option-to-option seams get one. */
1747
+ .option + .option::after {
1748
+ content: '';
1749
+ position: absolute;
1750
+ top: 0;
1751
+ left: 1rem;
1752
+ right: 1rem;
1753
+ border-top: 1px solid var(--_option-hover);
1754
+ }
1755
+ /* The first and last rows hug the panel's rounded corners (panel radius
1756
+ minus its 0.3em padding) so a highlighted edge item nests cleanly. */
1757
+ .dropdown > .option:first-child,
1758
+ .dropdown > .group-label:first-child {
1759
+ border-top-left-radius: calc(var(--radius-xl, 16px) - 0.3em);
1760
+ border-top-right-radius: calc(var(--radius-xl, 16px) - 0.3em);
1761
+ @supports (corner-shape: squircle) {
1762
+ corner-shape: squircle;
1763
+ border-top-left-radius: calc(
1764
+ (var(--radius-xl, 16px) - 0.3em) * var(--squircle-ratio, 2)
1765
+ );
1766
+ border-top-right-radius: calc(
1767
+ (var(--radius-xl, 16px) - 0.3em) * var(--squircle-ratio, 2)
1768
+ );
1769
+ }
1770
+ }
1771
+ .dropdown > .option:last-child,
1772
+ .dropdown > .empty:last-child {
1773
+ border-bottom-left-radius: calc(var(--radius-xl, 16px) - 0.3em);
1774
+ border-bottom-right-radius: calc(var(--radius-xl, 16px) - 0.3em);
1775
+ @supports (corner-shape: squircle) {
1776
+ corner-shape: squircle;
1777
+ border-bottom-left-radius: calc(
1778
+ (var(--radius-xl, 16px) - 0.3em) * var(--squircle-ratio, 2)
1779
+ );
1780
+ border-bottom-right-radius: calc(
1781
+ (var(--radius-xl, 16px) - 0.3em) * var(--squircle-ratio, 2)
1782
+ );
1783
+ }
1784
+ }
1785
+ .option.selected {
1786
+ color: var(--_border-focus);
1787
+ font-weight: 600;
1788
+ background: var(--_option-selected);
1789
+ }
1790
+ /* A hovered/highlighted selected row deepens its action-color wash (rather
1791
+ than switching to the gray hover fill, which would read as a downgrade).
1792
+ These win over the resting .selected tint by specificity; the
1793
+ snap-in/ease-out timing is still governed by the :hover rule above. */
1794
+ .option.selected:hover,
1795
+ .option.selected.highlighted {
1796
+ background: var(--_option-selected-hover);
1797
+ }
1798
+ /* Adjacent lit rows merge into one block (mirrors ListItem): when a
1799
+ selected/highlighted row touches another, square off the corners where
1800
+ they meet so the pair reads as one continuous selection instead of two
1801
+ rounded pills. The border-radius transition above animates the merge. */
1802
+ .option:is(.selected, .highlighted):not(.disabled):has(
1803
+ + .option:is(.selected, .highlighted):not(.disabled)
1804
+ ) {
1805
+ border-bottom-left-radius: 0;
1806
+ border-bottom-right-radius: 0;
1807
+ }
1808
+ .option:is(.selected, .highlighted):not(.disabled)
1809
+ + .option:is(.selected, .highlighted):not(.disabled) {
1810
+ border-top-left-radius: 0;
1811
+ border-top-right-radius: 0;
1812
+ }
1813
+ .option.disabled {
1814
+ opacity: 0.5;
1815
+ pointer-events: none;
1816
+ }
1817
+ /* Pressed feedback — the same tactile dip as ListItem (perspective 100px,
1818
+ * depth clamped off the font size). The perspective is baked into the
1819
+ * transform so the recede is relative to the option itself. */
1820
+ .option:active:not(.disabled) {
1821
+ transform: perspective(100px)
1822
+ translate3d(0, 1px, clamp(-10px, calc(0.2em - 12px), -2px));
1823
+ }
1824
+
1825
+ .option-content {
1826
+ display: flex;
1827
+ flex-direction: column;
1828
+ min-width: 0;
1829
+ flex: 1;
1830
+ }
1831
+ .option-label {
1832
+ overflow: hidden;
1833
+ text-overflow: ellipsis;
1834
+ white-space: nowrap;
1835
+ }
1836
+ .option-desc {
1837
+ font-size: 0.8em;
1838
+ color: var(--_text-muted);
1839
+ overflow: hidden;
1840
+ text-overflow: ellipsis;
1841
+ white-space: nowrap;
1842
+ }
1843
+
1844
+ /* Checkmarks */
1845
+ .check {
1846
+ display: flex;
1847
+ align-items: center;
1848
+ justify-content: center;
1849
+ width: 1.1em;
1850
+ height: 1.1em;
1851
+ flex-shrink: 0;
1852
+ color: var(--_border-focus);
1853
+ }
1854
+ .check-single {
1855
+ display: flex;
1856
+ align-items: center;
1857
+ margin-left: auto;
1858
+ flex-shrink: 0;
1859
+ color: var(--_border-focus);
1860
+ }
1861
+
1862
+ /* Group label — set off from the preceding group with space and a rule */
1863
+ .group-label {
1864
+ margin-top: 0.5em;
1865
+ padding: 0.7em 0.85em 0.3em;
1866
+ border-top: 1px solid color-mix(in oklch, var(--_text) 9%, transparent);
1867
+ font-size: 0.72em;
1868
+ font-weight: 700;
1869
+ letter-spacing: 0.06em;
1870
+ color: var(--_text-muted);
1871
+ text-transform: uppercase;
1872
+ user-select: none;
1873
+ }
1874
+ /* No rule above the first group when nothing precedes it in the panel */
1875
+ .group-label:first-child {
1876
+ margin-top: 0;
1877
+ border-top: none;
1878
+ padding-top: 0.4em;
1879
+ }
1880
+
1881
+ /* Create option */
1882
+ .create {
1883
+ font-style: italic;
1884
+ color: var(--_text-muted);
1885
+ }
1886
+
1887
+ /* Empty / loading state — same metrics as Input's .status row */
1888
+ .empty {
1889
+ display: flex;
1890
+ align-items: center;
1891
+ justify-content: center;
1892
+ gap: 0.5em;
1893
+ padding: 0.85em;
1894
+ color: var(--_text-muted);
1895
+ font-size: 0.9em;
1896
+ }
1897
+
1898
+ /* Error message */
1899
+ .error-text {
1900
+ display: block;
1901
+ font-size: 0.78em;
1902
+ color: var(--_border-error);
1903
+ margin-top: 0.35em;
1904
+ padding: 0 0.5em;
1905
+ }
1906
+
1907
+ .description-text {
1908
+ display: block;
1909
+ font-size: 0.78em;
1910
+ color: var(--_text-muted, light-dark(hsl(0 0% 46%), hsl(0 0% 62%)));
1911
+ margin-top: 0.35em;
1912
+ padding: 0 0.5em;
1913
+ }
1914
+
1915
+ /* Icons scale with the control's font size */
1916
+ .chevron svg {
1917
+ width: 1.4em;
1918
+ height: 1.4em;
1919
+ }
1920
+ .clear svg {
1921
+ width: 1.35em;
1922
+ height: 1.35em;
1923
+ }
1924
+ .chip-remove svg {
1925
+ width: 0.85em;
1926
+ height: 0.85em;
1927
+ }
1928
+ .check svg,
1929
+ .check-single svg {
1930
+ width: 100%;
1931
+ height: 100%;
1932
+ }
1933
+ </style>