@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.
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/SKILL.md +149 -0
- package/bin/agents.js +63 -0
- package/dist/actions/Alert.svelte +202 -0
- package/dist/actions/Alert.svelte.d.ts +36 -0
- package/dist/actions/Alert.svelte.d.ts.map +1 -0
- package/dist/actions/Button.svelte +1450 -0
- package/dist/actions/Button.svelte.d.ts +56 -0
- package/dist/actions/Button.svelte.d.ts.map +1 -0
- package/dist/actions/ButtonGroup.svelte +111 -0
- package/dist/actions/ButtonGroup.svelte.d.ts +41 -0
- package/dist/actions/ButtonGroup.svelte.d.ts.map +1 -0
- package/dist/actions/CommandPalette.svelte +939 -0
- package/dist/actions/CommandPalette.svelte.d.ts +37 -0
- package/dist/actions/CommandPalette.svelte.d.ts.map +1 -0
- package/dist/actions/ContextMenu.svelte +138 -0
- package/dist/actions/ContextMenu.svelte.d.ts +54 -0
- package/dist/actions/ContextMenu.svelte.d.ts.map +1 -0
- package/dist/actions/Modal.svelte +474 -0
- package/dist/actions/Modal.svelte.d.ts +28 -0
- package/dist/actions/Modal.svelte.d.ts.map +1 -0
- package/dist/actions/Popover.svelte +1214 -0
- package/dist/actions/Popover.svelte.d.ts +31 -0
- package/dist/actions/Popover.svelte.d.ts.map +1 -0
- package/dist/actions/Portal.svelte +80 -0
- package/dist/actions/Portal.svelte.d.ts +17 -0
- package/dist/actions/Portal.svelte.d.ts.map +1 -0
- package/dist/actions/ThemeToggle.svelte +345 -0
- package/dist/actions/ThemeToggle.svelte.d.ts +15 -0
- package/dist/actions/ThemeToggle.svelte.d.ts.map +1 -0
- package/dist/actions/index.d.ts +13 -0
- package/dist/actions/index.d.ts.map +1 -0
- package/dist/actions/index.js +10 -0
- package/dist/actions/scrollbar.d.ts +48 -0
- package/dist/actions/scrollbar.d.ts.map +1 -0
- package/dist/actions/scrollbar.js +404 -0
- package/dist/display/Accordion.svelte +586 -0
- package/dist/display/Accordion.svelte.d.ts +41 -0
- package/dist/display/Accordion.svelte.d.ts.map +1 -0
- package/dist/display/Avatar.svelte +527 -0
- package/dist/display/Avatar.svelte.d.ts +22 -0
- package/dist/display/Avatar.svelte.d.ts.map +1 -0
- package/dist/display/AvatarGroup.svelte +298 -0
- package/dist/display/AvatarGroup.svelte.d.ts +31 -0
- package/dist/display/AvatarGroup.svelte.d.ts.map +1 -0
- package/dist/display/Calendar.svelte +1366 -0
- package/dist/display/Calendar.svelte.d.ts +58 -0
- package/dist/display/Calendar.svelte.d.ts.map +1 -0
- package/dist/display/Chart.svelte +1426 -0
- package/dist/display/Chart.svelte.d.ts +35 -0
- package/dist/display/Chart.svelte.d.ts.map +1 -0
- package/dist/display/Code.svelte +780 -0
- package/dist/display/Code.svelte.d.ts +19 -0
- package/dist/display/Code.svelte.d.ts.map +1 -0
- package/dist/display/Comparison.svelte +686 -0
- package/dist/display/Comparison.svelte.d.ts +22 -0
- package/dist/display/Comparison.svelte.d.ts.map +1 -0
- package/dist/display/Counter.svelte +285 -0
- package/dist/display/Counter.svelte.d.ts +21 -0
- package/dist/display/Counter.svelte.d.ts.map +1 -0
- package/dist/display/Expand.svelte +48 -0
- package/dist/display/Expand.svelte.d.ts +9 -0
- package/dist/display/Expand.svelte.d.ts.map +1 -0
- package/dist/display/List.svelte +294 -0
- package/dist/display/List.svelte.d.ts +40 -0
- package/dist/display/List.svelte.d.ts.map +1 -0
- package/dist/display/ListContextReset.svelte +19 -0
- package/dist/display/ListContextReset.svelte.d.ts +7 -0
- package/dist/display/ListContextReset.svelte.d.ts.map +1 -0
- package/dist/display/ListItem.svelte +834 -0
- package/dist/display/ListItem.svelte.d.ts +22 -0
- package/dist/display/ListItem.svelte.d.ts.map +1 -0
- package/dist/display/QR.svelte +1193 -0
- package/dist/display/QR.svelte.d.ts +23 -0
- package/dist/display/QR.svelte.d.ts.map +1 -0
- package/dist/display/SplitPane.svelte +744 -0
- package/dist/display/SplitPane.svelte.d.ts +25 -0
- package/dist/display/SplitPane.svelte.d.ts.map +1 -0
- package/dist/display/Stat.svelte +439 -0
- package/dist/display/Stat.svelte.d.ts +24 -0
- package/dist/display/Stat.svelte.d.ts.map +1 -0
- package/dist/display/Table.svelte +4654 -0
- package/dist/display/Table.svelte.d.ts +249 -0
- package/dist/display/Table.svelte.d.ts.map +1 -0
- package/dist/display/TableCellEditor.svelte +935 -0
- package/dist/display/TableCellEditor.svelte.d.ts +58 -0
- package/dist/display/TableCellEditor.svelte.d.ts.map +1 -0
- package/dist/display/Timeline.svelte +1258 -0
- package/dist/display/Timeline.svelte.d.ts +43 -0
- package/dist/display/Timeline.svelte.d.ts.map +1 -0
- package/dist/display/Tree.svelte +1740 -0
- package/dist/display/Tree.svelte.d.ts +74 -0
- package/dist/display/Tree.svelte.d.ts.map +1 -0
- package/dist/display/Typewriter.svelte +338 -0
- package/dist/display/Typewriter.svelte.d.ts +22 -0
- package/dist/display/Typewriter.svelte.d.ts.map +1 -0
- package/dist/display/index.d.ts +24 -0
- package/dist/display/index.d.ts.map +1 -0
- package/dist/display/index.js +18 -0
- package/dist/feedback/Callout.svelte +529 -0
- package/dist/feedback/Callout.svelte.d.ts +24 -0
- package/dist/feedback/Callout.svelte.d.ts.map +1 -0
- package/dist/feedback/Confetti.svelte +631 -0
- package/dist/feedback/Confetti.svelte.d.ts +90 -0
- package/dist/feedback/Confetti.svelte.d.ts.map +1 -0
- package/dist/feedback/Progress.svelte +382 -0
- package/dist/feedback/Progress.svelte.d.ts +25 -0
- package/dist/feedback/Progress.svelte.d.ts.map +1 -0
- package/dist/feedback/Toast.svelte +967 -0
- package/dist/feedback/Toast.svelte.d.ts +54 -0
- package/dist/feedback/Toast.svelte.d.ts.map +1 -0
- package/dist/feedback/index.d.ts +7 -0
- package/dist/feedback/index.d.ts.map +1 -0
- package/dist/feedback/index.js +4 -0
- package/dist/form/Checkbox.svelte +449 -0
- package/dist/form/Checkbox.svelte.d.ts +27 -0
- package/dist/form/Checkbox.svelte.d.ts.map +1 -0
- package/dist/form/Fieldset.svelte +410 -0
- package/dist/form/Fieldset.svelte.d.ts +22 -0
- package/dist/form/Fieldset.svelte.d.ts.map +1 -0
- package/dist/form/FileUpload.svelte +934 -0
- package/dist/form/FileUpload.svelte.d.ts +41 -0
- package/dist/form/FileUpload.svelte.d.ts.map +1 -0
- package/dist/form/Form.svelte +530 -0
- package/dist/form/Form.svelte.d.ts +120 -0
- package/dist/form/Form.svelte.d.ts.map +1 -0
- package/dist/form/Input.svelte +2858 -0
- package/dist/form/Input.svelte.d.ts +66 -0
- package/dist/form/Input.svelte.d.ts.map +1 -0
- package/dist/form/Radio.svelte +507 -0
- package/dist/form/Radio.svelte.d.ts +39 -0
- package/dist/form/Radio.svelte.d.ts.map +1 -0
- package/dist/form/Range.svelte +912 -0
- package/dist/form/Range.svelte.d.ts +33 -0
- package/dist/form/Range.svelte.d.ts.map +1 -0
- package/dist/form/Rating.svelte +429 -0
- package/dist/form/Rating.svelte.d.ts +28 -0
- package/dist/form/Rating.svelte.d.ts.map +1 -0
- package/dist/form/Select.svelte +1933 -0
- package/dist/form/Select.svelte.d.ts +54 -0
- package/dist/form/Select.svelte.d.ts.map +1 -0
- package/dist/form/Toggle.svelte +645 -0
- package/dist/form/Toggle.svelte.d.ts +50 -0
- package/dist/form/Toggle.svelte.d.ts.map +1 -0
- package/dist/form/index.d.ts +15 -0
- package/dist/form/index.d.ts.map +1 -0
- package/dist/form/index.js +10 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/layout/README.md +172 -0
- package/dist/media/Carousel.svelte +2424 -0
- package/dist/media/Carousel.svelte.d.ts +47 -0
- package/dist/media/Carousel.svelte.d.ts.map +1 -0
- package/dist/media/Gallery.svelte +2881 -0
- package/dist/media/Gallery.svelte.d.ts +82 -0
- package/dist/media/Gallery.svelte.d.ts.map +1 -0
- package/dist/media/Image.svelte +389 -0
- package/dist/media/Image.svelte.d.ts +33 -0
- package/dist/media/Image.svelte.d.ts.map +1 -0
- package/dist/media/PDF.svelte +1793 -0
- package/dist/media/PDF.svelte.d.ts +44 -0
- package/dist/media/PDF.svelte.d.ts.map +1 -0
- package/dist/media/Panorama.svelte +1391 -0
- package/dist/media/Panorama.svelte.d.ts +47 -0
- package/dist/media/Panorama.svelte.d.ts.map +1 -0
- package/dist/media/Video.svelte +2501 -0
- package/dist/media/Video.svelte.d.ts +58 -0
- package/dist/media/Video.svelte.d.ts.map +1 -0
- package/dist/media/carousel.d.ts +211 -0
- package/dist/media/carousel.d.ts.map +1 -0
- package/dist/media/carousel.js +408 -0
- package/dist/media/index.d.ts +11 -0
- package/dist/media/index.d.ts.map +1 -0
- package/dist/media/index.js +5 -0
- package/dist/navigation/BottomSheet.svelte +636 -0
- package/dist/navigation/BottomSheet.svelte.d.ts +27 -0
- package/dist/navigation/BottomSheet.svelte.d.ts.map +1 -0
- package/dist/navigation/Breadcrumbs.svelte +611 -0
- package/dist/navigation/Breadcrumbs.svelte.d.ts +28 -0
- package/dist/navigation/Breadcrumbs.svelte.d.ts.map +1 -0
- package/dist/navigation/Pagination.svelte +641 -0
- package/dist/navigation/Pagination.svelte.d.ts +27 -0
- package/dist/navigation/Pagination.svelte.d.ts.map +1 -0
- package/dist/navigation/Steps.svelte +965 -0
- package/dist/navigation/Steps.svelte.d.ts +43 -0
- package/dist/navigation/Steps.svelte.d.ts.map +1 -0
- package/dist/navigation/Tabs.svelte +698 -0
- package/dist/navigation/Tabs.svelte.d.ts +41 -0
- package/dist/navigation/Tabs.svelte.d.ts.map +1 -0
- package/dist/navigation/index.d.ts +8 -0
- package/dist/navigation/index.d.ts.map +1 -0
- package/dist/navigation/index.js +5 -0
- 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>
|