{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "px-select",
  "title": "Select",
  "description": "Select with a menu that unfolds downward, a pixel highlight that springs between options and type-ahead by first letter.",
  "registryDependencies": [
    "https://elements.lumesec.ai/r/lumesec-theme.json",
    "https://elements.lumesec.ai/r/lumesec-motion.json",
    "https://elements.lumesec.ai/r/lumesec-pixel-canvas.json",
    "https://elements.lumesec.ai/r/lumesec-pixel-font.json",
    "https://elements.lumesec.ai/r/lumesec-roll-text.json",
    "https://elements.lumesec.ai/r/lumesec-use-controllable-state.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/components/lumesec/px-select.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { prefersReducedMotion, springEuler } from \"@/lib/lumesec/motion\";\nimport { usePixelCanvas } from \"@/lib/lumesec/pixel-canvas\";\nimport { SMALL_CHECK } from \"@/lib/lumesec/pixel-font\";\nimport { RollText } from \"@/lib/lumesec/roll-text\";\nimport { useControllableState, useFormReset } from \"@/lib/lumesec/use-controllable-state\";\nimport { cn } from \"@/lib/utils\";\n\n/** One choice of the menu. */\nexport interface PxSelectOption {\n  /** Submitted value. Defaults to `label`. */\n  value?: string;\n  /** Text shown in the trigger and the menu. */\n  label: string;\n  /** Second line in the menu. */\n  description?: string;\n}\n\n/** Props of `PxSelect`. Other `div` props go to the root. */\nexport interface PxSelectProps extends Omit<React.ComponentProps<\"div\">, \"children\" | \"defaultValue\" | \"onChange\"> {\n  /** The choices, in order. */\n  options: readonly PxSelectOption[];\n  /** Controlled selected value. Use with `onValueChange`. */\n  value?: string;\n  /** Initially selected value when uncontrolled; also restored on form reset. Defaults to the first option. */\n  defaultValue?: string;\n  /** Called when the user picks a different option. */\n  onValueChange?: (value: string) => void;\n  /** Controlled open state of the menu. Use with `onOpenChange`. */\n  open?: boolean;\n  /** Initial open state when uncontrolled. */\n  defaultOpen?: boolean;\n  /** Called when the menu opens or closes. */\n  onOpenChange?: (open: boolean) => void;\n  /** Label above the trigger; also names the listbox. */\n  label?: React.ReactNode;\n  /** Help text under the trigger; also describes it. */\n  hint?: React.ReactNode;\n  /** Form field name. */\n  name?: string;\n  /** Dims the select, disables the trigger and keeps the menu closed. */\n  disabled?: boolean;\n}\n\n/** Keyframes of the menu unfolding downward. */\nconst UNFOLD: Keyframe[] = [\n  { clipPath: \"inset(0 0 100% 0 round 12px)\", opacity: 0.4 },\n  { clipPath: \"inset(0 0 0 0 round 12px)\", opacity: 1 },\n];\n\n/**\n * Select with a menu that unfolds downward, a pixel highlight that springs between options, a pixel\n * check on the selected one and type-ahead by first letter. The selected label rolls when it changes.\n *\n * @example\n * <PxSelect\n *   name=\"model\"\n *   label=\"Model\"\n *   defaultValue=\"medium\"\n *   options={[\n *     { value: \"large\", label: \"Large\", description: \"Most capable\" },\n *     { value: \"medium\", label: \"Medium\", description: \"Balanced\" },\n *   ]}\n * />\n */\nexport function PxSelect({\n  options,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  label,\n  hint,\n  name,\n  disabled = false,\n  className,\n  ...props\n}: PxSelectProps) {\n  const baseId = React.useId();\n  const labelId = `${baseId}-label`;\n  const valueId = `${baseId}-value`;\n  const hintId = `${baseId}-hint`;\n  const listId = `${baseId}-list`;\n  const optionId = (index: number) => `${baseId}-option-${index}`;\n  const values = options.map((option) => option.value ?? option.label);\n  const initialValue = defaultValue ?? values[0] ?? \"\";\n\n  const anchorRef = React.useRef<HTMLDivElement>(null);\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const popRef = React.useRef<HTMLDivElement>(null);\n  const listRef = React.useRef<HTMLUListElement>(null);\n  const itemRefs = React.useRef<(HTMLLIElement | null)[]>([]);\n  const [value, setValue] = useControllableState({\n    value: valueProp,\n    defaultValue: initialValue,\n    onChange: onValueChange,\n  });\n  const [openState, setOpen] = useControllableState({\n    value: openProp,\n    defaultValue: defaultOpen,\n    onChange: onOpenChange,\n  });\n  // a disabled or empty select never shows its menu, even when `open` asks for it\n  const open = openState && !disabled && options.length > 0;\n  const selectedIndex = Math.max(0, values.indexOf(value));\n  const [highlightedIndex, setActiveIndex] = React.useState(selectedIndex);\n  // options can shrink while the menu is open\n  const activeIndex = Math.min(highlightedIndex, options.length - 1);\n\n  // opening highlights the selected option\n  const [shownOpen, setShownOpen] = React.useState(open);\n  if (shownOpen !== open) {\n    setShownOpen(open);\n    if (open) setActiveIndex(selectedIndex);\n  }\n\n  // the label rolls only when the user picks; resets and controlled changes swap it in place\n  const [roll, setRoll] = React.useState({ index: selectedIndex, direction: 1, animate: false });\n\n  const highlight = React.useRef({ position: 0, velocity: 0 });\n  const snap = React.useRef(true);\n\n  const { ref: canvasRef, redraw } = usePixelCanvas(({ clear, dot, palette, delta, width }) => {\n    if (!open) return false;\n    const list = listRef.current;\n    const item = itemRefs.current[activeIndex];\n    if (!list || !item) return false;\n    const targetY = item.offsetTop - list.scrollTop;\n    const height = item.offsetHeight;\n    if (snap.current || prefersReducedMotion()) {\n      highlight.current.position = targetY;\n      highlight.current.velocity = 0;\n      snap.current = false;\n    } else {\n      springEuler(highlight.current, targetY, delta, 360, 0.82);\n    }\n    const top = highlight.current.position;\n    clear();\n    for (let y = top + 3; y < top + height - 2; y += 4) {\n      for (let x = 3; x < width - 3; x += 4) dot(x, y, palette.accent, 0.1, 2.2);\n    }\n    for (let y = top + 8; y < top + height - 8; y += 3) dot(0, y, palette.accent, 1, 2.2);\n    const selected = itemRefs.current[selectedIndex];\n    if (selected) {\n      const centerY = selected.offsetTop - list.scrollTop + selected.offsetHeight / 2;\n      for (const [x, y] of SMALL_CHECK) dot(width - 24 + x * 2, centerY - 5 + y * 2, palette.accent, 1, 2);\n    }\n    return Math.abs(targetY - highlight.current.position) > 0.3 || Math.abs(highlight.current.velocity) > 1;\n  });\n\n  const close = (focusTrigger: boolean) => {\n    setOpen(false);\n    if (focusTrigger) triggerRef.current?.focus();\n  };\n\n  // on open: unfold, focus the list, and close on a press outside the trigger and the menu\n  React.useLayoutEffect(() => {\n    if (!open) return;\n    const pop = popRef.current;\n    if (pop && !prefersReducedMotion() && typeof pop.animate === \"function\") {\n      pop.animate(UNFOLD, { duration: 220, easing: \"cubic-bezier(.2,.8,.2,1)\" });\n    }\n    snap.current = true;\n    listRef.current?.focus({ preventScroll: true });\n    const onPointerDown = (event: PointerEvent) => {\n      const anchor = anchorRef.current;\n      if (anchor && !event.composedPath().includes(anchor)) setOpen(false);\n    };\n    document.addEventListener(\"pointerdown\", onPointerDown);\n    return () => document.removeEventListener(\"pointerdown\", onPointerDown);\n  }, [open, setOpen]);\n\n  React.useEffect(() => {\n    if (open) itemRefs.current[activeIndex]?.scrollIntoView({ block: \"nearest\" });\n    redraw();\n  }, [open, activeIndex, selectedIndex, redraw]);\n\n  useFormReset(triggerRef, () => setValue(initialValue));\n\n  const choose = (index: number) => {\n    setRoll({ index, direction: index >= selectedIndex ? 1 : -1, animate: true });\n    setValue(values[index]);\n    close(true);\n  };\n\n  const onListKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {\n    const last = options.length - 1;\n    if (event.key === \"ArrowDown\") {\n      event.preventDefault();\n      setActiveIndex(Math.min(last, activeIndex + 1));\n    } else if (event.key === \"ArrowUp\") {\n      event.preventDefault();\n      setActiveIndex(Math.max(0, activeIndex - 1));\n    } else if (event.key === \"Home\") {\n      event.preventDefault();\n      setActiveIndex(0);\n    } else if (event.key === \"End\") {\n      event.preventDefault();\n      setActiveIndex(last);\n    } else if (event.key === \"Enter\" || event.key === \" \") {\n      event.preventDefault();\n      choose(activeIndex);\n    } else if (event.key === \"Escape\") {\n      event.preventDefault();\n      close(true);\n    } else if (event.key === \"Tab\") {\n      // focus returns to the trigger first, so the browser moves on from there to the next or previous field\n      close(true);\n    } else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {\n      setActiveIndex(typeAhead(options, activeIndex, event.key));\n    }\n  };\n\n  return (\n    <div\n      data-slot=\"px-select\"\n      data-state={open ? \"open\" : \"closed\"}\n      data-disabled={disabled ? \"\" : undefined}\n      className={cn(\"block leading-[1.5] text-foreground\", disabled && \"pointer-events-none opacity-55\", className)}\n      {...props}\n    >\n      <span id={labelId} className=\"mb-1.5 block text-[12.5px] font-medium\">\n        {label}\n      </span>\n      <div ref={anchorRef} className=\"relative\">\n        <button\n          ref={triggerRef}\n          type=\"button\"\n          aria-haspopup=\"listbox\"\n          aria-expanded={open}\n          aria-controls={listId}\n          aria-labelledby={`${labelId} ${valueId}`}\n          aria-describedby={hint ? hintId : undefined}\n          disabled={disabled}\n          onClick={() => (open ? close(false) : setOpen(true))}\n          onKeyDown={(event) => {\n            if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n              event.preventDefault();\n              setOpen(true);\n            }\n          }}\n          className={cn(\n            \"flex h-[42px] w-full cursor-pointer items-center gap-2.5 rounded-[10px] border bg-card px-3 text-left font-[inherit] text-[14px] text-inherit outline-none select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lumesec focus-visible:outline-solid\",\n            open\n              ? \"border-[color-mix(in_srgb,var(--lumesec)_65%,var(--border))] ring-[3px] ring-lumesec/14\"\n              : \"border-border hover:border-[color-mix(in_srgb,var(--foreground)_25%,var(--border))]\",\n          )}\n        >\n          <span id={valueId} className=\"min-w-0 flex-1 overflow-x-clip\">\n            <RollText\n              text={options[selectedIndex]?.label ?? \"\"}\n              direction={roll.direction}\n              animate={roll.animate && roll.index === selectedIndex}\n            />\n          </span>\n          <svg\n            viewBox=\"0 0 12 12\"\n            aria-hidden=\"true\"\n            className={cn(\n              \"size-3 flex-none text-muted-foreground transition-transform duration-300 ease-[cubic-bezier(.2,.8,.2,1)] motion-reduce:transition-none\",\n              open && \"rotate-180\",\n            )}\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={1.8}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          >\n            <path d=\"M2.5 4.5L6 8l3.5-3.5\" />\n          </svg>\n        </button>\n        <div\n          ref={popRef}\n          hidden={!open}\n          className=\"absolute inset-x-0 top-[calc(100%+6px)] z-30 rounded-[12px] border border-border bg-card p-[5px] shadow-[0_16px_36px_-14px_rgb(0_0_0/0.45)]\"\n        >\n          <canvas\n            ref={canvasRef}\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute inset-[5px] block h-[calc(100%-10px)] w-[calc(100%-10px)] [image-rendering:pixelated]\"\n          />\n          <ul\n            ref={listRef}\n            id={listId}\n            role=\"listbox\"\n            tabIndex={-1}\n            aria-labelledby={labelId}\n            aria-activedescendant={activeIndex >= 0 ? optionId(activeIndex) : undefined}\n            onKeyDown={onListKeyDown}\n            onBlur={(event) => {\n              // focus that moves to another element closes the menu; a press outside is handled on pointerdown\n              const next = event.relatedTarget;\n              if (next instanceof Node && !anchorRef.current?.contains(next)) setOpen(false);\n            }}\n            onScroll={redraw}\n            className=\"relative m-0 max-h-[240px] list-none overflow-auto p-0 outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lumesec focus-visible:outline-solid\"\n          >\n            {options.map((option, index) => (\n              <li\n                key={values[index]}\n                ref={(node) => {\n                  itemRefs.current[index] = node;\n                }}\n                id={optionId(index)}\n                role=\"option\"\n                aria-selected={index === selectedIndex}\n                onPointerEnter={() => setActiveIndex(index)}\n                onClick={() => choose(index)}\n                className=\"grid cursor-pointer gap-px rounded-lg py-2 pr-8 pl-3 text-[13.5px]\"\n              >\n                {option.label}\n                {option.description ? (\n                  <small className=\"text-[12px] text-muted-foreground\">{option.description}</small>\n                ) : null}\n              </li>\n            ))}\n          </ul>\n        </div>\n      </div>\n      <span id={hintId} className=\"mt-1.5 block min-h-[1.45em] text-[12px] text-muted-foreground\">\n        {hint}\n      </span>\n      {name ? <input type=\"hidden\" name={name} value={values[selectedIndex] ?? \"\"} disabled={disabled} /> : null}\n    </div>\n  );\n}\n\n/** Type-ahead: the next option after `from` whose label starts with `key`, wrapping to the first; `from` when none does. */\nfunction typeAhead(options: readonly PxSelectOption[], from: number, key: string) {\n  const letter = key.toLowerCase();\n  const startsWith = (option: PxSelectOption) => option.label.toLowerCase().startsWith(letter);\n  const after = options.findIndex((option, index) => index > from && startsWith(option));\n  const match = after >= 0 ? after : options.findIndex(startsWith);\n  return match >= 0 ? match : from;\n}\n",
      "type": "registry:component",
      "target": "@components/lumesec/px-select.tsx"
    }
  ],
  "meta": {
    "tag": "px-select",
    "collection": "pixel-ui",
    "react": "PxSelect",
    "kind": "react"
  },
  "docs": "Select docs: https://elements.lumesec.ai/components/pixel-ui/select",
  "categories": [
    "pixel-ui",
    "forms"
  ],
  "type": "registry:component"
}