Skip to content

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.

Select.vue
Try Arrow Up/Down, Home/End, typing “a”, Enter, Escape, and Tab. Editor is disabled and is skipped.

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.

Terminal
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

MemberRole.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

MemberRole.jsx
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

MemberRole.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 searchable Select 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? }:

js
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

PurposeVueReactSvelte
Current valuev-modelvalue, onValueChangebind:value
Initial valuedefault-valuedefaultValuedefaultValue
Choicesoptionsoptionsoptions
Formname, required, disabled, formsame native namessame native names
Open statev-model:openopen, onOpenChangebind:open
Geometryplacement, offsetplacement, offsetplacement, offset
StylingclassclassNameclass

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.

slipway-select.vue
Slipway's compact trigger and popup are ordinary caller Tailwind. They do not require a variant or theme selector.

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.

  • 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

Select.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

Select.jsx
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

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>

All open source projects are released under the MIT License.