svelte-multiselect 11.5.1 → 11.6.0

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.
@@ -5,17 +5,17 @@ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
5
5
  import { highlight_matches } from './attachments';
6
6
  import CircleSpinner from './CircleSpinner.svelte';
7
7
  import Icon from './Icon.svelte';
8
- import { fuzzy_match, get_label, get_style, has_group, is_object } from './utils';
8
+ import * as utils from './utils';
9
9
  import Wiggle from './Wiggle.svelte';
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
+ 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) => `${utils.get_label(opt)}`.toLowerCase(), filterFunc = (opt, searchText) => {
11
11
  if (!searchText)
12
12
  return true;
13
- const label = `${get_label(opt)}`;
13
+ const label = `${utils.get_label(opt)}`;
14
14
  return fuzzy
15
- ? fuzzy_match(searchText, label)
15
+ ? utils.fuzzy_match(searchText, label)
16
16
  : label.toLowerCase().includes(searchText.toLowerCase());
17
17
  }, fuzzy = true, closeDropdownOnSelect = false, form_input = $bindable(null), highlightMatches = true, id = null, input = $bindable(null), inputClass = ``, inputStyle = null, inputmode = null, invalid = $bindable(false), liActiveOptionClass = ``, liActiveUserMsgClass = ``, liOptionClass = ``, liOptionStyle = null, liSelectedClass = ``, liSelectedStyle = null, liUserMsgClass = ``, loading = false, matchingOptions = $bindable([]), maxOptions = undefined, maxSelect = $bindable(null), maxSelectMsg = (current, max) => (max > 1 ? `${current}/${max}` : ``), maxSelectMsgClass = ``, name = null, noMatchingOptionsMsg = `No matching options`, open = $bindable(false), options = $bindable(), outerDiv = $bindable(null), outerDivClass = ``, parseLabelsAsHtml = false, pattern = null, placeholder = null, removeAllTitle = `Remove all`, removeBtnTitle = `Remove`, minSelect = null, required = false, resetFilterOnAdd = true, searchText = $bindable(``), value = $bindable(null), selected = $bindable(value !== null && value !== undefined
18
- ? (Array.isArray(value) ? value : [value])
18
+ ? Array.isArray(value) ? value : [value]
19
19
  : (options
20
20
  ?.filter((opt) => typeof opt === `object` && opt !== null && opt?.preselected)
21
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 = {},
@@ -26,24 +26,24 @@ loadOptions,
26
26
  // Animation parameters for selected options flip animation
27
27
  selectedFlipParams = { duration: 100 },
28
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(),
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, onsearch, onmaxreached, onduplicate, onactivate, collapseAllGroups = $bindable(), expandAllGroups = $bindable(),
30
30
  // Keyboard shortcuts for common actions
31
- shortcuts = {}, ...rest } = $props();
31
+ shortcuts = {},
32
+ // Selection history for undo/redo (enabled by default, set to false or 0 to disable)
33
+ history = true, undo = $bindable(), redo = $bindable(), canUndo = $bindable(false), canRedo = $bindable(false), onundo, onredo, ...rest } = $props();
32
34
  // Generate unique IDs for ARIA associations (combobox pattern)
33
35
  // Uses provided id prop or generates a random one using crypto API
34
- const internal_id = $derived(id ?? `sms-${crypto.randomUUID().slice(0, 8)}`);
36
+ const internal_id = $derived(id ?? `sms-${utils.get_uuid().slice(0, 8)}`);
35
37
  const listbox_id = $derived(`${internal_id}-listbox`);
36
38
  // Parse shortcut string into modifier+key parts
37
39
  function parse_shortcut(shortcut) {
38
40
  const parts = shortcut.toLowerCase().split(`+`).map((part) => part.trim());
39
41
  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
- };
42
+ const ctrl = parts.includes(`ctrl`);
43
+ const shift = parts.includes(`shift`);
44
+ const alt = parts.includes(`alt`);
45
+ const meta = parts.includes(`meta`) || parts.includes(`cmd`);
46
+ return { key, ctrl, shift, alt, meta };
47
47
  }
