1"use client";
2
3import React, { useState, useRef, useEffect, useId } from "react";
4
5type PopoverPosition = "top" | "bottom" | "left" | "right";
6
7interface PopoverProps {
8 trigger: React.ReactNode;
9 children: React.ReactNode;
10 position?: PopoverPosition;
11 className?: string;
12}
13
14/* Anchored with margins on a wrapper so the panel's own transform is free
15 for the enter motion, which scales out from the trigger side. */
16const positionStyles: Record<PopoverPosition, string> = {
17 top: "bottom-full left-1/2 -translate-x-1/2 pb-2",
18 bottom: "top-full left-1/2 -translate-x-1/2 pt-2",
19 left: "right-full top-1/2 -translate-y-1/2 pr-2",
20 right: "left-full top-1/2 -translate-y-1/2 pl-2",
21};
22
23const origin: Record<PopoverPosition, string> = {
24 top: "origin-bottom",
25 bottom: "origin-top",
26 left: "origin-right",
27 right: "origin-left",
28};
29
30const enterFrom: Record<PopoverPosition, string> = {
31 top: "translate-y-1.5",
32 bottom: "-translate-y-1.5",
33 left: "translate-x-1.5",
34 right: "-translate-x-1.5",
35};
36
37export function Popover({
38 trigger,
39 children,
40 position = "bottom",
41 className = "",
42}: PopoverProps) {
43 const [open, setOpen] = useState(false);
44 const [visible, setVisible] = useState(false);
45 const [mounted, setMounted] = useState(false);
46 const ref = useRef<HTMLDivElement>(null);
47 const panelRef = useRef<HTMLDivElement>(null);
48 const uid = useId();
49
50 useEffect(() => {
51 if (open) {
52 setMounted(true);
53 requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)));
54 } else {
55 setVisible(false);
56 const t = setTimeout(() => setMounted(false), 180);
57 return () => clearTimeout(t);
58 }
59 }, [open]);
60
61 useEffect(() => {
62 function onOutside(e: MouseEvent) {
63 if (ref.current && !ref.current.contains(e.target as Node)) {
64 setOpen(false);
65 }
66 }
67 document.addEventListener("mousedown", onOutside);
68 return () => document.removeEventListener("mousedown", onOutside);
69 }, []);
70
71 useEffect(() => {
72 if (!open) return;
73 const onKey = (e: KeyboardEvent) => {
74 if (e.key === "Escape") {
75 setOpen(false);
76 ref.current?.querySelector<HTMLElement>("[data-popover-trigger] button, [data-popover-trigger] a")?.focus();
77 }
78 };
79 document.addEventListener("keydown", onKey);
80 return () => document.removeEventListener("keydown", onKey);
81 }, [open]);
82
83 return (
84 <div ref={ref} className={["relative inline-flex", className].join(" ")}>
85 <div data-popover-trigger onClick={() => setOpen((v) => !v)} className="inline-flex cursor-pointer">
86 {React.isValidElement(trigger)
87 ? React.cloneElement(trigger as React.ReactElement<Record<string, unknown>>, {
88 "aria-haspopup": "dialog",
89 "aria-expanded": open,
90 "aria-controls": open ? `${uid}-popover` : undefined,
91 })
92 : trigger}
93 </div>
94 {mounted && (
95 <div className={["absolute z-50", positionStyles[position]].join(" ")}>
96 <div
97 ref={panelRef}
98 id={`${uid}-popover`}
99 role="dialog"
100 className={[
101 "min-w-[220px] rounded-[16px] p-1.5",
102 "transition-[opacity,transform,scale,translate] duration-200 ease-[var(--ease-spring)]",
103 origin[position],
104 visible
105 ? "opacity-100 scale-100 translate-x-0 translate-y-0"
106 : ["opacity-0 scale-[0.96]", enterFrom[position]].join(" "),
107 ].join(" ")}
108 style={{ background: "var(--panel-bezel)", boxShadow: "var(--overlay-shadow)" }}
109 >
110 <div
111 className="bg-white rounded-[11px] p-3.5 text-[13px] text-neutral-600 leading-relaxed"
112 style={{ boxShadow: "var(--panel-face-shadow)" }}
113 >
114 {children}
115 </div>
116 </div>
117 </div>
118 )}
119 </div>
120 );
121}