svelte-multiselect 11.2.3 → 11.3.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,14 +1,22 @@
1
- <script lang="ts">import { CircleSpinner, Icon, Wiggle } from './';
2
- import { tick } from 'svelte';
1
+ <script lang="ts" generics="Option extends import('./types').Option">import { tick } from 'svelte';
3
2
  import { flip } from 'svelte/animate';
4
- import { get_label, get_style, highlight_matching_nodes } from './utils';
3
+ import { highlight_matches } from './attachments';
4
+ import CircleSpinner from './CircleSpinner.svelte';
5
+ import Icon from './Icon.svelte';
6
+ import { fuzzy_match, get_label, get_style } from './utils';
7
+ import Wiggle from './Wiggle.svelte';
5
8
  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) => {
6
9
  if (!searchText)
7
10
  return true;
8
- return `${get_label(opt)}`.toLowerCase().includes(searchText.toLowerCase());
9
- }, closeDropdownOnSelect = `if-mobile`, form_input = $bindable(null), highlightMatches = true, id = null, input = $bindable(null), inputClass = ``, inputStyle = null, inputmode = null, invalid = $bindable(false), liActiveOptionClass = ``, liActiveUserMsgClass = ``, liOptionClass = ``, liOptionStyle = null, liSelectedClass = ``, liSelectedStyle = null, liUserMsgClass = ``, loading = false, matchingOptions = $bindable([]), maxOptions = undefined, maxSelect = null, maxSelectMsg = (current, max) => (max > 1 ? `${current}/${max}` : ``), maxSelectMsgClass = ``, name = null, noMatchingOptionsMsg = `No matching options`, open = $bindable(false), options = $bindable(), outerDiv = $bindable(null), outerDivClass = ``, parseLabelsAsHtml = false, pattern = null, placeholder = null, removeAllTitle = `Remove all`, removeBtnTitle = `Remove`, minSelect = null, required = false, resetFilterOnAdd = true, searchText = $bindable(``), selected = $bindable(options
10
- ?.filter((opt) => opt instanceof Object && opt?.preselected)
11
- .slice(0, maxSelect ?? undefined) ?? []), sortSelected = false, selectedOptionsDraggable = !sortSelected, style = null, ulOptionsClass = ``, ulSelectedClass = ``, ulSelectedStyle = null, ulOptionsStyle = null, value = $bindable(null), expandIcon, selectedItem, children, removeIcon, afterInput, spinner, disabledIcon, option, userMsg, onblur, onclick, onfocus, onkeydown, onkeyup, onmousedown, onmouseenter, onmouseleave, ontouchcancel, ontouchend, ontouchmove, ontouchstart, onadd, oncreate, onremove, onremoveAll, onchange, onopen, onclose, portal: portal_params = {}, ...rest } = $props();
11
+ const label = `${get_label(opt)}`;
12
+ return fuzzy
13
+ ? fuzzy_match(searchText, label)
14
+ : 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
+ ? (Array.isArray(value) ? value : [value])
17
+ : (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();
12
20
  $effect.pre(() => {
13
21
  // if maxSelect=1, value is the single item in selected (or null if selected is empty)
14
22
  // this solves both https://github.com/janosh/svelte-multiselect/issues/86 and
@@ -257,11 +265,25 @@ async function handle_keydown(event) {
257
265
  // if none of the above special cases apply, we make next/prev option
258
266
  // active with wrap around at both ends
259
267
  const increment = event.key === `ArrowUp` ? -1 : 1;
260
- activeIndex = (activeIndex + increment) % matchingOptions.length;
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;
261
274
  // in JS % behaves like remainder operator, not real modulo, so negative numbers stay negative
262
275
  // need to do manual wrap around at 0
263
276
  if (activeIndex < 0)
264
- activeIndex = matchingOptions.length - 1;
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
+ }
265
287
  if (autoScroll) {
266
288
  await tick();
267
289
  const li = document.querySelector(`ul.options > li.active`);
@@ -273,7 +295,9 @@ async function handle_keydown(event) {
273
295
  event.stopPropagation();
274
296
  // Only remove option if it wouldn't violate minSelect
275
297
  if (minSelect === null || selected.length > minSelect) {
276
- remove(selected.at(-1), event);
298
+ const last_option = selected.at(-1);
299
+ if (last_option)
300
+ remove(last_option, event);
277
301
  }
278
302
  // Don't prevent default, allow normal backspace behavior if not removing
279
303
  } // make first matching option active on any keypress (if none of the above special cases match)
@@ -310,9 +334,17 @@ const if_enter_or_space = (handler) => (event) => {
310
334
  }
311
335
  };
312
336
  function on_click_outside(event) {
313
- if (outerDiv && !outerDiv.contains(event.target)) {
314
- close_dropdown(event);
315
- }
337
+ if (!outerDiv)
338
+ return;
339
+ const target = event.target;
340
+ // Check if click is inside the main component
341
+ if (outerDiv.contains(target))
342
+ return;
343
+ // If portal is active, also check if click is inside the portalled options dropdown
344
+ if (portal_params?.active && ul_options && ul_options.contains(target))
345
+ return;
346
+ // Click is outside both the main component and any portalled dropdown
347
+ close_dropdown(event);
316
348
  }
317
349
  let drag_idx = $state(null);
318
350
  // event handlers enable dragging to reorder selected options
@@ -342,27 +374,6 @@ const dragstart = (idx) => (event) => {
342
374
  event.dataTransfer.setData(`text/plain`, `${idx}`);
343
375
  };
344
376
  let ul_options = $state();
345
- // Update highlights whenever search text changes (after ul_options is available)
346
- $effect(() => {
347
- if (ul_options && highlightMatches) {
348
- if (searchText) {
349
- highlight_matching_nodes(ul_options, searchText, noMatchingOptionsMsg);
350
- }
351
- else if (typeof CSS !== `undefined` && CSS.highlights) {
352
- CSS.highlights.delete?.(`sms-search-matches`); // Clear highlights when search text is empty
353
- }
354
- }
355
- });
356
- // highlight text matching user-entered search text in available options
357
- function highlight_matching_options(event) {
358
- if (!highlightMatches || !ul_options)
359
- return;
360
- // get input's search query
361
- const query = event?.target?.value.trim().toLowerCase();
362
- if (!query)
363
- return;
364
- highlight_matching_nodes(ul_options, query, noMatchingOptionsMsg);
365
- }
366
377
  const handle_input_keydown = (event) => {
367
378
  handle_keydown(event); // Restore internal logic
368
379
  // Call original forwarded handler
@@ -392,6 +403,7 @@ $effect(() => {
392
403
  const handle_input_blur = (event) => {
393
404
  // For portalled dropdowns, don't close on blur since clicks on portalled elements
394
405
  // will cause blur but we want to allow the click to register first
406
+ // (otherwise mobile touch event is unable to select options https://github.com/janosh/svelte-multiselect/issues/335)
395
407
  if (portal_params?.active) {
396
408
  onblur?.(event); // Let the click handler manage closing for portalled dropdowns
397
409
  return;
@@ -429,7 +441,7 @@ function portal(node, params) {
429
441
  tick().then(update_position);
430
442
  window.addEventListener(`scroll`, update_position, true);
431
443
  window.addEventListener(`resize`, update_position);
432
- $effect.pre(() => {
444
+ $effect(() => {
433
445
  if (open && target_node)
434
446
  update_position();
435
447
  else
@@ -505,8 +517,7 @@ function portal(node, params) {
505
517
  {:else}
506
518
  <Icon
507
519
  icon="ChevronExpand"
508
- width="15px"
509
- style="min-width: 1em; padding: 0 1pt; cursor: pointer"
520
+ style="width: 15px; min-width: 1em; padding: 0 1pt; cursor: pointer"
510
521
  />
511
522
  {/if}
512
523
  <ul
@@ -561,7 +572,7 @@ function portal(node, params) {
561
572
  {#if removeIcon}
562
573
  {@render removeIcon()}
563
574
  {:else}
564
- <Icon icon="Cross" width="15px" />
575
+ <Icon icon="Cross" style="width: 15px" />
565
576
  {/if}
566
577
  </button>
567
578
  {/if}
@@ -577,13 +588,12 @@ function portal(node, params) {
577
588
  {autocomplete}
578
589
  {inputmode}
579
590
  {pattern}
580
- placeholder={selected.length == 0 ? placeholder : null}
591
+ placeholder={selected.length === 0 ? placeholder : null}
581
592
  aria-invalid={invalid ? `true` : null}
582
593
  ondrop={() => false}
583
594
  onmouseup={open_dropdown}
584
595
  onkeydown={handle_input_keydown}
585
596
  onfocus={handle_input_focus}
586
- oninput={highlight_matching_options}
587
597
  onblur={handle_input_blur}
588
598
  {onclick}
589
599
  {onkeyup}
@@ -619,8 +629,7 @@ function portal(node, params) {
619
629
  {:else}
620
630
  <Icon
621
631
  icon="Disabled"
622
- width="14pt"
623
- style="margin: 0 2pt"
632
+ style="width: 14pt; margin: 0 2pt"
624
633
  data-name="disabled-icon"
625
634
  aria-disabled="true"
626
635
  />
@@ -644,7 +653,7 @@ function portal(node, params) {
644
653
  {#if removeIcon}
645
654
  {@render removeIcon()}
646
655
  {:else}
647
- <Icon icon="Cross" width="15px" />
656
+ <Icon icon="Cross" style="width: 15px" />
648
657
  {/if}
649
658
  </button>
650
659
  {/if}
@@ -654,6 +663,17 @@ function portal(node, params) {
654
663
  {#if (searchText && noMatchingOptionsMsg) || options?.length > 0}
655
664
  <ul
656
665
  use:portal={{ target_node: outerDiv, ...portal_params }}
666
+ {@attach highlight_matches({
667
+ query: searchText,
668
+ disabled: !highlightMatches,
669
+ fuzzy,
670
+ css_class: `sms-search-matches`,
671
+ // don't highlight text in the "Create this option..." message
672
+ node_filter: (node) =>
673
+ node?.parentElement?.closest(`li.user-msg`)
674
+ ? NodeFilter.FILTER_REJECT
675
+ : NodeFilter.FILTER_ACCEPT,
676
+ })}
657
677
  class:hidden={!open}
658
678
  class="options {ulOptionsClass}"
659
679
  role="listbox"
@@ -663,10 +683,13 @@ function portal(node, params) {
663
683
  bind:this={ul_options}
664
684
  style={ulOptionsStyle}
665
685
  >
666
- {#each matchingOptions.slice(0, Math.max(0, maxOptions ?? 0) || Infinity) as
667
- optionItem,
686
+ {#each matchingOptions.slice(
687
+ 0,
688
+ maxOptions == null ? Infinity : Math.max(0, maxOptions),
689
+ ) as
690
+ option_item,
668
691
  idx
669
- (duplicates ? [key(optionItem), idx] : key(optionItem))
692
+ (duplicates ? [key(option_item), idx] : key(option_item))
670
693
  }
671
694
  {@const {
672
695
  label,
@@ -674,21 +697,22 @@ function portal(node, params) {
674
697
  title = null,
675
698
  selectedTitle = null,
676
699
  disabledTitle = defaultDisabledTitle,
677
- } = optionItem instanceof Object ? optionItem : { label: optionItem }}
700
+ } = option_item instanceof Object ? option_item : { label: option_item }}
678
701
  {@const active = activeIndex === idx}
702
+ {@const selected = is_selected(label)}
679
703
  {@const optionStyle =
680
- [get_style(optionItem, `option`), liOptionStyle].filter(Boolean).join(
704
+ [get_style(option_item, `option`), liOptionStyle].filter(Boolean).join(
681
705
  ` `,
682
706
  ) ||
683
707
  null}
684
708
  <li
685
709
  onclick={(event) => {
686
710
  if (disabled) return
687
- if (keepSelectedInDropdown) toggle_option(optionItem, event)
688
- else add(optionItem, event)
711
+ if (keepSelectedInDropdown) toggle_option(option_item, event)
712
+ else add(option_item, event)
689
713
  }}
690
- title={disabled ? disabledTitle : (is_selected(label) && selectedTitle) || title}
691
- class:selected={is_selected(label)}
714
+ title={disabled ? disabledTitle : (selected && selectedTitle) || title}
715
+ class:selected
692
716
  class:active
693
717
  class:disabled
694
718
  class="{liOptionClass} {active ? liActiveOptionClass : ``}"
@@ -699,13 +723,13 @@ function portal(node, params) {
699
723
  if (!disabled) activeIndex = idx
700
724
  }}
701
725
  role="option"
702
- aria-selected="false"
726
+ aria-selected={selected ? `true` : `false`}
703
727
  style={optionStyle}
704
728
  onkeydown={(event) => {
705
729
  if (!disabled && (event.key === `Enter` || event.code === `Space`)) {
706
730
  event.preventDefault()
707
- if (keepSelectedInDropdown) toggle_option(optionItem, event)
708
- else add(optionItem, event)
731
+ if (keepSelectedInDropdown) toggle_option(option_item, event)
732
+ else add(option_item, event)
709
733
  }
710
734
  }}
711
735
  >
@@ -713,25 +737,25 @@ function portal(node, params) {
713
737
  <input
714
738
  type="checkbox"
715
739
  class="option-checkbox"
716
- checked={is_selected(label)}
717
- aria-label="Toggle {get_label(optionItem)}"
740
+ checked={selected}
741
+ aria-label="Toggle {get_label(option_item)}"
718
742
  tabindex="-1"
719
743
  />
720
744
  {/if}
721
745
  {#if option}
722
746
  {@render option({
723
- option: optionItem,
747
+ option: option_item,
724
748
  idx,
725
749
  })}
726
750
  {:else if children}
727
751
  {@render children({
728
- option: optionItem,
752
+ option: option_item,
729
753
  idx,
730
754
  })}
731
755
  {:else if parseLabelsAsHtml}
732
- {@html get_label(optionItem)}
756
+ {@html get_label(option_item)}
733
757
  {:else}
734
- {get_label(optionItem)}
758
+ {get_label(option_item)}
735
759
  {/if}
736
760
  </li>
737
761
  {/each}
@@ -739,7 +763,7 @@ function portal(node, params) {
739
763
  {@const text_input_is_duplicate = selected.map(get_label).includes(searchText)}
740
764
  {@const is_dupe = !duplicates && text_input_is_duplicate && `dupe`}
741
765
  {@const can_create = Boolean(allowUserOptions && createOptionMsg) && `create`}
742
- {@const no_match = Boolean(matchingOptions?.length == 0 && noMatchingOptionsMsg) &&
766
+ {@const no_match = Boolean(matchingOptions?.length === 0 && noMatchingOptionsMsg) &&
743
767
  `no-match`}
744
768
  {@const msgType = is_dupe || can_create || no_match}
745
769
  {#if msgType}
@@ -1,25 +1,25 @@
1
- import type { MultiSelectProps, Option as T } from './types';
2
- declare function $$render<Option extends T>(): {
3
- props: MultiSelectProps;
1
+ import type { MultiSelectProps } from './types';
2
+ declare function $$render<Option extends import('./types').Option>(): {
3
+ props: MultiSelectProps<Option>;
4
4
  exports: {};
5
- bindings: "value" | "selected" | "invalid" | "open" | "activeIndex" | "activeOption" | "form_input" | "input" | "matchingOptions" | "options" | "outerDiv" | "searchText";
5
+ bindings: "input" | "invalid" | "value" | "selected" | "open" | "activeIndex" | "activeOption" | "form_input" | "matchingOptions" | "options" | "outerDiv" | "searchText";
6
6
  slots: {};
7
7
  events: {};
8
8
  };
9
- declare class __sveltets_Render<Option extends T> {
9
+ declare class __sveltets_Render<Option extends import('./types').Option> {
10
10
  props(): ReturnType<typeof $$render<Option>>['props'];
11
11
  events(): ReturnType<typeof $$render<Option>>['events'];
12
12
  slots(): ReturnType<typeof $$render<Option>>['slots'];
13
- bindings(): "value" | "selected" | "invalid" | "open" | "activeIndex" | "activeOption" | "form_input" | "input" | "matchingOptions" | "options" | "outerDiv" | "searchText";
13
+ bindings(): "input" | "invalid" | "value" | "selected" | "open" | "activeIndex" | "activeOption" | "form_input" | "matchingOptions" | "options" | "outerDiv" | "searchText";
14
14
  exports(): {};
15
15
  }
16
16
  interface $$IsomorphicComponent {
17
- new <Option extends T>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<Option>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<Option>['props']>, ReturnType<__sveltets_Render<Option>['events']>, ReturnType<__sveltets_Render<Option>['slots']>> & {
17
+ new <Option extends import('./types').Option>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<Option>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<Option>['props']>, ReturnType<__sveltets_Render<Option>['events']>, ReturnType<__sveltets_Render<Option>['slots']>> & {
18
18
  $$bindings?: ReturnType<__sveltets_Render<Option>['bindings']>;
19
19
  } & ReturnType<__sveltets_Render<Option>['exports']>;
20
- <Option extends T>(internal: unknown, props: ReturnType<__sveltets_Render<Option>['props']> & {}): ReturnType<__sveltets_Render<Option>['exports']>;
20
+ <Option extends import('./types').Option>(internal: unknown, props: ReturnType<__sveltets_Render<Option>['props']> & {}): ReturnType<__sveltets_Render<Option>['exports']>;
21
21
  z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
22
22
  }
23
23
  declare const MultiSelect: $$IsomorphicComponent;
24
- type MultiSelect<Option extends T> = InstanceType<typeof MultiSelect<Option>>;
24
+ type MultiSelect<Option extends import('./types').Option> = InstanceType<typeof MultiSelect<Option>>;
25
25
  export default MultiSelect;