Multi-select

Choose several options from a list you can type in to filter; choices show as removable tags.

Install

If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.

npx ermineui init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add multi-select
pnpm dlx ermineui add multi-select
yarn dlx ermineui add multi-select
bunx ermineui add multi-select

That copies 4 files into src/components/ui/multi-select/, 4 helpers into src/lib/ermine/. Files you already have are kept.

Use

import { MultiSelect, MultiSelectItem } from '@/components/ui/multi-select'
<Stepper v-model="step" :steps="['Cart', 'Shipping', 'Payment']" />

<MultiSelect v-model="regions" name="regions" placeholder="Search regions…" :max="3">
  <MultiSelectItem value="fra1">Frankfurt</MultiSelectItem>
  <MultiSelectItem value="lhr1">London</MultiSelectItem>
</MultiSelect>

<NumberInput v-model="replicas" :min="1" :max="10" />

<FileUpload v-model="files" multiple accept=".csv,image/*" :max-size="2 * 1024 * 1024" :progress="progressByName" />

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.

What gets copied

FileWhere it goes
MultiSelect.vuesrc/components/ui/multi-select/MultiSelect.vue
MultiSelectItem.vuesrc/components/ui/multi-select/MultiSelectItem.vue
context.tssrc/components/ui/multi-select/context.ts
index.tssrc/components/ui/multi-select/index.ts
context.ts helpersrc/lib/ermine/context.ts
floating.ts helpersrc/lib/ermine/floating.ts
layer.ts helpersrc/lib/ermine/layer.ts
primitive.ts helpersrc/lib/ermine/primitive.ts

The folders are the suggested ones. ermine.json says where yours are.

Source

This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.

MultiSelect.vue 379 lines
<script setup lang="ts">
import { computed, nextTick, reactive, ref, useId, watch } from 'vue'
import { useFloating } from '../../lib/floating'
import { useLayer } from '../../lib/layer'
import { Portal } from '../../lib/primitive'
import { provideMultiSelect } from './context'

// Attributes such as id and aria-invalid go to the input, so <Label for="..."> works.
defineOptions({ inheritAttrs: false })

const props = defineProps<{
  /** Submit the values with a form under this name, one entry per choice. */
  name?: string
  disabled?: boolean
  /** Shown while nothing is chosen. */
  placeholder?: string
  /** Most choices allowed. Further options cannot be added until one is removed. */
  max?: number
}>()

const values = defineModel<string[]>({ default: () => [] })
const open = defineModel<boolean>('open', { default: false })

const anchor = ref<HTMLElement | null>(null)
const input = ref<HTMLInputElement | null>(null)
const list = ref<HTMLElement | null>(null)
const listId = useId()
const labels = reactive(new Map<string, string>())
const activeId = ref<string>()
const text = ref('')

const query = computed(() => text.value.trim().toLowerCase())
const matches = (label: string) => label.toLowerCase().includes(query.value)
const empty = computed(() => ![...labels.values()].some(matches))
const full = computed(() => props.max !== undefined && values.value.length >= props.max)

/** Said aloud: tags appear and disappear, and the list shrinks as you type, all without focus moving. */
const status = ref('')
watch(query, (text) => {
  if (!open.value || !text) return
  const count = [...labels.values()].filter(matches).length
  status.value = count === 0 ? 'No results' : `${count} ${count === 1 ? 'result' : 'results'}`
})

function options(): HTMLElement[] {
  return Array.from(
    list.value?.querySelectorAll<HTMLElement>('[role="option"]:not([data-disabled]):not([data-hidden])') ?? [],
  )
}

function setActive(option: HTMLElement | undefined) {
  activeId.value = option?.id
  option?.scrollIntoView({ block: 'nearest' })
}

async function show() {
  if (props.disabled || open.value) return
  open.value = true
  await nextTick()
  setActive(options()[0])
}

function close() {
  if (!open.value) return
  open.value = false
  text.value = ''
  activeId.value = undefined
}

function toggle(value: string) {
  const name = labels.get(value) ?? value
  if (values.value.includes(value)) {
    values.value = values.value.filter((other) => other !== value)
    status.value = `${name} removed`
  } else if (!full.value) {
    values.value = [...values.value, value]
    status.value = `${name} added`
  }
  // The list stays open for the next choice; only what was typed is cleared.
  text.value = ''
  input.value?.focus()
}

function remove(value: string) {
  values.value = values.value.filter((other) => other !== value)
  status.value = `${labels.get(value) ?? value} removed`
  input.value?.focus()
}

async function onInput(event: Event) {
  text.value = (event.target as HTMLInputElement).value
  if (!props.disabled) open.value = true
  await nextTick()
  setActive(options()[0])
}

