Select
Select chooses one persistent value from a known list. It keeps string, number, and boolean values typed in application state, works with ordinary forms, and gives pointer, touch, keyboard, and assistive-technology users the same outcome.
The common path is one component and one option array. There is no required SelectTrigger, SelectValue, SelectContent, or SelectItem ceremony.
Installation
One command detects Vue, React, or Svelte, installs the framework-native Select, 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 select- 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 Select from '@/components/ui/select/Select.vue'
const role = ref('viewer')
const roles = [
{ value: 'viewer', label: 'Viewer' },
{ value: 'editor', label: 'Editor' },
{ value: 'administrator', label: 'Administrator' }
]
</script>
<template>
<label for="member-role">Member role</label>
<Select id="member-role" v-model="role" name="role" :options="roles" />
</template>
React
import { useState } from 'react'
import Select from '@/components/ui/select/Select.jsx'
const roles = [
{ value: 'viewer', label: 'Viewer' },
{ value: 'editor', label: 'Editor' },
{ value: 'administrator', label: 'Administrator' }
]
export function MemberRole() {
const [role, setRole] = useState('viewer')
return (
<>
<label id="member-role-label" htmlFor="member-role">
Member role
</label>
<Select
id="member-role"
aria-labelledby="member-role-label"
value={role}
onValueChange={setRole}
name="role"
options={roles}
/>
</>
)
}
Svelte
<script>
import Select from '$lib/components/ui/select/Select.svelte'
let role = $state('viewer')
const roles = [
{ value: 'viewer', label: 'Viewer' },
{ value: 'editor', label: 'Editor' },
{ value: 'administrator', label: 'Administrator' }
]
</script>
<label for="member-role">Member role</label>
<Select id="member-role" bind:value={role} name="role" options={roles} />
The framework binding changes; the value and option contract does not.
When to use
Use Select when the choices are already known and someone must choose one persistent value: a role, status, environment, region, branch, billing interval, or sort order.
A native <select> is still the cleanest answer when its browser-owned picker and styling fit the product. Use Klean Select when the same value-selection job needs source-owned Tailwind styling, grouped choices, a consistent selected indicator, or a constrained long list.
When not to use
- Use Menu for actions or navigation. A Menu item does something; a Select option becomes the current value.
- Use Combobox when the person types a query, filters a long list, or waits for remote suggestions. Search will not become a
searchableSelect mode. - Use radio buttons when a small set should remain visible for immediate comparison.
- Use checkboxes or a purpose-built multiple-choice pattern when more than one value may be chosen.
- Use Date Picker or Schedule Picker for date and time decisions with their own input rules.
Options and values
The conventional option contract is { value, label, disabled?, group? }:
const regions = [
{ value: 'lagos', label: 'Lagos', group: 'Nigeria' },
{ value: 'abuja', label: 'Abuja', group: 'Nigeria' },
{ value: 'accra', label: 'Accra', group: 'Ghana' },
{ value: 'kumasi', label: 'Kumasi', group: 'Ghana', disabled: true }
]label is the default visible and accessible name. disabled prevents pointer and keyboard selection. group creates one labelled group without adding another component API. The placeholder describes an unselected control; it is never inserted as a fake selectable value.
String, number, and boolean values retain their type in application state. A name submits primitive values through an ordinary form. Object values remain valid application state but are not serialized automatically; submit a stable primitive identifier instead.
Dynamic options
Options may appear, disappear, reorder, or relabel as application data changes. Select recalculates the visible selection and keyboard highlight from the latest array. If the current value no longer has an option, the placeholder returns; Select does not silently choose a replacement.
API
| Purpose | Vue | React | Svelte |
|---|---|---|---|
| Current value | v-model | value, onValueChange | bind:value |
| Initial value | default-value | defaultValue | defaultValue |
| Choices | options | options | options |
| 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 |
The default placement is bottom-start with a four-pixel offset. Placement is preferred geometry, not a visual variant; the surface may flip or shift to stay visible. Control open state only when application behavior genuinely needs to observe it.
Vue offers value, option, icon, and empty slots. React offers equivalent render functions; Svelte offers equivalent snippets. They change rendering, not selection semantics.
required communicates the required relationship. Keep validation messages, error IDs, and server rules application-owned, following the same native markup contract as Input.
Keyboard and accessibility
- Give Select a visible
<label>or another accessible name. - Enter, Space, Arrow Down, or Arrow Up opens from the real button.
- Opening highlights the committed option, otherwise the first enabled option, without committing.
- Arrow Down and Arrow Up move between enabled options; Home and End reach the enabled edges.
- Printable characters provide buffered typeahead against accessible option labels.
- Enter or Space commits once, closes, and restores focus.
- Escape cancels without changing the value and restores focus.
- Tab closes and continues through the document normally. Select never traps focus.
- Disabled options remain understandable and are skipped.
Long lists scroll and keep the active option visible. The surface matches at least the trigger width, flips or shifts at viewport edges, and has no animation by default.
Styling
class or className merges onto the visible trigger, so caller Tailwind wins. There are no variant, tone, size, radius, theme, or part-class props. Stable data-slot and state attributes cover focused product recipes; because the source is copied into the application, editing it remains the final escape hatch.
Product recipes
This compact treatment comes directly from Slipway's Bearing feedback composer. Source-app recipes appear here only when they map to an interface that actually exists; Klean does not invent a product look to fill a comparison.
Durable state
Persist the selected value in a form, URL, or server only when the product needs that durability. Open state, keyboard highlight, and typeahead are temporary interaction state and are not written to storage or the URL by Klean.
Related components
- Input — free-form text rather than a fixed choice.
- Menu — actions and navigation rather than a persistent value.
- Popover — ordinary floating content in normal Tab order.
- Dialog — a modal task that makes the background inert.
- Combobox — editable search and application-owned remote suggestions.
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 },
/** Fixed choices in the form `{ value, label, disabled?, group? }`. */
options: { type: Array, default: () => [] },
/** Text shown when no option is selected. */
placeholder: { type: String, default: 'Select an option' },
/** Native form field name. */
name: { type: String, default: undefined },
/** Native and accessible required state. */
required: { type: Boolean, default: false },
/** Prevents opening and selection. */
disabled: { type: Boolean, default: false },
/** Stable control 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 trigger and popup. */
offset: { type: Number, default: 4 }
})
const emit = defineEmits(['update:modelValue', 'update:open', 'change', 'blur'])
const attrs = useAttrs()
const generatedId = useId()
const root = ref()
const trigger = ref()
const popover = ref()
const internalValue = ref(props.defaultValue)
const internalOpen = ref(props.defaultOpen)
const highlightedIndex = ref(-1)
const triggerWidth = ref(0)
const isValueControlled = computed(() => props.modelValue !== undefined)
const value = computed(() =>
isValueControlled.value ? props.modelValue : internalValue.value
)
const isOpenControlled = computed(() => props.open !== undefined)
const isOpen = computed(() =>
isOpenControlled.value ? props.open : internalOpen.value
)
const controlId = computed(
() => props.id ?? `klean-select-${generatedId.replace(/[^a-zA-Z0-9_-]/g, '')}`
)
const contentId = computed(() => `${controlId.value}-content`)
const listboxId = computed(() => `${controlId.value}-listbox`)
const triggerClasses = computed(() =>
twMerge(
'flex min-h-11 w-full cursor-pointer items-center justify-between gap-3 rounded-md border border-gray-300 bg-white px-3 py-2 text-left text-base text-gray-950 shadow-sm outline-none transition-colors duration-150 hover:border-gray-400 focus-visible:border-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-500 aria-invalid:border-red-600 aria-invalid:focus-visible:outline-red-600 dark:border-gray-700 dark:bg-gray-950 dark:text-white dark:hover:border-gray-600 dark:focus-visible:border-white dark:focus-visible:outline-white dark:disabled:bg-gray-900 dark:disabled:text-gray-500 dark:aria-invalid:border-red-500 dark:aria-invalid:focus-visible:outline-red-500 motion-reduce:transition-none',
attrs.class
)
)
const forwardedTriggerAttrs = computed(() => {
const {
class: _class,
style: _style,
id: _id,
role: _role,
type: _type,
disabled: _disabled,
name: _name,
required: _required,
value: _value,
'data-slot': _dataSlot,
...rest
} = attrs
return rest
})
const selectedIndex = computed(() =>
props.options.findIndex((option) => Object.is(option.value, value.value))
)
const selectedOption = computed(() => props.options[selectedIndex.value])
const serializedValue = computed(() => {
const current = value.value
return ['string', 'number', 'boolean'].includes(typeof current)
? String(current)
: ''
})
const groupedOptions = computed(() => {
const groups = new Map()
props.options.forEach((option, index) => {
const label = option.group ?? null
if (!groups.has(label)) groups.set(label, [])
groups.get(label).push({ option, index })
})
return [...groups].map(([label, entries]) => ({ label, entries }))
})
const activeDescendant = computed(() =>
isOpen.value && highlightedIndex.value >= 0
? optionId(highlightedIndex.value)
: undefined
)
let form
let resizeObserver
let typeahead = ''
let typeaheadTimer
let pendingEdge = 'selected'
function optionId(index) {
return `${controlId.value}-option-${index}`
}
function optionIsDisabled(index) {
return Boolean(props.options[index]?.disabled)
}
function enabledIndexes() {
return props.options
.map((option, index) => ({ option, index }))
.filter(({ option }) => !option.disabled)
.map(({ index }) => index)
}
function initialHighlight(edge = 'selected') {
const enabled = enabledIndexes()
if (!enabled.length) return -1
if (
edge === 'selected' &&
selectedIndex.value >= 0 &&
!optionIsDisabled(selectedIndex.value)
) {
return selectedIndex.value
}
return edge === 'last' ? enabled.at(-1) : enabled[0]
}
function syncTriggerWidth() {
triggerWidth.value = trigger.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 requestOpen(nextOpen) {
if (!isOpenControlled.value) internalOpen.value = nextOpen
emit('update:open', nextOpen)
}
function handlePopoverOpen(nextOpen) {
requestOpen(nextOpen)
}
function openSelect(edge = 'selected') {
if (props.disabled) return
pendingEdge = edge
syncTriggerWidth()
if (isOpen.value) {
highlightedIndex.value = initialHighlight(edge)
revealHighlighted()
} else {
popover.value?.open(trigger.value)
}
}
function closeSelect({ restoreFocus = false } = {}) {
popover.value?.close({ restoreFocus })
}
function clearTypeahead() {
typeahead = ''
clearTimeout(typeaheadTimer)
typeaheadTimer = undefined
}
function normalizedLabel(option) {
return String(option?.label ?? '')
.trim()
.toLocaleLowerCase()
}
function findTypeaheadMatch(text) {
const enabled = enabledIndexes()
if (!enabled.length) return -1
const current = isOpen.value
? enabled.indexOf(highlightedIndex.value)
: enabled.indexOf(selectedIndex.value)
const ordered = [
...enabled.slice(current + 1),
...enabled.slice(0, current + 1)
]
return (
ordered.find((index) =>
normalizedLabel(props.options[index]).startsWith(text)
) ?? -1
)
}
function handleTypeahead(event) {
if (
event.key.length !== 1 ||
event.key === ' ' ||
event.altKey ||
event.ctrlKey ||
event.metaKey
) {
return false
}
event.preventDefault()
clearTimeout(typeaheadTimer)
typeahead += event.key.toLocaleLowerCase()
typeaheadTimer = setTimeout(clearTypeahead, 500)
let match = findTypeaheadMatch(typeahead)
if (match < 0 && new Set(typeahead).size === 1) {
typeahead = typeahead.at(-1)
match = findTypeaheadMatch(typeahead)
}
if (match < 0) return true
if (isOpen.value) {
highlightedIndex.value = match
revealHighlighted()
} else {
choose(match, { close: false })
}
return true
}
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, { close = true } = {}) {
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
clearTypeahead()
if (close) closeSelect({ restoreFocus: true })
}
function handleKeydown(event) {
if (props.disabled) return
if (!isOpen.value) {
if (['Enter', ' ', 'ArrowDown', 'ArrowUp'].includes(event.key)) {
event.preventDefault()
openSelect(event.key === 'ArrowUp' ? 'last' : 'selected')
return
}
handleTypeahead(event)
return
}
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
closeSelect({ restoreFocus: true })
} else if (event.key === 'Tab') {
clearTypeahead()
closeSelect()
} else if (event.key === 'ArrowDown') {
event.preventDefault()
moveHighlight(1)
} else if (event.key === 'ArrowUp') {
event.preventDefault()
moveHighlight(-1)
} else if (event.key === 'Home') {
event.preventDefault()
highlightedIndex.value = initialHighlight('first')
revealHighlighted()
} else if (event.key === 'End') {
event.preventDefault()
highlightedIndex.value = initialHighlight('last')
revealHighlighted()
} else if (['Enter', ' '].includes(event.key)) {
event.preventDefault()
if (highlightedIndex.value >= 0) choose(highlightedIndex.value)
} else {
handleTypeahead(event)
}
}
function handleTriggerClick() {
if (!isOpen.value) pendingEdge = 'selected'
syncTriggerWidth()
}
function handleFormReset() {
if (!isValueControlled.value) internalValue.value = props.defaultValue
if (isOpen.value) closeSelect()
}
watch(
isOpen,
async (nextOpen) => {
clearTypeahead()
if (!nextOpen) {
highlightedIndex.value = -1
return
}
highlightedIndex.value = initialHighlight(pendingEdge)
pendingEdge = 'selected'
syncTriggerWidth()
await revealHighlighted()
},
{ flush: 'post' }
)
watch(
() => props.options,
() => {
if (!isOpen.value) return
highlightedIndex.value = initialHighlight('selected')
revealHighlighted()
},
{ deep: true }
)
onMounted(() => {
form = root.value?.closest?.('form')
form?.addEventListener('reset', handleFormReset)
if (typeof ResizeObserver !== 'undefined' && trigger.value) {
resizeObserver = new ResizeObserver(syncTriggerWidth)
resizeObserver.observe(trigger.value)
}
syncTriggerWidth()
})
onBeforeUnmount(() => {
clearTypeahead()
resizeObserver?.disconnect()
form?.removeEventListener('reset', handleFormReset)
})
defineExpose({
close: closeSelect,
focus: (options) => trigger.value?.focus(options),
open: openSelect,
trigger
})
</script>
<template>
<span
ref="root"
data-slot="select"
:data-state="isOpen ? 'open' : 'closed'"
:data-placeholder="selectedOption ? undefined : ''"
:data-disabled="disabled ? '' : undefined"
:data-invalid="
attrs['aria-invalid'] === true || attrs['aria-invalid'] === 'true'
? ''
: undefined
"
class="relative grid w-full"
>
<button
ref="trigger"
v-bind="forwardedTriggerAttrs"
:id="controlId"
type="button"
role="combobox"
:disabled="disabled"
:popovertarget="contentId"
popovertargetaction="toggle"
:aria-expanded="String(isOpen)"
:aria-controls="listboxId"
aria-haspopup="listbox"
:aria-activedescendant="activeDescendant"
:aria-required="required || undefined"
data-slot="select-trigger"
:data-state="isOpen ? 'open' : 'closed'"
:data-placeholder="selectedOption ? undefined : ''"
:class="triggerClasses"
:style="attrs.style"
@click="handleTriggerClick"
@keydown="handleKeydown"
@blur="emit('blur', $event)"
>
<span
data-slot="select-value"
:class="
selectedOption
? 'truncate'
: 'truncate text-gray-500 dark:text-gray-400'
"
>
<slot v-if="selectedOption" name="value" :option="selectedOption">
{{ selectedOption.label }}
</slot>
<template v-else>{{ placeholder }}</template>
</span>
<span
data-slot="select-icon"
class="shrink-0 text-gray-500 dark:text-gray-400"
>
<slot name="icon" :open="isOpen">
<svg
aria-hidden="true"
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>
</slot>
</span>
</button>
<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="select-content"
class="max-h-72 overflow-hidden p-1"
:style="triggerWidth ? { minWidth: `${triggerWidth}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
"
data-slot="select-listbox"
class="max-h-68 overflow-y-auto overscroll-contain outline-none"
>
<template v-if="options.length">
<div
v-for="(group, groupIndex) in groupedOptions"
:key="group.label ?? `ungrouped-${groupIndex}`"
:role="group.label ? 'group' : undefined"
:aria-label="group.label || undefined"
data-slot="select-group"
>
<p
v-if="group.label"
data-slot="select-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-label="String(option.label)"
:aria-selected="String(index === selectedIndex)"
:aria-disabled="option.disabled || undefined"
data-slot="select-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 truncate">
<slot
name="option"
:option="option"
:selected="index === selectedIndex"
:highlighted="index === highlightedIndex"
>
{{ option.label }}
</slot>
</span>
<span
data-slot="select-indicator"
class="grid size-5 shrink-0 place-items-center"
aria-hidden="true"
>
<svg
v-if="index === selectedIndex"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="2"
class="size-4"
>
<path
d="m5 10 3 3 7-7"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
</div>
</div>
</template>
<div
v-else
data-slot="select-empty"
class="px-3 py-6 text-center text-sm text-gray-500 dark:text-gray-400"
>
<slot name="empty">No options available.</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'
function enabledIndexes(options) {
return options.flatMap((option, index) => (option.disabled ? [] : [index]))
}
function serializedValue(value) {
return ['string', 'number', 'boolean'].includes(typeof value)
? String(value)
: ''
}
const Select = forwardRef(function Select(
{
value: controlledValue,
defaultValue,
options = [],
placeholder = 'Select an option',
name,
required = false,
disabled = false,
id,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
onValueChange,
onChange,
placement = 'bottom-start',
offset = 4,
className,
style,
renderValue,
renderOption,
renderIcon,
renderEmpty,
onClick,
onKeyDown,
onBlur,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
'aria-invalid': ariaInvalid,
...triggerProps
},
forwardedRef
) {
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
const controlId = id ?? `klean-select-${generatedId}`
const contentId = `${controlId}-content`
const listboxId = `${controlId}-listbox`
const triggerRef = useRef(null)
const rootRef = useRef(null)
const popoverRef = useRef(null)
const typeahead = useRef('')
const typeaheadTimer = useRef()
const pendingEdge = useRef('selected')
const [internalValue, setInternalValue] = useState(defaultValue)
const [internalOpen, setInternalOpen] = useState(defaultOpen)
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const [triggerWidth, setTriggerWidth] = useState(0)
const isValueControlled = controlledValue !== undefined
const currentValue = isValueControlled ? controlledValue : internalValue
const isOpenControlled = controlledOpen !== undefined
const isOpen = isOpenControlled ? controlledOpen : internalOpen
const selectedIndex = options.findIndex((option) =>
Object.is(option.value, currentValue)
)
const selectedOption = options[selectedIndex]
const activeDescendant =
isOpen && highlightedIndex >= 0
? `${controlId}-option-${highlightedIndex}`
: undefined
const groups = useMemo(() => {
const grouped = new Map()
options.forEach((option, index) => {
const label = option.group ?? null
if (!grouped.has(label)) grouped.set(label, [])
grouped.get(label).push({ option, index })
})
return [...grouped].map(([label, entries]) => ({ label, entries }))
}, [options])
const clearTypeahead = useCallback(() => {
typeahead.current = ''
clearTimeout(typeaheadTimer.current)
typeaheadTimer.current = undefined
}, [])
const initialHighlight = useCallback(
(edge = 'selected') => {
const enabled = enabledIndexes(options)
if (!enabled.length) return -1
if (
edge === 'selected' &&
selectedIndex >= 0 &&
!options[selectedIndex]?.disabled
) {
return selectedIndex
}
return edge === 'last' ? enabled.at(-1) : enabled[0]
},
[options, selectedIndex]
)
const syncTriggerWidth = useCallback(() => {
setTriggerWidth(triggerRef.current?.getBoundingClientRect().width ?? 0)
}, [])
const revealHighlighted = useCallback((index) => {
if (index < 0) return
queueMicrotask(() => {
popoverRef.current?.content
?.querySelector?.(`[data-option-index="${index}"]`)
?.scrollIntoView?.({ block: 'nearest' })
})
}, [])
const requestOpen = useCallback(
(nextOpen) => {
if (!isOpenControlled) setInternalOpen(nextOpen)
onOpenChange?.(nextOpen)
},
[isOpenControlled, onOpenChange]
)
const openSelect = useCallback(
(edge = 'selected') => {
if (disabled) return
pendingEdge.current = edge
syncTriggerWidth()
if (isOpen) {
const next = initialHighlight(edge)
setHighlightedIndex(next)
revealHighlighted(next)
} else {
popoverRef.current?.open(triggerRef.current)
}
},
[disabled, initialHighlight, isOpen, revealHighlighted, syncTriggerWidth]
)
const closeSelect = useCallback(({ restoreFocus = false } = {}) => {
popoverRef.current?.close({ restoreFocus })
}, [])
const choose = useCallback(
(index, { close = true } = {}) => {
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)
clearTypeahead()
if (close) closeSelect({ restoreFocus: true })
},
[
clearTypeahead,
closeSelect,
disabled,
isValueControlled,
onChange,
onValueChange,
options
]
)
const findTypeaheadMatch = useCallback(
(text) => {
const enabled = enabledIndexes(options)
if (!enabled.length) return -1
const current = enabled.indexOf(isOpen ? highlightedIndex : selectedIndex)
const ordered = [
...enabled.slice(current + 1),
...enabled.slice(0, current + 1)
]
return (
ordered.find((index) =>
String(options[index]?.label ?? '')
.trim()
.toLocaleLowerCase()
.startsWith(text)
) ?? -1
)
},
[highlightedIndex, isOpen, options, selectedIndex]
)
const handleTypeahead = useCallback(
(event) => {
if (
event.key.length !== 1 ||
event.key === ' ' ||
event.altKey ||
event.ctrlKey ||
event.metaKey
) {
return false
}
event.preventDefault()
clearTimeout(typeaheadTimer.current)
typeahead.current += event.key.toLocaleLowerCase()
typeaheadTimer.current = setTimeout(clearTypeahead, 500)
let match = findTypeaheadMatch(typeahead.current)
if (match < 0 && new Set(typeahead.current).size === 1) {
typeahead.current = typeahead.current.at(-1)
match = findTypeaheadMatch(typeahead.current)
}
if (match < 0) return true
if (isOpen) {
setHighlightedIndex(match)
revealHighlighted(match)
} else {
choose(match, { close: false })
}
return true
},
[choose, clearTypeahead, findTypeaheadMatch, isOpen, revealHighlighted]
)
const moveHighlight = useCallback(
(step) => {
const enabled = enabledIndexes(options)
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)
},
[highlightedIndex, options, revealHighlighted]
)
function handleKeydown(event) {
onKeyDown?.(event)
if (event.defaultPrevented || disabled) return
if (!isOpen) {
if (['Enter', ' ', 'ArrowDown', 'ArrowUp'].includes(event.key)) {
event.preventDefault()
openSelect(event.key === 'ArrowUp' ? 'last' : 'selected')
} else {
handleTypeahead(event)
}
return
}
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
closeSelect({ restoreFocus: true })
} else if (event.key === 'Tab') {
clearTypeahead()
closeSelect()
} else if (event.key === 'ArrowDown') {
event.preventDefault()
moveHighlight(1)
} else if (event.key === 'ArrowUp') {
event.preventDefault()
moveHighlight(-1)
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault()
const next = initialHighlight(event.key === 'End' ? 'last' : 'first')
setHighlightedIndex(next)
revealHighlighted(next)
} else if (['Enter', ' '].includes(event.key)) {
event.preventDefault()
if (highlightedIndex >= 0) choose(highlightedIndex)
} else {
handleTypeahead(event)
}
}
useEffect(() => {
clearTypeahead()
if (!isOpen) {
setHighlightedIndex(-1)
return
}
const next = initialHighlight(pendingEdge.current)
pendingEdge.current = 'selected'
setHighlightedIndex(next)
syncTriggerWidth()
revealHighlighted(next)
}, [
clearTypeahead,
initialHighlight,
isOpen,
revealHighlighted,
syncTriggerWidth
])
useEffect(() => {
if (!isOpen) return
const next = initialHighlight('selected')
setHighlightedIndex(next)
revealHighlighted(next)
}, [initialHighlight, isOpen, options, revealHighlighted])
useEffect(() => {
const form = rootRef.current?.closest?.('form')
const handleReset = () => {
if (!isValueControlled) setInternalValue(defaultValue)
if (isOpen) closeSelect()
}
form?.addEventListener('reset', handleReset)
const observer =
typeof ResizeObserver !== 'undefined' && triggerRef.current
? new ResizeObserver(syncTriggerWidth)
: undefined
if (triggerRef.current) observer?.observe(triggerRef.current)
syncTriggerWidth()
return () => {
clearTypeahead()
observer?.disconnect()
form?.removeEventListener('reset', handleReset)
}
}, [
clearTypeahead,
closeSelect,
defaultValue,
isOpen,
isValueControlled,
syncTriggerWidth
])
useImperativeHandle(
forwardedRef,
() => ({
close: closeSelect,
focus: (focusOptions) => triggerRef.current?.focus(focusOptions),
open: openSelect,
trigger: triggerRef.current
}),
[closeSelect, openSelect]
)
return (
<span
ref={rootRef}
data-slot="select"
data-state={isOpen ? 'open' : 'closed'}
data-placeholder={selectedOption ? undefined : ''}
data-disabled={disabled ? '' : undefined}
data-invalid={
ariaInvalid === true || ariaInvalid === 'true' ? '' : undefined
}
className="relative grid w-full"
>
<button
{...triggerProps}
ref={triggerRef}
id={controlId}
type="button"
role="combobox"
disabled={disabled}
popoverTarget={contentId}
popoverTargetAction="toggle"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-invalid={ariaInvalid}
aria-expanded={String(isOpen)}
aria-controls={listboxId}
aria-haspopup="listbox"
aria-activedescendant={activeDescendant}
aria-required={required || undefined}
data-slot="select-trigger"
data-state={isOpen ? 'open' : 'closed'}
data-placeholder={selectedOption ? undefined : ''}
className={twMerge(
'flex min-h-11 w-full cursor-pointer items-center justify-between gap-3 rounded-md border border-gray-300 bg-white px-3 py-2 text-left text-base text-gray-950 shadow-sm outline-none transition-colors duration-150 hover:border-gray-400 focus-visible:border-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-500 aria-invalid:border-red-600 aria-invalid:focus-visible:outline-red-600 dark:border-gray-700 dark:bg-gray-950 dark:text-white dark:hover:border-gray-600 dark:focus-visible:border-white dark:focus-visible:outline-white dark:disabled:bg-gray-900 dark:disabled:text-gray-500 dark:aria-invalid:border-red-500 dark:aria-invalid:focus-visible:outline-red-500 motion-reduce:transition-none',
className
)}
style={style}
onClick={(event) => {
onClick?.(event)
if (event.defaultPrevented) return
if (!isOpen) pendingEdge.current = 'selected'
syncTriggerWidth()
}}
onKeyDown={handleKeydown}
onBlur={onBlur}
>
<span
data-slot="select-value"
className={
selectedOption
? 'truncate'
: 'truncate text-gray-500 dark:text-gray-400'
}
>
{selectedOption
? (renderValue?.(selectedOption) ?? selectedOption.label)
: placeholder}
</span>
<span
data-slot="select-icon"
className="shrink-0 text-gray-500 dark:text-gray-400"
>
{renderIcon?.(isOpen) ?? (
<svg
aria-hidden="true"
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>
</button>
{name ? (
<input
type="hidden"
name={name}
value={serializedValue(currentValue)}
disabled={disabled}
form={triggerProps.form}
/>
) : null}
<Popover
ref={popoverRef}
id={contentId}
open={isOpen}
placement={placement}
offset={offset}
data-slot="select-content"
className="max-h-72 overflow-hidden p-1"
style={triggerWidth ? { minWidth: `${triggerWidth}px` } : undefined}
onOpenChange={requestOpen}
>
<div
id={listboxId}
role="listbox"
aria-labelledby={
ariaLabel ? undefined : (ariaLabelledby ?? controlId)
}
aria-label={ariaLabel ? `${ariaLabel} options` : undefined}
data-slot="select-listbox"
className="max-h-68 overflow-y-auto overscroll-contain outline-none"
>
{options.length ? (
groups.map((group, groupIndex) => (
<div
key={group.label ?? `ungrouped-${groupIndex}`}
role={group.label ? 'group' : undefined}
aria-label={group.label || undefined}
data-slot="select-group"
>
{group.label ? (
<p
data-slot="select-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-label={String(option.label)}
aria-selected={String(index === selectedIndex)}
aria-disabled={option.disabled || undefined}
data-slot="select-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={() => {
if (!option.disabled) setHighlightedIndex(index)
}}
onPointerDown={(event) => event.preventDefault()}
onClick={() => choose(index)}
>
<span className="min-w-0 flex-1 truncate">
{renderOption?.(option, {
selected: index === selectedIndex,
highlighted: index === highlightedIndex
}) ?? option.label}
</span>
<span
data-slot="select-indicator"
className="grid size-5 shrink-0 place-items-center"
aria-hidden="true"
>
{index === selectedIndex ? (
<svg
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="size-4"
>
<path
d="m5 10 3 3 7-7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : null}
</span>
</div>
))}
</div>
))
) : (
<div
data-slot="select-empty"
className="px-3 py-6 text-center text-sm text-gray-500 dark:text-gray-400"
>
{renderEmpty?.() ?? 'No options available.'}
</div>
)}
</div>
</Popover>
</span>
)
})
export default Select
Svelte
<script>
import { onMount, untrack } from "svelte";
import { twMerge } from "tailwind-merge";
import Popover from "../popover/Popover.svelte";
let {
value = $bindable(),
defaultValue,
options = [],
placeholder = "Select an option",
name,
required = false,
disabled = false,
id,
open = $bindable(),
defaultOpen = false,
onopenchange,
onchange,
placement = "bottom-start",
offset = 4,
class: className = "",
style,
valueContent,
optionContent,
icon,
empty,
onclick,
onkeydown,
onblur,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledby,
"aria-invalid": ariaInvalid,
...triggerProps
} = $props();
const componentIdentity = $props.id();
const componentId = componentIdentity.replace(/[^a-zA-Z0-9_-]/g, "");
let internalValue = $state(untrack(() => defaultValue));
let internalOpen = $state(untrack(() => defaultOpen));
let currentValue = $derived(value !== undefined ? value : internalValue);
let isOpen = $derived(open !== undefined ? open : internalOpen);
let controlId = $derived(id ?? `klean-select-${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 activeDescendant = $derived(
isOpen && highlightedIndex >= 0
? `${controlId}-option-${highlightedIndex}`
: undefined,
);
let groups = $derived.by(() => {
const grouped = new Map();
options.forEach((option, index) => {
const label = option.group ?? null;
if (!grouped.has(label)) grouped.set(label, []);
grouped.get(label).push({ option, index });
});
return [...grouped].map(([label, entries]) => ({ label, entries }));
});
let root;
let trigger;
let popover;
let highlightedIndex = $state(-1);
let triggerWidth = $state(0);
let typeahead = "";
let typeaheadTimer;
let pendingEdge = "selected";
function enabledIndexes() {
return options.flatMap((option, index) => (option.disabled ? [] : [index]));
}
function initialHighlight(edge = "selected") {
const enabled = enabledIndexes();
if (!enabled.length) return -1;
if (
edge === "selected" &&
selectedIndex >= 0 &&
!options[selectedIndex]?.disabled
) {
return selectedIndex;
}
return edge === "last" ? enabled.at(-1) : enabled[0];
}
function syncTriggerWidth() {
triggerWidth = trigger?.getBoundingClientRect().width ?? 0;
}
function revealHighlighted(index = highlightedIndex) {
if (index < 0) return;
queueMicrotask(() => {
popover
?.getContent?.()
?.querySelector?.(`[data-option-index="${index}"]`)
?.scrollIntoView?.({ block: "nearest" });
});
}
function requestOpen(nextOpen) {
if (open === undefined) internalOpen = nextOpen;
else open = nextOpen;
onopenchange?.(nextOpen);
}
function handlePopoverOpen(nextOpen) {
requestOpen(nextOpen);
}
export function show(edge = "selected") {
if (disabled) return;
pendingEdge = edge;
syncTriggerWidth();
if (isOpen) {
highlightedIndex = initialHighlight(edge);
revealHighlighted();
} else {
popover?.show(trigger);
}
}
export function close({ restoreFocus = false } = {}) {
popover?.close({ restoreFocus });
}
export function focus(options) {
trigger?.focus(options);
}
function clearTypeahead() {
typeahead = "";
clearTimeout(typeaheadTimer);
typeaheadTimer = undefined;
}
function findTypeaheadMatch(text) {
const enabled = enabledIndexes();
if (!enabled.length) return -1;
const current = enabled.indexOf(isOpen ? highlightedIndex : selectedIndex);
const ordered = [
...enabled.slice(current + 1),
...enabled.slice(0, current + 1),
];
return (
ordered.find((index) =>
String(options[index]?.label ?? "")
.trim()
.toLocaleLowerCase()
.startsWith(text),
) ?? -1
);
}
function choose(index, { shouldClose = true } = {}) {
const option = options[index];
if (!option || option.disabled || disabled) return;
internalValue = option.value;
value = option.value;
onchange?.(option.value, option);
highlightedIndex = index;
clearTypeahead();
if (shouldClose) close({ restoreFocus: true });
}
function handleTypeahead(event) {
if (
event.key.length !== 1 ||
event.key === " " ||
event.altKey ||
event.ctrlKey ||
event.metaKey
) {
return false;
}
event.preventDefault();
clearTimeout(typeaheadTimer);
typeahead += event.key.toLocaleLowerCase();
typeaheadTimer = setTimeout(clearTypeahead, 500);
let match = findTypeaheadMatch(typeahead);
if (match < 0 && new Set(typeahead).size === 1) {
typeahead = typeahead.at(-1);
match = findTypeaheadMatch(typeahead);
}
if (match < 0) return true;
if (isOpen) {
highlightedIndex = match;
revealHighlighted();
} else {
choose(match, { shouldClose: false });
}
return true;
}
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 handleKeydown(event) {
onkeydown?.(event);
if (event.defaultPrevented || disabled) return;
if (!isOpen) {
if (["Enter", " ", "ArrowDown", "ArrowUp"].includes(event.key)) {
event.preventDefault();
show(event.key === "ArrowUp" ? "last" : "selected");
} else {
handleTypeahead(event);
}
return;
}
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
close({ restoreFocus: true });
} else if (event.key === "Tab") {
clearTypeahead();
close();
} else if (event.key === "ArrowDown") {
event.preventDefault();
moveHighlight(1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
moveHighlight(-1);
} else if (event.key === "Home" || event.key === "End") {
event.preventDefault();
highlightedIndex = initialHighlight(
event.key === "End" ? "last" : "first",
);
revealHighlighted();
} else if (["Enter", " "].includes(event.key)) {
event.preventDefault();
if (highlightedIndex >= 0) choose(highlightedIndex);
} else {
handleTypeahead(event);
}
}
$effect(() => {
if (isOpen) {
const nextHighlight = initialHighlight(pendingEdge);
highlightedIndex = nextHighlight;
pendingEdge = "selected";
syncTriggerWidth();
revealHighlighted(nextHighlight);
} else {
highlightedIndex = -1;
}
clearTypeahead();
});
$effect(() => {
options;
if (!isOpen) return;
const nextHighlight = initialHighlight("selected");
highlightedIndex = nextHighlight;
revealHighlighted(nextHighlight);
});
onMount(() => {
const form = root?.closest?.("form");
const handleReset = () => {
internalValue = defaultValue;
value = defaultValue;
if (isOpen) close();
};
form?.addEventListener("reset", handleReset);
const observer =
typeof ResizeObserver !== "undefined" && trigger
? new ResizeObserver(syncTriggerWidth)
: undefined;
if (trigger) observer?.observe(trigger);
syncTriggerWidth();
return () => {
clearTypeahead();
observer?.disconnect();
form?.removeEventListener("reset", handleReset);
};
});
</script>
<span
bind:this={root}
data-slot="select"
data-state={isOpen ? "open" : "closed"}
data-placeholder={selectedOption ? undefined : ""}
data-disabled={disabled ? "" : undefined}
data-invalid={ariaInvalid === true || ariaInvalid === "true" ? "" : undefined}
class="relative grid w-full"
>
<button
{...triggerProps}
bind:this={trigger}
id={controlId}
type="button"
role="combobox"
{disabled}
popovertarget={contentId}
popovertargetaction="toggle"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-invalid={ariaInvalid}
aria-expanded={String(isOpen)}
aria-controls={listboxId}
aria-haspopup="listbox"
aria-activedescendant={activeDescendant}
aria-required={required || undefined}
data-slot="select-trigger"
data-state={isOpen ? "open" : "closed"}
data-placeholder={selectedOption ? undefined : ""}
class={twMerge(
"flex min-h-11 w-full cursor-pointer items-center justify-between gap-3 rounded-md border border-gray-300 bg-white px-3 py-2 text-left text-base text-gray-950 shadow-sm outline-none transition-colors duration-150 hover:border-gray-400 focus-visible:border-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-500 aria-invalid:border-red-600 aria-invalid:focus-visible:outline-red-600 dark:border-gray-700 dark:bg-gray-950 dark:text-white dark:hover:border-gray-600 dark:focus-visible:border-white dark:focus-visible:outline-white dark:disabled:bg-gray-900 dark:disabled:text-gray-500 dark:aria-invalid:border-red-500 dark:aria-invalid:focus-visible:outline-red-500 motion-reduce:transition-none",
className,
)}
{style}
onclick={(event) => {
onclick?.(event);
if (event.defaultPrevented) return;
if (!isOpen) pendingEdge = "selected";
syncTriggerWidth();
}}
onkeydown={handleKeydown}
{onblur}
>
<span
data-slot="select-value"
class={selectedOption
? "truncate"
: "truncate text-gray-500 dark:text-gray-400"}
>
{#if selectedOption}
{#if valueContent}
{@render valueContent(selectedOption)}
{:else}
{selectedOption.label}
{/if}
{:else}
{placeholder}
{/if}
</span>
<span
data-slot="select-icon"
class="shrink-0 text-gray-500 dark:text-gray-400"
>
{#if icon}
{@render icon(isOpen)}
{:else}
<svg
aria-hidden="true"
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>
</button>
{#if name}
<input
type="hidden"
{name}
value={["string", "number", "boolean"].includes(typeof currentValue)
? String(currentValue)
: ""}
{disabled}
form={triggerProps.form}
/>
{/if}
<Popover
bind:this={popover}
id={contentId}
open={isOpen}
{placement}
{offset}
data-slot="select-content"
class="max-h-72 overflow-hidden p-1"
style={triggerWidth ? { minWidth: `${triggerWidth}px` } : undefined}
onOpenChange={handlePopoverOpen}
>
<div
id={listboxId}
role="listbox"
aria-labelledby={ariaLabel ? undefined : (ariaLabelledby ?? controlId)}
aria-label={ariaLabel ? `${ariaLabel} options` : undefined}
data-slot="select-listbox"
class="max-h-68 overflow-y-auto overscroll-contain outline-none"
>
{#if options.length}
{#each groups as group, groupIndex (group.label ?? `ungrouped-${groupIndex}`)}
<div
role={group.label ? "group" : undefined}
aria-label={group.label || undefined}
data-slot="select-group"
>
{#if group.label}
<p
data-slot="select-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-label={String(option.label)}
aria-selected={String(index === selectedIndex)}
aria-disabled={option.disabled || undefined}
data-slot="select-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 truncate">
{#if optionContent}
{@render optionContent(option, {
selected: index === selectedIndex,
highlighted: index === highlightedIndex,
})}
{:else}
{option.label}
{/if}
</span>
<span
data-slot="select-indicator"
class="grid size-5 shrink-0 place-items-center"
aria-hidden="true"
>
{#if index === selectedIndex}
<svg
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="2"
class="size-4"
>
<path
d="m5 10 3 3 7-7"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{/if}
</span>
</div>
{/each}
</div>
{/each}
{:else}
<div
data-slot="select-empty"
class="px-3 py-6 text-center text-sm text-gray-500 dark:text-gray-400"
>
{#if empty}
{@render empty()}
{:else}
No options available.
{/if}
</div>
{/if}
</div>
</Popover>
</span>