{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "split-button",
  "type": "registry:ui",
  "title": "Split Button",
  "description": "A primary action joined to a chevron menu of alternatives, with descriptions, typeahead and an optional remember-last-action mode.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/split-button.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport interface SplitButtonAction {\n  id: string;\n  label: string;\n  /** Secondary line shown under the label in the menu. */\n  description?: string;\n  /** Decorative leading icon (menu and primary button). */\n  icon?: React.ReactNode;\n  disabled?: boolean;\n  /** Runs when this action is triggered, from the primary button or the menu. */\n  onSelect?: () => void;\n}\n\nexport interface SplitButtonProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onSelect\"> {\n  /** Every action. The primary one is shown on the main button; the rest are listed in the menu. */\n  actions: SplitButtonAction[];\n  /** Controlled id of the primary action. */\n  primaryId?: string;\n  /** Initial primary action when uncontrolled (defaults to the first action). */\n  defaultPrimaryId?: string;\n  /** Called when a menu choice becomes the new primary action (`rememberLast`). */\n  onPrimaryChange?: (id: string) => void;\n  /** Called with the id of every triggered action. */\n  onAction?: (id: string) => void;\n  /** Make the action chosen from the menu the new primary action. */\n  rememberLast?: boolean;\n  variant?: \"solid\" | \"outline\";\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Show each action's description in the menu. */\n  showDescriptions?: boolean;\n  /** Minimum menu width in px. */\n  menuWidth?: number;\n  /** Which edge of the button the menu lines up with. */\n  align?: \"start\" | \"end\";\n  /** Brand colour: solid fill, highlighted items and focus rings. */\n  accent?: string;\n  /** Spring for the menu scale-in. */\n  spring?: Spring;\n  /** Accessible name of the chevron button and its menu. */\n  menuLabel?: string;\n  /** Disable both halves. */\n  disabled?: boolean;\n}\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst GAP = 6; // px between the button and the menu\nconst EDGE = 8; // px kept from the viewport edge\nconst ITEM_SELECTOR = \"[data-sb-item]\";\n\nconst SIZES = {\n  sm: { height: \"h-8\", text: \"text-[13px]\", pad: \"pl-3 pr-3\", gap: \"gap-1.5\", chevron: \"w-8\", icon: \"[&_svg]:size-3.5\" },\n  md: { height: \"h-10\", text: \"text-sm\", pad: \"pl-4 pr-4\", gap: \"gap-2\", chevron: \"w-10\", icon: \"[&_svg]:size-4\" },\n  lg: { height: \"h-12\", text: \"text-[15px]\", pad: \"pl-5 pr-5\", gap: \"gap-2\", chevron: \"w-12\", icon: \"[&_svg]:size-[18px]\" },\n} as const;\n\nconst VARIANTS = {\n  solid: {\n    segment:\n      \"text-zinc-950 hover:bg-zinc-950/[0.07] data-[open]:bg-zinc-950/[0.12] disabled:hover:bg-transparent\",\n    divider: \"bg-zinc-950/20\",\n    shell:\n      \"bg-[var(--sb-accent)] after:ring-zinc-950/10 shadow-[inset_0_1px_0_rgb(255_255_255/0.3),0_8px_18px_-10px_color-mix(in_oklab,var(--sb-accent)_80%,transparent)]\",\n  },\n  outline: {\n    segment:\n      \"text-zinc-900 hover:bg-zinc-900/[0.04] data-[open]:bg-zinc-900/[0.07] disabled:hover:bg-transparent dark:text-zinc-100 dark:hover:bg-white/[0.05] dark:data-[open]:bg-white/[0.08]\",\n    divider: \"bg-zinc-900/12 dark:bg-white/12\",\n    shell: \"bg-white after:ring-zinc-900/15 shadow-[0_1px_2px_rgb(0_0_0/0.06)] dark:bg-[#16191c] dark:after:ring-white/15\",\n  },\n} as const;\n\n/** Enabled items of the menu, in order. */\nfunction menuItems(panel: HTMLElement | null) {\n  if (!panel) return [];\n  return Array.from(panel.querySelectorAll<HTMLElement>(ITEM_SELECTOR)).filter((el) => el.getAttribute(\"aria-disabled\") !== \"true\");\n}\n\n/**\n * A primary action joined to a chevron that opens a menu of alternative actions. The chevron is\n * an APG menu button (aria-haspopup / aria-expanded, arrows, Home / End, typeahead, Escape returns\n * focus). The menu renders in the top layer, flips above the button when there is no room below\n * and stays inside the viewport. With `rememberLast`, the chosen alternative becomes the primary.\n */\nexport function SplitButton({\n  actions,\n  primaryId: primaryIdProp,\n  defaultPrimaryId,\n  onPrimaryChange,\n  onAction,\n  rememberLast = true,\n  variant = \"solid\",\n  size = \"md\",\n  showDescriptions = true,\n  menuWidth = 260,\n  align = \"end\",\n  accent = \"#FF7448\",\n  spring = { stiffness: 560, damping: 34, mass: 0.7 },\n  menuLabel = \"More actions\",\n  disabled = false,\n  className = \"\",\n  style,\n  ...props\n}: SplitButtonProps) {\n  const uid = React.useId();\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const chevronRef = React.useRef<HTMLButtonElement>(null);\n  const panelRef = React.useRef<HTMLDivElement | null>(null);\n  const focusOnOpen = React.useRef<\"first\" | \"last\" | \"panel\">(\"panel\");\n  const typeahead = React.useRef({ buffer: \"\", timer: 0 });\n  const [open, setOpen] = React.useState(false);\n  const [primaryInternal, setPrimaryInternal] = React.useState(defaultPrimaryId);\n\n  const requestedId = primaryIdProp ?? primaryInternal;\n  const primary = actions.find((a) => a.id === requestedId) ?? actions[0];\n  const alternatives = actions.filter((a) => a !== primary);\n  const s = SIZES[size] ?? SIZES.md;\n  const v = VARIANTS[variant] ?? VARIANTS.solid;\n  const menuId = `${uid}-menu`;\n  const chevronId = `${uid}-chevron`;\n\n  const run = (action: SplitButtonAction) => {\n    if (action.disabled) return;\n    action.onSelect?.();\n    onAction?.(action.id);\n  };\n\n  const close = React.useCallback((returnFocus: boolean) => {\n    setOpen(false);\n    if (returnFocus) chevronRef.current?.focus({ preventScroll: true });\n  }, []);\n\n  const openMenu = (focus: \"first\" | \"last\" | \"panel\") => {\n    focusOnOpen.current = focus;\n    setOpen(true);\n  };\n\n  const choose = (action: SplitButtonAction) => {\n    if (action.disabled) return;\n    run(action);\n    if (rememberLast && action.id !== primary?.id) {\n      if (primaryIdProp === undefined) setPrimaryInternal(action.id);\n      onPrimaryChange?.(action.id);\n    }\n    close(true);\n  };\n\n  /** Places the menu under (or above) the button, aligned to one edge and clamped to the viewport. */\n  const position = React.useCallback(() => {\n    const panel = panelRef.current;\n    const root = rootRef.current;\n    if (!panel || !root) return;\n    const r = root.getBoundingClientRect();\n    const vw = document.documentElement.clientWidth;\n    const vh = window.innerHeight;\n    const w = panel.offsetWidth;\n    const h = panel.offsetHeight;\n    const spaceBelow = vh - r.bottom - GAP - EDGE;\n    const spaceAbove = r.top - GAP - EDGE;\n    const above = h > spaceBelow && spaceAbove > spaceBelow;\n    const preferredLeft = align === \"end\" ? r.right - w : r.left;\n    const left = Math.max(EDGE, Math.min(preferredLeft, vw - w - EDGE));\n    const top = above ? Math.max(EDGE, r.top - GAP - h) : Math.min(r.bottom + GAP, Math.max(EDGE, vh - h - EDGE));\n    panel.style.left = `${Math.round(left)}px`;\n    panel.style.top = `${Math.round(top)}px`;\n    // Grow out of the chevron corner.\n    const originX = Math.round(Math.min(w, Math.max(0, r.right - left - 16)));\n    panel.style.transformOrigin = `${originX}px ${above ? \"100%\" : \"0%\"}`;\n  }, [align]);\n\n  /** Mounts the panel in the top layer, positions it and moves focus into it. */\n  // Motion may re-invoke callback refs on re-render: mount, position and focus once per node.\n  const mountPanel = React.useCallback(\n    (node: HTMLDivElement | null) => {\n      if (!node || node === panelRef.current) return;\n      panelRef.current = node;\n      if (typeof node.showPopover === \"function\" && !node.matches(\":popover-open\")) {\n        try {\n          node.showPopover();\n        } catch {\n          // Unsupported: position: fixed still applies.\n        }\n      }\n      position();\n      const items = menuItems(node);\n      const target = focusOnOpen.current === \"first\" ? items[0] : focusOnOpen.current === \"last\" ? items[items.length - 1] : node;\n      (target ?? node).focus({ preventScroll: true });\n    },\n    [position],\n  );\n\n  // While open: outside pointer and window blur close; scroll and resize re-position.\n  React.useEffect(() => {\n    if (!open) return;\n    let frame = 0;\n    const onPointer = (event: PointerEvent) => {\n      const t = event.target as Node;\n      if (panelRef.current?.contains(t) || chevronRef.current?.contains(t)) return;\n      close(false);\n    };\n    const onMove = () => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(position);\n    };\n    const onBlur = () => close(false);\n    document.addEventListener(\"pointerdown\", onPointer, true);\n    window.addEventListener(\"scroll\", onMove, { capture: true, passive: true });\n    window.addEventListener(\"resize\", onMove);\n    window.addEventListener(\"blur\", onBlur);\n    return () => {\n      cancelAnimationFrame(frame);\n      document.removeEventListener(\"pointerdown\", onPointer, true);\n      window.removeEventListener(\"scroll\", onMove, { capture: true });\n      window.removeEventListener(\"resize\", onMove);\n      window.removeEventListener(\"blur\", onBlur);\n    };\n  }, [open, close, position]);\n\n  React.useEffect(() => {\n    const t = typeahead.current;\n    return () => window.clearTimeout(t.timer);\n  }, []);\n\n  const onMenuKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const items = menuItems(event.currentTarget);\n    const active = document.activeElement as HTMLElement | null;\n    const index = active ? items.indexOf(active) : -1;\n    const focusAt = (i: number) => items[(i + items.length) % items.length]?.focus({ preventScroll: true });\n    switch (event.key) {\n      case \"ArrowDown\":\n        event.preventDefault();\n        focusAt(index + 1);\n        return;\n      case \"ArrowUp\":\n        event.preventDefault();\n        focusAt(index < 0 ? -1 : index - 1);\n        return;\n      case \"Home\":\n      case \"PageUp\":\n        event.preventDefault();\n        focusAt(0);\n        return;\n      case \"End\":\n      case \"PageDown\":\n        event.preventDefault();\n        focusAt(-1);\n        return;\n      case \"Escape\":\n      case \"Tab\":\n        event.preventDefault();\n        close(true);\n        return;\n      case \"Enter\":\n      case \" \":\n        if (active && index >= 0) {\n          event.preventDefault();\n          active.click();\n        }\n        return;\n    }\n    // Typeahead: focus the next item whose label starts with the typed characters.\n    if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {\n      const t = typeahead.current;\n      window.clearTimeout(t.timer);\n      t.buffer += event.key.toLowerCase();\n      t.timer = window.setTimeout(() => (t.buffer = \"\"), 600);\n      const ordered = [...items.slice(index + 1), ...items.slice(0, index + 1)];\n      const repeated = t.buffer.split(\"\").every((c) => c === t.buffer[0]);\n      const match =\n        ordered.find((el) => (el.dataset.label ?? \"\").startsWith(t.buffer)) ??\n        (repeated ? ordered.find((el) => (el.dataset.label ?? \"\").startsWith(t.buffer[0])) : undefined);\n      match?.focus({ preventScroll: true });\n    }\n  };\n\n  const segment = `relative inline-flex ${s.height} items-center justify-center font-medium tracking-tight outline-hidden transition-[background-color,translate] duration-150 select-none focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--sb-accent)] active:translate-y-px disabled:cursor-not-allowed disabled:active:translate-y-0 ${v.segment}`;\n\n  const menuMotion = reduceMotion\n    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0.1 } }\n    : {\n        initial: { opacity: 0, scale: 0.92 },\n        animate: { opacity: 1, scale: 1 },\n        exit: { opacity: 0, scale: 0.97, transition: { duration: 0.1 } },\n        transition: { type: \"spring\" as const, ...spring },\n      };\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"group\"\n      className={`relative isolate inline-flex rounded-[10px] after:pointer-events-none after:absolute after:inset-0 after:rounded-[10px] after:ring-1 after:ring-inset ${s.text} ${disabled ? \"opacity-50\" : \"\"} ${v.shell} ${className}`}\n      style={{ [\"--sb-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <button\n        type=\"button\"\n        disabled={disabled || !primary || primary.disabled}\n        className={`${segment} ${s.pad} ${s.icon} rounded-l-[10px]`}\n        onClick={() => primary && run(primary)}\n      >\n        <span className=\"relative inline-grid\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              key={primary?.id}\n              className={`inline-flex items-center whitespace-nowrap [grid-area:1/1] ${s.gap}`}\n              initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 10, filter: \"blur(2px)\" }}\n              animate={reduceMotion ? { opacity: 1 } : { opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -10, filter: \"blur(2px)\" }}\n              transition={reduceMotion ? { duration: 0.12 } : { type: \"spring\", stiffness: 420, damping: 32 }}\n            >\n              {primary?.icon ? (\n                <span aria-hidden=\"true\" className=\"inline-flex shrink-0\">\n                  {primary.icon}\n                </span>\n              ) : null}\n              {primary?.label}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      </button>\n      <span aria-hidden=\"true\" className={`relative z-[1] my-auto h-[55%] w-px ${v.divider}`} />\n      <button\n        ref={chevronRef}\n        id={chevronId}\n        type=\"button\"\n        disabled={disabled || alternatives.length === 0}\n        aria-label={menuLabel}\n        aria-haspopup=\"menu\"\n        aria-expanded={open}\n        aria-controls={open ? menuId : undefined}\n        data-open={open || undefined}\n        className={`${segment} ${s.chevron} rounded-r-[10px]`}\n        onClick={() => (open ? close(false) : openMenu(\"panel\"))}\n        onKeyDown={(event) => {\n          if (event.key === \"ArrowDown\" || event.key === \"Enter\" || event.key === \" \") {\n            event.preventDefault();\n            openMenu(\"first\");\n          } else if (event.key === \"ArrowUp\") {\n            event.preventDefault();\n            openMenu(\"last\");\n          }\n        }}\n      >\n        <motion.svg\n          viewBox=\"0 0 16 16\"\n          fill=\"none\"\n          aria-hidden=\"true\"\n          className=\"size-4\"\n          initial={false}\n          animate={{ rotate: open ? 180 : 0 }}\n          transition={reduceMotion ? { duration: 0 } : { type: \"spring\", stiffness: 500, damping: 30 }}\n        >\n          <path d=\"M4 6.25 8 10.25l4-4\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n        </motion.svg>\n      </button>\n\n      <AnimatePresence>\n        {open ? (\n          <motion.div\n            key=\"menu\"\n            ref={mountPanel}\n            id={menuId}\n            role=\"menu\"\n            aria-labelledby={chevronId}\n            aria-orientation=\"vertical\"\n            tabIndex={-1}\n            popover=\"manual\"\n            onKeyDown={onMenuKeyDown}\n            {...menuMotion}\n            className=\"fixed inset-auto m-0 overflow-hidden rounded-xl border border-zinc-900/10 bg-white p-1 text-[13px] text-zinc-800 shadow-[0_24px_48px_-20px_rgb(0_0_0/0.4),0_2px_6px_-2px_rgb(0_0_0/0.08)] outline-hidden dark:border-white/10 dark:bg-[#1a1d21] dark:text-zinc-200 dark:shadow-[0_24px_48px_-16px_rgb(0_0_0/0.75)]\"\n            style={{ minWidth: menuWidth, maxWidth: `min(${Math.max(menuWidth, 320)}px, calc(100vw - ${EDGE * 2}px))`, [\"--sb-accent\" as string]: accent }}\n          >\n            {rememberLast ? (\n              <div aria-hidden=\"true\" className=\"flex items-center justify-between px-2.5 pb-1.5 pt-2 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:text-zinc-500\">\n                <span>Run &amp; set default</span>\n                <span className=\"text-zinc-300 dark:text-zinc-600\">↵</span>\n              </div>\n            ) : null}\n            {alternatives.map((action) => (\n              <div\n                key={action.id}\n                role=\"menuitem\"\n                tabIndex={-1}\n                data-sb-item=\"\"\n                data-label={action.label.toLowerCase()}\n                aria-disabled={action.disabled || undefined}\n                aria-describedby={showDescriptions && action.description ? `${uid}-${action.id}-desc` : undefined}\n                className=\"group/item flex w-full cursor-default items-start gap-2.5 rounded-lg px-2.5 py-2 text-left outline-hidden transition-colors duration-75 focus:bg-[color-mix(in_oklab,var(--sb-accent)_14%,transparent)] focus:text-zinc-950 aria-disabled:opacity-40 dark:focus:text-white [&_svg]:size-4\"\n                onClick={() => choose(action)}\n                onPointerMove={(event) => {\n                  if (event.pointerType !== \"mouse\" || action.disabled) return;\n                  if (document.activeElement !== event.currentTarget) event.currentTarget.focus({ preventScroll: true });\n                }}\n                onPointerLeave={(event) => {\n                  if (event.pointerType === \"mouse\" && document.activeElement === event.currentTarget) panelRef.current?.focus({ preventScroll: true });\n                }}\n              >\n                {actions.some((a) => a.icon) ? (\n                  <span aria-hidden=\"true\" className=\"mt-px flex h-[18px] w-4 shrink-0 items-center justify-center text-zinc-500 group-focus/item:text-[var(--sb-accent)] dark:text-zinc-400\">\n                    {action.icon}\n                  </span>\n                ) : null}\n                <span className=\"min-w-0 flex-1\">\n                  <span className=\"block truncate font-medium leading-[18px] tracking-tight\">{action.label}</span>\n                  {showDescriptions && action.description ? (\n                    <span id={`${uid}-${action.id}-desc`} className=\"mt-0.5 block text-[12px] leading-4 text-zinc-500 dark:text-zinc-400\">\n                      {action.description}\n                    </span>\n                  ) : null}\n                </span>\n              </div>\n            ))}\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "buttons"
  ],
  "docs": "Documentation: https://wancraft.my/components/split-button"
}