function onKeydown(event: KeyboardEvent) {
  if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
    event.preventDefault()
    if (!open.value) {
      show()
      return
    }
    const items = options()
    const current = items.findIndex((item) => item.id === activeId.value)
    const last = items.length - 1
    if (event.key === 'ArrowDown') setActive(items[current === last ? 0 : current + 1])
    else setActive(items[current <= 0 ? last : current - 1])
  } else if (event.key === 'Enter' && open.value) {
    // Keeps Enter from submitting the form while the list is open.
    event.preventDefault()
    options()
      .find((item) => item.id === activeId.value)
      ?.click()
  } else if (event.key === 'Backspace' && text.value === '' && values.value.length) {
    // With nothing typed, Backspace takes off the last tag.
    remove(values.value[values.value.length - 1]!)
  }
}

provideMultiSelect({ values, toggle, labels, matches, activeId })

useFloating(anchor, list, open, { side: 'bottom', align: 'start', sideOffset: 4 })
useLayer(list, open, {
  ignore: () => [anchor.value],
  onDismiss: close,
  // Focus never leaves the input; the highlighted option is tracked with aria-activedescendant.
  initialFocus: () => false,
  restoreFocus: false,
})
</script>

<template>
  <!-- Pressing anywhere in the box puts the cursor in the input, as it would in a plain text field. -->
  <div ref="anchor" class="er-multi-select" :data-disabled="disabled ? '' : undefined" @mousedown="($event.target as HTMLElement).tagName !== 'INPUT' && $event.preventDefault()" @click="input?.focus()">
    <span v-for="value in values" :key="value" class="er-multi-select-tag">
      {{ labels.get(value) ?? value }}
      <button type="button" tabindex="-1" :aria-label="`Remove ${labels.get(value) ?? value}`" :disabled="disabled" @click.stop="remove(value)">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
      </button>
    </span>
    <input
      ref="input"
      v-bind="$attrs"
      type="text"
      role="combobox"
      autocomplete="off"
      aria-autocomplete="list"
      :aria-expanded="open"
      :aria-controls="listId"
      :aria-activedescendant="open ? activeId : undefined"
      :placeholder="values.length ? undefined : placeholder"
      :disabled="disabled"
      :value="text"
      @input="onInput"
      @keydown="onKeydown"
      @click="show"
      @blur="close"
    />
    <span class="er-multi-select-status" role="status">{{ status }}</span>
  </div>

  <Portal>
    <!-- Stays mounted while closed so each item can register its label. Pressing here must not blur the input. -->
    <div
      :id="listId"
      ref="list"
      role="listbox"
      aria-multiselectable="true"
      class="er-floating er-multi-select-content"
      :data-state="open ? 'open' : 'closed'"
      :data-full="full ? '' : undefined"
      @mousedown.prevent
    >
      <slot />
      <div v-if="empty" class="er-multi-select-empty"><slot name="empty">No results.</slot></div>
    </div>
  </Portal>

  <template v-if="name">
    <input v-for="value in values" :key="value" type="hidden" :name="name" :value="value" :disabled="disabled" />
  </template>
</template>

