Skip to content

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-model is 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 input and change events.
  • For dropdown-style multiple selects, bind the slotted select to an array and include multiple size="1". The native picker keeps one checkmark per selected option when customizable select support is available.
  • Set close-after-first-multi-selection when a dropdown-style multiple select 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-select plus showPicker(), 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-label becomes 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>