svelte-multiselect 10.3.0 → 11.0.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,94 +1,29 @@
1
- <script>import { createEventDispatcher, tick } from 'svelte';
1
+ <script lang="ts">import { tick } from 'svelte';
2
2
  import { flip } from 'svelte/animate';
3
3
  import CircleSpinner from './CircleSpinner.svelte';
4
4
  import Wiggle from './Wiggle.svelte';
5
5
  import { CrossIcon, DisabledIcon, ExpandIcon } from './icons';
6
- import { get_label, get_style } from './utils';
7
- export let activeIndex = null;
8
- export let activeOption = null;
9
- export let createOptionMsg = `Create this option...`;
10
- export let allowUserOptions = false;
11
- export let allowEmpty = false; // added for https://github.com/janosh/svelte-multiselect/issues/192
12
- export let autocomplete = `off`;
13
- export let autoScroll = true;
14
- export let breakpoint = 800; // any screen with more horizontal pixels is considered desktop, below is mobile
15
- export let defaultDisabledTitle = `This option is disabled`;
16
- export let disabled = false;
17
- export let disabledInputTitle = `This input is disabled`;
18
- // prettier-ignore
19
- export let duplicateOptionMsg = `This option is already selected`;
20
- export let duplicates = false; // whether to allow duplicate options
21
- // takes two options and returns true if they are equal
22
- // case-insensitive equality comparison after string coercion and looks only at the `label` key of object options by default
23
- export let key = (opt) => `${get_label(opt)}`.toLowerCase();
24
- export let filterFunc = (opt, searchText) => {
6
+ import { get_label, get_style, highlight_matching_nodes } from './utils';
7
+ 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, key = (opt) => `${get_label(opt)}`.toLowerCase(), filterFunc = (opt, searchText) => {
25
8
  if (!searchText)
26
9
  return true;
27
10
  return `${get_label(opt)}`.toLowerCase().includes(searchText.toLowerCase());
28
- };
29
- export let closeDropdownOnSelect = `desktop`;
30
- export let form_input = null;
31
- export let highlightMatches = true;
32
- export let id = null;
33
- export let input = null;
34
- export let inputClass = ``;
35
- export let inputStyle = null;
36
- export let inputmode = null;
37
- export let invalid = false;
38
- export let liActiveOptionClass = ``;
39
- export let liActiveUserMsgClass = ``;
40
- export let liOptionClass = ``;
41
- export let liOptionStyle = null;
42
- export let liSelectedClass = ``;
43
- export let liSelectedStyle = null;
44
- export let liUserMsgClass = ``;
45
- export let loading = false;
46
- export let matchingOptions = [];
47
- export let maxOptions = undefined;
48
- export let maxSelect = null; // null means there is no upper limit for selected.length
49
- export let maxSelectMsg = (current, max) => (max > 1 ? `${current}/${max}` : ``);
50
- export let maxSelectMsgClass = ``;
51
- export let name = null;
52
- export let noMatchingOptionsMsg = `No matching options`;
53
- export let open = false;
54
- export let options;
55
- export let outerDiv = null;
56
- export let outerDivClass = ``;
57
- export let parseLabelsAsHtml = false; // should not be combined with allowUserOptions!
58
- export let pattern = null;
59
- export let placeholder = null;
60
- export let removeAllTitle = `Remove all`;
61
- export let removeBtnTitle = `Remove`;
62
- export let minSelect = null; // null means there is no lower limit for selected.length
63
- export let required = false;
64
- export let resetFilterOnAdd = true;
65
- export let searchText = ``;
66
- export let selected = options
11
+ }, closeDropdownOnSelect = `desktop`, 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
67
12
  ?.filter((opt) => opt instanceof Object && opt?.preselected)
