{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "rating-input",
  "type": "registry:ui",
  "title": "Rating Input",
  "description": "An accessible star rating built on native radios, with hover preview, half steps and a particle pop.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/rating-input.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion } from \"motion/react\";\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\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nexport interface RatingInputProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"defaultValue\"> {\n  /** Controlled value (0 = no rating). */\n  value?: number;\n  /** Initial value when uncontrolled. */\n  defaultValue?: number;\n  /** Called with the new value (0 when cleared). */\n  onChange?: (value: number) => void;\n  /** Number of icons. */\n  max?: number;\n  /** Allow half steps (3.5). */\n  allowHalf?: boolean;\n  /** Choosing the current value again clears the rating; Delete/Backspace also clear. */\n  allowClear?: boolean;\n  /** Display only: renders an image with an accessible name and supports fractional values (4.3). */\n  readOnly?: boolean;\n  disabled?: boolean;\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Fill, focus ring and particle colour (any CSS colour). */\n  accent?: string;\n  /** Text per whole value, e.g. [\"Poor\", \"Fair\", \"Good\", \"Great\", \"Excellent\"]. */\n  labels?: string[];\n  /** Show the numeric value (and label) next to the icons. */\n  showValue?: boolean;\n  /** Custom glyph (use fill=\"currentColor\"); defaults to a star. */\n  icon?: React.ReactNode;\n  /** Group label. */\n  label?: string;\n  /** Visually hide the group label (it stays available to assistive tech). */\n  hideLabel?: boolean;\n  /** Plural noun used in accessible names: \"3 of 5 stars\". */\n  unit?: string;\n  /** Name for the native radios, so the value submits with forms. */\n  name?: string;\n}\n\nconst SIZES = {\n  sm: { icon: 20, gap: \"gap-0.5\", text: \"text-[12px]\" },\n  md: { icon: 28, gap: \"gap-1\", text: \"text-[13px]\" },\n  lg: { icon: 36, gap: \"gap-1.5\", text: \"text-[15px]\" },\n};\n\nconst PARTICLES = 8;\n\nfunction StarGlyph() {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\">\n      <path d=\"M12 2.6c.3 0 .58.17.71.45l2.5 5.07 5.6.81c.65.1.9.89.43 1.35l-4.05 3.95.96 5.57c.11.64-.56 1.13-1.14.83L12 18.0l-5.01 2.63c-.58.3-1.25-.19-1.14-.83l.96-5.57L2.76 10.28c-.47-.46-.21-1.25.43-1.35l5.6-.81 2.5-5.07c.13-.28.41-.45.71-.45Z\" />\n    </svg>\n  );\n}\n\nconst formatValue = (v: number) => (Number.isInteger(v) ? String(v) : v.toFixed(1).replace(/\\.0$/, \"\"));\n\n/**\n * A rating control built from native radio inputs (one per step, two per icon with half\n * precision) inside role=\"radiogroup\": arrow keys, form submission and focus come from the\n * platform. Hovering previews the value; selecting pops the icon and bursts a few particles.\n */\nexport function RatingInput({\n  value,\n  defaultValue = 0,\n  onChange,\n  max = 5,\n  allowHalf = false,\n  allowClear = true,\n  readOnly = false,\n  disabled = false,\n  size = \"md\",\n  accent = \"#FF7448\",\n  labels,\n  showValue = true,\n  icon,\n  label = \"Rating\",\n  hideLabel = false,\n  unit = \"stars\",\n  name,\n  className = \"\",\n  style,\n  ...props\n}: RatingInputProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const uid = React.useId();\n  const labelId = `${uid}-label`;\n  const groupName = name ?? `rating-${uid}`;\n  const count = Math.max(1, Math.min(20, Math.round(max)));\n  const step = allowHalf ? 0.5 : 1;\n\n  const [inner, setInner] = React.useState(defaultValue);\n  const raw = value ?? inner;\n  // Interactive values snap to the step; read-only keeps fractions for averages.\n  const current = readOnly ? Math.min(count, Math.max(0, raw)) : Math.min(count, Math.max(0, Math.round(raw / step) * step));\n  const [hover, setHover] = React.useState<number | null>(null);\n  const [burst, setBurst] = React.useState<{ index: number; n: number } | null>(null);\n  const [cleared, setCleared] = React.useState(\"\");\n\n  const display = !readOnly && hover !== null ? hover : current;\n  const dims = SIZES[size];\n  const labelFor = (v: number) => (labels && v > 0 ? labels[Math.ceil(v) - 1] : undefined);\n  const nameFor = (v: number) => {\n    const text = labelFor(v);\n    return `${formatValue(v)} of ${count} ${unit}${text ? `, ${text}` : \"\"}`;\n  };\n\n  const commit = (next: number) => {\n    if (value === undefined) setInner(next);\n    onChange?.(next);\n    if (next > 0 && !reduceMotion) setBurst((b) => ({ index: Math.ceil(next), n: (b?.n ?? 0) + 1 }));\n    setCleared(next === 0 ? \"Rating cleared\" : \"\");\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    if (readOnly || disabled) return;\n    const onChecked = event.target instanceof HTMLInputElement && Number(event.target.value) === current;\n    // Space on the checked radio re-selects it (browsers don't fire a click for that), so it clears too.\n    if (allowClear && current > 0 && (event.key === \"Delete\" || event.key === \"Backspace\" || (event.key === \" \" && onChecked))) {\n      event.preventDefault();\n      commit(0);\n    } else if (event.key === \"Home\" || event.key === \"End\") {\n      event.preventDefault();\n      const target = event.key === \"Home\" ? step : count;\n      commit(target);\n      event.currentTarget.querySelector<HTMLInputElement>(`input[value=\"${target}\"]`)?.focus();\n    }\n  };\n\n  const glyph = icon ?? <StarGlyph />;\n\n  const renderIcon = (i: number) => {\n    const fill = Math.min(1, Math.max(0, display - (i - 1)));\n    const popping = burst?.index === i;\n    return (\n      <>\n        <motion.span\n          key={popping ? `pop-${burst.n}` : \"still\"}\n          aria-hidden=\"true\"\n          className=\"relative block size-full\"\n          initial={popping ? { scale: 0.55 } : false}\n          animate={{ scale: 1 }}\n          transition={{ type: \"spring\", stiffness: 520, damping: 14, mass: 0.6 }}\n        >\n          <span className=\"absolute inset-0 text-zinc-900/[0.13] dark:text-white/[0.14] [&>svg]:size-full\">{glyph}</span>\n          <span\n            className=\"absolute inset-0 text-[var(--ri-accent)] transition-[clip-path] duration-150 ease-out motion-reduce:transition-none [&>svg]:size-full\"\n            style={{ clipPath: `inset(0 ${((1 - fill) * 100).toFixed(2)}% 0 0)` }}\n          >\n            {glyph}\n          </span>\n        </motion.span>\n        {popping && !reduceMotion && (\n          <span key={`burst-${burst.n}`} aria-hidden=\"true\" className=\"pointer-events-none absolute left-1/2 top-1/2\">\n            {Array.from({ length: PARTICLES }, (_, p) => {\n              const angle = (p / PARTICLES) * Math.PI * 2 - Math.PI / 2;\n              const distance = dims.icon * (p % 2 ? 0.72 : 0.95);\n              return (\n                <motion.span\n                  key={p}\n                  className=\"absolute -ml-[2px] -mt-[2px] size-1 rounded-full bg-[var(--ri-accent)]\"\n                  initial={{ x: 0, y: 0, opacity: 1, scale: 1 }}\n                  animate={{ x: Math.cos(angle) * distance, y: Math.sin(angle) * distance, opacity: 0, scale: 0.4 }}\n                  transition={{ duration: 0.55, ease: [0.15, 0.8, 0.3, 1] }}\n                />\n              );\n            })}\n          </span>\n        )}\n      </>\n    );\n  };\n\n  const valueText = labelFor(display);\n\n  return (\n    <div\n      className={`inline-flex flex-col items-start ${className}`}\n      style={{ [\"--ri-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <span\n        id={labelId}\n        className={hideLabel ? \"sr-only\" : \"mb-2 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\"}\n      >\n        {label}\n      </span>\n\n      <div className=\"flex items-center gap-3\">\n        {readOnly ? (\n          <div\n            role=\"img\"\n            aria-label={`${label}: ${formatValue(Math.round(current * 10) / 10)} out of ${count} ${unit}`}\n            className={`flex items-center ${dims.gap}`}\n          >\n            {Array.from({ length: count }, (_, k) => (\n              <span key={k} className=\"relative block\" style={{ width: dims.icon, height: dims.icon }}>\n                {renderIcon(k + 1)}\n              </span>\n            ))}\n          </div>\n        ) : (\n          <div\n            role=\"radiogroup\"\n            aria-labelledby={labelId}\n            aria-disabled={disabled || undefined}\n            onKeyDown={handleKeyDown}\n            onPointerLeave={() => setHover(null)}\n            className={`flex items-center ${dims.gap} ${disabled ? \"opacity-50\" : \"\"}`}\n          >\n            {Array.from({ length: count }, (_, k) => {\n              const i = k + 1;\n              const steps = allowHalf ? [i - 0.5, i] : [i];\n              return (\n                <span\n                  key={i}\n                  className=\"relative block rounded-md has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ri-accent)]\"\n                  style={{ width: dims.icon, height: dims.icon }}\n                >\n                  {renderIcon(i)}\n                  {steps.map((v, s) => (\n                    <input\n                      key={v}\n                      type=\"radio\"\n                      name={groupName}\n                      value={v}\n                      checked={current === v}\n                      disabled={disabled}\n                      aria-label={nameFor(v)}\n                      onChange={() => commit(v)}\n                      onClick={() => {\n                        // Re-selecting the checked value clears it (onChange doesn't fire then).\n                        if (allowClear && current === v) {\n                          commit(0);\n                          setHover(null);\n                        }\n                      }}\n                      onPointerEnter={(e) => {\n                        if (e.pointerType === \"mouse\" && !disabled) setHover(v);\n                      }}\n                      className={`absolute top-0 m-0 h-full cursor-pointer appearance-none opacity-0 disabled:cursor-not-allowed ${\n                        steps.length === 2 ? (s === 0 ? \"left-0 w-1/2\" : \"right-0 w-1/2\") : \"inset-x-0 w-full\"\n                      }`}\n                    />\n                  ))}\n                </span>\n              );\n            })}\n          </div>\n        )}\n\n        {showValue && (\n          <span aria-hidden=\"true\" className={`flex items-baseline gap-2 tabular-nums ${dims.text}`}>\n            <span className=\"font-medium text-zinc-950 dark:text-zinc-50\">{display > 0 ? formatValue(Math.round(display * 10) / 10) : \"–\"}</span>\n            <span className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n              {valueText ?? `/ ${count}`}\n            </span>\n          </span>\n        )}\n      </div>\n\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {cleared}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Documentation: https://wancraft.my/components/rating-input"
}
