Select

Pick one option from a list, with keyboard navigation, type-to-select 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 select
pnpm dlx ermineui add select
yarn dlx ermineui add select
bunx ermineui add select

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

Use

import {
  Select,
  SelectContent,
  SelectItem,
  SelectLabel,
  SelectSeparator,
  SelectTrigger,
} from '@/components/ui/select'
<Select v-model="region" name="region">
  <SelectTrigger placeholder="Choose a region" />
  <SelectContent>
    <SelectItem value="fra1">Frankfurt</SelectItem>
    <SelectItem value="lhr1">London</SelectItem>
  </SelectContent>
</Select>

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
Select.vuesrc/components/ui/select/Select.vue
SelectContent.vuesrc/components/ui/select/SelectContent.vue
SelectItem.vuesrc/components/ui/select/SelectItem.vue
SelectLabel.vuesrc/components/ui/select/SelectLabel.vue
SelectSeparator.vuesrc/components/ui/select/SelectSeparator.vue
SelectTrigger.vuesrc/components/ui/select/SelectTrigger.vue
context.tssrc/components/ui/select/context.ts
index.tssrc/components/ui/select/index.ts
collection.ts helpersrc/lib/ermine/collection.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.

Select.vue 39 lines
<script setup lang="ts">
import { reactive, ref, toRef, useId } from 'vue'
import { provideSelect } from './context'

defineOptions({ inheritAttrs: false })

const props = defineProps<{
  /** Submit the value with a form under this name. */
  name?: string
  disabled?: boolean
}>()

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

function setOpen(next: boolean) {
  open.value = next && !props.disabled
}

provideSelect({
  value,
  open,
  setOpen,
  select: (next) => {
    value.value = next
    setOpen(false)
  },
  disabled: toRef(props, 'disabled'),
  anchor: ref(null),
  contentId: useId(),
  labels: reactive(new Map<string, string>()),
})
</script>

<template>
  <slot :open="open" :value="value" />
  <input v-if="name" type="hidden" :name="name" :value="value ?? ''" :disabled="disabled" />
</template>
SelectContent.vue 152 lines
<script setup lang="ts">
import { ref } from 'vue'
import { arrowTarget, createTypeahead } from '../../lib/collection'
import { useFloating, type Align, type Side } from '../../lib/floating'
import { useLayer } from '../../lib/layer'
import { Portal } from '../../lib/primitive'
import { useSelect } from './context'

defineOptions({ inheritAttrs: false })

const props = withDefaults(
  defineProps<{
    side?: Side
    align?: Align
    /** Gap between trigger and list, in px. */
    sideOffset?: number
  }>(),
  { side: 'bottom', align: 'start', sideOffset: 4 },
)

const select = useSelect()
const content = ref<HTMLElement | null>(null)
const typeahead = createTypeahead()

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

useFloating(select.anchor, content, select.open, props)
useLayer(content, select.open, {
  ignore: () => [select.anchor.value],
  onDismiss: () => select.setOpen(false),
  initialFocus: () => {
    const list = items()
    const target = list.find((item) => item.getAttribute('aria-selected') === 'true') ?? list[0]
    target?.scrollIntoView({ block: 'nearest' })
    return target ?? content.value
  },
})

function onKeydown(event: KeyboardEvent) {
  if (event.key === 'Tab') {
    event.preventDefault()
    return
  }
  const list = items()
  const target = arrowTarget(event, list, 'vertical', false) ?? typeahead(event, list)
  if (!target) return
  event.preventDefault()
  target.focus()
}
</script>

<template>
  <Portal>
    <!-- Stays mounted while closed so each item can tell the trigger its label. -->
    <div
      :id="select.contentId"
      ref="content"
      v-bind="$attrs"
      role="listbox"
      tabindex="-1"
      class="er-floating er-select-content"
      :data-state="select.open.value ? 'open' : 'closed'"
      @keydown="onKeydown"
    >
      <slot />
    </div>
  </Portal>
</template>

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

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

  .er-select-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;
    outline: none;
  }

  .er-select-item:focus {
    background: var(--er-accent);
    color: var(--er-accent-foreground);
  }

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

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

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

  .er-select-label {
    padding: 0.375rem 0.5rem;
    color: var(--er-muted-foreground);
    font-size: 0.75rem;
  }

  .er-select-separator {
    height: 1px;
    margin: 0.25rem -0.25rem;
    background: var(--er-border);
  }
}
</style>
SelectItem.vue 58 lines
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, onUpdated, ref } from 'vue'
import { useSelect } from './context'

