Combobox

A select you can type in to filter the options, with keyboard navigation and form support.

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 combobox
pnpm dlx ermineui add combobox
yarn dlx ermineui add combobox
bunx ermineui add combobox

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

Use

import { Combobox, ComboboxItem } from '@/components/ui/combobox'
<Combobox v-model="timezone" name="timezone" placeholder="Search cities…">
  <ComboboxItem value="Europe/Berlin">Berlin</ComboboxItem>
  <ComboboxItem value="Asia/Tokyo">Tokyo</ComboboxItem>
  <template #empty>No city matches.</template>
</Combobox>

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
Combobox.vuesrc/components/ui/combobox/Combobox.vue
ComboboxItem.vuesrc/components/ui/combobox/ComboboxItem.vue
context.tssrc/components/ui/combobox/context.ts
index.tssrc/components/ui/combobox/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.

Combobox.vue 320 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 { provideCombobox } 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 value with a form under this name. */
  name?: string
  disabled?: boolean
  placeholder?: string
}>()

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

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

/** What is in the input: the chosen option's label, or whatever the user is typing. */
const text = ref('')
/** Only filter once the user types — opening the list with a value chosen shows every option. */
const filtering = ref(false)

const selectedLabel = computed(() =>
  value.value === undefined || value.value === '' ? '' : (labels.get(value.value) ?? value.value),
)
const query = computed(() => (filtering.value ? text.value.trim().toLowerCase() : ''))
const matches = (label: string) => label.toLowerCase().includes(query.value)
const empty = computed(() => ![...labels.values()].some(matches))
/** Said aloud while typing: the list changes on screen, but focus stays in the input and nothing else reports it. */
const status = computed(() => {
  if (!open.value || !filtering.value) return ''
  const count = [...labels.values()].filter(matches).length
  return count === 0 ? 'No results' : `${count} ${count === 1 ? 'result' : 'results'}`
})

watch(
  selectedLabel,
  (label) => {
    if (!open.value) text.value = label
  },
  { immediate: true },
)

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()
  const items = options()
  setActive(items.find((item) => item.getAttribute('aria-selected') === 'true') ?? items[0])
}

function select(next: string) {
  value.value = next
  text.value = labels.get(next) ?? next
  open.value = false
  filtering.value = false
  activeId.value = undefined
}

/** Close without choosing: an emptied input clears the value, anything else goes back to the chosen label. */
function close() {
  if (!open.value) return
  const cleared = text.value.trim() === ''
  if (cleared) value.value = undefined
  text.value = cleared ? '' : selectedLabel.value
  open.value = false
  filtering.value = false
  activeId.value = undefined
}

async function onInput(event: Event) {
  text.value = (event.target as HTMLInputElement).value
  filtering.value = true
  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()
  }
}

provideCombobox({ value, select, 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>
  <div ref="anchor" class="er-combobox">
    <input
      v-bind="$attrs"
      type="text"
      role="combobox"
      class="er-combobox-input"
      autocomplete="off"
      aria-autocomplete="list"
      :aria-expanded="open"
      :aria-controls="listId"
      :aria-activedescendant="open ? activeId : undefined"
      :placeholder="placeholder"
      :disabled="disabled"
      :value="text"
      @input="onInput"
      @keydown="onKeydown"
      @click="show"
      @blur="close"
    />
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <path d="m7 15 5 5 5-5M7 9l5-5 5 5" />
    </svg>
    <span class="er-combobox-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"
      class="er-floating er-combobox-content"
      :data-state="open ? 'open' : 'closed'"
      @mousedown.prevent
    >
      <slot />
      <div v-if="empty" class="er-combobox-empty"><slot name="empty">No results.</slot></div>
    </div>
  </Portal>

  <input v-if="name" type="hidden" :name="name" :value="value ?? ''" :disabled="disabled" />
</template>

<style>
@layer components {
  .er-combobox {
    position: relative;
    width: 100%;
  }

  .er-combobox-input {
    display: block;
    width: 100%;
    min-width: 0;
    height: var(--er-control-md);
    padding: 0 2.25rem 0 0.75rem;
    border: 1px solid var(--er-input);
    border-radius: var(--er-radius-control);
    background: var(--er-input-background);
    color: var(--er-foreground);
    font-family: inherit;
    /* 16px stops iOS from zooming the page when the field is focused. */
    font-size: 1rem;
    line-height: 1.25rem;
    text-overflow: ellipsis;
    box-shadow: var(--er-shadow-xs);
    transition:
      border-color var(--er-duration),
      outline-color var(--er-duration);
  }

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

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

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

  .er-combobox-input:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }

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

  .er-combobox > svg {
    position: absolute;
    top: 50%;
    right: 0.75rem;
    width: 1rem;
    height: 1rem;
    color: var(--er-muted-foreground);
    transform: translateY(-50%);
    pointer-events: none;
  }

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

  .er-combobox-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-combobox-content[data-state='closed'] {
    display: none;
    opacity: 0;
    transform: scale(0.96);
  }

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

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

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

  .er-combobox-item[data-disabled] {
    opacity: 0.5;
    pointer-events: none;
  }

  .er-combobox-item svg {
    flex-shrink: 0;
    width: 1rem;
    height: 1rem;
    visibility: hidden;
  }

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

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

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

const combobox = useCombobox()
const el = ref<HTMLElement | null>(null)
const id = useId()

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

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

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

function choose() {
  if (!props.disabled) combobox.select(props.value)
}

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

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

export interface ComboboxContext {
  value: Ref<string | undefined>
  /** Choose a value and close the list. */
  select: (value: string) => void
  /** value → visible label, filled in by each ComboboxItem. Filtering and the input text both read it. */
  labels: Map<string, string>
  /** Whether a label fits what has been typed. Everything fits until the user types. */
  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 [provideCombobox, useCombobox] = createContext<ComboboxContext>('Combobox')
index.ts 4 lines
/** Combobox — a select you can type in to filter the options, with keyboard navigation and form support. */
export { default as Combobox } from './Combobox.vue'
export { default as ComboboxItem } from './ComboboxItem.vue'