{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "poll-results",
  "type": "registry:ui",
  "title": "Poll Results",
  "description": "Tap to vote, then bars grow and counts roll up from zero.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/poll-results.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nconst noop = () => () => {};\nfunction useLocale(locale?: string) {\n  const detected = useSyncExternalStore(\n    noop,\n    () => Intl.NumberFormat().resolvedOptions().locale,\n    () => \"en-US\",\n  );\n  return locale ?? detected;\n}\n\nexport type PollOption = { id: string; label: string; votes: number };\n\n/** Whole percentages that always add up to 100 (largest remainder), so 33 + 33 + 33 never shows. */\nexport function pollPercents(votes: number[]) {\n  const total = votes.reduce((a, b) => a + b, 0);\n  if (!total) return votes.map(() => 0);\n  const raw = votes.map((v) => (v / total) * 100);\n  const floor = raw.map(Math.floor);\n  let left = 100 - floor.reduce((a, b) => a + b, 0);\n  const order = raw.map((r, i) => [r - floor[i], i] as const).sort((a, b) => b[0] - a[0]);\n  for (const [, i] of order) {\n    if (left-- <= 0) break;\n    floor[i] += 1;\n  }\n  return floor;\n}\n\nconst expo = `cubic-bezier(${ease.out.join(\",\")})`;\nconst timing = {\n  transformTiming: { duration: 700, easing: expo },\n  spinTiming: { duration: 700, easing: expo },\n  opacityTiming: { duration: 200, easing: \"ease-out\" },\n};\n\nexport type PollResultsProps = Omit<React.ComponentProps<\"section\">, \"children\" | \"defaultValue\"> & {\n  question: string;\n  /** Vote counts from the server. If value is set on first render, its count is assumed to include that vote. */\n  options: PollOption[];\n  /** The viewer's vote: an option id, or null before voting. */\n  value?: string | null;\n  defaultValue?: string | null;\n  onValueChange?: (value: string | null) => void;\n  /** Persist the vote (null when withdrawn). Return a promise to get optimistic UI that reverts if it rejects. */\n  onVote?: (value: string | null) => void | Promise<unknown>;\n  /** Voting has ended: results only, the winner marked final. */\n  closed?: boolean;\n  /** Let people take their vote back and pick again. */\n  allowChange?: boolean;\n  /** Quiet line after the total: \"2 days left\", \"Closed Sep 12\". */\n  meta?: React.ReactNode;\n  locale?: string;\n};\n\nexport function PollResults({\n  question,\n  options,\n  value: valueProp,\n  defaultValue = null,\n  onValueChange,\n  onVote,\n  closed = false,\n  allowChange = true,\n  meta,\n  locale: localeProp,\n  className,\n  ...rest\n}: PollResultsProps) {\n  const locale = useLocale(localeProp);\n  const reduce = useReducedMotion();\n  const titleId = useId();\n  const [vote, setVote] = useControllableState<string | null>({ value: valueProp, defaultValue, onChange: onValueChange });\n  // The vote the server already counted. Local changes add or remove one on top of it.\n  const [counted] = useState(() => (valueProp !== undefined ? valueProp : defaultValue));\n  const [error, setError] = useState<string | null>(null);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const list = useRef<HTMLUListElement>(null);\n  const changeButton = useRef<HTMLButtonElement>(null);\n  const focusNext = useRef<\"results\" | \"options\" | null>(null);\n  const firstOption = useRef<HTMLButtonElement>(null);\n  // A failed request settles after later renders; it must revert through the newest setter, not the one it closed over.\n  const latestSet = useRef(setVote);\n  useEffect(() => {\n    latestSet.current = setVote;\n  });\n\n  const showResults = closed || vote != null;\n  const votes = options.map((o) => Math.max(0, o.votes + (o.id === vote ? 1 : 0) - (o.id === counted ? 1 : 0)));\n  const total = votes.reduce((a, b) => a + b, 0);\n  const pct = pollPercents(votes);\n  const top = Math.max(...votes);\n  const winners = new Set(top > 0 ? options.filter((_, i) => votes[i] === top).map((o) => o.id) : []);\n  const tied = winners.size > 1;\n  const fmt = new Intl.NumberFormat(locale);\n\n  // Move focus to where the next action is once the view has swapped, so it isn't lost with the pressed button.\n  useEffect(() => {\n    const target = focusNext.current;\n    focusNext.current = null;\n    if (target === \"results\") (changeButton.current ?? list.current)?.focus();\n    else if (target === \"options\") firstOption.current?.focus();\n  }, [showResults]);\n\n  const commit = (next: string | null) => {\n    const prev = vote;\n    setVote(next);\n    setError(null);\n    focusNext.current = next ? \"results\" : \"options\";\n    if (next) {\n      const i = options.findIndex((o) => o.id === next);\n      const after = options.map((o, j) => votes[j] + (o.id === next ? 1 : 0) - (o.id === prev ? 1 : 0));\n      const p = pollPercents(after)[i];\n      setAnnouncement(`Vote recorded for ${options[i].label}, ${p}%.`);\n    } else setAnnouncement(\"Vote removed. Choose an option.\");\n    const result = onVote?.(next);\n    if (result && typeof (result as Promise<unknown>).then === \"function\") {\n      (result as Promise<unknown>).catch(() => {\n        focusNext.current = prev ? \"results\" : \"options\";\n        latestSet.current(prev);\n        setError(next ? \"Couldn’t record your vote. Try again.\" : \"Couldn’t remove your vote. Try again.\");\n        setAnnouncement(\"\");\n      });\n    }\n  };\n\n  return (\n    <section\n      aria-labelledby={titleId}\n      data-state={closed ? \"closed\" : showResults ? \"voted\" : \"open\"}\n      className={cn(\"flex w-full min-w-0 flex-col gap-3\", className)}\n      {...rest}\n    >\n      <h3 id={titleId} className=\"text-[14px] font-medium leading-snug tracking-[-0.015em] text-balance text-fg\">\n        {question}\n      </h3>\n\n      <ul\n        ref={list}\n        tabIndex={-1}\n        aria-label={showResults ? \"Results\" : \"Options\"}\n        className=\"flex flex-col gap-1.5 rounded-lg outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-4 focus-visible:outline-fg-3\"\n      >\n        {options.map((o, i) => {\n          const mine = o.id === vote;\n          const lead = showResults && winners.has(o.id);\n          return (\n            <li\n              key={o.id}\n              data-winner={lead || undefined}\n              data-selected={mine || undefined}\n              className={cn(\n                \"relative min-h-10 overflow-hidden rounded-lg border transition-[border-color,background-color] duration-300\",\n                showResults ? \"border-transparent bg-fg/[0.03]\" : \"border-line-2 bg-raised\",\n              )}\n            >\n              {/* One bar per row that lives through both states: it grows on vote and drains on change. */}\n              <motion.span\n                aria-hidden\n                initial={{ width: \"0%\" }}\n                animate={{ width: showResults ? `${pct[i]}%` : \"0%\" }}\n                transition={\n                  reduce\n                    ? { duration: 0 }\n                    : showResults\n                      ? { duration: 0.7, ease: ease.out, delay: Math.min(i, 6) * 0.04 }\n                      : { duration: 0.25, ease: ease.inOut }\n                }\n                className={cn(\n                  \"absolute inset-y-0 left-0 transition-colors duration-300\",\n                  lead ? \"bg-fg/[0.16]\" : \"bg-fg/[0.07]\",\n                )}\n              />\n              {/* The results layer is always mounted, so its numbers roll up from 0 the moment a vote lands. */}\n              <div\n                aria-hidden={!showResults}\n                className={cn(\"relative flex min-h-10 items-center gap-2 px-3 py-2\", !showResults && \"invisible\")}\n              >\n                <span className={cn(\"min-w-0 flex-1 text-[13px] transition-colors duration-300\", lead ? \"font-medium text-fg\" : mine || !showResults ? \"text-fg\" : \"text-fg-2\")}>\n                  <span className=\"break-words\">{o.label}</span>\n                  {mine && <Tick reduce={!!reduce} />}\n                </span>\n                <span aria-hidden className=\"shrink-0 text-[12px] text-fg-3 tabular\">\n                  <span className=\"inline-flex h-[1lh] items-center\">\n                    <NumberFlow value={showResults ? votes[i] : 0} locales={locale} animated={!reduce} {...timing} />\n                  </span>\n                </span>\n                <span aria-hidden className={cn(\"inline-flex h-[1lh] w-10 shrink-0 items-center justify-end text-[13px] tabular\", lead ? \"font-medium text-fg\" : \"text-fg-2\")}>\n                  <NumberFlow value={showResults ? pct[i] : 0} suffix=\"%\" locales={locale} animated={!reduce} {...timing} />\n                </span>\n                <span className=\"sr-only\">\n                  {`, ${pct[i]}%, ${fmt.format(votes[i])} ${votes[i] === 1 ? \"vote\" : \"votes\"}${mine ? \", your vote\" : \"\"}${lead ? (tied ? \", tied for first\" : closed ? \", winner\" : \", leading\") : \"\"}`}\n                </span>\n              </div>\n              {!showResults && (\n                <button\n                  ref={i === 0 ? firstOption : undefined}\n                  type=\"button\"\n                  onClick={() => commit(o.id)}\n                  className={cn(\n                    \"group/opt absolute inset-0 flex w-full items-center gap-2 rounded-[7px] px-3 py-2 text-left outline-none\",\n                    \"transition-[background-color,scale] duration-150 hover:bg-hover active:scale-[0.985]\",\n                    \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n                  )}\n                >\n                  <span className=\"min-w-0 flex-1 break-words text-[13px] text-fg\">{o.label}</span>\n                  <span\n                    aria-hidden\n                    className=\"size-4 shrink-0 rounded-full border border-line-2 transition-[border-color,border-width] duration-150 group-hover/opt:border-fg-4 group-active/opt:border-[5px] group-active/opt:border-fg\"\n                  />\n                </button>\n              )}\n            </li>\n          );\n        })}\n      </ul>\n\n      <div className=\"flex min-h-7 flex-wrap items-center justify-between gap-x-3 gap-y-1 text-[12px] text-fg-3\">\n        {error ? (\n          <span role=\"alert\" className=\"text-danger\">\n            {error}\n          </span>\n        ) : (\n          <span className=\"flex items-center gap-1 tabular\">\n            <span className=\"inline-flex h-[1lh] items-center\">\n              <NumberFlow value={total} locales={locale} animated={!reduce} {...timing} />\n            </span>\n            <span>{total === 1 ? \"vote\" : \"votes\"}</span>\n            {closed ? <span>· Final results</span> : meta ? <span>· {meta}</span> : null}\n          </span>\n        )}\n        {!closed && vote != null && allowChange && (\n          <button\n            ref={changeButton}\n            type=\"button\"\n            onClick={() => commit(null)}\n            className=\"-mr-2 h-7 rounded-md px-2 text-[12px] font-medium text-fg-2 outline-none transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3 active:scale-[0.97]\"\n          >\n            Change vote\n          </button>\n        )}\n      </div>\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </section>\n  );\n}\n\nfunction Tick({ reduce }: { reduce: boolean }) {\n  return (\n    <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className=\"ml-1.5 inline-block -translate-y-px align-middle\">\n      <motion.path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        initial={reduce ? false : { pathLength: 0 }}\n        animate={{ pathLength: 1 }}\n        transition={{ duration: 0.32, ease: ease.out, delay: 0.25 }}\n      />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/poll-results"
}
