// ============================================================================ // Imara — shared widgets: Modal, Pager, Select, Toggle, Field, Empty, usePaged // ============================================================================ function Modal({ open, onClose, width = 860, children }) { useEffect(() => { if (!open) return; const h = (e) => e.key === "Escape" && onClose(); window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h); }, [open, onClose]); if (!open) return null; return (
e.stopPropagation()}> {children}
); } function Toggle({ on, onChange, size = 18 }) { return ( ); } function Select({ value, onChange, options, style }) { return (
); } function Field({ label, hint, children }) { return ( ); } // A small "?" that shows an explanation when clicked, so explanations do // not fill the page; it closes on a click elsewhere, Escape or scrolling. // Drawn on top of everything (a portal), so a card or list that clips its // content does not cut it off. Never put it inside another button. function Help({ children, label = "What does this mean?", width = 320 }) { const [at, setAt] = useState(null); // where the explanation shows, while open const btn = useRef(null); const pop = useRef(null); useEffect(() => { if (!at) return; const inside = (t) => (btn.current && btn.current.contains(t)) || (pop.current && pop.current.contains(t)); const away = (e) => { if (!inside(e.target)) setAt(null); }; const key = (e) => { if (e.key === "Escape") { e.stopPropagation(); setAt(null); if (btn.current) btn.current.focus(); } }; const moved = (e) => { if (!(pop.current && e.target && e.target.nodeType === 1 && pop.current.contains(e.target))) setAt(null); }; const resized = () => setAt(null); document.addEventListener("mousedown", away); window.addEventListener("keydown", key, true); window.addEventListener("scroll", moved, true); window.addEventListener("resize", resized); return () => { document.removeEventListener("mousedown", away); window.removeEventListener("keydown", key, true); window.removeEventListener("scroll", moved, true); window.removeEventListener("resize", resized); }; }, [at]); const toggle = (e) => { e.preventDefault(); e.stopPropagation(); if (at) { setAt(null); return; } const r = btn.current.getBoundingClientRect(); const left = Math.max(12, Math.min(r.left - 12, window.innerWidth - width - 12)); // Above the "?" when there is little room below it. setAt(window.innerHeight - r.bottom < 220 ? { left, bottom: window.innerHeight - r.top + 6 } : { left, top: r.bottom + 6 }); }; const stop = (e) => e.stopPropagation(); return ( {at && ReactDOM.createPortal(
{children}
, document.body)}
); } function Empty({ icon = "Inbox", text }) { return (
{text}
); } function Pager({ page, pageCount, total, onPage, unit = "rows", size }) { if (total === 0) return null; const btn = (to, disabled, children, title) => ( ); const range = size && pageCount > 1 ? `${(page * size + 1).toLocaleString("en-US")}-${Math.min(total, (page + 1) * size).toLocaleString("en-US")} of ${total.toLocaleString("en-US")} ${unit}` : `${total.toLocaleString("en-US")} ${unit}`; return (
{range} {pageCount > 1 && (
{btn(0, page === 0, , "First page")} {btn(page - 1, page === 0, Previous)} page {page + 1} of {pageCount} {btn(page + 1, page >= pageCount - 1, Next )} {btn(pageCount - 1, page >= pageCount - 1, , "Last page")}
)}
); } // resetKey: the list's search, filter and sort; the first page comes back // when it changes. Entries that arrive or go keep the page (clamped). function usePaged(rows, size = 12, resetKey) { const [page, setPage] = useState(0); const pageCount = Math.max(1, Math.ceil(rows.length / size)); const p = Math.min(page, pageCount - 1); const prevKey = useRef(resetKey); useEffect(() => { if (prevKey.current !== resetKey) { prevKey.current = resetKey; setPage(0); } }, [resetKey]); return { slice: rows.slice(p * size, p * size + size), page: p, pageCount, setPage, total: rows.length, size }; } // Confirmation dialog with an optional text field (e.g. a reject reason). // onConfirm(text) may be async: while it runs the dialog is busy; if it // throws, the server's error is shown and the dialog STAYS OPEN. It closes // only after onConfirm resolves. function ConfirmModal({ open, title, children, confirmLabel = "Confirm", tone = "primary", input, onConfirm, onClose }) { const [text, setText] = useState(""); const [busy, setBusy] = useState(false); const [err, setErr] = useState(""); useEffect(() => { if (open) { setText(""); setBusy(false); setErr(""); } }, [open]); if (!open) return null; const missing = !!(input && input.required && !text.trim()); const close = () => { if (!busy) onClose(); }; const go = async () => { if (missing || busy) return; setBusy(true); setErr(""); try { await onConfirm(text.trim()); setBusy(false); onClose(); } catch (e) { setErr((e && e.message) || "failed"); setBusy(false); } }; return (
{title}
{children &&
{children}
} {input && (