import { uid } from '../shared/dom'; import { TOAST_DISMISS_EVENT, TOAST_SHOW_EVENT, type ToastDismissDetail, type ToasterOptions, type ToastOptions, type ToastPosition, type ToastShowDetail, } from './toast.types'; interface ToastEntry { id: string; el: HTMLElement; timer: ReturnType | null; } /** * Headless toast/notification manager. Attaches to an existing region element * and creates, positions (via data attributes) and auto-dismisses toasts; it * never applies visual styles. * * Markup: * ```html *
* ``` * * Each call to `show()` injects: * ```html *
* ... *
...
* *
* ``` */ export class Toaster { private readonly region: HTMLElement; private readonly position: ToastPosition; private readonly duration: number; private readonly max: number; private toasts: ToastEntry[] = []; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: ToasterOptions = {}) { if (!root) { throw new Error('[42/toast] Needs a [data-c42-toast-region] element.'); } this.region = root; this.position = options.position ?? 'bottom-right'; this.duration = options.duration ?? 5000; this.max = options.max ?? Infinity; this.init(); } private init(): void { this.region.setAttribute('role', 'region'); this.region.setAttribute('aria-live', 'polite'); if (!this.region.hasAttribute('aria-label')) { this.region.setAttribute('aria-label', 'Notifications'); } this.region.dataset.position = this.position; } /** * Create and display a toast. Returns the generated id, which can be passed * to `dismiss()`. Auto-dismisses after `duration` ms unless it is `0`. */ show(options: ToastOptions = {}): string { const id = uid('toast'); const variant = options.variant ?? 'info'; const el = document.createElement('div'); el.dataset.c42Toast = ''; el.id = id; el.setAttribute('role', 'status'); el.dataset.variant = variant; el.dataset.state = 'open'; if (options.title) { const title = document.createElement('strong'); title.dataset.c42ToastTitle = ''; title.textContent = options.title; el.appendChild(title); } if (options.description) { const description = document.createElement('div'); description.dataset.c42ToastDescription = ''; description.textContent = options.description; el.appendChild(description); } const close = document.createElement('button'); close.type = 'button'; close.dataset.c42ToastClose = ''; close.setAttribute('aria-label', 'Dismiss'); close.textContent = '\u00d7'; const onClick = (): void => this.dismiss(id); close.addEventListener('click', onClick); el.appendChild(close); this.region.appendChild(el); const duration = options.duration ?? this.duration; const timer = duration > 0 ? setTimeout(() => { this.dismiss(id); }, duration) : null; this.toasts.push({ id, el, timer }); this.cleanups.push(() => close.removeEventListener('click', onClick)); while (this.toasts.length > this.max) { const oldest = this.toasts[0]; if (!oldest) { break; } this.dismiss(oldest.id); } const detail: ToastShowDetail = { id }; this.region.dispatchEvent(new CustomEvent(TOAST_SHOW_EVENT, { detail, bubbles: true })); return id; } /** Dismiss a toast by id. No-op if the id is unknown. */ dismiss(id: string): void { const index = this.toasts.findIndex((toast) => toast.id === id); if (index === -1) { return; } const [entry] = this.toasts.splice(index, 1); if (!entry) { return; } if (entry.timer !== null) { clearTimeout(entry.timer); } entry.el.dataset.state = 'closed'; entry.el.remove(); const detail: ToastDismissDetail = { id }; this.region.dispatchEvent(new CustomEvent(TOAST_DISMISS_EVENT, { detail, bubbles: true })); } /** Dismiss every visible toast. */ clear(): void { [...this.toasts].forEach((toast) => this.dismiss(toast.id)); } /** Subscribe to a DOM event on the region. Returns an unsubscribe fn. */ on(event: string, handler: (event: E) => void): () => void { const listener = handler as EventListener; this.region.addEventListener(event, listener); const off = (): void => this.region.removeEventListener(event, listener); this.cleanups.push(off); return off; } destroy(): void { this.clear(); this.cleanups.forEach((fn) => fn()); this.cleanups = []; } }