@ibobbyts/svelte-ui-utils 0.4.3 → 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 +101 -9
- 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 +372 -42
- package/dist/dropdown/Dropdown.svelte.d.ts +12 -1
- package/dist/dropdown/Dropdown.svelte.d.ts.map +1 -1
- package/dist/dropdown/DropdownMultiSelect.svelte +2 -0
- package/dist/dropdown/DropdownMultiSelect.svelte.d.ts +1 -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 +66 -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,7 +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[] = [];
|
|
24
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;
|
|
25
44
|
export let ariaLabel: string | undefined = undefined;
|
|
26
45
|
export let placement: DropdownPlacement = 'auto';
|
|
27
46
|
export let menuAlign: DropdownMenuAlign = 'left';
|
|
@@ -40,6 +59,7 @@
|
|
|
40
59
|
|
|
41
60
|
const viewportMargin = 20;
|
|
42
61
|
const menuGap = 6;
|
|
62
|
+
const instanceId = ++dropdownInstanceCount;
|
|
43
63
|
|
|
44
64
|
let open = false;
|
|
45
65
|
let activeValue: DropdownValue = '';
|
|
@@ -58,22 +78,44 @@
|
|
|
58
78
|
let typeaheadTimer: ReturnType<typeof setTimeout> | undefined;
|
|
59
79
|
let typeaheadGeneration = 0;
|
|
60
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>();
|
|
61
93
|
// Keep the buffer long enough for users to type multi-character aliases
|
|
62
94
|
// while the menu is rendering a large option list.
|
|
63
95
|
const typeaheadTimeoutMs = 1000;
|
|
64
96
|
|
|
65
|
-
$:
|
|
66
|
-
$:
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
$:
|
|
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);
|
|
70
115
|
$: selectedOption = Array.isArray(value) ? undefined : resolvedOptions.find((option) => option.value === value);
|
|
71
116
|
$: selectedText = multiselect
|
|
72
|
-
?
|
|
73
|
-
|
|
74
|
-
.map((option) => option.label)
|
|
75
|
-
.join(', ')
|
|
76
|
-
: selectedOption?.label ?? String(value);
|
|
117
|
+
? selectedLabelTexts(selectedValues).join(', ')
|
|
118
|
+
: labelForValue(Array.isArray(value) ? '' : value);
|
|
77
119
|
$: if (!open) {
|
|
78
120
|
activeValue = initialActiveValue();
|
|
79
121
|
}
|
|
@@ -81,13 +123,16 @@
|
|
|
81
123
|
placement;
|
|
82
124
|
if (open) {
|
|
83
125
|
enableOpenViewportTracking();
|
|
84
|
-
if (multiselect) {
|
|
126
|
+
if (multiselect || search) {
|
|
85
127
|
enableOutsidePointerDismissal();
|
|
86
128
|
}
|
|
87
129
|
void updateViewportPanelMaxHeight();
|
|
88
130
|
} else {
|
|
89
131
|
disableOpenViewportTracking();
|
|
90
132
|
disableOutsidePointerDismissal();
|
|
133
|
+
if (search) {
|
|
134
|
+
resetSearchOnClose();
|
|
135
|
+
}
|
|
91
136
|
resolvedPlacement = placement === 'up' ? 'up' : 'down';
|
|
92
137
|
viewportPanelMaxHeight = undefined;
|
|
93
138
|
}
|
|
@@ -98,25 +143,82 @@
|
|
|
98
143
|
}
|
|
99
144
|
|
|
100
145
|
function normalizeSelectedValues(nextValues: DropdownMultiValue): DropdownMultiValue {
|
|
101
|
-
const
|
|
146
|
+
const seen = new Set<DropdownValue>();
|
|
102
147
|
const normalized: DropdownMultiValue = [];
|
|
103
|
-
for (const
|
|
104
|
-
if (
|
|
105
|
-
|
|
148
|
+
for (const nextValue of nextValues) {
|
|
149
|
+
if (!seen.has(nextValue)) {
|
|
150
|
+
seen.add(nextValue);
|
|
151
|
+
normalized.push(nextValue);
|
|
106
152
|
}
|
|
107
153
|
}
|
|
108
154
|
return normalized;
|
|
109
155
|
}
|
|
110
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
|
+
|
|
111
203
|
function initialActiveValue(): DropdownValue {
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
114
213
|
);
|
|
115
|
-
|
|
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 ?? '';
|
|
116
218
|
}
|
|
117
219
|
|
|
118
220
|
function isOptionSelected(option: DropdownOption): boolean {
|
|
119
|
-
return multiselect ?
|
|
221
|
+
return multiselect ? selectedValueSet.has(option.value) : option.value === value;
|
|
120
222
|
}
|
|
121
223
|
|
|
122
224
|
function activeOptionIndex(nextActiveValue: DropdownValue): number {
|
|
@@ -142,6 +244,7 @@
|
|
|
142
244
|
const currentIndex = Math.max(0, enabledOptions.findIndex((option) => option.value === activeValue));
|
|
143
245
|
const nextIndex = (currentIndex + offset + enabledOptions.length) % enabledOptions.length;
|
|
144
246
|
activeValue = enabledOptions[nextIndex]?.value ?? activeValue;
|
|
247
|
+
scrollActiveOptionIntoView();
|
|
145
248
|
}
|
|
146
249
|
|
|
147
250
|
function clearTypeaheadBuffer() {
|
|
@@ -179,10 +282,14 @@
|
|
|
179
282
|
});
|
|
180
283
|
}
|
|
181
284
|
|
|
182
|
-
function
|
|
183
|
-
|
|
285
|
+
function normalizeTypeaheadKey(key: string): string {
|
|
286
|
+
return key.startsWith('Key') && key.length === 4
|
|
184
287
|
? key.slice(3).toLocaleLowerCase()
|
|
185
288
|
: key.toLocaleLowerCase();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function handleTypeahead(key: string) {
|
|
292
|
+
const normalizedKey = normalizeTypeaheadKey(key);
|
|
186
293
|
const nextBuffer = `${typeaheadBuffer}${normalizedKey}`;
|
|
187
294
|
const findMatch = (prefix: string) =>
|
|
188
295
|
visibleOptions.find(
|
|
@@ -201,8 +308,7 @@
|
|
|
201
308
|
}
|
|
202
309
|
|
|
203
310
|
if (!open) {
|
|
204
|
-
|
|
205
|
-
open = true;
|
|
311
|
+
openMenu();
|
|
206
312
|
}
|
|
207
313
|
activeValue = match.value;
|
|
208
314
|
scrollActiveOptionIntoView();
|
|
@@ -220,7 +326,7 @@
|
|
|
220
326
|
} else {
|
|
221
327
|
nextSelected.add(option.value);
|
|
222
328
|
}
|
|
223
|
-
const nextValues =
|
|
329
|
+
const nextValues = orderSelectedValues(resolvedOptions, [...nextSelected]);
|
|
224
330
|
clearTypeaheadBuffer();
|
|
225
331
|
void (onChange as DropdownMultiChangeHandler | undefined)?.(nextValues);
|
|
226
332
|
return;
|
|
@@ -232,16 +338,167 @@
|
|
|
232
338
|
buttonElement?.focus();
|
|
233
339
|
}
|
|
234
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
|
+
|
|
235
492
|
function toggleOpen() {
|
|
236
493
|
if (disabled) {
|
|
237
494
|
return;
|
|
238
495
|
}
|
|
496
|
+
clearTypeaheadBuffer();
|
|
239
497
|
if (!open) {
|
|
240
|
-
|
|
241
|
-
|
|
498
|
+
openMenu();
|
|
499
|
+
return;
|
|
242
500
|
}
|
|
243
|
-
|
|
244
|
-
open = !open;
|
|
501
|
+
open = false;
|
|
245
502
|
activeValue = initialActiveValue();
|
|
246
503
|
}
|
|
247
504
|
|
|
@@ -249,19 +506,19 @@
|
|
|
249
506
|
return collapsedGroupIndexes.has(groupIndex);
|
|
250
507
|
}
|
|
251
508
|
|
|
252
|
-
function initializeCollapsedGroups() {
|
|
253
|
-
if (
|
|
509
|
+
function initializeCollapsedGroups(nextOptionGroups: DropdownOptionGroup[] | undefined) {
|
|
510
|
+
if (nextOptionGroups === undefined || groupsCollapsedByDefault === 'false') {
|
|
254
511
|
collapsedGroupIndexes = new Set();
|
|
255
512
|
return;
|
|
256
513
|
}
|
|
257
514
|
if (groupsCollapsedByDefault === 'true') {
|
|
258
515
|
collapsedGroupIndexes = new Set(
|
|
259
|
-
|
|
516
|
+
nextOptionGroups.flatMap((group, index) => group.label ? [index] : [])
|
|
260
517
|
);
|
|
261
518
|
return;
|
|
262
519
|
}
|
|
263
520
|
collapsedGroupIndexes = new Set(
|
|
264
|
-
|
|
521
|
+
nextOptionGroups.flatMap((group, index) =>
|
|
265
522
|
group.label && group.options.some((option) => isOptionSelected(option)) ? [] : group.label ? [index] : []
|
|
266
523
|
)
|
|
267
524
|
);
|
|
@@ -296,9 +553,7 @@
|
|
|
296
553
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
297
554
|
event.preventDefault();
|
|
298
555
|
if (!open) {
|
|
299
|
-
|
|
300
|
-
open = true;
|
|
301
|
-
activeValue = initialActiveValue();
|
|
556
|
+
openMenu();
|
|
302
557
|
}
|
|
303
558
|
moveActiveOption(event.key === 'ArrowDown' ? 1 : -1);
|
|
304
559
|
return;
|
|
@@ -312,6 +567,15 @@
|
|
|
312
567
|
!event.altKey
|
|
313
568
|
) {
|
|
314
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
|
+
}
|
|
315
579
|
handleTypeahead(event.key);
|
|
316
580
|
return;
|
|
317
581
|
}
|
|
@@ -326,6 +590,33 @@
|
|
|
326
590
|
}
|
|
327
591
|
}
|
|
328
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;
|
|
614
|
+
if (option) {
|
|
615
|
+
selectOption(option);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
329
620
|
function handleFocusout(event: FocusEvent) {
|
|
330
621
|
const nextTarget = event.relatedTarget;
|
|
331
622
|
if (portal && nextTarget instanceof Node && menuElement?.contains(nextTarget)) {
|
|
@@ -473,6 +764,9 @@
|
|
|
473
764
|
return;
|
|
474
765
|
}
|
|
475
766
|
open = false;
|
|
767
|
+
if (search) {
|
|
768
|
+
buttonElement?.focus();
|
|
769
|
+
}
|
|
476
770
|
};
|
|
477
771
|
|
|
478
772
|
document.addEventListener('pointerdown', handleOutsidePointer, true);
|
|
@@ -490,6 +784,8 @@
|
|
|
490
784
|
disableOpenViewportTracking();
|
|
491
785
|
disableOutsidePointerDismissal();
|
|
492
786
|
clearTypeaheadBuffer();
|
|
787
|
+
clearSearchTimer();
|
|
788
|
+
abortSearchController();
|
|
493
789
|
});
|
|
494
790
|
</script>
|
|
495
791
|
|
|
@@ -498,6 +794,7 @@
|
|
|
498
794
|
class={[
|
|
499
795
|
'suu-dropdown',
|
|
500
796
|
multiselect ? 'suu-dropdown--multiselect' : '',
|
|
797
|
+
search ? 'suu-dropdown--search' : '',
|
|
501
798
|
width !== undefined || minWidth !== undefined || maxWidth !== undefined
|
|
502
799
|
? 'suu-dropdown--sized'
|
|
503
800
|
: '',
|
|
@@ -556,21 +853,54 @@
|
|
|
556
853
|
style:--suu-dropdown-menu-width={portalMenuWidth}
|
|
557
854
|
style:--suu-dropdown-panel-max-height={viewportPanelMaxHeight}
|
|
558
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}
|
|
559
879
|
<div
|
|
560
880
|
class="suu-dropdown__panel"
|
|
881
|
+
id={resolvedListboxId}
|
|
561
882
|
role="listbox"
|
|
562
883
|
aria-label={ariaLabel}
|
|
563
884
|
aria-multiselectable={multiselect || undefined}
|
|
564
885
|
>
|
|
565
|
-
{#if
|
|
566
|
-
|
|
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}
|
|
567
897
|
<button
|
|
568
898
|
type="button"
|
|
569
899
|
class="suu-dropdown__option"
|
|
570
900
|
class:suu-dropdown__option--active={option.value === activeValue}
|
|
571
901
|
class:suu-dropdown__option--disabled={option.disabled}
|
|
572
902
|
role="option"
|
|
573
|
-
aria-selected={multiselect ?
|
|
903
|
+
aria-selected={multiselect ? selectedValueSet.has(option.value) : option.value === value}
|
|
574
904
|
aria-disabled={option.disabled}
|
|
575
905
|
disabled={option.disabled}
|
|
576
906
|
data-value={String(option.value)}
|
|
@@ -585,7 +915,7 @@
|
|
|
585
915
|
{#if multiselect}
|
|
586
916
|
<span
|
|
587
917
|
class="suu-dropdown__checkbox"
|
|
588
|
-
data-checked={multiselect ?
|
|
918
|
+
data-checked={multiselect ? selectedValueSet.has(option.value) : option.value === value}
|
|
589
919
|
aria-hidden="true"
|
|
590
920
|
></span>
|
|
591
921
|
{/if}
|
|
@@ -593,7 +923,7 @@
|
|
|
593
923
|
</button>
|
|
594
924
|
{/each}
|
|
595
925
|
{:else}
|
|
596
|
-
{#each
|
|
926
|
+
{#each renderOptionGroups as group, groupIndex}
|
|
597
927
|
<div class="suu-dropdown__group" role="group" aria-label={group.label}>
|
|
598
928
|
{#if group.label}
|
|
599
929
|
<button
|
|
@@ -615,7 +945,7 @@
|
|
|
615
945
|
class:suu-dropdown__option--active={option.value === activeValue}
|
|
616
946
|
class:suu-dropdown__option--disabled={option.disabled}
|
|
617
947
|
role="option"
|
|
618
|
-
aria-selected={multiselect ?
|
|
948
|
+
aria-selected={multiselect ? selectedValueSet.has(option.value) : option.value === value}
|
|
619
949
|
aria-disabled={option.disabled}
|
|
620
950
|
disabled={option.disabled}
|
|
621
951
|
data-value={String(option.value)}
|
|
@@ -630,7 +960,7 @@
|
|
|
630
960
|
{#if multiselect}
|
|
631
961
|
<span
|
|
632
962
|
class="suu-dropdown__checkbox"
|
|
633
|
-
data-checked={multiselect ?
|
|
963
|
+
data-checked={multiselect ? selectedValueSet.has(option.value) : option.value === value}
|
|
634
964
|
aria-hidden="true"
|
|
635
965
|
></span>
|
|
636
966
|
{/if}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { type UiLanguage } from '../i18n.js';
|
|
2
|
+
import type { DropdownLoadOptions, DropdownMenuAlign, DropdownOption, DropdownOptionGroup, DropdownPlacement, DropdownSelectionChangeHandler, DropdownSelection, DropdownTriggerClickHandler } from './types.js';
|
|
2
3
|
interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
|
|
3
4
|
new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
|
|
4
5
|
$$bindings?: Bindings;
|
|
@@ -18,7 +19,17 @@ declare const Dropdown: $$__sveltets_2_IsomorphicComponent<{
|
|
|
18
19
|
multiselect?: boolean;
|
|
19
20
|
options?: DropdownOption[];
|
|
20
21
|
optionGroups?: DropdownOptionGroup[] | undefined;
|
|
22
|
+
/** Labels for controlled values that are not present in the current search result. */ selectedOptions?: DropdownOption[];
|
|
21
23
|
groupsCollapsedByDefault?: "true" | "false" | "auto";
|
|
24
|
+
search?: boolean;
|
|
25
|
+
loadOptions?: DropdownLoadOptions | undefined;
|
|
26
|
+
searchDebounceMs?: number;
|
|
27
|
+
searchLimit?: number;
|
|
28
|
+
searchPlaceholder?: string | undefined;
|
|
29
|
+
language?: UiLanguage;
|
|
30
|
+
loadingText?: string | undefined;
|
|
31
|
+
noResultsText?: string | undefined;
|
|
32
|
+
errorText?: string | undefined;
|
|
22
33
|
ariaLabel?: string | undefined;
|
|
23
34
|
placement?: DropdownPlacement;
|
|
24
35
|
menuAlign?: DropdownMenuAlign;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Dropdown.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/dropdown/Dropdown.svelte.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"Dropdown.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/dropdown/Dropdown.svelte.ts"],"names":[],"mappings":"AAOA,OAAO,EAAiB,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAC5D,OAAO,KAAK,EAER,mBAAmB,EAGnB,iBAAiB,EAGjB,cAAc,EACd,mBAAmB,EACnB,iBAAiB,EACjB,8BAA8B,EAC9B,iBAAiB,EACjB,2BAA2B,EAE5B,MAAM,YAAY,CAAC;AA81BtB,UAAU,kCAAkC,CAAC,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,OAAO,GAAG,EAAE,EAAE,QAAQ,GAAG,MAAM;IACpM,KAAK,OAAO,EAAE,OAAO,QAAQ,EAAE,2BAA2B,CAAC,KAAK,CAAC,GAAG,OAAO,QAAQ,EAAE,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,GAAG;QAAE,UAAU,CAAC,EAAE,QAAQ,CAAA;KAAE,GAAG,OAAO,CAAC;IACjK,CAAC,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,GAAG;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,KAAK,CAAA;KAAC,GAAG,OAAO,GAAG;QAAE,IAAI,CAAC,EAAE,GAAG,CAAC;QAAC,GAAG,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC;IAC9G,YAAY,CAAC,EAAE,QAAQ,CAAC;CAC3B;AAKD,QAAA,MAAM,QAAQ;SAXswB,MAAM,GAAG,SAAS;YAAU,iBAAiB;;cAA8C,cAAc,EAAE;mBAAiB,mBAAmB,EAAE,GAAG,SAAS;IACj7B,sFAAsF,mBAAkB,cAAc,EAAE;+BAA6B,MAAM,GAAG,OAAO,GAAG,MAAM;;kBAAwC,mBAAmB,GAAG,SAAS;;;wBAAoG,MAAM,GAAG,SAAS;eAAa,UAAU;kBAAgB,MAAM,GAAG,SAAS;oBAAkB,MAAM,GAAG,SAAS;gBAAc,MAAM,GAAG,SAAS;gBAAc,MAAM,GAAG,SAAS;gBAAc,iBAAiB;gBAAc,iBAAiB;;;;WAAuG,MAAM,GAAG,SAAS;;YAAsC,MAAM,GAAG,SAAS;eAAa,MAAM,GAAG,SAAS;eAAa,MAAM,GAAG,SAAS;gBAAc,MAAM,GAAG,SAAS;;eAAqC,8BAA8B,GAAG,SAAS;qBAAmB,2BAA2B,GAAG,SAAS;;;kBAUj4B,CAAC;AAC5E,KAAK,QAAQ,GAAG,YAAY,CAAC,OAAO,QAAQ,CAAC,CAAC;AAChD,eAAe,QAAQ,CAAC"}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
export let value: DropdownMultiValue = [];
|
|
17
17
|
export let options: DropdownOption[] = [];
|
|
18
18
|
export let optionGroups: DropdownOptionGroup[] | undefined = undefined;
|
|
19
|
+
export let selectedOptions: DropdownOption[] = [];
|
|
19
20
|
export let groupsCollapsedByDefault: 'true' | 'false' | 'auto' = 'false';
|
|
20
21
|
export let ariaLabel: string | undefined = undefined;
|
|
21
22
|
export let placement: DropdownPlacement = 'auto';
|
|
@@ -38,6 +39,7 @@
|
|
|
38
39
|
multiselect={true}
|
|
39
40
|
{options}
|
|
40
41
|
{optionGroups}
|
|
42
|
+
{selectedOptions}
|
|
41
43
|
{groupsCollapsedByDefault}
|
|
42
44
|
{ariaLabel}
|
|
43
45
|
{placement}
|
|
@@ -17,6 +17,7 @@ declare const DropdownMultiSelect: $$__sveltets_2_IsomorphicComponent<{
|
|
|
17
17
|
value?: DropdownMultiValue;
|
|
18
18
|
options?: DropdownOption[];
|
|
19
19
|
optionGroups?: DropdownOptionGroup[] | undefined;
|
|
20
|
+
selectedOptions?: DropdownOption[];
|
|
20
21
|
groupsCollapsedByDefault?: "true" | "false" | "auto";
|
|
21
22
|
ariaLabel?: string | undefined;
|
|
22
23
|
placement?: DropdownPlacement;
|