{"$schema":"https://ui.logic2b.com/schema/registry-item.json","name":"rating","type":"registry:ui","description":"A star rating input following the radio-group pattern: each step is a radio with aria-checked, arrow keys move the value (Home/End jump to the ends), hover previews, clicking the current value clears it, and readOnly renders a static labelled image. Swap the star for any icon via the icon prop.","dependencies":["lucide-react"],"registryDependencies":["utils"],"files":[{"path":"ui/rating.tsx","type":"registry:ui","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { StarIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * Rating — a star (or custom icon) rating following the radio-group\n * pattern: each star is a radio, arrows move the value, hover previews it.\n * `readOnly` renders a static, labelled image instead.\n */\n\nfunction Rating({\n  className,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  max = 5,\n  readOnly = false,\n  disabled = false,\n  icon,\n  \"aria-label\": ariaLabel = \"Rating\",\n  ...props\n}: Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  value?: number\n  defaultValue?: number\n  onValueChange?: (value: number) => void\n  max?: number\n  readOnly?: boolean\n  disabled?: boolean\n  icon?: React.ReactNode\n}) {\n  const [internal, setInternal] = React.useState(defaultValue ?? 0)\n  const [hovered, setHovered] = React.useState(0)\n  const isControlled = valueProp !== undefined\n  const value = isControlled ? valueProp : internal\n  const setValue = (next: number) => {\n    if (!isControlled) setInternal(next)\n    onValueChange?.(next)\n  }\n\n  const shown = hovered || value\n  const interactive = !readOnly && !disabled\n\n  const star = (n: number, active: boolean) => (\n    <span\n      data-slot=\"rating-icon\"\n      data-state={active ? \"active\" : \"inactive\"}\n      className={cn(\n        \"transition-colors [&_svg]:size-5\",\n        active\n          ? \"text-primary [&_svg]:fill-current\"\n          : \"text-muted-foreground/40\"\n      )}\n    >\n      {icon ?? <StarIcon />}\n    </span>\n  )\n\n  if (readOnly) {\n    return (\n      <div\n        role=\"img\"\n        data-slot=\"rating\"\n        aria-label={`${ariaLabel}: ${value} out of ${max}`}\n        className={cn(\"flex items-center gap-0.5\", className)}\n        {...props}\n      >\n        {Array.from({ length: max }, (_, i) => (\n          <React.Fragment key={i}>{star(i + 1, i + 1 <= value)}</React.Fragment>\n        ))}\n      </div>\n    )\n  }\n\n  return (\n    <div\n      role=\"radiogroup\"\n      data-slot=\"rating\"\n      aria-label={ariaLabel}\n      data-disabled={disabled || undefined}\n      className={cn(\n        \"flex items-center gap-0.5\",\n        disabled && \"pointer-events-none opacity-50\",\n        className\n      )}\n      onMouseLeave={() => setHovered(0)}\n      onKeyDown={(event) => {\n        if (!interactive) return\n        let next: number | null = null\n        if (event.key === \"ArrowRight\" || event.key === \"ArrowUp\")\n          next = Math.min(value + 1, max)\n        if (event.key === \"ArrowLeft\" || event.key === \"ArrowDown\")\n          next = Math.max(value - 1, 0)\n        if (event.key === \"Home\") next = 1\n        if (event.key === \"End\") next = max\n        if (next === null) return\n        event.preventDefault()\n        setValue(next)\n        const radios = event.currentTarget.querySelectorAll<HTMLElement>('[role=\"radio\"]')\n        radios[Math.max(next - 1, 0)]?.focus()\n      }}\n      {...props}\n    >\n      {Array.from({ length: max }, (_, i) => {\n        const n = i + 1\n        return (\n          <button\n            key={n}\n            type=\"button\"\n            role=\"radio\"\n            data-slot=\"rating-item\"\n            aria-checked={value === n}\n            aria-label={`${n} out of ${max}`}\n            disabled={disabled}\n            tabIndex={value === n || (value === 0 && n === 1) ? 0 : -1}\n            className=\"focus-visible:ring-ring/50 rounded-sm outline-none focus-visible:ring-[3px]\"\n            onClick={() => setValue(value === n ? 0 : n)}\n            onMouseEnter={() => setHovered(n)}\n          >\n            {star(n, n <= shown)}\n          </button>\n        )\n      })}\n    </div>\n  )\n}\n\nexport { Rating }\n"}],"categories":["form"],"accessibility":{"support":"authored","pattern":"radio group","keyboard":[{"keys":["ArrowUp","ArrowDown","ArrowLeft","ArrowRight"],"action":"Move within the composite control."},{"keys":["Home","End"],"action":"Move to the minimum or maximum rating."},{"keys":["Space"],"action":"Select the focused rating."}],"aria":["Interactive stars expose radiogroup/radio and aria-checked; read-only mode is a labelled image."],"consumer":["Provide a contextual label and visible text for the meaning of the scale."]},"api":{"source":"src/ui/rating.tsx","exports":[{"name":"Rating","kind":"component","description":"Rating — a star (or custom icon) rating following the radio-group pattern: each star is a radio, arrows move the value, hover previews it. `readOnly` renders a static, labelled image instead.","propsType":"Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & { value?: number defaultValue?: number onValueChange?: (value: number) => void max?: number readOnly?: boolean disabled?: boolean icon?: React.ReactNode }","props":[{"name":"value","type":"number","required":false},{"name":"defaultValue","type":"number","required":false},{"name":"onValueChange","type":"(value: number) => void","required":false},{"name":"max","type":"number","required":false,"default":"5"},{"name":"readOnly","type":"boolean","required":false,"default":"false"},{"name":"disabled","type":"boolean","required":false,"default":"false"},{"name":"icon","type":"React.ReactNode","required":false},{"name":"aria-label","type":"string","required":false,"default":"\"Rating\""}]}]}}