{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "choice-poll",
  "type": "registry:ui",
  "description": "Poll component with single or multiple selection, optional results, and keyboard navigation",
  "dependencies": [
    "@radix-ui/react-use-controllable-state",
    "lucide-react",
    "class-variance-authority"
  ],
  "files": [
    {
      "path": "registry/default/ui/choice-poll.tsx",
      "content": "\"use client\"\n\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ComponentProps,\n  type KeyboardEvent,\n  type MouseEvent,\n} from \"react\"\nimport { useControllableState } from \"@radix-ui/react-use-controllable-state\"\nimport { cva } from \"class-variance-authority\"\nimport { Check } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/* -----------------------------------------------------------------------------\n * Types\n * -------------------------------------------------------------------------- */\n\nexport interface ChoicePollRootProps\n  extends Omit<ComponentProps<\"div\">, \"defaultValue\"> {\n  /** Currently selected option(s) - controlled */\n  value?: string | string[]\n  /** Default selected option(s) - uncontrolled */\n  defaultValue?: string | string[]\n  /** Callback when selection changes */\n  onValueChange?: (value: string | string[]) => void\n  /** Whether multiple selections are allowed */\n  multiple?: boolean\n  /** Whether the poll is disabled */\n  disabled?: boolean\n  /** Whether poll results should be visible after voting */\n  showResults?: boolean\n  /** Vote counts per option (for showing results) */\n  votes?: Record<string, number>\n  /** Whether user has submitted their vote */\n  hasVoted?: boolean\n}\n\nexport interface ChoicePollOptionProps extends ComponentProps<\"button\"> {\n  /** Unique identifier for this option */\n  value: string\n  /** Whether this specific option is disabled */\n  disabled?: boolean\n}\n\nexport type ChoicePollHeaderProps = ComponentProps<\"div\">\n\nexport type ChoicePollTitleProps = ComponentProps<\"h3\">\n\nexport type ChoicePollDescriptionProps = ComponentProps<\"p\">\n\nexport type ChoicePollOptionsProps = ComponentProps<\"div\">\n\nexport type ChoicePollLabelProps = ComponentProps<\"span\">\n\nexport type ChoicePollIndicatorProps = ComponentProps<\"span\">\n\nexport type ChoicePollProgressProps = ComponentProps<\"div\">\n\nexport type ChoicePollPercentageProps = ComponentProps<\"span\">\n\nexport interface ChoicePollFooterProps extends ComponentProps<\"div\"> {\n  /** Total number of votes */\n  totalVotes?: number\n}\n\n/* -----------------------------------------------------------------------------\n * Context\n * -------------------------------------------------------------------------- */\n\ninterface ChoicePollContextValue {\n  selected: string[]\n  multiple: boolean\n  disabled: boolean\n  showResults: boolean\n  votes: Record<string, number>\n  totalVotes: number\n  hasVoted: boolean\n  select: (optionId: string) => void\n  isSelected: (optionId: string) => boolean\n  getPercentage: (optionId: string) => number\n}\n\nconst ChoicePollContext = createContext<ChoicePollContextValue | null>(null)\n\nfunction useChoicePollContext() {\n  const context = useContext(ChoicePollContext)\n  if (!context) {\n    throw new Error(\"ChoicePoll components must be used within ChoicePoll.Root\")\n  }\n  return context\n}\n\ninterface ChoicePollOptionContextValue {\n  optionId: string\n  disabled: boolean\n  isSelected: boolean\n  percentage: number\n}\n\nconst ChoicePollOptionContext =\n  createContext<ChoicePollOptionContextValue | null>(null)\n\nfunction useChoicePollOptionContext() {\n  const context = useContext(ChoicePollOptionContext)\n  if (!context) {\n    throw new Error(\n      \"ChoicePoll.Option sub-components must be used within ChoicePoll.Option\"\n    )\n  }\n  return context\n}\n\nfunction useAnimatedPercentage(percentage: number, shouldShowResults: boolean) {\n  const [animatedPercentage, setAnimatedPercentage] = useState(\n    shouldShowResults ? percentage : 0\n  )\n\n  useEffect(() => {\n    if (!shouldShowResults) {\n      setAnimatedPercentage(0)\n      return\n    }\n\n    const frame = requestAnimationFrame(() => {\n      setAnimatedPercentage(percentage)\n    })\n\n    return () => cancelAnimationFrame(frame)\n  }, [percentage, shouldShowResults])\n\n  return animatedPercentage\n}\n\n/* -----------------------------------------------------------------------------\n * Variants\n * -------------------------------------------------------------------------- */\n\nconst optionVariants = cva(\n  [\n    \"group relative flex w-full cursor-pointer items-center gap-3 rounded-xl border p-4 text-left\",\n    \"transition-all duration-200 ease-out\",\n    \"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none\",\n    \"disabled:cursor-not-allowed disabled:opacity-50\",\n  ],\n  {\n    variants: {\n      state: {\n        idle: [\n          \"border-border bg-background hover:border-primary/50 hover:bg-accent/50\",\n        ],\n        selected: [\n          \"border-primary bg-primary/5 shadow-sm\",\n          \"hover:border-primary hover:bg-primary/10\",\n        ],\n        voted: [\"border-border bg-muted/30 cursor-default\"],\n      },\n    },\n    defaultVariants: {\n      state: \"idle\",\n    },\n  }\n)\n\nconst indicatorVariants = cva(\n  [\n    \"flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2\",\n    \"transition-all duration-200 ease-out\",\n  ],\n  {\n    variants: {\n      state: {\n        idle: \"border-muted-foreground/30 bg-background\",\n        selected: \"border-primary bg-primary text-primary-foreground\",\n        voted: \"border-muted-foreground/30 bg-muted\",\n      },\n      multiple: {\n        true: \"rounded-md\",\n        false: \"rounded-full\",\n      },\n    },\n    defaultVariants: {\n      state: \"idle\",\n      multiple: false,\n    },\n  }\n)\n\nconst progressVariants = cva(\n  [\n    \"absolute inset-y-0 left-0 rounded-l-xl\",\n    \"transition-all duration-500 ease-out\",\n  ],\n  {\n    variants: {\n      state: {\n        idle: \"bg-transparent\",\n        selected: \"bg-primary/15\",\n        voted: \"bg-primary/10\",\n      },\n    },\n    defaultVariants: {\n      state: \"idle\",\n    },\n  }\n)\n\n/* -----------------------------------------------------------------------------\n * Root\n * -------------------------------------------------------------------------- */\n\nfunction ChoicePollRoot({\n  value: controlledValue,\n  defaultValue,\n  onValueChange,\n  multiple = false,\n  disabled = false,\n  showResults = false,\n  votes = {},\n  hasVoted = false,\n  children,\n  className,\n  ...props\n}: ChoicePollRootProps) {\n  const normalizeValue = (val: string | string[] | undefined): string[] => {\n    if (!val) {\n      return []\n    }\n    return Array.isArray(val) ? val : [val]\n  }\n\n  const [selectedArray, setSelectedArray] = useControllableState<string[]>({\n    prop: controlledValue ? normalizeValue(controlledValue) : undefined,\n    defaultProp: normalizeValue(defaultValue),\n    onChange: (arr) => {\n      if (onValueChange) {\n        onValueChange(multiple ? arr : (arr[0] ?? \"\"))\n      }\n    },\n  })\n\n  const selected = selectedArray ?? []\n\n  const totalVotes = useMemo(\n    () => Object.values(votes).reduce((sum, count) => sum + count, 0),\n    [votes]\n  )\n\n  const select = useCallback(\n    (optionId: string) => {\n      if (disabled || hasVoted) {\n        return\n      }\n\n      setSelectedArray((prev) => {\n        const current = prev ?? []\n        const isCurrentlySelected = current.includes(optionId)\n\n        if (multiple) {\n          if (isCurrentlySelected) {\n            return current.filter((id) => id !== optionId)\n          }\n          return [...current, optionId]\n        }\n        if (isCurrentlySelected) {\n          return []\n        }\n        return [optionId]\n      })\n    },\n    [disabled, hasVoted, multiple, setSelectedArray]\n  )\n\n  const isSelected = useCallback(\n    (optionId: string) => selected.includes(optionId),\n    [selected]\n  )\n\n  const getPercentage = useCallback(\n    (optionId: string) => {\n      if (totalVotes === 0) {\n        return 0\n      }\n      return Math.round(((votes[optionId] ?? 0) / totalVotes) * 100)\n    },\n    [votes, totalVotes]\n  )\n\n  const contextValue = useMemo(\n    () => ({\n      selected,\n      multiple,\n      disabled,\n      showResults: showResults && hasVoted,\n      votes,\n      totalVotes,\n      hasVoted,\n      select,\n      isSelected,\n      getPercentage,\n    }),\n    [\n      selected,\n      multiple,\n      disabled,\n      showResults,\n      hasVoted,\n      votes,\n      totalVotes,\n      select,\n      isSelected,\n      getPercentage,\n    ]\n  )\n\n  return (\n    <ChoicePollContext.Provider value={contextValue}>\n      <div\n        className={cn(\"flex flex-col gap-4\", className)}\n        data-disabled={disabled ? true : undefined}\n        data-has-voted={hasVoted ? true : undefined}\n        data-multiple={multiple ? true : undefined}\n        data-slot=\"choice-poll\"\n        {...props}\n      >\n        {children}\n      </div>\n    </ChoicePollContext.Provider>\n  )\n}\n\n/* -----------------------------------------------------------------------------\n * Header\n * -------------------------------------------------------------------------- */\n\nfunction ChoicePollHeader({\n  children,\n  className,\n  ...props\n}: ChoicePollHeaderProps) {\n  return (\n    <div\n      className={cn(\"flex flex-col gap-1\", className)}\n      data-slot=\"choice-poll-header\"\n      {...props}\n    >\n      {children}\n    </div>\n  )\n}\n\n/* -----------------------------------------------------------------------------\n * Title\n * -------------------------------------------------------------------------- */\n\nfunction ChoicePollTitle({\n  children,\n  className,\n  ...props\n}: ChoicePollTitleProps) {\n  return (\n    <h3\n      className={cn(\"text-lg font-semibold tracking-tight\", className)}\n      data-slot=\"choice-poll-title\"\n      {...props}\n    >\n      {children}\n    </h3>\n  )\n}\n\n/* -----------------------------------------------------------------------------\n * Description\n * -------------------------------------------------------------------------- */\n\nfunction ChoicePollDescription({\n  children,\n  className,\n  ...props\n}: ChoicePollDescriptionProps) {\n  return (\n    <p\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      data-slot=\"choice-poll-description\"\n      {...props}\n    >\n      {children}\n    </p>\n  )\n}\n\n/* -----------------------------------------------------------------------------\n * Options Container\n * -------------------------------------------------------------------------- */\n\nfunction ChoicePollOptions({\n  children,\n  className,\n  ...props\n}: ChoicePollOptionsProps) {\n  const containerRef = useRef<HTMLDivElement>(null)\n\n  const handleKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {\n    const container = containerRef.current\n    if (!container) {\n      return\n    }\n\n    const options = Array.from(\n      container.querySelectorAll<HTMLButtonElement>(\n        '[data-slot=\"choice-poll-option\"]:not([disabled])'\n      )\n    )\n    const currentIndex = options.indexOf(\n      document.activeElement as HTMLButtonElement\n    )\n\n    let nextIndex = currentIndex\n\n    switch (event.key) {\n      case \"ArrowDown\":\n      case \"ArrowRight\":\n        event.preventDefault()\n        nextIndex = currentIndex < options.length - 1 ? currentIndex + 1 : 0\n        break\n      case \"ArrowUp\":\n      case \"ArrowLeft\":\n        event.preventDefault()\n        nextIndex = currentIndex > 0 ? currentIndex - 1 : options.length - 1\n        break\n      case \"Home\":\n        event.preventDefault()\n        nextIndex = 0\n        break\n      case \"End\":\n        event.preventDefault()\n        nextIndex = options.length - 1\n        break\n      default:\n        break\n    }\n\n    options[nextIndex]?.focus()\n  }, [])\n\n  return (\n    <div\n      className={cn(\"flex flex-col gap-2\", className)}\n      data-slot=\"choice-poll-options\"\n      onKeyDown={handleKeyDown}\n      ref={containerRef}\n      role=\"listbox\"\n      {...props}\n    >\n      {children}\n    </div>\n  )\n}\n\n/* -----------------------------------------------------------------------------\n * Option\n * -------------------------------------------------------------------------- */\n\nfunction ChoicePollOption({\n  value,\n  disabled: optionDisabled = false,\n  children,\n  className,\n  onClick,\n  ...props\n}: ChoicePollOptionProps) {\n  const {\n    disabled: rootDisabled,\n    hasVoted,\n    showResults,\n    isSelected,\n    select,\n    getPercentage,\n  } = useChoicePollContext()\n\n  const disabled = rootDisabled || optionDisabled\n  const selected = isSelected(value)\n  const percentage = getPercentage(value)\n  const animatedPercentage = useAnimatedPercentage(percentage, showResults)\n\n  const getState = (): \"idle\" | \"selected\" | \"voted\" => {\n    if (hasVoted) {\n      return \"voted\"\n    }\n    if (selected) {\n      return \"selected\"\n    }\n    return \"idle\"\n  }\n  const state = getState()\n\n  const handleClick = useCallback(\n    (event: MouseEvent<HTMLButtonElement>) => {\n      onClick?.(event)\n      if (!(event.defaultPrevented || disabled)) {\n        select(value)\n      }\n    },\n    [onClick, disabled, select, value]\n  )\n\n  const optionContextValue = useMemo(\n    () => ({\n      optionId: value,\n      disabled,\n      isSelected: selected,\n      percentage,\n    }),\n    [value, disabled, selected, percentage]\n  )\n\n  return (\n    <ChoicePollOptionContext.Provider value={optionContextValue}>\n      <button\n        aria-disabled={disabled || hasVoted}\n        aria-selected={selected}\n        className={cn(optionVariants({ state }), className)}\n        data-disabled={disabled ? true : undefined}\n        data-percentage={percentage}\n        data-selected={selected ? true : undefined}\n        data-slot=\"choice-poll-option\"\n        data-state={state}\n        data-value={value}\n        disabled={disabled || hasVoted}\n        onClick={handleClick}\n        role=\"option\"\n        type=\"button\"\n        {...props}\n      >\n        {/* Progress bar background */}\n        {showResults && (\n          <span\n            aria-hidden=\"true\"\n            className={cn(\n              progressVariants({ state: selected ? \"selected\" : \"voted\" })\n            )}\n            style={{ width: `${animatedPercentage}%` }}\n          />\n        )}\n\n        {/* Content */}\n        <span className=\"relative z-10 flex w-full items-center gap-3\">\n          {children}\n        </span>\n      </button>\n    </ChoicePollOptionContext.Provider>\n  )\n}\n\n/* -----------------------------------------------------------------------------\n * Indicator (checkbox/radio visual)\n * -------------------------------------------------------------------------- */\n\nfunction ChoicePollIndicator({\n  children,\n  className,\n  ...props\n}: ChoicePollIndicatorProps) {\n  const { multiple, hasVoted } = useChoicePollContext()\n  const { isSelected } = useChoicePollOptionContext()\n\n  const getState = (): \"idle\" | \"selected\" | \"voted\" => {\n    if (hasVoted) {\n      return \"voted\"\n    }\n    if (isSelected) {\n      return \"selected\"\n    }\n    return \"idle\"\n  }\n  const state = getState()\n\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={cn(indicatorVariants({ state, multiple }), className)}\n      data-slot=\"choice-poll-indicator\"\n      data-state={state}\n      {...props}\n    >\n      {isSelected && (\n        <Check\n          className={cn(\n            \"h-3 w-3 transition-transform duration-200\",\n            isSelected ? \"scale-100\" : \"scale-0\"\n          )}\n          strokeWidth={3}\n        />\n      )}\n      {children}\n    </span>\n  )\n}\n\n/* -----------------------------------------------------------------------------\n * Label\n * -------------------------------------------------------------------------- */\n\nfunction ChoicePollLabel({\n  children,\n  className,\n  ...props\n}: ChoicePollLabelProps) {\n  return (\n    <span\n      className={cn(\"flex-1 font-medium\", className)}\n      data-slot=\"choice-poll-label\"\n      {...props}\n    >\n      {children}\n    </span>\n  )\n}\n\n/* -----------------------------------------------------------------------------\n * Progress (inline progress bar)\n * -------------------------------------------------------------------------- */\n\nfunction ChoicePollProgress({ className, ...props }: ChoicePollProgressProps) {\n  const { showResults } = useChoicePollContext()\n  const { percentage, isSelected } = useChoicePollOptionContext()\n  const animatedPercentage = useAnimatedPercentage(percentage, showResults)\n\n  if (!showResults) {\n    return null\n  }\n\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={cn(\n        \"bg-muted h-1.5 w-16 overflow-hidden rounded-full\",\n        className\n      )}\n      data-slot=\"choice-poll-progress\"\n      {...props}\n    >\n      <span\n        className={cn(\n          \"block h-full rounded-full transition-all duration-500 ease-out\",\n          isSelected ? \"bg-primary\" : \"bg-muted-foreground/30\"\n        )}\n        style={{ width: `${animatedPercentage}%` }}\n      />\n    </span>\n  )\n}\n\n/* -----------------------------------------------------------------------------\n * Percentage\n * -------------------------------------------------------------------------- */\n\nfunction ChoicePollPercentage({\n  children,\n  className,\n  ...props\n}: ChoicePollPercentageProps) {\n  const { showResults } = useChoicePollContext()\n  const { percentage } = useChoicePollOptionContext()\n  const animatedPercentage = useAnimatedPercentage(percentage, showResults)\n\n  if (!showResults) {\n    return null\n  }\n\n  return (\n    <span\n      className={cn(\n        \"text-muted-foreground min-w-[3ch] text-right text-sm font-medium tabular-nums\",\n        className\n      )}\n      data-slot=\"choice-poll-percentage\"\n      {...props}\n    >\n      {children ?? `${Math.round(animatedPercentage)}%`}\n    </span>\n  )\n}\n\n/* -----------------------------------------------------------------------------\n * Footer\n * -------------------------------------------------------------------------- */\n\nfunction ChoicePollFooter({\n  children,\n  className,\n  totalVotes,\n  ...props\n}: ChoicePollFooterProps) {\n  const {\n    totalVotes: contextTotalVotes,\n    hasVoted,\n    showResults,\n  } = useChoicePollContext()\n  const votes = totalVotes ?? contextTotalVotes\n\n  if (!showResults && !children) {\n    return null\n  }\n\n  return (\n    <div\n      className={cn(\n        \"text-muted-foreground flex items-center justify-between text-sm\",\n        className\n      )}\n      data-slot=\"choice-poll-footer\"\n      {...props}\n    >\n      {children ?? (\n        <>\n          <span>\n            {votes.toLocaleString()} {votes === 1 ? \"vote\" : \"votes\"}\n          </span>\n          {hasVoted && (\n            <span className=\"text-primary flex items-center gap-1.5\">\n              <Check className=\"h-3.5 w-3.5\" />\n              <span>You voted</span>\n            </span>\n          )}\n        </>\n      )}\n    </div>\n  )\n}\n\n/* -----------------------------------------------------------------------------\n * Hook for external access\n * -------------------------------------------------------------------------- */\n\nexport function useChoicePoll() {\n  return useChoicePollContext()\n}\n\n/* -----------------------------------------------------------------------------\n * Export\n * -------------------------------------------------------------------------- */\n\nexport const ChoicePoll = {\n  Root: ChoicePollRoot,\n  Header: ChoicePollHeader,\n  Title: ChoicePollTitle,\n  Description: ChoicePollDescription,\n  Options: ChoicePollOptions,\n  Option: ChoicePollOption,\n  Indicator: ChoicePollIndicator,\n  Label: ChoicePollLabel,\n  Progress: ChoicePollProgress,\n  Percentage: ChoicePollPercentage,\n  Footer: ChoicePollFooter,\n}\n\nexport {\n  ChoicePollRoot,\n  ChoicePollHeader,\n  ChoicePollTitle,\n  ChoicePollDescription,\n  ChoicePollOptions,\n  ChoicePollOption,\n  ChoicePollIndicator,\n  ChoicePollLabel,\n  ChoicePollProgress,\n  ChoicePollPercentage,\n  ChoicePollFooter,\n}\n",
      "type": "registry:ui"
    }
  ]
}