mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-05 02:41:45 +00:00
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>
311 lines
9.9 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|