import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { t as tr } from './i18n';
type Kind = 'success' | 'error';
type Notice = { id: number; message: string; kind: Kind };
let sequence = 0;
const listeners = new Set<(notice: Notice) => void>();
export function showToast(message: string, kind: Kind) {
if (message) {
const notice = { id: ++sequence, message, kind };
listeners.forEach((fn) => fn(notice));
}
}
export function useToast(message: string, kind: Kind) {
useEffect(() => {
showToast(message, kind);
}, [message, kind]);
}
function ToastItem({ notice, remove }: { notice: Notice; remove: (id: number) => void }) {
useEffect(() => {
const timer = setTimeout(() => remove(notice.id), notice.kind === 'error' ? 12000 : 6000);
return () => clearTimeout(timer);
}, [notice, remove]);
return (
{notice.kind === 'error' ? '!' : '✓'}
{tr(notice.message)}
);
}
export function ToastViewport() {
const [notices, setNotices] = useState([]);
const panel = useRef(null);
const [host, setHost] = useState(document.body);
useEffect(() => {
// A native modal makes content outside it inert. Keep the live notification
// and its dismiss control within the current dialog, while the popover
// renders them above the dialog in the browser's top layer.
const updateHost = () => {
const dialogs = document.querySelectorAll(
'dialog[open], [role="dialog"][aria-modal="true"]',
);
const next = dialogs.item(dialogs.length - 1) ?? document.body;
setHost((current) => (current === next ? current : next));
};
const observer = new MutationObserver(updateHost);
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['open'],
});
updateHost();
return () => observer.disconnect();
}, []);
const remove = useRef((id: number) =>
setNotices((rows) => rows.filter((n) => n.id !== id)),
).current;
useEffect(() => {
const receive = (n: Notice) =>
setNotices((rows) => [...rows.filter((v) => v.message !== n.message).slice(-2), n]);
listeners.add(receive);
return () => {
listeners.delete(receive);
};
}, []);
useEffect(() => {
const el = panel.current;
if (!el) return;
if (notices.length) {
el.hidePopover?.();
el.showPopover?.();
} else el.hidePopover?.();
}, [notices, host]);
return createPortal(
{notices.map((notice) => (
))}
,
host,
);
}