svelte-multiselect 11.3.0 → 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" generics="Option extends import('./types').Option">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,83 +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(``), value = $bindable(null), selected = $bindable(value !== null && value !== undefined
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
16
17
  ? (Array.isArray(value) ? value : [value])
17
18
  : (options
18
- ?.filter((opt) => opt instanceof Object && opt?.preselected)
19
- .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, portal: portal_params = {}, ...rest } = $props();
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.
20
55
  $effect.pre(() => {
21
- // if maxSelect=1, value is the single item in selected (or null if selected is empty)
22
- // this solves both https://github.com/janosh/svelte-multiselect/issues/86 and
23
- // https://github.com/janosh/svelte-multiselect/issues/136
24
- value = maxSelect === 1 ? (selected[0] ?? null) : selected;
25
- }); // 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
+ });
26
60
  $effect.pre(() => {
27
- if (maxSelect === 1)
28
- selected = value ? [value] : [];
29
- else
30
- selected = value ?? [];
31
- }); // 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
+ });
32
67
  let wiggle = $state(false); // controls wiggle animation when user tries to exceed maxSelect
33
- 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)) {
34
93
  if (allowUserOptions || loading || disabled || allowEmpty) {
35
94
  options = []; // initializing as array avoids errors when component mounts
36
95
  }
37
96
  else {
38
97
  // error on empty options if user is not allowed to create custom options and loading is false
39
98
  // and component is not disabled and allowEmpty is false
40
- console.error(`MultiSelect received no options`);
99
+ console.error(`MultiSelect: received no options`);
41
100
  }
42
101
  }
43
102
  if (maxSelect !== null && maxSelect < 1) {
44
- 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}`);
45
104
  }
46
105
  if (!Array.isArray(selected)) {
47
- 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}`);
48
107
  }
49
108
  if (maxSelect && typeof required === `number` && required > maxSelect) {
50
- 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`);
51
110
  }
52
111
  if (parseLabelsAsHtml && allowUserOptions) {
53
- 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!`);
54
113
  }
55
114
  if (sortSelected && selectedOptionsDraggable) {
56
- 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 ` +
57
116
  `user re-orderings of selected options will be undone by sortSelected on component re-renders.`);
58
117
  }
59
118
  if (allowUserOptions && !createOptionMsg && createOptionMsg !== null) {
60
- console.error(`MultiSelect has allowUserOptions=${allowUserOptions} but createOptionMsg=${createOptionMsg} is falsy. ` +
119
+ console.error(`MultiSelect: allowUserOptions=${allowUserOptions} but createOptionMsg=${createOptionMsg} is falsy. ` +
61
120
  `This prevents the "Add option" <span> from showing up, resulting in a confusing user experience.`);
62
121
  }
63
122
  if (maxOptions &&
64
123
  (typeof maxOptions != `number` || maxOptions < 0 || maxOptions % 1 != 0)) {
65
- 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}`);
66
125
  }
67
126
  let option_msg_is_active = $state(false); // controls active state of <li>{createOptionMsg}</li>
68
127
  let window_width = $state(0);
69
128
  // options matching the current search text
70
129
  $effect.pre(() => {
71
- 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)) &&
72
133
  // remove already selected options from dropdown list unless duplicate selections are allowed
73
134
  // or keepSelectedInDropdown is enabled
74
- (!selected.map(key).includes(key(opt)) || duplicates ||
75
- 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
+ }
76
142
  });
77
- // raise if matchingOptions[activeIndex] does not yield a value
78
- if (activeIndex !== null && !matchingOptions[activeIndex]) {
79
- throw `Run time error, activeIndex=${activeIndex} is out of bounds, matchingOptions.length=${matchingOptions.length}`;
80
- }
81
143
  // update activeOption when activeIndex changes
82
144
  $effect(() => {
83
145
  activeOption = matchingOptions[activeIndex ?? -1] ?? null;
84
146
  });
147
+ // Helper to check if removing an option would violate minSelect constraint
148
+ const can_remove = $derived(minSelect === null || selected.length > minSelect);
85
149
  // toggle an option between selected and unselected states (for keepSelectedInDropdown mode)
