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