@urbicon-ui/blocks 6.21.1 → 6.21.2
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/dist/components/NumberInput/NumberInput.svelte +197 -0
- package/dist/components/NumberInput/NumberInput.svelte.d.ts +4 -0
- package/dist/components/NumberInput/index.d.ts +55 -0
- package/dist/components/NumberInput/index.js +1 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.js +1 -0
- package/dist/i18n/index.d.ts +6 -0
- package/dist/primitives/Badge/Badge.svelte +12 -4
- package/dist/primitives/Badge/index.d.ts +26 -6
- package/dist/primitives/Breadcrumb/Breadcrumb.svelte +11 -6
- package/dist/primitives/Breadcrumb/index.d.ts +2 -2
- package/dist/primitives/Checkbox/Checkbox.svelte +3 -3
- package/dist/primitives/Combobox/Combobox.svelte +478 -104
- package/dist/primitives/Combobox/combobox.variants.d.ts +89 -17
- package/dist/primitives/Combobox/combobox.variants.js +94 -13
- package/dist/primitives/Combobox/index.d.ts +166 -51
- package/dist/primitives/ConfirmDialog/index.d.ts +19 -0
- package/dist/primitives/Dialog/Dialog.svelte +74 -0
- package/dist/primitives/Dialog/index.d.ts +12 -1
- package/dist/primitives/Drawer/Drawer.svelte +2 -1
- package/dist/primitives/Drawer/drawer.variants.d.ts +8 -0
- package/dist/primitives/Drawer/drawer.variants.js +45 -10
- package/dist/primitives/Drawer/index.d.ts +13 -4
- package/dist/primitives/FormField/FormField.svelte +6 -6
- package/dist/primitives/FormField/index.d.ts +13 -4
- package/dist/primitives/Input/Input.svelte +8 -4
- package/dist/primitives/Menu/Menu.svelte +66 -4
- package/dist/primitives/Menu/index.d.ts +25 -0
- package/dist/primitives/Pagination/Pagination.svelte +28 -13
- package/dist/primitives/Pagination/index.d.ts +44 -0
- package/dist/primitives/Popover/Popover.svelte +10 -0
- package/dist/primitives/Popover/index.d.ts +3 -3
- package/dist/primitives/RadioGroup/RadioGroup.svelte +3 -3
- package/dist/primitives/Select/Select.svelte +21 -11
- package/dist/primitives/Select/index.d.ts +7 -0
- package/dist/primitives/Select/select.variants.d.ts +17 -17
- package/dist/primitives/Select/select.variants.js +18 -0
- package/dist/primitives/Slider/Slider.svelte +4 -8
- package/dist/primitives/Textarea/Textarea.svelte +4 -5
- package/dist/primitives/Textarea/textarea.variants.d.ts +7 -7
- package/dist/primitives/Textarea/textarea.variants.js +5 -2
- package/dist/primitives/Toast/Toaster.svelte +42 -2
- package/dist/primitives/Toast/index.d.ts +30 -0
- package/dist/primitives/Toast/toast.store.svelte.d.ts +25 -1
- package/dist/primitives/Toast/toast.store.svelte.js +91 -1
- package/dist/primitives/Toast/toast.variants.d.ts +18 -0
- package/dist/primitives/Toast/toast.variants.js +15 -0
- package/dist/primitives/Toggle/Toggle.svelte +17 -3
- package/dist/primitives/Toggle/index.d.ts +8 -2
- package/dist/primitives/Toggle/toggle.variants.d.ts +7 -0
- package/dist/primitives/Toggle/toggle.variants.js +26 -0
- package/dist/primitives/Tooltip/Tooltip.svelte +47 -21
- package/dist/primitives/Tooltip/Tooltip.svelte.d.ts +1 -1
- package/dist/primitives/Tooltip/index.d.ts +31 -3
- package/dist/primitives/Tooltip/tooltip.variants.d.ts +4 -4
- package/dist/primitives/Tooltip/tooltip.variants.js +2 -2
- package/dist/primitives/index.d.ts +3 -3
- package/dist/translations/de.d.ts +3 -0
- package/dist/translations/de.js +4 -1
- package/dist/translations/en.d.ts +3 -0
- package/dist/translations/en.js +4 -1
- package/dist/utils/guide.svelte.d.ts +8 -2
- package/dist/utils/guide.svelte.js +86 -5
- package/dist/utils/use-form-field.svelte.d.ts +9 -9
- package/dist/utils/use-form-field.svelte.js +12 -12
- package/package.json +3 -3
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts" generics="T extends string | number | boolean = string">
|
|
2
2
|
import { useBlocksI18n, mintRegistry } from '../..';
|
|
3
3
|
import { tick } from 'svelte';
|
|
4
|
+
import { SvelteMap } from 'svelte/reactivity';
|
|
4
5
|
import { comboboxVariants, type ComboboxVariants } from './combobox.variants';
|
|
5
6
|
import { getBlocksConfig, resolveSlotClasses } from '../../provider';
|
|
6
7
|
import { resolveIcon } from '../../icons';
|
|
@@ -17,15 +18,23 @@
|
|
|
17
18
|
const CheckMarkIcon = resolveIcon('check', CheckIconDefault);
|
|
18
19
|
|
|
19
20
|
let {
|
|
20
|
-
options,
|
|
21
|
+
options = [],
|
|
22
|
+
groups,
|
|
21
23
|
value = $bindable(null),
|
|
22
24
|
query = $bindable(''),
|
|
25
|
+
multiple = false,
|
|
26
|
+
maxItems,
|
|
27
|
+
onRemoveTag,
|
|
28
|
+
customTag,
|
|
23
29
|
placeholder = 'Search…',
|
|
24
30
|
label,
|
|
25
31
|
helper,
|
|
26
32
|
error,
|
|
27
33
|
required = false,
|
|
28
34
|
filter,
|
|
35
|
+
queryFn,
|
|
36
|
+
debounceMs = 250,
|
|
37
|
+
loadingText = 'Loading…',
|
|
29
38
|
tier,
|
|
30
39
|
variant = 'outlined',
|
|
31
40
|
size = 'md',
|
|
@@ -35,6 +44,7 @@
|
|
|
35
44
|
noResultsText = 'No results found',
|
|
36
45
|
onValueChange,
|
|
37
46
|
open = $bindable(false),
|
|
47
|
+
onOpenChange,
|
|
38
48
|
customOption,
|
|
39
49
|
closeOnEscape = true,
|
|
40
50
|
closeOnClickOutside = true,
|
|
@@ -49,6 +59,13 @@
|
|
|
49
59
|
...restProps
|
|
50
60
|
}: ComboboxProps<T> = $props();
|
|
51
61
|
|
|
62
|
+
// The exported `ComboboxProps<T>` is a discriminated union over `multiple`, so
|
|
63
|
+
// externally `<Combobox multiple bind:value={x: T[]}>` and the single form
|
|
64
|
+
// narrow correctly. Internally we dispatch through a loose alias so the
|
|
65
|
+
// component can hand either shape into `onValueChange` without per-branch
|
|
66
|
+
// casts at every call site (mirrors Select).
|
|
67
|
+
const dispatchValueChange = $derived(onValueChange as ((v: unknown) => void) | undefined);
|
|
68
|
+
|
|
52
69
|
const tierCtx = getTierContext();
|
|
53
70
|
const effectiveTier = $derived(tier ?? tierCtx?.tier ?? 'modify');
|
|
54
71
|
|
|
@@ -57,7 +74,7 @@
|
|
|
57
74
|
const id = $derived(idProp ?? `combobox-${propsId}`);
|
|
58
75
|
const ff = useFormField(() => ({
|
|
59
76
|
fieldId: id,
|
|
60
|
-
|
|
77
|
+
helper,
|
|
61
78
|
error,
|
|
62
79
|
required,
|
|
63
80
|
disabled
|
|
@@ -82,12 +99,158 @@
|
|
|
82
99
|
|
|
83
100
|
const filterFn = $derived(filter ?? defaultFilter);
|
|
84
101
|
|
|
85
|
-
|
|
102
|
+
// ── Async / server-side search (queryFn) ──────────────────────────────
|
|
103
|
+
// Options resolved by the last settled `queryFn` call, and whether one is in
|
|
104
|
+
// flight. In async mode these replace `options`/`groups` entirely.
|
|
105
|
+
let asyncOptions = $state<ComboboxOption<T>[]>([]);
|
|
106
|
+
let loading = $state(false);
|
|
107
|
+
// Remembers the option the user picked so its label survives a later result
|
|
108
|
+
// set that no longer contains it (the classic async-combobox label-loss).
|
|
109
|
+
let selectedCache = $state<ComboboxOption<T> | null>(null);
|
|
110
|
+
|
|
111
|
+
// `groups` takes precedence over `options` (mirrors Select); in async mode the
|
|
112
|
+
// server-resolved list wins over both. Flattened for value lookup + keyboard nav.
|
|
113
|
+
const allOptions = $derived(
|
|
114
|
+
queryFn ? asyncOptions : groups ? groups.flatMap((g) => g.options) : options
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Single-mode selected option. `undefined` in multi mode, which makes every
|
|
118
|
+
// single-only path keyed on it (the label-restore effect, the query shortcut
|
|
119
|
+
// in `matchesQuery`, the click-outside/chevron label restore) inert without a
|
|
120
|
+
// per-site `!multiple` guard.
|
|
121
|
+
const selectedOption = $derived(
|
|
122
|
+
multiple
|
|
123
|
+
? undefined
|
|
124
|
+
: (allOptions.find((o) => o.value === value) ??
|
|
125
|
+
(selectedCache && selectedCache.value === value ? selectedCache : undefined))
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// ── Multi-select state (multiple) ─────────────────────────────────────────
|
|
129
|
+
// Selected values, normalized to an array regardless of what `value` holds
|
|
130
|
+
// (a stray `null`/non-array default binds to an empty selection).
|
|
131
|
+
const selectedValues = $derived<T[]>(multiple && Array.isArray(value) ? value : []);
|
|
132
|
+
|
|
133
|
+
// Remembers the option behind each picked value so its tag keeps its label
|
|
134
|
+
// when the option list changes (async results, or a filtered-out option) —
|
|
135
|
+
// the multi-select analogue of `selectedCache`.
|
|
136
|
+
const tagCache = new SvelteMap<T, ComboboxOption<T>>();
|
|
137
|
+
|
|
138
|
+
// Resolved options for the selected values, in selection order. Falls back to
|
|
139
|
+
// the cache, then to a bare `{ label: String(value) }` for an orphan value
|
|
140
|
+
// (bound but never in the options) so the tag still renders and stays
|
|
141
|
+
// removable rather than silently vanishing from the array.
|
|
142
|
+
//
|
|
143
|
+
// The dev-warn is gated to *sync* mode: with `queryFn`, a pre-bound value that
|
|
144
|
+
// isn't in the current result set is expected, not a bug (there is no API to
|
|
145
|
+
// seed labels for it), so warning there would cry wolf on a legitimate pattern.
|
|
146
|
+
// See technical-debt (async pre-selected labels + warn dedup).
|
|
147
|
+
const selectedTags = $derived.by<ComboboxOption<T>[]>(() =>
|
|
148
|
+
selectedValues.map((v) => {
|
|
149
|
+
const found = allOptions.find((o) => o.value === v) ?? tagCache.get(v);
|
|
150
|
+
if (found) return found;
|
|
151
|
+
if (!queryFn && import.meta.env?.DEV) {
|
|
152
|
+
console.warn(
|
|
153
|
+
`[Combobox] value ${JSON.stringify(v)} has no matching option — tag falls back to its raw value.`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return { label: String(v), value: v } satisfies ComboboxOption<T>;
|
|
157
|
+
})
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const isSelected = (opt: ComboboxOption<T>): boolean =>
|
|
161
|
+
multiple ? selectedValues.includes(opt.value) : opt.value === value;
|
|
162
|
+
|
|
163
|
+
// At the max-items cap, options that aren't already selected can't be added.
|
|
164
|
+
const atCap = $derived(multiple && maxItems != null && selectedValues.length >= maxItems);
|
|
165
|
+
|
|
166
|
+
// An option is effectively disabled when it declares `disabled`, or when the
|
|
167
|
+
// cap is reached and it isn't already selected (so it can't be added, but a
|
|
168
|
+
// selected one can still be toggled off). Drives both keyboard-nav skipping
|
|
169
|
+
// and the rendered `disabled`/`aria-disabled`.
|
|
170
|
+
const isOptionDisabled = (opt: ComboboxOption<T> | undefined): boolean =>
|
|
171
|
+
!!opt && (!!opt.disabled || (atCap && !isSelected(opt)));
|
|
172
|
+
|
|
173
|
+
// Whether the clear affordance is shown (and what it resets). Single mode uses
|
|
174
|
+
// `value != null` (not a truthy check) so a legitimately-selected falsy value
|
|
175
|
+
// — `0`, `''`, `false` under `T extends string | number | boolean` — is still
|
|
176
|
+
// clearable rather than stranding the field with no way to reset it.
|
|
177
|
+
const hasValue = $derived(multiple ? selectedValues.length > 0 : value != null);
|
|
178
|
+
|
|
179
|
+
// In multi mode the placeholder is suppressed once there are tags — sitting a
|
|
180
|
+
// "Search…" hint next to chips reads as clutter. Single mode always shows it.
|
|
181
|
+
const effectivePlaceholder = $derived(multiple && selectedValues.length > 0 ? '' : placeholder);
|
|
182
|
+
|
|
183
|
+
// Does an option survive the current query? In async mode the server already
|
|
184
|
+
// filtered, so everything shows. Otherwise the same rules the flat list always
|
|
185
|
+
// used: an empty query and a field still showing the selected label both show
|
|
186
|
+
// everything; else the (custom) filter decides.
|
|
187
|
+
const matchesQuery = (opt: ComboboxOption<T>): boolean => {
|
|
188
|
+
if (queryFn) return true;
|
|
189
|
+
if (selectedOption && query === selectedOption.label) return true;
|
|
190
|
+
if (!query.trim()) return true;
|
|
191
|
+
return filterFn(opt, query.trim());
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// Grouped view for rendering: each group's surviving options, empty groups
|
|
195
|
+
// dropped. `null` for a flat `options` list or async mode (server results are flat).
|
|
196
|
+
const filteredGroups = $derived.by(() => {
|
|
197
|
+
if (!groups || queryFn) return null;
|
|
198
|
+
return groups
|
|
199
|
+
.map((g) => ({ label: g.label, options: g.options.filter(matchesQuery) }))
|
|
200
|
+
.filter((g) => g.options.length > 0);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Flat list backing keyboard nav + aria-activedescendant. Kept in lockstep
|
|
204
|
+
// with `filteredGroups` (same predicate, same option refs) so the virtual
|
|
205
|
+
// cursor index always addresses a rendered option.
|
|
206
|
+
const filtered = $derived(
|
|
207
|
+
filteredGroups ? filteredGroups.flatMap((g) => g.options) : allOptions.filter(matchesQuery)
|
|
208
|
+
);
|
|
86
209
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
210
|
+
// Debounced query runner. The effect tracks `query` (+ debounceMs); each change
|
|
211
|
+
// resets the timer, and the fetch runs a tick later so bursty typing collapses
|
|
212
|
+
// into one request. A per-run AbortController lets a newer query cancel an
|
|
213
|
+
// older in-flight one, so a slow stale response can't overwrite a fresh result.
|
|
214
|
+
let inFlight: AbortController | undefined;
|
|
215
|
+
$effect(() => {
|
|
216
|
+
// Gated on `open` so no request fires in the background before the user
|
|
217
|
+
// engages the field; opening (focus) searches with the current query.
|
|
218
|
+
if (!queryFn || !open) {
|
|
219
|
+
loading = false;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const q = query;
|
|
223
|
+
// Mark loading *synchronously*: a search is pending the moment the query
|
|
224
|
+
// changes, not only once the debounced fetch starts. Otherwise the empty
|
|
225
|
+
// `asyncOptions` would render the "no results" row for the whole debounce
|
|
226
|
+
// window before "loading" appears — the opposite of the truth.
|
|
227
|
+
loading = true;
|
|
228
|
+
const timer = setTimeout(() => {
|
|
229
|
+
inFlight?.abort();
|
|
230
|
+
const controller = new AbortController();
|
|
231
|
+
inFlight = controller;
|
|
232
|
+
queryFn(q, controller.signal)
|
|
233
|
+
.then((result) => {
|
|
234
|
+
if (controller.signal.aborted) return;
|
|
235
|
+
asyncOptions = result;
|
|
236
|
+
activeIndex = -1;
|
|
237
|
+
})
|
|
238
|
+
.catch((err: unknown) => {
|
|
239
|
+
if (controller.signal.aborted || (err as { name?: string })?.name === 'AbortError')
|
|
240
|
+
return;
|
|
241
|
+
if (import.meta.env?.DEV) console.warn('[Combobox] queryFn rejected:', err);
|
|
242
|
+
})
|
|
243
|
+
.finally(() => {
|
|
244
|
+
if (!controller.signal.aborted) loading = false;
|
|
245
|
+
});
|
|
246
|
+
}, debounceMs);
|
|
247
|
+
// Abort the in-flight request as well as clearing the timer, so a
|
|
248
|
+
// superseded / closed / unmounted search doesn't run to completion in the
|
|
249
|
+
// background — the whole point of threading the AbortSignal through.
|
|
250
|
+
return () => {
|
|
251
|
+
clearTimeout(timer);
|
|
252
|
+
inFlight?.abort();
|
|
253
|
+
};
|
|
91
254
|
});
|
|
92
255
|
|
|
93
256
|
const variantProps: ComboboxVariants = $derived({
|
|
@@ -130,37 +293,104 @@
|
|
|
130
293
|
suppressFocusOpen = false;
|
|
131
294
|
}
|
|
132
295
|
|
|
296
|
+
// Single mutation point for internally-driven open changes, so
|
|
297
|
+
// `onOpenChange` fires exactly once per transition (and never when the
|
|
298
|
+
// consumer writes `bind:open` directly). The no-change guard also keeps
|
|
299
|
+
// repeat opens quiet — e.g. typing while already open (`handleInput`) or
|
|
300
|
+
// a focus event on an already-open field (`handleFocus`).
|
|
301
|
+
function setOpen(next: boolean) {
|
|
302
|
+
if (open === next) return;
|
|
303
|
+
open = next;
|
|
304
|
+
onOpenChange?.(next);
|
|
305
|
+
}
|
|
306
|
+
|
|
133
307
|
function select(opt: ComboboxOption<T>) {
|
|
134
308
|
if (opt.disabled) return;
|
|
135
309
|
value = opt.value;
|
|
136
310
|
query = opt.label;
|
|
137
|
-
|
|
311
|
+
selectedCache = opt;
|
|
312
|
+
setOpen(false);
|
|
138
313
|
activeIndex = -1;
|
|
139
|
-
onValueChange
|
|
314
|
+
// Dispatch through the loose alias — the union's `onValueChange` param type
|
|
315
|
+
// is the contravariant intersection `(T | null) & T[]`, which nothing is
|
|
316
|
+
// assignable to; the alias erases the mode split (mirrors Select).
|
|
317
|
+
dispatchValueChange?.(opt.value);
|
|
140
318
|
focusInputWithoutOpening();
|
|
141
319
|
}
|
|
142
320
|
|
|
143
321
|
function clear() {
|
|
322
|
+
if (multiple) {
|
|
323
|
+
// Clear all tags. `onRemoveTag` is reserved for single-value removals
|
|
324
|
+
// (× / Backspace / toggle-off); a bulk clear signals through
|
|
325
|
+
// `onValueChange([])` only. The listbox open state is left untouched.
|
|
326
|
+
tagCache.clear();
|
|
327
|
+
value = [] as T[];
|
|
328
|
+
query = '';
|
|
329
|
+
activeIndex = -1;
|
|
330
|
+
dispatchValueChange?.([]);
|
|
331
|
+
focusInputWithoutOpening();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
144
334
|
value = null;
|
|
145
335
|
query = '';
|
|
146
|
-
|
|
336
|
+
setOpen(false);
|
|
147
337
|
activeIndex = -1;
|
|
148
|
-
|
|
338
|
+
dispatchValueChange?.(null);
|
|
339
|
+
focusInputWithoutOpening();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Add or remove a value in multi mode. Selecting keeps the listbox open (so
|
|
343
|
+
// several picks flow without re-opening) and clears the query to search the
|
|
344
|
+
// next one; toggling an already-selected option off removes its tag.
|
|
345
|
+
function toggleValue(opt: ComboboxOption<T>) {
|
|
346
|
+
if (opt.disabled) return;
|
|
347
|
+
const values = [...selectedValues];
|
|
348
|
+
const idx = values.indexOf(opt.value);
|
|
349
|
+
if (idx === -1) {
|
|
350
|
+
// Adding — respect the cap. (A selected option is never blocked here.)
|
|
351
|
+
if (maxItems != null && values.length >= maxItems) return;
|
|
352
|
+
values.push(opt.value);
|
|
353
|
+
tagCache.set(opt.value, opt);
|
|
354
|
+
value = values;
|
|
355
|
+
dispatchValueChange?.(values);
|
|
356
|
+
} else {
|
|
357
|
+
values.splice(idx, 1);
|
|
358
|
+
tagCache.delete(opt.value);
|
|
359
|
+
value = values;
|
|
360
|
+
onRemoveTag?.(opt.value);
|
|
361
|
+
dispatchValueChange?.(values);
|
|
362
|
+
}
|
|
363
|
+
query = '';
|
|
364
|
+
activeIndex = -1;
|
|
365
|
+
focusInputWithoutOpening();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Remove a single tag (× button or Backspace). Does not change the open state.
|
|
369
|
+
function removeTag(v: T) {
|
|
370
|
+
if (disabled) return;
|
|
371
|
+
const values = selectedValues.filter((x) => x !== v);
|
|
372
|
+
tagCache.delete(v);
|
|
373
|
+
value = values;
|
|
374
|
+
onRemoveTag?.(v);
|
|
375
|
+
dispatchValueChange?.(values);
|
|
149
376
|
focusInputWithoutOpening();
|
|
150
377
|
}
|
|
151
378
|
|
|
152
379
|
function handleInput() {
|
|
153
|
-
|
|
380
|
+
setOpen(true);
|
|
154
381
|
activeIndex = -1;
|
|
155
|
-
|
|
382
|
+
// Single mode: editing the query away from the selected label clears the
|
|
383
|
+
// selection. Multi mode leaves the array alone — the query is transient
|
|
384
|
+
// search text, independent of the tags.
|
|
385
|
+
if (!multiple && value && query !== selectedOption?.label) {
|
|
156
386
|
value = null;
|
|
157
|
-
|
|
387
|
+
dispatchValueChange?.(null);
|
|
158
388
|
}
|
|
159
389
|
}
|
|
160
390
|
|
|
161
391
|
function handleFocus() {
|
|
162
392
|
if (suppressFocusOpen) return;
|
|
163
|
-
if (!disabled)
|
|
393
|
+
if (!disabled) setOpen(true);
|
|
164
394
|
}
|
|
165
395
|
|
|
166
396
|
// Chevron toggle. The input opens on focus but has no way to close itself
|
|
@@ -171,12 +401,15 @@
|
|
|
171
401
|
function toggleOpen() {
|
|
172
402
|
if (disabled) return;
|
|
173
403
|
if (open) {
|
|
174
|
-
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
404
|
+
setOpen(false);
|
|
405
|
+
// Multi: drop the transient search text so a leftover filter doesn't
|
|
406
|
+
// linger. Single: restore the selected label if the query was left
|
|
407
|
+
// dangling (mirrors the click-outside path), so the field doesn't read
|
|
408
|
+
// as blank after closing.
|
|
409
|
+
if (multiple) query = '';
|
|
410
|
+
else if (!value && selectedOption) query = selectedOption.label;
|
|
178
411
|
} else {
|
|
179
|
-
|
|
412
|
+
setOpen(true);
|
|
180
413
|
inputEl?.focus();
|
|
181
414
|
}
|
|
182
415
|
}
|
|
@@ -196,13 +429,15 @@
|
|
|
196
429
|
case 'ArrowDown': {
|
|
197
430
|
event.preventDefault();
|
|
198
431
|
if (!open) {
|
|
199
|
-
|
|
432
|
+
setOpen(true);
|
|
200
433
|
return;
|
|
201
434
|
}
|
|
202
435
|
const next = activeIndex + 1;
|
|
203
436
|
if (next < filtered.length) {
|
|
204
437
|
activeIndex = next;
|
|
205
|
-
|
|
438
|
+
// Skip disabled / at-cap options (see isOptionDisabled).
|
|
439
|
+
while (activeIndex < filtered.length && isOptionDisabled(filtered[activeIndex]))
|
|
440
|
+
activeIndex++;
|
|
206
441
|
if (activeIndex >= filtered.length) activeIndex = filtered.length - 1;
|
|
207
442
|
}
|
|
208
443
|
scrollToActive();
|
|
@@ -211,13 +446,13 @@
|
|
|
211
446
|
case 'ArrowUp': {
|
|
212
447
|
event.preventDefault();
|
|
213
448
|
if (!open) {
|
|
214
|
-
|
|
449
|
+
setOpen(true);
|
|
215
450
|
return;
|
|
216
451
|
}
|
|
217
452
|
const prev = activeIndex - 1;
|
|
218
453
|
if (prev >= 0) {
|
|
219
454
|
activeIndex = prev;
|
|
220
|
-
while (activeIndex >= 0 && filtered[activeIndex]
|
|
455
|
+
while (activeIndex >= 0 && isOptionDisabled(filtered[activeIndex])) activeIndex--;
|
|
221
456
|
if (activeIndex < 0) activeIndex = 0;
|
|
222
457
|
}
|
|
223
458
|
scrollToActive();
|
|
@@ -225,8 +460,25 @@
|
|
|
225
460
|
}
|
|
226
461
|
case 'Enter': {
|
|
227
462
|
event.preventDefault();
|
|
228
|
-
if (
|
|
229
|
-
|
|
463
|
+
if (
|
|
464
|
+
open &&
|
|
465
|
+
activeIndex >= 0 &&
|
|
466
|
+
filtered[activeIndex] &&
|
|
467
|
+
!isOptionDisabled(filtered[activeIndex])
|
|
468
|
+
) {
|
|
469
|
+
const opt = filtered[activeIndex];
|
|
470
|
+
if (multiple) toggleValue(opt);
|
|
471
|
+
else select(opt);
|
|
472
|
+
}
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
case 'Backspace': {
|
|
476
|
+
// Multi: Backspace on an empty query removes the last tag (standard
|
|
477
|
+
// tokenizer UX). Only when the query is empty, so it never eats a real
|
|
478
|
+
// character delete.
|
|
479
|
+
if (multiple && query === '' && selectedValues.length > 0) {
|
|
480
|
+
event.preventDefault();
|
|
481
|
+
removeTag(selectedValues[selectedValues.length - 1]);
|
|
230
482
|
}
|
|
231
483
|
break;
|
|
232
484
|
}
|
|
@@ -234,7 +486,7 @@
|
|
|
234
486
|
if (!open) break;
|
|
235
487
|
if (!closeOnEscape) break;
|
|
236
488
|
event.preventDefault();
|
|
237
|
-
|
|
489
|
+
setOpen(false);
|
|
238
490
|
activeIndex = -1;
|
|
239
491
|
onEscape?.();
|
|
240
492
|
break;
|
|
@@ -265,8 +517,10 @@
|
|
|
265
517
|
// close-then-reopen. The listbox lives in the top layer (separate subtree).
|
|
266
518
|
if (!wrapperEl?.contains(target) && !listboxEl?.contains(target)) {
|
|
267
519
|
if (!closeOnClickOutside) return;
|
|
268
|
-
|
|
269
|
-
if (
|
|
520
|
+
setOpen(false);
|
|
521
|
+
if (multiple) {
|
|
522
|
+
query = '';
|
|
523
|
+
} else if (!value && selectedOption) {
|
|
270
524
|
query = selectedOption.label;
|
|
271
525
|
}
|
|
272
526
|
onClickOutside?.();
|
|
@@ -334,29 +588,71 @@
|
|
|
334
588
|
? (slotClasses?.inputWrapper ?? '')
|
|
335
589
|
: styles.inputWrapper({ class: slotClasses?.inputWrapper })}
|
|
336
590
|
>
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
591
|
+
{#if multiple}
|
|
592
|
+
<!--
|
|
593
|
+
Multi-select tokenizer frame. The visible border/background/focus-ring
|
|
594
|
+
lives on this `control` div (not the borderless search input inside it),
|
|
595
|
+
so selected tags and the search field share one bordered box that grows
|
|
596
|
+
as tags wrap. Single mode keeps the input-as-frame layout untouched.
|
|
597
|
+
-->
|
|
598
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
599
|
+
<div
|
|
600
|
+
class={unstyled
|
|
601
|
+
? (slotClasses?.control ?? '')
|
|
602
|
+
: styles.control({ class: slotClasses?.control })}
|
|
603
|
+
onmousedown={(e) => {
|
|
604
|
+
// Clicking the empty frame area (not a tag or the input) focuses the
|
|
605
|
+
// input; `preventDefault` keeps focus from leaving on the mousedown.
|
|
606
|
+
if (e.target === e.currentTarget) {
|
|
607
|
+
e.preventDefault();
|
|
608
|
+
inputEl?.focus();
|
|
609
|
+
}
|
|
610
|
+
}}
|
|
611
|
+
>
|
|
612
|
+
<!--
|
|
613
|
+
Keyed with a positional suffix (matching the hidden inputs below), not
|
|
614
|
+
the bare value: a malformed bound array with a repeated value would
|
|
615
|
+
otherwise throw `each_key_duplicate`. Internal mutations never create
|
|
616
|
+
duplicates (toggleValue guards on indexOf), so for valid input this is
|
|
617
|
+
a stable per-position key.
|
|
618
|
+
-->
|
|
619
|
+
{#each selectedTags as opt, i (`${String(opt.value)}-${i}`)}
|
|
620
|
+
{#if customTag}
|
|
621
|
+
{@render customTag(opt, () => removeTag(opt.value))}
|
|
622
|
+
{:else}
|
|
623
|
+
<span
|
|
624
|
+
class={unstyled ? (slotClasses?.tag ?? '') : styles.tag({ class: slotClasses?.tag })}
|
|
625
|
+
>
|
|
626
|
+
<span
|
|
627
|
+
class={unstyled
|
|
628
|
+
? (slotClasses?.tagLabel ?? '')
|
|
629
|
+
: styles.tagLabel({ class: slotClasses?.tagLabel })}>{opt.label}</span
|
|
630
|
+
>
|
|
631
|
+
<button
|
|
632
|
+
type="button"
|
|
633
|
+
{disabled}
|
|
634
|
+
class={unstyled
|
|
635
|
+
? (slotClasses?.tagRemove ?? '')
|
|
636
|
+
: styles.tagRemove({ class: slotClasses?.tagRemove })}
|
|
637
|
+
onclick={() => removeTag(opt.value)}
|
|
638
|
+
aria-label={bt('accessibility.removeTag', { label: opt.label })}
|
|
639
|
+
>
|
|
640
|
+
<CloseIcon />
|
|
641
|
+
</button>
|
|
642
|
+
</span>
|
|
643
|
+
{/if}
|
|
644
|
+
{/each}
|
|
645
|
+
{@render searchField(
|
|
646
|
+
unstyled ? (slotClasses?.search ?? '') : styles.search({ class: slotClasses?.search })
|
|
647
|
+
)}
|
|
648
|
+
</div>
|
|
649
|
+
{:else}
|
|
650
|
+
{@render searchField(
|
|
651
|
+
unstyled ? (slotClasses?.input ?? '') : styles.input({ class: slotClasses?.input })
|
|
652
|
+
)}
|
|
653
|
+
{/if}
|
|
654
|
+
|
|
655
|
+
{#if clearable && hasValue}
|
|
360
656
|
<button
|
|
361
657
|
type="button"
|
|
362
658
|
{disabled}
|
|
@@ -389,11 +685,19 @@
|
|
|
389
685
|
{/if}
|
|
390
686
|
|
|
391
687
|
{#if name}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
688
|
+
{#if multiple}
|
|
689
|
+
<!-- One hidden input per selected value, so the form payload shape is
|
|
690
|
+
consistent (zero-or-more) regardless of how many tags are picked. -->
|
|
691
|
+
{#each selectedTags as opt, i (`${String(opt.value)}-${i}`)}
|
|
692
|
+
<input type="hidden" {name} value={String(opt.value)} />
|
|
693
|
+
{/each}
|
|
694
|
+
{:else}
|
|
695
|
+
<input
|
|
696
|
+
type="hidden"
|
|
697
|
+
{name}
|
|
698
|
+
value={value === null || value === undefined ? '' : String(value)}
|
|
699
|
+
/>
|
|
700
|
+
{/if}
|
|
397
701
|
{/if}
|
|
398
702
|
|
|
399
703
|
<!--
|
|
@@ -415,6 +719,7 @@
|
|
|
415
719
|
bind:this={listboxEl}
|
|
416
720
|
id={listboxId}
|
|
417
721
|
role="listbox"
|
|
722
|
+
aria-multiselectable={multiple || undefined}
|
|
418
723
|
popover={panel.topLayer ? 'manual' : null}
|
|
419
724
|
tabindex={-1}
|
|
420
725
|
class={unstyled
|
|
@@ -427,7 +732,16 @@
|
|
|
427
732
|
style:display={floatingPanelHidden(panel, open) ? 'none' : null}
|
|
428
733
|
>
|
|
429
734
|
{#if open}
|
|
430
|
-
{#if
|
|
735
|
+
{#if loading}
|
|
736
|
+
<div
|
|
737
|
+
class={unstyled
|
|
738
|
+
? (slotClasses?.loading ?? '')
|
|
739
|
+
: styles.loading({ class: slotClasses?.loading })}
|
|
740
|
+
role="status"
|
|
741
|
+
>
|
|
742
|
+
{loadingText}
|
|
743
|
+
</div>
|
|
744
|
+
{:else if filtered.length === 0}
|
|
431
745
|
<div
|
|
432
746
|
class={unstyled
|
|
433
747
|
? (slotClasses?.noResults ?? '')
|
|
@@ -435,51 +749,30 @@
|
|
|
435
749
|
>
|
|
436
750
|
{noResultsText}
|
|
437
751
|
</div>
|
|
438
|
-
{:else}
|
|
439
|
-
{#each
|
|
440
|
-
|
|
441
|
-
{@const isSelected = opt.value === value}
|
|
442
|
-
<button
|
|
443
|
-
id="{listboxId}-option-{opt.value}"
|
|
444
|
-
type="button"
|
|
445
|
-
role="option"
|
|
446
|
-
aria-selected={isSelected}
|
|
447
|
-
aria-disabled={opt.disabled}
|
|
448
|
-
data-active={isActive}
|
|
449
|
-
disabled={opt.disabled}
|
|
752
|
+
{:else if filteredGroups}
|
|
753
|
+
{#each filteredGroups as group (group.label)}
|
|
754
|
+
<div
|
|
450
755
|
class={unstyled
|
|
451
|
-
?
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
]
|
|
456
|
-
.filter(Boolean)
|
|
457
|
-
.join(' ')
|
|
458
|
-
: styles.option({
|
|
459
|
-
class: [
|
|
460
|
-
slotClasses?.option,
|
|
461
|
-
isActive
|
|
462
|
-
? styles.optionActive({ class: slotClasses?.optionActive })
|
|
463
|
-
: undefined,
|
|
464
|
-
isSelected
|
|
465
|
-
? styles.optionSelected({ class: slotClasses?.optionSelected })
|
|
466
|
-
: undefined
|
|
467
|
-
].filter(Boolean)
|
|
468
|
-
})}
|
|
469
|
-
onclick={() => select(opt)}
|
|
470
|
-
onmouseenter={() => {
|
|
471
|
-
activeIndex = i;
|
|
472
|
-
}}
|
|
756
|
+
? (slotClasses?.group ?? '')
|
|
757
|
+
: styles.group({ class: slotClasses?.group })}
|
|
758
|
+
role="group"
|
|
759
|
+
aria-label={group.label}
|
|
473
760
|
>
|
|
474
|
-
|
|
475
|
-
{
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
{
|
|
482
|
-
|
|
761
|
+
<div
|
|
762
|
+
class={unstyled
|
|
763
|
+
? (slotClasses?.groupLabel ?? '')
|
|
764
|
+
: styles.groupLabel({ class: slotClasses?.groupLabel })}
|
|
765
|
+
>
|
|
766
|
+
{group.label}
|
|
767
|
+
</div>
|
|
768
|
+
{#each group.options as opt (opt.value)}
|
|
769
|
+
{@render optionButton(opt, filtered.indexOf(opt))}
|
|
770
|
+
{/each}
|
|
771
|
+
</div>
|
|
772
|
+
{/each}
|
|
773
|
+
{:else}
|
|
774
|
+
{#each filtered as opt, i (opt.value)}
|
|
775
|
+
{@render optionButton(opt, i)}
|
|
483
776
|
{/each}
|
|
484
777
|
{/if}
|
|
485
778
|
{/if}
|
|
@@ -496,12 +789,93 @@
|
|
|
496
789
|
>
|
|
497
790
|
{error}
|
|
498
791
|
</div>
|
|
499
|
-
{:else if ff.
|
|
792
|
+
{:else if ff.helperId}
|
|
500
793
|
<div
|
|
501
|
-
id={ff.
|
|
502
|
-
class={unstyled ? (slotClasses?.
|
|
794
|
+
id={ff.helperId}
|
|
795
|
+
class={unstyled ? (slotClasses?.helper ?? '') : styles.helper({ class: slotClasses?.helper })}
|
|
503
796
|
>
|
|
504
797
|
{helper}
|
|
505
798
|
</div>
|
|
506
799
|
{/if}
|
|
507
800
|
</div>
|
|
801
|
+
|
|
802
|
+
<!--
|
|
803
|
+
Single option renderer, shared by the flat and grouped listbox paths. `i` is
|
|
804
|
+
the option's index in the flattened `filtered` array — the same index the
|
|
805
|
+
keyboard cursor (`activeIndex`) and `aria-activedescendant` address, so a
|
|
806
|
+
grouped option highlights and selects identically to a flat one.
|
|
807
|
+
-->
|
|
808
|
+
{#snippet optionButton(opt: ComboboxOption<T>, i: number)}
|
|
809
|
+
{@const isActive = i === activeIndex}
|
|
810
|
+
{@const selected = isSelected(opt)}
|
|
811
|
+
{@const optDisabled = isOptionDisabled(opt)}
|
|
812
|
+
<button
|
|
813
|
+
id="{listboxId}-option-{opt.value}"
|
|
814
|
+
type="button"
|
|
815
|
+
role="option"
|
|
816
|
+
aria-selected={selected}
|
|
817
|
+
aria-disabled={optDisabled || undefined}
|
|
818
|
+
data-active={isActive}
|
|
819
|
+
disabled={optDisabled}
|
|
820
|
+
class={unstyled
|
|
821
|
+
? [
|
|
822
|
+
slotClasses?.option,
|
|
823
|
+
isActive ? slotClasses?.optionActive : undefined,
|
|
824
|
+
selected ? slotClasses?.optionSelected : undefined
|
|
825
|
+
]
|
|
826
|
+
.filter(Boolean)
|
|
827
|
+
.join(' ')
|
|
828
|
+
: styles.option({
|
|
829
|
+
class: [
|
|
830
|
+
slotClasses?.option,
|
|
831
|
+
isActive ? styles.optionActive({ class: slotClasses?.optionActive }) : undefined,
|
|
832
|
+
selected ? styles.optionSelected({ class: slotClasses?.optionSelected }) : undefined
|
|
833
|
+
].filter(Boolean)
|
|
834
|
+
})}
|
|
835
|
+
onclick={() => (multiple ? toggleValue(opt) : select(opt))}
|
|
836
|
+
onmouseenter={() => {
|
|
837
|
+
if (!optDisabled) activeIndex = i;
|
|
838
|
+
}}
|
|
839
|
+
>
|
|
840
|
+
{#if customOption}
|
|
841
|
+
{@render customOption(opt, selected)}
|
|
842
|
+
{:else}
|
|
843
|
+
<span class="flex-1 truncate">{opt.label}</span>
|
|
844
|
+
{#if selected}
|
|
845
|
+
<CheckMarkIcon class="text-primary h-4 w-4 shrink-0" />
|
|
846
|
+
{/if}
|
|
847
|
+
{/if}
|
|
848
|
+
</button>
|
|
849
|
+
{/snippet}
|
|
850
|
+
|
|
851
|
+
<!--
|
|
852
|
+
The single search input, shared by both modes. Single mode styles it as the
|
|
853
|
+
full bordered frame (`input` slot); multi mode renders it borderless (`search`
|
|
854
|
+
slot) inside the tokenizer `control`. Factored into a snippet so the ARIA
|
|
855
|
+
wiring lives in one place. `required` is single-mode only — in multi the input
|
|
856
|
+
is transient search text (cleared after each pick), so a native `required` on
|
|
857
|
+
it would wrongly block submit even with tags selected.
|
|
858
|
+
-->
|
|
859
|
+
{#snippet searchField(cls: string)}
|
|
860
|
+
<input
|
|
861
|
+
bind:this={inputEl}
|
|
862
|
+
id={ff.fieldId}
|
|
863
|
+
type="text"
|
|
864
|
+
role="combobox"
|
|
865
|
+
aria-expanded={open}
|
|
866
|
+
aria-controls={listboxId}
|
|
867
|
+
aria-activedescendant={activeDescendant}
|
|
868
|
+
aria-autocomplete="list"
|
|
869
|
+
aria-invalid={ff.invalid ? 'true' : undefined}
|
|
870
|
+
aria-describedby={ff.describedBy}
|
|
871
|
+
autocomplete="off"
|
|
872
|
+
class={cls}
|
|
873
|
+
placeholder={effectivePlaceholder}
|
|
874
|
+
{disabled}
|
|
875
|
+
required={(!multiple && ff.required) || undefined}
|
|
876
|
+
bind:value={query}
|
|
877
|
+
oninput={handleInput}
|
|
878
|
+
onfocus={handleFocus}
|
|
879
|
+
onkeydown={handleKeydown}
|
|
880
|
+
/>
|
|
881
|
+
{/snippet}
|