{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "combobox",
  "type": "registry:component",
  "title": "Combobox",
  "description": "A text field that filters a list of options as you type.",
  "dependencies": [
    "@base-ui/react",
    "tailwind-variants"
  ],
  "registryDependencies": [
    "https://panelui.dev/r/web/spinner.json",
    "utils"
  ],
  "files": [
    {
      "path": "components/panelui/combobox.tsx",
      "type": "registry:component",
      "target": "@components/panelui/combobox.tsx",
      "content": "'use client';\n\n/**\n * Combobox — a text field that filters a list of options as you type.\n *\n * ```tsx\n * <Combobox value={framework} onValueChange={setFramework} clearable>\n *   <Combobox.Item value=\"expo\" label=\"Expo\" />\n *   <Combobox.Item value=\"next\" label=\"Next.js\" />\n * </Combobox>\n * ```\n *\n * ## Where the typing happens\n *\n * A Select is a button that opens a list, and its optional filter lives inside\n * that list once it opens. A Combobox is the field itself: the options appear\n * while you are already typing, which is what you want when the value is\n * something you know the name of — a city, a repository, a tag — rather than\n * something you expect to recognise by scrolling.\n *\n * ## One value or several\n *\n * `mode=\"multiple\"` draws the chosen options as removable chips in front of\n * the input and keeps the list open between picks. The value is an array in\n * that mode and a single string otherwise, so the type follows the mode.\n *\n * ## Filtering is yours to turn off\n *\n * Filtering happens here by default, matching case-insensitively on any part\n * of an option's label. Pass a function to `filter` to match on something\n * else. Pass `filter={false}` when a server is doing the matching: the options\n * are then shown exactly as given, because a second filter over results the\n * field cannot see the query behind would only remove correct answers.\n *\n * ## Values it does not know about\n *\n * `allowCustomValue` lets the typed text become the value when it matches no\n * option, committed on Enter. The list becomes a set of suggestions rather\n * than the set of legal answers, which is what a tag field is.\n *\n * ## Two presentations\n *\n * `overlay` (the default) floats the list above the page, anchored under the\n * field and flipped above it when there is no room below. `inline` expands the\n * list in normal layout flow instead, which is right in a form where nothing\n * should be covered.\n */\nimport { Combobox as BaseCombobox } from '@base-ui/react/combobox';\nimport {\n  Children,\n  Fragment,\n  isValidElement,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ComponentProps,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type ReactNode,\n} from 'react';\nimport { tv } from 'tailwind-variants';\nimport { cn } from '@/lib/utils';\nimport { Spinner } from '@/components/panelui/spinner';\n\nconst comboboxVariants = tv({\n  slots: {\n    root: 'w-full',\n    field: [\n      'group/combobox flex w-full cursor-text flex-wrap items-center gap-2 rounded-lg border border-input bg-background px-3 py-1 text-sm shadow-xs',\n      'transition-[border-color,box-shadow] duration-150',\n      'focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/30',\n      'has-disabled:cursor-not-allowed has-disabled:opacity-50 dark:bg-input/30',\n    ],\n    content: 'flex min-w-0 flex-1 flex-wrap items-center gap-1.5',\n    input: [\n      'h-7 min-w-24 flex-1 bg-transparent text-sm text-foreground outline-none',\n      'placeholder:text-muted-foreground/80 selection:bg-primary selection:text-primary-foreground',\n      'disabled:cursor-not-allowed',\n    ],\n    chip: [\n      'inline-flex h-6 shrink-0 items-center gap-1 rounded-full bg-accent px-2 text-xs font-medium text-accent-foreground outline-none',\n      'focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-1 focus-within:ring-offset-background',\n      'data-disabled:opacity-50',\n    ],\n    chipRemove: [\n      'relative -me-1 inline-flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground outline-none',\n      'hover:bg-foreground/10 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring',\n      '[&_svg]:size-3',\n      // A target larger than the mark, without moving the chip.\n      \"after:absolute after:-inset-1.5 after:content-['']\",\n    ],\n    action: [\n      'inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground outline-none',\n      'transition-[background-color,color,scale] duration-150 hover:bg-accent hover:text-foreground active:scale-90',\n      'focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',\n      'motion-reduce:transition-none motion-reduce:active:scale-100',\n      '[&_svg:not([class*=size-])]:size-4',\n    ],\n    chevron: 'transition-transform duration-200 motion-reduce:transition-none',\n    positioner: 'z-50 outline-none',\n    popup: [\n      'max-h-[min(20rem,var(--available-height))] overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-lg outline-none',\n      'origin-(--transform-origin) transition-[opacity,scale] duration-150 ease-out',\n      'data-starting-style:scale-95 data-starting-style:opacity-0 data-ending-style:scale-95 data-ending-style:opacity-0',\n      'motion-reduce:transition-none',\n    ],\n    inline: 'mt-2 max-h-80 overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-sm',\n    list: 'max-h-80 scroll-py-1 overflow-y-auto overscroll-contain p-1.5 [scrollbar-color:var(--border)_transparent] [scrollbar-width:thin]',\n    item: [\n      'flex min-h-10 cursor-default items-center gap-2.5 rounded-lg px-3 py-2 outline-none select-none',\n      'data-highlighted:bg-accent data-highlighted:text-accent-foreground data-selected:bg-accent data-selected:text-accent-foreground',\n      'data-disabled:pointer-events-none data-disabled:opacity-50',\n      '[&_svg]:pointer-events-none [&_svg]:shrink-0',\n    ],\n    itemStart: 'flex size-5 shrink-0 items-center justify-center [&_svg:not([class*=size-])]:size-4',\n    itemContent: 'flex min-w-0 flex-1 flex-col',\n    itemLabel: 'truncate text-sm font-medium',\n    itemDescription: 'truncate text-xs text-muted-foreground',\n    // The box is always there, so an option does not change width as it is chosen.\n    indicator: 'flex size-5 shrink-0 items-center justify-center text-current [&_svg:not([class*=size-])]:size-4',\n    group: 'flex flex-col gap-0.5',\n    groupLabel: 'px-3 pb-1 pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground',\n    empty: 'px-3 py-6 text-center text-sm text-muted-foreground',\n    status: 'flex items-center justify-center gap-2 px-3 py-6 text-sm text-muted-foreground',\n  },\n});\n\n/** How the list is presented. */\nexport type ComboboxPresentation = 'overlay' | 'inline';\n\n/** Which selection shape a `mode` produces. */\nexport type ComboboxMode = 'single' | 'multiple';\n\n/** The value shape each mode produces. */\nexport interface ComboboxSelection {\n  single: string | undefined;\n  multiple: string[];\n}\n\n/** One option, declared as a child of `Combobox`. */\nexport interface ComboboxItemProps {\n  /** Identifies the option; what `value` and `onValueChange` carry. */\n  value: string;\n  /** What is shown, and what the default filter matches on. */\n  label: string;\n  /**\n   * Shows the option but refuses it. Kept in the list rather than dropped from\n   * it, because an option that vanishes reads as one that never existed.\n   */\n  disabled?: boolean;\n  /** Anything to draw before the label — an avatar, a flag, a status dot. */\n  start?: ReactNode;\n  /** A second line under the label, for what the label alone cannot say. */\n  description?: ReactNode;\n  /** Extra classes for this option. */\n  className?: string;\n}\n\n/** A titled run of options. */\nexport interface ComboboxGroupProps {\n  /**\n   * Heading over the run of options. Announced as a group label, so a screen\n   * reader reaching the group is told what it is before walking into it.\n   */\n  label?: string;\n  /** Extra classes for the group wrapper. */\n  className?: string;\n  /** Extra classes for the heading. */\n  labelClassName?: string;\n  children: ReactNode;\n}\n\n/**\n * Declarative option. `Combobox` reads it from its children and renders it in\n * whichever surface is presenting; it is never rendered on its own.\n */\nfunction ComboboxItem(_props: ComboboxItemProps): never {\n  throw new Error('Combobox.Item must be used within a <Combobox>');\n}\nComboboxItem.displayName = 'Combobox.Item';\n\n/**\n * A titled run of options. Presentational only: a grouped Combobox reports the\n * same values a flat one would, and `Combobox.Item` needs to know nothing\n * about being inside one.\n */\nfunction ComboboxGroup(_props: ComboboxGroupProps): never {\n  throw new Error('Combobox.Group must be used within a <Combobox>');\n}\nComboboxGroup.displayName = 'Combobox.Group';\n\nexport interface ComboboxProps<Mode extends ComboboxMode = 'single'>\n  extends Omit<ComponentProps<'div'>, 'children' | 'defaultValue' | 'onChange' | 'onInput'> {\n  /** One value or several. `multiple` draws the chosen options as chips and keeps the list open. */\n  mode?: Mode;\n  /** Controlled selection. Its shape follows `mode`. */\n  value?: ComboboxSelection[Mode];\n  /** Starting selection when uncontrolled. */\n  defaultValue?: ComboboxSelection[Mode];\n  /** Called with the next selection, whose shape follows `mode`. */\n  onValueChange?: (value: ComboboxSelection[Mode]) => void;\n  /**\n   * Controlled query — the text actually in the field. Pair it with\n   * `onInputValueChange` when the options are fetched for it.\n   */\n  inputValue?: string;\n  /** Starting query when uncontrolled. */\n  defaultInputValue?: string;\n  /** Called with the typed text. */\n  onInputValueChange?: (value: string) => void;\n  /** What the field says while empty. Default `Search`. */\n  placeholder?: string;\n  /** Whether the field and its list are unavailable. */\n  disabled?: boolean;\n  /** Where the options appear. Default `overlay`. */\n  presentation?: ComboboxPresentation;\n  /**\n   * Narrow the options to the query here. `true` matches case-insensitively on\n   * any part of an option's label; pass a function to match on something else.\n   *\n   * Pass `false` when a server is doing the matching: the options you render\n   * are then shown exactly as given.\n   */\n  filter?: boolean | ((option: ComboboxItemProps, query: string) => boolean);\n  /**\n   * Let the typed text become the value when it matches no option, committed\n   * on Enter. Turns the list into a set of suggestions rather than the set of\n   * legal answers.\n   */\n  allowCustomValue?: boolean;\n  /** Show a spinner in place of the list. For options still being fetched. */\n  loading?: boolean;\n  /** Shown in place of the list when nothing matches. Default `No matches`. */\n  emptyMessage?: string;\n  /** Shown in place of the list while `loading`. Default `Searching`. */\n  loadingMessage?: string;\n  /** Offer a ✕ that clears the query and the selection. */\n  clearable?: boolean;\n  /** Open the list as soon as the field takes focus. */\n  openOnFocus?: boolean;\n  /** Called when the list opens or closes. */\n  onOpenChange?: (open: boolean) => void;\n  /**\n   * Width of the floating list. `field` matches the field, `content` sizes to\n   * the longest option, or pass a pixel value. `overlay` only.\n   */\n  contentWidth?: 'field' | 'content' | number;\n  /** Gap between the field and the floating list, in pixels. Default `8`. */\n  offset?: number;\n  /** Extra classes for the list surface. */\n  listClassName?: string;\n  /** Accessible name for the field. */\n  accessibilityLabel?: string;\n  /** The input's id, so a `<label htmlFor>` can name it. */\n  id?: string;\n  /** Form field name, so the value is sent with the form. */\n  name?: string;\n  /** The form the field belongs to. */\n  form?: string;\n  /** Whether the field must have a value before the form can be sent. */\n  required?: boolean;\n  /** Where in the document an overlay list is put. Default `document.body`. */\n  container?: BaseCombobox.Portal.Props['container'];\n  children: ReactNode;\n}\n\ninterface OptionData {\n  value: string;\n  label: string;\n  disabled?: boolean;\n  start?: ReactNode;\n  description?: ReactNode;\n  className?: string;\n}\n\ninterface GroupData {\n  /** A key for the group; unique within the list. */\n  key: string;\n  label?: string;\n  items: OptionData[];\n  className?: string;\n  labelClassName?: string;\n}\n\ninterface ItemModel {\n  data: OptionData[] | GroupData[];\n  grouped: boolean;\n  options: OptionData[];\n}\n\n/** React does not walk fragments for `Children.forEach`, so unwrap them first. */\nfunction withoutFragments(children: ReactNode): ReactNode[] {\n  const flat: ReactNode[] = [];\n  Children.forEach(children, (child) => {\n    if (isValidElement(child) && child.type === Fragment) {\n      flat.push(...withoutFragments((child.props as { children?: ReactNode }).children));\n      return;\n    }\n    flat.push(child);\n  });\n  return flat;\n}\n\n/**\n * Walk the declared children into the flat or grouped shape the primitive\n * filters and renders.\n *\n * Loose options become one unlabelled group when anything on the list is\n * grouped, so the rendering surface has a single shape to handle without\n * changing the order the reader wrote.\n */\nfunction readItems(children: ReactNode): ItemModel {\n  const options: OptionData[] = [];\n  const groups: GroupData[] = [];\n  let loose: OptionData[] = [];\n  let grouped = false;\n\n  const flush = () => {\n    if (loose.length === 0) return;\n    groups.push({ key: `loose-${groups.length}`, items: loose });\n    options.push(...loose);\n    loose = [];\n  };\n\n  withoutFragments(children).forEach((child, index) => {\n    if (!isValidElement(child)) return;\n\n    if (child.type === ComboboxGroup) {\n      const props = child.props as ComboboxGroupProps;\n      const items: OptionData[] = [];\n      withoutFragments(props.children).forEach((nested) => {\n        if (!isValidElement(nested) || nested.type !== ComboboxItem) return;\n        items.push((nested.props as ComboboxItemProps) as OptionData);\n      });\n      if (items.length === 0) return;\n      flush();\n      grouped = true;\n      groups.push({\n        key: `group-${index}-${props.label ?? ''}`,\n        label: props.label,\n        items,\n        className: props.className,\n        labelClassName: props.labelClassName,\n      });\n      options.push(...items);\n      return;\n    }\n\n    if (child.type === ComboboxItem) {\n      const option = child.props as ComboboxItemProps as OptionData;\n      options.push(option);\n      if (grouped) groups.push({ key: `loose-${index}`, items: [option] });\n      else loose.push(option);\n    }\n  });\n\n  if (!grouped) {\n    return { data: options, grouped: false, options };\n  }\n\n  flush();\n  return { data: groups, grouped: true, options };\n}\n\nfunction CheckIcon() {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.25\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n      <path d=\"M20 6 9 17l-5-5\" />\n    </svg>\n  );\n}\n\nfunction ChevronDownIcon() {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n      <path d=\"m6 9 6 6 6-6\" />\n    </svg>\n  );\n}\n\nfunction XIcon() {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n      <path d=\"M18 6 6 18M6 6l12 12\" />\n    </svg>\n  );\n}\n\nfunction ComboboxRoot<Mode extends ComboboxMode = 'single'>(allProps: ComboboxProps<Mode>) {\n  const {\n    className,\n    mode,\n    value,\n    defaultValue,\n    onValueChange,\n    inputValue,\n    defaultInputValue,\n    onInputValueChange,\n    placeholder = 'Search',\n    disabled = false,\n    presentation = 'overlay',\n    filter = true,\n    allowCustomValue = false,\n    loading = false,\n    emptyMessage = 'No matches',\n    loadingMessage = 'Searching',\n    clearable = false,\n    openOnFocus = false,\n    onOpenChange,\n    contentWidth = 'field',\n    offset = 8,\n    listClassName,\n    accessibilityLabel,\n    id,\n    name,\n    form,\n    required,\n    container,\n    children,\n    ...props\n  } = allProps;\n\n  // An explicit `undefined` is still a controlled value: a parent clearing a\n  // single selection sends undefined, and treating that as \"uncontrolled\"\n  // would resurrect the last internal choice.\n  const valueControlled = Object.prototype.hasOwnProperty.call(allProps, 'value');\n  const inputValueControlled = Object.prototype.hasOwnProperty.call(allProps, 'inputValue');\n  const multiple = mode === 'multiple';\n  const slots = comboboxVariants();\n  const model = useMemo(() => readItems(children), [children]);\n  const options = model.options;\n\n  // A selected value can leave the rendered options when a server or the\n  // caller filters them. Remember the labels it was shown with, so a chip or\n  // the field does not fall back to an id.\n  const labelCache = useRef(new Map<string, string>());\n  useEffect(() => {\n    for (const option of options) labelCache.current.set(option.value, option.label);\n  }, [options]);\n\n  const labelOf = useCallback(\n    (candidate: string) =>\n      options.find((option) => option.value === candidate)?.label ??\n      labelCache.current.get(candidate) ??\n      candidate,\n    [options]\n  );\n\n  const initialSelection = (valueControlled ? value : defaultValue) as\n    | string\n    | string[]\n    | undefined;\n  const initialSingle = multiple ? undefined : (initialSelection as string | undefined);\n\n  const [internalValue, setInternalValue] = useState<ComboboxSelection[Mode]>(\n    () => (defaultValue ?? (multiple ? [] : undefined)) as ComboboxSelection[Mode]\n  );\n  const [internalQuery, setInternalQuery] = useState(\n    () => defaultInputValue ?? (initialSingle != null ? labelOf(initialSingle) : '')\n  );\n  const [open, setOpenState] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const [highlighted, setHighlighted] = useState<OptionData | undefined>(undefined);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const rootRef = useRef<HTMLDivElement>(null);\n\n  const selection = (valueControlled ? value : internalValue) as\n    | string\n    | string[]\n    | undefined;\n  const singleValue = multiple ? undefined : (selection as string | undefined);\n  const values = useMemo(() => {\n    if (selection == null) return [];\n    return Array.isArray(selection) ? selection : [selection];\n  }, [selection]);\n\n  const query = inputValueControlled ? (inputValue ?? '') : internalQuery;\n\n  const setQuery = useCallback(\n    (next: string) => {\n      if (!inputValueControlled) setInternalQuery(next);\n      onInputValueChange?.(next);\n    },\n    [inputValueControlled, onInputValueChange]\n  );\n\n  const commit = useCallback(\n    (next: ComboboxSelection[Mode]) => {\n      if (!valueControlled) setInternalValue(next);\n      onValueChange?.(next);\n    },\n    [valueControlled, onValueChange]\n  );\n\n  const setOpen = useCallback((next: boolean) => setOpenState(next), []);\n\n  // Report a change once, after it has been accepted, rather than from inside\n  // a state updater where React may run it twice.\n  const reportedOpen = useRef<boolean | null>(null);\n  useEffect(() => {\n    if (reportedOpen.current === open) return;\n    const first = reportedOpen.current === null;\n    reportedOpen.current = open;\n    if (!first) onOpenChange?.(open);\n  }, [open, onOpenChange]);\n\n  // A single-select field shows the chosen option's label when it is not being\n  // typed into. Base UI writes it on a pick; an external value change has to be\n  // written back here.\n  const previousValue = useRef(singleValue);\n  const previousLabel = useRef(singleValue == null ? '' : labelOf(singleValue));\n  useEffect(() => {\n    if (multiple || inputValueControlled) return;\n    const nextLabel = singleValue == null ? '' : labelOf(singleValue);\n    const valueChanged = previousValue.current !== singleValue;\n    const labelChanged = previousLabel.current !== nextLabel;\n    if (!valueChanged && !labelChanged) return;\n    // A selection or its label arriving while the reader is typing waits for\n    // the blur; writing it now would fight the query. The references advance\n    // only when the update is applied, so blur can still see it is pending.\n    if (focused) return;\n    const unedited =\n      query === '' || query === previousLabel.current || query === previousValue.current;\n    previousValue.current = singleValue;\n    previousLabel.current = nextLabel;\n    if (unedited) setQuery(nextLabel);\n    // `setQuery` is stable per controlled-ness; re-running on every identity\n    // change would overwrite the query the caller is controlling.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [singleValue, multiple, focused, inputValueControlled, labelOf, query]);\n\n  const itemCollection = useMemo(\n    () =>\n      BaseCombobox.createItems(model.data as OptionData[], {\n        getValue: (option: OptionData) => option.value,\n        getLabel: (option: OptionData) => option.label,\n      }),\n    [model]\n  );\n\n  const baseFilter = useMemo(() => {\n    if (filter === false) return null;\n    if (typeof filter === 'function') {\n      return (option: OptionData, raw: string) => filter(option, raw.trim());\n    }\n    return (option: OptionData, raw: string) => {\n      const needle = raw.trim().toLowerCase();\n      return needle.length === 0 || option.label.toLowerCase().includes(needle);\n    };\n  }, [filter]);\n\n  const close = useCallback(() => setOpen(false), [setOpen]);\n\n  // Inline has no floating layer to notice an outside press, so the page\n  // itself closes it. Escape is the other way out, as it is for a popover.\n  useEffect(() => {\n    if (presentation !== 'inline' || !open) return;\n    function onPointerDown(event: PointerEvent) {\n      if (rootRef.current?.contains(event.target as Node)) return;\n      close();\n    }\n    function onKeyDown(event: globalThis.KeyboardEvent) {\n      if (event.key === 'Escape') close();\n    }\n    document.addEventListener('pointerdown', onPointerDown);\n    document.addEventListener('keydown', onKeyDown);\n    return () => {\n      document.removeEventListener('pointerdown', onPointerDown);\n      document.removeEventListener('keydown', onKeyDown);\n    };\n  }, [presentation, open, close]);\n\n  const handleOpenChange = useCallback(\n    (next: boolean, details: { reason: string; cancel: () => void }) => {\n      // Picking an option in multiple mode adds a chip; it must not close the\n      // list, or taking several in a row becomes a reopen per pick. Emptying\n      // the query has the same effect on the primitive, and should not close\n      // the list either.\n      if (\n        multiple &&\n        !next &&\n        (details.reason === 'item-press' || details.reason === 'none' || details.reason === 'input-clear')\n      ) {\n        details.cancel();\n        return;\n      }\n      setOpen(next);\n    },\n    [multiple, setOpen]\n  );\n\n  const handleInputChange = useCallback(\n    (next: string, details: { reason: string }) => {\n      if (!inputValueControlled) setInternalQuery(next);\n      onInputValueChange?.(next);\n      // Typing opens the list; a label written back by a pick does not.\n      if (details.reason === 'input-change' && !open) setOpen(true);\n    },\n    [inputValueControlled, onInputValueChange, open, setOpen]\n  );\n\n  const handleValueChange = useCallback(\n    (next: unknown, details?: { reason: string }) => {\n      if (multiple) {\n        commit((Array.isArray(next) ? next : []) as ComboboxSelection[Mode]);\n        // Canceling the primitive's close also skips its query cleanup. A pick\n        // has done the query's job, so clear it here and keep the list open.\n        if (details?.reason === 'item-press') setQuery('');\n        return;\n      }\n      const chosen = (next as string | null) ?? undefined;\n      commit(chosen as ComboboxSelection[Mode]);\n      if (chosen === undefined) return;\n      // A single pick writes its label back and closes the list. An overlay\n      // list does that for itself; an inline list, which has no popup to close,\n      // has to be told.\n      if (presentation === 'inline') setQuery(labelOf(chosen));\n      setOpen(false);\n    },\n    [multiple, commit, presentation, setQuery, labelOf, setOpen]\n  );\n\n  const exactMatch = useMemo(\n    () => options.find((option) => option.label.toLowerCase() === query.trim().toLowerCase()),\n    [options, query]\n  );\n\n  const submitCustom = useCallback(() => {\n    const typed = query.trim();\n    if (!typed) return;\n\n    if (exactMatch) {\n      if (exactMatch.disabled) return;\n      if (multiple) {\n        if (!values.includes(exactMatch.value)) {\n          commit([...values, exactMatch.value] as ComboboxSelection[Mode]);\n        }\n        setQuery('');\n        return;\n      }\n      commit(exactMatch.value as ComboboxSelection[Mode]);\n      setQuery(exactMatch.label);\n      setOpen(false);\n      return;\n    }\n\n    if (!allowCustomValue) return;\n\n    if (multiple) {\n      if (!values.includes(typed)) commit([...values, typed] as ComboboxSelection[Mode]);\n      setQuery('');\n      return;\n    }\n\n    commit(typed as ComboboxSelection[Mode]);\n    setOpen(false);\n  }, [query, exactMatch, allowCustomValue, multiple, values, commit, setQuery, setOpen]);\n\n  const onInputKeyDown = useCallback(\n    (event: ReactKeyboardEvent<HTMLInputElement>) => {\n      if (event.key !== 'Enter') return;\n      if (event.nativeEvent.isComposing) return;\n      // With a highlighted suggestion, Enter is the primitive's to take: the\n      // reader has pointed at the option they want, not at the typed text.\n      // The exception is an exact match in multiple mode, which has to stay\n      // open while the value is added to the chips.\n      const highlightedExact = multiple && highlighted && exactMatch && highlighted.value === exactMatch.value;\n      if (highlighted && !highlightedExact) return;\n      if (!exactMatch && !allowCustomValue) return;\n      event.preventDefault();\n      if (highlightedExact) {\n        (event as ReactKeyboardEvent<HTMLInputElement> & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.();\n      }\n      submitCustom();\n    },\n    [multiple, highlighted, exactMatch, allowCustomValue, submitCustom]\n  );\n\n  const clear = useCallback(() => {\n    setQuery('');\n    commit((multiple ? [] : undefined) as ComboboxSelection[Mode]);\n    setHighlighted(undefined);\n    inputRef.current?.focus();\n  }, [setQuery, commit, multiple]);\n\n  const hasContent = query.length > 0 || values.length > 0;\n\n  const contentWidthClass =\n    contentWidth === 'field'\n      ? 'w-(--anchor-width)'\n      : contentWidth === 'content'\n        ? 'w-max min-w-(--anchor-width) max-w-(--available-width)'\n        : 'max-w-(--available-width)';\n  const contentWidthStyle =\n    typeof contentWidth === 'number' ? { width: contentWidth } : undefined;\n\n  // Kept in the tree while idle, so the live region exists before the text\n  // that changes it does.\n  const status = (\n    <BaseCombobox.Status className={loading ? slots.status() : 'sr-only'}>\n      {loading ? (\n        <>\n          <Spinner size=\"sm\" />\n          {loadingMessage}\n        </>\n      ) : null}\n    </BaseCombobox.Status>\n  );\n\n  const body = loading ? null : (\n    <>\n      <BaseCombobox.Empty>\n        <div className={slots.empty()}>\n          {allowCustomValue && query.trim() && !exactMatch\n            ? `Press Enter to add “${query.trim()}”`\n            : emptyMessage}\n        </div>\n      </BaseCombobox.Empty>\n      {model.grouped ? (\n        <BaseCombobox.List className={slots.list()}>\n          {(group: GroupData) => (\n            <BaseCombobox.Group key={group.key} items={group.items} className={cn(slots.group(), group.className)}>\n              {group.label ? (\n                <BaseCombobox.GroupLabel className={cn(slots.groupLabel(), group.labelClassName)}>\n                  {group.label}\n                </BaseCombobox.GroupLabel>\n              ) : null}\n              <BaseCombobox.Collection>\n                {(option: OptionData) => <Option key={option.value} option={option} slots={slots} />}\n              </BaseCombobox.Collection>\n            </BaseCombobox.Group>\n          )}\n        </BaseCombobox.List>\n      ) : (\n        <BaseCombobox.List className={slots.list()}>\n          {(option: OptionData) => <Option key={option.value} option={option} slots={slots} />}\n        </BaseCombobox.List>\n      )}\n    </>\n  );\n\n  const input = (\n    <BaseCombobox.Input\n      ref={inputRef}\n      id={id}\n      className={slots.input()}\n      value={query}\n      placeholder={multiple && values.length > 0 ? undefined : placeholder}\n      aria-label={accessibilityLabel}\n      aria-expanded={open}\n      aria-description={\n        multiple && values.length > 0\n          ? `${values.length} selected. From the start of the input, press Left Arrow to focus the selected options`\n          : undefined\n      }\n      disabled={disabled}\n      autoComplete=\"off\"\n      autoCapitalize=\"none\"\n      autoCorrect=\"off\"\n      onFocus={() => {\n        setFocused(true);\n        if (openOnFocus) setOpen(true);\n      }}\n      onBlur={() => setFocused(false)}\n      onKeyDown={onInputKeyDown}\n    />\n  );\n\n  const field = (\n    <BaseCombobox.InputGroup data-slot=\"combobox-field\" className={slots.field()}>\n      <div className={slots.content()}>\n        {multiple ? (\n          <BaseCombobox.Chips className=\"flex min-w-0 flex-1 flex-wrap items-center gap-1.5\" aria-label={values.length ? 'Selected options' : undefined}>\n            {values.map((entry) => (\n              <BaseCombobox.Chip key={entry} aria-description=\"Press Backspace or Delete to remove\" className={slots.chip()}>\n                {labelOf(entry)}\n                <BaseCombobox.ChipRemove aria-label={`Remove ${labelOf(entry)}`} className={slots.chipRemove()}>\n                  <XIcon />\n                </BaseCombobox.ChipRemove>\n              </BaseCombobox.Chip>\n            ))}\n            {input}\n          </BaseCombobox.Chips>\n        ) : (\n          input\n        )}\n      </div>\n\n      {clearable && hasContent && !disabled ? (\n        <button\n          type=\"button\"\n          // The overlay hides the rest of the page from assistive technology\n          // while it is open. A clear button that can be tabbed to in a hidden\n          // region is a reading-order trap, so it stays out of the tab order —\n          // as the primitive's own clear does.\n          tabIndex={-1}\n          aria-label=\"Clear\"\n          className={slots.action()}\n          onMouseDown={(event) => event.preventDefault()}\n          onClick={clear}\n        >\n          <XIcon />\n        </button>\n      ) : null}\n\n      {presentation === 'inline' ? (\n        <button\n          type=\"button\"\n          aria-label={open ? 'Hide options' : 'Show options'}\n          aria-expanded={open}\n          disabled={disabled}\n          className={slots.action()}\n          onClick={() => {\n            if (open) {\n              close();\n              return;\n            }\n            setOpen(true);\n            inputRef.current?.focus();\n          }}\n        >\n          <span className={cn(slots.chevron(), open && 'rotate-180')}>\n            <ChevronDownIcon />\n          </span>\n        </button>\n      ) : (\n        <BaseCombobox.Trigger\n          aria-label={open ? 'Hide options' : 'Show options'}\n          disabled={disabled}\n          className={slots.action()}\n          onClick={() => inputRef.current?.focus()}\n        >\n          <span className={cn(slots.chevron(), open && 'rotate-180')}>\n            <ChevronDownIcon />\n          </span>\n        </BaseCombobox.Trigger>\n      )}\n    </BaseCombobox.InputGroup>\n  );\n\n  if (presentation === 'inline') {\n    return (\n      <div ref={rootRef} data-slot=\"combobox\" className={cn(slots.root(), className)} {...props}>\n        <BaseCombobox.Root\n          items={itemCollection}\n          value={(multiple ? values : (singleValue ?? null)) as never}\n          onValueChange={handleValueChange}\n          inputValue={query}\n          onInputValueChange={handleInputChange}\n          onOpenChange={handleOpenChange}\n          onItemHighlighted={(next: string | undefined) => {\n            setHighlighted(options.find((option) => option.value === next));\n          }}\n          filter={baseFilter as never}\n          itemToStringLabel={(next: string) => labelOf(next)}\n          multiple={multiple as never}\n          inline\n          open={open}\n          openOnInputClick={openOnFocus}\n          disabled={disabled}\n          required={required}\n          name={name}\n          form={form}\n        >\n          {field}\n          {open ? (\n            <div className={cn(slots.inline(), listClassName)}>\n              {status}\n              {body}\n            </div>\n          ) : null}\n        </BaseCombobox.Root>\n      </div>\n    );\n  }\n\n  return (\n    <div data-slot=\"combobox\" className={cn(slots.root(), className)} {...props}>\n      <BaseCombobox.Root\n        items={itemCollection}\n        value={(multiple ? values : (singleValue ?? null)) as never}\n        onValueChange={handleValueChange}\n        inputValue={query}\n        onInputValueChange={handleInputChange}\n        onOpenChange={handleOpenChange}\n        onItemHighlighted={(next: string | undefined) => {\n          setHighlighted(options.find((option) => option.value === next));\n        }}\n        filter={baseFilter as never}\n        itemToStringLabel={(next: string) => labelOf(next)}\n        multiple={multiple as never}\n        open={open}\n        openOnInputClick={openOnFocus}\n        disabled={disabled}\n        required={required}\n        name={name}\n        form={form}\n      >\n        {field}\n        <BaseCombobox.Portal container={container}>\n          <BaseCombobox.Positioner side=\"bottom\" align=\"start\" sideOffset={offset} collisionPadding={8} className={slots.positioner()}>\n            <BaseCombobox.Popup\n              data-slot=\"combobox-content\"\n              className={cn(slots.popup(), contentWidthClass, listClassName)}\n              style={contentWidthStyle}\n            >\n              {status}\n              {body}\n            </BaseCombobox.Popup>\n          </BaseCombobox.Positioner>\n        </BaseCombobox.Portal>\n      </BaseCombobox.Root>\n    </div>\n  );\n}\nComboboxRoot.displayName = 'Combobox';\n\nfunction Option({\n  option,\n  slots,\n}: {\n  option: OptionData;\n  slots: ReturnType<typeof comboboxVariants>;\n}) {\n  return (\n    <BaseCombobox.Item\n      value={option.value}\n      disabled={option.disabled}\n      className={cn(slots.item(), option.className)}\n    >\n      {option.start ? (\n        <span className={slots.itemStart()} aria-hidden=\"true\">\n          {option.start}\n        </span>\n      ) : null}\n      <span className={slots.itemContent()}>\n        <span className={slots.itemLabel()}>{option.label}</span>\n        {option.description ? <span className={slots.itemDescription()}>{option.description}</span> : null}\n      </span>\n      <span className={slots.indicator()}>\n        <BaseCombobox.ItemIndicator>\n          <CheckIcon />\n        </BaseCombobox.ItemIndicator>\n      </span>\n    </BaseCombobox.Item>\n  );\n}\n\nexport const Combobox = Object.assign(ComboboxRoot, {\n  Item: ComboboxItem,\n  Group: ComboboxGroup,\n});\n\nexport { ComboboxItem, ComboboxGroup };\n"
    }
  ]
}
