/** * Hoover — components/toast.js * * ToastContainer component that renders queued toast notifications. * Uses the toast/dismissToast state from api.js. * * Long messages (>200 chars or containing newlines) render compact — * first line with an ellipsis — plus a "Details" button that opens a * modal with the full text. Dismissal is only via the × button; * hovering the toast pauses its auto-dismiss timer. */ import { h } from '../vdom.js'; import { _toasts, dismissToast } from '../api.js'; import { openModal } from './modal.js'; /** Messages longer than this (or containing newlines) render compact. */ const _LONG_MESSAGE_CHARS = 200; function _isLong(message) { return message.length > _LONG_MESSAGE_CHARS || message.includes('\n'); } function _firstLine(message) { return message.split('\n')[0].trim(); } function _showDetails(t) { openModal( h('div', null, h('h2', { class: 'modal-title' }, 'Details'), h('div', { class: 'modal-body' }, h('pre', { class: 'toast-details-msg' }, t.message), ), ), ); } /** * Render all pending toast notifications. * * @returns {VNode} */ export function ToastContainer() { if (!_toasts.length) return h('#text', ''); const clsMap = { info: 'toast-info', success: 'toast-success', error: 'toast-error', warning: 'toast-warning', }; return h('div', { class: 'toast' }, ..._toasts.map(t => { const long = _isLong(t.message); return h('div', { class: `toast-message ${clsMap[t.type] || clsMap.info}`, 'on:mouseover': () => { t.hovered = true; }, 'on:mouseout': () => { t.hovered = false; }, }, h('span', { class: 'toast-text' + (long ? ' toast-text-long' : '') }, long ? _firstLine(t.message) : t.message), h('div', { class: 'toast-actions' }, long ? h('button', { class: 'toast-btn toast-details', 'on:click': (e) => { e.stopPropagation(); _showDetails(t); }, }, 'Details') : null, h('button', { class: 'toast-btn toast-close', 'on:click': (e) => { e.stopPropagation(); dismissToast(t.id); }, }, '\u00d7'), ), ); }), ); }