@urbicon-ui/blocks 6.21.0 → 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.
Files changed (142) hide show
  1. package/dist/components/Calendar/calendar.variants.d.ts +132 -419
  2. package/dist/components/CommandPalette/commandPalette.variants.d.ts +24 -51
  3. package/dist/components/CompositionBar/CompositionBar.svelte +6 -1
  4. package/dist/components/CompositionBar/composition-bar.variants.d.ts +23 -83
  5. package/dist/components/CompositionBar/composition-bar.variants.js +9 -3
  6. package/dist/components/EmptyState/emptyState.variants.d.ts +11 -27
  7. package/dist/components/FileUpload/fileUpload.variants.d.ts +24 -93
  8. package/dist/components/Guide/GuidePanel.svelte +38 -0
  9. package/dist/components/Guide/guide.variants.d.ts +69 -124
  10. package/dist/components/Guide/guide.variants.js +6 -1
  11. package/dist/components/NumberInput/NumberInput.svelte +197 -0
  12. package/dist/components/NumberInput/NumberInput.svelte.d.ts +4 -0
  13. package/dist/components/NumberInput/index.d.ts +55 -0
  14. package/dist/components/NumberInput/index.js +1 -0
  15. package/dist/components/Planner/planner.variants.d.ts +23 -94
  16. package/dist/components/Sankey/sankey.variants.d.ts +15 -48
  17. package/dist/components/SidebarLayout/sidebar-layout.variants.d.ts +10 -38
  18. package/dist/components/SidebarLayout/sidebar-layout.variants.js +12 -2
  19. package/dist/components/ThemeSwitcher/themeSwitcher.variants.d.ts +9 -36
  20. package/dist/components/index.d.ts +2 -0
  21. package/dist/components/index.js +1 -0
  22. package/dist/i18n/index.d.ts +8 -372
  23. package/dist/internal/charts/variants.d.ts +18 -31
  24. package/dist/primitives/Accordion/Accordion.svelte +11 -0
  25. package/dist/primitives/Accordion/AccordionItem.svelte +9 -3
  26. package/dist/primitives/Accordion/accordion.variants.d.ts +12 -43
  27. package/dist/primitives/Accordion/accordion.variants.js +6 -2
  28. package/dist/primitives/Accordion/index.d.ts +12 -0
  29. package/dist/primitives/Alert/alert.variants.d.ts +21 -62
  30. package/dist/primitives/Avatar/avatar.variants.d.ts +18 -138
  31. package/dist/primitives/Badge/Badge.svelte +12 -4
  32. package/dist/primitives/Badge/badge.variants.d.ts +19 -92
  33. package/dist/primitives/Badge/badge.variants.js +11 -4
  34. package/dist/primitives/Badge/index.d.ts +26 -6
  35. package/dist/primitives/Breadcrumb/Breadcrumb.svelte +11 -6
  36. package/dist/primitives/Breadcrumb/breadcrumb.variants.d.ts +13 -40
  37. package/dist/primitives/Breadcrumb/index.d.ts +2 -2
  38. package/dist/primitives/Button/button.variants.d.ts +19 -98
  39. package/dist/primitives/Button/button.variants.js +13 -9
  40. package/dist/primitives/ButtonGroup/buttongroup.variants.d.ts +9 -29
  41. package/dist/primitives/ButtonGroup/buttongroup.variants.js +3 -1
  42. package/dist/primitives/Card/card.variants.d.ts +14 -81
  43. package/dist/primitives/Checkbox/Checkbox.svelte +3 -3
  44. package/dist/primitives/Checkbox/checkbox.variants.d.ts +18 -77
  45. package/dist/primitives/Collapsible/Collapsible.svelte +10 -0
  46. package/dist/primitives/Collapsible/collapsible.variants.d.ts +11 -40
  47. package/dist/primitives/Collapsible/collapsible.variants.js +7 -2
  48. package/dist/primitives/Collapsible/index.d.ts +16 -0
  49. package/dist/primitives/Combobox/Combobox.svelte +478 -104
  50. package/dist/primitives/Combobox/combobox.variants.d.ts +112 -94
  51. package/dist/primitives/Combobox/combobox.variants.js +94 -13
  52. package/dist/primitives/Combobox/index.d.ts +166 -51
  53. package/dist/primitives/ConfirmDialog/index.d.ts +19 -0
  54. package/dist/primitives/Dialog/Dialog.svelte +95 -10
  55. package/dist/primitives/Dialog/dialog.variants.d.ts +15 -54
  56. package/dist/primitives/Dialog/index.d.ts +12 -1
  57. package/dist/primitives/Drawer/Drawer.svelte +23 -11
  58. package/dist/primitives/Drawer/drawer.variants.d.ts +22 -49
  59. package/dist/primitives/Drawer/drawer.variants.js +45 -10
  60. package/dist/primitives/Drawer/index.d.ts +13 -4
  61. package/dist/primitives/FormField/FormField.svelte +6 -6
  62. package/dist/primitives/FormField/index.d.ts +13 -4
  63. package/dist/primitives/Input/Input.svelte +8 -4
  64. package/dist/primitives/Input/input.variants.d.ts +32 -139
  65. package/dist/primitives/Input/input.variants.js +12 -10
  66. package/dist/primitives/JourneyTimeline/journey-timeline.variants.d.ts +55 -197
  67. package/dist/primitives/JourneyTimeline/journey-timeline.variants.js +4 -1
  68. package/dist/primitives/Menu/Menu.svelte +66 -4
  69. package/dist/primitives/Menu/index.d.ts +25 -0
  70. package/dist/primitives/Menu/menu.variants.d.ts +29 -93
  71. package/dist/primitives/Pagination/Pagination.svelte +28 -13
  72. package/dist/primitives/Pagination/index.d.ts +44 -0
  73. package/dist/primitives/Pagination/pagination.variants.d.ts +16 -54
  74. package/dist/primitives/Pagination/pagination.variants.js +16 -14
  75. package/dist/primitives/Popover/Popover.svelte +11 -1
  76. package/dist/primitives/Popover/index.d.ts +3 -3
  77. package/dist/primitives/Popover/popover.variants.d.ts +4 -2
  78. package/dist/primitives/Progress/progress.variants.d.ts +19 -83
  79. package/dist/primitives/Progress/progress.variants.js +2 -1
  80. package/dist/primitives/RadioGroup/RadioGroup.svelte +3 -3
  81. package/dist/primitives/RadioGroup/radioGroup.variants.d.ts +28 -109
  82. package/dist/primitives/SegmentGroup/SegmentGroup.svelte +14 -6
  83. package/dist/primitives/SegmentGroup/segmentgroup.variants.d.ts +12 -53
  84. package/dist/primitives/SegmentGroup/segmentgroup.variants.js +10 -10
  85. package/dist/primitives/Select/Select.svelte +21 -11
  86. package/dist/primitives/Select/index.d.ts +7 -0
  87. package/dist/primitives/Select/select.variants.d.ts +61 -150
  88. package/dist/primitives/Select/select.variants.js +24 -4
  89. package/dist/primitives/Separator/separator.variants.d.ts +4 -2
  90. package/dist/primitives/Sidebar/Sidebar.svelte +10 -15
  91. package/dist/primitives/Sidebar/sidebar.variants.d.ts +11 -29
  92. package/dist/primitives/Skeleton/skeleton.variants.d.ts +9 -36
  93. package/dist/primitives/Slider/Slider.svelte +4 -8
  94. package/dist/primitives/Slider/slider.variants.d.ts +37 -136
  95. package/dist/primitives/Slider/slider.variants.js +8 -5
  96. package/dist/primitives/Spinner/spinner.variants.d.ts +38 -135
  97. package/dist/primitives/Stepper/stepper.variants.d.ts +32 -130
  98. package/dist/primitives/Tab/Tab.svelte +14 -6
  99. package/dist/primitives/Tab/tab.variants.d.ts +17 -78
  100. package/dist/primitives/Textarea/Textarea.svelte +4 -5
  101. package/dist/primitives/Textarea/textarea.variants.d.ts +33 -118
  102. package/dist/primitives/Textarea/textarea.variants.js +17 -12
  103. package/dist/primitives/Toast/Toaster.svelte +58 -5
  104. package/dist/primitives/Toast/index.d.ts +39 -0
  105. package/dist/primitives/Toast/toast.store.svelte.d.ts +25 -1
  106. package/dist/primitives/Toast/toast.store.svelte.js +91 -1
  107. package/dist/primitives/Toast/toast.variants.d.ts +40 -78
  108. package/dist/primitives/Toast/toast.variants.js +15 -0
  109. package/dist/primitives/Toggle/Toggle.svelte +17 -3
  110. package/dist/primitives/Toggle/index.d.ts +8 -2
  111. package/dist/primitives/Toggle/toggle.variants.d.ts +24 -71
  112. package/dist/primitives/Toggle/toggle.variants.js +26 -0
  113. package/dist/primitives/Toolbar/toolbar.variants.d.ts +9 -60
  114. package/dist/primitives/Tooltip/Tooltip.svelte +58 -21
  115. package/dist/primitives/Tooltip/Tooltip.svelte.d.ts +1 -1
  116. package/dist/primitives/Tooltip/index.d.ts +45 -3
  117. package/dist/primitives/Tooltip/tooltip.variants.d.ts +14 -53
  118. package/dist/primitives/Tooltip/tooltip.variants.js +9 -3
  119. package/dist/primitives/index.d.ts +3 -3
  120. package/dist/style/foundation.css +4 -1
  121. package/dist/style/interaction.css +39 -11
  122. package/dist/style/semantic.css +2 -2
  123. package/dist/style/themes/index.css +1 -1
  124. package/dist/translations/de.d.ts +3 -0
  125. package/dist/translations/de.js +4 -1
  126. package/dist/translations/en.d.ts +3 -0
  127. package/dist/translations/en.js +4 -1
  128. package/dist/utils/guide.svelte.d.ts +8 -2
  129. package/dist/utils/guide.svelte.js +86 -5
  130. package/dist/utils/index.d.ts +2 -1
  131. package/dist/utils/index.js +5 -0
  132. package/dist/utils/overlay.d.ts +33 -4
  133. package/dist/utils/overlay.js +61 -18
  134. package/dist/utils/roving.d.ts +15 -0
  135. package/dist/utils/roving.js +44 -0
  136. package/dist/utils/use-floating-panel.svelte.d.ts +4 -1
  137. package/dist/utils/use-floating-panel.svelte.js +23 -1
  138. package/dist/utils/use-form-field.svelte.d.ts +9 -9
  139. package/dist/utils/use-form-field.svelte.js +12 -12
  140. package/dist/utils/variants.d.ts +60 -16
  141. package/dist/utils/variants.js +424 -123
  142. package/package.json +9 -6
