"use client"; import type { ProvenanceDTO } from "@dci/core"; import { ExternalLink, Info } from "lucide-react"; import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { fmtDate, fmtValue } from "@/lib/format"; import { CONFIDENCE_LABEL, SOURCE_KIND_LABEL } from "@/lib/labels"; import { cn } from "./cn"; import { ConfidenceBadge } from "./badge"; type Placement = "top" | "bottom"; function usePosition(open: boolean, anchor: HTMLElement | null, panel: HTMLElement | null, prefer: Placement) { const [pos, setPos] = useState<{ top: number; left: number; placement: Placement } | null>(null); useLayoutEffect(() => { if (!open || !anchor || !panel) return; const compute = () => { const a = anchor.getBoundingClientRect(); const p = panel.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; let placement = prefer; if (prefer === "top" && a.top - p.height - 8 < 8) placement = "bottom"; if (prefer === "bottom" && a.bottom + p.height + 8 > vh - 8) placement = "top"; const top = placement === "top" ? a.top - p.height - 6 : a.bottom + 6; let left = a.left + a.width / 2 - p.width / 2; left = Math.max(8, Math.min(left, vw - p.width - 8)); setPos({ top: top + window.scrollY, left: left + window.scrollX, placement }); }; compute(); window.addEventListener("resize", compute); window.addEventListener("scroll", compute, true); return () => { window.removeEventListener("resize", compute); window.removeEventListener("scroll", compute, true); }; }, [open, anchor, panel, prefer]); return pos; } /** Hover/focus tooltip (short text). Touch: tap toggles. */ export function Tooltip({ content, children, placement = "top", className }: { content: ReactNode; children: ReactNode; placement?: Placement; className?: string }) { const [open, setOpen] = useState(false); const [anchor, setAnchor] = useState(null); const [panel, setPanel] = useState(null); const id = useId(); const pos = usePosition(open, anchor, panel, placement); return ( <> setOpen(true)} onMouseLeave={() => setOpen(false)} onFocus={() => setOpen(true)} onBlur={() => setOpen(false)} onClick={() => setOpen((o) => !o)} > {children} {open && typeof document !== "undefined" && createPortal( , document.body, )} ); } /** Click popover with focus management and outside-click dismissal. */ export function Popover({ trigger, children, placement = "bottom", className, label }: { trigger: ReactNode; children: ReactNode; placement?: Placement; className?: string; label?: string }) { const [open, setOpen] = useState(false); const [anchor, setAnchor] = useState(null); const [panel, setPanel] = useState(null); const pos = usePosition(open, anchor, panel, placement); const id = useId(); const btnRef = useRef(null); useEffect(() => { if (!open) return; const onDoc = (e: MouseEvent | TouchEvent) => { const t = e.target as Node; if (panel?.contains(t) || anchor?.contains(t)) return; setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { setOpen(false); btnRef.current?.focus(); } }; document.addEventListener("mousedown", onDoc); document.addEventListener("touchstart", onDoc, { passive: true }); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("touchstart", onDoc); document.removeEventListener("keydown", onKey); }; }, [open, panel, anchor]); return ( <> {open && typeof document !== "undefined" && createPortal( , document.body, )} ); } /** Provenance popover: source, URL, first/last observed, confidence, method, estimate flag. */ export function ProvenancePopover({ items, className, size = 13 }: { items: ProvenanceDTO[]; className?: string; size?: number }) { if (!items.length) return null; return ( } >
Provenance
    {items.map((p, i) => (
  • {p.sourceName}
    {SOURCE_KIND_LABEL[p.sourceKind]} {p.method && <> · {p.method}} {p.isEstimate && <> · estimate}
    {fmtValue(p.value)}
    first {fmtDate(p.firstObserved)} last {fmtDate(p.lastObserved)} source
  • ))}

{CONFIDENCE_LABEL[items[0]!.confidence]} — see methodology for how confidence is computed.

); }