svelte-multiselect 11.2.4 → 11.4.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.
@@ -1,9 +1,10 @@
1
- <script lang="ts">import { tick } from 'svelte';
1
+ <!-- eslint-disable-next-line @stylistic/quotes -- TS generics require string literals -->
2
+ <script lang="ts" generics="Option extends import('./types').Option">import { tick, untrack } from 'svelte';
2
3
  import { flip } from 'svelte/animate';
3
4
  import { highlight_matches } from './attachments';
4
5
  import CircleSpinner from './CircleSpinner.svelte';
5
6
  import Icon from './Icon.svelte';
6
- import { fuzzy_match, get_label, get_style } from './utils';
7
+ import { fuzzy_match, get_label, get_style, is_object } from './utils';
7
8
  import Wiggle from './Wiggle.svelte';
8
9
  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) => {
9
10
  if (!searchText)
@@ -12,81 +13,145 @@ let { activeIndex = $bindable(null), activeOption = $bindable(null), createOptio
12
13
  return fuzzy
13
14
  ? fuzzy_match(searchText, label)
14
15
  : label.toLowerCase().includes(searchText.toLowerCase());
15
- }, fuzzy = true, closeDropdownOnSelect = `if-mobile`, 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 = 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(``), selected = $bindable(options
16
- ?.filter((opt) => opt instanceof Object && opt?.preselected)
17
- .slice(0, maxSelect ?? undefined) ?? []), sortSelected = false, selectedOptionsDraggable = !sortSelected, style = null, ulOptionsClass = ``, ulSelectedClass = ``, ulSelectedStyle = null, ulOptionsStyle = null, value = $bindable(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, portal: portal_params = {}, ...rest } = $props();
16
+ }, 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
17
+ ? (Array.isArray(value) ? value : [value])
18
+ : (options
19
+ ?.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
+ // Select all feature
22
+ selectAllOption = false, liSelectAllClass = ``,
23
+ // Dynamic options loading
24
+ loadOptions,
25
+ // 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
41
+ // Prevents infinite loops when value/selected are bound to reactive wrappers
42
+ // that clone arrays on assignment (e.g. Superforms, Svelte stores). See issue #309.
43
+ function values_equal(val1, val2) {
44
+ if (val1 === val2)
45
+ return true;
46
+ if (Array.isArray(val1) && Array.isArray(val2)) {
47
+ return val1.length === val2.length &&
48
+ val1.every((item, idx) => item === val2[idx]);
49
+ }
50
+ return false;
51
+ }
52
+ // Sync selected ↔ value bidirectionally. Use untrack to prevent each effect from
53
+ // reacting to changes in the "destination" value, and values_equal to prevent
54
+ // infinite loops with reactive wrappers that clone arrays. See issue #309.
18
55
  $effect.pre(() => {
19
- // if maxSelect=1, value is the single item in selected (or null if selected is empty)
20
- // this solves both https://github.com/janosh/svelte-multiselect/issues/86 and
21
- // https://github.com/janosh/svelte-multiselect/issues/136
22
- value = maxSelect === 1 ? (selected[0] ?? null) : selected;
23
- }); // sync selected updates to value
56
+ const new_value = maxSelect === 1 ? (selected[0] ?? null) : selected;
57
+ if (!values_equal(untrack(() => value), new_value))
58
+ value = new_value;
59
+ });
24
60
  $effect.pre(() => {
25
- if (maxSelect === 1)
26
- selected = value ? [value] : [];
27
- else
28
- selected = value ?? [];
29
- }); // sync value updates to selected
61
+ const new_selected = maxSelect === 1
62
+ ? (value ? [value] : [])
63
+ : (Array.isArray(value) ? value : []);
64
+ if (!values_equal(untrack(() => selected), new_selected))
65
+ selected = new_selected;
66
+ });
30
67
  let wiggle = $state(false); // controls wiggle animation when user tries to exceed maxSelect
