{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"select-menu","type":"registry:ui","title":"Select menu","description":"A select whose list opens with the chosen option over the trigger; the platform picker on touch.","dependencies":["@radix-ui/react-popover","clsx","lucide-react","tailwind-merge"],"registryDependencies":["https://caliperui.site/r/styles.json"],"files":[{"path":"ui/lib/cn.ts","type":"registry:ui","target":"components/ui/lib/cn.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\nconst twMerge = extendTailwindMerge({\n  extend: { theme: { radius: [\"control\", \"surface\", \"overlay\"], shadow: [\"overlay\"] } },\n});\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"},{"path":"ui/select-menu.tsx","type":"registry:ui","target":"components/ui/select-menu.tsx","content":"\"use client\";\n\nimport * as Popover from \"@radix-ui/react-popover\";\nimport { Check, ChevronsUpDown } from \"lucide-react\";\nimport {\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type ComponentProps,\n  type KeyboardEvent,\n  type ReactNode,\n  type Ref,\n} from \"react\";\nimport { cn } from \"./lib/cn\";\nimport \"./shared/select.css\";\nimport \"./shared/input-2.css\";\nimport \"./select-menu.css\";\n\nexport type SelectMenuOption = {\n  value: string;\n  label: string;\n  /** Short identifier shown in mono at the end of the row, e.g. \"eu-central-1\". */\n  code?: string;\n  /** A 16px mark before the label, e.g. a network dot or a person's initials. */\n  icon?: ReactNode;\n  disabled?: boolean;\n};\n\nconst ROW = 36;\nconst PAD = 4;\n// The list is a little wider than its trigger and starts before it, so the chosen row sits over the trigger.\nconst WIDER = 36;\nconst ALIGN = 28;\nconst EDGE = 12;\n\nfunction useCoarsePointer() {\n  const [coarse, setCoarse] = useState(false);\n  useEffect(() => {\n    const query = window.matchMedia(\"(pointer: coarse)\");\n    const update = () => setCoarse(query.matches);\n    update();\n    query.addEventListener(\"change\", update);\n    return () => query.removeEventListener(\"change\", update);\n  }, []);\n  return coarse;\n}\n\n/**\n * A select whose list opens with the chosen option lying exactly over the\n * trigger text, so the eye never jumps. On touch screens it is the platform\n * picker. A hidden input carries the value in native forms.\n */\nexport function SelectMenu({\n  options,\n  value: controlled,\n  defaultValue,\n  onValueChange,\n  placeholder = \"Choose…\",\n  name,\n  disabled,\n  size = \"md\",\n  variant = \"field\",\n  className,\n  ref,\n  ...aria\n}: {\n  options: readonly SelectMenuOption[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  placeholder?: string;\n  /** Submitted with the form. */\n  name?: string;\n  disabled?: boolean;\n  /** sm is 32px for toolbars, filters and table rows; md follows the control height. */\n  size?: \"sm\" | \"md\";\n  /** ghost drops the field ring, for a select inside a composed control (a currency in a money field). */\n  variant?: \"field\" | \"ghost\";\n  className?: string;\n  /** The trigger (or, on touch screens, the native select), e.g. for a dialog's first focus. */\n  ref?: Ref<HTMLButtonElement | HTMLSelectElement>;\n} & Pick<ComponentProps<\"button\">, \"id\" | \"aria-label\" | \"aria-labelledby\" | \"aria-describedby\" | \"aria-invalid\" | \"aria-required\">) {\n  const listId = useId();\n  const coarse = useCoarsePointer();\n  const [inner, setInner] = useState(defaultValue);\n  const value = controlled ?? inner;\n  const [open, setOpen] = useState(false);\n  const [active, setActive] = useState(0);\n  const [offset, setOffset] = useState(0);\n  // Keeps the list inside the dialog or card it opens in, when laying it over the trigger would cross the edge.\n  const [shift, setShift] = useState(0);\n  const [room, setRoom] = useState<number | undefined>(undefined);\n  const trigger = useRef<HTMLButtonElement>(null);\n  const list = useRef<HTMLDivElement>(null);\n  const selectedIndex = Math.max(0, options.findIndex((option) => option.value === value));\n  const selected = options.find((option) => option.value === value);\n\n  // If the change handler moves focus on (e.g. into a reply box), leave it there when the list closes.\n  const keepFocus = useRef(false);\n  // A form may disable the trigger while it saves the choice. Focus cannot return to a disabled button,\n  // so it returns as soon as the trigger is enabled again, unless the reader has moved on by then.\n  const refocus = useRef(false);\n  useEffect(() => {\n    if (disabled || !refocus.current) return;\n    refocus.current = false;\n    const now = document.activeElement;\n    if (!now || now === document.body) trigger.current?.focus();\n  }, [disabled]);\n  function choose(next: string) {\n    if (controlled === undefined) setInner(next);\n    onValueChange?.(next);\n    const now = document.activeElement;\n    keepFocus.current = !!now && now !== document.body && now !== trigger.current && !list.current?.contains(now);\n  }\n\n  function openList(nextOpen: boolean) {\n    if (nextOpen && trigger.current) {\n      // Lay the selected row over the trigger, unless that would leave the viewport.\n      const rect = trigger.current.getBoundingClientRect();\n      const above = PAD + selectedIndex * ROW;\n      const fits = rect.top - above > 8 && rect.top - above + options.length * ROW + PAD * 2 < window.innerHeight - 8;\n      setOffset(fits ? -(rect.height + above) - (ROW - rect.height) / 2 : 6);\n      const card = trigger.current.closest<HTMLElement>('[role=\"dialog\"]')?.getBoundingClientRect();\n      const left = rect.left - ALIGN;\n      const right = left + rect.width + WIDER;\n      setShift(!card ? 0 : left < card.left + EDGE ? card.left + EDGE - left : right > card.right - EDGE ? card.right - EDGE - right : 0);\n      setRoom(card ? card.width - EDGE * 2 : undefined);\n      setActive(selectedIndex);\n    }\n    setOpen(nextOpen);\n  }\n\n  function move(delta: number) {\n    let next = active;\n    for (let step = 0; step < options.length; step += 1) {\n      next = Math.min(options.length - 1, Math.max(0, next + delta));\n      if (!options[next].disabled) break;\n    }\n    setActive(next);\n  }\n\n  function onListKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n    if (event.key === \"ArrowDown\") move(1);\n    else if (event.key === \"ArrowUp\") move(-1);\n    else if (event.key === \"Home\") setActive(options.findIndex((option) => !option.disabled));\n    else if (event.key === \"End\") setActive(options.length - 1 - [...options].reverse().findIndex((option) => !option.disabled));\n    else if (event.key === \"Enter\" || event.key === \" \") {\n      const option = options[active];\n      if (option && !option.disabled) {\n        choose(option.value);\n        setOpen(false);\n      }\n    } else if (event.key === \"Tab\") setOpen(false);\n    else if (/^\\S$/.test(event.key)) {\n      const start = (active + 1) % options.length;\n      const order = [...options.slice(start), ...options.slice(0, start)];\n      const hit = order.find((option) => !option.disabled && option.label.toLowerCase().startsWith(event.key.toLowerCase()));\n      if (hit) setActive(options.indexOf(hit));\n    } else return;\n    event.preventDefault();\n  }\n\n  useEffect(() => {\n    if (!open) return;\n    list.current?.querySelector<HTMLElement>(`[data-index=\"${active}\"]`)?.scrollIntoView({ block: \"nearest\" });\n  }, [active, open]);\n\n  if (coarse) {\n    return (\n      <span data-slot=\"select-menu-native\" data-size={size} data-variant={variant} className={cn(\"relative block\", className)}>\n        <select\n          ref={ref as Ref<HTMLSelectElement>}\n          data-slot=\"select\"\n          {...aria}\n          name={name}\n          disabled={disabled}\n          value={value ?? \"\"}\n          onChange={(event) => choose(event.target.value)}\n          className={cn(\n            \"block w-full min-w-0 appearance-none text-base text-foreground outline-none\",\n            size === \"sm\" ? \"h-8 rounded-control pr-8 pl-2.5\" : \"h-[var(--control-height)] rounded-button pr-9 pl-3\",\n          )}\n        >\n          {!selected && <option value=\"\" disabled>{placeholder}</option>}\n          {options.map((option) => (\n            <option key={option.value} value={option.value} disabled={option.disabled}>\n              {option.label}\n              {option.code ? ` · ${option.code}` : \"\"}\n            </option>\n          ))}\n        </select>\n        <ChevronsUpDown aria-hidden=\"true\" className={cn(\"pointer-events-none absolute top-1/2 size-4 -translate-y-1/2 text-muted-foreground\", size === \"sm\" ? \"right-2.5\" : \"right-3\")} />\n      </span>\n    );\n  }\n\n  return (\n    <Popover.Root open={open} onOpenChange={openList}>\n      <Popover.Trigger asChild>\n        <button\n          ref={(node) => {\n            trigger.current = node;\n            if (typeof ref === \"function\") ref(node);\n            else if (ref) (ref as { current: HTMLButtonElement | HTMLSelectElement | null }).current = node;\n          }}\n          type=\"button\"\n          data-slot=\"select-menu\"\n          data-size={size}\n          data-variant={variant}\n          role=\"combobox\"\n          aria-haspopup=\"listbox\"\n          aria-expanded={open}\n          aria-controls={open ? listId : undefined}\n          disabled={disabled}\n          {...aria}\n          onKeyDown={(event) => {\n            if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n              event.preventDefault();\n              openList(true);\n            }\n          }}\n          className={cn(\n            \"flex w-full min-w-0 items-center justify-between gap-2 text-left text-foreground outline-none disabled:cursor-not-allowed\",\n            size === \"sm\" ? \"h-8 rounded-control px-2.5 text-[13px]\" : \"h-[var(--control-height)] rounded-button px-3 text-base md:text-sm\",\n            className,\n          )}\n        >\n          <span className={cn(\"flex min-w-0 items-center gap-2\", !selected && \"text-muted-foreground\")}>\n            {selected?.icon && <span data-slot=\"select-menu-icon\" aria-hidden=\"true\">{selected.icon}</span>}\n            <span className=\"truncate\">{selected ? selected.label : placeholder}</span>\n            {selected?.code && <span data-slot=\"select-menu-code\" className=\"shrink-0\">{selected.code}</span>}\n          </span>\n          <ChevronsUpDown aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n        </button>\n      </Popover.Trigger>\n      {name && <input type=\"hidden\" name={name} value={value ?? \"\"} />}\n      <Popover.Portal>\n        <Popover.Content\n          side=\"bottom\"\n          align=\"start\"\n          sideOffset={offset}\n          alignOffset={shift - ALIGN}\n          avoidCollisions={offset > 0}\n          onOpenAutoFocus={(event) => {\n            event.preventDefault();\n            keepFocus.current = false;\n            list.current?.focus();\n          }}\n          onCloseAutoFocus={(event) => {\n            if (keepFocus.current) event.preventDefault();\n            else if (trigger.current?.disabled) {\n              event.preventDefault();\n              refocus.current = true;\n            }\n          }}\n          data-slot=\"select-menu-content\"\n          style={{ width: `calc(var(--radix-popover-trigger-width) + ${WIDER}px)`, maxWidth: room, transformOrigin: `50% ${PAD + selectedIndex * ROW + ROW / 2}px` }}\n        >\n          <div\n            ref={list}\n            id={listId}\n            role=\"listbox\"\n            tabIndex={-1}\n            aria-labelledby={aria[\"aria-labelledby\"]}\n            aria-label={aria[\"aria-label\"]}\n            aria-activedescendant={`${listId}-${active}`}\n            onKeyDown={onListKeyDown}\n            className=\"max-h-[calc(8*36px+8px)] overflow-y-auto p-1 outline-none\"\n          >\n            {options.map((option, index) => (\n              <div\n                key={option.value}\n                id={`${listId}-${index}`}\n                role=\"option\"\n                data-index={index}\n                aria-selected={option.value === value}\n                aria-disabled={option.disabled || undefined}\n                data-active={index === active}\n                data-slot=\"select-menu-option\"\n                onPointerMove={() => !option.disabled && setActive(index)}\n                onClick={() => {\n                  if (option.disabled) return;\n                  choose(option.value);\n                  setOpen(false);\n                }}\n              >\n                <span className=\"grid size-4 place-items-center\">{option.value === value && <Check aria-hidden=\"true\" className=\"size-4\" />}</span>\n                {option.icon && <span data-slot=\"select-menu-icon\" aria-hidden=\"true\">{option.icon}</span>}\n                <span className=\"min-w-0 flex-1 truncate\">{option.label}</span>\n                {option.code && <span data-slot=\"select-menu-code\">{option.code}</span>}\n              </div>\n            ))}\n          </div>\n        </Popover.Content>\n      </Popover.Portal>\n    </Popover.Root>\n  );\n}\n"},{"path":"ui/shared/select.css","type":"registry:file","target":"components/ui/shared/select.css","content":"/* Shared by select-menu.tsx, select.tsx; each imports it. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  [data-slot=\"select\"] {\n    transition-property: background-color, color, border-color;\n    transition-duration: var(--duration-fast);\n    transition-timing-function: var(--ease-out);\n  }\n\n  [data-slot=\"select\"] {\n    cursor: pointer;\n    touch-action: manipulation;\n  }\n  /* The native select wears the select-menu trigger: no platform arrow, the\n     same up/down mark on the right. Lists (multiple or size > 1) keep theirs. */\n  [data-slot=\"select\"]:not([multiple]):not([size]) {\n    appearance: none;\n    padding-right: 36px;\n    background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%235d5d66' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m7 15 5 5 5-5'/%3E%3Cpath d='m7 9 5-5 5 5'/%3E%3C/svg%3E\");\n    background-repeat: no-repeat;\n    background-position: right 10px center;\n    background-size: 16px 16px;\n  }\n  [data-theme=\"dark\"] [data-slot=\"select\"]:not([multiple]):not([size]),\n  [data-slot=\"select\"][data-theme=\"dark\"]:not([multiple]):not([size]) {\n    background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23ababb4' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m7 15 5 5 5-5'/%3E%3Cpath d='m7 9 5-5 5 5'/%3E%3C/svg%3E\");\n  }\n\n  /* Recessed to fill. One ring for every field: 3:1 at rest, darker on\n     hover, a 2px ember ring with a soft halo on focus, danger when invalid.\n     Disabled and read-only drop into the sunken fill and stay legible. */\n  [data-slot=\"select\"] {\n    border: 0;\n    background-color: var(--field);\n    box-shadow: var(--shadow-field);\n    transition: box-shadow var(--duration-fast) var(--ease-out);\n  }\n\n  [data-slot=\"select\"]:disabled {\n    cursor: not-allowed;\n  }\n\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"select\"] {\n      transition: none;\n    }\n  }\n}\n"},{"path":"ui/shared/input-2.css","type":"registry:file","target":"components/ui/shared/input-2.css","content":"/* Shared by combobox.tsx, input.tsx, select-menu.tsx, select.tsx, textarea.tsx; each imports it. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  @media (hover: hover) {\n    :is([data-slot=\"input\"], [data-slot=\"textarea\"], [data-slot=\"select\"]):hover:enabled:not(:focus-visible):not([aria-invalid=\"true\"]) {\n      box-shadow: var(--shadow-field-hover);\n    }\n  }\n  :is([data-slot=\"input\"], [data-slot=\"textarea\"], [data-slot=\"select\"]):focus-visible {\n    outline: none;\n    box-shadow: var(--shadow-field-focus);\n    transition: none;\n  }\n  :is([data-slot=\"input\"], [data-slot=\"textarea\"], [data-slot=\"select\"])[aria-invalid=\"true\"] {\n    box-shadow: var(--shadow-field-invalid);\n  }\n  :is([data-slot=\"input\"], [data-slot=\"textarea\"], [data-slot=\"select\"])[aria-invalid=\"true\"]:focus-visible {\n    box-shadow:\n      var(--shadow-field-invalid),\n      0 0 0 4px color-mix(in srgb, var(--danger) 18%, transparent);\n  }\n  :is([data-slot=\"input\"], [data-slot=\"textarea\"], [data-slot=\"select\"]):disabled {\n    background-color: var(--sunken);\n    box-shadow: var(--shadow-field-quiet);\n    color: var(--muted-foreground);\n  }\n  :is([data-slot=\"input\"], [data-slot=\"textarea\"])[readonly] {\n    background-color: var(--sunken);\n    box-shadow: var(--shadow-field-quiet);\n  }\n}\n"},{"path":"ui/select-menu.css","type":"registry:file","target":"components/ui/select-menu.css","content":"/* select-menu.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  [data-slot=\"select-menu-native\"] [data-slot=\"select\"] {\n    background-image: none;\n  }\n  /* Select menu: the field trigger, and a frosted list that opens with the\n     chosen row over the trigger and grows around it. */\n  [data-slot=\"select-menu\"] {\n    border: 0;\n    background-color: var(--field);\n    box-shadow: var(--shadow-field);\n    cursor: pointer;\n    transition: box-shadow var(--duration-fast) var(--ease-out);\n  }\n  @media (hover: hover) {\n    [data-slot=\"select-menu\"]:hover:enabled:not(:focus-visible):not([aria-expanded=\"true\"]):not([aria-invalid=\"true\"]) {\n      box-shadow: var(--shadow-field-hover);\n    }\n  }\n  [data-slot=\"select-menu\"]:focus-visible,\n  [data-slot=\"select-menu\"][aria-expanded=\"true\"] {\n    box-shadow: var(--shadow-field-focus);\n  }\n  [data-slot=\"select-menu\"][aria-invalid=\"true\"] {\n    box-shadow: var(--shadow-field-invalid);\n  }\n  [data-slot=\"select-menu\"]:disabled {\n    background-color: var(--sunken);\n    box-shadow: var(--shadow-field-quiet);\n    color: var(--muted-foreground);\n  }\n  [data-slot=\"select-menu-code\"] {\n    margin-left: auto;\n    font-family: var(--font-mono);\n    font-size: 12px;\n    color: var(--muted-foreground);\n  }\n  [data-slot=\"select-menu-content\"] {\n    z-index: 50;\n    border-radius: var(--radius-surface);\n    background: var(--elevated-glass);\n    -webkit-backdrop-filter: blur(var(--blur-overlay));\n    backdrop-filter: blur(var(--blur-overlay));\n    box-shadow: var(--shadow-overlay), 0 0 0 1px var(--edge-glow), inset 0 1px 0 var(--edge-light);\n    color: var(--foreground);\n    animation: caliper-select-in var(--duration-normal) var(--ease-out);\n  }\n  [data-slot=\"select-menu-option\"] {\n    display: flex;\n    align-items: center;\n    gap: 10px;\n    height: 36px;\n    padding: 0 10px;\n    border-radius: var(--radius-control);\n    font-size: 14px;\n    cursor: default;\n    transition: background-color var(--duration-fast) var(--ease-out);\n  }\n  [data-slot=\"select-menu-option\"][data-active=\"true\"] {\n    background: var(--selected);\n  }\n  [data-slot=\"select-menu-option\"][aria-disabled=\"true\"] {\n    color: var(--muted-foreground);\n    opacity: 0.7;\n  }\n  [data-slot=\"select-menu-native\"] [data-slot=\"select\"] {\n    appearance: none;\n  }\n  [data-slot=\"select-menu-icon\"] {\n    display: inline-grid;\n    flex: none;\n    place-items: center;\n    width: 16px;\n    height: 16px;\n  }\n  [data-slot=\"select-menu-icon\"] svg { width: 16px; height: 16px; }\n  /* Ghost: no field ring, for a select inside a composed control. */\n  [data-slot=\"select-menu\"][data-variant=\"ghost\"],\n  [data-slot=\"select-menu-native\"][data-variant=\"ghost\"] [data-slot=\"select\"] {\n    background-color: transparent;\n    box-shadow: none;\n  }\n  @media (hover: hover) {\n    [data-slot=\"select-menu\"][data-variant=\"ghost\"]:hover:enabled:not([aria-expanded=\"true\"]) {\n      background-color: var(--raised-hover);\n      box-shadow: none;\n    }\n  }\n  [data-slot=\"select-menu\"][data-variant=\"ghost\"]:focus-visible,\n  [data-slot=\"select-menu\"][data-variant=\"ghost\"][aria-expanded=\"true\"] {\n    background-color: var(--raised-hover);\n    box-shadow: 0 0 0 2px var(--ring);\n  }\n  [data-slot=\"select-menu\"][data-variant=\"ghost\"]:disabled {\n    background-color: transparent;\n    box-shadow: none;\n  }\n  [data-slot=\"select-menu\"][data-size=\"sm\"] [data-slot=\"select-menu-code\"] { font-size: 12px; }\n\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"select-menu-content\"] {\n      animation: none;\n    }\n  }\n  [data-slot=\"toolbar\"] [data-toolbar-generic] [data-slot=\"select-menu\"] { height: calc(var(--control-height) - 6px); }\n}\n"}],"categories":["inputs"],"meta":{"tier":"free","license":"MIT","family":"inputs","docs":"/docs/components/select-menu"}}