@@ -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
- hint: helper,
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
- const selectedOption = $derived(options.find((o) => o.value === value));
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
- const filtered = $derived.by(() => {
88
- if (selectedOption && query === selectedOption.label) return options;
89
- if (!query.trim()) return options;
90
- return options.filter((o) => filterFn(o, query.trim()));
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
- open = false;
311
+ selectedCache = opt;
312
+ setOpen(false);
138
313
  activeIndex = -1;
139
- onValueChange?.(opt.value);
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
- open = false;
336
+ setOpen(false);
147
337
  activeIndex = -1;
148
- onValueChange?.(null);
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
- if (!open) open = true;
380
+ setOpen(true);
154
381
  activeIndex = -1;
155
- if (value && query !== selectedOption?.label) {
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
- onValueChange?.(null);
387
+ dispatchValueChange?.(null);
158
388
  }
159
389
  }
160
390
 
161
391
  function handleFocus() {
162
392
  if (suppressFocusOpen) return;
163
- if (!disabled) open = true;
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
- open = false;
175
- // Restore the selected label if the query was left dangling (mirrors the
176
- // click-outside path), so the field doesn't read as blank after closing.
177
- if (!value && selectedOption) query = selectedOption.label;
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
- open = true;
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
- open = true;
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
- while (activeIndex < filtered.length && filtered[activeIndex]?.disabled) activeIndex++;
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
- open = true;
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]?.disabled) 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 (open && activeIndex >= 0 && filtered[activeIndex]) {
229
- select(filtered[activeIndex]);
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
- open = false;
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
- open = false;
269
- if (!value && selectedOption) {
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
- <input
338
- bind:this={inputEl}
339
- id={ff.fieldId}
340
- type="text"
341
- role="combobox"
342
- aria-expanded={open}
343
- aria-controls={listboxId}
344
- aria-activedescendant={activeDescendant}
345
- aria-autocomplete="list"
346
- aria-invalid={ff.invalid ? 'true' : undefined}
347
- aria-describedby={ff.describedBy}
348
- autocomplete="off"
349
- class={unstyled ? (slotClasses?.input ?? '') : styles.input({ class: slotClasses?.input })}
350
- {placeholder}
351
- {disabled}
352
- required={ff.required || undefined}
353
- bind:value={query}
354
- oninput={handleInput}
355
- onfocus={handleFocus}
356
- onkeydown={handleKeydown}
357
- />
358
-
359
- {#if clearable && value}
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
- <input
393
- type="hidden"
394
- {name}
395
- value={value === null || value === undefined ? '' : String(value)}
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 filtered.length === 0}
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 filtered as opt, i (opt.value)}
440
- {@const isActive = i === activeIndex}
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
- slotClasses?.option,
453
- isActive ? slotClasses?.optionActive : undefined,
454
- isSelected ? slotClasses?.optionSelected : undefined
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
- {#if customOption}
475
- {@render customOption(opt, isSelected)}
476
- {:else}
477
- <span class="flex-1 truncate">{opt.label}</span>
478
- {#if isSelected}
479
- <CheckMarkIcon class="text-primary h-4 w-4 shrink-0" />
480
- {/if}
481
- {/if}
482
- </button>
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.hintId}
792
+ {:else if ff.helperId}
500
793
  <div
501
- id={ff.hintId}
502
- class={unstyled ? (slotClasses?.hint ?? '') : styles.hint({ class: slotClasses?.hint })}
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}