Combobox

Lets users choose from available options or type their own. Provides clear, responsive feedback for every interaction.

Simple

A plain repo picker — just pass options as an array of strings.

Selected: frappe-ui
vue
<script setup lang="ts">
import { ref } from 'vue'
import { Combobox } from 'frappe-ui'

const value = ref('frappe-ui')

const repos = [
  'gameplan',
  'frappe-ui',
  'frappe',
  'erpnext',
  'helpdesk',
  'crm',
  'wiki',
  'insights',
]
</script>

<template>
  <div class="grid gap-3">
    <Combobox
      v-model="value"
      :options="repos"
      placeholder="Pick a repo"
      open-on-focus
    />

    <div class="text-sm text-ink-gray-5">
      Selected: <code class="text-ink-gray-7">{{ value || 'None' }}</code>
    </div>
  </div>
</template>

Emoji Picker

Button-triggered combobox via trigger="button". The search input moves into the popover header. The button's label and prefix auto-derive from the selected option — #item-prefix doubles as the selected-state prefix, and #prefix is the placeholder icon shown before anything is picked.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { Combobox } from 'frappe-ui'

const value = ref<string>('')

const emojis = [
  {
    group: 'Smileys',
    options: [
      { label: 'Grinning', value: 'grinning', icon: '😀' },
      { label: 'Laughing', value: 'laughing', icon: '😂' },
      { label: 'Heart Eyes', value: 'heart-eyes', icon: '😍' },
      { label: 'Thinking', value: 'thinking', icon: '🤔' },
      { label: 'Mind Blown', value: 'mind-blown', icon: '🤯' },
    ],
  },
  {
    group: 'Gestures',
    options: [
      { label: 'Thumbs Up', value: 'thumbs-up', icon: '👍' },
      { label: 'Clap', value: 'clap', icon: '👏' },
      { label: 'Party', value: 'party', icon: '🎉' },
      { label: 'Rocket', value: 'rocket', icon: '🚀' },
      { label: 'Fire', value: 'fire', icon: '🔥' },
    ],
  },
  {
    group: 'Objects',
    options: [
      { label: 'Sparkles', value: 'sparkles', icon: '' },
      { label: 'Bulb', value: 'bulb', icon: '💡' },
      { label: 'Warning', value: 'warning', icon: '⚠️' },
      { label: 'Check', value: 'check', icon: '' },
      { label: 'Cross', value: 'cross', icon: '' },
    ],
  },
]
</script>

<template>
  <Combobox
    v-model="value"
    trigger="button"
    :options="emojis"
    placeholder="Pick a reaction"
  >
    <template #prefix>
      <span class="lucide-smile size-4 text-ink-gray-6" />
    </template>
  </Combobox>
</template>

Grouped Options

Options split into named groups. #item-prefix renders a colored swatch per row.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { Combobox } from 'frappe-ui'

type Space = { label: string; value: string; accent: string }

const value = ref<string>('platform-infra')

const spaces: { group: string; options: Space[] }[] = [
  {
    group: 'Engineering',
    options: [
      {
        label: 'Platform Infra',
        value: 'platform-infra',
        accent: 'bg-blue-500',
      },
      { label: 'Mobile 2.0', value: 'mobile-2', accent: 'bg-red-500' },
      { label: 'Growth', value: 'growth', accent: 'bg-amber-500' },
    ],
  },
  {
    group: 'Product',
    options: [
      { label: 'Discovery', value: 'discovery', accent: 'bg-cyan-500' },
      { label: 'Roadmap', value: 'roadmap', accent: 'bg-green-500' },
      { label: 'Feedback', value: 'feedback', accent: 'bg-violet-500' },
    ],
  },
  {
    group: 'Design',
    options: [
      { label: 'System', value: 'system', accent: 'bg-teal-500' },
      { label: 'Research', value: 'research', accent: 'bg-pink-500' },
      { label: 'Brand', value: 'brand', accent: 'bg-orange-500' },
    ],
  },
]
</script>

<template>
  <Combobox
    v-model="value"
    :options="spaces"
    placeholder="Move to space…"
    class="w-72"
  >
    <template #item-prefix="{ item }">
      <div
        :class="['size-2.5 rounded-[3px]', (item as Space).accent]"
        aria-hidden="true"
      />
    </template>
  </Combobox>
</template>

Custom Value

Free-form acceptance via allowCustomValue: the typed query becomes the model value when nothing matches, and unknown external values are preserved. The component renders a built-in "Create X" row as a click affordance. Use this when you want a "text input with autocomplete" feel. For richer create-new UX (custom label, icon, persistence callback), see Create New below.

