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.

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-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.
  • Arrow up and arrow down move the current selection inside the native select and emit the usual input and change events.
  • 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 { 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>