svelte-multiselect 8.4.0 → 8.6.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,12 +1,18 @@
1
- <script>import { tick } from 'svelte/internal';
1
+ <script>/* eslint-disable no-undef */ // TODO: remove when fixed
2
+ // https://github.com/sveltejs/eslint-plugin-svelte3/issues/201
3
+ import { tick } from 'svelte';
2
4
  import { fade } from 'svelte/transition';
3
5
  import Select from '.';
4
6
  export let actions;
5
7
  export let trigger = `k`;
6
8
  export let fade_duration = 200; // in ms
7
- let open = false;
8
- let dialog;
9
- let input;
9
+ export let style = ``; // for dialog
10
+ // for span in option slot, has no effect when passing slot="option"
11
+ export let span_style = ``;
12
+ export let open = false;
13
+ export let dialog;
14
+ export let input;
15
+ export let placeholder = `Filter actions...`;
10
16
  async function toggle(event) {
11
17
  if (event.key === trigger && event.metaKey && !open) {
12
18
  // open on cmd+trigger
@@ -24,7 +30,7 @@ function close_if_outside(event) {
24
30
  open = false;
25
31
  }
26
32
  }
27
- function move(event) {
33
+ function run_and_close(event) {
28
34
  event.detail.option.action();
29
35
  open = false;
30
36
  }
@@ -33,14 +39,25 @@ function move(event) {
33
39
  <svelte:window on:keydown={toggle} on:click={close_if_outside} />
34
40
 
35
41
  {#if open}
36
- <dialog class:open bind:this={dialog} transition:fade={{ duration: fade_duration }}>
42
+ <dialog
43
+ class:open
44
+ bind:this={dialog}
45
+ transition:fade={{ duration: fade_duration }}
46
+ {style}
47
+ >
37
48
  <Select
38
49
  options={actions}
39
50
  bind:input
40
- placeholder="Go to..."
41
- on:add={move}
51
+ {placeholder}
52
+ on:add={run_and_close}
42
53
  on:keydown={toggle}
43
- />
54
+ {...$$props}
55
+ >
56
+ <!-- wait for https://github.com/sveltejs/svelte/pull/8304 -->
57
+ <slot slot="option" name="option" let:option>
58
+ <span style={span_style}>{option.label}</span>
59
+ </slot>
60
+ </Select>
44
61
  </dialog>
45
62
  {/if}
46
63
 
@@ -1,17 +1,28 @@
1
1
  import { SvelteComponentTyped } from "svelte";
2
2
  declare const __propDef: {
3
3
  props: {
4
+ [x: string]: any;
4
5
  actions: {
5
6
  label: string;
6
7
  action: () => void;
7
8
  }[];
8
9
  trigger?: string | undefined;
9
10
  fade_duration?: number | undefined;
11
+ style?: string | undefined;
12
+ span_style?: string | undefined;
13
+ open?: boolean | undefined;
14
+ dialog: HTMLDialogElement;
15
+ input: HTMLInputElement;
16
+ placeholder?: string | undefined;
10
17
  };
11
18
  events: {
12
19
  [evt: string]: CustomEvent<any>;
13
20
  };
14
- slots: {};
21
+ slots: {
22
+ option: {
23
+ slot: string;
24
+ };
25
+ };
15
26
  };
16
27
  export type CmdPaletteProps = typeof __propDef.props;
17
28
  export type CmdPaletteEvents = typeof __propDef.events;
@@ -25,6 +25,7 @@ export let filterFunc = (op, searchText) => {
25
25
  };
26
26
  export let focusInputOnSelect = `desktop`;
27
27
  export let form_input = null;
28
+ export let highlightMatches = true;
28
29
  export let id = null;
29
30
  export let input = null;
30
31
  export let inputClass = ``;
@@ -128,7 +129,7 @@ function add(label, event) {
128
129
  // custom option twice in append mode
129
130
  [true, `append`].includes(allowUserOptions) &&
130
131
  searchText.length > 0) {
131
- // user entered text but no options match, so if allowUserOptions=true | 'append', we create
132
+ // user entered text but no options match, so if allowUserOptions = true | 'append', we create
132
133
  // a new option from the user-entered text
133
134
  if (typeof options[0] === `object`) {
134
135
  // if 1st option is an object, we create new option as object to keep type homogeneity
@@ -140,8 +141,10 @@ function add(label, event) {
140
141
  // create new option as number if it parses to a number and 1st option is also number or missing
141
142
  option = Number(searchText);
142
143
  }
143
- else
144
+ else {
144
145
  option = searchText; // else create custom option as string
146
+ }
147
+ dispatch(`create`, { option });
145
148
  }
146
149
  if (allowUserOptions === `append`)
147
150
  options = [...options, option];
@@ -199,10 +202,10 @@ function remove(label) {
199
202
  return console.error(`Multiselect can't remove selected option ${label}, not found in selected list`);
200
203
  }
201
204
  selected = selected.filter((op) => get_label(op) !== label); // remove option from selected list
202
- dispatch(`remove`, { option });
203
- dispatch(`change`, { option, type: `remove` });
204
205
  invalid = false; // reset error status whenever items are removed
205
206
  form_input?.setCustomValidity(``);
207
+ dispatch(`remove`, { option });
208
+ dispatch(`change`, { option, type: `remove` });
206
209
  }
207
210
  function open_dropdown(event) {
208
211
  if (disabled)
@@ -287,10 +290,10 @@ async function handle_keydown(event) {
287
290
  }
288
291
  }
289
292
  function remove_all() {
290
- dispatch(`removeAll`, { options: selected });
291
- dispatch(`change`, { options: selected, type: `removeAll` });
292
293
  selected = [];
293
294
  searchText = ``;
295
+ dispatch(`removeAll`, { options: selected });
296
+ dispatch(`change`, { options: selected, type: `removeAll` });
294
297
  }
295
298
  $: is_selected = (label) => selected.map(get_label).includes(label);
296
299
  const if_enter_or_space = (handler) => (event) => {
@@ -331,6 +334,47 @@ const dragstart = (idx) => (event) => {
331
334
  event.dataTransfer.dropEffect = `move`;
332
335
  event.dataTransfer.setData(`text/plain`, `${idx}`);
333
336
  };
337
+ let ul_options;
338
+ function highlight_matching_options(event) {
339
+ if (!highlightMatches || typeof CSS == `undefined` || !CSS.highlights)
340
+ return; // don't try if CSS highlight API not supported
341
+ // clear previous ranges from HighlightRegistry
342
+ CSS.highlights.clear();
343
+ // get input's search query
344
+ const query = event?.target?.value.trim().toLowerCase();
345
+ if (!query)
346
+ return;
347
+ const tree_walker = document.createTreeWalker(ul_options, NodeFilter.SHOW_TEXT);
348
+ const text_nodes = [];
349
+ let current_node = tree_walker.nextNode();
350
+ while (current_node) {
351
+ text_nodes.push(current_node);
352
+ current_node = tree_walker.nextNode();
353
+ }
354
+ // iterate over all text nodes and find matches
355
+ const ranges = text_nodes.map((el) => {
356
+ const text = el.textContent.toLowerCase();
357
+ const indices = [];
358
+ let start_pos = 0;
359
+ while (start_pos < text.length) {
360
+ const index = text.indexOf(query, start_pos);
361
+ if (index === -1)
362
+ break;
363
+ indices.push(index);
364
+ start_pos = index + query.length;
365
+ }
366
+ // create range object for each str found in the text node
367
+ return indices.map((index) => {
368
+ const range = new Range();
369
+ range.setStart(el, index);
370
+ range.setEnd(el, index + query.length);
371
+ return range;
372
+ });
373
+ });
374
+ // create Highlight object from ranges and add to registry
375
+ // eslint-disable-next-line no-undef
376
+ CSS.highlights.set(`search-results`, new Highlight(...ranges.flat()));
377
+ }
334
378
  </script>
335
379
 
336
380
  <svelte:window
@@ -344,13 +388,10 @@ const dragstart = (idx) => (event) => {
344
388
  class:disabled
345
389
  class:single={maxSelect === 1}
346
390
  class:open
347
- aria-expanded={open}
348
- aria-multiselectable={maxSelect === null || maxSelect > 1}
349
391
  class:invalid
350
392
  class="multiselect {outerDivClass}"
351
393
  on:mouseup|stopPropagation={open_dropdown}
352
394
  title={disabled ? disabledInputTitle : null}
353
- aria-disabled={disabled ? `true` : null}
354
395
  data-id={id}
355
396
  >
356
397
  <!-- bind:value={selected} prevents form submission if required prop is true and no options are selected -->
@@ -379,11 +420,10 @@ const dragstart = (idx) => (event) => {
379
420
  <slot name="expand-icon" {open}>
380
421
  <ExpandIcon width="15px" style="min-width: 1em; padding: 0 1pt; cursor: pointer;" />
381
422
  </slot>
382
- <ul class="selected {ulSelectedClass}">
423
+ <ul class="selected {ulSelectedClass}" aria-label="selected options">
383
424
  {#each selected as option, idx (get_label(option))}
384
425
  <li
385
426
  class={liSelectedClass}
386
- aria-selected="true"
387
427
  animate:flip={{ duration: 100 }}
388
428
  draggable={selectedOptionsDraggable && !disabled && selected.length > 1}
389
429
  on:dragstart={dragstart(idx)}
@@ -397,8 +437,7 @@ const dragstart = (idx) => (event) => {
397
437
  {#if parseLabelsAsHtml}
398
438
  {@html get_label(option)}
399
439
  {:else}
400
- {get_label(option)}
401
- {/if}
440
+ {get_label(option)}{/if}
402
441
  </slot>
403
442
  {#if !disabled && (minSelect === null || selected.length > minSelect)}
404
443
  <button
@@ -415,38 +454,37 @@ const dragstart = (idx) => (event) => {
415
454
  {/if}
416
455
  </li>
417
456
  {/each}
418
- <li style="display: contents;">
419
- <input
420
- class={inputClass}
421
- bind:this={input}
422
- bind:value={searchText}
423
- on:mouseup|self|stopPropagation={open_dropdown}
424
- on:keydown|stopPropagation={handle_keydown}
425
- on:focus
426
- on:focus={open_dropdown}
427
- {id}
428
- {disabled}
429
- {autocomplete}
430
- {inputmode}
431
- {pattern}
432
- placeholder={selected.length == 0 ? placeholder : null}
433
- aria-invalid={invalid ? `true` : null}
434
- ondrop="return false"
435
- on:blur
436
- on:change
437
- on:click
438
- on:keydown
439
- on:keyup
440
- on:mousedown
441
- on:mouseenter
442
- on:mouseleave
443
- on:touchcancel
444
- on:touchend
445
- on:touchmove
446
- on:touchstart
447
- />
448
- <!-- the above on:* lines forward potentially useful DOM events -->
449
- </li>
457
+ <input
458
+ class={inputClass}
459
+ bind:this={input}
460
+ bind:value={searchText}
461
+ on:mouseup|self|stopPropagation={open_dropdown}
462
+ on:keydown|stopPropagation={handle_keydown}
463
+ on:focus
464
+ on:focus={open_dropdown}
465
+ on:input={highlight_matching_options}
466
+ {id}
467
+ {disabled}
468
+ {autocomplete}
469
+ {inputmode}
470
+ {pattern}
471
+ placeholder={selected.length == 0 ? placeholder : null}
472
+ aria-invalid={invalid ? `true` : null}
473
+ ondrop="return false"
474
+ on:blur
475
+ on:change
476
+ on:click
477
+ on:keydown
478
+ on:keyup
479
+ on:mousedown
480
+ on:mouseenter
481
+ on:mouseleave
482
+ on:touchcancel
483
+ on:touchend
484
+ on:touchmove
485
+ on:touchstart
486
+ />
487
+ <!-- the above on:* lines forward potentially useful DOM events -->
450
488
  </ul>
451
489
  {#if loading}
452
490
  <slot name="spinner">
@@ -482,7 +520,7 @@ const dragstart = (idx) => (event) => {
482
520
 
483
521
  <!-- only render options dropdown if options or searchText is not empty needed to avoid briefly flashing empty dropdown -->
484
522
  {#if (searchText && noMatchingOptionsMsg) || options?.length > 0}
485
- <ul class:hidden={!open} class="options {ulOptionsClass}">
523
+ <ul class:hidden={!open} class="options {ulOptionsClass}" bind:this={ul_options}>
486
524
  {#each matchingOptions as option, idx}
487
525
  {@const {
488
526
  label,
@@ -512,7 +550,6 @@ const dragstart = (idx) => (event) => {
512
550
  }}
513
551
  on:mouseout={() => (activeIndex = null)}
514
552
  on:blur={() => (activeIndex = null)}
515
- aria-selected="false"
516
553
  >
517
554
  <slot name="option" {option} {idx}>
518
555
  {#if parseLabelsAsHtml}
@@ -533,7 +570,6 @@ const dragstart = (idx) => (event) => {
533
570
  on:focus={() => (add_option_msg_is_active = true)}
534
571
  on:mouseout={() => (add_option_msg_is_active = false)}
535
572
  on:blur={() => (add_option_msg_is_active = false)}
536
- aria-selected="false"
537
573
  >
538
574
  {!duplicates && selected.some((option) => duplicateFunc(option, searchText))
539
575
  ? duplicateOptionMsg
@@ -627,7 +663,7 @@ const dragstart = (idx) => (event) => {
627
663
  margin: auto 0; /* CSS reset */
628
664
  padding: 0; /* CSS reset */
629
665
  }
630
- :where(div.multiselect > ul.selected > li > input) {
666
+ :where(div.multiselect > ul.selected > input) {
631
667
  border: none;
632
668
  outline: none;
633
669
  background: none;
@@ -640,7 +676,7 @@ const dragstart = (idx) => (event) => {
640
676
  border-radius: 0; /* reset ul.selected > li */
641
677
  }
642
678
  /* don't wrap ::placeholder rules in :where() as it seems to be overpowered by browser defaults i.t.o. specificity */
643
- div.multiselect > ul.selected > li > input::placeholder {
679
+ div.multiselect > ul.selected > input::placeholder {
644
680
  padding-left: 5pt;
645
681
  color: var(--sms-placeholder-color);
646
682
  opacity: var(--sms-placeholder-opacity);
@@ -705,4 +741,10 @@ const dragstart = (idx) => (event) => {
705
741
  :where(span.max-select-msg) {
706
742
  padding: 0 3pt;
707
743
  }
744
+ ::highlight(search-results) {
745
+ color: var(--sms-highlight-color, orange);
746
+ background: var(--sms-highlight-bg);
747
+ text-decoration: var(--sms-highlight-text-decoration);
748
+ text-decoration-color: var(--sms-highlight-text-decoration-color);
749
+ }
708
750
  </style>
@@ -19,6 +19,7 @@ declare class __sveltets_Render<Option extends GenericOption> {
19
19
  filterFunc?: ((op: Option, searchText: string) => boolean) | undefined;
20
20
  focusInputOnSelect?: boolean | "desktop" | undefined;
21
21
  form_input?: HTMLInputElement | null | undefined;
22
+ highlightMatches?: boolean | undefined;
22
23
  id?: string | null | undefined;
23
24
  input?: HTMLInputElement | null | undefined;
24
25
  inputClass?: string | undefined;
package/dist/index.d.ts CHANGED
@@ -17,6 +17,9 @@ export type DispatchEvents<T = Option> = {
17
17
  add: {
18
18
  option: T;
19
19
  };
20
+ create: {
21
+ option: T;
22
+ };
20
23
  remove: {
21
24
  option: T;
22
25
  };
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": "8.4.0",
8
+ "version": "8.6.0",
9
9
  "type": "module",
10
10
  "svelte": "./dist/index.js",
11
11
  "bugs": "https://github.com/janosh/svelte-multiselect/issues",
@@ -23,37 +23,37 @@
23
23
  "update-coverage": "vitest tests/unit --run --coverage && npx istanbul-badges-readme"
24
24
  },
25
25
  "dependencies": {
26
- "svelte": "^3.55.1"
26
+ "svelte": "^3.57.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@iconify/svelte": "^3.1.0",
30
- "@playwright/test": "^1.31.1",
30
+ "@playwright/test": "^1.31.2",
31
31
  "@sveltejs/adapter-static": "^2.0.1",
32
- "@sveltejs/kit": "^1.9.2",
32
+ "@sveltejs/kit": "^1.12.0",
33
33
  "@sveltejs/package": "2.0.2",
34
34
  "@sveltejs/vite-plugin-svelte": "^2.0.3",
35
- "@typescript-eslint/eslint-plugin": "^5.54.0",
36
- "@typescript-eslint/parser": "^5.54.0",
37
- "@vitest/coverage-c8": "^0.29.2",
38
- "eslint": "^8.35.0",
35
+ "@typescript-eslint/eslint-plugin": "^5.55.0",
36
+ "@typescript-eslint/parser": "^5.55.0",
37
+ "@vitest/coverage-c8": "^0.29.3",
38
+ "eslint": "^8.36.0",
39
39
  "eslint-plugin-svelte3": "^4.0.0",
40
40
  "hastscript": "^7.2.0",
41
41
  "highlight.js": "^11.7.0",
42
- "jsdom": "^21.1.0",
42
+ "jsdom": "^21.1.1",
43
43
  "mdsvex": "^0.10.6",
44
44
  "mdsvexamples": "^0.3.3",
45
45
  "prettier": "^2.8.4",
46
46
  "prettier-plugin-svelte": "^2.9.0",
47
47
  "rehype-autolink-headings": "^6.1.1",
48
48
  "rehype-slug": "^5.1.0",
49
- "svelte-check": "^3.0.4",
50
- "svelte-preprocess": "^5.0.1",
51
- "svelte-toc": "^0.5.2",
52
- "svelte-zoo": "^0.3.4",
53
- "svelte2tsx": "^0.6.2",
54
- "typescript": "^4.9.5",
55
- "vite": "^4.1.4",
56
- "vitest": "^0.29.2"
49
+ "svelte-check": "^3.1.4",
50
+ "svelte-preprocess": "^5.0.3",
51
+ "svelte-toc": "^0.5.4",
52
+ "svelte-zoo": "^0.4.3",
53
+ "svelte2tsx": "^0.6.10",
54
+ "typescript": "5.0.2",
55
+ "vite": "^4.2.0",
56
+ "vitest": "^0.29.3"
57
57
  },
58
58
  "keywords": [
59
59
  "svelte",
package/readme.md CHANGED
@@ -8,7 +8,7 @@
8
8
  [![Tests](https://github.com/janosh/svelte-multiselect/actions/workflows/test.yml/badge.svg)](https://github.com/janosh/svelte-multiselect/actions/workflows/test.yml)
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
- [![Needs Svelte version](https://img.shields.io/npm/dependency-version/svelte-multiselect/dev/svelte?color=teal&logo=Svelte&label=Svelte)](https://github.com/sveltejs/svelte/blob/master/CHANGELOG.md)
11
+ [![Needs Svelte version](https://img.shields.io/npm/dependency-version/svelte-multiselect/svelte?color=teal&logo=Svelte&label=Svelte)](https://github.com/sveltejs/svelte/blob/master/CHANGELOG.md)
12
12
  [![REPL](https://img.shields.io/badge/Svelte-REPL-blue?label=Try%20it!)](https://svelte.dev/repl/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
 
@@ -185,6 +185,12 @@ Full list of props/bindable variables for this component. The `Option` type you
185
185
 
186
186
  Handle to the `<input>` DOM node that's responsible for form validity checks and passing selected options to form submission handlers. Only available after component mounts (`null` before then).
187
187
 
188
+ 1. ```ts
189
+ highlightMatches: boolean = true
190
+ ```
191
+
192
+ Whether to highlight text in the dropdown options that matches the current user-entered search query. Uses the [CSS Custom Highlight API](https://developer.mozilla.org/docs/Web/API/CSS_Custom_Highlight_API) with limited browser support and [styling options](https://developer.mozilla.org/docs/Web/CSS/::highlight). See `::highlight(search-results)` below for available CSS variables.
193
+
188
194
  1. ```ts
189
195
  id: string | null = null
190
196
  ```
@@ -500,6 +506,12 @@ There are 3 ways to style this component. To understand which options do what, i
500
506
 
501
507
  If you only want to make small adjustments, you can pass the following CSS variables directly to the component as props or define them in a `:global()` CSS context. See [`app.css`](https://github.com/janosh/svelte-multiselect/blob/main/src/app.css) for how these variables are set on the demo site of this component.
502
508
 
509
+ Minimal example that changes the background color of the options dropdown:
510
+
511
+ ```svelte
512
+ <MultiSelect --sms-options-bg="white" />
513
+ ```
514
+
503
515
  - `div.multiselect`
504
516
  - `border: var(--sms-border, 1pt solid lightgray)`: Change this to e.g. to `1px solid red` to indicate this form field is in an invalid state.
505
517
  - `border-radius: var(--sms-border-radius, 3pt)`
@@ -547,12 +559,11 @@ If you only want to make small adjustments, you can pass the following CSS varia
547
559
  - `div.multiselect > ul.options > li.disabled`
548
560
  - `background: var(--sms-li-disabled-bg, #f5f5f6)`: Background of disabled options in the dropdown list.
549
561
  - `color: var(--sms-li-disabled-text, #b8b8b8)`: Text color of disabled option in the dropdown list.
550
-
551
- For example, to change the background color of the options dropdown:
552
-
553
- ```svelte
554
- <MultiSelect --sms-options-bg="white" />
555
- ```
562
+ - `::highlight(search-results)`: applies to search results in dropdown list that match the current search query if `highlightMatches=true`
563
+ - `color: var(--sms-highlight-color, orange)`
564
+ - `background: var(--sms-highlight-bg)`
565
+ - `text-decoration: var(--sms-highlight-text-decoration)`
566
+ - `text-decoration-color: var(--sms-highlight-text-decoration-color)`
556
567
 
557
568
  ### With CSS frameworks
558
569
 
@@ -611,7 +622,7 @@ Odd as it may seem, you get the most fine-grained control over the styling of ev
611
622
  :global(div.multiselect > ul.selected > li button, button.remove-all) {
612
623
  /* buttons to remove a single or all selected options at once */
613
624
  }
614
- :global(div.multiselect > ul.selected > li > input) {
625
+ :global(div.multiselect > input[autocomplete]) {
615
626
  /* input inside the top-level wrapper div */
616
627
  }
617
628
  :global(div.multiselect > ul.options) {