JAVASCRIPT
Building a Headless Select Component in Vue 3
Create a highly customizable and accessible headless select component in Vue 3, managing its state and behavior without imposing specific UI rendering.
// HeadlessSelect.vue
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
options: { type: Array, required: true },
modelValue: { type: [String, Number], default: null }
})
const emit = defineEmits(['update:modelValue'])
const isOpen = ref(false)
const selectedValue = ref(props.modelValue)
const activeOptionIndex = ref(-1)
watch(() => props.modelValue, (newVal) => {
selectedValue.value = newVal
})
watch(selectedValue, (newVal) => {
emit('update:modelValue', newVal)
if (isOpen.value) {
isOpen.value = false // Close dropdown on selection
}
})
function toggle() {
isOpen.value = !isOpen.value
if (isOpen.value) {
// Set active index to current selected item when opening
activeOptionIndex.value = props.options.findIndex(opt => opt.value === selectedValue.value)
} else {
activeOptionIndex.value = -1 // Reset when closed
}
}
function selectOption(option) {
selectedValue.value = option.value
}
function handleKeyDown(event) {
const maxIndex = props.options.length - 1
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
if (!isOpen.value) isOpen.value = true
activeOptionIndex.value = (activeOptionIndex.value < maxIndex) ? activeOptionIndex.value + 1 : 0
break
case 'ArrowUp':
event.preventDefault()
if (!isOpen.value) isOpen.value = true
activeOptionIndex.value = (activeOptionIndex.value > 0) ? activeOptionIndex.value - 1 : maxIndex
break
case 'Enter':
case ' ':
event.preventDefault()
if (isOpen.value && activeOptionIndex.value !== -1) {
selectOption(props.options[activeOptionIndex.value])
} else if (!isOpen.value) {
toggle()
}
break
case 'Escape':
event.preventDefault()
isOpen.value = false
break
}
}
</script>
<template>
<div @keydown="handleKeyDown" tabindex="0" class="headless-select-wrapper">
<slot
:is-open="isOpen"
:selected-value="selectedValue"
:options="props.options"
:active-option-index="activeOptionIndex"
:toggle="toggle"
:select-option="selectOption"
/>
</div>
</template>
<!-- Usage in App.vue -->
<script setup>
import HeadlessSelect from './HeadlessSelect.vue'
import { ref } from 'vue'
const frameworks = ref([
{ label: 'Vue.js', value: 'vue' },
{ label: 'React', value: 'react' },
{ label: 'Angular', value: 'angular' },
])
const selectedFramework = ref('vue')
</script>
<template>
<HeadlessSelect v-model="selectedFramework" :options="frameworks">
<template #default="{ isOpen, selectedValue, toggle, options, selectOption, activeOptionIndex }">
<div class="custom-select" :class="{ 'is-open': isOpen }">
<button class="select-button" @click="toggle" aria-haspopup="listbox" :aria-expanded="isOpen">
{{ options.find(o => o.value === selectedValue)?.label || 'Select...' }}
</button>
<ul v-if="isOpen" class="select-options" role="listbox">
<li
v-for="(option, index) in options"
:key="option.value"
@click="selectOption(option)"
:class="{ 'is-active': activeOptionIndex === index, 'is-selected': selectedValue === option.value }"
role="option"
:aria-selected="selectedValue === option.value"
>
{{ option.label }}
</li>
</ul>
</div>
</template>
</HeadlessSelect>
</template>
<style>
/* Example styling for usage component */
.headless-select-wrapper:focus {
outline: none; /* remove default outline for the wrapper */
}
.custom-select {
position: relative;
width: 200px;
}
.select-button {
width: 100%;
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
background-color: #fff;
cursor: pointer;
text-align: left;
}
.select-options {
position: absolute;
top: 100%;
left: 0;
width: 100%;
background-color: #fff;
border: 1px solid #eee;
border-radius: 4px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
list-style: none;
padding: 0;
margin: 4px 0 0 0;
z-index: 10;
}
.select-options li {
padding: 8px 12px;
cursor: pointer;
}
.select-options li:hover,
.select-options li.is-active {
background-color: #f0f0f0;
}
.select-options li.is-selected {
font-weight: bold;
background-color: #e6f7ff;
}
</style>
How it works: This snippet illustrates how to build a 'headless' select component in Vue 3. A headless component manages the state and behavior (like opening/closing, selection, keyboard navigation) but renders no intrinsic UI elements. Instead, it exposes its internal state and methods via a default slot's scope, allowing the consumer to render any desired markup and styling. This approach provides maximum flexibility and accessibility control to the developer using the component, without being opinionated about its visual appearance. The `v-model` is used for bidirectional data binding, and `tabindex="0"` along with `handleKeyDown` ensures keyboard accessibility.