{
  "$schema": "https://abackui.com/schema/registry-item.json",
  "name": "rating",
  "type": "registry:ui",
  "title": "Rating",
  "description": "A star rating built on native radios, with a read-only display for averages.",
  "dependencies": [
    "@tabler/icons-react"
  ],
  "registryDependencies": [
    "https://abackui.com/r/utils.json"
  ],
  "categories": [
    "Forms"
  ],
  "docs": "https://abackui.com/docs/components/rating",
  "meta": {
    "status": "beta",
    "usedIn": [
      "aback-tools",
      "aback-launch"
    ]
  },
  "files": [
    {
      "path": "components/ui/rating.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { IconStar, IconStarFilled } from \"@tabler/icons-react\";\n\nimport { cn } from \"@/lib/utils\";\n\ntype RatingSize = \"sm\" | \"default\" | \"lg\";\n\nconst iconSize: Record<RatingSize, string> = {\n  sm: \"size-4\",\n  default: \"size-5\",\n  lg: \"size-6\",\n};\n\nfunction defaultItemLabel(value: number) {\n  return `${value} ${value === 1 ? \"star\" : \"stars\"}`;\n}\n\nfunction Star({ fill, size }: { fill: number; size: RatingSize }) {\n  // A partial fill clips the filled glyph over the outline, so read-only\n  // averages such as 4.5 render without a second icon set.\n  const percent = Math.round(Math.min(Math.max(fill, 0), 1) * 100);\n  return (\n    <span aria-hidden=\"true\" className={cn(\"relative inline-flex shrink-0\", iconSize[size])}>\n      <IconStar className=\"size-full text-muted-foreground\" />\n      {percent > 0 ? (\n        <span className=\"absolute inset-y-0 left-0 overflow-hidden\" style={{ width: `${percent}%` }}>\n          <IconStarFilled className={cn(\"text-warning\", iconSize[size])} />\n        </span>\n      ) : null}\n    </span>\n  );\n}\n\nexport interface RatingProps\n  extends Omit<React.ComponentProps<\"div\">, \"onChange\" | \"defaultValue\" | \"dir\"> {\n  /** Controlled value. 0 means no rating. */\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number) => void;\n  /** Number of stars. */\n  max?: number;\n  /** Form field name. The checked radio submits its value with the form. */\n  name?: string;\n  /**\n   * Display a value instead of collecting one. Accepts fractions such as 4.5\n   * and renders a single image with a text alternative.\n   */\n  readOnly?: boolean;\n  disabled?: boolean;\n  /** Let a second press on the selected star clear the rating. */\n  clearable?: boolean;\n  size?: RatingSize;\n  /** Accessible name for each option. Defaults to \"3 stars\". */\n  getItemLabel?: (value: number, max: number) => string;\n  /** Text alternative in read-only mode. Defaults to \"Rated 4.5 out of 5\". */\n  getValueLabel?: (value: number, max: number) => string;\n}\n\n/**\n * A star rating. Interactive ratings are a native radio group, so arrow keys,\n * Tab, and form submission work without custom key handling. Name the group\n * with `aria-label` or `aria-labelledby`.\n */\nfunction Rating({\n  value: valueProp,\n  defaultValue = 0,\n  onValueChange,\n  max = 5,\n  name,\n  readOnly = false,\n  disabled = false,\n  clearable = false,\n  size = \"default\",\n  getItemLabel = defaultItemLabel,\n  getValueLabel = (value, total) => `Rated ${value} out of ${total}`,\n  className,\n  onMouseLeave,\n  ...props\n}: RatingProps) {\n  const generatedName = React.useId();\n  const [uncontrolled, setUncontrolled] = React.useState(defaultValue);\n  const [hovered, setHovered] = React.useState<number | null>(null);\n  const value = valueProp ?? uncontrolled;\n  const stars = Array.from({ length: max }, (_, index) => index + 1);\n\n  if (readOnly) {\n    return (\n      <div\n        role=\"img\"\n        aria-label={getValueLabel(value, max)}\n        data-slot=\"rating\"\n        data-readonly=\"\"\n        className={cn(\"inline-flex items-center gap-0.5\", className)}\n        {...props}\n      >\n        {stars.map((star) => (\n          <Star key={star} fill={value - (star - 1)} size={size} />\n        ))}\n      </div>\n    );\n  }\n\n  function commit(next: number) {\n    if (valueProp === undefined) setUncontrolled(next);\n    onValueChange?.(next);\n  }\n\n  const shown = hovered ?? value;\n\n  return (\n    <div\n      role=\"radiogroup\"\n      data-slot=\"rating\"\n      data-disabled={disabled ? \"\" : undefined}\n      aria-disabled={disabled || undefined}\n      className={cn(\"inline-flex items-center gap-0.5\", disabled && \"opacity-50\", className)}\n      onMouseLeave={(event) => {\n        onMouseLeave?.(event);\n        setHovered(null);\n      }}\n      {...props}\n    >\n      {stars.map((star) => (\n        <label\n          key={star}\n          data-slot=\"rating-item\"\n          data-state={star <= value ? \"on\" : \"off\"}\n          onMouseEnter={() => {\n            if (!disabled) setHovered(star);\n          }}\n          className={cn(\n            \"relative inline-flex p-0.5 transition-transform duration-150\",\n            \"has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-focus-ring\",\n            disabled ? \"cursor-not-allowed\" : \"cursor-pointer hover:scale-110 motion-reduce:hover:scale-100\",\n          )}\n        >\n          <input\n            type=\"radio\"\n            className=\"sr-only\"\n            name={name ?? generatedName}\n            value={star}\n            checked={value === star}\n            disabled={disabled}\n            onChange={() => commit(star)}\n            onClick={() => {\n              if (clearable && value === star) commit(0);\n            }}\n          />\n          <Star fill={star <= shown ? 1 : 0} size={size} />\n          <span className=\"sr-only\">{getItemLabel(star, max)}</span>\n        </label>\n      ))}\n    </div>\n  );\n}\n\nexport { Rating };\n",
      "type": "registry:ui",
      "target": "components/ui/rating.tsx"
    }
  ]
}
