{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "rating",
  "type": "registry:ui",
  "title": "Rating",
  "description": "Star rating with hover preview, half steps, scrubbing and a pop on commit.",
  "categories": [
    "selection"
  ],
  "dependencies": [
    "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/rating.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useAnimate, useReducedMotion } from \"motion/react\";\nimport { useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\ntype Size = \"sm\" | \"md\" | \"lg\";\nconst SIZES: Record<Size, { icon: number; gap: number; text: string }> = {\n  sm: { icon: 14, gap: 2, text: \"text-[12px]\" },\n  md: { icon: 18, gap: 4, text: \"text-[12.5px]\" },\n  lg: { icon: 24, gap: 4, text: \"text-[13px]\" },\n};\n\n/** A filled star on the 16px grid. The stroke rounds its points. */\nexport function StarIcon() {\n  return (\n    <svg viewBox=\"0 0 16 16\" width=\"100%\" height=\"100%\" aria-hidden focusable={false}>\n      <path\n        d=\"m8 1.9 1.8 3.7 4.05.58-2.93 2.86.7 4.03L8 11.17l-3.62 1.9.7-4.03L2.15 6.18l4.05-.58z\"\n        fill=\"currentColor\"\n        stroke=\"currentColor\"\n        strokeWidth={1.2}\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nconst clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));\nconst roundTo = (n: number, step: number) => Math.round(n / step) * step;\n\nexport type RatingProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\" | \"children\"> & {\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number) => void;\n  /** Number of icons. */\n  max?: number;\n  /** 1 for whole icons, 0.5 to allow halves. */\n  step?: 0.5 | 1;\n  /** Pressing the current value again clears the rating to 0. */\n  clearable?: boolean;\n  /** Display only. Shows any fraction (4.3 fills 30% of the fifth icon) and is not focusable. */\n  readOnly?: boolean;\n  disabled?: boolean;\n  size?: Size;\n  /** Replace the star. Draw it filled with currentColor on a square viewBox. */\n  icon?: React.ReactNode;\n  /** One word per whole value (\"Poor\" … \"Excellent\"), shown beside the icons and read out. */\n  labels?: string[];\n  /** Shown beside the icons while nothing is rated, when labels are used. */\n  placeholder?: string;\n  /** Posts the value with a form. */\n  name?: string;\n  /** Screen reader text for a value. Defaults to \"4 out of 5\", with the label first when labels are set. */\n  getValueText?: (value: number, max: number) => string;\n};\n\nexport function Rating({\n  value: valueProp,\n  defaultValue = 0,\n  onValueChange,\n  max = 5,\n  step = 1,\n  clearable = true,\n  readOnly = false,\n  disabled = false,\n  size = \"md\",\n  icon,\n  labels,\n  placeholder = \"\",\n  name,\n  getValueText,\n  className,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledBy,\n  ...rest\n}: RatingProps) {\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  const [hover, setHover] = useState<number | null>(null);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const [scope, animate] = useAnimate<HTMLDivElement>();\n  const reduce = useReducedMotion();\n  const valueId = useId();\n  // Pointer bookkeeping that never needs a render.\n  const press = useRef<{ x: number; moved: boolean } | null>(null);\n  const muted = useRef<number | null>(null);\n\n  const s = SIZES[size];\n  const interactive = !readOnly && !disabled;\n  const shown = interactive && hover !== null ? hover : value;\n  const previewing = interactive && hover !== null && hover !== value;\n  const labelFor = (v: number) => (labels && v > 0 ? labels[Math.min(labels.length, Math.ceil(v)) - 1] ?? \"\" : \"\");\n  const valueText = (v: number) =>\n    getValueText ? getValueText(v, max) : v === 0 ? \"Not rated\" : [labelFor(v), `${v} out of ${max}`].filter(Boolean).join(\", \");\n\n  // Map a pointer position to a value, snapping to the step. Gaps belong to the icon on their left.\n  const fromPointer = (e: React.PointerEvent<HTMLDivElement>) => {\n    const rect = e.currentTarget.getBoundingClientRect();\n    const rtl = getComputedStyle(e.currentTarget).direction === \"rtl\";\n    const x = rtl ? rect.right - e.clientX : e.clientX - rect.left;\n    const cell = s.icon + s.gap;\n    const index = clamp(Math.floor(x / cell), 0, max - 1);\n    const within = clamp((x - index * cell) / s.icon, 0, 1);\n    return index + (step === 0.5 && within <= 0.5 ? 0.5 : 1);\n  };\n\n  const pop = (v: number) => {\n    if (reduce || !scope.current) return;\n    const stars = Array.from(scope.current.querySelectorAll<HTMLElement>(\"[data-icon]\")).slice(0, Math.ceil(v));\n    stars.forEach((el, i) => {\n      const last = i === stars.length - 1;\n      animate(el, { scale: [1, last ? 1.3 : 1.1, 1] }, { duration: last ? 0.42 : 0.32, times: [0, 0.35, 1], ease: [ease.out, ease.inOut], delay: i * 0.035 });\n    });\n  };\n\n  const commit = (v: number, from: \"pointer\" | \"keyboard\") => {\n    if (clearable && from === \"pointer\" && v === value) {\n      setValue(0);\n      setAnnouncement(\"Rating cleared\");\n      // Keep the cleared state visible until the pointer moves to a different value.\n      muted.current = v;\n      setHover(null);\n      return;\n    }\n    setValue(v);\n    setAnnouncement(\"\");\n    if (from === \"pointer\") pop(v);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    if (!interactive) return;\n    const rtl = getComputedStyle(e.currentTarget).direction === \"rtl\";\n    const map: Record<string, number> = {\n      ArrowRight: rtl ? -step : step,\n      ArrowUp: step,\n      ArrowLeft: rtl ? step : -step,\n      ArrowDown: -step,\n    };\n    let next: number | null = null;\n    if (e.key in map) next = clamp(roundTo(value + map[e.key], step), 0, max);\n    else if (e.key === \"Home\") next = 0;\n    else if (e.key === \"End\") next = max;\n    else if (/^[0-9]$/.test(e.key) && Number(e.key) <= max) next = Number(e.key);\n    else if ((e.key === \"Backspace\" || e.key === \"Delete\") && clearable) next = 0;\n    if (next === null) return;\n    // The key is ours: keep page-level single-key shortcuts (digits especially) from also firing.\n    e.preventDefault();\n    e.stopPropagation();\n    setHover(null);\n    commit(next, \"keyboard\");\n  };\n\n  const icons = (\n    <div\n      ref={scope}\n      className=\"relative flex items-center after:absolute after:-inset-y-3 after:inset-x-0 after:content-['']\"\n      style={{ gap: s.gap }}\n    >\n      {Array.from({ length: max }, (_, i) => {\n        const fill = clamp(shown - i, 0, 1);\n        return (\n          <span key={i} data-icon=\"\" className=\"relative block shrink-0\" style={{ width: s.icon, height: s.icon }}>\n            <span className=\"absolute inset-0 text-fg-4\">{icon ?? <StarIcon />}</span>\n            <span\n              className={cn(\n                \"absolute inset-0 transition-[clip-path,color] duration-100 ease-out motion-reduce:transition-none\",\n                previewing ? \"text-fg-2\" : \"text-fg\",\n              )}\n              style={{ clipPath: `inset(0 ${(1 - fill) * 100}% 0 0)` }}\n            >\n              {icon ?? <StarIcon />}\n            </span>\n          </span>\n        );\n      })}\n    </div>\n  );\n\n  const label = labels ? (\n    // Every label shares one grid cell, so the row is as wide as the longest and never shifts.\n    <span aria-hidden className={cn(\"grid select-none overflow-hidden py-0.5\", s.text)}>\n      {[placeholder, ...labels].map((l, i) => (\n        <span key={i} className=\"invisible col-start-1 row-start-1 whitespace-nowrap\">\n          {l || \" \"}\n        </span>\n      ))}\n      <AnimatePresence initial={false}>\n        <motion.span\n          key={labelFor(shown) || \"none\"}\n          className={cn(\n            \"col-start-1 row-start-1 whitespace-nowrap transition-colors duration-100\",\n            shown === 0 ? \"text-fg-3\" : previewing ? \"text-fg-2\" : \"text-fg\",\n          )}\n          initial={reduce ? { opacity: 0 } : { opacity: 0, y: 5, filter: \"blur(2px)\" }}\n          animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n          exit={reduce ? { opacity: 0 } : { opacity: 0, y: -5, filter: \"blur(2px)\", transition: { duration: 0.1 } }}\n          transition={{ duration: reduce ? 0.12 : 0.18, ease: ease.out }}\n        >\n          {labelFor(shown) || placeholder || \" \"}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  ) : null;\n\n  if (readOnly) {\n    return (\n      <div\n        role=\"img\"\n        aria-label={ariaLabel ? `${ariaLabel}: ${valueText(value)}` : valueText(value)}\n        // aria-labelledby wins over aria-label, so point it at the value too or the number is lost.\n        aria-labelledby={ariaLabelledBy ? `${ariaLabelledBy} ${valueId}` : undefined}\n        data-readonly=\"\"\n        data-size={size}\n        className={cn(\"inline-flex items-center gap-2\", className)}\n        {...rest}\n      >\n        {icons}\n        {label}\n        {ariaLabelledBy && (\n          <span id={valueId} hidden>\n            {valueText(value)}\n          </span>\n        )}\n      </div>\n    );\n  }\n\n  return (\n    <div\n      data-size={size}\n      data-disabled={disabled ? \"\" : undefined}\n      className={cn(\"inline-flex items-center gap-2.5 data-disabled:opacity-50\", className)}\n      {...rest}\n    >\n      <div\n        role=\"slider\"\n        tabIndex={disabled ? -1 : 0}\n        aria-label={ariaLabel}\n        aria-labelledby={ariaLabelledBy}\n        aria-valuemin={0}\n        aria-valuemax={max}\n        aria-valuenow={value}\n        aria-valuetext={valueText(value)}\n        aria-disabled={disabled || undefined}\n        data-disabled={disabled ? \"\" : undefined}\n        data-previewing={previewing ? \"\" : undefined}\n        onKeyDown={onKeyDown}\n        onPointerDown={(e) => {\n          if (!interactive || e.button !== 0) return;\n          e.currentTarget.setPointerCapture(e.pointerId);\n          press.current = { x: e.clientX, moved: false };\n          muted.current = null;\n          setHover(fromPointer(e));\n        }}\n        onPointerMove={(e) => {\n          if (!interactive) return;\n          const v = fromPointer(e);\n          if (press.current && Math.abs(e.clientX - press.current.x) > 4) press.current.moved = true;\n          if (muted.current !== null) {\n            if (muted.current === v) return;\n            muted.current = null;\n          }\n          // Mouse previews on hover; touch and pen preview only while pressed, so a scrub works.\n          if (press.current || e.pointerType === \"mouse\") setHover(v);\n        }}\n        onPointerUp={(e) => {\n          if (!press.current) return;\n          const v = fromPointer(e);\n          const scrubbed = press.current.moved;\n          press.current = null;\n          // A scrub that ends on the current value keeps it; only a tap clears.\n          if (scrubbed && v === value) return setHover(e.pointerType === \"mouse\" ? v : null);\n          commit(v, \"pointer\");\n          if (e.pointerType !== \"mouse\") setHover(null);\n        }}\n        onPointerCancel={() => {\n          press.current = null;\n          setHover(null);\n        }}\n        onPointerLeave={(e) => {\n          if (e.pointerType === \"mouse\" && !press.current) {\n            muted.current = null;\n            setHover(null);\n          }\n        }}\n        className={cn(\n          \"relative touch-pan-y select-none rounded-md\",\n          \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          interactive && \"cursor-pointer\",\n          disabled && \"pointer-events-none\",\n        )}\n      >\n        {icons}\n      </div>\n      {label}\n      {name && <input type=\"hidden\" name={name} value={value} disabled={disabled} />}\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/rating"
}
