{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "popover",
  "title": "Popover",
  "author": "AlgorizeTech",
  "description": "A floating panel anchored to a trigger, with collision detection and side/align control. Use it for filter menus and inline detail cards.",
  "dependencies": [],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/components/custom/blocks/PopoverBlock.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport React, {\n  createContext,\n  useContext,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\n\n// ==========================================\n// 1. CONTEXT\n// ==========================================\ninterface PopoverContextType {\n  open: boolean;\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>;\n  /**\n   * The trigger node itself, held in state rather than a ref. A ref shared\n   * through context makes every read of this context a ref read during render;\n   * a plain node lets the content position and portal itself without one.\n   */\n  trigger: HTMLDivElement | null;\n  setTrigger: React.Dispatch<React.SetStateAction<HTMLDivElement | null>>;\n}\nconst PopoverContext = createContext<PopoverContextType | undefined>(undefined);\n\n// ==========================================\n// 2. ROOT COMPONENT\n// ==========================================\nexport interface PopoverProps {\n  children: React.ReactNode;\n}\n\nexport function Popover({ children }: PopoverProps) {\n  const [open, setOpen] = useState(false);\n  const [trigger, setTrigger] = useState<HTMLDivElement | null>(null);\n\n  return (\n    <PopoverContext.Provider value={{ open, setOpen, trigger, setTrigger }}>\n      {children}\n    </PopoverContext.Provider>\n  );\n}\n\n// ==========================================\n// 3. TRIGGER\n// ==========================================\nexport interface PopoverTriggerProps\n  extends React.HTMLAttributes<HTMLDivElement> {\n  asChild?: boolean;\n}\n\nexport function PopoverTrigger({\n  children,\n  className,\n  ...props\n}: PopoverTriggerProps) {\n  const context = useContext(PopoverContext);\n  if (!context) throw new Error(\"PopoverTrigger must be used within a Popover\");\n\n  const { open, setOpen, setTrigger } = context;\n\n  const handleClick = (e: React.MouseEvent) => {\n    // Prevent event bubbling if placed inside another clickable element\n    e.stopPropagation();\n    setOpen((prev) => !prev);\n  };\n\n  return (\n    <div\n      ref={setTrigger}\n      onClick={handleClick}\n      className={cn(\"inline-block\", className)}\n      role=\"button\"\n      tabIndex={0}\n      aria-haspopup=\"dialog\"\n      aria-expanded={open}\n      onKeyDown={(e) => {\n        if (e.key === \"Enter\" || e.key === \" \") {\n          e.preventDefault();\n          setOpen((prev) => !prev);\n        }\n      }}\n      {...props}\n    >\n      {children}\n    </div>\n  );\n}\n\n// ==========================================\n// 4. CONTENT (Portal Renderer)\n// ==========================================\nexport interface PopoverContentProps\n  extends React.HTMLAttributes<HTMLDivElement> {\n  side?: \"top\" | \"right\" | \"bottom\" | \"left\";\n  align?: \"start\" | \"center\" | \"end\";\n  sideOffset?: number;\n  /**\n   * Flip to the opposite side and shift the panel back into view when the\n   * preferred placement would overflow the viewport. Set to false to pin the\n   * panel to `side`/`align` even when it gets cropped.\n   */\n  avoidCollisions?: boolean;\n  /** Minimum gap (px) kept between the panel and the viewport edges. */\n  collisionPadding?: number;\n}\n\nconst OPPOSITE_SIDE = {\n  top: \"bottom\",\n  bottom: \"top\",\n  left: \"right\",\n  right: \"left\",\n} as const;\n\n// `max` drops below `min` when the panel is larger than the viewport; pin to\n// `min` there so the panel starts at the top/left edge instead of jumping past it.\nconst clamp = (value: number, min: number, max: number) =>\n  Math.max(min, Math.min(value, Math.max(min, max)));\n\ntype Side = NonNullable<PopoverContentProps[\"side\"]>;\n\n// Where the panel ended up, in document coordinates. `null` while unmeasured:\n// the panel is mounted invisibly, measured, then shown in the same frame.\ntype Placement = { top: number; left: number; side: Side };\n\n// The entrance grows out of the trigger's edge: a panel below the button\n// scales from its own top edge and slides down a touch, and so on.\nconst ENTER_FROM_SIDE: Record<Side, string> = {\n  bottom: \"origin-top slide-in-from-top-2\",\n  top: \"origin-bottom slide-in-from-bottom-2\",\n  left: \"origin-right slide-in-from-right-2\",\n  right: \"origin-left slide-in-from-left-2\",\n};\n\nexport function PopoverContent({\n  side = \"bottom\",\n  align = \"center\",\n  sideOffset = 8,\n  avoidCollisions = true,\n  collisionPadding = 8,\n  className,\n  children,\n  ...props\n}: PopoverContentProps) {\n  const context = useContext(PopoverContext);\n  if (!context) throw new Error(\"PopoverContent must be used within a Popover\");\n\n  const { open, setOpen, trigger: triggerEl } = context;\n  const [placement, setPlacement] = useState<Placement | null>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n\n  // Forget the last position on close. This component stays mounted while\n  // closed, so without this a reopen would paint one frame at the stale\n  // coordinates (from before a scroll, or from a different `side`) and then\n  // jump next to the trigger. Adjusted while rendering, when `open` changes,\n  // rather than in an effect.\n  const [prevOpen, setPrevOpen] = useState(open);\n  if (open !== prevOpen) {\n    setPrevOpen(open);\n    if (!open) setPlacement(null);\n  }\n\n  // Handle Position Updates. A layout effect runs before the browser paints,\n  // so the first visible frame is already in the right place — a plain effect\n  // would paint first and reposition afterwards, which reads as a jump.\n  useLayoutEffect(() => {\n    if (!open || !triggerEl || !contentRef.current) return;\n\n    // Resolve the document/window the trigger actually lives in, so the\n    // popover works when rendered inside an iframe (e.g. docs device preview).\n    const doc = triggerEl.ownerDocument;\n    const win = doc.defaultView ?? window;\n\n    const updatePosition = () => {\n      const contentEl = contentRef.current;\n      if (!triggerEl || !contentEl) return;\n\n      const viewportWidth = doc.documentElement.clientWidth || win.innerWidth;\n      const viewportHeight = doc.documentElement.clientHeight || win.innerHeight;\n      const padding = avoidCollisions ? collisionPadding : 0;\n      const trigger = triggerEl.getBoundingClientRect();\n\n      // The panel's uncapped size, read from the scroll box. Clearing the caps\n      // and re-measuring would resize the element on every scroll frame and\n      // re-trigger the ResizeObserver below.\n      const chromeY = contentEl.offsetHeight - contentEl.clientHeight;\n      const chromeX = contentEl.offsetWidth - contentEl.clientWidth;\n      const naturalHeight = contentEl.scrollHeight + chromeY;\n      const naturalWidth = contentEl.scrollWidth + chromeX;\n\n      // Room between each trigger edge and the viewport edge, already minus the\n      // gap the panel keeps off the trigger — so this is the space the panel can\n      // occupy WITHOUT covering the control that opened it.\n      const space = {\n        top: trigger.top - padding - sideOffset,\n        bottom: viewportHeight - trigger.bottom - padding - sideOffset,\n        left: trigger.left - padding - sideOffset,\n        right: viewportWidth - trigger.right - padding - sideOffset,\n      };\n\n      let resolvedSide = side;\n      if (avoidCollisions) {\n        const wantsVertical = side === \"top\" || side === \"bottom\";\n        const needed = wantsVertical ? naturalHeight : naturalWidth;\n        const flipped = OPPOSITE_SIDE[side];\n\n        // 1. Flip to the opposite edge when the preferred one is too tight and\n        //    the other is roomier.\n        if (space[side] < needed && space[flipped] > space[side])\n          resolvedSide = flipped;\n\n        // 2. A 288px panel rarely fits beside a trigger on a phone. When neither\n        //    horizontal side can hold it, drop to the vertical axis, where the\n        //    panel gets the full width instead of a useless slice of it.\n        if (!wantsVertical && space[resolvedSide] < naturalWidth)\n          resolvedSide = space.bottom >= space.top ? \"bottom\" : \"top\";\n\n        // 3. Cap the panel to the room on the side it ended up on, and scroll\n        //    the remainder. This is what keeps the trigger visible: without it a\n        //    panel taller than the space below has to be pushed back up over the\n        //    trigger to fit on screen, hiding the button that was just pressed.\n        const onVertical = resolvedSide === \"top\" || resolvedSide === \"bottom\";\n        const across = onVertical\n          ? viewportWidth - padding * 2\n          : viewportHeight - padding * 2;\n        const along = Math.max(0, space[resolvedSide]);\n        contentEl.style.maxWidth = `${Math.max(0, onVertical ? across : along)}px`;\n        contentEl.style.maxHeight = `${Math.max(0, onVertical ? along : across)}px`;\n        contentEl.style.overflowY =\n          contentEl.scrollHeight > contentEl.clientHeight ? \"auto\" : \"\";\n        contentEl.style.overflowX =\n          contentEl.scrollWidth > contentEl.clientWidth ? \"auto\" : \"\";\n      }\n\n      // Measured after the cap. offsetWidth/Height are the untransformed layout\n      // box; getBoundingClientRect would report the shrunken size while the\n      // zoom-in entrance animation runs, throwing the alignment off a few pixels.\n      const width = contentEl.offsetWidth;\n      const height = contentEl.offsetHeight;\n\n      let top = 0;\n      let left = 0;\n\n      // 2. Calculate Side\n      if (resolvedSide === \"top\") top = trigger.top - height - sideOffset;\n      if (resolvedSide === \"bottom\") top = trigger.bottom + sideOffset;\n      if (resolvedSide === \"left\") left = trigger.left - width - sideOffset;\n      if (resolvedSide === \"right\") left = trigger.right + sideOffset;\n\n      // 3. Calculate Align\n      if (resolvedSide === \"top\" || resolvedSide === \"bottom\") {\n        if (align === \"center\")\n          left = trigger.left + trigger.width / 2 - width / 2;\n        if (align === \"start\") left = trigger.left;\n        if (align === \"end\") left = trigger.right - width;\n      } else {\n        if (align === \"center\")\n          top = trigger.top + trigger.height / 2 - height / 2;\n        if (align === \"start\") top = trigger.top;\n        if (align === \"end\") top = trigger.bottom - height;\n      }\n\n      // 4. Shift whatever still hangs off an edge back into view — but only\n      //    ACROSS the side axis. Alignment is a preference, not a guarantee, so\n      //    an align=\"start\"/\"end\" panel near an edge slides back. Shifting along\n      //    the side axis is what used to drag the panel over its own trigger;\n      //    the cap in step 3 owns that axis now.\n      if (avoidCollisions) {\n        if (resolvedSide === \"top\" || resolvedSide === \"bottom\") {\n          left = clamp(left, padding, viewportWidth - width - padding);\n        } else {\n          top = clamp(top, padding, viewportHeight - height - padding);\n        }\n      }\n\n      const nextTop = top + win.scrollY;\n      const nextLeft = left + win.scrollX;\n      setPlacement((prev) =>\n        prev &&\n        prev.top === nextTop &&\n        prev.left === nextLeft &&\n        prev.side === resolvedSide\n          ? prev\n          : { top: nextTop, left: nextLeft, side: resolvedSide },\n      );\n    };\n\n    updatePosition();\n    win.addEventListener(\"scroll\", updatePosition, true);\n    win.addEventListener(\"resize\", updatePosition);\n    // The visual viewport shrinks when the mobile keyboard opens over an input\n    // inside the panel, and changes on pinch-zoom — neither fires window resize.\n    win.visualViewport?.addEventListener(\"resize\", updatePosition);\n    win.visualViewport?.addEventListener(\"scroll\", updatePosition);\n\n    // Reposition when the panel or trigger changes size (content loading, the\n    // max-height cap applied above, a responsive breakpoint crossing).\n    const sizeObserver = new (win.ResizeObserver ?? ResizeObserver)(\n      updatePosition,\n    );\n    sizeObserver.observe(contentRef.current);\n    sizeObserver.observe(triggerEl);\n\n    return () => {\n      sizeObserver.disconnect();\n      win.removeEventListener(\"scroll\", updatePosition, true);\n      win.removeEventListener(\"resize\", updatePosition);\n      win.visualViewport?.removeEventListener(\"resize\", updatePosition);\n      win.visualViewport?.removeEventListener(\"scroll\", updatePosition);\n    };\n  }, [\n    open,\n    side,\n    align,\n    sideOffset,\n    avoidCollisions,\n    collisionPadding,\n    triggerEl,\n  ]);\n\n  // Handle Click Outside & Escape Key\n  useEffect(() => {\n    if (!open) return;\n\n    const handleInteractOutside = (e: MouseEvent | TouchEvent) => {\n      if (\n        contentRef.current &&\n        !contentRef.current.contains(e.target as Node) &&\n        triggerEl &&\n        !triggerEl.contains(e.target as Node)\n      ) {\n        setOpen(false);\n      }\n    };\n\n    const handleEscape = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") setOpen(false);\n    };\n\n    // Listen on the document the trigger actually lives in (supports iframe rendering).\n    const doc = contentRef.current?.ownerDocument ?? document;\n\n    doc.addEventListener(\"mousedown\", handleInteractOutside);\n    doc.addEventListener(\"touchstart\", handleInteractOutside);\n    doc.addEventListener(\"keydown\", handleEscape);\n\n    return () => {\n      doc.removeEventListener(\"mousedown\", handleInteractOutside);\n      doc.removeEventListener(\"touchstart\", handleInteractOutside);\n      doc.removeEventListener(\"keydown\", handleEscape);\n    };\n  }, [open, setOpen, triggerEl]);\n\n  // triggerEl is null until the trigger commits, which also covers SSR: there\n  // is no document to portal into on the server.\n  if (!triggerEl || !open) return null;\n\n  // Portal into the trigger's own document (supports iframe rendering).\n  const portalTarget = triggerEl.ownerDocument.body;\n\n  return createPortal(\n    <div\n      ref={contentRef}\n      role=\"dialog\"\n      className={cn(\n        // Below lg the panel competes with the screen for room: smaller type and\n        // padding buy back the height that would otherwise have to be scrolled.\n        // Width stays a single class so an overriding `className=\"w-48\"` wins at\n        // every breakpoint instead of only below lg.\n        \"absolute z-[9999] w-72 max-w-[calc(100vw-1rem)] p-3 lg:p-4 text-sm lg:text-base outline-none text-popover-foreground bg-popover border border-border rounded-xl shadow-lg\",\n        // `duration-200` sets transition-duration as well as the animation\n        // length, and the browser's default transition-property is `all` — so\n        // without `transition-none` the panel would tween its top/left from\n        // wherever it last was, sliding across the screen instead of opening in\n        // place. Radix/shadcn avoid this the same way: the positioned wrapper\n        // never transitions; only the entrance keyframes animate.\n        \"transition-none animate-in fade-in zoom-in-95 duration-200\",\n        // Animate from whichever side the panel actually landed on; until it\n        // is measured, fall back to the requested side so the classes are stable.\n        ENTER_FROM_SIDE[placement?.side ?? side],\n        className,\n      )}\n      style={{\n        top: placement?.top ?? 0,\n        left: placement?.left ?? 0,\n        // Mounted but not yet measured — keep it out of sight rather than\n        // flashing it at 0,0. Cleared in the same frame the layout effect runs.\n        visibility: placement ? undefined : \"hidden\",\n      }}\n      // Stop clicks inside the popover from bubbling up to the document\n      onClick={(e) => e.stopPropagation()}\n      {...props}\n    >\n      {children}\n    </div>,\n    portalTarget,\n  );\n}\n",
      "type": "registry:component",
      "target": "components/custom/blocks/PopoverBlock.tsx"
    }
  ],
  "docs": "Named exports: Popover, PopoverTrigger, PopoverContent. `side` and `align` set the preferred placement; `avoidCollisions` flips it near a viewport edge, with `collisionPadding` as the margin. Zero runtime dependencies — positioning is hand-rolled, not Floating UI. For hover labels use `tooltip`, for right-click use `context-menu`.",
  "categories": [
    "overlays"
  ],
  "type": "registry:component"
}
