{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"ui-rating","type":"registry:component","title":"Rating","description":"Star rating input and display primitive.","version":"1.0.0","status":"ga","files":[{"path":"src/components/ui/rating.tsx","type":"registry:component","content":"'use client'\n\nimport * as React from 'react'\n\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { StarIcon, type LucideProps } from 'lucide-react'\n\nimport { cn } from '@/lib/utils'\n\n// Variants\nconst ratingVariants = cva('transition-colors', {\n  variants: {\n    variant: {\n      default: 'text-foreground fill-current',\n      destructive: 'text-destructive fill-current',\n      outline: 'text-muted-foreground fill-transparent stroke-current',\n      secondary: 'text-secondary-foreground fill-current',\n      yellow: 'fill-current text-amber-600 dark:text-amber-400'\n    }\n  },\n  defaultVariants: {\n    variant: 'default'\n  }\n})\n\n// Constants\nconst RATING_DEFAULTS = {\n  precision: 1,\n  maxStars: 5,\n  size: 20,\n  variant: 'default' as const,\n  icon: <StarIcon />\n} as const\n\n// Types\ninterface RatingItemProps extends React.ComponentProps<'label'> {\n  variant?: VariantProps<typeof ratingVariants>['variant']\n  size: number\n  value: number\n  hoveredValue: number | null\n  point: number\n  name: string\n  readOnly?: boolean\n  disabled?: boolean\n  precision: number\n  Icon: React.ReactElement<LucideProps>\n  onMouseLeave: React.MouseEventHandler<HTMLLabelElement>\n  onValueHover: (value: number) => void\n  onValueChange?: (value: number) => void\n}\n\ninterface RatingProps extends React.ComponentProps<'div'> {\n  value?: number\n  defaultValue?: number\n  name?: string\n  max?: number\n  size?: number\n  icon?: React.ReactElement<LucideProps>\n  variant?: VariantProps<typeof ratingVariants>['variant']\n  readOnly?: boolean\n  disabled?: boolean\n  precision?: number\n  onValueChange?: (value: number) => void\n  onValueHover?: (value: number) => void\n}\n\n// Rating Item Component\nfunction RatingItem({\n  size,\n  variant = 'default',\n  value,\n  point,\n  hoveredValue,\n  name,\n  readOnly = false,\n  disabled = false,\n  precision,\n  Icon,\n  onMouseLeave,\n  onValueChange,\n  onValueHover,\n  className,\n  ...props\n}: RatingItemProps) {\n  const Comp = readOnly ? 'span' : 'label'\n  const id = React.useId()\n  const ratingIconId = `rating-icon-${id}`\n  const isInteractive = !readOnly && !disabled\n  const partialPoint = point % 1\n  const isPartialPoint = partialPoint !== 0\n  const shouldShowFilled = (hoveredValue || value) >= point\n  const partialPointWidth = isPartialPoint && shouldShowFilled ? `${partialPoint * 100}%` : undefined\n\n  const icons = React.useMemo(() => {\n    const emptyIcon = React.cloneElement(Icon, {\n      size,\n      className: cn(\n        'fill-muted-foreground/20 stroke-muted-foreground/10',\n        variant === 'yellow' && 'fill-amber-600/30 stroke-amber-600/10 dark:fill-amber-400/30 dark:stroke-amber-400/10'\n      ),\n      'aria-hidden': 'true'\n    })\n\n    const fullIcon = React.cloneElement(Icon, {\n      size,\n      className: cn(ratingVariants({ variant })),\n      'aria-hidden': 'true'\n    })\n\n    return { emptyIcon, fullIcon }\n  }, [Icon, size, variant])\n\n  const getRatingPoint = React.useCallback(\n    (event: React.MouseEvent<HTMLLabelElement>) => {\n      const { left, width } = event.currentTarget.getBoundingClientRect()\n\n      if (width === 0 || precision <= 0 || precision > 1) return 0\n      const x = event.clientX - left\n      const fillRatio = x / width\n      const base = Math.ceil(point) - 1\n\n      return base + Math.ceil(fillRatio / precision) * precision\n    },\n    [precision, point]\n  )\n\n  const handleMouseMove = React.useCallback(\n    (event: React.MouseEvent<HTMLLabelElement>) => {\n      if (!isInteractive) return\n      onValueHover(getRatingPoint(event))\n    },\n    [isInteractive, onValueHover, getRatingPoint]\n  )\n\n  const handleClick = React.useCallback(\n    (event: React.MouseEvent<HTMLLabelElement>) => {\n      if (!isInteractive) return\n      const newPoint = getRatingPoint(event)\n\n      onValueHover(0)\n      onValueChange?.(newPoint === value ? 0 : newPoint)\n\n      // Prevent focus on click by blurring the element\n      event.currentTarget.blur()\n    },\n    [isInteractive, value, onValueChange, onValueHover, getRatingPoint]\n  )\n\n  return (\n    <>\n      <Comp\n        data-slot='rating-item'\n        htmlFor={readOnly ? undefined : `${ratingIconId}-${point}`}\n        aria-label={`${point} Stars`}\n        onClick={!readOnly ? handleClick : undefined}\n        onMouseMove={!readOnly ? handleMouseMove : undefined}\n        onMouseLeave={!readOnly ? onMouseLeave : undefined}\n        data-disabled={disabled}\n        data-readonly={readOnly}\n        data-filled={shouldShowFilled}\n        className={cn(\n          '[&_svg]:pointer-events-none',\n          isPartialPoint && 'pointer-events-none absolute top-0 left-0 overflow-hidden',\n          isInteractive && 'cursor-pointer hover:scale-105',\n          disabled && 'cursor-not-allowed opacity-50',\n          className\n        )}\n        style={{ width: partialPointWidth }}\n        {...props}\n      >\n        {!isPartialPoint && !shouldShowFilled && icons.emptyIcon}\n        {shouldShowFilled && icons.fullIcon}\n      </Comp>\n      {!readOnly && (\n        <input\n          type='radio'\n          id={`${ratingIconId}-${point}`}\n          name={name}\n          value={point}\n          className='sr-only'\n          tabIndex={-1}\n          data-slot='rating-input'\n        />\n      )}\n    </>\n  )\n}\n\n// Rating Component\nfunction Rating({\n  value: controlledValue,\n  defaultValue = 0,\n  name,\n  max = RATING_DEFAULTS.maxStars,\n  size = RATING_DEFAULTS.size,\n  icon: Icon = RATING_DEFAULTS.icon,\n  variant = RATING_DEFAULTS.variant,\n  className,\n  readOnly = false,\n  disabled = false,\n  precision = RATING_DEFAULTS.precision,\n  onValueChange,\n  onValueHover,\n  ...props\n}: RatingProps) {\n  const id = React.useId()\n  const ratingName = name ?? `rating-${id}`\n  const [internalValue, setInternalValue] = React.useState<number>(defaultValue)\n  const [hoveredValue, setHoveredValue] = React.useState<number>(0)\n  const isControlled = controlledValue !== undefined\n  const value = isControlled ? controlledValue : internalValue\n  const isInteractive = !readOnly && !disabled\n\n  const handleValueChange = React.useCallback(\n    (newValue: number) => {\n      if (!isControlled) {\n        setInternalValue(newValue)\n      }\n\n      onValueChange?.(newValue)\n    },\n    [isControlled, onValueChange]\n  )\n\n  const handleValueHover = React.useCallback(\n    (point: number) => {\n      setHoveredValue(point)\n      onValueHover?.(point)\n    },\n    [onValueHover]\n  )\n\n  const handleKeyDown = React.useCallback(\n    (event: React.KeyboardEvent<HTMLDivElement>) => {\n      if (!isInteractive) return\n\n      switch (event.key) {\n        case 'ArrowRight':\n        case 'ArrowUp':\n          event.preventDefault()\n\n          if (value + precision > max) {\n            handleValueChange(0)\n          } else {\n            handleValueChange(value + precision)\n          }\n\n          break\n        case 'ArrowLeft':\n        case 'ArrowDown':\n          event.preventDefault()\n\n          if (value - precision < 0) {\n            handleValueChange(max)\n          } else {\n            handleValueChange(value - precision)\n          }\n\n          break\n        case ' ':\n        case 'Enter':\n          event.preventDefault()\n\n          // If no rating is set, set to first step, otherwise clear rating\n          if (value === 0) {\n            handleValueChange(precision)\n          } else {\n            handleValueChange(0)\n          }\n\n          break\n        case 'Home':\n          event.preventDefault()\n          handleValueChange(precision)\n\n          break\n        case 'End':\n          event.preventDefault()\n          handleValueChange(max)\n\n          break\n        default:\n          break\n      }\n    },\n    [isInteractive, value, max, precision, handleValueChange]\n  )\n\n  const handleMouseDown = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {\n    // Prevent focus on mouse click\n    event.preventDefault()\n  }, [])\n\n  const stars = React.useMemo(() => {\n    if (precision <= 0 || precision > 1) {\n      console.warn('Rating: precision must be greater than 0 and less than or equal to 1')\n\n      return []\n    }\n\n    return Array.from({ length: max }, (_, index) => ({\n      key: index,\n      points: Array.from({ length: Math.floor(1 / precision) }, (_, i) => index + precision * (i + 1))\n    }))\n  }, [max, precision])\n\n  return (\n    <div\n      data-slot='rating'\n      role={!readOnly ? 'radiogroup' : 'img'}\n      onKeyDown={!readOnly ? handleKeyDown : undefined}\n      onMouseDown={!readOnly ? handleMouseDown : undefined}\n      tabIndex={!readOnly && !disabled ? 0 : undefined}\n      data-disabled={disabled}\n      data-readonly={readOnly}\n      className={cn(\n        'focus-visible:ring-ring/50 flex gap-px focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',\n        disabled && 'opacity-50',\n        className\n      )}\n      aria-label={readOnly ? `${value} stars` : 'Rating'}\n      aria-valuemin={0}\n      aria-valuemax={max}\n      aria-valuenow={value}\n      aria-valuetext={`${value} of ${max} stars`}\n      {...props}\n    >\n      {stars.map(({ key, points }) => (\n        <span\n          key={key}\n          data-slot='rating-star'\n          className={cn(\n            'relative',\n            isInteractive && 'transition-transform hover:scale-110',\n            disabled && 'cursor-not-allowed'\n          )}\n          aria-disabled={disabled}\n          aria-hidden={readOnly}\n        >\n          {points.map(point => (\n            <RatingItem\n              key={point}\n              name={ratingName}\n              disabled={disabled}\n              hoveredValue={hoveredValue}\n              point={point}\n              precision={precision}\n              readOnly={readOnly}\n              size={size}\n              value={value}\n              variant={variant}\n              Icon={Icon}\n              onMouseLeave={() => setHoveredValue(0)}\n              onValueHover={handleValueHover}\n              onValueChange={handleValueChange}\n            />\n          ))}\n        </span>\n      ))}\n    </div>\n  )\n}\n\nexport { Rating, ratingVariants, type RatingProps }\n"}]}