svelte-multiselect 11.5.2 → 11.6.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.
@@ -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_option_key(opt), 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 = {},
@@ -28,22 +28,22 @@ selectedFlipParams = { duration: 100 },
28
28
  // Option grouping feature
29
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,83 @@ $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
+ };
128
210
  // Debounced onsearch event - fires 150ms after search text stops changing
129
211
  let search_debounce_timer = null;
130
212
  let search_initialized = false;
@@ -158,13 +240,22 @@ let debounce_timer = null;
158
240
  let effective_options = $derived(loadOptions ? loaded_options : (options ?? []));
159
241
  // Cache selected keys and labels to avoid repeated .map() calls
160
242
  let selected_keys = $derived(selected.map(key));
161
- let selected_labels = $derived(selected.map(get_label));
243
+ let selected_labels = $derived(selected.map(utils.get_label));
162
244
  // Sets for O(1) lookups (used in template, has_user_msg, group_header_state, batch operations)
163
245
  let selected_keys_set = $derived(new Set(selected_keys));
164
- let selected_labels_set = $derived(new Set(selected_labels));
246
+ // String-normalized for consistent comparison (numeric labels like 123 match "123")
247
+ let selected_labels_set = $derived(new Set(selected_labels.map((lbl) => `${lbl}`)));
248
+ // Lowercase labels set for case-insensitive duplicate detection
249
+ let selected_labels_lower_set = $derived(duplicates === `case-insensitive`
250
+ ? new Set(selected_labels.map((lbl) => `${lbl}`.toLowerCase()))
251
+ : null);
252
+ // Helper to check if a label is already selected (respects case-insensitive mode)
253
+ const is_label_selected = (label) => selected_labels_lower_set
254
+ ? selected_labels_lower_set.has(label.toLowerCase())
255
+ : selected_labels_set.has(label);
165
256
  // Memoized Set of disabled option keys for O(1) lookups in large option sets
166
257
  let disabled_option_keys = $derived(new Set(effective_options
167
- .filter((opt) => is_object(opt) && opt.disabled)
258
+ .filter((opt) => utils.is_object(opt) && opt.disabled)
168
259
  .map(key)));
169
260
  // Check if an option is disabled (uses memoized Set for O(1) lookup)
170
261
  const is_disabled = (opt) => disabled_option_keys.has(key(opt));