86
150
  function toggle_option(option_to_toggle, event) {
87
- const is_currently_selected = selected.map(key).includes(key(option_to_toggle));
151
+ const is_currently_selected = selected_keys.includes(key(option_to_toggle));
88
152
  if (is_currently_selected) {
89
- if (minSelect === null || selected.length > minSelect) { // Only remove if it wouldn't violate minSelect
153
+ if (can_remove)
90
154
  remove(option_to_toggle, event);
91
- }
92
155
  }
93
156
  else
94
157
  add(option_to_toggle, event);
@@ -98,25 +161,25 @@ function add(option_to_add, event) {
98
161
  event.stopPropagation();
99
162
  if (maxSelect !== null && selected.length >= maxSelect)
100
163
  wiggle = true;
101
- 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`) {
102
165
  option_to_add = Number(option_to_add); // convert to number if possible
103
166
  }
104
- const is_duplicate = selected.map(key).includes(key(option_to_add));
167
+ const is_duplicate = selected_keys.includes(key(option_to_add));
105
168
  if ((maxSelect === null || maxSelect === 1 || selected.length < maxSelect) &&
106
169
  (duplicates || !is_duplicate)) {
107
- 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
108
171
  // this has the side-effect of not allowing to user to add the same
109
172
  // custom option twice in append mode
110
173
  [true, `append`].includes(allowUserOptions) &&
111
174
  searchText.length > 0) {
112
175
  // user entered text but no options match, so if allowUserOptions = true | 'append', we create
113
176
  // a new option from the user-entered text
114
- if (typeof options[0] === `object`) {
177
+ if (typeof effective_options[0] === `object`) {
115
178
  // if 1st option is an object, we create new option as object to keep type homogeneity
116
179
  option_to_add = { label: searchText };
117
180
  }
118
181
  else {
119
- if ([`number`, `undefined`].includes(typeof options[0]) &&
182
+ if ([`number`, `undefined`].includes(typeof effective_options[0]) &&
120
183
  !isNaN(Number(searchText))) {
121
184
  // create new option as number if it parses to a number and 1st option is also number or missing
122
185
  option_to_add = Number(searchText);
@@ -127,46 +190,31 @@ function add(option_to_add, event) {
127
190
  }
128
191
  // Fire oncreate event for all user-created options, regardless of type
129
192
  oncreate?.({ option: option_to_add });
130
- if (allowUserOptions === `append`)
131
- 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
+ }
132
201
  }
133
202
  if (resetFilterOnAdd)
134
203
  searchText = ``; // reset search string on selection
135
204
  if ([``, undefined, null].includes(option_to_add)) {
136
- console.error(`MultiSelect: encountered falsy option ${option_to_add}`);
205
+ console.error(`MultiSelect: encountered falsy option`, option_to_add);
137
206
  return;
138
207
  }
139
208
  // for maxSelect = 1 we always replace current option with new one
140
209
  if (maxSelect === 1)
141
210
  selected = [option_to_add];
142
211
  else {
143
- selected = [...selected, option_to_add];
144
- if (sortSelected === true) {
145
- selected = selected.sort((op1, op2) => {
146
- const [label1, label2] = [get_label(op1), get_label(op2)];
147
- // coerce to string if labels are numbers
148
- return `${label1}`.localeCompare(`${label2}`);
149
- });
150
- }
151
- else if (typeof sortSelected === `function`) {
152
- selected = selected.sort(sortSelected);
153
- }
154
- }
155
- const reached_max_select = selected.length >= (maxSelect ?? Infinity);
156
- const dropdown_should_close = closeDropdownOnSelect === true ||
157
- closeDropdownOnSelect === `retain-focus` ||
158
- (closeDropdownOnSelect === `if-mobile` && window_width &&
159
- window_width < breakpoint);
160
- const should_retain_focus = closeDropdownOnSelect === `retain-focus`;
161
- if (reached_max_select || dropdown_should_close) {
162
- close_dropdown(event, should_retain_focus);
212
+ selected = sort_selected([...selected, option_to_add]);
163
213
  }
164
- else if (!dropdown_should_close)
165
- input?.focus();
214
+ clear_validity();
215
+ handle_dropdown_after_select(event);
166
216
  onadd?.({ option: option_to_add });
167
217
  onchange?.({ option: option_to_add, type: `add` });
168
- invalid = false; // reset error status whenever new items are selected
169
- form_input?.setCustomValidity(``);
170
218
  }
171
219
  }
172
220
  // remove an option from selected list
@@ -180,15 +228,15 @@ function remove(option_to_drop, event) {
180
228
  // if option with label could not be found but allowUserOptions is truthy,
181
229
  // assume it was created by user and create corresponding option object
182
230
  // on the fly for use as event payload
183
- const other_ops_type = typeof options[0];
184
- 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);
185
233
  }
186
234
  if (option_removed === undefined) {
187
- 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;
188
237
  }
189
238
  selected = [...selected]; // trigger Svelte rerender
190
- invalid = false; // reset error status whenever items are removed
191
- form_input?.setCustomValidity(``);
239
+ clear_validity();
192
240
  onremove?.({ option: option_removed });
193
241
  onchange?.({ option: option_removed, type: `remove` });
194
242
  }
@@ -210,6 +258,52 @@ function close_dropdown(event, retain_focus = false) {
210
258
  activeIndex = null;
211
259
  onclose?.({ event });
212
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
+ }
213
307
  // handle all keyboard events this component receives
214
308
  async function handle_keydown(event) {
215
309
  // on escape or tab out of input: close options dropdown and reset search text
@@ -217,20 +311,17 @@ async function handle_keydown(event) {
217
311
  event.stopPropagation();
218
312
  close_dropdown(event);
219
313
  searchText = ``;
220
- } // on enter key: toggle active option and reset search text
314
+ } // on enter key: toggle active option
221
315
  else if (event.key === `Enter`) {
222
316
  event.stopPropagation();
223
317
  event.preventDefault(); // prevent enter key from triggering form submission
224
318
  if (activeOption) {
225
- if (selected.includes(activeOption)) {
226
- // Only remove if it wouldn't violate minSelect
227
- if (minSelect === null || selected.length > minSelect) {
319
+ if (selected_keys.includes(key(activeOption))) {
320
+ if (can_remove)
228
321
  remove(activeOption, event);
229
- }
230
322
  }
231
323
  else
232
- add(activeOption, event);
233
- searchText = ``;
324
+ add(activeOption, event); // add() handles resetFilterOnAdd internally when successful
234
325
  }
235
326
  else if (allowUserOptions && searchText.length > 0) {
236
327
  // user entered text but no options match, so if allowUserOptions is truthy, we create new option
@@ -242,59 +333,14 @@ async function handle_keydown(event) {
242
333
  open_dropdown(event);
243
334
  }
244
335
  } // on up/down arrow keys: update active option
245
- else if ([`ArrowDown`, `ArrowUp`].includes(event.key)) {
336
+ else if (event.key === `ArrowDown` || event.key === `ArrowUp`) {
246
337
  event.stopPropagation();
247
- // if no option is active yet, but there are matching options, make first one active
248
- if (activeIndex === null && matchingOptions.length > 0) {
249
- event.preventDefault(); // Prevent scroll only if we handle the key
250
- activeIndex = 0;
251
- return;
252
- }
253
- else if (allowUserOptions && !matchingOptions.length && searchText.length > 0) {
254
- event.preventDefault(); // Prevent scroll only if we handle the key
255
- // if allowUserOptions is truthy and user entered text but no options match, we make
256
- // <li>{addUserMsg}</li> active on keydown (or toggle it if already active)
257
- option_msg_is_active = !option_msg_is_active;
258
- return;
259
- }
260
- else if (activeIndex === null) {
261
- // if no option is active and no options are matching, do nothing
262
- return;
263
- }
264
- event.preventDefault(); // Prevent scroll only if we handle the key
265
- // if none of the above special cases apply, we make next/prev option
266
- // active with wrap around at both ends
267
- const increment = event.key === `ArrowUp` ? -1 : 1;
268
- // Include user message in total count if it exists
269
- const has_user_msg = searchText && ((allowUserOptions && createOptionMsg) ||
270
- (!duplicates && selected.map(get_label).includes(searchText)) ||
271
- (matchingOptions.length === 0 && noMatchingOptionsMsg));
272
- const total_items = matchingOptions.length + (has_user_msg ? 1 : 0);
273
- activeIndex = (activeIndex + increment) % total_items;
274
- // in JS % behaves like remainder operator, not real modulo, so negative numbers stay negative
275
- // need to do manual wrap around at 0
276
- if (activeIndex < 0)
277
- activeIndex = total_items - 1;
278
- // Handle user message activation
279
- if (has_user_msg && activeIndex === matchingOptions.length) {
280
- option_msg_is_active = true;
281
- activeOption = null;
282
- }
283
- else {
284
- option_msg_is_active = false;
285
- activeOption = matchingOptions[activeIndex] ?? null;
286
- }
287
- if (autoScroll) {
288
- await tick();
289
- const li = document.querySelector(`ul.options > li.active`);
290
- if (li)
291
- li.scrollIntoViewIfNeeded?.();
292
- }
338
+ event.preventDefault();
339
+ await handle_arrow_navigation(event.key === `ArrowUp` ? -1 : 1);
293
340
  } // on backspace key: remove last selected option
294
341
  else if (event.key === `Backspace` && selected.length > 0 && !searchText) {
295
342
  event.stopPropagation();
296
- // Only remove option if it wouldn't violate minSelect
297
- if (minSelect === null || selected.length > minSelect) {
343
+ if (can_remove) {
298
344
  const last_option = selected.at(-1);
299
345
  if (last_option)
300
346
  remove(last_option, event);
@@ -314,21 +360,41 @@ function remove_all(event) {
314
360
  // If no minSelect constraint, remove all
315
361
  removed_options = selected;
316
362
  selected = [];
317
- searchText = ``;
363
+ searchText = ``; // always clear on remove all (resetFilterOnAdd only applies to add operations)
318
364
  }
319
365
  else if (selected.length > minSelect) {
320
366
  // Keep the first minSelect items
321
367
  removed_options = selected.slice(minSelect);
322
368
  selected = selected.slice(0, minSelect);
323
- 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` });
324
393
  }