const props = defineProps<{
  value: string
  disabled?: boolean
  /** Label shown in the trigger. Defaults to the item's text. */
  textValue?: string
}>()

const select = useSelect()
const el = ref<HTMLElement | null>(null)
const selected = computed(() => select.value.value === 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 choose() {
  if (!props.disabled) select.select(props.value)
}

function onKeydown(event: KeyboardEvent) {
  if (event.key !== 'Enter' && event.key !== ' ') return
  event.preventDefault()
  choose()
}

function onPointerMove(event: PointerEvent) {
  if (!props.disabled) (event.currentTarget as HTMLElement).focus({ preventScroll: true })
}
</script>

<template>
  <div
    ref="el"
    role="option"
    tabindex="-1"
    class="er-select-item"
    :aria-selected="selected"
    :aria-disabled="disabled || undefined"
    :data-disabled="disabled ? '' : undefined"
    @click="choose"
    @keydown="onKeydown"
    @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>
SelectLabel.vue 5 lines
<template>
  <!-- A visual heading inside the list. Marked as presentation, since a listbox may only hold options and groups. -->
  <div role="presentation" class="er-select-label"><slot /></div>
</template>
SelectSeparator.vue 5 lines
<template>
  <!-- A listbox may only hold options and groups, so the line is decoration and says nothing to screen readers. -->
  <div class="er-select-separator" aria-hidden="true" />
</template>
SelectTrigger.vue 112 lines
<script setup lang="ts">
import { computed } from 'vue'
import { toElement } from '../../lib/primitive'
import { useSelect } from './context'

defineProps<{
  /** Shown while nothing is selected. */
  placeholder?: string
}>()

const select = useSelect()

const hasValue = computed(() => select.value.value !== undefined && select.value.value !== '')
const label = computed(() =>
  hasValue.value ? (select.labels.get(select.value.value!) ?? select.value.value) : undefined,
)

function setAnchor(target: unknown) {
  select.anchor.value = toElement(target)
}

function onKeydown(event: KeyboardEvent) {
  if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
  event.preventDefault()
  select.setOpen(true)
}
</script>

<template>
  <button
    :ref="setAnchor"
    type="button"
    role="combobox"
    class="er-select-trigger"
    aria-haspopup="listbox"
    :aria-expanded="select.open.value"
    :aria-controls="select.contentId"
    :disabled="select.disabled.value"
    :data-state="select.open.value ? 'open' : 'closed'"
    :data-placeholder="hasValue ? undefined : ''"
    @click="select.setOpen(!select.open.value)"
    @keydown="onKeydown"
  >
    <span class="er-select-value">
      <slot :value="select.value.value" :label="label">{{ label ?? placeholder }}</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="m6 9 6 6 6-6" />
    </svg>
  </button>
</template>

<style>
@layer components {
  .er-select-trigger {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    width: 100%;
    height: var(--er-control-md);
    padding: 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;
    font-size: 0.875rem;
    line-height: 1.25rem;
    text-align: left;
    box-shadow: var(--er-shadow-xs);
    cursor: pointer;
    transition:
      border-color var(--er-duration),
      outline-color var(--er-duration);
  }

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

  .er-select-trigger:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }

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

  .er-select-trigger[data-placeholder] .er-select-value {
    color: var(--er-muted-foreground);
  }

  .er-select-value {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .er-select-trigger svg {
    flex-shrink: 0;
    width: 1rem;
    height: 1rem;
    color: var(--er-muted-foreground);
  }
}
</style>
context.ts 18 lines
import type { Ref } from 'vue'
import { createContext } from '../../lib/context'

export interface SelectContext {
  value: Ref<string | undefined>
  open: Ref<boolean>
  setOpen: (open: boolean) => void
  /** Choose a value and close the list. */
  select: (value: string) => void
  disabled: Ref<boolean>
  anchor: Ref<HTMLElement | null>
  contentId: string
  /** value → visible label, filled in by each SelectItem so the trigger can show it. */
  labels: Map<string, string>
}

export const [provideSelect, useSelect] = createContext<SelectContext>('Select')
index.ts 8 lines
/** Select — pick one option from a list, with keyboard navigation, type-to-select and form support. */
export { default as Select } from './Select.vue'
export { default as SelectContent } from './SelectContent.vue'
export { default as SelectItem } from './SelectItem.vue'
export { default as SelectLabel } from './SelectLabel.vue'
export { default as SelectSeparator } from './SelectSeparator.vue'
export { default as SelectTrigger } from './SelectTrigger.vue'