@ibobbyts/svelte-ui-utils 0.4.3 → 0.4.4

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