fabro/apps/fabro-web/app/components/ui.tsx
Bryan Helmkamp 96356a9a93
Show stage details in hover popover on run overview graph nodes
Reuses the StagePopover from the stages sidebar so graph nodes reveal
the same handler, timing, model, and status-specific detail on hover.
The graph is server-rendered SVG injected via innerHTML, so listeners
are attached imperatively alongside the existing click handlers; the
popover is portal-positioned via the shared hoverCardStyle helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:05:37 -04:00

311 lines
9.9 KiB
TypeScript

// Shared UI primitives. The install wizard set the visual baseline; this file
// exposes the primary button, secondary button, input, error message, and
// copy button so the auth and in-app surfaces can match.
import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react";
import {
ClipboardDocumentCheckIcon,
ClipboardIcon,
} from "@heroicons/react/16/solid";
export const INPUT_CLASS =
"block w-full rounded-lg bg-panel-alt px-3.5 py-2.5 text-base text-fg outline-1 -outline-offset-1 outline-white/10 placeholder:text-fg-muted focus:outline-2 focus:-outline-offset-1 focus:outline-teal-500 sm:text-sm";
export const PRIMARY_BUTTON_CLASS =
"inline-flex items-center justify-center gap-2 rounded-lg bg-teal-500 px-4 py-2 text-sm font-medium text-on-primary transition-colors hover:bg-teal-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-teal-500";
export const SECONDARY_BUTTON_CLASS =
"inline-flex items-center justify-center gap-2 rounded-lg bg-transparent px-3.5 py-2 text-sm font-medium text-fg-2 outline-1 -outline-offset-1 outline-white/10 hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60";
export const DANGER_BUTTON_CLASS =
"inline-flex items-center justify-center gap-2 rounded-lg bg-coral px-4 py-2 text-sm font-medium text-on-primary transition-colors hover:bg-coral/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-coral disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-coral";
export const COMPACT_SECONDARY_BUTTON_CLASS =
"rounded-md border border-line bg-overlay px-2.5 py-1 text-xs text-fg-2 transition-colors hover:bg-overlay-strong hover:text-fg disabled:cursor-not-allowed disabled:opacity-50";
export function ErrorMessage({ message }: { message: string }) {
return (
<p
role="alert"
className="rounded-md bg-coral/10 px-3 py-2 text-sm/6 text-fg-2 outline-1 -outline-offset-1 outline-coral/40"
>
{message}
</p>
);
}
export function FormField({
label,
htmlFor,
help,
children,
}: {
label: string;
htmlFor: string;
help?: ReactNode;
children: ReactNode;
}) {
return (
<div>
<label htmlFor={htmlFor} className="mb-1.5 block text-sm font-medium text-fg-2">
{label}
</label>
{children}
{help ? <p className="mt-1.5 text-xs/5 text-fg-3 text-pretty">{help}</p> : null}
</div>
);
}
export function CopyButton({
value,
label,
className = "",
}: {
value: string;
label: string;
className?: string;
}) {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard may be blocked; leave state unchanged.
}
}}
className={`inline-flex size-6 shrink-0 items-center justify-center rounded text-fg-muted outline-teal-500 hover:bg-overlay hover:text-fg-2 focus-visible:outline-2 focus-visible:outline-offset-1 ${className}`}
aria-label={copied ? "Copied" : label}
title={copied ? "Copied" : label}
>
{copied ? (
<ClipboardDocumentCheckIcon className="size-4 text-mint" />
) : (
<ClipboardIcon className="size-4" />
)}
</button>
);
}
export function ConfirmDialog({
open,
title,
description,
confirmLabel,
pendingLabel,
cancelLabel = "Cancel",
pending = false,
onConfirm,
onCancel,
}: {
open: boolean;
title: string;
description: ReactNode;
confirmLabel: string;
pendingLabel?: string;
cancelLabel?: string;
pending?: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<Dialog
open={open}
onClose={() => {
if (!pending) onCancel();
}}
className="relative z-50"
>
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" aria-hidden="true" />
<div className="fixed inset-0 flex items-start justify-center pt-[20vh] px-4">
<DialogPanel className="w-full max-w-md rounded-lg border border-line-strong bg-panel shadow-2xl shadow-black/40">
<div className="px-5 py-4">
<DialogTitle className="text-sm font-semibold text-fg">{title}</DialogTitle>
<div className="mt-2 text-sm text-fg-3">{description}</div>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
disabled={pending}
className={SECONDARY_BUTTON_CLASS}
>
{cancelLabel}
</button>
<button
type="button"
onClick={onConfirm}
disabled={pending}
className={DANGER_BUTTON_CLASS}
>
{pending ? (pendingLabel ?? `${confirmLabel}…`) : confirmLabel}
</button>
</div>
</div>
</DialogPanel>
</div>
</Dialog>
);
}
// Shared hover/focus state for `Tooltip` and `HoverCard`. Returns the trigger
// props to spread and the trigger rect (only while open) for positioning.
// `openDelay` (ms) defers showing the card until the pointer has dwelled on
// the trigger long enough — protects against fetch-on-mount popovers being
// triggered by an incidental cursor sweep through a list.
function useHoverAnchor(openDelay = 0) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLSpanElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimer = () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
const requestOpen = () => {
if (openDelay <= 0) {
setOpen(true);
return;
}
clearTimer();
timerRef.current = setTimeout(() => {
timerRef.current = null;
setOpen(true);
}, openDelay);
};
const requestClose = () => {
clearTimer();
setOpen(false);
};
useEffect(() => clearTimer, []);
const rect = open ? (triggerRef.current?.getBoundingClientRect() ?? null) : null;
const triggerProps = {
ref: triggerRef,
onMouseEnter: requestOpen,
onMouseLeave: requestClose,
onFocus: requestOpen,
onBlur: requestClose,
};
return { open, rect, triggerProps };
}
export function Tooltip({
label,
children,
}: {
label: ReactNode;
children: ReactNode;
}) {
const { open, rect, triggerProps } = useHoverAnchor();
const id = useId();
const portalTarget = typeof document === "undefined" ? null : document.body;
return (
<>
<span {...triggerProps} aria-describedby={open ? id : undefined} className="inline-flex">
{children}
</span>
{rect && portalTarget
? createPortal(
<div
role="tooltip"
id={id}
style={{
top: rect.top - 6,
left: rect.left + rect.width / 2,
}}
className="pointer-events-none fixed z-50 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded-md bg-panel px-2 py-1 text-xs text-fg-2 shadow-lg outline-1 -outline-offset-1 outline-line-strong"
>
{label}
</div>,
portalTarget,
)
: null}
</>
);
}
// Anchors the card just below the trigger. Triggers in the left half of the
// viewport grow rightward from their left edge; triggers in the right half
// grow leftward from their right edge — keeping the card on-screen without
// measuring its width.
export function hoverCardStyle(rect: DOMRect): CSSProperties {
const margin = 12;
const top = rect.bottom + 6;
if (rect.left > window.innerWidth / 2) {
return { top, right: Math.max(margin, window.innerWidth - rect.right) };
}
return { top, left: Math.max(margin, rect.left) };
}
/**
* Hover-triggered rich popover. Unlike `Tooltip`, it anchors below the trigger,
* stays within the viewport, and allows multi-line wrapping content. The
* `content` node is mounted only while open, so consumers may fetch lazily.
*/
export function HoverCard({
content,
children,
className = "inline-flex",
openDelay = 0,
}: {
content: ReactNode;
children: ReactNode;
className?: string;
openDelay?: number;
}) {
const { open, rect, triggerProps } = useHoverAnchor(openDelay);
const id = useId();
const portalTarget = typeof document === "undefined" ? null : document.body;
return (
<>
<span {...triggerProps} aria-describedby={open ? id : undefined} className={className}>
{children}
</span>
{rect && portalTarget
? createPortal(
<div
role="tooltip"
id={id}
style={hoverCardStyle(rect)}
className="pointer-events-none fixed z-50 max-w-[18rem] rounded-lg bg-panel p-3 text-xs text-fg-2 shadow-xl outline-1 -outline-offset-1 outline-line-strong"
>
{content}
</div>,
portalTarget,
)
: null}
</>
);
}
export function PopoverHeader({ children }: { children: ReactNode }) {
return (
<div className="mb-1.5 border-b border-line pb-1 font-medium text-fg-2">
{children}
</div>
);
}
export function PopoverRows({ children }: { children: ReactNode }) {
return <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">{children}</dl>;
}
export function PopoverRow({ label, children }: { label: string; children: ReactNode }) {
return (
<>
<dt className="text-fg-3">{label}</dt>
<dd className="min-w-0 text-fg">{children}</dd>
</>
);
}