{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "rating",
  "title": "Rating",
  "type": "registry:ui",
  "description": "Star (or custom icon) rating control — pick a value from 1 to N. Read-only mode for displaying review averages, with half-step support.",
  "categories": [
    "form"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/rating/rating.ts",
      "content": "import { LitElement, css, html, nothing, svg } from 'lit'\nimport { styleMap } from 'lit/directives/style-map.js'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\n\ntype Density = 'compact' | 'default' | 'comfortable'\ntype Size = 'x-small' | 'small' | 'medium' | 'large' | 'x-large'\ntype Variant = 'outlined' | 'filled' | 'soft'\n\n// Star path shared by all three icon states.\nconst STAR_PATH = 'M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z'\n\nconst variantClasses: Record<Variant, string> = {\n  outlined: '',\n  filled: 'bg-muted p-1 rounded-lg',\n  soft: 'bg-accent p-1 rounded-lg',\n}\n\n// React applies these as inline styles; they stay runtime style bindings here.\nconst sizeIcon: Record<Size, { width: string; height: string }> = {\n  'x-small': { width: '0.875rem', height: '0.875rem' },\n  small: { width: '1.125rem', height: '1.125rem' },\n  medium: { width: '1.375rem', height: '1.375rem' },\n  large: { width: '1.625rem', height: '1.625rem' },\n  'x-large': { width: '2rem', height: '2rem' },\n}\n\nconst densityPad: Record<Density, string> = {\n  compact: '0',\n  default: '0.0625rem',\n  comfortable: '0.125rem',\n}\n\nlet uid = 0\n\n/**\n * <uip-rating> — the registry Rating as a form-associated web component.\n *\n * Props match React's Rating (attributes are kebab-case): value, max,\n * readonly, disabled, density, color, clearable, hover, item-aria-label, size,\n * show-value, variant, half-increments, tooltips (property: string[];\n * attribute: comma-separated list).\n *\n * React injects a global <style> for the star \"pop\" keyframes and the hover\n * scale. Here both are utilities: the pop is tw-animate-css `animate-in\n * zoom-in-60 fade-in-40` with React's overshooting cubic-bezier (so the scale\n * passes ~1.18 like the keyframes' 60% step), and the hover scale is\n * `enabled:hover:scale-[1.12]`; both are motion-reduce safe.\n *\n * Form-associated: submits `name=value`, `required` fails on 0, resets with\n * the form. Events: `input` + `change` (value on `.value`) and\n * `value-change` (detail: { value }) — React's onValueChange.\n */\nexport class UipRating extends LitElement {\n  static formAssociated = true\n  static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }\n  // :host display can't be a utility class (the host has no template of its own).\n  static styles = [tailwind, css`:host { display: inline-flex; }`]\n\n  static properties = {\n    value: { type: Number },\n    max: { type: Number },\n    readonly: { type: Boolean, reflect: true },\n    disabled: { type: Boolean, reflect: true },\n    required: { type: Boolean, reflect: true },\n    name: { reflect: true },\n    density: { reflect: true },\n    color: {},\n    clearable: { type: Boolean },\n    hover: { type: Boolean },\n    itemAriaLabel: { attribute: 'item-aria-label' },\n    size: { reflect: true },\n    showValue: { type: Boolean, attribute: 'show-value' },\n    variant: { reflect: true },\n    halfIncrements: { type: Boolean, attribute: 'half-increments' },\n    tooltips: {\n      converter: {\n        fromAttribute: (v: string | null) => (v ? v.split(',').map((s) => s.trim()) : undefined),\n      },\n    },\n  }\n\n  value = 0\n  max = 5\n  readonly = false\n  disabled = false\n  required = false\n  name?: string\n  density: Density = 'default'\n  color = 'var(--warning)'\n  clearable = false\n  hover = false\n  itemAriaLabel = 'rating'\n  size: Size = 'medium'\n  showValue = false\n  variant: Variant = 'outlined'\n  halfIncrements = false\n  tooltips?: string[]\n\n  private defaultValue = 0\n  private readonly uidBase = `uip-rating-${++uid}`\n  private internals = this.attachInternals()\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'rating')\n    this.defaultValue = this.value\n  }\n\n  protected willUpdate(changed: Map<string, unknown>) {\n    if (changed.has('value') || changed.has('required')) {\n      this.internals.setFormValue(this.value ? String(this.value) : null)\n      if (this.required && !this.value) {\n        this.internals.setValidity({ valueMissing: true }, 'Please select a rating.', this.focusTarget ?? undefined)\n      } else {\n        this.internals.setValidity({})\n      }\n    }\n  }\n\n  formResetCallback() {\n    this.value = this.defaultValue\n  }\n  formDisabledCallback(disabled: boolean) {\n    this.disabled = disabled\n  }\n\n  private get focusTarget() {\n    return this.renderRoot?.querySelector<HTMLButtonElement>('button[tabindex=\"0\"]')\n  }\n\n  private emit(next: number) {\n    if (next === this.value) return\n    this.value = next\n    this.dispatchEvent(new Event('input', { bubbles: true, composed: true }))\n    this.dispatchEvent(new Event('change', { bubbles: true, composed: true }))\n    this.dispatchEvent(new CustomEvent('value-change', { detail: { value: next }, bubbles: true, composed: true }))\n    // Keep focus on the (new) roving star after keyboard moves.\n    this.updateComplete.then(() => {\n      if (this.renderRoot.contains((this.renderRoot as ShadowRoot).activeElement)) this.focusTarget?.focus()\n    })\n  }\n\n  private resolveClickValue(e: MouseEvent, star: number) {\n    if (!this.halfIncrements) return star\n    const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()\n    const isLeft = e.clientX - rect.left < rect.width / 2\n    const next = isLeft ? star - 0.5 : star\n    return next < 0.5 ? 0.5 : next\n  }\n\n  private handleClick(e: MouseEvent, star: number) {\n    if (this.disabled || this.readonly) return\n    const next = this.resolveClickValue(e, star)\n    if (this.clearable && next === this.value) this.emit(0)\n    else this.emit(next)\n  }\n\n  private handleKeydown(e: KeyboardEvent, star: number) {\n    const current = this.value\n    if (e.key === 'Enter' || e.key === ' ') {\n      e.preventDefault()\n      if (this.disabled || this.readonly) return\n      if (this.clearable && star === current) this.emit(0)\n      else this.emit(star)\n      return\n    }\n    const step = this.halfIncrements ? 0.5 : 1\n    if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {\n      e.preventDefault()\n      this.emit(Math.min(this.max, current + step))\n    } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {\n      e.preventDefault()\n      this.emit(Math.max(0, current - step))\n    } else if (e.key === 'Home') {\n      e.preventDefault()\n      this.emit(this.halfIncrements ? 0.5 : 1)\n    } else if (e.key === 'End') {\n      e.preventDefault()\n      this.emit(this.max)\n    }\n  }\n\n  private renderStar(n: number) {\n    const current = this.value\n    const iconStyle = sizeIcon[this.size] ?? sizeIcon.medium\n    if (current >= n) {\n      return html`<svg\n        class=\"rating-star-full-icon motion-safe:animate-in motion-safe:zoom-in-60 motion-safe:fade-in-40 motion-safe:duration-[280ms] motion-safe:ease-[cubic-bezier(0.22,1.4,0.36,1)] motion-safe:fill-mode-both motion-safe:delay-[var(--star-delay)]\"\n        viewBox=\"0 0 24 24\"\n        fill=\"currentColor\"\n        aria-hidden=\"true\"\n        style=${styleMap({ ...iconStyle, color: this.color })}\n      >\n        <path d=${STAR_PATH} />\n      </svg>`\n    }\n    if (current >= n - 0.5 && this.halfIncrements) {\n      const halfId = `${this.uidBase}-half-${n}`\n      return html`<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" style=${styleMap({ ...iconStyle, color: this.color })}>\n        ${svg`<defs>\n          <linearGradient id=${halfId}>\n            <stop offset=\"50%\" stop-color=\"currentColor\" />\n            <stop offset=\"50%\" stop-color=\"transparent\" />\n          </linearGradient>\n        </defs>\n        <path d=${STAR_PATH} fill=${`url(#${halfId})`} stroke=\"currentColor\" stroke-width=\"1\" />`}\n      </svg>`\n    }\n    return html`<svg\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      stroke-width=\"1.5\"\n      aria-hidden=\"true\"\n      style=${styleMap({ ...iconStyle, color: 'var(--muted-foreground)' })}\n    >\n      <path d=${STAR_PATH} />\n    </svg>`\n  }\n\n  render() {\n    const current = this.value\n    const max = this.max\n    const focusStar = Math.ceil(current || 1)\n    const inert = this.readonly || this.disabled\n    return html`<div\n      part=\"base\"\n      data-slot=\"rating\"\n      class=${cn(\n        'inline-flex items-center gap-0.5',\n        variantClasses[this.variant],\n        this.disabled && 'cursor-not-allowed opacity-50',\n        this.readonly && 'cursor-default',\n        this.showValue && 'flex items-center gap-1',\n      )}\n      role=\"radiogroup\"\n      aria-valuenow=${current}\n      aria-valuemin=\"0\"\n      aria-valuemax=${max}\n      aria-label=${`Rating: ${current} of ${max}`}\n      aria-required=${this.required ? 'true' : nothing}\n    >\n      ${Array.from({ length: max }, (_, i) => i + 1).map((n) => {\n        const filled = current >= n\n        const tip = this.tooltips?.[n - 1]\n        return html`<button\n          type=\"button\"\n          role=\"radio\"\n          ?disabled=${inert}\n          aria-label=${tip ?? `${this.itemAriaLabel} ${n} of ${max}`}\n          aria-checked=${Math.ceil(current || 0) === n ? 'true' : 'false'}\n          title=${tip ?? nothing}\n          tabindex=${inert ? -1 : focusStar === n ? 0 : -1}\n          style=${styleMap({\n            padding: densityPad[this.density] ?? densityPad.default,\n            outlineColor: this.color,\n            '--star-delay': filled ? `${(n - 1) * 45}ms` : '0ms',\n          })}\n          class=${cn(\n            'inline-flex items-center justify-center border-none bg-none leading-none transition-transform duration-150',\n            'focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none',\n            'enabled:hover:scale-[1.12] motion-reduce:transition-none motion-reduce:enabled:hover:scale-100',\n            this.clearable && 'cursor-pointer',\n            this.hover && 'hover:scale-[1.15]',\n          )}\n          @click=${(e: MouseEvent) => this.handleClick(e, n)}\n          @keydown=${(e: KeyboardEvent) => this.handleKeydown(e, n)}\n        >\n          ${this.renderStar(n)}\n        </button>`\n      })}\n      ${this.showValue ? html`<span class=\"text-foreground ml-2 font-semibold\">${current}</span>` : nothing}\n    </div>`\n  }\n}\n\ncustomElements.get('uip-rating') || customElements.define('uip-rating', UipRating)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-rating': UipRating\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/rating/rating.ts"
    }
  ],
  "dependencies": [
    "lit"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
