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.
On browsers that don't support customizable selects, single selects fall back to the plain native control. A multiple size="1" select is automatically expanded to a visible native listbox so the control stays usable instead of pretending to be a dropdown picker.
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>vue
<script setup>
import { computed, ref } from 'vue'
import { SelectSearch } from '@tickster/ui-framework/library.mjs'
const query = ref('')
const selectedCountries = ref(['Denmark', 'Sweden'])
const countries = ['Denmark', 'Sweden', 'Norway', 'Finland']
const filteredCountries = computed(() => {
const normalizedQuery = query.value.trim().toLowerCase()
if (!normalizedQuery) {
return countries
}
return countries.filter((country) => {
return selectedCountries.value.includes(country)
|| country.toLowerCase().includes(normalizedQuery)
})
})
</script>
<template>
<select-search
v-model="query"
search-label="Search countries"
placeholder="Search countries"
>
<select
v-model="selectedCountries"
multiple
size="1"
class="c-select"
>
<button>
<selectedcontent></selectedcontent>
</button>
<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 selectedCountries = ref([])
const countries = ['Denmark', 'Sweden', 'Norway', 'Finland']
const filteredCountries = computed(() => {
const normalizedQuery = query.value.trim().toLowerCase()
if (!normalizedQuery) {
return countries
}
return countries.filter((country) => {
return selectedCountries.value.includes(country)
|| country.toLowerCase().includes(normalizedQuery)
})
})
</script>
<template>
<select-search
v-model="query"
:close-after-first-multi-selection="true"
search-label="Search countries"
placeholder="Search countries"
>
<select
v-model="selectedCountries"
multiple
size="1"
class="c-select"
>
<button>
<selectedcontent></selectedcontent>
</button>
<option
v-for="country in filteredCountries"
:key="country"
:value="country"
>
{{ country }}
</option>
</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. - For single-select menus, arrow up and arrow down move the current selection inside the native select and emit the usual
inputandchangeevents. - For dropdown-style
multipleselects, bind the slotted select to an array and includemultiple size="1". The native picker keeps one checkmark per selected option when customizable select support is available. - Set
close-after-first-multi-selectionwhen a dropdown-stylemultipleselect should behave like a single-open picker for the first choice and then reopen for additional selections. - Multiple selected values preserve rich option content, such as icons or avatars, in the closed select display.
- 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 { onBeforeUnmount, onMounted, ref, useId } from 'vue'
defineOptions({
name: 'SelectSearch',
})
const query = defineModel({
type: String,
default: '',
})
const searchElement = 'search'
const props = defineProps({
searchLabel: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
closeAfterFirstMultiSelection: {
type: Boolean,
default: false,
},
})
const searchInput = ref(null)
const multipleValue = ref(null)
const searchInputId = useId()
const multipleSelectionCountBeforeInteraction = ref(null)
let cleanupMultipleSelectSelectedContent = null
let cleanupMultipleSelectFallback = null
const multipleFallbackMinRows = 4
const multipleFallbackMaxRows = 10
function isNativeSelectPickerSearchSupported() {
return (
typeof window !== 'undefined'
&& typeof HTMLSelectElement !== 'undefined'
&& typeof CSS !== 'undefined'
&& 'showPicker' in HTMLSelectElement.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
}
syncMultipleSelectedContent(select)
try {
select.showPicker?.()
input?.select?.()
input?.focus?.()
} catch {
// ignore native picker failures
}
}
function resetSearchString(event) {
const nextTarget = event?.relatedTarget
if (nextTarget && event?.currentTarget?.contains?.(nextTarget)) {
return
}
query.value = ''
}
function scrollSelectedOptionIntoView(select) {
try {
const option = Array.from(select?.selectedOptions ?? [])[0] ?? 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 waitForNextFrame() {
if (typeof requestAnimationFrame === 'function') {
return new Promise(resolve => requestAnimationFrame(resolve))
}
return new Promise(resolve => setTimeout(resolve, 0))
}
function getSelectedContent(select) {
return select?.querySelector?.('button selectedcontent') ?? null
}
function getOptionContentNodes(option) {
const childNodes = Array.from(option?.childNodes ?? [])
const hasElementContent = childNodes.some((node) => node.nodeType === 1)
if (!hasElementContent) {
return [document.createTextNode(option?.textContent?.trim() ?? '')]
}
return childNodes
.filter((node) => node.nodeType !== 3 || (node.textContent ?? '').trim().length > 0)
.map((node) => node.cloneNode(true))
}
function createSelectedOption(option) {
const item = document.createElement('span')
item.className = 'c-select-search__selected-option'
item.replaceChildren(...getOptionContentNodes(option))
return item
}
function renderSelectedOptions(target, options) {
target?.replaceChildren?.(...options.map(createSelectedOption))
}
function isMultipleDropdownSelect(select) {
return select?.multiple && select.getAttribute('size') === '1'
}
function getMultipleSelectFallbackSize(select) {
const optionCount = Array.from(select?.options ?? []).filter((option) => !option.hidden).length
return String(Math.max(multipleFallbackMinRows, Math.min(multipleFallbackMaxRows, optionCount)))
}
function setupMultipleSelectFallback(select) {
if (!isMultipleDropdownSelect(select) || isNativeSelectPickerSearchSupported()) {
return null
}
const originalSize = select.getAttribute('size')
select.setAttribute('size', getMultipleSelectFallbackSize(select))
return () => {
if (originalSize === null) {
select.removeAttribute('size')
return
}
select.setAttribute('size', originalSize)
}
}
function rememberMultipleDropdownSelectionCount() {
const select = getSelectFromInput(searchInput.value)
if (!isMultipleDropdownSelect(select)) {
multipleSelectionCountBeforeInteraction.value = null
return
}
multipleSelectionCountBeforeInteraction.value = Array.from(select.selectedOptions ?? []).length
}
async function closeSelectSearchMenu(select) {
await waitForNextFrame()
await waitForNextFrame()
searchInput.value?.blur?.()
select?.blur?.()
if (typeof document !== 'undefined' && (document.activeElement === searchInput.value || document.activeElement === select)) {
document.activeElement?.blur?.()
}
}
function syncMultipleSelectedContent(select) {
if (!select?.multiple) {
return
}
const selectedContent = getSelectedContent(select)
if (!selectedContent) {
return
}
const selectedOptions = Array.from(select.selectedOptions ?? [])
renderSelectedOptions(selectedContent, selectedOptions)
if (!isMultipleDropdownSelect(select)) {
multipleValue.value?.classList.add('u-hidden')
return
}
renderSelectedOptions(multipleValue.value, selectedOptions)
multipleValue.value?.classList.toggle('u-hidden', selectedOptions.length === 0)
}
function setupMultipleSelectSelectedContent(input) {
const select = getSelectFromInput(input)
if (!select?.multiple) {
return null
}
const sync = () => syncMultipleSelectedContent(select)
select.addEventListener('input', sync)
select.addEventListener('change', sync)
sync()
return () => {
select.removeEventListener('input', sync)
select.removeEventListener('change', sync)
}
}
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
}
if (select.multiple) {
try {
select.showPicker?.()
scrollSelectedOptionIntoView(select)
input?.focus?.()
} catch {
// ignore native picker failures
}
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
}
const shouldCloseAfterSelection = props.closeAfterFirstMultiSelection
&& isMultipleDropdownSelect(target)
&& multipleSelectionCountBeforeInteraction.value === 0
&& Array.from(target.selectedOptions ?? []).length > 0
query.value = ''
multipleSelectionCountBeforeInteraction.value = Array.from(target.selectedOptions ?? []).length
if (shouldCloseAfterSelection) {
closeSelectSearchMenu(target)
}
}
function selectAllSearchText() {
searchInput.value?.select?.()
}
onMounted(() => {
const input = searchInput.value
if (!input) {
return
}
const select = getSelectFromInput(input)
cleanupMultipleSelectFallback = setupMultipleSelectFallback(select)
cleanupMultipleSelectSelectedContent = setupMultipleSelectSelectedContent(input)
const supportedSelect = getEnabledSelectFromInput(input)
if (supportedSelect) {
input.classList.remove('u-hidden')
return
}
if (isNativeSelectPickerSearchSupported()) {
console.warn("SelectSearch expects a previous sibling <select> with class 'c-select'.")
}
})
onBeforeUnmount(() => {
cleanupMultipleSelectFallback?.()
cleanupMultipleSelectSelectedContent?.()
})
</script>
<template>
<component
:is="searchElement"
class="c-select-search"
@change="clearSearchOnSelectChange"
@focusin="rememberMultipleDropdownSelectionCount"
@focusout="resetSearchString"
>
<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')"
>
<output
ref="multipleValue"
class="c-select-search__multiple-value u-hidden"
aria-hidden="true"
></output>
<span
class="c-select-search__indicator"
aria-hidden="true"
></span>
</component>
</template>