@ibobbyts/svelte-ui-utils 0.4.3 → 0.4.5
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 +119 -14
- 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 +164 -71
- package/dist/dialog/Dialog.svelte.d.ts +1 -0
- package/dist/dialog/Dialog.svelte.d.ts.map +1 -1
- package/dist/dropdown/Dropdown.svelte +470 -43
- package/dist/dropdown/Dropdown.svelte.d.ts +18 -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 +24 -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 +119 -0
- package/package.json +1 -1
|
@@ -1,9 +1,19 @@
|
|
|
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
|
+
DropdownInputStyle,
|
|
13
|
+
DropdownItemLabelGetter,
|
|
14
|
+
DropdownLoadOptions,
|
|
15
|
+
DropdownLoadOptionsResult,
|
|
16
|
+
DropdownLoadStatus,
|
|
7
17
|
DropdownMenuAlign,
|
|
8
18
|
DropdownMultiChangeHandler,
|
|
9
19
|
DropdownMultiValue,
|
|
@@ -11,6 +21,7 @@
|
|
|
11
21
|
DropdownOptionGroup,
|
|
12
22
|
DropdownPlacement,
|
|
13
23
|
DropdownSelectionChangeHandler,
|
|
24
|
+
DropdownSearchChangeHandler,
|
|
14
25
|
DropdownSelection,
|
|
15
26
|
DropdownTriggerClickHandler,
|
|
16
27
|
DropdownValue
|
|
@@ -21,7 +32,21 @@
|
|
|
21
32
|
export let multiselect = false;
|
|
22
33
|
export let options: DropdownOption[] = [];
|
|
23
34
|
export let optionGroups: DropdownOptionGroup[] | undefined = undefined;
|
|
35
|
+
/** Labels for controlled values that are not present in the current search result. */
|
|
36
|
+
export let selectedOptions: DropdownOption[] = [];
|
|
24
37
|
export let groupsCollapsedByDefault: 'true' | 'false' | 'auto' = 'false';
|
|
38
|
+
export let search = false;
|
|
39
|
+
export let input_style: DropdownInputStyle = 'dropdown';
|
|
40
|
+
export let getItemLabel: DropdownItemLabelGetter = (option) => option.label;
|
|
41
|
+
export let loadOptions: DropdownLoadOptions | undefined = undefined;
|
|
42
|
+
export let searchDebounceMs = 300;
|
|
43
|
+
export let searchLimit = 10;
|
|
44
|
+
export let searchPlaceholder: string | undefined = undefined;
|
|
45
|
+
export let placeholder: string | undefined = undefined;
|
|
46
|
+
export let language: UiLanguage = 'en_us';
|
|
47
|
+
export let loadingText: string | undefined = undefined;
|
|
48
|
+
export let noResultsText: string | undefined = undefined;
|
|
49
|
+
export let errorText: string | undefined = undefined;
|
|
25
50
|
export let ariaLabel: string | undefined = undefined;
|
|
26
51
|
export let placement: DropdownPlacement = 'auto';
|
|
27
52
|
export let menuAlign: DropdownMenuAlign = 'left';
|
|
@@ -36,10 +61,12 @@
|
|
|
36
61
|
export let className: string | undefined = undefined;
|
|
37
62
|
export let portal = false;
|
|
38
63
|
export let onChange: DropdownSelectionChangeHandler | undefined = undefined;
|
|
64
|
+
export let onSearchChange: DropdownSearchChangeHandler | undefined = undefined;
|
|
39
65
|
export let onTriggerClick: DropdownTriggerClickHandler | undefined = undefined;
|
|
40
66
|
|
|
41
67
|
const viewportMargin = 20;
|
|
42
68
|
const menuGap = 6;
|
|
69
|
+
const instanceId = ++dropdownInstanceCount;
|
|
43
70
|
|
|
44
71
|
let open = false;
|
|
45
72
|
let activeValue: DropdownValue = '';
|
|
@@ -58,22 +85,52 @@
|
|
|
58
85
|
let typeaheadTimer: ReturnType<typeof setTimeout> | undefined;
|
|
59
86
|
let typeaheadGeneration = 0;
|
|
60
87
|
let collapsedGroupIndexes = new Set<number>();
|
|
88
|
+
// Whether the current open session has already seeded auto group collapse
|
|
89
|
+
// from a search response; once true, the user's manual toggles own the set.
|
|
90
|
+
let searchGroupsInitialized = false;
|
|
91
|
+
let searchQuery = '';
|
|
92
|
+
// 在受控 value 尚未响应 onChange 时保留用户当前输入,避免
|
|
93
|
+
// selectedText 的旧值在同一轮更新中覆盖输入框内容。
|
|
94
|
+
let inputDraft: string | undefined;
|
|
95
|
+
let preserveInputDraftOnClose = false;
|
|
96
|
+
let searchStatus: DropdownLoadStatus = 'idle';
|
|
97
|
+
let searchOptions: DropdownOption[] = [];
|
|
98
|
+
let searchOptionGroups: DropdownOptionGroup[] | undefined = undefined;
|
|
99
|
+
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
|
100
|
+
let searchController: AbortController | undefined;
|
|
101
|
+
let searchRequestId = 0;
|
|
102
|
+
let searchInputElement: HTMLInputElement | undefined;
|
|
103
|
+
const knownLabels = new Map<DropdownValue, string>();
|
|
61
104
|
// Keep the buffer long enough for users to type multi-character aliases
|
|
62
105
|
// while the menu is rendering a large option list.
|
|
63
106
|
const typeaheadTimeoutMs = 1000;
|
|
64
107
|
|
|
65
|
-
$:
|
|
66
|
-
$:
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
$:
|
|
108
|
+
$: messages = getUiMessages(language);
|
|
109
|
+
$: resolvedLoadingText = loadingText?.trim() ? loadingText : messages.dropdownSearch.loadingText;
|
|
110
|
+
$: resolvedNoResultsText = noResultsText?.trim() ? noResultsText : messages.dropdownSearch.noResultsText;
|
|
111
|
+
$: resolvedErrorText = errorText?.trim() ? errorText : 'Failed to load options';
|
|
112
|
+
$: resolvedListboxId = id ? `${id}-listbox` : search ? `suu-dropdown-listbox-${instanceId}` : undefined;
|
|
113
|
+
// Search results fully replace the instantiated options while searching;
|
|
114
|
+
// outside of search mode the caller-provided options stay authoritative.
|
|
115
|
+
$: renderOptions = search ? searchOptions : options;
|
|
116
|
+
$: renderOptionGroups = search ? searchOptionGroups : optionGroups;
|
|
117
|
+
$: resolvedOptions = renderOptionGroups === undefined ? renderOptions : renderOptionGroups.flatMap((group) => group.options);
|
|
118
|
+
$: visibleOptions = renderOptionGroups === undefined
|
|
119
|
+
? renderOptions
|
|
120
|
+
: renderOptionGroups.flatMap((group, groupIndex) => group.label && collapsedGroupIndexes.has(groupIndex) ? [] : group.options);
|
|
121
|
+
$: trackOptionLabels(resolvedOptions);
|
|
122
|
+
$: trackOptionLabels(selectedOptions);
|
|
123
|
+
$: rawSelectedValues = normalizeSelectedValues(multiselect && Array.isArray(value) ? value : []);
|
|
124
|
+
$: selectedValues = orderSelectedValues(resolvedOptions, rawSelectedValues);
|
|
125
|
+
$: selectedValueSet = new Set(rawSelectedValues);
|
|
70
126
|
$: selectedOption = Array.isArray(value) ? undefined : resolvedOptions.find((option) => option.value === value);
|
|
71
127
|
$: selectedText = multiselect
|
|
72
|
-
?
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
128
|
+
? selectedLabelTexts(selectedValues).join(', ')
|
|
129
|
+
: displayLabelForValue(Array.isArray(value) ? '' : value);
|
|
130
|
+
$: inputMode = search && input_style === 'input';
|
|
131
|
+
$: inputDisplayValue = inputMode
|
|
132
|
+
? (inputDraft ?? (searchQuery || (multiselect ? '' : selectedText)))
|
|
133
|
+
: '';
|
|
77
134
|
$: if (!open) {
|
|
78
135
|
activeValue = initialActiveValue();
|
|
79
136
|
}
|
|
@@ -81,13 +138,16 @@
|
|
|
81
138
|
placement;
|
|
82
139
|
if (open) {
|
|
83
140
|
enableOpenViewportTracking();
|
|
84
|
-
if (multiselect) {
|
|
141
|
+
if (multiselect || search) {
|
|
85
142
|
enableOutsidePointerDismissal();
|
|
86
143
|
}
|
|
87
144
|
void updateViewportPanelMaxHeight();
|
|
88
145
|
} else {
|
|
89
146
|
disableOpenViewportTracking();
|
|
90
147
|
disableOutsidePointerDismissal();
|
|
148
|
+
if (search) {
|
|
149
|
+
resetSearchOnClose();
|
|
150
|
+
}
|
|
91
151
|
resolvedPlacement = placement === 'up' ? 'up' : 'down';
|
|
92
152
|
viewportPanelMaxHeight = undefined;
|
|
93
153
|
}
|
|
@@ -98,25 +158,90 @@
|
|
|
98
158
|
}
|
|
99
159
|
|
|
100
160
|
function normalizeSelectedValues(nextValues: DropdownMultiValue): DropdownMultiValue {
|
|
101
|
-
const
|
|
161
|
+
const seen = new Set<DropdownValue>();
|
|
102
162
|
const normalized: DropdownMultiValue = [];
|
|
103
|
-
for (const
|
|
104
|
-
if (
|
|
105
|
-
|
|
163
|
+
for (const nextValue of nextValues) {
|
|
164
|
+
if (!seen.has(nextValue)) {
|
|
165
|
+
seen.add(nextValue);
|
|
166
|
+
normalized.push(nextValue);
|
|
106
167
|
}
|
|
107
168
|
}
|
|
108
169
|
return normalized;
|
|
109
170
|
}
|
|
110
171
|
|
|
172
|
+
// Selected values are reported in option order, but values that are not
|
|
173
|
+
// present in the current options (e.g. picked during an earlier async
|
|
174
|
+
// search) are preserved at the end instead of being dropped.
|
|
175
|
+
function orderSelectedValues(
|
|
176
|
+
nextOptions: DropdownOption[],
|
|
177
|
+
nextSelected: DropdownMultiValue
|
|
178
|
+
): DropdownMultiValue {
|
|
179
|
+
const selectedSet = new Set(nextSelected);
|
|
180
|
+
const ordered = nextOptions
|
|
181
|
+
.filter((option) => selectedSet.has(option.value))
|
|
182
|
+
.map((option) => option.value);
|
|
183
|
+
const orderedSet = new Set(ordered);
|
|
184
|
+
for (const nextValue of nextSelected) {
|
|
185
|
+
if (!orderedSet.has(nextValue)) {
|
|
186
|
+
ordered.push(nextValue);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return ordered;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function trackOptionLabels(nextOptions: DropdownOption[]) {
|
|
193
|
+
for (const option of nextOptions) {
|
|
194
|
+
knownLabels.set(option.value, option.label);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function labelForValue(nextValue: DropdownValue): string {
|
|
199
|
+
const option = resolvedOptions.find((candidate) => candidate.value === nextValue);
|
|
200
|
+
return option?.label ?? knownLabels.get(nextValue) ?? String(nextValue);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function displayLabelForValue(nextValue: DropdownValue): string {
|
|
204
|
+
const option = resolvedOptions.find((candidate) => candidate.value === nextValue) ??
|
|
205
|
+
selectedOptions.find((candidate) => candidate.value === nextValue);
|
|
206
|
+
return option ? getItemLabel(option) : labelForValue(nextValue);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Multiselect trigger text only lists values whose label is known; values
|
|
210
|
+
// without one never render a raw fallback string into the trigger.
|
|
211
|
+
function selectedLabelTexts(nextValues: DropdownMultiValue): string[] {
|
|
212
|
+
const texts: string[] = [];
|
|
213
|
+
for (const nextValue of nextValues) {
|
|
214
|
+
const label =
|
|
215
|
+
resolvedOptions.find((candidate) => candidate.value === nextValue)?.label ??
|
|
216
|
+
knownLabels.get(nextValue);
|
|
217
|
+
if (label !== undefined) {
|
|
218
|
+
const option = resolvedOptions.find((candidate) => candidate.value === nextValue) ??
|
|
219
|
+
selectedOptions.find((candidate) => candidate.value === nextValue);
|
|
220
|
+
texts.push(option ? getItemLabel(option) : label);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return texts;
|
|
224
|
+
}
|
|
225
|
+
|
|
111
226
|
function initialActiveValue(): DropdownValue {
|
|
112
|
-
|
|
113
|
-
|
|
227
|
+
return initialActiveValueFor(visibleOptions, resolvedOptions);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function initialActiveValueFor(
|
|
231
|
+
nextVisibleOptions: DropdownOption[],
|
|
232
|
+
nextResolvedOptions: DropdownOption[]
|
|
233
|
+
): DropdownValue {
|
|
234
|
+
const firstSelectedEnabled = nextVisibleOptions.find(
|
|
235
|
+
(option) => selectedValueSet.has(option.value) && !option.disabled
|
|
114
236
|
);
|
|
115
|
-
|
|
237
|
+
const selectedFallback = Array.isArray(value)
|
|
238
|
+
? undefined
|
|
239
|
+
: nextResolvedOptions.find((option) => option.value === value);
|
|
240
|
+
return firstSelectedEnabled?.value ?? selectedFallback?.value ?? nextVisibleOptions.find((option) => !option.disabled)?.value ?? '';
|
|
116
241
|
}
|
|
117
242
|
|
|
118
243
|
function isOptionSelected(option: DropdownOption): boolean {
|
|
119
|
-
return multiselect ?
|
|
244
|
+
return multiselect ? selectedValueSet.has(option.value) : option.value === value;
|
|
120
245
|
}
|
|
121
246
|
|
|
122
247
|
function activeOptionIndex(nextActiveValue: DropdownValue): number {
|
|
@@ -142,6 +267,7 @@
|
|
|
142
267
|
const currentIndex = Math.max(0, enabledOptions.findIndex((option) => option.value === activeValue));
|
|
143
268
|
const nextIndex = (currentIndex + offset + enabledOptions.length) % enabledOptions.length;
|
|
144
269
|
activeValue = enabledOptions[nextIndex]?.value ?? activeValue;
|
|
270
|
+
scrollActiveOptionIntoView();
|
|
145
271
|
}
|
|
146
272
|
|
|
147
273
|
function clearTypeaheadBuffer() {
|
|
@@ -179,10 +305,14 @@
|
|
|
179
305
|
});
|
|
180
306
|
}
|
|
181
307
|
|
|
182
|
-
function
|
|
183
|
-
|
|
308
|
+
function normalizeTypeaheadKey(key: string): string {
|
|
309
|
+
return key.startsWith('Key') && key.length === 4
|
|
184
310
|
? key.slice(3).toLocaleLowerCase()
|
|
185
311
|
: key.toLocaleLowerCase();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function handleTypeahead(key: string) {
|
|
315
|
+
const normalizedKey = normalizeTypeaheadKey(key);
|
|
186
316
|
const nextBuffer = `${typeaheadBuffer}${normalizedKey}`;
|
|
187
317
|
const findMatch = (prefix: string) =>
|
|
188
318
|
visibleOptions.find(
|
|
@@ -201,8 +331,7 @@
|
|
|
201
331
|
}
|
|
202
332
|
|
|
203
333
|
if (!open) {
|
|
204
|
-
|
|
205
|
-
open = true;
|
|
334
|
+
openMenu();
|
|
206
335
|
}
|
|
207
336
|
activeValue = match.value;
|
|
208
337
|
scrollActiveOptionIntoView();
|
|
@@ -220,28 +349,189 @@
|
|
|
220
349
|
} else {
|
|
221
350
|
nextSelected.add(option.value);
|
|
222
351
|
}
|
|
223
|
-
const nextValues =
|
|
352
|
+
const nextValues = orderSelectedValues(resolvedOptions, [...nextSelected]);
|
|
224
353
|
clearTypeaheadBuffer();
|
|
225
354
|
void (onChange as DropdownMultiChangeHandler | undefined)?.(nextValues);
|
|
226
355
|
return;
|
|
227
356
|
}
|
|
228
357
|
|
|
358
|
+
if (inputMode) {
|
|
359
|
+
inputDraft = getItemLabel(option);
|
|
360
|
+
preserveInputDraftOnClose = true;
|
|
361
|
+
}
|
|
229
362
|
open = false;
|
|
230
363
|
clearTypeaheadBuffer();
|
|
231
364
|
void (onChange as DropdownChangeHandler | undefined)?.(option.value);
|
|
232
365
|
buttonElement?.focus();
|
|
233
366
|
}
|
|
234
367
|
|
|
368
|
+
function clampSearchLimit(limit: number): number {
|
|
369
|
+
if (!Number.isFinite(limit)) {
|
|
370
|
+
return 10;
|
|
371
|
+
}
|
|
372
|
+
return Math.min(50, Math.max(1, Math.floor(limit)));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function clearSearchTimer() {
|
|
376
|
+
if (searchTimer !== undefined) {
|
|
377
|
+
clearTimeout(searchTimer);
|
|
378
|
+
searchTimer = undefined;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function abortSearchController() {
|
|
383
|
+
if (searchController !== undefined) {
|
|
384
|
+
searchController.abort();
|
|
385
|
+
searchController = undefined;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function scheduleSearch(query: string) {
|
|
390
|
+
clearSearchTimer();
|
|
391
|
+
searchTimer = setTimeout(() => {
|
|
392
|
+
searchTimer = undefined;
|
|
393
|
+
void runSearch(query);
|
|
394
|
+
}, Math.max(0, Math.floor(searchDebounceMs)));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function runSearch(query: string) {
|
|
398
|
+
if (!search || loadOptions === undefined) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
abortSearchController();
|
|
402
|
+
const currentRequestId = ++searchRequestId;
|
|
403
|
+
const controller = new AbortController();
|
|
404
|
+
searchController = controller;
|
|
405
|
+
searchStatus = 'loading';
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
const result = await loadOptions(query, {
|
|
409
|
+
limit: clampSearchLimit(searchLimit),
|
|
410
|
+
signal: controller.signal
|
|
411
|
+
});
|
|
412
|
+
// Stale or cancelled responses must never touch the UI.
|
|
413
|
+
if (controller.signal.aborted || currentRequestId !== searchRequestId) {
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
searchStatus = 'success';
|
|
417
|
+
applySearchResult(result ?? {}, query);
|
|
418
|
+
} catch {
|
|
419
|
+
if (controller.signal.aborted || currentRequestId !== searchRequestId) {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
searchOptions = [];
|
|
423
|
+
searchOptionGroups = undefined;
|
|
424
|
+
searchStatus = 'error';
|
|
425
|
+
} finally {
|
|
426
|
+
if (searchController === controller) {
|
|
427
|
+
searchController = undefined;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function mergeSelectedOptions(options: DropdownOption[]): DropdownOption[] {
|
|
433
|
+
const merged = [...options];
|
|
434
|
+
const seen = new Set(options.map((option) => option.value));
|
|
435
|
+
for (const option of selectedOptions) {
|
|
436
|
+
if (!seen.has(option.value)) {
|
|
437
|
+
merged.push(option);
|
|
438
|
+
seen.add(option.value);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return merged;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function applySearchResult(result: DropdownLoadOptionsResult, query = '') {
|
|
445
|
+
// optionGroups is the sole render source when present; empty groups stay hidden.
|
|
446
|
+
const groups = Array.isArray(result.optionGroups)
|
|
447
|
+
? result.optionGroups.filter((group) => Array.isArray(group.options) && group.options.length > 0)
|
|
448
|
+
: undefined;
|
|
449
|
+
if (groups !== undefined) {
|
|
450
|
+
if (query.trim()) {
|
|
451
|
+
searchOptionGroups = groups;
|
|
452
|
+
} else {
|
|
453
|
+
const groupedOptions = groups.flatMap((group) => group.options);
|
|
454
|
+
const groupedValues = new Set(groupedOptions.map((option) => option.value));
|
|
455
|
+
const missingSelected = selectedOptions.filter((option) => !groupedValues.has(option.value));
|
|
456
|
+
searchOptionGroups = missingSelected.length ? [...groups, { options: missingSelected }] : groups;
|
|
457
|
+
}
|
|
458
|
+
searchOptions = [];
|
|
459
|
+
} else {
|
|
460
|
+
searchOptionGroups = undefined;
|
|
461
|
+
searchOptions = query.trim() ? (Array.isArray(result.options) ? result.options : []) : mergeSelectedOptions(Array.isArray(result.options) ? result.options : []);
|
|
462
|
+
}
|
|
463
|
+
// Only the first valid grouped response of an open session seeds the auto
|
|
464
|
+
// collapse state; later responses just swap groups so manual collapse and
|
|
465
|
+
// expand choices survive query refreshes within the same session.
|
|
466
|
+
if (!searchGroupsInitialized && groups !== undefined && groups.length > 0) {
|
|
467
|
+
searchGroupsInitialized = true;
|
|
468
|
+
initializeCollapsedGroups(groups);
|
|
469
|
+
}
|
|
470
|
+
const nextResolvedOptions = groups === undefined ? searchOptions : groups.flatMap((group) => group.options);
|
|
471
|
+
const nextVisibleOptions = groups === undefined
|
|
472
|
+
? searchOptions
|
|
473
|
+
: groups.flatMap((group, groupIndex) => group.label && collapsedGroupIndexes.has(groupIndex) ? [] : group.options);
|
|
474
|
+
activeValue = initialActiveValueFor(nextVisibleOptions, nextResolvedOptions);
|
|
475
|
+
scrollActiveOptionIntoView();
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function resetSearchOnClose() {
|
|
479
|
+
clearSearchTimer();
|
|
480
|
+
abortSearchController();
|
|
481
|
+
searchQuery = '';
|
|
482
|
+
if (preserveInputDraftOnClose) {
|
|
483
|
+
preserveInputDraftOnClose = false;
|
|
484
|
+
} else {
|
|
485
|
+
inputDraft = undefined;
|
|
486
|
+
}
|
|
487
|
+
searchOptions = [];
|
|
488
|
+
searchOptionGroups = undefined;
|
|
489
|
+
searchStatus = 'idle';
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function beginSearchOnOpen() {
|
|
493
|
+
searchQuery = '';
|
|
494
|
+
inputDraft = undefined;
|
|
495
|
+
searchOptions = [];
|
|
496
|
+
searchOptionGroups = undefined;
|
|
497
|
+
collapsedGroupIndexes = new Set();
|
|
498
|
+
searchGroupsInitialized = false;
|
|
499
|
+
clearSearchTimer();
|
|
500
|
+
abortSearchController();
|
|
501
|
+
if (loadOptions === undefined) {
|
|
502
|
+
searchStatus = 'idle';
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
// The empty query fires immediately on every expand.
|
|
506
|
+
void runSearch('');
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function openMenu() {
|
|
510
|
+
updateResolvedPlacement();
|
|
511
|
+
if (search) {
|
|
512
|
+
beginSearchOnOpen();
|
|
513
|
+
} else {
|
|
514
|
+
initializeCollapsedGroups(renderOptionGroups);
|
|
515
|
+
}
|
|
516
|
+
open = true;
|
|
517
|
+
activeValue = initialActiveValue();
|
|
518
|
+
if (search && !inputMode) {
|
|
519
|
+
void tick().then(() => {
|
|
520
|
+
searchInputElement?.focus();
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
235
525
|
function toggleOpen() {
|
|
236
526
|
if (disabled) {
|
|
237
527
|
return;
|
|
238
528
|
}
|
|
529
|
+
clearTypeaheadBuffer();
|
|
239
530
|
if (!open) {
|
|
240
|
-
|
|
241
|
-
|
|
531
|
+
openMenu();
|
|
532
|
+
return;
|
|
242
533
|
}
|
|
243
|
-
|
|
244
|
-
open = !open;
|
|
534
|
+
open = false;
|
|
245
535
|
activeValue = initialActiveValue();
|
|
246
536
|
}
|
|
247
537
|
|
|
@@ -249,19 +539,19 @@
|
|
|
249
539
|
return collapsedGroupIndexes.has(groupIndex);
|
|
250
540
|
}
|
|
251
541
|
|
|
252
|
-
function initializeCollapsedGroups() {
|
|
253
|
-
if (
|
|
542
|
+
function initializeCollapsedGroups(nextOptionGroups: DropdownOptionGroup[] | undefined) {
|
|
543
|
+
if (nextOptionGroups === undefined || groupsCollapsedByDefault === 'false') {
|
|
254
544
|
collapsedGroupIndexes = new Set();
|
|
255
545
|
return;
|
|
256
546
|
}
|
|
257
547
|
if (groupsCollapsedByDefault === 'true') {
|
|
258
548
|
collapsedGroupIndexes = new Set(
|
|
259
|
-
|
|
549
|
+
nextOptionGroups.flatMap((group, index) => group.label ? [index] : [])
|
|
260
550
|
);
|
|
261
551
|
return;
|
|
262
552
|
}
|
|
263
553
|
collapsedGroupIndexes = new Set(
|
|
264
|
-
|
|
554
|
+
nextOptionGroups.flatMap((group, index) =>
|
|
265
555
|
group.label && group.options.some((option) => isOptionSelected(option)) ? [] : group.label ? [index] : []
|
|
266
556
|
)
|
|
267
557
|
);
|
|
@@ -296,9 +586,7 @@
|
|
|
296
586
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
297
587
|
event.preventDefault();
|
|
298
588
|
if (!open) {
|
|
299
|
-
|
|
300
|
-
open = true;
|
|
301
|
-
activeValue = initialActiveValue();
|
|
589
|
+
openMenu();
|
|
302
590
|
}
|
|
303
591
|
moveActiveOption(event.key === 'ArrowDown' ? 1 : -1);
|
|
304
592
|
return;
|
|
@@ -312,6 +600,16 @@
|
|
|
312
600
|
!event.altKey
|
|
313
601
|
) {
|
|
314
602
|
event.preventDefault();
|
|
603
|
+
if (search) {
|
|
604
|
+
const wasOpen = open;
|
|
605
|
+
if (!wasOpen) {
|
|
606
|
+
openMenu();
|
|
607
|
+
}
|
|
608
|
+
searchQuery = wasOpen ? `${searchQuery}${normalizeTypeaheadKey(event.key)}` : normalizeTypeaheadKey(event.key);
|
|
609
|
+
void onSearchChange?.(searchQuery);
|
|
610
|
+
scheduleSearch(searchQuery);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
315
613
|
handleTypeahead(event.key);
|
|
316
614
|
return;
|
|
317
615
|
}
|
|
@@ -326,6 +624,54 @@
|
|
|
326
624
|
}
|
|
327
625
|
}
|
|
328
626
|
|
|
627
|
+
function handleSearchInput(event: Event) {
|
|
628
|
+
const nextQuery = (event.currentTarget as HTMLInputElement).value;
|
|
629
|
+
inputDraft = nextQuery;
|
|
630
|
+
if (inputMode && !multiselect && value !== '' && nextQuery !== selectedText) {
|
|
631
|
+
void (onChange as DropdownChangeHandler | undefined)?.('');
|
|
632
|
+
}
|
|
633
|
+
searchQuery = nextQuery;
|
|
634
|
+
if (inputMode) {
|
|
635
|
+
if (nextQuery.trim()) {
|
|
636
|
+
if (!open) {
|
|
637
|
+
updateResolvedPlacement();
|
|
638
|
+
open = true;
|
|
639
|
+
activeValue = initialActiveValue();
|
|
640
|
+
}
|
|
641
|
+
} else {
|
|
642
|
+
open = false;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
void onSearchChange?.(searchQuery);
|
|
646
|
+
scheduleSearch(searchQuery);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function handleInputFocus() {
|
|
650
|
+
// 输入模式下聚焦只准备输入;用户输入有效搜索词后才展开结果。
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function handleSearchKeydown(event: KeyboardEvent) {
|
|
654
|
+
if (event.key === 'Escape') {
|
|
655
|
+
event.preventDefault();
|
|
656
|
+
open = false;
|
|
657
|
+
buttonElement?.focus();
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
661
|
+
event.preventDefault();
|
|
662
|
+
moveActiveOption(event.key === 'ArrowDown' ? 1 : -1);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
if (event.key === 'Enter') {
|
|
666
|
+
event.preventDefault();
|
|
667
|
+
const index = activeOptionIndex(activeValue);
|
|
668
|
+
const option = index >= 0 ? visibleOptions[index] : undefined;
|
|
669
|
+
if (option) {
|
|
670
|
+
selectOption(option);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
329
675
|
function handleFocusout(event: FocusEvent) {
|
|
330
676
|
const nextTarget = event.relatedTarget;
|
|
331
677
|
if (portal && nextTarget instanceof Node && menuElement?.contains(nextTarget)) {
|
|
@@ -473,6 +819,9 @@
|
|
|
473
819
|
return;
|
|
474
820
|
}
|
|
475
821
|
open = false;
|
|
822
|
+
if (search) {
|
|
823
|
+
buttonElement?.focus();
|
|
824
|
+
}
|
|
476
825
|
};
|
|
477
826
|
|
|
478
827
|
document.addEventListener('pointerdown', handleOutsidePointer, true);
|
|
@@ -490,6 +839,8 @@
|
|
|
490
839
|
disableOpenViewportTracking();
|
|
491
840
|
disableOutsidePointerDismissal();
|
|
492
841
|
clearTypeaheadBuffer();
|
|
842
|
+
clearSearchTimer();
|
|
843
|
+
abortSearchController();
|
|
493
844
|
});
|
|
494
845
|
</script>
|
|
495
846
|
|
|
@@ -498,6 +849,7 @@
|
|
|
498
849
|
class={[
|
|
499
850
|
'suu-dropdown',
|
|
500
851
|
multiselect ? 'suu-dropdown--multiselect' : '',
|
|
852
|
+
search ? 'suu-dropdown--search' : '',
|
|
501
853
|
width !== undefined || minWidth !== undefined || maxWidth !== undefined
|
|
502
854
|
? 'suu-dropdown--sized'
|
|
503
855
|
: '',
|
|
@@ -509,6 +861,47 @@
|
|
|
509
861
|
style:max-width={maxWidth}
|
|
510
862
|
on:focusout={handleFocusout}
|
|
511
863
|
>
|
|
864
|
+
{#if inputMode}
|
|
865
|
+
{#if multiselect && selectedValues.length > 0}
|
|
866
|
+
<div class="suu-dropdown__selected-items">
|
|
867
|
+
{#each selectedValues as selectedValue}
|
|
868
|
+
{@const selectedOption = resolvedOptions.find((option) => option.value === selectedValue) ?? selectedOptions.find((option) => option.value === selectedValue)}
|
|
869
|
+
{#if selectedOption}
|
|
870
|
+
<span class="suu-dropdown__selected-item">
|
|
871
|
+
<span>{getItemLabel(selectedOption)}</span>
|
|
872
|
+
<button
|
|
873
|
+
type="button"
|
|
874
|
+
class="suu-dropdown__selected-remove"
|
|
875
|
+
aria-label={`Remove ${getItemLabel(selectedOption)}`}
|
|
876
|
+
on:mousedown|preventDefault
|
|
877
|
+
on:click={() => selectOption(selectedOption)}
|
|
878
|
+
>×</button>
|
|
879
|
+
</span>
|
|
880
|
+
{/if}
|
|
881
|
+
{/each}
|
|
882
|
+
</div>
|
|
883
|
+
{/if}
|
|
884
|
+
<div class="suu-dropdown__input-field">
|
|
885
|
+
<input
|
|
886
|
+
bind:this={searchInputElement}
|
|
887
|
+
{id}
|
|
888
|
+
class="suu-dropdown__input"
|
|
889
|
+
type="text"
|
|
890
|
+
value={inputDisplayValue}
|
|
891
|
+
placeholder={searchPlaceholder ?? placeholder ?? ''}
|
|
892
|
+
aria-label={ariaLabel}
|
|
893
|
+
autocomplete="off"
|
|
894
|
+
role="combobox"
|
|
895
|
+
aria-expanded={open}
|
|
896
|
+
aria-autocomplete="list"
|
|
897
|
+
aria-controls={resolvedListboxId}
|
|
898
|
+
{disabled}
|
|
899
|
+
on:focus={handleInputFocus}
|
|
900
|
+
on:input={handleSearchInput}
|
|
901
|
+
on:keydown={handleSearchKeydown}
|
|
902
|
+
/>
|
|
903
|
+
</div>
|
|
904
|
+
{:else}
|
|
512
905
|
<button
|
|
513
906
|
bind:this={buttonElement}
|
|
514
907
|
{id}
|
|
@@ -522,9 +915,10 @@
|
|
|
522
915
|
on:click={handleTriggerClick}
|
|
523
916
|
on:keydown={handleKeydown}
|
|
524
917
|
>
|
|
525
|
-
<span class="suu-dropdown__label">{selectedText}</span>
|
|
918
|
+
<span class="suu-dropdown__label">{selectedText || placeholder || ''}</span>
|
|
526
919
|
<span class="suu-dropdown__chevron" aria-hidden="true"></span>
|
|
527
920
|
</button>
|
|
921
|
+
{/if}
|
|
528
922
|
|
|
529
923
|
{#if name !== undefined && !multiselect}
|
|
530
924
|
<input
|
|
@@ -556,21 +950,54 @@
|
|
|
556
950
|
style:--suu-dropdown-menu-width={portalMenuWidth}
|
|
557
951
|
style:--suu-dropdown-panel-max-height={viewportPanelMaxHeight}
|
|
558
952
|
>
|
|
953
|
+
{#if search && !inputMode}
|
|
954
|
+
<div class="suu-dropdown__search">
|
|
955
|
+
<svg class="suu-dropdown__search-icon" viewBox="0 0 24 24" aria-hidden="true">
|
|
956
|
+
<circle cx="11" cy="11" r="7"></circle>
|
|
957
|
+
<path d="m16 16 4 4"></path>
|
|
958
|
+
</svg>
|
|
959
|
+
<input
|
|
960
|
+
bind:this={searchInputElement}
|
|
961
|
+
class="suu-dropdown__search-input"
|
|
962
|
+
type="text"
|
|
963
|
+
value={searchQuery}
|
|
964
|
+
placeholder={searchPlaceholder ?? ''}
|
|
965
|
+
aria-label={ariaLabel}
|
|
966
|
+
autocomplete="off"
|
|
967
|
+
role="combobox"
|
|
968
|
+
aria-expanded="true"
|
|
969
|
+
aria-autocomplete="list"
|
|
970
|
+
aria-controls={resolvedListboxId}
|
|
971
|
+
on:input={handleSearchInput}
|
|
972
|
+
on:keydown={handleSearchKeydown}
|
|
973
|
+
/>
|
|
974
|
+
</div>
|
|
975
|
+
{/if}
|
|
559
976
|
<div
|
|
560
977
|
class="suu-dropdown__panel"
|
|
978
|
+
id={resolvedListboxId}
|
|
561
979
|
role="listbox"
|
|
562
980
|
aria-label={ariaLabel}
|
|
563
981
|
aria-multiselectable={multiselect || undefined}
|
|
564
982
|
>
|
|
565
|
-
{#if
|
|
566
|
-
|
|
983
|
+
{#if search && searchStatus === 'loading'}
|
|
984
|
+
<div class="suu-dropdown__status suu-dropdown__status--loading" aria-live="polite">
|
|
985
|
+
<span class="suu-dropdown__spinner" aria-hidden="true"></span>
|
|
986
|
+
<span>{resolvedLoadingText}</span>
|
|
987
|
+
</div>
|
|
988
|
+
{:else if search && searchStatus === 'error'}
|
|
989
|
+
<div class="suu-dropdown__status suu-dropdown__status--error" role="alert">{resolvedErrorText}</div>
|
|
990
|
+
{:else if search && resolvedOptions.length === 0}
|
|
991
|
+
<div class="suu-dropdown__status suu-dropdown__status--empty">{resolvedNoResultsText}</div>
|
|
992
|
+
{:else if renderOptionGroups === undefined}
|
|
993
|
+
{#each renderOptions as option}
|
|
567
994
|
<button
|
|
568
995
|
type="button"
|
|
569
996
|
class="suu-dropdown__option"
|
|
570
997
|
class:suu-dropdown__option--active={option.value === activeValue}
|
|
571
998
|
class:suu-dropdown__option--disabled={option.disabled}
|
|
572
999
|
role="option"
|
|
573
|
-
aria-selected={multiselect ?
|
|
1000
|
+
aria-selected={multiselect ? selectedValueSet.has(option.value) : option.value === value}
|
|
574
1001
|
aria-disabled={option.disabled}
|
|
575
1002
|
disabled={option.disabled}
|
|
576
1003
|
data-value={String(option.value)}
|
|
@@ -585,7 +1012,7 @@
|
|
|
585
1012
|
{#if multiselect}
|
|
586
1013
|
<span
|
|
587
1014
|
class="suu-dropdown__checkbox"
|
|
588
|
-
data-checked={multiselect ?
|
|
1015
|
+
data-checked={multiselect ? selectedValueSet.has(option.value) : option.value === value}
|
|
589
1016
|
aria-hidden="true"
|
|
590
1017
|
></span>
|
|
591
1018
|
{/if}
|
|
@@ -593,7 +1020,7 @@
|
|
|
593
1020
|
</button>
|
|
594
1021
|
{/each}
|
|
595
1022
|
{:else}
|
|
596
|
-
{#each
|
|
1023
|
+
{#each renderOptionGroups as group, groupIndex}
|
|
597
1024
|
<div class="suu-dropdown__group" role="group" aria-label={group.label}>
|
|
598
1025
|
{#if group.label}
|
|
599
1026
|
<button
|
|
@@ -615,7 +1042,7 @@
|
|
|
615
1042
|
class:suu-dropdown__option--active={option.value === activeValue}
|
|
616
1043
|
class:suu-dropdown__option--disabled={option.disabled}
|
|
617
1044
|
role="option"
|
|
618
|
-
aria-selected={multiselect ?
|
|
1045
|
+
aria-selected={multiselect ? selectedValueSet.has(option.value) : option.value === value}
|
|
619
1046
|
aria-disabled={option.disabled}
|
|
620
1047
|
disabled={option.disabled}
|
|
621
1048
|
data-value={String(option.value)}
|
|
@@ -630,7 +1057,7 @@
|
|
|
630
1057
|
{#if multiselect}
|
|
631
1058
|
<span
|
|
632
1059
|
class="suu-dropdown__checkbox"
|
|
633
|
-
data-checked={multiselect ?
|
|
1060
|
+
data-checked={multiselect ? selectedValueSet.has(option.value) : option.value === value}
|
|
634
1061
|
aria-hidden="true"
|
|
635
1062
|
></span>
|
|
636
1063
|
{/if}
|