@@ -175,7 +266,7 @@ let grouped_options = $derived.by(() => {
175
266
  const groups_map = new SvelteMap();
176
267
  const ungrouped = [];
177
268
  for (const opt of matchingOptions) {
178
- if (has_group(opt)) {
269
+ if (utils.has_group(opt)) {
179
270
  const existing = groups_map.get(opt.group);
180
271
  if (existing)
181
272
  existing.push(opt);
@@ -203,7 +294,11 @@ let grouped_options = $derived.by(() => {
203
294
  }
204
295
  if (ungrouped.length === 0)
205
296
  return grouped;
206
- const ungrouped_entry = { group: null, options: ungrouped, collapsed: false };
297
+ const ungrouped_entry = {
298
+ group: null,
299
+ options: ungrouped,
300
+ collapsed: false,
301
+ };
207
302
  return ungroupedPosition === `first`
208
303
  ? [ungrouped_entry, ...grouped]
209
304
  : [...grouped, ungrouped_entry];
@@ -272,8 +367,9 @@ function expand_groups(groups_to_expand) {
272
367
  if (groups_to_expand.length === 0)
273
368
  return;
274
369
  update_collapsed_groups(`delete`, groups_to_expand);
275
- for (const group of groups_to_expand)
370
+ for (const group of groups_to_expand) {
276
371
  ongroupToggle?.({ group, collapsed: false });
372
+ }
277
373
  }
278
374
  // Get names of collapsed groups that have matching options
279
375
  const get_collapsed_with_matches = () => grouped_options.flatMap(({ group, collapsed, options: opts }) => group && collapsed && opts.length > 0 ? [group] : []);
@@ -284,12 +380,12 @@ $effect(() => {
284
380
  }
285
381
  });
286
382
  // Normalize placeholder prop (supports string or { text, persistent } object)
287
- const placeholder_text = $derived(typeof placeholder === `string` ? placeholder : placeholder?.text ?? null);
383
+ const placeholder_text = $derived(typeof placeholder === `string` ? placeholder : (placeholder?.text ?? null));
288
384
  const placeholder_persistent = $derived(typeof placeholder === `object` && placeholder?.persistent === true);
289
385
  // Helper to sort selected options (used by add() and select_all())
290
386
  function sort_selected(items) {
291
387
  if (sortSelected === true) {
292
- return items.toSorted((op1, op2) => `${get_label(op1)}`.localeCompare(`${get_label(op2)}`));
388
+ return items.toSorted((op1, op2) => `${utils.get_label(op1)}`.localeCompare(`${utils.get_label(op2)}`));
293
389
  }
294
390
  else if (typeof sortSelected === `function`) {
295
391
  return items.toSorted(sortSelected);
@@ -340,9 +436,9 @@ let window_width = $state(0);
340
436
  const matches_search = (opt, search) => {
341
437
  if (filterFunc(opt, search))
342
438
  return true;
343
- if (searchMatchesGroups && search && has_group(opt)) {
439
+ if (searchMatchesGroups && search && utils.has_group(opt)) {
344
440
  return fuzzy
345
- ? fuzzy_match(search, opt.group)
441
+ ? utils.fuzzy_match(search, opt.group)
346
442
  : opt.group.toLowerCase().includes(search.toLowerCase());
347
443
  }
348
444
  return false;
@@ -391,21 +487,30 @@ function add(option_to_add, event) {
391
487
  event.stopPropagation();
392
488
  if (maxSelect !== null && selected.length >= maxSelect)
393
489
  wiggle = true;
394
- if (!isNaN(Number(option_to_add)) && typeof selected_labels[0] === `number`) {
490
+ if (!isNaN(Number(option_to_add)) &&
491
+ typeof selected_labels[0] === `number`) {
395
492
  option_to_add = Number(option_to_add); // convert to number if possible
396
493
  }
397
- const is_duplicate = selected_keys_set.has(key(option_to_add));
494
+ // Check for duplicates by key, plus label check for user-created options
495
+ // For duplicates=false (default), label check only applies to user-typed text
496
+ // For duplicates='case-insensitive', label check applies to all options
497
+ // Use key comparison instead of reference equality (more robust with Svelte proxies)
498
+ const option_key = key(option_to_add);
499
+ const is_from_options = effective_options.some((opt) => key(opt) === option_key);
500
+ const check_label = duplicates === `case-insensitive` || !is_from_options;
501
+ const is_duplicate = selected_keys_set.has(key(option_to_add)) ||
502
+ (check_label && is_label_selected(`${utils.get_label(option_to_add)}`));
398
503
  const max_reached = maxSelect !== null && maxSelect !== 1 &&
399
504
  selected.length >= maxSelect;
400
505
  // Fire events for blocked add attempts
401
506
  if (max_reached) {
402
507
  onmaxreached?.({ selected, maxSelect, attemptedOption: option_to_add });
403
508
  }
404
- if (is_duplicate && !duplicates)
509
+ if (is_duplicate && duplicates !== true)
405
510
  onduplicate?.({ option: option_to_add });
406
511
  if ((maxSelect === null || maxSelect === 1 || selected.length < maxSelect) &&
407
- (duplicates || !is_duplicate)) {
408
- if (!effective_options.includes(option_to_add) && // first check if we find option in the options list
512
+ (duplicates === true || !is_duplicate)) {
513
+ if (!is_from_options && // first check if we find option in the options list
409
514
  // this has the side-effect of not allowing to user to add the same
410
515
  // custom option twice in append mode
411
516
  [true, `append`].includes(allowUserOptions) &&
@@ -451,7 +556,7 @@ function add(option_to_add, event) {
451
556
  }
452
557
  clear_validity();
453
558
  handle_dropdown_after_select(event);
454
- last_action = { type: `add`, label: `${get_label(option_to_add)}` };
559
+ last_action = { type: `add`, label: `${utils.get_label(option_to_add)}` };
455
560
  onadd?.({ option: option_to_add });
456
561
  onchange?.({ option: option_to_add, type: `add` });
457
562
  }
@@ -476,7 +581,7 @@ function remove(option_to_drop, event) {
476
581
  }
477
582
  selected = selected.filter((_, remove_idx) => remove_idx !== idx);
478
583
  clear_validity();
479
- last_action = { type: `remove`, label: `${get_label(option_removed)}` };
584
+ last_action = { type: `remove`, label: `${utils.get_label(option_removed)}` };
480
585
  onremove?.({ option: option_removed });
481
586
  onchange?.({ option: option_removed, type: `remove` });
482
587
  }
@@ -506,7 +611,8 @@ function handle_dropdown_after_select(event) {
506
611
  const reached_max = selected.length >= (maxSelect ?? Infinity);
507
612
  const should_close = closeDropdownOnSelect === true ||
508
613
  closeDropdownOnSelect === `retain-focus` ||
509
- (closeDropdownOnSelect === `if-mobile` && window_width &&
614
+ (closeDropdownOnSelect === `if-mobile` &&
615
+ window_width &&
510
616
  window_width < breakpoint);
511
617
  if (reached_max || should_close) {
512
618
  close_dropdown(event, closeDropdownOnSelect === `retain-focus`);
@@ -515,19 +621,24 @@ function handle_dropdown_after_select(event) {
515
621
  input?.focus();
516
622
  }
517
623
  // Check if a user message (create option, duplicate warning, no match) is visible
518
- const has_user_msg = $derived(searchText.length > 0 && Boolean((allowUserOptions && createOptionMsg) ||
519
- (!duplicates && selected_labels_set.has(searchText)) ||
520
- (navigable_options.length === 0 && noMatchingOptionsMsg)));
624
+ const has_user_msg = $derived(searchText.length > 0 &&
625
+ Boolean((allowUserOptions && createOptionMsg) ||
626
+ (duplicates !== true && is_label_selected(searchText)) ||
627
+ (navigable_options.length === 0 && noMatchingOptionsMsg)));
521
628
  // Handle arrow key navigation through options (uses navigable_options to skip collapsed groups)
522
629
  async function handle_arrow_navigation(direction) {
523
630
  ignore_hover = true;
524
631
  // Auto-expand collapsed groups when keyboard navigating
525
- if (keyboardExpandsCollapsedGroups && collapsibleGroups && collapsedGroups.size > 0) {
632
+ if (keyboardExpandsCollapsedGroups &&
633
+ collapsibleGroups &&
634
+ collapsedGroups.size > 0) {
526
635
  expand_groups(get_collapsed_with_matches());
527
636
  await tick();
528
637
  }
529
638
  // toggle user message when no options match but user can create
530
- if (allowUserOptions && !navigable_options.length && searchText.length > 0) {
639
+ if (allowUserOptions &&
640
+ !navigable_options.length &&
641
+ searchText.length > 0) {
531
642
  option_msg_is_active = !option_msg_is_active;
532
643
  return;
533
644
  }
@@ -550,10 +661,12 @@ async function handle_arrow_navigation(direction) {
550
661
  option_msg_is_active = has_user_msg && activeIndex === navigable_options.length;
551
662
  activeOption = option_msg_is_active
552
663
  ? null
553
- : navigable_options[activeIndex] ?? null;
664
+ : (navigable_options[activeIndex] ?? null);
554
665
  if (autoScroll) {
555
666
  await tick();
556
- document.querySelector(`ul.options > li.active`)?.scrollIntoViewIfNeeded?.();
667
+ document
668
+ .querySelector(`ul.options > li.active`)
669
+ ?.scrollIntoViewIfNeeded?.();
557
670
  }
558
671
  // Fire onactivate for keyboard navigation only (not mouse hover)
559
672
  onactivate?.({ option: activeOption, index: activeIndex });
@@ -587,6 +700,16 @@ async function handle_keydown(event) {
587
700
  searchText = ``;
588
701
  },
589
702
  },
703
+ {
704
+ key: `undo`,
705
+ condition: () => !!canUndo,
706
+ action: () => undo?.(),
707
+ },
708
+ {
709
+ key: `redo`,
710
+ condition: () => !!canRedo,
711
+ action: () => redo?.(),
712
+ },
590
713
  ];
591
714
  for (const { key, condition, action } of shortcut_actions) {
592
715
  if (matches_shortcut(event, effective_shortcuts[key]) && condition()) {
@@ -659,7 +782,10 @@ function remove_all(event) {
659
782
  // Only fire events if something was actually removed
660
783
  if (removed_options.length > 0) {
661
784
  searchText = ``; // always clear on remove all (resetFilterOnAdd only applies to add operations)
662
- last_action = { type: `removeAll`, label: `${removed_options.length} options` };
785
+ last_action = {
786
+ type: `removeAll`,
787
+ label: `${removed_options.length} options`,
788
+ };
663
789
  onremoveAll?.({ options: removed_options });
664
790
  onchange?.({ options: selected, type: `removeAll` });
665
791
  }
@@ -718,7 +844,7 @@ function toggle_group_selection(group_opts, group_collapsed, all_selected, event
718
844
  }
719
845
  }
720
846
  // O(1) lookup using pre-computed Set instead of O(n) array.includes()
721
- const is_selected = (label) => selected_labels_set.has(label);
847
+ const is_selected = (label) => selected_labels_set.has(`${label}`);
722
848
  const if_enter_or_space = (handler) => (event) => {
723
849
  if (event.key === `Enter` || event.code === `Space`) {
724
850
  event.preventDefault();
@@ -742,8 +868,9 @@ function on_click_outside(event) {
742
868
  if (outerDiv.contains(target))
743
869
  return;
744
870
  // If portal is active, also check if click is inside the portalled options dropdown
745
- if (portal_params?.active && ul_options && ul_options.contains(target))
871
+ if (portal_params?.active && ul_options && ul_options.contains(target)) {
746
872
  return;
873
+ }
747
874
  // Click is outside both the main component and any portalled dropdown
748
875
  close_dropdown(event);
749
876
  }
@@ -855,8 +982,9 @@ function portal(node, params) {
855
982
  target_node = params.target_node;
856
983
  render_in_place = typeof window === `undefined` ||
857
984
  !document.body.contains(node);
858
- if (open && !render_in_place && target_node)
985
+ if (open && !render_in_place && target_node) {
859
986
  tick().then(update_position);
987
+ }
860
988
  else if (!open || !target_node)
861
989
  node.hidden = true;
862
990
  },
@@ -871,7 +999,8 @@ function portal(node, params) {
871
999
  }
872
1000
  // Dynamic options loading - captures search at call time to avoid race conditions
873
1001
  async function load_dynamic_options(reset) {
874
- if (!load_options_config || load_options_loading ||
1002
+ if (!load_options_config ||
1003
+ load_options_loading ||
875
1004
  (!reset && !load_options_has_more)) {
876
1005
  return;
877
1006
  }
@@ -932,11 +1061,13 @@ $effect(() => {
932
1061
  };
933
1062
  });
934
1063
  function handle_options_scroll(event) {
935
- if (!load_options_config || load_options_loading || !load_options_has_more)
1064
+ if (!load_options_config || load_options_loading || !load_options_has_more) {
936
1065
  return;
1066
+ }
937
1067
  const { scrollTop, scrollHeight, clientHeight } = event.target;
938
- if (scrollHeight - scrollTop - clientHeight <= 100)
1068
+ if (scrollHeight - scrollTop - clientHeight <= 100) {
939
1069
  load_dynamic_options(false);
1070
+ }
940
1071
  }
941
1072
  </script>
942
1073
 
@@ -1000,11 +1131,9 @@ function handle_options_scroll(event) {
1000
1131
  style={ulSelectedStyle}
1001
1132
  >
1002
1133
  {#each selected as option, idx (duplicates ? `${key(option)}-${idx}` : key(option))}
1003
- {@const selectedOptionStyle =
1004
- [get_style(option, `selected`), liSelectedStyle].filter(Boolean).join(
1005
- ` `,
1006
- ) ||
1007
- null}
1134
+ {@const selectedOptionStyle = [utils.get_style(option, `selected`), liSelectedStyle]
1135
+ .filter(Boolean)
1136
+ .join(` `) || null}
1008
1137
  <li
1009
1138
  class={liSelectedClass}
1010
1139
  role="option"
@@ -1022,26 +1151,20 @@ function handle_options_scroll(event) {
1022
1151
  onmouseup={(event) => event.stopPropagation()}
1023
1152
  >
1024
1153
  {#if selectedItem}
1025
- {@render selectedItem({
1026
- option,
1027
- idx,
1028
- })}
1154
+ {@render selectedItem({ option, idx })}
1029
1155
  {:else if children}
1030
- {@render children({
1031
- option,
1032
- idx,
1033
- })}
1156
+ {@render children({ option, idx })}
1034
1157
  {:else if parseLabelsAsHtml}
1035
- {@html get_label(option)}
1158
+ {@html utils.get_label(option)}
1036
1159
  {:else}
1037
- {get_label(option)}
1160
+ {utils.get_label(option)}
1038
1161
  {/if}
1039
1162
  {#if !disabled && can_remove}
1040
1163
  <button
1041
1164
  onclick={(event) => remove(option, event)}
1042
1165
  onkeydown={if_enter_or_space((event) => remove(option, event))}
1043
1166
  type="button"
1044
- title="{removeBtnTitle} {get_label(option)}"
1167
+ title="{removeBtnTitle} {utils.get_label(option)}"
1045
1168
  class="remove"
1046
1169
  >
1047
1170
  {#if removeIcon}
@@ -1188,8 +1311,9 @@ function handle_options_scroll(event) {
1188
1311
  (group_name ?? `ungrouped-${group_idx}`)
1189
1312
  }
1190
1313
  {#if group_name !== null}
1191
- {@const { all_selected, selected_count } = group_header_state.get(group_name) ??
1192
- { all_selected: false, selected_count: 0 }}
1314
+ {@const { all_selected, selected_count } = group_header_state.get(
1315
+ group_name,
1316
+ ) ?? { all_selected: false, selected_count: 0 }}
1193
1317
  {@const handle_toggle = () =>
1194
1318
  collapsibleGroups && toggle_group_collapsed(group_name)}
1195
1319
  {@const handle_group_select = (event: Event) =>
@@ -1253,14 +1377,12 @@ function handle_options_scroll(event) {
1253
1377
  title = null,
1254
1378
  selectedTitle = null,
1255
1379
  disabledTitle = defaultDisabledTitle,
1256
- } = is_object(option_item) ? option_item : { label: option_item }}
1380
+ } = utils.is_object(option_item) ? option_item : { label: option_item }}
1257
1381
  {@const active = activeIndex === flat_idx && flat_idx >= 0}
1258
1382
  {@const selected = is_selected(label)}
1259
- {@const optionStyle =
1260
- [get_style(option_item, `option`), liOptionStyle].filter(Boolean).join(
1261
- ` `,
1262
- ) ||
1263
- null}
1383
+ {@const optionStyle = [utils.get_style(option_item, `option`), liOptionStyle]
1384
+ .filter(Boolean)
1385
+ .join(` `) || null}
1264
1386
  {#if is_option_visible(flat_idx)}
1265
1387
  <li
1266
1388
  id="{internal_id}-opt-{flat_idx}"
@@ -1290,24 +1412,18 @@ function handle_options_scroll(event) {
1290
1412
  type="checkbox"
1291
1413
  class="option-checkbox"
1292
1414
  checked={selected}
1293
- aria-label="Toggle {get_label(option_item)}"
1415
+ aria-label="Toggle {utils.get_label(option_item)}"
1294
1416
  tabindex="-1"
1295
1417
  />
1296
1418
  {/if}
1297
1419
  {#if option}
1298
- {@render option({
1299
- option: option_item,
1300
- idx: flat_idx,
1301
- })}
1420
+ {@render option({ option: option_item, idx: flat_idx })}
1302
1421
  {:else if children}
1303
- {@render children({
1304
- option: option_item,
1305
- idx: flat_idx,
1306
- })}
1422
+ {@render children({ option: option_item, idx: flat_idx })}
1307
1423
  {:else if parseLabelsAsHtml}
1308
- {@html get_label(option_item)}
1424
+ {@html utils.get_label(option_item)}
1309
1425
  {:else}
1310
- {get_label(option_item)}
1426
+ {utils.get_label(option_item)}
1311
1427
  {/if}
1312
1428
  </li>
1313
1429
  {/if}
@@ -1315,8 +1431,7 @@ function handle_options_scroll(event) {
1315
1431
  {/if}
1316
1432
  {/each}
1317
1433
  {#if searchText}
1318
- {@const text_input_is_duplicate = selected_labels.includes(searchText)}
1319
- {@const is_dupe = !duplicates && text_input_is_duplicate && `dupe`}
1434
+ {@const is_dupe = duplicates !== true && is_label_selected(searchText) && `dupe`}
1320
1435
  {@const can_create = Boolean(allowUserOptions && createOptionMsg) && `create`}
1321
1436
  {@const no_match =
1322
1437
  Boolean(navigable_options?.length === 0 && noMatchingOptionsMsg) &&
@@ -1366,7 +1481,11 @@ function handle_options_scroll(event) {
1366
1481
  {/if}
1367
1482
  {/if}
1368
1483
  {#if loadOptions && load_options_loading}
1369
- <li class="loading-more" role="status" aria-label="Loading more options">
1484
+ <li
1485
+ class="loading-more"
1486
+ role="status"
1487
+ aria-label="Loading more options"
1488
+ >
1370
1489
  <CircleSpinner />
1371
1490
  </li>
1372
1491
  {/if}
@@ -1427,7 +1546,10 @@ function handle_options_scroll(event) {
1427
1546
  z-index: var(--sms-open-z-index, 4);
1428
1547
  }
1429
1548
  :where(div.multiselect:focus-within) {
1430
- border: var(--sms-focus-border, 1pt solid var(--sms-active-color, cornflowerblue));
1549
+ border: var(
1550
+ --sms-focus-border,
1551
+ 1pt solid var(--sms-active-color, cornflowerblue)
1552
+ );
1431
1553
  }
1432
1554
  :where(div.multiselect.disabled) {
1433
1555
  background: var(--sms-disabled-bg, light-dark(lightgray, #444));
@@ -1462,7 +1584,10 @@ function handle_options_scroll(event) {
1462
1584
  :where(div.multiselect > ul.selected > li.active) {
1463
1585
  background: var(
1464
1586
  --sms-li-active-bg,
1465
- var(--sms-active-color, light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15)))
1587
+ var(
1588
+ --sms-active-color,
1589
+ light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15))
1590
+ )
1466
1591
  );
1467
1592
  }
1468
1593
  :is(div.multiselect button) {
@@ -1539,10 +1664,8 @@ function handle_options_scroll(event) {
1539
1664
  width: 100%;
1540
1665
  /* Default z-index if not portaled/overridden by portal */
1541
1666
  z-index: var(--sms-options-z-index, 3);
1542
-
1543
1667
  overflow: auto;
1544
- transition: all
1545
- 0.2s; /* Consider if this transition is desirable with portal positioning */
1668
+ transition: all 0.2s; /* is this transition is desirable with portal positioning? */
1546
1669
  box-sizing: border-box;
1547
1670
  background: var(--sms-options-bg, light-dark(#fafafa, #1a1a1a));
1548
1671
  max-height: var(--sms-options-max-height, 50vh);
@@ -1587,7 +1710,10 @@ function handle_options_scroll(event) {
1587
1710
  :where(ul.options > li.active) {
1588
1711
  background: var(
1589
1712
  --sms-li-active-bg,
1590
- var(--sms-active-color, light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15)))
1713
+ var(
1714
+ --sms-active-color,
1715
+ light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15))
1716
+ )
1591
1717
  );
1592
1718
  }
1593
1719
  :where(ul.options > li.disabled) {
@@ -1641,7 +1767,10 @@ function handle_options_scroll(event) {
1641
1767
  }
1642
1768
  :where(ul.options > li.group-header:not(:first-child)) {
1643
1769
  margin-top: var(--sms-group-header-margin-top, 4pt);
1644
- border-top: var(--sms-group-header-border-top, 1px solid light-dark(#eee, #333));
1770
+ border-top: var(
1771
+ --sms-group-header-border-top,
1772
+ 1px solid light-dark(#eee, #333)
1773
+ );
1645
1774
  }
1646
1775
  :where(ul.options > li.group-header.collapsible) {
1647
1776
  cursor: pointer;