Selected: None
vue
<script setup lang="ts">
import { ref } from 'vue'
import { Combobox } from 'frappe-ui'

const value = ref('')
const options = ['John Doe', 'Jane Doe', 'John Smith', 'Jane Smith']
</script>

<template>
  <div class="grid gap-3">
    <Combobox
      v-model="value"
      :options="options"
      :allow-custom-value="true"
      open-on-focus
      placeholder="Type a person"
      class="w-64"
    />

    <div class="text-sm text-gray-600">Selected: {{ value || 'None' }}</div>
  </div>
</template>

Clearable

Uses the #trigger slot to compose a custom trigger with an inline clear button. The X clears v-model via @click.stop so the popover doesn't toggle, and @pointerdown.stop keeps the anchor from intercepting the press.

A
vue
<script setup lang="ts">
import { reactive } from 'vue'
import { Combobox } from 'frappe-ui'

type FieldOption = string | { label: string; value: string }
type Field = { key: string; label: string; options: FieldOption[] }

const fields: Field[] = [
  {
    key: 'colour',
    label: 'colour',
    options: ['gray', 'blue', 'green', 'red'],
  },
  { key: 'size', label: 'size', options: ['sm', 'md', 'lg', 'xl'] },
  { key: 'style', label: 'style', options: ['subtle', 'outline', 'ghost'] },
  {
    key: 'fontSize',
    label: 'font size',
    options: [
      { label: '12px', value: '12' },
      { label: '14px', value: '14' },
      { label: '16px', value: '16' },
      { label: '20px', value: '20' },
      { label: '24px', value: '24' },
    ],
  },
]

const values = reactive<Record<string, string>>({
  colour: 'gray',
  size: 'sm',
  style: 'ghost',
  fontSize: '14',
})

const colourSwatch: Record<string, string> = {
  gray: 'bg-surface-gray-5',
  blue: 'bg-surface-blue-3',
  green: 'bg-surface-green-3',
  red: 'bg-surface-red-5',
}

const sizeDot: Record<string, string> = {
  sm: 'size-1.5',
  md: 'size-2',
  lg: 'size-2.5',
  xl: 'size-3',
}

const fontSizePx: Record<string, number> = {
  '12': 8,
  '14': 10,
  '16': 12,
  '20': 14,
  '24': 16,
}

function getOptionValue(item: { value?: string; label: string }) {
  return item.value ?? item.label
}

function clear(key: string, event: Event) {
  event.stopPropagation()
  values[key] = ''
}
</script>

<template>
  <div class="grid w-[420px] gap-2">
    <div
      v-for="field in fields"
      :key="field.key"
      class="group grid grid-cols-[8rem_1fr] items-center gap-4"
    >
      <label class="text-base text-ink-gray-5">{{ field.label }}</label>

      <Combobox
        v-model="values[field.key]"
        :options="field.options"
        variant="outline"
        :placeholder="`Pick a ${field.label}`"
        open-on-focus
      >
        <template #item-prefix="{ item }">
          <span class="grid size-4 shrink-0 place-items-center">
            <span
              v-if="field.key === 'colour'"
              :class="[
                'inline-block size-3 rounded-sm',
                colourSwatch[getOptionValue(item)] ?? 'bg-surface-gray-3',
              ]"
            />

            <span
              v-else-if="field.key === 'size'"
              :class="[
                'inline-block rounded-full bg-surface-gray-4',
                sizeDot[getOptionValue(item)] ?? 'size-2',
              ]"
            />

            <template v-else-if="field.key === 'style'">
              <span
                v-if="getOptionValue(item) === 'outline'"
                class="lucide-square-dashed size-4 text-ink-gray-6"
              />
              <span
                v-else-if="getOptionValue(item) === 'ghost'"
                class="lucide-circle-dashed size-4 text-ink-gray-6"
              />
              <span v-else class="lucide-square size-4 text-ink-gray-6" />
            </template>

            <span
              v-else-if="field.key === 'fontSize'"
              class="font-semibold leading-none text-ink-gray-7"
              :style="{
                fontSize: `${fontSizePx[getOptionValue(item)] ?? 12}px`,
              }"
            >
              A
            </span>
          </span>
        </template>

        <template #suffix="{ open }">
          <button
            v-if="values[field.key]"
            type="button"
            aria-label="Clear"
            tabindex="-1"
            class="grid size-4 place-items-center rounded-sm text-ink-gray-5 opacity-0 hover:bg-surface-gray-3 hover:text-ink-gray-7 group-hover:opacity-100 focus:opacity-100"
            @click="clear(field.key, $event)"
            @pointerdown.stop
          >
            <span class="lucide-x size-4" />
          </button>
          <span
            v-else
            :class="[
              'lucide-chevron-down size-4 text-ink-gray-5 transition-transform duration-200',
              open && 'rotate-180',
            ]"
          />
        </template>
      </Combobox>
    </div>
  </div>
