svelte-multiselect 11.2.1 → 11.2.3

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.
@@ -14,6 +14,6 @@ interface Props extends Omit<MultiSelectProps<Action>, `options`> {
14
14
  input?: HTMLInputElement | null;
15
15
  placeholder?: string;
16
16
  }
17
- declare const CmdPalette: import("svelte").Component<Props, {}, "open" | "dialog" | "input">;
17
+ declare const CmdPalette: import("svelte").Component<Props, {}, "open" | "input" | "dialog">;
18
18
  type CmdPalette = ReturnType<typeof CmdPalette>;
19
19
  export default CmdPalette;
@@ -6,21 +6,25 @@ let { content = ``, state = $bindable(`ready`), global_selector = null, global =
6
6
  error: { icon: `Alert`, text: `` },
7
7
  }, children, ...rest } = $props();
8
8
  $effect(() => {
9
- if (global || global_selector) {
10
- for (const node of document.querySelectorAll(global_selector ?? `pre > code`)) {
11
- // skip if <pre> already contains a button (presumably for copy)
12
- const pre = node.parentElement;
13
- if (!pre || (skip_selector && pre.querySelector(skip_selector)))
14
- continue;
15
- mount(CopyButton, {
16
- target: pre,
17
- props: {
18
- content: node.textContent ?? ``,
19
- style: `position: absolute; top: 9pt; right: 9pt;`,
20
- },
21
- });
9
+ if (!global && !global_selector)
10
+ return;
11
+ const apply_copy_buttons = () => {
12
+ const btn_style = `position: absolute; top: 9pt; right: 9pt; ${rest.style ?? ``}`;
13
+ for (const code of document.querySelectorAll(global_selector ?? `pre > code`)) {
14
+ const pre = code.parentElement;
15
+ const content = code.textContent ?? ``;
16
+ if (pre && !(skip_selector && pre.querySelector(skip_selector))) {
17
+ mount(CopyButton, {
18
+ target: pre,
19
+ props: { content, as, labels, ...rest, style: btn_style },
20
+ });
21
+ }
22
22
  }
23
- }
23
+ };
24
+ apply_copy_buttons();
25
+ const observer = new MutationObserver(apply_copy_buttons);
26
+ observer.observe(document.body, { childList: true, subtree: true });
27
+ return () => observer.disconnect();
24
28
  });
25
29
  async function copy() {
26
30
  try {
@@ -18,7 +18,7 @@ export {};
18
18
 
19
19
  <svelte:element this={as} {style}>
20
20
  {#each files as file, idx (file.title)}
21
- {@const { title, content, _language = default_lang } = file ?? {}}
21
+ {@const { title, content, language = default_lang } = file ?? {}}
22
22
  <li>
23
23
  <!-- https://github.com/sveltejs/svelte/issues/12721#issuecomment-2269544690 -->
24
24
  <details bind:this={file.node}>
@@ -27,12 +27,12 @@ export {};
27
27
  {#if title_snippet}
28
28
  {@render title_snippet({ idx, ...file })}
29
29
  {:else}
30
- <code>{title.split(`/`).at(-1)}</code>
30
+ {@html title}
31
31
  {/if}
32
32
  </summary>
33
33
  {/if}
34
34
 
35
- <pre><code>{@html content}</code></pre>
35
+ <pre class="language-{language}"><code>{content}</code></pre>
36
36
  <!-- <pre><code>{@html hljs.highlight(content, { language }).value}</code></pre> -->
37
37
  </details>
38
38
  </li>
@@ -2,7 +2,7 @@
2
2
  import { tick } from 'svelte';
3
3
  import { flip } from 'svelte/animate';
4
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) => {
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, keepSelectedInDropdown = false, key = (opt) => `${get_label(opt)}`.toLowerCase(), filterFunc = (opt, searchText) => {
6
6
  if (!searchText)
7
7
  return true;
8
8
  return `${get_label(opt)}`.toLowerCase().includes(searchText.toLowerCase());
@@ -62,7 +62,9 @@ let window_width = $state(0);
62
62
  $effect.pre(() => {
63
63
  matchingOptions = options.filter((opt) => filterFunc(opt, searchText) &&
64
64
  // remove already selected options from dropdown list unless duplicate selections are allowed
65
- (!selected.map(key).includes(key(opt)) || duplicates));
65
+ // or keepSelectedInDropdown is enabled
66
+ (!selected.map(key).includes(key(opt)) || duplicates ||
67
+ keepSelectedInDropdown));
66
68
  });
67
69
  // raise if matchingOptions[activeIndex] does not yield a value
68
70
  if (activeIndex !== null && !matchingOptions[activeIndex]) {
@@ -72,6 +74,17 @@ if (activeIndex !== null && !matchingOptions[activeIndex]) {
72
74
  $effect(() => {
73
75
  activeOption = matchingOptions[activeIndex ?? -1] ?? null;
74
76
  });
77
+ // toggle an option between selected and unselected states (for keepSelectedInDropdown mode)
78
+ function toggle_option(option_to_toggle, event) {
79
+ const is_currently_selected = selected.map(key).includes(key(option_to_toggle));
80
+ if (is_currently_selected) {
81
+ if (minSelect === null || selected.length > minSelect) { // Only remove if it wouldn't violate minSelect
82
+ remove(option_to_toggle, event);
83
+ }
84
+ }
85
+ else
86
+ add(option_to_toggle, event);
87
+ }
75
88
  // add an option to selected list
76
89
  function add(option_to_add, event) {
77
90
  event.stopPropagation();
@@ -115,10 +128,9 @@ function add(option_to_add, event) {
115
128
  console.error(`MultiSelect: encountered falsy option ${option_to_add}`);
116
129
  return;
117
130
  }
118
- if (maxSelect === 1) {
119
- // for maxSelect = 1 we always replace current option with new one
131
+ // for maxSelect = 1 we always replace current option with new one
132
+ if (maxSelect === 1)
120
133
  selected = [option_to_add];
121
- }
122
134
  else {
123
135
  selected = [...selected, option_to_add];
124
136
  if (sortSelected === true) {
@@ -132,7 +144,7 @@ function add(option_to_add, event) {
132
144
  selected = selected.sort(sortSelected);
133
145
  }
134
146
  }
135
- const reached_max_select = selected.length === maxSelect;
147
+ const reached_max_select = selected.length >= (maxSelect ?? Infinity);
136
148
  const dropdown_should_close = closeDropdownOnSelect === true ||
137
149
  closeDropdownOnSelect === `retain-focus` ||
138
150
  (closeDropdownOnSelect === `if-mobile` && window_width &&
@@ -141,9 +153,8 @@ function add(option_to_add, event) {
141
153
  if (reached_max_select || dropdown_should_close) {
142
154
  close_dropdown(event, should_retain_focus);
143
155
  }
144
- else if (!dropdown_should_close) {
156
+ else if (!dropdown_should_close)
145
157
  input?.focus();
146
- }
147
158
  onadd?.({ option: option_to_add });
148
159
  onchange?.({ option: option_to_add, type: `add` });
149
160
  invalid = false; // reset error status whenever new items are selected
@@ -203,8 +214,12 @@ async function handle_keydown(event) {
203
214
  event.stopPropagation();
204
215
  event.preventDefault(); // prevent enter key from triggering form submission
205
216
  if (activeOption) {
206
- if (selected.includes(activeOption))
207
- remove(activeOption, event);
217
+ if (selected.includes(activeOption)) {
218
+ // Only remove if it wouldn't violate minSelect
219
+ if (minSelect === null || selected.length > minSelect) {
220
+ remove(activeOption, event);
221
+ }
222
+ }
208
223
  else
209
224
  add(activeOption, event);
210
225
  searchText = ``;
@@ -256,8 +271,11 @@ async function handle_keydown(event) {
256
271
  } // on backspace key: remove last selected option
257
272
  else if (event.key === `Backspace` && selected.length > 0 && !searchText) {
258
273
  event.stopPropagation();
274
+ // Only remove option if it wouldn't violate minSelect
275
+ if (minSelect === null || selected.length > minSelect) {
276
+ remove(selected.at(-1), event);
277
+ }
259
278
  // Don't prevent default, allow normal backspace behavior if not removing
260
- remove(selected.at(-1), event);
261
279
  } // make first matching option active on any keypress (if none of the above special cases match)
262
280
  else if (matchingOptions.length > 0 && activeIndex === null) {
263
281
  // Don't stop propagation or prevent default here, allow normal character input
@@ -266,11 +284,23 @@ async function handle_keydown(event) {
266
284
  }
267
285
  function remove_all(event) {
268
286
  event.stopPropagation();
269
- selected = []; // Set selected first
270
- searchText = ``;
271
- // Now trigger change events
272
- onremoveAll?.({ options: selected });
287
+ // Keep the first minSelect items, remove the rest
288
+ let removed_options = [];
289
+ if (minSelect === null) {
290
+ // If no minSelect constraint, remove all
291
+ removed_options = selected;
292
+ selected = [];
293
+ searchText = ``;
294
+ }
295
+ else if (selected.length > minSelect) {
296
+ // Keep the first minSelect items
297
+ removed_options = selected.slice(minSelect);
298
+ selected = selected.slice(0, minSelect);
299
+ searchText = ``;
300
+ }
301
+ onremoveAll?.({ options: removed_options });
273
302
  onchange?.({ options: selected, type: `removeAll` });
303
+ // If selected.length <= minSelect, do nothing (can't remove any more)
274
304
  }
275
305
  let is_selected = $derived((label) => selected.map(get_label).includes(label));
276
306
  const if_enter_or_space = (handler) => (event) => {
@@ -312,6 +342,17 @@ const dragstart = (idx) => (event) => {
312
342
  event.dataTransfer.setData(`text/plain`, `${idx}`);
313
343
  };
314
344
  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
+ });
315
356
  // highlight text matching user-entered search text in available options
316
357
  function highlight_matching_options(event) {
317
358
  if (!highlightMatches || !ul_options)
@@ -581,6 +622,7 @@ function portal(node, params) {
581
622
  width="14pt"
582
623
  style="margin: 0 2pt"
583
624
  data-name="disabled-icon"
625
+ aria-disabled="true"
584
626
  />
585
627
  {/if}
586
628
  {:else if selected.length > 0}
@@ -641,7 +683,9 @@ function portal(node, params) {
641
683
  null}
642
684
  <li
643
685
  onclick={(event) => {
644
- if (!disabled) add(optionItem, event)
686
+ if (disabled) return
687
+ if (keepSelectedInDropdown) toggle_option(optionItem, event)
688
+ else add(optionItem, event)
645
689
  }}
646
690
  title={disabled ? disabledTitle : (is_selected(label) && selectedTitle) || title}
647
691
  class:selected={is_selected(label)}
@@ -660,10 +704,20 @@ function portal(node, params) {
660
704
  onkeydown={(event) => {
661
705
  if (!disabled && (event.key === `Enter` || event.code === `Space`)) {
662
706
  event.preventDefault()
663
- add(optionItem, event)
707
+ if (keepSelectedInDropdown) toggle_option(optionItem, event)
708
+ else add(optionItem, event)
664
709
  }
665
710
  }}
666
711
  >
712
+ {#if keepSelectedInDropdown === `checkboxes`}
713
+ <input
714
+ type="checkbox"
715
+ class="option-checkbox"
716
+ checked={is_selected(label)}
717
+ aria-label="Toggle {get_label(optionItem)}"
718
+ tabindex="-1"
719
+ />
720
+ {/if}
667
721
  {#if option}
668
722
  {@render option({
669
723
  option: optionItem,
@@ -895,11 +949,13 @@ function portal(node, params) {
895
949
  visibility: hidden;
896
950
  opacity: 0;
897
951
  transform: translateY(50px);
952
+ pointer-events: none;
898
953
  }
899
954
  ul.options > li {
900
- padding: 3pt 2ex;
955
+ padding: 3pt 1ex;
901
956
  cursor: pointer;
902
957
  scroll-margin: var(--sms-options-scroll-margin, 100px);
958
+ border-left: 3px solid transparent;
903
959
  }
904
960
  ul.options .user-msg {
905
961
  /* block needed so vertical padding applies to span */
@@ -907,8 +963,11 @@ function portal(node, params) {
907
963
  padding: 3pt 2ex;
908
964
  }
909
965
  ul.options > li.selected {
910
- background: var(--sms-li-selected-bg);
911
- color: var(--sms-li-selected-color);
966
+ background: var(--sms-li-selected-plain-bg, rgba(0, 123, 255, 0.1));
967
+ border-left: var(
968
+ --sms-li-selected-plain-border,
969
+ 3px solid var(--sms-active-color, cornflowerblue)
970
+ );
912
971
  }
913
972
  ul.options > li.active {
914
973
  background: var(--sms-li-active-bg, var(--sms-active-color, rgba(0, 0, 0, 0.15)));
@@ -918,7 +977,13 @@ function portal(node, params) {
918
977
  background: var(--sms-li-disabled-bg, #f5f5f6);
919
978
  color: var(--sms-li-disabled-text, #b8b8b8);
920
979
  }
921
-
980
+ /* Checkbox styling for keepSelectedInDropdown='checkboxes' mode */
981
+ ul.options > li > input.option-checkbox {
982
+ width: 16px;
983
+ height: 16px;
984
+ margin-right: 6px;
985
+ accent-color: var(--sms-active-color, cornflowerblue);
986
+ }
922
987
  :is(span.max-select-msg) {
923
988
  padding: 0 3pt;
924
989
  }
@@ -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: "value" | "selected" | "invalid" | "open" | "activeIndex" | "activeOption" | "form_input" | "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(): "open" | "input" | "value" | "selected" | "invalid" | "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(): "value" | "selected" | "invalid" | "open" | "activeIndex" | "activeOption" | "form_input" | "input" | "matchingOptions" | "options" | "outerDiv" | "searchText";
7
14
  exports(): {};
8
15
  }
9
16
  interface $$IsomorphicComponent {
@@ -1,38 +1,45 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  export type Item = string | [string, unknown];
3
- declare class __sveltets_Render<T extends Item> {
4
- props(): {
3
+ declare function $$render<T extends Item>(): {
4
+ props: {
5
5
  [key: string]: unknown;
6
- items?: T[] | undefined;
6
+ items?: T[];
7
7
  node?: string;
8
8
  current?: string;
9
9
  log?: `verbose` | `errors` | `silent`;
10
10
  nav_options?: {
11
11
  replace_state: boolean;
12
12
  no_scroll: boolean;
13
- } | undefined;
13
+ };
14
14
  titles?: {
15
15
  prev: string;
16
16
  next: string;
17
- } | undefined;
17
+ };
18
18
  onkeyup?: ((obj: {
19
19
  prev: Item;
20
20
  next: Item;
21
- }) => Record<string, string>) | null | undefined;
21
+ }) => Record<string, string>) | null;
22
22
  prev_snippet?: Snippet<[{
23
23
  item: Item;
24
- }]> | undefined;
24
+ }]>;
25
25
  children?: Snippet<[{
26
26
  kind: `prev` | `next`;
27
27
  item: Item;
28
- }]> | undefined;
28
+ }]>;
29
29
  between?: Snippet<[]>;
30
30
  next_snippet?: Snippet<[{
31
31
  item: Item;
32
- }]> | undefined;
32
+ }]>;
33
33
  };
34
- events(): {};
35
- slots(): {};
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'];
36
43
  bindings(): "";
37
44
  exports(): {};
38
45
  }
@@ -3,8 +3,8 @@ type GenericOption = string | number | {
3
3
  value: unknown;
4
4
  label: string | number;
5
5
  };
6
- declare class __sveltets_Render<Option extends GenericOption> {
7
- props(): {
6
+ declare function $$render<Option extends GenericOption>(): {
7
+ props: {
8
8
  [key: string]: unknown;
9
9
  options: Option[];
10
10
  selected?: string | number | null;
@@ -13,22 +13,29 @@ declare class __sveltets_Render<Option extends GenericOption> {
13
13
  disabled?: boolean;
14
14
  required?: boolean;
15
15
  aria_label?: string | null;
16
- onclick?: ((event: MouseEvent) => void) | undefined;
17
- onchange?: ((event: Event) => void) | undefined;
18
- oninput?: ((event: Event) => void) | undefined;
16
+ onclick?: (event: MouseEvent) => void;
17
+ onchange?: (event: Event) => void;
18
+ oninput?: (event: Event) => void;
19
19
  option_snippet?: Snippet<[{
20
20
  option: Option;
21
21
  selected: boolean;
22
22
  active: boolean;
23
- }]> | undefined;
23
+ }]>;
24
24
  children?: Snippet<[{
25
25
  option: Option;
26
26
  selected: boolean;
27
27
  active: boolean;
28
- }]> | undefined;
28
+ }]>;
29
29
  };
30
- events(): {};
31
- slots(): {};
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'];
32
39
  bindings(): "selected";
33
40
  exports(): {};
34
41
  }
@@ -0,0 +1,46 @@
1
+ import { type Attachment } from 'svelte/attachments';
2
+ declare global {
3
+ interface CSS {
4
+ highlights: HighlightRegistry;
5
+ }
6
+ interface HighlightRegistry extends Map<string, Highlight> {
7
+ clear(): void;
8
+ delete(key: string): boolean;
9
+ set(key: string, value: Highlight): this;
10
+ }
11
+ }
12
+ export interface DraggableOptions {
13
+ handle_selector?: string;
14
+ on_drag_start?: (event: MouseEvent) => void;
15
+ on_drag?: (event: MouseEvent) => void;
16
+ on_drag_end?: (event: MouseEvent) => void;
17
+ }
18
+ export declare const draggable: (options?: DraggableOptions) => Attachment;
19
+ export declare function get_html_sort_value(element: HTMLElement): string;
20
+ export declare const sortable: ({ header_selector, asc_class, desc_class, sorted_style, }?: {
21
+ header_selector?: string | undefined;
22
+ asc_class?: string | undefined;
23
+ desc_class?: string | undefined;
24
+ sorted_style?: {
25
+ backgroundColor: string;
26
+ } | undefined;
27
+ }) => (node: HTMLElement) => () => void;
28
+ type HighlightOptions = {
29
+ query?: string;
30
+ disabled?: boolean;
31
+ node_filter?: (node: Node) => number;
32
+ css_class?: string;
33
+ };
34
+ export declare const highlight_matches: (ops: HighlightOptions) => (node: HTMLElement) => (() => void) | undefined;
35
+ export declare const tooltip: (options?: {
36
+ content?: string;
37
+ placement?: `top` | `bottom` | `left` | `right`;
38
+ delay?: number;
39
+ }) => Attachment;
40
+ type ClickOutsideConfig<T extends HTMLElement> = {
41
+ enabled?: boolean;
42
+ exclude?: string[];
43
+ callback?: (node: T, config: ClickOutsideConfig<T>) => void;
44
+ };
45
+ export declare const click_outside: <T extends HTMLElement>(config?: ClickOutsideConfig<T>) => (node: T) => () => void;
46
+ export {};
@@ -0,0 +1,400 @@
1
+ import {} from 'svelte/attachments';
2
+ // Svelte 5 attachment factory to make an element draggable
3
+ // @param options - Configuration options for dragging behavior
4
+ // @returns Attachment function that sets up dragging on an element
5
+ export const draggable = (options = {}) => (element) => {
6
+ const node = element;
7
+ // Use simple variables for maximum performance
8
+ let dragging = false;
9
+ let start = { x: 0, y: 0 };
10
+ const initial = { left: 0, top: 0 };
11
+ const handle = options.handle_selector
12
+ ? node.querySelector(options.handle_selector)
13
+ : node;
14
+ if (!handle) {
15
+ console.warn(`Draggable: handle not found with selector "${options.handle_selector}"`);
16
+ return;
17
+ }
18
+ function handle_mousedown(event) {
19
+ // Only drag if mousedown is on the handle or its children
20
+ if (!handle?.contains?.(event.target))
21
+ return;
22
+ dragging = true;
23
+ // For position: fixed elements, use getBoundingClientRect for viewport-relative position
24
+ const computed_style = getComputedStyle(node);
25
+ if (computed_style.position === `fixed`) {
26
+ const rect = node.getBoundingClientRect();
27
+ initial.left = rect.left;
28
+ initial.top = rect.top;
29
+ }
30
+ else {
31
+ // For other positioning, use offset values
32
+ initial.left = node.offsetLeft;
33
+ initial.top = node.offsetTop;
34
+ }
35
+ node.style.left = `${initial.left}px`;
36
+ node.style.top = `${initial.top}px`;
37
+ node.style.right = `auto`; // Prevent conflict with left
38
+ start = { x: event.clientX, y: event.clientY };
39
+ document.body.style.userSelect = `none`; // Prevent text selection during drag
40
+ if (handle)
41
+ handle.style.cursor = `grabbing`;
42
+ globalThis.addEventListener(`mousemove`, handle_mousemove);
43
+ globalThis.addEventListener(`mouseup`, handle_mouseup);
44
+ options.on_drag_start?.(event); // Call optional callback
45
+ }
46
+ function handle_mousemove(event) {
47
+ if (!dragging)
48
+ return;
49
+ // Use the exact same calculation as the fast old implementation
50
+ const dx = event.clientX - start.x;
51
+ const dy = event.clientY - start.y;
52
+ node.style.left = `${initial.left + dx}px`;
53
+ node.style.top = `${initial.top + dy}px`;
54
+ // Only call callback if it exists (minimize overhead)
55
+ if (options.on_drag)
56
+ options.on_drag(event);
57
+ }
58
+ function handle_mouseup(event) {
59
+ if (!dragging)
60
+ return;
61
+ dragging = false;
62
+ event.stopPropagation();
63
+ document.body.style.userSelect = ``;
64
+ if (handle)
65
+ handle.style.cursor = `grab`;
66
+ globalThis.removeEventListener(`mousemove`, handle_mousemove);
67
+ globalThis.removeEventListener(`mouseup`, handle_mouseup);
68
+ options.on_drag_end?.(event); // Call optional callback
69
+ }
70
+ if (handle) {
71
+ handle.addEventListener(`mousedown`, handle_mousedown);
72
+ handle.style.cursor = `grab`;
73
+ }
74
+ // Return cleanup function (this is the attachment pattern)
75
+ return () => {
76
+ globalThis.removeEventListener(`mousemove`, handle_mousemove);
77
+ globalThis.removeEventListener(`mouseup`, handle_mouseup);
78
+ if (handle) {
79
+ handle.removeEventListener(`mousedown`, handle_mousedown);
80
+ handle.style.cursor = ``; // Reset cursor
81
+ }
82
+ };
83
+ };
84
+ export function get_html_sort_value(element) {
85
+ if (element.dataset.sortValue !== undefined) {
86
+ return element.dataset.sortValue;
87
+ }
88
+ for (const child of Array.from(element.children)) {
89
+ const child_val = get_html_sort_value(child);
90
+ if (child_val !== ``)
91
+ return child_val;
92
+ }
93
+ return element.textContent ?? ``;
94
+ }
95
+ export const sortable = ({ header_selector = `thead th`, asc_class = `table-sort-asc`, desc_class = `table-sort-desc`, sorted_style = { backgroundColor: `rgba(255, 255, 255, 0.1)` }, } = {}) => (node) => {
96
+ // this action can be applied to bob-standard HTML tables to make them sortable by
97
+ // clicking on column headers (and clicking again to toggle sorting direction)
98
+ const headers = Array.from(node.querySelectorAll(header_selector));
99
+ let sort_col_idx;
100
+ let sort_dir = 1; // 1 = asc, -1 = desc
101
+ // Store event listeners for cleanup
102
+ const event_listeners = [];
103
+ for (const [idx, header] of headers.entries()) {
104
+ header.style.cursor = `pointer`; // add cursor pointer to headers
105
+ const init_styles = header.getAttribute(`style`) ?? ``;
106
+ const click_handler = () => {
107
+ // reset all headers to initial state
108
+ for (const header of headers) {
109
+ header.textContent = header.textContent?.replace(/ ↑| ↓/, ``) ?? ``;
110
+ header.classList.remove(asc_class, desc_class);
111
+ header.setAttribute(`style`, init_styles);
112
+ }
113
+ if (idx === sort_col_idx) {
114
+ sort_dir *= -1; // reverse sort direction
115
+ }
116
+ else {
117
+ sort_col_idx = idx; // set new sort column index
118
+ sort_dir = 1; // reset sort direction
119
+ }
120
+ header.classList.add(sort_dir > 0 ? asc_class : desc_class);
121
+ Object.assign(header.style, sorted_style);
122
+ header.textContent = `${header.textContent?.replace(/ ↑| ↓/, ``)} ${sort_dir > 0 ? `↑` : `↓`}`;
123
+ const table_body = node.querySelector(`tbody`);
124
+ if (!table_body)
125
+ return;
126
+ // re-sort table
127
+ const rows = Array.from(table_body.querySelectorAll(`tr`));
128
+ rows.sort((row_1, row_2) => {
129
+ const cell_1 = row_1.cells[sort_col_idx];
130
+ const cell_2 = row_2.cells[sort_col_idx];
131
+ const val_1 = get_html_sort_value(cell_1);
132
+ const val_2 = get_html_sort_value(cell_2);
133
+ if (val_1 === val_2)
134
+ return 0;
135
+ if (val_1 === ``)
136
+ return 1; // treat empty string as lower than any value
137
+ if (val_2 === ``)
138
+ return -1; // any value is considered higher than empty string
139
+ const num_1 = Number(val_1);
140
+ const num_2 = Number(val_2);
141
+ if (isNaN(num_1) && isNaN(num_2)) {
142
+ return sort_dir * val_1.localeCompare(val_2, undefined, { numeric: true });
143
+ }
144
+ return sort_dir * (num_1 - num_2);
145
+ });
146
+ for (const row of rows)
147
+ table_body.appendChild(row);
148
+ };
149
+ header.addEventListener(`click`, click_handler);
150
+ event_listeners.push({ header, handler: click_handler });
151
+ }
152
+ // Return cleanup function
153
+ return () => {
154
+ for (const { header, handler } of event_listeners) {
155
+ header.removeEventListener(`click`, handler);
156
+ header.style.cursor = ``; // Reset cursor
157
+ }
158
+ };
159
+ };
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
166
+ const tree_walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, {
167
+ acceptNode: node_filter,
168
+ });
169
+ const text_nodes = [];
170
+ let current_node = tree_walker.nextNode();
171
+ while (current_node) {
172
+ text_nodes.push(current_node);
173
+ current_node = tree_walker.nextNode();
174
+ }
175
+ // iterate over all text nodes and find matches
176
+ const ranges = text_nodes.map((el) => {
177
+ 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;
186
+ }
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
+ });
195
+ // create Highlight object from ranges and add to registry
196
+ CSS.highlights.set(css_class, new Highlight(...ranges.flat()));
197
+ return () => {
198
+ CSS.highlights.delete(css_class);
199
+ };
200
+ };
201
+ // Global tooltip state to ensure only one tooltip is shown at a time
202
+ let current_tooltip = null;
203
+ let show_timeout;
204
+ let hide_timeout;
205
+ function clear_tooltip() {
206
+ if (show_timeout)
207
+ clearTimeout(show_timeout);
208
+ if (hide_timeout)
209
+ clearTimeout(hide_timeout);
210
+ if (current_tooltip) {
211
+ current_tooltip.remove();
212
+ current_tooltip = null;
213
+ }
214
+ }
215
+ export const tooltip = (options = {}) => (node) => {
216
+ // Handle null/undefined elements
217
+ if (!node || !(node instanceof HTMLElement))
218
+ return;
219
+ // Handle null/undefined options
220
+ const safe_options = options || {};
221
+ const cleanup_functions = [];
222
+ function setup_tooltip(element) {
223
+ if (!element)
224
+ return;
225
+ const content = safe_options.content || element.title ||
226
+ element.getAttribute(`aria-label`) || element.getAttribute(`data-title`);
227
+ if (!content)
228
+ return;
229
+ // Store original title and remove it to prevent default tooltip
230
+ // Only store title if we're not using custom content
231
+ if (element.title && !safe_options.content) {
232
+ element.setAttribute(`data-original-title`, element.title);
233
+ element.removeAttribute(`title`);
234
+ }
235
+ function show_tooltip() {
236
+ clear_tooltip();
237
+ show_timeout = setTimeout(() => {
238
+ const tooltip = document.createElement(`div`);
239
+ tooltip.className = `custom-tooltip`;
240
+ const placement = safe_options.placement || `bottom`;
241
+ tooltip.setAttribute(`data-placement`, placement);
242
+ tooltip.style.cssText = `
243
+ position: absolute; z-index: 9999; opacity: 0;
244
+ background: var(--tooltip-bg, #333); color: var(--text-color, white); border: var(--tooltip-border, none);
245
+ padding: var(--tooltip-padding, 6px 10px); border-radius: var(--tooltip-radius, 6px); font-size: var(--tooltip-font-size, 13px); line-height: 1.4;
246
+ max-width: var(--tooltip-max-width, 280px); word-wrap: break-word; pointer-events: none;
247
+ filter: var(--tooltip-shadow, drop-shadow(0 2px 8px rgba(0,0,0,0.25))); transition: opacity 0.15s ease-out;
248
+ `;
249
+ tooltip.innerHTML = content?.replace(/\r/g, `<br/>`) ?? ``;
250
+ // Mirror CSS custom properties from the trigger node onto the tooltip element
251
+ const trigger_styles = getComputedStyle(element);
252
+ [
253
+ `--tooltip-bg`,
254
+ `--text-color`,
255
+ `--tooltip-border`,
256
+ `--tooltip-padding`,
257
+ `--tooltip-radius`,
258
+ `--tooltip-font-size`,
259
+ `--tooltip-shadow`,
260
+ `--tooltip-max-width`,
261
+ `--tooltip-opacity`,
262
+ `--tooltip-arrow-size`,
263
+ ].forEach((name) => {
264
+ const value = trigger_styles.getPropertyValue(name).trim();
265
+ if (value)
266
+ tooltip.style.setProperty(name, value);
267
+ });
268
+ // Arrow element pointing to the trigger, oriented by placement
269
+ const arrow = document.createElement(`div`);
270
+ arrow.className = `custom-tooltip-arrow`;
271
+ arrow.style.cssText =
272
+ `position: absolute; width: 0; height: 0; pointer-events: none;`;
273
+ const arrow_size_raw = trigger_styles.getPropertyValue(`--tooltip-arrow-size`)
274
+ .trim();
275
+ const arrow_size_num = Number.parseInt(arrow_size_raw || ``, 10);
276
+ const arrow_px = Number.isFinite(arrow_size_num) ? arrow_size_num : 6;
277
+ if (placement === `top`) {
278
+ arrow.style.left = `calc(50% - ${arrow_px}px)`;
279
+ arrow.style.bottom = `-${arrow_px}px`;
280
+ arrow.style.borderLeft = `${arrow_px}px solid transparent`;
281
+ arrow.style.borderRight = `${arrow_px}px solid transparent`;
282
+ arrow.style.borderTop = `${arrow_px}px solid var(--tooltip-bg, #333)`;
283
+ }
284
+ else if (placement === `left`) {
285
+ arrow.style.top = `calc(50% - ${arrow_px}px)`;
286
+ arrow.style.right = `-${arrow_px}px`;
287
+ arrow.style.borderTop = `${arrow_px}px solid transparent`;
288
+ arrow.style.borderBottom = `${arrow_px}px solid transparent`;
289
+ arrow.style.borderLeft = `${arrow_px}px solid var(--tooltip-bg, #333)`;
290
+ }
291
+ else if (placement === `right`) {
292
+ arrow.style.top = `calc(50% - ${arrow_px}px)`;
293
+ arrow.style.left = `-${arrow_px}px`;
294
+ arrow.style.borderTop = `${arrow_px}px solid transparent`;
295
+ arrow.style.borderBottom = `${arrow_px}px solid transparent`;
296
+ arrow.style.borderRight = `${arrow_px}px solid var(--tooltip-bg, #333)`;
297
+ }
298
+ else { // bottom
299
+ arrow.style.left = `calc(50% - ${arrow_px}px)`;
300
+ arrow.style.top = `-${arrow_px}px`;
301
+ arrow.style.borderLeft = `${arrow_px}px solid transparent`;
302
+ arrow.style.borderRight = `${arrow_px}px solid transparent`;
303
+ arrow.style.borderBottom = `${arrow_px}px solid var(--tooltip-bg, #333)`;
304
+ }
305
+ tooltip.appendChild(arrow);
306
+ document.body.appendChild(tooltip);
307
+ // Position tooltip
308
+ const rect = element.getBoundingClientRect();
309
+ const tooltip_rect = tooltip.getBoundingClientRect();
310
+ const margin = 12;
311
+ let top = 0, left = 0;
312
+ if (placement === `top`) {
313
+ top = rect.top - tooltip_rect.height - margin;
314
+ left = rect.left + rect.width / 2 - tooltip_rect.width / 2;
315
+ }
316
+ else if (placement === `left`) {
317
+ top = rect.top + rect.height / 2 - tooltip_rect.height / 2;
318
+ left = rect.left - tooltip_rect.width - margin;
319
+ }
320
+ else if (placement === `right`) {
321
+ top = rect.top + rect.height / 2 - tooltip_rect.height / 2;
322
+ left = rect.right + margin;
323
+ }
324
+ else { // bottom
325
+ top = rect.bottom + margin;
326
+ left = rect.left + rect.width / 2 - tooltip_rect.width / 2;
327
+ }
328
+ // Keep in viewport
329
+ left = Math.max(8, Math.min(left, globalThis.innerWidth - tooltip_rect.width - 8));
330
+ top = Math.max(8, Math.min(top, globalThis.innerHeight - tooltip_rect.height - 8));
331
+ tooltip.style.left = `${left + globalThis.scrollX}px`;
332
+ tooltip.style.top = `${top + globalThis.scrollY}px`;
333
+ const custom_opacity = trigger_styles.getPropertyValue(`--tooltip-opacity`).trim();
334
+ tooltip.style.opacity = custom_opacity || `1`;
335
+ current_tooltip = tooltip;
336
+ }, safe_options.delay || 100);
337
+ }
338
+ function hide_tooltip() {
339
+ clear_tooltip();
340
+ hide_timeout = setTimeout(() => {
341
+ 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);
349
+ }
350
+ }, 50);
351
+ }
352
+ const events = [`mouseenter`, `mouseleave`, `focus`, `blur`];
353
+ const handlers = [show_tooltip, hide_tooltip, show_tooltip, hide_tooltip];
354
+ events.forEach((event, idx) => element.addEventListener(event, handlers[idx]));
355
+ return () => {
356
+ events.forEach((event, idx) => element.removeEventListener(event, handlers[idx]));
357
+ const original_title = element.getAttribute(`data-original-title`);
358
+ if (original_title) {
359
+ element.setAttribute(`title`, original_title);
360
+ element.removeAttribute(`data-original-title`);
361
+ }
362
+ };
363
+ }
364
+ // Setup tooltip for main node and children
365
+ const main_cleanup = setup_tooltip(node);
366
+ if (main_cleanup)
367
+ cleanup_functions.push(main_cleanup);
368
+ node.querySelectorAll(`[title], [aria-label], [data-title]`).forEach((element) => {
369
+ const child_cleanup = setup_tooltip(element);
370
+ if (child_cleanup)
371
+ cleanup_functions.push(child_cleanup);
372
+ });
373
+ if (cleanup_functions.length === 0)
374
+ return;
375
+ return () => {
376
+ cleanup_functions.forEach((cleanup) => cleanup());
377
+ clear_tooltip();
378
+ };
379
+ };
380
+ export const click_outside = (config = {}) => (node) => {
381
+ const { callback, enabled = true, exclude = [] } = config;
382
+ function handle_click(event) {
383
+ if (!enabled)
384
+ return;
385
+ const target = event.target;
386
+ const path = event.composedPath();
387
+ // Check if click target is the node or inside it
388
+ if (path.includes(node))
389
+ return;
390
+ // Check excluded selectors
391
+ if (exclude.some((selector) => target.closest(selector)))
392
+ return;
393
+ // Execute callback if provided, passing node and full config
394
+ callback?.(node, { callback, enabled, exclude });
395
+ // Dispatch custom event if click was outside
396
+ node.dispatchEvent(new CustomEvent(`outside-click`));
397
+ }
398
+ document.addEventListener(`click`, handle_click, true);
399
+ return () => document.removeEventListener(`click`, handle_click, true);
400
+ };
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from './attachments';
1
2
  export { default as CircleSpinner } from './CircleSpinner.svelte';
2
3
  export { default as CmdPalette } from './CmdPalette.svelte';
3
4
  export { default as CodeExample } from './CodeExample.svelte';
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export * from './attachments';
1
2
  export { default as CircleSpinner } from './CircleSpinner.svelte';
2
3
  export { default as CmdPalette } from './CmdPalette.svelte';
3
4
  export { default as CodeExample } from './CodeExample.svelte';
package/dist/types.d.ts CHANGED
@@ -101,6 +101,7 @@ export interface MultiSelectParameters<T extends Option = Option> {
101
101
  disabledInputTitle?: string;
102
102
  duplicateOptionMsg?: string;
103
103
  duplicates?: boolean;
104
+ keepSelectedInDropdown?: false | `plain` | `checkboxes`;
104
105
  key?: (opt: T) => unknown;
105
106
  filterFunc?: (opt: T, searchText: string) => boolean;
106
107
  closeDropdownOnSelect?: boolean | `if-mobile` | `retain-focus`;
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.1",
8
+ "version": "11.2.3",
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.0",
17
- "@stylistic/eslint-plugin": "^5.1.0",
18
- "@sveltejs/adapter-static": "^3.0.8",
19
- "@sveltejs/kit": "^2.22.5",
20
- "@sveltejs/package": "2.3.12",
21
- "@sveltejs/vite-plugin-svelte": "^6.0.0",
22
- "@types/node": "^24.0.13",
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",
23
23
  "@vitest/coverage-v8": "^3.2.4",
24
- "eslint": "^9.30.1",
25
- "eslint-plugin-svelte": "^3.10.1",
24
+ "eslint": "^9.33.0",
25
+ "eslint-plugin-svelte": "^3.11.0",
26
+ "happy-dom": "^18.0.1",
26
27
  "hastscript": "^9.0.1",
27
- "jsdom": "^26.1.0",
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.35.6",
33
- "svelte-check": "^4.2.2",
32
+ "svelte": "^5.38.1",
33
+ "svelte-check": "^4.3.1",
34
34
  "svelte-preprocess": "^6.0.3",
35
- "svelte-toc": "^0.6.1",
36
- "svelte2tsx": "^0.7.40",
37
- "typescript": "5.8.3",
38
- "typescript-eslint": "^8.36.0",
39
- "vite": "^7.0.4",
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",
40
40
  "vitest": "^3.2.4"
41
41
  },
42
42
  "keywords": [
@@ -59,6 +59,10 @@
59
59
  "types": "./dist/index.d.ts",
60
60
  "svelte": "./dist/index.js",
61
61
  "default": "./dist/index.js"
62
+ },
63
+ "./attachments": {
64
+ "types": "./dist/attachments.d.ts",
65
+ "default": "./dist/attachments.js"
62
66
  }
63
67
  },
64
68
  "types": "./dist/index.d.ts",
package/readme.md CHANGED
@@ -619,7 +619,7 @@ Example using several snippets:
619
619
  onremoveAll={(event) => console.log(event.detail.options)}`
620
620
  ```
621
621
 
622
- Triggers when all selected options are removed. The payload `event.detail.options` gives the options that were previously selected.
622
+ Triggers when all selected options are removed. The payload `event.detail.options` gives the options that were removed (might not be all if `minSelect` is set).
623
623
 
624
624
  1. ```ts
625
625
  onchange={(event) => console.log(`${event.detail.type}: '${event.detail.option}'`)}