svelte-multiselect 8.0.3 → 8.1.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.
@@ -22,6 +22,7 @@ export let filterFunc = (op, searchText) => {
22
22
  return `${get_label(op)}`.toLowerCase().includes(searchText.toLowerCase());
23
23
  };
24
24
  export let focusInputOnSelect = `desktop`;
25
+ export let form_input;
25
26
  export let id = null;
26
27
  export let input = null;
27
28
  export let inputClass = ``;
@@ -32,8 +33,9 @@ export let liOptionClass = ``;
32
33
  export let liSelectedClass = ``;
33
34
  export let loading = false;
34
35
  export let matchingOptions = [];
35
- export let maxSelect = null; // null means any number of options are selectable
36
+ export let maxSelect = null; // null means there is no upper limit for selected.length
36
37
  export let maxSelectMsg = (current, max) => (max > 1 ? `${current}/${max}` : ``);
38
+ export let maxSelectMsgClass = ``;
37
39
  export let name = null;
38
40
  export let noMatchingOptionsMsg = `No matching options`;
39
41
  export let open = false;
@@ -45,6 +47,7 @@ export let pattern = null;
45
47
  export let placeholder = null;
46
48
  export let removeAllTitle = `Remove all`;
47
49
  export let removeBtnTitle = `Remove`;
50
+ export let minSelect = null; // null means there is no lower limit for selected.length
48
51
  export let required = false;
49
52
  export let resetFilterOnAdd = true;
50
53
  export let searchText = ``;
@@ -63,7 +66,7 @@ const get_label = (op) => (op instanceof Object ? op.label : op);
63
66
  $: value = maxSelect === 1 ? selected[0] ?? null : selected;
64
67
  let wiggle = false; // controls wiggle animation when user tries to exceed maxSelect