<style>
@layer components {
  .er-multi-select {
    position: relative;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.25rem;
    width: 100%;
    min-height: var(--er-control-md);
    padding: 0.25rem 0.375rem;
    border: 1px solid var(--er-input);
    border-radius: var(--er-radius-md);
    background: var(--er-input-background);
    box-shadow: var(--er-shadow-xs);
    cursor: text;
    transition:
      border-color var(--er-duration),
      outline-color var(--er-duration);
  }

  .er-multi-select:focus-within {
    border-color: var(--er-ring);
    outline: 2px solid color-mix(in oklab, var(--er-ring) 45%, transparent);
    outline-offset: 0;
  }

  .er-multi-select:has(input[aria-invalid='true']) {
    border-color: var(--er-destructive);
  }

  .er-multi-select[data-disabled] {
    opacity: 0.5;
    cursor: not-allowed;
  }

  .er-multi-select input {
    flex: 1;
    min-width: 6rem;
    height: 1.5rem;
    padding: 0 0.375rem;
    border: 0;
    background: transparent;
    color: var(--er-foreground);
    font-family: inherit;
    /* 16px stops iOS from zooming the page when the field is focused. */
    font-size: 1rem;
    outline: none;
  }

  @media (min-width: 48rem) {
    .er-multi-select input {
      font-size: 0.875rem;
    }
  }

  .er-multi-select input::placeholder {
    color: var(--er-muted-foreground);
  }

  .er-multi-select-tag {
    display: inline-flex;
    align-items: center;
    gap: 0.125rem;
    max-width: 100%;
    height: 1.5rem;
    padding: 0 0.125rem 0 0.5rem;
    border-radius: var(--er-radius-sm);
    background: var(--er-secondary);
    color: var(--er-secondary-foreground);
    font-size: 0.8125rem;
    font-weight: var(--er-weight-medium);
    white-space: nowrap;
  }

  .er-multi-select-tag button {
    display: grid;
    place-items: center;
    width: 1.125rem;
    height: 1.125rem;
    padding: 0;
    border: 0;
    border-radius: 3px;
    background: transparent;
    color: var(--er-muted-foreground);
    cursor: pointer;
  }

  .er-multi-select-tag button:hover {
    background: color-mix(in oklab, var(--er-foreground) 10%, transparent);
    color: var(--er-foreground);
  }

  .er-multi-select-tag svg {
    width: 0.75rem;
    height: 0.75rem;
  }

  .er-multi-select-status {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  .er-multi-select-content {
    min-width: max(8rem, var(--er-anchor-width, 8rem));
    max-height: min(20rem, var(--er-available-size, 20rem));
    padding: 0.25rem;
    border: 1px solid var(--er-border);
    border-radius: var(--er-radius-md);
    background: var(--er-popover);
    color: var(--er-popover-foreground);
    font-size: 0.875rem;
    box-shadow: var(--er-shadow-md);
    overflow-y: auto;
    /* Open/close animation in plain CSS: browsers without @starting-style simply skip it. */
    transition:
      opacity var(--er-duration) var(--er-ease),
      transform var(--er-duration) var(--er-ease),
      display var(--er-duration) allow-discrete;
  }

  .er-multi-select-content[data-state='closed'] {
    display: none;
    opacity: 0;
    transform: scale(0.96);
  }

  @starting-style {
    .er-multi-select-content[data-state='open'] {
      opacity: 0;
      transform: scale(0.96);
    }
  }

  .er-multi-select-item {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.375rem 0.5rem;
    border-radius: var(--er-radius-sm);
    line-height: 1.25rem;
    cursor: default;
    user-select: none;
  }

  .er-multi-select-item[data-active] {
    background: var(--er-accent);
    color: var(--er-accent-foreground);
  }

  /* At the limit, options not yet chosen fade: they cannot be added until one is removed. */
  .er-multi-select-item[data-disabled],
  .er-multi-select-content[data-full] .er-multi-select-item[aria-selected='false'] {
    opacity: 0.5;
    pointer-events: none;
  }

  .er-multi-select-box {
    display: grid;
    flex-shrink: 0;
    place-items: center;
    width: 1rem;
    height: 1rem;
    border: 1px solid var(--er-input);
    border-radius: 4px;
  }

  .er-multi-select-box svg {
    width: 0.75rem;
    height: 0.75rem;
    visibility: hidden;
  }

  .er-multi-select-item[aria-selected='true'] .er-multi-select-box {
    border-color: var(--er-primary);
    background: var(--er-primary);
    color: var(--er-primary-foreground);
  }

  .er-multi-select-item[aria-selected='true'] .er-multi-select-box svg {
    visibility: visible;
  }

  .er-multi-select-empty {
    padding: 0.75rem 0.5rem;
    color: var(--er-muted-foreground);
    text-align: center;
  }
}
</style>
MultiSelectItem.vue 53 lines
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, onUpdated, ref, useId } from 'vue'
import { useMultiSelect } from './context'

const props = defineProps<{
  value: string
  disabled?: boolean
  /** Text to filter on and show in the tag. Defaults to the item's text. */
  textValue?: string
}>()

const select = useMultiSelect()
const el = ref<HTMLElement | null>(null)
const id = useId()

const selected = computed(() => select.values.value.includes(props.value))
const visible = computed(() => select.matches(select.labels.get(props.value) ?? ''))

function register() {
  select.labels.set(props.value, props.textValue ?? el.value?.textContent?.trim() ?? props.value)
}

onMounted(register)
onUpdated(register)
onBeforeUnmount(() => select.labels.delete(props.value))

function onPointerMove() {
  if (!props.disabled) select.activeId.value = id
}
</script>

<template>
  <div
    v-show="visible"
    :id="id"
    ref="el"
    role="option"
    class="er-multi-select-item"
    :aria-selected="selected"
    :aria-disabled="disabled || undefined"
    :data-disabled="disabled ? '' : undefined"
    :data-hidden="visible ? undefined : ''"
    :data-active="select.activeId.value === id ? '' : undefined"
    @click="!disabled && select.toggle(value)"
    @pointermove="onPointerMove"
  >
    <span class="er-multi-select-box" aria-hidden="true">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
    </span>
    <span><slot /></span>
  </div>
</template>
context.ts 17 lines
import type { Ref } from 'vue'
import { createContext } from '../../lib/context'

export interface MultiSelectContext {
  values: Ref<string[]>
  /** Add the value if it is not chosen, remove it if it is. */
  toggle: (value: string) => void
  /** value → visible label, filled in by each MultiSelectItem. Filtering and the tags both read it. */
  labels: Map<string, string>
  /** Whether a label fits what has been typed. */
  matches: (label: string) => boolean
  /** id of the highlighted option. Focus stays in the input; this is what the arrow keys move. */
  activeId: Ref<string | undefined>
}

export const [provideMultiSelect, useMultiSelect] = createContext<MultiSelectContext>('MultiSelect')
index.ts 4 lines
/** Multi-select — choose several options from a list you can type in to filter; choices show as removable tags. */
export { default as MultiSelect } from './MultiSelect.vue'
export { default as MultiSelectItem } from './MultiSelectItem.vue'