{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"rating","type":"registry:ui","title":"Rating","description":"A star rating that works with the keyboard and in forms, and shows an average.","dependencies":["lucide-react"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/field.json"],"files":[{"path":"registry/dashral/rating.tsx","type":"registry:file","target":"components/dashral/rating.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { StarIcon } from \"lucide-react\"\n\nimport { useLocale } from \"./lib/locale\"\nimport { cn } from \"./lib/utils\"\nimport { FieldWrapper, splitFieldProps, useFieldControl, type FieldClassNames, type FieldControlProps, type FieldOwnProps } from \"./field\"\n\ntype ControlColor = \"primary\" | \"neutral\" | \"info\" | \"success\" | \"warning\" | \"destructive\" | \"highlight\"\n\n// The filled icons. Every class is written out in full so Tailwind finds it.\nconst fillColors: Record<ControlColor, string> = {\n  primary: \"text-primary\",\n  neutral: \"text-foreground\",\n  info: \"text-info\",\n  success: \"text-success\",\n  warning: \"text-warning\",\n  destructive: \"text-destructive\",\n  highlight: \"text-highlight\",\n}\n\nconst sizes = { sm: \"size-4\", default: \"size-5\", lg: \"size-7\" }\n\ntype RatingProps = FieldOwnProps & {\n  /** The rating, controlled: 0 for none. With readOnly it can be a fraction (4.5). */\n  value?: number\n  defaultValue?: number\n  onValueChange?: (value: number) => void\n  /** How many icons. */\n  max?: number\n  /** Shows the value without taking input, a fraction filling part of an icon: a product's average. */\n  readOnly?: boolean\n  disabled?: boolean\n  required?: boolean\n  /** Picking the current rating again clears it. On by default. */\n  clearable?: boolean\n  /** The form field's name: the rating is sent as a number, nothing when it's 0. */\n  name?: string\n  id?: string\n  /** A word for each value, lowest first (\"Poor\" … \"Excellent\"): each icon's name for screen readers, in place of \"3 stars\". */\n  labels?: string[]\n  /** With labels: the word for the hovered or picked value, beside the icons. */\n  showLabel?: boolean\n  /** In place of the star, e.g. a heart; it's filled in the color. */\n  icon?: React.ReactElement<{ className?: string; fill?: string }>\n  /** The filled icons' color. Warning (amber) by default, as stars usually are. */\n  color?: ControlColor\n  size?: \"sm\" | \"default\" | \"lg\"\n  \"aria-label\"?: string\n  \"aria-invalid\"?: React.AriaAttributes[\"aria-invalid\"]\n  className?: string\n  classNames?: FieldClassNames & { item?: string; valueLabel?: string }\n}\n\n/**\n * A rating from 1 to max, in one tag: `<Rating label=\"Your rating\" onValueChange={setStars} />`. Built from radio\n * buttons, so the arrow keys pick (in reading order, right to left in Arabic), a form sends it, and screen readers say\n * \"4 stars, 4 of 5\". Hovering previews a value. readOnly shows an average, fractions included.\n */\nfunction Rating(props: RatingProps) {\n  const [field, control] = splitFieldProps(props)\n  return (\n    <FieldWrapper {...field} group>\n      <RatingControl {...control} />\n    </FieldWrapper>\n  )\n}\n\nfunction RatingControl({\n  value,\n  defaultValue = 0,\n  onValueChange,\n  max = 5,\n  readOnly = false,\n  clearable = true,\n  name,\n  labels,\n  showLabel = false,\n  icon,\n  color = \"warning\",\n  size = \"default\",\n  className,\n  classNames,\n  ...rest\n}: Omit<RatingProps, keyof FieldOwnProps>) {\n  const { t, locale } = useLocale()\n  const { id, disabled, required, ...aria } = useFieldControl<FieldControlProps>({\n    id: rest.id,\n    disabled: rest.disabled,\n    required: rest.required,\n    \"aria-invalid\": rest[\"aria-invalid\"],\n  })\n  const generatedName = React.useId()\n  const [internal, setInternal] = React.useState(defaultValue)\n  const [hover, setHover] = React.useState<number | null>(null)\n  const current = value ?? internal\n  const shown = hover ?? current\n  const sizeClass = sizes[size]\n  const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 })\n  const ratingOf = t(\"ratingOf\", { value: number.format(current), max })\n\n  function set(next: number) {\n    if (value === undefined) setInternal(next)\n    onValueChange?.(next)\n  }\n\n  const draw = (fill: boolean) => {\n    const element = icon ?? <StarIcon />\n    return React.cloneElement(element, {\n      className: cn(sizeClass, \"shrink-0\", fill ? fillColors[color] : \"text-foreground/15\", element.props.className),\n      fill: \"currentColor\",\n    })\n  }\n\n  if (readOnly) {\n    return (\n      <div\n        id={id}\n        role=\"img\"\n        data-slot=\"rating\"\n        aria-label={rest[\"aria-label\"] ?? ratingOf}\n        aria-describedby={aria[\"aria-describedby\"]}\n        className={cn(\"inline-flex items-center gap-0.5\", className)}\n      >\n        {Array.from({ length: max }, (_, i) => {\n          // How much of this icon is filled: all, none, or the fraction's share (from the start, so the right in Arabic).\n          const share = Math.min(1, Math.max(0, current - i))\n          return (\n            <span key={i} aria-hidden className={cn(\"relative inline-flex\", classNames?.item)}>\n              {draw(false)}\n              {share > 0 && (\n                <span className=\"absolute inset-y-0 start-0 overflow-hidden\" style={{ width: `${share * 100}%` }}>\n                  {draw(true)}\n                </span>\n              )}\n            </span>\n          )\n        })}\n      </div>\n    )\n  }\n\n  const word = labels && shown > 0 ? labels[shown - 1] : undefined\n\n  return (\n    <div className={cn(\"inline-flex items-center gap-3\", className)}>\n      <div\n        id={id}\n        role=\"radiogroup\"\n        data-slot=\"rating\"\n        aria-label={rest[\"aria-label\"] ?? (aria[\"aria-labelledby\"] ? undefined : t(\"rating\"))}\n        aria-labelledby={aria[\"aria-labelledby\"]}\n        aria-describedby={aria[\"aria-describedby\"]}\n        aria-invalid={aria[\"aria-invalid\"]}\n        aria-required={required || undefined}\n        aria-disabled={disabled || undefined}\n        onPointerLeave={() => setHover(null)}\n        className={cn(\"inline-flex items-center\", disabled && \"opacity-50\")}\n      >\n        {Array.from({ length: max }, (_, i) => {\n          const n = i + 1\n          return (\n            <label\n              key={n}\n              onPointerEnter={() => !disabled && setHover(n)}\n              className={cn(\n                \"relative inline-flex cursor-pointer rounded-sm p-0.5 transition-transform duration-(--duration-fast) ease-spring motion-safe:hover:scale-110\",\n                \"has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring/50\",\n                disabled && \"pointer-events-none cursor-not-allowed\",\n                classNames?.item\n              )}\n            >\n              <input\n                type=\"radio\"\n                name={name ?? generatedName}\n                value={n}\n                checked={current === n}\n                disabled={disabled}\n                required={required && n === 1}\n                aria-label={labels?.[i] ?? t(\"ratingStars\", { count: n })}\n                className=\"peer sr-only\"\n                onChange={() => set(n)}\n                // Picking the checked one again doesn't fire change, only click: that's the moment to clear.\n                onClick={() => {\n                  if (clearable && current === n) set(0)\n                }}\n              />\n              {draw(n <= shown)}\n            </label>\n          )\n        })}\n      </div>\n      {showLabel && labels && (\n        <span aria-hidden className={cn(\"min-w-16 text-sm text-muted-foreground\", classNames?.valueLabel)}>\n          {word}\n        </span>\n      )}\n    </div>\n  )\n}\n\nexport { Rating, type RatingProps }\n"}],"cssVars":{"theme":{"color-info":"var(--info)","color-info-foreground":"var(--info-foreground)","color-info-text":"var(--info-text)","color-success":"var(--success)","color-success-foreground":"var(--success-foreground)","color-success-text":"var(--success-text)","color-warning":"var(--warning)","color-warning-foreground":"var(--warning-foreground)","color-warning-text":"var(--warning-text)","color-highlight":"var(--highlight)","color-highlight-foreground":"var(--highlight-foreground)","color-highlight-text":"var(--highlight-text)","ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"info":"oklch(0.5 0.134 242.749)","info-foreground":"oklch(0.985 0 0)","info-text":"oklch(0.443 0.11 240.79)","success":"oklch(0.508 0.118 165.612)","success-foreground":"oklch(0.985 0 0)","success-text":"oklch(0.432 0.095 166.913)","warning":"#f59200","warning-foreground":"oklch(0.3 0.07 60)","warning-text":"#964e00","highlight":"#b8008c","highlight-foreground":"oklch(0.985 0 0)","highlight-text":"#a3007b","duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{"info":"oklch(0.685 0.169 237.323)","info-foreground":"oklch(0.2 0 0)","info-text":"oklch(0.746 0.16 232.661)","success":"oklch(0.696 0.17 162.48)","success-foreground":"oklch(0.2 0.04 160)","success-text":"oklch(0.765 0.177 163.223)","warning":"#f59200","warning-foreground":"oklch(0.25 0.06 60)","warning-text":"#ffba42","highlight":"#ff2ecf","highlight-foreground":"oklch(0.2 0 0)","highlight-text":"#ff75df"}}}