325
- onremoveAll?.({ options: removed_options });
326
- onchange?.({ options: selected, type: `removeAll` });
327
- // If selected.length <= minSelect, do nothing (can't remove any more)
328
394
  }
329
- let is_selected = $derived((label) => selected.map(get_label).includes(label));
395
+ let is_selected = $derived((label) => selected_labels.includes(label));
330
396
  const if_enter_or_space = (handler) => (event) => {
331
- if ([`Enter`, `Space`].includes(event.code)) {
397
+ if (event.key === `Enter` || event.code === `Space`) {
332
398
  event.preventDefault();
333
399
  handler(event);
334
400
  }
@@ -466,6 +532,74 @@ function portal(node, params) {
466
532
  };
467
533
  }
468
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
+ }
469
603
  </script>
470
604
 
471
605
  <svelte:window
@@ -525,7 +659,7 @@ function portal(node, params) {
525
659
  aria-label="selected options"
526
660
  style={ulSelectedStyle}
527
661
  >
528
- {#each selected as option, idx (duplicates ? [key(option), idx] : key(option))}
662
+ {#each selected as option, idx (duplicates ? `${key(option)}-${idx}` : key(option))}
529
663
  {@const selectedOptionStyle =
530
664
  [get_style(option, `selected`), liSelectedStyle].filter(Boolean).join(
531
665
  ` `,
@@ -535,7 +669,7 @@ function portal(node, params) {
535
669
  class={liSelectedClass}
536
670
  role="option"
537
671
  aria-selected="true"
538
- animate:flip={{ duration: 100 }}
672
+ animate:flip={selectedFlipParams}
539
673
  draggable={selectedOptionsDraggable && !disabled && selected.length > 1}
540
674
  ondragstart={dragstart(idx)}
541
675
  ondragover={(event) => {
@@ -545,6 +679,7 @@ function portal(node, params) {
545
679
  ondragenter={() => (drag_idx = idx)}
546
680
  class:active={drag_idx === idx}
547
681
  style={selectedOptionStyle}
682
+ onmouseup={(event) => event.stopPropagation()}
548
683
  >
549
684
  {#if selectedItem}
550
685
  {@render selectedItem({
@@ -561,7 +696,7 @@ function portal(node, params) {
561
696
  {:else}
562
697
  {get_label(option)}
563
698
  {/if}
564
- {#if !disabled && (minSelect === null || selected.length > minSelect)}
699
+ {#if !disabled && can_remove}
565
700
  <button
566
701
  onclick={(event) => remove(option, event)}
567
702
  onkeydown={if_enter_or_space((event) => remove(option, event))}
@@ -588,7 +723,7 @@ function portal(node, params) {
588
723
  {autocomplete}
589
724
  {inputmode}
590
725
  {pattern}
591
- placeholder={selected.length === 0 ? placeholder : null}
726
+ placeholder={selected.length === 0 || placeholder_persistent ? placeholder_text : null}
592
727
  aria-invalid={invalid ? `true` : null}
593
728
  ondrop={() => false}
594
729
  onmouseup={open_dropdown}
@@ -611,7 +746,7 @@ function portal(node, params) {
611
746
  disabled,
612
747
  invalid,
613
748
  id,
614
- placeholder,
749
+ placeholder: placeholder_text,
615
750
  open,
616
751
  required,
617
752
  })}
@@ -660,7 +795,8 @@ function portal(node, params) {
660
795
  {/if}
661
796
 
662
797
  <!-- only render options dropdown if options or searchText is not empty (needed to avoid briefly flashing empty dropdown) -->
663
- {#if (searchText && noMatchingOptionsMsg) || options?.length > 0}
798
+ {#if (searchText && noMatchingOptionsMsg) || effective_options.length > 0 ||
799
+ loadOptions}
664
800
  <ul
665
801
  use:portal={{ target_node: outerDiv, ...portal_params }}
666
802
  {@attach highlight_matches({
@@ -682,14 +818,30 @@ function portal(node, params) {
682
818
  aria-disabled={disabled ? `true` : null}
683
819
  bind:this={ul_options}
684
820
  style={ulOptionsStyle}
821
+ onscroll={handle_options_scroll}
822
+ onmousemove={() => (ignore_hover = false)}
685
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}
686
838
  {#each matchingOptions.slice(
687
839
  0,
688
840
  maxOptions == null ? Infinity : Math.max(0, maxOptions),
689
841
  ) as
690
842
  option_item,
691
843
  idx
692
- (duplicates ? [key(option_item), idx] : key(option_item))
844
+ (duplicates ? `${key(option_item)}-${idx}` : key(option_item))
693
845
  }
694
846
  {@const {
695
847
  label,
@@ -697,7 +849,7 @@ function portal(node, params) {
697
849
  title = null,
698
850
  selectedTitle = null,
699
851
  disabledTitle = defaultDisabledTitle,
700
- } = option_item instanceof Object ? option_item : { label: option_item }}
852
+ } = is_object(option_item) ? option_item : { label: option_item }}
701
853
  {@const active = activeIndex === idx}
702
854
  {@const selected = is_selected(label)}
703
855
  {@const optionStyle =
@@ -717,7 +869,7 @@ function portal(node, params) {
717
869
  class:disabled
718
870
  class="{liOptionClass} {active ? liActiveOptionClass : ``}"
719
871
  onmouseover={() => {
720
- if (!disabled) activeIndex = idx
872
+ if (!disabled && !ignore_hover) activeIndex = idx
721
873
  }}
722
874
  onfocus={() => {
723
875
  if (!disabled) activeIndex = idx
@@ -760,18 +912,18 @@ function portal(node, params) {
760
912
  </li>
761
913
  {/each}
762
914
  {#if searchText}
763
- {@const text_input_is_duplicate = selected.map(get_label).includes(searchText)}
915
+ {@const text_input_is_duplicate = selected_labels.includes(searchText)}
764
916
  {@const is_dupe = !duplicates && text_input_is_duplicate && `dupe`}
765
917
  {@const can_create = Boolean(allowUserOptions && createOptionMsg) && `create`}
766
918
  {@const no_match = Boolean(matchingOptions?.length === 0 && noMatchingOptionsMsg) &&
767
919
  `no-match`}
768
920
  {@const msgType = is_dupe || can_create || no_match}
769
- {#if msgType}
770
- {@const msg = {
921
+ {@const msg = msgType && {
771
922
  dupe: duplicateOptionMsg,
772
923
  create: createOptionMsg,
773
924
  'no-match': noMatchingOptionsMsg,
774
925
  }[msgType]}
926
+ {#if msg}
775
927
  <li
776
928
  onclick={(event) => {
777
929
  if (msgType === `create` && allowUserOptions) {
@@ -794,7 +946,7 @@ function portal(node, params) {
794
946
  ? duplicateOptionMsg
795
947
  : ``}
796
948
  class:active={option_msg_is_active}
797
- onmouseover={() => (option_msg_is_active = true)}
949
+ onmouseover={() => !ignore_hover && (option_msg_is_active = true)}
798
950
  onfocus={() => (option_msg_is_active = true)}
799
951
  onmouseout={() => (option_msg_is_active = false)}
800
952
  onblur={() => (option_msg_is_active = false)}
@@ -819,6 +971,11 @@ function portal(node, params) {
819
971
  </li>
820
972
  {/if}
821
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}
822
979
  </ul>
823
980
  {/if}
824
981
  </div>
@@ -963,12 +1120,6 @@ function portal(node, params) {
963
1120
  padding: var(--sms-options-padding);
964
1121
  margin: var(--sms-options-margin, inherit);
965
1122
  }
966
- :is(div.multiselect.open) {
967
- /* increase z-index when open to ensure the dropdown of one <MultiSelect />
968
- displays above that of another slightly below it on the page */
969
- /* This z-index is for the div.multiselect itself, portal has its own higher z-index */
970
- z-index: var(--sms-open-z-index, 4);
971
- }
972
1123
  ul.options.hidden {
973
1124
  visibility: hidden;
974
1125
  opacity: 0;
@@ -1008,10 +1159,32 @@ function portal(node, params) {
1008
1159
  margin-right: 6px;
1009
1160
  accent-color: var(--sms-active-color, cornflowerblue);
1010
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
+ }
1011
1176
  :is(span.max-select-msg) {
1012
1177
  padding: 0 3pt;
1013
1178
  }
1014
1179
  ::highlight(sms-search-matches) {
1015
1180
  color: mediumaquamarine;
1016
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
+ }
1017
1190
  </style>