</template>

Create New

"Create new" is just a type: 'custom' option. condition hides the row when the query is empty or already matches an existing item, and onClick receives the typed query so you can persist the new value.

Selected: None
Tags: bug, enhancement, docs, discussion
vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Combobox } from 'frappe-ui'

const value = ref<string>('')
const tags = ref<string[]>(['bug', 'enhancement', 'docs', 'discussion'])

const selectableOptions = computed(() =>
  tags.value.map((t) => ({ label: t, value: t })),
)

// "Create new" is just a `type: 'custom'` row. `condition` is authoritative
// — it runs even before the user types, so the row can decide for itself
// when to appear based on the typed query and current selection.
const options = computed(() => [
  ...selectableOptions.value,
  {
    type: 'custom' as const,
    key: 'create',
    label: 'Create tag',
    slot: 'create',
    keepOpen: false,
    condition: ({ query }: { query: string }) => {
      const q = query.trim().toLowerCase()
      if (!q) return false
      if (q === value.value?.toLowerCase()) return false
      return !tags.value.some((t) => t.toLowerCase() === q)
    },
    onClick: ({ query }: { query: string }) => {
      const next = query.trim()
      if (!next) return
      tags.value = [...tags.value, next]
      value.value = next
    },
  },
])

function getBgClass(item: { label: string }) {
  const palette = [
    'bg-surface-amber-3',
    'bg-surface-blue-3',
    'bg-surface-green-3',
    'bg-surface-gray-3',
  ]
  const hash = item.label
    .toLowerCase()
    .split('')
    .reduce((a, b) => a + b.charCodeAt(0), 0)
  return palette[hash % palette.length]
}
</script>

<template>
  <div class="grid gap-3 shrink-0">
    <Combobox
      v-model="value"
      :options="options"
      placeholder="Search or create a tag"
      open-on-focus
      class="w-64"
    >
      <template #item-prefix="{ item }">
        <span
          v-if="item.key !== 'create'"
          :class="getBgClass(item)"
          class="size-3 rounded-sm"
        />
        <span
          v-if="item.key === 'create'"
          class="rounded-sm bg-surface-gray-5 lucide-tag"
        />
      </template>
      <template #item-create="{ query }">
        <div class="flex">
          <span class="truncate">
            Create
            <span v-if="query" class="font-medium text-ink-gray-8">
              {{ query }}
            </span>
          </span>
        </div>
      </template>
    </Combobox>

    <div class="text-sm text-ink-gray-5">
      Selected: <code class="text-ink-gray-7">{{ value || 'None' }}</code>
    </div>
    <div class="text-sm text-ink-gray-5">
      Tags: <code class="text-ink-gray-7">{{ tags.join(', ') }}</code>
    </div>
  </div>
</template>

Status Picker

Dotted indicator aligned to the first line, with supporting description text.

vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Combobox } from 'frappe-ui'

type StatusOption = {
  label: string
  value: string
  color: string
  description: string
}

const value = ref<string>('in-progress')

const statuses: StatusOption[] = [
  {
    label: 'Backlog',
    value: 'backlog',
    color: 'bg-gray-400',
    description: 'Ideas and future work',
  },
  {
    label: 'Todo',
    value: 'todo',
    color: 'bg-gray-500',
    description: 'Ready to be picked up',
  },
  {
    label: 'In Progress',
    value: 'in-progress',
    color: 'bg-blue-500',
    description: 'Actively being worked on',
  },
  {
    label: 'In Review',
    value: 'in-review',
    color: 'bg-yellow-500',
    description: 'Awaiting feedback',
  },
  {
    label: 'Done',
    value: 'done',
    color: 'bg-green-500',
    description: 'Shipped and verified',
  },
  {
    label: 'Cancelled',
    value: 'cancelled',
    color: 'bg-gray-300',
    description: 'Will not be worked on',
  },
]

const selected = computed(
  () => statuses.find((s) => s.value === value.value) ?? null,
)
</script>

