{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"popover","type":"registry:ui","title":"Popover","description":"Knows its origin, flips on collision.","dependencies":["motion"],"categories":["overlay"],"docs":"https://www.interior.dev/docs/popover","files":[{"path":"registry/interior/popover.tsx","type":"registry:ui","target":"components/interior/popover.tsx","content":"\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nconst EASE = [0.23, 1, 0.32, 1] as const;\nconst CROSSFADE = { type: \"spring\", stiffness: 260, damping: 34, mass: 0.8 } as const;\nconst RADIUS = 11;\nconst MIN_W = 160;\nconst MIN_H = 88;\n\nconst useIsoLayoutEffect = typeof document === \"undefined\" ? useEffect : useLayoutEffect;\n\nexport type PopoverSide = \"top\" | \"right\" | \"bottom\" | \"left\";\nexport type PopoverAlign = \"start\" | \"center\" | \"end\";\n\nconst FLIP: Record<PopoverSide, PopoverSide> = {\n  top: \"bottom\",\n  bottom: \"top\",\n  left: \"right\",\n  right: \"left\",\n};\n\nconst ARROW_EDGE: Record<PopoverSide, string> = {\n  bottom: \"border-t border-l\",\n  top: \"border-b border-r\",\n  right: \"border-b border-l\",\n  left: \"border-t border-r\",\n};\n\nconst FROM: Record<PopoverSide, { x?: number; y?: number }> = {\n  top: { y: 6 },\n  bottom: { y: -6 },\n  left: { x: 6 },\n  right: { x: -6 },\n};\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), Math.max(min, max));\n}\n\nexport type UsePopoverOptions = {\n  open: boolean;\n  side?: PopoverSide;\n  align?: PopoverAlign;\n  offset?: number;\n  padding?: number;\n  arrowSize?: number;\n  boundary?: React.RefObject<HTMLElement | null>;\n};\n\nexport type UsePopoverResult<A extends HTMLElement = HTMLElement> = {\n  anchorRef: React.RefObject<A | null>;\n  floatingRef: React.RefObject<HTMLDivElement | null>;\n  panelRef: React.RefObject<HTMLDivElement | null>;\n  contentRef: React.RefObject<HTMLDivElement | null>;\n  arrowRef: React.RefObject<HTMLSpanElement | null>;\n  side: PopoverSide;\n  update: () => void;\n};\n\nexport function usePopover<A extends HTMLElement = HTMLElement>({\n  open,\n  side = \"bottom\",\n  align = \"center\",\n  offset = 10,\n  padding = 8,\n  arrowSize = 9,\n  boundary,\n}: UsePopoverOptions): UsePopoverResult<A> {\n  const anchorRef = useRef<A>(null);\n  const floatingRef = useRef<HTMLDivElement>(null);\n  const panelRef = useRef<HTMLDivElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const arrowRef = useRef<HTMLSpanElement>(null);\n\n  const [resolved, setResolved] = useState<PopoverSide>(side);\n\n  const update = useCallback(() => {\n    const anchor = anchorRef.current;\n    const wrap = floatingRef.current;\n    const panel = panelRef.current;\n    if (!anchor || !wrap || !panel) return;\n\n    const content = contentRef.current;\n    panel.style.maxWidth = \"\";\n    if (content) content.style.maxHeight = \"\";\n\n    const a = anchor.getBoundingClientRect();\n    const b = boundary?.current?.getBoundingClientRect() ?? null;\n    const vw = document.documentElement.clientWidth;\n    const vh = document.documentElement.clientHeight;\n\n    const left = b ? Math.max(padding, b.left + padding) : padding;\n    const top = b ? Math.max(padding, b.top + padding) : padding;\n    const right = b ? Math.min(vw - padding, b.right - padding) : vw - padding;\n    const bottom = b ? Math.min(vh - padding, b.bottom - padding) : vh - padding;\n\n    panel.style.maxWidth = `${Math.max(MIN_W, right - left)}px`;\n\n    const room: Record<PopoverSide, number> = {\n      top: a.top - top - offset,\n      bottom: bottom - a.bottom - offset,\n      left: a.left - left - offset,\n      right: right - a.right - offset,\n    };\n\n    let next = side;\n    const wanted =\n      next === \"top\" || next === \"bottom\" ? panel.offsetHeight : panel.offsetWidth;\n    if (room[next] < wanted && room[FLIP[next]] > room[next]) next = FLIP[next];\n\n    const horizontal = next === \"top\" || next === \"bottom\";\n    if (!horizontal) {\n      panel.style.maxWidth = `${Math.max(MIN_W, Math.min(right - left, room[next]))}px`;\n    }\n    if (content) {\n      const chrome = panel.offsetHeight - content.offsetHeight;\n      const allowed = horizontal ? room[next] : bottom - top;\n      content.style.maxHeight = `${Math.max(MIN_H, allowed - chrome)}px`;\n    }\n\n    const w = panel.offsetWidth;\n    const h = panel.offsetHeight;\n\n    let x: number;\n    let y: number;\n    if (horizontal) {\n      y = next === \"top\" ? a.top - offset - h : a.bottom + offset;\n      x =\n        align === \"start\"\n          ? a.left\n          : align === \"end\"\n            ? a.right - w\n            : a.left + (a.width - w) / 2;\n    } else {\n      x = next === \"left\" ? a.left - offset - w : a.right + offset;\n      y =\n        align === \"start\"\n          ? a.top\n          : align === \"end\"\n            ? a.bottom - h\n            : a.top + (a.height - h) / 2;\n    }\n    x = clamp(x, left, right - w);\n    y = clamp(y, top, bottom - h);\n\n    const base = wrap.getBoundingClientRect();\n    const originX = base.left - (parseFloat(wrap.style.left) || 0);\n    const originY = base.top - (parseFloat(wrap.style.top) || 0);\n    wrap.style.left = `${Math.round(x - originX)}px`;\n    wrap.style.top = `${Math.round(y - originY)}px`;\n\n    const half = arrowSize / 2;\n    const point = horizontal\n      ? clamp(a.left + a.width / 2 - x, RADIUS + half, w - RADIUS - half)\n      : clamp(a.top + a.height / 2 - y, RADIUS + half, h - RADIUS - half);\n\n    panel.style.transformOrigin = horizontal\n      ? `${Math.round(point)}px ${next === \"top\" ? h : 0}px`\n      : `${next === \"left\" ? w : 0}px ${Math.round(point)}px`;\n\n    const arrow = arrowRef.current;\n    if (arrow) {\n      if (horizontal) {\n        arrow.style.left = `${Math.round(point - half)}px`;\n        arrow.style.top = `${Math.round(next === \"top\" ? h - half : -half)}px`;\n      } else {\n        arrow.style.top = `${Math.round(point - half)}px`;\n        arrow.style.left = `${Math.round(next === \"left\" ? w - half : -half)}px`;\n      }\n    }\n\n    setResolved((prev) => (prev === next ? prev : next));\n  }, [side, align, offset, padding, arrowSize, boundary]);\n\n  useIsoLayoutEffect(() => {\n    if (!open) return;\n    update();\n  }, [open, update]);\n\n  useEffect(() => {\n    if (!open) return;\n\n    let frame = 0;\n    const schedule = () => {\n      if (frame) return;\n      frame = requestAnimationFrame(() => {\n        frame = 0;\n        update();\n      });\n    };\n\n    const observer = new ResizeObserver(schedule);\n    if (anchorRef.current) observer.observe(anchorRef.current);\n    if (contentRef.current) observer.observe(contentRef.current);\n    window.addEventListener(\"scroll\", schedule, true);\n    window.addEventListener(\"resize\", schedule);\n\n    return () => {\n      cancelAnimationFrame(frame);\n      observer.disconnect();\n      window.removeEventListener(\"scroll\", schedule, true);\n      window.removeEventListener(\"resize\", schedule);\n    };\n  }, [open, update]);\n\n  return { anchorRef, floatingRef, panelRef, contentRef, arrowRef, side: resolved, update };\n}\n\nexport type PopoverProps = {\n  trigger: React.ReactNode;\n  children: React.ReactNode;\n  label: string;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  side?: PopoverSide;\n  align?: PopoverAlign;\n  offset?: number;\n  padding?: number;\n  arrowSize?: number;\n  boundary?: React.RefObject<HTMLElement | null>;\n  triggerClassName?: string;\n  className?: string;\n};\n\nexport function Popover({\n  trigger,\n  children,\n  label,\n  open: controlled,\n  defaultOpen = false,\n  onOpenChange,\n  side = \"bottom\",\n  align = \"center\",\n  offset = 10,\n  padding = 8,\n  arrowSize = 9,\n  boundary,\n  triggerClassName = \"\",\n  className = \"\",\n}: PopoverProps) {\n  const [uncontrolled, setUncontrolled] = useState(defaultOpen);\n  const open = controlled ?? uncontrolled;\n\n  const id = useId();\n  const reduced = useReducedMotion();\n\n  const notify = useRef(onOpenChange);\n  notify.current = onOpenChange;\n\n  const { anchorRef, floatingRef, panelRef, contentRef, arrowRef, side: at } =\n    usePopover<HTMLButtonElement>({\n      open,\n      side,\n      align,\n      offset,\n      padding,\n      arrowSize,\n      boundary,\n    });\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (controlled === undefined) setUncontrolled(next);\n      notify.current?.(next);\n    },\n    [controlled],\n  );\n\n  useEffect(() => {\n    if (!open) return;\n    panelRef.current?.focus({ preventScroll: true });\n  }, [open, panelRef]);\n\n  useEffect(() => {\n    if (!open) return;\n\n    const onPointerDown = (event: PointerEvent) => {\n      const target = event.target as Node | null;\n      if (!target) return;\n      if (panelRef.current?.contains(target) || anchorRef.current?.contains(target)) return;\n      setOpen(false);\n    };\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key !== \"Escape\") return;\n      event.stopPropagation();\n      anchorRef.current?.focus({ preventScroll: true });\n      setOpen(false);\n    };\n\n    document.addEventListener(\"pointerdown\", onPointerDown, true);\n    document.addEventListener(\"keydown\", onKeyDown, true);\n    return () => {\n      document.removeEventListener(\"pointerdown\", onPointerDown, true);\n      document.removeEventListener(\"keydown\", onKeyDown, true);\n    };\n  }, [open, setOpen, anchorRef, panelRef]);\n\n  return (\n    <>\n      <button\n        ref={anchorRef}\n        type=\"button\"\n        aria-haspopup=\"dialog\"\n        aria-expanded={open}\n        aria-controls={open ? id : undefined}\n        onClick={() => setOpen(!open)}\n        className={`inline-flex h-9 select-none items-center gap-2 rounded-[9px] border border-stone-200 bg-white px-3 text-[13px] font-medium text-stone-700 outline-none transition-[border-color,box-shadow] duration-150 hover:border-stone-300 focus-visible:border-[#4568FF] focus-visible:shadow-[0_1px_2px_rgba(28,25,23,0.08),0_10px_20px_-14px_rgba(69,104,255,0.6)] dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:text-stone-200 dark:hover:border-white/20 dark:focus-visible:border-[#93B0FF] dark:focus-visible:shadow-[0_10px_20px_-14px_rgba(147,176,255,0.5)] ${triggerClassName}`}\n      >\n        {trigger}\n      </button>\n      <AnimatePresence>\n        {open ? (\n          <div\n            key=\"popover\"\n            ref={floatingRef}\n            className=\"fixed left-0 top-0 z-50\"\n            onBlurCapture={(event) => {\n              const next = event.relatedTarget as Node | null;\n              if (!next) return;\n              if (panelRef.current?.contains(next) || anchorRef.current?.contains(next)) return;\n              setOpen(false);\n            }}\n          >\n            <motion.div\n              ref={panelRef}\n              id={id}\n              role=\"dialog\"\n              aria-label={label}\n              tabIndex={-1}\n              initial={\n                reduced ? { opacity: 0 } : { opacity: 0, scale: 0.95, ...FROM[at] }\n              }\n              animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}\n              exit={\n                reduced\n                  ? { opacity: 0, transition: { duration: 0.1 } }\n                  : {\n                      opacity: 0,\n                      scale: 0.97,\n                      transition: { duration: 0.13, ease: EASE },\n                    }\n              }\n              transition={\n                reduced\n                  ? { duration: 0 }\n                  : { ...CROSSFADE, opacity: { duration: 0.14, ease: EASE } }\n              }\n              className={`relative rounded-[11px] border border-stone-200 bg-white p-3 shadow-[0_18px_40px_-24px_rgba(28,25,23,0.5)] focus-visible:outline-none dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:shadow-[0_18px_40px_-24px_rgba(0,0,0,0.9)] ${className}`}\n            >\n              <span\n                ref={arrowRef}\n                aria-hidden\n                style={{ width: arrowSize, height: arrowSize, transform: \"rotate(45deg)\" }}\n                className={`absolute block bg-white dark:bg-[#1D1D1A] border-stone-200 dark:border-white/[0.16] ${ARROW_EDGE[at]}`}\n              />\n              <div ref={contentRef} className=\"relative overflow-y-auto overscroll-contain\">\n                {children}\n              </div>\n            </motion.div>\n          </div>\n        ) : null}\n      </AnimatePresence>\n    </>\n  );\n}\n"}]}