{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"branch-navigator","type":"registry:component","title":"Branch Navigator","description":"Version switcher for regenerated or edited messages: `Branches` shares a controlled or uncontrolled index, `BranchContent` cross-fades and slides the active version in from the direction you navigated, and `BranchNavigator` renders a \"‹ 2 / 3 ›\" control with a rolling counter, arrow/Home/End keys and live announcements, usable standalone too.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/agents/branch-navigator.tsx","type":"registry:component","target":"@components/agents/branch-navigator.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/agents/branch-navigator\n\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  Children,\n  createContext,\n  type KeyboardEvent,\n  type ReactNode,\n  useContext,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype Direction = 1 | -1;\n\ninterface BranchContextValue {\n  index: number;\n  count: number;\n  direction: Direction;\n  setIndex: (next: number) => void;\n}\n\nconst BranchContext = createContext<BranchContextValue | null>(null);\n\n/** Index state with the direction of the last move, controlled or not. */\nfunction useBranchState(\n  count: number,\n  index: number | undefined,\n  defaultIndex: number,\n  onIndexChange: ((index: number) => void) | undefined,\n) {\n  const [internal, setInternal] = useState(defaultIndex);\n  const raw = index ?? internal;\n  const current = Math.max(0, Math.min(raw, Math.max(count - 1, 0)));\n  // Derive direction from whichever source moved the index (render-time\n  // derived state, so controlled updates slide the right way too).\n  const [previous, setPrevious] = useState(current);\n  const [direction, setDirection] = useState<Direction>(1);\n  if (previous !== current) {\n    setDirection(current > previous ? 1 : -1);\n    setPrevious(current);\n  }\n\n  const setIndex = (next: number) => {\n    const clamped = Math.max(0, Math.min(next, count - 1));\n    if (clamped === current) return;\n    if (index === undefined) setInternal(clamped);\n    onIndexChange?.(clamped);\n  };\n\n  return { index: current, direction, setIndex };\n}\n\nexport interface BranchesProps {\n  /** Number of versions (match the `BranchContent` children). */\n  count: number;\n  index?: number;\n  defaultIndex?: number;\n  onIndexChange?: (index: number) => void;\n  children: ReactNode;\n  className?: string;\n}\n\n/** Shares one version index between `BranchContent` and `BranchNavigator`. */\nexport function Branches({\n  count,\n  index,\n  defaultIndex = 0,\n  onIndexChange,\n  children,\n  className,\n}: BranchesProps) {\n  const state = useBranchState(count, index, defaultIndex, onIndexChange);\n  return (\n    <BranchContext.Provider value={{ ...state, count }}>\n      <div data-slot=\"branches\" className={cn(\"min-w-0\", className)}>\n        {children}\n      </div>\n    </BranchContext.Provider>\n  );\n}\n\nexport interface BranchContentProps {\n  /** One child per version; only the active one renders. */\n  children: ReactNode;\n  className?: string;\n}\n\nconst SLIDE = 28;\n\n/** Renders the active version, sliding in from the side you navigated toward. */\nexport function BranchContent({ children, className }: BranchContentProps) {\n  const ctx = useContext(BranchContext);\n  if (!ctx) throw new Error(\"BranchContent must be used inside <Branches>.\");\n  const reduce = useReducedMotion() ?? false;\n  const versions = Children.toArray(children);\n  const active = versions[ctx.index] ?? null;\n\n  return (\n    <div data-slot=\"branch-content\" className={cn(\"relative\", className)}>\n      <AnimatePresence initial={false} mode=\"popLayout\" custom={ctx.direction}>\n        <motion.div\n          key={ctx.index}\n          custom={ctx.direction}\n          variants={{\n            enter: (dir: Direction) =>\n              reduce\n                ? { opacity: 0 }\n                : { opacity: 0, x: dir * SLIDE, filter: \"blur(4px)\" },\n            center: { opacity: 1, x: 0, filter: \"blur(0px)\" },\n            exit: (dir: Direction) =>\n              reduce\n                ? { opacity: 0, transition: { duration: 0.1 } }\n                : {\n                    opacity: 0,\n                    x: dir * -SLIDE * 0.6,\n                    filter: \"blur(4px)\",\n                    transition: { duration: 0.14, ease: EASE_OUT },\n                  },\n          }}\n          initial=\"enter\"\n          animate=\"center\"\n          exit=\"exit\"\n          transition={\n            reduce ? { duration: 0.15 } : { duration: 0.28, ease: EASE_OUT }\n          }\n        >\n          {active}\n        </motion.div>\n      </AnimatePresence>\n    </div>\n  );\n}\n\nexport interface BranchNavigatorProps {\n  /** Omit inside `Branches`; required standalone. */\n  count?: number;\n  index?: number;\n  defaultIndex?: number;\n  onIndexChange?: (index: number) => void;\n  /** Noun used in labels, e.g. \"response\" → \"Previous response\". */\n  label?: string;\n  /** Hide the control when there is only one version. Default true. */\n  hideWhenSingle?: boolean;\n  className?: string;\n}\n\n/** \"‹ 2 / 3 ›\" switcher for regenerated or edited message versions. */\nexport function BranchNavigator(props: BranchNavigatorProps) {\n  const ctx = useContext(BranchContext);\n  if (ctx && props.count === undefined && props.index === undefined) {\n    return <NavigatorView {...props} {...ctx} />;\n  }\n  return <StandaloneNavigator {...props} />;\n}\n\nfunction StandaloneNavigator({\n  count = 1,\n  index,\n  defaultIndex = 0,\n  onIndexChange,\n  ...rest\n}: BranchNavigatorProps) {\n  const state = useBranchState(count, index, defaultIndex, onIndexChange);\n  return <NavigatorView {...rest} {...state} count={count} />;\n}\n\nfunction NavigatorView({\n  index,\n  count,\n  direction,\n  setIndex,\n  label = \"version\",\n  hideWhenSingle = true,\n  className,\n}: BranchContextValue & Omit<BranchNavigatorProps, \"count\" | \"index\">) {\n  const reduce = useReducedMotion() ?? false;\n  if (hideWhenSingle && count <= 1) return null;\n\n  const atStart = index <= 0;\n  const atEnd = index >= count - 1;\n\n  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {\n    let next: number | null = null;\n    if (event.key === \"ArrowLeft\") next = index - 1;\n    else if (event.key === \"ArrowRight\") next = index + 1;\n    else if (event.key === \"Home\") next = 0;\n    else if (event.key === \"End\") next = count - 1;\n    if (next === null) return;\n    event.preventDefault();\n    const clamped = Math.max(0, Math.min(next, count - 1));\n    setIndex(clamped);\n    // Keep focus on a usable arrow once an edge disables the current one.\n    const group = event.currentTarget.parentElement;\n    const target =\n      clamped <= 0\n        ? group?.querySelector<HTMLButtonElement>(\"[data-branch-next]\")\n        : clamped >= count - 1\n          ? group?.querySelector<HTMLButtonElement>(\"[data-branch-prev]\")\n          : null;\n    if (target && target !== event.currentTarget) target.focus();\n  };\n\n  const arrow =\n    \"grid size-6 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-35\";\n\n  return (\n    <fieldset\n      data-slot=\"branch-navigator\"\n      className={cn(\n        \"m-0 inline-flex min-w-0 items-center gap-0.5 border-0 p-0 text-xs text-muted-foreground\",\n        className,\n      )}\n    >\n      <legend className=\"sr-only\">{`${capitalize(label)}s`}</legend>\n      <motion.button\n        type=\"button\"\n        data-branch-prev=\"\"\n        aria-label={`Previous ${label}`}\n        disabled={atStart}\n        onClick={() => setIndex(index - 1)}\n        onKeyDown={onKeyDown}\n        whileTap={reduce ? undefined : { x: -2 }}\n        transition={SPRING_SWAP}\n        className={arrow}\n      >\n        <ChevronLeft aria-hidden=\"true\" className=\"size-3.5\" />\n      </motion.button>\n      <span\n        aria-hidden=\"true\"\n        className=\"flex min-w-9 items-center justify-center gap-0.5 font-mono tabular-nums\"\n      >\n        <span className=\"relative inline-grid overflow-hidden\">\n          <AnimatePresence initial={false} mode=\"popLayout\" custom={direction}>\n            <motion.span\n              key={index}\n              custom={direction}\n              variants={{\n                enter: (dir: Direction) =>\n                  reduce ? { opacity: 0 } : { opacity: 0, y: dir * 8 },\n                center: { opacity: 1, y: 0 },\n                exit: (dir: Direction) =>\n                  reduce\n                    ? { opacity: 0 }\n                    : { opacity: 0, y: dir * -8, transition: { duration: 0.12 } },\n              }}\n              initial=\"enter\"\n              animate=\"center\"\n              exit=\"exit\"\n              transition={reduce ? { duration: 0.12 } : SPRING_SWAP}\n              className=\"text-foreground\"\n            >\n              {index + 1}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n        <span className=\"text-muted-foreground/60\">/</span>\n        <span>{count}</span>\n      </span>\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {`${capitalize(label)} ${index + 1} of ${count}`}\n      </span>\n      <motion.button\n        type=\"button\"\n        data-branch-next=\"\"\n        aria-label={`Next ${label}`}\n        disabled={atEnd}\n        onClick={() => setIndex(index + 1)}\n        onKeyDown={onKeyDown}\n        whileTap={reduce ? undefined : { x: 2 }}\n        transition={SPRING_SWAP}\n        className={arrow}\n      >\n        <ChevronRight aria-hidden=\"true\" className=\"size-3.5\" />\n      </motion.button>\n    </fieldset>\n  );\n}\n\nfunction capitalize(text: string) {\n  return text.charAt(0).toUpperCase() + text.slice(1);\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}