| |
| |
| |
|
|
|
|
| class UIManager {
|
| constructor() {
|
| this.toasts = [];
|
| this.modals = new Map();
|
| this.loading = new Set();
|
| this.init();
|
| }
|
|
|
| init() {
|
| this.createToastContainer();
|
| this.initializeGlobalHandlers();
|
| this.setupAccessibility();
|
| console.log('✅ UI Manager initialized');
|
| }
|
|
|
| |
| |
|
|
| createToastContainer() {
|
| if (!document.getElementById('toast-container')) {
|
| const container = document.createElement('div');
|
| container.id = 'toast-container';
|
| container.setAttribute('aria-live', 'polite');
|
| container.setAttribute('aria-atomic', 'true');
|
| container.style.cssText = `
|
| position: fixed;
|
| top: 1rem;
|
| right: 1rem;
|
| z-index: 9999;
|
| display: flex;
|
| flex-direction: column;
|
| gap: 0.5rem;
|
| `;
|
| document.body.appendChild(container);
|
| }
|
| }
|
|
|
| |
| |
|
|
| showToast(message, type = 'info', duration = 3000) {
|
| const container = document.getElementById('toast-container');
|
| if (!container) return;
|
|
|
| const toast = document.createElement('div');
|
| const id = `toast-${Date.now()}-${Math.random()}`;
|
| toast.id = id;
|
| toast.className = `toast ${type}`;
|
|
|
|
|
| const icons = {
|
| success: '✅',
|
| error: '❌',
|
| warning: '⚠️',
|
| info: 'ℹ️'
|
| };
|
|
|
| toast.innerHTML = `
|
| <div style="display: flex; align-items: center; gap: 0.75rem;">
|
| <span style="font-size: 1.25rem;">${icons[type] || icons.info}</span>
|
| <span style="flex: 1;">${this.escapeHtml(message)}</span>
|
| <button onclick="uiManager.closeToast('${id}')" style="background: none; border: none; color: inherit; cursor: pointer; opacity: 0.7; padding: 0.25rem;">
|
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| <line x1="18" y1="6" x2="6" y2="18"></line>
|
| <line x1="6" y1="6" x2="18" y2="18"></line>
|
| </svg>
|
| </button>
|
| </div>
|
| `;
|
|
|
| container.appendChild(toast);
|
| this.toasts.push(id);
|
|
|
|
|
| if (duration > 0) {
|
| setTimeout(() => this.closeToast(id), duration);
|
| }
|
|
|
| return id;
|
| }
|
|
|
| |
| |
|
|
| closeToast(id) {
|
| const toast = document.getElementById(id);
|
| if (toast) {
|
| toast.style.animation = 'slideOutRight 0.3s ease-out';
|
| setTimeout(() => {
|
| toast.remove();
|
| this.toasts = this.toasts.filter(t => t !== id);
|
| }, 300);
|
| }
|
| }
|
|
|
| |
| |
|
|
| showLoading(elementId, text = 'Loading...') {
|
| const element = document.getElementById(elementId);
|
| if (!element) return;
|
|
|
| this.loading.add(elementId);
|
|
|
| const originalContent = element.innerHTML;
|
| element.dataset.originalContent = originalContent;
|
|
|
| element.innerHTML = `
|
| <div class="loading-container">
|
| <div class="spinner"></div>
|
| <p style="color: var(--text-secondary); margin-top: 1rem;">${this.escapeHtml(text)}</p>
|
| </div>
|
| `;
|
| }
|
|
|
| |
| |
|
|
| hideLoading(elementId, content = null) {
|
| const element = document.getElementById(elementId);
|
| if (!element) return;
|
|
|
| this.loading.delete(elementId);
|
|
|
| if (content) {
|
| element.innerHTML = content;
|
| } else if (element.dataset.originalContent) {
|
| element.innerHTML = element.dataset.originalContent;
|
| delete element.dataset.originalContent;
|
| }
|
| }
|
|
|
| |
| |
|
|
| showModal(options = {}) {
|
| const {
|
| id = `modal-${Date.now()}`,
|
| title = 'Modal',
|
| content = '',
|
| size = 'md',
|
| onClose = null
|
| } = options;
|
|
|
|
|
| if (this.modals.has(id)) {
|
| const existing = this.modals.get(id);
|
| existing.modal.classList.add('active');
|
| return id;
|
| }
|
|
|
| const modal = document.createElement('div');
|
| modal.id = id;
|
| modal.className = 'modal active';
|
| modal.innerHTML = `
|
| <div class="modal-backdrop" onclick="uiManager.closeModal('${id}')"></div>
|
| <div class="modal-content modal-${size}">
|
| <div class="modal-header" style="display: flex; justify-content: space-between; align-items: center; padding: 1.5rem; border-bottom: 1px solid rgba(255,255,255,0.1);">
|
| <h2 style="margin: 0; font-size: 1.25rem; font-weight: 700;">${this.escapeHtml(title)}</h2>
|
| <button onclick="uiManager.closeModal('${id}')" class="btn-icon" aria-label="Close">
|
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| <line x1="18" y1="6" x2="6" y2="18"></line>
|
| <line x1="6" y1="6" x2="18" y2="18"></line>
|
| </svg>
|
| </button>
|
| </div>
|
| <div class="modal-body" style="padding: 1.5rem; overflow-y: auto; max-height: 60vh;">
|
| ${content}
|
| </div>
|
| </div>
|
| `;
|
|
|
| document.body.appendChild(modal);
|
| this.modals.set(id, { modal, onClose });
|
|
|
|
|
| const handleEscape = (e) => {
|
| if (e.key === 'Escape') {
|
| this.closeModal(id);
|
| }
|
| };
|
| document.addEventListener('keydown', handleEscape);
|
| modal.dataset.escapeHandler = handleEscape;
|
|
|
| return id;
|
| }
|
|
|
| |
| |
|
|
| closeModal(id) {
|
| const modalData = this.modals.get(id);
|
| if (!modalData) return;
|
|
|
| const { modal, onClose } = modalData;
|
|
|
| modal.classList.remove('active');
|
| setTimeout(() => {
|
| modal.remove();
|
| this.modals.delete(id);
|
| if (onClose) onClose();
|
| }, 300);
|
|
|
|
|
| if (modal.dataset.escapeHandler) {
|
| document.removeEventListener('keydown', modal.dataset.escapeHandler);
|
| }
|
| }
|
|
|
| |
| |
|
|
| async confirm(message, title = 'Confirm') {
|
| return new Promise((resolve) => {
|
| const id = this.showModal({
|
| title,
|
| content: `
|
| <p style="margin-bottom: 1.5rem; color: var(--text-primary);">${this.escapeHtml(message)}</p>
|
| <div style="display: flex; gap: 0.75rem; justify-content: flex-end;">
|
| <button class="btn btn-secondary" onclick="uiManager.closeModal('${id}'); window.uiManagerResolve(false);">
|
| Cancel
|
| </button>
|
| <button class="btn btn-primary" onclick="uiManager.closeModal('${id}'); window.uiManagerResolve(true);">
|
| Confirm
|
| </button>
|
| </div>
|
| `,
|
| onClose: () => resolve(false)
|
| });
|
|
|
| window.uiManagerResolve = resolve;
|
| });
|
| }
|
|
|
| |
| |
|
|
| showError(message, details = null) {
|
| const content = `
|
| <div style="color: var(--danger);">
|
| <h3 style="margin-bottom: 0.5rem;">⚠️ Error</h3>
|
| <p>${this.escapeHtml(message)}</p>
|
| ${details ? `<pre style="margin-top: 1rem; padding: 1rem; background: rgba(0,0,0,0.3); border-radius: 0.5rem; overflow-x: auto; font-size: 0.875rem;">${this.escapeHtml(details)}</pre>` : ''}
|
| </div>
|
| `;
|
|
|
| this.showModal({
|
| title: 'Error',
|
| content,
|
| size: 'md'
|
| });
|
|
|
| this.showToast(message, 'error');
|
| }
|
|
|
| |
| |
|
|
| initializeGlobalHandlers() {
|
|
|
| document.addEventListener('click', (e) => {
|
| const button = e.target.closest('button, .btn');
|
| if (button && !button.classList.contains('unstyled')) {
|
|
|
| this.createRipple(e, button);
|
| }
|
| });
|
|
|
|
|
| document.addEventListener('submit', (e) => {
|
| const form = e.target;
|
| if (form.tagName === 'FORM' && !form.classList.contains('no-prevent')) {
|
|
|
| }
|
| });
|
|
|
|
|
| window.addEventListener('beforeunload', (e) => {
|
| if (this.loading.size > 0) {
|
| e.preventDefault();
|
| e.returnValue = 'Operations in progress...';
|
| }
|
| });
|
| }
|
|
|
| |
| |
|
|
| createRipple(event, button) {
|
| const circle = document.createElement('span');
|
| const diameter = Math.max(button.clientWidth, button.clientHeight);
|
| const radius = diameter / 2;
|
|
|
| const rect = button.getBoundingClientRect();
|
| circle.style.width = circle.style.height = `${diameter}px`;
|
| circle.style.left = `${event.clientX - rect.left - radius}px`;
|
| circle.style.top = `${event.clientY - rect.top - radius}px`;
|
| circle.classList.add('ripple');
|
|
|
| const ripple = button.getElementsByClassName('ripple')[0];
|
| if (ripple) {
|
| ripple.remove();
|
| }
|
|
|
| circle.style.cssText += `
|
| position: absolute;
|
| border-radius: 50%;
|
| background: rgba(255, 255, 255, 0.3);
|
| transform: scale(0);
|
| animation: ripple 0.6s ease-out;
|
| pointer-events: none;
|
| `;
|
|
|
| button.style.position = 'relative';
|
| button.style.overflow = 'hidden';
|
| button.appendChild(circle);
|
|
|
| setTimeout(() => circle.remove(), 600);
|
| }
|
|
|
| |
| |
|
|
| setupAccessibility() {
|
|
|
| document.addEventListener('keydown', (e) => {
|
|
|
| if (e.key === 'Tab' && this.modals.size > 0) {
|
|
|
| const activeModal = Array.from(this.modals.values())
|
| .map(m => m.modal)
|
| .find(m => m.classList.contains('active'));
|
|
|
| if (activeModal) {
|
| const focusableElements = activeModal.querySelectorAll(
|
| 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
| );
|
|
|
| const firstElement = focusableElements[0];
|
| const lastElement = focusableElements[focusableElements.length - 1];
|
|
|
| if (e.shiftKey && document.activeElement === firstElement) {
|
| lastElement.focus();
|
| e.preventDefault();
|
| } else if (!e.shiftKey && document.activeElement === lastElement) {
|
| firstElement.focus();
|
| e.preventDefault();
|
| }
|
| }
|
| }
|
| });
|
| }
|
|
|
| |
| |
|
|
| escapeHtml(text) {
|
| const div = document.createElement('div');
|
| div.textContent = text;
|
| return div.innerHTML;
|
| }
|
|
|
| |
| |
|
|
| animateIn(element, animation = 'fadeIn') {
|
| if (typeof element === 'string') {
|
| element = document.getElementById(element);
|
| }
|
| if (!element) return;
|
|
|
| element.style.animation = `${animation} 0.3s ease-out`;
|
| }
|
|
|
| |
| |
|
|
| scrollTo(elementId, offset = 0) {
|
| const element = document.getElementById(elementId);
|
| if (!element) return;
|
|
|
| const top = element.getBoundingClientRect().top + window.pageYOffset - offset;
|
| window.scrollTo({
|
| top,
|
| behavior: 'smooth'
|
| });
|
| }
|
|
|
| |
| |
|
|
| async copyToClipboard(text) {
|
| try {
|
| await navigator.clipboard.writeText(text);
|
| this.showToast('Copied to clipboard!', 'success', 2000);
|
| return true;
|
| } catch (err) {
|
| this.showToast('Failed to copy', 'error');
|
| return false;
|
| }
|
| }
|
|
|
| |
| |
|
|
| formatNumber(number, decimals = 2) {
|
| return new Intl.NumberFormat('en-US', {
|
| minimumFractionDigits: decimals,
|
| maximumFractionDigits: decimals
|
| }).format(number);
|
| }
|
|
|
| |
| |
|
|
| formatCurrency(amount, currency = 'USD') {
|
| return new Intl.NumberFormat('en-US', {
|
| style: 'currency',
|
| currency
|
| }).format(amount);
|
| }
|
|
|
| |
| |
|
|
| formatRelativeTime(timestamp) {
|
| const now = Date.now();
|
| const diff = now - timestamp;
|
| const seconds = Math.floor(diff / 1000);
|
| const minutes = Math.floor(seconds / 60);
|
| const hours = Math.floor(minutes / 60);
|
| const days = Math.floor(hours / 24);
|
|
|
| if (seconds < 60) return 'just now';
|
| if (minutes < 60) return `${minutes}m ago`;
|
| if (hours < 24) return `${hours}h ago`;
|
| if (days < 7) return `${days}d ago`;
|
| return new Date(timestamp).toLocaleDateString();
|
| }
|
| }
|
|
|
|
|
| const uiManager = new UIManager();
|
|
|
|
|
| if (typeof module !== 'undefined' && module.exports) {
|
| module.exports = { UIManager, uiManager };
|
| }
|
|
|
|
|
| window.uiManager = uiManager;
|
| window.UIManager = UIManager;
|
|
|
|
|
| const style = document.createElement('style');
|
| style.textContent = `
|
| @keyframes ripple {
|
| to {
|
| transform: scale(4);
|
| opacity: 0;
|
| }
|
| }
|
|
|
| @keyframes fadeIn {
|
| from {
|
| opacity: 0;
|
| transform: translateY(1rem);
|
| }
|
| to {
|
| opacity: 1;
|
| transform: translateY(0);
|
| }
|
| }
|
|
|
| @keyframes slideOutRight {
|
| to {
|
| transform: translateX(100%);
|
| opacity: 0;
|
| }
|
| }
|
| `;
|
| document.head.appendChild(style);
|
|
|
| console.log('✅ UI Manager loaded and ready');
|
|
|