Files
WorthPath/apps/web/src/Toast.tsx
T

94 lines
3.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className={'toast ' + notice.kind} role={notice.kind === 'error' ? 'alert' : 'status'}>
<span className="toast-symbol" aria-hidden="true">
{notice.kind === 'error' ? '!' : '✓'}
</span>
<p>{tr(notice.message)}</p>
<button
type="button"
className="icon"
aria-label={tr('关闭')}
onClick={() => remove(notice.id)}
>
×
</button>
</div>
);
}
export function ToastViewport() {
const [notices, setNotices] = useState<Notice[]>([]);
const panel = useRef<HTMLDivElement>(null);
const [host, setHost] = useState<HTMLElement>(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<HTMLElement>(
'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(
<div ref={panel} popover="manual" className="toast-viewport" aria-label={tr('操作提示')}>
{notices.map((notice) => (
<ToastItem key={notice.id} notice={notice} remove={remove} />
))}
</div>,
host,
);
}