svelte-multiselect 11.4.0 → 11.5.1

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.
@@ -1,10 +1,11 @@
1
1
  <!-- eslint-disable-next-line @stylistic/quotes -- TS generics require string literals -->
2
2
  <script lang="ts" generics="Option extends import('./types').Option">import { tick, untrack } from 'svelte';
3
3
  import { flip } from 'svelte/animate';
4
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
4
5
  import { highlight_matches } from './attachments';
5
6
  import CircleSpinner from './CircleSpinner.svelte';
6
7
  import Icon from './Icon.svelte';
7
- import { fuzzy_match, get_label, get_style, is_object } from './utils';
8
+ import { fuzzy_match, get_label, get_style, has_group, is_object } from './utils';
8
9
  import Wiggle from './Wiggle.svelte';
9
10
  let { activeIndex = $bindable(null), activeOption = $bindable(null), createOptionMsg = `Create this option...`, allowUserOptions = false, allowEmpty = false, autocomplete = `off`, autoScroll = true, breakpoint = 800, defaultDisabledTitle = `This option is disabled`, disabled = false, disabledInputTitle = `This input is disabled`, duplicateOptionMsg = `This option is already selected`, duplicates = false, keepSelectedInDropdown = false, key = (opt) => `${get_label(opt)}`.toLowerCase(), filterFunc = (opt, searchText) => {
10
11
  if (!searchText)
@@ -17,32 +18,81 @@ let { activeIndex = $bindable(null), activeOption = $bindable(null), createOptio
17
18
  ? (Array.isArray(value) ? value : [value])
18
19
  : (options
19
20
  ?.filter((opt) => typeof opt === `object` && opt !== null && opt?.preselected)
20
- .slice(0, maxSelect ?? undefined) ?? [])), sortSelected = false, selectedOptionsDraggable = !sortSelected, style = null, ulOptionsClass = ``, ulSelectedClass = ``, ulSelectedStyle = null, ulOptionsStyle = null, expandIcon, selectedItem, children, removeIcon, afterInput, spinner, disabledIcon, option, userMsg, onblur, onclick, onfocus, onkeydown, onkeyup, onmousedown, onmouseenter, onmouseleave, ontouchcancel, ontouchend, ontouchmove, ontouchstart, onadd, oncreate, onremove, onremoveAll, onchange, onopen, onclose, onselectAll, portal: portal_params = {},
21
+ .slice(0, maxSelect ?? undefined) ?? [])), sortSelected = false, selectedOptionsDraggable = !sortSelected, style = null, ulOptionsClass = ``, ulSelectedClass = ``, ulSelectedStyle = null, ulOptionsStyle = null, expandIcon, selectedItem, children, removeIcon, afterInput, spinner, disabledIcon, option, userMsg, onblur, onclick, onfocus, onkeydown, onkeyup, onmousedown, onmouseenter, onmouseleave, ontouchcancel, ontouchend, ontouchmove, ontouchstart, onadd, oncreate, onremove, onremoveAll, onchange, onopen, onclose, onselectAll, onreorder, portal: portal_params = {},
21
22
  // Select all feature
22
23
  selectAllOption = false, liSelectAllClass = ``,
23
24
  // Dynamic options loading
24
25
  loadOptions,
25
26
  // Animation parameters for selected options flip animation
26
- selectedFlipParams = { duration: 100 }, ...rest } = $props();
27
- // Extract loadOptions function and config (supports both simple function and config object)
28
- const load_options_fn = $derived(loadOptions
29
- ? (typeof loadOptions === `function` ? loadOptions : loadOptions.fetch)
30
- : null);
31
- const load_options_debounce_ms = $derived(loadOptions && typeof loadOptions === `object`
32
- ? (loadOptions.debounceMs ?? 300)
33
- : 300);
34
- const load_options_batch_size = $derived(loadOptions && typeof loadOptions === `object`
35
- ? (loadOptions.batchSize ?? 50)
36
- : 50);
37
- const load_options_on_open = $derived(loadOptions && typeof loadOptions === `object`
38
- ? (loadOptions.onOpen ?? true)
39
- : true);
40
- // Helper to compare arrays/values for equality to avoid unnecessary updates
27
+ selectedFlipParams = { duration: 100 },
28
+ // Option grouping feature
29
+ collapsibleGroups = false, collapsedGroups = $bindable(new Set()), groupSelectAll = false, ungroupedPosition = `first`, groupSortOrder = `none`, searchExpandsCollapsedGroups = false, searchMatchesGroups = false, keyboardExpandsCollapsedGroups = false, stickyGroupHeaders = false, liGroupHeaderClass = ``, liGroupHeaderStyle = null, groupHeader, ongroupToggle, oncollapseAll, onexpandAll, collapseAllGroups = $bindable(), expandAllGroups = $bindable(),
30
+ // Keyboard shortcuts for common actions
31
+ shortcuts = {}, ...rest } = $props();
32
+ // Generate unique IDs for ARIA associations (combobox pattern)
33
+ // Uses provided id prop or generates a random one using crypto API
34
+ const internal_id = $derived(id ?? `sms-${crypto.randomUUID().slice(0, 8)}`);
35
+ const listbox_id = $derived(`${internal_id}-listbox`);
36
+ // Parse shortcut string into modifier+key parts
37
+ function parse_shortcut(shortcut) {
38
+ const parts = shortcut.toLowerCase().split(`+`).map((part) => part.trim());
39
+ const key = parts.pop() ?? ``;
40
+ return {
41
+ key,
42
+ ctrl: parts.includes(`ctrl`),
43
+ shift: parts.includes(`shift`),
44
+ alt: parts.includes(`alt`),
45
+ meta: parts.includes(`meta`) || parts.includes(`cmd`),
46
+ };
47
+ }
48
+ function matches_shortcut(event, shortcut) {
49
+ if (!shortcut)
50
+ return false;
51
+ const parsed = parse_shortcut(shortcut);
52
+ // Require non-empty key to prevent "ctrl+" from matching any key with ctrl pressed
53
+ if (!parsed.key)
54
+ return false;
55
+ const key_matches = event.key.toLowerCase() === parsed.key;
56
+ const ctrl_matches = event.ctrlKey === parsed.ctrl;
57
+ const shift_matches = event.shiftKey === parsed.shift;
58
+ const alt_matches = event.altKey === parsed.alt;
59
+ const meta_matches = event.metaKey === parsed.meta;
60
+ return (key_matches && ctrl_matches && shift_matches && alt_matches && meta_matches);
61
+ }
62
+ // Default shortcuts
63
+ const default_shortcuts = {
64
+ select_all: `ctrl+a`,
65
+ clear_all: `ctrl+shift+a`,
66
+ open: null,
67
+ close: null,
68
+ };
69
+ const effective_shortcuts = $derived({
70
+ ...default_shortcuts,
71
+ ...shortcuts,
72
+ });
73
+ // Extract loadOptions config into single derived object (supports both simple function and config object)
74
+ const load_options_config = $derived.by(() => {
75
+ if (!loadOptions)
76
+ return null;
77
+ const is_fn = typeof loadOptions === `function`;
78
+ return {
79
+ fetch: is_fn ? loadOptions : loadOptions.fetch,
80
+ debounce_ms: is_fn ? 300 : (loadOptions.debounceMs ?? 300),
81
+ batch_size: is_fn ? 50 : (loadOptions.batchSize ?? 50),
82
+ on_open: is_fn ? true : (loadOptions.onOpen ?? true),
83
+ };
84
+ });
85
+ // Helper to compare arrays/values for equality to avoid unnecessary updates.
41
86
  // Prevents infinite loops when value/selected are bound to reactive wrappers
