svelte-multiselect 11.2.3 → 11.2.4

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,26 +1,30 @@
1
- <script lang="ts">import { MultiSelect } from './';
2
- import { fade } from 'svelte/transition';
1
+ <script lang="ts">import { fade } from 'svelte/transition';
2
+ import MultiSelect from './MultiSelect.svelte';
3
3
  let { actions, triggers = [`k`], close_keys = [`Escape`], fade_duration = 200, dialog_style = ``, open = $bindable(false), dialog = $bindable(null), input = $bindable(null), placeholder = `Filter actions...`, ...rest } = $props();
4
4
  $effect(() => {
5
- if (open && input && document.activeElement !== input) {
5
+ if (open && input && document.activeElement !== input)
6
6
  input.focus();
7
- }
8
7
  });
9
8
  async function toggle(event) {
10
- if (triggers.includes(event.key) && event.metaKey && !open) {
9
+ const is_trigger = triggers.includes(event.key) &&
10
+ (event.metaKey || event.ctrlKey);
11
+ if (is_trigger && !open)
11
12
  open = true;
12
- }
13
- else if (close_keys.includes(event.key) && open) {
13
+ else if (close_keys.includes(event.key) && open)
14
14
  open = false;
15
- }
16
15
  }
17
16
  function close_if_outside(event) {
18
- if (open && !dialog?.contains(event.target)) {
17
+ const target = event.target;
18
+ if (!target || !(target instanceof HTMLElement))
19
+ return;
20
+ if (open && !dialog?.contains(target) && !target.closest(`ul.options`)) {
19
21
  open = false;
20
22
  }
21
23
  }
22
- function trigger_action_and_close(data) {
23
- const { action, label } = data.option;
24
+ function trigger_action_and_close({ option }) {
25
+ const { action, label } = (option ?? {});
26
+ if (!action)
27
+ return;
24
28
  action(label);
25
29
  open = false;
26
30
  }
@@ -1,9 +1,11 @@
1
- import type { MultiSelectProps, ObjectOption } from './types';
1
+ import type { ComponentProps } from 'svelte';
2
+ import MultiSelect from './MultiSelect.svelte';
3
+ import type { ObjectOption } from './types';
2
4
  interface Action extends ObjectOption {
3
5
  label: string;
4
6
  action: (label: string) => void;
5
7
  }
6
- interface Props extends Omit<MultiSelectProps<Action>, `options`> {
8
+ interface Props extends Omit<ComponentProps<typeof MultiSelect<Action>>, `options`> {
7
9
  actions: Action[];
8
10
  triggers?: string[];
9
11
  close_keys?: string[];
@@ -14,6 +16,6 @@ interface Props extends Omit<MultiSelectProps<Action>, `options`> {
14
16
  input?: HTMLInputElement | null;
15
17
  placeholder?: string;
16
18
  }
17
- declare const CmdPalette: import("svelte").Component<Props, {}, "open" | "input" | "dialog">;
19
+ declare const CmdPalette: import("svelte").Component<Props, {}, "dialog" | "input" | "open">;
18
20
  type CmdPalette = ReturnType<typeof CmdPalette>;
19
21
  export default CmdPalette;
@@ -10,13 +10,22 @@ $effect(() => {
10
10
  return;
11
11
  const apply_copy_buttons = () => {
12
12
  const btn_style = `position: absolute; top: 9pt; right: 9pt; ${rest.style ?? ``}`;
13
+ const skip_sel = skip_selector ?? as;
13
14
  for (const code of document.querySelectorAll(global_selector ?? `pre > code`)) {
14
15
  const pre = code.parentElement;
15
16
  const content = code.textContent ?? ``;
16
- if (pre && !(skip_selector && pre.querySelector(skip_selector))) {
17
+ if (pre && !pre.querySelector(`[data-sms-copy]`) &&
18
+ !(skip_sel && pre.querySelector(skip_sel))) {
17
19
  mount(CopyButton, {
18
20
  target: pre,
19
- props: { content, as, labels, ...rest, style: btn_style },
21
+ props: {
22
+ content,
23
+ as,
24
+ labels,
25
+ ...rest,
26
+ style: btn_style,
27
+ 'data-sms-copy': ``,
28
+ },
20
29
  });
21
30
  }
22
31
  }
@@ -41,7 +50,20 @@ async function copy() {
41
50
 
42
51
  {#if !(global || global_selector)}
43
52
  {@const { text, icon } = labels[state]}
44
- <svelte:element this={as} onclick={copy} role="button" tabindex={0} {...rest}>
53
+ <svelte:element
54
+ this={as}
55
+ onclick={copy}
56
+ onkeydown={(event) => {
57
+ if (event.key === `Enter` || event.key === ` `) {
58
+ event.preventDefault()
59
+ copy()
60
+ }
61
+ }}
62
+ role="button"
63
+ tabindex={0}
64
+ data-sms-copy=""
65
+ {...rest}
66
+ >
45
67
  {#if children}
46
68
  {@render children({ state, icon, text })}
47
69
  {:else}
@@ -1,8 +1,9 @@
1
1
  import { CopyButton } from './';
2
2
  import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
3
4
  import type { IconName } from './icons';
4
5
  type State = `ready` | `success` | `error`;
5
- interface Props {
6
+ interface Props extends Omit<HTMLAttributes<HTMLButtonElement>, `children`> {
6
7
  content?: string;
7
8
  state?: State;
8
9
  global_selector?: string | null;
@@ -18,7 +19,6 @@ interface Props {
18
19
  icon: IconName;
19
20
  text: string;
20
21
  }]>;
21
- [key: string]: unknown;
22
22
  }
23
23
  declare const CopyButton: import("svelte").Component<Props, {}, "state">;
24
24
  type CopyButton = ReturnType<typeof CopyButton>;
@@ -1,7 +1,7 @@
1
+ import type { HTMLAttributes } from 'svelte/elements';
1
2
  import { type IconName } from './icons';
2
- interface Props {
3
+ interface Props extends HTMLAttributes<SVGSVGElement> {
3
4
  icon: IconName;
4
- [key: string]: unknown;
5
5
  }
6
6
  declare const Icon: import("svelte").Component<Props, {}, "">;
7
7
  type Icon = ReturnType<typeof Icon>;
@@ -1,12 +1,18 @@
1
- <script lang="ts">import { CircleSpinner, Icon, Wiggle } from './';
2
- import { tick } from 'svelte';
1
+ <script lang="ts">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
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(``), selected = $bindable(options
10
16
  ?.filter((opt) => opt instanceof Object && opt?.preselected)
11
17
  .slice(0, maxSelect ?? undefined) ?? []), sortSelected = false, selectedOptionsDraggable = !sortSelected, style = null, ulOptionsClass = ``, ulSelectedClass = ``, ulSelectedStyle = null, ulOptionsStyle = null, value = $bindable(null), expandIcon, selectedItem, children, removeIcon, afterInput, spinner, disabledIcon, option, userMsg, onblur, onclick, onfocus, onkeydown, onkeyup, onmousedown, onmouseenter, onmouseleave, ontouchcancel, ontouchend, ontouchmove, ontouchstart, onadd, oncreate, onremove, onremoveAll, onchange, onopen, onclose, portal: portal_params = {}, ...rest } = $props();
12
18
  $effect.pre(() => {
@@ -257,11 +263,25 @@ async function handle_keydown(event) {
257
263
  // if none of the above special cases apply, we make next/prev option
258
264
  // active with wrap around at both ends
259
265
  const increment = event.key === `ArrowUp` ? -1 : 1;
260
- activeIndex = (activeIndex + increment) % matchingOptions.length;
266
+ // Include user message in total count if it exists
267
+ const has_user_msg = searchText && ((allowUserOptions && createOptionMsg) ||
268
+ (!duplicates && selected.map(get_label).includes(searchText)) ||
269
+ (matchingOptions.length === 0 && noMatchingOptionsMsg));
270
+ const total_items = matchingOptions.length + (has_user_msg ? 1 : 0);
271
+ activeIndex = (activeIndex + increment) % total_items;
261
272
  // in JS % behaves like remainder operator, not real modulo, so negative numbers stay negative
262
273
  // need to do manual wrap around at 0
263
274
  if (activeIndex < 0)
264
- activeIndex = matchingOptions.length - 1;
275
+ activeIndex = total_items - 1;
276
+ // Handle user message activation
277
+ if (has_user_msg && activeIndex === matchingOptions.length) {
278
+ option_msg_is_active = true;
279
+ activeOption = null;
280
+ }
281
+ else {
282
+ option_msg_is_active = false;
283
+ activeOption = matchingOptions[activeIndex] ?? null;
284
+ }
265
285
  if (autoScroll) {
266
286
  await tick();
267
287
  const li = document.querySelector(`ul.options > li.active`);
@@ -273,7 +293,9 @@ async function handle_keydown(event) {
273
293
  event.stopPropagation();
274
294
  // Only remove option if it wouldn't violate minSelect
275
295
  if (minSelect === null || selected.length > minSelect) {
276
- remove(selected.at(-1), event);
296
+ const last_option = selected.at(-1);
297
+ if (last_option)
298
+ remove(last_option, event);
277
299
  }
278
300
  // Don't prevent default, allow normal backspace behavior if not removing
279
301
  } // make first matching option active on any keypress (if none of the above special cases match)
@@ -310,9 +332,17 @@ const if_enter_or_space = (handler) => (event) => {
310
332
  }
311
333
  };
312
334
  function on_click_outside(event) {
313
- if (outerDiv && !outerDiv.contains(event.target)) {
314
- close_dropdown(event);
315
- }
335
+ if (!outerDiv)
336
+ return;
337
+ const target = event.target;
338
+ // Check if click is inside the main component
339
+ if (outerDiv.contains(target))
340
+ return;
341
+ // If portal is active, also check if click is inside the portalled options dropdown
342
+ if (portal_params?.active && ul_options && ul_options.contains(target))
343
+ return;
344
+ // Click is outside both the main component and any portalled dropdown
345
+ close_dropdown(event);
316
346
  }
317
347
  let drag_idx = $state(null);
318
348
  // event handlers enable dragging to reorder selected options
@@ -342,27 +372,6 @@ const dragstart = (idx) => (event) => {
342
372
  event.dataTransfer.setData(`text/plain`, `${idx}`);
343
373
  };
344
374
  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
375
  const handle_input_keydown = (event) => {
367
376
  handle_keydown(event); // Restore internal logic
368
377
  // Call original forwarded handler
@@ -392,6 +401,7 @@ $effect(() => {
392
401
  const handle_input_blur = (event) => {
393
402
  // For portalled dropdowns, don't close on blur since clicks on portalled elements
394
403
  // will cause blur but we want to allow the click to register first
404
+ // (otherwise mobile touch event is unable to select options https://github.com/janosh/svelte-multiselect/issues/335)
395
405
  if (portal_params?.active) {
396
406
  onblur?.(event); // Let the click handler manage closing for portalled dropdowns
397
407
  return;
@@ -429,7 +439,7 @@ function portal(node, params) {
429
439
  tick().then(update_position);
430
440
  window.addEventListener(`scroll`, update_position, true);
431
441
  window.addEventListener(`resize`, update_position);
432
- $effect.pre(() => {
442
+ $effect(() => {
433
443
  if (open && target_node)
434
444
  update_position();
435
445
  else
@@ -505,8 +515,7 @@ function portal(node, params) {
505
515
  {:else}
506
516
  <Icon
507
517
  icon="ChevronExpand"
508
- width="15px"
509
- style="min-width: 1em; padding: 0 1pt; cursor: pointer"
518
+ style="width: 15px; min-width: 1em; padding: 0 1pt; cursor: pointer"
510
519
  />
511
520
  {/if}
512
521
  <ul
@@ -561,7 +570,7 @@ function portal(node, params) {
561
570
  {#if removeIcon}
562
571
  {@render removeIcon()}
563
572
  {:else}
564
- <Icon icon="Cross" width="15px" />
573
+ <Icon icon="Cross" style="width: 15px" />
565
574
  {/if}
566
575
  </button>
567
576
  {/if}
@@ -577,13 +586,12 @@ function portal(node, params) {
577
586
  {autocomplete}
578
587
  {inputmode}
579
588
  {pattern}
580
- placeholder={selected.length == 0 ? placeholder : null}
589
+ placeholder={selected.length === 0 ? placeholder : null}
581
590
  aria-invalid={invalid ? `true` : null}
582
591
  ondrop={() => false}
583
592
  onmouseup={open_dropdown}
584
593
  onkeydown={handle_input_keydown}
585
594
  onfocus={handle_input_focus}
586
- oninput={highlight_matching_options}
587
595
  onblur={handle_input_blur}
588
596
  {onclick}
589
597
  {onkeyup}
@@ -619,8 +627,7 @@ function portal(node, params) {
619
627
  {:else}
620
628
  <Icon
621
629
  icon="Disabled"
622
- width="14pt"
623
- style="margin: 0 2pt"
630
+ style="width: 14pt; margin: 0 2pt"
624
631
  data-name="disabled-icon"
625
632
  aria-disabled="true"
626
633
  />
@@ -644,7 +651,7 @@ function portal(node, params) {
644
651
  {#if removeIcon}
645
652
  {@render removeIcon()}
646
653
  {:else}
647
- <Icon icon="Cross" width="15px" />
654
+ <Icon icon="Cross" style="width: 15px" />
648
655
  {/if}
649
656
  </button>
650
657
  {/if}
@@ -654,6 +661,17 @@ function portal(node, params) {
654
661
  {#if (searchText && noMatchingOptionsMsg) || options?.length > 0}
655
662
  <ul
656
663
  use:portal={{ target_node: outerDiv, ...portal_params }}
664
+ {@attach highlight_matches({
665
+ query: searchText,
666
+ disabled: !highlightMatches,
667
+ fuzzy,
668
+ css_class: `sms-search-matches`,
669
+ // don't highlight text in the "Create this option..." message
670
+ node_filter: (node) =>
671
+ node?.parentElement?.closest(`li.user-msg`)
672
+ ? NodeFilter.FILTER_REJECT
673
+ : NodeFilter.FILTER_ACCEPT,
674
+ })}
657
675
  class:hidden={!open}
658
676
  class="options {ulOptionsClass}"
659
677
  role="listbox"
@@ -663,10 +681,13 @@ function portal(node, params) {
663
681
  bind:this={ul_options}
664
682
  style={ulOptionsStyle}
665
683
  >
666
- {#each matchingOptions.slice(0, Math.max(0, maxOptions ?? 0) || Infinity) as
667
- optionItem,
684
+ {#each matchingOptions.slice(
685
+ 0,
686
+ maxOptions == null ? Infinity : Math.max(0, maxOptions),
687
+ ) as
688
+ option_item,
668
689
  idx
669
- (duplicates ? [key(optionItem), idx] : key(optionItem))
690
+ (duplicates ? [key(option_item), idx] : key(option_item))
670
691
  }
671
692
  {@const {
672
693
  label,
@@ -674,21 +695,22 @@ function portal(node, params) {
674
695
  title = null,
675
696
  selectedTitle = null,
676
697
  disabledTitle = defaultDisabledTitle,
677
- } = optionItem instanceof Object ? optionItem : { label: optionItem }}
698
+ } = option_item instanceof Object ? option_item : { label: option_item }}
678
699
  {@const active = activeIndex === idx}
700
+ {@const selected = is_selected(label)}
679
701
  {@const optionStyle =
680
- [get_style(optionItem, `option`), liOptionStyle].filter(Boolean).join(
702
+ [get_style(option_item, `option`), liOptionStyle].filter(Boolean).join(
681
703
  ` `,
682
704
  ) ||
683
705
  null}
684
706
  <li
685
707
  onclick={(event) => {
686
708
  if (disabled) return
687
- if (keepSelectedInDropdown) toggle_option(optionItem, event)
688
- else add(optionItem, event)
709
+ if (keepSelectedInDropdown) toggle_option(option_item, event)
710
+ else add(option_item, event)
689
711
  }}
690
- title={disabled ? disabledTitle : (is_selected(label) && selectedTitle) || title}
691
- class:selected={is_selected(label)}
712
+ title={disabled ? disabledTitle : (selected && selectedTitle) || title}
713
+ class:selected
692
714
  class:active
693
715
  class:disabled
694
716
  class="{liOptionClass} {active ? liActiveOptionClass : ``}"
@@ -699,13 +721,13 @@ function portal(node, params) {
699
721
  if (!disabled) activeIndex = idx
700
722
  }}
701
723
  role="option"
702
- aria-selected="false"
724
+ aria-selected={selected ? `true` : `false`}
703
725
  style={optionStyle}
704
726
  onkeydown={(event) => {
705
727
  if (!disabled && (event.key === `Enter` || event.code === `Space`)) {
706
728
  event.preventDefault()
707
- if (keepSelectedInDropdown) toggle_option(optionItem, event)
708
- else add(optionItem, event)
729
+ if (keepSelectedInDropdown) toggle_option(option_item, event)
730
+ else add(option_item, event)
709
731
  }
710
732
  }}
711
733
  >
@@ -713,25 +735,25 @@ function portal(node, params) {
713
735
  <input
714
736
  type="checkbox"
715
737
  class="option-checkbox"
716
- checked={is_selected(label)}
717
- aria-label="Toggle {get_label(optionItem)}"
738
+ checked={selected}
739
+ aria-label="Toggle {get_label(option_item)}"
718
740
  tabindex="-1"
719
741
  />
720
742
  {/if}
721
743
  {#if option}
722
744
  {@render option({
723
- option: optionItem,
745
+ option: option_item,
724
746
  idx,
725
747
  })}
726
748
  {:else if children}
727
749
  {@render children({
728
- option: optionItem,
750
+ option: option_item,
729
751
  idx,
730
752
  })}
731
753
  {:else if parseLabelsAsHtml}
732
- {@html get_label(optionItem)}
754
+ {@html get_label(option_item)}
733
755
  {:else}
734
- {get_label(optionItem)}
756
+ {get_label(option_item)}
735
757
  {/if}
736
758
  </li>
737
759
  {/each}
@@ -739,7 +761,7 @@ function portal(node, params) {
739
761
  {@const text_input_is_duplicate = selected.map(get_label).includes(searchText)}
740
762
  {@const is_dupe = !duplicates && text_input_is_duplicate && `dupe`}
741
763
  {@const can_create = Boolean(allowUserOptions && createOptionMsg) && `create`}
742
- {@const no_match = Boolean(matchingOptions?.length == 0 && noMatchingOptionsMsg) &&
764
+ {@const no_match = Boolean(matchingOptions?.length === 0 && noMatchingOptionsMsg) &&
743
765
  `no-match`}
744
766
  {@const msgType = is_dupe || can_create || no_match}
745
767
  {#if msgType}
@@ -2,7 +2,7 @@ import type { MultiSelectProps, Option as T } from './types';
2
2
  declare function $$render<Option extends T>(): {
3
3
  props: MultiSelectProps;
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
  };
@@ -10,7 +10,7 @@ declare class __sveltets_Render<Option extends T> {
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 {
@@ -1,7 +1,4 @@
1
- <script lang="ts">let { items = [], node = `nav`, current = ``, log = `errors`, nav_options = { replace_state: true, no_scroll: true }, titles = { prev: `&larr; Previous`, next: `Next &rarr;` }, onkeyup = ({ prev, next }) => ({
2
- ArrowLeft: prev[0],
3
- ArrowRight: next[0],
4
- }), prev_snippet, children, between, next_snippet, ...rest } = $props();
1
+ <script lang="ts">let { items = [], node = `nav`, current = ``, log = `errors`, nav_options = { replace_state: true, no_scroll: true }, titles = { prev: `&larr; Previous`, next: `Next &rarr;` }, onkeyup = ({ prev, next }) => ({ ArrowLeft: prev[0], ArrowRight: next[0] }), prev_snippet, children, between, next_snippet, min_items = 3, ...rest } = $props();
5
2
  // Convert items to consistent [key, value] format
6
3
  let items_arr = $derived((items ?? []).map((itm) => (typeof itm === `string` ? [itm, itm] : itm)));
7
4
  // Calculate prev/next items with wraparound
@@ -11,8 +8,8 @@ let next = $derived(items_arr[idx + 1] ?? items_arr[0]);
11
8
  // Validation and logging
12
9
  $effect.pre(() => {
13
10
  if (log !== `silent`) {
14
- if (items_arr.length < 2 && log === `verbose`) {
15
- console.warn(`PrevNext received ${items_arr.length} items - minimum of 2 expected`);
11
+ if (items_arr.length < min_items && log === `verbose`) {
12
+ console.warn(`PrevNext received ${items_arr.length} items - minimum of ${min_items} expected`);
16
13
  }
17
14
  if (idx < 0 && log === `errors`) {
18
15
  const valid = items_arr.map(([key]) => key);
@@ -23,9 +20,13 @@ $effect.pre(() => {
23
20
  function handle_keyup(event) {
24
21
  if (!onkeyup)
25
22
  return;
23
+ if ((items_arr?.length ?? 0) < min_items)
24
+ return;
25
+ if (!prev || !next)
26
+ return;
26
27
  const key_map = onkeyup({ prev, next });
27
28
  const to = key_map[event.key];
28
- if (to) {
29
+ if (to !== undefined) {
29
30
  const { replace_state, no_scroll } = nav_options;
30
31
  const [scroll_x, scroll_y] = no_scroll
31
32
  ? [window.scrollX, window.scrollY]
@@ -41,7 +42,7 @@ export {};
41
42
 
42
43
  <svelte:window onkeyup={handle_keyup} />
43
44
 
44
- {#if items_arr.length > 2}
45
+ {#if items_arr.length >= min_items}
45
46
  <svelte:element this={node} class="prev-next" {...rest}>
46
47
  <!-- ensures `prev` is a defined [key, value] tuple.
47
48
  Due to prior normalization of the `items` prop, any defined `prev` item
@@ -1,55 +1,36 @@
1
1
  import type { Snippet } from 'svelte';
2
- export type Item = string | [string, unknown];
3
- declare function $$render<T extends Item>(): {
4
- props: {
5
- [key: string]: unknown;
6
- items?: T[];
7
- node?: string;
8
- current?: string;
9
- log?: `verbose` | `errors` | `silent`;
10
- nav_options?: {
11
- replace_state: boolean;
12
- no_scroll: boolean;
13
- };
14
- titles?: {
15
- prev: string;
16
- next: string;
17
- };
18
- onkeyup?: ((obj: {
19
- prev: Item;
20
- next: Item;
21
- }) => Record<string, string>) | null;
22
- prev_snippet?: Snippet<[{
23
- item: Item;
24
- }]>;
25
- children?: Snippet<[{
26
- kind: `prev` | `next`;
27
- item: Item;
28
- }]>;
29
- between?: Snippet<[]>;
30
- next_snippet?: Snippet<[{
31
- item: Item;
32
- }]>;
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ export type Item = [string, unknown];
4
+ interface Props extends Omit<HTMLAttributes<HTMLElement>, `children` | `onkeyup`> {
5
+ items?: (string | Item)[];
6
+ node?: string;
7
+ current?: string;
8
+ log?: `verbose` | `errors` | `silent`;
9
+ nav_options?: {
10
+ replace_state: boolean;
11
+ no_scroll: boolean;
33
12
  };
34
- exports: {};
35
- bindings: "";
36
- slots: {};
37
- events: {};
38
- };
39
- declare class __sveltets_Render<T extends Item> {
40
- props(): ReturnType<typeof $$render<T>>['props'];
41
- events(): ReturnType<typeof $$render<T>>['events'];
42
- slots(): ReturnType<typeof $$render<T>>['slots'];
43
- bindings(): "";
44
- exports(): {};
45
- }
46
- interface $$IsomorphicComponent {
47
- new <T extends Item>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
48
- $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
49
- } & ReturnType<__sveltets_Render<T>['exports']>;
50
- <T extends Item>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
51
- z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
13
+ titles?: {
14
+ prev: string;
15
+ next: string;
16
+ };
17
+ onkeyup?: ((obj: {
18
+ prev: Item;
19
+ next: Item;
20
+ }) => Record<string, string | undefined>) | null;
21
+ prev_snippet?: Snippet<[{
22
+ item: Item;
23
+ }]>;
24
+ children?: Snippet<[{
25
+ kind: `prev` | `next`;
26
+ item: Item;
27
+ }]>;
28
+ between?: Snippet<[]>;
29
+ next_snippet?: Snippet<[{
30
+ item: Item;
31
+ }]>;
32
+ min_items?: number;
52
33
  }
53
- declare const PrevNext: $$IsomorphicComponent;
54
- type PrevNext<T extends Item> = InstanceType<typeof PrevNext<T>>;
34
+ declare const PrevNext: import("svelte").Component<Props, {}, "">;
35
+ type PrevNext = ReturnType<typeof PrevNext>;
55
36
  export default PrevNext;
@@ -4,7 +4,8 @@ function handle_keydown(event) {
4
4
  onkeydown?.(event);
5
5
  if (event.key === `Enter`) {
6
6
  event.preventDefault();
7
- checked = !checked;
7
+ const target = event.target;
8
+ target.click(); // simulate real user toggle so 'change' is dispatched
8
9
  }
9
10
  }
10
11
  export {};
@@ -1,5 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
- interface Props {
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ interface Props extends HTMLAttributes<HTMLLabelElement> {
3
4
  checked?: boolean;
4
5
  required?: boolean;
5
6
  input_style?: string;
@@ -9,7 +10,6 @@ interface Props {
9
10
  onblur?: (event: FocusEvent) => void;
10
11
  onkeydown?: (event: KeyboardEvent) => void;
11
12
  children?: Snippet<[]>;
12
- [key: string]: unknown;
13
13
  }
14
14
  declare const Toggle: import("svelte").Component<Props, {}, "checked">;
15
15
  type Toggle = ReturnType<typeof Toggle>;
@@ -25,22 +25,25 @@ export declare const sortable: ({ header_selector, asc_class, desc_class, sorted
25
25
  backgroundColor: string;
26
26
  } | undefined;
27
27
  }) => (node: HTMLElement) => () => void;
28
- type HighlightOptions = {
28
+ export type HighlightOptions = {
29
29
  query?: string;
30
30
  disabled?: boolean;
31
+ fuzzy?: boolean;
31
32
  node_filter?: (node: Node) => number;
32
33
  css_class?: string;
33
34
  };
34
- export declare const highlight_matches: (ops: HighlightOptions) => (node: HTMLElement) => (() => void) | undefined;
35
- export declare const tooltip: (options?: {
35
+ export declare const highlight_matches: (ops: HighlightOptions) => (node: HTMLElement) => (() => boolean) | undefined;
36
+ export interface TooltipOptions {
36
37
  content?: string;
37
38
  placement?: `top` | `bottom` | `left` | `right`;
38
39
  delay?: number;
39
- }) => Attachment;
40
- type ClickOutsideConfig<T extends HTMLElement> = {
40
+ disabled?: boolean;
41
+ style?: string;
42
+ }
43
+ export declare const tooltip: (options?: TooltipOptions) => Attachment;
44
+ export type ClickOutsideConfig<T extends HTMLElement> = {
41
45
  enabled?: boolean;
42
46
  exclude?: string[];
43
47
  callback?: (node: T, config: ClickOutsideConfig<T>) => void;
44
48
  };
45
49
  export declare const click_outside: <T extends HTMLElement>(config?: ClickOutsideConfig<T>) => (node: T) => () => void;
46
- export {};
@@ -158,11 +158,15 @@ export const sortable = ({ header_selector = `thead th`, asc_class = `table-sort
158
158
  };
159
159
  };
160
160
  export const highlight_matches = (ops) => (node) => {
161
- const { query = ``, disabled = false, node_filter = () => NodeFilter.FILTER_ACCEPT, css_class = `highlight-match`, } = ops;
162
- // clear previous ranges from HighlightRegistry
163
- CSS.highlights.clear();
164
- if (!query || disabled || typeof CSS === `undefined` || !CSS.highlights)
165
- return; // abort if CSS highlight API not supported
161
+ const { query = ``, disabled = false, fuzzy = false, node_filter = () => NodeFilter.FILTER_ACCEPT, css_class = `highlight-match`, } = ops;
162
+ // abort if CSS highlight API not supported
163
+ if (typeof CSS === `undefined` || !CSS.highlights)
164
+ return;
165
+ // always clear our own highlight first
166
+ CSS.highlights.delete(css_class);
167
+ // if disabled or empty query, stop after cleanup
168
+ if (!query || disabled)
169
+ return;
166
170
  const tree_walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, {
167
171
  acceptNode: node_filter,
168
172
  });
@@ -175,28 +179,57 @@ export const highlight_matches = (ops) => (node) => {
175
179
  // iterate over all text nodes and find matches
176
180
  const ranges = text_nodes.map((el) => {
177
181
  const text = el.textContent?.toLowerCase();
178
- const indices = [];
179
- let start_pos = 0;
180
- while (text && start_pos < text.length) {
181
- const index = text.indexOf(query, start_pos);
182
- if (index === -1)
183
- break;
184
- indices.push(index);
185
- start_pos = index + query.length;
182
+ if (!text)
183
+ return [];
184
+ const search = query.toLowerCase();
185
+ if (fuzzy) {
186
+ // Fuzzy highlighting: highlight individual characters that match in order
187
+ const matching_indices = [];
188
+ let search_idx = 0;
189
+ let target_idx = 0;
190
+ // Find matching character indices
191
+ while (search_idx < search.length && target_idx < text.length) {
192
+ if (search[search_idx] === text[target_idx]) {
193
+ matching_indices.push(target_idx);
194
+ search_idx++;
195
+ }
196
+ target_idx++;
197
+ }
198
+ // Only create ranges if we found all characters in order
199
+ if (search_idx === search.length) {
200
+ return matching_indices.map((index) => {
201
+ const range = new Range();
202
+ range.setStart(el, index);
203
+ range.setEnd(el, index + 1); // highlight single character
204
+ return range;
205
+ });
206
+ }
207
+ return [];
208
+ }
209
+ else {
210
+ // Substring highlighting: highlight consecutive substrings
211
+ const indices = [];
212
+ let start_pos = 0;
213
+ while (start_pos < text.length) {
214
+ const index = text.indexOf(search, start_pos);
215
+ if (index === -1)
216
+ break;
217
+ indices.push(index);
218
+ start_pos = index + search.length;
219
+ }
220
+ // create range object for each substring found in the text node
221
+ return indices.map((index) => {
222
+ const range = new Range();
223
+ range.setStart(el, index);
224
+ range.setEnd(el, index + search.length);
225
+ return range;
226
+ });
186
227
  }
187
- // create range object for each str found in the text node
188
- return indices.map((index) => {
189
- const range = new Range();
190
- range.setStart(el, index);
191
- range.setEnd(el, index + query?.length);
192
- return range;
193
- });
194
228
  });
195
229
  // create Highlight object from ranges and add to registry
196
230
  CSS.highlights.set(css_class, new Highlight(...ranges.flat()));
197
- return () => {
198
- CSS.highlights.delete(css_class);
199
- };
231
+ // Return cleanup function
232
+ return () => CSS.highlights.delete(css_class);
200
233
  };
201
234
  // Global tooltip state to ensure only one tooltip is shown at a time
202
235
  let current_tooltip = null;
@@ -220,7 +253,7 @@ export const tooltip = (options = {}) => (node) => {
220
253
  const safe_options = options || {};
221
254
  const cleanup_functions = [];
222
255
  function setup_tooltip(element) {
223
- if (!element)
256
+ if (!element || safe_options.disabled)
224
257
  return;
225
258
  const content = safe_options.content || element.title ||
226
259
  element.getAttribute(`aria-label`) || element.getAttribute(`data-title`);
@@ -239,6 +272,7 @@ export const tooltip = (options = {}) => (node) => {
239
272
  tooltip.className = `custom-tooltip`;
240
273
  const placement = safe_options.placement || `bottom`;
241
274
  tooltip.setAttribute(`data-placement`, placement);
275
+ // Apply base styles
242
276
  tooltip.style.cssText = `
243
277
  position: absolute; z-index: 9999; opacity: 0;
244
278
  background: var(--tooltip-bg, #333); color: var(--text-color, white); border: var(--tooltip-border, none);
@@ -246,6 +280,16 @@ export const tooltip = (options = {}) => (node) => {
246
280
  max-width: var(--tooltip-max-width, 280px); word-wrap: break-word; pointer-events: none;
247
281
  filter: var(--tooltip-shadow, drop-shadow(0 2px 8px rgba(0,0,0,0.25))); transition: opacity 0.15s ease-out;
248
282
  `;
283
+ // Apply custom styles if provided (these will override base styles due to CSS specificity)
284
+ if (safe_options.style) {
285
+ // Parse and apply custom styles as individual properties for better control
286
+ const custom_styles = safe_options.style.split(`;`).filter((style) => style.trim());
287
+ custom_styles.forEach((style) => {
288
+ const [property, value] = style.split(`:`).map((s) => s.trim());
289
+ if (property && value)
290
+ tooltip.style.setProperty(property, value);
291
+ });
292
+ }
249
293
  tooltip.innerHTML = content?.replace(/\r/g, `<br/>`) ?? ``;
250
294
  // Mirror CSS custom properties from the trigger node onto the tooltip element
251
295
  const trigger_styles = getComputedStyle(element);
@@ -265,7 +309,10 @@ export const tooltip = (options = {}) => (node) => {
265
309
  if (value)
266
310
  tooltip.style.setProperty(name, value);
267
311
  });
268
- // Arrow element pointing to the trigger, oriented by placement
312
+ // Append early so we can read computed border styles for arrow border
313
+ document.body.appendChild(tooltip);
314
+ // Arrow elements: optional border triangle behind fill triangle
315
+ const tooltip_styles = getComputedStyle(tooltip);
269
316
  const arrow = document.createElement(`div`);
270
317
  arrow.className = `custom-tooltip-arrow`;
271
318
  arrow.style.cssText =
@@ -274,6 +321,49 @@ export const tooltip = (options = {}) => (node) => {
274
321
  .trim();
275
322
  const arrow_size_num = Number.parseInt(arrow_size_raw || ``, 10);
276
323
  const arrow_px = Number.isFinite(arrow_size_num) ? arrow_size_num : 6;
324
+ const border_color = tooltip_styles.borderTopColor;
325
+ const border_width_num = Number.parseFloat(tooltip_styles.borderTopWidth || `0`);
326
+ const has_border = !!border_color && border_color !== `rgba(0, 0, 0, 0)` &&
327
+ border_width_num > 0;
328
+ const maybe_append_border_arrow = () => {
329
+ if (!has_border)
330
+ return;
331
+ const border_arrow = document.createElement(`div`);
332
+ border_arrow.className = `custom-tooltip-arrow-border`;
333
+ border_arrow.style.cssText =
334
+ `position: absolute; width: 0; height: 0; pointer-events: none;`;
335
+ const border_size = arrow_px + (border_width_num * 1.4);
336
+ if (placement === `top`) {
337
+ border_arrow.style.left = `calc(50% - ${border_size}px)`;
338
+ border_arrow.style.bottom = `-${border_size}px`;
339
+ border_arrow.style.borderLeft = `${border_size}px solid transparent`;
340
+ border_arrow.style.borderRight = `${border_size}px solid transparent`;
341
+ border_arrow.style.borderTop = `${border_size}px solid ${border_color}`;
342
+ }
343
+ else if (placement === `left`) {
344
+ border_arrow.style.top = `calc(50% - ${border_size}px)`;
345
+ border_arrow.style.right = `-${border_size}px`;
346
+ border_arrow.style.borderTop = `${border_size}px solid transparent`;
347
+ border_arrow.style.borderBottom = `${border_size}px solid transparent`;
348
+ border_arrow.style.borderLeft = `${border_size}px solid ${border_color}`;
349
+ }
350
+ else if (placement === `right`) {
351
+ border_arrow.style.top = `calc(50% - ${border_size}px)`;
352
+ border_arrow.style.left = `-${border_size}px`;
353
+ border_arrow.style.borderTop = `${border_size}px solid transparent`;
354
+ border_arrow.style.borderBottom = `${border_size}px solid transparent`;
355
+ border_arrow.style.borderRight = `${border_size}px solid ${border_color}`;
356
+ }
357
+ else { // bottom
358
+ border_arrow.style.left = `calc(50% - ${border_size}px)`;
359
+ border_arrow.style.top = `-${border_size}px`;
360
+ border_arrow.style.borderLeft = `${border_size}px solid transparent`;
361
+ border_arrow.style.borderRight = `${border_size}px solid transparent`;
362
+ border_arrow.style.borderBottom = `${border_size}px solid ${border_color}`;
363
+ }
364
+ tooltip.appendChild(border_arrow);
365
+ };
366
+ // Create the fill arrow on top
277
367
  if (placement === `top`) {
278
368
  arrow.style.left = `calc(50% - ${arrow_px}px)`;
279
369
  arrow.style.bottom = `-${arrow_px}px`;
@@ -302,8 +392,8 @@ export const tooltip = (options = {}) => (node) => {
302
392
  arrow.style.borderRight = `${arrow_px}px solid transparent`;
303
393
  arrow.style.borderBottom = `${arrow_px}px solid var(--tooltip-bg, #333)`;
304
394
  }
395
+ maybe_append_border_arrow();
305
396
  tooltip.appendChild(arrow);
306
- document.body.appendChild(tooltip);
307
397
  // Position tooltip
308
398
  const rect = element.getBoundingClientRect();
309
399
  const tooltip_rect = tooltip.getBoundingClientRect();
@@ -337,23 +427,22 @@ export const tooltip = (options = {}) => (node) => {
337
427
  }
338
428
  function hide_tooltip() {
339
429
  clear_tooltip();
340
- hide_timeout = setTimeout(() => {
430
+ if (current_tooltip) {
431
+ current_tooltip.style.opacity = `0`;
341
432
  if (current_tooltip) {
342
- current_tooltip.style.opacity = `0`;
343
- setTimeout(() => {
344
- if (current_tooltip) {
345
- current_tooltip.remove();
346
- current_tooltip = null;
347
- }
348
- }, 150);
433
+ current_tooltip.remove();
434
+ current_tooltip = null;
349
435
  }
350
- }, 50);
436
+ }
351
437
  }
352
438
  const events = [`mouseenter`, `mouseleave`, `focus`, `blur`];
353
439
  const handlers = [show_tooltip, hide_tooltip, show_tooltip, hide_tooltip];
354
440
  events.forEach((event, idx) => element.addEventListener(event, handlers[idx]));
441
+ // Hide tooltip when user scrolls
442
+ globalThis.addEventListener(`scroll`, hide_tooltip, true);
355
443
  return () => {
356
444
  events.forEach((event, idx) => element.removeEventListener(event, handlers[idx]));
445
+ globalThis.removeEventListener(`scroll`, hide_tooltip, true);
357
446
  const original_title = element.getAttribute(`data-original-title`);
358
447
  if (original_title) {
359
448
  element.setAttribute(`title`, original_title);
package/dist/index.d.ts CHANGED
@@ -8,8 +8,8 @@ export { default as GitHubCorner } from './GitHubCorner.svelte';
8
8
  export { default as Icon } from './Icon.svelte';
9
9
  export { default, default as MultiSelect } from './MultiSelect.svelte';
10
10
  export { default as PrevNext } from './PrevNext.svelte';
11
- export { default as RadioButtons } from './RadioButtons.svelte';
12
11
  export { default as Toggle } from './Toggle.svelte';
13
12
  export * from './types';
13
+ export * from './utils';
14
14
  export { default as Wiggle } from './Wiggle.svelte';
15
15
  export declare function scroll_into_view_if_needed_polyfill(element: Element, centerIfNeeded?: boolean): IntersectionObserver;
package/dist/index.js CHANGED
@@ -8,9 +8,9 @@ export { default as GitHubCorner } from './GitHubCorner.svelte';
8
8
  export { default as Icon } from './Icon.svelte';
9
9
  export { default, default as MultiSelect } from './MultiSelect.svelte';
10
10
  export { default as PrevNext } from './PrevNext.svelte';
11
- export { default as RadioButtons } from './RadioButtons.svelte';
12
11
  export { default as Toggle } from './Toggle.svelte';
13
12
  export * from './types';
13
+ export * from './utils';
14
14
  export { default as Wiggle } from './Wiggle.svelte';
15
15
  // Firefox lacks support for scrollIntoViewIfNeeded (https://caniuse.com/scrollintoviewifneeded).
16
16
  // See https://github.com/janosh/svelte-multiselect/issues/87
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Snippet } from 'svelte';
2
- import type { FocusEventHandler, HTMLInputAttributes, KeyboardEventHandler, MouseEventHandler, TouchEventHandler } from 'svelte/elements';
2
+ import type { HTMLAttributes, HTMLInputAttributes } from 'svelte/elements';
3
3
  export type Option = string | number | ObjectOption;
4
4
  export type OptionStyle = string | {
5
5
  option: string;
@@ -16,20 +16,6 @@ export type ObjectOption = {
16
16
  style?: OptionStyle;
17
17
  [key: string]: unknown;
18
18
  };
19
- export interface MultiSelectNativeEvents {
20
- onblur?: FocusEventHandler<HTMLInputElement>;
21
- onclick?: MouseEventHandler<HTMLInputElement>;
22
- onfocus?: FocusEventHandler<HTMLInputElement>;
23
- onkeydown?: KeyboardEventHandler<HTMLInputElement>;
24
- onkeyup?: KeyboardEventHandler<HTMLInputElement>;
25
- onmousedown?: MouseEventHandler<HTMLInputElement>;
26
- onmouseenter?: MouseEventHandler<HTMLInputElement>;
27
- onmouseleave?: MouseEventHandler<HTMLInputElement>;
28
- ontouchcancel?: TouchEventHandler<HTMLInputElement>;
29
- ontouchend?: TouchEventHandler<HTMLInputElement>;
30
- ontouchmove?: TouchEventHandler<HTMLInputElement>;
31
- ontouchstart?: TouchEventHandler<HTMLInputElement>;
32
- }
33
19
  export interface MultiSelectEvents<T extends Option = Option> {
34
20
  onadd?: (data: {
35
21
  option: T;
@@ -55,7 +41,7 @@ export interface MultiSelectEvents<T extends Option = Option> {
55
41
  event: Event;
56
42
  }) => unknown;
57
43
  }
58
- type AfterInputProps = Pick<MultiSelectParameters, `selected` | `disabled` | `invalid` | `id` | `placeholder` | `open` | `required`>;
44
+ type AfterInputProps = Pick<MultiSelectProps, `selected` | `disabled` | `invalid` | `id` | `placeholder` | `open` | `required`>;
59
45
  type UserMsgProps = {
60
46
  searchText: string;
61
47
  msgType: false | `dupe` | `create` | `no-match`;
@@ -87,7 +73,7 @@ export interface PortalParams {
87
73
  target_node?: HTMLElement | null;
88
74
  active?: boolean;
89
75
  }
90
- export interface MultiSelectParameters<T extends Option = Option> {
76
+ export interface MultiSelectProps<T extends Option = Option> extends MultiSelectEvents<T>, MultiSelectSnippets<T>, Omit<HTMLAttributes<HTMLDivElement>, `children` | `onchange` | `onclose`> {
91
77
  activeIndex?: number | null;
92
78
  activeOption?: T | null;
93
79
  createOptionMsg?: string | null;
@@ -104,6 +90,7 @@ export interface MultiSelectParameters<T extends Option = Option> {
104
90
  keepSelectedInDropdown?: false | `plain` | `checkboxes`;
105
91
  key?: (opt: T) => unknown;
106
92
  filterFunc?: (opt: T, searchText: string) => boolean;
93
+ fuzzy?: boolean;
107
94
  closeDropdownOnSelect?: boolean | `if-mobile` | `retain-focus`;
108
95
  form_input?: HTMLInputElement | null;
109
96
  highlightMatches?: boolean;
@@ -151,8 +138,5 @@ export interface MultiSelectParameters<T extends Option = Option> {
151
138
  ulOptionsStyle?: string | null;
152
139
  value?: T | T[] | null;
153
140
  portal?: PortalParams;
154
- [key: string]: unknown;
155
- }
156
- export interface MultiSelectProps<T extends Option = Option> extends MultiSelectNativeEvents, MultiSelectEvents<T>, MultiSelectSnippets<T>, MultiSelectParameters<T> {
157
141
  }
158
142
  export {};
package/dist/utils.d.ts CHANGED
@@ -1,9 +1,4 @@
1
- import type { Option, OptionStyle } from './types';
1
+ import type { Option } from './types';
2
2
  export declare const get_label: (opt: Option) => string | number;
3
- export declare function get_style(option: {
4
- style?: OptionStyle;
5
- [key: string]: unknown;
6
- } | string | number, key?: `selected` | `option` | null): string;
7
- export declare function highlight_matching_nodes(element: HTMLElement, // parent element
8
- query?: string, // search query
9
- noMatchingOptionsMsg?: string): void;
3
+ export declare function get_style(option: Option, key?: `selected` | `option` | null | undefined): string;
4
+ export declare function fuzzy_match(search_text: string, target_text: string): boolean;
package/dist/utils.js CHANGED
@@ -14,14 +14,15 @@ export const get_label = (opt) => {
14
14
  // object to be used in the style attribute of the option.
15
15
  // If the style is a string, it will be returned as is
16
16
  export function get_style(option, key = null) {
17
+ if (key === undefined)
18
+ key = null;
17
19
  let css_str = ``;
18
- if (![`selected`, `option`, null].includes(key)) {
20
+ const valid_key = key === null || key === `selected` || key === `option`;
21
+ if (!valid_key)
19
22
  console.error(`MultiSelect: Invalid key=${key} for get_style`);
20
- }
21
23
  if (typeof option === `object` && option.style) {
22
- if (typeof option.style === `string`) {
24
+ if (typeof option.style === `string`)
23
25
  css_str = option.style;
24
- }
25
26
  if (typeof option.style === `object`) {
26
27
  if (key && key in option.style)
27
28
  return option.style[key] ?? ``;
@@ -35,49 +36,24 @@ export function get_style(option, key = null) {
35
36
  css_str += `;`;
36
37
  return css_str;
37
38
  }
38
- // Highlights text nodes that matching the string query
39
- export function highlight_matching_nodes(element, // parent element
40
- query, // search query
41
- noMatchingOptionsMsg) {
42
- if (typeof CSS === `undefined` || !CSS.highlights || !query)
43
- return; // abort if CSS highlight API not supported
44
- // clear previous ranges from HighlightRegistry
45
- CSS.highlights.clear();
46
- const tree_walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, {
47
- acceptNode: (node) => {
48
- // don't highlight text in the "no matching options" message
49
- if (node?.textContent === noMatchingOptionsMsg) {
50
- return NodeFilter.FILTER_REJECT;
51
- }
52
- return NodeFilter.FILTER_ACCEPT;
53
- },
54
- });
55
- const text_nodes = [];
56
- let current_node = tree_walker.nextNode();
57
- while (current_node) {
58
- text_nodes.push(current_node);
59
- current_node = tree_walker.nextNode();
39
+ // Fuzzy string matching function
40
+ // Returns true if the search string can be found as a subsequence in the target string
41
+ // e.g., "tageoo" matches "tasks/geo-opt" because t-a-g-e-o-o appears in order
42
+ export function fuzzy_match(search_text, target_text) {
43
+ // Handle null/undefined inputs first
44
+ if (search_text === null || search_text === undefined || target_text === null ||
45
+ target_text === undefined)
46
+ return false;
47
+ if (!search_text)
48
+ return true;
49
+ if (!target_text)
50
+ return false;
51
+ const [search, target] = [search_text.toLowerCase(), target_text.toLowerCase()];
52
+ let [search_idx, target_idx] = [0, 0];
53
+ while (search_idx < search.length && target_idx < target.length) {
54
+ if (search[search_idx] === target[target_idx])
55
+ search_idx++;
56
+ target_idx++;
60
57
  }
61
- // iterate over all text nodes and find matches
62
- const ranges = text_nodes.map((el) => {
63
- const text = el.textContent?.toLowerCase();
64
- const indices = [];
65
- let start_pos = 0;
66
- while (text && start_pos < text.length) {
67
- const index = text.indexOf(query, start_pos);
68
- if (index === -1)
69
- break;
70
- indices.push(index);
71
- start_pos = index + query.length;
72
- }
73
- // create range object for each str found in the text node
74
- return indices.map((index) => {
75
- const range = new Range();
76
- range.setStart(el, index);
77
- range.setEnd(el, index + query.length);
78
- return range;
79
- });
80
- });
81
- // create Highlight object from ranges and add to registry
82
- CSS.highlights.set(`sms-search-matches`, new Highlight(...ranges.flat()));
58
+ return search_idx === search.length;
83
59
  }
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "homepage": "https://janosh.github.io/svelte-multiselect",
6
6
  "repository": "https://github.com/janosh/svelte-multiselect",
7
7
  "license": "MIT",
8
- "version": "11.2.3",
8
+ "version": "11.2.4",
9
9
  "type": "module",
10
10
  "svelte": "./dist/index.js",
11
11
  "bugs": "https://github.com/janosh/svelte-multiselect/issues",
@@ -13,30 +13,30 @@
13
13
  "svelte": "^5.35.6"
14
14
  },
15
15
  "devDependencies": {
16
- "@playwright/test": "^1.54.2",
17
- "@stylistic/eslint-plugin": "^5.2.3",
18
- "@sveltejs/adapter-static": "^3.0.9",
19
- "@sveltejs/kit": "^2.28.0",
20
- "@sveltejs/package": "2.4.1",
21
- "@sveltejs/vite-plugin-svelte": "^6.1.1",
22
- "@types/node": "^24.2.1",
16
+ "@playwright/test": "^1.55.1",
17
+ "@stylistic/eslint-plugin": "^5.4.0",
18
+ "@sveltejs/adapter-static": "^3.0.10",
19
+ "@sveltejs/kit": "^2.43.8",
20
+ "@sveltejs/package": "2.5.4",
21
+ "@sveltejs/vite-plugin-svelte": "^6.2.1",
22
+ "@types/node": "^24.6.2",
23
23
  "@vitest/coverage-v8": "^3.2.4",
24
- "eslint": "^9.33.0",
25
- "eslint-plugin-svelte": "^3.11.0",
26
- "happy-dom": "^18.0.1",
24
+ "eslint": "^9.37.0",
25
+ "eslint-plugin-svelte": "^3.12.4",
26
+ "happy-dom": "^19.0.2",
27
27
  "hastscript": "^9.0.1",
28
28
  "mdsvex": "^0.12.6",
29
29
  "mdsvexamples": "^0.5.0",
30
30
  "rehype-autolink-headings": "^7.1.0",
31
31
  "rehype-slug": "^6.0.0",
32
- "svelte": "^5.38.1",
33
- "svelte-check": "^4.3.1",
32
+ "svelte": "^5.39.8",
33
+ "svelte-check": "^4.3.2",
34
34
  "svelte-preprocess": "^6.0.3",
35
35
  "svelte-toc": "^0.6.2",
36
- "svelte2tsx": "^0.7.42",
37
- "typescript": "5.9.2",
38
- "typescript-eslint": "^8.39.1",
39
- "vite": "^7.1.2",
36
+ "svelte2tsx": "^0.7.44",
37
+ "typescript": "5.9.3",
38
+ "typescript-eslint": "^8.45.0",
39
+ "vite": "^7.1.9",
40
40
  "vitest": "^3.2.4"
41
41
  },
42
42
  "keywords": [
package/readme.md CHANGED
@@ -643,10 +643,10 @@ For example, here's how you might annoy your users with an alert every time one
643
643
 
644
644
  ```svelte
645
645
  <MultiSelect
646
- onchange={(e) => {
647
- if (e.detail.type === 'add') alert(`You added ${e.detail.option}`)
648
- if (e.detail.type === 'remove') alert(`You removed ${e.detail.option}`)
649
- if (e.detail.type === 'removeAll') alert(`You removed ${e.detail.options}`)
646
+ onchange={(event) => {
647
+ if (event.detail.type === 'add') alert(`You added ${event.detail.option}`)
648
+ if (event.detail.type === 'remove') alert(`You removed ${event.detail.option}`)
649
+ if (event.detail.type === 'removeAll') alert(`You removed ${event.detail.options}`)
650
650
  }}
651
651
  />
652
652
  ```
@@ -1,67 +0,0 @@
1
- <script lang="ts">// get the label key from an option object or the option itself if it's a string or number
2
- const get_label = (op) => {
3
- if (op instanceof Object) {
4
- if (op.label === undefined) {
5
- console.error(`RadioButton option ${JSON.stringify(op)} is an object but has no label key`);
6
- }
7
- return op.label;
8
- }
9
- return op;
10
- };
11
- let { options, selected = $bindable(), id = null, name = null, disabled = false, required = false, aria_label = null, onclick, onchange, oninput, option_snippet, children, ...rest } = $props();
12
- export {};
13
- </script>
14
-
15
- <div {id} {...rest}>
16
- {#each options as option (JSON.stringify(option))}
17
- {@const label = get_label(option)}
18
- {@const active = selected && get_label(option) === get_label(selected)}
19
- <label class:active aria-label={aria_label}>
20
- <input
21
- type="radio"
22
- value={option}
23
- {name}
24
- {disabled}
25
- {required}
26
- bind:group={selected}
27
- {onchange}
28
- {oninput}
29
- {onclick}
30
- />
31
- {#if option_snippet}
32
- {@render option_snippet({ option, selected, active })}
33
- {:else if children}
34
- {@render children({ option, selected, active })}
35
- {:else}<span>{label}</span>{/if}
36
- </label>
37
- {/each}
38
- </div>
39
-
40
- <style>
41
- div {
42
- max-width: max-content;
43
- overflow: hidden;
44
- height: fit-content;
45
- display: var(--radio-btn-display, inline-flex);
46
- border-radius: var(--radio-btn-border-radius, 0.5em);
47
- }
48
- input {
49
- display: none;
50
- }
51
- span {
52
- cursor: pointer;
53
- display: inline-block;
54
- color: var(--radio-btn-color, white);
55
- padding: var(--radio-btn-padding, 2pt 5pt);
56
- background: var(--radio-btn-bg, black);
57
- transition: var(--radio-btn-transition, background 0.3s, transform 0.3s);
58
- }
59
- label:not(.active) span:hover {
60
- background: var(--radio-btn-hover-bg, cornflowerblue);
61
- color: var(--radio-btn-hover-color, white);
62
- }
63
- label.active span {
64
- box-shadow: var(--radio-btn-checked-shadow, inset 0 0 1em -3pt black);
65
- background: var(--radio-btn-checked-bg, darkcyan);
66
- }
67
- </style>
@@ -1,51 +0,0 @@
1
- import type { Snippet } from 'svelte';
2
- type GenericOption = string | number | {
3
- value: unknown;
4
- label: string | number;
5
- };
6
- declare function $$render<Option extends GenericOption>(): {
7
- props: {
8
- [key: string]: unknown;
9
- options: Option[];
10
- selected?: string | number | null;
11
- id?: string | null;
12
- name?: string | null;
13
- disabled?: boolean;
14
- required?: boolean;
15
- aria_label?: string | null;
16
- onclick?: (event: MouseEvent) => void;
17
- onchange?: (event: Event) => void;
18
- oninput?: (event: Event) => void;
19
- option_snippet?: Snippet<[{
20
- option: Option;
21
- selected: boolean;
22
- active: boolean;
23
- }]>;
24
- children?: Snippet<[{
25
- option: Option;
26
- selected: boolean;
27
- active: boolean;
28
- }]>;
29
- };
30
- exports: {};
31
- bindings: "selected";
32
- slots: {};
33
- events: {};
34
- };
35
- declare class __sveltets_Render<Option extends GenericOption> {
36
- props(): ReturnType<typeof $$render<Option>>['props'];
37
- events(): ReturnType<typeof $$render<Option>>['events'];
38
- slots(): ReturnType<typeof $$render<Option>>['slots'];
39
- bindings(): "selected";
40
- exports(): {};
41
- }
42
- interface $$IsomorphicComponent {
43
- new <Option extends GenericOption>(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']>> & {
44
- $$bindings?: ReturnType<__sveltets_Render<Option>['bindings']>;
45
- } & ReturnType<__sveltets_Render<Option>['exports']>;
46
- <Option extends GenericOption>(internal: unknown, props: ReturnType<__sveltets_Render<Option>['props']> & {}): ReturnType<__sveltets_Render<Option>['exports']>;
47
- z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
48
- }
49
- declare const RadioButtons: $$IsomorphicComponent;
50
- type RadioButtons<Option extends GenericOption> = InstanceType<typeof RadioButtons<Option>>;
51
- export default RadioButtons;