<template>
  <div class="grid gap-3">
    <Combobox
      v-model="value"
      :options="statuses"
      placeholder="Set status"
      open-on-focus
      class="w-72"
    >
      <template #prefix>
        <span
          v-if="selected"
          :class="['size-2 rounded-full', selected.color]"
          aria-hidden="true"
        />
      </template>

      <!--
        The dot is rendered inside the label region so it aligns with the
        first line of text (not the vertical center of a two-line row).
      -->
      <template #item-label="{ item }">
        <div class="flex items-start gap-2">
          <span
            :class="[
              'mt-[4px] size-2 shrink-0 rounded-full',
              (item as StatusOption).color,
            ]"
            aria-hidden="true"
          />
          <div class="min-w-0">
            <div class="truncate">{{ item.label }}</div>
            <div class="truncate text-p-sm text-ink-gray-5">
              {{ (item as StatusOption).description }}
            </div>
          </div>
        </div>
      </template>
    </Combobox>
  </div>
</template>

Member Picker

Avatar rows with a contextual invite action authored through a template slot.

No one assigned
vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Avatar, Combobox } from 'frappe-ui'

type Member = {
  label: string
  value: string
  email: string
  image: string
  role: string
}

const value = ref<string>('')
const lastAction = ref<string>('')

// Using pravatar.cc for stable, realistic avatar photos keyed by email.
const members: Member[] = [
  {
    label: 'Alex Rivera',
    value: 'alex@example.com',
    email: 'alex@example.com',
    image: 'https://i.pravatar.cc/80?u=alex@example.com',
    role: 'Engineering',
  },
  {
    label: 'Priya Shah',
    value: 'priya@example.com',
    email: 'priya@example.com',
    image: 'https://i.pravatar.cc/80?u=priya@example.com',
    role: 'Design',
  },
  {
    label: 'Marcus Lee',
    value: 'marcus@example.com',
    email: 'marcus@example.com',
    image: 'https://i.pravatar.cc/80?u=marcus@example.com',
    role: 'Product',
  },
  {
    label: 'Sofia Hartmann',
    value: 'sofia@example.com',
    email: 'sofia@example.com',
    image: 'https://i.pravatar.cc/80?u=sofia@example.com',
    role: 'Engineering',
  },
  {
    label: 'Kenji Tanaka',
    value: 'kenji@example.com',
    email: 'kenji@example.com',
    image: 'https://i.pravatar.cc/80?u=kenji@example.com',
    role: 'Design',
  },
]

// Members appear as regular selectable options. The invite row is a custom
// action with `condition: () => true` so the picker always offers it,
// regardless of the current query.
const options = [
  ...members,
  {
    type: 'custom' as const,
    key: 'invite',
    label: 'Invite new member',
    slot: 'invite',
    condition: () => true,
    onClick: ({ query }: { query: string }) => {
      lastAction.value = query ? `Invited "${query}"` : 'Opened invite dialog'
    },
  },
]

const selected = computed(
  () => members.find((m) => m.value === value.value) ?? null,
)
</script>

<template>
  <div class="grid gap-3">
    <Combobox
      v-model="value"
      :options="options"
      placeholder="Assign to…"
      open-on-focus
      class="w-80"
    >
      <template #prefix>
        <Avatar v-if="selected" :image="selected.image" size="sm" />
      </template>

      <template #item-prefix="{ item }">
        <Avatar
          v-if="item.type !== 'custom'"
          :image="(item as Member).image"
          :label="item.label"
          size="sm"
        />
        <div
          v-else
          class="flex size-6 items-center justify-center rounded-full bg-surface-blue-2 text-ink-blue-600"
        >
          <span class="lucide-user-plus size-3.5" />
        </div>
      </template>

      <template #item-label="{ item }">
        <div v-if="item.type !== 'custom'" class="min-w-0">
          <div class="truncate">{{ item.label }}</div>
          <div class="truncate text-p-sm text-ink-gray-5">
            {{ (item as Member).email }}
          </div>
        </div>
      </template>

      <template #item-invite="{ query }">
        <span class="truncate text-ink-blue-600">
          {{ query ? `Invite "${query}"` : 'Invite new member' }}
        </span>
      </template>
    </Combobox>

    <div class="text-sm text-ink-gray-5">
      {{
        selected
          ? `Assigned to ${selected.label}`
          : lastAction || 'No one assigned'
      }}
    </div>
  </div>
</template>

The #footer slot renders below the list and stays pinned to the bottom of the popover — it does not scroll with the options. Scroll the list to confirm the footer remains fixed.

Selected: None
vue
<script setup lang="ts">
import { ref } from 'vue'
import { Combobox } from 'frappe-ui'

const value = ref('')

