{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "combobox",
  "type": "registry:ui",
  "title": "Combobox",
  "description": "Type to find one value, or several, in a long list: a client, a framework, a teammate. The Input's field with the Menu's glass list under it: filtered as you type, ignoring case and accents, with the first match lit, one gliding highlight, a check on each pick and optional group labels. With multiple the picks sit in the field as removable tokens. Solid inside a dialog, and anchored under the field on a phone.",
  "dependencies": [
    "@radix-ui/react-popover",
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/glide.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/motion.json",
    "https://littmd.vercel.app/r/tag.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/combobox.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/combobox 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Combobox: type to find one value, or several, in a long list (components.md, \"Combobox\"): a client, a\n * framework, a teammate. Select is for a short fixed list, and Command menu is for actions.\n *\n * - Field: the Input's cut (R3: the --litt-cut floor, the control line and the top wall's shade) and sizes (sm 32, md 44,\n *   lg 56; 16px text on coarse pointers, where sm grows to 44), with the same focus line and ring, error line and\n *   flat disabled look. The text is the query. A 16px chevron button in\n *   --litt-fg-subtle sits at the end, --litt-control-gap from the text, and opens and closes the list; it never turns.\n *   The label sits above and the hint or error under it, wired as TextField's are. Under Device the field is LCD glass in the mono, through the --litt-field tokens.\n * - Flat (proposals/flat-pill.md): the field is a grey pill with no outline (ink ring on focus, danger ring on an\n *   error), tokens are --litt-surface-1 bubbles on it; list rows are grey bubbles at the panel radius less its 4px padding,\n *   groups parted by a gap, no separators.\n * - List: anchored under the field and flipped above it when there is no room, as wide as the field. It is the\n *   Menu's panel: glass at the surface radius with the 4px inset, 44px rows at the control radius, one gliding\n *   highlight (glide.tsx), and the picked option's 16px check in a fixed cell at the row's start, so labels never\n *   shift. Group labels are the Menu's. At most eight rows show, then it scrolls inside. With no match a quiet row\n *   says so and nothing is lit. Opened inside glass (a dialog, a sheet, the header, a popover) it is solid\n *   (bg-litt-surface-2 + shadow-litt-float), found from the DOM when it opens.\n * - Behavior: the combobox and listbox pattern. Focus stays in the field and aria-activedescendant names the lit\n *   option; rows never take focus (the panel prevents mousedown). Typing opens the list and filters it in the\n *   original order, ignoring case and accents, and lights the first match. With an empty query the picked option\n *   is lit. ArrowDown opens or moves, ArrowUp moves, both wrap and skip disabled options, and Enter picks the lit\n *   option. Esc closes the list first (the list is a Radix layer, so inside a dialog it closes only the list), then\n *   puts back the picked option's label. Leaving the field closes the list and puts the label back, so the field\n *   never holds text that is not a value; a field left empty clears the pick. Every key handler returns early\n *   while an IME is composing.\n * - multiple: the picks sit inside the field before the query as Token input's tokens (raised Tags, R3: blocks you can\n *   lift out of the cut, flat while the field is disabled; a 24px remove button that grows to 44px on coarse\n *   pointers). A pick clears the query and keeps the list open, and\n *   picking a picked option again removes it. Backspace in an empty query removes the last token. ArrowLeft at the\n *   start of the query walks into the remove buttons, which are not Tab stops, so the field stays one Tab stop.\n * - Positioning is Radix Popover with the field as its anchor (role, focus and the trigger are ours): it places\n *   and flips the list, gives its transform origin and the room on screen, closes it on Esc and outside presses,\n *   and stacks it above a dialog's own layer. After the first placement the side is pinned, so the list never\n *   jumps from one side to the other while typing changes its height.\n * - Motion, responsive tier, as the Menu: the list grows from the field (--radix-popover-content-transform-origin),\n *   travels a quarter of the travel token from the side it really opens on, and leaves on useExit with its rows\n *   frozen, so a pick never flashes the full list on the way out. Typing changes the list at once and its height\n *   is never animated. Tokens fade in with a 0.98 scale, their neighbors slide by transform (layout=\"position\"),\n *   and a removed one fades out on the exit token. The edge and the ring fade on the instant tier.\n * - Compact screens (useDevice().compact): the list stays anchored, never a sheet, because a sheet would cover the\n *   field. Once placed, its height is the visible screen on its side of the field less --litt-space-2, read from the\n *   visual viewport (useVisibleScreen), so no option hides behind the keyboard.\n * - Coarse pointers: a tap on the chevron opens the list without focusing the field, so the keyboard stays shut\n *   while a person browses.\n * - hideLabel keeps the label for screen readers only, where the row around the field already says what it holds (the\n *   value of a rule in a Rule builder).\n * - Accessibility: a real label; aria-expanded, aria-controls and aria-autocomplete=\"list\" on the field; a polite\n *   live region says how many options match a moment after typing stops, and with multiple what was added or\n *   removed. A form gets one hidden field per value under `name`.\n */\nimport {\n  Fragment,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ChangeEvent,\n  type KeyboardEvent,\n  type MouseEvent,\n  type ReactNode,\n  type RefObject,\n} from \"react\";\nimport * as PopoverPrimitive from \"@radix-ui/react-popover\";\nimport { AnimatePresence, animate, motion, useIsPresent, useMotionValue } from \"motion/react\";\nimport { useDevice, useExit, useTransition, useTravel, useVisibleScreen } from \"../../lib/litt/motion\";\nimport { useOverlayMotion } from \"../../lib/litt/motion/deal\";\nimport { CheckIcon, ChevronDownIcon } from \"./icons\";\nimport { Tag } from \"./tag\";\nimport { GLIDE_ROW, useGlide, type GlideSource } from \"../../lib/litt/glide\";\n\n/* Radix's asChild, spread in: shadcn's CLI rewrites a written asChild to Base UI's render prop in base-* hosts. */\nconst asChildProps = { asChild: true } as const;\n\nexport type ComboboxSize = \"sm\" | \"md\" | \"lg\";\n\nexport type ComboboxOption = {\n  /** What the form sends and onValueChange receives. Keep it unique, and not an empty string. */\n  value: string;\n  /** What people read in the list, and what the field shows once it is picked. The search matches it, ignoring case and accents. */\n  label: string;\n  /** Dims the option. The arrow keys skip it, and a click does nothing. */\n  disabled?: boolean;\n  /** Files the option under a group label, such as \"Past clients\". Options of one group show together, in the order the groups first appear. */\n  group?: string;\n};\n\nexport type ComboboxGroup = {\n  /** The group label above its options, at the caption size. */\n  label: string;\n  /** The options in the group, in order. */\n  options: ComboboxOption[];\n};\n\n/** One value is a string (\"\" while nothing is picked); with multiple it is an array of strings. */\nexport type ComboboxValue<Multiple extends boolean> = Multiple extends true ? string[] : string;\n\nexport type ComboboxProps<Multiple extends boolean = false> = {\n  /** The label above the field. Required, because a placeholder is not a label. Clicking it focuses the field. */\n  label: ReactNode;\n  /** Hides the label from sight and keeps it for screen readers, where the row already says what the field holds, such as the value of a rule. */\n  hideLabel?: boolean;\n  /** A hint under the field. An error replaces it. */\n  hint?: ReactNode;\n  /** Shown in danger color under the field and linked with aria-describedby. Also draws the danger edge and sets aria-invalid. Say how to fix it. */\n  error?: ReactNode;\n  /** The choices: a flat list of { value, label, disabled?, group? }, or groups of { label, options }. */\n  options: ComboboxOption[] | ComboboxGroup[];\n  /** Pick several values. They sit in the field as removable tokens, and the list stays open after each pick. */\n  multiple?: Multiple;\n  /** The picked value, when you control it: a string, or an array with multiple. Pair it with onValueChange. */\n  value?: ComboboxValue<Multiple>;\n  /** The value to start with when you do not control it. */\n  defaultValue?: ComboboxValue<Multiple>;\n  /** Called with the new value after a pick, a removal, or a field left empty: a string (\"\" for none), or an array with multiple. */\n  onValueChange?: (value: ComboboxValue<Multiple>) => void;\n  /** Hint text inside the empty field, in the subtle color. It is never a label. With multiple it shows only while nothing is picked. */\n  placeholder?: string;\n  /** Height on the shared control scale: sm is 32, md is 44, lg is 56. With multiple it is the minimum, and the field grows as tokens wrap. */\n  size?: ComboboxSize;\n  /** The field name a form sends the value under, through one hidden input per value. */\n  name?: string;\n  /** What the quiet row says when nothing matches. It gets the query. The default reads No matches for “marl”. */\n  emptyText?: (query: string) => ReactNode;\n  /** Makes the field unavailable: muted, skipped by Tab, and left out of a form, like a native field. */\n  disabled?: boolean;\n  /** The id of the text field, for a label or an error summary link of your own. */\n  id?: string;\n  /** Extra classes for the whole component: the label, the field and the note under it. */\n  className?: string;\n};\n\ntype Side = \"top\" | \"right\" | \"bottom\" | \"left\";\n\n/** An option with its place in the whole list and its label folded for matching. */\ntype Entry = ComboboxOption & { index: number; folded: string };\ntype Section = { key: string; label: string | null; options: Entry[] };\n\n/** What the list must not be glass over: any glass surface, and any popover. */\nconst FLOATING = \".litt-glass, [data-popover-surface]\";\n/** The gap between the field and the list, in px. */\nconst GAP = 8;\n/** Eight 44px rows and the 4px inset above and below them. */\nconst EIGHT_ROWS = \"calc(8 * var(--litt-control-md) + var(--litt-space-2))\";\n/** How long typing must pause before the live region says how many options match, in ms. */\nconst SETTLE = 500;\n\n/** Lowercase, with accents taken off, so \"zurich\" finds \"Zürich\". */\nconst fold = (text: string) => text.normalize(\"NFD\").replace(/\\p{M}/gu, \"\").toLowerCase();\n\n/** Groups or a flat list, as sections in order. Flat options with a group gather under it where the group first appears. */\nfunction sectionsOf(options: ComboboxOption[] | ComboboxGroup[]): Section[] {\n  const out: Section[] = [];\n  let index = 0;\n  const entry = (o: ComboboxOption): Entry => ({ ...o, index: index++, folded: fold(o.label) });\n  for (const item of options) {\n    if (\"options\" in item) {\n      out.push({ key: `g${out.length}`, label: item.label, options: item.options.map(entry) });\n      continue;\n    }\n    const label = item.group ?? null;\n    let section = out.find((s) => s.label === label);\n    if (!section) out.push((section = { key: `g${out.length}`, label, options: [] }));\n    section.options.push(entry(item));\n  }\n  return out;\n}\n\nconst toList = (value: string | string[] | undefined): string[] | undefined =>\n  value === undefined ? undefined : Array.isArray(value) ? value : value === \"\" ? [] : [value];\n\n/* ---------------------------------------------------------------- the field */\n\n/* The Input's cut (floor, line, shade) and its focus, error and disabled looks, on a wrapper that holds the text\n   field (and the tokens). The ring follows the text field only: the remove buttons draw their own. */\nconst FIELD =\n  \"relative flex w-full min-w-0 cursor-text bg-litt-field font-litt-field font-normal text-litt-field-ink \" +\n  \"shadow-[inset_0_0_0_1px_var(--litt-field-line),var(--litt-field-shade)] \" +\n  \"transition-[box-shadow] duration-(--litt-dur-instant) ease-litt \" +\n  \"has-[input:focus-visible]:shadow-[inset_0_0_0_1px_var(--litt-focus),var(--litt-field-shade),var(--litt-focus-ring)] \" +\n  \"data-[invalid]:shadow-[inset_0_0_0_1px_var(--litt-danger),var(--litt-field-shade)] \" +\n  \"data-[invalid]:has-[input:focus-visible]:shadow-[inset_0_0_0_1px_var(--litt-danger),var(--litt-field-shade),var(--litt-focus-ring)] \" +\n  \"data-[disabled]:cursor-not-allowed data-[disabled]:bg-litt-surface-2 data-[disabled]:shadow-[inset_0_0_0_1px_var(--litt-line)] \" +\n  \"pointer-coarse:text-(length:--litt-control-text-lg) \" +\n  /* Flat: a grey field with no outline; the ink ring on focus, the danger ring on an error. */\n  \"in-[[data-material=flat]]:shadow-none in-[[data-material=flat]]:has-[input:focus-visible]:shadow-(--litt-focus-ring) \" +\n  \"in-[[data-material=flat]]:data-[invalid]:shadow-[0_0_0_2px_var(--litt-canvas),0_0_0_4px_var(--litt-danger)] \" +\n  \"in-[[data-material=flat]]:data-[invalid]:has-[input:focus-visible]:shadow-[0_0_0_2px_var(--litt-canvas),0_0_0_4px_var(--litt-danger)] \" +\n  \"in-[[data-material=flat]]:data-[disabled]:shadow-none \" +\n  /* Forced colors drops box-shadow, so the edge becomes a real border and focus gets an outline. */\n  \"forced-colors:border forced-colors:has-[input:focus-visible]:outline-2 forced-colors:has-[input:focus-visible]:outline-solid forced-colors:has-[input:focus-visible]:outline-offset-2\";\n\n/* The corners: the control radius, but never past half the one-line height, so a field that wraps to more rows of\n   tokens keeps the corners it had on one line and stays concentric with the tokens inside, instead of turning into a\n   lozenge at the pill. On one line it is the same as the control radius. */\nconst RADIUS: Record<ComboboxSize, string> = {\n  sm: \"rounded-[min(var(--litt-radius-control),calc(var(--litt-control-sm)/2))] pointer-coarse:rounded-[min(var(--litt-radius-control),calc(var(--litt-control-md)/2))]\",\n  md: \"rounded-[min(var(--litt-radius-control),calc(var(--litt-control-md)/2))]\",\n  lg: \"rounded-[min(var(--litt-radius-control),calc(var(--litt-control-lg)/2))]\",\n};\n\nconst TEXT_SIZE: Record<ComboboxSize, string> = {\n  sm: \"text-litt-small\",\n  md: \"text-litt-body\",\n  lg: \"text-(length:--litt-control-text-lg)\",\n};\n\n/* One value: a fixed height, and the text field fills it, so a click anywhere places the caret. */\nconst ONE_FIELD: Record<ComboboxSize, string> = {\n  sm: \"h-litt-control-sm pointer-coarse:h-litt-control-md\",\n  md: \"h-litt-control-md\",\n  lg: \"h-litt-control-lg\",\n};\nconst ONE_INPUT: Record<ComboboxSize, string> = {\n  sm: \"pl-litt-control-pad-sm\",\n  md: \"pl-litt-control-pad-md\",\n  lg: \"pl-litt-control-pad-lg\",\n};\nconst ONE_CHEVRON: Record<ComboboxSize, string> = {\n  sm: \"pl-litt-control pr-litt-control-pad-sm\",\n  md: \"pl-litt-control pr-litt-control-pad-md\",\n  lg: \"pl-litt-control pr-litt-control-pad-lg\",\n};\n\n/* Several values: Token input's padding on the 4px grid, so the field grows by whole 24px token rows. The chevron\n   cancels the padding to fill the field's height and keeps the control padding from the right edge. */\nconst MANY_FIELD: Record<ComboboxSize, string> = {\n  sm: \"min-h-litt-control-sm px-1 py-1 pointer-coarse:min-h-litt-control-md\",\n  md: \"min-h-litt-control-md px-2 py-2\",\n  lg: \"min-h-litt-control-lg px-4 py-4\",\n};\nconst MANY_CHEVRON: Record<ComboboxSize, string> = {\n  sm: \"-my-1 -mr-1 pl-litt-control pr-litt-control-pad-sm\",\n  md: \"-my-2 -mr-2 pl-litt-control pr-litt-control-pad-md\",\n  lg: \"-my-4 -mr-4 pl-litt-control pr-litt-control-pad-lg\",\n};\n\nconst CHEVRON =\n  \"grid shrink-0 cursor-pointer place-items-center self-stretch text-litt-field-ink-subtle outline-none \" +\n  \"[-webkit-tap-highlight-color:transparent] transition-[color] duration-(--litt-dur-instant) ease-litt hover:text-litt-field-ink \" +\n  \"disabled:cursor-not-allowed disabled:text-litt-fg-disabled disabled:hover:text-litt-fg-disabled\";\n\nconst REMOVE =\n  \"relative grid size-6 shrink-0 cursor-pointer place-items-center rounded-litt-control text-litt-fg-subtle in-[[data-material=flat]]:rounded-litt-pill \" +\n  \"transition-[color] duration-(--litt-dur-instant) ease-litt hover:text-litt-fg \" +\n  \"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-litt-focus \" +\n  \"disabled:cursor-not-allowed disabled:text-litt-fg-disabled disabled:hover:text-litt-fg-disabled \" +\n  /* An invisible 44px target around the 24px button on a finger. */\n  \"pointer-coarse:before:absolute pointer-coarse:before:-inset-2.5\";\n\n/** A 16px cross, drawn smaller than the 24px button so it sits with the caption-size label (Token input's). */\nfunction RemoveIcon() {\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" aria-hidden=\"true\" focusable=\"false\">\n      <path d=\"M5.5 5.5l5 5M10.5 5.5l-5 5\" />\n    </svg>\n  );\n}\n\n/* ------------------------------------------------------------------ combobox */\n\n/**\n * A labelled field that filters a long list as you type and picks one value, or several with multiple. Give it a\n * label and options, and hold the value in state with value and onValueChange, or pass a defaultValue and a name to\n * post it with a form. The label, the hint and the error are wired up for you.\n */\nexport function Combobox<Multiple extends boolean = false>({\n  label,\n  hideLabel = false,\n  hint,\n  error,\n  options,\n  multiple,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  placeholder,\n  size = \"md\",\n  name,\n  emptyText,\n  disabled = false,\n  id: idProp,\n  className = \"\",\n}: ComboboxProps<Multiple>) {\n  const many = multiple === true;\n  const uid = useId();\n  const inputId = idProp ?? `${uid}-input`;\n  const labelId = `${uid}-label`;\n  const noteId = `${uid}-note`;\n  const listId = `${uid}-list`;\n  const optionId = (o: Entry) => `${uid}-option-${o.index}`;\n\n  const { coarse } = useDevice();\n  const enter = useTransition(\"responsive\");\n  const exit = useExit(\"responsive\");\n\n  const sections = useMemo(() => sectionsOf(options), [options]);\n  const all = useMemo(() => sections.flatMap((s) => s.options), [sections]);\n  const labelOf = (v: string) => all.find((o) => o.value === v)?.label ?? v;\n\n  const [inner, setInner] = useState<string[]>(() => toList(defaultValue) ?? []);\n  const picked = toList(valueProp) ?? inner;\n\n  /* The typed text. Null while nothing is typed: the field then shows the picked label (one value) or nothing. */\n  const [query, setQuery] = useState<string | null>(null);\n  const [open, setOpen] = useState(false);\n  /* The lit option, when a key or the pointer chose it. Null means the rule: the first match, else the pick. */\n  const [active, setActive] = useState<string | null>(null);\n  const [source, setSource] = useState<GlideSource>(\"keyboard\");\n  const [said, setSaid] = useState(\"\");\n\n  const fieldRef = useRef<HTMLDivElement | null>(null);\n  const inputRef = useRef<HTMLInputElement | null>(null);\n  const buttons = useRef(new Map<string, HTMLButtonElement>());\n  const after = useRef<{ token: string } | \"input\" | null>(null);\n  /* The Esc that closed the list. Radix hears it first, on the document, and React may re-render before the field's\n     own handler runs, so the field checks the event itself rather than `open`. */\n  const spentEscape = useRef<Event | null>(null);\n\n  const text = query ?? (many || picked[0] === undefined ? \"\" : labelOf(picked[0]));\n  const filter = (query ?? \"\").trim();\n\n  const { shown, visible } = useMemo(() => {\n    const q = fold(filter);\n    const kept = sections\n      .map((s) => ({ ...s, options: q ? s.options.filter((o) => o.folded.includes(q)) : s.options }))\n      .filter((s) => s.options.length > 0);\n    return { shown: kept, visible: kept.flatMap((s) => s.options) };\n  }, [sections, filter]);\n\n  const enabled = visible.filter((o) => !o.disabled);\n  const lightable = (v: string | undefined) => v !== undefined && enabled.some((o) => o.value === v);\n  const fallback = filter ? (enabled[0]?.value ?? null) : !many && lightable(picked[0]) ? picked[0] : null;\n  const current = active !== null && lightable(active) ? active : fallback;\n  const currentIndex = current === null ? -1 : visible.findIndex((o) => o.value === current);\n  const currentEntry = currentIndex >= 0 ? visible[currentIndex] : undefined;\n\n  const note = error ?? hint;\n\n  function setValue(next: string[]) {\n    if (valueProp === undefined) setInner(next);\n    onValueChange?.((many ? next : (next[0] ?? \"\")) as ComboboxValue<Multiple>);\n  }\n\n  function openList(how: \"down\" | \"up\" | \"type\" | \"chevron\") {\n    if (disabled) return;\n    setOpen(true);\n    setSource(how === \"chevron\" ? (coarse ? \"touch\" : \"pointer\") : \"keyboard\");\n    if ((how === \"down\" || how === \"up\") && fallback === null && enabled.length) {\n      setActive(enabled[how === \"down\" ? 0 : enabled.length - 1].value);\n    } else if (how !== \"type\") {\n      setActive(null);\n    }\n  }\n\n  function closeList() {\n    setOpen(false);\n    setActive(null);\n  }\n\n  /** Focus left the field: close, and put the label back. A one-value field left empty clears its pick. */\n  function leave() {\n    closeList();\n    if (!many && query !== null && query.trim() === \"\" && picked.length) setValue([]);\n    setQuery(null);\n  }\n\n  function pick(o: Entry) {\n    if (o.disabled) return;\n    if (!many) {\n      if (picked[0] !== o.value) setValue([o.value]);\n      setQuery(null);\n      closeList();\n      return;\n    }\n    const had = picked.includes(o.value);\n    setValue(had ? picked.filter((v) => v !== o.value) : [...picked, o.value]);\n    setQuery(null);\n    setActive(o.value);\n    setSaid(`${had ? \"Removed\" : \"Added\"} ${o.label}`);\n  }\n\n  /** Removes a token. A key moves focus to the next token or the field; a press leaves it where it was. */\n  function remove(v: string, focus: \"stay\" | \"key\") {\n    const at = picked.indexOf(v);\n    if (at < 0 || disabled) return;\n    const next = picked.filter((p) => p !== v);\n    setValue(next);\n    setSaid(`Removed ${labelOf(v)}`);\n    if (focus === \"key\") after.current = next[at] !== undefined ? { token: next[at] } : \"input\";\n  }\n\n  /* Focus lands where the removed token was, once the tokens have re-rendered. */\n  useEffect(() => {\n    const target = after.current;\n    if (!target) return;\n    after.current = null;\n    if (target !== \"input\") {\n      const next = buttons.current.get(target.token);\n      if (next) return next.focus();\n    }\n    inputRef.current?.focus();\n  });\n\n  /* A moment after typing stops, say how many options match. */\n  const count = visible.length;\n  useEffect(() => {\n    if (!open || query === null) return;\n    const timer = window.setTimeout(() => setSaid(count === 0 ? \"No matches\" : count === 1 ? \"1 option\" : `${count} options`), SETTLE);\n    return () => window.clearTimeout(timer);\n  }, [open, query, count]);\n\n  function move(dir: 1 | -1) {\n    if (!enabled.length) return;\n    const at = enabled.findIndex((o) => o.value === current);\n    const next = at < 0 ? enabled[dir === 1 ? 0 : enabled.length - 1] : enabled[(at + dir + enabled.length) % enabled.length];\n    setActive(next.value);\n    setSource(\"keyboard\");\n  }\n\n  function onChange(e: ChangeEvent<HTMLInputElement>) {\n    setQuery(e.target.value);\n    setActive(null);\n    setSource(\"keyboard\");\n    if (!open) openList(\"type\");\n  }\n\n  function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {\n    if (e.nativeEvent.isComposing) return;\n    const el = e.currentTarget;\n    switch (e.key) {\n      case \"ArrowDown\":\n      case \"ArrowUp\": {\n        if (e.altKey && e.key === \"ArrowUp\") {\n          if (open) {\n            e.preventDefault();\n            closeList();\n          }\n          return;\n        }\n        e.preventDefault();\n        if (!open) openList(e.key === \"ArrowDown\" ? \"down\" : \"up\");\n        else move(e.key === \"ArrowDown\" ? 1 : -1);\n        return;\n      }\n      case \"Enter\": {\n        if (!open) return; // a closed list lets Enter submit the form\n        e.preventDefault();\n        if (currentEntry) pick(currentEntry);\n        return;\n      }\n      case \"Escape\": {\n        /* An open list is a Radix layer: its document listener has already closed it with this Esc. */\n        if (open || spentEscape.current === e.nativeEvent) return;\n        if (query !== null) {\n          e.preventDefault();\n          setQuery(null);\n        }\n        return;\n      }\n      case \"Backspace\": {\n        if (many && el.value === \"\" && picked.length > 0) {\n          e.preventDefault();\n          remove(picked[picked.length - 1], \"stay\");\n        }\n        return;\n      }\n      case \"ArrowLeft\": {\n        if (many && picked.length > 0 && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey && el.selectionStart === 0 && el.selectionEnd === 0) {\n          e.preventDefault();\n          buttons.current.get(picked[picked.length - 1])?.focus();\n        }\n        return;\n      }\n    }\n  }\n\n  function onRemoveKeyDown(e: KeyboardEvent<HTMLButtonElement>, v: string, at: number) {\n    if (e.key === \"ArrowLeft\") {\n      e.preventDefault();\n      buttons.current.get(picked[Math.max(0, at - 1)])?.focus();\n    } else if (e.key === \"ArrowRight\" || e.key === \"Escape\") {\n      e.preventDefault();\n      if (e.key === \"ArrowRight\" && at < picked.length - 1) buttons.current.get(picked[at + 1])?.focus();\n      else inputRef.current?.focus();\n    } else if (e.key === \"Backspace\" || e.key === \"Delete\") {\n      e.preventDefault();\n      remove(v, \"key\");\n    }\n  }\n\n  /* A press on the chevron or a remove button keeps focus in the field when it is already there, so the caret and an\n     open keyboard stay put. From elsewhere the button takes focus, which lets the other field go (and its keyboard). */\n  const holdFocus = (e: MouseEvent) => {\n    if (fieldRef.current?.contains(document.activeElement)) e.preventDefault();\n  };\n\n  const input = (\n    <input\n      ref={inputRef}\n      id={inputId}\n      type=\"text\"\n      role=\"combobox\"\n      aria-expanded={open}\n      aria-controls={open ? listId : undefined}\n      aria-autocomplete=\"list\"\n      aria-activedescendant={open && currentEntry ? optionId(currentEntry) : undefined}\n      aria-invalid={error ? true : undefined}\n      aria-describedby={note ? noteId : undefined}\n      value={text}\n      placeholder={many && picked.length > 0 ? undefined : placeholder}\n      disabled={disabled}\n      autoComplete=\"off\"\n      autoCorrect=\"off\"\n      autoCapitalize=\"none\"\n      spellCheck={false}\n      onChange={onChange}\n      onKeyDown={onKeyDown}\n      className={\n        \"min-w-0 flex-1 truncate bg-transparent text-litt-field-ink outline-none placeholder:text-litt-field-ink-subtle \" +\n        \"disabled:cursor-not-allowed disabled:text-litt-fg-disabled disabled:placeholder:text-litt-fg-disabled \" +\n        (many ? \"box-content h-6 pl-2\" : \"h-full \" + ONE_INPUT[size])\n      }\n    />\n  );\n\n  return (\n    <div className={\"grid gap-2 \" + className}>\n      <label id={labelId} htmlFor={inputId} className={\"text-litt-small font-litt-title \" + (disabled ? \"text-litt-fg-disabled\" : \"text-litt-fg\") + (hideLabel ? \" sr-only\" : \"\")}>\n        {label}\n      </label>\n\n      <PopoverPrimitive.Root\n        open={open}\n        onOpenChange={(next) => {\n          if (!next) closeList();\n        }}\n      >\n        <PopoverPrimitive.Anchor {...asChildProps}>\n          <div\n            ref={fieldRef}\n            data-invalid={error ? \"\" : undefined}\n            data-disabled={disabled ? \"\" : undefined}\n            className={[FIELD, RADIUS[size], many ? MANY_FIELD[size] : ONE_FIELD[size], TEXT_SIZE[size]].join(\" \")}\n            onMouseDown={(e) => {\n              /* A press on the field's own air focuses the text field and keeps focus from falling off it. */\n              if (disabled || (e.target as HTMLElement).closest(\"button, input\")) return;\n              e.preventDefault();\n              inputRef.current?.focus();\n            }}\n            onBlur={(e) => {\n              if (e.currentTarget.contains(e.relatedTarget as Node | null)) return; // moving inside the field\n              if (document.activeElement === e.target) return; // the window lost focus, not the field\n              leave();\n            }}\n          >\n            {many ? (\n              <div className=\"relative flex min-w-0 flex-1 flex-wrap items-center gap-1\">\n                <ul role=\"list\" aria-labelledby={labelId} className=\"contents\">\n                  <AnimatePresence initial={false} mode=\"popLayout\">\n                    {picked.map((v, at) => (\n                      <motion.li\n                        key={v}\n                        layout=\"position\"\n                        className=\"flex max-w-full min-w-0 cursor-default\"\n                        initial={{ opacity: 0, scale: 0.98 }}\n                        animate={{ opacity: 1, scale: 1 }}\n                        exit={{ opacity: 0, transition: exit }}\n                        transition={enter}\n                      >\n                        <Tag raised={!disabled} className=\"max-w-full min-w-0 pr-0\">\n                          <span className={\"min-w-0 truncate \" + (disabled ? \"text-litt-fg-subtle\" : \"\")}>{labelOf(v)}</span>\n                          <button\n                            type=\"button\"\n                            ref={(el) => {\n                              if (el) buttons.current.set(v, el);\n                              else buttons.current.delete(v);\n                            }}\n                            aria-label={`Remove ${labelOf(v)}`}\n                            tabIndex={-1}\n                            disabled={disabled}\n                            className={REMOVE}\n                            onMouseDown={holdFocus}\n                            onKeyDown={(e) => onRemoveKeyDown(e, v, at)}\n                            onClick={(e) => remove(v, e.detail === 0 ? \"key\" : \"stay\")}\n                          >\n                            <RemoveIcon />\n                          </button>\n                        </Tag>\n                      </motion.li>\n                    ))}\n                  </AnimatePresence>\n                </ul>\n                {/* A disabled field takes no text, so its query does not claim a line of its own after the tokens. */}\n                <motion.div layout=\"position\" transition={enter} className={\"flex flex-1 \" + (disabled && picked.length ? \"min-w-1\" : \"min-w-[8ch]\")}>\n                  {input}\n                </motion.div>\n              </div>\n            ) : (\n              input\n            )}\n\n            <button\n              type=\"button\"\n              tabIndex={-1}\n              aria-label=\"Show options\"\n              aria-expanded={open}\n              aria-controls={open ? listId : undefined}\n              disabled={disabled}\n              className={CHEVRON + \" \" + (many ? MANY_CHEVRON[size] : ONE_CHEVRON[size])}\n              onMouseDown={holdFocus}\n              onClick={() => {\n                if (open) return closeList();\n                openList(\"chevron\");\n                /* A finger browses without the keyboard; a mouse keeps typing in the field. */\n                if (!coarse) inputRef.current?.focus();\n              }}\n            >\n              <ChevronDownIcon />\n            </button>\n\n            {name && (many ? picked : [picked[0] ?? \"\"]).map((v) => <input key={v} type=\"hidden\" name={name} value={v} disabled={disabled} />)}\n          </div>\n        </PopoverPrimitive.Anchor>\n\n        <PopoverPrimitive.Portal forceMount>\n          <AnimatePresence>\n            {open && (\n              <Panel\n                key=\"list\"\n                field={fieldRef}\n                input={inputRef}\n                listId={listId}\n                labelId={labelId}\n                many={many}\n                shown={shown}\n                visible={visible}\n                current={currentIndex}\n                source={source}\n                picked={picked}\n                empty={emptyText ? emptyText(filter) : filter ? `No matches for “${filter}”` : \"No options\"}\n                optionId={optionId}\n                onPointerRow={(index, type) => {\n                  const o = visible[index];\n                  if (!o) return;\n                  setActive(o.value);\n                  setSource(type === \"touch\" ? \"touch\" : \"pointer\");\n                }}\n                onPick={pick}\n                onEscape={(e) => {\n                  spentEscape.current = e;\n                }}\n              />\n            )}\n          </AnimatePresence>\n        </PopoverPrimitive.Portal>\n      </PopoverPrimitive.Root>\n\n      {note && (\n        <p id={noteId} className={\"text-litt-small \" + (error ? \"text-litt-danger\" : \"text-litt-fg-subtle\")}>\n          {note}\n        </p>\n      )}\n\n      <div role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {said}\n      </div>\n    </div>\n  );\n}\n\n/* --------------------------------------------------------------------- the list */\n\ntype PanelProps = {\n  field: RefObject<HTMLDivElement | null>;\n  input: RefObject<HTMLInputElement | null>;\n  listId: string;\n  labelId: string;\n  many: boolean;\n  shown: Section[];\n  visible: Entry[];\n  /** The lit option's index in `visible`, or -1. */\n  current: number;\n  source: GlideSource;\n  picked: string[];\n  empty: ReactNode;\n  optionId: (o: Entry) => string;\n  onPointerRow: (index: number, type: \"mouse\" | \"touch\") => void;\n  onPick: (o: Entry) => void;\n  /** Hears the Esc that closes the list, so the field does not act on it as well. */\n  onEscape: (e: Event) => void;\n};\n\nconst ROW =\n  \"relative flex h-litt-control-md w-full cursor-pointer select-none items-center gap-3 rounded-litt-control in-[[data-material=flat]]:rounded-[calc(var(--litt-radius-surface)-4px)] px-3 text-litt-small outline-none \" +\n  \"[-webkit-tap-highlight-color:transparent] transition-colors duration-(--litt-dur-instant) ease-litt \" +\n  \"text-litt-fg-muted aria-selected:text-litt-fg data-[active]:text-litt-fg aria-disabled:cursor-not-allowed aria-disabled:text-litt-fg-disabled \" +\n  \"forced-colors:data-[active]:[forced-color-adjust:none] forced-colors:data-[active]:text-[HighlightText] forced-colors:data-[active]:**:text-[HighlightText]\";\n/** The group label row: the Menu's. */\nconst LABEL_ROW = \"flex h-litt-control-sm items-center px-3 text-litt-caption text-litt-fg-subtle litt-legend\";\n\n/**\n * The anchored list. It mounts on open and stays through its exit, so the glide starts fresh each time. While it\n * leaves it keeps the rows it had (only the checks follow the value), so a pick never flashes the full list.\n */\nfunction Panel(props: PanelProps) {\n  const present = useIsPresent();\n  const frozen = useRef(props);\n  if (present) frozen.current = props;\n  const { field, input, listId, labelId, many, shown, visible, current, source, empty, optionId, onPointerRow, onPick, onEscape } =\n    frozen.current;\n  const { picked } = props;\n\n  /* The material's motion language (@/motion/deal): it grows from om.grow, and under Device it switches on and off\n     in steps where it stands, with no slide. */\n  const om = useOverlayMotion();\n  const springIn = useTransition(\"responsive\");\n  const springOut = useExit(\"responsive\");\n  const enter = om.switches ? om.switchIn : springIn;\n  const exit = om.switches ? om.switchOut : springOut;\n  const fullTravel = useTravel();\n  const travel = om.slides ? fullTravel : 0;\n  const latest = useRef(enter);\n  latest.current = enter;\n  const { compact } = useDevice();\n  const screen = useVisibleScreen(compact);\n\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const el = useRef<HTMLDivElement | null>(null);\n  const scroller = useRef<HTMLDivElement | null>(null);\n  /* Null until Radix has placed the list; then the side it really opened on, pinned from there. */\n  const [placed, setPlaced] = useState<Side | null>(null);\n  const placedRef = useRef<Side | null>(null);\n  /* Glass never sits on glass: is the field inside another floating surface? Read once, as the list opens. */\n  const [flat] = useState(() => Boolean(field.current?.closest(FLOATING)));\n  /* Compact: the room on screen beside the field on the list's side, from the visual viewport. */\n  const [room, setRoom] = useState<number | null>(null);\n\n  /* A quarter of the travel, starting nearer the field than the resting place. */\n  const offset = (s: Side) => {\n    const d = travel / 4;\n    return { x: s === \"left\" ? d : s === \"right\" ? -d : 0, y: s === \"top\" ? d : s === \"bottom\" ? -d : 0 };\n  };\n\n  const onPlaced = () => {\n    if (placedRef.current) return;\n    const s = (el.current?.dataset.side as Side | undefined) ?? \"bottom\";\n    placedRef.current = s;\n    const o = offset(s);\n    x.jump(o.x);\n    y.jump(o.y);\n    setPlaced(s);\n  };\n\n  useEffect(() => {\n    if (!placed) return;\n    const runs = [animate(x, 0, latest.current), animate(y, 0, latest.current)];\n    return () => runs.forEach((r) => r.stop());\n  }, [placed, x, y]);\n\n  useLayoutEffect(() => {\n    if (!compact || !placed) return;\n    const read = () => {\n      const r = field.current?.getBoundingClientRect();\n      if (!r) return;\n      const top = screen ? screen.top : 0;\n      const bottom = screen ? screen.top + screen.height : window.innerHeight;\n      setRoom(Math.max(0, Math.floor(placed === \"top\" ? r.top - top - GAP : bottom - r.bottom - GAP)));\n    };\n    read();\n    window.addEventListener(\"scroll\", read, { capture: true, passive: true });\n    window.addEventListener(\"resize\", read);\n    return () => {\n      window.removeEventListener(\"scroll\", read, { capture: true });\n      window.removeEventListener(\"resize\", read);\n    };\n  }, [compact, placed, screen, field]);\n\n  /** Scrolls the list (never the page) so a row, and its group label above it, are in view. */\n  const reveal = (index: number, center = false) => {\n    const box = scroller.current;\n    const row = box?.querySelectorAll<HTMLElement>(\"[data-glide-row]\")[index];\n    if (!box || !row) return;\n    if (center) {\n      box.scrollTop = row.offsetTop - (box.clientHeight - row.offsetHeight) / 2;\n      return;\n    }\n    const head = row.previousElementSibling as HTMLElement | null;\n    const top = (head?.hasAttribute(\"data-group-label\") ? head.offsetTop : row.offsetTop) - 4;\n    const bottom = row.offsetTop + row.offsetHeight + 4;\n    if (index === 0 || top < box.scrollTop) box.scrollTop = top;\n    else if (bottom > box.scrollTop + box.clientHeight) box.scrollTop = bottom - box.clientHeight;\n  };\n\n  const glide = useGlide({\n    keepOnLeave: true,\n    onPointerRow: (index, _row, type) => onPointerRow(index, type),\n  });\n\n  /* The glide follows the lit option. A new list snaps it; a move glides. The list scrolls to it on a key, when the\n     rows change, and once when the list has been placed (its height is known only then). */\n  const { setActive } = glide;\n  const rowsKey = visible.map((o) => o.index).join(\",\");\n  const prev = useRef<{ rows: string | null; placed: Side | null }>({ rows: null, placed: null });\n  useLayoutEffect(() => {\n    const changed = prev.current.rows !== rowsKey;\n    const justPlaced = placed !== null && prev.current.placed === null;\n    prev.current = { rows: rowsKey, placed };\n    setActive(current >= 0 ? current : null, source, { jump: changed });\n    if (current < 0) {\n      if (changed && scroller.current) scroller.current.scrollTop = 0;\n      return;\n    }\n    if (justPlaced) reveal(current, true);\n    else if (changed || source === \"keyboard\") reveal(current);\n  }, [current, source, rowsKey, placed, setActive]);\n\n  const out = offset(placed ?? \"bottom\");\n  const grouped = shown.some((s) => s.label !== null);\n\n  /* Radix forwards onPlaced to its popper, but the popover types omit it. */\n  const placedProp = { onPlaced } as Record<string, unknown>;\n\n  const maxHeight = compact\n    ? room !== null\n      ? `min(${EIGHT_ROWS}, calc(${room}px - var(--litt-space-2)))`\n      : EIGHT_ROWS\n    : `min(${EIGHT_ROWS}, var(--radix-popover-content-available-height, ${EIGHT_ROWS}))`;\n\n  let at = -1;\n  const row = (o: Entry) => {\n    at += 1;\n    const isPicked = picked.includes(o.value);\n    return (\n      <div\n        key={o.index}\n        id={optionId(o)}\n        role=\"option\"\n        aria-selected={isPicked}\n        aria-disabled={o.disabled || undefined}\n        data-active={at === current || undefined}\n        {...GLIDE_ROW}\n        onClick={() => onPick(o)}\n        className={ROW + (grouped ? \" scroll-mt-9\" : \"\")}\n      >\n        <span className=\"grid size-4 shrink-0 place-items-center text-litt-fg\">{isPicked && <CheckIcon />}</span>\n        <span className=\"min-w-0 truncate\">{o.label}</span>\n      </div>\n    );\n  };\n\n  return (\n    <PopoverPrimitive.Content\n      forceMount\n      {...asChildProps}\n      role=\"presentation\"\n      side={placed ?? \"bottom\"}\n      align=\"start\"\n      sideOffset={GAP}\n      collisionPadding={GAP}\n      avoidCollisions={placed === null}\n      hideWhenDetached\n      onOpenAutoFocus={(e) => e.preventDefault()}\n      onCloseAutoFocus={(e) => e.preventDefault()}\n      onEscapeKeyDown={(e) => {\n        /* Composing an IME word: Esc cancels the composition, not the list. */\n        if (e.isComposing) e.preventDefault();\n        else onEscape(e);\n      }}\n      onPointerDownOutside={(e) => {\n        /* The field is outside the list, but a press on it (the text, the chevron, a token) is not a dismissal. */\n        if (field.current?.contains(e.target as Node)) e.preventDefault();\n      }}\n      onFocusOutside={(e) => {\n        if (e.target === input.current) e.preventDefault();\n      }}\n      {...placedProp}\n    >\n      <motion.div\n        ref={el}\n        className={\n          \"relative z-50 w-(--radix-popover-trigger-width) origin-(--radix-popover-content-transform-origin) rounded-litt-surface outline-none \" +\n          \"data-[state=closed]:pointer-events-none \" +\n          (flat ? \"bg-litt-surface-2 shadow-litt-float\" : \"litt-glass\")\n        }\n        initial={{ opacity: 0, scale: om.grow }}\n        animate={{ opacity: placed ? 1 : 0, scale: placed ? 1 : om.grow, transition: enter }}\n        exit={{ opacity: 0, scale: om.grow, x: out.x, y: out.y, transition: exit }}\n        style={{ x, y }}\n        /* Rows never take focus: the caret stays in the field. */\n        onMouseDown={(e) => e.preventDefault()}\n      >\n        {/* The glass cannot clip, its shadow lives on ::before, so this box clips and scrolls the rows. */}\n        <div\n          ref={(node) => {\n            scroller.current = node;\n            glide.ref(node);\n          }}\n          {...glide.props}\n          className=\"relative overflow-y-auto overscroll-contain rounded-litt-surface p-1 [scrollbar-color:var(--litt-line-strong)_transparent] [scrollbar-width:thin]\"\n          style={{ maxHeight }}\n        >\n          {glide.highlight}\n          <div id={listId} role=\"listbox\" aria-labelledby={labelId} aria-multiselectable={many || undefined}>\n            {shown.map((s) =>\n              s.label === null ? (\n                <Fragment key={s.key}>{s.options.map(row)}</Fragment>\n              ) : (\n                <div key={s.key} role=\"group\" aria-labelledby={`${listId}-${s.key}`} className=\"in-[[data-material=flat]]:not-first:mt-2\">\n                  <div id={`${listId}-${s.key}`} data-group-label=\"\" className={LABEL_ROW}>\n                    {s.label}\n                  </div>\n                  {s.options.map(row)}\n                </div>\n              ),\n            )}\n          </div>\n          {visible.length === 0 && (\n            <div className=\"flex h-litt-control-md min-w-0 items-center px-3 text-litt-small text-litt-fg-subtle\">\n              <span className=\"min-w-0 truncate\">{empty}</span>\n            </div>\n          )}\n        </div>\n      </motion.div>\n    </PopoverPrimitive.Content>\n  );\n}\n",
      "target": "components/litt/combobox.tsx"
    }
  ],
  "docs": "Import Combobox from components/litt/combobox. Give it a label and options as { value, label, disabled?, group? } or groups of { label, options }, and hold the value in state with value and onValueChange: a string (\"\" for none), or an array with multiple. Add hint, error and placeholder as on a field, and name to post the value with a form.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
