{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"poll-results","type":"registry:ui","title":"Poll Results","description":"The winner lands last.","dependencies":["motion"],"categories":["data"],"docs":"https://www.interior.dev/docs/poll-results","files":[{"path":"registry/interior/poll-results.tsx","type":"registry:ui","target":"components/interior/poll-results.tsx","content":"\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport {\n  animate,\n  AnimatePresence,\n  motion,\n  motionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\n\nconst FILL = { type: \"spring\", stiffness: 210, damping: 34, mass: 0.9 } as const;\n\nconst POP = { type: \"spring\", stiffness: 640, damping: 22, mass: 0.7 } as const;\nconst EASE = [0.23, 1, 0.32, 1] as const;\nconst ENTER = { duration: 0.2, ease: EASE } as const;\nconst STILL = { duration: 0 } as const;\n\nexport type PollOption = {\n  id: string;\n  label: string;\n  votes: number;\n};\n\nexport type UsePollResultsOptions = {\n  options: PollOption[];\n\n  value?: string | null;\n  defaultValue?: string | null;\n  onVote?: (id: string) => void;\n};\n\nexport function usePollResults({\n  options,\n  value,\n  defaultValue = null,\n  onVote,\n}: UsePollResultsOptions) {\n  const [internal, setInternal] = useState<string | null>(defaultValue);\n  const controlled = value !== undefined;\n  const chosen = controlled ? value : internal;\n\n  const emit = useRef(onVote);\n  emit.current = onVote;\n\n  const vote = useCallback(\n    (id: string) => {\n      if (chosen !== null) return;\n      if (!controlled) setInternal(id);\n      emit.current?.(id);\n    },\n    [chosen, controlled],\n  );\n\n  const total = options.reduce((sum, o) => sum + Math.max(0, o.votes), 0);\n  const top = options.reduce(\n    (best, o) => (o.votes > best ? o.votes : best),\n    0,\n  );\n\n  const rows = options.map((option) => ({\n    ...option,\n    share: total > 0 ? Math.max(0, option.votes) / total : 0,\n    winner: total > 0 && option.votes === top,\n    mine: option.id === chosen,\n  }));\n\n  return {\n    rows,\n    total,\n    chosen,\n    revealed: chosen !== null,\n    vote,\n  };\n}\n\nconst Tick = (\n  <svg viewBox=\"0 0 256 256\" width=\"11\" height=\"11\" fill=\"none\" aria-hidden>\n    <polyline\n      points=\"216 72 104 184 48 128\"\n      stroke=\"currentColor\"\n      strokeWidth=\"26\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    />\n  </svg>\n);\n\ntype RowProps = {\n  label: string;\n  share: number;\n  winner: boolean;\n  mine: boolean;\n  revealed: boolean;\n  reduced: boolean;\n  onPick: () => void;\n};\n\nfunction Row({ label, share, winner, mine, revealed, reduced, onPick }: RowProps) {\n  const progress = useRef(motionValue(0)).current;\n  const clipPath = useTransform(\n    progress,\n    (p) => `inset(0 ${((1 - p) * 100).toFixed(2)}% 0 0 round 5px)`,\n  );\n\n  const [landed, setLanded] = useState(false);\n  const readout = useRef<HTMLSpanElement | null>(null);\n\n  useMotionValueEvent(progress, \"change\", (p) => {\n    const node = readout.current;\n    if (!node) return;\n    const next = `${Math.round(p * 100)}%`;\n    if (node.textContent !== next) node.textContent = next;\n  });\n\n  useEffect(() => {\n    if (!revealed) return;\n    if (reduced) {\n      progress.jump(share);\n      setLanded(true);\n      return;\n    }\n    const controls = animate(progress, share, FILL);\n    void controls.finished.then(() => setLanded(true));\n    return () => controls.stop();\n  }, [revealed, share, reduced, progress]);\n\n  return (\n    // eslint-disable-next-line jsx-a11y/control-has-associated-label\n    <button\n      type=\"button\"\n      onClick={onPick}\n      aria-disabled={revealed}\n      aria-pressed={revealed ? mine : undefined}\n      className={`group relative h-9 w-full overflow-hidden rounded-[8px] border text-left outline-none transition-[border-color,background-color,box-shadow,transform] duration-200 focus-visible:after:pointer-events-none focus-visible:after:absolute focus-visible:after:inset-0 focus-visible:after:rounded-[7px] focus-visible:after:bg-[#4568FF]/[0.06] focus-visible:after:shadow-[inset_0_0_0_1px_#4568FF] dark:focus-visible:after:bg-[#93B0FF]/[0.1] dark:focus-visible:after:shadow-[inset_0_0_0_1px_#93B0FF] ${\n        revealed\n          ? \"cursor-default border-stone-200 bg-stone-100/70 shadow-[inset_0_1px_2px_rgba(28,25,23,0.07)] dark:border-white/[0.08] dark:bg-[#1D1D1A] dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.45)]\"\n          : \"border-stone-200 bg-white shadow-[inset_0_1.5px_0_rgba(255,255,255,0.95),inset_0_-1px_0_rgba(28,25,23,0.06),0_1px_2px_rgba(28,25,23,0.08)] hover:bg-stone-50 active:translate-y-px active:shadow-[inset_0_1px_2px_rgba(28,25,23,0.06)] dark:border-white/[0.16] dark:bg-[#252522] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.07),0_1px_2px_rgba(0,0,0,0.4)] dark:hover:bg-[#2A2A27]\"\n      }`}\n    >\n      <motion.span\n        aria-hidden\n        style={{ clipPath }}\n        className={`absolute inset-[3px] rounded-[5px] ${\n          mine\n            ? \"bg-[#4568FF]/[0.22] shadow-[inset_0_1px_0_rgba(255,255,255,0.55),inset_0_-1px_0_rgba(69,104,255,0.28)] dark:bg-[#93B0FF]/[0.26] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.16),inset_0_-1px_0_rgba(0,0,0,0.3)]\"\n            : \"bg-stone-800/[0.10] shadow-[inset_0_1px_0_rgba(255,255,255,0.5),inset_0_-1px_0_rgba(28,25,23,0.1)] dark:bg-white/[0.12] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.12),inset_0_-1px_0_rgba(0,0,0,0.3)]\"\n        }`}\n      />\n      <span className=\"relative flex h-full items-center gap-2 px-3\">\n        <span\n          className={`min-w-0 flex-1 truncate text-[13px] transition-[color,font-weight] duration-200 ${\n            revealed && winner\n              ? \"font-medium text-stone-800 dark:text-stone-100\"\n              : \"text-stone-600 group-hover:text-stone-800 dark:text-stone-300 dark:group-hover:text-stone-100\"\n          }`}\n        >\n          {label}\n        </span>\n        <span className=\"grid size-4 shrink-0 place-items-center\">\n          <AnimatePresence initial={false}>\n            {revealed && winner && landed ? (\n              <motion.span\n                key=\"win\"\n                initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.4 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={{ opacity: 0, transition: STILL }}\n                transition={reduced ? STILL : POP}\n                className=\"text-stone-800 dark:text-stone-100\"\n              >\n                {Tick}\n              </motion.span>\n            ) : null}\n          </AnimatePresence>\n        </span>\n        <span className=\"relative grid shrink-0 text-right\">\n          <span\n            aria-hidden\n            className=\"invisible col-start-1 row-start-1 font-mono text-[11px] tabular-nums\"\n          >\n            100%\n          </span>\n          <motion.span\n            ref={readout}\n            aria-hidden\n            initial={false}\n            animate={{ opacity: revealed ? 1 : 0 }}\n            transition={reduced ? STILL : ENTER}\n            className=\"col-start-1 row-start-1 font-mono text-[11px] tabular-nums text-stone-500 dark:text-stone-400\"\n          >\n            0%\n          </motion.span>\n        </span>\n      </span>\n    </button>\n  );\n}\n\nexport type PollResultsProps = UsePollResultsOptions & {\n  label: string;\n  className?: string;\n};\n\nexport function PollResults({\n  options,\n  value,\n  defaultValue,\n  onVote,\n  label,\n  className = \"\",\n}: PollResultsProps) {\n  const poll = usePollResults({ options, value, defaultValue, onVote });\n  const reduced = useReducedMotion() === true;\n\n  const [spoken, setSpoken] = useState(\"\");\n  useEffect(() => {\n    if (!poll.revealed) return;\n    const winner = poll.rows.find((r) => r.winner);\n    const t = setTimeout(\n      () =>\n        setSpoken(\n          winner\n            ? `Results: ${winner.label} leads with ${Math.round(winner.share * 100)} percent of ${poll.total} votes`\n            : `Results shown, ${poll.total} votes`,\n        ),\n      700,\n    );\n    return () => clearTimeout(t);\n  }, [poll.revealed, poll.rows, poll.total]);\n\n  return (\n    <div role=\"group\" aria-label={label} className={`w-full ${className}`}>\n      <p className=\"mb-2.5 text-[13px] font-medium text-stone-800 dark:text-stone-100\">\n        {label}\n      </p>\n      <div className=\"space-y-1.5\">\n        {poll.rows.map((row) => (\n          <Row\n            key={row.id}\n            label={row.label}\n            share={row.share}\n            winner={row.winner}\n            mine={row.mine}\n            revealed={poll.revealed}\n            reduced={reduced}\n            onPick={() => poll.vote(row.id)}\n          />\n        ))}\n      </div>\n      <p className=\"mt-2 h-4 font-mono text-[10.5px] tabular-nums text-stone-400 dark:text-stone-500\">\n        <motion.span\n          initial={false}\n          animate={{ opacity: poll.revealed ? 1 : 0 }}\n          transition={reduced ? STILL : { ...ENTER, delay: 0.4 }}\n          className=\"inline-block\"\n        >\n          {poll.total.toLocaleString(\"en-US\")} votes\n        </motion.span>\n      </p>\n      <span role=\"status\" className=\"sr-only\">\n        {spoken}\n      </span>\n    </div>\n  );\n}\n"}]}