// A long list so the viewport scrolls — the footer should stay pinned to the
// bottom of the popover instead of scrolling away with the items (#717).
const countries = [
  'Argentina',
  'Australia',
  'Brazil',
  'Canada',
  'Denmark',
  'Egypt',
  'France',
  'Germany',
  'India',
  'Indonesia',
  'Japan',
  'Kenya',
  'Mexico',
  'Netherlands',
  'Norway',
  'Portugal',
  'Singapore',
  'Spain',
  'Sweden',
  'United Kingdom',
  'United States',
  'Vietnam',
]
</script>

<template>
  <div class="grid gap-3">
    <Combobox
      v-model="value"
      :options="countries"
      placeholder="Pick a country"
      open-on-focus
      class="w-64"
    >
      <template #footer="{ query, selectedOption, clearSelection }">
        <div
          class="flex items-center justify-between border-t border-outline-gray-1 px-3 py-2 text-sm text-ink-gray-5"
        >
          <span v-if="query">
            Searching “<span class="text-ink-gray-7">{{ query }}</span>
          </span>
          <span v-else>{{ countries.length }} countries</span>
          <button
            v-if="selectedOption"
            class="text-ink-gray-7 hover:text-ink-gray-8"
            @click="clearSelection"
          >
            Clear
          </button>
        </div>
      </template>
    </Combobox>

    <div class="text-sm text-ink-gray-5">
      Selected: <code class="text-ink-gray-7">{{ value || 'None' }}</code>
    </div>
  </div>
</template>

In Dialog

Combobox rendered inside a Dialog. Verifies focus restores to the trigger after the popover closes, even when wrapped by the Dialog's focus scope.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { Button, Combobox, Dialog } from 'frappe-ui'

const open = ref(false)
const repo = ref('frappe-ui')
const reaction = ref('')

const repos = [
  'gameplan',
  'frappe-ui',
  'frappe',
  'erpnext',
  'helpdesk',
  'crm',
  'wiki',
  'insights',
]

const emojis = [
  {
    group: 'Smileys',
    options: [
      { label: 'Grinning', value: 'grinning', icon: '😀' },
      { label: 'Laughing', value: 'laughing', icon: '😂' },
      { label: 'Heart Eyes', value: 'heart-eyes', icon: '😍' },
      { label: 'Thinking', value: 'thinking', icon: '🤔' },
    ],
  },
  {
    group: 'Gestures',
    options: [
      { label: 'Thumbs Up', value: 'thumbs-up', icon: '👍' },
      { label: 'Party', value: 'party', icon: '🎉' },
      { label: 'Fire', value: 'fire', icon: '🔥' },
    ],
  },
]
</script>

<template>
  <Button @click="open = true">Open dialog</Button>

  <Dialog v-model="open">
    <template #body-title>
      <h3 class="text-2xl font-semibold text-ink-gray-9">
        Combobox inside Dialog
      </h3>
    </template>

    <template #body-content>
      <div class="space-y-4">
        <div class="flex flex-col gap-1">
          <label class="text-sm text-ink-gray-7">Repository</label>
          <Combobox
            v-model="repo"
            :options="repos"
            placeholder="Pick a repo"
            open-on-focus
          />
        </div>

        <div class="flex flex-col gap-1">
          <label class="text-sm text-ink-gray-7">Reaction</label>
          <Combobox
            v-model="reaction"
            trigger="button"
            :options="emojis"
            placeholder="Pick a reaction"
          >
            <template #prefix>
              <span class="lucide-smile size-4 text-ink-gray-6" />
            </template>
          </Combobox>
        </div>

        <div class="rounded bg-surface-gray-1 p-3 text-sm text-ink-gray-7">
          <div>
            Repo: <code>{{ repo || 'None' }}</code>
          </div>
          <div>
            Reaction: <code>{{ reaction || 'None' }}</code>
          </div>
        </div>
      </div>
    </template>

    <template #actions="{ close }">
      <Button variant="solid" @click="close">Done</Button>
    </template>
  </Dialog>
</template>

Label, Description, Error

Combobox supports label, description, error, and required directly — no FormControl wrapper needed. The error suppresses the description and wires aria-invalid + aria-errormessage onto the input.

Start typing to filter.

vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Checkbox, Combobox } from 'frappe-ui'

const value = ref<string | null>(null)
const required = ref(true)
const showError = ref(false)

const error = computed(() => (showError.value ? 'Please pick an option.' : ''))

const options = [
  { label: 'Apple', value: 'apple' },
  { label: 'Banana', value: 'banana' },
  { label: 'Cherry', value: 'cherry' },
  { label: 'Durian', value: 'durian' },
]
</script>

