{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "vote-control",
  "type": "registry:ui",
  "title": "Vote Control",
  "description": "Forum-style up / down voting with rolling digits, bouncing arrows and optimistic updates that roll back on error.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/vote-control.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\nexport type Vote = -1 | 0 | 1;\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport interface VoteControlProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onError\" | \"children\"> {\n  /** Score *without* the viewer's own vote. The control shows `score + vote`. */\n  score?: number;\n  /** Controlled vote: 1 (up), -1 (down) or 0 (none). */\n  vote?: Vote;\n  /** Initial vote in uncontrolled mode. */\n  defaultVote?: Vote;\n  /** Called immediately with every optimistic change, and again with the previous vote on rollback. */\n  onVoteChange?: (vote: Vote) => void;\n  /**\n   * Persist the vote. The UI updates optimistically first; if the returned promise rejects, the vote\n   * rolls back (unless a newer vote has been cast since).\n   */\n  onVote?: (next: Vote, previous: Vote) => Promise<unknown> | unknown;\n  /** Receives the rejection reason after a rollback. */\n  onError?: (error: unknown) => void;\n  /** Stack the arrows above and below the score, or place them side by side. */\n  orientation?: \"vertical\" | \"horizontal\";\n  /** Colour of an active upvote. */\n  upColor?: string;\n  /** Colour of an active downvote. */\n  downColor?: string;\n  /** Duration of each digit roll, in ms. */\n  rollDuration?: number;\n  /** Spring for the arrow bounce. */\n  spring?: Spring;\n  /** Abbreviate large scores (12.4K). */\n  compact?: boolean;\n  /** BCP 47 locale used to format the score. */\n  locale?: string;\n  /** Noun used in the accessible group name, e.g. \"post\" → \"Vote on this post\". */\n  itemLabel?: string;\n}\n\n/* --------------------------------- helpers --------------------------------- */\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 number whose changed characters roll in the direction of the change: up when the score rises,\n * down when it falls. Characters are keyed by their position from the right, so only digits that\n * actually change animate.\n */\nfunction RollingScore({ text, direction, duration, instant }: { text: string; direction: 1 | -1; duration: number; instant: boolean }) {\n  const chars = text.split(\"\");\n  return (\n    <span aria-hidden=\"true\" className=\"inline-flex tabular-nums\">\n      {chars.map((char, i) => (\n        <span key={chars.length - i} className=\"relative inline-flex justify-center overflow-hidden\">\n          <AnimatePresence initial={false} mode=\"popLayout\" custom={direction}>\n            <motion.span\n              key={char}\n              custom={direction}\n              className=\"inline-block\"\n              variants={{\n                enter: (d: number) => ({ y: instant ? 0 : `${d * 100}%`, opacity: instant ? 1 : 0 }),\n                center: { y: \"0%\", opacity: 1 },\n                exit: (d: number) => ({ y: instant ? 0 : `${d * -100}%`, opacity: 0 }),\n              }}\n              initial=\"enter\"\n              animate=\"center\"\n              exit=\"exit\"\n              transition={{ duration: instant ? 0 : duration / 1000, ease: [0.22, 1, 0.36, 1] }}\n            >\n              {char}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      ))}\n    </span>\n  );\n}\n\nconst ARROW = \"M8 2.25 13.5 8.25H10.25V13.25H5.75V8.25H2.5Z\";\n\nfunction Arrow({ direction, active, bump, spring, instant }: { direction: 1 | -1; active: boolean; bump: number; spring: Spring; instant: boolean }) {\n  return (\n    <motion.svg\n      // Remounting on each bump replays the pop; the first render stays still.\n      key={bump}\n      viewBox=\"0 0 16 16\"\n      aria-hidden=\"true\"\n      className=\"size-[18px] overflow-visible\"\n      initial={bump > 0 && !instant ? { y: direction * -5, scale: 1.22 } : false}\n      animate={{ y: 0, scale: 1 }}\n      transition={{ type: \"spring\", ...spring }}\n      style={{ rotate: direction === -1 ? 180 : 0 }}\n    >\n      <path\n        d={ARROW}\n        strokeWidth={1.5}\n        strokeLinejoin=\"round\"\n        className={`stroke-current transition-[fill] duration-200 ${active ? \"fill-current\" : \"fill-transparent\"}`}\n      />\n    </motion.svg>\n  );\n}\n\n/**\n * Forum-style up / down voting. Votes apply optimistically, the score rolls digit by digit in the\n * direction of the change, and a failed `onVote` rolls the vote back with a small shake.\n */\nexport function VoteControl({\n  score = 128,\n  vote: voteProp,\n  defaultVote = 0,\n  onVoteChange,\n  onVote,\n  onError,\n  orientation = \"vertical\",\n  upColor = \"#FF7448\",\n  downColor = \"#5B8DEF\",\n  rollDuration = 380,\n  spring = { stiffness: 520, damping: 14, mass: 0.6 },\n  compact = false,\n  locale = \"en-US\",\n  itemLabel = \"post\",\n  className = \"\",\n  style,\n  ...props\n}: VoteControlProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const [internalVote, setInternalVote] = React.useState<Vote>(defaultVote);\n  const vote = voteProp ?? internalVote;\n  const total = score + vote;\n\n  const [bumps, setBumps] = React.useState({ up: 0, down: 0 });\n  const [shakes, setShakes] = React.useState(0);\n  const [message, setMessage] = React.useState(\"\");\n  const requestRef = React.useRef(0);\n\n  // Remember which way the score last moved so the digits roll the right way.\n  const [previousTotal, setPreviousTotal] = React.useState(total);\n  const [direction, setDirection] = React.useState<1 | -1>(1);\n  if (total !== previousTotal) {\n    setPreviousTotal(total);\n    setDirection(total > previousTotal ? 1 : -1);\n  }\n\n  const formatter = new Intl.NumberFormat(locale, compact ? { notation: \"compact\", maximumFractionDigits: 1 } : undefined);\n  const display = formatter.format(total);\n\n  const setVote = (next: Vote) => {\n    if (voteProp === undefined) setInternalVote(next);\n    onVoteChange?.(next);\n  };\n\n  const cast = async (pressed: 1 | -1) => {\n    const previous = vote;\n    const next: Vote = previous === pressed ? 0 : pressed;\n    setVote(next);\n    if (next === 1) setBumps((b) => ({ ...b, up: b.up + 1 }));\n    if (next === -1) setBumps((b) => ({ ...b, down: b.down + 1 }));\n    const nextTotal = formatter.format(score + next);\n    setMessage(next === 1 ? `Upvoted. Score ${nextTotal}` : next === -1 ? `Downvoted. Score ${nextTotal}` : `Vote removed. Score ${nextTotal}`);\n\n    if (!onVote) return;\n    const id = ++requestRef.current;\n    try {\n      await onVote(next, previous);\n    } catch (error) {\n      // A newer vote supersedes this one; only the latest request may roll back.\n      if (id !== requestRef.current) return;\n      setVote(previous);\n      setShakes((n) => n + 1);\n      setMessage(`Vote could not be saved. Score ${formatter.format(score + previous)}`);\n      onError?.(error);\n    }\n  };\n\n  const vertical = orientation === \"vertical\";\n  const tone = vote === 1 ? \"text-[var(--vc-up-ink)]\" : vote === -1 ? \"text-[var(--vc-down-ink)]\" : \"text-zinc-900 dark:text-zinc-100\";\n\n  const button = (dir: 1 | -1) => {\n    const active = vote === dir;\n    return (\n      <button\n        type=\"button\"\n        aria-pressed={active}\n        aria-label={`${dir === 1 ? \"Upvote\" : \"Downvote\"}, current score ${display}`}\n        onClick={() => void cast(dir)}\n        className={`inline-flex size-8 shrink-0 items-center justify-center rounded-full outline-hidden transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-solid focus-visible:outline-[var(--vc-ring)] ${\n          active\n            ? dir === 1\n              ? \"bg-[color-mix(in_oklab,var(--vc-up)_14%,transparent)] text-[var(--vc-up)]\"\n              : \"bg-[color-mix(in_oklab,var(--vc-down)_14%,transparent)] text-[var(--vc-down)]\"\n            : dir === 1\n              ? \"text-zinc-500 hover:bg-zinc-900/[0.06] hover:text-[var(--vc-up)] dark:text-zinc-400 dark:hover:bg-white/[0.07]\"\n              : \"text-zinc-500 hover:bg-zinc-900/[0.06] hover:text-[var(--vc-down)] dark:text-zinc-400 dark:hover:bg-white/[0.07]\"\n        }`}\n      >\n        <Arrow direction={dir} active={active} bump={dir === 1 ? bumps.up : bumps.down} spring={spring} instant={reduceMotion} />\n      </button>\n    );\n  };\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={`Vote on this ${itemLabel}`}\n      data-vote={vote}\n      className={`inline-flex ${className}`}\n      style={{\n        [\"--vc-up\" as string]: upColor,\n        [\"--vc-down\" as string]: downColor,\n        // Score ink mixes the vote colour with the text colour so pale colours stay legible.\n        [\"--vc-up-ink\" as string]: `color-mix(in oklab, ${upColor} 80%, currentColor)`,\n        [\"--vc-down-ink\" as string]: `color-mix(in oklab, ${downColor} 80%, currentColor)`,\n        [\"--vc-ring\" as string]: vote === -1 ? downColor : upColor,\n        ...style,\n      }}\n      {...props}\n    >\n      <motion.div\n        key={shakes}\n        initial={shakes > 0 && !reduceMotion ? { x: vertical ? 0 : -4, y: vertical ? -3 : 0 } : false}\n        animate={{ x: 0, y: 0 }}\n        transition={{ type: \"spring\", stiffness: 900, damping: 9, mass: 0.5 }}\n        className={`inline-flex items-center rounded-full border border-zinc-900/10 bg-white/80 p-1 text-zinc-900 shadow-[0_1px_0_rgba(0,0,0,0.03)] dark:border-white/10 dark:bg-white/[0.04] dark:text-zinc-100 ${\n          vertical ? \"flex-col gap-0.5\" : \"flex-row gap-1\"\n        }`}\n      >\n        {button(1)}\n        <span\n          className={`inline-flex min-w-[3ch] justify-center text-sm font-semibold tracking-tight transition-colors duration-200 ${tone} ${vertical ? \"py-0.5\" : \"px-0.5\"}`}\n        >\n          <RollingScore text={display} direction={direction} duration={rollDuration} instant={reduceMotion} />\n          <span className=\"sr-only\">{`Score ${display}`}</span>\n        </span>\n        {button(-1)}\n      </motion.div>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {message}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "buttons"
  ],
  "docs": "Documentation: https://wancraft.my/components/vote-control"
}
