"use client"; /** * TASK-0124 — shared chrome for the three database-AI dialogs * (generate / extract / formula). Reuses the exact a11y + focus-trap + * error-banner + loading-skeleton semantics from `writing-diff.tsx` * (TASK-0123) so the database-AI surfaces are consistent with the writing * surfaces: role="dialog" aria-modal, focus trap, Escape closes (and aborts), * focus restored to the trigger, and an aria-live region announcing the * result / error. * * Pinned testids (do not rename): `db-ai-error-banner`, `db-ai-loading`. */ import { useCallback, useEffect, useRef } from "react"; import type { ReactNode } from "react"; import { AlertTriangle, Loader2, X } from "lucide-react"; export interface DbAiDialogProps { open: boolean; /** Dialog title (the header + aria-label). */ title: string; testId: string; onClose: () => void; /** Body + footer content. */ children: ReactNode; /** Optional footer row (the action buttons). */ footer?: ReactNode; /** The loading skeleton caption (e.g. "Generating…"). */ loadingText?: string; /** aria-live summary (announced, not just painted). */ live: string; } export function DbAiDialog({ open, title, testId, onClose, children, footer, loadingText, live }: DbAiDialogProps) { const dialogRef = useRef(null); const triggerRef = useRef(null); const close = useCallback(() => { onClose(); requestAnimationFrame(() => { triggerRef.current?.focus(); triggerRef.current = null; }); }, [onClose]); useEffect(() => { if (!open) return; const prev = document.activeElement as HTMLElement | null; triggerRef.current = prev; requestAnimationFrame(() => dialogRef.current?.querySelector("button, [tabindex], textarea, input")?.focus()); const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); close(); } else if (e.key === "Tab" && dialogRef.current) { const focusables = dialogRef.current.querySelectorAll('button, [tabindex]:not([tabindex="-1"]), textarea, input'); if (focusables.length === 0) return; const first = focusables[0]; const last = focusables[focusables.length - 1]; if (!first || !last) return; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open, close]); if (!open) return null; return (

{title}

{children}
{footer ?
{footer}
: null}
{live}
); } /** The canonical AI error banner (budget / rate-limit / validation / generic). */ export function DbAiErrorBanner({ code, message, budget, retryable, onRetry }: { code: string; message: string; budget?: boolean; retryable?: boolean; onRetry?: () => void }) { const heading = budget ? "Monthly AI budget reached" : code === "RATE_LIMITED" ? "Sending too fast" : code === "FORBIDDEN" ? "You don't have permission" : code === "AI_BUDGET_EXCEEDED" || code === "AI_BUDGET_MONTHLY_EXCEEDED" ? "Monthly AI budget reached" : "Something went wrong"; return (

{heading}

{message}

{budget ? (

Your workspace has hit its monthly token limit. Ask an admin to raise it in Settings → AI.

) : null} {code === "FORBIDDEN" ? (

You need edit access to this workspace to use AI. Ask an admin for access.

) : null}
{retryable && onRetry ? ( ) : null}
); } /** The streaming loading skeleton (the shimmer rows + a "Generating…" line). */ export function DbAiLoading({ text, rows = 3 }: { text?: string; rows?: number }) { return (

{text ?? "Generating…"}

{Array.from({ length: rows }).map((_, i) => (
))}
); }