"use client"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { cloneElement, createContext, isValidElement, type MutableRefObject, type ReactElement, type FocusEvent as ReactFocusEvent, type ReactNode, type PointerEvent as ReactPointerEvent, type Ref, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, } from "react"; import { createPortal } from "react-dom"; import { type PlacementAlign, type PlacementSide, resolvePlacement, } from "@/components/motion/anchored-placement"; import { usePopoverPortalPosition } from "@/components/motion/popover-position"; import { EASE_OUT, SPRING_PANEL } from "@/lib/ease"; import { useHoverGesture } from "@/lib/hooks/use-hover-gesture"; import { cn } from "@/lib/utils"; interface HoverCardContextValue { open: boolean; contentId: string; triggerRef: MutableRefObject; contentRef: MutableRefObject; /** Open after `openDelay` (or at once when `immediate`). */ scheduleOpen: (immediate?: boolean) => void; /** Close after `closeDelay` — cancelled if the pointer comes back. */ scheduleClose: () => void; cancel: () => void; close: () => void; reduce: boolean; } const HoverCardContext = createContext(null); function useHoverCardContext(component: string) { const ctx = useContext(HoverCardContext); if (!ctx) throw new Error(`${component} must be used within `); return ctx; } export interface HoverCardProps { children: ReactNode; /** Controlled open state. */ open?: boolean; /** Uncontrolled initial open state. */ defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; /** Hover time before the card opens, in ms. Default 500. */ openDelay?: number; /** Time after the pointer leaves before the card closes, in ms. Default 300. */ closeDelay?: number; className?: string; } /** * A rich preview shown while the pointer rests on a trigger (usually a link). * * - Opens after `openDelay`, closes `closeDelay` after the pointer leaves both * the trigger and the card, so the pointer can travel into the card. * - Opens when the trigger receives keyboard focus; closes on Escape or blur. * - Touch has no hover: a tap is just a tap and the trigger behaves as the * link it is. The card is supplementary — never put the only path to an * action inside it. */ export function HoverCard({ children, open: controlledOpen, defaultOpen = false, onOpenChange, openDelay = 500, closeDelay = 300, className, }: HoverCardProps) { const [internalOpen, setInternalOpen] = useState(defaultOpen); const controlled = controlledOpen !== undefined; const open = controlled ? controlledOpen : internalOpen; const triggerRef = useRef(null); const contentRef = useRef(null); const timer = useRef | null>(null); const contentId = useId(); const reduce = useReducedMotion() ?? false; const setOpen = useCallback( (next: boolean) => { if (!controlled) setInternalOpen(next); onOpenChange?.(next); }, [controlled, onOpenChange], ); const cancel = useCallback(() => { if (timer.current) clearTimeout(timer.current); timer.current = null; }, []); const scheduleOpen = useCallback( (immediate = false) => { cancel(); if (open) return; if (immediate) { setOpen(true); return; } timer.current = setTimeout(() => { timer.current = null; setOpen(true); }, openDelay); }, [cancel, open, openDelay, setOpen], ); const scheduleClose = useCallback(() => { cancel(); if (!open) return; timer.current = setTimeout(() => { timer.current = null; setOpen(false); }, closeDelay); }, [cancel, closeDelay, open, setOpen]); const close = useCallback(() => { cancel(); setOpen(false); }, [cancel, setOpen]); useEffect(() => cancel, [cancel]); useEffect(() => { if (!open) return; const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") close(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open, close]); const ctx = useMemo( () => ({ open, contentId, triggerRef, contentRef, scheduleOpen, scheduleClose, cancel, close, reduce, }), [open, contentId, scheduleOpen, scheduleClose, cancel, close, reduce], ); return ( {children} ); } type TriggerChildProps = React.HTMLAttributes & { ref?: Ref; [key: `data-${string}`]: string | undefined; }; export interface HoverCardTriggerProps { /** A single focusable element, usually an ``. Its own handlers still run. */ children: ReactElement; className?: string; } function assignRef(ref: Ref | undefined, value: T | null) { if (typeof ref === "function") ref(value); else if (ref) (ref as MutableRefObject).current = value; } export function HoverCardTrigger({ children, className }: HoverCardTriggerProps) { const ctx = useHoverCardContext("HoverCardTrigger"); const hover = useHoverGesture(); if (!isValidElement(children)) { throw new Error(" requires a single React element"); } const childProps = children.props; const childRef = childProps.ref; return cloneElement(children, { ref: (node: HTMLElement | null) => { ctx.triggerRef.current = node; assignRef(childRef, node); }, "data-state": ctx.open ? "open" : "closed", className: cn(childProps.className, className), // Pointer events, filtered through useHoverGesture: a finger never rests, // so touch never schedules an open and the tap reaches the link intact. onPointerEnter: (event: ReactPointerEvent) => { childProps.onPointerEnter?.(event); if (hover.enter(event)) ctx.scheduleOpen(); }, onPointerLeave: (event: ReactPointerEvent) => { childProps.onPointerLeave?.(event); if (hover.leave(event)) ctx.scheduleClose(); }, onFocus: (event: ReactFocusEvent) => { childProps.onFocus?.(event); // Only keyboard focus opens it; a click's focus is already a hover. if (event.currentTarget.matches(":focus-visible")) ctx.scheduleOpen(); }, onBlur: (event: ReactFocusEvent) => { childProps.onBlur?.(event); const next = event.relatedTarget as Node | null; if (next && ctx.contentRef.current?.contains(next)) return; ctx.close(); }, }); } export interface HoverCardContentProps { children: ReactNode; /** Preferred side; flips when the viewport lacks room. Default "bottom". */ side?: PlacementSide; /** Default "center". */ align?: PlacementAlign; /** Gap between trigger and card, in px. Default 8. */ sideOffset?: number; alignOffset?: number; className?: string; } // Distance the card travels in from the trigger side on entrance. const ENTER_SHIFT = 6; function shiftFor(side: PlacementSide) { if (side === "bottom") return { x: 0, y: -ENTER_SHIFT }; if (side === "top") return { x: 0, y: ENTER_SHIFT }; if (side === "right") return { x: -ENTER_SHIFT, y: 0 }; return { x: ENTER_SHIFT, y: 0 }; } export function HoverCardContent({ children, side = "bottom", align = "center", sideOffset = 8, alignOffset = 0, className, }: HoverCardContentProps) { const ctx = useHoverCardContext("HoverCardContent"); const hover = useHoverGesture(); const [portalReady, setPortalReady] = useState(false); const layout = usePopoverPortalPosition( ctx.triggerRef, ctx.contentRef, portalReady && ctx.open, ); useEffect(() => setPortalReady(true), []); if (!portalReady) return null; const placement = layout ? resolvePlacement(layout, { side, align, sideOffset, alignOffset }) : null; // The requested side seeds the entrance offset: the first frame mounts // before measurement, and a flip is the rare case. const shift = shiftFor(side); return createPortal( {ctx.open ? ( { if (hover.enter(event)) ctx.cancel(); }} onPointerLeave={(event) => { if (hover.leave(event)) ctx.scheduleClose(); }} onBlur={(event) => { const next = event.relatedTarget as Node | null; if ( next && (event.currentTarget.contains(next) || ctx.triggerRef.current?.contains(next)) ) return; ctx.close(); }} className={cn( "fixed z-[100] w-72 rounded-2xl border border-border bg-card p-4 text-card-foreground shadow-[0_20px_44px_-14px_rgba(0,0,0,0.3)]", className, )} > {children} ) : null} , document.body, ); }