svelte-5-select 1.0.2 → 2.1.0

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