siftlode/frontend/src/components/Modal.tsx
npeter83 14b8eb6084 fix(ux): modal error dialog for server-refused actions + ESC closes only the topmost
- Duplicate-tag create/rename hit the uq_tags_user_name constraint and 500'd; now caught and
  returned as a clear 409 ('You already have a tag named …').
- New global error dialog (errorDialog store + ErrorDialog modal) for definitive server refusals;
  the api layer wiring + mount land with the rest of the round.
- Modal now keeps a stack so ESC dismisses only the top modal — an error over the tag editor
  closes the error and leaves the editor open.
2026-06-18 01:17:31 +02:00

70 lines
2.2 KiB
TypeScript

import { useEffect, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
// Stack of open modals so ESC only closes the topmost one — e.g. an error dialog over the
// tag editor: pressing ESC dismisses just the error and returns to the editor underneath.
let modalStack: number[] = [];
let nextModalId = 1;
// Small centered modal shell (portaled to <body>): backdrop + ESC + scroll-lock close.
export default function Modal({
title,
onClose,
children,
maxWidth = "max-w-lg",
}: {
title: ReactNode;
onClose: () => void;
children: ReactNode;
maxWidth?: string;
}) {
// Keep a stable handler across renders so the stack id is assigned once per mount.
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
const id = nextModalId++;
modalStack.push(id);
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && modalStack[modalStack.length - 1] === id) {
e.stopPropagation();
onCloseRef.current();
}
};
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
modalStack = modalStack.filter((x) => x !== id);
document.body.style.overflow = prev;
};
}, []);
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-black/70 backdrop-blur-sm"
onClick={onClose}
role="dialog"
aria-modal="true"
>
<div
className={`glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl shadow-2xl`}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between gap-3 p-5 pb-3">
<h2 className="text-lg font-semibold leading-snug">{title}</h2>
<button
onClick={onClose}
title="Close"
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="px-5 pb-5">{children}</div>
</div>
</div>,
document.body
);
}