svelte-multiselect 11.4.0 → 11.5.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,1190 +0,0 @@
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';
3
- import { flip } from 'svelte/animate';
4
- import { highlight_matches } from './attachments';
5
- import CircleSpinner from './CircleSpinner.svelte';
6
- import Icon from './Icon.svelte';
7
- import { fuzzy_match, get_label, get_style, is_object } from './utils';
8
- import Wiggle from './Wiggle.svelte';
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) => {
10
- if (!searchText)
11
- return true;
12
- const label = `${get_label(opt)}`;
13
- return fuzzy
14
- ? fuzzy_match(searchText, label)
15
- : label.toLowerCase().includes(searchText.toLowerCase());
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.
55
- $effect.pre(() => {
56
- const new_value = maxSelect === 1 ? (selected[0] ?? null) : selected;
57
- if (!values_equal(untrack(() => value), new_value))
58
- value = new_value;
59
- });
60
- $effect.pre(() => {
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
- });
67
- let wiggle = $state(false); // controls wiggle animation when user tries to exceed maxSelect
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)) {
93
- if (allowUserOptions || loading || disabled || allowEmpty) {
94
- options = []; // initializing as array avoids errors when component mounts
95
- }
96
- else {
97
- // error on empty options if user is not allowed to create custom options and loading is false
98
- // and component is not disabled and allowEmpty is false
99
- console.error(`MultiSelect: received no options`);
100
- }
101
- }
102
- if (maxSelect !== null && maxSelect < 1) {
103
- console.error(`MultiSelect: maxSelect must be null or positive integer, got ${maxSelect}`);
104
- }
105
- if (!Array.isArray(selected)) {
106
- console.error(`MultiSelect: selected prop should always be an array, got ${selected}`);
107
- }
108
- if (maxSelect && typeof required === `number` && required > maxSelect) {
109
- console.error(`MultiSelect: maxSelect=${maxSelect} < required=${required}, makes it impossible for users to submit a valid form`);
110
- }
111
- if (parseLabelsAsHtml && allowUserOptions) {
112
- console.warn(`MultiSelect: don't combine parseLabelsAsHtml and allowUserOptions. It's susceptible to XSS attacks!`);
113
- }
114
- if (sortSelected && selectedOptionsDraggable) {
115
- console.warn(`MultiSelect: sortSelected and selectedOptionsDraggable should not be combined as any ` +
116
- `user re-orderings of selected options will be undone by sortSelected on component re-renders.`);
117
- }
118
- if (allowUserOptions && !createOptionMsg && createOptionMsg !== null) {
119
- console.error(`MultiSelect: allowUserOptions=${allowUserOptions} but createOptionMsg=${createOptionMsg} is falsy. ` +
120
- `This prevents the "Add option" <span> from showing up, resulting in a confusing user experience.`);
121
- }
122
- if (maxOptions &&
123
- (typeof maxOptions != `number` || maxOptions < 0 || maxOptions % 1 != 0)) {
124
- console.error(`MultiSelect: maxOptions must be undefined or a positive integer, got ${maxOptions}`);
125
- }
126
- let option_msg_is_active = $state(false); // controls active state of <li>{createOptionMsg}</li>
127
- let window_width = $state(0);
128
- // options matching the current search text
129
- $effect.pre(() => {
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)) &&
133
- // remove already selected options from dropdown list unless duplicate selections are allowed
134
- // or keepSelectedInDropdown is enabled
135
- (!selected_keys.includes(key(opt)) || duplicates || keepSelectedInDropdown));
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
- }
142
- });
143
- // update activeOption when activeIndex changes
144
- $effect(() => {
145
- activeOption = matchingOptions[activeIndex ?? -1] ?? null;
146
- });
147
- // Helper to check if removing an option would violate minSelect constraint
148
- const can_remove = $derived(minSelect === null || selected.length > minSelect);
149
- // toggle an option between selected and unselected states (for keepSelectedInDropdown mode)
150
- function toggle_option(option_to_toggle, event) {
151
- const is_currently_selected = selected_keys.includes(key(option_to_toggle));
152
- if (is_currently_selected) {
153
- if (can_remove)
154
- remove(option_to_toggle, event);
155
- }
156
- else
157
- add(option_to_toggle, event);
158
- }
159
- // add an option to selected list
160
- function add(option_to_add, event) {
161
- event.stopPropagation();
162
- if (maxSelect !== null && selected.length >= maxSelect)
163
- wiggle = true;
164
- if (!isNaN(Number(option_to_add)) && typeof selected_labels[0] === `number`) {
165
- option_to_add = Number(option_to_add); // convert to number if possible
166
- }
167
- const is_duplicate = selected_keys.includes(key(option_to_add));
168
- if ((maxSelect === null || maxSelect === 1 || selected.length < maxSelect) &&
169
- (duplicates || !is_duplicate)) {
170
- if (!effective_options.includes(option_to_add) && // first check if we find option in the options list
171
- // this has the side-effect of not allowing to user to add the same
172
- // custom option twice in append mode
173
- [true, `append`].includes(allowUserOptions) &&
174
- searchText.length > 0) {
175
- // user entered text but no options match, so if allowUserOptions = true | 'append', we create
176
- // a new option from the user-entered text
177
- if (typeof effective_options[0] === `object`) {
178
- // if 1st option is an object, we create new option as object to keep type homogeneity
179
- option_to_add = { label: searchText };
180
- }
181
- else {
182
- if ([`number`, `undefined`].includes(typeof effective_options[0]) &&
183
- !isNaN(Number(searchText))) {
184
- // create new option as number if it parses to a number and 1st option is also number or missing
185
- option_to_add = Number(searchText);
186
- }
187
- else {
188
- option_to_add = searchText; // else create custom option as string
189
- }
190
- }
191
- // Fire oncreate event for all user-created options, regardless of type
192
- oncreate?.({ option: 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
- }
201
- }
202
- if (resetFilterOnAdd)
203
- searchText = ``; // reset search string on selection
204
- if ([``, undefined, null].includes(option_to_add)) {
205
- console.error(`MultiSelect: encountered falsy option`, option_to_add);
206
- return;
207
- }
208
- // for maxSelect = 1 we always replace current option with new one
209
- if (maxSelect === 1)
210
- selected = [option_to_add];
211
- else {
212
- selected = sort_selected([...selected, option_to_add]);
213
- }
214
- clear_validity();
215
- handle_dropdown_after_select(event);
216
- onadd?.({ option: option_to_add });
217
- onchange?.({ option: option_to_add, type: `add` });
218
- }
219
- }
220
- // remove an option from selected list
221
- function remove(option_to_drop, event) {
222
- event.stopPropagation();
223
- if (selected.length === 0)
224
- return;
225
- const idx = selected.findIndex((opt) => key(opt) === key(option_to_drop));
226
- let [option_removed] = selected.splice(idx, 1); // remove option from selected list
227
- if (option_removed === undefined && allowUserOptions) {
228
- // if option with label could not be found but allowUserOptions is truthy,
229
- // assume it was created by user and create corresponding option object
230
- // on the fly for use as event payload
231
- const is_object_option = typeof effective_options[0] === `object`;
232
- option_removed = (is_object_option ? { label: option_to_drop } : option_to_drop);
233
- }
234
- if (option_removed === undefined) {
235
- console.error(`MultiSelect: can't remove option ${JSON.stringify(option_to_drop)}, not found in selected list`);
236
- return;
237
- }
238
- selected = [...selected]; // trigger Svelte rerender
239
- clear_validity();
240
- onremove?.({ option: option_removed });
241
- onchange?.({ option: option_removed, type: `remove` });
242
- }
243
- function open_dropdown(event) {
244
- event.stopPropagation();
245
- if (disabled)
246
- return;
247
- open = true;
248
- if (!(event instanceof FocusEvent)) {
249
- // avoid double-focussing input when event that opened dropdown was already input FocusEvent
250
- input?.focus();
251
- }
252
- onopen?.({ event });
253
- }
254
- function close_dropdown(event, retain_focus = false) {
255
- open = false;
256
- if (!retain_focus)
257
- input?.blur();
258
- activeIndex = null;
259
- onclose?.({ event });
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
- }
307
- // handle all keyboard events this component receives
308
- async function handle_keydown(event) {
309
- // on escape or tab out of input: close options dropdown and reset search text
310
- if (event.key === `Escape` || event.key === `Tab`) {
311
- event.stopPropagation();
312
- close_dropdown(event);
313
- searchText = ``;
314
- } // on enter key: toggle active option
315
- else if (event.key === `Enter`) {
316
- event.stopPropagation();
317
- event.preventDefault(); // prevent enter key from triggering form submission
318
- if (activeOption) {
319
- if (selected_keys.includes(key(activeOption))) {
320
- if (can_remove)
321
- remove(activeOption, event);
322
- }
323
- else
324
- add(activeOption, event); // add() handles resetFilterOnAdd internally when successful
325
- }
326
- else if (allowUserOptions && searchText.length > 0) {
327
- // user entered text but no options match, so if allowUserOptions is truthy, we create new option
328
- add(searchText, event);
329
- }
330
- else {
331
- // no active option and no search text means the options dropdown is closed
332
- // in which case enter means open it
333
- open_dropdown(event);
334
- }
335
- } // on up/down arrow keys: update active option
336
- else if (event.key === `ArrowDown` || event.key === `ArrowUp`) {
337
- event.stopPropagation();
338
- event.preventDefault();
339
- await handle_arrow_navigation(event.key === `ArrowUp` ? -1 : 1);
340
- } // on backspace key: remove last selected option
341
- else if (event.key === `Backspace` && selected.length > 0 && !searchText) {
342
- event.stopPropagation();
343
- if (can_remove) {
344
- const last_option = selected.at(-1);
345
- if (last_option)
346
- remove(last_option, event);
347
- }
348
- // Don't prevent default, allow normal backspace behavior if not removing
349
- } // make first matching option active on any keypress (if none of the above special cases match)
350
- else if (matchingOptions.length > 0 && activeIndex === null) {
351
- // Don't stop propagation or prevent default here, allow normal character input
352
- activeIndex = 0;
353
- }
354
- }
355
- function remove_all(event) {
356
- event.stopPropagation();
357
- // Keep the first minSelect items, remove the rest
358
- let removed_options = [];
359
- if (minSelect === null) {
360
- // If no minSelect constraint, remove all
361
- removed_options = selected;
362
- selected = [];
363
- searchText = ``; // always clear on remove all (resetFilterOnAdd only applies to add operations)
364
- }
365
- else if (selected.length > minSelect) {
366
- // Keep the first minSelect items
367
- removed_options = selected.slice(minSelect);
368
- selected = selected.slice(0, minSelect);
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` });
393
- }
394
- }
395
- let is_selected = $derived((label) => selected_labels.includes(label));
396
- const if_enter_or_space = (handler) => (event) => {
397
- if (event.key === `Enter` || event.code === `Space`) {
398
- event.preventDefault();
399
- handler(event);
400
- }
401
- };
402
- function on_click_outside(event) {
403
- if (!outerDiv)
404
- return;
405
- const target = event.target;
406
- // Check if click is inside the main component
407
- if (outerDiv.contains(target))
408
- return;
409
- // If portal is active, also check if click is inside the portalled options dropdown
410
- if (portal_params?.active && ul_options && ul_options.contains(target))
411
- return;
412
- // Click is outside both the main component and any portalled dropdown
413
- close_dropdown(event);
414
- }
415
- let drag_idx = $state(null);
416
- // event handlers enable dragging to reorder selected options
417
- const drop = (target_idx) => (event) => {
418
- if (!event.dataTransfer)
419
- return;
420
- event.dataTransfer.dropEffect = `move`;
421
- const start_idx = parseInt(event.dataTransfer.getData(`text/plain`));
422
- const new_selected = [...selected];
423
- if (start_idx < target_idx) {
424
- new_selected.splice(target_idx + 1, 0, new_selected[start_idx]);
425
- new_selected.splice(start_idx, 1);
426
- }
427
- else {
428
- new_selected.splice(target_idx, 0, new_selected[start_idx]);
429
- new_selected.splice(start_idx + 1, 1);
430
- }
431
- selected = new_selected;
432
- drag_idx = null;
433
- };
434
- const dragstart = (idx) => (event) => {
435
- if (!event.dataTransfer)
436
- return;
437
- // only allow moving, not copying (also affects the cursor during drag)
438
- event.dataTransfer.effectAllowed = `move`;
439
- event.dataTransfer.dropEffect = `move`;
440
- event.dataTransfer.setData(`text/plain`, `${idx}`);
441
- };
442
- let ul_options = $state();
443
- const handle_input_keydown = (event) => {
444
- handle_keydown(event); // Restore internal logic
445
- // Call original forwarded handler
446
- onkeydown?.(event);
447
- };
448
- const handle_input_focus = (event) => {
449
- open_dropdown(event);
450
- onfocus?.(event);
451
- };
452
- // Override input's focus method to ensure dropdown opens on programmatic focus
453
- // https://github.com/janosh/svelte-multiselect/issues/289
454
- $effect(() => {
455
- if (!input)
456
- return;
457
- const orig_focus = input.focus.bind(input);
458
- input.focus = (options) => {
459
- orig_focus(options);
460
- if (!disabled && !open) {
461
- open_dropdown(new FocusEvent(`focus`, { bubbles: true }));
462
- }
463
- };
464
- return () => {
465
- if (input)
466
- input.focus = orig_focus;
467
- };
468
- });
469
- const handle_input_blur = (event) => {
470
- // For portalled dropdowns, don't close on blur since clicks on portalled elements
471
- // will cause blur but we want to allow the click to register first
472
- // (otherwise mobile touch event is unable to select options https://github.com/janosh/svelte-multiselect/issues/335)
473
- if (portal_params?.active) {
474
- onblur?.(event); // Let the click handler manage closing for portalled dropdowns
475
- return;
476
- }
477
- // For non-portalled dropdowns, close when focus moves outside the component
478
- if (!outerDiv?.contains(event.relatedTarget))
479
- close_dropdown(event);
480
- onblur?.(event); // Call original handler (if any passed as component prop)
481
- };
482
- // reset form validation when required prop changes
483
- // https://github.com/janosh/svelte-multiselect/issues/285
484
- $effect.pre(() => {
485
- required = required; // trigger effect when required changes
486
- form_input?.setCustomValidity(``);
487
- });
488
- function portal(node, params) {
489
- let { target_node, active } = params;
490
- if (!active)
491
- return;
492
- let render_in_place = typeof window === `undefined` ||
493
- !document.body.contains(node);
494
- if (!render_in_place) {
495
- document.body.appendChild(node);
496
- node.style.position = `fixed`;
497
- const update_position = () => {
498
- if (!target_node || !open)
499
- return (node.hidden = true);
500
- const rect = target_node.getBoundingClientRect();
501
- node.style.left = `${rect.left}px`;
502
- node.style.top = `${rect.bottom}px`;
503
- node.style.width = `${rect.width}px`;
504
- node.hidden = false;
505
- };
506
- if (open)
507
- tick().then(update_position);
508
- window.addEventListener(`scroll`, update_position, true);
509
- window.addEventListener(`resize`, update_position);
510
- $effect(() => {
511
- if (open && target_node)
512
- update_position();
513
- else
514
- node.hidden = true;
515
- });
516
- return {
517
- update(params) {
518
- target_node = params.target_node;
519
- render_in_place = typeof window === `undefined` ||
520
- !document.body.contains(node);
521
- if (open && !render_in_place && target_node)
522
- tick().then(update_position);
523
- else if (!open || !target_node)
524
- node.hidden = true;
525
- },
526
- destroy() {
527
- if (!render_in_place)
528
- node.remove();
529
- window.removeEventListener(`scroll`, update_position, true);
530
- window.removeEventListener(`resize`, update_position);
531
- },
532
- };
533
- }
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
- }
603
- </script>
604
-
605
- <svelte:window
606
- onclick={on_click_outside}
607
- ontouchstart={on_click_outside}
608
- bind:innerWidth={window_width}
609
- />
610
-
611
- <div
612
- bind:this={outerDiv}
613
- class:disabled
614
- class:single={maxSelect === 1}
615
- class:open
616
- class:invalid
617
- class="multiselect {outerDivClass} {rest.class ?? ``}"
618
- onmouseup={open_dropdown}
619
- title={disabled ? disabledInputTitle : null}
620
- data-id={id}
621
- role="searchbox"
622
- tabindex="-1"
623
- {style}
624
- >
625
- <!-- form control input invisible to the user, only purpose is to abort form submission if this component fails data validation -->
626
- <!-- bind:value={selected} prevents form submission if required prop is true and no options are selected -->
627
- <input
628
- {name}
629
- required={Boolean(required)}
630
- value={selected.length >= Number(required) ? JSON.stringify(selected) : null}
631
- tabindex="-1"
632
- aria-hidden="true"
633
- aria-label="ignore this, used only to prevent form submission if select is required but empty"
634
- class="form-control"
635
- bind:this={form_input}
636
- oninvalid={() => {
637
- invalid = true
638
- let msg
639
- if (maxSelect && maxSelect > 1 && Number(required) > 1) {
640
- msg = `Please select between ${required} and ${maxSelect} options`
641
- } else if (Number(required) > 1) {
642
- msg = `Please select at least ${required} options`
643
- } else {
644
- msg = `Please select an option`
645
- }
646
- form_input?.setCustomValidity(msg)
647
- }}
648
- />
649
- {#if expandIcon}
650
- {@render expandIcon({ open })}
651
- {:else}
652
- <Icon
653
- icon="ChevronExpand"
654
- style="width: 15px; min-width: 1em; padding: 0 1pt; cursor: pointer"
655
- />
656
- {/if}
657
- <ul
658
- class="selected {ulSelectedClass}"
659
- aria-label="selected options"
660
- style={ulSelectedStyle}
661
- >
662
- {#each selected as option, idx (duplicates ? `${key(option)}-${idx}` : key(option))}
663
- {@const selectedOptionStyle =
664
- [get_style(option, `selected`), liSelectedStyle].filter(Boolean).join(
665
- ` `,
666
- ) ||
667
- null}
668
- <li
669
- class={liSelectedClass}
670
- role="option"
671
- aria-selected="true"
672
- animate:flip={selectedFlipParams}
673
- draggable={selectedOptionsDraggable && !disabled && selected.length > 1}
674
- ondragstart={dragstart(idx)}
675
- ondragover={(event) => {
676
- event.preventDefault() // needed for ondrop to fire
677
- }}
678
- ondrop={drop(idx)}
679
- ondragenter={() => (drag_idx = idx)}
680
- class:active={drag_idx === idx}
681
- style={selectedOptionStyle}
682
- onmouseup={(event) => event.stopPropagation()}
683
- >
684
- {#if selectedItem}
685
- {@render selectedItem({
686
- option,
687
- idx,
688
- })}
689
- {:else if children}
690
- {@render children({
691
- option,
692
- idx,
693
- })}
694
- {:else if parseLabelsAsHtml}
695
- {@html get_label(option)}
696
- {:else}
697
- {get_label(option)}
698
- {/if}
699
- {#if !disabled && can_remove}
700
- <button
701
- onclick={(event) => remove(option, event)}
702
- onkeydown={if_enter_or_space((event) => remove(option, event))}
703
- type="button"
704
- title="{removeBtnTitle} {get_label(option)}"
705
- class="remove"
706
- >
707
- {#if removeIcon}
708
- {@render removeIcon()}
709
- {:else}
710
- <Icon icon="Cross" style="width: 15px" />
711
- {/if}
712
- </button>
713
- {/if}
714
- </li>
715
- {/each}
716
- <input
717
- class={inputClass}
718
- style={inputStyle}
719
- bind:this={input}
720
- bind:value={searchText}
721
- {id}
722
- {disabled}
723
- {autocomplete}
724
- {inputmode}
725
- {pattern}
726
- placeholder={selected.length === 0 || placeholder_persistent ? placeholder_text : null}
727
- aria-invalid={invalid ? `true` : null}
728
- ondrop={() => false}
729
- onmouseup={open_dropdown}
730
- onkeydown={handle_input_keydown}
731
- onfocus={handle_input_focus}
732
- onblur={handle_input_blur}
733
- {onclick}
734
- {onkeyup}
735
- {onmousedown}
736
- {onmouseenter}
737
- {onmouseleave}
738
- {ontouchcancel}
739
- {ontouchend}
740
- {ontouchmove}
741
- {ontouchstart}
742
- {...rest}
743
- />
744
- {@render afterInput?.({
745
- selected,
746
- disabled,
747
- invalid,
748
- id,
749
- placeholder: placeholder_text,
750
- open,
751
- required,
752
- })}
753
- </ul>
754
- {#if loading}
755
- {#if spinner}
756
- {@render spinner()}
757
- {:else}
758
- <CircleSpinner />
759
- {/if}
760
- {/if}
761
- {#if disabled}
762
- {#if disabledIcon}
763
- {@render disabledIcon()}
764
- {:else}
765
- <Icon
766
- icon="Disabled"
767
- style="width: 14pt; margin: 0 2pt"
768
- data-name="disabled-icon"
769
- aria-disabled="true"
770
- />
771
- {/if}
772
- {:else if selected.length > 0}
773
- {#if maxSelect && (maxSelect > 1 || maxSelectMsg)}
774
- <Wiggle bind:wiggle angle={20}>
775
- <span class="max-select-msg {maxSelectMsgClass}">
776
- {maxSelectMsg?.(selected.length, maxSelect)}
777
- </span>
778
- </Wiggle>
779
- {/if}
780
- {#if maxSelect !== 1 && selected.length > 1}
781
- <button
782
- type="button"
783
- class="remove remove-all"
784
- title={removeAllTitle}
785
- onclick={remove_all}
786
- onkeydown={if_enter_or_space(remove_all)}
787
- >
788
- {#if removeIcon}
789
- {@render removeIcon()}
790
- {:else}
791
- <Icon icon="Cross" style="width: 15px" />
792
- {/if}
793
- </button>
794
- {/if}
795
- {/if}
796
-
797
- <!-- only render options dropdown if options or searchText is not empty (needed to avoid briefly flashing empty dropdown) -->
798
- {#if (searchText && noMatchingOptionsMsg) || effective_options.length > 0 ||
799
- loadOptions}
800
- <ul
801
- use:portal={{ target_node: outerDiv, ...portal_params }}
802
- {@attach highlight_matches({
803
- query: searchText,
804
- disabled: !highlightMatches,
805
- fuzzy,
806
- css_class: `sms-search-matches`,
807
- // don't highlight text in the "Create this option..." message
808
- node_filter: (node) =>
809
- node?.parentElement?.closest(`li.user-msg`)
810
- ? NodeFilter.FILTER_REJECT
811
- : NodeFilter.FILTER_ACCEPT,
812
- })}
813
- class:hidden={!open}
814
- class="options {ulOptionsClass}"
815
- role="listbox"
816
- aria-multiselectable={maxSelect === null || maxSelect > 1}
817
- aria-expanded={open}
818
- aria-disabled={disabled ? `true` : null}
819
- bind:this={ul_options}
820
- style={ulOptionsStyle}
821
- onscroll={handle_options_scroll}
822
- onmousemove={() => (ignore_hover = false)}
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}
838
- {#each matchingOptions.slice(
839
- 0,
840
- maxOptions == null ? Infinity : Math.max(0, maxOptions),
841
- ) as
842
- option_item,
843
- idx
844
- (duplicates ? `${key(option_item)}-${idx}` : key(option_item))
845
- }
846
- {@const {
847
- label,
848
- disabled = null,
849
- title = null,
850
- selectedTitle = null,
851
- disabledTitle = defaultDisabledTitle,
852
- } = is_object(option_item) ? option_item : { label: option_item }}
853
- {@const active = activeIndex === idx}
854
- {@const selected = is_selected(label)}
855
- {@const optionStyle =
856
- [get_style(option_item, `option`), liOptionStyle].filter(Boolean).join(
857
- ` `,
858
- ) ||
859
- null}
860
- <li
861
- onclick={(event) => {
862
- if (disabled) return
863
- if (keepSelectedInDropdown) toggle_option(option_item, event)
864
- else add(option_item, event)
865
- }}
866
- title={disabled ? disabledTitle : (selected && selectedTitle) || title}
867
- class:selected
868
- class:active
869
- class:disabled
870
- class="{liOptionClass} {active ? liActiveOptionClass : ``}"
871
- onmouseover={() => {
872
- if (!disabled && !ignore_hover) activeIndex = idx
873
- }}
874
- onfocus={() => {
875
- if (!disabled) activeIndex = idx
876
- }}
877
- role="option"
878
- aria-selected={selected ? `true` : `false`}
879
- style={optionStyle}
880
- onkeydown={(event) => {
881
- if (!disabled && (event.key === `Enter` || event.code === `Space`)) {
882
- event.preventDefault()
883
- if (keepSelectedInDropdown) toggle_option(option_item, event)
884
- else add(option_item, event)
885
- }
886
- }}
887
- >
888
- {#if keepSelectedInDropdown === `checkboxes`}
889
- <input
890
- type="checkbox"
891
- class="option-checkbox"
892
- checked={selected}
893
- aria-label="Toggle {get_label(option_item)}"
894
- tabindex="-1"
895
- />
896
- {/if}
897
- {#if option}
898
- {@render option({
899
- option: option_item,
900
- idx,
901
- })}
902
- {:else if children}
903
- {@render children({
904
- option: option_item,
905
- idx,
906
- })}
907
- {:else if parseLabelsAsHtml}
908
- {@html get_label(option_item)}
909
- {:else}
910
- {get_label(option_item)}
911
- {/if}
912
- </li>
913
- {/each}
914
- {#if searchText}
915
- {@const text_input_is_duplicate = selected_labels.includes(searchText)}
916
- {@const is_dupe = !duplicates && text_input_is_duplicate && `dupe`}
917
- {@const can_create = Boolean(allowUserOptions && createOptionMsg) && `create`}
918
- {@const no_match = Boolean(matchingOptions?.length === 0 && noMatchingOptionsMsg) &&
919
- `no-match`}
920
- {@const msgType = is_dupe || can_create || no_match}
921
- {@const msg = msgType && {
922
- dupe: duplicateOptionMsg,
923
- create: createOptionMsg,
924
- 'no-match': noMatchingOptionsMsg,
925
- }[msgType]}
926
- {#if msg}
927
- <li
928
- onclick={(event) => {
929
- if (msgType === `create` && allowUserOptions) {
930
- add(searchText as Option, event)
931
- }
932
- }}
933
- onkeydown={(event) => {
934
- if (
935
- msgType === `create` &&
936
- allowUserOptions &&
937
- (event.key === `Enter` || event.code === `Space`)
938
- ) {
939
- event.preventDefault()
940
- add(searchText as Option, event)
941
- }
942
- }}
943
- title={msgType === `create`
944
- ? createOptionMsg
945
- : msgType === `dupe`
946
- ? duplicateOptionMsg
947
- : ``}
948
- class:active={option_msg_is_active}
949
- onmouseover={() => !ignore_hover && (option_msg_is_active = true)}
950
- onfocus={() => (option_msg_is_active = true)}
951
- onmouseout={() => (option_msg_is_active = false)}
952
- onblur={() => (option_msg_is_active = false)}
953
- role="option"
954
- aria-selected="false"
955
- class="
956
- user-msg {liUserMsgClass} {option_msg_is_active
957
- ? liActiveUserMsgClass
958
- : ``}
959
- "
960
- style:cursor={{
961
- dupe: `not-allowed`,
962
- create: `pointer`,
963
- 'no-match': `default`,
964
- }[msgType]}
965
- >
966
- {#if userMsg}
967
- {@render userMsg({ searchText, msgType, msg })}
968
- {:else}
969
- {msg}
970
- {/if}
971
- </li>
972
- {/if}
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}
979
- </ul>
980
- {/if}
981
- </div>
982
-
983
- <style>
984
- :is(div.multiselect) {
985
- position: relative;
986
- align-items: center;
987
- display: flex;
988
- cursor: text;
989
- box-sizing: border-box;
990
- border: var(--sms-border, 1pt solid lightgray);
991
- border-radius: var(--sms-border-radius, 3pt);
992
- background: var(--sms-bg);
993
- width: var(--sms-width);
994
- max-width: var(--sms-max-width);
995
- padding: var(--sms-padding, 0 3pt);
996
- color: var(--sms-text-color);
997
- font-size: var(--sms-font-size, inherit);
998
- min-height: var(--sms-min-height, 22pt);
999
- margin: var(--sms-margin);
1000
- }
1001
- :is(div.multiselect.open) {
1002
- /* increase z-index when open to ensure the dropdown of one <MultiSelect />
1003
- displays above that of another slightly below it on the page */
1004
- z-index: var(--sms-open-z-index, 4);
1005
- }
1006
- :is(div.multiselect:focus-within) {
1007
- border: var(--sms-focus-border, 1pt solid var(--sms-active-color, cornflowerblue));
1008
- }
1009
- :is(div.multiselect.disabled) {
1010
- background: var(--sms-disabled-bg, lightgray);
1011
- cursor: not-allowed;
1012
- }
1013
-
1014
- :is(div.multiselect > ul.selected) {
1015
- display: flex;
1016
- flex: 1;
1017
- padding: 0;
1018
- margin: 0;
1019
- flex-wrap: wrap;
1020
- }
1021
- :is(div.multiselect > ul.selected > li) {
1022
- align-items: center;
1023
- border-radius: 3pt;
1024
- display: flex;
1025
- margin: 2pt;
1026
- line-height: normal;
1027
- transition: 0.3s;
1028
- white-space: nowrap;
1029
- background: var(--sms-selected-bg, rgba(0, 0, 0, 0.15));
1030
- padding: var(--sms-selected-li-padding, 1pt 5pt);
1031
- color: var(--sms-selected-text-color, var(--sms-text-color));
1032
- }
1033
- :is(div.multiselect > ul.selected > li[draggable='true']) {
1034
- cursor: grab;
1035
- }
1036
- :is(div.multiselect > ul.selected > li.active) {
1037
- background: var(--sms-li-active-bg, var(--sms-active-color, rgba(0, 0, 0, 0.15)));
1038
- }
1039
- :is(div.multiselect button) {
1040
- border-radius: 50%;
1041
- display: flex;
1042
- transition: 0.2s;
1043
- color: inherit;
1044
- background: transparent;
1045
- border: none;
1046
- cursor: pointer;
1047
- outline: none;
1048
- padding: 1pt;
1049
- margin: 0 0 0 3pt; /* CSS reset */
1050
- }
1051
- :is(div.multiselect button.remove-all) {
1052
- margin: 0 3pt;
1053
- }
1054
- :is(ul.selected > li button:hover, button.remove-all:hover, button:focus) {
1055
- color: var(--sms-remove-btn-hover-color, lightskyblue);
1056
- background: var(--sms-remove-btn-hover-bg, rgba(0, 0, 0, 0.2));
1057
- }
1058
-
1059
- :is(div.multiselect input) {
1060
- margin: auto 0; /* CSS reset */
1061
- padding: 0; /* CSS reset */
1062
- }
1063
- :is(div.multiselect > ul.selected > input) {
1064
- border: none;
1065
- outline: none;
1066
- background: none;
1067
- flex: 1; /* this + next line fix issue #12 https://git.io/JiDe3 */
1068
- min-width: 2em;
1069
- /* ensure input uses text color and not --sms-selected-text-color */
1070
- color: var(--sms-text-color);
1071
- font-size: inherit;
1072
- cursor: inherit; /* needed for disabled state */
1073
- border-radius: 0; /* reset ul.selected > li */
1074
- }
1075
-
1076
- /* When options are selected, placeholder is hidden in which case we minimize input width to avoid adding unnecessary width to div.multiselect */
1077
- :is(div.multiselect > ul.selected > input:not(:placeholder-shown)) {
1078
- min-width: 1px; /* Minimal width to remain interactive */
1079
- }
1080
-
1081
- /* don't wrap ::placeholder rules in :is() as it seems to be overpowered by browser defaults i.t.o. specificity */
1082
- div.multiselect > ul.selected > input::placeholder {
1083
- padding-left: 5pt;
1084
- color: var(--sms-placeholder-color);
1085
- opacity: var(--sms-placeholder-opacity);
1086
- }
1087
- :is(div.multiselect > input.form-control) {
1088
- width: 2em;
1089
- position: absolute;
1090
- background: transparent;
1091
- border: none;
1092
- outline: none;
1093
- z-index: -1;
1094
- opacity: 0;
1095
- pointer-events: none;
1096
- }
1097
-
1098
- ul.options {
1099
- list-style: none;
1100
- /* top, left, width, position are managed by portal when active */
1101
- /* but provide defaults for non-portaled or initial state */
1102
- position: absolute; /* Default, overridden by portal to fixed when open */
1103
- top: 100%;
1104
- left: 0;
1105
- width: 100%;
1106
- /* Default z-index if not portaled/overridden by portal */
1107
- z-index: var(--sms-options-z-index, 3);
1108
-
1109
- overflow: auto;
1110
- transition: all
1111
- 0.2s; /* Consider if this transition is desirable with portal positioning */
1112
- box-sizing: border-box;
1113
- background: var(--sms-options-bg, white);
1114
- max-height: var(--sms-options-max-height, 50vh);
1115
- overscroll-behavior: var(--sms-options-overscroll, none);
1116
- box-shadow: var(--sms-options-shadow, 0 0 14pt -8pt black);
1117
- border: var(--sms-options-border);
1118
- border-width: var(--sms-options-border-width);
1119
- border-radius: var(--sms-options-border-radius, 1ex);
1120
- padding: var(--sms-options-padding);
1121
- margin: var(--sms-options-margin, inherit);
1122
- }
1123
- ul.options.hidden {
1124
- visibility: hidden;
1125
- opacity: 0;
1126
- transform: translateY(50px);
1127
- pointer-events: none;
1128
- }
1129
- ul.options > li {
1130
- padding: 3pt 1ex;
1131
- cursor: pointer;
1132
- scroll-margin: var(--sms-options-scroll-margin, 100px);
1133
- border-left: 3px solid transparent;
1134
- }
1135
- ul.options .user-msg {
1136
- /* block needed so vertical padding applies to span */
1137
- display: block;
1138
- padding: 3pt 2ex;
1139
- }
1140
- ul.options > li.selected {
1141
- background: var(--sms-li-selected-plain-bg, rgba(0, 123, 255, 0.1));
1142
- border-left: var(
1143
- --sms-li-selected-plain-border,
1144
- 3px solid var(--sms-active-color, cornflowerblue)
1145
- );
1146
- }
1147
- ul.options > li.active {
1148
- background: var(--sms-li-active-bg, var(--sms-active-color, rgba(0, 0, 0, 0.15)));
1149
- }
1150
- ul.options > li.disabled {
1151
- cursor: not-allowed;
1152
- background: var(--sms-li-disabled-bg, #f5f5f6);
1153
- color: var(--sms-li-disabled-text, #b8b8b8);
1154
- }
1155
- /* Checkbox styling for keepSelectedInDropdown='checkboxes' mode */
1156
- ul.options > li > input.option-checkbox {
1157
- width: 16px;
1158
- height: 16px;
1159
- margin-right: 6px;
1160
- accent-color: var(--sms-active-color, cornflowerblue);
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
- }
1176
- :is(span.max-select-msg) {
1177
- padding: 0 3pt;
1178
- }
1179
- ::highlight(sms-search-matches) {
1180
- color: mediumaquamarine;
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
- }
1190
- </style>