spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1"use client";23import type { ProvenanceDTO } from "@dci/core";4import { ExternalLink, Info } from "lucide-react";5import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";6import { createPortal } from "react-dom";7import { fmtDate, fmtValue } from "@/lib/format";8import { CONFIDENCE_LABEL, SOURCE_KIND_LABEL } from "@/lib/labels";9import { cn } from "./cn";10import { ConfidenceBadge } from "./badge";1112type Placement = "top" | "bottom";1314function usePosition(open: boolean, anchor: HTMLElement | null, panel: HTMLElement | null, prefer: Placement) {15 const [pos, setPos] = useState<{ top: number; left: number; placement: Placement } | null>(null);16 useLayoutEffect(() => {17 if (!open || !anchor || !panel) return;18 const compute = () => {19 const a = anchor.getBoundingClientRect();20 const p = panel.getBoundingClientRect();21 const vw = window.innerWidth;22 const vh = window.innerHeight;23 let placement = prefer;24 if (prefer === "top" && a.top - p.height - 8 < 8) placement = "bottom";25 if (prefer === "bottom" && a.bottom + p.height + 8 > vh - 8) placement = "top";26 const top = placement === "top" ? a.top - p.height - 6 : a.bottom + 6;27 let left = a.left + a.width / 2 - p.width / 2;28 left = Math.max(8, Math.min(left, vw - p.width - 8));29 setPos({ top: top + window.scrollY, left: left + window.scrollX, placement });30 };31 compute();32 window.addEventListener("resize", compute);33 window.addEventListener("scroll", compute, true);34 return () => {35 window.removeEventListener("resize", compute);36 window.removeEventListener("scroll", compute, true);37 };38 }, [open, anchor, panel, prefer]);39 return pos;40}4142/** Hover/focus tooltip (short text). Touch: tap toggles. */43export function Tooltip({ content, children, placement = "top", className }: { content: ReactNode; children: ReactNode; placement?: Placement; className?: string }) {44 const [open, setOpen] = useState(false);45 const [anchor, setAnchor] = useState<HTMLElement | null>(null);46 const [panel, setPanel] = useState<HTMLElement | null>(null);47 const id = useId();48 const pos = usePosition(open, anchor, panel, placement);49 return (50 <>51 <span52 ref={setAnchor}53 aria-describedby={open ? id : undefined}54 tabIndex={0}55 className={cn("inline-flex cursor-help", className)}56 onMouseEnter={() => setOpen(true)}57 onMouseLeave={() => setOpen(false)}58 onFocus={() => setOpen(true)}59 onBlur={() => setOpen(false)}60 onClick={() => setOpen((o) => !o)}61 >62 {children}63 </span>64 {open &&65 typeof document !== "undefined" &&66 createPortal(67 <div68 ref={setPanel}69 role="tooltip"70 id={id}71 style={pos ? { top: pos.top, left: pos.left } : { top: -9999, left: -9999 }}72 className="pointer-events-none absolute z-[90] max-w-[260px] rounded-sm border border-hair-2 bg-ink px-2 py-1.5 text-[11.5px] leading-snug text-paper shadow-none animate-fade-in dark:bg-surface-3 dark:text-ink"73 >74 {content}75 </div>,76 document.body,77 )}78 </>79 );80}8182/** Click popover with focus management and outside-click dismissal. */83export function Popover({ trigger, children, placement = "bottom", className, label }: { trigger: ReactNode; children: ReactNode; placement?: Placement; className?: string; label?: string }) {84 const [open, setOpen] = useState(false);85 const [anchor, setAnchor] = useState<HTMLElement | null>(null);86 const [panel, setPanel] = useState<HTMLElement | null>(null);87 const pos = usePosition(open, anchor, panel, placement);88 const id = useId();89 const btnRef = useRef<HTMLButtonElement | null>(null);9091 useEffect(() => {92 if (!open) return;93 const onDoc = (e: MouseEvent | TouchEvent) => {94 const t = e.target as Node;95 if (panel?.contains(t) || anchor?.contains(t)) return;96 setOpen(false);97 };98 const onKey = (e: KeyboardEvent) => {99 if (e.key === "Escape") {100 setOpen(false);101 btnRef.current?.focus();102 }103 };104 document.addEventListener("mousedown", onDoc);105 document.addEventListener("touchstart", onDoc, { passive: true });106 document.addEventListener("keydown", onKey);107 return () => {108 document.removeEventListener("mousedown", onDoc);109 document.removeEventListener("touchstart", onDoc);110 document.removeEventListener("keydown", onKey);111 };112 }, [open, panel, anchor]);113114 return (115 <>116 <button117 type="button"118 ref={(el) => {119 btnRef.current = el;120 setAnchor(el);121 }}122 aria-expanded={open}123 aria-controls={open ? id : undefined}124 aria-label={label}125 onClick={() => setOpen((o) => !o)}126 className={cn("inline-flex items-center rounded-xs", className)}127 >128 {trigger}129 </button>130 {open &&131 typeof document !== "undefined" &&132 createPortal(133 <div134 ref={setPanel}135 id={id}136 role="dialog"137 style={pos ? { top: pos.top, left: pos.left } : { top: -9999, left: -9999 }}138 className="absolute z-[90] w-[min(320px,calc(100vw-16px))] rounded-sm border border-hair-2 bg-surface p-3 text-[12.5px] text-ink animate-fade-in"139 >140 {children}141 </div>,142 document.body,143 )}144 </>145 );146}147148/** Provenance popover: source, URL, first/last observed, confidence, method, estimate flag. */149export function ProvenancePopover({ items, className, size = 13 }: { items: ProvenanceDTO[]; className?: string; size?: number }) {150 if (!items.length) return null;151 return (152 <Popover153 label={`Provenance (${items.length} ${items.length === 1 ? "source" : "sources"})`}154 className={cn("text-ink-4 hover:text-ink-2 max-sm:-m-3.5 max-sm:size-10 max-sm:justify-center", className)}155 trigger={<Info style={{ width: size, height: size }} strokeWidth={1.75} />}156 >157 <div className="label-caps mb-2">Provenance</div>158 <ul className="divide-y divide-hair">159 {items.map((p, i) => (160 <li key={i} className="py-2 first:pt-0 last:pb-0">161 <div className="flex items-start justify-between gap-2">162 <div className="min-w-0">163 <div className="truncate font-medium">{p.sourceName}</div>164 <div className="text-[11px] text-ink-3">165 {SOURCE_KIND_LABEL[p.sourceKind]}166 {p.method && <> · {p.method}</>}167 {p.isEstimate && <> · estimate</>}168 </div>169 </div>170 <ConfidenceBadge level={p.confidence} size="xs" />171 </div>172 <div className="mt-1 figure text-[11.5px] text-ink-2">{fmtValue(p.value)}</div>173 <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[11px] text-ink-3">174 <span title="First observed">first {fmtDate(p.firstObserved)}</span>175 <span title="Last observed">last {fmtDate(p.lastObserved)}</span>176 <a href={p.url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-ink-2 hover:text-accent">177 source <ExternalLink className="size-3" />178 </a>179 </div>180 </li>181 ))}182 </ul>183 <p className="mt-2 text-[10.5px] text-ink-4">{CONFIDENCE_LABEL[items[0]!.confidence]} — see methodology for how confidence is computed.</p>184 </Popover>185 );186}187