| |
| |
| |
| |
|
|
| 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'); |
|
|