Select search
This Vue component wraps a native <select class="c-select"> and adds a small search field inside the browser's native picker when the browser supports customizable selects. The component keeps the DOM contract from Promote: you pass the select through the default slot, bind the query with v-model, and filter the slotted options in the parent.
It relies on the existing Select CSS component plus the framework CSS file components.select-search.css.
Live example
Usage
vue
<script setup>
import { computed, ref } from 'vue'
import { SelectSearch } from '@tickster/ui-framework/library.mjs'
const query = ref('')
const selectedCountry = ref('')
const countries = ['Sweden', 'Norway', 'Denmark', 'Finland']
const filteredCountries = computed(() => {
const normalizedQuery = query.value.trim().toLowerCase()
if (!normalizedQuery) {
return countries
}
return countries.filter((country) => country.toLowerCase().includes(normalizedQuery))
})
</script>
<template>
<select-search
v-model="query"
placeholder="Search countries"
>
<select v-model="selectedCountry" class="c-select">
<button>
<selectedcontent></selectedcontent>
</button>
<option value="">Choose a country</option>
<option
v-for="country in filteredCountries"
:key="country"
:value="country"
>
{{ country }}
</option>
</select>
</select-search>
</template>vue
<script setup>
import { computed, ref } from 'vue'
import { SelectSearch } from '@tickster/ui-framework/library.mjs'
const query = ref('')
const selectedValue = ref('')
const options = {
events: ['Arena tour', 'Club night'],
productions: ['Spring campaign', 'Summer campaign'],
}
const filteredOptions = computed(() => {
const normalizedQuery = query.value.trim().toLowerCase()
if (!normalizedQuery) {
return options
}
return {
events: options.events.filter((label) => label.toLowerCase().includes(normalizedQuery)),
productions: options.productions.filter((label) => label.toLowerCase().includes(normalizedQuery)),
}
})
</script>
<template>
<select-search
v-model="query"
search-label="Search options"
placeholder="Search menu"
>
<select v-model="selectedValue" class="c-select">
<button>
<selectedcontent></selectedcontent>
</button>
<option value="">Choose one</option>
<optgroup label="Productions">
<legend>Productions</legend>
<option
v-for="label in filteredOptions.productions"
:key="label"
:value="label"
>
{{ label }}
</option>
</optgroup>
<optgroup label="Events">
<legend>Events</legend>
<option
v-for="label in filteredOptions.events"
:key="label"
:value="label"
>
{{ label }}
</option>
</optgroup>
</select>
</select-search>
</template>Behavior notes
v-modelis the search query only. The component does not mutate or filter the options list by itself.- The slotted select must be the previous sibling of the internal search input, which means the slot should render a native
<select class="c-select">directly. - Arrow up and arrow down move the current selection inside the native select and emit the usual
inputandchangeevents. - When the select value changes, the search query resets to an empty string.
Progressive enhancement
- On browsers that support
appearance: base-selectplusshowPicker(), the search field is shown in the native picker surface. - On unsupported browsers, the component falls back to the plain slotted select and keeps the search input hidden.
Accessibility notes
- Keep using a visible
<label>for the slotted<select>. search-labelbecomes the accessible name for the internal search field.- If the parent filters options, make sure the filtered results still preserve the currently selected option when that matters for the flow.
Component source
vue
<!-- Usage: wrap a native <select class="c-select"> in the default slot and bind the search query with v-model. -->
<script setup>
import { onMounted, ref, useId } from 'vue'
defineOptions({
name: 'SelectSearch',
})
const query = defineModel({
type: String,
default: '',
})
const props = defineProps({
searchLabel: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
})
const searchInput = ref(null)
const searchInputId = useId()
const suppressNextReset = ref(false)
function isNativeSelectPickerSearchSupported() {
return (
typeof window !== 'undefined'
&& typeof HTMLInputElement !== 'undefined'
&& typeof CSS !== 'undefined'
&& 'showPicker' in HTMLInputElement.prototype
&& CSS.supports?.('appearance: base-select')
)
}
function getSelectFromInput(input) {
const select = input?.previousElementSibling
const isValidSelect = select?.tagName?.toLowerCase() === 'select'
&& select?.classList?.contains('c-select')
return isValidSelect ? select : null
}
function getEnabledSelectFromInput(input) {
if (!isNativeSelectPickerSearchSupported()) {
return null
}
return getSelectFromInput(input)
}
function showPicker(event) {
const input = event?.currentTarget
const select = getEnabledSelectFromInput(input)
if (!select) {
return
}
try {
select.showPicker?.()
input?.select?.()
input?.focus?.()
} catch {
// ignore native picker failures
}
}
function suppressResetOnce() {
suppressNextReset.value = true
}
function resetSearchString(event) {
if (suppressNextReset.value) {
suppressNextReset.value = false
return
}
const nextTarget = event?.relatedTarget
if (nextTarget && (nextTarget === getSelectFromInput(searchInput.value) || nextTarget?.closest?.('.c-select-search'))) {
return
}
query.value = ''
}
function scrollSelectedOptionIntoView(select) {
try {
const option = select?.options?.[select?.selectedIndex]
option?.scrollIntoView?.({ block: 'nearest' })
} catch {
// ignore scroll failures
}
}
function isSelectableOption(option) {
return Boolean(option) && option.disabled !== true && option.hidden !== true
}
function dispatchSelectChanged(select) {
select?.dispatchEvent?.(new Event('input', { bubbles: true }))
select?.dispatchEvent?.(new Event('change', { bubbles: true }))
}
function selectAdjacentOption(event, direction) {
if (event?.ctrlKey || event?.metaKey || event?.altKey) {
return
}
const step = direction === 'down' ? 1 : direction === 'up' ? -1 : 0
if (step === 0) {
return
}
const input = event?.currentTarget
const select = getEnabledSelectFromInput(input)
if (!select) {
return
}
event.preventDefault()
const options = Array.from(select.options ?? [])
if (options.length === 0) {
return
}
let startIndex = Number.isInteger(select.selectedIndex) ? select.selectedIndex : -1
if (startIndex < 0 && step < 0) {
startIndex = options.length
}
for (let index = startIndex + step; index >= 0 && index < options.length; index += step) {
if (!isSelectableOption(options[index])) {
continue
}
if (select.selectedIndex !== index) {
select.selectedIndex = index
dispatchSelectChanged(select)
query.value = ''
}
try {
select.showPicker?.()
scrollSelectedOptionIntoView(select)
input?.focus?.()
} catch {
// ignore native picker failures
}
return
}
}
function clearSearchOnSelectChange(event) {
const target = event?.target
if (target?.tagName?.toLowerCase() !== 'select') {
return
}
query.value = ''
}
function selectAllSearchText() {
searchInput.value?.select?.()
}
onMounted(() => {
const input = searchInput.value
if (!input) {
return
}
const select = getEnabledSelectFromInput(input)
if (select) {
input.classList.remove('u-hidden')
return
}
if (isNativeSelectPickerSearchSupported()) {
console.warn("SelectSearch expects a previous sibling <select> with class 'c-select'.")
}
})
</script>
<template>
<search
class="c-select-search"
@mousedown="suppressResetOnce"
@change="clearSearchOnSelectChange"
>
<slot></slot>
<input
:id="searchInputId"
ref="searchInput"
v-model="query"
type="search"
:aria-label="props.searchLabel"
:placeholder="props.placeholder || undefined"
class="c-select-search__input u-hidden"
@click="showPicker"
@focus="selectAllSearchText"
@keydown.up="selectAdjacentOption($event, 'up')"
@keydown.down="selectAdjacentOption($event, 'down')"
@blur="resetSearchString"
>
</search>
</template>