{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "item-hover-card",
  "title": "Item hover card",
  "description": "Reveals an inspect popup for the trigger (e.g. an ItemTile / gear slot) — the in-game \"hover gear to inspect\" flyout.",
  "registryDependencies": [
    "@engram/cn",
    "@engram/tokens"
  ],
  "files": [
    {
      "path": "src/components/item-hover-card.tsx",
      "content": "\"use client\";\nimport {\n  type ElementType,\n  type FocusEvent as ReactFocusEvent,\n  type ReactNode,\n  type PointerEvent as ReactPointerEvent,\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"../lib/cn.js\";\n\nconst useIsoLayoutEffect =\n  typeof window !== \"undefined\" ? useLayoutEffect : useEffect;\n\n// Only one card may be \"pinned\" (click-opened) at a time across the whole app.\n// Pinning or hovering a different card dismisses the previously pinned one —\n// the inspect sheet belongs to whichever item you last acted on, matching the\n// in-game feel where opening one details view closes the last.\nlet dismissActivePin: (() => void) | null = null;\n\ntype Mode = \"closed\" | \"hover\" | \"pinned\";\n\n/** Inspect panels (e.g. {@link ItemPopup}) carry this marker so a nested,\n *  panel-anchored card can find the panel to sit beside. */\nexport const INSPECT_PANEL_ATTR = \"data-engram-inspect-panel\";\n\nexport interface ItemHoverCardProps {\n  /** The trigger — typically an {@link ItemTile} / gear slot. */\n  children: ReactNode;\n  /** The inspect content shown on hover/click — typically an {@link ItemPopup}. */\n  popup: ReactNode;\n  /** ms before the cursor-following preview opens on hover/focus. */\n  openDelay?: number;\n  /** ms before the preview closes after the pointer leaves. */\n  closeDelay?: number;\n  /** Trigger element. Use `\"span\"` when the children are themselves interactive\n   *  (e.g. an {@link AbilitySlot} button) so the wrapper doesn't nest a `<button>`\n   *  in a `<button>`; the child's own focus/click bubble up. Defaults to\n   *  `\"button\"` (focusable on its own, for non-interactive children like a tile). */\n  as?: \"button\" | \"span\";\n  /** Where the preview is placed.\n   *  - `\"cursor\"` (default): follows the cursor on hover, pins beside the trigger\n   *    on click — the top-level item inspect behaviour.\n   *  - `\"panel\"`: a secondary inspect nested *inside* an already-pinned panel\n   *    (e.g. a mod within an {@link ItemPopup}). It sits beside the whole panel\n   *    (left/right), stays non-interactive, and never dismisses the parent pin —\n   *    so sweeping across the mods just swaps the side popup. */\n  anchor?: \"cursor\" | \"panel\";\n  className?: string;\n}\n\n/**\n * Reveals an inspect popup for the trigger (e.g. an ItemTile / gear slot) — the\n * in-game \"hover gear to inspect\" flyout. Two modes mirror the game: while you\n * hover, a non-interactive preview *follows the cursor* (so it never blocks\n * sweeping across a grid); clicking pins it as a fixed, interactive sheet beside\n * the trigger so you can click perks. A pin is dismissed by hovering another\n * item, clicking elsewhere, or pressing Escape.\n */\nexport function ItemHoverCard({\n  children,\n  popup,\n  openDelay = 90,\n  closeDelay = 140,\n  as = \"button\",\n  anchor = \"cursor\",\n  className,\n}: ItemHoverCardProps) {\n  // A panel-anchored card is a nested sub-inspect: hover-only, non-interactive,\n  // and it leaves the parent pin alone.\n  const nested = anchor === \"panel\";\n  const triggerRef = useRef<HTMLElement>(null);\n  const popupRef = useRef<HTMLDivElement>(null);\n  const openTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const closeTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const cursor = useRef({ x: 0, y: 0 });\n  const [mode, setMode] = useState<Mode>(\"closed\");\n  const [pos, setPos] = useState({ left: -9999, top: -9999 });\n\n  const open = mode !== \"closed\";\n  const pinned = mode === \"pinned\";\n\n  // Pinned: snap beside the trigger, centered vertically, flipping at the right\n  // edge — the fixed location the sheet lives in once you commit to inspecting.\n  const pinBesideTrigger = useCallback(() => {\n    const trigger = triggerRef.current;\n    const pop = popupRef.current;\n    if (!trigger) return;\n    const r = trigger.getBoundingClientRect();\n    const pw = pop?.offsetWidth ?? 348;\n    const ph = pop?.offsetHeight ?? 440;\n    const gap = 10;\n    let left = r.right + gap;\n    if (left + pw > window.innerWidth - 8) left = r.left - pw - gap; // flip left\n    left = Math.max(8, Math.min(left, window.innerWidth - pw - 8));\n    const top = Math.max(\n      8,\n      Math.min(r.top + r.height / 2 - ph / 2, window.innerHeight - ph - 8),\n    );\n    setPos({ left, top });\n  }, []);\n\n  // Hover: float beside the cursor, vertically centered on it, flipping at the\n  // edges so it stays on screen as you move.\n  const followCursor = useCallback(() => {\n    const pop = popupRef.current;\n    const pw = pop?.offsetWidth ?? 348;\n    const ph = pop?.offsetHeight ?? 440;\n    const gap = 18;\n    const { x, y } = cursor.current;\n    let left = x + gap;\n    if (left + pw > window.innerWidth - 8) left = x - pw - gap; // flip left of cursor\n    left = Math.max(8, left);\n    const top = Math.max(8, Math.min(y - ph / 2, window.innerHeight - ph - 8));\n    setPos({ left, top });\n  }, []);\n\n  // Nested sub-inspect: sit beside the whole parent panel (to its right, or\n  // flipped to its left when there's no room), flush with the panel's top —\n  // so it reads as a companion sheet beside the primary inspect (matching the\n  // in-game ancillary panel) without covering it.\n  const besidePanel = useCallback(() => {\n    const trigger = triggerRef.current;\n    const pop = popupRef.current;\n    if (!trigger) return;\n    const panel = trigger.closest(`[${INSPECT_PANEL_ATTR}]`);\n    const anchorRect = (panel ?? trigger).getBoundingClientRect();\n    const pw = pop?.offsetWidth ?? 320;\n    const ph = pop?.offsetHeight ?? 360;\n    const gap = 10;\n    let left = anchorRect.right + gap;\n    if (left + pw > window.innerWidth - 8) left = anchorRect.left - pw - gap; // flip\n    left = Math.max(8, Math.min(left, window.innerWidth - pw - 8));\n    const top = Math.max(\n      8,\n      Math.min(anchorRect.top, window.innerHeight - ph - 8),\n    );\n    setPos({ left, top });\n  }, []);\n\n  // Position before paint (the first render is off-screen, then snapped in).\n  useIsoLayoutEffect(() => {\n    if (mode === \"pinned\") pinBesideTrigger();\n    else if (mode === \"hover\") (nested ? besidePanel : followCursor)();\n  }, [mode, nested, besidePanel]);\n\n  // Suppress native `title` tooltips inside the trigger — this rich popup is the\n  // inspect surface, and an OS text tooltip on top of it is redundant (and not\n  // Destiny's visual language). Re-run each render in case React re-adds them.\n  useIsoLayoutEffect(() => {\n    const el = triggerRef.current;\n    if (!el) return;\n    for (const n of el.querySelectorAll(\"[title]\")) n.removeAttribute(\"title\");\n  });\n\n  function startHover() {\n    if (pinned) return; // a pinned card ignores its own hover\n    clearTimeout(closeTimer.current);\n    // A nested sub-inspect lives inside an already-pinned panel — it must not\n    // dismiss that pin (which is the very panel it's anchored to). Only a\n    // top-level card claims the global pin slot from others.\n    if (!nested) dismissActivePin?.();\n    openTimer.current = setTimeout(() => setMode(\"hover\"), openDelay);\n  }\n\n  function endHover() {\n    clearTimeout(openTimer.current);\n    if (pinned) return; // a pin stays until click-off / Escape / another card\n    closeTimer.current = setTimeout(() => setMode(\"closed\"), closeDelay);\n  }\n\n  function pin() {\n    clearTimeout(openTimer.current);\n    clearTimeout(closeTimer.current);\n    dismissActivePin?.(); // close any other pin before claiming the spot\n    setMode(\"pinned\");\n  }\n\n  function handleBlur(e: ReactFocusEvent) {\n    const next = e.relatedTarget as Node | null;\n    if (\n      next &&\n      (triggerRef.current?.contains(next) || popupRef.current?.contains(next))\n    )\n      return; // focus moved into the trigger or the popup — keep it open\n    clearTimeout(openTimer.current);\n    setMode(\"closed\");\n  }\n\n  // While pinned: own the global pin slot, and dismiss on outside click / Escape.\n  useEffect(() => {\n    if (mode !== \"pinned\") return;\n    const close = () => setMode(\"closed\");\n    dismissActivePin = close;\n\n    function onPointerDown(e: PointerEvent) {\n      const t = e.target as Node;\n      if (triggerRef.current?.contains(t) || popupRef.current?.contains(t))\n        return;\n      close();\n    }\n    function onKeyDown(e: KeyboardEvent) {\n      if (e.key === \"Escape\") close();\n    }\n    function onReflow() {\n      pinBesideTrigger();\n    }\n    document.addEventListener(\"pointerdown\", onPointerDown, true);\n    document.addEventListener(\"keydown\", onKeyDown);\n    window.addEventListener(\"scroll\", onReflow, true);\n    window.addEventListener(\"resize\", onReflow);\n    return () => {\n      document.removeEventListener(\"pointerdown\", onPointerDown, true);\n      document.removeEventListener(\"keydown\", onKeyDown);\n      window.removeEventListener(\"scroll\", onReflow, true);\n      window.removeEventListener(\"resize\", onReflow);\n      if (dismissActivePin === close) dismissActivePin = null;\n    };\n  }, [mode, pinBesideTrigger]);\n\n  const Trigger = as as ElementType;\n  return (\n    <Trigger\n      {...(as === \"button\" ? { type: \"button\" } : {})}\n      ref={triggerRef}\n      className={cn(\n        \"relative inline-flex cursor-default appearance-none border-0 bg-transparent p-0 text-left outline-none focus-visible:outline-2 focus-visible:outline-white\",\n        className,\n      )}\n      onPointerEnter={(e: ReactPointerEvent) => {\n        cursor.current = { x: e.clientX, y: e.clientY };\n        startHover();\n      }}\n      onPointerMove={(e: ReactPointerEvent) => {\n        cursor.current = { x: e.clientX, y: e.clientY };\n        if (!nested && mode === \"hover\") followCursor();\n      }}\n      onPointerLeave={endHover}\n      // Nested cards never pin — they're a hover/focus-only side panel.\n      onClick={nested ? undefined : pin}\n      onFocus={nested ? startHover : pin}\n      onBlur={handleBlur}\n    >\n      {children}\n      {open\n        ? createPortal(\n            <div\n              ref={popupRef}\n              className={cn(\n                \"fixed z-50 max-w-[92vw]\",\n                pinned ? \"pointer-events-auto\" : \"pointer-events-none\",\n              )}\n              style={{ left: pos.left, top: pos.top }}\n            >\n              {popup}\n            </div>,\n            document.body,\n          )\n        : null}\n    </Trigger>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/engram/item-hover-card.tsx"
    }
  ],
  "meta": {
    "level": "component"
  },
  "docs": "Extend without forking: edit the copied source, use `asChild` (Radix Slot) to change the rendered element, pass the typed `annotations` prop for curated data (verdict/tags/per-plug), or use slot / render-prop props for arbitrary content. Requires the @engram/tokens theme (--engram-* CSS variables).",
  "categories": [
    "item"
  ],
  "type": "registry:component"
}