svelte-5-select 1.0.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +239 -109
  2. package/dist/ChevronIcon.svelte +8 -13
  3. package/dist/ClearIcon.svelte +3 -11
  4. package/dist/LoadingIcon.svelte +9 -2
  5. package/dist/Select.svelte +954 -436
  6. package/dist/Select.svelte.d.ts +37 -5
  7. package/dist/aria-handlers.svelte.d.ts +4 -1
  8. package/dist/aria-handlers.svelte.js +19 -8
  9. package/dist/filter.d.ts +10 -2
  10. package/dist/filter.js +14 -7
  11. package/dist/index.d.ts +2 -3
  12. package/dist/index.js +1 -2
  13. package/dist/keyboard-navigation.svelte.d.ts +7 -2
  14. package/dist/keyboard-navigation.svelte.js +285 -47
  15. package/dist/no-styles/ChevronIcon.svelte +11 -0
  16. package/dist/no-styles/ChevronIcon.svelte.d.ts +26 -0
  17. package/dist/no-styles/ClearIcon.svelte +8 -0
  18. package/dist/no-styles/ClearIcon.svelte.d.ts +26 -0
  19. package/dist/no-styles/LoadingIcon.svelte +13 -0
  20. package/dist/no-styles/LoadingIcon.svelte.d.ts +26 -0
  21. package/dist/no-styles/Select.svelte +1383 -0
  22. package/dist/no-styles/Select.svelte.d.ts +38 -0
  23. package/dist/select-state.svelte.d.ts +15 -0
  24. package/dist/select-state.svelte.js +161 -0
  25. package/dist/styles/default.css +112 -71
  26. package/dist/tailwind.css +120 -17
  27. package/dist/types.d.ts +459 -129
  28. package/dist/use-hover.svelte.d.ts +3 -7
  29. package/dist/use-hover.svelte.js +91 -36
  30. package/dist/use-load-options.svelte.d.ts +18 -3
  31. package/dist/use-load-options.svelte.js +333 -42
  32. package/dist/use-value.svelte.d.ts +2 -11
  33. package/dist/use-value.svelte.js +322 -111
  34. package/dist/utils.d.ts +19 -8
  35. package/dist/utils.js +52 -8
  36. package/package.json +117 -94
@@ -1,9 +1,27 @@
1
1
  <svelte:options runes={true} />
2
- <script lang="ts">
3
- import { onDestroy, onMount, untrack } from 'svelte';
2
+
3
+ <!--
4
+ @component
5
+ A select/autocomplete/typeahead control: a WAI-ARIA combobox with a floating
6
+ listbox popup. Generic over your item type; supports multi-select, async
7
+ loading (`loadOptions`), grouping (`groupBy`), and snippet-based customization.
8
+ Bind `value` (and optionally `justValue`, `filterText`, `listOpen`, `focused`).
9
+ -->
10
+
11
+ <script lang="ts" generics="Item extends ItemLike = SelectItem, Multiple extends boolean = false">
12
+ import { onDestroy, onMount, tick, untrack } from 'svelte';
13
+ import { DEV } from 'esm-env';
4
14
  import { offset, flip, shift } from 'svelte-floating-ui/dom';
15
+ import type {
16
+ FloatingConfig,
17
+ ItemLike,
18
+ SelectProps,
19
+ SelectRow,
20
+ SelectValue,
21
+ SelectClearValue,
22
+ SelectErrorEvent,
23
+ } from './types';
5
24
  import { createFloatingActions } from 'svelte-floating-ui';
6
- import type { FloatingConfig, SelectProps, SelectValue, ErrorEvent as SelectErrorEvent } from './types';
7
25
  import { useAriaHandlers } from './aria-handlers.svelte';
8
26
 
9
27
  import _filter from './filter';
@@ -13,27 +31,33 @@
13
31
  import LoadingIcon from './LoadingIcon.svelte';
14
32
  import type { SelectItem } from './types';
15
33
  import type { HTMLInputAttributes } from 'svelte/elements';
34
+ import { createSelectState } from './select-state.svelte';
16
35
  import { useKeyboardNavigation } from './keyboard-navigation.svelte';
17
36
  import { useHover } from './use-hover.svelte';
18
37
  import { useValue } from './use-value.svelte';
19
38
  import { useLoadOptions } from './use-load-options.svelte';
20
- import { areItemsEqual, isItemSelectableCheck, createGroupHeaderItem as _createGroupHeaderItem } from './utils';
21
-
22
- const defaultItemFilter = (label: string, filterText: string, option: SelectItem): boolean =>
39
+ import {
40
+ areItemsEqual,
41
+ convertStringItemsToObjects,
42
+ getItemProperty,
43
+ isItemSelectableCheck,
44
+ normalizeItem,
45
+ createGroupHeaderItem as _createGroupHeaderItem,
46
+ } from './utils';
47
+
48
+ const defaultItemFilter = (label: string, filterText: string, _option: SelectItem): boolean =>
23
49
  `${label}`.toLowerCase().includes(filterText?.toLowerCase());
24
50
 
25
- const defaultOnError = (error: SelectErrorEvent): void => {};
26
- const defaultOnLoaded = (options: SelectItem[]): void => {};
27
- const defaultHandleClear = (e?: MouseEvent): void => {};
51
+ const defaultOnError = (_error: SelectErrorEvent): void => {};
52
+ const defaultOnLoaded = (_options: SelectItem[]): void => {};
28
53
 
29
54
  let timeout = $state<ReturnType<typeof setTimeout>>();
30
- let clearState = $state(false);
31
55
 
32
56
  let {
33
57
  // Core data props
34
58
  filterText = $bindable(''),
35
59
  itemId = 'value',
36
- items = $bindable<SelectItem[] | string[] | null>(null),
60
+ items = $bindable<Item[] | string[] | null>(null),
37
61
  justValue = $bindable(),
38
62
  label = 'label',
39
63
  value = $bindable(),
@@ -57,7 +81,7 @@
57
81
  filterSelectedItems = true,
58
82
  groupHeaderSelectable = false,
59
83
  multiFullItemClearable = false,
60
- multiple = false,
84
+ multiple = false as Multiple,
61
85
  required = false,
62
86
  searchable = true,
63
87
  useJustValue = false,
@@ -65,7 +89,7 @@
65
89
  // Styling props
66
90
  containerStyles = '',
67
91
  inputStyles = '',
68
- listStyle = '',
92
+ listStyles = '',
69
93
  hideEmptyState = false,
70
94
 
71
95
  // Advanced props
@@ -78,17 +102,14 @@
78
102
  loadOptionsDeps = [],
79
103
 
80
104
  // Function props
81
- createGroupHeaderItem = (groupValue: string, item: SelectItem) => {
82
- return _createGroupHeaderItem(groupValue, item, label);
105
+ createGroupHeaderItem = (groupValue: string, _item: Item) => {
106
+ return _createGroupHeaderItem(groupValue, label);
83
107
  },
84
108
  debounce = (fn: () => void, wait = 1) => {
85
109
  clearTimeout(timeout);
86
110
  timeout = setTimeout(fn, wait);
87
111
  },
88
112
  filter = _filter,
89
- getFilteredItems = () => {
90
- return filteredItems;
91
- },
92
113
  groupBy = undefined,
93
114
  groupFilter = (groups: string[]) => groups,
94
115
  itemFilter = defaultItemFilter,
@@ -96,30 +117,46 @@
96
117
 
97
118
  // ARIA props
98
119
  ariaLabel = undefined,
120
+ ariaErrorMessage = undefined,
121
+ ariaClearSelectLabel = 'Clear selection',
122
+ ariaRemoveItemLabel = (label: string) => `Remove ${label}`,
123
+ ariaCleared = () => {
124
+ return `Selection cleared.`;
125
+ },
126
+ ariaEmpty = () => {
127
+ return `No options`;
128
+ },
99
129
  ariaFocused = () => {
100
- return `Select is focused, type to refine list, press down to open the menu.`;
130
+ // In select-only mode (searchable={false}) the input is readonly, so
131
+ // "type to refine" would instruct an action that does nothing
132
+ return searchable
133
+ ? `Select is focused, type to refine list, press down to open the menu.`
134
+ : `Select is focused, press down to open the menu.`;
101
135
  },
102
136
  ariaListOpen = (label: string, count: number) => {
103
137
  return `You are currently focused on option ${label}. There are ${count} results available.`;
104
138
  },
139
+ ariaLoading = () => {
140
+ return `Loading Data`;
141
+ },
105
142
  ariaValues = (values: string) => {
106
143
  return `Option ${values}, selected.`;
107
144
  },
108
145
 
109
146
  // Custom behavior
110
- handleClear = defaultHandleClear,
147
+ handleClear = internalHandleClear,
111
148
 
112
149
  // Event handlers
113
150
  onblur = () => {},
114
- onchange = () => {},
115
151
  onclear = () => {},
116
152
  onerror = defaultOnError,
117
153
  onfilter = () => {},
118
154
  onfocus = () => {},
119
155
  onhoveritem = () => {},
120
- oninput = () => {},
121
156
  onloaded = defaultOnLoaded,
122
157
  onselect = () => {},
158
+ onSelectionChange = () => {},
159
+ onValueChange = () => {},
123
160
 
124
161
  // Snippet props
125
162
  chevronIconSnippet,
@@ -137,22 +174,20 @@
137
174
  selectionSnippet,
138
175
 
139
176
  // DOM references (for binding)
140
- container = undefined,
141
- input = undefined,
177
+ container = $bindable(undefined),
178
+ input = $bindable(undefined),
142
179
  ...rest
143
- }: SelectProps = $props();
144
-
145
- let normalizedValue = $derived<SelectItem | SelectItem[] | null>(
146
- !value
147
- ? null
148
- : typeof value === 'string'
149
- ? { value, label: value }
150
- : value
151
- );
180
+ }: SelectProps<Item, Multiple> = $props();
152
181
 
153
- const _generatedId = `svelte-select-${Math.random().toString(36).slice(2, 9)}`;
182
+ let normalizedValue = $derived<SelectItem | SelectItem[] | null>(normalizeItem(value));
183
+
184
+ const _uid = $props.id();
185
+ const _generatedId = `svelte-select-${_uid}`;
154
186
  let _id = $derived(id ?? _generatedId);
155
187
 
188
+ // Group headers render as options only when they are selectable; otherwise they are presentational
189
+ const isPresentationalHeader = (item: SelectItem | undefined): boolean => !!item?.groupHeader && !item.selectable;
190
+
156
191
  const DEFAULT_INPUT_ATTRS = {
157
192
  autocapitalize: 'none',
158
193
  autocomplete: 'off',
@@ -164,348 +199,632 @@
164
199
  } as const;
