From cb1456e55f5dc682666c9e400aaf452714116bc7 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Tue, 9 Jun 2026 10:38:09 -0500 Subject: [PATCH] feat(shared): sticky, dismissible, selectable toasts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- shared/toast.css | 66 +++++++++++++++++++++++--- shared/toast.js | 120 +++++++++++++++++++++++++++++++++-------------- 2 files changed, 143 insertions(+), 43 deletions(-) diff --git a/shared/toast.css b/shared/toast.css index fdd3fac..b48841a 100644 --- a/shared/toast.css +++ b/shared/toast.css @@ -3,23 +3,75 @@ with tool-local .toast classes; the old classifier rules can stay 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; - bottom: 2rem; - right: 2rem; + bottom: 1.5rem; + 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); 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: 1px solid var(--border); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - z-index: 9000; - max-width: 400px; + max-width: 420px; font-size: 0.875rem; - cursor: pointer; 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--error { border-left: 4px solid var(--danger); } .zddc-toast--info { border-left: 4px solid var(--info); } diff --git a/shared/toast.js b/shared/toast.js index 6e32da1..dc99cf5 100644 --- a/shared/toast.js +++ b/shared/toast.js @@ -1,72 +1,120 @@ // shared/toast.js — non-blocking notification helper available to every -// tool via window.zddc.toast(msg, level, opts). Originated as classifier's -// local showToast (classifier/js/excel.js); promoted here so tools that -// today use alert() or silent console.error can switch to a uniform -// non-blocking surface. +// 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'); -// window.zddc.toast('Could not load: ' + err.message, 'error'); +// 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'. -// Each tool may also expose app.notify(msg, level) as a thin wrapper — -// see ARCHITECTURE.md for the convention. (function () { 'use strict'; if (!window.zddc) window.zddc = {}; - // Don't overwrite if a tool defined its own first. 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'; - - // 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 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'); - el.textContent = message == null ? '' : String(message); - document.body.appendChild(el); - var dur = typeof opts.durationMs === 'number' ? - opts.durationMs : DEFAULT_DURATION_MS; - var timer = setTimeout(function () { - el.classList.add('zddc-toast--fade'); - setTimeout(function () { - if (el.parentNode) el.parentNode.removeChild(el); - }, FADE_MS); - }, dur); + // 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); - // Click-to-dismiss. Useful for sticky errors the user wants gone. - el.addEventListener('click', function () { - clearTimeout(timer); - if (el.parentNode) el.parentNode.removeChild(el); - }); + 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() calls into the toast helper. Every tool has - // accumulated some `alert(...)` sites for error reporting; rather - // than touch each one, intercept globally so they're non-blocking - // 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). + // 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) {