SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
yesterdaylast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
7.4 KB · 187 lines tsx
Raw Blame History
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