svelte-multiselect 11.2.4 โ†’ 11.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import type { Snippet } from 'svelte';
2
- interface Props {
2
+ type $$ComponentProps = {
3
3
  wiggle?: boolean;
4
4
  angle?: number;
5
5
  scale?: number;
@@ -9,7 +9,7 @@ interface Props {
9
9
  stiffness?: number;
10
10
  damping?: number;
11
11
  children?: Snippet;
12
- }
13
- declare const Wiggle: import("svelte").Component<Props, {}, "wiggle">;
12
+ };
13
+ declare const Wiggle: import("svelte").Component<$$ComponentProps, {}, "wiggle">;
14
14
  type Wiggle = ReturnType<typeof Wiggle>;
15
15
  export default Wiggle;
@@ -11,20 +11,21 @@ declare global {
11
11
  }
12
12
  export interface DraggableOptions {
13
13
  handle_selector?: string;
14
+ disabled?: boolean;
14
15
  on_drag_start?: (event: MouseEvent) => void;
15
16
  on_drag?: (event: MouseEvent) => void;
16
17
  on_drag_end?: (event: MouseEvent) => void;
17
18
  }
18
19
  export declare const draggable: (options?: DraggableOptions) => Attachment;
19
20
  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;
21
+ export interface SortableOptions {
22
+ header_selector?: string;
23
+ asc_class?: string;
24
+ desc_class?: string;
25
+ sorted_style?: Partial<CSSStyleDeclaration>;
26
+ disabled?: boolean;
27
+ }
28
+ export declare const sortable: (options?: SortableOptions) => (node: HTMLElement) => (() => void) | undefined;
28
29
  export type HighlightOptions = {
29
30
  query?: string;
30
31
  disabled?: boolean;
@@ -33,6 +34,13 @@ export type HighlightOptions = {
33
34
  css_class?: string;
34
35
  };
35
36
  export declare const highlight_matches: (ops: HighlightOptions) => (node: HTMLElement) => (() => boolean) | undefined;
37
+ /**
38
+ * Options for the tooltip attachment.
39
+ *
40
+ * @security Tooltip content is rendered as HTML. If you allow user-provided content
41
+ * to be set via `title`, `aria-label`, or `data-title` attributes, you MUST sanitize
42
+ * it first to prevent XSS attacks. This attachment does not perform any sanitization.
43
+ */
36
44
  export interface TooltipOptions {
37
45
  content?: string;
38
46
  placement?: `top` | `bottom` | `left` | `right`;
@@ -46,4 +54,4 @@ export type ClickOutsideConfig<T extends HTMLElement> = {
46
54
  exclude?: string[];
47
55
  callback?: (node: T, config: ClickOutsideConfig<T>) => void;
48
56
  };
49
- export declare const click_outside: <T extends HTMLElement>(config?: ClickOutsideConfig<T>) => (node: T) => () => void;
57
+ export declare const click_outside: <T extends HTMLElement>(config?: ClickOutsideConfig<T>) => (node: T) => (() => void) | undefined;
@@ -3,6 +3,8 @@ import {} from 'svelte/attachments';
3
3
  // @param options - Configuration options for dragging behavior
4
4
  // @returns Attachment function that sets up dragging on an element
5
5
  export const draggable = (options = {}) => (element) => {
6
+ if (options.disabled)
7
+ return;
6
8
  const node = element;
7
9
  // Use simple variables for maximum performance
8
10
  let dragging = false;
@@ -92,23 +94,33 @@ export function get_html_sort_value(element) {
92
94
  }
93
95
  return element.textContent ?? ``;
94
96
  }
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
+ export const sortable = (options = {}) => (node) => {
98
+ const { header_selector = `thead th`, asc_class = `table-sort-asc`, desc_class = `table-sort-desc`, sorted_style = { backgroundColor: `rgba(255, 255, 255, 0.1)` }, disabled = false, } = options;
99
+ if (disabled)
100
+ return;
101
+ // This action can be applied to standard HTML tables to make them sortable by
97
102
  // clicking on column headers (and clicking again to toggle sorting direction)
98
103
  const headers = Array.from(node.querySelectorAll(header_selector));
99
104
  let sort_col_idx;
100
105
  let sort_dir = 1; // 1 = asc, -1 = desc
101
- // Store event listeners for cleanup
102
- const event_listeners = [];
106
+ // Store original state for cleanup
107
+ const header_state = [];
103
108
  for (const [idx, header] of headers.entries()) {
109
+ const original_text = header.textContent ?? ``;
110
+ const original_style = header.getAttribute(`style`) ?? ``;
104
111
  header.style.cursor = `pointer`; // add cursor pointer to headers
105
- const init_styles = header.getAttribute(`style`) ?? ``;
106
112
  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);
113
+ // reset all headers to unsorted state
114
+ for (const { header: hdr, original_text, original_style } of header_state) {
115
+ hdr.textContent = original_text;
116
+ hdr.classList.remove(asc_class, desc_class);
117
+ if (original_style) {
118
+ hdr.setAttribute(`style`, original_style);
119
+ }
120
+ else {
121
+ hdr.removeAttribute(`style`);
122
+ }
123
+ hdr.style.cursor = `pointer`;
112
124
  }
113
125
  if (idx === sort_col_idx) {
114
126
  sort_dir *= -1; // reverse sort direction
@@ -147,13 +159,20 @@ export const sortable = ({ header_selector = `thead th`, asc_class = `table-sort
147
159
  table_body.appendChild(row);
148
160
  };
149
161
  header.addEventListener(`click`, click_handler);
150
- event_listeners.push({ header, handler: click_handler });
162
+ header_state.push({ header, handler: click_handler, original_text, original_style });
151
163
  }
152
- // Return cleanup function
164
+ // Return cleanup function that fully restores original state
153
165
  return () => {
154
- for (const { header, handler } of event_listeners) {
166
+ for (const { header, handler, original_text, original_style } of header_state) {
155
167
  header.removeEventListener(`click`, handler);
156
- header.style.cursor = ``; // Reset cursor
168
+ header.textContent = original_text;
169
+ header.classList.remove(asc_class, desc_class);
170
+ if (original_style) {
171
+ header.setAttribute(`style`, original_style);
172
+ }
173
+ else {
174
+ header.removeAttribute(`style`);
175
+ }
157
176
  }
158
177
  };
159
178
  };
@@ -255,7 +274,8 @@ export const tooltip = (options = {}) => (node) => {
255
274
  function setup_tooltip(element) {
256
275
  if (!element || safe_options.disabled)
257
276
  return;
258
- const content = safe_options.content || element.title ||
277
+ // Use let so content can be updated reactively
278
+ let content = safe_options.content || element.title ||
259
279
  element.getAttribute(`aria-label`) || element.getAttribute(`data-title`);
260
280
  if (!content)
261
281
  return;
@@ -265,6 +285,36 @@ export const tooltip = (options = {}) => (node) => {
265
285
  element.setAttribute(`data-original-title`, element.title);
266
286
  element.removeAttribute(`title`);
267
287
  }
288
+ // Reactively update content when tooltip attributes change
289
+ const tooltip_attrs = [`title`, `aria-label`, `data-title`];
290
+ const observer = new MutationObserver((mutations) => {
291
+ if (safe_options.content)
292
+ return; // custom content takes precedence
293
+ for (const { type, attributeName } of mutations) {
294
+ if (type !== `attributes` || !attributeName)
295
+ continue;
296
+ const new_content = element.getAttribute(attributeName);
297
+ // null = attribute removed (by us), skip entirely
298
+ if (new_content === null)
299
+ continue;
300
+ // Always remove title to prevent browser's native tooltip (even if empty)
301
+ // Disconnect observer temporarily to avoid re-entrancy from our own removal
302
+ if (attributeName === `title`) {
303
+ observer.disconnect();
304
+ element.removeAttribute(`title`);
305
+ observer.observe(element, { attributes: true, attributeFilter: tooltip_attrs });
306
+ }
307
+ // Only update content if non-empty
308
+ if (!new_content)
309
+ continue;
310
+ content = new_content;
311
+ // Only update tooltip if this element owns it
312
+ if (current_tooltip?._owner === element) {
313
+ current_tooltip.innerHTML = content.replace(/\r/g, `<br/>`);
314
+ }
315
+ }
316
+ });
317
+ observer.observe(element, { attributes: true, attributeFilter: tooltip_attrs });
268
318
  function show_tooltip() {
269
319
  clear_tooltip();
270
320
  show_timeout = setTimeout(() => {
@@ -277,7 +327,7 @@ export const tooltip = (options = {}) => (node) => {
277
327
  position: absolute; z-index: 9999; opacity: 0;
278
328
  background: var(--tooltip-bg, #333); color: var(--text-color, white); border: var(--tooltip-border, none);
279
329
  padding: var(--tooltip-padding, 6px 10px); border-radius: var(--tooltip-radius, 6px); font-size: var(--tooltip-font-size, 13px); line-height: 1.4;
280
- max-width: var(--tooltip-max-width, 280px); word-wrap: break-word; pointer-events: none;
330
+ max-width: var(--tooltip-max-width, 280px); word-wrap: break-word; text-wrap: balance; pointer-events: none;
281
331
  filter: var(--tooltip-shadow, drop-shadow(0 2px 8px rgba(0,0,0,0.25))); transition: opacity 0.15s ease-out;
282
332
  `;
283
333
  // Apply custom styles if provided (these will override base styles due to CSS specificity)
@@ -422,7 +472,7 @@ export const tooltip = (options = {}) => (node) => {
422
472
  tooltip.style.top = `${top + globalThis.scrollY}px`;
423
473
  const custom_opacity = trigger_styles.getPropertyValue(`--tooltip-opacity`).trim();
424
474
  tooltip.style.opacity = custom_opacity || `1`;
425
- current_tooltip = tooltip;
475
+ current_tooltip = Object.assign(tooltip, { _owner: element });
426
476
  }, safe_options.delay || 100);
427
477
  }
428
478
  function hide_tooltip() {
@@ -435,14 +485,22 @@ export const tooltip = (options = {}) => (node) => {
435
485
  }
436
486
  }
437
487
  }
488
+ function handle_scroll(event) {
489
+ // Hide if document or any ancestor scrolls (would move element). Skip internal element scrolls.
490
+ const target = event.target;
491
+ if (target instanceof Node && target !== element && target.contains(element)) {
492
+ hide_tooltip();
493
+ }
494
+ }
438
495
  const events = [`mouseenter`, `mouseleave`, `focus`, `blur`];
439
496
  const handlers = [show_tooltip, hide_tooltip, show_tooltip, hide_tooltip];
440
497
  events.forEach((event, idx) => element.addEventListener(event, handlers[idx]));
441
- // Hide tooltip when user scrolls
442
- globalThis.addEventListener(`scroll`, hide_tooltip, true);
498
+ // Hide tooltip when user scrolls the page (not element-level scrolls like input fields)
499
+ globalThis.addEventListener(`scroll`, handle_scroll, true);
443
500
  return () => {
501
+ observer.disconnect();
444
502
  events.forEach((event, idx) => element.removeEventListener(event, handlers[idx]));
445
- globalThis.removeEventListener(`scroll`, hide_tooltip, true);
503
+ globalThis.removeEventListener(`scroll`, handle_scroll, true);
446
504
  const original_title = element.getAttribute(`data-original-title`);
447
505
  if (original_title) {
448
506
  element.setAttribute(`title`, original_title);
@@ -468,9 +526,9 @@ export const tooltip = (options = {}) => (node) => {
468
526
  };
469
527
  export const click_outside = (config = {}) => (node) => {
470
528
  const { callback, enabled = true, exclude = [] } = config;
529
+ if (!enabled)
530
+ return; // Early return avoids registering unused listener
471
531
  function handle_click(event) {
472
- if (!enabled)
473
- return;
474
532
  const target = event.target;
475
533
  const path = event.composedPath();
476
534
  // Check if click target is the node or inside it
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export { default as FileDetails } from './FileDetails.svelte';
7
7
  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
+ export { default as Nav } from './Nav.svelte';
10
11
  export { default as PrevNext } from './PrevNext.svelte';
11
12
  export { default as Toggle } from './Toggle.svelte';
12
13
  export * from './types';
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ export { default as FileDetails } from './FileDetails.svelte';
7
7
  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
+ export { default as Nav } from './Nav.svelte';
10
11
  export { default as PrevNext } from './PrevNext.svelte';
11
12
  export { default as Toggle } from './Toggle.svelte';
12
13
  export * from './types';
package/dist/types.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { FlipParams } from 'svelte/animate';
1
2
  import type { Snippet } from 'svelte';
2
3
  import type { HTMLAttributes, HTMLInputAttributes } from 'svelte/elements';
3
4
  export type Option = string | number | ObjectOption;
@@ -16,6 +17,10 @@ export type ObjectOption = {
16
17
  style?: OptionStyle;
17
18
  [key: string]: unknown;
18
19
  };
20
+ export type PlaceholderConfig = {
21
+ text: string;
22
+ persistent?: boolean;
23
+ };
19
24
  export interface MultiSelectEvents<T extends Option = Option> {
20
25
  onadd?: (data: {
21
26
  option: T;
@@ -29,10 +34,13 @@ export interface MultiSelectEvents<T extends Option = Option> {
29
34
  onremoveAll?: (data: {
30
35
  options: T[];
31
36
  }) => unknown;
37
+ onselectAll?: (data: {
38
+ options: T[];
39
+ }) => unknown;
32
40
  onchange?: (data: {
33
41
  option?: T;
34
42
  options?: T[];
35
- type: `add` | `remove` | `removeAll`;
43
+ type: `add` | `remove` | `removeAll` | `selectAll`;
36
44
  }) => unknown;
37
45
  onopen?: (data: {
38
46
  event: Event;
@@ -41,6 +49,23 @@ export interface MultiSelectEvents<T extends Option = Option> {
41
49
  event: Event;
42
50
  }) => unknown;
43
51
  }
52
+ export interface LoadOptionsParams {
53
+ search: string;
54
+ offset: number;
55
+ limit: number;
56
+ }
57
+ export interface LoadOptionsResult<T extends Option = Option> {
58
+ options: T[];
59
+ hasMore: boolean;
60
+ }
61
+ export type LoadOptionsFn<T extends Option = Option> = (params: LoadOptionsParams) => Promise<LoadOptionsResult<T>>;
62
+ export interface LoadOptionsConfig<T extends Option = Option> {
63
+ fetch: LoadOptionsFn<T>;
64
+ debounceMs?: number;
65
+ batchSize?: number;
66
+ onOpen?: boolean;
67
+ }
68
+ export type LoadOptions<T extends Option = Option> = LoadOptionsFn<T> | LoadOptionsConfig<T>;
44
69
  type AfterInputProps = Pick<MultiSelectProps, `selected` | `disabled` | `invalid` | `id` | `placeholder` | `open` | `required`>;
45
70
  type UserMsgProps = {
46
71
  searchText: string;
@@ -73,7 +98,7 @@ export interface PortalParams {
73
98
  target_node?: HTMLElement | null;
74
99
  active?: boolean;
75
100
  }
76
- export interface MultiSelectProps<T extends Option = Option> extends MultiSelectEvents<T>, MultiSelectSnippets<T>, Omit<HTMLAttributes<HTMLDivElement>, `children` | `onchange` | `onclose`> {
101
+ export interface MultiSelectProps<T extends Option = Option> extends MultiSelectEvents<T>, MultiSelectSnippets<T>, Omit<HTMLAttributes<HTMLDivElement>, `children` | `onchange` | `onclose` | `placeholder`> {
77
102
  activeIndex?: number | null;
78
103
  activeOption?: T | null;
79
104
  createOptionMsg?: string | null;
@@ -116,12 +141,12 @@ export interface MultiSelectProps<T extends Option = Option> extends MultiSelect
116
141
  name?: string | null;
117
142
  noMatchingOptionsMsg?: string;
118
143
  open?: boolean;
119
- options: T[];
144
+ options?: T[];
120
145
  outerDiv?: HTMLDivElement | null;
121
146
  outerDivClass?: string;
122
147
  parseLabelsAsHtml?: boolean;
123
148
  pattern?: string | null;
124
- placeholder?: string | null;
149
+ placeholder?: string | PlaceholderConfig | null;
125
150
  removeAllTitle?: string;
126
151
  removeBtnTitle?: string;
127
152
  minSelect?: number | null;
@@ -138,5 +163,9 @@ export interface MultiSelectProps<T extends Option = Option> extends MultiSelect
138
163
  ulOptionsStyle?: string | null;
139
164
  value?: T | T[] | null;
140
165
  portal?: PortalParams;
166
+ selectAllOption?: boolean | string;
167
+ liSelectAllClass?: string;
168
+ loadOptions?: LoadOptions<T>;
169
+ selectedFlipParams?: FlipParams;
141
170
  }
142
171
  export {};
package/dist/utils.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Option } from './types';
2
+ export declare const is_object: (val: unknown) => val is Record<string, unknown>;
2
3
  export declare const get_label: (opt: Option) => string | number;
3
4
  export declare function get_style(option: Option, key?: `selected` | `option` | null | undefined): string;
4
5
  export declare function fuzzy_match(search_text: string, target_text: string): boolean;
package/dist/utils.js CHANGED
@@ -1,10 +1,12 @@
1
+ // Type guard for checking if a value is a non-null object
2
+ export const is_object = (val) => typeof val === `object` && val !== null;
1
3
  // Get the label key from an option object or the option itself
2
4
  // if it's a string or number
3
5
  export const get_label = (opt) => {
4
- if (opt instanceof Object) {
6
+ if (is_object(opt)) {
5
7
  if (opt.label === undefined) {
6
8
  const opt_str = JSON.stringify(opt);
7
- console.error(`MultiSelect option ${opt_str} is an object but has no label key`);
9
+ console.error(`MultiSelect: option is an object but has no label key`, opt_str);
8
10
  }
9
11
  return opt.label;
10
12
  }
@@ -27,7 +29,7 @@ export function get_style(option, key = null) {
27
29
  if (key && key in option.style)
28
30
  return option.style[key] ?? ``;
29
31
  else {
30
- console.error(`Invalid style object for option=${JSON.stringify(option)}`);
32
+ console.error(`MultiSelect: invalid style object for option`, option);
31
33
  }
32
34
  }
33
35
  }
package/package.json CHANGED
@@ -5,39 +5,46 @@
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.4",
8
+ "version": "11.4.0",
9
9
  "type": "module",
10
+ "scripts": {
11
+ "dev": "vite dev",
12
+ "build": "vite build",
13
+ "preview": "vite preview",
14
+ "test": "vitest --run && playwright test",
15
+ "check": "svelte-check"
16
+ },
10
17
  "svelte": "./dist/index.js",
11
18
  "bugs": "https://github.com/janosh/svelte-multiselect/issues",
12
19
  "peerDependencies": {
13
20
  "svelte": "^5.35.6"
14
21
  },
15
22
  "devDependencies": {
16
- "@playwright/test": "^1.55.1",
17
- "@stylistic/eslint-plugin": "^5.4.0",
23
+ "@playwright/test": "^1.57.0",
24
+ "@stylistic/eslint-plugin": "^5.6.1",
18
25
  "@sveltejs/adapter-static": "^3.0.10",
19
- "@sveltejs/kit": "^2.43.8",
20
- "@sveltejs/package": "2.5.4",
26
+ "@sveltejs/kit": "^2.49.1",
27
+ "@sveltejs/package": "2.5.7",
21
28
  "@sveltejs/vite-plugin-svelte": "^6.2.1",
22
- "@types/node": "^24.6.2",
23
- "@vitest/coverage-v8": "^3.2.4",
24
- "eslint": "^9.37.0",
25
- "eslint-plugin-svelte": "^3.12.4",
26
- "happy-dom": "^19.0.2",
29
+ "@types/node": "^24.10.1",
30
+ "@vitest/coverage-v8": "^4.0.15",
31
+ "eslint": "^9.39.1",
32
+ "eslint-plugin-svelte": "^3.13.1",
33
+ "happy-dom": "^20.0.11",
27
34
  "hastscript": "^9.0.1",
28
35
  "mdsvex": "^0.12.6",
29
36
  "mdsvexamples": "^0.5.0",
30
37
  "rehype-autolink-headings": "^7.1.0",
31
38
  "rehype-slug": "^6.0.0",
32
- "svelte": "^5.39.8",
33
- "svelte-check": "^4.3.2",
39
+ "svelte": "^5.45.6",
40
+ "svelte-check": "^4.3.4",
34
41
  "svelte-preprocess": "^6.0.3",
35
42
  "svelte-toc": "^0.6.2",
36
- "svelte2tsx": "^0.7.44",
43
+ "svelte2tsx": "^0.7.45",
37
44
  "typescript": "5.9.3",
38
- "typescript-eslint": "^8.45.0",
39
- "vite": "^7.1.9",
40
- "vitest": "^3.2.4"
45
+ "typescript-eslint": "^8.48.1",
46
+ "vite": "^7.2.7",
47
+ "vitest": "^4.0.15"
41
48
  },
42
49
  "keywords": [
43
50
  "svelte",
package/readme.md CHANGED
@@ -9,7 +9,7 @@
9
9
  [![GitHub Pages](https://github.com/janosh/svelte-multiselect/actions/workflows/gh-pages.yml/badge.svg)](https://github.com/janosh/svelte-multiselect/actions/workflows/gh-pages.yml)
10
10
  [![NPM version](https://img.shields.io/npm/v/svelte-multiselect?logo=NPM&color=purple)](https://npmjs.com/package/svelte-multiselect)
11
11
  [![Needs Svelte version](https://img.shields.io/npm/dependency-version/svelte-multiselect/peer/svelte?color=teal&logo=Svelte&label=Svelte)](https://github.com/sveltejs/svelte/blob/master/packages/svelte/CHANGELOG.md)
12
- [![REPL](https://img.shields.io/badge/Svelte-REPL-blue?label=Try%20it!)](https://svelte.dev/repl/a5a14b8f15d64cb083b567292480db05)
12
+ [![Playground](https://img.shields.io/badge/Svelte-Playground-blue?label=Try%20it!)](https://svelte.dev/playground/a5a14b8f15d64cb083b567292480db05)
13
13
  [![Open in StackBlitz](https://img.shields.io/badge/Open%20in-StackBlitz-darkblue?logo=stackblitz)](https://stackblitz.com/github/janosh/svelte-multiselect)
14
14
 
15
15
  </h4>
@@ -32,8 +32,6 @@
32
32
  - **Single / multiple select:** pass `maxSelect={1, 2, 3, ...}` prop to restrict the number of selectable options
33
33
  - **Configurable:** see props
34
34
 
35
- <slot name="nav" />
36
-
37
35
  ## ๐Ÿงช &thinsp; Coverage
38
36
 
39
37
  | Statements | Branches | Lines |
@@ -174,10 +172,18 @@ These are the core props you'll use in most cases:
174
172
  ```
175
173
 
176
174
  1. ```ts
177
- placeholder: string | null = null
175
+ placeholder: string | { text: string; persistent?: boolean } | null = null
178
176
  ```
179
177
 
180
- Text shown when no options are selected.
178
+ Text shown when no options are selected. Can be a simple string or an object with extended options:
179
+
180
+ ```svelte
181
+ <!-- Simple string -->
182
+ <MultiSelect placeholder="Choose..." />
183
+
184
+ <!-- Object with persistent option (stays visible even when options selected) -->
185
+ <MultiSelect placeholder={{ text: 'Add items...', persistent: true }} />
186
+ ```
181
187
 
182
188
  1. ```ts
183
189
  disabled: boolean = false
@@ -231,6 +237,41 @@ These are the core props you'll use in most cases:
231
237
 
232
238
  ### Advanced Props
233
239
 
240
+ 1. ```ts
241
+ loadOptions: LoadOptionsFn | LoadOptionsConfig = undefined
242
+ ```
243
+
244
+ **Dynamic loading for large datasets.** Enables lazy loading / infinite scroll instead of passing static `options`. Pass either a function or an object with config:
245
+
246
+ ```svelte
247
+ <!-- Simple: just a function -->
248
+ <MultiSelect loadOptions={myFetchFn} />
249
+
250
+ <!-- With config -->
251
+ <MultiSelect loadOptions={{ fetch: myFetchFn, debounceMs: 500, batchSize: 20 }} />
252
+ ```
253
+
254
+ The function receives `{ search, offset, limit }` and must return `{ options, hasMore }`:
255
+
256
+ ```ts
257
+ async function load_options({ search, offset, limit }) {
258
+ const response = await fetch(`/api/items?q=${search}&skip=${offset}&take=${limit}`)
259
+ const { items, total } = await response.json()
260
+ return { options: items, hasMore: offset + limit < total }
261
+ }
262
+ ```
263
+
264
+ Config options (when passing an object):
265
+
266
+ | Key | Type | Default | Description |
267
+ | ------------ | --------- | ------- | ------------------------------------------- |
268
+ | `fetch` | `fn` | โ€” | Async function to load options (required) |
269
+ | `debounceMs` | `number` | `300` | Debounce delay for search queries |
270
+ | `batchSize` | `number` | `50` | Number of options to load per batch |
271
+ | `onOpen` | `boolean` | `true` | Whether to load options when dropdown opens |
272
+
273
+ Features automatic state management, debounced search, infinite scroll pagination, and loading indicators. See the [infinite-scroll demo](https://janosh.github.io/svelte-multiselect/infinite-scroll) for live examples.
274
+
234
275
  1. ```ts
235
276
  activeIndex: number | null = null // bindable
236
277
  ```
@@ -270,10 +311,10 @@ These are the core props you'll use in most cases:
270
311
  Function to determine option equality. Default compares by lowercased label.
271
312
 
272
313
  1. ```ts
273
- closeDropdownOnSelect: boolean | 'if-mobile' | 'retain-focus' = 'if-mobile'
314
+ closeDropdownOnSelect: boolean | 'if-mobile' | 'retain-focus' = false
274
315
  ```
275
316
 
276
- Whether to close dropdown after selection. `'if-mobile'` closes dropdown on mobile devices only (responsive). `'retain-focus'` closes dropdown but keeps input focused for rapid typing to create custom options from text input (see `allowUserOptions`).
317
+ Whether to close dropdown after selection. `false` (default) keeps dropdown open for rapid multi-selection. `true` closes after each selection. `'if-mobile'` closes on mobile devices only (screen width below `breakpoint`). `'retain-focus'` closes dropdown but keeps input focused for rapid typing to create custom options from text input (see `allowUserOptions`).
277
318
 
278
319
  1. ```ts
279
320
  resetFilterOnAdd: boolean = true
@@ -351,12 +392,36 @@ These are the core props you'll use in most cases:
351
392
 
352
393
  Screen width (px) that separates 'mobile' from 'desktop' behavior.
353
394
 
395
+ 1. ```ts
396
+ fuzzy: boolean = true
397
+ ```
398
+
399
+ Whether to use fuzzy matching for filtering options. When `true` (default), matches non-consecutive characters (e.g., "ga" matches "Grapes" and "Green Apple"). When `false`, uses substring matching only.
400
+
354
401
  1. ```ts
355
402
  highlightMatches: boolean = true
356
403
  ```
357
404
 
358
405
  Whether to highlight matching text in dropdown options.
359
406
 
407
+ 1. ```ts
408
+ keepSelectedInDropdown: false | 'plain' | 'checkboxes' = false
409
+ ```
410
+
411
+ Controls whether selected options remain visible in dropdown. `false` (default) hides selected options. `'plain'` shows them with visual distinction. `'checkboxes'` prefixes each option with a checkbox.
412
+
413
+ 1. ```ts
414
+ selectAllOption: boolean | string = false
415
+ ```
416
+
417
+ Adds a "Select All" option at the top of the dropdown. `true` shows default label, or pass a custom string label.
418
+
419
+ 1. ```ts
420
+ liSelectAllClass: string = ''
421
+ ```
422
+
423
+ CSS class applied to the "Select All" `<li>` element.
424
+
360
425
  1. ```ts
361
426
  parseLabelsAsHtml: boolean = false
362
427
  ```
@@ -369,6 +434,12 @@ These are the core props you'll use in most cases:
369
434
 
370
435
  Whether selected options can be reordered by dragging.
371
436
 
437
+ 1. ```ts
438
+ selectedFlipParams: FlipParams = { duration: 100 }
439
+ ```
440
+
441
+ Animation parameters for the [Svelte flip animation](https://svelte.dev/docs/svelte/svelte-animate) when reordering selected options via drag-and-drop. Set `{ duration: 0 }` to disable animation. Accepts `duration`, `delay`, and `easing` properties.
442
+
372
443
  ### Message Props
373
444
 
374
445
  1. ```ts
@@ -684,7 +755,12 @@ You can also import [the types this component uses](https://github.com/janosh/sv
684
755
 
685
756
  ```ts
686
757
  import {
687
- DispatchEvents,
758
+ LoadOptions, // Dynamic option loading callback
759
+ LoadOptionsConfig,
760
+ LoadOptionsFn,
761
+ LoadOptionsParams,
762
+ LoadOptionsResult,
763
+ MultiSelectEvents,
688
764
  MultiSelectEvents,
689
765
  ObjectOption,
690
766
  Option,