48
48
  function matches_shortcut(event, shortcut) {
49
49
  if (!shortcut)
@@ -57,19 +57,22 @@ function matches_shortcut(event, shortcut) {
57
57
  const shift_matches = event.shiftKey === parsed.shift;
58
58
  const alt_matches = event.altKey === parsed.alt;
59
59
  const meta_matches = event.metaKey === parsed.meta;
60
- return (key_matches && ctrl_matches && shift_matches && alt_matches && meta_matches);
60
+ return key_matches && ctrl_matches && shift_matches && alt_matches && meta_matches;
61
61
  }
62
+ // Platform detection for keyboard shortcuts (Mac uses Cmd, others use Ctrl)
63
+ const is_mac = typeof navigator !== `undefined` &&
64
+ /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
65
+ const mod_key = is_mac ? `meta` : `ctrl`;
62
66
  // Default shortcuts
63
67
  const default_shortcuts = {
64
- select_all: `ctrl+a`,
65
- clear_all: `ctrl+shift+a`,
68
+ select_all: `${mod_key}+a`,
69
+ clear_all: `${mod_key}+shift+a`,
66
70
  open: null,
67
71
  close: null,
72
+ undo: `${mod_key}+z`,
73
+ redo: `${mod_key}+shift+z`,
68
74
  };
69
- const effective_shortcuts = $derived({
70
- ...default_shortcuts,
71
- ...shortcuts,
72
- });
75
+ const effective_shortcuts = $derived({ ...default_shortcuts, ...shortcuts });
73
76
  // Extract loadOptions config into single derived object (supports both simple function and config object)
74
77
  const load_options_config = $derived.by(() => {
75
78
  if (!loadOptions)
@@ -94,8 +97,8 @@ function values_equal(val1, val2) {
94
97
  if (empty1 && empty2)
95
98
  return true;
96
99
  if (Array.isArray(val1) && Array.isArray(val2)) {
97
- return val1.length === val2.length &&
98
- val1.every((item, idx) => item === val2[idx]);
100
+ return (val1.length === val2.length &&
101
+ val1.every((item, idx) => item === val2[idx]));
99
102
  }
100
103
  return false;
101
104
  }
@@ -104,15 +107,17 @@ function values_equal(val1, val2) {
104
107
  // infinite loops with reactive wrappers that clone arrays. See issue #309.
105
108
  $effect.pre(() => {
106
109
  const new_value = maxSelect === 1 ? (selected[0] ?? null) : selected;
107
- if (!values_equal(untrack(() => value), new_value))
110
+ if (!values_equal(untrack(() => value), new_value)) {
108
111
  value = new_value;
112
+ }
109
113
  });
110
114
  $effect.pre(() => {
111
115
  const new_selected = maxSelect === 1
112
116
  ? (value ? [value] : [])
113
117
  : (Array.isArray(value) ? value : []);
114
- if (!values_equal(untrack(() => selected), new_selected))
118
+ if (!values_equal(untrack(() => selected), new_selected)) {
115
119
  selected = new_selected;
120
+ }
116
121
  });
117
122
  let wiggle = $state(false); // controls wiggle animation when user tries to exceed maxSelect
118
123
  let ignore_hover = $state(false); // ignore mouseover during keyboard navigation to prevent scroll-triggered hover
@@ -125,6 +130,107 @@ $effect(() => {
125
130
  return () => clearTimeout(timer);
126
131
  }
127
132
  });
133
+ // History tracking for undo/redo
134
+ const max_history = $derived(history === true
135
+ ? 50
136
+ : typeof history === `number` && Number.isFinite(history)
137
+ ? Math.max(0, Math.floor(history))
138
+ : 0);
139
+ let history_stack = $state([]);
140
+ let history_index = $state(-1); // -1 = no history yet
141
+ let prev_selected = null; // null = uninitialized, sync on first run
142
+ // Track changes to selected via $effect (catches internal + external changes)
143
+ $effect(() => {
144
+ // Disabled when max_history is 0 (handles false, 0, negative, non-finite inputs)
145
+ const history_disabled = !(max_history > 0);
146
+ if (history_disabled) {
147
+ // Clear history when disabled so re-enabling starts fresh
148
+ history_stack = [];
149
+ history_index = -1;
150
+ // Don't read `selected` here to avoid creating unnecessary reactive dependency
151
+ prev_selected = null;
152
+ return;
153
+ }
154
+ // Initialize prev_selected on first run to avoid phantom undo from [] → initial selection
155
+ if (prev_selected === null) {
156
+ prev_selected = [...selected];
157
+ return;
158
+ }
159
+ // Check if actually changed (avoid duplicates from reactive updates)
160
+ if (values_equal(selected, prev_selected))
161
+ return;
162
+ // On first change, push initial state first
163
+ if (history_stack.length === 0) {
164
+ history_stack = [[...prev_selected]];
165
+ history_index = 0;
166
+ }
167
+ // Truncate any redo states
168
+ if (history_index < history_stack.length - 1) {
169
+ history_stack = history_stack.slice(0, history_index + 1);
170
+ }
171
+ // Push new state
172
+ history_stack = [...history_stack, [...selected]];
173
+ history_index = history_stack.length - 1;
174
+ // Trim to max size (remove oldest)
175
+ if (history_stack.length > max_history) {
176
+ const excess = history_stack.length - max_history;
177
+ history_stack = history_stack.slice(excess);
178
+ history_index = Math.max(0, history_index - excess);
179
+ }
180
+ prev_selected = [...selected];
181
+ });
182
+ // Derived canUndo/canRedo (update bindable props reactively)
183
+ $effect(() => {
184
+ canUndo = max_history > 0 && !disabled && history_index > 0;
185
+ canRedo = max_history > 0 && !disabled && history_index < history_stack.length - 1;
186
+ });
187
+ // Undo: restore previous state
188
+ undo = () => {
189
+ if (max_history <= 0 || disabled || history_index <= 0)
190
+ return false;
191
+ const previous = [...selected];
192
+ history_index--;
193
+ selected = [...history_stack[history_index]];
194
+ prev_selected = [...selected]; // sync tracker to prevent $effect re-recording
195
+ onundo?.({ previous, current: selected });
196
+ return true;
197
+ };
198
+ // Redo: restore next state
199
+ redo = () => {
200
+ if (max_history <= 0 || disabled || history_index >= history_stack.length - 1) {
201
+ return false;
202
+ }
203
+ const previous = [...selected];
204
+ history_index++;
205
+ selected = [...history_stack[history_index]];
206
+ prev_selected = [...selected]; // sync tracker to prevent $effect re-recording
207
+ onredo?.({ previous, current: selected });
208
+ return true;
209
+ };
210
+ // Debounced onsearch event - fires 150ms after search text stops changing
211
+ let search_debounce_timer = null;
212
+ let search_initialized = false;
213
+ $effect(() => {
214
+ const current_search = searchText;
215
+ // Skip initial mount - only fire on actual user input
216
+ if (!search_initialized) {
217
+ search_initialized = true;
218
+ return;
219
+ }
220
+ if (!onsearch)
221
+ return; // cleanup handles any pending timer
222
+ search_debounce_timer = setTimeout(() => {
223
+ // Optional chaining in case onsearch is removed while timer is pending
224
+ onsearch?.({
225
+ searchText: current_search,
226
+ matchingCount: matchingOptions.length,
227
+ });
228
+ }, 150);
229
+ return () => {
230
+ if (search_debounce_timer)
231
+ clearTimeout(search_debounce_timer);
232
+ };
233
+ });
128
234
  // Internal state for loadOptions feature (null = never loaded)
129
235
  let loaded_options = $state([]);
130
236
  let load_options_has_more = $state(true);
@@ -134,13 +240,13 @@ let debounce_timer = null;
134
240
  let effective_options = $derived(loadOptions ? loaded_options : (options ?? []));
135
241
  // Cache selected keys and labels to avoid repeated .map() calls
136
242
  let selected_keys = $derived(selected.map(key));
137
- let selected_labels = $derived(selected.map(get_label));
243
+ let selected_labels = $derived(selected.map(utils.get_label));
138
244
  // Sets for O(1) lookups (used in template, has_user_msg, group_header_state, batch operations)
139
245
  let selected_keys_set = $derived(new Set(selected_keys));
140
246
  let selected_labels_set = $derived(new Set(selected_labels));
141
247
  // Memoized Set of disabled option keys for O(1) lookups in large option sets
142
248
  let disabled_option_keys = $derived(new Set(effective_options
143
- .filter((opt) => is_object(opt) && opt.disabled)
249
+ .filter((opt) => utils.is_object(opt) && opt.disabled)
144
250
  .map(key)));
145
251
  // Check if an option is disabled (uses memoized Set for O(1) lookup)
146
252
  const is_disabled = (opt) => disabled_option_keys.has(key(opt));
@@ -151,7 +257,7 @@ let grouped_options = $derived.by(() => {
151
257
  const groups_map = new SvelteMap();
152
258
  const ungrouped = [];
153
259
  for (const opt of matchingOptions) {
154
- if (has_group(opt)) {
260
+ if (utils.has_group(opt)) {
155
261
  const existing = groups_map.get(opt.group);
156
262
  if (existing)
157
263
  existing.push(opt);
@@ -179,7 +285,11 @@ let grouped_options = $derived.by(() => {
179
285
  }
180
286
  if (ungrouped.length === 0)
181
287
  return grouped;
182
- const ungrouped_entry = { group: null, options: ungrouped, collapsed: false };
288
+ const ungrouped_entry = {
289
+ group: null,
290
+ options: ungrouped,
291
+ collapsed: false,
292
+ };
183
293
  return ungroupedPosition === `first`
184
294
  ? [ungrouped_entry, ...grouped]
185
295
  : [...grouped, ungrouped_entry];
@@ -248,8 +358,9 @@ function expand_groups(groups_to_expand) {
248
358
  if (groups_to_expand.length === 0)
249
359
  return;
250
360
  update_collapsed_groups(`delete`, groups_to_expand);
251
- for (const group of groups_to_expand)
361
+ for (const group of groups_to_expand) {
252
362
  ongroupToggle?.({ group, collapsed: false });
363
+ }
253
364
  }
254
365
  // Get names of collapsed groups that have matching options
255
366
  const get_collapsed_with_matches = () => grouped_options.flatMap(({ group, collapsed, options: opts }) => group && collapsed && opts.length > 0 ? [group] : []);
@@ -260,61 +371,65 @@ $effect(() => {
260
371
  }
261
372
  });
262
373
  // Normalize placeholder prop (supports string or { text, persistent } object)
263
- const placeholder_text = $derived(typeof placeholder === `string` ? placeholder : placeholder?.text ?? null);
374
+ const placeholder_text = $derived(typeof placeholder === `string` ? placeholder : (placeholder?.text ?? null));
264
375
  const placeholder_persistent = $derived(typeof placeholder === `object` && placeholder?.persistent === true);
265
376
  // Helper to sort selected options (used by add() and select_all())
266
377
  function sort_selected(items) {
267
378
  if (sortSelected === true) {
268
- return items.toSorted((op1, op2) => `${get_label(op1)}`.localeCompare(`${get_label(op2)}`));
379
+ return items.toSorted((op1, op2) => `${utils.get_label(op1)}`.localeCompare(`${utils.get_label(op2)}`));
269
380
  }
270
381
  else if (typeof sortSelected === `function`) {
271
382
  return items.toSorted(sortSelected);
272
383
  }
273
384
  return items;
274
385
  }
275
- if (!loadOptions && !((options?.length ?? 0) > 0)) {
276
- if (allowUserOptions || loading || disabled || allowEmpty) {
277
- options = []; // initializing as array avoids errors when component mounts
278
- }
279
- else {
280
- // error on empty options if user is not allowed to create custom options and loading is false
281
- // and component is not disabled and allowEmpty is false
282
- console.error(`MultiSelect: received no options`);
386
+ untrack(() => {
387
+ if (!loadOptions && !((options?.length ?? 0) > 0)) {
388
+ if (allowUserOptions || loading || disabled || allowEmpty) {
389
+ options = []; // initializing as array avoids errors when component mounts
390
+ }
391
+ else {
392
+ // error on empty options if user is not allowed to create custom options and loading is false
393
+ // and component is not disabled and allowEmpty is false
394
+ console.error(`MultiSelect: received no options`);
395
+ }
283
396
  }
284
- }
397
+ });
285
398
  if (maxSelect !== null && maxSelect < 1) {
286
399
  console.error(`MultiSelect: maxSelect must be null or positive integer, got ${maxSelect}`);
287
400
  }
288
401
  if (!Array.isArray(selected)) {
289
402
  console.error(`MultiSelect: selected prop should always be an array, got ${selected}`);
290
403
  }
291
- if (maxSelect && typeof required === `number` && required > maxSelect) {
292
- console.error(`MultiSelect: maxSelect=${maxSelect} < required=${required}, makes it impossible for users to submit a valid form`);
293
- }
294
- if (parseLabelsAsHtml && allowUserOptions) {
295
- console.warn(`MultiSelect: don't combine parseLabelsAsHtml and allowUserOptions. It's susceptible to XSS attacks!`);
296
- }
297
- if (sortSelected && selectedOptionsDraggable) {
298
- console.warn(`MultiSelect: sortSelected and selectedOptionsDraggable should not be combined as any ` +
299
- `user re-orderings of selected options will be undone by sortSelected on component re-renders.`);
300
- }
301
- if (allowUserOptions && !createOptionMsg && createOptionMsg !== null) {
302
- console.error(`MultiSelect: allowUserOptions=${allowUserOptions} but createOptionMsg=${createOptionMsg} is falsy. ` +
303
- `This prevents the "Add option" <span> from showing up, resulting in a confusing user experience.`);
304
- }
305
- if (maxOptions &&
306
- (typeof maxOptions != `number` || maxOptions < 0 || maxOptions % 1 != 0)) {
307
- console.error(`MultiSelect: maxOptions must be undefined or a positive integer, got ${maxOptions}`);
308
- }
404
+ $effect(() => {
405
+ if (maxSelect && typeof required === `number` && required > maxSelect) {
406
+ console.error(`MultiSelect: maxSelect=${maxSelect} < required=${required}, makes it impossible for users to submit a valid form`);
407
+ }
408
+ if (parseLabelsAsHtml && allowUserOptions) {
409
+ console.warn(`MultiSelect: don't combine parseLabelsAsHtml and allowUserOptions. It's susceptible to XSS attacks!`);
410
+ }
411
+ if (sortSelected && selectedOptionsDraggable) {
412
+ console.warn(`MultiSelect: sortSelected and selectedOptionsDraggable should not be combined as any ` +
413
+ `user re-orderings of selected options will be undone by sortSelected on component re-renders.`);
414
+ }
415
+ if (allowUserOptions && !createOptionMsg && createOptionMsg !== null) {
416
+ console.error(`MultiSelect: allowUserOptions=${allowUserOptions} but createOptionMsg=${createOptionMsg} is falsy. ` +
417
+ `This prevents the "Add option" <span> from showing up, resulting in a confusing user experience.`);
418
+ }
419
+ if (maxOptions &&
420
+ (typeof maxOptions != `number` || maxOptions < 0 || maxOptions % 1 != 0)) {
421
+ console.error(`MultiSelect: maxOptions must be undefined or a positive integer, got ${maxOptions}`);
422
+ }
423
+ });
309
424
  let option_msg_is_active = $state(false); // controls active state of <li>{createOptionMsg}</li>
310
425
  let window_width = $state(0);
311
426
  // Check if option matches search text (label or optionally group name)
312
427
  const matches_search = (opt, search) => {
313
428
  if (filterFunc(opt, search))
314
429
  return true;
315
- if (searchMatchesGroups && search && has_group(opt)) {
430
+ if (searchMatchesGroups && search && utils.has_group(opt)) {
316
431
  return fuzzy
317
- ? fuzzy_match(search, opt.group)
432
+ ? utils.fuzzy_match(search, opt.group)
318
433
  : opt.group.toLowerCase().includes(search.toLowerCase());
319
434
  }
320
435
  return false;
@@ -363,10 +478,19 @@ function add(option_to_add, event) {
363
478
  event.stopPropagation();
364
479
  if (maxSelect !== null && selected.length >= maxSelect)
365
480
  wiggle = true;
366
- if (!isNaN(Number(option_to_add)) && typeof selected_labels[0] === `number`) {
481
+ if (!isNaN(Number(option_to_add)) &&
482
+ typeof selected_labels[0] === `number`) {
367
483
  option_to_add = Number(option_to_add); // convert to number if possible
368
484
  }
369
485
  const is_duplicate = selected_keys_set.has(key(option_to_add));
486
+ const max_reached = maxSelect !== null && maxSelect !== 1 &&
487
+ selected.length >= maxSelect;
488
+ // Fire events for blocked add attempts
489
+ if (max_reached) {
490
+ onmaxreached?.({ selected, maxSelect, attemptedOption: option_to_add });
491
+ }
492
+ if (is_duplicate && !duplicates)
493
+ onduplicate?.({ option: option_to_add });
370
494
  if ((maxSelect === null || maxSelect === 1 || selected.length < maxSelect) &&
371
495
  (duplicates || !is_duplicate)) {
372
496
  if (!effective_options.includes(option_to_add) && // first check if we find option in the options list
@@ -415,7 +539,7 @@ function add(option_to_add, event) {
415
539
  }
416
540
  clear_validity();
417
541
  handle_dropdown_after_select(event);
418
- last_action = { type: `add`, label: `${get_label(option_to_add)}` };
542
+ last_action = { type: `add`, label: `${utils.get_label(option_to_add)}` };
419
543
  onadd?.({ option: option_to_add });
420
544
  onchange?.({ option: option_to_add, type: `add` });
421
545
  }
@@ -440,7 +564,7 @@ function remove(option_to_drop, event) {
440
564
  }
441
565
  selected = selected.filter((_, remove_idx) => remove_idx !== idx);
442
566
  clear_validity();
443
- last_action = { type: `remove`, label: `${get_label(option_removed)}` };
567
+ last_action = { type: `remove`, label: `${utils.get_label(option_removed)}` };
444
568
  onremove?.({ option: option_removed });
445
569
  onchange?.({ option: option_removed, type: `remove` });
446
570
  }
@@ -470,7 +594,8 @@ function handle_dropdown_after_select(event) {
470
594
  const reached_max = selected.length >= (maxSelect ?? Infinity);
471
595
  const should_close = closeDropdownOnSelect === true ||
472
596
  closeDropdownOnSelect === `retain-focus` ||
473
- (closeDropdownOnSelect === `if-mobile` && window_width &&
597
+ (closeDropdownOnSelect === `if-mobile` &&
598
+ window_width &&
474
599
  window_width < breakpoint);
475
600
  if (reached_max || should_close) {
476
601
  close_dropdown(event, closeDropdownOnSelect === `retain-focus`);
@@ -479,19 +604,24 @@ function handle_dropdown_after_select(event) {
479
604
  input?.focus();
480
605
  }
481
606
  // Check if a user message (create option, duplicate warning, no match) is visible
482
- const has_user_msg = $derived(searchText.length > 0 && Boolean((allowUserOptions && createOptionMsg) ||
483
- (!duplicates && selected_labels_set.has(searchText)) ||
484
- (navigable_options.length === 0 && noMatchingOptionsMsg)));
607
+ const has_user_msg = $derived(searchText.length > 0 &&
608
+ Boolean((allowUserOptions && createOptionMsg) ||
609
+ (!duplicates && selected_labels_set.has(searchText)) ||
610
+ (navigable_options.length === 0 && noMatchingOptionsMsg)));
485
611
  // Handle arrow key navigation through options (uses navigable_options to skip collapsed groups)
486
612
  async function handle_arrow_navigation(direction) {
487
613
  ignore_hover = true;
488
614
  // Auto-expand collapsed groups when keyboard navigating
489
- if (keyboardExpandsCollapsedGroups && collapsibleGroups && collapsedGroups.size > 0) {
615
+ if (keyboardExpandsCollapsedGroups &&
616
+ collapsibleGroups &&
617
+ collapsedGroups.size > 0) {
490
618
  expand_groups(get_collapsed_with_matches());
491
619
  await tick();
492
620
  }
493
621
  // toggle user message when no options match but user can create
494
- if (allowUserOptions && !navigable_options.length && searchText.length > 0) {
622
+ if (allowUserOptions &&
623
+ !navigable_options.length &&
624
+ searchText.length > 0) {
495
625
  option_msg_is_active = !option_msg_is_active;
496
626
  return;
497
627
  }
@@ -503,17 +633,26 @@ async function handle_arrow_navigation(direction) {
503
633
  }
504
634
  else {
505
635
  const total = navigable_options.length + (has_user_msg ? 1 : 0);
636
+ // Guard against division by zero (can happen if options filtered away before effect resets activeIndex)
637
+ if (total === 0) {
638
+ activeIndex = null;
639
+ return;
640
+ }
506
641
  activeIndex = (activeIndex + direction + total) % total; // +total handles negative mod
507
642
  }
508
643
  // update active state based on new index
509
644
  option_msg_is_active = has_user_msg && activeIndex === navigable_options.length;
510
645
  activeOption = option_msg_is_active
511
646
  ? null
512
- : navigable_options[activeIndex] ?? null;
647
+ : (navigable_options[activeIndex] ?? null);
513
648
  if (autoScroll) {
514
649
  await tick();
515
- document.querySelector(`ul.options > li.active`)?.scrollIntoViewIfNeeded?.();
650
+ document
651
+ .querySelector(`ul.options > li.active`)
652
+ ?.scrollIntoViewIfNeeded?.();
516
653
  }
654
+ // Fire onactivate for keyboard navigation only (not mouse hover)
655
+ onactivate?.({ option: activeOption, index: activeIndex });
517
656
  }
518
657
  // handle all keyboard events this component receives
519
658
  async function handle_keydown(event) {
@@ -544,6 +683,16 @@ async function handle_keydown(event) {
544
683
  searchText = ``;
545
684
  },
546
685
  },
686
+ {
687
+ key: `undo`,
688
+ condition: () => !!canUndo,
689
+ action: () => undo?.(),
690
+ },
691
+ {
692
+ key: `redo`,
693
+ condition: () => !!canRedo,
694
+ action: () => redo?.(),
695
+ },
547
696
  ];
548
697
  for (const { key, condition, action } of shortcut_actions) {
549
698
  if (matches_shortcut(event, effective_shortcuts[key]) && condition()) {
@@ -616,7 +765,10 @@ function remove_all(event) {
616
765
  // Only fire events if something was actually removed
617
766
  if (removed_options.length > 0) {
618
767
  searchText = ``; // always clear on remove all (resetFilterOnAdd only applies to add operations)
619
- last_action = { type: `removeAll`, label: `${removed_options.length} options` };
768
+ last_action = {
769
+ type: `removeAll`,
770
+ label: `${removed_options.length} options`,
771
+ };
620
772
  onremoveAll?.({ options: removed_options });
621
773
  onchange?.({ options: selected, type: `removeAll` });
622
774
  }
@@ -699,8 +851,9 @@ function on_click_outside(event) {
699
851
  if (outerDiv.contains(target))
700
852
  return;
701
853
  // If portal is active, also check if click is inside the portalled options dropdown
702
- if (portal_params?.active && ul_options && ul_options.contains(target))
854
+ if (portal_params?.active && ul_options && ul_options.contains(target)) {
703
855
  return;
856
+ }
704
857
  // Click is outside both the main component and any portalled dropdown
705
858
  close_dropdown(event);
706
859
  }
@@ -812,8 +965,9 @@ function portal(node, params) {
812
965
  target_node = params.target_node;
813
966
  render_in_place = typeof window === `undefined` ||
814
967
  !document.body.contains(node);
815
- if (open && !render_in_place && target_node)
968
+ if (open && !render_in_place && target_node) {
816
969
  tick().then(update_position);
970
+ }
817
971
  else if (!open || !target_node)
818
972
  node.hidden = true;
819
973
  },
@@ -828,7 +982,8 @@ function portal(node, params) {
828
982
  }
829
983
  // Dynamic options loading - captures search at call time to avoid race conditions
830
984
  async function load_dynamic_options(reset) {
831
- if (!load_options_config || load_options_loading ||
985
+ if (!load_options_config ||
986
+ load_options_loading ||
832
987
  (!reset && !load_options_has_more)) {
833
988
  return;
834
989
  }
@@ -889,11 +1044,13 @@ $effect(() => {
889
1044
  };
890
1045
  });
891
1046
  function handle_options_scroll(event) {
892
- if (!load_options_config || load_options_loading || !load_options_has_more)
1047
+ if (!load_options_config || load_options_loading || !load_options_has_more) {
893
1048
  return;
1049
+ }
894
1050
  const { scrollTop, scrollHeight, clientHeight } = event.target;
895
- if (scrollHeight - scrollTop - clientHeight <= 100)
1051
+ if (scrollHeight - scrollTop - clientHeight <= 100) {
896
1052
  load_dynamic_options(false);
1053
+ }
897
1054
  }
898
1055
  </script>
899
1056
 
@@ -957,11 +1114,9 @@ function handle_options_scroll(event) {
957
1114
  style={ulSelectedStyle}
958
1115
  >
959
1116
  {#each selected as option, idx (duplicates ? `${key(option)}-${idx}` : key(option))}
960
- {@const selectedOptionStyle =
961
- [get_style(option, `selected`), liSelectedStyle].filter(Boolean).join(
962
- ` `,
963
- ) ||
964
- null}
1117
+ {@const selectedOptionStyle = [utils.get_style(option, `selected`), liSelectedStyle]
1118
+ .filter(Boolean)
1119
+ .join(` `) || null}
965
1120
  <li
966
1121
  class={liSelectedClass}
967
1122
  role="option"
@@ -979,26 +1134,20 @@ function handle_options_scroll(event) {
979
1134
  onmouseup={(event) => event.stopPropagation()}
980
1135
  >
981
1136
  {#if selectedItem}
982
- {@render selectedItem({
983
- option,
984
- idx,
985
- })}
1137
+ {@render selectedItem({ option, idx })}
986
1138
  {:else if children}
987
- {@render children({
988
- option,
989
- idx,
990
- })}
1139
+ {@render children({ option, idx })}
991
1140
  {:else if parseLabelsAsHtml}
992
- {@html get_label(option)}
1141
+ {@html utils.get_label(option)}
993
1142
  {:else}
994
- {get_label(option)}
1143
+ {utils.get_label(option)}
995
1144
  {/if}
996
1145
  {#if !disabled && can_remove}
997
1146
  <button
998
1147
  onclick={(event) => remove(option, event)}
999
1148
  onkeydown={if_enter_or_space((event) => remove(option, event))}
1000
1149
  type="button"
1001
- title="{removeBtnTitle} {get_label(option)}"
1150
+ title="{removeBtnTitle} {utils.get_label(option)}"
1002
1151
  class="remove"
1003
1152
  >
1004
1153
  {#if removeIcon}
@@ -1145,8 +1294,9 @@ function handle_options_scroll(event) {
1145
1294
  (group_name ?? `ungrouped-${group_idx}`)
1146
1295
  }
1147
1296
  {#if group_name !== null}
1148
- {@const { all_selected, selected_count } = group_header_state.get(group_name) ??
1149
- { all_selected: false, selected_count: 0 }}
1297
+ {@const { all_selected, selected_count } = group_header_state.get(
1298
+ group_name,
1299
+ ) ?? { all_selected: false, selected_count: 0 }}
1150
1300
  {@const handle_toggle = () =>
1151
1301
  collapsibleGroups && toggle_group_collapsed(group_name)}
1152
1302
  {@const handle_group_select = (event: Event) =>
@@ -1210,14 +1360,12 @@ function handle_options_scroll(event) {
1210
1360
  title = null,
1211
1361
  selectedTitle = null,
1212
1362
  disabledTitle = defaultDisabledTitle,
1213
- } = is_object(option_item) ? option_item : { label: option_item }}
1363
+ } = utils.is_object(option_item) ? option_item : { label: option_item }}
1214
1364
  {@const active = activeIndex === flat_idx && flat_idx >= 0}
1215
1365
  {@const selected = is_selected(label)}
1216
- {@const optionStyle =
1217
- [get_style(option_item, `option`), liOptionStyle].filter(Boolean).join(
1218
- ` `,
1219
- ) ||
1220
- null}
1366
+ {@const optionStyle = [utils.get_style(option_item, `option`), liOptionStyle]
1367
+ .filter(Boolean)
1368
+ .join(` `) || null}
1221
1369
  {#if is_option_visible(flat_idx)}
1222
1370
  <li
1223
1371
  id="{internal_id}-opt-{flat_idx}"
@@ -1247,24 +1395,18 @@ function handle_options_scroll(event) {
1247
1395
  type="checkbox"
1248
1396
  class="option-checkbox"
1249
1397
  checked={selected}
1250
- aria-label="Toggle {get_label(option_item)}"
1398
+ aria-label="Toggle {utils.get_label(option_item)}"
1251
1399
  tabindex="-1"
1252
1400
  />
1253
1401
  {/if}
1254
1402
  {#if option}
1255
- {@render option({
1256
- option: option_item,
1257
- idx: flat_idx,
1258
- })}
1403
+ {@render option({ option: option_item, idx: flat_idx })}
1259
1404
  {:else if children}
1260
- {@render children({
1261
- option: option_item,
1262
- idx: flat_idx,
1263
- })}
1405
+ {@render children({ option: option_item, idx: flat_idx })}
1264
1406
  {:else if parseLabelsAsHtml}
1265
- {@html get_label(option_item)}
1407
+ {@html utils.get_label(option_item)}
1266
1408
  {:else}
1267
- {get_label(option_item)}
1409
+ {utils.get_label(option_item)}
1268
1410
  {/if}
1269
1411
  </li>
1270
1412
  {/if}
@@ -1323,7 +1465,11 @@ function handle_options_scroll(event) {
1323
1465
  {/if}
1324
1466
  {/if}
1325
1467
  {#if loadOptions && load_options_loading}
1326
- <li class="loading-more" role="status" aria-label="Loading more options">
1468
+ <li
1469
+ class="loading-more"
1470
+ role="status"
1471
+ aria-label="Loading more options"
1472
+ >
1327
1473
  <CircleSpinner />
1328
1474
  </li>
1329
1475
  {/if}
@@ -1359,7 +1505,9 @@ function handle_options_scroll(event) {
1359
1505
  border: 0;
1360
1506
  }
1361
1507
 
1362
- :is(div.multiselect) {
1508
+ /* Use :where() for elements with user-overridable class props (outerDivClass, ulSelectedClass, liSelectedClass)
1509
+ so user-provided classes take precedence. See: https://github.com/janosh/svelte-multiselect/issues/380 */
1510
+ :where(div.multiselect) {
1363
1511
  position: relative;
1364
1512
  align-items: center;
1365
1513
  display: flex;
@@ -1376,27 +1524,30 @@ function handle_options_scroll(event) {
1376
1524
  min-height: var(--sms-min-height, 22pt);
1377
1525
  margin: var(--sms-margin);
1378
1526
  }
1379
- :is(div.multiselect.open) {
1527
+ :where(div.multiselect.open) {
1380
1528
  /* increase z-index when open to ensure the dropdown of one <MultiSelect />
1381
1529
  displays above that of another slightly below it on the page */
1382
1530
  z-index: var(--sms-open-z-index, 4);
1383
1531
  }
1384
- :is(div.multiselect:focus-within) {
1385
- border: var(--sms-focus-border, 1pt solid var(--sms-active-color, cornflowerblue));
1532
+ :where(div.multiselect:focus-within) {
1533
+ border: var(
1534
+ --sms-focus-border,
1535
+ 1pt solid var(--sms-active-color, cornflowerblue)
1536
+ );
1386
1537
  }
1387
- :is(div.multiselect.disabled) {
1538
+ :where(div.multiselect.disabled) {
1388
1539
  background: var(--sms-disabled-bg, light-dark(lightgray, #444));
1389
1540
  cursor: not-allowed;
1390
1541
  }
1391
1542
 
1392
- :is(div.multiselect > ul.selected) {
1543
+ :where(div.multiselect > ul.selected) {
1393
1544
  display: flex;
1394
1545
  flex: 1;
1395
1546
  padding: 0;
1396
1547
  margin: 0;
1397
1548
  flex-wrap: wrap;
1398
1549
  }
1399
- :is(div.multiselect > ul.selected > li) {
1550
+ :where(div.multiselect > ul.selected > li) {
1400
1551
  align-items: center;
1401
1552
  border-radius: 3pt;
1402
1553
  display: flex;
@@ -1411,13 +1562,16 @@ function handle_options_scroll(event) {
1411
1562
  padding: var(--sms-selected-li-padding, 1pt 5pt);
1412
1563
  color: var(--sms-selected-text-color, var(--sms-text-color));
1413
1564
  }
1414
- :is(div.multiselect > ul.selected > li[draggable='true']) {
1565
+ :where(div.multiselect > ul.selected > li[draggable='true']) {
1415
1566
  cursor: grab;
1416
1567
  }
1417
- :is(div.multiselect > ul.selected > li.active) {
1568
+ :where(div.multiselect > ul.selected > li.active) {
1418
1569
  background: var(
1419
1570
  --sms-li-active-bg,
1420
- var(--sms-active-color, light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15)))
1571
+ var(
1572
+ --sms-active-color,
1573
+ light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15))
1574
+ )
1421
1575
  );
1422
1576
  }
1423
1577
  :is(div.multiselect button) {
@@ -1448,7 +1602,7 @@ function handle_options_scroll(event) {
1448
1602
  margin: auto 0; /* CSS reset */
1449
1603
  padding: 0; /* CSS reset */
1450
1604
  }
1451
- :is(div.multiselect > ul.selected > input) {
1605
+ :where(div.multiselect > ul.selected > input) {
1452
1606
  border: none;
1453
1607
  outline: none;
1454
1608
  background: none;
@@ -1462,7 +1616,7 @@ function handle_options_scroll(event) {
1462
1616
  }
1463
1617
 
1464
1618
  /* When options are selected, placeholder is hidden in which case we minimize input width to avoid adding unnecessary width to div.multiselect */
1465
- :is(div.multiselect > ul.selected > input:not(:placeholder-shown)) {
1619
+ :where(div.multiselect > ul.selected > input:not(:placeholder-shown)) {
1466
1620
  min-width: 1px; /* Minimal width to remain interactive */
1467
1621
  }
1468
1622
 
@@ -1483,7 +1637,8 @@ function handle_options_scroll(event) {
1483
1637
  pointer-events: none;
1484
1638
  }
1485
1639
 
1486
- ul.options {
1640
+ /* Use :where() for ul.options elements with class props (ulOptionsClass, liOptionClass, liUserMsgClass) */
1641
+ :where(ul.options) {
1487
1642
  list-style: none;
1488
1643
  /* top, left, width, position are managed by portal when active */
1489
1644
  /* but provide defaults for non-portaled or initial state */
@@ -1493,10 +1648,8 @@ function handle_options_scroll(event) {
1493
1648
  width: 100%;
1494
1649
  /* Default z-index if not portaled/overridden by portal */
1495
1650
  z-index: var(--sms-options-z-index, 3);
1496
-
1497
1651
  overflow: auto;
1498
- transition: all
1499
- 0.2s; /* Consider if this transition is desirable with portal positioning */
1652
+ transition: all 0.2s; /* is this transition is desirable with portal positioning? */
1500
1653
  box-sizing: border-box;
1501
1654
  background: var(--sms-options-bg, light-dark(#fafafa, #1a1a1a));
1502
1655
  max-height: var(--sms-options-max-height, 50vh);
@@ -1511,24 +1664,24 @@ function handle_options_scroll(event) {
1511
1664
  padding: var(--sms-options-padding);
1512
1665
  margin: var(--sms-options-margin, 6pt 0 0 0);
1513
1666
  }
1514
- ul.options.hidden {
1667
+ :where(ul.options.hidden) {
1515
1668
  visibility: hidden;
1516
1669
  opacity: 0;
1517
1670
  transform: translateY(50px);
1518
1671
  pointer-events: none;
1519
1672
  }
1520
- ul.options > li {
1673
+ :where(ul.options > li) {
1521
1674
  padding: 3pt 1ex;
1522
1675
  cursor: pointer;
1523
1676
  scroll-margin: var(--sms-options-scroll-margin, 100px);
1524
1677
  border-left: 3px solid transparent;
1525
1678
  }
1526
- ul.options .user-msg {
1679
+ :where(ul.options .user-msg) {
1527
1680
  /* block needed so vertical padding applies to span */
1528
1681
  display: block;
1529
1682
  padding: 3pt 2ex;
1530
1683
  }
1531
- ul.options > li.selected {
1684
+ :where(ul.options > li.selected) {
1532
1685
  background: var(
1533
1686
  --sms-li-selected-plain-bg,
1534
1687
  light-dark(rgba(0, 123, 255, 0.1), rgba(100, 180, 255, 0.2))
@@ -1538,26 +1691,29 @@ function handle_options_scroll(event) {
1538
1691
  3px solid var(--sms-active-color, cornflowerblue)
1539
1692
  );
1540
1693
  }
1541
- ul.options > li.active {
1694
+ :where(ul.options > li.active) {
1542
1695
  background: var(
1543
1696
  --sms-li-active-bg,
1544
- var(--sms-active-color, light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15)))
1697
+ var(
1698
+ --sms-active-color,
1699
+ light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15))
1700
+ )
1545
1701
  );
1546
1702
  }
1547
- ul.options > li.disabled {
1703
+ :where(ul.options > li.disabled) {
1548
1704
  cursor: not-allowed;
1549
1705
  background: var(--sms-li-disabled-bg, light-dark(#f5f5f6, #2a2a2a));
1550
1706
  color: var(--sms-li-disabled-text, light-dark(#b8b8b8, #666));
1551
1707
  }
1552
- /* Checkbox styling for keepSelectedInDropdown='checkboxes' mode */
1553
- ul.options > li > input.option-checkbox {
1708
+ /* Checkbox styling for keepSelectedInDropdown='checkboxes' mode - internal, no class prop */
1709
+ :is(ul.options > li > input.option-checkbox) {
1554
1710
  width: 16px;
1555
1711
  height: 16px;
1556
1712
  margin-right: 6px;
1557
1713
  accent-color: var(--sms-active-color, cornflowerblue);
1558
1714
  }
1559
- /* Select all option styling */
1560
- ul.options > li.select-all {
1715
+ /* Select all option styling - has liSelectAllClass prop */
1716
+ :where(ul.options > li.select-all) {
1561
1717
  border-bottom: var(
1562
1718
  --sms-select-all-border-bottom,
1563
1719
  1px solid light-dark(lightgray, #555)
@@ -1567,7 +1723,7 @@ function handle_options_scroll(event) {
1567
1723
  background: var(--sms-select-all-bg, transparent);
1568
1724
  margin-bottom: var(--sms-select-all-margin-bottom, 2pt);
1569
1725
  }
1570
- ul.options > li.select-all:hover {
1726
+ :where(ul.options > li.select-all:hover) {
1571
1727
  background: var(
1572
1728
  --sms-select-all-hover-bg,
1573
1729
  var(
@@ -1579,8 +1735,8 @@ function handle_options_scroll(event) {
1579
1735
  )
1580
1736
  );
1581
1737
  }
1582
- /* Group header styling */
1583
- ul.options > li.group-header {
1738
+ /* Group header styling - has liGroupHeaderClass prop */
1739
+ :where(ul.options > li.group-header) {
1584
1740
  display: flex;
1585
1741
  align-items: center;
1586
1742
  font-weight: var(--sms-group-header-font-weight, 600);
@@ -1593,30 +1749,34 @@ function handle_options_scroll(event) {
1593
1749
  text-transform: var(--sms-group-header-text-transform, uppercase);
1594
1750
  letter-spacing: var(--sms-group-header-letter-spacing, 0.5px);
1595
1751
  }
1596
- ul.options > li.group-header:not(:first-child) {
1752
+ :where(ul.options > li.group-header:not(:first-child)) {
1597
1753
  margin-top: var(--sms-group-header-margin-top, 4pt);
1598
- border-top: var(--sms-group-header-border-top, 1px solid light-dark(#eee, #333));
1754
+ border-top: var(
1755
+ --sms-group-header-border-top,
1756
+ 1px solid light-dark(#eee, #333)
1757
+ );
1599
1758
  }
1600
- ul.options > li.group-header.collapsible {
1759
+ :where(ul.options > li.group-header.collapsible) {
1601
1760
  cursor: pointer;
1602
1761
  }
1603
- ul.options > li.group-header.collapsible:hover {
1762
+ :where(ul.options > li.group-header.collapsible:hover) {
1604
1763
  background: var(
1605
1764
  --sms-group-header-hover-bg,
1606
1765
  light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05))
1607
1766
  );
1608
1767
  }
1609
- ul.options > li.group-header .group-label {
1768
+ /* Internal elements without class props - keep :is() for specificity */
1769
+ :is(ul.options > li.group-header .group-label) {
1610
1770
  flex: 1;
1611
1771
  }
1612
- ul.options > li.group-header .group-count {
1772
+ :is(ul.options > li.group-header .group-count) {
1613
1773
  opacity: 0.6;
1614
1774
  font-size: 0.9em;
1615
1775
  font-weight: normal;
1616
1776
  margin-left: 4pt;
1617
1777
  }
1618
1778
  /* Sticky group headers when enabled */
1619
- ul.options > li.group-header.sticky {
1779
+ :where(ul.options > li.group-header.sticky) {
1620
1780
  position: sticky;
1621
1781
  top: 0;
1622
1782
  z-index: 1;
@@ -1626,17 +1786,20 @@ function handle_options_scroll(event) {
1626
1786
  );
1627
1787
  }
1628
1788
  /* Indent grouped options for visual hierarchy */
1629
- ul.options > li:not(.group-header):not(.select-all):not(.user-msg):not(.loading-more) {
1789
+ :where(
1790
+ ul.options > li:not(.group-header):not(.select-all):not(.user-msg):not(.loading-more)
1791
+ ) {
1630
1792
  padding-left: var(
1631
1793
  --sms-group-item-padding-left,
1632
1794
  var(--sms-group-option-indent, 1.5ex)
1633
1795
  );
1634
1796
  }
1635
- /* Collapse/expand animation for group chevron icon */
1636
- ul.options > li.group-header :global(svg) {
1797
+ /* Collapse/expand animation for group chevron icon - internal, keep :is() for specificity */
1798
+ :is(ul.options > li.group-header) :global(svg) {
1637
1799
  transition: transform var(--sms-group-collapse-duration, 0.15s) ease-out;
1638
1800
  }
1639
- ul.options > li.group-header button.group-select-all {
1801
+ /* Keep :is() for internal buttons without class props */
1802
+ :is(ul.options > li.group-header button.group-select-all) {
1640
1803
  font-size: 0.9em;
1641
1804
  font-weight: normal;
1642
1805
  text-transform: none;
@@ -1649,23 +1812,23 @@ function handle_options_scroll(event) {
1649
1812
  border-radius: 3pt;
1650
1813
  aspect-ratio: auto; /* override global button aspect-ratio: 1 */
1651
1814
  }
1652
- ul.options > li.group-header button.group-select-all:hover {
1815
+ :is(ul.options > li.group-header button.group-select-all:hover) {
1653
1816
  background: var(
1654
1817
  --sms-group-select-all-hover-bg,
1655
1818
  light-dark(rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0.1))
1656
1819
  );
1657
1820
  }
1658
- ul.options > li.group-header button.group-select-all.deselect {
1821
+ :is(ul.options > li.group-header button.group-select-all.deselect) {
1659
1822
  color: var(--sms-group-deselect-color, light-dark(#c44, #f77));
1660
1823
  }
1661
- :is(span.max-select-msg) {
1824
+ :where(span.max-select-msg) {
1662
1825
  padding: 0 3pt;
1663
1826
  }
1664
1827
  ::highlight(sms-search-matches) {
1665
1828
  color: light-dark(#1a8870, mediumaquamarine);
1666
1829
  }
1667
- /* Loading more indicator for infinite scrolling */
1668
- ul.options > li.loading-more {
1830
+ /* Loading more indicator for infinite scrolling - internal, no class prop */
1831
+ :is(ul.options > li.loading-more) {
1669
1832
  display: flex;
1670
1833
  justify-content: center;
1671
1834
  align-items: center;