Combobox
Combobox lets someone search a long list and commit one application value. The editable query is temporary; the chosen customer, repository, assignee, or relationship is the value that persists.
The common path is one component and one option array. There is no required trigger, input, content, or item-component ceremony.
Installation
One command detects Vue, React, or Svelte, installs the framework-native Combobox, and resolves Popover first when it is missing:
Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.
npx klean-ui add combobox- No initializer or configuration file
- No framework, alias, or theme questions
- No Klean runtime dependency
The installation creates no initializer, provider, klean-ui.json, alias questionnaire, generated class helper, or Klean runtime dependency.
Usage
Vue
<script setup>
import { ref } from 'vue'
import Combobox from '@/components/ui/combobox/Combobox.vue'
const project = ref()
const projects = [
{ value: 'slipway', label: 'Slipway', keywords: ['deployments'] },
{ value: 'hagfish', label: 'Hagfish', keywords: ['billing'] }
]
</script>
<template>
<label for="project">Project</label>
<Combobox id="project" v-model="project" name="project" :options="projects" />
</template>
React
import { useState } from 'react'
import Combobox from '@/components/ui/combobox/Combobox.jsx'
const projects = [
{ value: 'slipway', label: 'Slipway', keywords: ['deployments'] },
{ value: 'hagfish', label: 'Hagfish', keywords: ['billing'] }
]
export function ProjectPicker() {
const [project, setProject] = useState()
return (
<>
<label htmlFor="project">Project</label>
<Combobox
id="project"
value={project}
onValueChange={setProject}
name="project"
options={projects}
/>
</>
)
}
Svelte
<script>
import Combobox from '$lib/components/ui/combobox/Combobox.svelte'
let project = $state()
const projects = [
{ value: 'slipway', label: 'Slipway', keywords: ['deployments'] },
{ value: 'hagfish', label: 'Hagfish', keywords: ['billing'] }
]
</script>
<label for="project">Project</label>
<Combobox id="project" bind:value={project} name="project" options={projects} />
The framework binding changes; the value, options, query, and keyboard contract do not.
Which control should I use?
Use Select when the complete list is known, reasonably short, and typing would not help. Use Combobox when a query narrows a long list, searches relationships, or requests suggestions from the server. Use Input when arbitrary text is valid and no option must be chosen.
That is the whole decision. Combobox is not a Select variant because editable and select-only controls have different browser, focus, keyboard, and assistive-technology contracts.
Options and values
The conventional option contract is { value, label, description?, disabled?, group?, keywords? }:
const repositories = [
{
value: 'sailscastshq/slipway',
label: 'sailscastshq/slipway',
description: 'Deploy and operate Sails applications',
keywords: ['hosting', 'deployments'],
group: 'Sailscasts'
}
]label, description, and keywords participate in local matching. keywords provides invisible aliases without changing what people see. disabled leaves an option understandable but removes it from pointer and keyboard selection. group creates one labelled group without adding another component API.
String, number, and boolean values retain their type in application state. A name submits primitive values through an ordinary form. Prefer a stable ID as the value when the visible option represents an object.
Remote search
Combobox emits search after 300 milliseconds when it opens and as the query changes. The opening query is empty, so the application can provide a useful first page before anyone types.
The application owns its URL, credentials, pagination, response shape, and request cancellation. Abort replaced work and pass options, loading, and error back to Combobox:
<script setup>
import { ref } from 'vue'
import Combobox from '@/components/ui/combobox/Combobox.vue'
const repository = ref()
const repositories = ref([])
const loading = ref(false)
const error = ref('')
let request
async function search(query) {
request?.abort()
request = new AbortController()
loading.value = true
error.value = ''
try {
const response = await fetch(
`/api/repositories?q=${encodeURIComponent(query)}`,
{ signal: request.signal }
)
repositories.value = await response.json()
} catch (cause) {
if (cause.name !== 'AbortError')
error.value = 'Could not search repositories.'
} finally {
if (!request.signal.aborted) loading.value = false
}
}
</script>
<template>
<label for="repository">Repository</label>
<Combobox
id="repository"
v-model="repository"
:options="repositories"
:loading="loading"
:error="error"
@search="search"
/>
</template>
Existing results remain usable while loading. Combobox replaces its pending debounce timer; the application cancels its pending request because only the application knows the transport policy.
API
| Purpose | Vue | React | Svelte |
|---|---|---|---|
| Current value | v-model | value, onValueChange | bind:value |
| Initial value | default-value | defaultValue | defaultValue |
| Choices | options | options | options |
| Query | v-model:query | query, onQueryChange | bind:query |
| Search | @search | onSearch | onsearch |
| Request state | loading, error | loading, error | loading, error |
| Form | name, required, disabled, form | same native names | same native names |
| Open state | v-model:open | open, onOpenChange | bind:open |
| Geometry | placement, offset | placement, offset | placement, offset |
| Styling | class | className | class |
searchDelay defaults to 300 milliseconds. The default placement is bottom-start with a four-pixel offset. Geometry may flip or shift to remain visible. Control query or open state only when application behavior genuinely needs to observe that temporary state.
Vue offers option, empty, loading, and error slots. React offers equivalent render functions; Svelte offers equivalent snippets. They change rendering, not selection semantics.
Keyboard and accessibility
- Give Combobox a visible
<label>or another accessible name. - Focus or click opens the choices while DOM focus stays on the real editable input.
- Arrow Down and Arrow Up move between enabled filtered options; Home and End reach the enabled edges.
aria-activedescendantexposes the highlight without moving focus into the popup.- Enter commits the highlighted value once, closes, and restores the selected label.
- Escape abandons an unfinished query without changing the committed value.
- Tab closes and continues through the document normally. Combobox never traps focus.
- Pointer selection prevents an early blur and commits before closing.
- Disabled options remain understandable and are skipped.
Long lists scroll and keep the active option visible. The surface matches at least the input width, flips or shifts at viewport edges, and has no product animation by default.
Styling
class or className merges onto the real editable input, so caller Tailwind wins. There are no variant, tone, size, radius, theme, or part-class props.
Stable data-slot hooks cover the root, control, input, icon, content, listbox, group, option, indicator, empty, loading, and error surfaces. Repeated product treatments belong in application-owned components; the copied source is the final escape hatch.
Durable state
Persist the committed value in a form, URL, storage, or server only when the product needs that durability. Query, open state, and keyboard highlight are temporary by default. Escape and outside dismissal restore the committed label. Unmounting removes observers and pending debounce work.
Related components
- Select — a non-editable choice from a fixed list.
- Input — arbitrary free-form text.
- Popover — ordinary floating content without selection semantics.
- Menu — actions and navigation rather than a persistent value.
- Dialog — a modal task that makes the background inert.
Complete framework source
Vue
<script setup>
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
useAttrs,
useId,
watch
} from 'vue'
import { twMerge } from 'tailwind-merge'
import Popover from '../popover/Popover.vue'
defineOptions({ inheritAttrs: false })
const props = defineProps({
/** Framework-native controlled value. Omit for uncontrolled use. */
modelValue: { default: undefined },
/** Initial value when `modelValue` is not controlled. */
defaultValue: { default: undefined },
/** Choices in the form `{ value, label, description?, disabled?, group?, keywords? }`. */
options: { type: Array, default: () => [] },
/** Framework-native controlled search text. Usually left uncontrolled. */
query: { type: String, default: undefined },
/** Initial search text when `query` is not controlled. */
defaultQuery: { type: String, default: '' },
/** Text shown while no value is selected and the user is not searching. */
placeholder: { type: String, default: 'Search and choose' },
/** Text shown when the current query has no matches. */
emptyText: { type: String, default: 'No matches found.' },
/** Text shown while application-owned results are loading. */
loadingText: { type: String, default: 'Searching…' },
/** Application-owned remote loading state. Existing results remain usable. */
loading: { type: Boolean, default: false },
/** Application-owned remote search error. */
error: { type: String, default: '' },
/** Delay before the application-owned `search` event, in milliseconds. */
searchDelay: { type: Number, default: 300 },
/** Native form field name for the committed value. */
name: { type: String, default: undefined },
/** Accessible required state for the committed application value. */
required: { type: Boolean, default: false },
/** Prevents searching, opening, and selection. */
disabled: { type: Boolean, default: false },
/** Stable input ID. */
id: { type: String, default: undefined },
/** Framework-native controlled popup state. */
open: { type: Boolean, default: undefined },
/** Initial popup state when `open` is not controlled. */
defaultOpen: { type: Boolean, default: false },
/** Preferred logical placement. Collision handling may flip it. */
placement: { type: String, default: 'bottom-start' },
/** Space in pixels between the input and popup. */
offset: { type: Number, default: 4 }
})
const emit = defineEmits([
'update:modelValue',
'update:query',
'update:open',
'change',
'search',
'blur'
])
const attrs = useAttrs()
const generatedId = useId()
const root = ref()
const input = ref()
const popover = ref()
const internalValue = ref(props.defaultValue)
const internalQuery = ref(props.defaultQuery)
const internalOpen = ref(props.defaultOpen)
const highlightedIndex = ref(-1)
const inputWidth = ref(0)
let form
let resizeObserver
let searchTimer
const isValueControlled = computed(() => props.modelValue !== undefined)
const value = computed(() =>
isValueControlled.value ? props.modelValue : internalValue.value
)
const isQueryControlled = computed(() => props.query !== undefined)
const currentQuery = computed(() =>
isQueryControlled.value ? props.query : internalQuery.value
)
const isOpenControlled = computed(() => props.open !== undefined)
const isOpen = computed(() =>
isOpenControlled.value ? props.open : internalOpen.value
)
const controlId = computed(
() =>
props.id ?? `klean-combobox-${generatedId.replace(/[^a-zA-Z0-9_-]/g, '')}`
)
const contentId = computed(() => `${controlId.value}-content`)
const listboxId = computed(() => `${controlId.value}-listbox`)
const selectedIndex = computed(() =>
props.options.findIndex((option) => Object.is(option.value, value.value))
)
const selectedOption = computed(() => props.options[selectedIndex.value])
const visibleValue = computed(() =>
isOpen.value ? currentQuery.value : String(selectedOption.value?.label ?? '')
)
const serializedValue = computed(() => {
const current = value.value
return ['string', 'number', 'boolean'].includes(typeof current)
? String(current)
: ''
})
const inputClasses = computed(() =>
twMerge(
'min-h-11 w-full rounded-md border border-gray-300 bg-white px-3 py-2 pr-10 text-base text-gray-950 shadow-sm outline-none transition-colors duration-150 placeholder:text-gray-500 hover:border-gray-400 focus:border-gray-950 focus:outline-2 focus:outline-offset-2 focus:outline-gray-950 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-500 aria-invalid:border-red-600 aria-invalid:focus:outline-red-600 dark:border-gray-700 dark:bg-gray-950 dark:text-white dark:placeholder:text-gray-400 dark:hover:border-gray-600 dark:focus:border-white dark:focus:outline-white dark:disabled:bg-gray-900 dark:disabled:text-gray-500 dark:aria-invalid:border-red-500 dark:aria-invalid:focus:outline-red-500 motion-reduce:transition-none',
attrs.class
)
)
const forwardedInputAttrs = computed(() => {
const {
class: _class,
style: _style,
id: _id,
role: _role,
type: _type,
value: _value,
name: _name,
required: _required,
disabled: _disabled,
autocomplete: _autocomplete,
'data-slot': _dataSlot,
...rest
} = attrs
return rest
})
function searchableText(option) {
return [option.label, option.description, ...(option.keywords ?? [])]
.filter(Boolean)
.join(' ')
.normalize('NFKD')
.toLocaleLowerCase()
}
const filteredEntries = computed(() => {
const needle = currentQuery.value.trim().normalize('NFKD').toLocaleLowerCase()
return props.options
.map((option, index) => ({ option, index }))
.filter(({ option }) => !needle || searchableText(option).includes(needle))
})
const groupedEntries = computed(() => {
const groups = new Map()
for (const entry of filteredEntries.value) {
const label = entry.option.group ?? null
if (!groups.has(label)) groups.set(label, [])
groups.get(label).push(entry)
}
return [...groups].map(([label, entries]) => ({ label, entries }))
})
const activeDescendant = computed(() =>
isOpen.value && highlightedIndex.value >= 0
? optionId(highlightedIndex.value)
: undefined
)
function optionId(index) {
return `${controlId.value}-option-${index}`
}
function enabledIndexes() {
return filteredEntries.value
.filter(({ option }) => !option.disabled)
.map(({ index }) => index)
}
function initialHighlight(edge = 'selected') {
const enabled = enabledIndexes()
if (!enabled.length) return -1
if (edge === 'selected' && enabled.includes(selectedIndex.value)) {
return selectedIndex.value
}
return edge === 'last' ? enabled.at(-1) : enabled[0]
}
function syncInputWidth() {
inputWidth.value = input.value?.getBoundingClientRect().width ?? 0
}
async function revealHighlighted() {
await nextTick()
if (highlightedIndex.value < 0) return
const content = popover.value?.content?.value ?? popover.value?.content
content
?.querySelector?.(`[data-option-index="${highlightedIndex.value}"]`)
?.scrollIntoView?.({ block: 'nearest' })
}
function setQuery(nextQuery, { search = false } = {}) {
if (!isQueryControlled.value) internalQuery.value = nextQuery
emit('update:query', nextQuery)
clearTimeout(searchTimer)
searchTimer = undefined
if (!search) return
searchTimer = setTimeout(
() => {
emit('search', nextQuery)
searchTimer = undefined
},
Math.max(0, props.searchDelay)
)
}
function requestOpen(nextOpen) {
if (!isOpenControlled.value) internalOpen.value = nextOpen
emit('update:open', nextOpen)
}
function openCombobox(edge = 'first') {
if (props.disabled) return
syncInputWidth()
if (!isOpen.value) {
setQuery('', { search: true })
popover.value?.open(input.value)
} else {
highlightedIndex.value = initialHighlight(edge)
revealHighlighted()
}
}
function closeCombobox({ restoreFocus = false } = {}) {
setQuery('')
popover.value?.close({ restoreFocus })
}
function handlePopoverOpen(nextOpen) {
requestOpen(nextOpen)
if (!nextOpen) setQuery('')
}
function moveHighlight(step) {
const enabled = enabledIndexes()
if (!enabled.length) return
const current = enabled.indexOf(highlightedIndex.value)
const next =
current < 0
? step > 0
? 0
: enabled.length - 1
: (current + step + enabled.length) % enabled.length
highlightedIndex.value = enabled[next]
revealHighlighted()
}
function choose(index) {
const option = props.options[index]
if (!option || option.disabled || props.disabled) return
if (!isValueControlled.value) internalValue.value = option.value
emit('update:modelValue', option.value)
emit('change', option.value, option)
highlightedIndex.value = index
closeCombobox({ restoreFocus: true })
}
function handleInput(event) {
const nextQuery = event.currentTarget.value
if (!isOpen.value) openCombobox()
setQuery(nextQuery, { search: true })
}
function handleKeydown(event) {
if (props.disabled) return
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
if (!isOpen.value) openCombobox(event.key === 'ArrowUp' ? 'last' : 'first')
else moveHighlight(event.key === 'ArrowDown' ? 1 : -1)
} else if (event.key === 'Home' && isOpen.value) {
event.preventDefault()
highlightedIndex.value = initialHighlight('first')
revealHighlighted()
} else if (event.key === 'End' && isOpen.value) {
event.preventDefault()
highlightedIndex.value = initialHighlight('last')
revealHighlighted()
} else if (event.key === 'Enter' && isOpen.value) {
event.preventDefault()
if (highlightedIndex.value >= 0) choose(highlightedIndex.value)
} else if (event.key === 'Escape' && isOpen.value) {
event.preventDefault()
event.stopPropagation()
closeCombobox({ restoreFocus: true })
} else if (event.key === 'Tab' && isOpen.value) {
closeCombobox()
}
}
function handleFormReset() {
clearTimeout(searchTimer)
if (!isValueControlled.value) internalValue.value = props.defaultValue
setQuery(props.defaultQuery)
if (isOpen.value) closeCombobox()
}
watch(
isOpen,
async (nextOpen) => {
if (!nextOpen) {
highlightedIndex.value = -1
return
}
highlightedIndex.value = initialHighlight('selected')
syncInputWidth()
await revealHighlighted()
},
{ flush: 'post' }
)
watch(filteredEntries, () => {
if (!isOpen.value) return
const enabled = enabledIndexes()
if (!enabled.includes(highlightedIndex.value)) {
highlightedIndex.value = enabled[0] ?? -1
}
revealHighlighted()
})
onMounted(() => {
form = root.value?.closest?.('form')
form?.addEventListener('reset', handleFormReset)
if (typeof ResizeObserver !== 'undefined' && input.value) {
resizeObserver = new ResizeObserver(syncInputWidth)
resizeObserver.observe(input.value)
}
syncInputWidth()
})
onBeforeUnmount(() => {
clearTimeout(searchTimer)
resizeObserver?.disconnect()
form?.removeEventListener('reset', handleFormReset)
})
defineExpose({
close: closeCombobox,
focus: (options) => input.value?.focus(options),
input,
open: openCombobox
})
</script>
<template>
<span
ref="root"
data-slot="combobox"
:data-state="isOpen ? 'open' : 'closed'"
:data-disabled="disabled ? '' : undefined"
:data-invalid="
attrs['aria-invalid'] === true || attrs['aria-invalid'] === 'true'
? ''
: undefined
"
class="relative grid w-full"
>
<span data-slot="combobox-control" class="relative grid">
<input
ref="input"
v-bind="forwardedInputAttrs"
:id="controlId"
type="text"
role="combobox"
autocomplete="off"
:disabled="disabled"
:value="visibleValue"
:placeholder="placeholder"
:popovertarget="contentId"
popovertargetaction="show"
:aria-expanded="String(isOpen)"
:aria-controls="listboxId"
aria-haspopup="listbox"
aria-autocomplete="list"
:aria-required="required || undefined"
:aria-activedescendant="activeDescendant"
data-slot="combobox-input"
:data-state="isOpen ? 'open' : 'closed'"
:class="inputClasses"
:style="attrs.style"
@focus="openCombobox('selected')"
@click="openCombobox('selected')"
@input="handleInput"
@keydown="handleKeydown"
@blur="emit('blur', $event)"
/>
<span
data-slot="combobox-icon"
class="pointer-events-none absolute inset-y-0 right-3 grid place-items-center text-gray-500 dark:text-gray-400"
aria-hidden="true"
>
<svg
v-if="loading"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="1.8"
class="size-4 animate-spin motion-reduce:animate-none"
>
<path d="M17 10a7 7 0 1 1-2.05-4.95" stroke-linecap="round" />
</svg>
<svg
v-else
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="1.8"
class="size-4"
>
<path
d="m6 8 4 4 4-4"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
</span>
<input
v-if="name"
type="hidden"
:name="name"
:value="serializedValue"
:disabled="disabled"
:form="attrs.form"
/>
<Popover
ref="popover"
:id="contentId"
:open="isOpen"
:placement="placement"
:offset="offset"
data-slot="combobox-content"
class="max-h-80 overflow-hidden p-1"
:style="inputWidth ? { minWidth: `${inputWidth}px` } : undefined"
@update:open="handlePopoverOpen"
>
<div
:id="listboxId"
role="listbox"
:aria-labelledby="
attrs['aria-label']
? undefined
: (attrs['aria-labelledby'] ?? controlId)
"
:aria-label="
attrs['aria-label'] ? `${attrs['aria-label']} options` : undefined
"
:aria-busy="loading || undefined"
data-slot="combobox-listbox"
class="max-h-76 overflow-y-auto overscroll-contain outline-none"
>
<div
v-if="error"
role="status"
data-slot="combobox-error"
class="px-3 py-2 text-sm text-red-700 dark:text-red-400"
>
<slot name="error" :error="error">{{ error }}</slot>
</div>
<template v-if="filteredEntries.length">
<div
v-for="(group, groupIndex) in groupedEntries"
:key="group.label ?? `ungrouped-${groupIndex}`"
:role="group.label ? 'group' : undefined"
:aria-label="group.label || undefined"
data-slot="combobox-group"
>
<p
v-if="group.label"
data-slot="combobox-group-label"
class="px-3 py-2 text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{ group.label }}
</p>
<div
v-for="{ option, index } in group.entries"
:id="optionId(index)"
:key="index"
role="option"
:aria-selected="String(index === selectedIndex)"
:aria-disabled="option.disabled || undefined"
data-slot="combobox-option"
:data-option-index="index"
:data-highlighted="index === highlightedIndex ? '' : undefined"
:data-selected="index === selectedIndex ? '' : undefined"
:data-disabled="option.disabled ? '' : undefined"
class="flex min-h-11 cursor-pointer items-center justify-between gap-3 rounded px-3 py-2 text-sm text-gray-700 outline-none data-highlighted:bg-gray-100 data-highlighted:text-gray-950 data-disabled:cursor-not-allowed data-disabled:opacity-40 dark:text-gray-200 dark:data-highlighted:bg-white/10 dark:data-highlighted:text-white"
@pointermove="!option.disabled && (highlightedIndex = index)"
@pointerdown.prevent
@click="choose(index)"
>
<span class="min-w-0 flex-1">
<slot
name="option"
:option="option"
:selected="index === selectedIndex"
:highlighted="index === highlightedIndex"
>
<span class="block truncate">{{ option.label }}</span>
<span
v-if="option.description"
class="mt-0.5 block truncate text-xs text-gray-500 dark:text-gray-400"
>
{{ option.description }}
</span>
</slot>
</span>
<svg
v-if="index === selectedIndex"
data-slot="combobox-indicator"
aria-hidden="true"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="2"
class="size-4 shrink-0"
>
<path
d="m5 10 3 3 7-7"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</div>
</template>
<div
v-else-if="!loading"
data-slot="combobox-empty"
class="px-3 py-6 text-center text-sm text-gray-500 dark:text-gray-400"
>
<slot name="empty" :query="currentQuery">{{ emptyText }}</slot>
</div>
<div
v-if="loading"
role="status"
data-slot="combobox-loading"
class="px-3 py-2 text-sm text-gray-500 dark:text-gray-400"
>
<slot name="loading">{{ loadingText }}</slot>
</div>
</div>
</Popover>
</span>
</template>
React
import {
forwardRef,
useCallback,
useEffect,
useId,
useImperativeHandle,
useMemo,
useRef,
useState
} from 'react'
import { twMerge } from 'tailwind-merge'
import Popover from '../popover/Popover.jsx'
const INPUT_CLASSES =
'min-h-11 w-full rounded-md border border-gray-300 bg-white px-3 py-2 pr-10 text-base text-gray-950 shadow-sm outline-none transition-colors duration-150 placeholder:text-gray-500 hover:border-gray-400 focus:border-gray-950 focus:outline-2 focus:outline-offset-2 focus:outline-gray-950 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-500 aria-invalid:border-red-600 aria-invalid:focus:outline-red-600 dark:border-gray-700 dark:bg-gray-950 dark:text-white dark:placeholder:text-gray-400 dark:hover:border-gray-600 dark:focus:border-white dark:focus:outline-white dark:disabled:bg-gray-900 dark:disabled:text-gray-500 dark:aria-invalid:border-red-500 dark:aria-invalid:focus:outline-red-500 motion-reduce:transition-none'
function serializedValue(value) {
return ['string', 'number', 'boolean'].includes(typeof value)
? String(value)
: ''
}
function searchableText(option) {
return [option.label, option.description, ...(option.keywords ?? [])]
.filter(Boolean)
.join(' ')
.normalize('NFKD')
.toLocaleLowerCase()
}
const Combobox = forwardRef(function Combobox(
{
value: controlledValue,
defaultValue,
options = [],
query: controlledQuery,
defaultQuery = '',
placeholder = 'Search and choose',
emptyText = 'No matches found.',
loadingText = 'Searching…',
loading = false,
error = '',
searchDelay = 300,
name,
required = false,
disabled = false,
id,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
onValueChange,
onQueryChange,
onChange,
onSearch,
placement = 'bottom-start',
offset = 4,
className,
style,
renderOption,
renderEmpty,
renderLoading,
renderError,
onFocus,
onClick,
onInput,
onKeyDown,
onBlur,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
'aria-invalid': ariaInvalid,
'aria-describedby': ariaDescribedby,
form,
...inputProps
},
forwardedRef
) {
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
const controlId = id ?? `klean-combobox-${generatedId}`
const contentId = `${controlId}-content`
const listboxId = `${controlId}-listbox`
const rootRef = useRef(null)
const inputRef = useRef(null)
const popoverRef = useRef(null)
const searchTimer = useRef()
const [internalValue, setInternalValue] = useState(defaultValue)
const [internalQuery, setInternalQuery] = useState(defaultQuery)
const [internalOpen, setInternalOpen] = useState(defaultOpen)
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const [inputWidth, setInputWidth] = useState(0)
const isValueControlled = controlledValue !== undefined
const currentValue = isValueControlled ? controlledValue : internalValue
const isQueryControlled = controlledQuery !== undefined
const currentQuery = isQueryControlled ? controlledQuery : internalQuery
const isOpenControlled = controlledOpen !== undefined
const isOpen = isOpenControlled ? controlledOpen : internalOpen
const selectedIndex = options.findIndex((option) =>
Object.is(option.value, currentValue)
)
const selectedOption = options[selectedIndex]
const visibleValue = isOpen
? currentQuery
: String(selectedOption?.label ?? '')
const filteredEntries = useMemo(() => {
const needle = currentQuery.trim().normalize('NFKD').toLocaleLowerCase()
return options
.map((option, index) => ({ option, index }))
.filter(
({ option }) => !needle || searchableText(option).includes(needle)
)
}, [currentQuery, options])
const groupedEntries = useMemo(() => {
const groups = new Map()
for (const entry of filteredEntries) {
const label = entry.option.group ?? null
if (!groups.has(label)) groups.set(label, [])
groups.get(label).push(entry)
}
return [...groups].map(([label, entries]) => ({ label, entries }))
}, [filteredEntries])
const activeDescendant =
isOpen && highlightedIndex >= 0
? `${controlId}-option-${highlightedIndex}`
: undefined
const enabledIndexes = useCallback(
() =>
filteredEntries
.filter(({ option }) => !option.disabled)
.map(({ index }) => index),
[filteredEntries]
)
const initialHighlight = useCallback(
(edge = 'selected') => {
const enabled = enabledIndexes()
if (!enabled.length) return -1
if (edge === 'selected' && enabled.includes(selectedIndex)) {
return selectedIndex
}
return edge === 'last' ? enabled.at(-1) : enabled[0]
},
[enabledIndexes, selectedIndex]
)
const revealHighlighted = useCallback((index) => {
if (index < 0) return
queueMicrotask(() => {
popoverRef.current?.content
?.querySelector?.(`[data-option-index="${index}"]`)
?.scrollIntoView?.({ block: 'nearest' })
})
}, [])
const syncInputWidth = useCallback(() => {
setInputWidth(inputRef.current?.getBoundingClientRect().width ?? 0)
}, [])
const setQuery = useCallback(
(nextQuery, { search = false } = {}) => {
if (!isQueryControlled) setInternalQuery(nextQuery)
onQueryChange?.(nextQuery)
clearTimeout(searchTimer.current)
searchTimer.current = undefined
if (!search) return
searchTimer.current = setTimeout(
() => {
onSearch?.(nextQuery)
searchTimer.current = undefined
},
Math.max(0, searchDelay)
)
},
[isQueryControlled, onQueryChange, onSearch, searchDelay]
)
const requestOpen = useCallback(
(nextOpen) => {
if (!isOpenControlled) setInternalOpen(nextOpen)
onOpenChange?.(nextOpen)
},
[isOpenControlled, onOpenChange]
)
const openCombobox = useCallback(
(edge = 'first') => {
if (disabled) return
syncInputWidth()
if (!isOpen) {
setQuery('', { search: true })
popoverRef.current?.open(inputRef.current)
} else {
const next = initialHighlight(edge)
setHighlightedIndex(next)
revealHighlighted(next)
}
},
[
disabled,
initialHighlight,
isOpen,
revealHighlighted,
setQuery,
syncInputWidth
]
)
const closeCombobox = useCallback(
({ restoreFocus = false } = {}) => {
setQuery('')
popoverRef.current?.close({ restoreFocus })
},
[setQuery]
)
const moveHighlight = useCallback(
(step) => {
const enabled = enabledIndexes()
if (!enabled.length) return
const current = enabled.indexOf(highlightedIndex)
const position =
current < 0
? step > 0
? 0
: enabled.length - 1
: (current + step + enabled.length) % enabled.length
const next = enabled[position]
setHighlightedIndex(next)
revealHighlighted(next)
},
[enabledIndexes, highlightedIndex, revealHighlighted]
)
const choose = useCallback(
(index) => {
const option = options[index]
if (!option || option.disabled || disabled) return
if (!isValueControlled) setInternalValue(option.value)
onValueChange?.(option.value, option)
onChange?.(option.value, option)
setHighlightedIndex(index)
closeCombobox({ restoreFocus: true })
},
[
closeCombobox,
disabled,
isValueControlled,
onChange,
onValueChange,
options
]
)
useImperativeHandle(
forwardedRef,
() => ({
close: closeCombobox,
focus: (options) => inputRef.current?.focus(options),
input: inputRef.current,
open: openCombobox
}),
[closeCombobox, openCombobox]
)
useEffect(() => {
if (!isOpen) {
setHighlightedIndex(-1)
return
}
const next = initialHighlight('selected')
setHighlightedIndex(next)
syncInputWidth()
revealHighlighted(next)
}, [isOpen, initialHighlight, revealHighlighted, syncInputWidth])
useEffect(() => {
if (!isOpen) return
const enabled = enabledIndexes()
if (!enabled.includes(highlightedIndex)) {
const next = enabled[0] ?? -1
setHighlightedIndex(next)
revealHighlighted(next)
}
}, [enabledIndexes, highlightedIndex, isOpen, revealHighlighted])
useEffect(() => {
const node = inputRef.current
const parentForm = rootRef.current?.closest?.('form')
const handleReset = () => {
clearTimeout(searchTimer.current)
if (!isValueControlled) setInternalValue(defaultValue)
setQuery(defaultQuery)
if (isOpen) closeCombobox()
}
parentForm?.addEventListener('reset', handleReset)
const observer =
typeof ResizeObserver !== 'undefined' && node
? new ResizeObserver(syncInputWidth)
: undefined
observer?.observe(node)
syncInputWidth()
return () => {
clearTimeout(searchTimer.current)
observer?.disconnect()
parentForm?.removeEventListener('reset', handleReset)
}
}, [
closeCombobox,
defaultQuery,
defaultValue,
isOpen,
isValueControlled,
setQuery,
syncInputWidth
])
function handleInput(event) {
onInput?.(event)
if (event.defaultPrevented) return
if (!isOpen) openCombobox()
setQuery(event.currentTarget.value, { search: true })
}
function handleKeydown(event) {
onKeyDown?.(event)
if (event.defaultPrevented || disabled) return
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
if (!isOpen) openCombobox(event.key === 'ArrowUp' ? 'last' : 'first')
else moveHighlight(event.key === 'ArrowDown' ? 1 : -1)
} else if (event.key === 'Home' && isOpen) {
event.preventDefault()
const next = initialHighlight('first')
setHighlightedIndex(next)
revealHighlighted(next)
} else if (event.key === 'End' && isOpen) {
event.preventDefault()
const next = initialHighlight('last')
setHighlightedIndex(next)
revealHighlighted(next)
} else if (event.key === 'Enter' && isOpen) {
event.preventDefault()
if (highlightedIndex >= 0) choose(highlightedIndex)
} else if (event.key === 'Escape' && isOpen) {
event.preventDefault()
event.stopPropagation()
closeCombobox({ restoreFocus: true })
} else if (event.key === 'Tab' && isOpen) {
closeCombobox()
}
}
function handlePopoverOpen(nextOpen) {
requestOpen(nextOpen)
if (!nextOpen) setQuery('')
}
return (
<span
ref={rootRef}
data-slot="combobox"
data-state={isOpen ? 'open' : 'closed'}
data-disabled={disabled ? '' : undefined}
data-invalid={
ariaInvalid === true || ariaInvalid === 'true' ? '' : undefined
}
className="relative grid w-full"
>
<span data-slot="combobox-control" className="relative grid">
<input
{...inputProps}
ref={inputRef}
id={controlId}
type="text"
role="combobox"
autoComplete="off"
disabled={disabled}
value={visibleValue}
placeholder={placeholder}
popovertarget={contentId}
popovertargetaction="show"
aria-expanded={isOpen}
aria-controls={listboxId}
aria-haspopup="listbox"
aria-autocomplete="list"
aria-activedescendant={activeDescendant}
aria-required={required || undefined}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-invalid={ariaInvalid}
aria-describedby={ariaDescribedby}
data-slot="combobox-input"
data-state={isOpen ? 'open' : 'closed'}
className={twMerge(INPUT_CLASSES, className)}
style={style}
onFocus={(event) => {
onFocus?.(event)
if (!event.defaultPrevented) openCombobox('selected')
}}
onClick={(event) => {
onClick?.(event)
if (!event.defaultPrevented) openCombobox('selected')
}}
onInput={handleInput}
onKeyDown={handleKeydown}
onBlur={onBlur}
/>
<span
data-slot="combobox-icon"
className="pointer-events-none absolute inset-y-0 right-3 grid place-items-center text-gray-500 dark:text-gray-400"
aria-hidden="true"
>
{loading ? (
<svg
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
className="size-4 animate-spin motion-reduce:animate-none"
>
<path d="M17 10a7 7 0 1 1-2.05-4.95" strokeLinecap="round" />
</svg>
) : (
<svg
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
className="size-4"
>
<path
d="m6 8 4 4 4-4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</span>
</span>
{name ? (
<input
type="hidden"
name={name}
value={serializedValue(currentValue)}
disabled={disabled}
form={form}
/>
) : null}
<Popover
ref={popoverRef}
id={contentId}
open={isOpen}
onOpenChange={handlePopoverOpen}
placement={placement}
offset={offset}
data-slot="combobox-content"
className="max-h-80 overflow-hidden p-1"
style={inputWidth ? { minWidth: inputWidth } : undefined}
>
<div
id={listboxId}
role="listbox"
aria-labelledby={
ariaLabel ? undefined : (ariaLabelledby ?? controlId)
}
aria-label={ariaLabel ? `${ariaLabel} options` : undefined}
aria-busy={loading || undefined}
data-slot="combobox-listbox"
className="max-h-76 overflow-y-auto overscroll-contain outline-none"
>
{error ? (
<div
role="status"
data-slot="combobox-error"
className="px-3 py-2 text-sm text-red-700 dark:text-red-400"
>
{renderError?.(error) ?? error}
</div>
) : null}
{groupedEntries.map((group, groupIndex) => (
<div
key={group.label ?? `ungrouped-${groupIndex}`}
role={group.label ? 'group' : undefined}
aria-label={group.label || undefined}
data-slot="combobox-group"
>
{group.label ? (
<p
data-slot="combobox-group-label"
className="px-3 py-2 text-xs font-medium text-gray-500 dark:text-gray-400"
>
{group.label}
</p>
) : null}
{group.entries.map(({ option, index }) => (
<div
id={`${controlId}-option-${index}`}
key={index}
role="option"
aria-selected={index === selectedIndex}
aria-disabled={option.disabled || undefined}
data-slot="combobox-option"
data-option-index={index}
data-highlighted={index === highlightedIndex ? '' : undefined}
data-selected={index === selectedIndex ? '' : undefined}
data-disabled={option.disabled ? '' : undefined}
className="flex min-h-11 cursor-pointer items-center justify-between gap-3 rounded px-3 py-2 text-sm text-gray-700 outline-none data-highlighted:bg-gray-100 data-highlighted:text-gray-950 data-disabled:cursor-not-allowed data-disabled:opacity-40 dark:text-gray-200 dark:data-highlighted:bg-white/10 dark:data-highlighted:text-white"
onPointerMove={() =>
!option.disabled && setHighlightedIndex(index)
}
onPointerDown={(event) => event.preventDefault()}
onClick={() => choose(index)}
>
<span className="min-w-0 flex-1">
{renderOption ? (
renderOption(option, {
selected: index === selectedIndex,
highlighted: index === highlightedIndex
})
) : (
<>
<span className="block truncate">{option.label}</span>
{option.description ? (
<span className="mt-0.5 block truncate text-xs text-gray-500 dark:text-gray-400">
{option.description}
</span>
) : null}
</>
)}
</span>
{index === selectedIndex ? (
<svg
data-slot="combobox-indicator"
aria-hidden="true"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="size-4 shrink-0"
>
<path
d="m5 10 3 3 7-7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : null}
</div>
))}
</div>
))}
{!filteredEntries.length && !loading ? (
<div
data-slot="combobox-empty"
className="px-3 py-6 text-center text-sm text-gray-500 dark:text-gray-400"
>
{renderEmpty?.(currentQuery) ?? emptyText}
</div>
) : null}
{loading ? (
<div
role="status"
data-slot="combobox-loading"
className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400"
>
{renderLoading?.() ?? loadingText}
</div>
) : null}
</div>
</Popover>
</span>
)
})
export default Combobox
Svelte
<script>
import { onMount, untrack } from "svelte";
import { twMerge } from "tailwind-merge";
import Popover from "../popover/Popover.svelte";
let {
value = $bindable(),
defaultValue,
options = [],
query = $bindable(),
defaultQuery = "",
placeholder = "Search and choose",
emptyText = "No matches found.",
loadingText = "Searching…",
loading = false,
error = "",
searchDelay = 300,
name,
required = false,
disabled = false,
id,
open = $bindable(),
defaultOpen = false,
onopenchange,
onchange,
onquerychange,
onsearch,
placement = "bottom-start",
offset = 4,
class: className = "",
style,
optionContent,
empty,
loadingContent,
errorContent,
onfocus,
onclick,
oninput,
onkeydown,
onblur,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledby,
"aria-invalid": ariaInvalid,
"aria-describedby": ariaDescribedby,
...inputProps
} = $props();
const componentIdentity = $props.id();
const componentId = componentIdentity.replace(/[^a-zA-Z0-9_-]/g, "");
let internalValue = $state(untrack(() => defaultValue));
let internalQuery = $state(untrack(() => defaultQuery));
let internalOpen = $state(untrack(() => defaultOpen));
let currentValue = $derived(value !== undefined ? value : internalValue);
let currentQuery = $derived(query !== undefined ? query : internalQuery);
let isOpen = $derived(open !== undefined ? open : internalOpen);
let controlId = $derived(id ?? `klean-combobox-${componentId}`);
let contentId = $derived(`${controlId}-content`);
let listboxId = $derived(`${controlId}-listbox`);
let selectedIndex = $derived(
options.findIndex((option) => Object.is(option.value, currentValue)),
);
let selectedOption = $derived(options[selectedIndex]);
let visibleValue = $derived(
isOpen ? currentQuery : String(selectedOption?.label ?? ""),
);
let filteredEntries = $derived.by(() => {
const needle = currentQuery.trim().normalize("NFKD").toLocaleLowerCase();
return options
.map((option, index) => ({ option, index }))
.filter(
({ option }) => !needle || searchableText(option).includes(needle),
);
});
let groups = $derived.by(() => {
const result = new Map();
for (const entry of filteredEntries) {
const label = entry.option.group ?? null;
if (!result.has(label)) result.set(label, []);
result.get(label).push(entry);
}
return [...result].map(([label, entries]) => ({ label, entries }));
});
let activeDescendant = $derived(
isOpen && highlightedIndex >= 0
? `${controlId}-option-${highlightedIndex}`
: undefined,
);
let root;
let input;
let popover;
let highlightedIndex = $state(-1);
let inputWidth = $state(0);
let searchTimer;
function searchableText(option) {
return [option.label, option.description, ...(option.keywords ?? [])]
.filter(Boolean)
.join(" ")
.normalize("NFKD")
.toLocaleLowerCase();
}
function enabledIndexes() {
return filteredEntries
.filter(({ option }) => !option.disabled)
.map(({ index }) => index);
}
function initialHighlight(edge = "selected") {
const enabled = enabledIndexes();
if (!enabled.length) return -1;
if (edge === "selected" && enabled.includes(selectedIndex)) {
return selectedIndex;
}
return edge === "last" ? enabled.at(-1) : enabled[0];
}
function syncInputWidth() {
inputWidth = input?.getBoundingClientRect().width ?? 0;
}
function revealHighlighted(index = highlightedIndex) {
if (index < 0) return;
queueMicrotask(() => {
popover
?.getContent?.()
?.querySelector?.(`[data-option-index="${index}"]`)
?.scrollIntoView?.({ block: "nearest" });
});
}
function setQuery(nextQuery, { search = false } = {}) {
internalQuery = nextQuery;
query = nextQuery;
onquerychange?.(nextQuery);
clearTimeout(searchTimer);
searchTimer = undefined;
if (!search) return;
searchTimer = setTimeout(
() => {
onsearch?.(nextQuery);
searchTimer = undefined;
},
Math.max(0, searchDelay),
);
}
function requestOpen(nextOpen) {
if (open === undefined) internalOpen = nextOpen;
else open = nextOpen;
onopenchange?.(nextOpen);
}
export function show(edge = "first") {
if (disabled) return;
syncInputWidth();
if (!isOpen) {
setQuery("", { search: true });
popover?.show(input);
} else {
highlightedIndex = initialHighlight(edge);
revealHighlighted();
}
}
export function close({ restoreFocus = false } = {}) {
setQuery("");
popover?.close({ restoreFocus });
}
export function focus(options) {
input?.focus(options);
}
function handlePopoverOpen(nextOpen) {
requestOpen(nextOpen);
if (!nextOpen) setQuery("");
}
function moveHighlight(step) {
const enabled = enabledIndexes();
if (!enabled.length) return;
const current = enabled.indexOf(highlightedIndex);
const position =
current < 0
? step > 0
? 0
: enabled.length - 1
: (current + step + enabled.length) % enabled.length;
highlightedIndex = enabled[position];
revealHighlighted();
}
function choose(index) {
const option = options[index];
if (!option || option.disabled || disabled) return;
internalValue = option.value;
value = option.value;
onchange?.(option.value, option);
highlightedIndex = index;
close({ restoreFocus: true });
}
function handleInput(event) {
oninput?.(event);
if (event.defaultPrevented) return;
if (!isOpen) show();
setQuery(event.currentTarget.value, { search: true });
}
function handleKeydown(event) {
onkeydown?.(event);
if (event.defaultPrevented || disabled) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (!isOpen) show(event.key === "ArrowUp" ? "last" : "first");
else moveHighlight(event.key === "ArrowDown" ? 1 : -1);
} else if (event.key === "Home" && isOpen) {
event.preventDefault();
highlightedIndex = initialHighlight("first");
revealHighlighted();
} else if (event.key === "End" && isOpen) {
event.preventDefault();
highlightedIndex = initialHighlight("last");
revealHighlighted();
} else if (event.key === "Enter" && isOpen) {
event.preventDefault();
if (highlightedIndex >= 0) choose(highlightedIndex);
} else if (event.key === "Escape" && isOpen) {
event.preventDefault();
event.stopPropagation();
close({ restoreFocus: true });
} else if (event.key === "Tab" && isOpen) {
close();
}
}
$effect(() => {
if (isOpen) {
const next = initialHighlight("selected");
highlightedIndex = next;
syncInputWidth();
revealHighlighted(next);
} else {
highlightedIndex = -1;
}
});
$effect(() => {
filteredEntries;
if (!isOpen) return;
const enabled = enabledIndexes();
if (!enabled.includes(highlightedIndex)) {
highlightedIndex = enabled[0] ?? -1;
revealHighlighted();
}
});
onMount(() => {
const formElement = root?.closest?.("form");
const handleReset = () => {
clearTimeout(searchTimer);
internalValue = defaultValue;
value = defaultValue;
setQuery(defaultQuery);
if (isOpen) close();
};
formElement?.addEventListener("reset", handleReset);
const observer =
typeof ResizeObserver !== "undefined" && input
? new ResizeObserver(syncInputWidth)
: undefined;
observer?.observe(input);
syncInputWidth();
return () => {
clearTimeout(searchTimer);
observer?.disconnect();
formElement?.removeEventListener("reset", handleReset);
};
});
</script>
<span
bind:this={root}
data-slot="combobox"
data-state={isOpen ? "open" : "closed"}
data-disabled={disabled ? "" : undefined}
data-invalid={ariaInvalid === true || ariaInvalid === "true" ? "" : undefined}
class="relative grid w-full"
>
<span data-slot="combobox-control" class="relative grid">
<input
{...inputProps}
bind:this={input}
id={controlId}
type="text"
role="combobox"
autocomplete="off"
{disabled}
value={visibleValue}
{placeholder}
popovertarget={contentId}
popovertargetaction="show"
aria-expanded={String(isOpen)}
aria-controls={listboxId}
aria-haspopup="listbox"
aria-autocomplete="list"
aria-activedescendant={activeDescendant}
aria-required={required || undefined}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-invalid={ariaInvalid}
aria-describedby={ariaDescribedby}
data-slot="combobox-input"
data-state={isOpen ? "open" : "closed"}
class={twMerge(
"min-h-11 w-full rounded-md border border-gray-300 bg-white px-3 py-2 pr-10 text-base text-gray-950 shadow-sm outline-none transition-colors duration-150 placeholder:text-gray-500 hover:border-gray-400 focus:border-gray-950 focus:outline-2 focus:outline-offset-2 focus:outline-gray-950 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-500 aria-invalid:border-red-600 aria-invalid:focus:outline-red-600 dark:border-gray-700 dark:bg-gray-950 dark:text-white dark:placeholder:text-gray-400 dark:hover:border-gray-600 dark:focus:border-white dark:focus:outline-white dark:disabled:bg-gray-900 dark:disabled:text-gray-500 dark:aria-invalid:border-red-500 dark:aria-invalid:focus:outline-red-500 motion-reduce:transition-none",
className,
)}
{style}
onfocus={(event) => {
onfocus?.(event);
if (!event.defaultPrevented) show("selected");
}}
onclick={(event) => {
onclick?.(event);
if (!event.defaultPrevented) show("selected");
}}
oninput={handleInput}
onkeydown={handleKeydown}
{onblur}
/>
<span
data-slot="combobox-icon"
class="pointer-events-none absolute inset-y-0 right-3 grid place-items-center text-gray-500 dark:text-gray-400"
aria-hidden="true"
>
{#if loading}
<svg
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="1.8"
class="size-4 animate-spin motion-reduce:animate-none"
>
<path d="M17 10a7 7 0 1 1-2.05-4.95" stroke-linecap="round" />
</svg>
{:else}
<svg
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="1.8"
class="size-4"
>
<path
d="m6 8 4 4 4-4"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{/if}
</span>
</span>
{#if name}
<input
type="hidden"
{name}
value={["string", "number", "boolean"].includes(typeof currentValue)
? String(currentValue)
: ""}
{disabled}
form={inputProps.form}
/>
{/if}
<Popover
bind:this={popover}
id={contentId}
open={isOpen}
{placement}
{offset}
data-slot="combobox-content"
class="max-h-80 overflow-hidden p-1"
style={inputWidth ? { minWidth: `${inputWidth}px` } : undefined}
onOpenChange={handlePopoverOpen}
>
<div
id={listboxId}
role="listbox"
aria-labelledby={ariaLabel ? undefined : (ariaLabelledby ?? controlId)}
aria-label={ariaLabel ? `${ariaLabel} options` : undefined}
aria-busy={loading || undefined}
data-slot="combobox-listbox"
class="max-h-76 overflow-y-auto overscroll-contain outline-none"
>
{#if error}
<div
role="status"
data-slot="combobox-error"
class="px-3 py-2 text-sm text-red-700 dark:text-red-400"
>
{#if errorContent}{@render errorContent(error)}{:else}{error}{/if}
</div>
{/if}
{#each groups as group, groupIndex (group.label ?? `ungrouped-${groupIndex}`)}
<div
role={group.label ? "group" : undefined}
aria-label={group.label || undefined}
data-slot="combobox-group"
>
{#if group.label}
<p
data-slot="combobox-group-label"
class="px-3 py-2 text-xs font-medium text-gray-500 dark:text-gray-400"
>
{group.label}
</p>
{/if}
{#each group.entries as { option, index } (index)}
<div
id={`${controlId}-option-${index}`}
role="option"
tabindex="-1"
aria-selected={String(index === selectedIndex)}
aria-disabled={option.disabled || undefined}
data-slot="combobox-option"
data-option-index={index}
data-highlighted={index === highlightedIndex ? "" : undefined}
data-selected={index === selectedIndex ? "" : undefined}
data-disabled={option.disabled ? "" : undefined}
class="flex min-h-11 cursor-pointer items-center justify-between gap-3 rounded px-3 py-2 text-sm text-gray-700 outline-none data-highlighted:bg-gray-100 data-highlighted:text-gray-950 data-disabled:cursor-not-allowed data-disabled:opacity-40 dark:text-gray-200 dark:data-highlighted:bg-white/10 dark:data-highlighted:text-white"
onpointermove={() => {
if (!option.disabled) highlightedIndex = index;
}}
onpointerdown={(event) => event.preventDefault()}
onclick={() => choose(index)}
onkeydown={handleKeydown}
>
<span class="min-w-0 flex-1">
{#if optionContent}
{@render optionContent(option, {
selected: index === selectedIndex,
highlighted: index === highlightedIndex,
})}
{:else}
<span class="block truncate">{option.label}</span>
{#if option.description}
<span
class="mt-0.5 block truncate text-xs text-gray-500 dark:text-gray-400"
>{option.description}</span
>
{/if}
{/if}
</span>
{#if index === selectedIndex}
<svg
data-slot="combobox-indicator"
aria-hidden="true"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="2"
class="size-4 shrink-0"
>
<path
d="m5 10 3 3 7-7"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{/if}
</div>
{/each}
</div>
{/each}
{#if !filteredEntries.length && !loading}
<div
data-slot="combobox-empty"
class="px-3 py-6 text-center text-sm text-gray-500 dark:text-gray-400"
>
{#if empty}{@render empty(currentQuery)}{:else}{emptyText}{/if}
</div>
{/if}
{#if loading}
<div
role="status"
data-slot="combobox-loading"
class="px-3 py-2 text-sm text-gray-500 dark:text-gray-400"
>
{#if loadingContent}{@render loadingContent()}{:else}{loadingText}{/if}
</div>
{/if}
</div>
</Popover>
</span>