@ibobbyts/svelte-ui-utils 0.4.2 → 0.4.4

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