Files
basecoat/src/js/toast.js
T
GrugandMaurits van Riezen 30e5bdcf3a Escape toast text by default, add html option (#203)
Toast title, description and action labels were inserted into the page
as HTML, so passing user input could inject markup or script. They are
now escaped by default, and `html: true` keeps the old raw-HTML
behaviour. The action href, onclick strings, category and duration are
escaped as attribute values. `icon` stays raw markup.

Documents the `html` and `icon` options, adds a warning about `html`
and `onclick`, and notes that the Jinja and Nunjucks macros rely on
template autoescaping.

Co-authored-by: Maurits van Riezen <12109031+mousetail@users.noreply.github.com>
2026-10-23 09:23:05 +08:00

198 lines
7.3 KiB
JavaScript

(() => {
let toaster;
const toasts = new WeakMap();
let isPaused = false;
const ICONS = {
success: '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>',
error: '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>',
info: '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>',
warning: '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>'
};
function initToaster(toasterElement) {
if (toasterElement.dataset.toasterInitialized) return;
toaster = toasterElement;
const handleClick = (event) => {
const actionLink = event.target.closest('.toast footer a');
const actionButton = event.target.closest('.toast footer button');
if (actionLink || actionButton) {
closeToast(event.target.closest('.toast'));
}
};
toaster.addEventListener('mouseenter', pauseAllTimeouts);
toaster.addEventListener('mouseleave', resumeAllTimeouts);
toaster.addEventListener('click', handleClick);
toasterElement.toast = (config = {}) => {
const toastElement = createToast(config);
toasterElement.appendChild(toastElement);
initToast(toastElement);
return toastElement;
};
toasterElement.closeAll = () => {
toasterElement.querySelectorAll('.toast:not([aria-hidden="true"])').forEach(closeToast);
};
toaster.querySelectorAll('.toast:not([data-toast-initialized])').forEach(initToast);
toaster._destroy = () => {
toaster.removeEventListener('mouseenter', pauseAllTimeouts);
toaster.removeEventListener('mouseleave', resumeAllTimeouts);
toaster.removeEventListener('click', handleClick);
toaster.querySelectorAll('.toast[data-toast-initialized]').forEach(toast => toast._destroy?.());
delete toaster.toast;
delete toaster.closeAll;
if (toaster === toasterElement) toaster = null;
};
toaster.dataset.toasterInitialized = 'true';
toaster.dispatchEvent(new CustomEvent('basecoat:initialized'));
}
function initToast(element) {
if (element.dataset.toastInitialized) return;
const duration = parseInt(element.dataset.duration);
const timeoutDuration = duration !== -1
? duration || (element.dataset.category === 'error' ? 5000 : 3000)
: -1;
const state = {
remainingTime: timeoutDuration,
timeoutId: null,
startTime: null,
};
if (timeoutDuration !== -1) {
if (isPaused) {
state.timeoutId = null;
} else {
state.startTime = Date.now();
state.timeoutId = setTimeout(() => closeToast(element), timeoutDuration);
}
}
toasts.set(element, state);
element.close = () => closeToast(element);
element._destroy = () => {
clearTimeout(state.timeoutId);
toasts.delete(element);
delete element.close;
};
element.dataset.toastInitialized = 'true';
}
function pauseAllTimeouts() {
if (isPaused || !toaster) return;
isPaused = true;
toaster.querySelectorAll('.toast:not([aria-hidden="true"])').forEach(element => {
if (!toasts.has(element)) return;
const state = toasts.get(element);
if (state.timeoutId) {
clearTimeout(state.timeoutId);
state.timeoutId = null;
state.remainingTime -= Date.now() - state.startTime;
}
});
}
function resumeAllTimeouts() {
if (!isPaused || !toaster) return;
isPaused = false;
toaster.querySelectorAll('.toast:not([aria-hidden="true"])').forEach(element => {
if (!toasts.has(element)) return;
const state = toasts.get(element);
if (state.remainingTime !== -1 && !state.timeoutId) {
if (state.remainingTime > 0) {
state.startTime = Date.now();
state.timeoutId = setTimeout(() => closeToast(element), state.remainingTime);
} else {
closeToast(element);
}
}
});
}
function closeToast(element) {
if (!element || !toasts.has(element)) return;
const state = toasts.get(element);
clearTimeout(state.timeoutId);
toasts.delete(element);
if (element.contains(document.activeElement)) document.activeElement.blur();
element.setAttribute('aria-hidden', 'true');
element.addEventListener('transitionend', () => element.remove(), { once: true });
}
const escapeHtml = (value) => String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
function createToast(config) {
const {
category = 'info',
title,
description,
action,
cancel,
duration,
icon,
html: allowHtml = false,
} = config;
// Text is escaped unless `html: true`. `icon` is always raw markup (SVG).
const text = (value) => (allowHtml ? String(value) : escapeHtml(value));
const iconHtml = icon || (category && ICONS[category]) || '';
const titleHtml = title ? `<h2>${text(title)}</h2>` : '';
const descriptionHtml = description ? `<p>${text(description)}</p>` : '';
const actionHtml = action?.href
? `<a href="${escapeHtml(action.href)}" class="btn" data-toast-action>${text(action.label)}</a>`
: action?.onclick
? `<button type="button" class="btn" data-toast-action onclick="${escapeHtml(action.onclick)}">${text(action.label)}</button>`
: '';
const cancelHtml = cancel
? `<button type="button" class="btn h-6 text-xs px-2.5 rounded-sm" data-variant="outline" data-toast-cancel onclick="${escapeHtml(cancel?.onclick || '')}">${text(cancel.label)}</button>`
: '';
const footerHtml = actionHtml || cancelHtml ? `<footer>${actionHtml}${cancelHtml}</footer>` : '';
const html = `
<div
class="toast"
role="${category === 'error' ? 'alert' : 'status'}"
aria-atomic="true"
${category ? `data-category="${escapeHtml(category)}"` : ''}
${duration !== undefined ? `data-duration="${escapeHtml(duration)}"` : ''}
>
<div class="toast-content">
${iconHtml}
<section>
${titleHtml}
${descriptionHtml}
</section>
${footerHtml}
</div>
</div>
`;
const template = document.createElement('template');
template.innerHTML = html.trim();
return template.content.firstChild;
}
if (window.basecoat) {
window.basecoat.register('toaster', '#toaster:not([data-toaster-initialized])', initToaster);
window.basecoat.register('toast', '.toast:not([data-toast-initialized])', initToast);
}
})();