Toasts now collect in a bottom-right stack. Error/warning toasts are STICKY — they stay until dismissed so the user can read, select, and copy them while troubleshooting (e.g. classifier scan errors); info/success still auto-dismiss (opts.durationMs:0 forces sticky for any level). Each toast has a × to dismiss it, a "Clear all" appears when 2+ are stacked, and the message text is selectable/copyable. alert() maps to a sticky error toast. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
124 lines
4.7 KiB
JavaScript
124 lines
4.7 KiB
JavaScript
// shared/toast.js — non-blocking notification helper available to every
|
||
// tool via window.zddc.toast(msg, level, opts).
|
||
//
|
||
// Toasts collect in a bottom-right STACK. Error/warning toasts are STICKY —
|
||
// they stay until the user dismisses them (per-toast × or a "Clear all"
|
||
// button) so the message can be read, selected, and copied while
|
||
// troubleshooting. info/success toasts auto-dismiss. The message text is
|
||
// always selectable.
|
||
//
|
||
// Usage:
|
||
// window.zddc.toast('Saved.', 'success'); // auto-dismiss
|
||
// window.zddc.toast('Could not load: ' + e.message, 'error'); // sticky
|
||
// window.zddc.toast('Note', 'info', { durationMs: 3000 });
|
||
// window.zddc.toast('Heads up', 'info', { durationMs: 0 }); // force sticky
|
||
//
|
||
// Levels: 'info' (default) | 'success' | 'warning' | 'error'.
|
||
(function () {
|
||
'use strict';
|
||
|
||
if (!window.zddc) window.zddc = {};
|
||
if (typeof window.zddc.toast === 'function') return;
|
||
|
||
var DEFAULT_DURATION_MS = 5000;
|
||
var FADE_MS = 300;
|
||
// Levels that persist until the user dismisses them (troubleshooting).
|
||
var STICKY = { error: true, warning: true };
|
||
|
||
function container() {
|
||
var c = document.getElementById('zddc-toasts');
|
||
if (c) return c;
|
||
c = document.createElement('div');
|
||
c.id = 'zddc-toasts';
|
||
c.className = 'zddc-toasts';
|
||
document.body.appendChild(c);
|
||
return c;
|
||
}
|
||
|
||
// Show/hide a "Clear all" control when 2+ toasts are stacked.
|
||
function refreshClearAll(c) {
|
||
var bar = c.querySelector('.zddc-toasts__clear');
|
||
var count = c.querySelectorAll('.zddc-toast').length;
|
||
if (count >= 2) {
|
||
if (!bar) {
|
||
bar = document.createElement('button');
|
||
bar.type = 'button';
|
||
bar.className = 'zddc-toasts__clear';
|
||
bar.textContent = 'Clear all';
|
||
bar.addEventListener('click', function () {
|
||
var all = c.querySelectorAll('.zddc-toast');
|
||
for (var i = 0; i < all.length; i++) dismiss(all[i]);
|
||
});
|
||
c.insertBefore(bar, c.firstChild);
|
||
}
|
||
} else if (bar) {
|
||
bar.remove();
|
||
}
|
||
}
|
||
|
||
function dismiss(el) {
|
||
if (el._dismissed) return;
|
||
el._dismissed = true;
|
||
if (el._timer) clearTimeout(el._timer);
|
||
el.classList.add('zddc-toast--fade');
|
||
setTimeout(function () {
|
||
if (el.parentNode) el.parentNode.removeChild(el);
|
||
refreshClearAll(container());
|
||
}, FADE_MS);
|
||
}
|
||
|
||
function toast(message, level, opts) {
|
||
opts = opts || {};
|
||
var lvl = (level === 'success' || level === 'error' ||
|
||
level === 'warning') ? level : 'info';
|
||
var c = container();
|
||
|
||
var el = document.createElement('div');
|
||
el.className = 'zddc-toast zddc-toast--' + lvl;
|
||
// ARIA: errors get assertive (interrupts SR queue), others polite.
|
||
el.setAttribute('role', lvl === 'error' ? 'alert' : 'status');
|
||
el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite');
|
||
|
||
// Selectable, copyable message text (its own element so clicking to
|
||
// select doesn't dismiss the toast — only the × does).
|
||
var msg = document.createElement('span');
|
||
msg.className = 'zddc-toast__msg';
|
||
msg.textContent = message == null ? '' : String(message);
|
||
el.appendChild(msg);
|
||
|
||
var close = document.createElement('button');
|
||
close.type = 'button';
|
||
close.className = 'zddc-toast__close';
|
||
close.setAttribute('aria-label', 'Dismiss');
|
||
close.textContent = '×';
|
||
close.addEventListener('click', function () { dismiss(el); });
|
||
el.appendChild(close);
|
||
|
||
c.appendChild(el);
|
||
|
||
// Sticky (error/warning, or opts.durationMs === 0) persists; otherwise
|
||
// auto-dismiss after the (overridable) duration.
|
||
var sticky = opts.durationMs === 0 ||
|
||
(typeof opts.durationMs !== 'number' && STICKY[lvl]);
|
||
if (!sticky) {
|
||
var dur = typeof opts.durationMs === 'number'
|
||
? opts.durationMs : DEFAULT_DURATION_MS;
|
||
el._timer = setTimeout(function () { dismiss(el); }, dur);
|
||
}
|
||
|
||
refreshClearAll(c);
|
||
return el;
|
||
}
|
||
|
||
window.zddc.toast = toast;
|
||
|
||
// Route window.alert() into the toast helper (non-blocking, ARIA-announced,
|
||
// consistent). Native alert preserved on window.alertNative. alert() maps
|
||
// to an error toast (sticky) since that's its usual purpose.
|
||
if (typeof window.alert === 'function' && !window.alertNative) {
|
||
window.alertNative = window.alert.bind(window);
|
||
window.alert = function (msg) {
|
||
toast(String(msg == null ? '' : msg), 'error');
|
||
};
|
||
}
|
||
})();
|