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 initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add selectpnpm dlx ermineui add selectyarn dlx ermineui add selectbunx ermineui add selectThat 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
| File | Where it goes |
|---|---|
Select.vue | src/components/ui/select/Select.vue |
SelectContent.vue | src/components/ui/select/SelectContent.vue |
SelectItem.vue | src/components/ui/select/SelectItem.vue |
SelectLabel.vue | src/components/ui/select/SelectLabel.vue |
SelectSeparator.vue | src/components/ui/select/SelectSeparator.vue |
SelectTrigger.vue | src/components/ui/select/SelectTrigger.vue |
context.ts | src/components/ui/select/context.ts |
index.ts | src/components/ui/select/index.ts |
collection.ts helper | src/lib/ermine/collection.ts |
context.ts helper | src/lib/ermine/context.ts |
floating.ts helper | src/lib/ermine/floating.ts |
layer.ts helper | src/lib/ermine/layer.ts |
primitive.ts helper | src/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'