{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "PeekRating-JS-TW",
	"title": "PeekRating",
	"description": "Star rating that lifts a wave of stars up to the pointer, then commits with a pop on click.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:component",
			"path": "PeekRating/PeekRating.jsx",
			"content": "'use client';\n\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport { HugeiconsIcon } from '@hugeicons/react';\nimport { FavouriteIcon, FlashIcon, StarIcon } from '@hugeicons/core-free-icons';\n\nconst EASE_OUT = 'cubic-bezier(0.23, 1, 0.32, 1)';\nconst SHAPES = { star: StarIcon, heart: FavouriteIcon, bolt: FlashIcon };\n\nconst clamp = (value, min, max) => Math.min(max, Math.max(min, value));\nconst reducedMotion = () =>\n  typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;\n\nexport default function PeekRating({\n  value: valueProp,\n  defaultValue = 0,\n  onChange,\n  onPreview,\n  count = 5,\n  shape = 'star',\n  icon,\n  labels = [],\n  activeColor = '#f5b400',\n  idleColor = '#52525b',\n  tipColor = '#27272a',\n  tipTextColor = '#f5f5f5',\n  size = 28,\n  lift = 6,\n  magnify = 1.15,\n  riseDuration = 320,\n  popScale = 1.3,\n  showTip = true,\n  allowClear = true,\n  readOnly = false,\n  disabled = false,\n  ariaLabel = 'Rating',\n  className = ''\n}) {\n  const [inner, setInner] = useState(defaultValue);\n  const value = clamp(valueProp ?? inner, 0, count);\n  const interactive = !readOnly && !disabled;\n\n  const rootRef = useRef(null);\n  const rowRef = useRef(null);\n  const tipEl = useRef(null);\n  const starEls = useRef([]);\n  const liftEls = useRef([]);\n  const glyphEls = useRef([]);\n  const st = useRef({ hover: null, pressing: false, pointerId: null, settled: false, rect: null, rtl: false });\n\n  const paint = () => {\n    const { hover, settled, rtl } = st.current;\n    const previewing = hover !== null && !settled;\n    const shown = previewing ? hover + 1 : value;\n    const still = reducedMotion();\n\n    for (let i = 0; i < count; i++) {\n      const liftEl = liftEls.current[i];\n      const glyphEl = glyphEls.current[i];\n      if (!liftEl || !glyphEl) continue;\n      const lifted = previewing && !still && i <= hover;\n      liftEl.style.transform = lifted\n        ? `translateY(${-lift}px) scale(${i === hover ? magnify : 1})`\n        : 'translateY(0px) scale(1)';\n      glyphEl.dataset.lit = i < shown;\n    }\n\n    const tip = tipEl.current;\n    if (!tip) return;\n    if (previewing && showTip) {\n      const row = rowRef.current;\n      const slot = row ? row.clientWidth / count : size;\n      const visual = rtl ? count - 1 - hover : hover;\n      const wasHidden = tip.dataset.show !== 'true';\n      if (wasHidden) tip.style.transition = 'none';\n      tip.textContent = labels[hover] ?? String(hover + 1);\n      tip.style.transform = `translate(calc(${slot * (visual + 0.5)}px - 50%), 0)`;\n      if (wasHidden) {\n        void tip.offsetWidth;\n        tip.style.transition = '';\n      }\n      tip.dataset.show = 'true';\n    } else {\n      tip.dataset.show = 'false';\n    }\n  };\n  useLayoutEffect(paint);\n\n  const setHover = index => {\n    if (index === st.current.hover) return;\n    st.current.hover = index;\n    if (index !== null) st.current.settled = false;\n    paint();\n    onPreview?.(index === null ? null : index + 1);\n  };\n  const setHoverRef = useRef(setHover);\n  setHoverRef.current = setHover;\n\n  const measure = () => {\n    const row = rowRef.current;\n    if (!row) return;\n    st.current.rect = row.getBoundingClientRect();\n    st.current.rtl = getComputedStyle(row).direction === 'rtl';\n  };\n\n  const indexAt = (x, y) => {\n    const { rect, pressing, rtl } = st.current;\n    if (!rect || !rect.width) return null;\n    if (pressing && (y < rect.top - size || y > rect.bottom + size)) return null;\n    const index = clamp(Math.floor(((x - rect.left) / rect.width) * count), 0, count - 1);\n    return rtl ? count - 1 - index : index;\n  };\n\n  const commit = (next, pop = true) => {\n    if (valueProp === undefined) setInner(next);\n    onChange?.(next);\n    st.current.settled = true;\n    paint();\n    const glyph = glyphEls.current[next - 1];\n    if (pop && next > 0 && popScale > 1 && glyph && typeof glyph.animate === 'function' && !reducedMotion()) {\n      glyph.getAnimations().forEach(animation => animation.cancel());\n      glyph.animate(\n        [\n          { transform: 'scale(1)', easing: EASE_OUT },\n          { transform: `scale(${popScale})`, offset: 0.35, easing: EASE_OUT },\n          { transform: 'scale(1)' }\n        ],\n        { duration: 300 }\n      );\n    }\n  };\n\n  const handlePointerEnter = e => {\n    if (!interactive || e.pointerType !== 'mouse') return;\n    rootRef.current?.removeAttribute('data-instant');\n    measure();\n  };\n\n  const handlePointerDown = e => {\n    if (!interactive || e.button !== 0 || st.current.pointerId !== null) return;\n    rootRef.current?.removeAttribute('data-instant');\n    try {\n      e.currentTarget.setPointerCapture(e.pointerId);\n    } catch {}\n    st.current.pointerId = e.pointerId;\n    st.current.pressing = true;\n    measure();\n    setHover(indexAt(e.clientX, e.clientY));\n  };\n\n  const handlePointerMove = e => {\n    if (!interactive) return;\n    const { pressing, pointerId } = st.current;\n    if (e.pointerType !== 'mouse' && !pressing) return;\n    if (pressing && e.pointerId !== pointerId) return;\n    if (!pressing && !st.current.rect) measure();\n    setHover(indexAt(e.clientX, e.clientY));\n  };\n\n  const endPress = e => {\n    const { pressing, pointerId, hover } = st.current;\n    if (!pressing || e.pointerId !== pointerId) return;\n    st.current.pressing = false;\n    st.current.pointerId = null;\n    if (e.type === 'pointerup' && hover !== null) {\n      const next = hover + 1;\n      commit(allowClear && next === value ? 0 : next);\n    }\n    if (e.pointerType !== 'mouse') setHover(null);\n  };\n\n  const handlePointerLeave = () => {\n    if (!st.current.pressing) setHover(null);\n  };\n\n  const handleKeyDown = e => {\n    if (!interactive) return;\n    const min = allowClear ? 0 : 1;\n    let next;\n    switch (e.key) {\n      case 'ArrowRight':\n      case 'ArrowUp':\n        next = clamp(value + 1, min, count);\n        break;\n      case 'ArrowLeft':\n      case 'ArrowDown':\n        next = clamp(value - 1, min, count);\n        break;\n      case 'Home':\n        next = 1;\n        break;\n      case 'End':\n        next = count;\n        break;\n      case 'Backspace':\n      case 'Delete':\n        if (!allowClear) return;\n        next = 0;\n        break;\n      case ' ':\n      case 'Enter': {\n        const index = starEls.current.indexOf(e.target);\n        if (index === -1) return;\n        next = allowClear && index + 1 === value ? 0 : index + 1;\n        break;\n      }\n      default:\n        return;\n    }\n    e.preventDefault();\n    rootRef.current?.setAttribute('data-instant', 'true');\n    st.current.hover = null;\n    commit(next, false);\n    starEls.current[Math.max(next, 1) - 1]?.focus();\n  };\n\n  useEffect(() => {\n    const reset = () => {\n      st.current.pressing = false;\n      st.current.pointerId = null;\n      setHoverRef.current(null);\n    };\n    const onVisibility = () => {\n      if (document.hidden) reset();\n    };\n    document.addEventListener('visibilitychange', onVisibility);\n    window.addEventListener('blur', reset);\n    return () => {\n      document.removeEventListener('visibilitychange', onVisibility);\n      window.removeEventListener('blur', reset);\n    };\n  }, []);\n\n  const Star = readOnly ? 'span' : 'button';\n  const shapeIcon = SHAPES[shape] || SHAPES.star;\n  const tipRoom = interactive && showTip ? Math.round(size * 0.9) : 0;\n\n  return (\n    <div\n      ref={rootRef}\n      role={readOnly ? 'img' : 'radiogroup'}\n      aria-label={readOnly ? `${value} of ${count}` : ariaLabel}\n      aria-disabled={disabled || undefined}\n      className={`group inline-flex font-[inherit] text-inherit aria-disabled:pointer-events-none aria-disabled:opacity-50${className ? ` ${className}` : ''}`}\n      style={{\n        '--pr-active': activeColor,\n        '--pr-idle': idleColor,\n        '--pr-tip': tipColor,\n        '--pr-tip-text': tipTextColor,\n        '--pr-size': `${size}px`,\n        '--pr-gap': `${Math.round(size * 0.22)}px`,\n        '--pr-room': `${lift + tipRoom}px`,\n        '--pr-rise': `${riseDuration}ms`,\n        '--pr-ease-out': EASE_OUT\n      }}\n      onKeyDown={readOnly ? undefined : handleKeyDown}\n    >\n      <div\n        ref={rowRef}\n        className=\"relative inline-flex touch-pan-y select-none items-end pt-[var(--pr-room)] [-webkit-tap-highlight-color:transparent] [-webkit-touch-callout:none]\"\n        onPointerEnter={handlePointerEnter}\n        onPointerDown={handlePointerDown}\n        onPointerMove={handlePointerMove}\n        onPointerUp={endPress}\n        onPointerCancel={endPress}\n        onLostPointerCapture={endPress}\n        onPointerLeave={handlePointerLeave}\n      >\n        {interactive && showTip ? (\n          <span\n            ref={tipEl}\n            className=\"pointer-events-none absolute left-0 top-0 inline-flex h-[calc(var(--pr-size)*0.6)] items-center whitespace-nowrap rounded-full px-[calc(var(--pr-size)*0.3)] text-[length:calc(var(--pr-size)*0.38)] font-semibold leading-none tracking-[0.01em] opacity-0 shadow-[0_2px_10px_rgba(0,0,0,0.18)] [background:var(--pr-tip)] [color:var(--pr-tip-text)] [transition:transform_var(--pr-rise)_var(--pr-ease-out),opacity_180ms_ease] data-[show=true]:opacity-100 group-data-[instant=true]:[transition-duration:0ms] motion-reduce:[transition:opacity_180ms_ease]\"\n            aria-hidden=\"true\"\n          />\n        ) : null}\n        {Array.from({ length: count }, (_, i) => {\n          const checked = value === i + 1;\n          const label = labels[i] ? `${i + 1} of ${count}, ${labels[i]}` : `${i + 1} of ${count}`;\n          return (\n            <Star\n              key={i}\n              ref={el => {\n                starEls.current[i] = el;\n              }}\n              type={readOnly ? undefined : 'button'}\n              className=\"m-0 grid h-[calc(var(--pr-size)_+_8px)] w-[calc(var(--pr-size)_+_var(--pr-gap))] touch-manipulation place-items-center border-0 bg-transparent p-0 font-[inherit] text-inherit outline-none [@media(hover:hover)_and_(pointer:fine)]:cursor-pointer [@media(pointer:coarse)]:min-h-11 [@media(pointer:coarse)]:min-w-11 focus-visible:rounded-md focus-visible:outline-offset-2 focus-visible:[outline:2px_solid_color-mix(in_srgb,var(--pr-active)_70%,transparent)]\"\n              role={readOnly ? undefined : 'radio'}\n              aria-checked={readOnly ? undefined : checked}\n              aria-label={readOnly ? undefined : label}\n              aria-hidden={readOnly || undefined}\n              tabIndex={!interactive ? -1 : (value === 0 ? i === 0 : checked) ? 0 : -1}\n            >\n              <span\n                ref={el => {\n                  liftEls.current[i] = el;\n                }}\n                className=\"block origin-bottom [transition:transform_var(--pr-rise)_var(--pr-ease-out)] group-data-[instant=true]:[transition-duration:0ms] motion-reduce:transition-none\"\n              >\n                <span\n                  ref={el => {\n                    glyphEls.current[i] = el;\n                  }}\n                  className=\"block h-[var(--pr-size)] w-[var(--pr-size)] text-[var(--pr-idle)] [transition:color_160ms_ease] data-[lit=true]:text-[var(--pr-active)] group-data-[instant=true]:[transition-duration:0ms] [&>svg]:block [&>svg]:h-full [&>svg]:w-full\"\n                >\n                  {icon ?? <HugeiconsIcon icon={shapeIcon} size={size} fill=\"currentColor\" strokeWidth={1.5} />}\n                </span>\n              </span>\n            </Star>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n"
		}
	],
	"registryDependencies": [],
	"dependencies": [
		"@hugeicons/react@^1.1.10",
		"@hugeicons/core-free-icons@^4.3.3"
	]
}