Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
95 lines
3.0 KiB
JavaScript
95 lines
3.0 KiB
JavaScript
/**
|
|
* Toast primitive + ToastContainer
|
|
* Variants: success, error, info, warn
|
|
* Auto-dismiss, hover-pause, max 5 visible, stacks bottom-right
|
|
*/
|
|
const { html } = window;
|
|
const { useState, useEffect, useRef, useCallback } = hooks;
|
|
const { h, render } = preact;
|
|
|
|
// ── Singleton state ──────────────────────────
|
|
|
|
let _toasts = [];
|
|
let _id = 0;
|
|
let _setToasts = null;
|
|
|
|
export function toast(message, variant = 'info', duration) {
|
|
if (!duration) duration = variant === 'error' ? 5000 : 3000;
|
|
const id = ++_id;
|
|
const t = { id, message, variant, duration };
|
|
_toasts = [..._toasts, t];
|
|
if (_toasts.length > 5) _toasts = _toasts.slice(-5);
|
|
if (_setToasts) _setToasts([..._toasts]);
|
|
return id;
|
|
}
|
|
|
|
toast.success = (msg, dur) => toast(msg, 'success', dur);
|
|
toast.error = (msg, dur) => toast(msg, 'error', dur);
|
|
toast.info = (msg, dur) => toast(msg, 'info', dur);
|
|
toast.warn = (msg, dur) => toast(msg, 'warn', dur);
|
|
|
|
function dismiss(id) {
|
|
_toasts = _toasts.filter(t => t.id !== id);
|
|
if (_setToasts) _setToasts([..._toasts]);
|
|
}
|
|
|
|
// ── Single Toast ─────────────────────────────
|
|
|
|
function ToastItem({ id, message, variant, duration }) {
|
|
const [exiting, setExiting] = useState(false);
|
|
const timer = useRef(null);
|
|
const hovered = useRef(false);
|
|
|
|
const startTimer = useCallback(() => {
|
|
clearTimeout(timer.current);
|
|
timer.current = setTimeout(() => {
|
|
setExiting(true);
|
|
setTimeout(() => dismiss(id), 200);
|
|
}, duration);
|
|
}, [id, duration]);
|
|
|
|
useEffect(() => {
|
|
startTimer();
|
|
return () => clearTimeout(timer.current);
|
|
}, [startTimer]);
|
|
|
|
const onEnter = useCallback(() => {
|
|
hovered.current = true;
|
|
clearTimeout(timer.current);
|
|
}, []);
|
|
|
|
const onLeave = useCallback(() => {
|
|
hovered.current = false;
|
|
startTimer();
|
|
}, [startTimer]);
|
|
|
|
const cls = `sw-toast sw-toast--${variant} ${exiting ? 'sw-toast--exit' : 'sw-toast--enter'}`;
|
|
return html`
|
|
<div class=${cls} role="alert"
|
|
onMouseEnter=${onEnter}
|
|
onMouseLeave=${onLeave}>
|
|
<span class="sw-toast__msg">${message}</span>
|
|
<button class="sw-toast__close" onClick=${() => {
|
|
setExiting(true);
|
|
setTimeout(() => dismiss(id), 200);
|
|
}} aria-label="Dismiss">\u00d7</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ── Container ────────────────────────────────
|
|
|
|
export function ToastContainer() {
|
|
const [toasts, setToasts] = useState(_toasts);
|
|
_setToasts = setToasts;
|
|
|
|
useEffect(() => () => { _setToasts = null; }, []);
|
|
|
|
if (!toasts.length) return null;
|
|
return html`
|
|
<div class="sw-toast-container" aria-live="polite">
|
|
${toasts.map(t => html`<${ToastItem} key=${t.id} ...${t} />`)}
|
|
</div>
|
|
`;
|
|
}
|