{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "combobox",
  "type": "registry:component",
  "title": "Combobox",
  "description": "Filters and selects an option from a listbox through one text field.",
  "registryDependencies": [
    "https://vlak.dev/r/vlak-base.json",
    "https://vlak.dev/r/inter.json",
    "https://vlak.dev/r/input.json",
    "https://vlak.dev/r/dropdown-menu.json",
    "https://vlak.dev/r/vlak-lib.json",
    "https://vlak.dev/r/select.json"
  ],
  "dependencies": [
    "@stylexjs/stylex"
  ],
  "devDependencies": [
    "@stylexjs/babel-plugin"
  ],
  "docs": "Vlak leaves are StyleX. Compile them with @stylexjs/babel-plugin (Vite: @stylexjs/unplugin, Next: @stylexjs/nextjs-plugin). If you would rather not run a compiler, import @noorddev/vlak-react instead: it ships precompiled with one stylesheet.",
  "files": [
    {
      "path": "vlak/combobox.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { vlak, mq } from \"./tokens.stylex\";\nimport { rs } from \"./rs\";\nimport { useMergedRefs } from \"./merge-refs\";\nimport { useOverlayPosition } from \"./use-overlay-position\";\n\nimport { menuStyles } from \"./dropdown-menu\";\nimport { optionText, type SelectOption } from \"./select\";\n\nexport interface ComboboxProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\" | \"defaultValue\"> {\n  options: SelectOption[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  placeholder?: string;\n  emptyLabel?: React.ReactNode;\n  disabled?: boolean;\n}\n\nconst styles = stylex.create({\n  root: {\n    position: \"relative\",\n    display: {\n      default: \"inline-block\",\n      [mq.phone]: \"block\",\n    },\n    minWidth: {\n      default: \"12.5rem\",\n      [mq.phone]: 0,\n    },\n    width: {\n      default: null,\n      [mq.phone]: \"100%\",\n    },\n  },\n  empty: {\n    padding: {\n      default: \"10px 12px\",\n      [mq.phone]: \"0.875rem\",\n    },\n    fontSize: {\n      default: \"0.8125rem\",\n      [mq.phone]: \"0.9375rem\",\n    },\n    letterSpacing: \"-0.01em\",\n    color: vlak.gray,\n  },\n});\n\nconst PAGE = 10;\n\n/**\n * Editable combobox: the input holds focus, filters on typed text, and\n * points at the active option with aria-activedescendant.\n */\nexport const Combobox = React.forwardRef<HTMLDivElement, ComboboxProps>(function Combobox({\n  options,\n  value,\n  defaultValue,\n  onValueChange,\n  placeholder = \"Search…\",\n  emptyLabel = \"Nothing found.\",\n  disabled,\n  className,\n  style,\n  id,\n  onBlur,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledby,\n  ...props\n}: ComboboxProps, ref: React.ForwardedRef<HTMLDivElement>) {\n  const idBase = React.useId();\n  const inputId = id ?? `${idBase}-input`;\n  const listboxId = `${idBase}-listbox`;\n  const optionId = (index: number) => `${idBase}-opt-${index}`;\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const setRootRef = useMergedRefs(rootRef, ref);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const activeRef = React.useRef<HTMLDivElement>(null);\n  const [open, setOpen] = React.useState(false);\n  const panelRef = React.useRef<HTMLDivElement>(null);\n  const placement = useOverlayPosition(open, panelRef, inputRef);\n  const [inner, setInner] = React.useState(defaultValue);\n  const [searchValue, setSearchValue] = React.useState(\"\");\n  const [activeIndex, setActiveIndex] = React.useState(0);\n\n  const isControlled = value !== undefined;\n  const current = isControlled ? value : inner;\n  const selected = options.find((o) => o.value === current);\n  const needle = searchValue.trim().toLowerCase();\n  const matches = needle ? options.filter((o) => optionText(o).toLowerCase().includes(needle)) : options;\n  const hasList = open && matches.length > 0;\n  const enabled = matches.flatMap((option, index) => option.disabled ? [] : [index]);\n  const active = enabled.includes(activeIndex) ? activeIndex : enabled[0] ?? -1;\n  React.useEffect(() => { if (disabled) setOpen(false); }, [disabled]);\n\n  React.useEffect(() => {\n    if (!open) return;\n    const onPointerDown = (e: PointerEvent) => {\n      if (!rootRef.current?.contains(e.target as Node)) setOpen(false);\n    };\n    document.addEventListener(\"pointerdown\", onPointerDown);\n    return () => document.removeEventListener(\"pointerdown\", onPointerDown);\n  }, [open]);\n\n  React.useEffect(() => {\n    if (hasList) activeRef.current?.scrollIntoView?.({ block: \"nearest\" });\n  }, [hasList, active]);\n\n  const openList = (at: \"selected\" | \"last\" = \"selected\") => {\n    setSearchValue(\"\");\n    const available = options.flatMap((option, index) => option.disabled ? [] : [index]);\n    const selectedIndex = options.findIndex((o) => o.value === current && !o.disabled);\n    setActiveIndex(at === \"last\" ? available.at(-1) ?? -1 : selectedIndex >= 0 ? selectedIndex : available[0] ?? -1);\n    setOpen(true);\n  };\n\n  const pick = (option: SelectOption) => {\n    if (disabled || option.disabled) return;\n    if (!isControlled) setInner(option.value);\n    onValueChange?.(option.value);\n    setSearchValue(\"\");\n    setOpen(false);\n    inputRef.current?.focus();\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (disabled) return;\n    const move = (offset: number) => {\n      if (!enabled.length) { setActiveIndex(-1); return; }\n      const next = Math.max(0, Math.min(enabled.length - 1, enabled.indexOf(active) + offset));\n      setActiveIndex(enabled[next]!);\n    };\n    if (!open) {\n      if (e.key === \"ArrowDown\") {\n        e.preventDefault();\n        openList();\n      } else if (e.key === \"ArrowUp\") {\n        e.preventDefault();\n        openList(\"last\");\n      }\n      return;\n    }\n    switch (e.key) {\n      case \"ArrowDown\":\n        e.preventDefault();\n        move(1);\n        return;\n      case \"ArrowUp\":\n        e.preventDefault();\n        move(-1);\n        return;\n      case \"Home\":\n        e.preventDefault();\n        setActiveIndex(enabled[0] ?? -1);\n        return;\n      case \"End\":\n        e.preventDefault();\n        setActiveIndex(enabled.at(-1) ?? -1);\n        return;\n      case \"PageUp\":\n        e.preventDefault();\n        move(-PAGE);\n        return;\n      case \"PageDown\":\n        e.preventDefault();\n        move(PAGE);\n        return;\n      case \"Enter\": {\n        e.preventDefault();\n        const match = matches[active];\n        if (match) pick(match);\n        return;\n      }\n      case \"Escape\":\n        e.preventDefault();\n        setSearchValue(\"\");\n        setOpen(false);\n        return;\n      case \"Tab\":\n        setOpen(false);\n        return;\n      default:\n        return;\n    }\n  };\n\n  const root = rs([\"rs-combobox\", className], styles.root);\n  const menu = rs([\"rs-menu\"], menuStyles.menu, menuStyles.menuCombobox);\n  const empty = rs([\"rs-combobox-empty\"], styles.empty);\n\n  return (\n    <div\n      ref={setRootRef}\n      className={root.className}\n      style={{ ...root.style, ...style }}\n      onBlur={(e) => {\n        onBlur?.(e);\n        const to = e.relatedTarget as Node | null;\n        if (open && to && !rootRef.current?.contains(to)) setOpen(false);\n      }}\n      {...props}\n    >\n      <input\n        ref={inputRef}\n        id={inputId}\n        className=\"rs-input rs-input-full\"\n        type=\"text\"\n        role=\"combobox\"\n        autoComplete=\"off\"\n        aria-label={ariaLabel}\n        aria-labelledby={ariaLabelledby}\n        aria-expanded={open}\n        aria-controls={hasList ? listboxId : undefined}\n        aria-activedescendant={hasList && active >= 0 ? optionId(active) : undefined}\n        aria-autocomplete=\"list\"\n        disabled={disabled}\n        placeholder={placeholder}\n        value={open ? searchValue : selected ? optionText(selected) : \"\"}\n        onClick={() => {\n          if (!open) openList();\n        }}\n        onChange={(e) => {\n          setSearchValue(e.target.value);\n          setActiveIndex(0);\n          setOpen(true);\n        }}\n        onKeyDown={onKeyDown}\n      />\n      {open && (\n        <div\n          id={hasList ? listboxId : undefined}\n          role={hasList ? \"listbox\" : undefined}\n          aria-labelledby={hasList ? (ariaLabelledby ?? inputId) : undefined}\n          tabIndex={-1}\n          className={menu.className}\n          ref={panelRef}\n          style={{ ...menu.style, ...placement }}\n          onMouseDown={(e) => e.preventDefault()}\n        >\n          {matches.length === 0 && (\n            <div className={empty.className} style={empty.style}>\n              {emptyLabel}\n            </div>\n          )}\n          {matches.map((option, index) => {\n            const highlighted = index === active && !option.disabled;\n            const row = rs(\n              [\"rs-menu-item\", highlighted && \"rs-menu-item-active\", option.disabled && \"rs-menu-item-disabled\"],\n              menuStyles.item,\n              highlighted && menuStyles.itemActive,\n              option.disabled && menuStyles.itemDisabled,\n            );\n            return (\n              <div\n                key={option.value}\n                id={optionId(index)}\n                ref={highlighted ? activeRef : undefined}\n                role=\"option\"\n                tabIndex={-1}\n                aria-selected={option.value === current}\n                aria-disabled={option.disabled || undefined}\n                className={row.className}\n                style={row.style}\n                onPointerEnter={() => { if (!option.disabled) setActiveIndex(index); }}\n                onClick={() => pick(option)}\n              >\n                {option.label}\n              </div>\n            );\n          })}\n        </div>\n      )}\n    </div>\n  );\n});\n",
      "type": "registry:component",
      "target": "components/vlak/combobox.tsx"
    },
    {
      "path": "vlak/use-overlay-position.ts",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\n/** Keep lightweight overlays inside the visual viewport. The native top layer avoids clipping. */\nexport function useOverlayPosition(\n  open: boolean,\n  panel: React.RefObject<HTMLElement | null>,\n  anchor: React.RefObject<HTMLElement | null>,\n  point?: { x: number; y: number } | null,\n  placement: \"bottom\" | \"inline-end\" = \"bottom\",\n  options?: {\n    popover?: \"auto\" | \"manual\";\n    edge?: number;\n    matchAnchorWidth?: boolean;\n    /** Preferred block side. Collision handling may use the opposite side. */\n    side?: \"auto\" | \"top\" | \"bottom\";\n  },\n) {\n  const [position, setPosition] = React.useState<React.CSSProperties>({ visibility: \"hidden\" });\n  React.useLayoutEffect(() => {\n    const element = panel.current;\n    if (!open || !element) return;\n    const native = typeof element.showPopover === \"function\";\n    if (native) {\n      element.setAttribute(\"popover\", options?.popover ?? \"manual\");\n      if (!element.matches(\":popover-open\")) element.showPopover();\n    }\n    const place = () => {\n      const viewport = window.visualViewport;\n      const edge = Math.max(0, options?.edge ?? 8);\n      const leftEdge = (viewport?.offsetLeft ?? 0) + edge;\n      const topEdge = (viewport?.offsetTop ?? 0) + edge;\n      const viewportWidth = viewport?.width ?? window.innerWidth;\n      const viewportHeight = viewport?.height ?? window.innerHeight;\n      const rightEdge = leftEdge + viewportWidth - edge * 2;\n      const bottomEdge = topEdge + viewportHeight - edge * 2;\n      const target = anchor.current?.getBoundingClientRect();\n      const paint = getComputedStyle(element);\n      const borderX = (Number.parseFloat(paint.borderLeftWidth) || 0) + (Number.parseFloat(paint.borderRightWidth) || 0);\n      const borderY = (Number.parseFloat(paint.borderTopWidth) || 0) + (Number.parseFloat(paint.borderBottomWidth) || 0);\n      const matchAnchor = options?.matchAnchorWidth !== false;\n      let panelWidth = element.scrollWidth + borderX;\n      if (!matchAnchor) {\n        // Measure the authored width without the last placement’s viewport clamp.\n        // Restoring it here also keeps resize observation tied to the final layout.\n        const { width, minWidth, maxWidth } = element.style;\n        element.style.width = \"\";\n        element.style.minWidth = \"\";\n        element.style.maxWidth = \"\";\n        panelWidth = element.getBoundingClientRect().width;\n        Object.assign(element.style, { width, minWidth, maxWidth });\n      }\n      const width = Math.min(Math.max(panelWidth, matchAnchor ? target?.width ?? 0 : 0), viewportWidth - edge * 2);\n      const height = Math.min(element.scrollHeight + borderY, viewportHeight - edge * 2);\n      let left = point?.x ?? target?.left ?? leftEdge;\n      let top = point?.y ?? (target ? target.bottom + 6 : topEdge);\n      if (placement === \"inline-end\" && target) {\n        const rtl = getComputedStyle(anchor.current!).direction === \"rtl\";\n        left = rtl ? target.left - width - 4 : target.right + 4;\n        if (left + width > rightEdge) left = target.left - width - 4;\n        if (left < leftEdge) left = target.right + 4;\n        top = target.top;\n        if (top + height > bottomEdge) top = bottomEdge - height;\n      } else if (target && point?.y === undefined) {\n        const below = target.bottom + 6;\n        const above = target.top - height - 6;\n        if (options?.side === \"top\") {\n          top = above >= topEdge || below + height > bottomEdge ? above : below;\n        } else {\n          top = below + height <= bottomEdge || above < topEdge ? below : above;\n        }\n      } else if (top + height > bottomEdge) {\n        top = point?.y !== undefined ? point.y - height : bottomEdge - height;\n      }\n      left = Math.max(leftEdge, Math.min(left, rightEdge - width));\n      top = Math.max(topEdge, Math.min(top, bottomEdge - height));\n      const next: React.CSSProperties = {\n        position: \"fixed\", inset: \"auto\", left, top, margin: 0,\n        minWidth: matchAnchor ? Math.min(target?.width ?? 0, viewportWidth - edge * 2) : 0,\n        maxWidth: viewportWidth - edge * 2, maxHeight: viewportHeight - edge * 2,\n        width, overflow: \"auto\", visibility: \"visible\",\n      };\n      setPosition((previous) => JSON.stringify(previous) === JSON.stringify(next) ? previous : next);\n    };\n    place();\n    const observer = typeof ResizeObserver === \"undefined\" ? undefined : new ResizeObserver(place);\n    observer?.observe(element);\n    if (anchor.current) observer?.observe(anchor.current);\n    window.addEventListener(\"resize\", place);\n    window.addEventListener(\"scroll\", place, true);\n    window.visualViewport?.addEventListener(\"resize\", place);\n    window.visualViewport?.addEventListener(\"scroll\", place);\n    return () => {\n      observer?.disconnect();\n      window.removeEventListener(\"resize\", place);\n      window.removeEventListener(\"scroll\", place, true);\n      window.visualViewport?.removeEventListener(\"resize\", place);\n      window.visualViewport?.removeEventListener(\"scroll\", place);\n    };\n  }, [open, panel, anchor, point?.x, point?.y, placement, options?.popover, options?.edge, options?.matchAnchorWidth, options?.side]);\n  return position;\n}\n",
      "type": "registry:file",
      "target": "components/vlak/use-overlay-position.ts"
    },
    {
      "path": "vlak/styles/combobox.css",
      "content": "/* ── combobox: generated from packages/react/src/components/combobox.tsx ── */\n.rs-combobox{position:relative;display:inline-block;min-width:12.5rem}\n@media (max-width: 640px){.rs-combobox{display:block;min-width:0;width:100%}}\n.rs-combobox-empty{padding:10px 12px;font-size:0.8125rem;letter-spacing:-0.01em;color:var(--text-secondary)}\n@media (max-width: 640px){.rs-combobox-empty{padding:0.875rem;font-size:0.9375rem}}\n",
      "type": "registry:file",
      "target": "styles/vlak/combobox.css"
    }
  ],
  "meta": {
    "vlak": {
      "category": "forms",
      "classes": [
        "rs-combobox",
        "rs-combobox-empty"
      ],
      "snippet": "<div class=\"rs-combobox\"><input class=\"rs-input rs-input-full\" role=\"combobox\" placeholder=\"Search…\" /><div class=\"rs-menu\" role=\"listbox\"><div class=\"rs-menu-item rs-menu-item-active\" role=\"option\" aria-selected=\"false\">Alkmaar</div><div class=\"rs-menu-item\" role=\"option\" aria-selected=\"false\">Amsterdam</div></div></div>",
      "cssOnly": false,
      "registryDependencies": [
        "input",
        "dropdown-menu",
        "vlak-lib",
        "select"
      ],
      "aliases": [
        "Combobox",
        "Autocomplete",
        "Typeahead",
        "Searchable select"
      ],
      "example": "import { useState } from \"react\";\nimport { Combobox } from \"@noorddev/vlak-react\";\n\nconst [city, setCity] = useState(\"\");\n\n<Combobox\n  aria-label=\"City\"\n  options={cities}\n  value={city}\n  onValueChange={setCity}\n  placeholder=\"Search cities…\"\n  emptyLabel=\"No city matches.\"\n/>",
      "usage": {
        "use": [
          "One choice from a long list the user knows how to type: cities, people, tags.",
          "searchText on an option when its label is not a plain string."
        ],
        "avoid": [
          "Short lists; use Select.",
          "Free-form values not in the list; the combobox only picks options."
        ]
      },
      "keyboard": [
        {
          "keys": "Type",
          "does": "Filters the options and opens the list"
        },
        {
          "keys": "Arrow down",
          "does": "Opens the list on the selected option, then moves down"
        },
        {
          "keys": "Arrow up",
          "does": "Opens the list on the last option, then moves up"
        },
        {
          "keys": "Home, End",
          "does": "First or last match"
        },
        {
          "keys": "Page up, Page down",
          "does": "Moves ten matches"
        },
        {
          "keys": "Enter",
          "does": "Picks the active match and closes"
        },
        {
          "keys": "Escape",
          "does": "Clears the search and closes"
        },
        {
          "keys": "Tab",
          "does": "Closes and moves on"
        }
      ],
      "a11y": [
        "APG editable combobox: an <input role=\"combobox\"> with aria-autocomplete=\"list\", aria-expanded, aria-controls, and aria-activedescendant; focus stays in the input.",
        "The list is role=\"listbox\" with role=\"option\" rows carrying aria-selected; it does not open on focus alone.",
        "Pass aria-label or aria-labelledby. Controlled with value and onValueChange, or uncontrolled with defaultValue."
      ]
    }
  }
}
