mirror of
https://github.com/hunvreus/basecoat.git
synced 2026-11-14 14:08:18 +00:00
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>
198 lines
7.3 KiB
JavaScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
|
|
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);
|
|
}
|
|
})();
|