{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "split-button",
  "type": "registry:component",
  "title": "Split Button",
  "description": "A primary action with a menu of alternatives beside it: pick one and it runs, then takes the primary slot, its label springing to the new width.",
  "categories": [
    "component",
    "Workspace"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/button.json",
    "https://coss.com/ui/r/menu.json",
    "https://coss.com/ui/r/spinner.json"
  ],
  "files": [
    {
      "path": "registry/devl/split-button.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { CheckIcon, ChevronDownIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Menu, MenuGroup, MenuGroupLabel, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from \"@/components/ui/menu\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface SplitAction {\n  id: string;\n  label: string;\n  description?: string;\n  icon?: ReactNode;\n  done?: string;\n}\n\nexport interface SplitButtonProps {\n  actions: SplitAction[];\n  defaultActionId?: string;\n  onAction?: (action: SplitAction) => void | Promise<unknown>;\n  remember?: boolean;\n  menuLabel?: string;\n  variant?: \"default\" | \"outline\";\n  disabled?: boolean;\n  className?: string;\n}\n\ntype Phase = \"idle\" | \"running\" | \"done\";\n\nexport function SplitButton({\n  actions,\n  defaultActionId,\n  onAction,\n  remember = true,\n  menuLabel = \"Choose an action\",\n  variant = \"default\",\n  disabled,\n  className,\n}: SplitButtonProps) {\n  const reduce = useReducedMotion();\n  const [primaryId, setPrimaryId] = useState(defaultActionId ?? actions[0]?.id);\n  const [phase, setPhase] = useState<Phase>(\"idle\");\n  const [width, setWidth] = useState<number | \"auto\">(\"auto\");\n  const measureRef = useRef<HTMLSpanElement>(null);\n  const doneTimer = useRef<number>(undefined);\n  const mounted = useRef(true);\n  useEffect(() => {\n    mounted.current = true;\n    return () => {\n      mounted.current = false;\n      clearTimeout(doneTimer.current);\n    };\n  }, []);\n\n  const primary = actions.find((a) => a.id === primaryId) ?? actions[0];\n  const label = phase === \"done\" ? (primary.done ?? primary.label) : primary.label;\n\n  useLayoutEffect(() => {\n    if (measureRef.current) setWidth(measureRef.current.offsetWidth);\n  }, [label]);\n\n  const run = async (action: SplitAction) => {\n    if (phase === \"running\") return;\n    if (remember) setPrimaryId(action.id);\n    clearTimeout(doneTimer.current);\n    setPhase(\"running\");\n    try {\n      await onAction?.(action);\n      if (!mounted.current) return;\n      setPhase(\"done\");\n      doneTimer.current = window.setTimeout(() => setPhase(\"idle\"), 1600);\n    } catch {\n      if (mounted.current) setPhase(\"idle\");\n    }\n  };\n\n  const ease = [0.23, 1, 0.32, 1] as const;\n  const swap = {\n    initial: reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" },\n    animate: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n    exit: reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\" },\n    transition: { duration: 0.18, ease },\n  };\n  const icon = phase === \"running\" ? <Spinner className=\"size-4\" key=\"spin\" /> : phase === \"done\" ? <CheckIcon key=\"done\" /> : primary.icon;\n\n  return (\n    <div className={cn(\"inline-flex rounded-lg\", className)} role=\"group\">\n      <Button\n        aria-busy={phase === \"running\" || undefined}\n        className=\"rounded-e-none before:rounded-e-none\"\n        disabled={disabled}\n        onClick={() => run(primary)}\n        variant={variant}\n      >\n        <span className=\"relative flex size-4 shrink-0 items-center justify-center [&_svg]:size-4\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span className=\"flex\" key={phase === \"idle\" ? primary.id : phase} {...swap}>\n              {icon}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n        <motion.span\n          animate={{ width }}\n          className=\"relative flex h-5 items-center overflow-hidden\"\n          initial={false}\n          transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 520, damping: 42 }}\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span className=\"whitespace-nowrap\" key={label} {...swap}>\n              {label}\n            </motion.span>\n          </AnimatePresence>\n          <span aria-hidden className=\"pointer-events-none invisible absolute whitespace-nowrap\" ref={measureRef}>\n            {label}\n          </span>\n        </motion.span>\n        <span aria-live=\"polite\" className=\"sr-only\">\n          {phase === \"running\" ? `${primary.label}…` : phase === \"done\" ? (primary.done ?? `${primary.label} finished`) : \"\"}\n        </span>\n      </Button>\n      <Menu>\n        <MenuTrigger\n          disabled={disabled}\n          render={\n            <Button\n              aria-label=\"More actions\"\n              className={cn(\n                \"group/chev rounded-s-none px-2 before:rounded-s-none\",\n                variant === \"default\"\n                  ? \"border-s-primary-foreground/16 dark:border-s-primary-foreground/24\"\n                  : \"-ms-px\",\n              )}\n              variant={variant}\n            />\n          }\n        >\n          <ChevronDownIcon className=\"transition-transform duration-200 ease-out group-data-popup-open/chev:rotate-180\" />\n        </MenuTrigger>\n        <MenuPopup\n          align=\"end\"\n          className=\"w-72 transition-[scale,opacity] duration-150 ease-out data-ending-style:scale-[0.98] data-starting-style:scale-[0.96] data-ending-style:opacity-0 data-starting-style:opacity-0\"\n          sideOffset={6}\n        >\n          <MenuGroup>\n            <MenuGroupLabel>{menuLabel}</MenuGroupLabel>\n            {actions.map((a) => (\n              <MenuItem className=\"items-start gap-2.5 py-2\" key={a.id} onClick={() => run(a)}>\n                {a.icon && (\n                  <span className=\"mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-md border bg-background text-muted-foreground shadow-xs/5 [&_svg]:size-3.5\">\n                    {a.icon}\n                  </span>\n                )}\n                <span className=\"min-w-0 flex-1\">\n                  <span className=\"block font-medium text-sm\">{a.label}</span>\n                  {a.description && <span className=\"block text-pretty text-muted-foreground text-xs leading-snug\">{a.description}</span>}\n                </span>\n                <CheckIcon className={cn(\"mt-1 size-4 shrink-0 transition-opacity\", a.id === primary.id ? \"opacity-100\" : \"opacity-0\")} />\n              </MenuItem>\n            ))}\n          </MenuGroup>\n          {remember && (\n            <>\n              <MenuSeparator />\n              <p className=\"px-2 py-1.5 text-muted-foreground text-xs\">The one you pick becomes the default.</p>\n            </>\n          )}\n        </MenuPopup>\n      </Menu>\n    </div>\n  );\n}\n"
    }
  ]
}