"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
/* ----------------------------------------------------------------------------
* Positioning: flip to the opposite side when there is no room, shift along
* the cross axis to stay inside the viewport, and aim the arrow at the anchor.
* ------------------------------------------------------------------------- */
export type Side = "top" | "bottom" | "left" | "right";
export type Align = "start" | "center" | "end";
type Placement = { x: number; y: number; side: Side; arrow: number };
const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };
const clamp = (n: number, lo: number, hi: number) => Math.min(Math.max(n, lo), Math.max(lo, hi));
export function computePlacement(
a: DOMRect,
f: { width: number; height: number },
side: Side,
align: Align,
offset: number,
pad = 8,
): Placement {
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
const room: Record<Side, number> = {
top: a.top - pad,
bottom: vh - a.bottom - pad,
left: a.left - pad,
right: vw - a.right - pad,
};
const need = (s: Side) => (s === "top" || s === "bottom" ? f.height : f.width) + offset;
let s = side;
if (room[s] < need(s) && room[OPPOSITE[s]] > room[s]) s = OPPOSITE[s];
if (s === "top" || s === "bottom") {
const y = s === "top" ? a.top - offset - f.height : a.bottom + offset;
let x = align === "start" ? a.left : align === "end" ? a.right - f.width : a.left + a.width / 2 - f.width / 2;
x = clamp(x, pad, vw - pad - f.width);
return { x, y, side: s, arrow: clamp(a.left + a.width / 2 - x, 12, f.width - 12) };
}
const x = s === "left" ? a.left - offset - f.width : a.right + offset;
let y = align === "start" ? a.top : align === "end" ? a.bottom - f.height : a.top + a.height / 2 - f.height / 2;
y = clamp(y, pad, vh - pad - f.height);
return { x, y, side: s, arrow: clamp(a.top + a.height / 2 - y, 10, f.height - 10) };
}
function useFloating(open: boolean, side: Side, align: Align, offset: number) {
const anchorRef = React.useRef<HTMLElement | null>(null);
const floatingRef = React.useRef<HTMLDivElement | null>(null);
const [place, setPlace] = React.useState<Placement | null>(null);
React.useLayoutEffect(() => {
if (!open) return;
const update = () => {
const a = anchorRef.current;
const f = floatingRef.current;
if (!a || !f) return;
setPlace(computePlacement(a.getBoundingClientRect(), { width: f.offsetWidth, height: f.offsetHeight }, side, align, offset));
};
update();
const ro = new ResizeObserver(update);
if (floatingRef.current) ro.observe(floatingRef.current);
if (anchorRef.current) ro.observe(anchorRef.current);
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
ro.disconnect();
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [open, side, align, offset]);
return { anchorRef, floatingRef, place: open ? place : null };
}
/* ---------------------------------------------------------------------------- */
type AnyProps = Record<string, unknown> & { ref?: React.Ref<HTMLElement> };
type TriggerElement = React.ReactElement<AnyProps>;
function setRef<T>(ref: React.Ref<T> | undefined, value: T) {
if (typeof ref === "function") ref(value);
else if (ref && typeof ref === "object") (ref as React.RefObject<T>).current = value;
}
/** Merge our handlers into the child's own props (theirs run first). */
function withHandlers(child: TriggerElement, ours: Record<string, unknown>, ref: (el: HTMLElement | null) => void) {
const props: Record<string, unknown> = { ...ours };
for (const [k, fn] of Object.entries(ours)) {
const theirs = child.props[k];
if (typeof fn === "function" && typeof theirs === "function") {
props[k] = (...args: unknown[]) => {
(theirs as (...a: unknown[]) => void)(...args);
(fn as (...a: unknown[]) => void)(...args);
};
}
}
const childRef = child.props.ref;
props.ref = (el: HTMLElement | null) => {
ref(el);
setRef(childRef, el);
};
return React.cloneElement(child, props);
}
function Arrow({ side, pos, className }: { side: Side; pos: number; className?: string }) {
const style: React.CSSProperties =
side === "top"
? { left: pos, bottom: -5, transform: "translateX(-50%) rotate(45deg)" }
: side === "bottom"
? { left: pos, top: -5, transform: "translateX(-50%) rotate(45deg)" }
: side === "left"
? { top: pos, right: -5, transform: "translateY(-50%) rotate(45deg)" }
: { top: pos, left: -5, transform: "translateY(-50%) rotate(45deg)" };
const borders = { top: "border-b border-r", bottom: "border-t border-l", left: "border-t border-r", right: "border-b border-l" }[side];
return <span aria-hidden className={cn("absolute size-2.5", borders, className)} style={style} />;
}
const originFor = (p: Placement): string =>
p.side === "top" ? `${p.arrow}px 100%` : p.side === "bottom" ? `${p.arrow}px 0` : p.side === "left" ? `100% ${p.arrow}px` : `0 ${p.arrow}px`;
const nudge = (side: Side, d: number) =>
side === "top" ? { y: d } : side === "bottom" ? { y: -d } : side === "left" ? { x: d } : { x: -d };
/* ----------------------------------------------------------------------------
* Tooltip + delay group
* ------------------------------------------------------------------------- */
type GroupCtx = {
activeId: string | null;
setActive: (id: string | null) => void;
delay: number;
/** True when another tooltip in the group closed moments ago. */
isWarm: () => boolean;
markClosed: () => void;
};
const TooltipGroupContext = React.createContext<GroupCtx | null>(null);
export function TooltipGroup({ children, delay = 550, skipDelay = 350 }: { children: React.ReactNode; delay?: number; skipDelay?: number }) {
const [activeId, setActiveId] = React.useState<string | null>(null);
const closedAt = React.useRef(-Infinity);
const value = React.useMemo<GroupCtx>(
() => ({
activeId,
setActive: setActiveId,
delay,
isWarm: () => performance.now() - closedAt.current < skipDelay,
markClosed: () => {
closedAt.current = performance.now();
},
}),
[activeId, delay, skipDelay],
);
return <TooltipGroupContext.Provider value={value}>{children}</TooltipGroupContext.Provider>;
}
export interface TooltipProps {
children: TriggerElement;
content: React.ReactNode;
side?: Side;
align?: Align;
offset?: number;
/** Hover delay in ms (falls back to the group delay). */
delay?: number;
disabled?: boolean;
className?: string;
}
export function Tooltip({ children, content, side = "top", align = "center", offset = 8, delay, disabled, className }: TooltipProps) {
const id = React.useId();
const reduce = useReducedMotion();
const group = React.useContext(TooltipGroupContext);
const [localOpen, setLocalOpen] = React.useState(false);
const [instant, setInstant] = React.useState(false);
const open = !disabled && (group ? group.activeId === id : localOpen);
const timer = React.useRef<number | undefined>(undefined);
const { anchorRef, floatingRef, place } = useFloating(open, side, align, offset);
const setOpen = React.useCallback(
(v: boolean) => {
if (group) {
if (v) group.setActive(id);
else if (group.activeId === id) {
group.markClosed();
group.setActive(null);
}
} else setLocalOpen(v);
},
[group, id],
);
const show = (now = false) => {
window.clearTimeout(timer.current);
const warm = !!group?.isWarm() || (!!group?.activeId && group.activeId !== id);
setInstant(warm);
const d = now || warm ? 0 : (delay ?? group?.delay ?? 550);
timer.current = window.setTimeout(() => setOpen(true), d);
};
const hide = (after = 90) => {
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setOpen(false), after);
};
React.useEffect(() => () => window.clearTimeout(timer.current), []);
React.useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, setOpen]);
const trigger = withHandlers(
children,
{
"aria-describedby": open ? id : undefined,
onPointerEnter: (e: React.PointerEvent) => e.pointerType === "mouse" && show(),
onPointerLeave: () => hide(),
onPointerDown: () => hide(0),
onFocus: (e: React.FocusEvent<HTMLElement>) => {
if (e.currentTarget.matches(":focus-visible")) show(true);
},
onBlur: () => hide(0),
},
(el) => {
anchorRef.current = el;
},
);
return (
<>
{trigger}
<AnimatePresence>
{open && (
<motion.div
ref={floatingRef}
id={id}
role="tooltip"
onPointerEnter={() => window.clearTimeout(timer.current)}
onPointerLeave={() => hide()}
initial={reduce || instant ? { opacity: 0 } : { opacity: 0, scale: 0.9, ...nudge(place?.side ?? side, 4) }}
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
exit={{ opacity: 0, scale: instant || reduce ? 1 : 0.96, transition: { duration: 0.1 } }}
transition={instant ? { duration: 0.08 } : { type: "spring", stiffness: 600, damping: 32 }}
style={{
position: "fixed",
left: place?.x ?? 0,
top: place?.y ?? 0,
visibility: place ? "visible" : "hidden",
transformOrigin: place ? originFor(place) : undefined,
}}
className={cn(
"z-50 max-w-[min(18rem,calc(100vw-1rem))] rounded-lg border border-foreground/10 bg-foreground px-2.5 py-1.5 text-xs leading-snug text-background shadow-lg",
className,
)}
>
{content}
{place && <Arrow side={place.side} pos={place.arrow} className="border-foreground/10 bg-foreground" />}
</motion.div>
)}
</AnimatePresence>
</>
);
}
/* ----------------------------------------------------------------------------
* Popover (click) / hover card (hover)
* ------------------------------------------------------------------------- */
export interface PopoverProps {
children: TriggerElement;
content: React.ReactNode | ((close: () => void) => React.ReactNode);
/** "click" = dialog popover, "hover" = hover card that also opens on keyboard focus. */
trigger?: "click" | "hover";
side?: Side;
align?: Align;
offset?: number;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Accessible name of the popover dialog. */
label?: string;
arrow?: boolean;
className?: string;
}
const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select,textarea,[tabindex]:not([tabindex="-1"])';
export function Popover({
children,
content,
trigger = "click",
side = "bottom",
align = "center",
offset = 10,
open: openProp,
defaultOpen = false,
onOpenChange,
label,
arrow = true,
className,
}: PopoverProps) {
const id = React.useId();
const reduce = useReducedMotion();
const [inner, setInner] = React.useState(defaultOpen);
const open = openProp ?? inner;
const { anchorRef, floatingRef, place } = useFloating(open, side, align, offset);
const timer = React.useRef<number | undefined>(undefined);
const focusOnOpen = React.useRef(false);
const setOpen = React.useCallback(
(v: boolean) => {
if (openProp === undefined) setInner(v);
onOpenChange?.(v);
},
[openProp, onOpenChange],
);
const close = React.useCallback(
(restore = true) => {
setOpen(false);
if (restore) anchorRef.current?.focus();
},
[setOpen, anchorRef],
);
// dismissal: Escape, outside press
React.useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
close(true);
}
};
const onDown = (e: PointerEvent) => {
const t = e.target as Node;
if (!floatingRef.current?.contains(t) && !anchorRef.current?.contains(t)) close(false);
};
window.addEventListener("keydown", onKey);
document.addEventListener("pointerdown", onDown);
return () => {
window.removeEventListener("keydown", onKey);
document.removeEventListener("pointerdown", onDown);
};
}, [open, close, floatingRef, anchorRef]);
// move focus inside click popovers once positioned
React.useEffect(() => {
if (!open || !place || !focusOnOpen.current || trigger !== "click") return;
focusOnOpen.current = false;
const f = floatingRef.current;
(f?.querySelector<HTMLElement>("[data-autofocus]") ?? f?.querySelector<HTMLElement>(FOCUSABLE) ?? f)?.focus({ preventScroll: true });
}, [open, place, trigger, floatingRef]);
React.useEffect(() => () => window.clearTimeout(timer.current), []);
const hoverOpen = (d = 320) => {
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setOpen(true), d);
};
const hoverClose = (d = 220) => {
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setOpen(false), d);
};
const handlers: Record<string, unknown> =
trigger === "click"
? {
"aria-haspopup": "dialog",
"aria-expanded": open,
"aria-controls": open ? id : undefined,
onClick: () => {
focusOnOpen.current = !open;
setOpen(!open);
},
}
: {
"aria-expanded": open,
"aria-controls": open ? id : undefined,
onPointerEnter: (e: React.PointerEvent) => e.pointerType === "mouse" && hoverOpen(),
onPointerLeave: () => hoverClose(),
onFocus: (e: React.FocusEvent<HTMLElement>) => e.currentTarget.matches(":focus-visible") && hoverOpen(0),
onBlur: (e: React.FocusEvent) => {
if (!floatingRef.current?.contains(e.relatedTarget as Node)) hoverClose(80);
},
};
const el = withHandlers(children, handlers, (node) => {
anchorRef.current = node;
});
return (
<>
{el}
<AnimatePresence>
{open && (
<motion.div
ref={floatingRef}
id={id}
role="dialog"
aria-label={label}
tabIndex={-1}
onPointerEnter={trigger === "hover" ? () => window.clearTimeout(timer.current) : undefined}
onPointerLeave={trigger === "hover" ? () => hoverClose() : undefined}
onBlur={(e) => {
const to = e.relatedTarget as Node | null;
if (to && !e.currentTarget.contains(to) && !anchorRef.current?.contains(to)) close(false);
}}
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.92, ...nudge(place?.side ?? side, 6) }}
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, transition: { duration: 0.12 } }}
transition={{ type: "spring", stiffness: 480, damping: 30 }}
style={{
position: "fixed",
left: place?.x ?? 0,
top: place?.y ?? 0,
visibility: place ? "visible" : "hidden",
transformOrigin: place ? originFor(place) : undefined,
}}
className={cn(
"z-50 w-max max-w-[min(22rem,calc(100vw-1rem))] rounded-xl border bg-popover p-4 text-sm text-popover-foreground shadow-xl shadow-black/10 outline-none dark:shadow-black/40",
className,
)}
>
{typeof content === "function" ? content(() => close(true)) : content}
{arrow && place && <Arrow side={place.side} pos={place.arrow} className="bg-popover" />}
</motion.div>
)}
</AnimatePresence>
</>
);
}