<template>
  <div class="flex gap-8 items-start">
    <Combobox
      v-model="value"
      :options="options"
      label="Favourite fruit"
      description="Start typing to filter."
      :error="error"
      :required="required"
      placeholder="Pick one"
      class="w-72"
    />
    <div
      class="flex flex-col gap-2 items-start border-l border-outline-gray-2 pl-6"
    >
      <Checkbox v-model="required" label="required" />
      <Checkbox v-model="showError" label="show error" />
    </div>
  </div>
</template>

API Reference

Show types
typescript
import type { Component, VNode, VNodeChild } from 'vue'
import type { InputLabelingProps } from '../../composables/useInputLabeling'

export type ComboboxVariant = 'subtle' | 'outline' | 'ghost'
export type ComboboxSize = 'sm' | 'md' | 'lg' | 'xl'

export type PopoverSide = 'top' | 'right' | 'bottom' | 'left'
export type PopoverAlign = 'start' | 'center' | 'end'

/** @deprecated alias for `align` */
export type ComboboxPlacement = PopoverAlign

export type ComboboxSlotFn<TProps> = (props: TProps) => VNodeChild

export interface ComboboxItemSlots<TProps> {
  /** Replaces the prefix region of the standard row shell. */
  prefix?: ComboboxSlotFn<TProps>

  /** Replaces the label region of the standard row shell. */
  label?: ComboboxSlotFn<TProps>

  /** Replaces the suffix region of the standard row shell. */
  suffix?: ComboboxSlotFn<TProps>

  /** Replaces the entire row; mutually exclusive with `prefix` / `label` / `suffix`. */
  item?: ComboboxSlotFn<TProps>
}

export type ComboboxSelectableOption = {
  type?: 'option'
  label: string
  value: string
  icon?: string | Component
  description?: string
  disabled?: boolean
  slot?: string
  /** Per-item inline slot implementations for the row shell. */
  slots?: ComboboxItemSlots<ComboboxItemSlotProps>
  /** @deprecated use `slot` */
  slotName?: string
  /** @deprecated use `slots` — function form maps to `slots.item`, object form to `slots` */
  render?: (() => VNode | VNode[]) | ComboboxItemSlots<ComboboxItemSlotProps>
  [key: string]: any
}

export type ComboboxCustomOptionContext = {
  query: string
  /** @deprecated use `query` */
  searchTerm: string
}

export type ComboboxCustomOption = {
  type: 'custom'
  key: string
  label: string
  icon?: string | Component
  description?: string
  disabled?: boolean
  slot?: string
  /** Per-item inline slot implementations for the row shell. */
  slots?: ComboboxItemSlots<ComboboxItemSlotProps>
  /** @deprecated use `slot` */
  slotName?: string
  onClick: (context: ComboboxCustomOptionContext) => void
  keepOpen?: boolean
  condition?: (context: ComboboxCustomOptionContext) => boolean
  /** @deprecated use `slots` — function form maps to `slots.item`, object form to `slots` */
  render?: (() => VNode | VNode[]) | ComboboxItemSlots<ComboboxItemSlotProps>
  [key: string]: any
}

export type SelectableOption = ComboboxSelectableOption
export type CustomOption = ComboboxCustomOption

export type ComboboxSimpleOption =
  | string
  | ComboboxSelectableOption
  | ComboboxCustomOption

export type SimpleOption = ComboboxSimpleOption

export interface ComboboxGroupedOption {
  key?: string | number
  group: string
  hideLabel?: boolean
  options: ComboboxSimpleOption[]
}

export type GroupedOption = ComboboxGroupedOption
export type ComboboxOption = ComboboxSimpleOption | ComboboxGroupedOption

export interface ComboboxProps extends InputLabelingProps {
  /** Options rendered in the popover. */
  options?: ComboboxOption[]

  /**
   * Shape of the trigger.
   * - `'input'` (default): user types directly into the trigger
   * - `'button'`: render a button trigger; search input moves into the
   *   popover header. Label + prefix auto-derive from the selected option.
   */
  trigger?: 'input' | 'button'

  /** Visual style of the combobox. */
  variant?: ComboboxVariant

  /** Size of the trigger and option rows. */
  size?: ComboboxSize

  /** Placeholder text shown when no value is selected. */
  placeholder?: string

  /** Disables the combobox. */
  disabled?: boolean

  /** Controls the popover visibility. */
  open?: boolean

  /** Opens the popover when the input receives focus. */
  openOnFocus?: boolean

  /** Opens the popover when the input is clicked. */
  openOnClick?: boolean

