/* global React, Icon */
/* Overlays: Modal, Drawer, Popover, Toast, Empty, Loading, Error, Skeleton */
const { useState, useEffect, useRef, useCallback } = React;
function Modal({ open, onClose, title, eyebrow, children, footer, width = 520 }) {
useEffect(() => {
if (!open) return;
const onKey = (e) => e.key === "Escape" && onClose && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
e.stopPropagation()}
style={{
background: "var(--bg)", color: "var(--ink)", maxWidth: width, width: "100%",
border: "1px solid var(--ink)", borderRadius: "var(--r-sharp)",
animation: "popIn 220ms cubic-bezier(.2,.7,.2,1)", maxHeight: "85vh", overflow: "auto",
}} className="scroll"
>
{eyebrow && {eyebrow}}
{title &&
{title}
}
{children}
{footer && (
{footer}
)}
);
}
function Drawer({ open, onClose, title, eyebrow, children, footer, width = 520 }) {
useEffect(() => {
if (!open) return;
const onKey = (e) => e.key === "Escape" && onClose && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
e.stopPropagation()}
style={{
position: "absolute", right: 0, top: 0, bottom: 0, width, maxWidth: "94vw",
background: "var(--bg)", borderLeft: "1px solid var(--line-2)",
display: "flex", flexDirection: "column",
animation: "slideInR 280ms cubic-bezier(.2,.7,.2,1)",
}}>
{eyebrow &&
{eyebrow}
}
{title}
{children}
{footer && (
{footer}
)}
);
}
function Popover({ trigger, children, align = "right" }) {
const [open, setOpen] = useState(false);
const ref = useRef();
useEffect(() => {
if (!open) return;
const onClick = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
setTimeout(() => document.addEventListener("click", onClick), 0);
return () => document.removeEventListener("click", onClick);
}, [open]);
return (
setOpen(!open)} style={{ display: "inline-block" }}>{trigger}
{open && (
{typeof children === "function" ? children({ close: () => setOpen(false) }) : children}
)}
);
}
function PopoverItem({ icon, label, shortcut, danger, onClick }) {
return (
);
}
/* ---------- Toasts ---------- */
const ToastCtx = React.createContext(null);
function ToastHost({ children }) {
const [items, setItems] = useState([]);
const push = useCallback((t) => {
const id = Math.random().toString(36).slice(2);
setItems((prev) => [...prev, { id, ...t }]);
setTimeout(() => setItems((prev) => prev.filter(x => x.id !== id)), t.duration || 3500);
}, []);
return (
{children}
{items.map(t => (
{t.title &&
{t.title}
}
{t.body &&
{t.body}
}
{t.action && (
)}
))}
);
}
function useToast() { return React.useContext(ToastCtx); }
/* ---------- Empty / Loading / Error states ---------- */
function EmptyState({ eyebrow = "Nothing yet", title, body, action, illustration }) {
return (
{illustration || (
)}
{eyebrow}
{title}
{body &&
{body}
}
{action &&
{action}
}
);
}
function LoadingState({ label = "Loading", body }) {
return (
);
}
function ErrorState({ title = "Something went wrong", body, action }) {
return (
Error
{title}
{body &&
{body}
}
{action &&
{action}
}
);
}
function Skeleton({ w = "100%", h = 14, radius = 2, circle, style }) {
return (
);
}
/* ---------- ErrorBoundary ---------- */
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, info) {
if (this.props.onError) this.props.onError(error, info);
}
reset = () => this.setState({ hasError: false, error: null });
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return typeof this.props.fallback === "function"
? this.props.fallback({ error: this.state.error, reset: this.reset })
: this.props.fallback;
}
return (
Component error
{String(this.state.error?.message || this.state.error || "Something went wrong")}
);
}
return this.props.children;
}
}
/* ---------- Banner ---------- */
function Banner({ tone = "info", title, body, action, onClose }) {
const tones = {
info: { color: "var(--info)", bg: "var(--info-bg)" },
pos: { color: "var(--pos)", bg: "var(--pos-bg)" },
warn: { color: "var(--warn)", bg: "var(--warn-bg)" },
neg: { color: "var(--neg)", bg: "var(--neg-bg)" },
grad: { color: "white", bg: "var(--brand-grad-band)" },
};
const t = tones[tone];
return (
{title &&
{title}
}
{body &&
{body}
}
{action}
{onClose && (
)}
);
}
Object.assign(window, { Modal, Drawer, Popover, PopoverItem, ToastHost, useToast, EmptyState, LoadingState, ErrorState, Skeleton, Banner, ErrorBoundary });