{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "segmented-control",
  "type": "registry:ui",
  "title": "Segmented Control",
  "description": "A radio-group segmented control with a spring thumb, roving arrow keys, sizes and native form support.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/segmented-control.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion } from \"motion/react\";\n\nexport interface SegmentedOption {\n  /** Unique value submitted with forms and passed to `onValueChange`. */\n  value: string;\n  /** Visible label. With `iconOnly`, it becomes the accessible name. */\n  label: string;\n  /** Optional leading icon (decorative). */\n  icon?: React.ReactNode;\n  disabled?: boolean;\n}\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport interface SegmentedControlProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\" | \"children\"> {\n  options: SegmentedOption[];\n  /** Selected value (controlled). */\n  value?: string;\n  /** Initially selected value (uncontrolled). Defaults to the first enabled option. */\n  defaultValue?: string;\n  /** Called when the user picks another option. */\n  onValueChange?: (value: string) => void;\n  /** Accessible name of the radio group. Use this or `aria-labelledby`. */\n  label?: string;\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** \"neutral\" raises a white thumb; \"accent\" fills it with the accent colour. */\n  variant?: \"neutral\" | \"accent\";\n  /** Stretch the control and share the width equally between options. */\n  fullWidth?: boolean;\n  /** Hide the labels visually and show icons only (labels stay the accessible names). */\n  iconOnly?: boolean;\n  /** Spring that slides the thumb between options. */\n  spring?: Spring;\n  /** Thumb fill (accent variant) and focus ring colour. */\n  accent?: string;\n  /** Disable the whole control. */\n  disabled?: boolean;\n  /** Adds a hidden input with this name so the value submits with a form. */\n  name?: string;\n}\n\nconst SIZES = {\n  sm: { root: \"h-8 rounded-[10px] p-[3px]\", item: \"gap-1.5 rounded-[7px] px-2.5 text-xs\", icon: \"[&_svg]:size-3.5\", square: \"w-[26px]\" },\n  md: { root: \"h-10 rounded-[12px] p-1\", item: \"gap-2 rounded-[8px] px-3.5 text-sm\", icon: \"[&_svg]:size-4\", square: \"w-8\" },\n  lg: { root: \"h-12 rounded-[14px] p-1\", item: \"gap-2 rounded-[10px] px-5 text-[15px]\", icon: \"[&_svg]:size-[18px]\", square: \"w-10\" },\n} as const;\n\nconst THUMB = {\n  neutral:\n    \"bg-white shadow-[0_1px_2px_rgb(0_0_0/0.08),0_0_0_1px_rgb(0_0_0/0.05)] dark:bg-zinc-700 dark:shadow-[0_1px_2px_rgb(0_0_0/0.5),0_0_0_1px_rgb(255_255_255/0.07)]\",\n  accent: \"bg-[var(--sc-accent)] shadow-[0_1px_2px_rgb(0_0_0/0.12)]\",\n} as const;\n\nconst TEXT = {\n  neutral: \"aria-checked:text-zinc-950 dark:aria-checked:text-white\",\n  accent: \"aria-checked:text-zinc-950 dark:aria-checked:text-zinc-950\",\n} as const;\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/**\n * A segmented control built on the WAI-ARIA radio group pattern (not tabs): one option is always\n * checked, arrow keys move focus and selection together (roving tabindex), and a single thumb\n * slides between options with a shared-layout spring.\n */\nexport function SegmentedControl({\n  options,\n  value,\n  defaultValue,\n  onValueChange,\n  label,\n  size = \"md\",\n  variant = \"neutral\",\n  fullWidth = false,\n  iconOnly = false,\n  spring = { stiffness: 420, damping: 34, mass: 0.8 },\n  accent = \"#FF7448\",\n  disabled = false,\n  name,\n  className = \"\",\n  style,\n  ...props\n}: SegmentedControlProps) {\n  const id = React.useId();\n  const reduceMotion = usePrefersReducedMotion();\n  const radioRefs = React.useRef(new Map<string, HTMLButtonElement>());\n  const enabled = disabled ? [] : options.filter((option) => !option.disabled);\n  const [internal, setInternal] = React.useState(defaultValue ?? options.find((o) => !o.disabled)?.value);\n  const selected = value ?? internal;\n  // Tab lands on the checked radio; if it is disabled (or nothing is checked) on the first enabled one.\n  const tabStop = enabled.some((o) => o.value === selected) ? selected : enabled[0]?.value;\n  const s = SIZES[size];\n\n  const select = (next: string) => {\n    if (next === selected) return;\n    if (value === undefined) setInternal(next);\n    onValueChange?.(next);\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    if (!enabled.length) return;\n    const current = (event.target as HTMLElement).dataset.value;\n    const index = enabled.findIndex((o) => o.value === current);\n    let nextIndex: number;\n    switch (event.key) {\n      case \"ArrowRight\":\n      case \"ArrowDown\":\n        nextIndex = (index + 1) % enabled.length;\n        break;\n      case \"ArrowLeft\":\n      case \"ArrowUp\":\n        nextIndex = (index - 1 + enabled.length) % enabled.length;\n        break;\n      case \"Home\":\n        nextIndex = 0;\n        break;\n      case \"End\":\n        nextIndex = enabled.length - 1;\n        break;\n      case \" \":\n        // Space checks the focused radio (it is normally already checked).\n        event.preventDefault();\n        if (current) select(current);\n        return;\n      default:\n        return;\n    }\n    event.preventDefault();\n    const next = enabled[nextIndex];\n    radioRefs.current.get(next.value)?.focus();\n    select(next.value);\n  };\n\n  const transition = reduceMotion ? { duration: 0 } : { type: \"spring\" as const, ...spring };\n\n  return (\n    <div\n      role=\"radiogroup\"\n      aria-label={label}\n      aria-disabled={disabled || undefined}\n      onKeyDown={handleKeyDown}\n      className={`relative ${fullWidth ? \"flex w-full\" : \"inline-flex\"} select-none items-stretch border border-zinc-900/[0.07] bg-zinc-900/[0.045] dark:border-white/[0.07] dark:bg-white/[0.05] ${\n        s.root\n      } ${disabled ? \"opacity-50\" : \"\"} ${className}`}\n      style={{ [\"--sc-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      {options.map((option, index) => {\n        const isChecked = option.value === selected;\n        const isDisabled = disabled || option.disabled;\n        return (\n          <React.Fragment key={option.value}>\n            {/* Hairline divider between two unselected neighbours, like a native segmented control. */}\n            {index > 0 && (\n              <span\n                aria-hidden=\"true\"\n                className={`my-[22%] w-px shrink-0 bg-zinc-900/10 transition-opacity duration-200 motion-reduce:transition-none dark:bg-white/10 ${\n                  isChecked || options[index - 1].value === selected ? \"opacity-0\" : \"opacity-100\"\n                }`}\n              />\n            )}\n            <button\n              ref={(node) => {\n                if (node) radioRefs.current.set(option.value, node);\n                else radioRefs.current.delete(option.value);\n              }}\n              type=\"button\"\n              role=\"radio\"\n              data-value={option.value}\n              aria-checked={isChecked}\n              aria-label={iconOnly ? option.label : undefined}\n              title={iconOnly ? option.label : undefined}\n              tabIndex={option.value === tabStop ? 0 : -1}\n              disabled={isDisabled}\n              onClick={() => select(option.value)}\n              className={`relative inline-flex min-w-0 items-center justify-center whitespace-nowrap font-medium tracking-tight text-zinc-500 outline-hidden transition-colors duration-200 hover:text-zinc-900 focus-visible:z-20 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-solid focus-visible:outline-[var(--sc-accent)] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-zinc-500 dark:text-zinc-400 dark:hover:text-zinc-100 ${\n                s.item\n              } ${TEXT[variant]} ${fullWidth ? \"flex-1\" : \"\"} ${iconOnly ? `${s.square} px-0` : \"\"}`}\n            >\n              {isChecked && (\n                <motion.span\n                  aria-hidden=\"true\"\n                  // useId scopes the layoutId, so several controls on one page never share a thumb.\n                  layoutId={`${id}-thumb`}\n                  transition={transition}\n                  className={`absolute inset-0 rounded-[inherit] ${THUMB[variant]}`}\n                />\n              )}\n              {option.icon && (\n                <span aria-hidden=\"true\" className={`relative z-10 inline-flex shrink-0 ${s.icon}`}>\n                  {option.icon}\n                </span>\n              )}\n              {!iconOnly && <span className=\"relative z-10 truncate\">{option.label}</span>}\n            </button>\n          </React.Fragment>\n        );\n      })}\n      {name !== undefined && <input type=\"hidden\" name={name} value={selected ?? \"\"} disabled={disabled} />}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "buttons"
  ],
  "docs": "Documentation: https://wancraft.my/components/segmented-control"
}
