{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "select",
  "type": "registry:component",
  "title": "Select",
  "description": "Selects one option from an overlay. The closed trigger carries a chevron.",
  "registryDependencies": [
    "https://vlak.dev/r/vlak-base.json",
    "https://vlak.dev/r/inter.json",
    "https://vlak.dev/r/dropdown-menu.json",
    "https://vlak.dev/r/vlak-lib.json",
    "https://vlak.dev/r/icons.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/select.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { vlak } from \"./tokens.stylex\";\nimport { rs } from \"./rs\";\nimport { useMergedRefs } from \"./merge-refs\";\nimport { useOverlayPosition } from \"./use-overlay-position\";\nimport { Icon } from \"./icons\";\nimport { menuStyles, typeAheadIndex } from \"./dropdown-menu\";\n\nconst styles = stylex.create({\n  root: {\n    position: \"relative\",\n    display: \"inline-block\",\n    minWidth: \"var(--rs-select-min-width, 11.25rem)\",\n    backgroundColor: vlak.paper,\n  },\n  // The shared semantic base reads this too, regardless of stylesheet order.\n  fluid: { display: \"block\", width: \"100%\", minWidth: 0, \"--rs-select-min-width\": \"0px\" },\n  native: { position: \"absolute\", width: 1, height: 1, margin: -1, padding: 0, overflow: \"hidden\", clipPath: \"inset(50%)\", whiteSpace: \"nowrap\", borderWidth: 0 },\n  feedback: { margin: \"0.5rem 0 0\", fontSize: \"0.75rem\", color: vlak.gray, lineHeight: 1.45 },\n  list: {\n    maxHeight: \"17.5rem\",\n    overflowY: \"auto\",\n  },\n});\n\nexport interface SelectOption {\n  value: string;\n  label: React.ReactNode;\n  /** Plain text for type-ahead and filtering when `label` is not a string. */\n  searchText?: string;\n  /** Visible but unavailable options are skipped by keyboard navigation. */\n  disabled?: boolean;\n}\n\n/** Text an option answers to: `searchText`, a string label, else the value. */\nexport function optionText(option: SelectOption): string {\n  if (option.searchText) return option.searchText;\n  return typeof option.label === \"string\" ? option.label : option.value;\n}\n\nexport interface SelectProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\" | \"defaultValue\"> {\n  options: SelectOption[];\n  value?: string;\n  defaultValue?: string;\n  placeholder?: React.ReactNode;\n  onValueChange?: (value: string) => void;\n  disabled?: boolean;\n  /** Fill a compound field without imposing a minimum column width. */\n  fullWidth?: boolean;\n  name?: string;\n  form?: string;\n  required?: boolean;\n  readOnly?: boolean;\n}\n\nconst PAGE = 10;\n\n/**\n * Select-only combobox: the trigger holds focus and points at the active\n * option with aria-activedescendant; the listbox overlays.\n */\nexport const Select = React.forwardRef<HTMLDivElement, SelectProps>(function Select({\n  options,\n  value,\n  defaultValue,\n  placeholder = \"Select…\",\n  onValueChange,\n  disabled,\n  fullWidth = false,\n  name, form, required = false, readOnly = false,\n  className,\n  style,\n  onKeyDown,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledby,\n  \"aria-describedby\": ariaDescribedBy,\n  \"aria-invalid\": ariaInvalid,\n  \"aria-required\": ariaRequired,\n  ...props\n}: SelectProps, ref: React.ForwardedRef<HTMLDivElement>) {\n  const idBase = React.useId();\n  const triggerId = `${idBase}-trigger`;\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 nativeRef = React.useRef<HTMLSelectElement>(null);\n  const [, refresh] = React.useReducer((n: number) => n + 1, 0);\n  const [showRequiredError, setShowRequiredError] = React.useState(false);\n  const triggerRef = React.useRef<HTMLButtonElement>(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, triggerRef);\n  const [inner, setInner] = React.useState(defaultValue);\n  const isControlled = value !== undefined;\n  const current = isControlled ? value : inner;\n  const selectedIndex = options.findIndex((o) => o.value === current);\n  const enabledIndices = options.flatMap((option, index) => option.disabled ? [] : [index]);\n  const available = enabledIndices.length;\n  const resetState = React.useRef({ isControlled, defaultValue });\n  resetState.current = { isControlled, defaultValue };\n  const [activeIndex, setActiveIndex] = React.useState(Math.max(0, selectedIndex));\n  const typed = React.useRef({ buffer: \"\", at: 0 });\n  const typedTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: the form attribute changes the native control's owner after commit.\n  React.useEffect(() => {\n    const owner = nativeRef.current?.form;\n    if (!owner) return;\n    const reset = (event: Event) => queueMicrotask(() => {\n      if (event.defaultPrevented) return;\n      if (!resetState.current.isControlled) setInner(resetState.current.defaultValue);\n      setOpen(false); setShowRequiredError(false); refresh();\n    });\n    owner.addEventListener(\"reset\", reset);\n    return () => owner.removeEventListener(\"reset\", reset);\n  }, [form]);\n  React.useEffect(() => { if (disabled || readOnly || !available) setOpen(false); }, [disabled, readOnly, available]);\n\n  // A fieldset can disable the native controls without changing our props.\n  // Follow that state while the custom popup is open, including the native\n  // first-legend exception, and guard events before the observer runs.\n  React.useEffect(() => {\n    if (!open) return;\n    const native = nativeRef.current;\n    if (!native) return;\n    const syncDisabled = () => { if (native.matches(\":disabled\")) setOpen(false); };\n    const observer = new MutationObserver(syncDisabled);\n    for (let ancestor = native.parentElement; ancestor; ancestor = ancestor.parentElement) {\n      if (ancestor.tagName === \"FIELDSET\") observer.observe(ancestor, { attributes: true, attributeFilter: [\"disabled\"] });\n    }\n    syncDisabled();\n    return () => observer.disconnect();\n  }, [open]);\n\n  /* Resync the highlight whenever the value changes underneath. */\n  React.useEffect(() => {\n    setActiveIndex(Math.max(0, selectedIndex));\n  }, [selectedIndex]);\n\n  React.useEffect(() => () => clearTimeout(typedTimer.current), []);\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 (open) activeRef.current?.scrollIntoView?.({ block: \"nearest\" });\n  }, [open, activeIndex]);\n\n  const openAt = (index: number) => {\n    if (disabled || readOnly || !available || nativeRef.current?.matches(\":disabled\")) return;\n    setActiveIndex(enabledIndices.find(candidate => candidate >= index) ?? enabledIndices[available - 1]!);\n    setOpen(true);\n  };\n\n  const close = (restoreFocus = true) => {\n    setOpen(false);\n    if (restoreFocus) triggerRef.current?.focus();\n  };\n\n  const select = (next: string) => {\n    if (disabled || readOnly || nativeRef.current?.matches(\":disabled\") || options.find(option => option.value === next)?.disabled) return;\n    setShowRequiredError(false);\n    if (!isControlled) setInner(next);\n    onValueChange?.(next);\n    close();\n  };\n\n  const typeAhead = (key: string, from: number): number => {\n    const now = Date.now();\n    const buffer = now - typed.current.at < 500 ? typed.current.buffer + key : key;\n    typed.current = { buffer, at: now };\n    clearTimeout(typedTimer.current);\n    typedTimer.current = setTimeout(() => {\n      typed.current = { buffer: \"\", at: 0 };\n    }, 500);\n    const match = typeAheadIndex(enabledIndices.map(index => optionText(options[index]!)), enabledIndices.indexOf(from), buffer);\n    return match < 0 ? -1 : enabledIndices[match]!;\n  };\n\n  const onRootKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    onKeyDown?.(e);\n    if (e.defaultPrevented || disabled || readOnly || available === 0 || nativeRef.current?.matches(\":disabled\")) return;\n    const first = enabledIndices[0]!;\n    const last = enabledIndices[available - 1]!;\n    const printable = e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey;\n    const typing = typed.current.buffer.length > 0 && Date.now() - typed.current.at < 500;\n\n    if (!open) {\n      if ([\"ArrowDown\", \"ArrowUp\", \"Enter\", \" \"].includes(e.key)) {\n        e.preventDefault();\n        openAt(selectedIndex);\n      } else if (e.key === \"Home\") {\n        e.preventDefault();\n        openAt(first);\n      } else if (e.key === \"End\") {\n        e.preventDefault();\n        openAt(last);\n      } else if (printable && e.key !== \" \") {\n        e.preventDefault();\n        const match = typeAhead(e.key, selectedIndex);\n        openAt(match >= 0 ? match : selectedIndex);\n      }\n      return;\n    }\n\n    switch (e.key) {\n      case \"ArrowDown\":\n        e.preventDefault();\n        setActiveIndex((i) => enabledIndices.find(index => index > i) ?? i);\n        return;\n      case \"ArrowUp\":\n        e.preventDefault();\n        setActiveIndex((i) => [...enabledIndices].reverse().find(index => index < i) ?? i);\n        return;\n      case \"Home\":\n        e.preventDefault();\n        setActiveIndex(first);\n        return;\n      case \"End\":\n        e.preventDefault();\n        setActiveIndex(last);\n        return;\n      case \"PageUp\":\n        e.preventDefault();\n        setActiveIndex((i) => enabledIndices[Math.max(0, enabledIndices.indexOf(i) - PAGE)]!);\n        return;\n      case \"PageDown\":\n        e.preventDefault();\n        setActiveIndex((i) => enabledIndices[Math.min(available - 1, enabledIndices.indexOf(i) + PAGE)]!);\n        return;\n      case \"Escape\":\n        e.preventDefault();\n        close();\n        return;\n      case \"Tab\":\n        close(false);\n        return;\n      case \"Enter\":\n      case \" \": {\n        if (e.key === \" \" && typing) break;\n        e.preventDefault();\n        const active = options[activeIndex];\n        if (active) select(active.value);\n        return;\n      }\n      default:\n        break;\n    }\n    if (printable) {\n      e.preventDefault();\n      const match = typeAhead(e.key, activeIndex);\n      if (match >= 0) setActiveIndex(match);\n    }\n  };\n\n  const selected = selectedIndex >= 0 ? options[selectedIndex] : undefined;\n  const root = rs([\"rs-select\", className, fullWidth && \"rs-select-fluid\"], menuStyles.select, styles.root, fullWidth && styles.fluid);\n  const native = rs([\"rs-select-native\"], styles.native);\n  const feedback = rs([\"rs-select-feedback\"], styles.feedback);\n  const requiredError = showRequiredError && required && !selected?.value && !readOnly;\n  const describedBy = [ariaDescribedBy, requiredError && `${idBase}-error`].filter(Boolean).join(\" \") || undefined;\n  const trigger = rs([\"rs-dropdown\"], menuStyles.dropdown);\n  const menu = rs([\"rs-menu\", \"rs-select-list\"], menuStyles.menu, menuStyles.menuOverlay, styles.list);\n\n  return (\n    <div\n      ref={setRootRef}\n      className={root.className}\n      style={{ ...root.style, ...style }}\n      onKeyDown={onRootKeyDown}\n      {...props}\n    >\n      <button\n        ref={triggerRef}\n        id={triggerId}\n        type=\"button\"\n        role=\"combobox\"\n        className={trigger.className}\n        style={trigger.style}\n        aria-label={ariaLabel}\n        /* A combobox is not named by its content. Without a label prop the\n           trigger names itself, so the current value is at least announced. */\n        aria-labelledby={ariaLabelledby ?? (ariaLabel ? undefined : triggerId)}\n        aria-haspopup=\"listbox\"\n        aria-expanded={open}\n        aria-controls={open ? listboxId : undefined}\n        aria-activedescendant={open && options[activeIndex] ? optionId(activeIndex) : undefined}\n        aria-describedby={describedBy}\n        aria-invalid={ariaInvalid ?? (requiredError || undefined)}\n        aria-required={ariaRequired ?? (required || undefined)}\n        aria-readonly={readOnly || undefined}\n        disabled={disabled || readOnly || !available}\n        onClick={() => (open ? close() : openAt(selectedIndex))}\n      >\n        <span>{selected ? selected.label : placeholder}</span>\n        <Icon name=\"chevron-right\" rotate={90} />\n      </button>\n      <select {...native} ref={nativeRef} aria-hidden=\"true\" tabIndex={-1} name={readOnly ? undefined : name} form={form} required={required} disabled={disabled || readOnly} value={selected ? selected.value : \"\"} onChange={event => select(event.currentTarget.value)} onInvalid={event => { event.preventDefault(); setShowRequiredError(true); triggerRef.current?.focus(); }}>\n        {!options.some(option => option.value === \"\") && <option value=\"\" label=\"Choose an option\" />}\n        {options.map(option => <option key={option.value} value={option.value} disabled={option.disabled} label={optionText(option)} />)}\n      </select>\n      {readOnly && <input type=\"hidden\" name={name} form={form} disabled={disabled} value={selected?.value ?? \"\"} />}\n      {requiredError && <p {...feedback} id={`${idBase}-error`}>Choose an option</p>}\n      {open && (\n        <div\n          id={listboxId}\n          role=\"listbox\"\n          tabIndex={-1}\n          aria-labelledby={ariaLabelledby ?? triggerId}\n          className={menu.className}\n          ref={panelRef}\n          style={{ ...menu.style, ...placement }}\n          onMouseDown={(e) => e.preventDefault()}\n        >\n          {options.map((option, index) => {\n            const active = index === activeIndex;\n            const row = rs(\n              [\"rs-menu-item\", active && \"rs-menu-item-active\", option.disabled && \"rs-menu-item-disabled\"],\n              menuStyles.item,\n              active && menuStyles.itemActive,\n              option.disabled && menuStyles.itemDisabled,\n            );\n            return (\n              <div\n                key={option.value}\n                id={optionId(index)}\n                ref={active ? 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={() => select(option.value)}\n              >\n                {option.label}\n              </div>\n            );\n          })}\n        </div>\n      )}\n    </div>\n  );\n});\n",
      "type": "registry:component",
      "target": "components/vlak/select.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/select.css",
      "content": "/* ── select: generated from packages/react/src/components/select.tsx ── */\n.rs-select{position:relative;display:inline-block;min-width:var(--rs-select-min-width, 11.25rem);background-color:var(--bg)}\n.rs-select-fluid{display:block;width:100%;min-width:0;--rs-select-min-width:0px}\n.rs-select-native{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border-width:0}\n.rs-select-feedback{margin:0.5rem 0 0;font-size:0.75rem;color:var(--text-secondary);line-height:1.45}\n.rs-menu.rs-select-list{max-height:17.5rem;overflow-y:auto}\n",
      "type": "registry:file",
      "target": "styles/vlak/select.css"
    }
  ],
  "meta": {
    "vlak": {
      "category": "forms",
      "classes": [
        "rs-select",
        "rs-select-list",
        "rs-select-fluid",
        "rs-select-native",
        "rs-select-feedback"
      ],
      "snippet": "<button class=\"rs-dropdown\" role=\"combobox\" aria-haspopup=\"listbox\" aria-expanded=\"true\"><span>Alkmaar</span></button>\n<div class=\"rs-menu rs-select-list\" role=\"listbox\"><div class=\"rs-menu-item rs-menu-item-active\" role=\"option\" aria-selected=\"true\">Alkmaar</div><div class=\"rs-menu-item\" role=\"option\" aria-selected=\"false\">Amsterdam</div></div>",
      "cssOnly": false,
      "registryDependencies": [
        "dropdown-menu",
        "vlak-lib",
        "icons"
      ],
      "aliases": [
        "Select",
        "Listbox",
        "Dropdown select",
        "Picker"
      ],
      "example": "import { useState } from \"react\";\nimport { Select } from \"@noorddev/vlak-react\";\n\nconst [city, setCity] = useState(\"alkmaar\");\n\n<Select\n  aria-label=\"City\"\n  options={[\n    { value: \"alkmaar\", label: \"Alkmaar\" },\n    { value: \"amsterdam\", label: \"Amsterdam\" },\n    { value: \"rotterdam\", label: \"Rotterdam\" },\n  ]}\n  value={city}\n  onValueChange={setCity}\n/>",
      "usage": {
        "use": [
          "One choice from a list of five or more when the labels are short.",
          "Lists that benefit from type-ahead and a consistent overlay across platforms.",
          "Use fullWidth in compound field layouts. name and form support native submission; required uses native validation and reset restores uncontrolled defaults. readOnly preserves the submitted selection.",
          "Mark unavailable options disabled; they stay visible and are skipped by keyboard navigation."
        ],
        "avoid": [
          "Free text or filtering; use Combobox.",
          "A platform picker when it is specifically preferred; use NativeSelect."
        ]
      },
      "keyboard": [
        {
          "keys": "Arrow down, Arrow up, Enter, Space",
          "does": "Opens the list on the selected option"
        },
        {
          "keys": "Home, End",
          "does": "Opens on the first or last option; moves there when open"
        },
        {
          "keys": "Type letters",
          "does": "Jumps to the next matching option, open or closed"
        },
        {
          "keys": "Arrow down, Arrow up",
          "does": "Moves the active option"
        },
        {
          "keys": "Page up, Page down",
          "does": "Moves ten options"
        },
        {
          "keys": "Enter, Space",
          "does": "Selects the active option and closes"
        },
        {
          "keys": "Escape",
          "does": "Closes and returns focus to the trigger"
        },
        {
          "keys": "Tab",
          "does": "Closes and moves on"
        }
      ],
      "a11y": [
        "APG select-only combobox: the trigger is a <button role=\"combobox\"> with aria-haspopup=\"listbox\", aria-expanded, aria-controls, and aria-activedescendant.",
        "The list is role=\"listbox\" with role=\"option\" rows carrying aria-selected; focus stays on the trigger.",
        "Pass aria-label or aria-labelledby; the listbox is labelled by the same source.",
        "Controlled with value and onValueChange, or uncontrolled with defaultValue. A click outside closes it."
      ]
    }
  }
}