  /** Preferred popover side. */
  side?: PopoverSide

  /** Preferred popover alignment. */
  align?: PopoverAlign

  /** Gap between trigger and content. */
  offset?: number

  /** Teleport target for the popover content. */
  portalTo?: string | HTMLElement

  /**
   * Free-form acceptance: the typed query is accepted as the model value
   * when nothing matches, and external `modelValue` updates with unknown
   * strings are preserved. The combobox also renders a built-in "Create X"
   * row as a click affordance.
   *
   * For richer create-new UX (custom label / icon / persistence callback),
   * prefer a `type: 'custom'` option with `condition` instead — see the
   * Create New story. The two are independent and can be combined.
   */
  allowCustomValue?: boolean

  /** Replaces the results with a loading state. */
  loading?: boolean

  /** Fallback empty-state copy. */
  emptyText?: string

  /**
   * Alignment of the popover along the trigger edge.
   * @deprecated use `align` instead; `placement` is kept as a back-compat alias
   */
  placement?: ComboboxPlacement
}

export interface ComboboxTriggerSlotProps {
  /** Whether the popover is open. */
  open: boolean

  /** Whether the combobox is disabled. */
  disabled: boolean

  /** Current input query. */
  query: string

  /** Resolved selected option, if any. */
  selectedOption: ComboboxSelectableOption | null

  /** Resolved display text for the committed value. */
  displayValue: string

  /** Clears the current selection (sets the model to `null`). */
  clearSelection: () => void

  /** Toggles the popover open state (no-op while disabled). */
  toggleOpen: () => void
}

/**
 * Shared shape for `#trigger`, `#prefix`, and `#suffix`. `selectedOption`
 * is always `null` in `#prefix` because the prefix only renders before a
 * selection — the field is still exposed for slot-prop symmetry across
 * the trio.
 */
export type ComboboxSlotProps = ComboboxTriggerSlotProps
export type ComboboxPrefixSlotProps = ComboboxSlotProps
export type ComboboxSuffixSlotProps = ComboboxSlotProps

export interface ComboboxItemSlotProps {
  /** Item currently being rendered. */
  item: ComboboxSelectableOption | ComboboxCustomOption

  /** Current search query — empty when the user hasn't typed since opening. */
  query: string

  /** Whether the item is selected. */
  selected: boolean
}

export interface ComboboxGroupLabelSlotProps {
  /** Group currently being rendered. */
  group: ComboboxGroupedOption
}

export interface ComboboxEmptySlotProps {
  /** Current search query — empty when the user hasn't typed since opening. */
  query: string
}

export interface ComboboxFooterSlotProps {
  /** Current search query — empty when the user hasn't typed since opening. */
  query: string

  /** Resolved selected option, if any. */
  selectedOption: ComboboxSelectableOption | null

  /** Clears the current selection (sets the model to `null`). */
  clearSelection: () => void
}

export interface ComboboxSlots {
  /** Fully custom trigger renderer. */
  trigger?: (props: ComboboxTriggerSlotProps) => any

  /** Overrides the rendered label content. Receives `{ required }`. */
  label?: (props: { required: boolean }) => any

  /** Overrides the rendered description content. */
  description?: () => any

  /** Content rendered before the default input. Receives the same shape
   * as `#trigger` and `#suffix` (`ComboboxSlotProps`). */
  prefix?: (props: ComboboxPrefixSlotProps) => any

  /**
   * Content rendered after the input (input mode) or label (button mode).
   * Providing this slot **replaces the default chevron** — render your
   * own fallback (e.g. the chevron) when your slot content is conditional.
   * Common use: an inline clear button. Use `@click.stop` and
   * `@pointerdown.stop` so the press doesn't toggle the popover.
   */
  suffix?: (props: ComboboxSuffixSlotProps) => any

  /** Shared content rendered before the standard row label. */
  'item-prefix'?: (props: ComboboxItemSlotProps) => any

  /** Shared content rendered for the standard row label area. */
  'item-label'?: (props: ComboboxItemSlotProps) => any

  /** Shared content rendered after the standard row label area. */
  'item-suffix'?: (props: ComboboxItemSlotProps) => any

  /** Replaces the entire row. */
  item?: (props: ComboboxItemSlotProps) => any

  /** Custom renderer for group labels. */
  'group-label'?: (props: ComboboxGroupLabelSlotProps) => any

  /** Fallback content rendered when there are no results. */
  empty?: (props: ComboboxEmptySlotProps) => any

  /** Content rendered after the list. Stays pinned below the scrollable
   * options. */
  footer?: (props: ComboboxFooterSlotProps) => any

