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