svelte-multiselect 11.2.2 → 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[];
@@ -9,15 +9,23 @@ $effect(() => {
9
9
  if (!global && !global_selector)
10
10
  return;
11
11
  const apply_copy_buttons = () => {
12
- const button_style = typeof global === `string`
13
- ? global
14
- : `position: absolute; top: 9pt; right: 9pt;`;
12
+ const btn_style = `position: absolute; top: 9pt; right: 9pt; ${rest.style ?? ``}`;
13
+ const skip_sel = skip_selector ?? as;
15
14
  for (const code of document.querySelectorAll(global_selector ?? `pre > code`)) {
16
15
  const pre = code.parentElement;
17
- if (pre && !(skip_selector && pre.querySelector(skip_selector))) {
16
+ const content = code.textContent ?? ``;
17
+ if (pre && !pre.querySelector(`[data-sms-copy]`) &&
18
+ !(skip_sel && pre.querySelector(skip_sel))) {
18
19
  mount(CopyButton, {
19
20
  target: pre,
20
- props: { content: code.textContent ?? ``, style: button_style },
21
+ props: {
22
+ content,
23
+ as,
24
+ labels,
25
+ ...rest,
26
+ style: btn_style,
27
+ 'data-sms-copy': ``,
28
+ },
21
29
  });
22
30
  }
23
31
  }
@@ -42,7 +50,20 @@ async function copy() {
42
50
 
43
51
  {#if !(global || global_selector)}
44
52
  {@const { text, icon } = labels[state]}
45
- <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
+ >
46
67
  {#if children}
47
68
  {@render children({ state, icon, text })}
48
69
  {:else}
@@ -1,12 +1,13 @@
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;
9
- global?: boolean | string;
10
+ global?: boolean;
10
11
  skip_selector?: string | null;
11
12
  as?: string;
12
13
  labels?: Record<State, {
@@ -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';
5
- 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) => {
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';
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(() => {
@@ -62,7 +68,9 @@ let window_width = $state(0);
62
68
  $effect.pre(() => {
63
69
  matchingOptions = options.filter((opt) => filterFunc(opt, searchText) &&
64
70
  // remove already selected options from dropdown list unless duplicate selections are allowed
65
- (!selected.map(key).includes(key(opt)) || duplicates));
71
+ // or keepSelectedInDropdown is enabled
72
+ (!selected.map(key).includes(key(opt)) || duplicates ||
73
+ keepSelectedInDropdown));
66
74
  });
67
75
  // raise if matchingOptions[activeIndex] does not yield a value
68
76
  if (activeIndex !== null && !matchingOptions[activeIndex]) {
@@ -72,6 +80,17 @@ if (activeIndex !== null && !matchingOptions[activeIndex]) {
72
80
  $effect(() => {
73
81
  activeOption = matchingOptions[activeIndex ?? -1] ?? null;
74
82
  });
83
+ // toggle an option between selected and unselected states (for keepSelectedInDropdown mode)
84
+ function toggle_option(option_to_toggle, event) {
85
+ const is_currently_selected = selected.map(key).includes(key(option_to_toggle));
86
+ if (is_currently_selected) {
87
+ if (minSelect === null || selected.length > minSelect) { // Only remove if it wouldn't violate minSelect
88
+ remove(option_to_toggle, event);
89
+ }
90
+ }
91
+ else
92
+ add(option_to_toggle, event);
93
+ }
75
94
  // add an option to selected list
76
95
  function add(option_to_add, event) {
77
96
  event.stopPropagation();
@@ -115,10 +134,9 @@ function add(option_to_add, event) {
115
134
  console.error(`MultiSelect: encountered falsy option ${option_to_add}`);
116
135
  return;
117
136
  }
118
- if (maxSelect === 1) {
119
- // for maxSelect = 1 we always replace current option with new one
137
+ // for maxSelect = 1 we always replace current option with new one
138
+ if (maxSelect === 1)
120
139
  selected = [option_to_add];
121
- }
122
140
  else {
123
141
  selected = [...selected, option_to_add];
124
142
  if (sortSelected === true) {
@@ -132,7 +150,7 @@ function add(option_to_add, event) {
132
150
  selected = selected.sort(sortSelected);
133
151
  }
134
152
  }
135
- const reached_max_select = selected.length === maxSelect;
153
+ const reached_max_select = selected.length >= (maxSelect ?? Infinity);
136
154
  const dropdown_should_close = closeDropdownOnSelect === true ||
137
155
  closeDropdownOnSelect === `retain-focus` ||
138
156
  (closeDropdownOnSelect === `if-mobile` && window_width &&
@@ -141,9 +159,8 @@ function add(option_to_add, event) {
141
159
  if (reached_max_select || dropdown_should_close) {
142
160
  close_dropdown(event, should_retain_focus);
143
161
  }
144
- else if (!dropdown_should_close) {
162
+ else if (!dropdown_should_close)
145
163
  input?.focus();
146
- }
147
164
  onadd?.({ option: option_to_add });
148
165
  onchange?.({ option: option_to_add, type: `add` });
149
166
  invalid = false; // reset error status whenever new items are selected
@@ -203,8 +220,12 @@ async function handle_keydown(event) {
203
220
  event.stopPropagation();
204
221
  event.preventDefault(); // prevent enter key from triggering form submission
205
222
  if (activeOption) {
206
- if (selected.includes(activeOption))
207
- remove(activeOption, event);
223
+ if (selected.includes(activeOption)) {
224
+ // Only remove if it wouldn't violate minSelect
225
+ if (minSelect === null || selected.length > minSelect) {
226
+ remove(activeOption, event);
227
+ }
228
+ }
208
229
  else
209
230
  add(activeOption, event);
210
231
  searchText = ``;
@@ -242,11 +263,25 @@ async function handle_keydown(event) {
242
263
  // if none of the above special cases apply, we make next/prev option
243
264
  // active with wrap around at both ends
244
265
  const increment = event.key === `ArrowUp` ? -1 : 1;
245
- 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;
246
272
  // in JS % behaves like remainder operator, not real modulo, so negative numbers stay negative
247
273
  // need to do manual wrap around at 0
248
274
  if (activeIndex < 0)
249
- 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
+ }
250
285
  if (autoScroll) {
251
286
  await tick();
252
287
  const li = document.querySelector(`ul.options > li.active`);
@@ -256,8 +291,13 @@ async function handle_keydown(event) {
256
291
  } // on backspace key: remove last selected option
257
292
  else if (event.key === `Backspace` && selected.length > 0 && !searchText) {
258
293
  event.stopPropagation();
294
+ // Only remove option if it wouldn't violate minSelect
295
+ if (minSelect === null || selected.length > minSelect) {
296
+ const last_option = selected.at(-1);
297
+ if (last_option)
298
+ remove(last_option, event);
299
+ }
259
300
  // Don't prevent default, allow normal backspace behavior if not removing
260
- remove(selected.at(-1), event);
261
301
  } // make first matching option active on any keypress (if none of the above special cases match)
262
302
  else if (matchingOptions.length > 0 && activeIndex === null) {
263
303
  // Don't stop propagation or prevent default here, allow normal character input
@@ -266,11 +306,23 @@ async function handle_keydown(event) {
266
306
  }
267
307
  function remove_all(event) {
268
308
  event.stopPropagation();
269
- selected = []; // Set selected first
270
- searchText = ``;
271
- // Now trigger change events
272
- onremoveAll?.({ options: selected });
309
+ // Keep the first minSelect items, remove the rest
310
+ let removed_options = [];
311
+ if (minSelect === null) {
312
+ // If no minSelect constraint, remove all
313
+ removed_options = selected;
314
+ selected = [];
315
+ searchText = ``;
316
+ }
317
+ else if (selected.length > minSelect) {
318
+ // Keep the first minSelect items
319
+ removed_options = selected.slice(minSelect);
320
+ selected = selected.slice(0, minSelect);
321
+ searchText = ``;
322
+ }
323
+ onremoveAll?.({ options: removed_options });
273
324
  onchange?.({ options: selected, type: `removeAll` });
325
+ // If selected.length <= minSelect, do nothing (can't remove any more)
274
326
  }
275
327
  let is_selected = $derived((label) => selected.map(get_label).includes(label));
276
328
  const if_enter_or_space = (handler) => (event) => {
@@ -280,9 +332,17 @@ const if_enter_or_space = (handler) => (event) => {
280
332
  }
281
333
  };
282
334
  function on_click_outside(event) {
283
- if (outerDiv && !outerDiv.contains(event.target)) {
284
- close_dropdown(event);
285
- }
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);
286
346
  }
287
347
  let drag_idx = $state(null);
288
348
  // event handlers enable dragging to reorder selected options
@@ -312,16 +372,6 @@ const dragstart = (idx) => (event) => {
312
372
  event.dataTransfer.setData(`text/plain`, `${idx}`);
313
373
  };
314
374
  let ul_options = $state();
315
- // highlight text matching user-entered search text in available options
316
- function highlight_matching_options(event) {
317
- if (!highlightMatches || !ul_options)
318
- return;
319
- // get input's search query
320
- const query = event?.target?.value.trim().toLowerCase();
321
- if (!query)
322
- return;
323
- highlight_matching_nodes(ul_options, query, noMatchingOptionsMsg);
324
- }
325
375
  const handle_input_keydown = (event) => {
326
376
  handle_keydown(event); // Restore internal logic
327
377
  // Call original forwarded handler
@@ -351,6 +401,7 @@ $effect(() => {
351
401
  const handle_input_blur = (event) => {
352
402
  // For portalled dropdowns, don't close on blur since clicks on portalled elements
353
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)
354
405
  if (portal_params?.active) {
355
406
  onblur?.(event); // Let the click handler manage closing for portalled dropdowns
356
407
  return;
@@ -388,7 +439,7 @@ function portal(node, params) {
388
439
  tick().then(update_position);
389
440
  window.addEventListener(`scroll`, update_position, true);
390
441
  window.addEventListener(`resize`, update_position);
391
- $effect.pre(() => {
442
+ $effect(() => {
392
443
  if (open && target_node)
393
444
  update_position();
394
445
  else
@@ -464,8 +515,7 @@ function portal(node, params) {
464
515
  {:else}
465
516
  <Icon
466
517
  icon="ChevronExpand"
467
- width="15px"
468
- style="min-width: 1em; padding: 0 1pt; cursor: pointer"
518
+ style="width: 15px; min-width: 1em; padding: 0 1pt; cursor: pointer"
469
519
  />
470
520
  {/if}
471
521
  <ul
@@ -520,7 +570,7 @@ function portal(node, params) {
520
570
  {#if removeIcon}
521
571
  {@render removeIcon()}
522
572
  {:else}
523
- <Icon icon="Cross" width="15px" />
573
+ <Icon icon="Cross" style="width: 15px" />
524
574
  {/if}
525
575
  </button>
526
576
  {/if}
@@ -536,13 +586,12 @@ function portal(node, params) {
536
586
  {autocomplete}
537
587
  {inputmode}
538
588
  {pattern}
539
- placeholder={selected.length == 0 ? placeholder : null}
589
+ placeholder={selected.length === 0 ? placeholder : null}
540
590
  aria-invalid={invalid ? `true` : null}
541
591
  ondrop={() => false}
542
592
  onmouseup={open_dropdown}
543
593
  onkeydown={handle_input_keydown}
544
594
  onfocus={handle_input_focus}
545
- oninput={highlight_matching_options}
546
595
  onblur={handle_input_blur}
547
596
  {onclick}
548
597
  {onkeyup}
@@ -578,9 +627,9 @@ function portal(node, params) {
578
627
  {:else}
579
628
  <Icon
580
629
  icon="Disabled"
581
- width="14pt"
582
- style="margin: 0 2pt"
630
+ style="width: 14pt; margin: 0 2pt"
583
631
  data-name="disabled-icon"
632
+ aria-disabled="true"
584
633
  />
585
634
  {/if}
586
635
  {:else if selected.length > 0}
@@ -602,7 +651,7 @@ function portal(node, params) {
602
651
  {#if removeIcon}
603
652
  {@render removeIcon()}
604
653
  {:else}
605
- <Icon icon="Cross" width="15px" />
654
+ <Icon icon="Cross" style="width: 15px" />
606
655
  {/if}
607
656
  </button>
608
657
  {/if}
@@ -612,6 +661,17 @@ function portal(node, params) {
612
661
  {#if (searchText && noMatchingOptionsMsg) || options?.length > 0}
613
662
  <ul
614
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
+ })}
615
675
  class:hidden={!open}
616
676
  class="options {ulOptionsClass}"
617
677
  role="listbox"
@@ -621,10 +681,13 @@ function portal(node, params) {
621
681
  bind:this={ul_options}
622
682
  style={ulOptionsStyle}
623
683
  >
624
- {#each matchingOptions.slice(0, Math.max(0, maxOptions ?? 0) || Infinity) as
625
- optionItem,
684
+ {#each matchingOptions.slice(
685
+ 0,
686
+ maxOptions == null ? Infinity : Math.max(0, maxOptions),
687
+ ) as
688
+ option_item,
626
689
  idx
627
- (duplicates ? [key(optionItem), idx] : key(optionItem))
690
+ (duplicates ? [key(option_item), idx] : key(option_item))
628
691
  }
629
692
  {@const {
630
693
  label,
@@ -632,19 +695,22 @@ function portal(node, params) {
632
695
  title = null,
633
696
  selectedTitle = null,
634
697
  disabledTitle = defaultDisabledTitle,
635
- } = optionItem instanceof Object ? optionItem : { label: optionItem }}
698
+ } = option_item instanceof Object ? option_item : { label: option_item }}
636
699
  {@const active = activeIndex === idx}
700
+ {@const selected = is_selected(label)}
637
701
  {@const optionStyle =
638
- [get_style(optionItem, `option`), liOptionStyle].filter(Boolean).join(
702
+ [get_style(option_item, `option`), liOptionStyle].filter(Boolean).join(
639
703
  ` `,
640
704
  ) ||
641
705
  null}
642
706
  <li
643
707
  onclick={(event) => {
644
- if (!disabled) add(optionItem, event)
708
+ if (disabled) return
709
+ if (keepSelectedInDropdown) toggle_option(option_item, event)
710
+ else add(option_item, event)
645
711
  }}
646
- title={disabled ? disabledTitle : (is_selected(label) && selectedTitle) || title}
647
- class:selected={is_selected(label)}
712
+ title={disabled ? disabledTitle : (selected && selectedTitle) || title}
713
+ class:selected
648
714
  class:active
649
715
  class:disabled
650
716
  class="{liOptionClass} {active ? liActiveOptionClass : ``}"
@@ -655,29 +721,39 @@ function portal(node, params) {
655
721
  if (!disabled) activeIndex = idx
656
722
  }}
657
723
  role="option"
658
- aria-selected="false"
724
+ aria-selected={selected ? `true` : `false`}
659
725
  style={optionStyle}
660
726
  onkeydown={(event) => {
661
727
  if (!disabled && (event.key === `Enter` || event.code === `Space`)) {
662
728
  event.preventDefault()
663
- add(optionItem, event)
729
+ if (keepSelectedInDropdown) toggle_option(option_item, event)
730
+ else add(option_item, event)
664
731
  }
665
732
  }}
666
733
  >
734
+ {#if keepSelectedInDropdown === `checkboxes`}
735
+ <input
736
+ type="checkbox"
737
+ class="option-checkbox"
738
+ checked={selected}
739
+ aria-label="Toggle {get_label(option_item)}"
740
+ tabindex="-1"
741
+ />
742
+ {/if}
667
743
  {#if option}
668
744
  {@render option({
669
- option: optionItem,
745
+ option: option_item,
670
746
  idx,
671
747
  })}
672
748
  {:else if children}
673
749
  {@render children({
674
- option: optionItem,
750
+ option: option_item,
675
751
  idx,
676
752
  })}
677
753
  {:else if parseLabelsAsHtml}
678
- {@html get_label(optionItem)}
754
+ {@html get_label(option_item)}
679
755
  {:else}
680
- {get_label(optionItem)}
756
+ {get_label(option_item)}
681
757
  {/if}
682
758
  </li>
683
759
  {/each}
@@ -685,7 +761,7 @@ function portal(node, params) {
685
761
  {@const text_input_is_duplicate = selected.map(get_label).includes(searchText)}
686
762
  {@const is_dupe = !duplicates && text_input_is_duplicate && `dupe`}
687
763
  {@const can_create = Boolean(allowUserOptions && createOptionMsg) && `create`}
688
- {@const no_match = Boolean(matchingOptions?.length == 0 && noMatchingOptionsMsg) &&
764
+ {@const no_match = Boolean(matchingOptions?.length === 0 && noMatchingOptionsMsg) &&
689
765
  `no-match`}
690
766
  {@const msgType = is_dupe || can_create || no_match}
691
767
  {#if msgType}
@@ -895,11 +971,13 @@ function portal(node, params) {
895
971
  visibility: hidden;
896
972
  opacity: 0;
897
973
  transform: translateY(50px);
974
+ pointer-events: none;
898
975
  }
899
976
  ul.options > li {
900
- padding: 3pt 2ex;
977
+ padding: 3pt 1ex;
901
978
  cursor: pointer;
902
979
  scroll-margin: var(--sms-options-scroll-margin, 100px);
980
+ border-left: 3px solid transparent;
903
981
  }
904
982
  ul.options .user-msg {
905
983
  /* block needed so vertical padding applies to span */
@@ -907,8 +985,11 @@ function portal(node, params) {
907
985
  padding: 3pt 2ex;
908
986
  }
909
987
  ul.options > li.selected {
910
- background: var(--sms-li-selected-bg);
911
- color: var(--sms-li-selected-color);
988
+ background: var(--sms-li-selected-plain-bg, rgba(0, 123, 255, 0.1));
989
+ border-left: var(
990
+ --sms-li-selected-plain-border,
991
+ 3px solid var(--sms-active-color, cornflowerblue)
992
+ );
912
993
  }
913
994
  ul.options > li.active {
914
995
  background: var(--sms-li-active-bg, var(--sms-active-color, rgba(0, 0, 0, 0.15)));
@@ -918,7 +999,13 @@ function portal(node, params) {
918
999
  background: var(--sms-li-disabled-bg, #f5f5f6);
919
1000
  color: var(--sms-li-disabled-text, #b8b8b8);
920
1001
  }
921
-
1002
+ /* Checkbox styling for keepSelectedInDropdown='checkboxes' mode */
1003
+ ul.options > li > input.option-checkbox {
1004
+ width: 16px;
1005
+ height: 16px;
1006
+ margin-right: 6px;
1007
+ accent-color: var(--sms-active-color, cornflowerblue);
1008
+ }
922
1009
  :is(span.max-select-msg) {
923
1010
  padding: 0 3pt;
924
1011
  }
@@ -1,9 +1,16 @@
1
1
  import type { MultiSelectProps, Option as T } from './types';
2
+ declare function $$render<Option extends T>(): {
3
+ props: MultiSelectProps;
4
+ exports: {};
5
+ bindings: "input" | "invalid" | "value" | "selected" | "open" | "activeIndex" | "activeOption" | "form_input" | "matchingOptions" | "options" | "outerDiv" | "searchText";
6
+ slots: {};
7
+ events: {};
8
+ };
2
9
  declare class __sveltets_Render<Option extends T> {
3
- props(): MultiSelectProps<T>;
4
- events(): {};
5
- slots(): {};
6
- bindings(): "input" | "invalid" | "open" | "value" | "selected" | "activeIndex" | "activeOption" | "form_input" | "matchingOptions" | "options" | "outerDiv" | "searchText";
10
+ props(): ReturnType<typeof $$render<Option>>['props'];
11
+ events(): ReturnType<typeof $$render<Option>>['events'];
12
+ slots(): ReturnType<typeof $$render<Option>>['slots'];
13
+ bindings(): "input" | "invalid" | "value" | "selected" | "open" | "activeIndex" | "activeOption" | "form_input" | "matchingOptions" | "options" | "outerDiv" | "searchText";
7
14
  exports(): {};
8
15
  }
9
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