MultiSelect

Pick several values from a fixed list.

import { ... } from '@corndev/ui'

Only the schemes this document declares.

OAuth 2

Selected: oauth2

The rest are disabled rather than hidden.

API key

HTML

<div class="corn-multi-select">
  <div class="corn-pills-input" data-size="md">
    <span class="corn-pill" data-size="md">
      <span class="corn-pill-label">production</span>
      <button type="button" class="corn-pill-remove" aria-label="Remove production"></button>
    </span>
    <input class="corn-pills-input-field" role="combobox" aria-expanded="false" aria-label="Environments" />
  </div>
</div>

Classes

Class
.corn-multi-select

Vanilla JS

Required for behaviour — adds filters the options as you type, handles keyboard selection, and builds the pills and the listbox.

import { createMultiSelect } from '@corndev/ui';
import '@corndev/ui/theme';

const el = document.querySelector('#target');
const instance = createMultiSelect(el, { /* options */ });

Options

OptionTypeDefaultDescription
optionsArray<MultiSelectOption|string>undefinedThe vocabulary. A bare string is shorthand for { label: s, value: s }.
valueArray<*>undefinedSelected values (controlled). Pass it to update() to drive the field from outside.
defaultValueArray<*>undefinedInitial selected values (uncontrolled).
onChange(values: Array<*>) => voidundefinedCalled with the whole next array whenever it changes.
placeholderstringundefinedShown only while nothing is selected.
size'sm'|'md'|'lg''md'
disabledbooleanfalse
errorbooleanfalsePaints the field’s error state.
maxnumberundefinedStop accepting values at this many. Reaching it disables the remaining options rather than hiding them.
emptyMessagestring'No matches'Shown when the filter matches nothing.

React

MultiSelect — from @corndev/ui-react.

Controlled with value + onChange, or uncontrolled with defaultValue. onChange receives the whole next array — adding, removing and clearing all look the same to the caller. The field is built from PillsInput, Pill and the shared listbox, so selected values match every other removable token in the library.

Props

PropTypeDefaultDescription
optionsArray<{label: string, value: *}|string>undefinedThe vocabulary. A bare string is shorthand for { label: s, value: s }.
valueArray<*>undefinedSelected values (controlled).
defaultValueArray<*>undefinedInitial selected values (uncontrolled).
onChange(values: Array<*>) => voidundefinedCalled with the whole next array whenever it changes — adding, removing or clearing.
placeholderstringundefinedShown only while nothing is selected; beside a row of pills it is noise.
size'sm'|'md'|'lg''md'
disabledbooleanfalse
errorbooleanfalsePaints the field’s error state. The message belongs to the Field around it.
maxnumberundefinedStop accepting values at this many. Reaching it disables the remaining options rather than hiding them, so the list does not appear to lose entries.
emptyMessagestring'No matches'Shown when the filter matches nothing.
clearablebooleanfalseShow a control that empties the field. It appears only when there is something to clear, and calls onChange with an empty array. Escape clears too, while the field has focus — the button is tabIndex={-1} so it does not sit between the input and the list in the tab order.
clearLabelstring'Clear selection'Accessible name for that control. Say what is being cleared when a page has several.
classNamestringundefinedMerged with the Corn UI classes on the wrapper, which is what positions the listbox. Everything else — id, name, required, aria-label, aria-describedby — goes to the input, not the wrapper, as it does on Combobox. That is what lets a Field around this wire its label and its description to the thing a screen reader actually lands on.

MultiSelect or TagsInput?

They look almost identical and they mean different things to the data underneath, so picking the wrong one is a modelling error rather than a cosmetic one.

MultiSelectThe options are the vocabulary. A value outside them cannot be entered.
TagsInputThe user extends the vocabulary. Suggestions are only a shortcut for typing.

Choose a multi-select for a closed set — the security schemes a document declares, the roles a system defines. Choose a tags input for an open one — a document’s tags, an enum’s values, OAuth scopes.

Selected values stay in the list

A chosen option is not removed from the dropdown. It stays where it was, marked with a check, for two reasons: the list does not shuffle under the cursor as you pick, and a second click un-picks the thing you just picked — which is what a list of checkboxes does.

production
<MultiSelect
  options={['production', 'staging', 'development']}
  defaultValue={['production']}
  aria-label="Environments"
/>

Labels separate from values

Pass objects when what the user reads is not what you store. The pill shows the label; onChange gives you the values.

OAuth 2
<MultiSelect
  options={[
    { label: 'API key', value: 'apiKey' },
    { label: 'OAuth 2', value: 'oauth2' },
  ]}
  defaultValue={['oauth2']}
  aria-label="Schemes"
/>

The keyboard

TypeFilters the list
↓ ↑Move the cursor. It clamps at the ends rather than wrapping.
EnterChooses, or un-chooses, the option under the cursor
BackspaceOn an empty field, removes the last value
EscCloses the list

Backspace only acts on an empty field, so it never eats a value while you are still typing over it.

Every pill’s remove button is a real <button> named for the value it removes — “Remove production”, not “Remove” — so a keyboard or screen-reader user can tell six of them apart.

Inside a Field

id, name, aria-label and the rest go to the input, not the wrapper, so a Field around this wires its label and description to the thing a screen reader lands on. className stays on the wrapper, which is what positions the dropdown.

<Field label="Environments" description="Where this deploys.">
  <MultiSelect options={['production', 'staging']} defaultValue={['staging']} />
</Field>

The interactive example at the top of this page is wrapped in a Field; the static previews below are not, so they carry their own aria-label.

Capping the count

max stops the list accepting more. The remaining options are disabled rather than hidden, so the list does not appear to have lost entries — and anything already chosen can still be removed.

Clearing the field

clearable shows a control that empties the field, and only when there is something to empty. It calls onChange with an empty array.

production
<MultiSelect
  options={['production', 'staging']}
  defaultValue={['production']}
  clearable
/>

Escape clears too, while the field has focus and the list is shut. When the list is open Escape closes it instead — that is what Escape means when something is in the way.

The control is deliberately not in the tab order. A field that tabs to its input, then its clear control, then its list is tedious to escape, and the keyboard already has Escape. The control is for the pointer.

Its accessible name says what it clears rather than just “Clear”, because a form with several of these otherwise announces the same button three times. Its pointer target is 24px even though the glyph is 1rem — the shared target rule does that.