@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,2858 @@
1
+ <script lang="ts" module>
2
+ export type InputType =
3
+ | 'text'
4
+ | 'email'
5
+ | 'password'
6
+ | 'url'
7
+ | 'tel'
8
+ | 'search'
9
+ | 'number'
10
+ | 'textarea'
11
+ | 'date'
12
+ | 'time'
13
+ | 'datetime-local'
14
+ | 'color'
15
+ | 'file';
16
+
17
+ export interface InputOption {
18
+ /** The value inserted into the input when this option is chosen */
19
+ value: string;
20
+ /** Display text for the option */
21
+ label: string;
22
+ /** Whether this option cannot be selected */
23
+ disabled?: boolean;
24
+ /** Secondary descriptive text shown under the label */
25
+ description?: string;
26
+ }
27
+ </script>
28
+
29
+ <script lang="ts">
30
+ import { tooltip } from '@delightstack/utilities';
31
+ import { getContext, type Component, type Snippet } from 'svelte';
32
+ import { scale } from 'svelte/transition';
33
+ import { flip } from 'svelte/animate';
34
+ import { backOut, quintOut } from 'svelte/easing';
35
+ import type { FormContext } from './Form.svelte';
36
+ import Button from '../actions/Button.svelte';
37
+ import List from '../display/List.svelte';
38
+ import ListItem from '../display/ListItem.svelte';
39
+
40
+ type InputValue =
41
+ | string
42
+ | number
43
+ | boolean
44
+ | string[]
45
+ | File
46
+ | File[]
47
+ | null
48
+ | undefined;
49
+
50
+ const propId = $props.id();
51
+ let {
52
+ /* ---- Core ---- */
53
+ /** Input type */
54
+ type = 'text' as InputType,
55
+
56
+ /** Current value (bindable) */
57
+ value = $bindable() as InputValue,
58
+
59
+ /** Floating label text */
60
+ label = undefined as string | undefined,
61
+
62
+ /** Placeholder text */
63
+ placeholder = undefined as string | undefined,
64
+
65
+ /** How the label coexists with a distinct placeholder.
66
+ * 'float' — label rests in the field; on focus it floats up and the placeholder fades in
67
+ * 'pinned' — label is always pinned to the top; placeholder always visible in the field */
68
+ label_display = 'float' as 'float' | 'pinned',
69
+
70
+ /** Whether the input is disabled */
71
+ disabled = false,
72
+
73
+ /** Whether the input is read-only */
74
+ readonly = false,
75
+
76
+ /** Whether the input is required */
77
+ required = false,
78
+
79
+ /** Form field name (used for Form context registration) */
80
+ name = undefined as string | undefined,
81
+
82
+ /** Show skeleton loading state */
83
+ skeleton = false,
84
+
85
+ /** Tooltip text */
86
+ tooltip: tooltip_message = undefined as string | undefined,
87
+
88
+ /* ---- Validation ---- */
89
+ /** Error message or boolean error state */
90
+ error = undefined as string | boolean | undefined,
91
+
92
+ /** Parses & validates the value (e.g. a database table form field's `parse`).
93
+ * Throws an error whose message is shown below the input.
94
+ * Standalone, it runs when the input is blurred (and re-runs on every
95
+ * change while the input is errored, so the error clears immediately).
96
+ * Inside a Form, it is registered with the form instead — the form runs
97
+ * it alongside the form-level schema, so the two never conflict. */
98
+ parse = undefined as ((value: unknown) => unknown) | undefined,
99
+
100
+ /** Regex pattern for validation */
101
+ pattern = undefined as string | undefined,
102
+
103
+ /** Minimum length */
104
+ minlength = undefined as number | undefined,
105
+
106
+ /** Maximum length */
107
+ maxlength = undefined as number | undefined,
108
+
109
+ /** Minimum value (number/date) */
110
+ min = undefined as number | string | undefined,
111
+
112
+ /** Maximum value (number/date) */
113
+ max = undefined as number | string | undefined,
114
+
115
+ /** Step value for number inputs */
116
+ step = undefined as number | undefined,
117
+
118
+ /* ---- Visual Options ---- */
119
+ /** Input size */
120
+ size = '1' as '0' | '1' | '2' | '3',
121
+
122
+ /** Text displayed before the input */
123
+ prefix = undefined as string | undefined,
124
+
125
+ /** Text displayed after the input */
126
+ suffix = undefined as string | undefined,
127
+
128
+ /** Leading icon component */
129
+ icon = undefined as Component | undefined,
130
+
131
+ /** Show clear button when value is present */
132
+ clearable = false,
133
+
134
+ /** Show character count */
135
+ show_counter = false,
136
+
137
+ /** Description text displayed below the input */
138
+ description = undefined as string | undefined,
139
+
140
+ /** Tighter internal spacing */
141
+ dense = false,
142
+
143
+ /** More internal spacing */
144
+ comfortable = false,
145
+
146
+ /** Paint a filled surface background behind the control (vs the default
147
+ * transparent/outlined look) */
148
+ filled = false,
149
+
150
+ /** Element ID */
151
+ id = propId,
152
+
153
+ /** Additional CSS classes */
154
+ class: class_name = '',
155
+
156
+ /* ---- Autocomplete ---- */
157
+ /** Suggestion options for autocomplete */
158
+ options = undefined as InputOption[] | undefined,
159
+
160
+ /** Async filter callback for loading suggestions */
161
+ onfilter = undefined as ((query: string) => Promise<InputOption[]>) | undefined,
162
+
163
+ /* ---- Multiple/Chips ---- */
164
+ /** Enable chips/tags mode (value becomes string[]) */
165
+ multiple = false,
166
+
167
+ /* ---- Textarea ---- */
168
+ /** Initial rows for textarea */
169
+ rows = 3,
170
+
171
+ /** Auto-grow textarea to fit content */
172
+ auto_resize = false,
173
+
174
+ /* ---- Password ---- */
175
+ /** Show password visibility toggle */
176
+ show_toggle = false,
177
+
178
+ /** Show password strength meter */
179
+ strength_indicator = false,
180
+
181
+ /* ---- Mask ---- */
182
+ /** Input mask pattern (#=digit, A=letter, *=any) */
183
+ mask = undefined as string | undefined,
184
+
185
+ /* ---- File ---- */
186
+ /** Accepted file types */
187
+ accept = undefined as string | undefined,
188
+
189
+ /* ---- Autocomplete option snippet ---- */
190
+ /** Custom snippet for rendering an autocomplete option */
191
+ option: option_snippet = undefined as Snippet<[InputOption]> | undefined,
192
+
193
+ /* ---- Events ---- */
194
+ /** Called when value is changing */
195
+ oninput = undefined as ((detail: { value: InputValue }) => void) | undefined,
196
+
197
+ /** Called when value is committed */
198
+ onchange = undefined as ((detail: { value: InputValue }) => void) | undefined,
199
+
200
+ /** Called when input is focused */
201
+ onfocus = undefined as (() => void) | undefined,
202
+
203
+ /** Called when input is blurred */
204
+ onblur = undefined as (() => void) | undefined,
205
+ } = $props();
206
+
207
+ /* ------------------------------------------------------------------ */
208
+ /* Form context integration */
209
+ /* ------------------------------------------------------------------ */
210
+
211
+ const form_ctx = getContext<FormContext | undefined>('form');
212
+
213
+ $effect(() => {
214
+ if (!form_ctx || !name) return;
215
+ const el = input_element ?? textarea_element ?? file_input_element;
216
+ if (el) form_ctx.register(name, el, parse);
217
+ return () => {
218
+ if (name) form_ctx.unregister(name);
219
+ };
220
+ });
221
+
222
+ /**
223
+ * Context-driven mode: inside a Form, with a name, and no value passed,
224
+ * the input mirrors the form data (e.g. an entity's draft) instead of a
225
+ * local binding — `<Input {...field.email} />` needs no bind:value.
226
+ * Decided once at mount so an explicit (initially-undefined) binding
227
+ * isn't hijacked after its first write.
228
+ */
229
+ const context_driven = !!(form_ctx && name && value === undefined);
230
+
231
+ $effect(() => {
232
+ if (!context_driven || !form_ctx || !name) return;
233
+ const ctx_value = form_ctx.getValue(name);
234
+ if (ctx_value !== value) value = ctx_value as InputValue;
235
+ });
236
+
237
+ /* ------------------------------------------------------------------ */
238
+ /* Standalone parse validation (outside a Form) */
239
+ /* ------------------------------------------------------------------ */
240
+
241
+ /** Error from running `parse` standalone. Inside a Form the form runs
242
+ * `parse` instead (it was registered above), so this never sets there. */
243
+ let parse_error = $state<string | undefined>(undefined);
244
+
245
+ function runParse() {
246
+ if (!parse || form_ctx) return;
247
+ try {
248
+ parse(value);
249
+ parse_error = undefined;
250
+ } catch (e) {
251
+ parse_error = e instanceof Error ? e.message : 'Invalid value';
252
+ }
253
+ }
254
+
255
+ /** Re-validate as the user types, but only while already errored — the
256
+ * error clears the moment the value is fixed without nagging beforehand. */
257
+ function reparseIfErrored() {
258
+ if (parse_error) runParse();
259
+ }
260
+
261
+ /** Error from local prop, standalone parse, or form context */
262
+ const resolved_error = $derived.by(() => {
263
+ if (error !== undefined) return error;
264
+ if (parse_error) return parse_error;
265
+ if (form_ctx && name && form_ctx.errors[name]) return form_ctx.errors[name];
266
+ return undefined;
267
+ });
268
+
269
+ /** Whether the input is effectively disabled (loading skeleton counts) */
270
+ const effectively_disabled = $derived(
271
+ disabled || skeleton || (form_ctx?.disabled ?? false),
272
+ );
273
+
274
+ /* ------------------------------------------------------------------ */
275
+ /* Internal state */
276
+ /* ------------------------------------------------------------------ */
277
+
278
+ let input_element = $state<HTMLInputElement | HTMLButtonElement | undefined>(undefined);
279
+ let textarea_element = $state<HTMLTextAreaElement | undefined>(undefined);
280
+ let wrapper_element = $state<HTMLElement | undefined>(undefined);
281
+ let focused = $state(false);
282
+ let password_visible = $state(false);
283
+ let chip_input_value = $state('');
284
+
285
+ /* Autocomplete state */
286
+ let ac_open = $state(false);
287
+ let ac_highlighted = $state(-1);
288
+ let ac_loading = $state(false);
289
+ let ac_filtered = $state<InputOption[]>([]);
290
+ let ac_debounce_timer: ReturnType<typeof setTimeout> | undefined;
291
+ let dropdown_element = $state<HTMLElement | undefined>(undefined);
292
+ /* Whether the panel flipped above the field, so it can expand from the edge
293
+ nearest the control (matching Select's panel). */
294
+ let ac_above = $state(false);
295
+
296
+ /* File state */
297
+ let file_input_element = $state<HTMLInputElement | undefined>(undefined);
298
+
299
+ /* ------------------------------------------------------------------ */
300
+ /* Derived values */
301
+ /* ------------------------------------------------------------------ */
302
+
303
+ const is_textarea = $derived(type === 'textarea');
304
+ const is_password = $derived(type === 'password');
305
+ const is_number = $derived(type === 'number');
306
+ const is_search = $derived(type === 'search');
307
+ const is_file = $derived(type === 'file');
308
+ const is_color = $derived(type === 'color');
309
+ const is_datelike = $derived(
310
+ type === 'date' || type === 'time' || type === 'datetime-local',
311
+ );
312
+ const has_autocomplete = $derived(!!(options || onfilter));
313
+
314
+ /** A unique CSS anchor name for native anchor positioning of the panel. */
315
+ const ac_anchor_name = $derived(
316
+ `--ds-input-${String(id).replace(/[^a-zA-Z0-9_-]/g, '')}`,
317
+ );
318
+
319
+ /** Resolved HTML input type */
320
+ const html_type = $derived.by(() => {
321
+ if (is_password) return password_visible ? 'text' : 'password';
322
+ if (type === 'datetime-local') return 'datetime-local';
323
+ if (is_textarea || is_file || is_color) return 'text';
324
+ return type;
325
+ });
326
+
327
+ /**
328
+ * Types that render their own intrinsic content — a colour swatch, the
329
+ * browser's native date format, a file button — and therefore can't use the
330
+ * label as an in-field placeholder. Their label stays pinned to the top.
331
+ */
332
+ const always_float_type = $derived(is_color || is_file || is_datelike);
333
+
334
+ /**
335
+ * A placeholder is "distinct" only when it differs from the label. Without
336
+ * one, the label animates and doubles as the placeholder (the legacy
337
+ * behaviour). With one, `label_display` decides: 'pinned' keeps the label at
338
+ * the top with the placeholder always visible; 'float' (default) lets the
339
+ * label rest in the field and fades the placeholder in once it floats.
340
+ */
341
+ const has_distinct_placeholder = $derived(!!placeholder && placeholder !== label);
342
+
343
+ /**
344
+ * Whether the label is permanently pinned to the top: an always-visible
345
+ * prefix, a type that can't host the label as a placeholder, or a distinct
346
+ * placeholder in 'pinned' mode.
347
+ */
348
+ const label_pinned = $derived(
349
+ !!label &&
350
+ (always_float_type ||
351
+ !!prefix ||
352
+ (has_distinct_placeholder && label_display === 'pinned')),
353
+ );
354
+
355
+ /**
356
+ * Whether the placeholder is deferred: rendered on the native control but
357
+ * kept invisible (CSS) until the label floats out of its way on focus.
358
+ */
359
+ const placeholder_deferred = $derived(
360
+ !!label && has_distinct_placeholder && !label_pinned,
361
+ );
362
+
363
+ /**
364
+ * The placeholder handed to the native control. Suppressed while the label is
365
+ * acting as the in-field placeholder, so the two never overlap. A deferred
366
+ * placeholder is still passed through — CSS hides it until the label floats.
367
+ */
368
+ const native_placeholder = $derived.by(() => {
369
+ if (!label) return placeholder;
370
+ if (has_distinct_placeholder) return placeholder;
371
+ return undefined;
372
+ });
373
+
374
+ /** Whether the label should float (up position) */
375
+ const label_floated = $derived.by(() => {
376
+ if (!label) return false;
377
+ if (label_pinned) return true;
378
+ /* Otherwise the label animates up on focus or once there's a value. */
379
+ if (focused) return true;
380
+ /* In chips mode `value` is an array; an empty array is "no content", so
381
+ float only once there's at least one chip (don't fall through to the
382
+ scalar check below, where `[] !== ''` would wrongly count as content). */
383
+ if (multiple) return Array.isArray(value) && value.length > 0;
384
+ if (value !== undefined && value !== null && value !== '') return true;
385
+ return false;
386
+ });
387
+
388
+ /** Whether there is a displayable error */
389
+ const has_error = $derived(!!resolved_error);
390
+ const error_message = $derived(
391
+ typeof resolved_error === 'string' ? resolved_error : '',
392
+ );
393
+
394
+ /** Display string for value length */
395
+ const value_length = $derived.by(() => {
396
+ if (typeof value === 'string') return value.length;
397
+ return 0;
398
+ });
399
+
400
+ /** Visible autocomplete options */
401
+ const ac_options = $derived.by((): InputOption[] => {
402
+ if (onfilter) return ac_filtered;
403
+ if (!options) return [];
404
+ const q = typeof value === 'string' ? value.toLowerCase().trim() : '';
405
+ if (!q) return options;
406
+ return options.filter((o) => o.label.toLowerCase().includes(q));
407
+ });
408
+
409
+ /** Password strength (0-4) */
410
+ const password_strength = $derived.by((): number => {
411
+ if (!strength_indicator || type !== 'password' || typeof value !== 'string' || !value)
412
+ return 0;
413
+ let score = 0;
414
+ if (value.length >= 8) score++;
415
+ if (value.length >= 12) score++;
416
+ if (/[A-Z]/.test(value) && /[a-z]/.test(value)) score++;
417
+ if (/[0-9]/.test(value)) score++;
418
+ if (/[^A-Za-z0-9]/.test(value)) score++;
419
+ return Math.min(score, 4);
420
+ });
421
+
422
+ const strength_label = $derived.by(() => {
423
+ const labels = ['', 'Weak', 'Fair', 'Strong', 'Very strong'];
424
+ return labels[password_strength] ?? '';
425
+ });
426
+
427
+ const strength_color = $derived.by(() => {
428
+ const colors = [
429
+ 'var(--color-border, hsl(0 0% 80%))',
430
+ 'var(--color-error, #d32f2f)',
431
+ 'var(--color-warning, #f59e0b)',
432
+ 'var(--color-success, #16a34a)',
433
+ 'var(--color-success, #16a34a)',
434
+ ];
435
+ return colors[password_strength] ?? colors[0];
436
+ });
437
+
438
+ /** Size config */
439
+ const size_config = $derived.by(() => {
440
+ /* The font drives the whole control's scale (see CSS --_height). The
441
+ per-size font comes from the shared --control-font-* tokens so Input,
442
+ Select and Button line up at the same height for a given size. */
443
+ const configs: Record<string, { font: string; icon_size: number }> = {
444
+ '0': { font: 'var(--control-font-0, 0.875rem)', icon_size: 15 },
445
+ '1': { font: 'var(--control-font-1, 1rem)', icon_size: 17 },
446
+ '2': { font: 'var(--control-font-2, 1.125rem)', icon_size: 19 },
447
+ '3': { font: 'var(--control-font-3, 1.25rem)', icon_size: 21 },
448
+ };
449
+ return configs[size] ?? configs['1'];
450
+ });
451
+
452
+ /* ------------------------------------------------------------------ */
453
+ /* Mask logic */
454
+ /* ------------------------------------------------------------------ */
455
+
456
+ function applyMask(raw: string): string {
457
+ if (!mask) return raw;
458
+ let result = '';
459
+ let raw_idx = 0;
460
+ for (let i = 0; i < mask.length && raw_idx < raw.length; i++) {
461
+ const m = mask[i];
462
+ if (m === '#') {
463
+ /* digit */
464
+ while (raw_idx < raw.length && !/\d/.test(raw[raw_idx])) raw_idx++;
465
+ if (raw_idx < raw.length) {
466
+ result += raw[raw_idx];
467
+ raw_idx++;
468
+ } else break;
469
+ } else if (m === 'A') {
470
+ /* letter */
471
+ while (raw_idx < raw.length && !/[a-zA-Z]/.test(raw[raw_idx])) raw_idx++;
472
+ if (raw_idx < raw.length) {
473
+ result += raw[raw_idx];
474
+ raw_idx++;
475
+ } else break;
476
+ } else if (m === '*') {
477
+ /* any */
478
+ result += raw[raw_idx];
479
+ raw_idx++;
480
+ } else {
481
+ /* literal */
482
+ result += m;
483
+ }
484
+ }
485
+ return result;
486
+ }
487
+
488
+ function stripMask(masked: string): string {
489
+ if (!mask) return masked;
490
+ let result = '';
491
+ for (let i = 0; i < masked.length && i < mask.length; i++) {
492
+ const m = mask[i];
493
+ if (m === '#' || m === 'A' || m === '*') {
494
+ result += masked[i];
495
+ }
496
+ }
497
+ return result;
498
+ }
499
+
500
+ /* ------------------------------------------------------------------ */
501
+ /* Textarea auto-resize */
502
+ /* ------------------------------------------------------------------ */
503
+
504
+ function autoResizeTextarea() {
505
+ if (!auto_resize || !textarea_element) return;
506
+ textarea_element.style.height = 'auto';
507
+ textarea_element.style.height = textarea_element.scrollHeight + 'px';
508
+ }
509
+
510
+ $effect(() => {
511
+ if (auto_resize && textarea_element && value !== undefined) {
512
+ autoResizeTextarea();
513
+ }
514
+ });
515
+
516
+ /* Manual resize via the custom corner handle (native grip is hidden so the
517
+ handle can sit on the wrapper's corner instead of the inset textarea's). */
518
+ let resizing = $state(false);
519
+ let resize_start_y = 0;
520
+ let resize_start_height = 0;
521
+
522
+ function handleResizeStart(event: PointerEvent) {
523
+ if (!textarea_element) return;
524
+ event.preventDefault();
525
+ resizing = true;
526
+ resize_start_y = event.clientY;
527
+ resize_start_height = textarea_element.offsetHeight;
528
+ (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
529
+ }
530
+
531
+ function handleResizeMove(event: PointerEvent) {
532
+ if (!resizing || !textarea_element) return;
533
+ const height = resize_start_height + event.clientY - resize_start_y;
534
+ textarea_element.style.height = `${height}px`;
535
+ }
536
+
537
+ function handleResizeEnd(event: PointerEvent) {
538
+ if (!resizing) return;
539
+ resizing = false;
540
+ const handle = event.currentTarget as HTMLElement;
541
+ if (handle.hasPointerCapture(event.pointerId)) {
542
+ handle.releasePointerCapture(event.pointerId);
543
+ }
544
+ }
545
+
546
+ /** Double-click snaps the textarea back to its natural (rows-based) height */
547
+ function handleResizeReset() {
548
+ if (textarea_element) textarea_element.style.height = '';
549
+ }
550
+
551
+ /* ------------------------------------------------------------------ */
552
+ /* Autocomplete */
553
+ /* ------------------------------------------------------------------ */
554
+
555
+ /* Mirror `ac_open` onto the native popover element, and detect whether the
556
+ browser flipped it above the field so the panel expands from the edge
557
+ nearest the control (matching Select's panel behaviour). */
558
+ $effect(() => {
559
+ const el = dropdown_element;
560
+ if (!el) return;
561
+ const shown = el.matches(':popover-open');
562
+ if (ac_open && !shown) {
563
+ try {
564
+ el.showPopover();
565
+ /* Measure synchronously — showPopover() has already placed the
566
+ popover (incl. any flip-block fallback), so the expand origin is
567
+ correct from the first frame. */
568
+ if (wrapper_element) {
569
+ const t = wrapper_element.getBoundingClientRect();
570
+ const d = el.getBoundingClientRect();
571
+ ac_above = d.top < t.top;
572
+ }
573
+ } catch {
574
+ /* not connected yet */
575
+ }
576
+ } else if (!ac_open && shown) {
577
+ try {
578
+ el.hidePopover();
579
+ } catch {
580
+ /* already hidden */
581
+ }
582
+ }
583
+ });
584
+
585
+ function openAutocomplete() {
586
+ if (!has_autocomplete || effectively_disabled || readonly) return;
587
+ ac_open = true;
588
+ // `ac_highlighted` is parked on the first selectable option by the effect
589
+ // below, so pressing Enter selects the top match without arrowing first.
590
+ }
591
+
592
+ function closeAutocomplete() {
593
+ ac_open = false;
594
+ ac_highlighted = -1;
595
+ }
596
+
597
+ /* Keep the first selectable option highlighted whenever the panel opens or
598
+ the filtered list changes — one option is always active, so focusing the
599
+ field and pressing Enter selects the top match. */
600
+ $effect(() => {
601
+ const opts = ac_options;
602
+ if (!ac_open) return;
603
+ ac_highlighted = opts.findIndex((o) => !o.disabled);
604
+ });
605
+
606
+ /* The option rows are <button>s (ListItem), which would otherwise land in
607
+ the tab order — tabbing out of the field would dive into the panel and
608
+ lose focus when it closes. Pull them out so Tab moves to the next field;
609
+ they stay clickable (pointerdown is prevented, so a click never focuses
610
+ them). Re-runs when the rendered rows change. */
611
+ $effect(() => {
612
+ if (!dropdown_element || ac_options.length === 0) return;
613
+ dropdown_element.querySelectorAll('button').forEach((btn) => {
614
+ btn.tabIndex = -1;
615
+ });
616
+ });
617
+
618
+ async function filterAutocomplete(query: string) {
619
+ if (!onfilter) return;
620
+ ac_loading = true;
621
+ try {
622
+ ac_filtered = await onfilter(query);
623
+ } finally {
624
+ ac_loading = false;
625
+ }
626
+ }
627
+
628
+ function selectAutocompleteOption(opt: InputOption) {
629
+ if (opt.disabled) return;
630
+ // Show the label in the input rather than the value — the value is
631
+ // for the form payload, but the human-readable label is what the user
632
+ // just clicked, so the displayed text should match.
633
+ value = opt.label;
634
+ closeAutocomplete();
635
+ onchange?.({ value: opt.value });
636
+ }
637
+
638
+ function scrollAcHighlightedIntoView() {
639
+ requestAnimationFrame(() => {
640
+ if (!dropdown_element) return;
641
+ const items = dropdown_element.querySelectorAll('.list-item');
642
+ const item = items[ac_highlighted];
643
+ if (item) item.scrollIntoView({ block: 'nearest' });
644
+ });
645
+ }
646
+
647
+ /* ------------------------------------------------------------------ */
648
+ /* Event handlers */
649
+ /* ------------------------------------------------------------------ */
650
+
651
+ function handleFocus() {
652
+ focused = true;
653
+ if (has_autocomplete) openAutocomplete();
654
+ onfocus?.();
655
+ }
656
+
657
+ function handleBlur() {
658
+ focused = false;
659
+ if (form_ctx && name) form_ctx.setTouched(name);
660
+ runParse();
661
+ /* Delay close so click on option registers */
662
+ setTimeout(() => {
663
+ if (!focused) closeAutocomplete();
664
+ }, 200);
665
+ onblur?.();
666
+ }
667
+
668
+ function handleInput(e: Event) {
669
+ const target = e.target as HTMLInputElement | HTMLTextAreaElement;
670
+ let new_value: string | number | null = target.value;
671
+
672
+ if (mask) {
673
+ const masked = new_value as string;
674
+ const raw = stripMask(masked) + masked.slice((value as string)?.length ?? 0);
675
+ new_value = applyMask(raw.replace(/[^a-zA-Z0-9]/g, ''));
676
+ target.value = new_value as string;
677
+ }
678
+
679
+ if (is_number) {
680
+ new_value = target.value === '' ? null : Number(target.value);
681
+ }
682
+
683
+ value = new_value;
684
+
685
+ if (form_ctx && name) form_ctx.setValue(name, value);
686
+ reparseIfErrored();
687
+ oninput?.({ value });
688
+
689
+ if (is_textarea && auto_resize) autoResizeTextarea();
690
+
691
+ /* Autocomplete filtering */
692
+ if (has_autocomplete && typeof new_value === 'string') {
693
+ openAutocomplete();
694
+ if (onfilter) {
695
+ clearTimeout(ac_debounce_timer);
696
+ ac_debounce_timer = setTimeout(() => filterAutocomplete(new_value), 300);
697
+ }
698
+ }
699
+ }
700
+
701
+ function handleChange(e: Event) {
702
+ const target = e.target as HTMLInputElement;
703
+ if (is_number) {
704
+ value = target.value === '' ? null : Number(target.value);
705
+ }
706
+ if (form_ctx && name) form_ctx.setValue(name, value);
707
+ onchange?.({ value });
708
+ }
709
+
710
+ function handleClear() {
711
+ if (multiple) {
712
+ value = [];
713
+ } else if (is_number) {
714
+ value = null;
715
+ } else if (is_file) {
716
+ value = null;
717
+ if (file_input_element) file_input_element.value = '';
718
+ } else {
719
+ value = '';
720
+ }
721
+ if (form_ctx && name) form_ctx.setValue(name, value);
722
+ reparseIfErrored();
723
+ oninput?.({ value });
724
+ onchange?.({ value });
725
+
726
+ const el = input_element ?? textarea_element;
727
+ el?.focus();
728
+ }
729
+
730
+ function stepNumber(delta: number, commit = true) {
731
+ if (effectively_disabled || readonly) return;
732
+ const current = typeof value === 'number' ? value : 0;
733
+ const s = step ?? 1;
734
+ let next = current + delta * s;
735
+ if (min !== undefined && typeof min === 'number') next = Math.max(min, next);
736
+ if (max !== undefined && typeof max === 'number') next = Math.min(max, next);
737
+ /* Round to step precision to avoid float issues */
738
+ const precision = String(s).includes('.') ? String(s).split('.')[1].length : 0;
739
+ value = Number(next.toFixed(precision));
740
+ if (form_ctx && name) form_ctx.setValue(name, value);
741
+ oninput?.({ value });
742
+ if (commit) onchange?.({ value });
743
+ }
744
+
745
+ /* ---- Stepper hold-to-repeat ----
746
+ Pressing a stepper steps once immediately; holding it starts auto-repeat
747
+ after an initial delay, accelerating gently while held. Each tick fires
748
+ `oninput`; `onchange` fires once on release (the committed value). */
749
+ let repeat_timer: ReturnType<typeof setTimeout> | undefined;
750
+ let repeat_delay = 0;
751
+ let repeat_pressed = false;
752
+
753
+ function numberAtLimit(delta: number): boolean {
754
+ if (typeof value !== 'number') return false;
755
+ if (delta > 0) return typeof max === 'number' && value >= max;
756
+ return typeof min === 'number' && value <= min;
757
+ }
758
+
759
+ function startNumberRepeat(e: PointerEvent, delta: number) {
760
+ if (effectively_disabled || readonly) return;
761
+ if (e.button !== 0) return;
762
+ /* Keep focus where it is and suppress text selection during the hold. */
763
+ e.preventDefault();
764
+ stopNumberRepeat(false);
765
+ repeat_pressed = true;
766
+ stepNumber(delta, false);
767
+ repeat_delay = 80;
768
+ repeat_timer = setTimeout(() => repeatStep(delta), 450);
769
+ /* The release can land anywhere (drag off the button, or the button
770
+ disables itself at min/max mid-hold), so listen on window. */
771
+ window.addEventListener('pointerup', handleRepeatRelease);
772
+ window.addEventListener('pointercancel', handleRepeatRelease);
773
+ }
774
+
775
+ function repeatStep(delta: number) {
776
+ /* Stop ticking at min/max; the commit still fires on release. */
777
+ if (numberAtLimit(delta)) return;
778
+ stepNumber(delta, false);
779
+ repeat_delay = Math.max(40, repeat_delay * 0.92);
780
+ repeat_timer = setTimeout(() => repeatStep(delta), repeat_delay);
781
+ }
782
+
783
+ function handleRepeatRelease() {
784
+ stopNumberRepeat(true);
785
+ }
786
+
787
+ function stopNumberRepeat(commit: boolean) {
788
+ clearTimeout(repeat_timer);
789
+ repeat_timer = undefined;
790
+ window.removeEventListener('pointerup', handleRepeatRelease);
791
+ window.removeEventListener('pointercancel', handleRepeatRelease);
792
+ if (commit && repeat_pressed) onchange?.({ value });
793
+ repeat_pressed = false;
794
+ }
795
+
796
+ $effect(() => () => stopNumberRepeat(false));
797
+
798
+ function handlePasswordToggle() {
799
+ password_visible = !password_visible;
800
+ input_element?.focus();
801
+ }
802
+
803
+ /**
804
+ * Open the native date/time picker from our own icon button. The native
805
+ * ::-webkit-calendar-picker-indicator is hidden (its fixed black glyph
806
+ * ignores the design tokens), so this restores click-to-open behind a
807
+ * token-tinted icon that matches the field's other icons. Falls back to
808
+ * focusing the field where showPicker() isn't available.
809
+ */
810
+ function openDatePicker() {
811
+ if (effectively_disabled || readonly) return;
812
+ const el = input_element as HTMLInputElement | undefined;
813
+ try {
814
+ el?.showPicker?.();
815
+ } catch {
816
+ el?.focus();
817
+ }
818
+ }
819
+
820
+ function handleFileClick() {
821
+ file_input_element?.click();
822
+ }
823
+
824
+ /**
825
+ * Mirror the component's file value back onto the native <input> via a
826
+ * DataTransfer, so dropped files and per-file removals still submit
827
+ * correctly when the input is inside a form.
828
+ */
829
+ function syncFileInput(files: File[]) {
830
+ if (!file_input_element || typeof DataTransfer === 'undefined') return;
831
+ const dt = new DataTransfer();
832
+ for (const f of files) dt.items.add(f);
833
+ file_input_element.files = dt.files;
834
+ }
835
+
836
+ function commitFiles(next: File | File[] | null) {
837
+ value = next;
838
+ if (form_ctx && name) form_ctx.setValue(name, value);
839
+ oninput?.({ value });
840
+ onchange?.({ value });
841
+ }
842
+
843
+ function handleFileChange(e: Event) {
844
+ const picked = (e.target as HTMLInputElement).files;
845
+ if (!picked || picked.length === 0) return;
846
+ if (multiple) {
847
+ const merged = [...file_list, ...Array.from(picked)];
848
+ syncFileInput(merged);
849
+ commitFiles(merged);
850
+ } else {
851
+ commitFiles(picked[0]);
852
+ }
853
+ }
854
+
855
+ function handleFileDrop(e: DragEvent) {
856
+ e.preventDefault();
857
+ if (effectively_disabled || readonly) return;
858
+ const dropped = e.dataTransfer?.files;
859
+ if (!dropped || dropped.length === 0) return;
860
+ if (multiple) {
861
+ const merged = [...file_list, ...Array.from(dropped)];
862
+ syncFileInput(merged);
863
+ commitFiles(merged);
864
+ } else {
865
+ syncFileInput([dropped[0]]);
866
+ commitFiles(dropped[0]);
867
+ }
868
+ }
869
+
870
+ function handleFileDragOver(e: DragEvent) {
871
+ e.preventDefault();
872
+ }
873
+
874
+ /** Remove a single selected file by index. */
875
+ function removeFile(index: number) {
876
+ if (multiple) {
877
+ const next = file_list.filter((_, i) => i !== index);
878
+ syncFileInput(next);
879
+ commitFiles(next);
880
+ } else {
881
+ if (file_input_element) file_input_element.value = '';
882
+ commitFiles(null);
883
+ }
884
+ }
885
+
886
+ /* ---- Chips / Multiple ---- */
887
+ function handleChipKeyDown(e: KeyboardEvent) {
888
+ if (e.key === 'Enter' || e.key === ',') {
889
+ e.preventDefault();
890
+ addChip();
891
+ } else if (
892
+ e.key === 'Backspace' &&
893
+ chip_input_value === '' &&
894
+ Array.isArray(value) &&
895
+ value.length > 0
896
+ ) {
897
+ removeChip(value.length - 1);
898
+ }
899
+ }
900
+
901
+ function addChip() {
902
+ const trimmed = chip_input_value.trim();
903
+ if (!trimmed) return;
904
+ if (!Array.isArray(value)) value = [];
905
+ const chips = value as string[];
906
+ if (!chips.includes(trimmed)) {
907
+ value = [...chips, trimmed];
908
+ if (form_ctx && name) form_ctx.setValue(name, value);
909
+ reparseIfErrored();
910
+ oninput?.({ value });
911
+ onchange?.({ value });
912
+ }
913
+ chip_input_value = '';
914
+ }
915
+
916
+ function removeChip(index: number) {
917
+ if (!Array.isArray(value)) return;
918
+ const chips = value as string[];
919
+ value = chips.filter((_, i) => i !== index);
920
+ if (form_ctx && name) form_ctx.setValue(name, value);
921
+ reparseIfErrored();
922
+ oninput?.({ value });
923
+ onchange?.({ value });
924
+ }
925
+
926
+ /**
927
+ * Out transition for a chip. A plain `out:scale` keeps the leaving chip in
928
+ * the layout for the whole outro, so the surviving chips don't reflow into
929
+ * the gap until it finishes — `animate:flip` then measures no movement and
930
+ * they snap. Pinning the chip with `position: absolute` at its current spot
931
+ * pulls it out of flow immediately, so the others reflow now and flip slides
932
+ * them while this one scales + fades in place (same look as `out:scale`).
933
+ */
934
+ function chipOut(node: HTMLElement, { duration = 150 } = {}) {
935
+ const { offsetLeft, offsetTop, offsetWidth, offsetHeight } = node;
936
+ node.style.position = 'absolute';
937
+ node.style.left = `${offsetLeft}px`;
938
+ node.style.top = `${offsetTop}px`;
939
+ node.style.width = `${offsetWidth}px`;
940
+ node.style.height = `${offsetHeight}px`;
941
+ node.style.pointerEvents = 'none';
942
+ return {
943
+ duration,
944
+ easing: quintOut,
945
+ css: (t: number) => `opacity: ${t}; transform: scale(${0.6 + 0.4 * t});`,
946
+ };
947
+ }
948
+
949
+ /* ---- Autocomplete keyboard ---- */
950
+ function handleKeyDown(e: KeyboardEvent) {
951
+ if (!has_autocomplete || !ac_open) return;
952
+
953
+ switch (e.key) {
954
+ case 'ArrowDown': {
955
+ e.preventDefault();
956
+ const opts = ac_options;
957
+ if (opts.length === 0) break;
958
+ ac_highlighted = ac_highlighted < opts.length - 1 ? ac_highlighted + 1 : 0;
959
+ /* Skip disabled */
960
+ let attempts = 0;
961
+ while (opts[ac_highlighted]?.disabled && attempts < opts.length) {
962
+ ac_highlighted = ac_highlighted < opts.length - 1 ? ac_highlighted + 1 : 0;
963
+ attempts++;
964
+ }
965
+ scrollAcHighlightedIntoView();
966
+ break;
967
+ }
968
+ case 'ArrowUp': {
969
+ e.preventDefault();
970
+ const opts = ac_options;
971
+ if (opts.length === 0) break;
972
+ ac_highlighted = ac_highlighted > 0 ? ac_highlighted - 1 : opts.length - 1;
973
+ let attempts = 0;
974
+ while (opts[ac_highlighted]?.disabled && attempts < opts.length) {
975
+ ac_highlighted = ac_highlighted > 0 ? ac_highlighted - 1 : opts.length - 1;
976
+ attempts++;
977
+ }
978
+ scrollAcHighlightedIntoView();
979
+ break;
980
+ }
981
+ case 'Enter': {
982
+ e.preventDefault();
983
+ if (ac_highlighted >= 0 && ac_highlighted < ac_options.length) {
984
+ selectAutocompleteOption(ac_options[ac_highlighted]);
985
+ }
986
+ break;
987
+ }
988
+ case 'Escape': {
989
+ e.preventDefault();
990
+ closeAutocomplete();
991
+ break;
992
+ }
993
+ case 'Tab': {
994
+ /* Don't let the open panel capture Tab focus. Hide it
995
+ synchronously (so it leaves the top layer before the browser
996
+ resolves Tab navigation) and let the default Tab move focus on
997
+ to the next field — no preventDefault. */
998
+ closeAutocomplete();
999
+ try {
1000
+ dropdown_element?.hidePopover();
1001
+ } catch {
1002
+ /* already hidden */
1003
+ }
1004
+ break;
1005
+ }
1006
+ }
1007
+ }
1008
+
1009
+ /* ---- File previews ---- */
1010
+ /** The selected files, normalised to an array regardless of `multiple`. */
1011
+ const file_list = $derived.by((): File[] => {
1012
+ if (!is_file) return [];
1013
+ if (Array.isArray(value)) return value.filter((f): f is File => f instanceof File);
1014
+ if (value instanceof File) return [value];
1015
+ return [];
1016
+ });
1017
+
1018
+ /** Object-URL thumbnails for image files, revoked on change/unmount. */
1019
+ let file_previews = $state<{ name: string; url: string | null }[]>([]);
1020
+ $effect(() => {
1021
+ const created: string[] = [];
1022
+ file_previews = file_list.map((f) => {
1023
+ if (f.type.startsWith('image/')) {
1024
+ const url = URL.createObjectURL(f);
1025
+ created.push(url);
1026
+ return { name: f.name, url };
1027
+ }
1028
+ return { name: f.name, url: null };
1029
+ });
1030
+ return () => created.forEach((u) => URL.revokeObjectURL(u));
1031
+ });
1032
+
1033
+ /** Whether the clear button should show */
1034
+ const show_clear = $derived.by(() => {
1035
+ if (!clearable || effectively_disabled || readonly) return false;
1036
+ if (multiple) return Array.isArray(value) && value.length > 0;
1037
+ /* File inputs carry their own per-file remove buttons. */
1038
+ if (is_file) return false;
1039
+ if (is_number) return value !== null && value !== undefined;
1040
+ return value !== undefined && value !== null && value !== '';
1041
+ });
1042
+
1043
+ /** Highlight matching text in autocomplete option */
1044
+ function highlightMatch(text: string): string {
1045
+ const q = typeof value === 'string' ? value.trim() : '';
1046
+ if (!q) return text;
1047
+ const idx = text.toLowerCase().indexOf(q.toLowerCase());
1048
+ if (idx === -1) return text;
1049
+ const before = text.slice(0, idx);
1050
+ const match = text.slice(idx, idx + q.length);
1051
+ const after = text.slice(idx + q.length);
1052
+ return `${before}<strong>${match}</strong>${after}`;
1053
+ }
1054
+
1055
+ /** Counter warning state */
1056
+ const counter_state = $derived.by((): 'normal' | 'warning' | 'error' => {
1057
+ if (!show_counter || !maxlength) return 'normal';
1058
+ const ratio = value_length / maxlength;
1059
+ if (ratio >= 1) return 'error';
1060
+ if (ratio >= 0.8) return 'warning';
1061
+ return 'normal';
1062
+ });
1063
+ </script>
1064
+
1065
+ <!-- ================================================================== -->
1066
+ <!-- TEMPLATE -->
1067
+ <!-- ================================================================== -->
1068
+
1069
+ <div
1070
+ class={['input', `size-${size}`, class_name].filter(Boolean).join(' ')}
1071
+ class:focused
1072
+ class:disabled={effectively_disabled}
1073
+ class:readonly
1074
+ class:has-error={has_error}
1075
+ class:skeleton
1076
+ class:dense
1077
+ class:comfortable
1078
+ class:filled
1079
+ class:has-label={!!label}
1080
+ class:placeholder-deferred={placeholder_deferred}
1081
+ class:has-prefix={!!prefix}
1082
+ class:has-suffix={!!suffix}
1083
+ class:has-icon={!!icon || is_search}
1084
+ class:is-textarea={is_textarea}
1085
+ class:is-file={is_file}
1086
+ class:is-color={is_color}
1087
+ class:multiple
1088
+ style:--input-font={size_config.font}
1089
+ style:--input-icon-size="{size_config.icon_size}px"
1090
+ {@attach tooltip_message ? tooltip(tooltip_message) : () => {}}>
1091
+ <!-- Main input wrapper -->
1092
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1093
+ <div
1094
+ class="wrapper"
1095
+ class:focused
1096
+ class:has-error={has_error}
1097
+ bind:this={wrapper_element}
1098
+ style:anchor-name={has_autocomplete ? ac_anchor_name : undefined}
1099
+ ondrop={is_file ? handleFileDrop : undefined}
1100
+ ondragover={is_file ? handleFileDragOver : undefined}>
1101
+ <!-- Leading icon -->
1102
+ {#if icon}
1103
+ <span class="icon" aria-hidden="true">
1104
+ {@render iconRender(icon)}
1105
+ </span>
1106
+ {/if}
1107
+
1108
+ <!-- Prefix -->
1109
+ {#if prefix}
1110
+ <span class="prefix" aria-hidden="true">{prefix}</span>
1111
+ {/if}
1112
+
1113
+ <!-- Multiple chips -->
1114
+ {#if multiple && !is_file && Array.isArray(value)}
1115
+ <div class="chips">
1116
+ {#each value as chip, i (chip)}
1117
+ <span
1118
+ class="chip"
1119
+ in:scale={{ duration: 200, start: 0.6, easing: backOut }}
1120
+ out:chipOut={{ duration: 150 }}
1121
+ animate:flip={{ duration: 150, easing: quintOut }}>
1122
+ <span class="chip-text">{chip}</span>
1123
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
1124
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1125
+ <span class="chip-remove" onclick={() => removeChip(i)}>
1126
+ <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
1127
+ <path
1128
+ d="M18 6L6 18M6 6l12 12"
1129
+ stroke="currentColor"
1130
+ stroke-width="2"
1131
+ stroke-linecap="round"
1132
+ fill="none" />
1133
+ </svg>
1134
+ </span>
1135
+ </span>
1136
+ {/each}
1137
+ <input
1138
+ type="text"
1139
+ class="chip-input"
1140
+ bind:this={input_element}
1141
+ bind:value={chip_input_value}
1142
+ {id}
1143
+ placeholder={native_placeholder}
1144
+ disabled={effectively_disabled}
1145
+ {readonly}
1146
+ aria-label={label || placeholder || 'Add tag'}
1147
+ onfocus={handleFocus}
1148
+ onblur={handleBlur}
1149
+ onkeydown={handleChipKeyDown} />
1150
+ </div>
1151
+ {:else if is_textarea}
1152
+ <!-- Textarea -->
1153
+ <!-- svelte-ignore element_invalid_self_closing_tag -->
1154
+ <textarea
1155
+ bind:this={textarea_element}
1156
+ {id}
1157
+ {name}
1158
+ class="field"
1159
+ placeholder={native_placeholder}
1160
+ disabled={effectively_disabled}
1161
+ {readonly}
1162
+ {required}
1163
+ {rows}
1164
+ {maxlength}
1165
+ {minlength}
1166
+ aria-invalid={has_error || undefined}
1167
+ aria-required={required || undefined}
1168
+ aria-describedby={has_error
1169
+ ? `${id}-error`
1170
+ : description
1171
+ ? `${id}-description`
1172
+ : undefined}
1173
+ onfocus={handleFocus}
1174
+ onblur={handleBlur}
1175
+ oninput={handleInput}
1176
+ onchange={handleChange}
1177
+ value={(value ?? '') as string} />
1178
+ {#if !auto_resize}
1179
+ <!-- Custom resize handle: sits on the wrapper's corner (the native
1180
+ grip would be inset by the wrapper padding) and draws arcs that
1181
+ follow the wrapper's own corner curve. -->
1182
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1183
+ <span
1184
+ class="resize-handle"
1185
+ class:dragging={resizing}
1186
+ aria-hidden="true"
1187
+ onpointerdown={handleResizeStart}
1188
+ onpointermove={handleResizeMove}
1189
+ onpointerup={handleResizeEnd}
1190
+ onpointercancel={handleResizeEnd}
1191
+ ondblclick={handleResizeReset}>
1192
+ </span>
1193
+ {/if}
1194
+ {:else if is_file}
1195
+ <!-- File: hidden native input + visible preview list -->
1196
+ <input
1197
+ bind:this={file_input_element}
1198
+ type="file"
1199
+ {name}
1200
+ {accept}
1201
+ {multiple}
1202
+ disabled={effectively_disabled}
1203
+ class="file-native"
1204
+ aria-hidden="true"
1205
+ tabindex={-1}
1206
+ onchange={handleFileChange} />
1207
+ {#if file_list.length === 0}
1208
+ <button
1209
+ type="button"
1210
+ bind:this={input_element}
1211
+ {id}
1212
+ class="field file-trigger"
1213
+ disabled={effectively_disabled}
1214
+ aria-describedby={has_error
1215
+ ? `${id}-error`
1216
+ : description
1217
+ ? `${id}-description`
1218
+ : undefined}
1219
+ onfocus={handleFocus}
1220
+ onblur={handleBlur}
1221
+ onclick={handleFileClick}>
1222
+ <span class="file-placeholder">
1223
+ {native_placeholder ?? (multiple ? 'Choose files…' : 'Choose file…')}
1224
+ </span>
1225
+ </button>
1226
+ {:else}
1227
+ <div class="file-items">
1228
+ {#each file_previews as preview, i (preview.name + '-' + i)}
1229
+ <span class="file-item">
1230
+ <span class="file-thumb">
1231
+ {#if preview.url}
1232
+ <img src={preview.url} alt="" />
1233
+ {:else}
1234
+ <svg
1235
+ viewBox="0 0 24 24"
1236
+ width="100%"
1237
+ height="100%"
1238
+ fill="none"
1239
+ aria-hidden="true">
1240
+ <path
1241
+ d="M14 3v4a1 1 0 0 0 1 1h4"
1242
+ stroke="currentColor"
1243
+ stroke-width="2"
1244
+ stroke-linecap="round"
1245
+ stroke-linejoin="round" />
1246
+ <path
1247
+ d="M5 3h9l5 5v12a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"
1248
+ stroke="currentColor"
1249
+ stroke-width="2"
1250
+ stroke-linejoin="round" />
1251
+ </svg>
1252
+ {/if}
1253
+ </span>
1254
+ <span class="file-item-name">{preview.name}</span>
1255
+ <Button
1256
+ icon
1257
+ dense
1258
+ transparent
1259
+ class="input-pill-btn"
1260
+ aria-label="Remove {preview.name}"
1261
+ disabled={effectively_disabled}
1262
+ onclick={() => removeFile(i)}>
1263
+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
1264
+ <path
1265
+ d="M18 6L6 18M6 6l12 12"
1266
+ stroke="currentColor"
1267
+ stroke-width="2"
1268
+ stroke-linecap="round" />
1269
+ </svg>
1270
+ </Button>
1271
+ </span>
1272
+ {/each}
1273
+ {#if multiple}
1274
+ <div class="file-add-row">
1275
+ <Button
1276
+ translucent
1277
+ dense
1278
+ full_width
1279
+ disabled={effectively_disabled}
1280
+ onclick={handleFileClick}>
1281
+ <svg
1282
+ viewBox="0 0 24 24"
1283
+ width="15"
1284
+ height="15"
1285
+ fill="none"
1286
+ aria-hidden="true">
1287
+ <path
1288
+ d="M12 5v14M5 12h14"
1289
+ stroke="currentColor"
1290
+ stroke-width="2"
1291
+ stroke-linecap="round" />
1292
+ </svg>
1293
+ Add files
1294
+ </Button>
1295
+ </div>
1296
+ {/if}
1297
+ </div>
1298
+ {/if}
1299
+ {:else if is_color}
1300
+ <!-- Colour: the swatch overlays the native picker; text shows the value -->
1301
+ <span class="color-control">
1302
+ <span
1303
+ class="color-swatch"
1304
+ style:background={typeof value === 'string' && value ? value : '#000000'}
1305
+ aria-hidden="true">
1306
+ </span>
1307
+ <input
1308
+ type="color"
1309
+ class="color-native"
1310
+ value={typeof value === 'string' && value ? value : '#000000'}
1311
+ disabled={effectively_disabled}
1312
+ aria-label={label || 'Choose colour'}
1313
+ oninput={(e) => {
1314
+ value = (e.target as HTMLInputElement).value;
1315
+ if (form_ctx && name) form_ctx.setValue(name, value);
1316
+ oninput?.({ value });
1317
+ }}
1318
+ onchange={(e) => {
1319
+ value = (e.target as HTMLInputElement).value;
1320
+ onchange?.({ value });
1321
+ }}
1322
+ onfocus={handleFocus}
1323
+ onblur={handleBlur} />
1324
+ </span>
1325
+ <input
1326
+ bind:this={input_element}
1327
+ {id}
1328
+ {name}
1329
+ type="text"
1330
+ class="field"
1331
+ placeholder={native_placeholder}
1332
+ disabled={effectively_disabled}
1333
+ {readonly}
1334
+ {required}
1335
+ value={value ?? ''}
1336
+ aria-invalid={has_error || undefined}
1337
+ aria-required={required || undefined}
1338
+ aria-describedby={has_error
1339
+ ? `${id}-error`
1340
+ : description
1341
+ ? `${id}-description`
1342
+ : undefined}
1343
+ onfocus={handleFocus}
1344
+ onblur={handleBlur}
1345
+ oninput={handleInput}
1346
+ onchange={handleChange} />
1347
+ {:else}
1348
+ <!-- Standard input -->
1349
+ <input
1350
+ bind:this={input_element}
1351
+ {id}
1352
+ {name}
1353
+ type={html_type}
1354
+ class="field"
1355
+ class:has-autocomplete={has_autocomplete}
1356
+ placeholder={native_placeholder}
1357
+ disabled={effectively_disabled}
1358
+ {readonly}
1359
+ {required}
1360
+ {maxlength}
1361
+ {minlength}
1362
+ {pattern}
1363
+ {min}
1364
+ {max}
1365
+ step={is_number ? step : undefined}
1366
+ autocomplete={has_autocomplete ? 'off' : undefined}
1367
+ role={has_autocomplete ? 'combobox' : undefined}
1368
+ aria-expanded={has_autocomplete ? ac_open : undefined}
1369
+ aria-autocomplete={has_autocomplete ? 'list' : undefined}
1370
+ aria-controls={has_autocomplete ? `${id}-listbox` : undefined}
1371
+ aria-activedescendant={has_autocomplete && ac_highlighted >= 0
1372
+ ? `${id}-option-${ac_highlighted}`
1373
+ : undefined}
1374
+ aria-invalid={has_error || undefined}
1375
+ aria-required={required || undefined}
1376
+ aria-describedby={has_error
1377
+ ? `${id}-error`
1378
+ : description
1379
+ ? `${id}-description`
1380
+ : undefined}
1381
+ value={is_number ? (value ?? '') : (value ?? '')}
1382
+ onfocus={handleFocus}
1383
+ onblur={handleBlur}
1384
+ oninput={handleInput}
1385
+ onchange={handleChange}
1386
+ onkeydown={has_autocomplete ? handleKeyDown : undefined} />
1387
+ {/if}
1388
+
1389
+ <!-- Floating label (notched-outline style) -->
1390
+ {#if label}
1391
+ <label class:floated={label_floated} for={id}>
1392
+ <span class="label-text">
1393
+ {label}{#if required}<span class="required-mark" aria-hidden="true">
1394
+ *
1395
+ </span>{/if}
1396
+ </span>
1397
+ </label>
1398
+ {/if}
1399
+
1400
+ <!-- Suffix -->
1401
+ {#if suffix}
1402
+ <span class="suffix" aria-hidden="true">{suffix}</span>
1403
+ {/if}
1404
+
1405
+ <!-- Number steppers -->
1406
+ {#if is_number}
1407
+ <div class="steppers">
1408
+ <Button
1409
+ icon
1410
+ transparent
1411
+ class="input-icon-btn"
1412
+ tabindex={-1}
1413
+ aria-label="Decrease"
1414
+ disabled={effectively_disabled ||
1415
+ (min !== undefined &&
1416
+ typeof min === 'number' &&
1417
+ typeof value === 'number' &&
1418
+ value <= min)}
1419
+ onpointerdown={(e: PointerEvent) => startNumberRepeat(e, -1)}
1420
+ onclick={(e: MouseEvent) => {
1421
+ /* Pointer presses are handled by pointerdown; this catches
1422
+ synthesized clicks (assistive tech), which have detail 0. */
1423
+ if (e.detail === 0) stepNumber(-1);
1424
+ }}>
1425
+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
1426
+ <path
1427
+ d="M5 12h14"
1428
+ stroke="currentColor"
1429
+ stroke-width="2"
1430
+ stroke-linecap="round" />
1431
+ </svg>
1432
+ </Button>
1433
+ <Button
1434
+ icon
1435
+ transparent
1436
+ class="input-icon-btn"
1437
+ tabindex={-1}
1438
+ aria-label="Increase"
1439
+ disabled={effectively_disabled ||
1440
+ (max !== undefined &&
1441
+ typeof max === 'number' &&
1442
+ typeof value === 'number' &&
1443
+ value >= max)}
1444
+ onpointerdown={(e: PointerEvent) => startNumberRepeat(e, 1)}
1445
+ onclick={(e: MouseEvent) => {
1446
+ if (e.detail === 0) stepNumber(1);
1447
+ }}>
1448
+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
1449
+ <path
1450
+ d="M12 5v14M5 12h14"
1451
+ stroke="currentColor"
1452
+ stroke-width="2"
1453
+ stroke-linecap="round" />
1454
+ </svg>
1455
+ </Button>
1456
+ </div>
1457
+ {/if}
1458
+
1459
+ <!-- Password toggle -->
1460
+ {#if is_password && show_toggle}
1461
+ <Button
1462
+ icon
1463
+ transparent
1464
+ class="input-icon-btn"
1465
+ tabindex={-1}
1466
+ aria-label={password_visible ? 'Hide password' : 'Show password'}
1467
+ onclick={handlePasswordToggle}>
1468
+ {#if password_visible}
1469
+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
1470
+ <path
1471
+ d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"
1472
+ stroke="currentColor"
1473
+ stroke-width="2"
1474
+ stroke-linecap="round"
1475
+ stroke-linejoin="round" />
1476
+ <line
1477
+ x1="1"
1478
+ y1="1"
1479
+ x2="23"
1480
+ y2="23"
1481
+ stroke="currentColor"
1482
+ stroke-width="2"
1483
+ stroke-linecap="round" />
1484
+ </svg>
1485
+ {:else}
1486
+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
1487
+ <path
1488
+ d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12z"
1489
+ stroke="currentColor"
1490
+ stroke-width="2"
1491
+ stroke-linecap="round"
1492
+ stroke-linejoin="round" />
1493
+ <circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" />
1494
+ </svg>
1495
+ {/if}
1496
+ </Button>
1497
+ {/if}
1498
+
1499
+ <!-- Search icon -->
1500
+ {#if is_search && !icon}
1501
+ <span class="icon search-icon" aria-hidden="true">
1502
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none">
1503
+ <circle cx="11" cy="11" r="8" stroke="currentColor" stroke-width="2" />
1504
+ <path
1505
+ d="M21 21l-4.35-4.35"
1506
+ stroke="currentColor"
1507
+ stroke-width="2"
1508
+ stroke-linecap="round" />
1509
+ </svg>
1510
+ </span>
1511
+ {/if}
1512
+
1513
+ <!-- Clear button -->
1514
+ {#if show_clear}
1515
+ <Button
1516
+ icon
1517
+ dense
1518
+ transparent
1519
+ class="input-icon-btn input-clear-btn"
1520
+ tabindex={-1}
1521
+ aria-label="Clear"
1522
+ onclick={handleClear}>
1523
+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
1524
+ <path
1525
+ d="M18 6L6 18M6 6l12 12"
1526
+ stroke="currentColor"
1527
+ stroke-width="2"
1528
+ stroke-linecap="round" />
1529
+ </svg>
1530
+ </Button>
1531
+ {/if}
1532
+
1533
+ <!-- Date / time / datetime-local picker icon: replaces the native black
1534
+ ::-webkit-calendar-picker-indicator with a token-tinted icon (a clock
1535
+ for time, a calendar otherwise) that matches the field's other icons.
1536
+ Clicking it reopens the native picker via showPicker(). -->
1537
+ {#if is_datelike}
1538
+ <Button
1539
+ icon
1540
+ transparent
1541
+ class="input-icon-btn"
1542
+ tabindex={-1}
1543
+ aria-label="Open picker"
1544
+ disabled={effectively_disabled || readonly}
1545
+ onclick={openDatePicker}>
1546
+ {#if type === 'time'}
1547
+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
1548
+ <circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" />
1549
+ <path
1550
+ d="M12 7.5V12l3 2"
1551
+ stroke="currentColor"
1552
+ stroke-width="2"
1553
+ stroke-linecap="round"
1554
+ stroke-linejoin="round" />
1555
+ </svg>
1556
+ {:else}
1557
+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
1558
+ <rect
1559
+ x="3"
1560
+ y="4.5"
1561
+ width="18"
1562
+ height="16"
1563
+ rx="2"
1564
+ stroke="currentColor"
1565
+ stroke-width="2" />
1566
+ <path
1567
+ d="M3 9.5h18M8 3v3M16 3v3"
1568
+ stroke="currentColor"
1569
+ stroke-width="2"
1570
+ stroke-linecap="round" />
1571
+ </svg>
1572
+ {/if}
1573
+ </Button>
1574
+ {/if}
1575
+
1576
+ <!-- Skeleton shimmer overlay — its own element so the sweep can be
1577
+ clipped to the field's corners without overflow:hidden on the
1578
+ wrapper (which would clip the floating label). -->
1579
+ {#if skeleton}
1580
+ <span class="skeleton-sweep" aria-hidden="true"></span>
1581
+ {/if}
1582
+ </div>
1583
+
1584
+ <!-- Password strength indicator -->
1585
+ {#if is_password && strength_indicator && typeof value === 'string' && value.length > 0}
1586
+ <div class="strength-meter" aria-label="Password strength: {strength_label}">
1587
+ <div class="strength-track">
1588
+ {#each [1, 2, 3, 4] as segment}
1589
+ <div
1590
+ class="strength-segment"
1591
+ class:active={password_strength >= segment}
1592
+ style:background={password_strength >= segment ? strength_color : undefined}>
1593
+ </div>
1594
+ {/each}
1595
+ </div>
1596
+ <span class="strength-label" style:color={strength_color}>{strength_label}</span>
1597
+ </div>
1598
+ {/if}
1599
+
1600
+ <!-- Footer row: error, description, counter -->
1601
+ {#if has_error || description || (show_counter && maxlength)}
1602
+ <div class="footer">
1603
+ {#if has_error && error_message}
1604
+ <span class="error" id="{id}-error" role="alert">{error_message}</span>
1605
+ {:else if description}
1606
+ <span class="description" id="{id}-description">{description}</span>
1607
+ {:else}
1608
+ <span></span>
1609
+ {/if}
1610
+
1611
+ {#if show_counter && maxlength}
1612
+ <span
1613
+ class="counter"
1614
+ class:counter-warning={counter_state === 'warning'}
1615
+ class:counter-error={counter_state === 'error'}>
1616
+ {value_length}/{maxlength}
1617
+ </span>
1618
+ {/if}
1619
+ </div>
1620
+ {/if}
1621
+
1622
+ <!-- Autocomplete dropdown — native popover, CSS anchor positioned (matches Select) -->
1623
+ {#if has_autocomplete}
1624
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1625
+ <div
1626
+ class="dropdown"
1627
+ class:above={ac_above}
1628
+ popover="manual"
1629
+ bind:this={dropdown_element}
1630
+ role="listbox"
1631
+ id="{id}-listbox"
1632
+ style:position-anchor={ac_anchor_name}
1633
+ onpointerdown={(e) => e.preventDefault()}>
1634
+ {#if ac_loading}
1635
+ <div class="status">
1636
+ <span class="spinner" aria-hidden="true"></span>
1637
+ Loading...
1638
+ </div>
1639
+ {:else if ac_options.length === 0}
1640
+ <div class="status">No results</div>
1641
+ {:else}
1642
+ <List>
1643
+ {#each ac_options as opt, i (opt.value)}
1644
+ <ListItem
1645
+ id="{id}-option-{i}"
1646
+ active={ac_highlighted === i}
1647
+ disabled={opt.disabled}
1648
+ onclick={() => selectAutocompleteOption(opt)}>
1649
+ {#if option_snippet}
1650
+ {@render option_snippet(opt)}
1651
+ {:else}
1652
+ <span class="option">
1653
+ <span class="option-label">{@html highlightMatch(opt.label)}</span>
1654
+ {#if opt.description}
1655
+ <span class="option-desc">{opt.description}</span>
1656
+ {/if}
1657
+ </span>
1658
+ {/if}
1659
+ </ListItem>
1660
+ {/each}
1661
+ </List>
1662
+ {/if}
1663
+ </div>
1664
+ {/if}
1665
+ </div>
1666
+
1667
+ <!-- Hidden native inputs for chips-mode form submission (the visible chip
1668
+ input has no name; single-value types submit via the named control itself) -->
1669
+ {#if name && multiple && !is_file && Array.isArray(value)}
1670
+ {#each value as v (v)}
1671
+ <input type="hidden" {name} value={v} />
1672
+ {/each}
1673
+ {/if}
1674
+
1675
+ {#snippet iconRender(IconComponent: Component)}
1676
+ <IconComponent />
1677
+ {/snippet}
1678
+
1679
+ <style>
1680
+ /* ================================================================== */
1681
+ /* ROOT */
1682
+ /* ================================================================== */
1683
+
1684
+ .input {
1685
+ --_font: var(--input-font, var(--control-font-1, 1rem));
1686
+ --_icon-size: var(--input-icon-size, 17px);
1687
+ /* Height scales off --_font, so the whole control scales from one
1688
+ number. The ratio is the SHARED --control-height-ratio (tokens.css),
1689
+ so a row of controls — Input, Select, Button — lands on the same
1690
+ height. --_font is a length the floated-label maths can divide by. */
1691
+ --_height: calc(var(--_font) * var(--control-height-ratio, 3));
1692
+ --_radius: var(--radius-lg, 10px);
1693
+ --_border: var(--color-border, light-dark(hsl(0 0% 78%), hsl(0 0% 32%)));
1694
+ --_border-hover: var(--color-border-active, light-dark(hsl(0 0% 60%), hsl(0 0% 48%)));
1695
+ --_border-focus: var(--color-action, hsl(217 75% 52%));
1696
+ --_border-error: var(--color-error, light-dark(#ef6262, #b04343));
1697
+ --_bg: var(--color-surface, light-dark(#fff, hsl(0 0% 9%)));
1698
+ --_panel: var(--color-surface, light-dark(#fff, hsl(0 0% 13%)));
1699
+ --_panel-hover: var(--color-bg-active, light-dark(hsl(0 0% 95%), hsl(0 0% 18%)));
1700
+ --_text: var(--color-text, inherit);
1701
+ --_text-muted: var(--color-text-muted, light-dark(hsl(0 0% 46%), hsl(0 0% 62%)));
1702
+ --_chip-bg: var(--color-action, hsl(217 75% 52%));
1703
+ --_chip-bg-hover: var(--color-action-active, hsl(217 80% 46%));
1704
+ --_chip-text: var(--color-action-text, #fff);
1705
+ --_duration: 150ms;
1706
+ --_ease: var(--ease-in-out, cubic-bezier(0.76, 0, 0.24, 1));
1707
+ /* The legacy label glide easing */
1708
+ --_ease-label: cubic-bezier(0, 0.54, 0.47, 1);
1709
+ /* Snappy ease-out for the panel's expand-in animation (matches Select) */
1710
+ --_ease-expand: cubic-bezier(0.16, 1, 0.3, 1);
1711
+
1712
+ position: relative;
1713
+ width: 100%;
1714
+ font-size: var(--_font);
1715
+ text-align: left;
1716
+ }
1717
+
1718
+ .input.disabled {
1719
+ opacity: 0.55;
1720
+ pointer-events: none;
1721
+ }
1722
+
1723
+ /* Density modifiers swap the shared height ratio (see tokens.css), so a
1724
+ dense Input/Select/Button row also lands on a single height. */
1725
+ .input.dense {
1726
+ --_height: calc(var(--_font) * var(--control-height-ratio-dense, 2.5));
1727
+ }
1728
+ .input.comfortable {
1729
+ --_height: calc(var(--_font) * var(--control-height-ratio-comfortable, 3.5));
1730
+ }
1731
+
1732
+ /* ================================================================== */
1733
+ /* SKELETON / LOADING */
1734
+ /* ================================================================== */
1735
+
1736
+ /*
1737
+ * The skeleton/loading state renders the real field (label and placeholder
1738
+ * are known up front) so there's no layout shift when it resolves. It is
1739
+ * disabled via `effectively_disabled`; a soft sweeping shimmer signals that
1740
+ * the page isn't ready yet.
1741
+ */
1742
+ .skeleton-sweep {
1743
+ position: absolute;
1744
+ inset: 0;
1745
+ z-index: 1;
1746
+ border-radius: inherit;
1747
+ @supports (corner-shape: squircle) {
1748
+ corner-shape: inherit;
1749
+ }
1750
+ overflow: hidden;
1751
+ pointer-events: none;
1752
+
1753
+ &::after {
1754
+ content: '';
1755
+ position: absolute;
1756
+ inset: 0;
1757
+ transform: translateX(-100%);
1758
+ background-image: linear-gradient(
1759
+ 105deg,
1760
+ transparent 25%,
1761
+ var(--skeleton-sheen, rgb(from var(--color-text, #888) r g b / 0.12)) 50%,
1762
+ transparent 75%
1763
+ );
1764
+ animation: delight-skeleton-shimmer var(--skeleton-duration, 2.4s) ease-in-out
1765
+ infinite;
1766
+ animation-delay: var(--shimmer-delay, 0s);
1767
+ }
1768
+ }
1769
+
1770
+ @keyframes -global-delight-skeleton-shimmer {
1771
+ 0% {
1772
+ transform: translateX(-100%);
1773
+ }
1774
+ 55%,
1775
+ 100% {
1776
+ transform: translateX(100%);
1777
+ }
1778
+ }
1779
+
1780
+ @media (prefers-reduced-motion: reduce) {
1781
+ .skeleton-sweep::after {
1782
+ animation: none;
1783
+ }
1784
+ }
1785
+
1786
+ /* ================================================================== */
1787
+ /* WRAPPER */
1788
+ /* ================================================================== */
1789
+
1790
+ .wrapper {
1791
+ position: relative;
1792
+ display: flex;
1793
+ align-items: center;
1794
+ gap: 0.5em;
1795
+ min-height: var(--_height);
1796
+ /* No top margin: the bordered box IS the control's layout height, so a
1797
+ row of Input/Select/Button top-aligns. The floating label is
1798
+ absolutely positioned and straddles the top border out of flow — it
1799
+ overflows ~0.4em above the box without adding to the layout height. */
1800
+ padding: 0 var(--control-pad-x, 1em);
1801
+ border-radius: var(--_radius);
1802
+ /* Squircle + a rounder radius. The notch shoulders (label ::before/::after)
1803
+ draw the top corners, so the radius is doubled like elsewhere but CAPPED at the
1804
+ label's left content offset (1em) — past that the corner would crowd the floated
1805
+ label. --_cr is the shared corner radius; the shoulders scale to it (height +
1806
+ floated width) so the squircle seam stays aligned with the side borders. */
1807
+ @supports (corner-shape: squircle) {
1808
+ --_cr: min(calc(var(--_radius) * var(--squircle-ratio, 2)), 1em);
1809
+ corner-shape: squircle;
1810
+ border-radius: var(--_cr);
1811
+ }
1812
+ /* Transparent (outlined) by default; the `filled` prop paints the surface. */
1813
+ background: transparent;
1814
+ cursor: text;
1815
+ }
1816
+
1817
+ /* Filled variant — paint the control surface behind the field. */
1818
+ .input.filled .wrapper {
1819
+ background: var(--_bg);
1820
+ }
1821
+
1822
+ .input.dense .wrapper {
1823
+ padding: 0 var(--control-pad-x-dense, 0.75em);
1824
+ }
1825
+ .input.comfortable .wrapper {
1826
+ padding: 0 var(--control-pad-x-comfortable, 1.25em);
1827
+ }
1828
+
1829
+ /* The outline is painted by a pseudo-element so the 1px -> 2px focus
1830
+ transition never nudges the field's contents. */
1831
+ .wrapper::before {
1832
+ content: '';
1833
+ position: absolute;
1834
+ inset: 0;
1835
+ border: 1px solid var(--_border);
1836
+ border-radius: inherit;
1837
+ @supports (corner-shape: squircle) {
1838
+ corner-shape: inherit;
1839
+ }
1840
+ pointer-events: none;
1841
+ /* Width is NOT transitioned: the top edge (notch shoulders) thickens
1842
+ instantly on focus, so the sides/bottom must snap too or the box
1843
+ visibly thickens at two different rates. */
1844
+ transition: border-color var(--_duration) var(--_ease);
1845
+ }
1846
+
1847
+ /* With a label present, the label itself paints the top edge (the notch) */
1848
+ .input.has-label .wrapper::before {
1849
+ border-top-color: transparent;
1850
+ }
1851
+
1852
+ .wrapper:hover::before {
1853
+ border-color: var(--_border-hover);
1854
+ /* Snap the border color in on hover; the base rule eases it back out on leave. */
1855
+ transition: none;
1856
+ }
1857
+ .input.has-label .wrapper:hover::before {
1858
+ border-top-color: transparent;
1859
+ }
1860
+
1861
+ .wrapper.focused::before {
1862
+ border-color: var(--_border-focus);
1863
+ border-width: 2px;
1864
+ /* Snap the border in on focus (matches the hover rule above); the base
1865
+ rule eases it back out on blur. Without this, keyboard-focus eased the
1866
+ color over --_duration while the width snapped — the same two-rate
1867
+ mismatch the notch fix removed, just on focus instead of hover. */
1868
+ transition: none;
1869
+ }
1870
+ .input.has-label .wrapper.focused::before {
1871
+ border-top-color: transparent;
1872
+ }
1873
+
1874
+ .wrapper.has-error::before {
1875
+ border-color: var(--_border-error);
1876
+ }
1877
+ .input.has-label .wrapper.has-error::before {
1878
+ border-top-color: transparent;
1879
+ }
1880
+
1881
+ .input.is-textarea .wrapper {
1882
+ align-items: stretch;
1883
+ min-height: auto;
1884
+ }
1885
+
1886
+ /* ================================================================== */
1887
+ /* INPUT FIELD */
1888
+ /* ================================================================== */
1889
+
1890
+ .field {
1891
+ flex: 1;
1892
+ min-width: 0;
1893
+ border: none;
1894
+ outline: none;
1895
+ /* The wrapper outline is the focus indicator — neutralise any focus
1896
+ ring a host app applies to bare controls (e.g. a global
1897
+ `*:focus-visible { box-shadow }` rule). */
1898
+ box-shadow: none;
1899
+ background: transparent;
1900
+ font: inherit;
1901
+ font-size: var(--_font);
1902
+ color: var(--_text);
1903
+ padding: 0;
1904
+ height: var(--_height);
1905
+ line-height: var(--_height);
1906
+ }
1907
+
1908
+ .field::placeholder {
1909
+ color: var(--_text-muted);
1910
+ opacity: 0.85;
1911
+ }
1912
+
1913
+ /* Deferred placeholder: hidden while the label rests in the field, fading
1914
+ in once focus floats the label out of the way. The fade-in is delayed so
1915
+ the label's 200ms glide mostly clears before the placeholder appears;
1916
+ the fade-out is quick and immediate so the two never overlap while the
1917
+ label glides back down on blur. */
1918
+ .input.placeholder-deferred :is(.field, .chip-input)::placeholder {
1919
+ opacity: 0;
1920
+ transition: opacity 80ms ease;
1921
+ }
1922
+ .input.placeholder-deferred.focused :is(.field, .chip-input)::placeholder {
1923
+ opacity: 0.85;
1924
+ transition: opacity 150ms ease 100ms;
1925
+ }
1926
+
1927
+ /* Textarea specifics. Resizing is handled by the custom .resize-handle —
1928
+ the native grip sits at the textarea's corner, which the wrapper padding
1929
+ pushes ~4px in from the wrapper's actual corner. */
1930
+ textarea.field {
1931
+ height: auto;
1932
+ min-height: var(--_height);
1933
+ line-height: 1.5;
1934
+ resize: none;
1935
+ padding: 0.9em 0;
1936
+ }
1937
+
1938
+ /*
1939
+ * The handle's grip is two concentric arcs that parallel the wrapper's
1940
+ * bottom-right corner: each pseudo is a box whose bottom-right radius is
1941
+ * the wrapper radius minus its inset, with only the right/bottom borders
1942
+ * painted — so the stroke follows the exact curve of the outline (squircle
1943
+ * included) with short straight tails, like a curved take on the classic
1944
+ * diagonal grip.
1945
+ */
1946
+ .resize-handle {
1947
+ --_grip: var(--_border-hover);
1948
+ position: absolute;
1949
+ right: 0;
1950
+ bottom: 0;
1951
+ width: 20px;
1952
+ height: 20px;
1953
+ cursor: ns-resize;
1954
+ touch-action: none;
1955
+
1956
+ &::before,
1957
+ &::after {
1958
+ content: '';
1959
+ position: absolute;
1960
+ right: var(--_inset);
1961
+ bottom: var(--_inset);
1962
+ border-right: 2.5px solid var(--_grip);
1963
+ border-bottom: 2.5px solid var(--_grip);
1964
+ border-bottom-right-radius: max(calc(var(--_radius) - var(--_inset)), 2px);
1965
+ @supports (corner-shape: squircle) {
1966
+ corner-shape: squircle;
1967
+ border-bottom-right-radius: max(calc(var(--_cr) - var(--_inset)), 2px);
1968
+ }
1969
+ /* OUT transitions — colors ease away, shapes ease home */
1970
+ transition:
1971
+ border-color 250ms ease,
1972
+ opacity 250ms ease,
1973
+ translate 250ms var(--_ease);
1974
+ }
1975
+
1976
+ /* Outer arc — always visible */
1977
+ &::before {
1978
+ --_inset: 3.5px;
1979
+ width: 13px;
1980
+ height: 13px;
1981
+ }
1982
+
1983
+ /* Inner arc — tucked into the corner at rest, fans in on hover */
1984
+ &::after {
1985
+ --_inset: 7.5px;
1986
+ width: 7px;
1987
+ height: 7px;
1988
+ opacity: 0;
1989
+ translate: 3px 3px;
1990
+ }
1991
+
1992
+ &:hover,
1993
+ &.dragging {
1994
+ --_grip: var(--_text);
1995
+
1996
+ &::before {
1997
+ /* color snaps in; nothing else moves on the outer arc */
1998
+ transition: none;
1999
+ }
2000
+ &::after {
2001
+ opacity: 1;
2002
+ translate: 0 0;
2003
+ /* color snaps in; the fan-in reveal eases in */
2004
+ transition:
2005
+ opacity 150ms ease,
2006
+ translate 150ms var(--_ease);
2007
+ }
2008
+ }
2009
+
2010
+ &.dragging {
2011
+ --_grip: var(--_border-focus);
2012
+ }
2013
+ }
2014
+
2015
+ @media (prefers-reduced-motion: reduce) {
2016
+ .resize-handle::before,
2017
+ .resize-handle::after {
2018
+ transition: none;
2019
+ }
2020
+ }
2021
+
2022
+ /* Number: hide native spinner */
2023
+ input[type='number'].field {
2024
+ appearance: textfield;
2025
+ -moz-appearance: textfield;
2026
+ }
2027
+ input[type='number'].field::-webkit-outer-spin-button,
2028
+ input[type='number'].field::-webkit-inner-spin-button {
2029
+ -webkit-appearance: none;
2030
+ margin: 0;
2031
+ }
2032
+
2033
+ /* Search: hide native clear */
2034
+ input[type='search'].field::-webkit-search-cancel-button {
2035
+ -webkit-appearance: none;
2036
+ }
2037
+
2038
+ /* Date / time / datetime-local: hide the native picker affordances. The
2039
+ ::-webkit-calendar-picker-indicator is a fixed black glyph that ignores
2040
+ the design tokens, so we render our own token-tinted icon (see the
2041
+ template) and reopen the picker via showPicker(). The inner spinner and
2042
+ clear button are dropped too — the field carries its own clear button and
2043
+ stays keyboard-editable. */
2044
+ input[type='date'].field::-webkit-calendar-picker-indicator,
2045
+ input[type='time'].field::-webkit-calendar-picker-indicator,
2046
+ input[type='datetime-local'].field::-webkit-calendar-picker-indicator {
2047
+ display: none;
2048
+ }
2049
+ input[type='date'].field::-webkit-inner-spin-button,
2050
+ input[type='time'].field::-webkit-inner-spin-button,
2051
+ input[type='datetime-local'].field::-webkit-inner-spin-button,
2052
+ input[type='date'].field::-webkit-clear-button,
2053
+ input[type='time'].field::-webkit-clear-button,
2054
+ input[type='datetime-local'].field::-webkit-clear-button {
2055
+ -webkit-appearance: none;
2056
+ display: none;
2057
+ }
2058
+
2059
+ /* ================================================================== */
2060
+ /* FLOATING LABEL (notched outline, legacy-style) */
2061
+ /* ================================================================== */
2062
+
2063
+ /*
2064
+ * The label spans the full width of the wrapper and paints the top edge
2065
+ * of the outline itself. At rest it is a single continuous border with the
2066
+ * label text centred inside (acting as the placeholder). When floated, the
2067
+ * label's own border disappears and two short "shoulder" segments
2068
+ * (::before / ::after) light up instead, leaving a gap — the notch —
2069
+ * exactly the width of the shrunken label text.
2070
+ */
2071
+ label {
2072
+ position: absolute;
2073
+ inset: 0 0 auto 0;
2074
+ display: flex;
2075
+ align-items: center;
2076
+ /* Fixed to the field's base height so the notch stays pinned to the
2077
+ top edge even when the wrapper grows (wrapping chips, textarea). */
2078
+ height: var(--_height);
2079
+ margin: 0;
2080
+ padding: 0;
2081
+ box-sizing: border-box;
2082
+ border-top: 1px solid var(--_border);
2083
+ /* Invisible counterweight to the top border: with border-box sizing the
2084
+ 1px top border alone would push the flex-centred resting text 0.5px
2085
+ below the field's true centre. */
2086
+ border-bottom: 1px solid transparent;
2087
+ border-radius: var(--_radius);
2088
+ @supports (corner-shape: squircle) {
2089
+ corner-shape: squircle;
2090
+ border-radius: var(--_cr);
2091
+ }
2092
+ color: var(--_text-muted);
2093
+ pointer-events: none;
2094
+ transition:
2095
+ border-color var(--_duration) var(--_ease),
2096
+ color var(--_duration) var(--_ease);
2097
+ }
2098
+
2099
+ /* Notch shoulders — short border runs either side of the label text,
2100
+ pinned to the top edge regardless of where the label text sits. */
2101
+ label::before,
2102
+ label::after {
2103
+ content: '';
2104
+ display: block;
2105
+ box-sizing: border-box;
2106
+ flex: 0 0 auto;
2107
+ align-self: flex-start;
2108
+ width: 0;
2109
+ min-width: 1em;
2110
+ height: var(--_radius);
2111
+ @supports (corner-shape: squircle) {
2112
+ height: var(--_cr);
2113
+ }
2114
+ border-top: 1px solid transparent;
2115
+ transition:
2116
+ border-color var(--_duration) var(--_ease),
2117
+ min-width 200ms var(--_ease-label);
2118
+ }
2119
+ label::before {
2120
+ /* End the left border run 0.3em before the text so the notch has a small
2121
+ gap on the left, matching the 0.3em the ::after leaves on the right.
2122
+ The text's own margin-left keeps it aligned with the field contents. */
2123
+ min-width: 0.7em;
2124
+ border-top-left-radius: var(--_radius);
2125
+ @supports (corner-shape: squircle) {
2126
+ corner-shape: squircle;
2127
+ border-top-left-radius: var(--_cr);
2128
+ }
2129
+ }
2130
+ label::after {
2131
+ flex: 1 1 auto;
2132
+ min-width: 0.5em;
2133
+ margin-left: 0.3em;
2134
+ border-top-right-radius: var(--_radius);
2135
+ @supports (corner-shape: squircle) {
2136
+ corner-shape: squircle;
2137
+ border-top-right-radius: var(--_cr);
2138
+ /* Room for the bigger corner when a long label squeezes the shoulder,
2139
+ so the curve never gets scaled down (which would break the seam). */
2140
+ min-width: 1em;
2141
+ }
2142
+ }
2143
+
2144
+ /* While resting, a leading icon widens the left shoulder so the label text
2145
+ (acting as the placeholder) clears the icon. Once floated, the shoulder
2146
+ returns to its base width so the notch always sits in the top-left
2147
+ corner — even with an icon or prefix. */
2148
+ .input.has-icon label:not(.floated)::before {
2149
+ min-width: calc(1em + var(--_icon-size) + 0.5em);
2150
+ }
2151
+
2152
+ .label-text {
2153
+ display: flex;
2154
+ align-items: center;
2155
+ max-width: 100%;
2156
+ padding: 0;
2157
+ /* Small gap from the left notch shoulder (mirrors the ::after gap on the
2158
+ right); the shoulder is shortened by the same amount so the text stays
2159
+ aligned with the field contents. */
2160
+ margin-left: 0.3em;
2161
+ font-size: var(--_font);
2162
+ /* Roomier than 1 so the line box contains descenders (g, y, p): with
2163
+ line-height 1 the box is exactly the font size and overflow:hidden
2164
+ clips them. Half-leading is symmetric, so the glyph stays centred on
2165
+ the border when floated — nothing shifts. */
2166
+ line-height: 1.4;
2167
+ white-space: nowrap;
2168
+ overflow: hidden;
2169
+ text-overflow: ellipsis;
2170
+ transition:
2171
+ font-size 200ms var(--_ease-label),
2172
+ transform 200ms var(--_ease-label),
2173
+ padding 200ms var(--_ease-label),
2174
+ margin-left 200ms var(--_ease-label);
2175
+ }
2176
+
2177
+ /* --- Floated state ------------------------------------------------- */
2178
+ label.floated {
2179
+ border-top-color: transparent;
2180
+ }
2181
+ label.floated::before,
2182
+ label.floated::after {
2183
+ border-top-color: var(--_border);
2184
+ }
2185
+ /* Glide from the vertically-centred resting spot up onto the top edge.
2186
+ --_height is a plain length, so half of it lands the text exactly on
2187
+ the outline — and transform + font-size both animate smoothly. */
2188
+ label.floated .label-text {
2189
+ font-size: calc(var(--_font) * 0.8);
2190
+ transform: translateY(calc(var(--_height) / -2));
2191
+ @supports (corner-shape: squircle) {
2192
+ /* The floated left shoulder is widened to the 1em label offset below, so
2193
+ drop the text's own gap to keep it landing at the same spot (the
2194
+ ::before min-width and this margin animate together → no horizontal shift). */
2195
+ margin-left: 0;
2196
+ }
2197
+ }
2198
+ /* Floated: widen the left shoulder to the label's 1em content offset so the
2199
+ (now larger, capped) squircle corner has room and its seam meets the side. */
2200
+ @supports (corner-shape: squircle) {
2201
+ label.floated::before {
2202
+ min-width: 1em;
2203
+ /* Trim the trailing 0.3em of the shoulder so the line stops short of
2204
+ the text — the same gap the ::after's margin leaves on the right.
2205
+ A squircle is dead flat over its last third, so only the straight
2206
+ tail of the corner falls in the trimmed region; the curve itself
2207
+ still reads complete. */
2208
+ mask-image: linear-gradient(to right, #000 calc(100% - 0.3em), #0000 0);
2209
+ }
2210
+ }
2211
+
2212
+ /* --- Textarea: rest the label at the top, straddle the edge on float - */
2213
+ .input.is-textarea label {
2214
+ align-items: flex-start;
2215
+ }
2216
+ .input.is-textarea .label-text {
2217
+ padding-top: 0.9em;
2218
+ }
2219
+ .input.is-textarea label.floated .label-text {
2220
+ padding-top: 0;
2221
+ transform: translateY(-50%);
2222
+ }
2223
+
2224
+ /* --- Hover ---------------------------------------------------------- */
2225
+ .wrapper:hover label {
2226
+ border-top-color: var(--_border-hover);
2227
+ /* Snap the notch color in on hover; the base rule eases it back out on leave. */
2228
+ transition: color var(--_duration) var(--_ease);
2229
+ }
2230
+ .wrapper:hover label.floated {
2231
+ border-top-color: transparent;
2232
+ }
2233
+ .wrapper:hover label.floated::before,
2234
+ .wrapper:hover label.floated::after {
2235
+ border-top-color: var(--_border-hover);
2236
+ /* Snap the notch shoulders in on hover; the base rule eases them back out. */
2237
+ transition: min-width 200ms var(--_ease-label);
2238
+ }
2239
+
2240
+ /* --- Focused -------------------------------------------------------- */
2241
+ /* The label's own border-top stays 1px on focus. A focused label is always
2242
+ floated (its own border is then transparent), so thickening it here was
2243
+ invisible yet still grew the label's content box — nudging the notch
2244
+ shoulders and centred text down ~1px. The focus emphasis comes from the
2245
+ notch shoulders (::before/::after) below, which thicken without moving. */
2246
+ .wrapper.focused label {
2247
+ border-top-color: var(--_border-focus);
2248
+ color: var(--_border-focus);
2249
+ /* Snap the notch color in on focus (mirrors the hover rule); the text
2250
+ color still eases. The base rule eases both back out on blur. */
2251
+ transition: color var(--_duration) var(--_ease);
2252
+ }
2253
+ .wrapper.focused label.floated {
2254
+ border-top-color: transparent;
2255
+ }
2256
+ .wrapper.focused label::before,
2257
+ .wrapper.focused label::after {
2258
+ border-top-width: 2px;
2259
+ }
2260
+ .wrapper.focused label.floated::before,
2261
+ .wrapper.focused label.floated::after {
2262
+ border-top-color: var(--_border-focus);
2263
+ /* Snap the shoulder color in on focus (mirrors the hover rule); keep
2264
+ min-width animating so the notch still opens smoothly. */
2265
+ transition: min-width 200ms var(--_ease-label);
2266
+ }
2267
+
2268
+ /* --- Error ---------------------------------------------------------- */
2269
+ .wrapper.has-error label {
2270
+ border-top-color: var(--_border-error);
2271
+ color: var(--_border-error);
2272
+ }
2273
+ .wrapper.has-error label.floated {
2274
+ border-top-color: transparent;
2275
+ }
2276
+ .wrapper.has-error label.floated::before,
2277
+ .wrapper.has-error label.floated::after {
2278
+ border-top-color: var(--_border-error);
2279
+ }
2280
+
2281
+ .required-mark {
2282
+ color: var(--_border-error);
2283
+ margin-left: 0.1em;
2284
+ }
2285
+
2286
+ /* ================================================================== */
2287
+ /* ICONS & PREFIX / SUFFIX */
2288
+ /* ================================================================== */
2289
+
2290
+ .icon {
2291
+ display: flex;
2292
+ align-items: center;
2293
+ justify-content: center;
2294
+ color: var(--_text-muted);
2295
+ flex-shrink: 0;
2296
+ width: var(--_icon-size);
2297
+ height: var(--_icon-size);
2298
+ transition: color var(--_duration) var(--_ease);
2299
+ }
2300
+
2301
+ .wrapper.focused .icon {
2302
+ color: var(--_border-focus);
2303
+ }
2304
+ .wrapper.has-error .icon {
2305
+ color: var(--_border-error);
2306
+ }
2307
+
2308
+ .search-icon {
2309
+ order: -1;
2310
+ }
2311
+
2312
+ .prefix,
2313
+ .suffix {
2314
+ flex-shrink: 0;
2315
+ color: var(--_text-muted);
2316
+ font-size: 0.92em;
2317
+ user-select: none;
2318
+ white-space: nowrap;
2319
+ }
2320
+
2321
+ .suffix {
2322
+ order: 1;
2323
+ }
2324
+
2325
+ /* ================================================================== */
2326
+ /* IN-FIELD BUTTONS (@delightstack Button, scaled to fit) */
2327
+ /* ================================================================== */
2328
+
2329
+ /*
2330
+ * The clear, password-toggle, stepper and remove controls are all
2331
+ * <Button> instances. Button's icon mode is sized in `em` (4em square),
2332
+ * so a font-size keyed off --_font scales them to fit the field without
2333
+ * reaching into Button's internals.
2334
+ */
2335
+ .input :global(.button.input-icon-btn) {
2336
+ font-size: calc(var(--_font) * 0.5);
2337
+ flex-shrink: 0;
2338
+ /* Pin to the legacy 4em square (relative to the reduced font above) so
2339
+ the in-field buttons stay sized to the field, independent of the
2340
+ control-height-based default for standalone icon buttons. */
2341
+ width: 4em;
2342
+ height: 4em;
2343
+ }
2344
+ .input :global(.button.input-pill-btn) {
2345
+ font-size: calc(var(--_font) * 0.35);
2346
+ flex-shrink: 0;
2347
+ width: 4em;
2348
+ height: 4em;
2349
+ }
2350
+
2351
+ /* The clear button fades in on hover/focus of the field. */
2352
+ .input :global(.button.input-clear-btn) {
2353
+ opacity: 0;
2354
+ transition: opacity var(--_duration) var(--_ease);
2355
+ }
2356
+ .wrapper:hover :global(.button.input-clear-btn),
2357
+ .wrapper.focused :global(.button.input-clear-btn) {
2358
+ opacity: 1;
2359
+ }
2360
+
2361
+ /* ================================================================== */
2362
+ /* NUMBER STEPPERS */
2363
+ /* ================================================================== */
2364
+
2365
+ /* The stepper pair sits after the suffix. */
2366
+ .steppers {
2367
+ display: flex;
2368
+ flex-direction: row;
2369
+ align-items: center;
2370
+ gap: 0.1em;
2371
+ flex-shrink: 0;
2372
+ order: 2;
2373
+ margin-right: -0.35em;
2374
+ }
2375
+
2376
+ /* A thin divider sets the steppers off from the suffix — only needed when
2377
+ a suffix is present; without one the field reads cleaner divider-free. */
2378
+ .input.has-suffix .steppers::before {
2379
+ content: '';
2380
+ align-self: center;
2381
+ width: 1px;
2382
+ height: 1.5em;
2383
+ margin-right: 0.35em;
2384
+ background: var(--_border);
2385
+ }
2386
+
2387
+ /* ================================================================== */
2388
+ /* COLOR INPUT */
2389
+ /* ================================================================== */
2390
+
2391
+ .color-control {
2392
+ position: relative;
2393
+ display: inline-flex;
2394
+ flex-shrink: 0;
2395
+ width: 1.6em;
2396
+ height: 1.6em;
2397
+ }
2398
+
2399
+ .color-swatch {
2400
+ width: 100%;
2401
+ height: 100%;
2402
+ border-radius: var(--radius-md, 5px);
2403
+ @supports (corner-shape: squircle) {
2404
+ corner-shape: squircle;
2405
+ border-radius: calc(var(--radius-md, 5px) * var(--squircle-ratio, 2));
2406
+ }
2407
+ border: 1px solid var(--_border);
2408
+ pointer-events: none;
2409
+ }
2410
+
2411
+ /* The real <input type=color> sits invisibly over the swatch, so a click
2412
+ anywhere on it opens the native picker anchored at the swatch. */
2413
+ .color-native {
2414
+ position: absolute;
2415
+ inset: 0;
2416
+ width: 100%;
2417
+ height: 100%;
2418
+ margin: 0;
2419
+ padding: 0;
2420
+ border: none;
2421
+ opacity: 0;
2422
+ cursor: pointer;
2423
+ }
2424
+ .color-native:disabled {
2425
+ cursor: not-allowed;
2426
+ }
2427
+
2428
+ /* ================================================================== */
2429
+ /* FILE INPUT */
2430
+ /* ================================================================== */
2431
+
2432
+ .file-native {
2433
+ position: absolute;
2434
+ width: 1px;
2435
+ height: 1px;
2436
+ overflow: hidden;
2437
+ clip: rect(0, 0, 0, 0);
2438
+ border: 0;
2439
+ padding: 0;
2440
+ margin: -1px;
2441
+ }
2442
+
2443
+ .file-trigger {
2444
+ cursor: pointer;
2445
+ text-align: left;
2446
+ }
2447
+
2448
+ .file-placeholder {
2449
+ color: var(--_text-muted);
2450
+ opacity: 0.85;
2451
+ }
2452
+
2453
+ .file-items {
2454
+ display: flex;
2455
+ flex-wrap: wrap;
2456
+ align-items: center;
2457
+ gap: 0.4em;
2458
+ flex: 1;
2459
+ min-width: 0;
2460
+ padding: 0.4em 0;
2461
+ }
2462
+ .input.has-label .file-items {
2463
+ padding-top: 0.6em;
2464
+ }
2465
+
2466
+ .file-item {
2467
+ display: inline-flex;
2468
+ align-items: center;
2469
+ gap: 0.5em;
2470
+ max-width: 100%;
2471
+ padding: 0.25em 0.3em;
2472
+ border-radius: var(--radius-md, 6px);
2473
+ @supports (corner-shape: squircle) {
2474
+ corner-shape: squircle;
2475
+ border-radius: calc(var(--radius-md, 6px) * var(--squircle-ratio, 2));
2476
+ }
2477
+ background: var(--_panel-hover);
2478
+ }
2479
+
2480
+ .file-thumb {
2481
+ display: flex;
2482
+ align-items: center;
2483
+ justify-content: center;
2484
+ width: 2em;
2485
+ height: 2em;
2486
+ flex-shrink: 0;
2487
+ overflow: hidden;
2488
+ border-radius: var(--radius-sm, 4px);
2489
+ @supports (corner-shape: squircle) {
2490
+ corner-shape: squircle;
2491
+ border-radius: calc(var(--radius-sm, 4px) * var(--squircle-ratio, 2));
2492
+ }
2493
+ background: var(--_bg);
2494
+ color: var(--_text-muted);
2495
+ }
2496
+ .file-thumb img {
2497
+ width: 100%;
2498
+ height: 100%;
2499
+ object-fit: cover;
2500
+ }
2501
+ .file-thumb svg {
2502
+ width: 62%;
2503
+ height: 62%;
2504
+ }
2505
+
2506
+ .file-item-name {
2507
+ overflow: hidden;
2508
+ text-overflow: ellipsis;
2509
+ white-space: nowrap;
2510
+ font-size: 0.92em;
2511
+ }
2512
+
2513
+ /* The "Add files" Button takes a full-width row of its own. */
2514
+ .file-add-row {
2515
+ flex-basis: 100%;
2516
+ width: 100%;
2517
+ margin-top: 0.1em;
2518
+ }
2519
+
2520
+ /* ================================================================== */
2521
+ /* CHIPS (Multiple mode) */
2522
+ /* ================================================================== */
2523
+
2524
+ .chips {
2525
+ display: flex;
2526
+ flex-wrap: wrap;
2527
+ align-items: center;
2528
+ gap: 0.4em;
2529
+ flex: 1;
2530
+ min-width: 0;
2531
+ padding: 0.45em 0;
2532
+ }
2533
+
2534
+ /* Keep chips clear of the floated label straddling the top edge */
2535
+ .input.has-label .chips {
2536
+ padding-top: 0.7em;
2537
+ }
2538
+
2539
+ .chip {
2540
+ display: inline-flex;
2541
+ align-items: center;
2542
+ gap: 0.35em;
2543
+ padding: 0.2em 0.3em 0.2em 0.7em;
2544
+ border-radius: var(--radius-full, 999px);
2545
+ background: var(--_chip-bg);
2546
+ color: var(--_chip-text);
2547
+ font-size: 0.85em;
2548
+ max-width: 100%;
2549
+ line-height: 1.4;
2550
+ transition:
2551
+ background var(--_duration) var(--_ease),
2552
+ scale 150ms var(--_ease);
2553
+ }
2554
+
2555
+ .chip:active {
2556
+ scale: 0.96;
2557
+ }
2558
+
2559
+ .chip-text {
2560
+ overflow: hidden;
2561
+ text-overflow: ellipsis;
2562
+ white-space: nowrap;
2563
+ }
2564
+
2565
+ .chip-remove {
2566
+ position: relative;
2567
+ display: inline-flex;
2568
+ align-items: center;
2569
+ justify-content: center;
2570
+ width: 1.3em;
2571
+ height: 1.3em;
2572
+ padding: 0;
2573
+ border: none;
2574
+ border-radius: var(--radius-full, 999px);
2575
+ background: none;
2576
+ color: inherit;
2577
+ cursor: pointer;
2578
+ opacity: 0.75;
2579
+ flex-shrink: 0;
2580
+ transition:
2581
+ opacity var(--_duration) var(--_ease),
2582
+ background var(--_duration) var(--_ease);
2583
+ }
2584
+
2585
+ /* Invisible hit area extending ~10px past the icon on every side so the
2586
+ button is easy to tap. The visible hover feedback stays the size of the
2587
+ element itself (above), not the touch target. */
2588
+ .chip-remove::before {
2589
+ content: '';
2590
+ position: absolute;
2591
+ inset: -10px;
2592
+ }
2593
+
2594
+ .chip-remove:hover {
2595
+ opacity: 1;
2596
+ background: color-mix(in oklch, currentColor 22%, transparent);
2597
+ /* Snap the tint in on hover; keep the opacity reveal eased both ways. */
2598
+ transition: opacity var(--_duration) var(--_ease);
2599
+ }
2600
+
2601
+ .chip-remove svg {
2602
+ width: 0.85em;
2603
+ height: 0.85em;
2604
+ }
2605
+
2606
+ .chip-input {
2607
+ flex: 1;
2608
+ min-width: 5em;
2609
+ border: none;
2610
+ outline: none;
2611
+ box-shadow: none;
2612
+ background: transparent;
2613
+ font: inherit;
2614
+ font-size: var(--_font);
2615
+ color: var(--_text);
2616
+ padding: 0;
2617
+ height: 1.9em;
2618
+ }
2619
+
2620
+ .chip-input::placeholder {
2621
+ color: var(--_text-muted);
2622
+ opacity: 0.85;
2623
+ }
2624
+
2625
+ /* ================================================================== */
2626
+ /* PASSWORD STRENGTH */
2627
+ /* ================================================================== */
2628
+
2629
+ .strength-meter {
2630
+ display: flex;
2631
+ align-items: center;
2632
+ gap: 0.5em;
2633
+ margin-top: 0.4em;
2634
+ padding: 0 0.4em;
2635
+ }
2636
+
2637
+ .strength-track {
2638
+ display: flex;
2639
+ gap: 3px;
2640
+ flex: 1;
2641
+ }
2642
+
2643
+ .strength-segment {
2644
+ height: 3px;
2645
+ flex: 1;
2646
+ border-radius: 2px;
2647
+ background: var(--_border);
2648
+ transition: background 300ms var(--_ease);
2649
+ }
2650
+
2651
+ .strength-label {
2652
+ font-size: 0.75em;
2653
+ white-space: nowrap;
2654
+ transition: color 300ms var(--_ease);
2655
+ }
2656
+
2657
+ /* ================================================================== */
2658
+ /* FOOTER (error, description, counter) */
2659
+ /* ================================================================== */
2660
+
2661
+ .footer {
2662
+ display: flex;
2663
+ justify-content: space-between;
2664
+ align-items: baseline;
2665
+ gap: 0.5em;
2666
+ margin-top: 0.35em;
2667
+ padding: 0 0.5em;
2668
+ min-height: 1.2em;
2669
+ }
2670
+
2671
+ .error {
2672
+ font-size: 0.78em;
2673
+ color: var(--_border-error);
2674
+ animation: error-in 200ms var(--_ease);
2675
+ }
2676
+
2677
+ @keyframes error-in {
2678
+ from {
2679
+ opacity: 0;
2680
+ transform: translateY(-3px);
2681
+ }
2682
+ to {
2683
+ opacity: 1;
2684
+ transform: translateY(0);
2685
+ }
2686
+ }
2687
+
2688
+ .description {
2689
+ font-size: 0.78em;
2690
+ color: var(--_text-muted);
2691
+ }
2692
+
2693
+ .counter {
2694
+ font-size: 0.74em;
2695
+ color: var(--_text-muted);
2696
+ margin-left: auto;
2697
+ font-variant-numeric: tabular-nums;
2698
+ transition: color var(--_duration) var(--_ease);
2699
+ }
2700
+
2701
+ .counter.counter-warning {
2702
+ color: var(--color-warning, #f59e0b);
2703
+ }
2704
+
2705
+ .counter.counter-error {
2706
+ color: var(--_border-error);
2707
+ font-weight: 600;
2708
+ }
2709
+
2710
+ /* ================================================================== */
2711
+ /* AUTOCOMPLETE DROPDOWN (native popover, CSS anchor positioned) */
2712
+ /* ================================================================== */
2713
+
2714
+ /*
2715
+ * The panel is a native `popover` element placed with CSS anchor
2716
+ * positioning relative to the field — it renders in the top layer (no
2717
+ * clipping, no z-index juggling, no Portal) and matches the Select
2718
+ * component's panel: same width as the field, the same expand-from-the-edge
2719
+ * animation, and the same flip-when-no-room-below behaviour. The rows
2720
+ * themselves are List/ListItem.
2721
+ */
2722
+ .dropdown {
2723
+ position: fixed;
2724
+ top: anchor(bottom);
2725
+ bottom: auto;
2726
+ left: anchor(left);
2727
+ right: auto;
2728
+ width: anchor-size(width);
2729
+ margin: 0.4em 0 0 0;
2730
+ padding: 0;
2731
+ box-sizing: border-box;
2732
+ max-height: 18em;
2733
+ overflow-y: auto;
2734
+ /* Border + shadow together: in light mode the shadow lifts the panel and
2735
+ the border is a faint edge; in dark mode --shadow-md is transparent, so
2736
+ the border is what separates the panel from the page. */
2737
+ border: 1px solid var(--_border);
2738
+ background: var(--_panel);
2739
+ color: var(--_text);
2740
+ border-radius: calc(var(--radius-lg, 10px) * 1.5);
2741
+ /* Keep the native (baseline-styled) thumb clear of the rounded corners.
2742
+ scrollbar-width/scrollbar-color must NOT be set here — they disable the
2743
+ ::-webkit-scrollbar baseline styling in Chromium. */
2744
+ --scrollbar-track-inset: calc(var(--radius-xl, 16px) / 2);
2745
+ @supports (corner-shape: squircle) {
2746
+ corner-shape: squircle;
2747
+ border-radius: calc(var(--radius-lg, 10px) * 1.5 * var(--squircle-ratio, 2));
2748
+ --scrollbar-track-inset: calc(
2749
+ var(--radius-lg, 10px) * 1.5 * var(--squircle-ratio, 2) / 2
2750
+ );
2751
+ }
2752
+ overscroll-behavior: contain;
2753
+ box-shadow: var(--shadow-md, 0 8px 28px -8px rgb(0 0 0 / 0.3));
2754
+ /* Flip above the field when there is no room below */
2755
+ position-try-fallbacks: flip-block;
2756
+ /* Expand-in from the edge closest to the field — origin flips to
2757
+ `bottom` when the panel is placed above the control (`.above`). */
2758
+ transform-origin: center top;
2759
+ opacity: 1;
2760
+ transform: scaleY(1);
2761
+ transition:
2762
+ opacity 200ms var(--_ease-expand),
2763
+ transform 200ms var(--_ease-expand),
2764
+ display 200ms allow-discrete,
2765
+ overlay 200ms allow-discrete;
2766
+ }
2767
+ .dropdown.above {
2768
+ transform-origin: center bottom;
2769
+ }
2770
+ /* Collapsed state — drives both the open (@starting-style) and close
2771
+ transitions, so the panel expands/collapses toward the field. */
2772
+ .dropdown:not(:popover-open) {
2773
+ opacity: 0;
2774
+ transform: scaleY(0.6);
2775
+ }
2776
+ @starting-style {
2777
+ .dropdown:popover-open {
2778
+ opacity: 0;
2779
+ transform: scaleY(0.6);
2780
+ }
2781
+ }
2782
+
2783
+ /* Option content rendered inside each ListItem. ListItem renders a native
2784
+ <button>, whose UA `text-align: center` would otherwise centre the label
2785
+ once `.option` fills the row — pin it back to the start. */
2786
+ .option {
2787
+ display: flex;
2788
+ flex-direction: column;
2789
+ min-width: 0;
2790
+ flex: 1;
2791
+ text-align: left;
2792
+ }
2793
+ .option-label {
2794
+ overflow: hidden;
2795
+ text-overflow: ellipsis;
2796
+ white-space: nowrap;
2797
+ }
2798
+ /* Emphasise the matched substring (from highlightMatch) */
2799
+ .option-label :global(strong) {
2800
+ color: var(--_border-focus);
2801
+ font-weight: 700;
2802
+ }
2803
+ .option-desc {
2804
+ font-size: 0.8em;
2805
+ color: var(--_text-muted);
2806
+ overflow: hidden;
2807
+ text-overflow: ellipsis;
2808
+ white-space: nowrap;
2809
+ }
2810
+
2811
+ /* Loading / empty status row */
2812
+ .status {
2813
+ display: flex;
2814
+ align-items: center;
2815
+ justify-content: center;
2816
+ gap: 0.5em;
2817
+ padding: 0.85em;
2818
+ color: var(--_text-muted);
2819
+ font-size: 0.9em;
2820
+ }
2821
+
2822
+ .spinner {
2823
+ display: inline-block;
2824
+ width: 14px;
2825
+ height: 14px;
2826
+ border: 2px solid var(--_border);
2827
+ border-top-color: var(--_border-focus);
2828
+ border-radius: 50%;
2829
+ animation: spin 0.6s linear infinite;
2830
+ flex-shrink: 0;
2831
+ }
2832
+
2833
+ @keyframes spin {
2834
+ to {
2835
+ transform: rotate(360deg);
2836
+ }
2837
+ }
2838
+
2839
+ /* ================================================================== */
2840
+ /* SIZE */
2841
+ /* ================================================================== */
2842
+
2843
+ /*
2844
+ * Sizes 0–3 only set --input-font (inline, from size_config). Because the
2845
+ * field height, padding, icon gap and label are all em-based, the whole
2846
+ * component scales from that single font-size — no per-size overrides
2847
+ * needed.
2848
+ */
2849
+
2850
+ /* ================================================================== */
2851
+ /* READONLY */
2852
+ /* ================================================================== */
2853
+
2854
+ .input.readonly .wrapper {
2855
+ background: var(--color-bg-disabled, light-dark(hsl(0 0% 96%), hsl(0 0% 13%)));
2856
+ cursor: default;
2857
+ }
2858
+ </style>