feat(shared): sticky, dismissible, selectable toasts
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>
This commit is contained in:
parent
28bfcc6e8c
commit
cb1456e55f
2 changed files with 143 additions and 43 deletions
|
|
@ -3,23 +3,75 @@
|
||||||
with tool-local .toast classes; the old classifier rules can stay
|
with tool-local .toast classes; the old classifier rules can stay
|
||||||
alongside until this file is concatenated above them in the build. */
|
alongside until this file is concatenated above them in the build. */
|
||||||
|
|
||||||
.zddc-toast {
|
/* Toast STACK — bottom-right, newest at the bottom. The container is
|
||||||
|
click-through (pointer-events:none) so the gaps don't block the page; each
|
||||||
|
toast + button re-enables pointer events. */
|
||||||
|
.zddc-toasts {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 2rem;
|
bottom: 1.5rem;
|
||||||
right: 2rem;
|
right: 1.5rem;
|
||||||
|
z-index: 9000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-height: calc(100vh - 3rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "Clear all" — shown above the stack when 2+ toasts are present. */
|
||||||
|
.zddc-toasts__clear {
|
||||||
|
pointer-events: auto;
|
||||||
|
align-self: flex-end;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
padding: 0.875rem 1.25rem;
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.zddc-toasts__clear:hover { background: var(--bg-secondary, rgba(0, 0, 0, 0.05)); }
|
||||||
|
|
||||||
|
.zddc-toast {
|
||||||
|
position: relative;
|
||||||
|
pointer-events: auto;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0.7rem 1.7rem 0.7rem 1rem; /* room for the × at top-right */
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
z-index: 9000;
|
max-width: 420px;
|
||||||
max-width: 400px;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
cursor: pointer;
|
|
||||||
animation: zddc-toast-in 0.3s ease-out;
|
animation: zddc-toast-in 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Message text — selectable + copyable; long/multi-line errors wrap. */
|
||||||
|
.zddc-toast__msg {
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
cursor: text;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Per-toast dismiss. */
|
||||||
|
.zddc-toast__close {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.2rem;
|
||||||
|
right: 0.35rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted, #888);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 0.15rem;
|
||||||
|
}
|
||||||
|
.zddc-toast__close:hover { color: var(--text); }
|
||||||
|
|
||||||
.zddc-toast--success { border-left: 4px solid var(--success); }
|
.zddc-toast--success { border-left: 4px solid var(--success); }
|
||||||
.zddc-toast--error { border-left: 4px solid var(--danger); }
|
.zddc-toast--error { border-left: 4px solid var(--danger); }
|
||||||
.zddc-toast--info { border-left: 4px solid var(--info); }
|
.zddc-toast--info { border-left: 4px solid var(--info); }
|
||||||
|
|
|
||||||
120
shared/toast.js
120
shared/toast.js
|
|
@ -1,72 +1,120 @@
|
||||||
// shared/toast.js — non-blocking notification helper available to every
|
// shared/toast.js — non-blocking notification helper available to every
|
||||||
// tool via window.zddc.toast(msg, level, opts). Originated as classifier's
|
// tool via window.zddc.toast(msg, level, opts).
|
||||||
// local showToast (classifier/js/excel.js); promoted here so tools that
|
//
|
||||||
// today use alert() or silent console.error can switch to a uniform
|
// Toasts collect in a bottom-right STACK. Error/warning toasts are STICKY —
|
||||||
// non-blocking surface.
|
// 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:
|
// Usage:
|
||||||
// window.zddc.toast('Saved.', 'success');
|
// window.zddc.toast('Saved.', 'success'); // auto-dismiss
|
||||||
// window.zddc.toast('Could not load: ' + err.message, 'error');
|
// window.zddc.toast('Could not load: ' + e.message, 'error'); // sticky
|
||||||
// window.zddc.toast('Note', 'info', { durationMs: 3000 });
|
// window.zddc.toast('Note', 'info', { durationMs: 3000 });
|
||||||
|
// window.zddc.toast('Heads up', 'info', { durationMs: 0 }); // force sticky
|
||||||
//
|
//
|
||||||
// Levels: 'info' (default) | 'success' | 'warning' | 'error'.
|
// Levels: 'info' (default) | 'success' | 'warning' | 'error'.
|
||||||
// Each tool may also expose app.notify(msg, level) as a thin wrapper —
|
|
||||||
// see ARCHITECTURE.md for the convention.
|
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
if (!window.zddc) window.zddc = {};
|
if (!window.zddc) window.zddc = {};
|
||||||
// Don't overwrite if a tool defined its own first.
|
|
||||||
if (typeof window.zddc.toast === 'function') return;
|
if (typeof window.zddc.toast === 'function') return;
|
||||||
|
|
||||||
var DEFAULT_DURATION_MS = 5000;
|
var DEFAULT_DURATION_MS = 5000;
|
||||||
var FADE_MS = 300;
|
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) {
|
function toast(message, level, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
var lvl = (level === 'success' || level === 'error' ||
|
var lvl = (level === 'success' || level === 'error' ||
|
||||||
level === 'warning') ? level : 'info';
|
level === 'warning') ? level : 'info';
|
||||||
|
var c = container();
|
||||||
// Single-toast policy: dismiss any existing toast immediately
|
|
||||||
// so the new one is always the most recent. Matches the
|
|
||||||
// classifier's prior behavior and avoids stack-of-toasts UX.
|
|
||||||
var existing = document.querySelector('.zddc-toast');
|
|
||||||
if (existing) existing.remove();
|
|
||||||
|
|
||||||
var el = document.createElement('div');
|
var el = document.createElement('div');
|
||||||
el.className = 'zddc-toast zddc-toast--' + lvl;
|
el.className = 'zddc-toast zddc-toast--' + lvl;
|
||||||
// ARIA: errors get assertive (interrupts SR queue), others polite.
|
// ARIA: errors get assertive (interrupts SR queue), others polite.
|
||||||
el.setAttribute('role', lvl === 'error' ? 'alert' : 'status');
|
el.setAttribute('role', lvl === 'error' ? 'alert' : 'status');
|
||||||
el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite');
|
el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite');
|
||||||
el.textContent = message == null ? '' : String(message);
|
|
||||||
document.body.appendChild(el);
|
|
||||||
|
|
||||||
var dur = typeof opts.durationMs === 'number' ?
|
// Selectable, copyable message text (its own element so clicking to
|
||||||
opts.durationMs : DEFAULT_DURATION_MS;
|
// select doesn't dismiss the toast — only the × does).
|
||||||
var timer = setTimeout(function () {
|
var msg = document.createElement('span');
|
||||||
el.classList.add('zddc-toast--fade');
|
msg.className = 'zddc-toast__msg';
|
||||||
setTimeout(function () {
|
msg.textContent = message == null ? '' : String(message);
|
||||||
if (el.parentNode) el.parentNode.removeChild(el);
|
el.appendChild(msg);
|
||||||
}, FADE_MS);
|
|
||||||
}, dur);
|
|
||||||
|
|
||||||
// Click-to-dismiss. Useful for sticky errors the user wants gone.
|
var close = document.createElement('button');
|
||||||
el.addEventListener('click', function () {
|
close.type = 'button';
|
||||||
clearTimeout(timer);
|
close.className = 'zddc-toast__close';
|
||||||
if (el.parentNode) el.parentNode.removeChild(el);
|
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;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.zddc.toast = toast;
|
window.zddc.toast = toast;
|
||||||
|
|
||||||
// Route window.alert() calls into the toast helper. Every tool has
|
// Route window.alert() into the toast helper (non-blocking, ARIA-announced,
|
||||||
// accumulated some `alert(...)` sites for error reporting; rather
|
// consistent). Native alert preserved on window.alertNative. alert() maps
|
||||||
// than touch each one, intercept globally so they're non-blocking
|
// to an error toast (sticky) since that's its usual purpose.
|
||||||
// and ARIA-announced consistently. Native alert is preserved on
|
|
||||||
// window.alertNative for the rare case where a truly modal block
|
|
||||||
// is needed (e.g. before navigating away with unsaved changes).
|
|
||||||
if (typeof window.alert === 'function' && !window.alertNative) {
|
if (typeof window.alert === 'function' && !window.alertNative) {
|
||||||
window.alertNative = window.alert.bind(window);
|
window.alertNative = window.alert.bind(window);
|
||||||
window.alert = function (msg) {
|
window.alert = function (msg) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue