@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.
Files changed (38) hide show
  1. package/README.md +119 -14
  2. package/dist/data-table/FilterControl.svelte +30 -4
  3. package/dist/data-table/FilterControl.svelte.d.ts.map +1 -1
  4. package/dist/data-table/filter.d.ts +3 -1
  5. package/dist/data-table/filter.d.ts.map +1 -1
  6. package/dist/data-table/filter.js +4 -0
  7. package/dist/data-table/index.d.ts +1 -1
  8. package/dist/data-table/index.d.ts.map +1 -1
  9. package/dist/data-table/types.d.ts +41 -11
  10. package/dist/data-table/types.d.ts.map +1 -1
  11. package/dist/dialog/ConfirmDialog.svelte +2 -0
  12. package/dist/dialog/ConfirmDialog.svelte.d.ts +1 -0
  13. package/dist/dialog/ConfirmDialog.svelte.d.ts.map +1 -1
  14. package/dist/dialog/Dialog.svelte +164 -71
  15. package/dist/dialog/Dialog.svelte.d.ts +1 -0
  16. package/dist/dialog/Dialog.svelte.d.ts.map +1 -1
  17. package/dist/dropdown/Dropdown.svelte +470 -43
  18. package/dist/dropdown/Dropdown.svelte.d.ts +18 -1
  19. package/dist/dropdown/Dropdown.svelte.d.ts.map +1 -1
  20. package/dist/dropdown/DropdownMultiSelect.svelte +2 -0
  21. package/dist/dropdown/DropdownMultiSelect.svelte.d.ts +1 -0
  22. package/dist/dropdown/DropdownMultiSelect.svelte.d.ts.map +1 -1
  23. package/dist/dropdown/index.d.ts +1 -1
  24. package/dist/dropdown/index.d.ts.map +1 -1
  25. package/dist/dropdown/types.d.ts +24 -2
  26. package/dist/dropdown/types.d.ts.map +1 -1
  27. package/dist/dropdown-search/DropdownSearch.svelte +12 -10
  28. package/dist/dropdown-search/DropdownSearch.svelte.d.ts +2 -2
  29. package/dist/dropdown-search/DropdownSearch.svelte.d.ts.map +1 -1
  30. package/dist/dropdown-search/DropdownSearchMultiSelect.svelte +3 -3
  31. package/dist/dropdown-search/DropdownSearchMultiSelect.svelte.d.ts +2 -2
  32. package/dist/dropdown-search/index.d.ts +1 -1
  33. package/dist/dropdown-search/types.d.ts +10 -3
  34. package/dist/dropdown-search/types.d.ts.map +1 -1
  35. package/dist/pagination/Pagination.svelte +5 -4
  36. package/dist/pagination/Pagination.svelte.d.ts.map +1 -1
  37. package/dist/style.css +119 -0
  38. 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
- $: resolvedOptions = optionGroups === undefined ? options : optionGroups.flatMap((group) => group.options);
66
- $: visibleOptions = optionGroups === undefined
67
- ? options
68
- : optionGroups.flatMap((group, groupIndex) => group.label && collapsedGroupIndexes.has(groupIndex) ? [] : group.options);
69
- $: selectedValues = normalizeSelectedValues(multiselect && Array.isArray(value) ? value : []);
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
- ? resolvedOptions
73
- .filter((option) => selectedValues.includes(option.value))
74
- .map((option) => option.label)
75
- .join(', ')
76
- : selectedOption?.label ?? String(value);
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 incoming = new Set(nextValues);
161
+ const seen = new Set<DropdownValue>();
102
162
  const normalized: DropdownMultiValue = [];
103
- for (const option of resolvedOptions) {
104
- if (incoming.has(option.value) && !normalized.includes(option.value)) {
105
- normalized.push(option.value);
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
- const firstSelectedEnabled = visibleOptions.find(
113
- (option) => selectedValues.includes(option.value) && !option.disabled
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
- return firstSelectedEnabled?.value ?? selectedOption?.value ?? firstEnabledOption()?.value ?? '';
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 ? selectedValues.includes(option.value) : option.value === value;
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 handleTypeahead(key: string) {
183
- const normalizedKey = key.startsWith('Key') && key.length === 4
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
- updateResolvedPlacement();
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 = normalizeSelectedValues([...nextSelected]);
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
- updateResolvedPlacement();
241
- initializeCollapsedGroups();
531
+ openMenu();
532
+ return;
242
533
  }
243
- clearTypeaheadBuffer();
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 (optionGroups === undefined || groupsCollapsedByDefault === 'false') {
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
- optionGroups.flatMap((group, index) => group.label ? [index] : [])
549
+ nextOptionGroups.flatMap((group, index) => group.label ? [index] : [])
260
550
  );
261
551
  return;
262
552
  }
263
553
  collapsedGroupIndexes = new Set(
264
- optionGroups.flatMap((group, index) =>
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
- updateResolvedPlacement();
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 optionGroups === undefined}
566
- {#each options as option}
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 ? selectedValues.includes(option.value) : option.value === value}
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 ? selectedValues.includes(option.value) : option.value === value}
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 optionGroups as group, groupIndex}
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 ? selectedValues.includes(option.value) : option.value === value}
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 ? selectedValues.includes(option.value) : option.value === value}
1060
+ data-checked={multiselect ? selectedValueSet.has(option.value) : option.value === value}
634
1061
  aria-hidden="true"
635
1062
  ></span>
636
1063
  {/if}