42
87
  // that clone arrays on assignment (e.g. Superforms, Svelte stores). See issue #309.
88
+ // Treats null/undefined/[] as equivalent empty states to prevent extra updates on init (#369).
43
89
  function values_equal(val1, val2) {
44
90
  if (val1 === val2)
45
91
  return true;
92
+ const empty1 = val1 == null || (Array.isArray(val1) && val1.length === 0);
93
+ const empty2 = val2 == null || (Array.isArray(val2) && val2.length === 0);
94
+ if (empty1 && empty2)
95
+ return true;
46
96
  if (Array.isArray(val1) && Array.isArray(val2)) {
47
97
  return val1.length === val2.length &&
48
98
  val1.every((item, idx) => item === val2[idx]);
@@ -66,6 +116,15 @@ $effect.pre(() => {
66
116
  });
67
117
  let wiggle = $state(false); // controls wiggle animation when user tries to exceed maxSelect
68
118
  let ignore_hover = $state(false); // ignore mouseover during keyboard navigation to prevent scroll-triggered hover
119
+ // Track last selection action for aria-live announcements
120
+ let last_action = $state(null);
121
+ // Clear last_action after announcement so option counts can be announced again
122
+ $effect(() => {
123
+ if (last_action) {
124
+ const timer = setTimeout(() => (last_action = null), 1000);
125
+ return () => clearTimeout(timer);
126
+ }
127
+ });
69
128
  // Internal state for loadOptions feature (null = never loaded)
70
129
  let loaded_options = $state([]);
71
130
  let load_options_has_more = $state(true);
@@ -76,6 +135,130 @@ let effective_options = $derived(loadOptions ? loaded_options : (options ?? []))
76
135
  // Cache selected keys and labels to avoid repeated .map() calls
77
136
  let selected_keys = $derived(selected.map(key));
78
137
  let selected_labels = $derived(selected.map(get_label));
138
+ // Sets for O(1) lookups (used in template, has_user_msg, group_header_state, batch operations)
139
+ let selected_keys_set = $derived(new Set(selected_keys));
140
+ let selected_labels_set = $derived(new Set(selected_labels));
141
+ // Memoized Set of disabled option keys for O(1) lookups in large option sets
142
+ let disabled_option_keys = $derived(new Set(effective_options
143
+ .filter((opt) => is_object(opt) && opt.disabled)
144
+ .map(key)));
145
+ // Check if an option is disabled (uses memoized Set for O(1) lookup)
146
+ const is_disabled = (opt) => disabled_option_keys.has(key(opt));
147
+ // Group matching options by their `group` key
148
+ // Note: SvelteMap used here to satisfy eslint svelte/prefer-svelte-reactivity rule,
149
+ // though a plain Map would work since this is recreated fresh on each derivation
150
+ let grouped_options = $derived.by(() => {
151
+ const groups_map = new SvelteMap();
152
+ const ungrouped = [];
153
+ for (const opt of matchingOptions) {
154
+ if (has_group(opt)) {
155
+ const existing = groups_map.get(opt.group);
156
+ if (existing)
157
+ existing.push(opt);
158
+ else
159
+ groups_map.set(opt.group, [opt]);
160
+ }
161
+ else {
162
+ ungrouped.push(opt);
163
+ }
164
+ }
165
+ let grouped = [...groups_map.entries()].map(([group, options]) => ({
166
+ group,
167
+ options,
168
+ collapsed: collapsedGroups.has(group),
169
+ }));
170
+ // Apply group sorting if specified
171
+ if (groupSortOrder && groupSortOrder !== `none`) {
172
+ grouped = grouped.toSorted((group_a, group_b) => {
173
+ if (typeof groupSortOrder === `function`) {
174
+ return groupSortOrder(group_a.group, group_b.group);
175
+ }
176
+ const cmp = group_a.group.localeCompare(group_b.group);
177
+ return groupSortOrder === `desc` ? -cmp : cmp;
178
+ });
179
+ }
180
+ if (ungrouped.length === 0)
181
+ return grouped;
182
+ const ungrouped_entry = { group: null, options: ungrouped, collapsed: false };
183
+ return ungroupedPosition === `first`
184
+ ? [ungrouped_entry, ...grouped]
185
+ : [...grouped, ungrouped_entry];
186
+ });
187
+ // Flattened options for navigation (excludes options in collapsed groups)
188
+ let navigable_options = $derived(grouped_options.flatMap(({ options: group_opts, collapsed }) => collapsed && collapsibleGroups ? [] : group_opts));
189
+ // Pre-computed Map for O(1) index lookups (avoids O(n²) in template)
190
+ let navigable_index_map = $derived(new Map(navigable_options.map((opt, idx) => [opt, idx])));
191
+ let group_header_state = $derived.by(() => {
192
+ const state = new SvelteMap();
193
+ for (const { group, options: opts, collapsed } of grouped_options) {
194
+ if (group === null)
195
+ continue;
196
+ const selectable = get_selectable_opts(opts, collapsed);
197
+ const all_selected = selectable.length > 0 &&
198
+ selectable.every((opt) => selected_keys_set.has(key(opt)));
199
+ // Count selected options (only needed when keepSelectedInDropdown is enabled)
200
+ let selected_count = 0;
201
+ if (keepSelectedInDropdown) {
202
+ for (const opt of opts) {
203
+ if (selected_keys_set.has(key(opt)))
204
+ selected_count++;
205
+ }
206
+ }
207
+ state.set(group, { all_selected, selected_count });
208
+ }
209
+ return state;
210
+ });
211
+ // Update collapsedGroups state: 'add' adds groups, 'delete' removes groups, 'set' replaces all
212
+ function update_collapsed_groups(action, groups) {
213
+ const items = typeof groups === `string` ? [groups] : [...groups];
214
+ if (action === `set`)
215
+ collapsedGroups = new SvelteSet(items);
216
+ else {
217
+ const updated = new SvelteSet(collapsedGroups);
218
+ for (const group of items)
219
+ updated[action](group);
220
+ collapsedGroups = updated;
221
+ }
222
+ }
223
+ // Toggle group collapsed state
224
+ function toggle_group_collapsed(group_name) {
225
+ const was_collapsed = collapsedGroups.has(group_name);
226
+ update_collapsed_groups(was_collapsed ? `delete` : `add`, group_name);
227
+ ongroupToggle?.({ group: group_name, collapsed: !was_collapsed });
228
+ }
229
+ // Collapse/expand all groups (exposed via bindable props)
230
+ collapseAllGroups = () => {
231
+ const groups = grouped_options
232
+ .map((entry) => entry.group)
233
+ .filter((group_name) => group_name !== null);
234
+ if (groups.length === 0)
235
+ return;
236
+ update_collapsed_groups(`set`, groups);
237
+ oncollapseAll?.({ groups });
238
+ };
239
+ expandAllGroups = () => {
240
+ const groups = [...collapsedGroups];
241
+ if (groups.length === 0)
242
+ return;
243
+ update_collapsed_groups(`set`, []);
244
+ onexpandAll?.({ groups });
245
+ };
246
+ // Expand specified groups and fire ongroupToggle for each
247
+ function expand_groups(groups_to_expand) {
248
+ if (groups_to_expand.length === 0)
249
+ return;
250
+ update_collapsed_groups(`delete`, groups_to_expand);
251
+ for (const group of groups_to_expand)
252
+ ongroupToggle?.({ group, collapsed: false });
253
+ }
254
+ // Get names of collapsed groups that have matching options
255
+ const get_collapsed_with_matches = () => grouped_options.flatMap(({ group, collapsed, options: opts }) => group && collapsed && opts.length > 0 ? [group] : []);
256
+ // Auto-expand collapsed groups when search matches their options
257
+ $effect(() => {
258
+ if (searchExpandsCollapsedGroups && searchText && collapsibleGroups) {
259
+ expand_groups(get_collapsed_with_matches());
260
+ }
261
+ });
79
262
  // Normalize placeholder prop (supports string or { text, persistent } object)
80
263
  const placeholder_text = $derived(typeof placeholder === `string` ? placeholder : placeholder?.text ?? null);
81
264
  const placeholder_persistent = $derived(typeof placeholder === `object` && placeholder?.persistent === true);
@@ -125,30 +308,49 @@ if (maxOptions &&
125
308
  }
126
309
  let option_msg_is_active = $state(false); // controls active state of <li>{createOptionMsg}</li>
127
310
  let window_width = $state(0);
128
- // options matching the current search text
311
+ // Check if option matches search text (label or optionally group name)
312
+ const matches_search = (opt, search) => {
313
+ if (filterFunc(opt, search))
314
+ return true;
315
+ if (searchMatchesGroups && search && has_group(opt)) {
316
+ return fuzzy
317
+ ? fuzzy_match(search, opt.group)
318
+ : opt.group.toLowerCase().includes(search.toLowerCase());
319
+ }
320
+ return false;
321
+ };
129
322
  $effect.pre(() => {
130
323
  // When using loadOptions, server handles filtering, so skip client-side filterFunc
131
- const opts_to_filter = effective_options;
132
- matchingOptions = opts_to_filter.filter((opt) => (loadOptions || filterFunc(opt, searchText)) &&
133
- // remove already selected options from dropdown list unless duplicate selections are allowed
134
- // or keepSelectedInDropdown is enabled
135
- (!selected_keys.includes(key(opt)) || duplicates || keepSelectedInDropdown));
324
+ matchingOptions = effective_options.filter((opt) => {
325
+ // Check if option is already selected and should be excluded
326
+ const keep_in_list = !selected_keys_set.has(key(opt)) ||
327
+ duplicates ||
328
+ keepSelectedInDropdown;
329
+ if (!keep_in_list)
330
+ return false;
331
+ // When using loadOptions, server handles filtering; otherwise check search match
332
+ return loadOptions || matches_search(opt, searchText);
333
+ });
136
334
  });
137
335
  // reset activeIndex if out of bounds (can happen when options change while dropdown is open)
138
336
  $effect(() => {
139
- if (activeIndex !== null && !matchingOptions[activeIndex]) {
337
+ if (activeIndex !== null && !navigable_options[activeIndex]) {
140
338
  activeIndex = null;
141
339
  }
142
340
  });
143
341
  // update activeOption when activeIndex changes
144
342
  $effect(() => {
145
- activeOption = matchingOptions[activeIndex ?? -1] ?? null;
343
+ activeOption = navigable_options[activeIndex ?? -1] ?? null;
146
344
  });
345
+ // Compute the ID of the currently active option for aria-activedescendant
346
+ const active_option_id = $derived(activeIndex !== null && activeIndex < navigable_options.length
347
+ ? `${internal_id}-opt-${activeIndex}`
348
+ : undefined);
147
349
  // Helper to check if removing an option would violate minSelect constraint
148
350
  const can_remove = $derived(minSelect === null || selected.length > minSelect);
149
351
  // toggle an option between selected and unselected states (for keepSelectedInDropdown mode)
150
352
  function toggle_option(option_to_toggle, event) {
151
- const is_currently_selected = selected_keys.includes(key(option_to_toggle));
353
+ const is_currently_selected = selected_keys_set.has(key(option_to_toggle));
152
354
  if (is_currently_selected) {
153
355
  if (can_remove)
154
356
  remove(option_to_toggle, event);
@@ -164,7 +366,7 @@ function add(option_to_add, event) {
164
366
  if (!isNaN(Number(option_to_add)) && typeof selected_labels[0] === `number`) {
165
367
  option_to_add = Number(option_to_add); // convert to number if possible
166
368
  }
167
- const is_duplicate = selected_keys.includes(key(option_to_add));
369
+ const is_duplicate = selected_keys_set.has(key(option_to_add));
168
370
  if ((maxSelect === null || maxSelect === 1 || selected.length < maxSelect) &&
169
371
  (duplicates || !is_duplicate)) {
170
372
  if (!effective_options.includes(option_to_add) && // first check if we find option in the options list
@@ -213,6 +415,7 @@ function add(option_to_add, event) {
213
415
  }
214
416
  clear_validity();
215
417
  handle_dropdown_after_select(event);
418
+ last_action = { type: `add`, label: `${get_label(option_to_add)}` };
216
419
  onadd?.({ option: option_to_add });
217
420
  onchange?.({ option: option_to_add, type: `add` });
218
421
  }
@@ -223,7 +426,7 @@ function remove(option_to_drop, event) {
223
426
  if (selected.length === 0)
224
427
  return;
225
428
  const idx = selected.findIndex((opt) => key(opt) === key(option_to_drop));
226
- let [option_removed] = selected.splice(idx, 1); // remove option from selected list
429
+ let option_removed = selected[idx];
227
430
  if (option_removed === undefined && allowUserOptions) {
228
431
  // if option with label could not be found but allowUserOptions is truthy,
229
432
  // assume it was created by user and create corresponding option object
@@ -235,8 +438,9 @@ function remove(option_to_drop, event) {
235
438
  console.error(`MultiSelect: can't remove option ${JSON.stringify(option_to_drop)}, not found in selected list`);
236
439
  return;
237
440
  }
238
- selected = [...selected]; // trigger Svelte rerender
441
+ selected = selected.filter((_, remove_idx) => remove_idx !== idx);
239
442
  clear_validity();
443
+ last_action = { type: `remove`, label: `${get_label(option_removed)}` };
240
444
  onremove?.({ option: option_removed });
241
445
  onchange?.({ option: option_removed, type: `remove` });
242
446
  }
@@ -276,29 +480,36 @@ function handle_dropdown_after_select(event) {
276
480
  }
277
481
  // Check if a user message (create option, duplicate warning, no match) is visible
278
482
  const has_user_msg = $derived(searchText.length > 0 && Boolean((allowUserOptions && createOptionMsg) ||
279
- (!duplicates && selected_labels.includes(searchText)) ||
280
- (matchingOptions.length === 0 && noMatchingOptionsMsg)));
281
- // Handle arrow key navigation through options (uses module-scope `has_user_msg`)
483
+ (!duplicates && selected_labels_set.has(searchText)) ||
484
+ (navigable_options.length === 0 && noMatchingOptionsMsg)));
485
+ // Handle arrow key navigation through options (uses navigable_options to skip collapsed groups)
282
486
  async function handle_arrow_navigation(direction) {
283
487
  ignore_hover = true;
488
+ // Auto-expand collapsed groups when keyboard navigating
489
+ if (keyboardExpandsCollapsedGroups && collapsibleGroups && collapsedGroups.size > 0) {
490
+ expand_groups(get_collapsed_with_matches());
491
+ await tick();
492
+ }
284
493
  // toggle user message when no options match but user can create
285
- if (allowUserOptions && !matchingOptions.length && searchText.length > 0) {
494
+ if (allowUserOptions && !navigable_options.length && searchText.length > 0) {
286
495
  option_msg_is_active = !option_msg_is_active;
287
496
  return;
288
497
  }
289
- if (activeIndex === null && !matchingOptions.length)
498
+ if (activeIndex === null && !navigable_options.length)
290
499
  return; // nothing to navigate
291
500
  // activate first option or navigate with wrap-around
292
501
  if (activeIndex === null) {
293
502
  activeIndex = 0;
294
503
  }
295
504
  else {
296
- const total = matchingOptions.length + (has_user_msg ? 1 : 0);
505
+ const total = navigable_options.length + (has_user_msg ? 1 : 0);
297
506
  activeIndex = (activeIndex + direction + total) % total; // +total handles negative mod
298
507
  }
299
508
  // update active state based on new index
300
- option_msg_is_active = has_user_msg && activeIndex === matchingOptions.length;
301
- activeOption = option_msg_is_active ? null : matchingOptions[activeIndex] ?? null;
509
+ option_msg_is_active = has_user_msg && activeIndex === navigable_options.length;
510
+ activeOption = option_msg_is_active
511
+ ? null
512
+ : navigable_options[activeIndex] ?? null;
302
513
  if (autoScroll) {
303
514
  await tick();
304
515
  document.querySelector(`ul.options > li.active`)?.scrollIntoViewIfNeeded?.();
@@ -306,6 +517,42 @@ async function handle_arrow_navigation(direction) {
306
517
  }
307
518
  // handle all keyboard events this component receives
308
519
  async function handle_keydown(event) {
520
+ if (disabled)
521
+ return; // Block all keyboard handling when disabled
522
+ // Check keyboard shortcuts first (before other key handling)
523
+ const shortcut_actions = [
524
+ {
525
+ key: `select_all`,
526
+ condition: () => !!selectAllOption && navigable_options.length > 0 && maxSelect !== 1,
527
+ action: () => select_all(event),
528
+ },
529
+ {
530
+ key: `clear_all`,
531
+ condition: () => selected.length > 0,
532
+ action: () => remove_all(event),
533
+ },
534
+ {
535
+ key: `open`,
536
+ condition: () => !open,
537
+ action: () => open_dropdown(event),
538
+ },
539
+ {
540
+ key: `close`,
541
+ condition: () => open,
542
+ action: () => {
543
+ close_dropdown(event);
544
+ searchText = ``;
545
+ },
546
+ },
547
+ ];
548
+ for (const { key, condition, action } of shortcut_actions) {
549
+ if (matches_shortcut(event, effective_shortcuts[key]) && condition()) {
550
+ event.preventDefault();
551
+ event.stopPropagation();
552
+ action();
553
+ return;
554
+ }
555
+ }
309
556
  // on escape or tab out of input: close options dropdown and reset search text
310
557
  if (event.key === `Escape` || event.key === `Tab`) {
311
558
  event.stopPropagation();
@@ -316,7 +563,7 @@ async function handle_keydown(event) {
316
563
  event.stopPropagation();
317
564
  event.preventDefault(); // prevent enter key from triggering form submission
318
565
  if (activeOption) {
319
- if (selected_keys.includes(key(activeOption))) {
566
+ if (selected_keys_set.has(key(activeOption))) {
320
567
  if (can_remove)
321
568
  remove(activeOption, event);
322
569
  }
@@ -347,7 +594,7 @@ async function handle_keydown(event) {
347
594
  }
348
595
  // Don't prevent default, allow normal backspace behavior if not removing
349
596
  } // make first matching option active on any keypress (if none of the above special cases match)
350
- else if (matchingOptions.length > 0 && activeIndex === null) {
597
+ else if (navigable_options.length > 0 && activeIndex === null) {
351
598
  // Don't stop propagation or prevent default here, allow normal character input
352
599
  activeIndex = 0;
353
600
  }
@@ -360,45 +607,90 @@ function remove_all(event) {
360
607
  // If no minSelect constraint, remove all
361
608
  removed_options = selected;
362
609
  selected = [];
363
- searchText = ``; // always clear on remove all (resetFilterOnAdd only applies to add operations)
364
610
  }
365
611
  else if (selected.length > minSelect) {
366
612
  // Keep the first minSelect items
367
613
  removed_options = selected.slice(minSelect);
368
614
  selected = selected.slice(0, minSelect);
369
- searchText = ``; // always clear on remove all (resetFilterOnAdd only applies to add operations)
370
615
  }
371
616
  // Only fire events if something was actually removed
372
617
  if (removed_options.length > 0) {
618
+ searchText = ``; // always clear on remove all (resetFilterOnAdd only applies to add operations)
619
+ last_action = { type: `removeAll`, label: `${removed_options.length} options` };
373
620
  onremoveAll?.({ options: removed_options });
374
621
  onchange?.({ options: selected, type: `removeAll` });
375
622
  }
376
623
  }
624
+ // Check if option index is within maxOptions visibility limit
625
+ const is_option_visible = (idx) => idx >= 0 && (maxOptions == null || idx < maxOptions);
626
+ // Get non-disabled, selectable options from a list
627
+ // For collapsed groups: returns all non-disabled options (user explicitly wants this group)
628
+ // For expanded groups/top-level: respects maxOptions rendering limit
629
+ const get_selectable_opts = (opts, skip_visibility_check = false) => opts.filter((opt) => {
630
+ if (is_disabled(opt))
631
+ return false;
632
+ if (skip_visibility_check)
633
+ return true;
634
+ return is_option_visible(navigable_index_map.get(opt) ?? -1);
635
+ });
636
+ // Batch-add options to selection with all side effects (used by select_all and group select)
637
+ function batch_add_options(options_to_add, event) {
638
+ const remaining = Math.max(0, (maxSelect ?? Infinity) - selected.length);
639
+ const to_add = options_to_add
640
+ .filter((opt) => !selected_keys_set.has(key(opt)))
641
+ .slice(0, remaining);
642
+ if (to_add.length === 0)
643
+ return;
644
+ selected = sort_selected([...selected, ...to_add]);
645
+ if (resetFilterOnAdd)
646
+ searchText = ``;
647
+ clear_validity();
648
+ handle_dropdown_after_select(event);
649
+ onselectAll?.({ options: to_add });
650
+ onchange?.({ options: selected, type: `selectAll` });
651
+ }
652
+ // Batch-add options for top-level "Select all" (only visible/navigable options)
377
653
  function select_all(event) {
378
654
  event.stopPropagation();
379
- const limit = maxSelect ?? Infinity;
380
- // Use matchingOptions for "select all visible" semantics
381
- const options_to_add = matchingOptions.filter((opt) => {
382
- const is_disabled = is_object(opt) && opt.disabled;
383
- return !is_disabled && !selected_keys.includes(key(opt));
384
- }).slice(0, limit - selected.length);
385
- if (options_to_add.length > 0) {
386
- selected = sort_selected([...selected, ...options_to_add]);
387
- if (resetFilterOnAdd)
388
- searchText = ``;
389
- clear_validity();
390
- handle_dropdown_after_select(event);
391
- onselectAll?.({ options: options_to_add });
392
- onchange?.({ options: selected, type: `selectAll` });
655
+ batch_add_options(get_selectable_opts(navigable_options), event);
656
+ }
657
+ // Toggle group selection: works even when group is collapsed
658
+ // If all selectable options are selected, deselect them; otherwise select all
659
+ function toggle_group_selection(group_opts, group_collapsed, all_selected, event) {
660
+ event.stopPropagation();
661
+ const selectable = get_selectable_opts(group_opts, group_collapsed);
662
+ if (all_selected) {
663
+ // Deselect all options in this group
664
+ const keys_to_remove = new Set(selectable.map(key));
665
+ const removed = selected.filter((opt) => keys_to_remove.has(key(opt)));
666
+ selected = selected.filter((opt) => !keys_to_remove.has(key(opt)));
667
+ if (removed.length > 0) {
668
+ onremoveAll?.({ options: removed });
669
+ onchange?.({ options: selected, type: `removeAll` });
670
+ }
671
+ }
672
+ else {
673
+ // Select all non-disabled, non-selected options in this group
674
+ batch_add_options(selectable, event);
393
675
  }
394
676
  }
395
- let is_selected = $derived((label) => selected_labels.includes(label));
677
+ // O(1) lookup using pre-computed Set instead of O(n) array.includes()
678
+ const is_selected = (label) => selected_labels_set.has(label);
396
679
  const if_enter_or_space = (handler) => (event) => {
397
680
  if (event.key === `Enter` || event.code === `Space`) {
398
681
  event.preventDefault();
399
682
  handler(event);
400
683
  }
401
684
  };
685
+ // Handle option interaction (click or keyboard) - DRY helper for template
686
+ const handle_option_interact = (opt, opt_disabled, event) => {
687
+ if (opt_disabled)
688
+ return;
689
+ if (keepSelectedInDropdown)
690
+ toggle_option(opt, event);
691
+ else
692
+ add(opt, event);
693
+ };
402
694
  function on_click_outside(event) {
403
695
  if (!outerDiv)
404
696
  return;
@@ -430,6 +722,8 @@ const drop = (target_idx) => (event) => {
430
722
  }
431
723
  selected = new_selected;
432
724
  drag_idx = null;
725
+ onreorder?.({ options: new_selected });
726
+ onchange?.({ options: new_selected, type: `reorder` });
433
727
  };
434
728
  const dragstart = (idx) => (event) => {
435
729
  if (!event.dataTransfer)
@@ -534,7 +828,8 @@ function portal(node, params) {
534
828
  }
535
829
  // Dynamic options loading - captures search at call time to avoid race conditions
536
830
  async function load_dynamic_options(reset) {
537
- if (!load_options_fn || load_options_loading || (!reset && !load_options_has_more)) {
831
+ if (!load_options_config || load_options_loading ||
832
+ (!reset && !load_options_has_more)) {
538
833
  return;
539
834
  }
540
835
  // Capture search term at call time to avoid race with user typing during fetch
@@ -542,8 +837,8 @@ async function load_dynamic_options(reset) {
542
837
  const offset = reset ? 0 : loaded_options.length;
543
838
  load_options_loading = true;
544
839
  try {
545
- const limit = load_options_batch_size;
546
- const result = await load_options_fn({ search, offset, limit });
840
+ const limit = load_options_config.batch_size;
841
+ const result = await load_options_config.fetch({ search, offset, limit });
547
842
  loaded_options = reset ? result.options : [...loaded_options, ...result.options];
548
843
  load_options_has_more = result.hasMore;
549
844
  load_options_last_search = search;
@@ -557,7 +852,7 @@ async function load_dynamic_options(reset) {
557
852
  }
558
853
  // Single effect handles initial load + search changes
559
854
  $effect(() => {
560
- if (!load_options_fn)
855
+ if (!load_options_config)
561
856
  return;
562
857
  // Reset state when dropdown closes so next open triggers fresh load
563
858
  if (!open) {
@@ -571,13 +866,13 @@ $effect(() => {
571
866
  const search = searchText;
572
867
  const is_first_load = load_options_last_search === null;
573
868
  if (is_first_load) {
574
- if (load_options_on_open) {
869
+ if (load_options_config.on_open) {
575
870
  // Load immediately on dropdown open
576
871
  load_dynamic_options(true);
577
872
  }
578
873
  else if (search) {
579
874
  // onOpen=false but user typed - debounce and load
580
- debounce_timer = setTimeout(() => load_dynamic_options(true), load_options_debounce_ms);
875
+ debounce_timer = setTimeout(() => load_dynamic_options(true), load_options_config.debounce_ms);
581
876
  }
582
877
  // If onOpen=false and no search text, do nothing (wait for user to type)
583
878
  }
@@ -586,7 +881,7 @@ $effect(() => {
586
881
  // Clear stale results immediately so UI doesn't show wrong results while loading
587
882
  loaded_options = [];
588
883
  load_options_has_more = true;
589
- debounce_timer = setTimeout(() => load_dynamic_options(true), load_options_debounce_ms);
884
+ debounce_timer = setTimeout(() => load_dynamic_options(true), load_options_config.debounce_ms);
590
885
  }
591
886
  return () => {
592
887
  if (debounce_timer)
@@ -594,7 +889,7 @@ $effect(() => {
594
889
  };
595
890
  });
596
891
  function handle_options_scroll(event) {
597
- if (!load_options_fn || load_options_loading || !load_options_has_more)
892
+ if (!load_options_config || load_options_loading || !load_options_has_more)
598
893
  return;
599
894
  const { scrollTop, scrollHeight, clientHeight } = event.target;
600
895
  if (scrollHeight - scrollTop - clientHeight <= 100)
@@ -646,14 +941,16 @@ function handle_options_scroll(event) {
646
941
  form_input?.setCustomValidity(msg)
647
942
  }}
648
943
  />
649
- {#if expandIcon}
650
- {@render expandIcon({ open })}
651
- {:else}
652
- <Icon
653
- icon="ChevronExpand"
654
- style="width: 15px; min-width: 1em; padding: 0 1pt; cursor: pointer"
655
- />
656
- {/if}
944
+ <span class="expand-icon">
945
+ {#if expandIcon}
946
+ {@render expandIcon({ open })}
947
+ {:else}
948
+ <Icon
949
+ icon="ChevronExpand"
950
+ style="width: 15px; min-width: 1em; padding: 0 1pt; cursor: pointer"
951
+ />
952
+ {/if}
953
+ </span>
657
954
  <ul
658
955
  class="selected {ulSelectedClass}"
659
956
  aria-label="selected options"
@@ -724,6 +1021,12 @@ function handle_options_scroll(event) {
724
1021
  {inputmode}
725
1022
  {pattern}
726
1023
  placeholder={selected.length === 0 || placeholder_persistent ? placeholder_text : null}
1024
+ role="combobox"
1025
+ aria-haspopup="listbox"
1026
+ aria-expanded={open}
1027
+ aria-controls={listbox_id}
1028
+ aria-activedescendant={active_option_id}
1029
+ aria-busy={loading || load_options_loading || null}
727
1030
  aria-invalid={invalid ? `true` : null}
728
1031
  ondrop={() => false}
729
1032
  onmouseup={open_dropdown}
@@ -810,6 +1113,7 @@ function handle_options_scroll(event) {
810
1113
  ? NodeFilter.FILTER_REJECT
811
1114
  : NodeFilter.FILTER_ACCEPT,
812
1115
  })}
1116
+ id={listbox_id}
813
1117
  class:hidden={!open}
814
1118
  class="options {ulOptionsClass}"
815
1119
  role="listbox"
@@ -835,87 +1139,144 @@ function handle_options_scroll(event) {
835
1139
  {label}
836
1140
  </li>
837
1141
  {/if}
838
- {#each matchingOptions.slice(
839
- 0,
840
- maxOptions == null ? Infinity : Math.max(0, maxOptions),
841
- ) as
842
- option_item,
843
- idx
844
- (duplicates ? `${key(option_item)}-${idx}` : key(option_item))
1142
+ {#each grouped_options as
1143
+ { group: group_name, options: group_opts, collapsed },
1144
+ group_idx
1145
+ (group_name ?? `ungrouped-${group_idx}`)
845
1146
  }
846
- {@const {
1147
+ {#if group_name !== null}
1148
+ {@const { all_selected, selected_count } = group_header_state.get(group_name) ??
1149
+ { all_selected: false, selected_count: 0 }}
1150
+ {@const handle_toggle = () =>
1151
+ collapsibleGroups && toggle_group_collapsed(group_name)}
1152
+ {@const handle_group_select = (event: Event) =>
1153
+ toggle_group_selection(group_opts, collapsed, all_selected, event)}
1154
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
1155
+ <li
1156
+ class="group-header {liGroupHeaderClass}"
1157
+ class:collapsible={collapsibleGroups}
1158
+ class:sticky={stickyGroupHeaders}
1159
+ role={collapsibleGroups ? `button` : `presentation`}
1160
+ aria-expanded={collapsibleGroups ? !collapsed : undefined}
1161
+ aria-label="Group: {group_name}"
1162
+ style={liGroupHeaderStyle}
1163
+ onclick={handle_toggle}
1164
+ onkeydown={if_enter_or_space(handle_toggle)}
1165
+ tabindex={collapsibleGroups ? 0 : -1}
1166
+ >
1167
+ {#if groupHeader}
1168
+ {@render groupHeader({ group: group_name, options: group_opts, collapsed })}
1169
+ {:else}
1170
+ <span class="group-label">{group_name}</span>
1171
+ <span class="group-count">
1172
+ {#if keepSelectedInDropdown && selected_count > 0}
1173
+ ({selected_count}/{group_opts.length})
1174
+ {:else}
1175
+ ({group_opts.length})
1176
+ {/if}
1177
+ </span>
1178
+ {#if groupSelectAll && (maxSelect === null || maxSelect > 1)}
1179
+ <button
1180
+ type="button"
1181
+ class="group-select-all"
1182
+ class:deselect={all_selected}
1183
+ onclick={handle_group_select}
1184
+ onkeydown={if_enter_or_space(handle_group_select)}
1185
+ >
1186
+ {all_selected ? `Deselect all` : `Select all`}
1187
+ </button>
1188
+ {/if}
1189
+ {#if collapsibleGroups}
1190
+ <Icon
1191
+ icon={collapsed ? `ChevronRight` : `ChevronDown`}
1192
+ style="width: 12px; margin-left: auto"
1193
+ />
1194
+ {/if}
1195
+ {/if}
1196
+ </li>
1197
+ {/if}
1198
+ {#if !collapsed || !collapsibleGroups}
1199
+ {#each group_opts as
1200
+ option_item,
1201
+ local_idx
1202
+ (duplicates
1203
+ ? `${key(option_item)}-${group_idx}-${local_idx}`
1204
+ : key(option_item))
1205
+ }
1206
+ {@const flat_idx = navigable_index_map.get(option_item) ?? -1}
1207
+ {@const {
847
1208
  label,
848
1209
  disabled = null,
849
1210
  title = null,
850
1211
  selectedTitle = null,
851
1212
  disabledTitle = defaultDisabledTitle,
852
1213
  } = is_object(option_item) ? option_item : { label: option_item }}
853
- {@const active = activeIndex === idx}
854
- {@const selected = is_selected(label)}
855
- {@const optionStyle =
1214
+ {@const active = activeIndex === flat_idx && flat_idx >= 0}
1215
+ {@const selected = is_selected(label)}
1216
+ {@const optionStyle =
856
1217
  [get_style(option_item, `option`), liOptionStyle].filter(Boolean).join(
857
1218
  ` `,
858
1219
  ) ||
859
1220
  null}
860
- <li
861
- onclick={(event) => {
862
- if (disabled) return
863
- if (keepSelectedInDropdown) toggle_option(option_item, event)
864
- else add(option_item, event)
865
- }}
866
- title={disabled ? disabledTitle : (selected && selectedTitle) || title}
867
- class:selected
868
- class:active
869
- class:disabled
870
- class="{liOptionClass} {active ? liActiveOptionClass : ``}"
871
- onmouseover={() => {
872
- if (!disabled && !ignore_hover) activeIndex = idx
873
- }}
874
- onfocus={() => {
875
- if (!disabled) activeIndex = idx
876
- }}
877
- role="option"
878
- aria-selected={selected ? `true` : `false`}
879
- style={optionStyle}
880
- onkeydown={(event) => {
881
- if (!disabled && (event.key === `Enter` || event.code === `Space`)) {
882
- event.preventDefault()
883
- if (keepSelectedInDropdown) toggle_option(option_item, event)
884
- else add(option_item, event)
885
- }
886
- }}
887
- >
888
- {#if keepSelectedInDropdown === `checkboxes`}
889
- <input
890
- type="checkbox"
891
- class="option-checkbox"
892
- checked={selected}
893
- aria-label="Toggle {get_label(option_item)}"
894
- tabindex="-1"
895
- />
896
- {/if}
897
- {#if option}
898
- {@render option({
1221
+ {#if is_option_visible(flat_idx)}
1222
+ <li
1223
+ id="{internal_id}-opt-{flat_idx}"
1224
+ onclick={(event) => handle_option_interact(option_item, disabled, event)}
1225
+ title={disabled ? disabledTitle : (selected && selectedTitle) || title}
1226
+ class:selected
1227
+ class:active
1228
+ class:disabled
1229
+ class="{liOptionClass} {active ? liActiveOptionClass : ``}"
1230
+ onmouseover={() => {
1231
+ if (!disabled && !ignore_hover) activeIndex = flat_idx
1232
+ }}
1233
+ onfocus={() => {
1234
+ if (!disabled) activeIndex = flat_idx
1235
+ }}
1236
+ role="option"
1237
+ aria-selected={selected ? `true` : `false`}
1238
+ aria-posinset={flat_idx + 1}
1239
+ aria-setsize={navigable_options.length}
1240
+ style={optionStyle}
1241
+ onkeydown={if_enter_or_space((event) =>
1242
+ handle_option_interact(option_item, disabled, event)
1243
+ )}
1244
+ >
1245
+ {#if keepSelectedInDropdown === `checkboxes`}
1246
+ <input
1247
+ type="checkbox"
1248
+ class="option-checkbox"
1249
+ checked={selected}
1250
+ aria-label="Toggle {get_label(option_item)}"
1251
+ tabindex="-1"
1252
+ />
1253
+ {/if}
1254
+ {#if option}
1255
+ {@render option({
899
1256
  option: option_item,
900
- idx,
1257
+ idx: flat_idx,
901
1258
  })}
902
- {:else if children}
903
- {@render children({
1259
+ {:else if children}
1260
+ {@render children({
904
1261
  option: option_item,
905
- idx,
1262
+ idx: flat_idx,
906
1263
  })}
907
- {:else if parseLabelsAsHtml}
908
- {@html get_label(option_item)}
909
- {:else}
910
- {get_label(option_item)}
911
- {/if}
912
- </li>
1264
+ {:else if parseLabelsAsHtml}
1265
+ {@html get_label(option_item)}
1266
+ {:else}
1267
+ {get_label(option_item)}
1268
+ {/if}
1269
+ </li>
1270
+ {/if}
1271
+ {/each}
1272
+ {/if}
913
1273
  {/each}
914
1274
  {#if searchText}
915
1275
  {@const text_input_is_duplicate = selected_labels.includes(searchText)}
916
1276
  {@const is_dupe = !duplicates && text_input_is_duplicate && `dupe`}
917
1277
  {@const can_create = Boolean(allowUserOptions && createOptionMsg) && `create`}
918
- {@const no_match = Boolean(matchingOptions?.length === 0 && noMatchingOptionsMsg) &&
1278
+ {@const no_match =
1279
+ Boolean(navigable_options?.length === 0 && noMatchingOptionsMsg) &&
919
1280
  `no-match`}
920
1281
  {@const msgType = is_dupe || can_create || no_match}
921
1282
  {@const msg = msgType && {
@@ -923,23 +1284,13 @@ function handle_options_scroll(event) {
923
1284
  create: createOptionMsg,
924
1285
  'no-match': noMatchingOptionsMsg,
925
1286
  }[msgType]}
1287
+ {@const can_add_user_option = msgType === `create` && allowUserOptions}
1288
+ {@const handle_create = (event: Event) =>
1289
+ can_add_user_option && add(searchText as Option, event)}
926
1290
  {#if msg}
927
1291
  <li
928
- onclick={(event) => {
929
- if (msgType === `create` && allowUserOptions) {
930
- add(searchText as Option, event)
931
- }
932
- }}
933
- onkeydown={(event) => {
934
- if (
935
- msgType === `create` &&
936
- allowUserOptions &&
937
- (event.key === `Enter` || event.code === `Space`)
938
- ) {
939
- event.preventDefault()
940
- add(searchText as Option, event)
941
- }
942
- }}
1292
+ onclick={handle_create}
1293
+ onkeydown={can_add_user_option ? if_enter_or_space(handle_create) : undefined}
943
1294
  title={msgType === `create`
944
1295
  ? createOptionMsg
945
1296
  : msgType === `dupe`
@@ -978,18 +1329,45 @@ function handle_options_scroll(event) {
978
1329
  {/if}
979
1330
  </ul>
980
1331
  {/if}
1332
+ <!-- Screen reader announcements for dropdown state, option count, and selection changes -->
1333
+ <div class="sr-only" aria-live="polite" aria-atomic="true">
1334
+ {#if last_action}
1335
+ {#if last_action.type === `add`}
1336
+ {last_action.label} selected
1337
+ {:else if last_action.type === `remove`}
1338
+ {last_action.label} removed
1339
+ {:else if last_action.type === `removeAll`}
1340
+ {last_action.label} removed
1341
+ {/if}
1342
+ {:else if open}
1343
+ {matchingOptions.length} option{matchingOptions.length === 1 ? `` : `s`} available
1344
+ {/if}
1345
+ </div>
981
1346
  </div>
982
1347
 
983
1348
  <style>
1349
+ /* Screen reader only - visually hidden but accessible to assistive technology */
1350
+ .sr-only {
1351
+ position: absolute;
1352
+ width: 1px;
1353
+ height: 1px;
1354
+ padding: 0;
1355
+ margin: -1px;
1356
+ overflow: hidden;
1357
+ clip: rect(0, 0, 0, 0);
1358
+ white-space: nowrap;
1359
+ border: 0;
1360
+ }
1361
+
984
1362
  :is(div.multiselect) {
985
1363
  position: relative;
986
1364
  align-items: center;
987
1365
  display: flex;
988
1366
  cursor: text;
989
1367
  box-sizing: border-box;
990
- border: var(--sms-border, 1pt solid lightgray);
1368
+ border: var(--sms-border, 1pt solid light-dark(lightgray, #555));
991
1369
  border-radius: var(--sms-border-radius, 3pt);
992
- background: var(--sms-bg);
1370
+ background: var(--sms-bg, light-dark(white, #1a1a1a));
993
1371
  width: var(--sms-width);
994
1372
  max-width: var(--sms-max-width);
995
1373
  padding: var(--sms-padding, 0 3pt);
@@ -1007,7 +1385,7 @@ function handle_options_scroll(event) {
1007
1385
  border: var(--sms-focus-border, 1pt solid var(--sms-active-color, cornflowerblue));
1008
1386
  }
1009
1387
  :is(div.multiselect.disabled) {
1010
- background: var(--sms-disabled-bg, lightgray);
1388
+ background: var(--sms-disabled-bg, light-dark(lightgray, #444));
1011
1389
  cursor: not-allowed;
1012
1390
  }
1013
1391
 
@@ -1026,7 +1404,10 @@ function handle_options_scroll(event) {
1026
1404
  line-height: normal;
1027
1405
  transition: 0.3s;
1028
1406
  white-space: nowrap;
1029
- background: var(--sms-selected-bg, rgba(0, 0, 0, 0.15));
1407
+ background: var(
1408
+ --sms-selected-bg,
1409
+ light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15))
1410
+ );
1030
1411
  padding: var(--sms-selected-li-padding, 1pt 5pt);
1031
1412
  color: var(--sms-selected-text-color, var(--sms-text-color));
1032
1413
  }
@@ -1034,10 +1415,14 @@ function handle_options_scroll(event) {
1034
1415
  cursor: grab;
1035
1416
  }
1036
1417
  :is(div.multiselect > ul.selected > li.active) {
1037
- background: var(--sms-li-active-bg, var(--sms-active-color, rgba(0, 0, 0, 0.15)));
1418
+ background: var(
1419
+ --sms-li-active-bg,
1420
+ var(--sms-active-color, light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15)))
1421
+ );
1038
1422
  }
1039
1423
  :is(div.multiselect button) {
1040
1424
  border-radius: 50%;
1425
+ aspect-ratio: 1; /* ensure circle, not ellipse */
1041
1426
  display: flex;
1042
1427
  transition: 0.2s;
1043
1428
  color: inherit;
@@ -1052,8 +1437,11 @@ function handle_options_scroll(event) {
1052
1437
  margin: 0 3pt;
1053
1438
  }
1054
1439
  :is(ul.selected > li button:hover, button.remove-all:hover, button:focus) {
1055
- color: var(--sms-remove-btn-hover-color, lightskyblue);
1056
- background: var(--sms-remove-btn-hover-bg, rgba(0, 0, 0, 0.2));
1440
+ color: var(--sms-remove-btn-hover-color, light-dark(#0088cc, lightskyblue));
1441
+ background: var(
1442
+ --sms-remove-btn-hover-bg,
1443
+ light-dark(rgba(0, 0, 0, 0.2), rgba(255, 255, 255, 0.2))
1444
+ );
1057
1445
  }
1058
1446
 
1059
1447
  :is(div.multiselect input) {
@@ -1110,15 +1498,18 @@ function handle_options_scroll(event) {
1110
1498
  transition: all
1111
1499
  0.2s; /* Consider if this transition is desirable with portal positioning */
1112
1500
  box-sizing: border-box;
1113
- background: var(--sms-options-bg, white);
1501
+ background: var(--sms-options-bg, light-dark(#fafafa, #1a1a1a));
1114
1502
  max-height: var(--sms-options-max-height, 50vh);
1115
1503
  overscroll-behavior: var(--sms-options-overscroll, none);
1116
- box-shadow: var(--sms-options-shadow, 0 0 14pt -8pt black);
1504
+ box-shadow: var(
1505
+ --sms-options-shadow,
1506
+ light-dark(0 0 14pt -8pt black, 0 0 14pt -4pt rgba(0, 0, 0, 0.8))
1507
+ );
1117
1508
  border: var(--sms-options-border);
1118
1509
  border-width: var(--sms-options-border-width);
1119
1510
  border-radius: var(--sms-options-border-radius, 1ex);
1120
1511
  padding: var(--sms-options-padding);
1121
- margin: var(--sms-options-margin, inherit);
1512
+ margin: var(--sms-options-margin, 6pt 0 0 0);
1122
1513
  }
1123
1514
  ul.options.hidden {
1124
1515
  visibility: hidden;
@@ -1138,19 +1529,25 @@ function handle_options_scroll(event) {
1138
1529
  padding: 3pt 2ex;
1139
1530
  }
1140
1531
  ul.options > li.selected {
1141
- background: var(--sms-li-selected-plain-bg, rgba(0, 123, 255, 0.1));
1532
+ background: var(
1533
+ --sms-li-selected-plain-bg,
1534
+ light-dark(rgba(0, 123, 255, 0.1), rgba(100, 180, 255, 0.2))
1535
+ );
1142
1536
  border-left: var(
1143
1537
  --sms-li-selected-plain-border,
1144
1538
  3px solid var(--sms-active-color, cornflowerblue)
1145
1539
  );
1146
1540
  }
1147
1541
  ul.options > li.active {
1148
- background: var(--sms-li-active-bg, var(--sms-active-color, rgba(0, 0, 0, 0.15)));
1542
+ background: var(
1543
+ --sms-li-active-bg,
1544
+ var(--sms-active-color, light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15)))
1545
+ );
1149
1546
  }
1150
1547
  ul.options > li.disabled {
1151
1548
  cursor: not-allowed;
1152
- background: var(--sms-li-disabled-bg, #f5f5f6);
1153
- color: var(--sms-li-disabled-text, #b8b8b8);
1549
+ background: var(--sms-li-disabled-bg, light-dark(#f5f5f6, #2a2a2a));
1550
+ color: var(--sms-li-disabled-text, light-dark(#b8b8b8, #666));
1154
1551
  }
1155
1552
  /* Checkbox styling for keepSelectedInDropdown='checkboxes' mode */
1156
1553
  ul.options > li > input.option-checkbox {
@@ -1161,7 +1558,10 @@ function handle_options_scroll(event) {
1161
1558
  }
1162
1559
  /* Select all option styling */
1163
1560
  ul.options > li.select-all {
1164
- border-bottom: var(--sms-select-all-border-bottom, 1px solid lightgray);
1561
+ border-bottom: var(
1562
+ --sms-select-all-border-bottom,
1563
+ 1px solid light-dark(lightgray, #555)
1564
+ );
1165
1565
  font-weight: var(--sms-select-all-font-weight, 500);
1166
1566
  color: var(--sms-select-all-color, inherit);
1167
1567
  background: var(--sms-select-all-bg, transparent);
@@ -1170,14 +1570,99 @@ function handle_options_scroll(event) {
1170
1570
  ul.options > li.select-all:hover {
1171
1571
  background: var(
1172
1572
  --sms-select-all-hover-bg,
1173
- var(--sms-li-active-bg, var(--sms-active-color, rgba(0, 0, 0, 0.15)))
1573
+ var(
1574
+ --sms-li-active-bg,
1575
+ var(
1576
+ --sms-active-color,
1577
+ light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15))
1578
+ )
1579
+ )
1580
+ );
1581
+ }
1582
+ /* Group header styling */
1583
+ ul.options > li.group-header {
1584
+ display: flex;
1585
+ align-items: center;
1586
+ font-weight: var(--sms-group-header-font-weight, 600);
1587
+ font-size: var(--sms-group-header-font-size, 0.85em);
1588
+ color: var(--sms-group-header-color, light-dark(#666, #aaa));
1589
+ background: var(--sms-group-header-bg, transparent);
1590
+ padding: var(--sms-group-header-padding, 6pt 1ex 3pt);
1591
+ cursor: default;
1592
+ border-left: none;
1593
+ text-transform: var(--sms-group-header-text-transform, uppercase);
1594
+ letter-spacing: var(--sms-group-header-letter-spacing, 0.5px);
1595
+ }
1596
+ ul.options > li.group-header:not(:first-child) {
1597
+ margin-top: var(--sms-group-header-margin-top, 4pt);
1598
+ border-top: var(--sms-group-header-border-top, 1px solid light-dark(#eee, #333));
1599
+ }
1600
+ ul.options > li.group-header.collapsible {
1601
+ cursor: pointer;
1602
+ }
1603
+ ul.options > li.group-header.collapsible:hover {
1604
+ background: var(
1605
+ --sms-group-header-hover-bg,
1606
+ light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05))
1607
+ );
1608
+ }
1609
+ ul.options > li.group-header .group-label {
1610
+ flex: 1;
1611
+ }
1612
+ ul.options > li.group-header .group-count {
1613
+ opacity: 0.6;
1614
+ font-size: 0.9em;
1615
+ font-weight: normal;
1616
+ margin-left: 4pt;
1617
+ }
1618
+ /* Sticky group headers when enabled */
1619
+ ul.options > li.group-header.sticky {
1620
+ position: sticky;
1621
+ top: 0;
1622
+ z-index: 1;
1623
+ background: var(
1624
+ --sms-group-header-sticky-bg,
1625
+ var(--sms-options-bg, light-dark(#fafafa, #1a1a1a))
1174
1626
  );
1175
1627
  }
1628
+ /* Indent grouped options for visual hierarchy */
1629
+ ul.options > li:not(.group-header):not(.select-all):not(.user-msg):not(.loading-more) {
1630
+ padding-left: var(
1631
+ --sms-group-item-padding-left,
1632
+ var(--sms-group-option-indent, 1.5ex)
1633
+ );
1634
+ }
1635
+ /* Collapse/expand animation for group chevron icon */
1636
+ ul.options > li.group-header :global(svg) {
1637
+ transition: transform var(--sms-group-collapse-duration, 0.15s) ease-out;
1638
+ }
1639
+ ul.options > li.group-header button.group-select-all {
1640
+ font-size: 0.9em;
1641
+ font-weight: normal;
1642
+ text-transform: none;
1643
+ color: var(--sms-active-color, cornflowerblue);
1644
+ background: transparent;
1645
+ border: none;
1646
+ cursor: pointer;
1647
+ padding: 2pt 4pt;
1648
+ margin-left: 8pt;
1649
+ border-radius: 3pt;
1650
+ aspect-ratio: auto; /* override global button aspect-ratio: 1 */
1651
+ }
1652
+ ul.options > li.group-header button.group-select-all:hover {
1653
+ background: var(
1654
+ --sms-group-select-all-hover-bg,
1655
+ light-dark(rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0.1))
1656
+ );
1657
+ }
1658
+ ul.options > li.group-header button.group-select-all.deselect {
1659
+ color: var(--sms-group-deselect-color, light-dark(#c44, #f77));
1660
+ }
1176
1661
  :is(span.max-select-msg) {
1177
1662
  padding: 0 3pt;
1178
1663
  }
1179
1664
  ::highlight(sms-search-matches) {
1180
- color: mediumaquamarine;
1665
+ color: light-dark(#1a8870, mediumaquamarine);
1181
1666
  }
1182
1667
  /* Loading more indicator for infinite scrolling */
1183
1668
  ul.options > li.loading-more {