  [slotName: string]: ((props: any) => any) | undefined
}

export interface ComboboxEmits {
  /** Fired when the open state changes. */
  'update:open': [value: boolean]

  /** Fired when the query changes due to user input. */
  'update:query': [value: string]

  /** Fired when the resolved selected option changes. */
  'update:selectedOption': [
    option: ComboboxSelectableOption | ComboboxCustomOption | null,
  ]

  /** Fired when the input receives focus. */
  focus: [event: FocusEvent]

  /** Fired when the input loses focus. */
  blur: [event: FocusEvent]

  /** @deprecated compatibility alias for `update:query`. */
  input: [value: string]
}

export interface ComboboxExposed {
  reset: () => void
  focus: () => void
}
options
= []
ComboboxOption[]

Options rendered in the popover.

trigger
= "input"
"button" | "input"

Shape of the trigger. - `'input'` (default): user types directly into the trigger - `'button'`: render a button trigger; search input moves into the popover header. Label + prefix auto-derive from the selected option.

variant
= "subtle"
ComboboxVariant

Visual style of the combobox.

size
= "sm"
ComboboxSize

Size of the trigger and option rows.

placeholder
= "Select option"
string

Placeholder text shown when no value is selected.

disabled
= false
boolean

Disables the combobox.

open
boolean

Controls the popover visibility.

openOnFocus
= false
boolean

Opens the popover when the input receives focus.

openOnClick
= true
boolean

Opens the popover when the input is clicked.

side
= "bottom"
PopoverSide

Preferred popover side.

align
PopoverAlign

Preferred popover alignment.

offset
= 4
number

Gap between trigger and content.

portalTo
= "body"
string | HTMLElement

Teleport target for the popover content.

allowCustomValue
= false
boolean

Free-form acceptance: the typed query is accepted as the model value when nothing matches, and external `modelValue` updates with unknown strings are preserved. The combobox also renders a built-in "Create X" row as a click affordance. For richer create-new UX (custom label / icon / persistence callback), prefer a `type: 'custom'` option with `condition` instead — see the Create New story. The two are independent and can be combined.

loading
= false
boolean

Replaces the results with a loading state.

emptyText
= "No results"
string

Fallback empty-state copy.

placement

Deprecated — use `align` instead; `placement` is kept as a back-compat alias

label
string

Label rendered above (or beside, for binary controls) the input.

description
string

Helper text rendered below the input. Hidden when `error` is set.

error
string | FrappeUIError

Error message rendered below the input. When set, the control receives `aria-invalid="true"` and `data-state="invalid"`. May be either a string or an `Error` object whose `messages?: string[]` is rendered as stacked lines (with `Error.message` as the fallback).

required
boolean

Marks the field as required. Renders an asterisk next to the label and forwards `required` / `aria-required` to the underlying control.

id
string

HTML id of the underlying control. Auto-generated via `useId()` if omitted.

modelValue
= null
string | null
trigger
ComboboxTriggerSlotProps

Fully custom trigger renderer.

label
{ required: boolean; }

Overrides the rendered label content. Receives `{ required }`.

description

Overrides the rendered description content.

prefix
ComboboxTriggerSlotProps

Content rendered before the default input. Receives the same shape as `#trigger` and `#suffix` (`ComboboxSlotProps`).

suffix
ComboboxTriggerSlotProps

Content rendered after the input (input mode) or label (button mode). Providing this slot **replaces the default chevron** — render your own fallback (e.g. the chevron) when your slot content is conditional. Common use: an inline clear button. Use `@click.stop` and `@pointerdown.stop` so the press doesn't toggle the popover.

item-prefix
ComboboxItemSlotProps

Shared content rendered before the standard row label.

item-label
ComboboxItemSlotProps

Shared content rendered for the standard row label area.

item-suffix
ComboboxItemSlotProps

Shared content rendered after the standard row label area.

item
ComboboxItemSlotProps

Replaces the entire row.

group-label
ComboboxGroupLabelSlotProps

Custom renderer for group labels.

empty
ComboboxEmptySlotProps

Fallback content rendered when there are no results.

footer
ComboboxFooterSlotProps

Content rendered after the list. Stays pinned below the scrollable options.

update:modelValue
[value: string | null]

Fired when the model value changes.

update:query
[value: string]

Fired when the query changes.

input
[value: string]
update:open
[value: boolean]

Fired when the open state changes.

update:selectedOption
[option: ComboboxSelectableOption | ComboboxCustomOption | null]

Fired when the selected option changes.

focus
[event: FocusEvent]
blur
[event: FocusEvent]