31
- if (!(options?.length > 0)) {
68
+ let ignore_hover = $state(false); // ignore mouseover during keyboard navigation to prevent scroll-triggered hover
69
+ // Internal state for loadOptions feature (null = never loaded)
70
+ let loaded_options = $state([]);
71
+ let load_options_has_more = $state(true);
72
+ let load_options_loading = $state(false);
73
+ let load_options_last_search = $state(null);
74
+ let debounce_timer = null;
75
+ let effective_options = $derived(loadOptions ? loaded_options : (options ?? []));
76
+ // Cache selected keys and labels to avoid repeated .map() calls
77
+ let selected_keys = $derived(selected.map(key));
78
+ let selected_labels = $derived(selected.map(get_label));
79
+ // Normalize placeholder prop (supports string or { text, persistent } object)
80
+ const placeholder_text = $derived(typeof placeholder === `string` ? placeholder : placeholder?.text ?? null);
81
+ const placeholder_persistent = $derived(typeof placeholder === `object` && placeholder?.persistent === true);
82
+ // Helper to sort selected options (used by add() and select_all())
83
+ function sort_selected(items) {
84
+ if (sortSelected === true) {
85
+ return items.toSorted((op1, op2) => `${get_label(op1)}`.localeCompare(`${get_label(op2)}`));
86
+ }
87
+ else if (typeof sortSelected === `function`) {
88
+ return items.toSorted(sortSelected);
89
+ }
90
+ return items;
91
+ }
92
+ if (!loadOptions && !((options?.length ?? 0) > 0)) {
32
93
  if (allowUserOptions || loading || disabled || allowEmpty) {
33
94
  options = []; // initializing as array avoids errors when component mounts
34
95
  }
35
96
  else {
36
97
  // error on empty options if user is not allowed to create custom options and loading is false
37
98
  // and component is not disabled and allowEmpty is false
38
- console.error(`MultiSelect received no options`);
99
+ console.error(`MultiSelect: received no options`);
39
100
  }
40
101
  }
41
102
  if (maxSelect !== null && maxSelect < 1) {
42
- console.error(`MultiSelect's maxSelect must be null or positive integer, got ${maxSelect}`);
103
+ console.error(`MultiSelect: maxSelect must be null or positive integer, got ${maxSelect}`);
43
104
  }
44
105
  if (!Array.isArray(selected)) {
45
- console.error(`MultiSelect's selected prop should always be an array, got ${selected}`);
106
+ console.error(`MultiSelect: selected prop should always be an array, got ${selected}`);
46
107
  }
47
108
  if (maxSelect && typeof required === `number` && required > maxSelect) {
48
- console.error(`MultiSelect maxSelect=${maxSelect} < required=${required}, makes it impossible for users to submit a valid form`);
109
+ console.error(`MultiSelect: maxSelect=${maxSelect} < required=${required}, makes it impossible for users to submit a valid form`);
49
110
  }
50
111
  if (parseLabelsAsHtml && allowUserOptions) {
51
- console.warn(`Don't combine parseLabelsAsHtml and allowUserOptions. It's susceptible to XSS attacks!`);
112
+ console.warn(`MultiSelect: don't combine parseLabelsAsHtml and allowUserOptions. It's susceptible to XSS attacks!`);
52
113
  }
53
114
  if (sortSelected && selectedOptionsDraggable) {
54
- console.warn(`MultiSelect's sortSelected and selectedOptionsDraggable should not be combined as any ` +
115
+ console.warn(`MultiSelect: sortSelected and selectedOptionsDraggable should not be combined as any ` +
55
116
  `user re-orderings of selected options will be undone by sortSelected on component re-renders.`);
56
117
  }
57
118
  if (allowUserOptions && !createOptionMsg && createOptionMsg !== null) {
58
- console.error(`MultiSelect has allowUserOptions=${allowUserOptions} but createOptionMsg=${createOptionMsg} is falsy. ` +
119
+ console.error(`MultiSelect: allowUserOptions=${allowUserOptions} but createOptionMsg=${createOptionMsg} is falsy. ` +
59
120
  `This prevents the "Add option" <span> from showing up, resulting in a confusing user experience.`);
60
121
  }
61
122
  if (maxOptions &&
62
123
  (typeof maxOptions != `number` || maxOptions < 0 || maxOptions % 1 != 0)) {
63
- console.error(`MultiSelect's maxOptions must be undefined or a positive integer, got ${maxOptions}`);
124
+ console.error(`MultiSelect: maxOptions must be undefined or a positive integer, got ${maxOptions}`);
64
125
  }
65
126
  let option_msg_is_active = $state(false); // controls active state of <li>{createOptionMsg}</li>
66
127
  let window_width = $state(0);
67
128
  // options matching the current search text
68
129
  $effect.pre(() => {
69
- matchingOptions = options.filter((opt) => filterFunc(opt, searchText) &&
130
+ // 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)) &&
70
133
  // remove already selected options from dropdown list unless duplicate selections are allowed
71
134
  // or keepSelectedInDropdown is enabled
72
- (!selected.map(key).includes(key(opt)) || duplicates ||
73
- keepSelectedInDropdown));
135
+ (!selected_keys.includes(key(opt)) || duplicates || keepSelectedInDropdown));
136
+ });
137
+ // reset activeIndex if out of bounds (can happen when options change while dropdown is open)
138
+ $effect(() => {
139
+ if (activeIndex !== null && !matchingOptions[activeIndex]) {
140
+ activeIndex = null;
141
+ }
74
142
  });
75
- // raise if matchingOptions[activeIndex] does not yield a value
76
- if (activeIndex !== null && !matchingOptions[activeIndex]) {
77
- throw `Run time error, activeIndex=${activeIndex} is out of bounds, matchingOptions.length=${matchingOptions.length}`;
78
- }
79
143
  // update activeOption when activeIndex changes
80
144
  $effect(() => {
81
145
  activeOption = matchingOptions[activeIndex ?? -1] ?? null;
82
146
  });
147
+ // Helper to check if removing an option would violate minSelect constraint
148
+ const can_remove = $derived(minSelect === null || selected.length > minSelect);
83
149
  // toggle an option between selected and unselected states (for keepSelectedInDropdown mode)
84
150
  function toggle_option(option_to_toggle, event) {
85
- const is_currently_selected = selected.map(key).includes(key(option_to_toggle));
151
+ const is_currently_selected = selected_keys.includes(key(option_to_toggle));
86
152
  if (is_currently_selected) {
87
- if (minSelect === null || selected.length > minSelect) { // Only remove if it wouldn't violate minSelect
153
+ if (can_remove)
88
154
  remove(option_to_toggle, event);
89
- }
90
155
  }
91
156
  else
92
157
  add(option_to_toggle, event);
@@ -96,25 +161,25 @@ function add(option_to_add, event) {
96
161
  event.stopPropagation();
97
162
  if (maxSelect !== null && selected.length >= maxSelect)
98
163
  wiggle = true;
99
- if (!isNaN(Number(option_to_add)) && typeof selected.map(get_label)[0] === `number`) {
164
+ if (!isNaN(Number(option_to_add)) && typeof selected_labels[0] === `number`) {
100
165
  option_to_add = Number(option_to_add); // convert to number if possible
101
166
  }
102
- const is_duplicate = selected.map(key).includes(key(option_to_add));
167
+ const is_duplicate = selected_keys.includes(key(option_to_add));
103
168
  if ((maxSelect === null || maxSelect === 1 || selected.length < maxSelect) &&
104
169
  (duplicates || !is_duplicate)) {
105
- if (!options.includes(option_to_add) && // first check if we find option in the options list
170
+ if (!effective_options.includes(option_to_add) && // first check if we find option in the options list
106
171
  // this has the side-effect of not allowing to user to add the same
107
172
  // custom option twice in append mode
108
173
  [true, `append`].includes(allowUserOptions) &&
109
174
  searchText.length > 0) {
110
175
  // user entered text but no options match, so if allowUserOptions = true | 'append', we create
111
176
  // a new option from the user-entered text
112
- if (typeof options[0] === `object`) {
177
+ if (typeof effective_options[0] === `object`) {
113
178
  // if 1st option is an object, we create new option as object to keep type homogeneity
114
179
  option_to_add = { label: searchText };
115
180
  }
116
181
  else {
117
- if ([`number`, `undefined`].includes(typeof options[0]) &&
182
+ if ([`number`, `undefined`].includes(typeof effective_options[0]) &&
118
183
  !isNaN(Number(searchText))) {
119
184
  // create new option as number if it parses to a number and 1st option is also number or missing
120
185
  option_to_add = Number(searchText);
@@ -125,46 +190,31 @@ function add(option_to_add, event) {
125
190
  }
126
191
  // Fire oncreate event for all user-created options, regardless of type
127
192
  oncreate?.({ option: option_to_add });
128
- if (allowUserOptions === `append`)
129
- options = [...options, option_to_add];
193
+ if (allowUserOptions === `append`) {
194
+ if (loadOptions) {
195
+ loaded_options = [...loaded_options, option_to_add];
196
+ }
197
+ else {
198
+ options = [...(options ?? []), option_to_add];
199
+ }
200
+ }
130
201
  }
131
202
  if (resetFilterOnAdd)
132
203
  searchText = ``; // reset search string on selection
133
204
  if ([``, undefined, null].includes(option_to_add)) {
134
- console.error(`MultiSelect: encountered falsy option ${option_to_add}`);
205
+ console.error(`MultiSelect: encountered falsy option`, option_to_add);
135
206
  return;
136
207
  }
137
208
  // for maxSelect = 1 we always replace current option with new one
138
209
  if (maxSelect === 1)
139
210
  selected = [option_to_add];
140
211
  else {
141
- selected = [...selected, option_to_add];
142
- if (sortSelected === true) {
143
- selected = selected.sort((op1, op2) => {
144
- const [label1, label2] = [get_label(op1), get_label(op2)];
145
- // coerce to string if labels are numbers
146
- return `${label1}`.localeCompare(`${label2}`);
147
- });
148
- }
149
- else if (typeof sortSelected === `function`) {
150
- selected = selected.sort(sortSelected);
151
- }
152
- }
153
- const reached_max_select = selected.length >= (maxSelect ?? Infinity);
154
- const dropdown_should_close = closeDropdownOnSelect === true ||
155
- closeDropdownOnSelect === `retain-focus` ||
156
- (closeDropdownOnSelect === `if-mobile` && window_width &&
157
- window_width < breakpoint);
158
- const should_retain_focus = closeDropdownOnSelect === `retain-focus`;
159
- if (reached_max_select || dropdown_should_close) {
160
- close_dropdown(event, should_retain_focus);
212
+ selected = sort_selected([...selected, option_to_add]);
161
213
  }
162
- else if (!dropdown_should_close)
163
- input?.focus();
214
+ clear_validity();
215
+ handle_dropdown_after_select(event);
164
216
  onadd?.({ option: option_to_add });
165
217
  onchange?.({ option: option_to_add, type: `add` });
166
- invalid = false; // reset error status whenever new items are selected
167
- form_input?.setCustomValidity(``);
168
218
  }
169
219
  }
170
220
  // remove an option from selected list
@@ -178,15 +228,15 @@ function remove(option_to_drop, event) {
178
228
  // if option with label could not be found but allowUserOptions is truthy,
179
229
  // assume it was created by user and create corresponding option object
180
230
  // on the fly for use as event payload
181
- const other_ops_type = typeof options[0];
182
- option_removed = (other_ops_type ? { label: option_to_drop } : option_to_drop);
231
+ const is_object_option = typeof effective_options[0] === `object`;
232
+ option_removed = (is_object_option ? { label: option_to_drop } : option_to_drop);
183
233
  }
184
234
  if (option_removed === undefined) {
185
- return console.error(`Multiselect can't remove selected option ${JSON.stringify(option_to_drop)}, not found in selected list`);
235
+ console.error(`MultiSelect: can't remove option ${JSON.stringify(option_to_drop)}, not found in selected list`);
236
+ return;
186
237
  }
187
238
  selected = [...selected]; // trigger Svelte rerender
188
- invalid = false; // reset error status whenever items are removed
189
- form_input?.setCustomValidity(``);
239
+ clear_validity();
190
240
  onremove?.({ option: option_removed });
191
241
  onchange?.({ option: option_removed, type: `remove` });
192
242
  }
@@ -208,6 +258,52 @@ function close_dropdown(event, retain_focus = false) {
208
258
  activeIndex = null;
209
259
  onclose?.({ event });
210
260
  }
261
+ function clear_validity() {
262
+ invalid = false;
263
+ form_input?.setCustomValidity(``);
264
+ }
265
+ function handle_dropdown_after_select(event) {
266
+ const reached_max = selected.length >= (maxSelect ?? Infinity);
267
+ const should_close = closeDropdownOnSelect === true ||
268
+ closeDropdownOnSelect === `retain-focus` ||
269
+ (closeDropdownOnSelect === `if-mobile` && window_width &&
270
+ window_width < breakpoint);
271
+ if (reached_max || should_close) {
272
+ close_dropdown(event, closeDropdownOnSelect === `retain-focus`);
273
+ }
274
+ else
275
+ input?.focus();
276
+ }
277
+ // Check if a user message (create option, duplicate warning, no match) is visible
278
+ 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`)
282
+ async function handle_arrow_navigation(direction) {
283
+ ignore_hover = true;
284
+ // toggle user message when no options match but user can create
285
+ if (allowUserOptions && !matchingOptions.length && searchText.length > 0) {
286
+ option_msg_is_active = !option_msg_is_active;
287
+ return;
288
+ }
289
+ if (activeIndex === null && !matchingOptions.length)
290
+ return; // nothing to navigate
291
+ // activate first option or navigate with wrap-around
292
+ if (activeIndex === null) {
293
+ activeIndex = 0;
294
+ }
295
+ else {
296
+ const total = matchingOptions.length + (has_user_msg ? 1 : 0);
297
+ activeIndex = (activeIndex + direction + total) % total; // +total handles negative mod
298
+ }
299
+ // 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;
302
+ if (autoScroll) {
303
+ await tick();
304
+ document.querySelector(`ul.options > li.active`)?.scrollIntoViewIfNeeded?.();
305
+ }
306
+ }
211
307
  // handle all keyboard events this component receives
212
308
  async function handle_keydown(event) {
213
309
  // on escape or tab out of input: close options dropdown and reset search text
@@ -215,20 +311,17 @@ async function handle_keydown(event) {
215
311
  event.stopPropagation();
216
312
  close_dropdown(event);
217
313
  searchText = ``;
218
- } // on enter key: toggle active option and reset search text
314
+ } // on enter key: toggle active option
219
315
  else if (event.key === `Enter`) {
220
316
  event.stopPropagation();
221
317
  event.preventDefault(); // prevent enter key from triggering form submission
222
318
  if (activeOption) {
223
- if (selected.includes(activeOption)) {
224
- // Only remove if it wouldn't violate minSelect
225
- if (minSelect === null || selected.length > minSelect) {
319
+ if (selected_keys.includes(key(activeOption))) {
320
+ if (can_remove)
226
321
  remove(activeOption, event);
227
- }
228
322
  }
229
323
  else
230
- add(activeOption, event);
231
- searchText = ``;
324
+ add(activeOption, event); // add() handles resetFilterOnAdd internally when successful
232
325
  }
233
326
  else if (allowUserOptions && searchText.length > 0) {
234
327
  // user entered text but no options match, so if allowUserOptions is truthy, we create new option
@@ -240,59 +333,14 @@ async function handle_keydown(event) {
240
333
  open_dropdown(event);
241
334
  }
242
335
  } // on up/down arrow keys: update active option
243
- else if ([`ArrowDown`, `ArrowUp`].includes(event.key)) {
336
+ else if (event.key === `ArrowDown` || event.key === `ArrowUp`) {
244
337
  event.stopPropagation();
245
- // if no option is active yet, but there are matching options, make first one active
246
- if (activeIndex === null && matchingOptions.length > 0) {
247
- event.preventDefault(); // Prevent scroll only if we handle the key
248
- activeIndex = 0;
249
- return;
250
- }
251
- else if (allowUserOptions && !matchingOptions.length && searchText.length > 0) {
252
- event.preventDefault(); // Prevent scroll only if we handle the key
253
- // if allowUserOptions is truthy and user entered text but no options match, we make
254
- // <li>{addUserMsg}</li> active on keydown (or toggle it if already active)
255
- option_msg_is_active = !option_msg_is_active;
256
- return;
257
- }
258
- else if (activeIndex === null) {
259
- // if no option is active and no options are matching, do nothing
260
- return;
261
- }
262
- event.preventDefault(); // Prevent scroll only if we handle the key
263
- // if none of the above special cases apply, we make next/prev option
264
- // active with wrap around at both ends
265
- const increment = event.key === `ArrowUp` ? -1 : 1;
266
- // Include user message in total count if it exists
267
- const has_user_msg = searchText && ((allowUserOptions && createOptionMsg) ||
268
- (!duplicates && selected.map(get_label).includes(searchText)) ||
269
- (matchingOptions.length === 0 && noMatchingOptionsMsg));
270
- const total_items = matchingOptions.length + (has_user_msg ? 1 : 0);
271
- activeIndex = (activeIndex + increment) % total_items;
272
- // in JS % behaves like remainder operator, not real modulo, so negative numbers stay negative
273
- // need to do manual wrap around at 0
274
- if (activeIndex < 0)
275
- activeIndex = total_items - 1;
276
- // Handle user message activation
277
- if (has_user_msg && activeIndex === matchingOptions.length) {
278
- option_msg_is_active = true;
279
- activeOption = null;
280
- }
281
- else {
282
- option_msg_is_active = false;
283
- activeOption = matchingOptions[activeIndex] ?? null;
284
- }
285
- if (autoScroll) {
286
- await tick();
287
- const li = document.querySelector(`ul.options > li.active`);
288
- if (li)
289
- li.scrollIntoViewIfNeeded?.();
290
- }
338
+ event.preventDefault();
339
+ await handle_arrow_navigation(event.key === `ArrowUp` ? -1 : 1);
291
340
  } // on backspace key: remove last selected option
292
341
  else if (event.key === `Backspace` && selected.length > 0 && !searchText) {
293
342
  event.stopPropagation();
294
- // Only remove option if it wouldn't violate minSelect
295
- if (minSelect === null || selected.length > minSelect) {
343
+ if (can_remove) {
296
344
  const last_option = selected.at(-1);
297
345
  if (last_option)
298
346
  remove(last_option, event);
@@ -312,21 +360,41 @@ function remove_all(event) {
312
360
  // If no minSelect constraint, remove all
313
361
  removed_options = selected;
314
362
  selected = [];
315
- searchText = ``;
363
+ searchText = ``; // always clear on remove all (resetFilterOnAdd only applies to add operations)
316
364
  }
317
365
  else if (selected.length > minSelect) {
318
366
  // Keep the first minSelect items
319
367
  removed_options = selected.slice(minSelect);
320
368
  selected = selected.slice(0, minSelect);
321
- searchText = ``;
369
+ searchText = ``; // always clear on remove all (resetFilterOnAdd only applies to add operations)
370
+ }
371
+ // Only fire events if something was actually removed
372
+ if (removed_options.length > 0) {
373
+ onremoveAll?.({ options: removed_options });
374
+ onchange?.({ options: selected, type: `removeAll` });
375
+ }
376
+ }
377
+ function select_all(event) {
378
+ 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` });
322
393
  }
323
- onremoveAll?.({ options: removed_options });
324
- onchange?.({ options: selected, type: `removeAll` });
325
- // If selected.length <= minSelect, do nothing (can't remove any more)
326
394
  }
327
- let is_selected = $derived((label) => selected.map(get_label).includes(label));
395
+ let is_selected = $derived((label) => selected_labels.includes(label));
328
396
  const if_enter_or_space = (handler) => (event) => {
329
- if ([`Enter`, `Space`].includes(event.code)) {
397
+ if (event.key === `Enter` || event.code === `Space`) {
330
398
  event.preventDefault();
331
399
  handler(event);
332
400
  }
@@ -464,6 +532,74 @@ function portal(node, params) {
464
532
  };
465
533
  }
466
534
  }
535
+ // Dynamic options loading - captures search at call time to avoid race conditions
536
+ async function load_dynamic_options(reset) {
537
+ if (!load_options_fn || load_options_loading || (!reset && !load_options_has_more)) {
538
+ return;
539
+ }
540
+ // Capture search term at call time to avoid race with user typing during fetch
541
+ const search = searchText;
542
+ const offset = reset ? 0 : loaded_options.length;
543
+ load_options_loading = true;
544
+ try {
545
+ const limit = load_options_batch_size;
546
+ const result = await load_options_fn({ search, offset, limit });
547
+ loaded_options = reset ? result.options : [...loaded_options, ...result.options];
548
+ load_options_has_more = result.hasMore;
549
+ load_options_last_search = search;
550
+ }
551
+ catch (err) {
552
+ console.error(`MultiSelect: loadOptions error:`, err);
553
+ }
554
+ finally {
555
+ load_options_loading = false;
556
+ }
557
+ }
558
+ // Single effect handles initial load + search changes
559
+ $effect(() => {
560
+ if (!load_options_fn)
561
+ return;
562
+ // Reset state when dropdown closes so next open triggers fresh load
563
+ if (!open) {
564
+ load_options_last_search = null;
565
+ loaded_options = [];
566
+ load_options_has_more = true;
567
+ return;
568
+ }
569
+ if (debounce_timer)
570
+ clearTimeout(debounce_timer);
571
+ const search = searchText;
572
+ const is_first_load = load_options_last_search === null;
573
+ if (is_first_load) {
574
+ if (load_options_on_open) {
575
+ // Load immediately on dropdown open
576
+ load_dynamic_options(true);
577
+ }
578
+ else if (search) {
579
+ // onOpen=false but user typed - debounce and load
580
+ debounce_timer = setTimeout(() => load_dynamic_options(true), load_options_debounce_ms);
581
+ }
582
+ // If onOpen=false and no search text, do nothing (wait for user to type)
583
+ }
584
+ else if (search !== load_options_last_search) {
585
+ // Subsequent loads: debounce search changes
586
+ // Clear stale results immediately so UI doesn't show wrong results while loading
587
+ loaded_options = [];
588
+ load_options_has_more = true;
589
+ debounce_timer = setTimeout(() => load_dynamic_options(true), load_options_debounce_ms);
590
+ }
591
+ return () => {
592
+ if (debounce_timer)
593
+ clearTimeout(debounce_timer);
594
+ };
595
+ });
596
+ function handle_options_scroll(event) {
597
+ if (!load_options_fn || load_options_loading || !load_options_has_more)
598
+ return;
599
+ const { scrollTop, scrollHeight, clientHeight } = event.target;
600
+ if (scrollHeight - scrollTop - clientHeight <= 100)
601
+ load_dynamic_options(false);
602
+ }
467
603
  </script>
468
604
 
469
605
  <svelte:window
@@ -523,7 +659,7 @@ function portal(node, params) {
523
659
  aria-label="selected options"
524
660
  style={ulSelectedStyle}
525
661
  >
526
- {#each selected as option, idx (duplicates ? [key(option), idx] : key(option))}
662
+ {#each selected as option, idx (duplicates ? `${key(option)}-${idx}` : key(option))}
527
663
  {@const selectedOptionStyle =
528
664
  [get_style(option, `selected`), liSelectedStyle].filter(Boolean).join(
529
665
  ` `,
@@ -533,7 +669,7 @@ function portal(node, params) {
533
669
  class={liSelectedClass}
534
670
  role="option"
535
671
  aria-selected="true"
536
- animate:flip={{ duration: 100 }}
672
+ animate:flip={selectedFlipParams}
537
673
  draggable={selectedOptionsDraggable && !disabled && selected.length > 1}
538
674
  ondragstart={dragstart(idx)}
539
675
  ondragover={(event) => {
@@ -543,6 +679,7 @@ function portal(node, params) {
543
679
  ondragenter={() => (drag_idx = idx)}
544
680
  class:active={drag_idx === idx}
545
681
  style={selectedOptionStyle}
682
+ onmouseup={(event) => event.stopPropagation()}
546
683
  >
547
684
  {#if selectedItem}
548
685
  {@render selectedItem({
@@ -559,7 +696,7 @@ function portal(node, params) {
559
696
  {:else}
560
697
  {get_label(option)}
561
698
  {/if}
562
- {#if !disabled && (minSelect === null || selected.length > minSelect)}
699
+ {#if !disabled && can_remove}
563
700
  <button
564
701
  onclick={(event) => remove(option, event)}
565
702
  onkeydown={if_enter_or_space((event) => remove(option, event))}
@@ -586,7 +723,7 @@ function portal(node, params) {
586
723
  {autocomplete}
587
724
  {inputmode}
588
725
  {pattern}
589
- placeholder={selected.length === 0 ? placeholder : null}
726
+ placeholder={selected.length === 0 || placeholder_persistent ? placeholder_text : null}
590
727
  aria-invalid={invalid ? `true` : null}
591
728
  ondrop={() => false}
592
729
  onmouseup={open_dropdown}
@@ -609,7 +746,7 @@ function portal(node, params) {
609
746
  disabled,
610
747
  invalid,
611
748
  id,
612
- placeholder,
749
+ placeholder: placeholder_text,
613
750
  open,
614
751
  required,
615
752
  })}
@@ -658,7 +795,8 @@ function portal(node, params) {
658
795
  {/if}
659
796
 
660
797
  <!-- only render options dropdown if options or searchText is not empty (needed to avoid briefly flashing empty dropdown) -->
661
- {#if (searchText && noMatchingOptionsMsg) || options?.length > 0}
798
+ {#if (searchText && noMatchingOptionsMsg) || effective_options.length > 0 ||
799
+ loadOptions}
662
800
  <ul
663
801
  use:portal={{ target_node: outerDiv, ...portal_params }}
664
802
  {@attach highlight_matches({
@@ -680,14 +818,30 @@ function portal(node, params) {
680
818
  aria-disabled={disabled ? `true` : null}
681
819
  bind:this={ul_options}
682
820
  style={ulOptionsStyle}
821
+ onscroll={handle_options_scroll}
822
+ onmousemove={() => (ignore_hover = false)}
683
823
  >
824
+ {#if selectAllOption && effective_options.length > 0 &&
825
+ (maxSelect === null || maxSelect > 1)}
826
+ {@const label = typeof selectAllOption === `string` ? selectAllOption : `Select all`}
827
+ <li
828
+ class="select-all {liSelectAllClass}"
829
+ onclick={select_all}
830
+ onkeydown={if_enter_or_space(select_all)}
831
+ role="option"
832
+ aria-selected="false"
833
+ tabindex="0"
834
+ >
835
+ {label}
836
+ </li>
837
+ {/if}
684
838
  {#each matchingOptions.slice(
685
839
  0,
686
840
  maxOptions == null ? Infinity : Math.max(0, maxOptions),
687
841
  ) as
688
842
  option_item,
689
843
  idx
690
- (duplicates ? [key(option_item), idx] : key(option_item))
844
+ (duplicates ? `${key(option_item)}-${idx}` : key(option_item))
691
845
  }
692
846
  {@const {
693
847
  label,
@@ -695,7 +849,7 @@ function portal(node, params) {
695
849
  title = null,
696
850
  selectedTitle = null,
697
851
  disabledTitle = defaultDisabledTitle,
698
- } = option_item instanceof Object ? option_item : { label: option_item }}
852
+ } = is_object(option_item) ? option_item : { label: option_item }}
699
853
  {@const active = activeIndex === idx}
700
854
  {@const selected = is_selected(label)}
701
855
  {@const optionStyle =
@@ -715,7 +869,7 @@ function portal(node, params) {
715
869
  class:disabled
716
870
  class="{liOptionClass} {active ? liActiveOptionClass : ``}"
717
871
  onmouseover={() => {
718
- if (!disabled) activeIndex = idx
872
+ if (!disabled && !ignore_hover) activeIndex = idx
719
873
  }}
720
874
  onfocus={() => {
721
875
  if (!disabled) activeIndex = idx
@@ -758,18 +912,18 @@ function portal(node, params) {
758
912
  </li>
759
913
  {/each}
760
914
  {#if searchText}
761
- {@const text_input_is_duplicate = selected.map(get_label).includes(searchText)}
915
+ {@const text_input_is_duplicate = selected_labels.includes(searchText)}
762
916
  {@const is_dupe = !duplicates && text_input_is_duplicate && `dupe`}
763
917
  {@const can_create = Boolean(allowUserOptions && createOptionMsg) && `create`}
764
918
  {@const no_match = Boolean(matchingOptions?.length === 0 && noMatchingOptionsMsg) &&
765
919
  `no-match`}
766
920
  {@const msgType = is_dupe || can_create || no_match}
767
- {#if msgType}
768
- {@const msg = {
921
+ {@const msg = msgType && {
769
922
  dupe: duplicateOptionMsg,
770
923
  create: createOptionMsg,
771
924
  'no-match': noMatchingOptionsMsg,
772
925
  }[msgType]}
926
+ {#if msg}
773
927
  <li
774
928
  onclick={(event) => {
775
929
  if (msgType === `create` && allowUserOptions) {
@@ -792,7 +946,7 @@ function portal(node, params) {
792
946
  ? duplicateOptionMsg
793
947
  : ``}
794
948
  class:active={option_msg_is_active}
795
- onmouseover={() => (option_msg_is_active = true)}
949
+ onmouseover={() => !ignore_hover && (option_msg_is_active = true)}
796
950
  onfocus={() => (option_msg_is_active = true)}
797
951
  onmouseout={() => (option_msg_is_active = false)}
798
952
  onblur={() => (option_msg_is_active = false)}
@@ -817,6 +971,11 @@ function portal(node, params) {
817
971
  </li>
818
972
  {/if}
819
973
  {/if}
974
+ {#if loadOptions && load_options_loading}
975
+ <li class="loading-more" role="status" aria-label="Loading more options">
976
+ <CircleSpinner />
977
+ </li>
978
+ {/if}
820
979
  </ul>
821
980
  {/if}
822
981
  </div>
@@ -961,12 +1120,6 @@ function portal(node, params) {
961
1120
  padding: var(--sms-options-padding);
962
1121
  margin: var(--sms-options-margin, inherit);
963
1122
  }
964
- :is(div.multiselect.open) {
965
- /* increase z-index when open to ensure the dropdown of one <MultiSelect />
966
- displays above that of another slightly below it on the page */
967
- /* This z-index is for the div.multiselect itself, portal has its own higher z-index */
968
- z-index: var(--sms-open-z-index, 4);
969
- }
970
1123
  ul.options.hidden {
971
1124
  visibility: hidden;
972
1125
  opacity: 0;
@@ -1006,10 +1159,32 @@ function portal(node, params) {
1006
1159
  margin-right: 6px;
1007
1160
  accent-color: var(--sms-active-color, cornflowerblue);
1008
1161
  }
1162
+ /* Select all option styling */
1163
+ ul.options > li.select-all {
1164
+ border-bottom: var(--sms-select-all-border-bottom, 1px solid lightgray);
1165
+ font-weight: var(--sms-select-all-font-weight, 500);
1166
+ color: var(--sms-select-all-color, inherit);
1167
+ background: var(--sms-select-all-bg, transparent);
1168
+ margin-bottom: var(--sms-select-all-margin-bottom, 2pt);
1169
+ }
1170
+ ul.options > li.select-all:hover {
1171
+ background: var(
1172
+ --sms-select-all-hover-bg,
1173
+ var(--sms-li-active-bg, var(--sms-active-color, rgba(0, 0, 0, 0.15)))
1174
+ );
1175
+ }
1009
1176
  :is(span.max-select-msg) {
1010
1177
  padding: 0 3pt;
1011
1178
  }
1012
1179
  ::highlight(sms-search-matches) {
1013
1180
  color: mediumaquamarine;
1014
1181
  }
1182
+ /* Loading more indicator for infinite scrolling */
1183
+ ul.options > li.loading-more {
1184
+ display: flex;
1185
+ justify-content: center;
1186
+ align-items: center;
1187
+ padding: 8pt;
1188
+ cursor: default;
1189
+ }
1015
1190
  </style>