@ibobbyts/svelte-ui-utils 0.4.2 → 0.4.4
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 +107 -10
- package/dist/data-table/FilterControl.svelte +30 -4
- package/dist/data-table/FilterControl.svelte.d.ts.map +1 -1
- package/dist/data-table/filter.d.ts +3 -1
- package/dist/data-table/filter.d.ts.map +1 -1
- package/dist/data-table/filter.js +4 -0
- package/dist/data-table/index.d.ts +1 -1
- package/dist/data-table/index.d.ts.map +1 -1
- package/dist/data-table/types.d.ts +41 -11
- package/dist/data-table/types.d.ts.map +1 -1
- package/dist/dialog/ConfirmDialog.svelte +2 -0
- package/dist/dialog/ConfirmDialog.svelte.d.ts +1 -0
- package/dist/dialog/ConfirmDialog.svelte.d.ts.map +1 -1
- package/dist/dialog/Dialog.svelte +22 -2
- package/dist/dialog/Dialog.svelte.d.ts +1 -0
- package/dist/dialog/Dialog.svelte.d.ts.map +1 -1
- package/dist/dropdown/Dropdown.svelte +419 -44
- package/dist/dropdown/Dropdown.svelte.d.ts +13 -1
- package/dist/dropdown/Dropdown.svelte.d.ts.map +1 -1
- package/dist/dropdown/DropdownMultiSelect.svelte +4 -0
- package/dist/dropdown/DropdownMultiSelect.svelte.d.ts +2 -0
- package/dist/dropdown/DropdownMultiSelect.svelte.d.ts.map +1 -1
- package/dist/dropdown/index.d.ts +1 -1
- package/dist/dropdown/index.d.ts.map +1 -1
- package/dist/dropdown/types.d.ts +21 -2
- package/dist/dropdown/types.d.ts.map +1 -1
- package/dist/dropdown-search/DropdownSearch.svelte +12 -10
- package/dist/dropdown-search/DropdownSearch.svelte.d.ts +2 -2
- package/dist/dropdown-search/DropdownSearch.svelte.d.ts.map +1 -1
- package/dist/dropdown-search/DropdownSearchMultiSelect.svelte +3 -3
- package/dist/dropdown-search/DropdownSearchMultiSelect.svelte.d.ts +2 -2
- package/dist/dropdown-search/index.d.ts +1 -1
- package/dist/dropdown-search/types.d.ts +10 -3
- package/dist/dropdown-search/types.d.ts.map +1 -1
- package/dist/pagination/Pagination.svelte +5 -4
- package/dist/pagination/Pagination.svelte.d.ts.map +1 -1
- package/dist/style.css +87 -0
- package/package.json +1 -1
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
<svelte:options runes={false} />
|
|
2
2
|
|
|
3
|
+
<script context="module" lang="ts">
|
|
4
|
+
let dropdownInstanceCount = 0;
|
|
5
|
+
</script>
|
|
6
|
+
|
|
3
7
|
<script lang="ts">
|
|
4
8
|
import { onDestroy, tick } from 'svelte';
|
|
9
|
+
import { getUiMessages, type UiLanguage } from '../i18n.js';
|
|
5
10
|
import type {
|
|
6
11
|
DropdownChangeHandler,
|
|
12
|
+
DropdownLoadOptions,
|
|
13
|
+
DropdownLoadOptionsResult,
|
|
14
|
+
DropdownLoadStatus,
|
|
7
15
|
DropdownMenuAlign,
|
|
8
16
|
DropdownMultiChangeHandler,
|
|
9
17
|
DropdownMultiValue,
|
|
@@ -21,6 +29,18 @@
|
|
|
21
29
|
export let multiselect = false;
|
|
22
30
|
export let options: DropdownOption[] = [];
|
|
23
31
|
export let optionGroups: DropdownOptionGroup[] | undefined = undefined;
|
|
32
|
+
/** Labels for controlled values that are not present in the current search result. */
|
|
33
|
+
export let selectedOptions: DropdownOption[] = [];
|
|
34
|
+
export let groupsCollapsedByDefault: 'true' | 'false' | 'auto' = 'false';
|
|
35
|
+
export let search = false;
|
|
36
|
+
export let loadOptions: DropdownLoadOptions | undefined = undefined;
|
|
37
|
+
export let searchDebounceMs = 300;
|
|
38
|
+
export let searchLimit = 10;
|
|
39
|
+
export let searchPlaceholder: string | undefined = undefined;
|
|
40
|
+
export let language: UiLanguage = 'en_us';
|
|
41
|
+
export let loadingText: string | undefined = undefined;
|
|
42
|
+
export let noResultsText: string | undefined = undefined;
|
|
43
|
+
export let errorText: string | undefined = undefined;
|
|
24
44
|
export let ariaLabel: string | undefined = undefined;
|
|
25
45
|
export let placement: DropdownPlacement = 'auto';
|
|
26
46
|
export let menuAlign: DropdownMenuAlign = 'left';
|
|
@@ -39,6 +59,7 @@
|
|
|
39
59
|
|
|
40
60
|
const viewportMargin = 20;
|
|
41
61
|
const menuGap = 6;
|
|
62
|
+
const instanceId = ++dropdownInstanceCount;
|
|
42
63
|
|
|
43
64
|
let open = false;
|
|
44
65
|
let activeValue: DropdownValue = '';
|
|
@@ -56,19 +77,45 @@
|
|
|
56
77
|
let typeaheadBuffer = '';
|
|
57
78
|
let typeaheadTimer: ReturnType<typeof setTimeout> | undefined;
|
|
58
79
|
let typeaheadGeneration = 0;
|
|
80
|
+
let collapsedGroupIndexes = new Set<number>();
|
|
81
|
+
// Whether the current open session has already seeded auto group collapse
|
|
82
|
+
// from a search response; once true, the user's manual toggles own the set.
|
|
83
|
+
let searchGroupsInitialized = false;
|
|
84
|
+
let searchQuery = '';
|
|
85
|
+
let searchStatus: DropdownLoadStatus = 'idle';
|
|
86
|
+
let searchOptions: DropdownOption[] = [];
|
|
87
|
+
let searchOptionGroups: DropdownOptionGroup[] | undefined = undefined;
|
|
88
|
+
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
|
89
|
+
let searchController: AbortController | undefined;
|
|
90
|
+
let searchRequestId = 0;
|
|
91
|
+
let searchInputElement: HTMLInputElement | undefined;
|
|
92
|
+
const knownLabels = new Map<DropdownValue, string>();
|
|
59
93
|
// Keep the buffer long enough for users to type multi-character aliases
|
|
60
94
|
// while the menu is rendering a large option list.
|
|
61
95
|
const typeaheadTimeoutMs = 1000;
|
|
62
96
|
|
|
63
|
-
$:
|
|
64
|
-
$:
|
|
97
|
+
$: messages = getUiMessages(language);
|
|
98
|
+
$: resolvedLoadingText = loadingText?.trim() ? loadingText : messages.dropdownSearch.loadingText;
|
|
99
|
+
$: resolvedNoResultsText = noResultsText?.trim() ? noResultsText : messages.dropdownSearch.noResultsText;
|
|
100
|
+
$: resolvedErrorText = errorText?.trim() ? errorText : 'Failed to load options';
|
|
101
|
+
$: resolvedListboxId = id ? `${id}-listbox` : search ? `suu-dropdown-listbox-${instanceId}` : undefined;
|
|
102
|
+
// Search results fully replace the instantiated options while searching;
|
|
103
|
+
// outside of search mode the caller-provided options stay authoritative.
|
|
104
|
+
$: renderOptions = search ? searchOptions : options;
|
|
105
|
+
$: renderOptionGroups = search ? searchOptionGroups : optionGroups;
|
|
106
|
+
$: resolvedOptions = renderOptionGroups === undefined ? renderOptions : renderOptionGroups.flatMap((group) => group.options);
|
|
107
|
+
$: visibleOptions = renderOptionGroups === undefined
|
|
108
|
+
? renderOptions
|
|
109
|
+
: renderOptionGroups.flatMap((group, groupIndex) => group.label && collapsedGroupIndexes.has(groupIndex) ? [] : group.options);
|
|
110
|
+
$: trackOptionLabels(resolvedOptions);
|
|
111
|
+
$: trackOptionLabels(selectedOptions);
|
|
112
|
+
$: rawSelectedValues = normalizeSelectedValues(multiselect && Array.isArray(value) ? value : []);
|
|
113
|
+
$: selectedValues = orderSelectedValues(resolvedOptions, rawSelectedValues);
|
|
114
|
+
$: selectedValueSet = new Set(rawSelectedValues);
|
|
65
115
|
$: selectedOption = Array.isArray(value) ? undefined : resolvedOptions.find((option) => option.value === value);
|
|
66
116
|
$: selectedText = multiselect
|
|
67
|
-
?
|
|
68
|
-
|
|
69
|
-
.map((option) => option.label)
|
|
70
|
-
.join(', ')
|
|
71
|
-
: selectedOption?.label ?? String(value);
|
|
117
|
+
? selectedLabelTexts(selectedValues).join(', ')
|
|
118
|
+
: labelForValue(Array.isArray(value) ? '' : value);
|
|
72
119
|
$: if (!open) {
|
|
73
120
|
activeValue = initialActiveValue();
|
|
74
121
|
}
|
|
@@ -76,60 +123,120 @@
|
|
|
76
123
|
placement;
|
|
77
124
|
if (open) {
|
|
78
125
|
enableOpenViewportTracking();
|
|
79
|
-
if (multiselect) {
|
|
126
|
+
if (multiselect || search) {
|
|
80
127
|
enableOutsidePointerDismissal();
|
|
81
128
|
}
|
|
82
129
|
void updateViewportPanelMaxHeight();
|
|
83
130
|
} else {
|
|
84
131
|
disableOpenViewportTracking();
|
|
85
132
|
disableOutsidePointerDismissal();
|
|
133
|
+
if (search) {
|
|
134
|
+
resetSearchOnClose();
|
|
135
|
+
}
|
|
86
136
|
resolvedPlacement = placement === 'up' ? 'up' : 'down';
|
|
87
137
|
viewportPanelMaxHeight = undefined;
|
|
88
138
|
}
|
|
89
139
|
}
|
|
90
140
|
|
|
91
141
|
function firstEnabledOption(): DropdownOption | undefined {
|
|
92
|
-
return
|
|
142
|
+
return visibleOptions.find((option) => !option.disabled);
|
|
93
143
|
}
|
|
94
144
|
|
|
95
145
|
function normalizeSelectedValues(nextValues: DropdownMultiValue): DropdownMultiValue {
|
|
96
|
-
const
|
|
146
|
+
const seen = new Set<DropdownValue>();
|
|
97
147
|
const normalized: DropdownMultiValue = [];
|
|
98
|
-
for (const
|
|
99
|
-
if (
|
|
100
|
-
|
|
148
|
+
for (const nextValue of nextValues) {
|
|
149
|
+
if (!seen.has(nextValue)) {
|
|
150
|
+
seen.add(nextValue);
|
|
151
|
+
normalized.push(nextValue);
|
|
101
152
|
}
|
|
102
153
|
}
|
|
103
154
|
return normalized;
|
|
104
155
|
}
|
|
105
156
|
|
|
157
|
+
// Selected values are reported in option order, but values that are not
|
|
158
|
+
// present in the current options (e.g. picked during an earlier async
|
|
159
|
+
// search) are preserved at the end instead of being dropped.
|
|
160
|
+
function orderSelectedValues(
|
|
161
|
+
nextOptions: DropdownOption[],
|
|
162
|
+
nextSelected: DropdownMultiValue
|
|
163
|
+
): DropdownMultiValue {
|
|
164
|
+
const selectedSet = new Set(nextSelected);
|
|
165
|
+
const ordered = nextOptions
|
|
166
|
+
.filter((option) => selectedSet.has(option.value))
|
|
167
|
+
.map((option) => option.value);
|
|
168
|
+
const orderedSet = new Set(ordered);
|
|
169
|
+
for (const nextValue of nextSelected) {
|
|
170
|
+
if (!orderedSet.has(nextValue)) {
|
|
171
|
+
ordered.push(nextValue);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return ordered;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function trackOptionLabels(nextOptions: DropdownOption[]) {
|
|
178
|
+
for (const option of nextOptions) {
|
|
179
|
+
knownLabels.set(option.value, option.label);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function labelForValue(nextValue: DropdownValue): string {
|
|
184
|
+
const option = resolvedOptions.find((candidate) => candidate.value === nextValue);
|
|
185
|
+
return option?.label ?? knownLabels.get(nextValue) ?? String(nextValue);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Multiselect trigger text only lists values whose label is known; values
|
|
189
|
+
// without one never render a raw fallback string into the trigger.
|
|
190
|
+
function selectedLabelTexts(nextValues: DropdownMultiValue): string[] {
|
|
191
|
+
const texts: string[] = [];
|
|
192
|
+
for (const nextValue of nextValues) {
|
|
193
|
+
const label =
|
|
194
|
+
resolvedOptions.find((candidate) => candidate.value === nextValue)?.label ??
|
|
195
|
+
knownLabels.get(nextValue);
|
|
196
|
+
if (label !== undefined) {
|
|
197
|
+
texts.push(label);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return texts;
|
|
201
|
+
}
|
|
202
|
+
|
|
106
203
|
function initialActiveValue(): DropdownValue {
|
|
107
|
-
|
|
108
|
-
|
|
204
|
+
return initialActiveValueFor(visibleOptions, resolvedOptions);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function initialActiveValueFor(
|
|
208
|
+
nextVisibleOptions: DropdownOption[],
|
|
209
|
+
nextResolvedOptions: DropdownOption[]
|
|
210
|
+
): DropdownValue {
|
|
211
|
+
const firstSelectedEnabled = nextVisibleOptions.find(
|
|
212
|
+
(option) => selectedValueSet.has(option.value) && !option.disabled
|
|
109
213
|
);
|
|
110
|
-
|
|
214
|
+
const selectedFallback = Array.isArray(value)
|
|
215
|
+
? undefined
|
|
216
|
+
: nextResolvedOptions.find((option) => option.value === value);
|
|
217
|
+
return firstSelectedEnabled?.value ?? selectedFallback?.value ?? nextVisibleOptions.find((option) => !option.disabled)?.value ?? '';
|
|
111
218
|
}
|
|
112
219
|
|
|
113
220
|
function isOptionSelected(option: DropdownOption): boolean {
|
|
114
|
-
return multiselect ?
|
|
221
|
+
return multiselect ? selectedValueSet.has(option.value) : option.value === value;
|
|
115
222
|
}
|
|
116
223
|
|
|
117
224
|
function activeOptionIndex(nextActiveValue: DropdownValue): number {
|
|
118
|
-
const index =
|
|
225
|
+
const index = visibleOptions.findIndex((option) => option.value === nextActiveValue && !option.disabled);
|
|
119
226
|
if (index >= 0) {
|
|
120
227
|
return index;
|
|
121
228
|
}
|
|
122
|
-
const fallbackIndex =
|
|
229
|
+
const fallbackIndex = visibleOptions.findIndex(
|
|
123
230
|
(option) => isOptionSelected(option) && !option.disabled
|
|
124
231
|
);
|
|
125
232
|
if (fallbackIndex >= 0) {
|
|
126
233
|
return fallbackIndex;
|
|
127
234
|
}
|
|
128
|
-
return
|
|
235
|
+
return visibleOptions.findIndex((option) => !option.disabled);
|
|
129
236
|
}
|
|
130
237
|
|
|
131
238
|
function moveActiveOption(offset: number) {
|
|
132
|
-
const enabledOptions =
|
|
239
|
+
const enabledOptions = visibleOptions.filter((option) => !option.disabled);
|
|
133
240
|
if (enabledOptions.length === 0) {
|
|
134
241
|
return;
|
|
135
242
|
}
|
|
@@ -137,6 +244,7 @@
|
|
|
137
244
|
const currentIndex = Math.max(0, enabledOptions.findIndex((option) => option.value === activeValue));
|
|
138
245
|
const nextIndex = (currentIndex + offset + enabledOptions.length) % enabledOptions.length;
|
|
139
246
|
activeValue = enabledOptions[nextIndex]?.value ?? activeValue;
|
|
247
|
+
scrollActiveOptionIntoView();
|
|
140
248
|
}
|
|
141
249
|
|
|
142
250
|
function clearTypeaheadBuffer() {
|
|
@@ -174,13 +282,17 @@
|
|
|
174
282
|
});
|
|
175
283
|
}
|
|
176
284
|
|
|
177
|
-
function
|
|
178
|
-
|
|
285
|
+
function normalizeTypeaheadKey(key: string): string {
|
|
286
|
+
return key.startsWith('Key') && key.length === 4
|
|
179
287
|
? key.slice(3).toLocaleLowerCase()
|
|
180
288
|
: key.toLocaleLowerCase();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function handleTypeahead(key: string) {
|
|
292
|
+
const normalizedKey = normalizeTypeaheadKey(key);
|
|
181
293
|
const nextBuffer = `${typeaheadBuffer}${normalizedKey}`;
|
|
182
294
|
const findMatch = (prefix: string) =>
|
|
183
|
-
|
|
295
|
+
visibleOptions.find(
|
|
184
296
|
(option) =>
|
|
185
297
|
!option.disabled &&
|
|
186
298
|
[option.searchText, option.label]
|
|
@@ -196,8 +308,7 @@
|
|
|
196
308
|
}
|
|
197
309
|
|
|
198
310
|
if (!open) {
|
|
199
|
-
|
|
200
|
-
open = true;
|
|
311
|
+
openMenu();
|
|
201
312
|
}
|
|
202
313
|
activeValue = match.value;
|
|
203
314
|
scrollActiveOptionIntoView();
|
|
@@ -215,7 +326,7 @@
|
|
|
215
326
|
} else {
|
|
216
327
|
nextSelected.add(option.value);
|
|
217
328
|
}
|
|
218
|
-
const nextValues =
|
|
329
|
+
const nextValues = orderSelectedValues(resolvedOptions, [...nextSelected]);
|
|
219
330
|
clearTypeaheadBuffer();
|
|
220
331
|
void (onChange as DropdownMultiChangeHandler | undefined)?.(nextValues);
|
|
221
332
|
return;
|
|
@@ -227,18 +338,199 @@
|
|
|
227
338
|
buttonElement?.focus();
|
|
228
339
|
}
|
|
229
340
|
|
|
341
|
+
function clampSearchLimit(limit: number): number {
|
|
342
|
+
if (!Number.isFinite(limit)) {
|
|
343
|
+
return 10;
|
|
344
|
+
}
|
|
345
|
+
return Math.min(50, Math.max(1, Math.floor(limit)));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function clearSearchTimer() {
|
|
349
|
+
if (searchTimer !== undefined) {
|
|
350
|
+
clearTimeout(searchTimer);
|
|
351
|
+
searchTimer = undefined;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function abortSearchController() {
|
|
356
|
+
if (searchController !== undefined) {
|
|
357
|
+
searchController.abort();
|
|
358
|
+
searchController = undefined;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function scheduleSearch(query: string) {
|
|
363
|
+
clearSearchTimer();
|
|
364
|
+
searchTimer = setTimeout(() => {
|
|
365
|
+
searchTimer = undefined;
|
|
366
|
+
void runSearch(query);
|
|
367
|
+
}, Math.max(0, Math.floor(searchDebounceMs)));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function runSearch(query: string) {
|
|
371
|
+
if (!search || loadOptions === undefined) {
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
abortSearchController();
|
|
375
|
+
const currentRequestId = ++searchRequestId;
|
|
376
|
+
const controller = new AbortController();
|
|
377
|
+
searchController = controller;
|
|
378
|
+
searchStatus = 'loading';
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
const result = await loadOptions(query, {
|
|
382
|
+
limit: clampSearchLimit(searchLimit),
|
|
383
|
+
signal: controller.signal
|
|
384
|
+
});
|
|
385
|
+
// Stale or cancelled responses must never touch the UI.
|
|
386
|
+
if (controller.signal.aborted || currentRequestId !== searchRequestId) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
searchStatus = 'success';
|
|
390
|
+
applySearchResult(result ?? {}, query);
|
|
391
|
+
} catch {
|
|
392
|
+
if (controller.signal.aborted || currentRequestId !== searchRequestId) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
searchOptions = [];
|
|
396
|
+
searchOptionGroups = undefined;
|
|
397
|
+
searchStatus = 'error';
|
|
398
|
+
} finally {
|
|
399
|
+
if (searchController === controller) {
|
|
400
|
+
searchController = undefined;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function mergeSelectedOptions(options: DropdownOption[]): DropdownOption[] {
|
|
406
|
+
const merged = [...options];
|
|
407
|
+
const seen = new Set(options.map((option) => option.value));
|
|
408
|
+
for (const option of selectedOptions) {
|
|
409
|
+
if (!seen.has(option.value)) {
|
|
410
|
+
merged.push(option);
|
|
411
|
+
seen.add(option.value);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return merged;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function applySearchResult(result: DropdownLoadOptionsResult, query = '') {
|
|
418
|
+
// optionGroups is the sole render source when present; empty groups stay hidden.
|
|
419
|
+
const groups = Array.isArray(result.optionGroups)
|
|
420
|
+
? result.optionGroups.filter((group) => Array.isArray(group.options) && group.options.length > 0)
|
|
421
|
+
: undefined;
|
|
422
|
+
if (groups !== undefined) {
|
|
423
|
+
if (query.trim()) {
|
|
424
|
+
searchOptionGroups = groups;
|
|
425
|
+
} else {
|
|
426
|
+
const groupedOptions = groups.flatMap((group) => group.options);
|
|
427
|
+
const groupedValues = new Set(groupedOptions.map((option) => option.value));
|
|
428
|
+
const missingSelected = selectedOptions.filter((option) => !groupedValues.has(option.value));
|
|
429
|
+
searchOptionGroups = missingSelected.length ? [...groups, { options: missingSelected }] : groups;
|
|
430
|
+
}
|
|
431
|
+
searchOptions = [];
|
|
432
|
+
} else {
|
|
433
|
+
searchOptionGroups = undefined;
|
|
434
|
+
searchOptions = query.trim() ? (Array.isArray(result.options) ? result.options : []) : mergeSelectedOptions(Array.isArray(result.options) ? result.options : []);
|
|
435
|
+
}
|
|
436
|
+
// Only the first valid grouped response of an open session seeds the auto
|
|
437
|
+
// collapse state; later responses just swap groups so manual collapse and
|
|
438
|
+
// expand choices survive query refreshes within the same session.
|
|
439
|
+
if (!searchGroupsInitialized && groups !== undefined && groups.length > 0) {
|
|
440
|
+
searchGroupsInitialized = true;
|
|
441
|
+
initializeCollapsedGroups(groups);
|
|
442
|
+
}
|
|
443
|
+
const nextResolvedOptions = groups === undefined ? searchOptions : groups.flatMap((group) => group.options);
|
|
444
|
+
const nextVisibleOptions = groups === undefined
|
|
445
|
+
? searchOptions
|
|
446
|
+
: groups.flatMap((group, groupIndex) => group.label && collapsedGroupIndexes.has(groupIndex) ? [] : group.options);
|
|
447
|
+
activeValue = initialActiveValueFor(nextVisibleOptions, nextResolvedOptions);
|
|
448
|
+
scrollActiveOptionIntoView();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function resetSearchOnClose() {
|
|
452
|
+
clearSearchTimer();
|
|
453
|
+
abortSearchController();
|
|
454
|
+
searchQuery = '';
|
|
455
|
+
searchOptions = [];
|
|
456
|
+
searchOptionGroups = undefined;
|
|
457
|
+
searchStatus = 'idle';
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function beginSearchOnOpen() {
|
|
461
|
+
searchQuery = '';
|
|
462
|
+
searchOptions = [];
|
|
463
|
+
searchOptionGroups = undefined;
|
|
464
|
+
collapsedGroupIndexes = new Set();
|
|
465
|
+
searchGroupsInitialized = false;
|
|
466
|
+
clearSearchTimer();
|
|
467
|
+
abortSearchController();
|
|
468
|
+
if (loadOptions === undefined) {
|
|
469
|
+
searchStatus = 'idle';
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
// The empty query fires immediately on every expand.
|
|
473
|
+
void runSearch('');
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function openMenu() {
|
|
477
|
+
updateResolvedPlacement();
|
|
478
|
+
if (search) {
|
|
479
|
+
beginSearchOnOpen();
|
|
480
|
+
} else {
|
|
481
|
+
initializeCollapsedGroups(renderOptionGroups);
|
|
482
|
+
}
|
|
483
|
+
open = true;
|
|
484
|
+
activeValue = initialActiveValue();
|
|
485
|
+
if (search) {
|
|
486
|
+
void tick().then(() => {
|
|
487
|
+
searchInputElement?.focus();
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
230
492
|
function toggleOpen() {
|
|
231
493
|
if (disabled) {
|
|
232
494
|
return;
|
|
233
495
|
}
|
|
496
|
+
clearTypeaheadBuffer();
|
|
234
497
|
if (!open) {
|
|
235
|
-
|
|
498
|
+
openMenu();
|
|
499
|
+
return;
|
|
236
500
|
}
|
|
237
|
-
|
|
238
|
-
open = !open;
|
|
501
|
+
open = false;
|
|
239
502
|
activeValue = initialActiveValue();
|
|
240
503
|
}
|
|
241
504
|
|
|
505
|
+
function isGroupCollapsed(groupIndex: number): boolean {
|
|
506
|
+
return collapsedGroupIndexes.has(groupIndex);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function initializeCollapsedGroups(nextOptionGroups: DropdownOptionGroup[] | undefined) {
|
|
510
|
+
if (nextOptionGroups === undefined || groupsCollapsedByDefault === 'false') {
|
|
511
|
+
collapsedGroupIndexes = new Set();
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
if (groupsCollapsedByDefault === 'true') {
|
|
515
|
+
collapsedGroupIndexes = new Set(
|
|
516
|
+
nextOptionGroups.flatMap((group, index) => group.label ? [index] : [])
|
|
517
|
+
);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
collapsedGroupIndexes = new Set(
|
|
521
|
+
nextOptionGroups.flatMap((group, index) =>
|
|
522
|
+
group.label && group.options.some((option) => isOptionSelected(option)) ? [] : group.label ? [index] : []
|
|
523
|
+
)
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function toggleGroup(groupIndex: number) {
|
|
528
|
+
const next = new Set(collapsedGroupIndexes);
|
|
529
|
+
if (next.has(groupIndex)) next.delete(groupIndex);
|
|
530
|
+
else next.add(groupIndex);
|
|
531
|
+
collapsedGroupIndexes = next;
|
|
532
|
+
}
|
|
533
|
+
|
|
242
534
|
function handleTriggerClick(event: MouseEvent) {
|
|
243
535
|
onTriggerClick?.(event);
|
|
244
536
|
toggleOpen();
|
|
@@ -261,9 +553,7 @@
|
|
|
261
553
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
262
554
|
event.preventDefault();
|
|
263
555
|
if (!open) {
|
|
264
|
-
|
|
265
|
-
open = true;
|
|
266
|
-
activeValue = initialActiveValue();
|
|
556
|
+
openMenu();
|
|
267
557
|
}
|
|
268
558
|
moveActiveOption(event.key === 'ArrowDown' ? 1 : -1);
|
|
269
559
|
return;
|
|
@@ -277,6 +567,15 @@
|
|
|
277
567
|
!event.altKey
|
|
278
568
|
) {
|
|
279
569
|
event.preventDefault();
|
|
570
|
+
if (search) {
|
|
571
|
+
const wasOpen = open;
|
|
572
|
+
if (!wasOpen) {
|
|
573
|
+
openMenu();
|
|
574
|
+
}
|
|
575
|
+
searchQuery = wasOpen ? `${searchQuery}${normalizeTypeaheadKey(event.key)}` : normalizeTypeaheadKey(event.key);
|
|
576
|
+
scheduleSearch(searchQuery);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
280
579
|
handleTypeahead(event.key);
|
|
281
580
|
return;
|
|
282
581
|
}
|
|
@@ -284,7 +583,34 @@
|
|
|
284
583
|
if ((event.key === 'Enter' || event.key === ' ') && open) {
|
|
285
584
|
event.preventDefault();
|
|
286
585
|
const index = activeOptionIndex(activeValue);
|
|
287
|
-
const option = index >= 0 ?
|
|
586
|
+
const option = index >= 0 ? visibleOptions[index] : undefined;
|
|
587
|
+
if (option) {
|
|
588
|
+
selectOption(option);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function handleSearchInput(event: Event) {
|
|
594
|
+
searchQuery = (event.currentTarget as HTMLInputElement).value;
|
|
595
|
+
scheduleSearch(searchQuery);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function handleSearchKeydown(event: KeyboardEvent) {
|
|
599
|
+
if (event.key === 'Escape') {
|
|
600
|
+
event.preventDefault();
|
|
601
|
+
open = false;
|
|
602
|
+
buttonElement?.focus();
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
606
|
+
event.preventDefault();
|
|
607
|
+
moveActiveOption(event.key === 'ArrowDown' ? 1 : -1);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (event.key === 'Enter') {
|
|
611
|
+
event.preventDefault();
|
|
612
|
+
const index = activeOptionIndex(activeValue);
|
|
613
|
+
const option = index >= 0 ? visibleOptions[index] : undefined;
|
|
288
614
|
if (option) {
|
|
289
615
|
selectOption(option);
|
|
290
616
|
}
|
|
@@ -438,6 +764,9 @@
|
|
|
438
764
|
return;
|
|
439
765
|
}
|
|
440
766
|
open = false;
|
|
767
|
+
if (search) {
|
|
768
|
+
buttonElement?.focus();
|
|
769
|
+
}
|
|
441
770
|
};
|
|
442
771
|
|
|
443
772
|
document.addEventListener('pointerdown', handleOutsidePointer, true);
|
|
@@ -455,6 +784,8 @@
|
|
|
455
784
|
disableOpenViewportTracking();
|
|
456
785
|
disableOutsidePointerDismissal();
|
|
457
786
|
clearTypeaheadBuffer();
|
|
787
|
+
clearSearchTimer();
|
|
788
|
+
abortSearchController();
|
|
458
789
|
});
|
|
459
790
|
</script>
|
|
460
791
|
|
|
@@ -463,6 +794,7 @@
|
|
|
463
794
|
class={[
|
|
464
795
|
'suu-dropdown',
|
|
465
796
|
multiselect ? 'suu-dropdown--multiselect' : '',
|
|
797
|
+
search ? 'suu-dropdown--search' : '',
|
|
466
798
|
width !== undefined || minWidth !== undefined || maxWidth !== undefined
|
|
467
799
|
? 'suu-dropdown--sized'
|
|
468
800
|
: '',
|
|
@@ -521,21 +853,54 @@
|
|
|
521
853
|
style:--suu-dropdown-menu-width={portalMenuWidth}
|
|
522
854
|
style:--suu-dropdown-panel-max-height={viewportPanelMaxHeight}
|
|
523
855
|
>
|
|
856
|
+
{#if search}
|
|
857
|
+
<div class="suu-dropdown__search">
|
|
858
|
+
<svg class="suu-dropdown__search-icon" viewBox="0 0 24 24" aria-hidden="true">
|
|
859
|
+
<circle cx="11" cy="11" r="7"></circle>
|
|
860
|
+
<path d="m16 16 4 4"></path>
|
|
861
|
+
</svg>
|
|
862
|
+
<input
|
|
863
|
+
bind:this={searchInputElement}
|
|
864
|
+
class="suu-dropdown__search-input"
|
|
865
|
+
type="text"
|
|
866
|
+
value={searchQuery}
|
|
867
|
+
placeholder={searchPlaceholder ?? ''}
|
|
868
|
+
aria-label={ariaLabel}
|
|
869
|
+
autocomplete="off"
|
|
870
|
+
role="combobox"
|
|
871
|
+
aria-expanded="true"
|
|
872
|
+
aria-autocomplete="list"
|
|
873
|
+
aria-controls={resolvedListboxId}
|
|
874
|
+
on:input={handleSearchInput}
|
|
875
|
+
on:keydown={handleSearchKeydown}
|
|
876
|
+
/>
|
|
877
|
+
</div>
|
|
878
|
+
{/if}
|
|
524
879
|
<div
|
|
525
880
|
class="suu-dropdown__panel"
|
|
881
|
+
id={resolvedListboxId}
|
|
526
882
|
role="listbox"
|
|
527
883
|
aria-label={ariaLabel}
|
|
528
884
|
aria-multiselectable={multiselect || undefined}
|
|
529
885
|
>
|
|
530
|
-
{#if
|
|
531
|
-
|
|
886
|
+
{#if search && searchStatus === 'loading'}
|
|
887
|
+
<div class="suu-dropdown__status suu-dropdown__status--loading" aria-live="polite">
|
|
888
|
+
<span class="suu-dropdown__spinner" aria-hidden="true"></span>
|
|
889
|
+
<span>{resolvedLoadingText}</span>
|
|
890
|
+
</div>
|
|
891
|
+
{:else if search && searchStatus === 'error'}
|
|
892
|
+
<div class="suu-dropdown__status suu-dropdown__status--error" role="alert">{resolvedErrorText}</div>
|
|
893
|
+
{:else if search && resolvedOptions.length === 0}
|
|
894
|
+
<div class="suu-dropdown__status suu-dropdown__status--empty">{resolvedNoResultsText}</div>
|
|
895
|
+
{:else if renderOptionGroups === undefined}
|
|
896
|
+
{#each renderOptions as option}
|
|
532
897
|
<button
|
|
533
898
|
type="button"
|
|
534
899
|
class="suu-dropdown__option"
|
|
535
900
|
class:suu-dropdown__option--active={option.value === activeValue}
|
|
536
901
|
class:suu-dropdown__option--disabled={option.disabled}
|
|
537
902
|
role="option"
|
|
538
|
-
aria-selected={multiselect ?
|
|
903
|
+
aria-selected={multiselect ? selectedValueSet.has(option.value) : option.value === value}
|
|
539
904
|
aria-disabled={option.disabled}
|
|
540
905
|
disabled={option.disabled}
|
|
541
906
|
data-value={String(option.value)}
|
|
@@ -550,7 +915,7 @@
|
|
|
550
915
|
{#if multiselect}
|
|
551
916
|
<span
|
|
552
917
|
class="suu-dropdown__checkbox"
|
|
553
|
-
data-checked={multiselect ?
|
|
918
|
+
data-checked={multiselect ? selectedValueSet.has(option.value) : option.value === value}
|
|
554
919
|
aria-hidden="true"
|
|
555
920
|
></span>
|
|
556
921
|
{/if}
|
|
@@ -558,12 +923,21 @@
|
|
|
558
923
|
</button>
|
|
559
924
|
{/each}
|
|
560
925
|
{:else}
|
|
561
|
-
{#each
|
|
926
|
+
{#each renderOptionGroups as group, groupIndex}
|
|
562
927
|
<div class="suu-dropdown__group" role="group" aria-label={group.label}>
|
|
563
928
|
{#if group.label}
|
|
564
|
-
<
|
|
929
|
+
<button
|
|
930
|
+
type="button"
|
|
931
|
+
class="suu-dropdown__group-label"
|
|
932
|
+
aria-expanded={!collapsedGroupIndexes.has(groupIndex)}
|
|
933
|
+
on:click|stopPropagation={() => toggleGroup(groupIndex)}
|
|
934
|
+
>
|
|
935
|
+
<span>{group.label}</span>
|
|
936
|
+
<span class="suu-dropdown__group-chevron" aria-hidden="true"></span>
|
|
937
|
+
</button>
|
|
565
938
|
{/if}
|
|
566
|
-
{#
|
|
939
|
+
{#if !group.label || !collapsedGroupIndexes.has(groupIndex)}
|
|
940
|
+
{#each group.options as option, optionIndex}
|
|
567
941
|
<button
|
|
568
942
|
type="button"
|
|
569
943
|
class="suu-dropdown__option"
|
|
@@ -571,7 +945,7 @@
|
|
|
571
945
|
class:suu-dropdown__option--active={option.value === activeValue}
|
|
572
946
|
class:suu-dropdown__option--disabled={option.disabled}
|
|
573
947
|
role="option"
|
|
574
|
-
aria-selected={multiselect ?
|
|
948
|
+
aria-selected={multiselect ? selectedValueSet.has(option.value) : option.value === value}
|
|
575
949
|
aria-disabled={option.disabled}
|
|
576
950
|
disabled={option.disabled}
|
|
577
951
|
data-value={String(option.value)}
|
|
@@ -586,13 +960,14 @@
|
|
|
586
960
|
{#if multiselect}
|
|
587
961
|
<span
|
|
588
962
|
class="suu-dropdown__checkbox"
|
|
589
|
-
data-checked={multiselect ?
|
|
963
|
+
data-checked={multiselect ? selectedValueSet.has(option.value) : option.value === value}
|
|
590
964
|
aria-hidden="true"
|
|
591
965
|
></span>
|
|
592
966
|
{/if}
|
|
593
967
|
<span class="suu-dropdown__label">{option.label}</span>
|
|
594
968
|
</button>
|
|
595
|
-
|
|
969
|
+
{/each}
|
|
970
|
+
{/if}
|
|
596
971
|
</div>
|
|
597
972
|
{/each}
|
|
598
973
|
{/if}
|