{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"rating","type":"registry:component","title":"Rating","description":"Star rating radiogroup with hover preview fill, a staggered spring bounce on pick, optional half-star precision from the pointer position, click-to-clear, arrow/Home/End keyboard control, custom icons and a read-only display mode; controlled/uncontrolled, reduced-motion safe.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/rating.tsx","type":"registry:component","target":"@components/motion/rating.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/rating\n\nimport { Star } from \"lucide-react\";\nimport { animate, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type MouseEvent,\n  type PointerEvent,\n  type ReactNode,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport { SPRING_PRESS } from \"@/lib/ease\";\nimport { isHoveringPointer } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface RatingProps {\n  /** Controlled rating. 0 means unrated. */\n  value?: number;\n  /** Uncontrolled initial rating. Default 0. */\n  defaultValue?: number;\n  onChange?: (value: number) => void;\n  /** Number of icons. Default 5. */\n  max?: number;\n  /** 1 for whole icons, 0.5 for halves (picked by pointer x within the icon). */\n  precision?: 1 | 0.5;\n  /** Display only: no hover, click or keyboard. */\n  readOnly?: boolean;\n  disabled?: boolean;\n  /** Clicking the current value clears it to 0. Default true. */\n  clearable?: boolean;\n  /** Custom icon, rendered for both the empty and filled layer. Defaults to a star. */\n  icon?: ReactNode;\n  /** Icon box size in px. Default 24. */\n  size?: number;\n  /** Accessible name for the group. Default \"Rating\". */\n  \"aria-label\"?: string;\n  /** Builds each option's accessible label. */\n  getLabel?: (value: number, max: number) => string;\n  className?: string;\n  /** Classes for the filled layer — set the fill color here. */\n  filledClassName?: string;\n  /** Classes for the empty layer. */\n  emptyClassName?: string;\n}\n\nconst defaultLabel = (value: number, max: number) =>\n  `${value} ${value === 1 ? \"star\" : \"stars\"} out of ${max}`;\n\nconst round = (n: number, precision: number) =>\n  Math.round(n / precision) * precision;\n\nexport function Rating({\n  value: valueProp,\n  defaultValue = 0,\n  onChange,\n  max = 5,\n  precision = 1,\n  readOnly = false,\n  disabled = false,\n  clearable = true,\n  icon,\n  size = 24,\n  \"aria-label\": ariaLabel = \"Rating\",\n  getLabel = defaultLabel,\n  className,\n  filledClassName,\n  emptyClassName,\n}: RatingProps) {\n  const uid = useId();\n  const reduce = useReducedMotion();\n  const controlled = valueProp !== undefined;\n  const [internal, setInternal] = useState(defaultValue);\n  const value = Math.min(\n    max,\n    Math.max(0, round(controlled ? valueProp : internal, precision)),\n  );\n  const [hover, setHover] = useState<number | null>(null);\n  const itemRefs = useRef<(HTMLElement | null)[]>([]);\n  // Bounce runs on an inner span so it never fights the button's whileTap.\n  const glyphRefs = useRef<(HTMLElement | null)[]>([]);\n\n  const interactive = !readOnly && !disabled;\n  const shown = interactive && hover !== null ? hover : value;\n\n  const iconNode = icon ?? (\n    <Star className=\"size-full\" fill=\"currentColor\" strokeWidth={0} />\n  );\n\n  // A short pop across every icon up to the picked one, staggered outward.\n  const bounce = (upTo: number) => {\n    if (reduce) return;\n    const last = Math.ceil(upTo);\n    for (let i = 0; i < last; i++) {\n      const el = glyphRefs.current[i];\n      if (!el) continue;\n      animate(\n        el,\n        { scale: [i === last - 1 ? 1.3 : 1.12, 1] },\n        { ...SPRING_PRESS, delay: i * 0.025 },\n      );\n    }\n  };\n\n  const commit = (next: number, focus = false) => {\n    const clean = Math.min(max, Math.max(0, round(next, precision)));\n    if (!controlled) setInternal(clean);\n    onChange?.(clean);\n    if (clean > 0) bounce(clean);\n    if (focus) {\n      itemRefs.current[Math.max(Math.ceil(clean) - 1, 0)]?.focus();\n    }\n  };\n\n  const valueAt = (index: number, clientX: number, rect: DOMRect | null) => {\n    if (precision === 0.5 && rect && clientX - rect.left < rect.width / 2) {\n      return index + 0.5;\n    }\n    return index + 1;\n  };\n\n  const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {\n    const floor = clearable ? 0 : precision;\n    const map: Record<string, number> = {\n      ArrowRight: value + precision,\n      ArrowUp: value + precision,\n      ArrowLeft: value - precision,\n      ArrowDown: value - precision,\n      Home: precision,\n      End: max,\n    };\n    if (!(e.key in map)) return;\n    e.preventDefault();\n    const next = Math.min(max, Math.max(floor, map[e.key]));\n    if (next !== value) commit(next, true);\n  };\n\n  if (readOnly) {\n    return (\n      <div\n        role=\"img\"\n        aria-label={`${ariaLabel}: ${getLabel(value, max)}`}\n        className={cn(\"inline-flex items-center gap-1\", className)}\n      >\n        {Array.from({ length: max }, (_, i) => (\n          <Glyph\n            // biome-ignore lint/suspicious/noArrayIndexKey: fixed-length row, never reordered.\n            key={`${uid}-${i}`}\n            fill={Math.min(1, Math.max(0, value - i))}\n            size={size}\n            icon={iconNode}\n            filledClassName={filledClassName}\n            emptyClassName={emptyClassName}\n          />\n        ))}\n      </div>\n    );\n  }\n\n  // Roving tabindex: the checked icon (or the first, when unrated) is the stop.\n  const checkedIndex = value > 0 ? Math.ceil(value) - 1 : -1;\n  const tabStop = checkedIndex >= 0 ? checkedIndex : 0;\n\n  return (\n    <div\n      role=\"radiogroup\"\n      aria-label={ariaLabel}\n      aria-disabled={disabled || undefined}\n      onPointerLeave={() => setHover(null)}\n      className={cn(\n        \"inline-flex items-center gap-1\",\n        disabled && \"opacity-50\",\n        className,\n      )}\n    >\n      {Array.from({ length: max }, (_, i) => {\n        const checked = i === checkedIndex;\n        // The checked radio speaks the exact value (halves included).\n        const optionValue = checked ? value : i + 1;\n        return (\n          <motion.button\n            // biome-ignore lint/suspicious/noArrayIndexKey: fixed-length row, never reordered.\n            key={`${uid}-${i}`}\n            ref={(node) => {\n              itemRefs.current[i] = node;\n            }}\n            type=\"button\"\n            role=\"radio\"\n            aria-checked={checked}\n            aria-label={getLabel(optionValue, max)}\n            tabIndex={tabStop === i ? 0 : -1}\n            disabled={disabled}\n            whileTap={reduce ? undefined : { scale: 0.85 }}\n            transition={SPRING_PRESS}\n            onPointerMove={(e: PointerEvent<HTMLButtonElement>) => {\n              if (!isHoveringPointer(e)) return;\n              setHover(\n                valueAt(i, e.clientX, e.currentTarget.getBoundingClientRect()),\n              );\n            }}\n            onClick={(e: MouseEvent<HTMLButtonElement>) => {\n              // Keyboard activation (detail 0) picks the whole icon.\n              const next =\n                e.detail === 0\n                  ? i + 1\n                  : valueAt(i, e.clientX, e.currentTarget.getBoundingClientRect());\n              setHover(null);\n              if (clearable && next === value) {\n                commit(0);\n                return;\n              }\n              commit(next);\n            }}\n            onKeyDown={onKeyDown}\n            className=\"relative cursor-pointer rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed\"\n          >\n            <span\n              ref={(node) => {\n                glyphRefs.current[i] = node;\n              }}\n              className=\"block\"\n            >\n              <Glyph\n                fill={Math.min(1, Math.max(0, shown - i))}\n                size={size}\n                icon={iconNode}\n                preview={hover !== null}\n                filledClassName={filledClassName}\n                emptyClassName={emptyClassName}\n              />\n            </span>\n          </motion.button>\n        );\n      })}\n    </div>\n  );\n}\n\nfunction Glyph({\n  fill,\n  size,\n  icon,\n  preview = false,\n  filledClassName,\n  emptyClassName,\n}: {\n  fill: number;\n  size: number;\n  icon: ReactNode;\n  preview?: boolean;\n  filledClassName?: string;\n  emptyClassName?: string;\n}) {\n  return (\n    <span\n      aria-hidden\n      className=\"relative block\"\n      style={{ width: size, height: size }}\n    >\n      <span\n        className={cn(\n          \"absolute inset-0 block text-muted-foreground/30\",\n          emptyClassName,\n        )}\n      >\n        {icon}\n      </span>\n      {/* The filled layer is clipped to the fill fraction; its width only\n          ever switches between 0, half and full, it is never animated. */}\n      <span\n        className={cn(\n          \"absolute inset-y-0 left-0 block overflow-hidden text-amber-400 transition-opacity duration-150\",\n          preview && \"opacity-70\",\n          filledClassName,\n        )}\n        style={{ width: `${fill * 100}%` }}\n      >\n        <span className=\"block\" style={{ width: size, height: size }}>\n          {icon}\n        </span>\n      </span>\n    </span>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/touch.ts","type":"registry:lib","target":"@lib/touch.ts","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple\n * HIG) on a coarse pointer, without changing what it looks like or the space\n * it takes in layout. An invisible `::after` box, centered on the control and\n * `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button\n * reaches 6px past each edge, a wide 40px button only grows vertically, and a\n * control already 44px or larger is left as it is. Hit-testing a pseudo\n * element lands on its owner, so handlers, focus and `:active` all behave.\n *\n * Mouse and trackpad rendering is untouched — nothing here applies outside\n * `pointer: coarse`. Requirements on the element it goes on:\n * - It must be the containing block: add `relative` (and keep it mergeable, so\n *   a consumer's `absolute` still wins through `cn`).\n * - It must not clip itself (`overflow-hidden`), or the overhang is clipped\n *   away along with its hit area.\n * - It must render children: `<input>` and other void elements have no\n *   `::after`. Put it on the wrapper or label instead.\n * - It must not already use `::after` for something visible.\n *\n * Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the\n * later one wins that strip, which still ends well short of the earlier\n * button's visual center. Below a ~32px pitch the later neighbour starts\n * eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.\n *\n * The overhang is real geometry: inside a scroll container it counts as\n * scrollable overflow, so a control flush against a scroller's edge can add a\n * few pixels of scroll on touch screens. Give such a scroller a little padding\n * on that side.\n */\nexport const TOUCH_TARGET_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter\n * than ~32px apart in a row (pagination dots, dense swatches), where growing\n * sideways would hand most of each control to its neighbour. Same rules.\n */\nexport const TOUCH_TARGET_Y_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}