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.
- package/README.md +255 -115
- package/dist/ChevronIcon.svelte +14 -13
- package/dist/ChevronIcon.svelte.d.ts +6 -14
- package/dist/ClearIcon.svelte +9 -11
- package/dist/ClearIcon.svelte.d.ts +6 -14
- package/dist/LoadingIcon.svelte +15 -2
- package/dist/LoadingIcon.svelte.d.ts +6 -14
- package/dist/Select.svelte +1048 -442
- package/dist/Select.svelte.d.ts +37 -5
- package/dist/aria-handlers.svelte.d.ts +5 -2
- package/dist/aria-handlers.svelte.js +30 -8
- package/dist/filter.d.ts +10 -2
- package/dist/filter.js +15 -8
- package/dist/index.d.ts +3 -4
- package/dist/index.js +2 -3
- package/dist/keyboard-navigation.svelte.d.ts +7 -2
- package/dist/keyboard-navigation.svelte.js +293 -47
- package/dist/no-styles/ChevronIcon.svelte +17 -0
- package/dist/no-styles/ChevronIcon.svelte.d.ts +18 -0
- package/dist/no-styles/ClearIcon.svelte +14 -0
- package/dist/no-styles/ClearIcon.svelte.d.ts +18 -0
- package/dist/no-styles/LoadingIcon.svelte +19 -0
- package/dist/no-styles/LoadingIcon.svelte.d.ts +18 -0
- package/dist/no-styles/Select.svelte +1452 -0
- package/dist/no-styles/Select.svelte.d.ts +38 -0
- package/dist/select-state.svelte.d.ts +15 -0
- package/dist/select-state.svelte.js +161 -0
- package/dist/styles/default.css +131 -71
- package/dist/tailwind.css +139 -17
- package/dist/types.d.ts +488 -129
- package/dist/use-hover.svelte.d.ts +3 -7
- package/dist/use-hover.svelte.js +110 -36
- package/dist/use-load-options.svelte.d.ts +18 -3
- package/dist/use-load-options.svelte.js +362 -42
- package/dist/use-value.svelte.d.ts +2 -11
- package/dist/use-value.svelte.js +334 -111
- package/dist/utils.d.ts +19 -8
- package/dist/utils.js +52 -8
- package/package.json +121 -94
|
@@ -0,0 +1,1452 @@
|
|
|
1
|
+
<svelte:options runes={true} />
|
|
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';
|
|
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';
|
|
24
|
+
import { createFloatingActions } from 'svelte-floating-ui';
|
|
25
|
+
import { useAriaHandlers } from '../aria-handlers.svelte';
|
|
26
|
+
|
|
27
|
+
import _filter from '../filter.js';
|
|
28
|
+
|
|
29
|
+
import ChevronIcon from './ChevronIcon.svelte';
|
|
30
|
+
import ClearIcon from './ClearIcon.svelte';
|
|
31
|
+
import LoadingIcon from './LoadingIcon.svelte';
|
|
32
|
+
import type { SelectItem } from '../types.js';
|
|
33
|
+
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
34
|
+
import { createSelectState } from '../select-state.svelte';
|
|
35
|
+
import { useKeyboardNavigation } from '../keyboard-navigation.svelte';
|
|
36
|
+
import { useHover } from '../use-hover.svelte';
|
|
37
|
+
import { useValue } from '../use-value.svelte';
|
|
38
|
+
import { useLoadOptions } from '../use-load-options.svelte';
|
|
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 =>
|
|
49
|
+
`${label}`.toLowerCase().includes(filterText?.toLowerCase());
|
|
50
|
+
|
|
51
|
+
const defaultOnError = (_error: SelectErrorEvent): void => {};
|
|
52
|
+
const defaultOnLoaded = (_options: SelectItem[]): void => {};
|
|
53
|
+
|
|
54
|
+
let timeout = $state<ReturnType<typeof setTimeout>>();
|
|
55
|
+
|
|
56
|
+
let {
|
|
57
|
+
// Core data props
|
|
58
|
+
filterText = $bindable(''),
|
|
59
|
+
itemId = 'value',
|
|
60
|
+
items = $bindable<Item[] | string[] | null>(null),
|
|
61
|
+
justValue = $bindable(),
|
|
62
|
+
label = 'label',
|
|
63
|
+
value = $bindable(),
|
|
64
|
+
|
|
65
|
+
// UI props
|
|
66
|
+
disabled = false,
|
|
67
|
+
focused = $bindable(false),
|
|
68
|
+
hasError = false,
|
|
69
|
+
id = null,
|
|
70
|
+
listOpen = $bindable(false),
|
|
71
|
+
loading = $bindable(false),
|
|
72
|
+
name = null,
|
|
73
|
+
placeholder = 'Please select',
|
|
74
|
+
placeholderAlwaysShow = false,
|
|
75
|
+
showChevron = false,
|
|
76
|
+
|
|
77
|
+
// Behavior props
|
|
78
|
+
clearable = true,
|
|
79
|
+
clearFilterTextOnBlur = true,
|
|
80
|
+
closeListOnChange = true,
|
|
81
|
+
filterSelectedItems = true,
|
|
82
|
+
groupHeaderSelectable = false,
|
|
83
|
+
multiFullItemClearable = false,
|
|
84
|
+
multiple = false as Multiple,
|
|
85
|
+
required = false,
|
|
86
|
+
searchable = true,
|
|
87
|
+
useJustValue = false,
|
|
88
|
+
|
|
89
|
+
// Styling props
|
|
90
|
+
containerStyles = '',
|
|
91
|
+
inputStyles = '',
|
|
92
|
+
listStyles = '',
|
|
93
|
+
hideEmptyState = false,
|
|
94
|
+
|
|
95
|
+
// Advanced props
|
|
96
|
+
debounceWait = 300,
|
|
97
|
+
floatingConfig = {},
|
|
98
|
+
hoverItemIndex = $bindable(0),
|
|
99
|
+
inputAttributes = {},
|
|
100
|
+
listAutoWidth = true,
|
|
101
|
+
listOffset = 5,
|
|
102
|
+
loadOptionsDeps = [],
|
|
103
|
+
|
|
104
|
+
// Function props
|
|
105
|
+
createGroupHeaderItem = (groupValue: string, _item: Item) => {
|
|
106
|
+
return _createGroupHeaderItem(groupValue, label);
|
|
107
|
+
},
|
|
108
|
+
debounce = (fn: () => void, wait = 1) => {
|
|
109
|
+
clearTimeout(timeout);
|
|
110
|
+
timeout = setTimeout(fn, wait);
|
|
111
|
+
},
|
|
112
|
+
filter = _filter,
|
|
113
|
+
groupBy = undefined,
|
|
114
|
+
groupFilter = (groups: string[]) => groups,
|
|
115
|
+
itemFilter = defaultItemFilter,
|
|
116
|
+
loadOptions = undefined,
|
|
117
|
+
|
|
118
|
+
// ARIA props
|
|
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
|
+
},
|
|
131
|
+
ariaFocused = () => {
|
|
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.`;
|
|
137
|
+
},
|
|
138
|
+
ariaListOpen = (label: string, count: number) => {
|
|
139
|
+
return `You are currently focused on option ${label}. There are ${count} results available.`;
|
|
140
|
+
},
|
|
141
|
+
ariaLoading = () => {
|
|
142
|
+
return `Loading Data`;
|
|
143
|
+
},
|
|
144
|
+
ariaValues = (values: string) => {
|
|
145
|
+
return `Option ${values}, selected.`;
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
// Custom behavior
|
|
149
|
+
handleClear = internalHandleClear,
|
|
150
|
+
|
|
151
|
+
// Event handlers
|
|
152
|
+
onblur = () => {},
|
|
153
|
+
onclear = () => {},
|
|
154
|
+
onerror = defaultOnError,
|
|
155
|
+
onfilter = () => {},
|
|
156
|
+
onfocus = () => {},
|
|
157
|
+
onhoveritem = () => {},
|
|
158
|
+
onloaded = defaultOnLoaded,
|
|
159
|
+
onselect = () => {},
|
|
160
|
+
onSelectionChange = () => {},
|
|
161
|
+
onValueChange = () => {},
|
|
162
|
+
|
|
163
|
+
// Snippet props
|
|
164
|
+
chevronIconSnippet,
|
|
165
|
+
clearIconSnippet,
|
|
166
|
+
emptySnippet,
|
|
167
|
+
inputHiddenSnippet,
|
|
168
|
+
itemSnippet,
|
|
169
|
+
listAppendSnippet,
|
|
170
|
+
listPrependSnippet,
|
|
171
|
+
listSnippet,
|
|
172
|
+
prependSnippet,
|
|
173
|
+
loadingIconSnippet,
|
|
174
|
+
multiClearIconSnippet,
|
|
175
|
+
requiredSnippet,
|
|
176
|
+
selectionSnippet,
|
|
177
|
+
|
|
178
|
+
// DOM references (for binding)
|
|
179
|
+
container = $bindable(undefined),
|
|
180
|
+
input = $bindable(undefined),
|
|
181
|
+
...rest
|
|
182
|
+
}: SelectProps<Item, Multiple> = $props();
|
|
183
|
+
|
|
184
|
+
let normalizedValue = $derived<SelectItem | SelectItem[] | null>(normalizeItem(value));
|
|
185
|
+
|
|
186
|
+
const _uid = $props.id();
|
|
187
|
+
const _generatedId = `svelte-select-${_uid}`;
|
|
188
|
+
let _id = $derived(id ?? _generatedId);
|
|
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
|
+
|
|
193
|
+
const DEFAULT_INPUT_ATTRS = {
|
|
194
|
+
autocapitalize: 'none',
|
|
195
|
+
autocomplete: 'off',
|
|
196
|
+
autocorrect: 'off',
|
|
197
|
+
spellcheck: false,
|
|
198
|
+
tabindex: 0,
|
|
199
|
+
type: 'text',
|
|
200
|
+
'aria-autocomplete': 'list',
|
|
201
|
+
} as const;
|
|
202
|
+
|
|
203
|
+
const ariaHandlers = useAriaHandlers({
|
|
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
|
+
},
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
function internalHandleClear(_e?: MouseEvent): void {
|
|
222
|
+
selectState.clearState = true;
|
|
223
|
+
onclear(value as unknown as SelectClearValue<Item, Multiple>);
|
|
224
|
+
value = undefined;
|
|
225
|
+
closeList();
|
|
226
|
+
handleFocus();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
let list = $state<HTMLDivElement | undefined>();
|
|
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;
|
|
350
|
+
});
|
|
351
|
+
let prefloat = $state(true);
|
|
352
|
+
let hasValue = $derived(multiple ? Array.isArray(value) && value.length > 0 : !!value);
|
|
353
|
+
let placeholderText = $derived(
|
|
354
|
+
placeholderAlwaysShow && multiple
|
|
355
|
+
? placeholder
|
|
356
|
+
: multiple && Array.isArray(value) && value.length === 0
|
|
357
|
+
? placeholder
|
|
358
|
+
: value
|
|
359
|
+
? ''
|
|
360
|
+
: placeholder,
|
|
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
|
+
|
|
392
|
+
let ariaSelection = $derived(
|
|
393
|
+
hasValue
|
|
394
|
+
? ariaHandlers.handleAriaSelection({
|
|
395
|
+
value,
|
|
396
|
+
filteredItems,
|
|
397
|
+
hoverItemIndex,
|
|
398
|
+
listOpen,
|
|
399
|
+
multiple,
|
|
400
|
+
label,
|
|
401
|
+
})
|
|
402
|
+
: selectionCleared
|
|
403
|
+
? ariaCleared()
|
|
404
|
+
: '',
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
// svelte-floating-ui keeps a reference to this object; effects below mutate it in place
|
|
408
|
+
let _floatingConfig = $state<FloatingConfig>({
|
|
409
|
+
strategy: 'absolute',
|
|
410
|
+
placement: 'bottom-start',
|
|
411
|
+
autoUpdate: false,
|
|
412
|
+
});
|
|
413
|
+
|
|
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
|
+
},
|
|
508
|
+
});
|
|
509
|
+
|
|
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
|
+
);
|
|
542
|
+
});
|
|
543
|
+
|
|
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)[]),
|
|
560
|
+
onerror: (err) => onerror(err),
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
const keyboardNav = useKeyboardNavigation(selectState, {
|
|
564
|
+
closeList,
|
|
565
|
+
setHoverIndex: hoverManager.setHoverIndex,
|
|
566
|
+
handleSelect,
|
|
567
|
+
handleMultiItemClear: valueManager.handleMultiItemClear,
|
|
568
|
+
});
|
|
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
|
+
}
|
|
598
|
+
|
|
599
|
+
const [floatingRef, floatingContent, floatingUpdate] = createFloatingActions(_floatingConfig);
|
|
600
|
+
|
|
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;
|
|
605
|
+
if (listOpen) focused = true;
|
|
606
|
+
if (focused && input) input.focus();
|
|
607
|
+
});
|
|
608
|
+
|
|
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.
|
|
613
|
+
$effect.pre(() => {
|
|
614
|
+
const middleware = [offset(listOffset), flip(), shift()];
|
|
615
|
+
floatingConfig;
|
|
616
|
+
untrack(() => {
|
|
617
|
+
_floatingConfig.middleware = middleware;
|
|
618
|
+
if (floatingConfig) Object.assign(_floatingConfig, floatingConfig);
|
|
619
|
+
if (container && list) {
|
|
620
|
+
floatingUpdate(_floatingConfig);
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
|
|
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;
|
|
633
|
+
$effect(() => {
|
|
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
|
+
});
|
|
652
|
+
});
|
|
653
|
+
|
|
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;
|
|
662
|
+
$effect(() => {
|
|
663
|
+
filterText;
|
|
664
|
+
untrack(() => {
|
|
665
|
+
if (!filterTextSeeded) {
|
|
666
|
+
filterTextSeeded = true;
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
setupFilterText();
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
|
|
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.
|
|
676
|
+
$effect(() => {
|
|
677
|
+
hoverItemIndex;
|
|
678
|
+
untrack(() => onhoveritem?.(hoverItemIndex));
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
// Fire onfilter — untracked like onhoveritem; an onfilter that writes
|
|
682
|
+
// `items` would otherwise loop through filteredItems
|
|
683
|
+
$effect(() => {
|
|
684
|
+
filteredItems;
|
|
685
|
+
listOpen;
|
|
686
|
+
untrack(() => {
|
|
687
|
+
if (filteredItems && listOpen) onfilter?.(filteredItems as SelectRow<Item>[]);
|
|
688
|
+
});
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
// Floating UI config
|
|
692
|
+
$effect(() => {
|
|
693
|
+
if (container && floatingConfig) {
|
|
694
|
+
untrack(() => {
|
|
695
|
+
Object.assign(_floatingConfig, floatingConfig);
|
|
696
|
+
floatingUpdate(_floatingConfig);
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
// List mounted
|
|
702
|
+
$effect(() => {
|
|
703
|
+
listOpen;
|
|
704
|
+
untrack(() => {
|
|
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
|
+
}
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
// Focus when list opens
|
|
721
|
+
$effect(() => {
|
|
722
|
+
if (input && listOpen && !focused) handleFocus();
|
|
723
|
+
});
|
|
724
|
+
|
|
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.
|
|
729
|
+
$effect(() => {
|
|
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
|
+
});
|
|
743
|
+
});
|
|
744
|
+
|
|
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;
|
|
752
|
+
$effect(() => {
|
|
753
|
+
if (!DEV) return;
|
|
754
|
+
const currentItems = items;
|
|
755
|
+
const currentItemId = itemId;
|
|
756
|
+
untrack(() => {
|
|
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
|
+
);
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
|
|
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;
|
|
782
|
+
$effect(() => {
|
|
783
|
+
if (!DEV) return;
|
|
784
|
+
const currentValue = value;
|
|
785
|
+
const currentItemId = itemId;
|
|
786
|
+
untrack(() => {
|
|
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
|
+
);
|
|
801
|
+
});
|
|
802
|
+
});
|
|
803
|
+
|
|
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.
|
|
807
|
+
$effect(() => {
|
|
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;
|
|
815
|
+
untrack(() => {
|
|
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
|
+
);
|
|
831
|
+
}
|
|
832
|
+
});
|
|
833
|
+
});
|
|
834
|
+
|
|
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>();
|
|
845
|
+
$effect(() => {
|
|
846
|
+
listOpen;
|
|
847
|
+
ariaLabel;
|
|
848
|
+
untrack(() => {
|
|
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
|
+
}
|
|
886
|
+
});
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
// Auto update floating config
|
|
890
|
+
$effect(() => {
|
|
891
|
+
if (container && floatingConfig?.autoUpdate === undefined) {
|
|
892
|
+
_floatingConfig.autoUpdate = true;
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
// Disabled state
|
|
897
|
+
$effect(() => {
|
|
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;
|
|
906
|
+
listOpen = false;
|
|
907
|
+
filterText = '';
|
|
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
|
+
});
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
function applyGrouping(_items: SelectItem[]): SelectItem[] {
|
|
919
|
+
if (!groupBy) return _items;
|
|
920
|
+
|
|
921
|
+
const groupValues: string[] = [];
|
|
922
|
+
const groups: Record<string, SelectItem[]> = {};
|
|
923
|
+
|
|
924
|
+
_items.forEach((item) => {
|
|
925
|
+
const groupValue: string = groupBy(item as Item);
|
|
926
|
+
|
|
927
|
+
if (!groupValues.includes(groupValue)) {
|
|
928
|
+
groupValues.push(groupValue);
|
|
929
|
+
groups[groupValue] = [];
|
|
930
|
+
if (groupValue) {
|
|
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);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
groups[groupValue].push(Object.assign({ groupItem: !!groupValue }, item));
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
const sortedGroupedItems: SelectItem[] = [];
|
|
953
|
+
|
|
954
|
+
groupFilter(groupValues).forEach((groupValue: string) => {
|
|
955
|
+
if (groups[groupValue]) sortedGroupedItems.push(...groups[groupValue]);
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
return sortedGroupedItems;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function setupFilterText() {
|
|
962
|
+
if (loadOptions) {
|
|
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;
|
|
970
|
+
}
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
if (filterText.length === 0) return;
|
|
975
|
+
|
|
976
|
+
listOpen = true;
|
|
977
|
+
|
|
978
|
+
if (multiple) {
|
|
979
|
+
selectState.activeValue = undefined;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
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;
|
|
987
|
+
if (focused && input === document?.activeElement) return;
|
|
988
|
+
if (e) {
|
|
989
|
+
onfocus?.(e);
|
|
990
|
+
}
|
|
991
|
+
input?.focus();
|
|
992
|
+
focused = true;
|
|
993
|
+
}
|
|
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
|
+
|
|
1000
|
+
async function handleBlur(e?: FocusEvent): Promise<void> {
|
|
1001
|
+
if (selectState.isScrolling) {
|
|
1002
|
+
pendingBlur = e ?? true;
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
pendingBlur = false;
|
|
1006
|
+
if (listOpen || focused) {
|
|
1007
|
+
if (e) onblur?.(e);
|
|
1008
|
+
closeList();
|
|
1009
|
+
focused = false;
|
|
1010
|
+
selectState.activeValue = undefined;
|
|
1011
|
+
input?.blur();
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
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
|
+
|
|
1027
|
+
function handleClick(ev: MouseEvent) {
|
|
1028
|
+
ev.preventDefault();
|
|
1029
|
+
if (disabled) return;
|
|
1030
|
+
if (filterText && filterText.length > 0) return (listOpen = true);
|
|
1031
|
+
listOpen = !listOpen;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function closeList() {
|
|
1035
|
+
if (clearFilterTextOnBlur) {
|
|
1036
|
+
filterText = '';
|
|
1037
|
+
}
|
|
1038
|
+
listOpen = false;
|
|
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();
|
|
1047
|
+
}
|
|
1048
|
+
|
|
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
|
|
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();
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
function handleSelect(item: SelectItem): void {
|
|
1061
|
+
if (!item || item.selectable === false) return;
|
|
1062
|
+
valueManager.itemSelected(item);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
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;
|
|
1070
|
+
if (item?.selectable === false) return;
|
|
1071
|
+
if (!multiple && !Array.isArray(normalizedValue) && areItemsEqual(normalizedValue, item, itemId))
|
|
1072
|
+
return closeList();
|
|
1073
|
+
if (hoverManager.isItemSelectable(item)) {
|
|
1074
|
+
hoverItemIndex = i;
|
|
1075
|
+
handleSelect(item);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function setListWidth(): void {
|
|
1080
|
+
if (!container || !list) return;
|
|
1081
|
+
const { width } = container.getBoundingClientRect();
|
|
1082
|
+
list.style.width = listAutoWidth ? width + 'px' : 'auto';
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
let prefloatTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
1086
|
+
function listMounted(list: HTMLDivElement | undefined, listOpen: boolean) {
|
|
1087
|
+
if (!list || !listOpen) return (prefloat = true);
|
|
1088
|
+
clearTimeout(prefloatTimeout);
|
|
1089
|
+
prefloatTimeout = setTimeout(() => {
|
|
1090
|
+
prefloat = false;
|
|
1091
|
+
}, 0);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
function handleInput(ev: Event): void {
|
|
1095
|
+
const target = ev.target as HTMLInputElement;
|
|
1096
|
+
listOpen = true;
|
|
1097
|
+
selectState.prevFilterText = filterText;
|
|
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;
|
|
1105
|
+
}
|
|
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
|
+
*/
|
|
1112
|
+
export function reset(): void {
|
|
1113
|
+
selectState.clearState = true;
|
|
1114
|
+
value = undefined;
|
|
1115
|
+
filterText = '';
|
|
1116
|
+
listOpen = false;
|
|
1117
|
+
hoverItemIndex = 0;
|
|
1118
|
+
selectState.activeValue = undefined;
|
|
1119
|
+
justValue = undefined;
|
|
1120
|
+
focused = false;
|
|
1121
|
+
}
|
|
1122
|
+
</script>
|
|
1123
|
+
|
|
1124
|
+
<!-- Outside clicks close the list via the input's native blur -->
|
|
1125
|
+
<svelte:window onkeydown={handleKeyDown} />
|
|
1126
|
+
|
|
1127
|
+
<div
|
|
1128
|
+
class="svelte-select {rest.class}"
|
|
1129
|
+
class:multi={multiple}
|
|
1130
|
+
class:disabled
|
|
1131
|
+
class:focused
|
|
1132
|
+
class:list-open={listOpen}
|
|
1133
|
+
class:show-chevron={showChevron}
|
|
1134
|
+
class:error={hasError}
|
|
1135
|
+
style={containerStyles}
|
|
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
|
+
}}
|
|
1147
|
+
bind:this={container}
|
|
1148
|
+
use:floatingRef
|
|
1149
|
+
role="none">
|
|
1150
|
+
{#if listOpen}
|
|
1151
|
+
<div
|
|
1152
|
+
use:floatingContent
|
|
1153
|
+
bind:this={list}
|
|
1154
|
+
class="svelte-select-list"
|
|
1155
|
+
class:prefloat
|
|
1156
|
+
style={listStyles}
|
|
1157
|
+
onscroll={hoverManager.handleListScroll}
|
|
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
|
+
}}
|
|
1166
|
+
onpointerup={(ev) => {
|
|
1167
|
+
if (ev.pointerType === 'mouse') ev.preventDefault();
|
|
1168
|
+
ev.stopPropagation();
|
|
1169
|
+
}}
|
|
1170
|
+
onmousedown={(ev) => {
|
|
1171
|
+
ev.preventDefault();
|
|
1172
|
+
ev.stopPropagation();
|
|
1173
|
+
}}
|
|
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}
|
|
1180
|
+
id="listbox-{_id}">
|
|
1181
|
+
{#if listPrependSnippet}
|
|
1182
|
+
{@render listPrependSnippet()}
|
|
1183
|
+
{/if}
|
|
1184
|
+
{#if listSnippet}
|
|
1185
|
+
{@render listSnippet(filteredItems as SelectRow<Item>[])}
|
|
1186
|
+
{:else if filteredItems?.length > 0}
|
|
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. -->
|
|
1194
|
+
<div
|
|
1195
|
+
onmousemove={() => hoverManager.handleHover(i)}
|
|
1196
|
+
onfocus={() => hoverManager.handleHover(i)}
|
|
1197
|
+
onclick={(ev) => {
|
|
1198
|
+
ev.stopPropagation();
|
|
1199
|
+
handleItemClick(item, i);
|
|
1200
|
+
}}
|
|
1201
|
+
onkeydown={(ev) => {
|
|
1202
|
+
ev.preventDefault();
|
|
1203
|
+
ev.stopPropagation();
|
|
1204
|
+
}}
|
|
1205
|
+
class="list-item"
|
|
1206
|
+
tabindex="-1"
|
|
1207
|
+
role={isPresentationalHeader(item) ? undefined : 'option'}
|
|
1208
|
+
id="listbox-{_id}-item-{i}"
|
|
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}>
|
|
1212
|
+
<div
|
|
1213
|
+
class="item"
|
|
1214
|
+
class:list-group-title={item.groupHeader}
|
|
1215
|
+
class:active={hoverManager.isItemActive(item)}
|
|
1216
|
+
class:first={i === 0}
|
|
1217
|
+
class:last={i === filteredItems.length - 1}
|
|
1218
|
+
class:hover={hoverItemIndex === i}
|
|
1219
|
+
class:group-item={item.groupItem}
|
|
1220
|
+
class:not-selectable={item?.selectable === false}>
|
|
1221
|
+
{#if itemSnippet}
|
|
1222
|
+
{@render itemSnippet(item as SelectRow<Item>, i)}
|
|
1223
|
+
{:else}
|
|
1224
|
+
{item?.[label]}
|
|
1225
|
+
{/if}
|
|
1226
|
+
</div>
|
|
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}
|
|
1242
|
+
{/each}
|
|
1243
|
+
{:else if !hideEmptyState}
|
|
1244
|
+
{#if emptySnippet}
|
|
1245
|
+
{@render emptySnippet()}
|
|
1246
|
+
{:else if !loading}
|
|
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>
|
|
1254
|
+
{:else}
|
|
1255
|
+
<div class="empty" aria-hidden="true">{ariaLoading()}</div>
|
|
1256
|
+
{/if}
|
|
1257
|
+
{/if}
|
|
1258
|
+
{#if listAppendSnippet}
|
|
1259
|
+
{@render listAppendSnippet()}
|
|
1260
|
+
{/if}
|
|
1261
|
+
</div>
|
|
1262
|
+
{/if}
|
|
1263
|
+
|
|
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 : ''}
|
|
1275
|
+
</span>
|
|
1276
|
+
|
|
1277
|
+
<div class="prepend">
|
|
1278
|
+
{#if prependSnippet}
|
|
1279
|
+
{@render prependSnippet()}
|
|
1280
|
+
{/if}
|
|
1281
|
+
</div>
|
|
1282
|
+
|
|
1283
|
+
<div class="value-container">
|
|
1284
|
+
{#if hasValue}
|
|
1285
|
+
{#if multiple}
|
|
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). -->
|
|
1294
|
+
<div
|
|
1295
|
+
class="multi-item"
|
|
1296
|
+
class:active={selectState.activeValue === i}
|
|
1297
|
+
class:disabled
|
|
1298
|
+
onclick={(ev) => {
|
|
1299
|
+
ev.preventDefault();
|
|
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
|
+
}
|
|
1312
|
+
}}
|
|
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}>
|
|
1335
|
+
<span class="multi-item-text">
|
|
1336
|
+
{#if selectionSnippet}
|
|
1337
|
+
{@render selectionSnippet(item, i)}
|
|
1338
|
+
{:else}
|
|
1339
|
+
{item[label]}
|
|
1340
|
+
{/if}
|
|
1341
|
+
</span>
|
|
1342
|
+
|
|
1343
|
+
{#if !disabled && !multiFullItemClearable && ClearIcon}
|
|
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"
|
|
1349
|
+
class="multi-item-clear"
|
|
1350
|
+
aria-label={ariaRemoveItemLabel(String(item[label]))}
|
|
1351
|
+
onmousedown={(ev) => ev.preventDefault()}
|
|
1352
|
+
onpointerup={(ev) => ev.stopPropagation()}
|
|
1353
|
+
onclick={(ev) => {
|
|
1354
|
+
ev.stopPropagation();
|
|
1355
|
+
valueManager.handleMultiItemClear(i);
|
|
1356
|
+
handleFocus();
|
|
1357
|
+
}}>
|
|
1358
|
+
{#if multiClearIconSnippet}
|
|
1359
|
+
{@render multiClearIconSnippet()}
|
|
1360
|
+
{:else}
|
|
1361
|
+
<ClearIcon />
|
|
1362
|
+
{/if}
|
|
1363
|
+
</button>
|
|
1364
|
+
{/if}
|
|
1365
|
+
</div>
|
|
1366
|
+
{/each}
|
|
1367
|
+
{:else}
|
|
1368
|
+
<div id="selected-{_id}" class="selected-item" class:hide-selected-item={hideSelectedItem}>
|
|
1369
|
+
{#if selectionSnippet}
|
|
1370
|
+
{@render selectionSnippet(value as Item)}
|
|
1371
|
+
{:else}
|
|
1372
|
+
{!Array.isArray(normalizedValue) ? normalizedValue?.[label] : ''}
|
|
1373
|
+
{/if}
|
|
1374
|
+
</div>
|
|
1375
|
+
{/if}
|
|
1376
|
+
{/if}
|
|
1377
|
+
|
|
1378
|
+
<input
|
|
1379
|
+
onkeydown={handleKeyDown}
|
|
1380
|
+
onblur={handleBlur}
|
|
1381
|
+
oninput={handleInput}
|
|
1382
|
+
onfocus={handleFocus}
|
|
1383
|
+
{..._inputAttributes}
|
|
1384
|
+
bind:this={input}
|
|
1385
|
+
value={filterText}
|
|
1386
|
+
placeholder={placeholderText}
|
|
1387
|
+
style={inputStyles} />
|
|
1388
|
+
</div>
|
|
1389
|
+
|
|
1390
|
+
<div class="indicators">
|
|
1391
|
+
{#if loading}
|
|
1392
|
+
<div class="icon loading" aria-hidden="true">
|
|
1393
|
+
{#if loadingIconSnippet}
|
|
1394
|
+
{@render loadingIconSnippet()}
|
|
1395
|
+
{:else}
|
|
1396
|
+
<LoadingIcon />
|
|
1397
|
+
{/if}
|
|
1398
|
+
</div>
|
|
1399
|
+
{/if}
|
|
1400
|
+
|
|
1401
|
+
{#if showClear}
|
|
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}>
|
|
1412
|
+
{#if clearIconSnippet}
|
|
1413
|
+
{@render clearIconSnippet()}
|
|
1414
|
+
{:else}
|
|
1415
|
+
<ClearIcon />
|
|
1416
|
+
{/if}
|
|
1417
|
+
</button>
|
|
1418
|
+
{/if}
|
|
1419
|
+
|
|
1420
|
+
{#if showChevron}
|
|
1421
|
+
<div class="icon chevron" aria-hidden="true">
|
|
1422
|
+
{#if chevronIconSnippet}
|
|
1423
|
+
{@render chevronIconSnippet(listOpen)}
|
|
1424
|
+
{:else}
|
|
1425
|
+
<ChevronIcon />
|
|
1426
|
+
{/if}
|
|
1427
|
+
</div>
|
|
1428
|
+
{/if}
|
|
1429
|
+
</div>
|
|
1430
|
+
{#if inputHiddenSnippet}
|
|
1431
|
+
{@render inputHiddenSnippet(value)}
|
|
1432
|
+
{:else if multiple && Array.isArray(value) && value.length > 0}
|
|
1433
|
+
{#each value as Item[] as item}
|
|
1434
|
+
<input {name} type="hidden" value={useJustValue ? item[itemId] : JSON.stringify(item)} />
|
|
1435
|
+
{/each}
|
|
1436
|
+
{:else if !multiple}
|
|
1437
|
+
<input {name} type="hidden" value={value ? (useJustValue ? justValue : JSON.stringify(value)) : ''} />
|
|
1438
|
+
{/if}
|
|
1439
|
+
|
|
1440
|
+
{#if required && (!value || (Array.isArray(value) && value.length === 0))}
|
|
1441
|
+
{#if requiredSnippet}
|
|
1442
|
+
{@render requiredSnippet(value)}
|
|
1443
|
+
{:else}
|
|
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>
|
|
1448
|
+
{/if}
|
|
1449
|
+
{/if}
|
|
1450
|
+
</div>
|
|
1451
|
+
|
|
1452
|
+
|