{
  "name": "select",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "select.tsx",
      "content": "\"use client\";\r\n\r\nimport { computePosition, css, scaleIn, scaleOut, themeVars as theme } from \"@yugnex/core\";\r\nimport { useClickOutside, useControllableState, useEscapeKey, usePortal, usePresence } from \"@yugnex/core/client\";\r\nimport { useCallback, useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from \"react\";\r\nimport { createPortal } from \"react-dom\";\r\n\r\nconst triggerClass = css({\r\n  display: \"inline-flex\",\r\n  alignItems: \"center\",\r\n  justifyContent: \"space-between\",\r\n  gap: theme.space[2],\r\n  width: \"100%\",\r\n  height: \"2.5rem\",\r\n  padding: `0 ${theme.space[3]}`,\r\n  borderRadius: theme.radius.md,\r\n  border: `1px solid ${theme.color.input}`,\r\n  backgroundColor: theme.color.background,\r\n  color: theme.color.foreground,\r\n  fontFamily: theme.fontFamily.sans,\r\n  fontSize: theme.fontSize.sm,\r\n  cursor: \"pointer\",\r\n  transitionProperty: \"border-color, box-shadow\",\r\n  transitionDuration: theme.duration.fast,\r\n  \"&:focus-visible\": {\r\n    outline: \"none\",\r\n    borderColor: theme.color.ring,\r\n    boxShadow: `0 0 0 3px ${theme.color.accent}`,\r\n  },\r\n  \"&:disabled\": { opacity: 0.5, cursor: \"not-allowed\" },\r\n});\r\n\r\nconst placeholderClass = css({ color: theme.color.mutedForeground });\r\n\r\nconst valueClass = css({\r\n  overflow: \"hidden\",\r\n  textOverflow: \"ellipsis\",\r\n  whiteSpace: \"nowrap\",\r\n  textAlign: \"left\",\r\n  flex: 1,\r\n});\r\n\r\nconst chevronClass = css({\r\n  flexShrink: 0,\r\n  color: theme.color.mutedForeground,\r\n  transitionProperty: \"transform\",\r\n  transitionDuration: theme.duration.fast,\r\n  '[data-state=\"open\"] &': { transform: \"rotate(180deg)\" },\r\n});\r\n\r\nconst listboxClass = css({\r\n  position: \"fixed\",\r\n  top: 0,\r\n  left: 0,\r\n  zIndex: theme.zIndex.dropdown,\r\n  maxHeight: \"16rem\",\r\n  overflowY: \"auto\",\r\n  padding: theme.space[1],\r\n  margin: 0,\r\n  listStyle: \"none\",\r\n  borderRadius: theme.radius.md,\r\n  border: `1px solid ${theme.color.border}`,\r\n  backgroundColor: theme.color.popover,\r\n  color: theme.color.popoverForeground,\r\n  boxShadow: theme.shadow.lg,\r\n  fontFamily: theme.fontFamily.sans,\r\n  fontSize: theme.fontSize.sm,\r\n  '&[data-state=\"open\"]': { animation: `${scaleIn} ${theme.duration.fast} ${theme.easing.decelerate}` },\r\n  '&[data-state=\"closed\"]': { animation: `${scaleOut} ${theme.duration.fast} ${theme.easing.accelerate}` },\r\n  \"&:focus-visible\": { outline: \"none\" },\r\n});\r\n\r\nconst optionClass = css({\r\n  display: \"flex\",\r\n  alignItems: \"center\",\r\n  gap: theme.space[2],\r\n  padding: `${theme.space[2]} ${theme.space[2.5]}`,\r\n  borderRadius: theme.radius.sm,\r\n  cursor: \"pointer\",\r\n  userSelect: \"none\",\r\n  '&[data-active=\"true\"]': {\r\n    backgroundColor: theme.color.accent,\r\n    color: theme.color.accentForeground,\r\n  },\r\n  '&[aria-disabled=\"true\"]': { opacity: 0.5, cursor: \"not-allowed\" },\r\n});\r\n\r\nconst checkClass = css({ marginLeft: \"auto\", flexShrink: 0 });\r\n\r\nconst groupLabelClass = css({\r\n  padding: `${theme.space[1.5]} ${theme.space[2.5]}`,\r\n  fontSize: theme.fontSize.xs,\r\n  fontWeight: theme.fontWeight.semibold,\r\n  letterSpacing: theme.letterSpacing.wide,\r\n  textTransform: \"uppercase\",\r\n  color: theme.color.mutedForeground,\r\n});\r\n\r\nexport interface SelectOption {\r\n  value: string;\r\n  label: string;\r\n  description?: string;\r\n  disabled?: boolean;\r\n  /** Options sharing a group render under one heading, in the order first seen. */\r\n  group?: string;\r\n}\r\n\r\nexport interface SelectProps {\r\n  options: SelectOption[];\r\n  value?: string;\r\n  defaultValue?: string;\r\n  onValueChange?: (value: string) => void;\r\n  placeholder?: string;\r\n  disabled?: boolean;\r\n  /** Accessible name when there's no visible <label> wired to `id`. */\r\n  label?: string;\r\n  id?: string;\r\n  className?: string;\r\n}\r\n\r\n/**\r\n * A single-select dropdown following the ARIA listbox-popup pattern: focus\r\n * moves into the list and `aria-activedescendant` tracks the highlighted\r\n * option, so screen readers announce each option as you arrow through without\r\n * the DOM focus churn of moving focus per item. Supports arrows, Home/End,\r\n * Enter/Space, Escape, and printable-character typeahead.\r\n */\r\nexport function Select({\r\n  options,\r\n  value,\r\n  defaultValue,\r\n  onValueChange,\r\n  placeholder = \"Select…\",\r\n  disabled,\r\n  label,\r\n  id,\r\n  className,\r\n}: SelectProps) {\r\n  const [selected, setSelected] = useControllableState<string | undefined>({\r\n    value,\r\n    defaultValue,\r\n    onChange: (next) => next != null && onValueChange?.(next),\r\n  });\r\n\r\n  const [open, setOpen] = useState(false);\r\n  const { mounted, dataState } = usePresence(open, { exitDuration: 150 });\r\n  const portalNode = usePortal();\r\n\r\n  const triggerRef = useRef<HTMLButtonElement | null>(null);\r\n  const listRef = useRef<HTMLUListElement | null>(null);\r\n  const [pos, setPos] = useState({ x: 0, y: 0, width: 0 });\r\n\r\n  const enabled = options.filter((o) => !o.disabled);\r\n  const selectedOption = options.find((o) => o.value === selected);\r\n\r\n  const [activeIndex, setActiveIndex] = useState(0);\r\n  const baseId = useId();\r\n  const optionId = (index: number) => `${baseId}-option-${index}`;\r\n\r\n  useEscapeKey(() => setOpen(false), open);\r\n  useClickOutside(listRef, () => setOpen(false), open);\r\n\r\n  useEffect(() => {\r\n    if (!mounted) return;\r\n    const update = () => {\r\n      if (!triggerRef.current || !listRef.current) return;\r\n      const rect = triggerRef.current.getBoundingClientRect();\r\n      const result = computePosition(triggerRef.current, listRef.current, { placement: \"bottom\", align: \"start\" });\r\n      setPos({ x: result.x, y: result.y, width: rect.width });\r\n    };\r\n    update();\r\n    window.addEventListener(\"scroll\", update, true);\r\n    window.addEventListener(\"resize\", update);\r\n    return () => {\r\n      window.removeEventListener(\"scroll\", update, true);\r\n      window.removeEventListener(\"resize\", update);\r\n    };\r\n  }, [mounted]);\r\n\r\n  // Read through refs so this effect can depend on `open` alone. Callers\r\n  // routinely pass an inline `options={[...]}` array, whose identity changes\r\n  // every render — depending on it directly would re-run this on each render\r\n  // and snap the highlight back to the selected item mid-keyboard-navigation.\r\n  const latest = useRef({ options, selected });\r\n  latest.current = { options, selected };\r\n\r\n  // Open with the current selection highlighted.\r\n  useEffect(() => {\r\n    if (!open) return;\r\n    const { options: opts, selected: current } = latest.current;\r\n    const index = opts.findIndex((o) => o.value === current);\r\n    setActiveIndex(index >= 0 ? index : opts.findIndex((o) => !o.disabled));\r\n  }, [open]);\r\n\r\n  // Focus is keyed on `mounted`, not `open`: the portaled <ul> only exists a\r\n  // tick after `open` flips, so focusing on `open` reads a null ref and leaves\r\n  // focus on the trigger — where arrow keys would never reach the listbox.\r\n  useEffect(() => {\r\n    if (!mounted) return;\r\n    const raf = requestAnimationFrame(() => listRef.current?.focus());\r\n    return () => cancelAnimationFrame(raf);\r\n  }, [mounted]);\r\n\r\n  // Keep the highlighted option scrolled into view as arrows move past the edge.\r\n  useEffect(() => {\r\n    if (!mounted) return;\r\n    document.getElementById(`${baseId}-option-${activeIndex}`)?.scrollIntoView({ block: \"nearest\" });\r\n  }, [mounted, activeIndex, baseId]);\r\n\r\n  const typeaheadRef = useRef({ query: \"\", timer: 0 });\r\n\r\n  const moveActive = useCallback(\r\n    (direction: 1 | -1) => {\r\n      setActiveIndex((current) => {\r\n        // Walk outward until we land on an enabled option, wrapping at both ends.\r\n        for (let step = 1; step <= options.length; step++) {\r\n          const raw = current + direction * step;\r\n          const next = ((raw % options.length) + options.length) % options.length;\r\n          if (!options[next]?.disabled) return next;\r\n        }\r\n        return current;\r\n      });\r\n    },\r\n    [options],\r\n  );\r\n\r\n  function commit(index: number) {\r\n    const option = options[index];\r\n    if (!option || option.disabled) return;\r\n    setSelected(option.value);\r\n    setOpen(false);\r\n    triggerRef.current?.focus();\r\n  }\r\n\r\n  function handleListKeyDown(event: KeyboardEvent<HTMLUListElement>) {\r\n    if (event.key === \"ArrowDown\") {\r\n      event.preventDefault();\r\n      moveActive(1);\r\n    } else if (event.key === \"ArrowUp\") {\r\n      event.preventDefault();\r\n      moveActive(-1);\r\n    } else if (event.key === \"Home\") {\r\n      event.preventDefault();\r\n      setActiveIndex(options.findIndex((o) => !o.disabled));\r\n    } else if (event.key === \"End\") {\r\n      event.preventDefault();\r\n      for (let i = options.length - 1; i >= 0; i--) {\r\n        if (!options[i]?.disabled) {\r\n          setActiveIndex(i);\r\n          break;\r\n        }\r\n      }\r\n    } else if (event.key === \"Enter\" || event.key === \" \") {\r\n      event.preventDefault();\r\n      commit(activeIndex);\r\n    } else if (event.key === \"Tab\") {\r\n      setOpen(false);\r\n    } else if (event.key.length === 1 && /\\S/.test(event.key)) {\r\n      // Typeahead: repeated characters build a query for ~600ms.\r\n      const state = typeaheadRef.current;\r\n      window.clearTimeout(state.timer);\r\n      state.query += event.key.toLowerCase();\r\n      state.timer = window.setTimeout(() => (state.query = \"\"), 600);\r\n      const match = options.findIndex((o) => !o.disabled && o.label.toLowerCase().startsWith(state.query));\r\n      if (match >= 0) setActiveIndex(match);\r\n    }\r\n  }\r\n\r\n  function handleTriggerKeyDown(event: KeyboardEvent<HTMLButtonElement>) {\r\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\" || event.key === \"Enter\" || event.key === \" \") {\r\n      event.preventDefault();\r\n      setOpen(true);\r\n    }\r\n  }\r\n\r\n  // Group headings render inline before the first option of each group.\r\n  const seenGroups = new Set<string>();\r\n\r\n  return (\r\n    <>\r\n      <button\r\n        ref={triggerRef}\r\n        type=\"button\"\r\n        id={id}\r\n        role=\"combobox\"\r\n        aria-haspopup=\"listbox\"\r\n        aria-expanded={open}\r\n        aria-controls={mounted ? `${baseId}-listbox` : undefined}\r\n        aria-label={label}\r\n        disabled={disabled || enabled.length === 0}\r\n        data-state={open ? \"open\" : \"closed\"}\r\n        className={className ? `${triggerClass} ${className}` : triggerClass}\r\n        onClick={() => setOpen(!open)}\r\n        onKeyDown={handleTriggerKeyDown}\r\n      >\r\n        <span className={selectedOption ? valueClass : `${valueClass} ${placeholderClass}`}>\r\n          {selectedOption?.label ?? placeholder}\r\n        </span>\r\n        <svg className={chevronClass} width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" aria-hidden=\"true\">\r\n          <path d=\"M3 5.5L7 9.5L11 5.5\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\r\n        </svg>\r\n      </button>\r\n\r\n      {mounted && portalNode\r\n        ? createPortal(\r\n            <ul\r\n              ref={listRef}\r\n              id={`${baseId}-listbox`}\r\n              role=\"listbox\"\r\n              tabIndex={-1}\r\n              aria-label={label ?? placeholder}\r\n              aria-activedescendant={optionId(activeIndex)}\r\n              data-state={dataState}\r\n              className={listboxClass}\r\n              style={{ transform: `translate(${pos.x}px, ${pos.y}px)`, minWidth: pos.width }}\r\n              onKeyDown={handleListKeyDown}\r\n            >\r\n              {options.map((option, index) => {\r\n                const showGroup = option.group && !seenGroups.has(option.group);\r\n                if (option.group) seenGroups.add(option.group);\r\n                return (\r\n                  <li key={option.value} style={{ listStyle: \"none\" }}>\r\n                    {showGroup ? (\r\n                      <div className={groupLabelClass} role=\"presentation\">\r\n                        {option.group}\r\n                      </div>\r\n                    ) : null}\r\n                    <div\r\n                      id={optionId(index)}\r\n                      role=\"option\"\r\n                      aria-selected={option.value === selected}\r\n                      aria-disabled={option.disabled || undefined}\r\n                      data-active={index === activeIndex}\r\n                      className={optionClass}\r\n                      onClick={() => commit(index)}\r\n                      onMouseEnter={() => !option.disabled && setActiveIndex(index)}\r\n                    >\r\n                      <span>\r\n                        {option.label}\r\n                        {option.description ? (\r\n                          <span style={{ display: \"block\", fontSize: \"0.75rem\", opacity: 0.7 }}>\r\n                            {option.description}\r\n                          </span>\r\n                        ) : null}\r\n                      </span>\r\n                      {option.value === selected ? (\r\n                        <svg className={checkClass} width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" aria-hidden=\"true\">\r\n                          <path d=\"M2.5 7.5L5.5 10.5L11.5 3.5\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\r\n                        </svg>\r\n                      ) : null}\r\n                    </div>\r\n                  </li>\r\n                );\r\n              })}\r\n            </ul>,\r\n            portalNode,\r\n          )\r\n        : null}\r\n    </>\r\n  );\r\n}\r\n\r\nexport interface SelectFieldProps extends SelectProps {\r\n  fieldLabel?: ReactNode;\r\n  description?: ReactNode;\r\n  error?: ReactNode;\r\n}\r\n\r\nconst fieldLabelClass = css({\r\n  display: \"block\",\r\n  marginBottom: theme.space[1.5],\r\n  fontSize: theme.fontSize.sm,\r\n  fontWeight: theme.fontWeight.medium,\r\n  color: theme.color.foreground,\r\n});\r\n\r\nconst fieldHelpClass = css({\r\n  marginTop: theme.space[1.5],\r\n  fontSize: theme.fontSize.xs,\r\n  color: theme.color.mutedForeground,\r\n});\r\n\r\nconst fieldErrorClass = css({\r\n  marginTop: theme.space[1.5],\r\n  fontSize: theme.fontSize.xs,\r\n  color: theme.color.destructive,\r\n});\r\n\r\n/** <Select> wrapped with a visible label, description, and error text. */\r\nexport function SelectField({ fieldLabel, description, error, id, ...props }: SelectFieldProps) {\r\n  const generatedId = useId();\r\n  const fieldId = id ?? generatedId;\r\n  return (\r\n    <div>\r\n      {fieldLabel ? (\r\n        <label className={fieldLabelClass} htmlFor={fieldId}>\r\n          {fieldLabel}\r\n        </label>\r\n      ) : null}\r\n      <Select id={fieldId} {...props} />\r\n      {description ? <p className={fieldHelpClass}>{description}</p> : null}\r\n      {error ? (\r\n        <p className={fieldErrorClass} role=\"alert\">\r\n          {error}\r\n        </p>\r\n      ) : null}\r\n    </div>\r\n  );\r\n}\r\n",
      "type": "registry:component"
    }
  ]
}