65
68
  if (!(options?.length > 0)) {
66
- if (allowUserOptions || loading) {
69
+ if (allowUserOptions || loading || disabled) {
67
70
  options = []; // initializing as array avoids errors when component mounts
68
71
  }
69
72
  else {
@@ -76,10 +79,13 @@ if (parseLabelsAsHtml && allowUserOptions) {
76
79
  console.warn(`Don't combine parseLabelsAsHtml and allowUserOptions. It's susceptible to XSS attacks!`);
77
80
  }
78
81
  if (maxSelect !== null && maxSelect < 1) {
79
- console.error(`maxSelect must be null or positive integer, got ${maxSelect}`);
82
+ console.error(`MultiSelect's maxSelect must be null or positive integer, got ${maxSelect}`);
80
83
  }
81
84
  if (!Array.isArray(selected)) {
82
- console.error(`internal variable selected prop should always be an array, got ${selected}`);
85
+ console.error(`MultiSelect's selected prop should always be an array, got ${selected}`);
86
+ }
87
+ if (maxSelect && typeof required === `number` && required > maxSelect) {
88
+ console.error(`MultiSelect maxSelect=${maxSelect} < required=${required}, makes it impossible for users to submit a valid form`);
83
89
  }
84
90
  const dispatch = createEventDispatcher();
85
91
  let add_option_msg_is_active = false; // controls active state of <li>{addOptionMsg}</li>
@@ -161,6 +167,7 @@ function add(label, event) {
161
167
  dispatch(`add`, { option });
162
168
  dispatch(`change`, { option, type: `add` });
163
169
  invalid = false; // reset error status whenever new items are selected
170
+ form_input?.setCustomValidity(``);
164
171
  }
165
172
  }
166
173
  // remove an option from selected list
@@ -180,6 +187,7 @@ function remove(label) {
180
187
  dispatch(`remove`, { option });
181
188
  dispatch(`change`, { option, type: `remove` });
182
189
  invalid = false; // reset error status whenever items are removed
190
+ form_input?.setCustomValidity(``);
183
191
  }
184
192
  function open_dropdown(event) {
185
193
  if (disabled)
@@ -296,17 +304,30 @@ function on_click_outside(event) {
296
304
  on:mouseup|stopPropagation={open_dropdown}
297
305
  title={disabled ? disabledInputTitle : null}
298
306
  aria-disabled={disabled ? `true` : null}
307
+ data-id={id}
299
308
  >
300
309
  <!-- bind:value={selected} prevents form submission if required prop is true and no options are selected -->
301
310
  <input
302
- {required}
303
311
  {name}
304
- value={selected.length > 0 ? JSON.stringify(selected) : null}
312
+ required={Boolean(required)}
313
+ value={selected.length >= required ? JSON.stringify(selected) : null}
305
314
  tabindex="-1"
306
315
  aria-hidden="true"
307
316
  aria-label="ignore this, used only to prevent form submission if select is required but empty"
308
317
  class="form-control"
309
- on:invalid={() => (invalid = true)}
318
+ bind:this={form_input}
319
+ on:invalid={() => {
320
+ invalid = true
321
+ let msg
322
+ if (maxSelect && maxSelect > 1 && required > 1) {
323
+ msg = `Please select between ${required} and ${maxSelect} options`
324
+ } else if (required > 1) {
325
+ msg = `Please select at least ${required} options`
326
+ } else {
327
+ msg = `Please select an option`
328
+ }
329
+ form_input.setCustomValidity(msg)
330
+ }}
310
331
  />
311
332
  <ExpandIcon width="15px" style="min-width: 1em; padding: 0 1pt;" />
312
333
  <ul class="selected {ulSelectedClass}">
@@ -319,7 +340,7 @@ function on_click_outside(event) {
319
340
  {get_label(option)}
320
341
  {/if}
321
342
  </slot>
322
- {#if !disabled}
343
+ {#if !disabled && (minSelect === null || selected.length > minSelect)}
323
344
  <button
324
345
  on:mouseup|stopPropagation={() => remove(get_label(option))}
325
346
  on:keydown={if_enter_or_space(() => remove(get_label(option)))}
@@ -377,7 +398,7 @@ function on_click_outside(event) {
377
398
  {:else if selected.length > 0}
378
399
  {#if maxSelect && (maxSelect > 1 || maxSelectMsg)}
379
400
  <Wiggle bind:wiggle angle={20}>
380
- <span style="padding: 0 3pt;">
401
+ <span class="max-select-msg {maxSelectMsgClass}">
381
402
  {maxSelectMsg?.(selected.length, maxSelect)}
382
403
  </span>
383
404
  </Wiggle>
@@ -607,4 +628,8 @@ function on_click_outside(event) {
607
628
  background: var(--sms-li-disabled-bg, #f5f5f6);
608
629
  color: var(--sms-li-disabled-text, #b8b8b8);
609
630
  }
631
+
632
+ :where(span.max-select-msg) {
633
+ padding: 0 3pt;
634
+ }
610
635
  </style>
@@ -17,6 +17,7 @@ declare const __propDef: {
17
17
  duplicates?: boolean | undefined;
18
18
  filterFunc?: ((op: Option, searchText: string) => boolean) | undefined;
19
19
  focusInputOnSelect?: boolean | "desktop" | undefined;
20
+ form_input: HTMLInputElement;
20
21
  id?: string | null | undefined;
21
22
  input?: HTMLInputElement | null | undefined;
22
23
  inputClass?: string | undefined;
@@ -29,6 +30,7 @@ declare const __propDef: {
29
30
  matchingOptions?: Option[] | undefined;
30
31
  maxSelect?: number | null | undefined;
31
32
  maxSelectMsg?: ((current: number, max: number) => string) | null | undefined;
33
+ maxSelectMsgClass?: string | undefined;
32
34
  name?: string | null | undefined;
33
35
  noMatchingOptionsMsg?: string | undefined;
34
36
  open?: boolean | undefined;
@@ -40,7 +42,8 @@ declare const __propDef: {
40
42
  placeholder?: string | null | undefined;
41
43
  removeAllTitle?: string | undefined;
42
44
  removeBtnTitle?: string | undefined;
43
- required?: boolean | undefined;
45
+ minSelect?: number | null | undefined;
46
+ required?: number | boolean | undefined;
44
47
  resetFilterOnAdd?: boolean | undefined;
45
48
  searchText?: string | undefined;
46
49
  selected?: Option[] | undefined;
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "homepage": "https://svelte-multiselect.netlify.app",
6
6
  "repository": "https://github.com/janosh/svelte-multiselect",
7
7
  "license": "MIT",
8
- "version": "8.0.3",
8
+ "version": "8.1.0",
9
9
  "type": "module",
10
10
  "svelte": "index.js",
11
11
  "main": "index.js",
package/readme.md CHANGED
@@ -21,7 +21,7 @@
21
21
 
22
22
  <slot name="examples" />
23
23
 
24
- ## Key features
24
+ ## Features
25
25
 
26
26
  - **Bindable:** `bind:selected` gives you an array of the currently selected options. Thanks to Svelte's 2-way binding, it can also control the component state externally through assignment `selected = ['foo', 42]`.
27
27
  - **Keyboard friendly** for mouse-less form completion
@@ -36,9 +36,6 @@
36
36
 
37
37
  ## Recent breaking changes
38
38
 
39
- - **v6.0.0**&nbsp; The prop `showOptions` which controls whether the list of dropdown options is currently open or closed was renamed to `open`. [PR 103](https://github.com/janosh/svelte-multiselect/pull/103).
40
- - **v6.0.1**&nbsp; The prop `disabledTitle` which sets the title of the `<MultiSelect>` `<input>` node if in `disabled` mode was renamed to `disabledInputTitle`. [PR 105](https://github.com/janosh/svelte-multiselect/pull/105).
41
- - **v6.0.1**&nbsp; The default margin of `1em 0` on the wrapper `div.multiselect` was removed. Instead, there is now a new CSS variable `--sms-margin`. Set it to `--sms-margin: 1em 0;` to restore the old appearance. [PR 105](https://github.com/janosh/svelte-multiselect/pull/105).
42
39
  - **6.1.0**&nbsp; The `dispatch` events `focus` and `blur` were renamed to `open` and `close`, respectively. These actions refer to the dropdown list, i.e. `<MultiSelect on:open={(event) => console.log(event)}>` will trigger when the dropdown list opens. The focus and blur events are now regular DOM (not Svelte `dispatch`) events emitted by the `<input>` node. [PR 120](https://github.com/janosh/svelte-multiselect/pull/120).
43
40
  - **v7.0.0**&nbsp; `selected` (as well `selectedLabels` and `selectedValues`) used to be arrays always. Now, if `maxSelect=1`, they will no longer be a length-1 array but simply a single a option (label/value respectively) or `null` if no option is selected. [PR 123](https://github.com/janosh/svelte-multiselect/pull/123).
44
41
  - **8.0.0**&nbsp;
@@ -171,6 +168,12 @@ Full list of props/bindable variables for this component. The `Option` type you
171
168
 
172
169
  One of `true`, `false` or `'desktop'`. Whether to set the cursor back to the input element after selecting an element. 'desktop' means only do so if current window width is larger than the current value of `breakpoint` prop (default 800).
173
170
 
171
+ 1. ```ts
172
+ form_input: HTMLInputElement
173
+ ```
174
+
175
+ 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).
176
+
174
177
  1. ```ts
175
178
  id: string | null = null
176
179
  ```
@@ -193,7 +196,7 @@ Full list of props/bindable variables for this component. The `Option` type you
193
196
  invalid: boolean = false
194
197
  ```
195
198
 
196
- If `required=true` and user tries to submit but `selected = []` is empty, `invalid` is automatically set to `true` and CSS class `invalid` applied to the top-level `div.multiselect`. `invalid` class is removed again as soon as the user selects an option. `invalid` can also be controlled externally by binding to it `<MultiSelect bind:invalid />` and setting it to `true` based on outside events or custom validation.
199
+ If `required = true, 1, 2, ...` and user tries to submit form but `selected = []` is empty/`selected.length < required`, `invalid` is automatically set to `true` and CSS class `invalid` applied to the top-level `div.multiselect`. `invalid` class is removed as soon as any change to `selected` is registered. `invalid` can also be controlled externally by binding to it `<MultiSelect bind:invalid />` and setting it to `true` based on outside events or custom validation.
197
200
 
198
201
  1. ```ts
199
202
  loading: boolean = false
@@ -226,6 +229,16 @@ Full list of props/bindable variables for this component. The `Option` type you
226
229
  maxSelectMsg = (current: number, max: number) => `${current}/${max}`
227
230
  ```
228
231
 
232
+ Use CSS selector `span.max-select-msg` (or prop `maxSelectMsgClass` if you're using a CSS framework like Tailwind) to customize appearance of the message container.
233
+
234
+ 1. ```ts
235
+ minSelect: number | null = null
236
+ ```
237
+
238
+ Conditionally render the `x` button which removes a selected option depending on the number of selected options. Meaning all remove buttons disappear if `selected.length <= minSelect`. E.g. if 2 options are selected and `minSelect={3}`, users will not be able to remove any selections until they selected more than 3 options.
239
+
240
+ Note: Prop `required={3}` should be used instead if you only care about the component state at form submission time. `minSelect={3}` should be used if you want to place constraints on component state at all times.
241
+
229
242
  1. ```ts
230
243
  name: string | null = null
231
244
  ```
@@ -287,10 +300,10 @@ Full list of props/bindable variables for this component. The `Option` type you
287
300
  Title text to display when user hovers over button to remove selected option (which defaults to a cross icon).
288
301
 
289
302
  1. ```ts
290
- required: boolean = false
303
+ required: boolean | number = false
291
304
  ```
292
305
 
293
- Whether forms can be submitted without selecting any options. Aborts submission, is scrolled into view and shows help "Please fill out" message when true and user tries to submit with no options selected.
306
+ If `required = true, 1, 2, ...` forms can't be submitted without selecting given number of options. `true` means 1. `false` means even empty MultiSelect will pass form validity check. If user tries to submit a form containing MultiSelect with less than the required number of options, submission is aborted, MultiSelect scrolls into view and shows message "Please select at least `required` options".
294
307
 
295
308
  1. ```ts
296
309
  resetFilterOnAdd: boolean = true
@@ -517,6 +530,7 @@ The second method allows you to pass in custom classes to the important DOM elem
517
530
  - `ulOptionsClass`: available options listed in the dropdown when component is in `open` state
518
531
  - `liOptionClass`: list items selectable from dropdown list
519
532
  - `liActiveOptionClass`: the currently active dropdown list item (i.e. hovered or navigated to with arrow keys)
533
+ - `maxSelectMsgClass`: small span towards the right end of the input field displaying to the user how many of the allowed number of options they've already selected
520
534
 
521
535
  This simplified version of the DOM structure of the component shows where these classes are inserted:
522
536
 
@@ -527,6 +541,7 @@ This simplified version of the DOM structure of the component shows where these
527
541
  <li class={liSelectedClass}>Selected 1</li>
528
542
  <li class={liSelectedClass}>Selected 2</li>
529
543
  </ul>
544
+ <span class="maxSelectMsgClass">2/5 selected</span>
530
545
  <ul class="options {ulOptionsClass}">
531
546
  <li class={liOptionClass}>Option 1</li>
532
547
  <li class="{liOptionClass} {liActiveOptionClass}">
@@ -588,7 +603,7 @@ Odd as it may seem, you get the most fine-grained control over the styling of ev
588
603
 
589
604
  ## Want to contribute?
590
605
 
591
- To submit a PR, clone the repo, install dependencies and start the dev server to try out your changes.
606
+ To submit a PR, clone the repo, install dependencies and start the dev server to see changes as you make them.
592
607
 
593
608
  ```sh
594
609
  git clone https://github.com/janosh/svelte-multiselect
@@ -596,3 +611,9 @@ cd svelte-multiselect
596
611
  pnpm install
597
612
  pnpm dev
598
613
  ```
614
+
615
+ To make sure your changes didn't break anything, you can run the full test suite (which also runs in CI) using:
616
+
617
+ ```sh
618
+ pnpm test
619
+ ```