claw-code/.guardrails/mcp-server/web/js/components/Toast.js

259 lines
8.7 KiB
JavaScript

/**
* Toast Component
* Success/error notifications with auto-dismiss and stacking support
* WCAG 3.0+ compliant with ARIA live regions, reduced motion support, and focus management
*/
class Toast {
constructor() {
throw new Error('Toast is a static class. Use Toast.success(), Toast.error(), etc.');
}
/**
* Check if user prefers reduced motion
*/
static prefersReducedMotion() {
return window.matchMedia('(prefers-reduced-motion)').matches;
}
/**
* Get or create toast container with ARIA live region
*/
static getContainer() {
let container = document.getElementById('toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'toast-container';
container.className = 'toast-container';
container.setAttribute('role', 'status');
container.setAttribute('aria-live', 'polite');
container.setAttribute('aria-atomic', 'true');
container.style.cssText = `
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: ${getComputedStyle(document.documentElement).getPropertyValue('--z-toast') || 500};
display: flex;
flex-direction: column;
gap: 0.75rem;
`;
document.body.appendChild(container);
}
return container;
}
/**
* Show a toast notification with ARIA attributes
*/
static show(options = {}) {
const {
type = 'info',
title = '',
message = '',
duration = 5000,
closable = true,
ariaLive = 'polite', // 'polite' or 'assertive'
id = 'toast-' + Date.now()
} = options;
const container = this.getContainer();
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.id = id;
// Determine aria-live based on toast type: errors are assertive, others are polite
const liveRegion = type === 'error' ? 'assertive' : ariaLive;
// Check for reduced motion preference
const prefersReducedMotion = this.prefersReducedMotion();
const animationValue = prefersReducedMotion ? '' : 'animation: slideIn 0.3s ease;';
toast.style.cssText = `
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-left: 4px solid var(--color-${type === 'error' ? 'error' : type === 'success' ? 'success' : type === 'warning' ? 'warning' : 'info'});
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
min-width: 300px;
max-width: 400px;
${animationValue}
`;
// Set ARIA attributes for screen reader support
toast.setAttribute('role', type === 'error' ? 'alert' : 'status');
toast.setAttribute('aria-live', liveRegion);
toast.setAttribute('aria-atomic', 'true');
toast.setAttribute('aria-label', title || `${type} notification`);
const icons = {
success: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-success)" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>`,
error: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-error)" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`,
warning: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-warning)" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`,
info: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-info)" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>`
};
toast.innerHTML = `
<span class="toast-icon" style="flex-shrink: 0; margin-top: 0.125rem;" aria-hidden="true">${icons[type]}</span>
<div class="toast-content" style="flex: 1; min-width: 0;">
${title ? `<div id="${id}-title" class="toast-title" style="font-weight: 500; color: var(--color-text-primary); margin-bottom: 0.25rem;">${title}</div>` : ''}
${message ? `<div id="${id}-desc" class="toast-message" style="font-size: 0.875rem; color: var(--color-text-secondary);">${message}</div>` : ''}
</div>
${closable ? `
<button class="toast-close" style="
background: none;
border: none;
color: var(--color-text-tertiary);
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 0.125rem;
border-radius: var(--radius-md);
" aria-label="Close ${title || type} notification">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
` : ''}
`;
// Add close handler
const closeBtn = toast.querySelector('.toast-close');
if (closeBtn) {
closeBtn.addEventListener('click', () => this.dismiss(toast));
}
// Add progress bar for auto-dismiss
if (duration > 0) {
const progressBar = document.createElement('div');
// Check for reduced motion preference
const prefersReducedMotion = this.prefersReducedMotion();
const animationValue = prefersReducedMotion ? '' : `animation: progress ${duration}ms linear;`;
progressBar.style.cssText = `
position: absolute;
bottom: 0;
left: 0;
height: 3px;
background-color: var(--color-${type === 'error' ? 'error' : type === 'success' ? 'success' : type === 'warning' ? 'warning' : 'info'});
opacity: 0.3;
${animationValue}
`;
toast.style.position = 'relative';
toast.appendChild(progressBar);
// Add keyframes if not present
if (!document.getElementById('toast-keyframes')) {
const style = document.createElement('style');
style.id = 'toast-keyframes';
style.textContent = `
@keyframes progress {
from { width: 100%; }
to { width: 0%; }
}
`;
document.head.appendChild(style);
}
}
container.appendChild(toast);
// Auto dismiss
if (duration > 0) {
setTimeout(() => this.dismiss(toast), duration);
}
return toast;
}
/**
* Dismiss a toast with reduced motion support
*/
static dismiss(toast) {
const prefersReducedMotion = this.prefersReducedMotion();
// Use reduced motion animation if preferred
if (prefersReducedMotion) {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.2s ease';
} else {
toast.style.animation = 'slideOut 0.3s ease forwards';
// Add keyframes if not present
if (!document.getElementById('toast-slide-out-keyframes')) {
const style = document.createElement('style');
style.id = 'toast-slide-out-keyframes';
style.textContent = `
@keyframes slideOut {
to {
transform: translateX(100%);
opacity: 0;
}
}
`;
document.head.appendChild(style);
}
}
const animationDuration = prefersReducedMotion ? 200 : 300;
setTimeout(() => {
toast.remove();
// Remove container if empty
const container = document.getElementById('toast-container');
if (container && !container.children.length) {
container.remove();
}
}, animationDuration);
}
/**
* Show a success toast with polite aria-live
*/
static success(message, title = 'Success') {
return this.show({ type: 'success', title, message, ariaLive: 'polite' });
}
/**
* Show an error toast with assertive aria-live (urgent)
*/
static error(message, title = 'Error') {
return this.show({ type: 'error', title, message, duration: 8000, ariaLive: 'assertive' });
}
/**
* Show a warning toast with assertive aria-live
*/
static warning(message, title = 'Warning') {
return this.show({ type: 'warning', title, message, duration: 7000, ariaLive: 'assertive' });
}
/**
* Show an info toast with polite aria-live
*/
static info(message, title = 'Info') {
return this.show({ type: 'info', title, message, ariaLive: 'polite' });
}
/**
* Clear all toasts
*/
static clear() {
const container = document.getElementById('toast-container');
if (container) {
Array.from(container.children).forEach(toast => this.dismiss(toast));
}
}
}
window.Toast = Toast;