GithubHelp home page GithubHelp logo

janosh / svelte-multiselect Goto Github PK

View Code? Open in Web Editor NEW
271.0 5.0 32.0 1.08 MB

Keyboard-friendly, accessible and highly customizable multi-select component

Home Page: https://multiselect.janosh.dev

License: MIT License

Svelte 34.20% HTML 1.23% JavaScript 1.89% CSS 3.85% TypeScript 58.83%
svelte forms input single-select svelte-component multi-select

svelte-multiselect's Introduction

Svelte MultiSelect
 Svelte MultiSelect

Tests GitHub Pages NPM version Needs Svelte version REPL Open in StackBlitz

Keyboard-friendly, accessible and highly customizable multi-select component. View the docs

πŸ’‘   Features

  • 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].
  • Keyboard friendly for mouse-less form completion
  • No run-time deps: needs only Svelte as dev dependency
  • Dropdowns: scrollable lists for large numbers of options
  • Searchable: start typing to filter options
  • Tagging: selected options are listed as tags within the input
  • Single / multiple select: pass maxSelect={1, 2, 3, ...} prop to restrict the number of selectable options
  • Configurable: see props

πŸ§ͺ   Coverage

Statements Branches Lines
Statements Branches Lines

πŸ“œ   Breaking changes

  • 8.0.0 (2022-10-22)Β 

    • Props selectedLabels and selectedValues were removed. If you were using them, they were equivalent to assigning bind:selected to a local variable and then running selectedLabels = selected.map(option => option.label) and selectedValues = selected.map(option => option.value) if your options were objects with label and value keys. If they were simple strings/numbers, there was no point in using selected{Labels,Values} anyway. PR 138
    • Prop noOptionsMsg was renamed to noMatchingOptionsMsg. PR 133.
  • v8.3.0 (2023-01-25)Β  addOptionMsg was renamed to createOptionMsg (no major since version since it's rarely used) sha.

  • v9.0.0 (2023-06-01)Β  Svelte bumped from v3 to v4. Also, not breaking but noteworthy: MultiSelect received a default slot that functions as both "option" and "selected". If you previously had two identical slots for "option" and "selected", you can now remove the name from one of them and drop the other:

    <MultiSelect
      {options}
    + let:option
    >
    - <SlotComponent let:option {option} slot="selected" />
    - <SlotComponent let:option {option} slot="option" />
    + <SlotComponent {option} />
    </MultiSelect>
  • v10.0.0 (2023-06-23)Β  duplicateFunc() renamed to key in #238. Signature changed:

    - duplicateFunc: (op1: T, op2: T) => boolean = (op1, op2) => `${get_label(op1)}`.toLowerCase() === `${get_label(op2)}`.toLowerCase()
    + key: (opt: T) => unknown = (opt) => `${get_label(opt)}`.toLowerCase()

    Rather than implementing custom equality in duplicateFunc, the key function is now expected to map options to a unique identifier. key(op1) === key(op2) should mean op1 and op2 are the same option. key can return any type but usually best to return primitives (string, number, ...) for Svelte keyed each blocks (see #217).

πŸ”¨   Installation

npm install --dev svelte-multiselect
pnpm add -D svelte-multiselect
yarn add --dev svelte-multiselect

πŸ“™   Usage

<script>
  import MultiSelect from 'svelte-multiselect'

  const ui_libs = [`Svelte`, `React`, `Vue`, `Angular`, `...`]

  let selected = []
</script>

Favorite Frontend Tools?

<code>selected = {JSON.stringify(selected)}</code>

<MultiSelect bind:selected options={ui_libs} />

πŸ”£   Props

Full list of props/bindable variables for this component. The Option type you see below is defined in src/lib/index.ts and can be imported as import { type Option } from 'svelte-multiselect'.

  1. activeIndex: number | null = null

    Zero-based index of currently active option in the array of currently matching options, i.e. if the user typed a search string into the input and only a subset of options match, this index refers to the array position of the matching subset of options

  2. activeOption: Option | null = null

    Currently active option, i.e. the one the user currently hovers or navigated to with arrow keys.

  3. createOptionMsg: string | null = `Create this option...`

    The message shown to users when allowUserOptions is truthy and they entered text that doesn't match any existing options to suggest creating a new option from the entered text. Emits console.error if allowUserOptions is true or 'append' and createOptionMsg='' to since users might be unaware they can create new option. The error can be silenced by setting createOptionMsg=null indicating developer intent is to e.g. use MultiSelect as a tagging component where a user message might be unwanted.

  4. allowEmpty: boolean = false

    Whether to console.error if dropdown list of options is empty. allowEmpty={false} will suppress errors. allowEmpty={true} will report a console error if component is not disabled, not in loading state and doesn't allowUserOptions.

  5. allowUserOptions: boolean | 'append' = false

    Whether users can enter values that are not in the dropdown list. true means add user-defined options to the selected list only, 'append' means add to both options and selected. If allowUserOptions is true or 'append' then the type object | number | string of entered value is determined by typeof options[0] (i.e. the first option in the dropdown list) to keep type homogeneity.

  6. autocomplete: string = `off`

    Applied to the <input>. Specifies if browser is permitted to auto-fill this form field. Should usually be one of 'on' or 'off' but see MDN docs for other admissible values.

  7. autoScroll: boolean = true

    false disables keeping the active dropdown items in view when going up/down the list of options with arrow keys.

  8. breakpoint: number = 800

    Screens wider than breakpoint in pixels will be considered 'desktop', everything narrower as 'mobile'.

  9. defaultDisabledTitle: string = `This option is disabled`

    Title text to display when user hovers over a disabled option. Each option can override this through its disabledTitle attribute.

  10. disabled: boolean = false

    Disable the component. It will still be rendered but users won't be able to interact with it.

  11. disabledInputTitle: string = `This input is disabled`

    Tooltip text to display on hover when the component is in disabled state.

  12. duplicates: boolean = false

    Whether to allow users to select duplicate options. Applies only to the selected item list, not the options dropdown. Keeping that free of duplicates is left to developer. The selected item list can have duplicates if allowUserOptions is truthy, duplicates is true and users create the same option multiple times. Use duplicateOptionMsg to customize the message shown to user if duplicates is false and users attempt this and key to customize when a pair of options is considered equal.

  13. duplicateOptionMsg: string = `This option is already selected`

    Text to display to users when allowUserOptions is truthy and they try to create a new option that's already selected.

  1. key: (opt: T) => unknown = (opt) => `${get_label(opt)}`.toLowerCase()

    A function that maps options to a value by which equality of options is determined. Defaults to mapping options to their lower-cased label. E.g. by default const opt1 = { label: `foo`, id: 1 } and const opt2 = { label: `foo`, id: 2 } are considered equal. If you want to consider them different, you can set key to e.g. key={(opt) => opt.id} or key={(opt) => `${opt.label}-${opt.id}} or even key={JSON.stringify}.

  2. filterFunc = (opt: Option, searchText: string): boolean => {
      if (!searchText) return true
      return `${get_label(opt)}`.toLowerCase().includes(searchText.toLowerCase())
    }

    Customize how dropdown options are filtered when user enters search string into <MultiSelect />. Defaults to:

  3. closeDropdownOnSelect: boolean | 'desktop' = `desktop`

    One of true, false or 'desktop'. Whether to close the dropdown list after selecting a dropdown item. If true, component will loose focus and dropdown is closed. 'desktop' means false if current window width is larger than the current value of breakpoint prop (default is 800, meaning screen width in pixels). This is to align with the default behavior of many mobile browsers like Safari which close dropdowns after selecting an option while desktop browsers facilitate multi-selection by leaving dropdowns open.

  4. form_input: HTMLInputElement | null = null

    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).

  5. highlightMatches: boolean = true

    Whether to highlight text in the dropdown options that matches the current user-entered search query. Uses the CSS Custom Highlight API with limited browser support (70% as of May 2023) and styling options. See ::highlight(sms-search-matches) below for available CSS variables.

  6. id: string | null = null

    Applied to the <input> element for associating HTML form <label>s with this component for accessibility. Also, clicking a <label> with same for attribute as id will focus this component.

  7. input: HTMLInputElement | null = null

    Handle to the <input> DOM node. Only available after component mounts (null before then).

  8. inputmode: string | null = null

    The inputmode attribute hints at the type of data the user may enter. Values like 'numeric' | 'tel' | 'email' allow mobile browsers to display an appropriate virtual on-screen keyboard. See MDN for details. If you want to suppress the on-screen keyboard to leave full-screen real estate for the dropdown list of options, set inputmode="none".

  9. inputStyle: string | null = null

    One-off CSS rules applied to the <input> element.

  10. invalid: boolean = false

    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.

  11. liOptionStyle: string | null = null

    One-off CSS rules applied to the <li> elements that wrap the dropdown options.

  12. liSelectedStyle: string | null = null

    One-off CSS rules applied to the <li> elements that wrap the selected options.

  13. loading: boolean = false

    Whether the component should display a spinner to indicate it's in loading state. Use <slot name='spinner'> to specify a custom spinner.

  14. matchingOptions: Option[] = []

    List of options currently displayed to the user. Same as options unless the user entered searchText in which case this array contains only those options for which filterFunc = (op: Option, searchText: string) => boolean returned true.

  15. maxOptions: number | undefined = undefined

    Positive integer to limit the number of options displayed in the dropdown. undefined and 0 mean no limit.

  16. maxSelect: number | null = null

    Positive integer to limit the number of options users can pick. null means no limit. maxSelect={1} will change the type of selected to be a single Option (or null) (not a length-1 array). Likewise, the type of selectedLabels changes from (string | number)[] to string | number | null and selectedValues from unknown[] to unknown | null. maxSelect={1} will also give div.multiselect a class of single. I.e. you can target the selector div.multiselect.single to give single selects a different appearance from multi selects.

  17. maxSelectMsg: ((current: number, max: number) => string) | null = (
      current: number,
      max: number
    ) => (max > 1 ? `${current}/${max}` : ``)

    Inform users how many of the maximum allowed options they have already selected. Set maxSelectMsg={null} to not show a message. Defaults to null when maxSelect={1} or maxSelect={null}. Else if maxSelect > 1, defaults to:

    maxSelectMsg = (current: number, max: number) => `${current}/${max}`

    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.

  18. minSelect: number | null = null

    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.

    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.

  19. name: string | null = null

    Applied to the <input> element. Sets the key of this field in a submitted form data object. See form example.

  20. noMatchingOptionsMsg: string = `No matching options`

    What message to show if no options match the user-entered search string.

  21. open: boolean = false

    Whether the dropdown list is currently visible. Is two-way bindable, i.e. can be used for external control of when the options are visible.

  22. options: Option[]

    The only required prop (no default). Array of strings/numbers or Option objects to be listed in the dropdown. The only required key on objects is label which must also be unique. An object's value defaults to label if undefined. You can add arbitrary additional keys to your option objects. A few keys like preselected and title have special meaning though. See type ObjectOption in /src/lib/types.ts for all special keys and their purpose.

  23. outerDiv: HTMLDivElement | null = null

    Handle to outer <div class="multiselect"> that wraps the whole component. Only available after component mounts (null before then).

  24. parseLabelsAsHtml: boolean = false

    Whether option labels should be passed to Svelte's @html directive or inserted into the DOM as plain text. true will raise an error if allowUserOptions is also truthy as it makes your site susceptible to cross-site scripting (XSS) attacks.

  25. pattern: string | null = null

    The pattern attribute specifies a regular expression which the input's value must match. If a non-null value doesn't match the pattern regex, the read-only patternMismatch property will be true. See MDN for details.

  26. placeholder: string | null = null

    String shown in the text input when no option is selected.

  27. removeAllTitle: string = `Remove all`

    Title text to display when user hovers over remove-all button.

  28. removeBtnTitle: string = `Remove`

    Title text to display when user hovers over button to remove selected option (which defaults to a cross icon).

  29. required: boolean | number = false

    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".

  30. resetFilterOnAdd: boolean = true

    Whether text entered into the input to filter options in the dropdown list is reset to empty string when user selects an option.

  31. searchText: string = ``

    Text the user-entered to filter down on the list of options. Binds both ways, i.e. can also be used to set the input text.

  32. selected: Option[] =
     options
       ?.filter((op) => (op as ObjectOption)?.preselected)
       .slice(0, maxSelect ?? undefined) ?? []

    Array of currently selected options. Supports 2-way binding bind:selected={[1, 2, 3]} to control component state externally. Can be passed as prop to set pre-selected options that will already be populated when component mounts before any user interaction.

  33. sortSelected: boolean | ((op1: Option, op2: Option) => number) = false

    Default behavior is to render selected items in the order they were chosen. sortSelected={true} uses default JS array sorting. A compare function enables custom logic for sorting selected options. See the /sort-selected example.

  34. selectedOptionsDraggable: boolean = !sortSelected

    Whether selected options are draggable so users can change their order.

  35. style: string | null = null

    One-off CSS rules applied to the outer <div class="multiselect"> that wraps the whole component for passing one-off CSS.

  36. ulSelectedStyle: string | null = null

    One-off CSS rules applied to the <ul class="selected"> that wraps the list of selected options.

  37. ulOptionsStyle: string | null = null

    One-off CSS rules applied to the <ul class="options"> that wraps the list of selected options.

  38. value: Option | Option[] | null = null

    If maxSelect={1}, value will be the single item in selected (or null if selected is empty). If maxSelect != 1, maxSelect and selected are equal. Warning: Setting value does not rendered state on initial mount, meaning bind:value will update local variable value whenever internal component state changes but passing a value when component first mounts won't be reflected in UI. This is because the source of truth for rendering is bind:selected. selected is reactive to value internally but only on reassignment from initial value. Suggestions for better solutions than #249 welcome!

🎰   Slots

MultiSelect.svelte accepts the following named slots:

  1. slot="option": Customize rendering of dropdown options. Receives as props an option and the zero-indexed position (idx) it has in the dropdown.
  2. slot="selected": Customize rendering of selected items. Receives as props an option and the zero-indexed position (idx) it has in the list of selected items.
  3. slot="spinner": Custom spinner component to display when in loading state. Receives no props.
  4. slot="disabled-icon": Custom icon to display inside the input when in disabled state. Receives no props. Use an empty <span slot="disabled-icon" /> or div to remove the default disabled icon.
  5. slot="expand-icon": Allows setting a custom icon to indicate to users that the Multiselect text input field is expandable into a dropdown list. Receives prop open: boolean which is true if the Multiselect dropdown is visible and false if it's hidden.
  6. slot="remove-icon": Custom icon to display as remove button. Will be used both by buttons to remove individual selected options and the 'remove all' button that clears all options at once. Receives no props.
  7. slot="user-msg": Displayed like a dropdown item when the list is empty and user is allowed to create custom options based on text input (or if the user's text input clashes with an existing option). Receives props:
    • searchText: The text user typed into search input.
    • msgType: false | 'create' | 'dupe' | 'no-match': 'dupe' means user input is a duplicate of an existing option. 'create' means user is allowed to convert their input into a new option not previously in the dropdown. 'no-match' means user input doesn't match any dropdown items and users are not allowed to create new options. false means none of the above.
    • msg: Will be duplicateOptionMsg or createOptionMsg (see props) based on whether user input is a duplicate or can be created as new option. Note this slot replaces the default UI for displaying these messages so the slot needs to render them instead (unless purposely not showing a message).
  8. slot='after-input': Placed after the search input. For arbitrary content like icons or temporary messages. Receives props selected: Option[], disabled: boolean, invalid: boolean, id: string | null, placeholder: string, open: boolean, required: boolean. Can serve as a more dynamic, more customizable alternative to the placeholder prop.

Example using several slots:

<MultiSelect options={[`Red`, `Green`, `Blue`, `Yellow`, `Purple`]} let:idx let:option>
   <!-- default slot overrides rendering of both dropdown-listed and selected options -->
  <span>
    {idx + 1}
    {option.label}
    <span style:background={option.label} style=" width: 1em; height: 1em;" />
  </span>

  <CustomSpinner slot="spinner">
  <strong slot="remove-icon">X</strong>
</MultiSelect>

🎬   Events

MultiSelect.svelte dispatches the following events:

  1. on:add={(event) => console.log(event.detail.option)}

    Triggers when a new option is selected. The newly selected option is provided as event.detail.option.

  2. on:remove={(event) => console.log(event.detail.option)}`

    Triggers when a single selected option is removed. The removed option is provided as event.detail.option.

  3. on:removeAll={(event) => console.log(event.detail.options)}`

    Triggers when all selected options are removed. The payload event.detail.options gives the options that were previously selected.

  4. on:change={(event) => console.log(`${event.detail.type}: '${event.detail.option}'`)}

    Triggers when an option is either added (selected) or removed from selected, or all selected options are removed at once. type is one of 'add' | 'remove' | 'removeAll' and payload will be option: Option or options: Option[], respectively.

  5. on:open={(event) => console.log(`Multiselect dropdown was opened by ${event}`)}

    Triggers when the dropdown list of options appears. Event is the DOM's FocusEvent,KeyboardEvent or ClickEvent that initiated this Svelte dispatch event.

  6. on:close={(event) => console.log(`Multiselect dropdown was closed by ${event}`)}

    Triggers when the dropdown list of options disappears. Event is the DOM's FocusEvent, KeyboardEvent or ClickEvent that initiated this Svelte dispatch event.

For example, here's how you might annoy your users with an alert every time one or more options are added or removed:

<MultiSelect
  on:change={(e) => {
    if (e.detail.type === 'add') alert(`You added ${e.detail.option}`)
    if (e.detail.type === 'remove') alert(`You removed ${e.detail.option}`)
    if (e.detail.type === 'removeAll') alert(`You removed ${e.detail.options}`)
  }}
/>

Note: Depending on the data passed to the component the options(s) payload will either be objects or simple strings/numbers.

The above list of events are Svelte dispatch events. This component also forwards many DOM events from the <input> node: blur, change, click, keydown, keyup, mousedown, mouseenter, mouseleave, touchcancel, touchend, touchmove, touchstart. Registering listeners for these events works the same:

<MultiSelect
  options={[1, 2, 3]}
  on:keyup={(event) => console.log('key', event.target.value)}
/>

🦺   TypeScript

The type of options is inferred automatically from the data you pass. E.g.

const options = [
   { label: `foo`, value: 42 }
   { label: `bar`, value: 69 }
]
// type Option = { label: string, value: number }
const options = [`foo`, `bar`]
// type Option = string
const options = [42, 69]
// type Option = number

The inferred type of Option is used to enforce type-safety on derived props like selected as well as slot components. E.g. you'll get an error when trying to use a slot component that expects a string if your options are objects (see this comment for example screenshots).

You can also import the types this component uses for downstream applications:

import {
  Option,
  ObjectOption,
  DispatchEvents,
  MultiSelectEvents,
} from 'svelte-multiselect'

✨   Styling

There are 3 ways to style this component. To understand which options do what, it helps to keep in mind this simplified DOM structure of the component:

<div class="multiselect">
  <ul class="selected">
    <li>Selected 1</li>
    <li>Selected 2</li>
  </ul>
  <ul class="options">
    <li>Option 1</li>
    <li>Option 2</li>
  </ul>
</div>

With CSS variables

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 for how these variables are set on the demo site of this component.

Minimal example that changes the background color of the options dropdown:

<MultiSelect --sms-options-bg="white" />
  • div.multiselect

    • 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.
    • border-radius: var(--sms-border-radius, 3pt)
    • padding: var(--sms-padding, 0 3pt)
    • background: var(--sms-bg)
    • color: var(--sms-text-color)
    • min-height: var(--sms-min-height, 22pt)
    • width: var(--sms-width)
    • max-width: var(--sms-max-width)
    • margin: var(--sms-margin)
    • font-size: var(--sms-font-size, inherit)
  • div.multiselect.open

    • z-index: var(--sms-open-z-index, 4): Increase this if needed to ensure the dropdown list is displayed atop all other page elements.
  • div.multiselect:focus-within

    • border: var(--sms-focus-border, 1pt solid var(--sms-active-color, cornflowerblue)): Border when component has focus. Defaults to --sms-active-color which in turn defaults to cornflowerblue.
  • div.multiselect.disabled

    • background: var(--sms-disabled-bg, lightgray): Background when in disabled state.
  • div.multiselect input::placeholder

    • color: var(--sms-placeholder-color)
    • opacity: var(--sms-placeholder-opacity)
  • div.multiselect > ul.selected > li

    • background: var(--sms-selected-bg, rgba(0, 0, 0, 0.15)): Background of selected options.
    • padding: var(--sms-selected-li-padding, 1pt 5pt): Height of selected options.
    • color: var(--sms-selected-text-color, var(--sms-text-color)): Text color for selected options.
  • ul.selected > li button:hover, button.remove-all:hover, button:focus

    • color: var(--sms-remove-btn-hover-color, lightskyblue): Color of the remove-icon buttons for removing all or individual selected options when in :focus or :hover state.
    • background: var(--sms-remove-btn-hover-bg, rgba(0, 0, 0, 0.2)): Background for hovered remove buttons.
  • div.multiselect > ul.options

    • background: var(--sms-options-bg, white): Background of dropdown list.
    • max-height: var(--sms-options-max-height, 50vh): Maximum height of options dropdown.
    • overscroll-behavior: var(--sms-options-overscroll, none): Whether scroll events bubble to parent elements when reaching the top/bottom of the options dropdown. See MDN.
    • box-shadow: var(--sms-options-shadow, 0 0 14pt -8pt black): Box shadow of dropdown list.
    • border: var(--sms-options-border)
    • border-width: var(--sms-options-border-width)
    • border-radius: var(--sms-options-border-radius, 1ex)
    • padding: var(--sms-options-padding)
    • margin: var(--sms-options-margin, inherit)
  • div.multiselect > ul.options > li

    • scroll-margin: var(--sms-options-scroll-margin, 100px): Top/bottom margin to keep between dropdown list items and top/bottom screen edge when auto-scrolling list to keep items in view.
  • div.multiselect > ul.options > li.selected

    • background: var(--sms-li-selected-bg): Background of selected list items in options pane.
    • color: var(--sms-li-selected-color): Text color of selected list items in options pane.
  • div.multiselect > ul.options > li.active

    • background: var(--sms-li-active-bg, var(--sms-active-color, rgba(0, 0, 0, 0.15))): Background of active options. Options in the dropdown list become active either by mouseover or by navigating to them with arrow keys. Selected options become active when selectedOptionsDraggable=true and an option is being dragged to a new position. Note the active option in that case is not the dragged option but the option under it whose place it will take on drag end.
  • div.multiselect > ul.options > li.disabled

    • background: var(--sms-li-disabled-bg, #f5f5f6): Background of disabled options in the dropdown list.
    • color: var(--sms-li-disabled-text, #b8b8b8): Text color of disabled option in the dropdown list.
  • ::highlight(sms-search-matches): applies to search results in dropdown list that match the current search query if highlightMatches=true. These styles cannot be set via CSS variables. Instead, use a new rule set. For example:

    ::highlight(sms-search-matches) {
      color: orange;
      background: rgba(0, 0, 0, 0.15);
      text-decoration: underline;
    }

With CSS frameworks

The second method allows you to pass in custom classes to the important DOM elements of this component to target them with frameworks like Tailwind CSS.

  • outerDivClass: wrapper div enclosing the whole component
  • ulSelectedClass: list of selected options
  • liSelectedClass: selected list items
  • ulOptionsClass: available options listed in the dropdown when component is in open state
  • liOptionClass: list items selectable from dropdown list
  • liActiveOptionClass: the currently active dropdown list item (i.e. hovered or navigated to with arrow keys)
  • liUserMsgClass: user message (last child of dropdown list when no options match user input)
  • liActiveUserMsgClass: user message when active (i.e. hovered or navigated to with arrow keys)
  • 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

This simplified version of the DOM structure of the component shows where these classes are inserted:

<div class="multiselect {outerDivClass}">
  <input class={inputClass} />
  <ul class="selected {ulSelectedClass}">
    <li class={liSelectedClass}>Selected 1</li>
    <li class={liSelectedClass}>Selected 2</li>
  </ul>
  <span class="maxSelectMsgClass">2/5 selected</span>
  <ul class="options {ulOptionsClass}">
    <li class={liOptionClass}>Option 1</li>
    <li class="{liOptionClass} {liActiveOptionClass}">
      Option 2 (currently active)
    </li>
    ...
    <li class="{liUserMsgClass} {liActiveUserMsgClass}">
      Create this option...
    </li>
  </ul>
</div>

With global CSS

Odd as it may seem, you get the most fine-grained control over the styling of every part of this component by using the following :global() CSS selectors. ul.selected is the list of currently selected options rendered inside the component's input whereas ul.options is the list of available options that slides out when the component is in its open state. See also simplified DOM structure.

:global(div.multiselect) {
  /* top-level wrapper div */
}
:global(div.multiselect.open) {
  /* top-level wrapper div when dropdown open */
}
:global(div.multiselect.disabled) {
  /* top-level wrapper div when in disabled state */
}
:global(div.multiselect > ul.selected) {
  /* selected list */
}
:global(div.multiselect > ul.selected > li) {
  /* selected list items */
}
:global(div.multiselect button) {
  /* target all buttons in this component */
}
:global(div.multiselect > ul.selected > li button, button.remove-all) {
  /* buttons to remove a single or all selected options at once */
}
:global(div.multiselect > input[autocomplete]) {
  /* input inside the top-level wrapper div */
}
:global(div.multiselect > ul.options) {
  /* dropdown options */
}
:global(div.multiselect > ul.options > li) {
  /* dropdown list items */
}
:global(div.multiselect > ul.options > li.selected) {
  /* selected options in the dropdown list */
}
:global(div.multiselect > ul.options > li:not(.selected):hover) {
  /* unselected but hovered options in the dropdown list */
}
:global(div.multiselect > ul.options > li.active) {
  /* active means item was navigated to with up/down arrow keys */
  /* ready to be selected by pressing enter */
}
:global(div.multiselect > ul.options > li.disabled) {
  /* options with disabled key set to true (see props above) */
}

πŸ†•   Changelog

View the changelog.

πŸ™   Contributing

Here are some steps to get you started if you'd like to contribute to this project!

svelte-multiselect's People

Contributors

acidmyke avatar arnwolff avatar briandonahue avatar codicocodes avatar derschiw avatar devbieres avatar failpark avatar frederikhors avatar gauben avatar janosh avatar joelmukuthu avatar ngyewch avatar nohehf avatar oskar-gmerek avatar pingu-codes avatar pre-commit-ci[bot] avatar ryanzhinie avatar shanestreator avatar snailedlt avatar trieuduongle avatar wd-david avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar

svelte-multiselect's Issues

Event handlers

hallo
i want to have a function running any time the value is changes, i tried different things like on:change={function} and on:selected={function} non seems to work.
thanks and thanks for these amazing multiselect

Add ability to control selected items through selected{Labels,Values}

#13 resulted in the suboptimal situation that if you're only interested in primitive labels/values for the selected items, you can get those through

<MultiSelect bind:selectedLabels bind:selectedValues />

but it's no longer a two-way binding. You cannot control the component's internal selected array through those props since they are reactive to selected internally:

$: selectedLabels = selected.map((op) => op.label)
$: selectedValues = selected.map((op) => op.value)

Need to figure out how to allow users to reverse this dependence and set bind:selectedLabels/Values as the source of truth.

Input tag inside <ul>

IΒ΄ve just run Google Lighthouse and IΒ΄m getting the following:

"Lists do not contain only li elements and script supporting elements (script and template)."

I believe this is because there is an input tag inside ul here:

<ul class="selected {ulSelectedClass}">
    {#each selected as option, idx}
      <li class={liSelectedClass}>
        <slot name="renderSelected" {option} {idx}>
          {option.label}
        </slot>
        {#if !readonly}
          <button
            on:mouseup|stopPropagation={() => remove(option.label)}
            on:keydown={handleEnterAndSpaceKeys(() => remove(option.label))}
            type="button"
            title="{removeBtnTitle} {option.label}"
          >
            <CrossIcon height="12pt" />
          </button>
        {/if}
      </li>
    {/each}
    <input
      bind:this={input}
      autocomplete="off"
      bind:value={searchText}
      on:mouseup|self|stopPropagation={() => setOptionsVisible(true)}
      on:keydown={handleKeydown}
      on:focus={() => setOptionsVisible(true)}
      {id}
      {name}
      placeholder={selectedLabels.length ? `` : placeholder}
    />
  </ul>

Is it possible to have the input tag after the closing ul tag?

Add option to persist state across page reloads

Currently MultiSelect returns to its initial state on page reloads. It's not too difficult to manually write the current state to a store which is synced to localStorage. But perhaps this is a common enough use case that it should get its own prop:

persist: 'localStorage' | 'sessionStorage' | false = false

sessionStorage persists until the window/tab is closed while localStorage persists until user or app manually clears the cache.

Missing form label

When I run WAVE chrome plugin on the solution I'm working on, I get a couple of errors that says "A form control does not have a corresponding label". The same occurs if I run it on the svelte-multiselect demo page where the code looks like this:

image

IΒ΄m not entirely sure what the purpose is of that input? Maybe it can be hidden from screen readers somehow?

Latest svelte , tried to make same component from the README.

bundles src/main.js β†’ public/build/bundle.js...
[!] Error: Unexpected token (Note that you need plugins to import files that are not JavaScript)
node_modules/svelte-multiselect/MultiSelect.css (1:0)
1: .multiselect.svelte-19t1nlc.svelte-19t1nlc{position:relative;margin:1em 0;border:var(--sms-border, 1pt solid lightgray);border-radius:var(--sms-border-radius, 5pt);align-items:center;min-height:18pt;display:flex;cursor:text}.multiselect.svelte-19t1nlc.svelte-19t1nlc:focus-within{border:var(--sms-focus-border, 1pt solid var(--sms-active-color, cornflowerblue))}.multiselect.readonly.svelte-19t1nlc.svelte-19t1nlc{background:var(--sms-readonly-bg, lightgray)}ul.tokens.svelte-19t1nlc>li.svelte-19t1nlc{background:var(--sms-token-bg, var(--sms-active-color, cornflowerblue));align-items:center;border-radius:4pt;display:flex;margin:2pt;padding:0 0 0 1ex;transition:0.3s;white-space:nowrap;height:16pt}ul.tokens.svelte-19t1nlc>li button.svelte-19t1nlc,button.remove-all.svelte-19t1nlc.svelte-19t1nlc{align-items:center;border-radius:50%;display:flex;cursor:pointer;transition:0.2s}ul.tokens.svelte-19t1nlc>li button.svelte-19t1nlc:hover,button.remove-all.svelte-19t1nlc.svelte-19t1nlc:hover{color:var(--sms-remove-x-hover-color, lightgray)}button.svelte-19t1nlc.svelte-19t1nlc{color:inherit;background:transparent;border:none;cursor:pointer;outline:none;padding:0 2pt}.multiselect.svelte-19t1nlc input.svelte-19t1nlc{border:none;outline:none;background:none;box-shadow:none;color:var(--sms-text-color, inherit);flex:1;width:1pt}ul.tokens.svelte-19t1nlc.svelte-19t1nlc{display:flex;padding:0;margin:0;flex-wrap:wrap;flex:1}ul.options.svelte-19t1nlc.svelte-19t1nlc{list-style:none;max-height:50vh;padding:0;top:100%;width:100%;cursor:pointer;position:absolute;border-radius:1ex;overflow:auto;background:var(--sms-options-bg, white)}ul.options.hidden.svelte-19t1nlc.svelte-19t1nlc{visibility:hidden}ul.options.svelte-19t1nlc li.svelte-19t1nlc{padding:3pt 2ex}ul.options.svelte-19t1nlc li.selected.svelte-19t1nlc{border-left:var(
   ^
2:       --sms-li-selected-border-left,
3:       3pt solid var(--sms-selected-color, green)
Error: Unexpected token (Note that you need plugins to import files that are not JavaScript)
    at error (/Users/macuser/Downloads/notus-svelte-main/node_modules/rollup/dist/shared/rollup.js:5307:30)
    at Module.error (/Users/macuser/Downloads/notus-svelte-main/node_modules/rollup/dist/shared/rollup.js:9716:16)
    at Module.tryParse (/Users/macuser/Downloads/notus-svelte-main/node_modules/rollup/dist/shared/rollup.js:10122:25)
    at Module.setSource (/Users/macuser/Downloads/notus-svelte-main/node_modules/rollup/dist/shared/rollup.js:10023:24)
    at ModuleLoader.addModuleSource (/Users/macuser/Downloads/notus-svelte-main/node_modules/rollup/dist/shared/rollup.js:18337:20)
    at ModuleLoader.fetchModule (/Users/macuser/Downloads/notus-svelte-main/node_modules/rollup/dist/shared/rollup.js:18393:9)
    at async Promise.all (index 6)
    at ModuleLoader.fetchStaticDependencies (/Users/macuser/Downloads/notus-svelte-main/node_modules/rollup/dist/shared/rollup.js:18418:34)
    at async Promise.all (index 0)
    at ModuleLoader.fetchModule (/Users/macuser/Downloads/notus-svelte-main/node_modules/rollup/dist/shared/rollup.js:18395:9)


[2021-08-28 08:35:44] waiting for changes...

Add prop to give input an id

Apologies if this is already possible: IΒ΄d like to give the input tag an id to be able to associate a label with it (accessibility-related). I tried to pull main, create a branch, do the changes and push it to the repository to then create a PR, but getting a 403 error. Anyway, IΒ΄m suggesting adding a prop "inputId" to MultiSelect.svelte and using it on line 254:
id={inputId}

Shadow on the list?

Maybe we need some "shadow" for the list which could blend in with the page when it is open if it is of the same color.

Add prop to sort selected options

Let's add sortSelected: boolean | (op1: Option, op2: Option) => number which if false, keeps current behavior of simply rendering selected items in the order they were chosen, true will use default JS array sorting and a compare function allows user to define custom logic for sorting selected options.

get values from foreach

i'm working with svelte-multiselect alot and its beautiful and enjoying.
now i need multi select for a loop showing an array like // {#each miData as data, i} // so every line there have a an multi select input, now with the text input i use // bind:value={data.spnot} // and it work fine but with the multiselect its not working, any ideas? thank alot!

Close list when losing focus

When using the keyboard to navigate, the list opens automatically when the component receives focus. It doesn't however close when losing focus. Would that be possible to implement, or have a prop to enable it?

To reproduce what I'm describing:
https://svelte-multiselect.netlify.app/
Use tab to navigate to the first list
When it opens use tab to jump to the next list (or other item if this had been a typical form)
The second list opens but the first one stays open.

Loading indicator

I think we need a loading indicator if we are querying options...

Write tests

MultiSelect.svelte is a fairly complex component by now. Needs some tests to confidently develop new features/make changes in the future. Could take some inspiration from https://youtu.be/R265FKnzswI.

CSS docs: div.multiselect overwrites .multiselect

Simply wish to notify something that I am unsure is by design or bug:

In the documentation, it is suggested for granular control, use :global(.multiselect) to modify the style of the top-level wrapper. However, as will be shown in the first screenshot, this does not seems to be working at the moment:
Not Applied

There does seem to be a solution by selecting :global(div.multiselect) instead, as is shown here (Notice only the bottom border remains):
Applied

Apologies if this was intended that I just did not catch.
Note this is not a major issue, can be solved by changing the documentation to provide guidance.

Thank you for developing this library, it is useful

HasError prop?

I think we need an hasError prop.

For the same reason we have an isLoading (or loading) one.

selected.length Property Cause Issue

Hi, there seem to be some issue with the binding selected property

Although supposedly the value should be an array, accessing the .length property will break the page.

It can be demonstrated by using this code as App.svelte:

<script>
  import MultiSelect from "svelte-multiselect";

  const data = [
    {label: "alpha", value: "a"}, 
    {label: "bravo", value: "b"}, 
    {lable: "charlie", value: "c"},
  ];

	let selected;
</script>

<main>
  <MultiSelect bind:selected options={data} placeholder="Search for tags" />
  <p>{selected.length}</p>
</main>

We can confirm it is the .length that broke the page because if replace selected.length with JSON.stringify(selected), we can then see that data is added.

A similar issue occurs when trying to access an value of selected (like selected[0]).

Personally, I think this is a rather big issue, since its behaviour is unpredictable.

PS: Using the code also reveals another issue, that the "charlie" option becomes undefined in the drop-down menu. I am not sure if I should open a different issue for that. Sorry I could not identify the cause of the issue and fix it.

Change the UX a bit if we are in single mode

If we are in "single" mode I think we can remove the "external X" at all.

Or maybe we can remove (or change a little) the UI for the single mode and remove the "tag" x.

I don't know, but both together is strange.

And even the 1/1. I know we can remove it with maxSelectMsg={() => ""} but maybe is unnecessary.

Single and multi style ... and much more

First, it's look great, thank you.

I'm not sure it it's a bug but I have:

  • for single=false options looks like ul.tokens > li.token
  • for single=true options looks like ul.tokens, option name is directly in ul.tokens, so it's not styled.

Example, the same settings and options except single=true on top.
20210619-162651-155x128

Maybe you could add li.token and add some info about single (.multiselect.single or ul.tokens.single or new added ul.tokens.single). to help styling multi and single select.

If you won't to add li the single class will be very useful too.

Style option for token text

Hi, this component is great! Would it be possible to add an option to change the color of the token text? Looks like you can change the background, but I have a dark background and would like white text.

More generally, would it be possible to pass in custom classes? Using tailwind, would be nice to use it to pass in class styles.

Thanks!

What about a more generic/standard UX?

What do you think about having a more generic/standard UX like https://primer.style/design/components/autocomplete?

In this issue I can summarize all the graphic quirks that are ugly to look at and require manual labor in each project:

  1. As you can see from the picture here: the font of the "placeholder" is very bad. Too big compared to other selects I am using right now.
    issue

  2. When you select an item and then delete it you can see that the seelct increases in height, even if only by 1px is very annoying.

  3. The rounded border is too much.

  4. If I click on a label for that select each time I click the dropdown is faded away... I think this is useless.
    issue

  5. I would like to have another (more classical) animation for hover on list voices. Not the actual which is not really suitable for those with vision problems because it moves every single item to the right. I think a simple background color on:hover is enough.

  6. As you can see in the same gif the gray background is not aligned in the select content.

on:change not called if all elements removed

Hello!

My issue is that when you choose to remove all items from the Muliti-select, the on:change is not called.

I will give you an example.

<MultiSelect
    bind:selected={selectedGenres}
    on:change={debounceInput}
    options={genres}
    placeholder="Select genres..."
/>

When I remove 1 element at a time on:change is called fine, but as soon as I remove all elements it will not be called.

image

Am I doing something wrong? :)

Best regards

Don't remove duplicate labels all at once

When several items have identical labels, trying to remove just one with backspace removes them all at once.

Also unintentionally not possible: letting users create multiple identical labels when allowUserOptions is truthy.

Screen.Recording.2022-04-07.at.22.21.34.mp4

Turn options from from strings into objects?

Considering a move from a simple string array as options to objects with keys like

type Option = {
  label: string
  value: string | number
  title: string // tooltip to display on hovering this option
  disabled: boolean
  disabledTitle: string // overrides the fallback disabledTitle = 'This option is disabled'
  selectedTitle: string // tooltip to display when this option is selected and hovered
  // maybe more?
}

Posting this here to gather thoughts and solicit feedback.

Unit test failed when component has svelte-multiselect

Hi @janosh, I like to thank you for creating such a great component! I love it!

Everything works great so far (v4.0.0) in my svelte app until I tried to add some unit tests.
Test failed when I tested component which has svelte-multiselect, Jest threw error (Vitest broke right away) like this:
Screen Shot 2022-02-26 at 12 45 38 PM

I'd already setup transformers so it's not a ESM problem in Jest, and other tests passed with different svelte libraries.

Here is a reproducible repo that you can check: https://github.com/davipon/test-failed
I simply import svelte-tiny-virtual-list & svelte-multiselect in the same test file and try to render them.
Test would fail before rendering Multiselect:

test('render multiselect', () => {
  render(Multiselect, { options: webFrameworks });
});

I'm not familiar with creating Svelte package, but I found a difference in these two libraries' index.js:
svelte-tiny-virtual-list:
Screen Shot 2022-02-26 at 1 02 41 PM

svelte-multiselect:
Screen Shot 2022-02-26 at 1 04 51 PM

Not sure if information above can help, and thanks again for your hard work!

A new prop for `searchText`?

I need a way to query options based on what searchText currently is. As the user is typing.

Can we use the DOM input element value?

Do we need a new prop to bind?

What are your thoughts on this?

Better a11y

I suggest adding aria-disabled equal to true to when the disabled prop has been used (true). Not to replace that the "disabled" attribute that we already have (thanks!), but to additionaly inform screen readers.

From mdn:

"The aria-disabled attribute is similar to the HTML disabled attribute, but better. The first rule of ARIA is "If you can use a native HTML element or attribute with the semantics and behavior you require already built in, instead of repurposing an element and adding an ARIA role, state or property to make it accessible, then do so." aria-disabled is an exception to that rule. It is one of the only cases where an ARIA attribute may be better than the native disabled attribute HTML equivalent.

[bug] A recent change to the library seems to be causing a build error

Looks like these lines are causing it.

[!] (plugin svelte) ParseError: Unexpected character '@'
node_modules\svelte-multiselect\MultiSelect.svelte
266:     >
267:       {#each matchingOptions as option, idx}
268:         {@const { label, disabled, title = null, selectedTitle } = option}
              ^
269:         {@const { disabledTitle = defaultDisabledTitle } = option}
270:         <li
ParseError: Unexpected character '@'

image

Sidenote:
I've never seen @const before, is it dependant on some external library to work?

Fix options conversion

Options is currently mapped to _options to convert "raw" options to objects with label/value for objects it uses this check
if (!op.value) op.value = op.label;
to check for ops without values however this is incorrectly flagged on a value of 0 I'd suggest the check is changed to if(op.value == undefined)

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    πŸ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. πŸ“ŠπŸ“ˆπŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❀️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.