68
- .slice(0, maxSelect ?? undefined) ?? []; // don't allow more than maxSelect preselected options
69
- export let sortSelected = false;
70
- export let selectedOptionsDraggable = !sortSelected;
71
- export let style = null;
72
- export let ulOptionsClass = ``;
73
- export let ulSelectedClass = ``;
74
- export let ulSelectedStyle = null;
75
- export let ulOptionsStyle = null;
76
- export let value = null;
77
- const selected_to_value = (selected) => {
78
- value = maxSelect === 1 ? selected[0] ?? null : selected;
79
- };
80
- const value_to_selected = (value) => {
13
+ .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, ...rest } = $props();
14
+ $effect.pre(() => {
15
+ // if maxSelect=1, value is the single item in selected (or null if selected is empty)
16
+ // this solves both https://github.com/janosh/svelte-multiselect/issues/86 and
17
+ // https://github.com/janosh/svelte-multiselect/issues/136
18
+ value = maxSelect === 1 ? (selected[0] ?? null) : selected;
19
+ }); // sync selected updates to value
20
+ $effect.pre(() => {
81
21
  if (maxSelect === 1)
82
22
  selected = value ? [value] : [];
83
23
  else
84
24
  selected = value ?? [];
85
- };
86
- // if maxSelect=1, value is the single item in selected (or null if selected is empty)
87
- // this solves both https://github.com/janosh/svelte-multiselect/issues/86 and
88
- // https://github.com/janosh/svelte-multiselect/issues/136
89
- $: selected_to_value(selected);
90
- $: value_to_selected(value);
91
- let wiggle = false; // controls wiggle animation when user tries to exceed maxSelect
25
+ }); // sync value updates to selected
26
+ let wiggle = $state(false); // controls wiggle animation when user tries to exceed maxSelect
92
27
  if (!(options?.length > 0)) {
93
28
  if (allowUserOptions || loading || disabled || allowEmpty) {
94
29
  options = []; // initializing as array avoids errors when component mounts
@@ -123,21 +58,25 @@ if (maxOptions &&
123
58
  (typeof maxOptions != `number` || maxOptions < 0 || maxOptions % 1 != 0)) {
124
59
  console.error(`MultiSelect's maxOptions must be undefined or a positive integer, got ${maxOptions}`);
125
60
  }
126
- const dispatch = createEventDispatcher();
127
- let option_msg_is_active = false; // controls active state of <li>{createOptionMsg}</li>
128
- let window_width;
61
+ let option_msg_is_active = $state(false); // controls active state of <li>{createOptionMsg}</li>
62
+ let window_width = $state(0);
129
63
  // options matching the current search text
130
- $: matchingOptions = options.filter((opt) => filterFunc(opt, searchText) &&
131
- // remove already selected options from dropdown list unless duplicate selections are allowed
132
- (!selected.map(key).includes(key(opt)) || duplicates));
64
+ $effect.pre(() => {
65
+ matchingOptions = options.filter((opt) => filterFunc(opt, searchText) &&
66
+ // remove already selected options from dropdown list unless duplicate selections are allowed
67
+ (!selected.map(key).includes(key(opt)) || duplicates));
68
+ });
133
69
  // raise if matchingOptions[activeIndex] does not yield a value
134
70
  if (activeIndex !== null && !matchingOptions[activeIndex]) {
135
71
  throw `Run time error, activeIndex=${activeIndex} is out of bounds, matchingOptions.length=${matchingOptions.length}`;
136
72
  }
137
73
  // update activeOption when activeIndex changes
138
- $: activeOption = matchingOptions[activeIndex ?? -1] ?? null;
74
+ $effect(() => {
75
+ activeOption = matchingOptions[activeIndex ?? -1] ?? null;
76
+ });
139
77
  // add an option to selected list
140
78
  function add(option, event) {
79
+ event.stopPropagation();
141
80
  if (maxSelect && maxSelect > 1 && selected.length >= maxSelect)
142
81
  wiggle = true;
143
82
  if (!isNaN(Number(option)) && typeof selected.map(get_label)[0] === `number`) {
@@ -166,7 +105,7 @@ function add(option, event) {
166
105
  else {
167
106
  option = searchText; // else create custom option as string
168
107
  }
169
- dispatch(`create`, { option });
108
+ oncreate?.({ option });
170
109
  }
171
110
  if (allowUserOptions === `append`)
172
111
  options = [...options, option];
@@ -196,21 +135,22 @@ function add(option, event) {
196
135
  }
197
136
  const reached_max_select = selected.length === maxSelect;
198
137
  const dropdown_should_close = closeDropdownOnSelect === true ||
199
- (closeDropdownOnSelect === `desktop` && window_width < breakpoint);
138
+ (closeDropdownOnSelect === `desktop` && window_width && window_width < breakpoint);
200
139
  if (reached_max_select || dropdown_should_close) {
201
140
  close_dropdown(event);
202
141
  }
203
142
  else if (!dropdown_should_close) {
204
143
  input?.focus();
205
144
  }
206
- dispatch(`add`, { option });
207
- dispatch(`change`, { option, type: `add` });
145
+ onadd?.({ option });
146
+ onchange?.({ option, type: `add` });
208
147
  invalid = false; // reset error status whenever new items are selected
209
148
  form_input?.setCustomValidity(``);
210
149
  }
211
150
  }
212
151
  // remove an option from selected list
213
- function remove(to_remove) {
152
+ function remove(to_remove, event) {
153
+ event.stopPropagation();
214
154
  if (selected.length === 0)
215
155
  return;
216
156
  const idx = selected.findIndex((opt) => key(opt) === key(to_remove));
@@ -228,10 +168,11 @@ function remove(to_remove) {
228
168
  selected = [...selected]; // trigger Svelte rerender
229
169
  invalid = false; // reset error status whenever items are removed
230
170
  form_input?.setCustomValidity(``);
231
- dispatch(`remove`, { option });
232
- dispatch(`change`, { option, type: `remove` });
171
+ onremove?.({ option });
172
+ onchange?.({ option, type: `remove` });
233
173
  }
234
174
  function open_dropdown(event) {
175
+ event.stopPropagation();
235
176
  if (disabled)
236
177
  return;
237
178
  open = true;
@@ -239,45 +180,54 @@ function open_dropdown(event) {
239
180
  // avoid double-focussing input when event that opened dropdown was already input FocusEvent
240
181
  input?.focus();
241
182
  }
242
- dispatch(`open`, { event });
183
+ onopen?.({ event });
243
184
  }
244
185
  function close_dropdown(event) {
245
186
  open = false;
246
187
  input?.blur();
247
188
  activeIndex = null;
248
- dispatch(`close`, { event });
189
+ onclose?.({ event });
249
190
  }
250
191
  // handle all keyboard events this component receives
251
192
  async function handle_keydown(event) {
252
193
  // on escape or tab out of input: close options dropdown and reset search text
253
194
  if (event.key === `Escape` || event.key === `Tab`) {
195
+ event.stopPropagation();
254
196
  close_dropdown(event);
255
197
  searchText = ``;
256
198
  }
257
199
  // on enter key: toggle active option and reset search text
258
200
  else if (event.key === `Enter`) {
201
+ event.stopPropagation();
259
202
  event.preventDefault(); // prevent enter key from triggering form submission
260
203
  if (activeOption) {
261
- selected.includes(activeOption) ? remove(activeOption) : add(activeOption, event);
204
+ if (selected.includes(activeOption))
205
+ remove(activeOption, event);
206
+ else
207
+ add(activeOption, event);
262
208
  searchText = ``;
263
209
  }
264
210
  else if (allowUserOptions && searchText.length > 0) {
265
211
  // user entered text but no options match, so if allowUserOptions is truthy, we create new option
266
212
  add(searchText, event);
267
213
  }
268
- // no active option and no search text means the options dropdown is closed
269
- // in which case enter means open it
270
- else
214
+ else {
215
+ // no active option and no search text means the options dropdown is closed
216
+ // in which case enter means open it
271
217
  open_dropdown(event);
218
+ }
272
219
  }
273
220
  // on up/down arrow keys: update active option
274
221
  else if ([`ArrowDown`, `ArrowUp`].includes(event.key)) {
222
+ event.stopPropagation();
275
223
  // if no option is active yet, but there are matching options, make first one active
276
224
  if (activeIndex === null && matchingOptions.length > 0) {
225
+ event.preventDefault(); // Prevent scroll only if we handle the key
277
226
  activeIndex = 0;
278
227
  return;
279
228
  }
280
229
  else if (allowUserOptions && !matchingOptions.length && searchText.length > 0) {
230
+ event.preventDefault(); // Prevent scroll only if we handle the key
281
231
  // if allowUserOptions is truthy and user entered text but no options match, we make
282
232
  // <li>{addUserMsg}</li> active on keydown (or toggle it if already active)
283
233
  option_msg_is_active = !option_msg_is_active;
@@ -287,7 +237,7 @@ async function handle_keydown(event) {
287
237
  // if no option is active and no options are matching, do nothing
288
238
  return;
289
239
  }
290
- event.preventDefault();
240
+ event.preventDefault(); // Prevent scroll only if we handle the key
291
241
  // if none of the above special cases apply, we make next/prev option
292
242
  // active with wrap around at both ends
293
243
  const increment = event.key === `ArrowUp` ? -1 : 1;
@@ -305,24 +255,28 @@ async function handle_keydown(event) {
305
255
  }
306
256
  // on backspace key: remove last selected option
307
257
  else if (event.key === `Backspace` && selected.length > 0 && !searchText) {
308
- remove(selected.at(-1));
258
+ event.stopPropagation();
259
+ // Don't prevent default, allow normal backspace behavior if not removing
260
+ remove(selected.at(-1), event);
309
261
  }
310
262
  // make first matching option active on any keypress (if none of the above special cases match)
311
- else if (matchingOptions.length > 0) {
263
+ else if (matchingOptions.length > 0 && activeIndex === null) {
264
+ // Don't stop propagation or prevent default here, allow normal character input
312
265
  activeIndex = 0;
313
266
  }
314
267
  }
315
- function remove_all() {
316
- dispatch(`removeAll`, { options: selected });
317
- dispatch(`change`, { options: selected, type: `removeAll` });
268
+ function remove_all(event) {
269
+ event.stopPropagation();
270
+ onremoveAll?.({ options: selected });
271
+ onchange?.({ options: selected, type: `removeAll` });
318
272
  selected = [];
319
273
  searchText = ``;
320
274
  }
321
- $: is_selected = (label) => selected.map(get_label).includes(label);
275
+ let is_selected = $derived((label) => selected.map(get_label).includes(label));
322
276
  const if_enter_or_space = (handler) => (event) => {
323
277
  if ([`Enter`, `Space`].includes(event.code)) {
324
278
  event.preventDefault();
325
- handler();
279
+ handler(event);
326
280
  }
327
281
  };
328
282
  function on_click_outside(event) {
@@ -330,9 +284,10 @@ function on_click_outside(event) {
330
284
  close_dropdown(event);
331
285
  }
332
286
  }
333
- let drag_idx = null;
287
+ let drag_idx = $state(null);
334
288
  // event handlers enable dragging to reorder selected options
335
289
  const drop = (target_idx) => (event) => {
290
+ event.preventDefault();
336
291
  if (!event.dataTransfer)
337
292
  return;
338
293
  event.dataTransfer.dropEffect = `move`;
@@ -357,62 +312,38 @@ const dragstart = (idx) => (event) => {
357
312
  event.dataTransfer.dropEffect = `move`;
358
313
  event.dataTransfer.setData(`text/plain`, `${idx}`);
359
314
  };
360
- let ul_options;
315
+ let ul_options = $state();
361
316
  // highlight text matching user-entered search text in available options
362
317
  function highlight_matching_options(event) {
363
- if (!highlightMatches || typeof CSS == `undefined` || !CSS.highlights)
364
- return; // abort if CSS highlight API not supported
365
- // clear previous ranges from HighlightRegistry
366
- CSS.highlights.clear();
318
+ if (!highlightMatches || !ul_options)
319
+ return;
367
320
  // get input's search query
368
321
  const query = event?.target?.value.trim().toLowerCase();
369
322
  if (!query)
370
323
  return;
371
- const tree_walker = document.createTreeWalker(ul_options, NodeFilter.SHOW_TEXT, {
372
- acceptNode: (node) => {
373
- // don't highlight text in the "no matching options" message
374
- if (node?.textContent === noMatchingOptionsMsg)
375
- return NodeFilter.FILTER_REJECT;
376
- return NodeFilter.FILTER_ACCEPT;
377
- },
378
- });
379
- const text_nodes = [];
380
- let current_node = tree_walker.nextNode();
381
- while (current_node) {
382
- text_nodes.push(current_node);
383
- current_node = tree_walker.nextNode();
384
- }
385
- // iterate over all text nodes and find matches
386
- const ranges = text_nodes.map((el) => {
387
- const text = el.textContent?.toLowerCase();
388
- const indices = [];
389
- let start_pos = 0;
390
- while (text && start_pos < text.length) {
391
- const index = text.indexOf(query, start_pos);
392
- if (index === -1)
393
- break;
394
- indices.push(index);
395
- start_pos = index + query.length;
396
- }
397
- // create range object for each str found in the text node
398
- return indices.map((index) => {
399
- const range = new Range();
400
- range.setStart(el, index);
401
- range.setEnd(el, index + query.length);
402
- return range;
403
- });
404
- });
405
- // create Highlight object from ranges and add to registry
406
- CSS.highlights.set(`sms-search-matches`, new Highlight(...ranges.flat()));
324
+ highlight_matching_nodes(ul_options, query, noMatchingOptionsMsg);
407
325
  }
326
+ const handle_input_keydown = (event) => {
327
+ handle_keydown(event); // Restore internal logic
328
+ // Call original forwarded handler
329
+ onkeydown?.(event);
330
+ };
331
+ const handle_input_focus = (event) => {
332
+ open_dropdown(event); // Internal logic
333
+ // Call original forwarded handler
334
+ onfocus?.(event);
335
+ };
408
336
  // reset form validation when required prop changes
409
337
  // https://github.com/janosh/svelte-multiselect/issues/285
410
- $: required, form_input?.setCustomValidity(``);
338
+ $effect.pre(() => {
339
+ required = required; // trigger effect when required changes
340
+ form_input?.setCustomValidity(``);
341
+ });
411
342
  </script>
412
343
 
413
344
  <svelte:window
414
- on:click={on_click_outside}
415
- on:touchstart={on_click_outside}
345
+ onclick={on_click_outside}
346
+ ontouchstart={on_click_outside}
416
347
  bind:innerWidth={window_width}
417
348
  />
418
349
 
@@ -423,7 +354,7 @@ $: required, form_input?.setCustomValidity(``);
423
354
  class:open
424
355
  class:invalid
425
356
  class="multiselect {outerDivClass}"
426
- on:mouseup|stopPropagation={open_dropdown}
357
+ onmouseup={open_dropdown}
427
358
  title={disabled ? disabledInputTitle : null}
428
359
  data-id={id}
429
360
  role="searchbox"
@@ -434,14 +365,14 @@ $: required, form_input?.setCustomValidity(``);
434
365
  <!-- bind:value={selected} prevents form submission if required prop is true and no options are selected -->
435
366
  <input
436
367
  {name}
437
- {required}
368
+ required={Boolean(required)}
438
369
  value={selected.length >= Number(required) ? JSON.stringify(selected) : null}
439
370
  tabindex="-1"
440
371
  aria-hidden="true"
441
372
  aria-label="ignore this, used only to prevent form submission if select is required but empty"
442
373
  class="form-control"
443
374
  bind:this={form_input}
444
- on:invalid={() => {
375
+ oninvalid={() => {
445
376
  invalid = true
446
377
  let msg
447
378
  if (maxSelect && maxSelect > 1 && Number(required) > 1) {
@@ -454,49 +385,60 @@ $: required, form_input?.setCustomValidity(``);
454
385
  form_input?.setCustomValidity(msg)
455
386
  }}
456
387
  />
457
- <slot name="expand-icon" {open}>
388
+ {#if expandIcon}
389
+ {@render expandIcon({ open })}
390
+ {:else}
458
391
  <ExpandIcon width="15px" style="min-width: 1em; padding: 0 1pt; cursor: pointer;" />
459
- </slot>
392
+ {/if}
460
393
  <ul
461
394
  class="selected {ulSelectedClass}"
462
395
  aria-label="selected options"
463
396
  style={ulSelectedStyle}
464
397
  >
465
398
  {#each selected as option, idx (duplicates ? [key(option), idx] : key(option))}
399
+ {@const selectedOptionStyle =
400
+ [get_style(option, `selected`), liSelectedStyle].filter(Boolean).join(` `) ||
401
+ null}
466
402
  <li
467
403
  class={liSelectedClass}
468
404
  role="option"
469
405
  aria-selected="true"
470
406
  animate:flip={{ duration: 100 }}
471
407
  draggable={selectedOptionsDraggable && !disabled && selected.length > 1}
472
- on:dragstart={dragstart(idx)}
473
- on:drop|preventDefault={drop(idx)}
474
- on:dragenter={() => (drag_idx = idx)}
475
- on:dragover|preventDefault
408
+ ondragstart={dragstart(idx)}
409
+ ondrop={drop(idx)}
410
+ ondragenter={() => (drag_idx = idx)}
476
411
  class:active={drag_idx === idx}
477
- style="{get_style(option, `selected`)} {liSelectedStyle}"
412
+ style={selectedOptionStyle}
478
413
  >
479
- <!-- on:dragover|preventDefault needed for the drop to succeed https://stackoverflow.com/a/31085796 -->
480
- <slot name="selected" {option} {idx}>
481
- <slot {option} {idx}>
482
- {#if parseLabelsAsHtml}
483
- {@html get_label(option)}
484
- {:else}
485
- {get_label(option)}
486
- {/if}
487
- </slot>
488
- </slot>
414
+ {#if selectedItem}
415
+ {@render selectedItem({
416
+ option,
417
+ idx,
418
+ })}
419
+ {:else if children}
420
+ {@render children({
421
+ option,
422
+ idx,
423
+ })}
424
+ {:else if parseLabelsAsHtml}
425
+ {@html get_label(option)}
426
+ {:else}
427
+ {get_label(option)}
428
+ {/if}
489
429
  {#if !disabled && (minSelect === null || selected.length > minSelect)}
490
430
  <button
491
- on:mouseup|stopPropagation={() => remove(option)}
492
- on:keydown={if_enter_or_space(() => remove(option))}
431
+ onclick={(event) => remove(option, event)}
432
+ onkeydown={if_enter_or_space((event) => remove(option, event))}
493
433
  type="button"
494
434
  title="{removeBtnTitle} {get_label(option)}"
495
435
  class="remove"
496
436
  >
497
- <slot name="remove-icon">
437
+ {#if removeIcon}
438
+ {@render removeIcon()}
439
+ {:else}
498
440
  <CrossIcon width="15px" />
499
- </slot>
441
+ {/if}
500
442
  </button>
501
443
  {/if}
502
444
  </li>
@@ -506,11 +448,6 @@ $: required, form_input?.setCustomValidity(``);
506
448
  style={inputStyle}
507
449
  bind:this={input}
508
450
  bind:value={searchText}
509
- on:mouseup|self|stopPropagation={open_dropdown}
510
- on:keydown|stopPropagation={handle_keydown}
511
- on:focus
512
- on:focus={open_dropdown}
513
- on:input={highlight_matching_options}
514
451
  {id}
515
452
  {disabled}
516
453
  {autocomplete}
@@ -518,41 +455,47 @@ $: required, form_input?.setCustomValidity(``);
518
455
  {pattern}
519
456
  placeholder={selected.length == 0 ? placeholder : null}
520
457
  aria-invalid={invalid ? `true` : null}
521
- ondrop="return false"
522
- on:blur
523
- on:change
524
- on:click
525
- on:keydown
526
- on:keyup
527
- on:mousedown
528
- on:mouseenter
529
- on:mouseleave
530
- on:touchcancel
531
- on:touchend
532
- on:touchmove
533
- on:touchstart
458
+ ondrop={() => false}
459
+ onmouseup={open_dropdown}
460
+ onkeydown={handle_input_keydown}
461
+ onfocus={handle_input_focus}
462
+ oninput={highlight_matching_options}
463
+ {onblur}
464
+ {onclick}
465
+ {onkeyup}
466
+ {onmousedown}
467
+ {onmouseenter}
468
+ {onmouseleave}
469
+ {ontouchcancel}
470
+ {ontouchend}
471
+ {ontouchmove}
472
+ {ontouchstart}
473
+ {...rest}
534
474
  />
535
475
  <!-- the above on:* lines forward potentially useful DOM events -->
536
- <slot
537
- name="after-input"
538
- {selected}
539
- {disabled}
540
- {invalid}
541
- {id}
542
- {placeholder}
543
- {open}
544
- {required}
545
- />
476
+ {@render afterInput?.({
477
+ selected,
478
+ disabled,
479
+ invalid,
480
+ id,
481
+ placeholder,
482
+ open,
483
+ required,
484
+ })}
546
485
  </ul>
547
486
  {#if loading}
548
- <slot name="spinner">
487
+ {#if spinner}
488
+ {@render spinner()}
489
+ {:else}
549
490
  <CircleSpinner />
550
- </slot>
491
+ {/if}
551
492
  {/if}
552
493
  {#if disabled}
553
- <slot name="disabled-icon">
494
+ {#if disabledIcon}
495
+ {@render disabledIcon()}
496
+ {:else}
554
497
  <DisabledIcon width="14pt" style="margin: 0 2pt;" data-name="disabled-icon" />
555
- </slot>
498
+ {/if}
556
499
  {:else if selected.length > 0}
557
500
  {#if maxSelect && (maxSelect > 1 || maxSelectMsg)}
558
501
  <Wiggle bind:wiggle angle={20}>
@@ -566,12 +509,14 @@ $: required, form_input?.setCustomValidity(``);
566
509
  type="button"
567
510
  class="remove remove-all"
568
511
  title={removeAllTitle}
569
- on:mouseup|stopPropagation={remove_all}
570
- on:keydown={if_enter_or_space(remove_all)}
512
+ onclick={remove_all}
513
+ onkeydown={if_enter_or_space(remove_all)}
571
514
  >
572
- <slot name="remove-icon">
515
+ {#if removeIcon}
516
+ {@render removeIcon()}
517
+ {:else}
573
518
  <CrossIcon width="15px" />
574
- </slot>
519
+ {/if}
575
520
  </button>
576
521
  {/if}
577
522
  {/if}
@@ -588,19 +533,21 @@ $: required, form_input?.setCustomValidity(``);
588
533
  bind:this={ul_options}
589
534
  style={ulOptionsStyle}
590
535
  >
591
- {#each matchingOptions.slice(0, Math.max(0, maxOptions ?? 0) || Infinity) as option, idx}
536
+ {#each matchingOptions.slice(0, Math.max(0, maxOptions ?? 0) || Infinity) as optionItem, idx (duplicates ? [key(optionItem), idx] : key(optionItem))}
592
537
  {@const {
593
538
  label,
594
539
  disabled = null,
595
540
  title = null,
596
541
  selectedTitle = null,
597
542
  disabledTitle = defaultDisabledTitle,
598
- } = option instanceof Object ? option : { label: option }}
543
+ } = optionItem instanceof Object ? optionItem : { label: optionItem }}
599
544
  {@const active = activeIndex === idx}
545
+ {@const optionStyle =
546
+ [get_style(optionItem, `option`), liOptionStyle].filter(Boolean).join(` `) ||
547
+ null}
600
548
  <li
601
- on:mousedown|stopPropagation
602
- on:mouseup|stopPropagation={(event) => {
603
- if (!disabled) add(option, event)
549
+ onclick={(event) => {
550
+ if (!disabled) add(optionItem, event)
604
551
  }}
605
552
  title={disabled
606
553
  ? disabledTitle
@@ -609,27 +556,37 @@ $: required, form_input?.setCustomValidity(``);
609
556
  class:active
610
557
  class:disabled
611
558
  class="{liOptionClass} {active ? liActiveOptionClass : ``}"
612
- on:mouseover={() => {
559
+ onmouseover={() => {
613
560
  if (!disabled) activeIndex = idx
614
561
  }}
615
- on:focus={() => {
562
+ onfocus={() => {
616
563
  if (!disabled) activeIndex = idx
617
564
  }}
618
- on:mouseout={() => (activeIndex = null)}
619
- on:blur={() => (activeIndex = null)}
620
565
  role="option"
621
566
  aria-selected="false"
622
- style="{get_style(option, `option`)} {liOptionStyle}"
567
+ style={optionStyle}
568
+ onkeydown={(event) => {
569
+ if (!disabled && (event.key === `Enter` || event.code === `Space`)) {
570
+ event.preventDefault()
571
+ add(optionItem, event)
572
+ }
573
+ }}
623
574
  >
624
- <slot name="option" {option} {idx}>
625
- <slot {option} {idx}>
626
- {#if parseLabelsAsHtml}
627
- {@html get_label(option)}
628
- {:else}
629
- {get_label(option)}
630
- {/if}
631
- </slot>
632
- </slot>
575
+ {#if option}
576
+ {@render option({
577
+ option: optionItem,
578
+ idx,
579
+ })}
580
+ {:else if children}
581
+ {@render children({
582
+ option: optionItem,
583
+ idx,
584
+ })}
585
+ {:else if parseLabelsAsHtml}
586
+ {@html get_label(optionItem)}
587
+ {:else}
588
+ {get_label(optionItem)}
589
+ {/if}
633
590
  </li>
634
591
  {/each}
635
592
  {#if searchText}
@@ -646,16 +603,31 @@ $: required, form_input?.setCustomValidity(``);
646
603
  'no-match': noMatchingOptionsMsg,
647
604
  }[msgType]}
648
605
  <li
649
- on:mousedown|stopPropagation
650
- on:mouseup|stopPropagation={(event) => {
651
- if (allowUserOptions) add(searchText, event)
606
+ onclick={(event) => {
607
+ if (msgType === `create` && allowUserOptions) {
608
+ add(searchText as Option, event)
609
+ }
610
+ }}
611
+ onkeydown={(event) => {
612
+ if (
613
+ msgType === `create` &&
614
+ allowUserOptions &&
615
+ (event.key === `Enter` || event.code === `Space`)
616
+ ) {
617
+ event.preventDefault()
618
+ add(searchText as Option, event)
619
+ }
652
620
  }}
653
- title={createOptionMsg}
621
+ title={msgType === `create`
622
+ ? createOptionMsg
623
+ : msgType === `dupe`
624
+ ? duplicateOptionMsg
625
+ : ``}
654
626
  class:active={option_msg_is_active}
655
- on:mouseover={() => (option_msg_is_active = true)}
656
- on:focus={() => (option_msg_is_active = true)}
657
- on:mouseout={() => (option_msg_is_active = false)}
658
- on:blur={() => (option_msg_is_active = false)}
627
+ onmouseover={() => (option_msg_is_active = true)}
628
+ onfocus={() => (option_msg_is_active = true)}
629
+ onmouseout={() => (option_msg_is_active = false)}
630
+ onblur={() => (option_msg_is_active = false)}
659
631
  role="option"
660
632
  aria-selected="false"
661
633
  class="user-msg {liUserMsgClass} {option_msg_is_active
@@ -667,9 +639,11 @@ $: required, form_input?.setCustomValidity(``);
667
639
  'no-match': `default`,
668
640
  }[msgType]}
669
641
  >
670
- <slot name="user-msg" {searchText} {msgType} {msg}>
642
+ {#if userMsg}
643
+ {@render userMsg({ searchText, msgType, msg })}
644
+ {:else}
671
645
  {msg}
672
- </slot>
646
+ {/if}
673
647
  </li>
674
648
  {/if}
675
649
  {/if}
@@ -678,7 +652,7 @@ $: required, form_input?.setCustomValidity(``);
678
652
  </div>
679
653
 
680
654
  <style>
681
- :where(div.multiselect) {
655
+ :is(div.multiselect) {
682
656
  position: relative;
683
657
  align-items: center;
684
658
  display: flex;
@@ -695,27 +669,27 @@ $: required, form_input?.setCustomValidity(``);
695
669
  min-height: var(--sms-min-height, 22pt);
696
670
  margin: var(--sms-margin);
697
671
  }
698
- :where(div.multiselect.open) {
672
+ :is(div.multiselect.open) {
699
673
  /* increase z-index when open to ensure the dropdown of one <MultiSelect />
700
674
  displays above that of another slightly below it on the page */
701
675
  z-index: var(--sms-open-z-index, 4);
702
676
  }
703
- :where(div.multiselect:focus-within) {
677
+ :is(div.multiselect:focus-within) {
704
678
  border: var(--sms-focus-border, 1pt solid var(--sms-active-color, cornflowerblue));
705
679
  }
706
- :where(div.multiselect.disabled) {
680
+ :is(div.multiselect.disabled) {
707
681
  background: var(--sms-disabled-bg, lightgray);
708
682
  cursor: not-allowed;
709
683
  }
710
684
 
711
- :where(div.multiselect > ul.selected) {
685
+ :is(div.multiselect > ul.selected) {
712
686
  display: flex;
713
687
  flex: 1;
714
688
  padding: 0;
715
689
  margin: 0;
716
690
  flex-wrap: wrap;
717
691
  }
718
- :where(div.multiselect > ul.selected > li) {
692
+ :is(div.multiselect > ul.selected > li) {
719
693
  align-items: center;
720
694
  border-radius: 3pt;
721
695
  display: flex;
@@ -727,13 +701,13 @@ $: required, form_input?.setCustomValidity(``);
727
701
  padding: var(--sms-selected-li-padding, 1pt 5pt);
728
702
  color: var(--sms-selected-text-color, var(--sms-text-color));
729
703
  }
730
- :where(div.multiselect > ul.selected > li[draggable='true']) {
704
+ :is(div.multiselect > ul.selected > li[draggable='true']) {
731
705
  cursor: grab;
732
706
  }
733
- :where(div.multiselect > ul.selected > li.active) {
707
+ :is(div.multiselect > ul.selected > li.active) {
734
708
  background: var(--sms-li-active-bg, var(--sms-active-color, rgba(0, 0, 0, 0.15)));
735
709
  }
736
- :where(div.multiselect button) {
710
+ :is(div.multiselect button) {
737
711
  border-radius: 50%;
738
712
  display: flex;
739
713
  transition: 0.2s;
@@ -742,22 +716,22 @@ $: required, form_input?.setCustomValidity(``);
742
716
  border: none;
743
717
  cursor: pointer;
744
718
  outline: none;
745
- padding: 0;
719
+ padding: 1pt;
746
720
  margin: 0 0 0 3pt; /* CSS reset */
747
721
  }
748
- :where(div.multiselect button.remove-all) {
722
+ :is(div.multiselect button.remove-all) {
749
723
  margin: 0 3pt;
750
724
  }
751
- :where(ul.selected > li button:hover, button.remove-all:hover, button:focus) {
725
+ :is(ul.selected > li button:hover, button.remove-all:hover, button:focus) {
752
726
  color: var(--sms-remove-btn-hover-color, lightskyblue);
753
727
  background: var(--sms-remove-btn-hover-bg, rgba(0, 0, 0, 0.2));
754
728
  }
755
729
 
756
- :where(div.multiselect input) {
730
+ :is(div.multiselect input) {
757
731
  margin: auto 0; /* CSS reset */
758
732
  padding: 0; /* CSS reset */
759
733
  }
760
- :where(div.multiselect > ul.selected > input) {
734
+ :is(div.multiselect > ul.selected > input) {
761
735
  border: none;
762
736
  outline: none;
763
737
  background: none;
@@ -769,13 +743,13 @@ $: required, form_input?.setCustomValidity(``);
769
743
  cursor: inherit; /* needed for disabled state */
770
744
  border-radius: 0; /* reset ul.selected > li */
771
745
  }
772
- /* don't wrap ::placeholder rules in :where() as it seems to be overpowered by browser defaults i.t.o. specificity */
746
+ /* don't wrap ::placeholder rules in :is() as it seems to be overpowered by browser defaults i.t.o. specificity */
773
747
  div.multiselect > ul.selected > input::placeholder {
774
748
  padding-left: 5pt;
775
749
  color: var(--sms-placeholder-color);
776
750
  opacity: var(--sms-placeholder-opacity);
777
751
  }
778
- :where(div.multiselect > input.form-control) {
752
+ :is(div.multiselect > input.form-control) {
779
753
  width: 2em;
780
754
  position: absolute;
781
755
  background: transparent;
@@ -786,7 +760,7 @@ $: required, form_input?.setCustomValidity(``);
786
760
  pointer-events: none;
787
761
  }
788
762
 
789
- :where(div.multiselect > ul.options) {
763
+ :is(div.multiselect > ul.options) {
790
764
  list-style: none;
791
765
  top: 100%;
792
766
  left: 0;
@@ -805,35 +779,35 @@ $: required, form_input?.setCustomValidity(``);
805
779
  padding: var(--sms-options-padding);
806
780
  margin: var(--sms-options-margin, inherit);
807
781
  }
808
- :where(div.multiselect > ul.options.hidden) {
782
+ :is(div.multiselect > ul.options.hidden) {
809
783
  visibility: hidden;
810
784
  opacity: 0;
811
785
  transform: translateY(50px);
812
786
  }
813
- :where(div.multiselect > ul.options > li) {
787
+ :is(div.multiselect > ul.options > li) {
814
788
  padding: 3pt 2ex;
815
789
  cursor: pointer;
816
790
  scroll-margin: var(--sms-options-scroll-margin, 100px);
817
791
  }
818
- :where(div.multiselect > ul.options .user-msg) {
792
+ :is(div.multiselect > ul.options .user-msg) {
819
793
  /* block needed so vertical padding applies to span */
820
794
  display: block;
821
795
  padding: 3pt 2ex;
822
796
  }
823
- :where(div.multiselect > ul.options > li.selected) {
797
+ :is(div.multiselect > ul.options > li.selected) {
824
798
  background: var(--sms-li-selected-bg);
825
799
  color: var(--sms-li-selected-color);
826
800
  }
827
- :where(div.multiselect > ul.options > li.active) {
801
+ :is(div.multiselect > ul.options > li.active) {
828
802
  background: var(--sms-li-active-bg, var(--sms-active-color, rgba(0, 0, 0, 0.15)));
829
803
  }
830
- :where(div.multiselect > ul.options > li.disabled) {
804
+ :is(div.multiselect > ul.options > li.disabled) {
831
805
  cursor: not-allowed;
832
806
  background: var(--sms-li-disabled-bg, #f5f5f6);
833
807
  color: var(--sms-li-disabled-text, #b8b8b8);
834
808
  }
835
809
 
836
- :where(span.max-select-msg) {
810
+ :is(span.max-select-msg) {
837
811
  padding: 0 3pt;
838
812
  }
839
813
  ::highlight(sms-search-matches) {