165
200
 
166
201
  const ariaHandlers = useAriaHandlers({
167
- ariaValues,
168
- ariaListOpen,
169
- ariaFocused,
202
+ get ariaValues() {
203
+ return ariaValues;
204
+ },
205
+ get ariaListOpen() {
206
+ return ariaListOpen;
207
+ },
208
+ get ariaFocused() {
209
+ return ariaFocused;
210
+ },
211
+ get ariaEmpty() {
212
+ return ariaEmpty;
213
+ },
214
+ get ariaLoading() {
215
+ return ariaLoading;
216
+ },
170
217
  });
171
218
 
172
- handleClear = (e?: MouseEvent): void => {
173
- clearState = true;
174
- onclear(value as SelectValue);
219
+ function internalHandleClear(_e?: MouseEvent): void {
220
+ selectState.clearState = true;
221
+ onclear(value as unknown as SelectClearValue<Item, Multiple>);
175
222
  value = undefined;
176
223
  closeList();
177
224
  handleFocus();
178
- };
225
+ }
179
226
 
180
227
  let list = $state<HTMLDivElement | undefined>();
181
- let _inputAttributes = $derived<HTMLInputAttributes>({
182
- ...DEFAULT_INPUT_ATTRS,
183
- role: 'combobox',
184
- 'aria-controls': listOpen ? `listbox-${_id}` : undefined,
185
- 'aria-expanded': listOpen,
186
- 'aria-haspopup': 'listbox',
187
- 'aria-activedescendant': listOpen ? `listbox-${_id}-item-${hoverItemIndex}` : undefined,
188
- 'aria-label': ariaLabel,
189
- tabindex: 0,
190
- readonly: !searchable,
191
- id: id ? id : undefined,
192
- ...inputAttributes,
228
+ let filteredItems = $derived.by<SelectItem[]>(() =>
229
+ filter({
230
+ loadOptions,
231
+ filterText,
232
+ items,
233
+ multiple,
234
+ value: normalizedValue,
235
+ itemId,
236
+ groupBy,
237
+ label,
238
+ filterSelectedItems,
239
+ itemFilter,
240
+ convertStringItemsToObjects,
241
+ applyGrouping,
242
+ }),
243
+ );
244
+
245
+ // Fold the flat filteredItems (header, item, item, header, …) into real
246
+ // listbox groups so each group can carry role="group" named by its header.
247
+ // Flat indices are preserved on every entry so ids, aria-activedescendant
248
+ // and hover keep working. Without groupBy every row is a plain option and
249
+ // the list renders flat exactly as before.
250
+ type GroupRow =
251
+ | { type: 'option'; item: SelectItem; index: number }
252
+ | { type: 'group'; headerIndex: number; header: SelectItem; options: { item: SelectItem; index: number }[] };
253
+
254
+ let groupedRows = $derived.by<GroupRow[]>(() => {
255
+ const rows: GroupRow[] = [];
256
+ let current: Extract<GroupRow, { type: 'group' }> | null = null;
257
+ filteredItems.forEach((item, index) => {
258
+ if (item.groupHeader) {
259
+ current = { type: 'group', headerIndex: index, header: item, options: [] };
260
+ rows.push(current);
261
+ } else if (item.groupItem && current) {
262
+ current.options.push({ item, index });
263
+ } else {
264
+ current = null;
265
+ rows.push({ type: 'option', item, index });
266
+ }
267
+ });
268
+ return rows;
269
+ });
270
+ // The option the combobox currently points at. With a custom listSnippet the
271
+ // consumer owns option rendering, so this only resolves if they honour the
272
+ // documented id contract — a dev-only effect below warns when it dangles.
273
+ let _activeDescendantId = $derived(
274
+ listOpen && filteredItems[hoverItemIndex] && !isPresentationalHeader(filteredItems[hoverItemIndex])
275
+ ? `listbox-${_id}-item-${hoverItemIndex}`
276
+ : undefined,
277
+ );
278
+ let _inputAttributes = $derived.by<HTMLInputAttributes>(() => {
279
+ const attrs: HTMLInputAttributes = {
280
+ ...DEFAULT_INPUT_ATTRS,
281
+ // The APG select-only pattern has no autocomplete behavior: with
282
+ // searchable={false} the input is readonly and typing never refines
283
+ // the list, so advertising list-autocomplete would misinform AT
284
+ 'aria-autocomplete': searchable ? ('list' as const) : undefined,
285
+ // The selection renders in a sibling div, so the combobox's own
286
+ // accessible value is the (empty) filter text; describe the element
287
+ // holding the selection so browse-mode users can read it from the
288
+ // input itself. Multiple mode is excluded: each chip is separately
289
+ // focusable and announced, and the description would also read
290
+ // every remove-button label.
291
+ 'aria-describedby': !multiple && value ? `selected-${_id}` : undefined,
292
+ role: 'combobox',
293
+ 'aria-controls': listOpen ? `listbox-${_id}` : undefined,
294
+ 'aria-expanded': listOpen,
295
+ 'aria-haspopup': 'listbox',
296
+ 'aria-activedescendant': _activeDescendantId,
297
+ // Only an explicit ariaLabel: a default aria-label would override an external
298
+ // <label for={id}> in accessible-name computation. Without either, the
299
+ // placeholder still names the input as the spec's last-resort fallback.
300
+ 'aria-label': ariaLabel,
301
+ 'aria-required': required || undefined,
302
+ 'aria-invalid': hasError || undefined,
303
+ // aria-errormessage is only meaningful alongside aria-invalid="true"
304
+ 'aria-errormessage': hasError && ariaErrorMessage ? ariaErrorMessage : undefined,
305
+ readonly: !searchable,
306
+ id: id ? id : undefined,
307
+ ...inputAttributes,
308
+ // Disabled is expressed with aria-disabled + readonly rather than the native
309
+ // `disabled` attribute so the current value stays in the accessibility tree
310
+ // and screen readers can still announce it. Spread last (after
311
+ // inputAttributes) so a disabled control is never left interactive or
312
+ // tab-reachable; interaction is blocked by the `disabled` guards in
313
+ // handleClick/handleFocus/handleItemClick and the disabled effect, which
314
+ // also force-closes a programmatically opened list.
315
+ ...(disabled ? { 'aria-disabled': true, readonly: true, tabindex: -1 } : {}),
316
+ };
317
+ // A consumer handler in inputAttributes must add to the input's own
318
+ // oninput/onblur/onfocus/onkeydown, not replace them: these attributes
319
+ // are spread after the template's handlers (later-spread-wins), which
320
+ // would otherwise silently disable filtering, focus handling, and
321
+ // keyboard navigation. Composed order: the component's handler first,
322
+ // then the consumer's.
323
+ const internalHandlers = {
324
+ oninput: handleInput,
325
+ onblur: handleBlur,
326
+ onfocus: handleFocus,
327
+ onkeydown: handleKeyDown,
328
+ };
329
+ for (const key of Object.keys(internalHandlers) as (keyof typeof internalHandlers)[]) {
330
+ const consumer = inputAttributes?.[key];
331
+ if (typeof consumer !== 'function') continue;
332
+ const internal = internalHandlers[key] as (e: Event) => unknown;
333
+ (attrs as Record<string, unknown>)[key] = (e: Event) => {
334
+ internal(e);
335
+ (consumer as (e: Event) => unknown)(e);
336
+ };
337
+ }
338
+ return attrs;
193
339
  });
194
- let activeValue = $state<number | undefined>(undefined);
195
- let prev_value = $state<SelectItem | SelectItem[] | string | null | undefined>();
196
- let prev_filterText: string | undefined = $state();
197
- let prev_multiple = $state();
198
- let isScrolling = $state(false);
199
340
  let prefloat = $state(true);
200
- let hasValue = $state(false);
341
+ let hasValue = $derived(multiple ? Array.isArray(value) && value.length > 0 : !!value);
201
342
  let placeholderText = $derived(
202
343
  placeholderAlwaysShow && multiple
203
344
  ? placeholder
204
- : multiple && value?.length === 0
205
- ? placeholder
206
- : value ? '' : placeholder
207
- );
208
- let showClear = $derived(
209
- hasValue && clearable && !disabled && !loading
210
- );
211
- let hideSelectedItem = $derived(
212
- hasValue && filterText.length > 0
213
- );
214
- let filteredItems = $state<SelectItem[]>([]);
215
-
216
- let ariaContext = $derived(
217
- ariaHandlers.handleAriaContent({
218
- value,
219
- filteredItems,
220
- hoverItemIndex,
221
- listOpen,
222
- multiple,
223
- label,
224
- })
345
+ : multiple && Array.isArray(value) && value.length === 0
346
+ ? placeholder
347
+ : value
348
+ ? ''
349
+ : placeholder,
225
350
  );
351
+ let showClear = $derived(hasValue && clearable && !disabled && !loading);
352
+ let hideSelectedItem = $derived(hasValue && filterText.length > 0);
353
+
354
+ /**
355
+ * Instance export — call via a `bind:this` reference: returns the currently
356
+ * rendered rows. They include any group headers synthesized by `groupBy`
357
+ * (hence `SelectRow` rather than `Item`); narrow with `isGroupHeader`.
358
+ */
359
+ export function getFilteredItems(): SelectRow<Item>[] {
360
+ return filteredItems as SelectRow<Item>[];
361
+ }
362
+
363
+ // Announce a cleared selection explicitly: the live region uses
364
+ // aria-relevant="additions text", so merely emptying the selection span
365
+ // announces nothing.
366
+ let selectionCleared = $state(false);
367
+ let prevHadValue = false; // seeded by the first effect run below
368
+ $effect(() => {
369
+ hasValue;
370
+ focused;
371
+ untrack(() => {
372
+ if (!focused || hasValue) {
373
+ selectionCleared = false;
374
+ } else if (prevHadValue) {
375
+ selectionCleared = true;
376
+ }
377
+ prevHadValue = hasValue;
378
+ });
379
+ });
380
+
226
381
  let ariaSelection = $derived(
227
- value ? ariaHandlers.handleAriaSelection({
228
- value,
229
- filteredItems,
230
- hoverItemIndex,
231
- listOpen,
232
- multiple,
233
- label,
234
- }) : ''
382
+ hasValue
383
+ ? ariaHandlers.handleAriaSelection({
384
+ value,
385
+ filteredItems,
386
+ hoverItemIndex,
387
+ listOpen,
388
+ multiple,
389
+ label,
390
+ })
391
+ : selectionCleared
392
+ ? ariaCleared()
393
+ : '',
235
394
  );
236
395
 
396
+ // svelte-floating-ui keeps a reference to this object; effects below mutate it in place
237
397
  let _floatingConfig = $state<FloatingConfig>({
238
398
  strategy: 'absolute',
239
399
  placement: 'bottom-start',
240
- middleware: [offset(listOffset), flip(), shift()],
241
400
  autoUpdate: false,
242
401
  });
243
402
 
244
- // Initialize composables
245
- const valueManager = useValue({
246
- getState: () => ({
247
- value,
248
- prevValue: prev_value,
249
- items,
250
- multiple,
251
- itemId,
252
- label,
253
- hasValue,
254
- normalizedValue,
255
- useJustValue,
256
- justValue,
257
- clearState,
258
- closeListOnChange,
259
- }),
260
- setValue: (v) => value = v,
261
- setJustValue: (v) => justValue = v,
262
- setPrevValue: (v) => prev_value = v,
263
- setClearState: (v) => clearState = v,
264
- setActiveValue: (v) => activeValue = v,
265
- setFilterText: (v) => filterText = v,
266
- closeList,
267
- oninput: (v) => oninput?.(v as SelectValue),
268
- onchange: (v) => onchange?.(v as SelectValue),
269
- onclear: (v) => onclear(v as SelectValue),
270
- onselect: (s) => onselect?.(s),
403
+ // The shared reactive state object: live accessors over the props and derived
404
+ // values above, plus internal shared state owned by the factory. Composables
405
+ // read and write these fields directly.
406
+ const selectState = createSelectState<Item>({
407
+ get value() {
408
+ return value;
409
+ },
410
+ set value(v) {
411
+ value = v;
412
+ },
413
+ get items() {
414
+ return items;
415
+ },
416
+ set items(v) {
417
+ items = v;
418
+ },
419
+ get filterText() {
420
+ return filterText;
421
+ },
422
+ set filterText(v) {
423
+ filterText = v;
424
+ },
425
+ get justValue() {
426
+ return justValue;
427
+ },
428
+ set justValue(v) {
429
+ justValue = v;
430
+ },
431
+ get listOpen() {
432
+ return listOpen;
433
+ },
434
+ set listOpen(v) {
435
+ listOpen = v;
436
+ },
437
+ get loading() {
438
+ return loading;
439
+ },
440
+ set loading(v) {
441
+ loading = v;
442
+ },
443
+ get focused() {
444
+ return focused;
445
+ },
446
+ set focused(v) {
447
+ focused = v;
448
+ },
449
+ get hoverItemIndex() {
450
+ return hoverItemIndex;
451
+ },
452
+ set hoverItemIndex(v) {
453
+ hoverItemIndex = v;
454
+ },
455
+ get multiple() {
456
+ return multiple;
457
+ },
458
+ get itemId() {
459
+ return itemId;
460
+ },
461
+ get label() {
462
+ return label;
463
+ },
464
+ get searchable() {
465
+ return searchable;
466
+ },
467
+ get disabled() {
468
+ return disabled;
469
+ },
470
+ get useJustValue() {
471
+ return useJustValue;
472
+ },
473
+ get closeListOnChange() {
474
+ return closeListOnChange;
475
+ },
476
+ get debounceWait() {
477
+ return debounceWait;
478
+ },
479
+ get groupBy() {
480
+ return groupBy;
481
+ },
482
+ get loadOptions() {
483
+ return loadOptions;
484
+ },
485
+ get loadOptionsDeps() {
486
+ return loadOptionsDeps;
487
+ },
488
+ get filteredItems() {
489
+ return filteredItems;
490
+ },
491
+ get normalizedValue() {
492
+ return normalizedValue;
493
+ },
494
+ get hasValue() {
495
+ return hasValue;
496
+ },
271
497
  });
272
498
 
273
- const hoverManager = useHover({
274
- getState: () => ({
275
- listOpen,
276
- filteredItems,
277
- hoverItemIndex,
278
- multiple,
279
- value,
280
- isScrolling,
281
- groupBy,
282
- itemId,
283
- }),
284
- setHoverItemIndex: (v) => hoverItemIndex = v,
285
- setIsScrolling: (v) => isScrolling = v,
286
- onhoveritem: (i) => onhoveritem?.(i),
499
+ // The active-tag mechanism (ArrowLeft/ArrowRight + Backspace) is otherwise
500
+ // only visible as a CSS outline; announce it so non-sighted users can use it
501
+ let activeTagLabel = $derived(
502
+ multiple && selectState.activeValue !== undefined && Array.isArray(value)
503
+ ? ((value as Item[])[selectState.activeValue]?.[label] as string | undefined)
504
+ : undefined,
505
+ );
506
+ let ariaContext = $derived.by(() => {
507
+ if (activeTagLabel !== undefined) {
508
+ return `${activeTagLabel} is active. Press Backspace to remove, or left and right arrow keys to move between selected options.`;
509
+ }
510
+ // Tracked triggers: the list opening/closing, the result count changing
511
+ // (filtering), and the loading flag. hoverItemIndex is deliberately read
512
+ // untracked aria-activedescendant already announces each option as the
513
+ // keyboard cursor lands on it, so recomputing here per keystroke made every
514
+ // arrow key announce the option name twice AND re-read the whole result
515
+ // count. Leaving the region's text unchanged means it simply stays silent
516
+ // while arrowing, which is what the APG pattern expects.
517
+ listOpen;
518
+ filteredItems.length;
519
+ loading;
520
+ return untrack(() =>
521
+ ariaHandlers.handleAriaContent({
522
+ value,
523
+ filteredItems,
524
+ hoverItemIndex,
525
+ listOpen,
526
+ multiple,
527
+ label,
528
+ loading,
529
+ }),
530
+ );
287
531
  });
288
532
 
289
- const loadOptionsManager = useLoadOptions({
290
- getState: () => ({
291
- filterText,
292
- prevFilterText: prev_filterText,
293
- loadOptionsDeps,
294
- loadOptions,
295
- disabled,
296
- multiple,
297
- value,
298
- items,
299
- itemId,
300
- useJustValue,
301
- justValue,
302
- listOpen,
303
- debounceWait,
304
- }),
305
- setItems: (v) => items = v,
306
- setValue: (v) => value = v,
307
- setJustValue: (v) => justValue = v,
308
- setLoading: (v) => loading = v,
309
- setListOpen: (v) => listOpen = v,
310
- debounce,
311
- convertStringItemsToObjects: valueManager.convertStringItemsToObjects,
312
- onloaded: (opts) => onloaded(opts),
533
+ // Initialize composables (creation order is effect order: value, hover, load options)
534
+ const valueManager = useValue(selectState, {
535
+ closeList,
536
+ // Deferred: loadOptionsManager is created a few lines below
537
+ retireStaleValidation: () => loadOptionsManager.retireValidationForFreshSelection(),
538
+ onValueChange: (v) => onValueChange?.(v as unknown as SelectValue<Item, Multiple>),
539
+ onSelectionChange: (v) => onSelectionChange?.(v as unknown as SelectValue<Item, Multiple>),
540
+ onclear: (v) => onclear(v as unknown as SelectClearValue<Item, Multiple>),
541
+ onselect: (s) => onselect?.(s as Item),
542
+ });
543
+
544
+ const hoverManager = useHover(selectState);
545
+
546
+ const loadOptionsManager = useLoadOptions(selectState, {
547
+ debounce: (fn, wait) => debounce(fn, wait),
548
+ onloaded: (opts) => onloaded(opts as (Item | SelectItem)[]),
313
549
  onerror: (err) => onerror(err),
314
550
  });
315
551
 
316
- const keyboardNav = useKeyboardNavigation({
317
- getState: () => ({
318
- listOpen,
319
- filteredItems,
320
- hoverItemIndex,
321
- multiple,
322
- value,
323
- filterText,
324
- activeValue,
325
- itemId,
326
- focused,
327
- }),
328
- setListOpen: (v) => listOpen = v,
329
- setHoverItemIndex: (v) => hoverItemIndex = v,
330
- setActiveValue: (v) => activeValue = v,
552
+ const keyboardNav = useKeyboardNavigation(selectState, {
331
553
  closeList,
332
554
  setHoverIndex: hoverManager.setHoverIndex,
333
555
  handleSelect,
334
556
  handleMultiItemClear: valueManager.handleMultiItemClear,
335
557
  });
336
- const handleKeyDown = keyboardNav.handleKeyDown;
558
+
559
+ // Keyboard navigation must keep the hovered option visible. This hooks the
560
+ // key handler rather than an effect on hoverItemIndex so mouse hover never
561
+ // triggers scrolling.
562
+ function handleKeyDown(e: KeyboardEvent): void {
563
+ keyboardNav.handleKeyDown(e);
564
+ const isTypeAhead = !searchable && e.key.length === 1;
565
+ if (
566
+ e.key === 'ArrowDown' ||
567
+ e.key === 'ArrowUp' ||
568
+ e.key === 'Home' ||
569
+ e.key === 'End' ||
570
+ e.key === 'PageDown' ||
571
+ e.key === 'PageUp' ||
572
+ isTypeAhead
573
+ ) {
574
+ void tick().then(scrollHoveredItemIntoView);
575
+ }
576
+ }
577
+
578
+ function scrollHoveredItemIntoView(): void {
579
+ if (!listOpen || !list) return;
580
+ const el = document.getElementById(`listbox-${_id}-item-${hoverItemIndex}`);
581
+ el?.scrollIntoView?.({ block: 'nearest' });
582
+ }
337
583
 
338
584
  const [floatingRef, floatingContent, floatingUpdate] = createFloatingActions(_floatingConfig);
339
585
 
340
586
  onMount(() => {
587
+ // A disabled Select never grabs focus on mount; the disabled effect
588
+ // below also normalizes an initial `focused: true` back to false.
589
+ if (disabled) return;
341
590
  if (listOpen) focused = true;
342
591
  if (focused && input) input.focus();
343
592
  });
344
593
 
345
- // Filter items
594
+ // Keep the floating middleware in sync with listOffset. User overrides are
595
+ // merged INTO _floatingConfig: svelte-floating-ui re-reads that object on its
596
+ // own deferred/autoUpdate recomputes, so a merge into a throwaway copy would
597
+ // be reverted one tick later.
346
598
  $effect.pre(() => {
347
- filterText;
348
- value;
349
- items;
350
- untrack(
351
- () =>
352
- (filteredItems = filter({
353
- loadOptions: undefined,
354
- filterText,
355
- items,
356
- multiple,
357
- value: normalizedValue,
358
- itemId,
359
- groupBy,
360
- label,
361
- filterSelectedItems,
362
- itemFilter,
363
- convertStringItemsToObjects: valueManager.convertStringItemsToObjects,
364
- filterGroupedItems,
365
- })),
366
- );
367
- });
368
-
369
- // Set value on hasValue change
370
- $effect(() => {
371
- hasValue;
599
+ const middleware = [offset(listOffset), flip(), shift()];
600
+ floatingConfig;
372
601
  untrack(() => {
373
- if (items) valueManager.setValue();
602
+ _floatingConfig.middleware = middleware;
603
+ if (floatingConfig) Object.assign(_floatingConfig, floatingConfig);
604
+ if (container && list) {
605
+ floatingUpdate(_floatingConfig);
606
+ }
374
607
  });
375
608
  });
376
609
 
377
- // Setup multi when multiple changes
610
+ // Sync the focused prop with real DOM focus, and close the list on unfocus.
611
+ // `bind:focused` is documented as writable, so a parent write must move real
612
+ // DOM focus: `focused = true` alone used to leave focus elsewhere while the
613
+ // window keydown handler (gated only on `focused`) claimed arrow/Enter keys
614
+ // page-wide, with no blur ever able to reset it. Transition-guarded so the
615
+ // mount run doesn't closeList(), which wiped an initial filterText via
616
+ // clearFilterTextOnBlur while the mount loadOptions fetch used the original text.
617
+ let prevFocused = focused;
378
618
  $effect(() => {
379
- if (multiple) {
380
- untrack(() => valueManager.setupMulti());
381
- }
619
+ focused;
620
+ untrack(() => {
621
+ if (focused === prevFocused) return;
622
+ prevFocused = focused;
623
+ if (focused) {
624
+ if (disabled) {
625
+ // A disabled Select cannot be focused programmatically
626
+ focused = false;
627
+ prevFocused = false;
628
+ return;
629
+ }
630
+ if (input && document.activeElement !== input) handleFocus();
631
+ } else {
632
+ closeList();
633
+ selectState.activeValue = undefined;
634
+ if (input && document.activeElement === input) input.blur();
635
+ }
636
+ });
382
637
  });
383
638
 
384
- // Clear value when switching from multiple to single
639
+ // Setup filter text: every write after mount behaves like typing (opens the
640
+ // list); only the mount run stays passive — an initial filterText filters
641
+ // (and fetches, with loadOptions) without opening the list or moving focus.
642
+ // A first-run flag, not a prevFilterText compare: the effect only reruns on
643
+ // real changes, and the old compare against the last *typed* value silently
644
+ // ignored a programmatic write that happened to equal it (fetching against
645
+ // a closed list with loadOptions).
646
+ let filterTextSeeded = false;
385
647
  $effect(() => {
386
- if (prev_multiple && !multiple && value) {
387
- value = null;
388
- }
389
- prev_multiple = multiple;
648
+ filterText;
649
+ untrack(() => {
650
+ if (!filterTextSeeded) {
651
+ filterTextSeeded = true;
652
+ return;
653
+ }
654
+ setupFilterText();
655
+ });
390
656
  });
391
657
 
392
- // Check for duplicates
658
+ // Fire onhoveritem. The callback runs untracked: reactive reads inside a
659
+ // consumer callback must not become dependencies of this effect, and an
660
+ // inline callback prop changing identity per parent render must not refire.
393
661
  $effect(() => {
394
- if (multiple && value && value.length > 1) {
395
- untrack(() => valueManager.checkValueForDuplicates());
396
- }
662
+ hoverItemIndex;
663
+ untrack(() => onhoveritem?.(hoverItemIndex));
397
664
  });
398
665
 
399
- // Dispatch selected item
666
+ // Fire onfilter — untracked like onhoveritem; an onfilter that writes
667
+ // `items` would otherwise loop through filteredItems
400
668
  $effect(() => {
401
- if (value) {
402
- untrack(() => valueManager.dispatchSelectedItem());
403
- }
669
+ filteredItems;
670
+ listOpen;
671
+ untrack(() => {
672
+ if (filteredItems && listOpen) onfilter?.(filteredItems as SelectRow<Item>[]);
673
+ });
404
674
  });
405
675
 
406
- // Value cleared notification
676
+ // Floating UI config
407
677
  $effect(() => {
408
- if (prev_value && !value) {
409
- oninput?.((value || []) as SelectValue);
678
+ if (container && floatingConfig) {
679
+ untrack(() => {
680
+ Object.assign(_floatingConfig, floatingConfig);
681
+ floatingUpdate(_floatingConfig);
682
+ });
410
683
  }
411
684
  });
412
685
 
413
- // Close list on unfocus
414
- $effect(() => {
415
- if (!focused && input) closeList();
416
- });
417
-
418
- // Setup filter text
686
+ // List mounted
419
687
  $effect(() => {
420
- filterText;
688
+ listOpen;
421
689
  untrack(() => {
422
- if (filterText !== prev_filterText) setupFilterText();
690
+ // A closed list retires Tab's commit-intent; the next open starts
691
+ // from an auto-parked cursor again (see handleTabKey). Reset on
692
+ // close, not open: a type-ahead press that opens the list parks
693
+ // the cursor in the same handler, and a reset-on-open flush would
694
+ // clobber that intent.
695
+ if (!listOpen) selectState.userNavigatedSinceOpen = false;
696
+ listMounted(list, listOpen);
697
+ if (listOpen && container && list) {
698
+ setListWidth();
699
+ // Opening with a value starts hovered on that value; bring it into view
700
+ void tick().then(scrollHoveredItemIntoView);
701
+ }
423
702
  });
424
703
  });
425
704
 
426
- // Set value index as hover when list opens
427
- $effect(() => {
428
- if (!multiple && listOpen && value && filteredItems) {
429
- untrack(() => hoverManager.setValueIndexAsHoverIndex());
430
- }
431
- });
432
-
433
- // Fire onhoveritem
705
+ // Focus when list opens
434
706
  $effect(() => {
435
- onhoveritem?.(hoverItemIndex);
707
+ if (input && listOpen && !focused) handleFocus();
436
708
  });
437
709
 
438
- // Compute hasValue
710
+ // Dev-only: a custom listSnippet takes over option rendering, so the combobox's
711
+ // aria-activedescendant only resolves if the consumer reproduces the option ids.
712
+ // A dangling activedescendant points assistive tech at nothing, which is worse
713
+ // than a plain listbox — so surface it rather than letting it fail silently.
439
714
  $effect(() => {
440
- multiple;
441
- value;
715
+ if (!DEV) return;
716
+ const activeId = _activeDescendantId;
717
+ const hasListSnippet = !!listSnippet;
442
718
  untrack(() => {
443
- hasValue = multiple
444
- ? !!(value && value.length > 0)
445
- : !!value;
719
+ if (!hasListSnippet || !activeId || document.getElementById(activeId)) return;
720
+ console.warn(
721
+ `[svelte-select] listSnippet is rendering the list, but no element with id "${activeId}" ` +
722
+ "exists, so the combobox's aria-activedescendant points at nothing and screen readers " +
723
+ 'cannot follow keyboard navigation. Give each option you render ' +
724
+ `id="listbox-${_id}-item-{index}" (matching its index in the snippet argument) and ` +
725
+ 'role="option".',
726
+ );
446
727
  });
447
728
  });
448
729
 
449
- // Compute justValue
730
+ // Dev-only: items are compared by their `itemId` field everywhere
731
+ // (selection, hover, dedup), and entries missing that field all compare
732
+ // equal — the first pick works, every later click on a different option
733
+ // reads "already selected" and just closes the list, and every option
734
+ // renders selected. The failure is completely silent, so surface the
735
+ // config error instead.
736
+ let warnedAboutMissingItemId = false;
450
737
  $effect(() => {
451
- multiple;
452
- itemId;
453
- value;
738
+ if (!DEV) return;
739
+ const currentItems = items;
740
+ const currentItemId = itemId;
454
741
  untrack(() => {
455
- justValue = valueManager.computeJustValue();
742
+ if (warnedAboutMissingItemId || !currentItems?.length) return;
743
+ const missing = (currentItems as (Item | string)[]).some(
744
+ (item) => typeof item !== 'string' && getItemProperty(item, currentItemId) === undefined,
745
+ );
746
+ if (!missing) return;
747
+ warnedAboutMissingItemId = true;
748
+ console.warn(
749
+ `[svelte-select] Some items have no "${currentItemId}" field (the current \`itemId\`). ` +
750
+ 'Items are compared by that field, so items without it all compare as the same ' +
751
+ 'item — selection, hover state, and deduplication will misbehave. Point `itemId` ' +
752
+ 'at a field your items actually have, or add the field to your items.',
753
+ );
456
754
  });
457
755
  });
458
756
 
459
- // Check hover selectable
757
+ // Dev-only: warn once the input is mounted if it has no robust accessible
758
+ // name. The placeholder is only a last-resort fallback that some screen
759
+ // readers ignore, so an unnamed combobox is a real gap worth surfacing.
460
760
  $effect(() => {
461
- filteredItems;
462
- value;
463
- multiple;
464
- listOpen;
761
+ // Gate on esm-env's DEV, never on Vite's build-time env object: that object
762
+ // is undefined outside a Vite bundle, so reading its DEV flag threw at mount
763
+ // for rollup/webpack/no-build consumers. esm-env resolves per-bundler and
764
+ // tree-shakes this whole effect out of production builds.
765
+ if (!DEV) return;
766
+ input;
767
+ ariaLabel;
465
768
  untrack(() => {
466
- if (listOpen && filteredItems.length > 0) {
467
- if (!isItemSelectableCheck(filteredItems[hoverItemIndex])) {
468
- hoverManager.checkHoverSelectable();
469
- } else if (groupBy && hoverItemIndex === 0) {
470
- hoverManager.checkHoverSelectable();
471
- }
769
+ if (!input) return;
770
+ const named = !!ariaLabel || !!input.getAttribute('aria-labelledby') || (input.labels?.length ?? 0) > 0;
771
+ if (!named) {
772
+ console.warn(
773
+ '[svelte-select] The Select input has no accessible name. Pass `ariaLabel`, ' +
774
+ 'or set the `id` prop and add a matching `<label for={id}>`, so screen readers ' +
775
+ 'can announce the field. The placeholder is only a last-resort fallback and is ' +
776
+ 'ignored by some assistive tech.',
777
+ );
472
778
  }
473
779
  });
474
780
  });
475
781
 
476
- // Fire onfilter
477
- $effect(() => {
478
- if (filteredItems && listOpen) onfilter?.(filteredItems);
479
- });
480
-
481
- // Floating UI config
482
- $effect(() => {
483
- if (container && floatingConfig) floatingUpdate({..._floatingConfig, ...floatingConfig});
484
- });
485
-
486
- // List mounted
782
+ // The listbox needs its own accessible name (ARIA 1.2): `ariaLabel` covers
783
+ // one recommended naming path, but the others — `id` + external `<label for>`
784
+ // or an `aria-labelledby` supplied through `inputAttributes` — name only the
785
+ // input; neither cascades to the floating list. Resolve a name when the list
786
+ // opens, in the same precedence the input uses: explicit ariaLabel, then the
787
+ // input's own aria-labelledby (forwarded), then the associated label —
788
+ // referenced via aria-labelledby when it is external and has an id (live
789
+ // text), otherwise a text snapshot. The consumer's label is never mutated.
790
+ let listboxLabelledby = $state<string | undefined>();
791
+ let listboxLabelText = $state<string | undefined>();
487
792
  $effect(() => {
488
793
  listOpen;
794
+ ariaLabel;
489
795
  untrack(() => {
490
- listMounted(list, listOpen);
491
- if (listOpen && container && list) setListWidth();
796
+ listboxLabelledby = undefined;
797
+ listboxLabelText = undefined;
798
+ if (!listOpen || ariaLabel || !input) return;
799
+ const inputLabelledby = input.getAttribute('aria-labelledby');
800
+ if (inputLabelledby) {
801
+ listboxLabelledby = inputLabelledby;
802
+ return;
803
+ }
804
+ const labelEl = input.labels?.[0];
805
+ if (!labelEl) return;
806
+ // An implicit wrapping <label> contains the component itself, so
807
+ // both aria-labelledby and a plain textContent snapshot would pull
808
+ // in everything rendered inside (chips, options, live regions).
809
+ // Snapshot only the label's own text — the nodes outside this
810
+ // component's subtree.
811
+ const wrapsComponent = container ? labelEl.contains(container) : false;
812
+ if (!wrapsComponent && labelEl.id) {
813
+ listboxLabelledby = labelEl.id;
814
+ } else if (!wrapsComponent) {
815
+ listboxLabelText = labelEl.textContent?.trim() || undefined;
816
+ } else {
817
+ let text = '';
818
+ for (const node of labelEl.childNodes) {
819
+ if (node === container || (node instanceof Element && container && node.contains(container)))
820
+ continue;
821
+ text += node.textContent ?? '';
822
+ }
823
+ listboxLabelText = text.trim() || undefined;
824
+ }
492
825
  });
493
826
  });
494
827
 
495
- // Focus when list opens
496
- $effect(() => {
497
- if (input && listOpen && !focused) handleFocus();
498
- });
499
-
500
- // Reset hover on filterText change
501
- $effect(() => {
502
- if (filterText) {
503
- untrack(() => {
504
- hoverItemIndex = hoverManager.getFirstSelectableIndex();
505
- });
506
- }
507
- });
508
-
509
828
  // Auto update floating config
510
829
  $effect(() => {
511
830
  if (container && floatingConfig?.autoUpdate === undefined) {
@@ -515,44 +834,41 @@
515
834
 
516
835
  // Disabled state
517
836
  $effect(() => {
518
- if (disabled) {
837
+ disabled;
838
+ // listOpen is a tracked trigger too: neither setupFilterText nor the list
839
+ // template gates on disabled, so a programmatic bind:listOpen (or
840
+ // filterText) write while disabled would otherwise render an operable
841
+ // list — this effect only reruns on `disabled` flips without it.
842
+ listOpen;
843
+ untrack(() => {
844
+ if (!disabled) return;
519
845
  listOpen = false;
520
846
  filterText = '';
521
- }
522
- });
523
-
524
- // Load options handler
525
- $effect(() => {
526
- const currentFilterText = filterText;
527
- const currentDeps = [...loadOptionsDeps];
528
-
529
- if (loadOptions) {
530
- untrack(() => {
531
- loadOptionsManager.handleLoadOptions(currentFilterText, currentDeps);
532
- });
533
-
534
- // Keep outside untrack for proper reactivity
535
- if (!disabled && currentFilterText.length > 0 && !listOpen) {
536
- listOpen = true;
537
- }
538
- }
847
+ // Disabling must also release focus: every keyboard handler gates
848
+ // on `focused`, so a Select disabled while it held focus stayed
849
+ // fully keyboard-operable (list toggling, Enter selection,
850
+ // Backspace tag removal) despite being aria-disabled. This also
851
+ // normalizes an initial `focused: true, disabled: true` mount.
852
+ if (focused) focused = false;
853
+ if (input && document.activeElement === input) input.blur();
854
+ });
539
855
  });
540
856
 
541
- function filterGroupedItems(_items: SelectItem[]): SelectItem[] {
857
+ function applyGrouping(_items: SelectItem[]): SelectItem[] {
542
858
  if (!groupBy) return _items;
543
859
 
544
860
  const groupValues: string[] = [];
545
861
  const groups: Record<string, SelectItem[]> = {};
546
862
 
547
863
  _items.forEach((item) => {
548
- const groupValue: string = groupBy(item);
864
+ const groupValue: string = groupBy(item as Item);
549
865
 
550
866
  if (!groupValues.includes(groupValue)) {
551
867
  groupValues.push(groupValue);
552
868
  groups[groupValue] = [];
553
869
  if (groupValue) {
554
870
  groups[groupValue].push(
555
- Object.assign(createGroupHeaderItem(groupValue, item), {
871
+ Object.assign(createGroupHeaderItem(groupValue, item as Item), {
556
872
  id: groupValue,
557
873
  groupHeader: true,
558
874
  selectable: groupHeaderSelectable,
@@ -575,8 +891,13 @@
575
891
 
576
892
  function setupFilterText() {
577
893
  if (loadOptions) {
578
- if (filterText.length > 0 && !listOpen) {
579
- listOpen = true;
894
+ if (filterText.length > 0) {
895
+ if (!listOpen) listOpen = true;
896
+ // Typing dismisses an armed tag cursor here too — mirrors the
897
+ // non-loadOptions branch below; without this the cursor stayed
898
+ // highlighted and announced while the user typed (Backspace
899
+ // itself was already guarded by the filterText check)
900
+ if (multiple) selectState.activeValue = undefined;
580
901
  }
581
902
  return;
582
903
  }
@@ -586,11 +907,14 @@
586
907
  listOpen = true;
587
908
 
588
909
  if (multiple) {
589
- activeValue = undefined;
910
+ selectState.activeValue = undefined;
590
911
  }
591
912
  }
592
913
 
593
914
  function handleFocus(e?: FocusEvent): void {
915
+ // The input is no longer natively `disabled`, so it can still be
916
+ // click-focused — keep a disabled Select non-interactive here.
917
+ if (disabled) return;
594
918
  if (focused && input === document?.activeElement) return;
595
919
  if (e) {
596
920
  onfocus?.(e);
@@ -599,17 +923,38 @@
599
923
  focused = true;
600
924
  }
601
925
 
926
+ // A blur while the list is scrolling is deferred, not dropped: DOM focus is
927
+ // already gone, so no further blur will ever fire — without a replay the list
928
+ // stays open and the window keydown handler keeps hijacking keys.
929
+ let pendingBlur: FocusEvent | boolean = false;
930
+
602
931
  async function handleBlur(e?: FocusEvent): Promise<void> {
603
- if (isScrolling) return;
932
+ if (selectState.isScrolling) {
933
+ pendingBlur = e ?? true;
934
+ return;
935
+ }
936
+ pendingBlur = false;
604
937
  if (listOpen || focused) {
605
938
  if (e) onblur?.(e);
606
939
  closeList();
607
940
  focused = false;
608
- activeValue = undefined;
941
+ selectState.activeValue = undefined;
609
942
  input?.blur();
610
943
  }
611
944
  }
612
945
 
946
+ // Replay a deferred blur once scrolling settles — unless focus is back on
947
+ // the input (the scroll itself blurred it only transiently)
948
+ $effect(() => {
949
+ selectState.isScrolling;
950
+ untrack(() => {
951
+ if (selectState.isScrolling || pendingBlur === false) return;
952
+ const deferred = pendingBlur === true ? undefined : pendingBlur;
953
+ pendingBlur = false;
954
+ if (document.activeElement !== input) void handleBlur(deferred);
955
+ });
956
+ });
957
+
613
958
  function handleClick(ev: MouseEvent) {
614
959
  ev.preventDefault();
615
960
  if (disabled) return;
@@ -622,17 +967,25 @@
622
967
  filterText = '';
623
968
  }
624
969
  listOpen = false;
625
- }
626
-
627
- function handleClickOutside(event: MouseEvent): void {
628
- const target = event.target as Node;
629
- if (!listOpen && !focused && container && !container.contains(target) && !list?.contains(target)) {
630
- handleBlur();
631
- }
970
+ // Reset Tab's commit-intent synchronously, not only in the listOpen
971
+ // effect below: a consumer callback that reopens the list in the same
972
+ // flush would otherwise carry stale intent into the reopened list (the
973
+ // effect would run once and only ever observe listOpen === true)
974
+ selectState.userNavigatedSinceOpen = false;
975
+ // A load armed by typing must not fire (spinner + stale items) on a closed list;
976
+ // reads through the effect miss this when clearFilterTextOnBlur is false
977
+ loadOptionsManager.cancelPendingFilterLoad();
632
978
  }
633
979
 
634
980
  onDestroy(() => {
981
+ // The floating list can outlive the component's own tree; remove it explicitly
982
+ // eslint-disable-next-line svelte/no-dom-manipulating
635
983
  list?.remove();
984
+ // A debounced load firing after unmount would fetch and invoke callbacks
985
+ // on a dead component
986
+ clearTimeout(timeout);
987
+ clearTimeout(prefloatTimeout);
988
+ loadOptionsManager.invalidateLoads();
636
989
  });
637
990
 
638
991
  function handleSelect(item: SelectItem): void {
@@ -641,23 +994,30 @@
641
994
  }
642
995
 
643
996
  function handleItemClick(item: SelectItem, i: number): void {
997
+ // Defence in depth: the disabled effect force-closes the list, but a
998
+ // pointer must never mutate a disabled control's value even if a list
999
+ // is momentarily rendered
1000
+ if (disabled) return;
644
1001
  if (item?.selectable === false) return;
645
- if (!multiple && areItemsEqual(normalizedValue, item, itemId)) return closeList();
1002
+ if (!multiple && !Array.isArray(normalizedValue) && areItemsEqual(normalizedValue, item, itemId))
1003
+ return closeList();
646
1004
  if (hoverManager.isItemSelectable(item)) {
647
1005
  hoverItemIndex = i;
648
1006
  handleSelect(item);
649
1007
  }
650
1008
  }
651
1009
 
652
- function setListWidth():void {
1010
+ function setListWidth(): void {
653
1011
  if (!container || !list) return;
654
1012
  const { width } = container.getBoundingClientRect();
655
1013
  list.style.width = listAutoWidth ? width + 'px' : 'auto';
656
1014
  }
657
1015
 
1016
+ let prefloatTimeout: ReturnType<typeof setTimeout> | undefined;
658
1017
  function listMounted(list: HTMLDivElement | undefined, listOpen: boolean) {
659
1018
  if (!list || !listOpen) return (prefloat = true);
660
- setTimeout(() => {
1019
+ clearTimeout(prefloatTimeout);
1020
+ prefloatTimeout = setTimeout(() => {
661
1021
  prefloat = false;
662
1022
  }, 0);
663
1023
  }
@@ -665,23 +1025,35 @@
665
1025
  function handleInput(ev: Event): void {
666
1026
  const target = ev.target as HTMLInputElement;
667
1027
  listOpen = true;
668
- prev_filterText = filterText;
1028
+ selectState.prevFilterText = filterText;
669
1029
  filterText = target.value;
1030
+ // Typing this session is commit-intent for Tab (see handleTabKey).
1031
+ // Marked here — on the real input event — rather than gating on the
1032
+ // current filterText, so a consumer-seeded initial value or text
1033
+ // retained across a close (clearFilterTextOnBlur={false}) can't arm a
1034
+ // commit the user never expressed.
1035
+ selectState.userNavigatedSinceOpen = true;
670
1036
  }
671
1037
 
1038
+ /**
1039
+ * Instance export — call via a `bind:this` reference: clears the selection,
1040
+ * filter text, and open/focus state back to an empty control. Does not fire
1041
+ * `onclear` (it is a programmatic reset, not a user clear).
1042
+ */
672
1043
  export function reset(): void {
673
- clearState = true;
1044
+ selectState.clearState = true;
674
1045
  value = undefined;
675
1046
  filterText = '';
676
1047
  listOpen = false;
677
1048
  hoverItemIndex = 0;
678
- activeValue = undefined;
1049
+ selectState.activeValue = undefined;
679
1050
  justValue = undefined;
680
1051
  focused = false;
681
1052
  }
682
1053
  </script>
683
1054
 
684
- <svelte:window onclick={handleClickOutside} onkeydown={handleKeyDown} />
1055
+ <!-- Outside clicks close the list via the input's native blur -->
1056
+ <svelte:window onkeydown={handleKeyDown} />
685
1057
 
686
1058
  <div
687
1059
  class="svelte-select {rest.class}"
@@ -693,6 +1065,16 @@
693
1065
  class:error={hasError}
694
1066
  style={containerStyles}
695
1067
  onpointerup={handleClick}
1068
+ onmousedown={(ev) => {
1069
+ // Pressing a non-input surface (the chevron area, a chip, multi-mode
1070
+ // whitespace) must not blur the input: the blur handler closes the
1071
+ // list, and this press's own pointerup — bubbling to handleClick —
1072
+ // would read that fresh closed state and toggle the list straight
1073
+ // back open. The input keeps its default behavior so caret placement
1074
+ // and text selection still work. (The list has its own guard and
1075
+ // stops propagation before reaching this one.)
1076
+ if (ev.target !== input) ev.preventDefault();
1077
+ }}
696
1078
  bind:this={container}
697
1079
  use:floatingRef
698
1080
  role="none">
@@ -702,9 +1084,16 @@
702
1084
  bind:this={list}
703
1085
  class="svelte-select-list"
704
1086
  class:prefloat
705
- style={listStyle}
1087
+ style={listStyles}
706
1088
  onscroll={hoverManager.handleListScroll}
707
1089
  onscrollend={hoverManager.handleListScrollEnd}
1090
+ onmousemove={() => {
1091
+ // Real pointer movement over the open list is commit-intent for
1092
+ // Tab (see handleTabKey). mousemove — not mouseover — because
1093
+ // browsers synthesize mouseover when the list renders under a
1094
+ // stationary cursor, which is zero user action.
1095
+ selectState.userNavigatedSinceOpen = true;
1096
+ }}
708
1097
  onpointerup={(ev) => {
709
1098
  if (ev.pointerType === 'mouse') ev.preventDefault();
710
1099
  ev.stopPropagation();
@@ -714,14 +1103,25 @@
714
1103
  ev.stopPropagation();
715
1104
  }}
716
1105
  role="listbox"
1106
+ tabindex="-1"
1107
+ aria-label={ariaLabel ?? listboxLabelText}
1108
+ aria-labelledby={listboxLabelledby}
1109
+ aria-busy={loading || undefined}
1110
+ aria-multiselectable={multiple || undefined}
717
1111
  id="listbox-{_id}">
718
1112
  {#if listPrependSnippet}
719
1113
  {@render listPrependSnippet()}
720
1114
  {/if}
721
1115
  {#if listSnippet}
722
- {@render listSnippet(filteredItems)}
1116
+ {@render listSnippet(filteredItems as SelectRow<Item>[])}
723
1117
  {:else if filteredItems?.length > 0}
724
- {#each filteredItems as item, i}
1118
+ {#snippet optionEntry(item: SelectItem, i: number)}
1119
+ <!-- Non-selectable group headers are aria-hidden rather than
1120
+ role="presentation": a listbox may only own option/group children,
1121
+ and groups are transparent for that check, so a presentational row
1122
+ inside one is an invalid listbox child (axe aria-required-children).
1123
+ The group's accessible name still resolves via aria-labelledby,
1124
+ which follows hidden targets. Selectable headers are real options. -->
725
1125
  <div
726
1126
  onmouseover={() => hoverManager.handleHover(i)}
727
1127
  onfocus={() => hoverManager.handleHover(i)}
@@ -735,34 +1135,55 @@
735
1135
  }}
736
1136
  class="list-item"
737
1137
  tabindex="-1"
738
- role={item.groupHeader ? 'presentation' : 'option'}
1138
+ role={isPresentationalHeader(item) ? undefined : 'option'}
739
1139
  id="listbox-{_id}-item-{i}"
740
- aria-selected={item.groupHeader ? undefined : hoverManager.isItemActive(item, normalizedValue, itemId) || false}>
1140
+ aria-hidden={isPresentationalHeader(item) ? true : undefined}
1141
+ aria-selected={isPresentationalHeader(item) ? undefined : hoverManager.isItemActive(item)}
1142
+ aria-disabled={isPresentationalHeader(item) || isItemSelectableCheck(item) ? undefined : true}>
741
1143
  <div
742
1144
  class="item"
743
1145
  class:list-group-title={item.groupHeader}
744
- class:active={hoverManager.isItemActive(item, normalizedValue, itemId)}
1146
+ class:active={hoverManager.isItemActive(item)}
745
1147
  class:first={i === 0}
1148
+ class:last={i === filteredItems.length - 1}
746
1149
  class:hover={hoverItemIndex === i}
747
1150
  class:group-item={item.groupItem}
748
- class:not-selectable={item?.selectable === false}
749
- role={item.groupHeader ? 'group' : undefined}
750
- aria-label={item.groupHeader ? item[label] : undefined}>
1151
+ class:not-selectable={item?.selectable === false}>
751
1152
  {#if itemSnippet}
752
- {@render itemSnippet(item, i)}
1153
+ {@render itemSnippet(item as SelectRow<Item>, i)}
753
1154
  {:else}
754
1155
  {item?.[label]}
755
1156
  {/if}
756
1157
  </div>
757
1158
  </div>
1159
+ {/snippet}
1160
+ {#each groupedRows as row}
1161
+ {#if row.type === 'group'}
1162
+ <!-- A real listbox group named by its header (presentational or
1163
+ selectable) via aria-labelledby, wrapping that group's options -->
1164
+ <div role="group" aria-labelledby="listbox-{_id}-item-{row.headerIndex}">
1165
+ {@render optionEntry(row.header, row.headerIndex)}
1166
+ {#each row.options as opt}
1167
+ {@render optionEntry(opt.item, opt.index)}
1168
+ {/each}
1169
+ </div>
1170
+ {:else}
1171
+ {@render optionEntry(row.item, row.index)}
1172
+ {/if}
758
1173
  {/each}
759
1174
  {:else if !hideEmptyState}
760
1175
  {#if emptySnippet}
761
1176
  {@render emptySnippet()}
762
1177
  {:else if !loading}
763
- <div class="empty">No options</div>
1178
+ <!-- Decorative: this state is announced via the role="status" live
1179
+ region below, so hide the visual copy from AT to avoid stray
1180
+ non-option text inside the listbox. The visible copy comes from
1181
+ the same ariaEmpty/ariaLoading props as the announcement, so a
1182
+ localized consumer never shows one language and announces
1183
+ another. -->
1184
+ <div class="empty" aria-hidden="true">{ariaEmpty()}</div>
764
1185
  {:else}
765
- <div class="empty">Loading Data</div>
1186
+ <div class="empty" aria-hidden="true">{ariaLoading()}</div>
766
1187
  {/if}
767
1188
  {/if}
768
1189
  {#if listAppendSnippet}
@@ -771,13 +1192,17 @@
771
1192
  </div>
772
1193
  {/if}
773
1194
 
774
- <span aria-live="polite" aria-atomic="false" aria-relevant="additions" class="a11y-text">
775
- {#if focused}
776
- <span id="aria-selection">{ariaSelection}</span>
777
- <span id="aria-context">
778
- {ariaContext}
779
- </span>
780
- {/if}
1195
+ <!-- Two dedicated polite live regions (role="status" implies aria-live="polite"
1196
+ + aria-atomic="true"). Atomic re-reads the whole region on any content change,
1197
+ which announces edits and clears reliably across screen readers — unlike the
1198
+ old aria-atomic="false"/aria-relevant="additions text" combination. The spans
1199
+ persist (only their text is gated on focus) so they exist before content
1200
+ changes, as live regions require. -->
1201
+ <span id="aria-selection-{_id}" role="status" class="a11y-text a11y-selection">
1202
+ {focused ? ariaSelection : ''}
1203
+ </span>
1204
+ <span id="aria-context-{_id}" role="status" class="a11y-text a11y-context">
1205
+ {focused ? ariaContext : ''}
781
1206
  </span>
782
1207
 
783
1208
  <div class="prepend">
@@ -789,20 +1214,55 @@
789
1214
  <div class="value-container">
790
1215
  {#if hasValue}
791
1216
  {#if multiple}
792
- {#each (Array.isArray(value) ? value : []) as item, i}
1217
+ {#each Array.isArray(value) ? (value as Item[]) : [] as item, i}
1218
+ <!-- In multiFullItemClearable mode the whole chip is the remove control, so it
1219
+ must be a focusable button with a keyboard (Enter/Space) path — otherwise
1220
+ removal is mouse-only. Otherwise it stays a non-semantic wrapper around the
1221
+ dedicated remove button below. -->
1222
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
1223
+ <!-- tabindex and role="button" are set together under the same guard, so the
1224
+ element is interactive exactly when it is focusable (the linter can't see this). -->
793
1225
  <div
794
1226
  class="multi-item"
795
- class:active={activeValue === i}
1227
+ class:active={selectState.activeValue === i}
796
1228
  class:disabled
797
1229
  onclick={(ev) => {
798
1230
  ev.preventDefault();
799
- return multiFullItemClearable ? valueManager.handleMultiItemClear(i) : {};
1231
+ // Gated like the keydown path below: a pointer must never
1232
+ // mutate a disabled control's value (see handleItemClick)
1233
+ if (multiFullItemClearable && !disabled) {
1234
+ void valueManager.handleMultiItemClear(i);
1235
+ // Like the dedicated remove button below: focus returns
1236
+ // to the input so the removal announcement is not lost —
1237
+ // the live regions are gated on `focused`, and a chip
1238
+ // press that kept focus on the chip would otherwise
1239
+ // remove the chip from the DOM and drop focus to <body>
1240
+ // with the removal never announced.
1241
+ handleFocus();
1242
+ }
800
1243
  }}
801
- onkeydown={(ev) => {
802
- ev.preventDefault();
803
- ev.stopPropagation();
804
- }}
805
- role="none">
1244
+ onpointerup={multiFullItemClearable && !disabled
1245
+ ? (ev) => {
1246
+ // Same containment as the dedicated remove button below:
1247
+ // removing a tag must not bubble to the container's list
1248
+ // toggle and pop the dropdown as a side effect
1249
+ ev.stopPropagation();
1250
+ }
1251
+ : undefined}
1252
+ onkeydown={multiFullItemClearable && !disabled
1253
+ ? (ev) => {
1254
+ if (ev.key !== 'Enter' && ev.key !== ' ') return;
1255
+ ev.preventDefault();
1256
+ ev.stopPropagation();
1257
+ valueManager.handleMultiItemClear(i);
1258
+ handleFocus();
1259
+ }
1260
+ : undefined}
1261
+ role={multiFullItemClearable && !disabled ? 'button' : 'none'}
1262
+ tabindex={multiFullItemClearable && !disabled ? 0 : undefined}
1263
+ aria-label={multiFullItemClearable && !disabled
1264
+ ? ariaRemoveItemLabel(String(item[label]))
1265
+ : undefined}>
806
1266
  <span class="multi-item-text">
807
1267
  {#if selectionSnippet}
808
1268
  {@render selectionSnippet(item, i)}
@@ -812,26 +1272,33 @@
812
1272
  </span>
813
1273
 
814
1274
  {#if !disabled && !multiFullItemClearable && ClearIcon}
815
- <div
1275
+ <!-- A real button: keyboard-focusable, Enter/Space activate via click.
1276
+ mousedown is prevented so a mouse removal never steals focus from
1277
+ the input; pointerup must not bubble to the container's list toggle. -->
1278
+ <button
1279
+ type="button"
816
1280
  class="multi-item-clear"
817
- onpointerup={(ev) => {
818
- if (ev.pointerType === 'mouse') ev.preventDefault();
1281
+ aria-label={ariaRemoveItemLabel(String(item[label]))}
1282
+ onmousedown={(ev) => ev.preventDefault()}
1283
+ onpointerup={(ev) => ev.stopPropagation()}
1284
+ onclick={(ev) => {
819
1285
  ev.stopPropagation();
820
1286
  valueManager.handleMultiItemClear(i);
1287
+ handleFocus();
821
1288
  }}>
822
1289
  {#if multiClearIconSnippet}
823
1290
  {@render multiClearIconSnippet()}
824
1291
  {:else}
825
1292
  <ClearIcon />
826
1293
  {/if}
827
- </div>
1294
+ </button>
828
1295
  {/if}
829
1296
  </div>
830
1297
  {/each}
831
1298
  {:else}
832
- <div class="selected-item" class:hide-selected-item={hideSelectedItem}>
1299
+ <div id="selected-{_id}" class="selected-item" class:hide-selected-item={hideSelectedItem}>
833
1300
  {#if selectionSnippet}
834
- {@render selectionSnippet(value as SelectItem)}
1301
+ {@render selectionSnippet(value as Item)}
835
1302
  {:else}
836
1303
  {!Array.isArray(normalizedValue) ? normalizedValue?.[label] : ''}
837
1304
  {/if}
@@ -844,13 +1311,11 @@
844
1311
  onblur={handleBlur}
845
1312
  oninput={handleInput}
846
1313
  onfocus={handleFocus}
847
- readOnly={!searchable}
848
1314
  {..._inputAttributes}
849
1315
  bind:this={input}
850
1316
  value={filterText}
851
1317
  placeholder={placeholderText}
852
- style={inputStyles}
853
- {disabled} />
1318
+ style={inputStyles} />
854
1319
  </div>
855
1320
 
856
1321
  <div class="indicators">
@@ -865,7 +1330,16 @@
865
1330
  {/if}
866
1331
 
867
1332
  {#if showClear}
868
- <button type="button" class="icon clear-select" onclick={handleClear}>
1333
+ <!-- Same guards as the tag-remove buttons: mousedown is prevented so
1334
+ clearing never steals focus from the input, and pointerup must not
1335
+ bubble to the container's list toggle (a clear would reopen the list). -->
1336
+ <button
1337
+ type="button"
1338
+ class="icon clear-select"
1339
+ aria-label={ariaClearSelectLabel}
1340
+ onmousedown={(ev) => ev.preventDefault()}
1341
+ onpointerup={(ev) => ev.stopPropagation()}
1342
+ onclick={handleClear}>
869
1343
  {#if clearIconSnippet}
870
1344
  {@render clearIconSnippet()}
871
1345
  {:else}
@@ -885,92 +1359,34 @@
885
1359
  {/if}
886
1360
  </div>
887
1361
  {#if inputHiddenSnippet}
888
- {@render inputHiddenSnippet(value as SelectValue)}
1362
+ {@render inputHiddenSnippet(value)}
889
1363
  {:else if multiple && Array.isArray(value) && value.length > 0}
890
- {#each value as item}
1364
+ {#each value as Item[] as item}
891
1365
  <input {name} type="hidden" value={useJustValue ? item[itemId] : JSON.stringify(item)} />
892
1366
  {/each}
893
1367
  {:else if !multiple}
894
1368
  <input {name} type="hidden" value={value ? (useJustValue ? justValue : JSON.stringify(value)) : ''} />
895
1369
  {/if}
896
1370
 
897
- {#if required && (!value || value.length === 0)}
1371
+ {#if required && (!value || (Array.isArray(value) && value.length === 0))}
898
1372
  {#if requiredSnippet}
899
- {@render requiredSnippet(value as SelectValue)}
1373
+ {@render requiredSnippet(value)}
900
1374
  {:else}
901
- <select class="required" required tabindex="-1" aria-hidden="true"></select>
1375
+ <!-- Constraint validation focuses the first invalid control — this one.
1376
+ An aria-hidden element must never hold focus (and this one is
1377
+ invisible), so forward it to the real combobox input immediately. -->
1378
+ <select class="required" required tabindex="-1" aria-hidden="true" onfocus={() => handleFocus()}></select>
902
1379
  {/if}
903
1380
  {/if}
904
1381
  </div>
905
1382
 
906
1383
  <style>.svelte-select {
907
- /* deprecating camelCase custom props in favour of kebab-case for v5 */
908
- --borderRadius: var(--border-radius);
909
- --clearSelectColor: var(--clear-select-color);
910
- --clearSelectWidth: var(--clear-select-width);
911
- --disabledBackground: var(--disabled-background);
912
- --disabledBorderColor: var(--disabled-border-color);
913
- --disabledColor: var(--disabled-color);
914
- --disabledPlaceholderColor: var(--disabled-placeholder-color);
915
- --disabledPlaceholderOpacity: var(--disabled-placeholder-opacity);
916
- --errorBackground: var(--error-background);
917
- --errorBorder: var(--error-border);
918
- --groupItemPaddingLeft: var(--group-item-padding-left);
919
- --groupTitleColor: var(--group-title-color);
920
- --groupTitleFontSize: var(--group-title-font-size);
921
- --groupTitleFontWeight: var(--group-title-font-weight);
922
- --groupTitlePadding: var(--group-title-padding);
923
- --groupTitleTextTransform: var(--group-title-text-transform);
924
- --groupTitleBorderColor: var(--group-title-border-color);
925
- --groupTitleBorderWidth: var(--group-title-border-width);
926
- --groupTitleBorderStyle: var(--group-title-border-style);
927
- --indicatorColor: var(--chevron-color);
928
- --indicatorHeight: var(--chevron-height);
929
- --indicatorWidth: var(--chevron-width);
930
- --inputColor: var(--input-color);
931
- --inputLeft: var(--input-left);
932
- --inputLetterSpacing: var(--input-letter-spacing);
933
- --inputMargin: var(--input-margin);
934
- --inputPadding: var(--input-padding);
935
- --itemActiveBackground: var(--item-active-background);
936
- --itemColor: var(--item-color);
937
- --itemFirstBorderRadius: var(--item-first-border-radius);
938
- --itemHoverBG: var(--item-hover-bg);
939
- --itemHoverColor: var(--item-hover-color);
940
- --itemIsActiveBG: var(--item-is-active-bg);
941
- --itemIsActiveColor: var(--item-is-active-color);
942
- --itemIsNotSelectableColor: var(--item-is-not-selectable-color);
943
- --itemPadding: var(--item-padding);
944
- --listBackground: var(--list-background);
945
- --listBorder: var(--list-border);
946
- --listBorderRadius: var(--list-border-radius);
947
- --listEmptyColor: var(--list-empty-color);
948
- --listEmptyPadding: var(--list-empty-padding);
949
- --listEmptyTextAlign: var(--list-empty-text-align);
950
- --listMaxHeight: var(--list-max-height);
951
- --listPosition: var(--list-position);
952
- --listShadow: var(--list-shadow);
953
- --listZIndex: var(--list-z-index);
954
- --multiItemBG: var(--multi-item-bg);
955
- --multiItemBorderRadius: var(--multi-item-border-radius);
956
- --multiItemDisabledHoverBg: var(--multi-item-disabled-hover-bg);
957
- --multiItemDisabledHoverColor: var(--multi-item-disabled-hover-color);
958
- --multiItemHeight: var(--multi-item-height);
959
- --multiItemMargin: var(--multi-item-margin);
960
- --multiItemPadding: var(--multi-item-padding);
961
- --multiSelectInputMargin: var(--multi-select-input-margin);
962
- --multiSelectInputPadding: var(--multi-select-input-padding);
963
- --multiSelectPadding: var(--multi-select-padding);
964
- --placeholderColor: var(--placeholder-color);
965
- --placeholderOpacity: var(--placeholder-opacity);
966
- --selectedItemPadding: var(--selected-item-padding);
967
- --spinnerColor: var(--spinner-color);
968
- --spinnerHeight: var(--spinner-height);
969
- --spinnerWidth: var(--spinner-width);
970
-
971
1384
  --internal-padding: 0 0 0 16px;
972
1385
 
973
- border: var(--border, 1px solid #d8dbdf);
1386
+ /* #858a93 is 3.47:1 on the default #fff background. The old #d8dbdf was 1.39:1 —
1387
+ the border is what identifies the control's boundary, so it needs 3:1
1388
+ (WCAG 1.4.11 Non-text Contrast). Override --border to restyle. */
1389
+ border: var(--border, 1px solid #858a93);
974
1390
  border-radius: var(--border-radius, 6px);
975
1391
  min-height: var(--height, 42px);
976
1392
  position: relative;
@@ -989,7 +1405,9 @@
989
1405
  }
990
1406
 
991
1407
  .svelte-select:hover {
992
- border: var(--border-hover, 1px solid #b2b8bf);
1408
+ /* Darker than --border so hover stays a visible change, and 4.91:1 on #fff
1409
+ (the old #b2b8bf was 2.00:1) */
1410
+ border: var(--border-hover, 1px solid #67727d);
993
1411
  }
994
1412
 
995
1413
  .value-container {
@@ -1041,7 +1459,7 @@ input {
1041
1459
  }
1042
1460
 
1043
1461
  input::placeholder {
1044
- color: var(--placeholder-color, #78848f);
1462
+ color: var(--placeholder-color, #67727d);
1045
1463
  opacity: var(--placeholder-opacity, 1);
1046
1464
  }
1047
1465
 
@@ -1052,6 +1470,10 @@ input:focus {
1052
1470
  .svelte-select.focused {
1053
1471
  border: var(--border-focused, 1px solid #006fe8);
1054
1472
  border-radius: var(--border-radius-focused, var(--border-radius, 6px));
1473
+ /* A focus ring in addition to the border colour change: the input's own
1474
+ outline is suppressed, so relying on the 1px border alone was a
1475
+ colour-only cue (WCAG 2.4.7/2.4.11) that vanished if --border was overridden. */
1476
+ box-shadow: var(--focused-box-shadow, 0 0 0 2px rgba(0, 111, 232, 0.4));
1055
1477
  }
1056
1478
 
1057
1479
  .disabled {
@@ -1108,7 +1530,7 @@ input:focus {
1108
1530
  flex-shrink: 0;
1109
1531
  }
1110
1532
 
1111
- .clear-select:focus {
1533
+ .clear-select:focus-visible {
1112
1534
  outline: var(--clear-select-focus-outline, 1px solid #006fe8);
1113
1535
  }
1114
1536
 
@@ -1188,12 +1610,33 @@ input:focus {
1188
1610
  }
1189
1611
 
1190
1612
  .multi-item-clear {
1613
+ all: unset;
1191
1614
  display: flex;
1192
1615
  align-items: center;
1193
1616
  justify-content: center;
1617
+ cursor: pointer;
1618
+ /* The button stretches to the chip's height (25px); without a width floor
1619
+ it shrinks to the 20px icon, under the 24px activation-target minimum
1620
+ (WCAG 2.2 2.5.8) */
1621
+ min-width: var(--multi-item-clear-min-width, 24px);
1194
1622
  --clear-icon-color: var(--multi-item-clear-icon-color, #000);
1195
1623
  }
1196
1624
 
1625
+ .multi-item-clear:focus-visible {
1626
+ outline: var(--multi-item-clear-focus-outline, 1px solid #006fe8);
1627
+ }
1628
+
1629
+ /* With multiFullItemClearable the whole chip is a real tab stop (role="button",
1630
+ tabindex="0"). .multi-item sets `outline` unconditionally, and an author outline
1631
+ beats the UA's :focus-visible ring by cascade origin, so without this rule a
1632
+ keyboard-focused chip looked identical to an unfocused one (WCAG 2.4.7 Focus
1633
+ Visible). Distinct from .multi-item.active below, which is the arrow-key tag
1634
+ cursor, not DOM focus. */
1635
+ .multi-item:focus-visible {
1636
+ outline: var(--multi-item-focus-outline, 2px solid #006fe8);
1637
+ outline-offset: -1px;
1638
+ }
1639
+
1197
1640
  .multi-item.active {
1198
1641
  outline: var(--multi-item-active-outline, 1px solid #006fe8);
1199
1642
  }
@@ -1234,7 +1677,7 @@ input:focus {
1234
1677
  .empty {
1235
1678
  text-align: var(--list-empty-text-align, center);
1236
1679
  padding: var(--list-empty-padding, 20px 0);
1237
- color: var(--list-empty-color, #78848f);
1680
+ color: var(--list-empty-color, #67727d);
1238
1681
  }
1239
1682
 
1240
1683
  .item {
@@ -1260,7 +1703,7 @@ input:focus {
1260
1703
  }
1261
1704
 
1262
1705
  .item.active {
1263
- background: var(--item-is-active-bg, #007aff);
1706
+ background: var(--item-is-active-bg, #006fe8);
1264
1707
  color: var(--item-is-active-color, #fff);
1265
1708
  }
1266
1709
 
@@ -1268,9 +1711,28 @@ input:focus {
1268
1711
  border-radius: var(--item-first-border-radius, 4px 4px 0 0);
1269
1712
  }
1270
1713
 
1714
+ /* Mirror of .item.first: without it the last item's hover/selection background
1715
+ (and the keyboard-cursor outline) square off against the list's rounded
1716
+ bottom corners */
1717
+ .item.last {
1718
+ border-radius: var(--item-last-border-radius, 0 0 4px 4px);
1719
+ }
1720
+
1721
+ /* A single-option list is both first and last: round all corners (the two
1722
+ shorthand variables above cannot compose, so this matches their defaults) */
1723
+ .item.first.last {
1724
+ border-radius: 4px;
1725
+ }
1726
+
1271
1727
  .item.hover:not(.active) {
1272
1728
  background: var(--item-hover-bg, #e7f2ff);
1273
1729
  color: var(--item-hover-color, inherit);
1730
+ /* The background alone is ~1.13:1 against the default #fff list — a
1731
+ colour-only cue for the keyboard cursor (WCAG 1.4.11 Non-text Contrast).
1732
+ #006fe8 is 4.5:1 on #fff and ~4.1:1 on the hover background; set
1733
+ --item-hover-outline to `none` to opt out. */
1734
+ outline: var(--item-hover-outline, 2px solid #006fe8);
1735
+ outline-offset: -2px;
1274
1736
  }
1275
1737
 
1276
1738
  .item.not-selectable,
@@ -1279,6 +1741,22 @@ input:focus {
1279
1741
  .item.not-selectable:active {
1280
1742
  color: var(--item-is-not-selectable-color, #999);
1281
1743
  background: transparent;
1744
+ /* Not actionable, so it must not show the keyboard-cursor outline either */
1745
+ outline: none;
1746
+ }
1747
+
1748
+ /* Group titles are informative text, not disabled controls, so WCAG 1.4.3's
1749
+ inactive-component exemption does not apply to them: the not-selectable
1750
+ dimming above (#999 = 2.84:1) must not reach a header. Keep them at the
1751
+ group-title color (default #000 = 21:1 on white). The hover variant must
1752
+ win over `.item.hover.item.not-selectable` too: the first frame parks the
1753
+ keyboard cursor on index 0 before the keep-hover effect moves it off a
1754
+ header, and without it that frame paints #999 which then *animates* back
1755
+ through `transition: all` — mid-transition greys fail contrast when
1756
+ sampled (the CI-only axe flake). */
1757
+ .item.list-group-title.not-selectable,
1758
+ .item.hover.item.list-group-title.not-selectable {
1759
+ color: var(--group-title-color, #000000);
1282
1760
  }
1283
1761
 
1284
1762
  .required {
@@ -1290,4 +1768,44 @@ input:focus {
1290
1768
  bottom: 0;
1291
1769
  right: 0;
1292
1770
  }
1771
+
1772
+ @media (prefers-reduced-motion: reduce) {
1773
+ .item {
1774
+ transition: none;
1775
+ }
1776
+ }
1777
+
1778
+ @media (forced-colors: active) {
1779
+ /* box-shadow is dropped in forced-colors mode, so the focus ring above is
1780
+ invisible there — restore a real outline using a system colour. */
1781
+ .svelte-select.focused {
1782
+ outline: 2px solid Highlight;
1783
+ outline-offset: 1px;
1784
+ }
1785
+
1786
+ /* Custom background colours are also flattened, so distinguish the selected
1787
+ option (filled) from the option under the keyboard cursor (outlined). */
1788
+ .item.active {
1789
+ background: Highlight;
1790
+ color: HighlightText;
1791
+ }
1792
+
1793
+ .item.hover:not(.active) {
1794
+ outline: 2px solid Highlight;
1795
+ outline-offset: -2px;
1796
+ }
1797
+
1798
+ /* The chip's focus ring is an author colour, which is flattened here */
1799
+ .multi-item:focus-visible {
1800
+ outline: 2px solid Highlight;
1801
+ }
1802
+
1803
+ /* Every chip carries a 1px outline and the arrow-key tag cursor differs
1804
+ only by outline colour — flattened to the same system colour here, the
1805
+ Backspace target vanished. Width + style survive the forced palette. */
1806
+ .multi-item.active {
1807
+ outline: 2px dashed Highlight;
1808
+ outline-offset: -2px;
1809
+ }
1810
+ }
1293
1811
  </style>