UNPKG

mcp-quiz-server

Version:

🧠 AI-Powered Quiz Management via Model Context Protocol (MCP) - Create, manage, and take quizzes directly from VS Code, Claude, and other AI agents.

312 lines • 12 kB
import { Component } from '../Component'; export class UnifiedModal extends Component { constructor(config) { super(`#${config.id}`); this.isOpen = false; this.previouslyFocusedElement = null; this.focusableElements = []; this.eventListeners = []; this.config = this.validateAndNormalizeConfig(config); this.initialize(); } render() { if (!this.element) { console.warn(`Modal element #${this.config.id} not found in DOM`); return; } } validateAndNormalizeConfig(config) { if (!config.id || typeof config.id !== 'string') { throw new Error('Modal config must have a valid string id'); } if (!config.type || !['tour', 'settings', 'results', 'confirmation', 'custom'].includes(config.type)) { throw new Error('Modal config must have a valid type'); } const defaultStyling = { theme: 'educational', size: 'medium', position: 'center', backdrop: 'dark', }; const defaultBehavior = { escapeToClose: true, backdropToClose: false, preventBodyScroll: true, autoFocus: true, restoreFocus: true, closeOnAction: false, }; const defaultAccessibility = { ariaLabel: config.accessibility?.ariaLabel || `${config.type} modal`, role: 'dialog', screenReaderAnnouncements: true, highContrastSupport: true, keyboardNavigation: true, focusTrap: true, }; const defaultAnimation = { entrance: 'fade', exit: 'fade', duration: 300, easing: 'ease-out', reducedMotion: false, }; const normalizedConfig = { ...config, styling: { ...defaultStyling, ...config.styling }, behavior: { ...defaultBehavior, ...config.behavior }, accessibility: { ...defaultAccessibility, ...config.accessibility }, animation: { ...defaultAnimation, ...config.animation }, zIndex: config.zIndex || 1000, }; return normalizedConfig; } initialize() { this.setupAccessibility(); this.setupStyling(); this.setupZIndex(); this.setupEventListeners(); } setupAccessibility() { const { accessibility } = this.config; this.element.setAttribute('role', accessibility.role); this.element.setAttribute('aria-label', accessibility.ariaLabel); this.element.setAttribute('aria-modal', 'true'); if (accessibility.ariaDescribedBy) { this.element.setAttribute('aria-describedby', accessibility.ariaDescribedBy); } if (accessibility.highContrastSupport) { this.element.classList.add('high-contrast-ready'); } if (accessibility.keyboardNavigation) { this.element.setAttribute('tabindex', '-1'); } } setupStyling() { const { styling } = this.config; this.element.classList.add(`modal-theme-${styling.theme}`); this.element.classList.add(`modal-size-${styling.size}`); this.element.classList.add(`modal-position-${styling.position}`); this.element.classList.add(`modal-backdrop-${styling.backdrop}`); } setupZIndex() { this.element.style.zIndex = this.config.zIndex.toString(); } setupEventListeners() { if (this.config.behavior.escapeToClose) { const escapeHandler = (e) => { const keyEvent = e; if (keyEvent.key === 'Escape' && this.isOpen) { this.hide(); } }; document.addEventListener('keydown', escapeHandler); this.eventListeners.push({ element: document, event: 'keydown', handler: escapeHandler }); } if (this.config.behavior.backdropToClose) { const backdropHandler = (e) => { if (e.target === this.element && this.isOpen) { this.hide(); } }; this.element.addEventListener('click', backdropHandler); this.eventListeners.push({ element: this.element, event: 'click', handler: backdropHandler }); } } async show(data) { if (this.isOpen) return; const validation = this.validateVisibility(); if (validation.errors.length > 0) { console.error('Modal validation failed:', validation.errors); throw new Error(`Modal validation failed: ${validation.errors.join(', ')}`); } if (this.config.behavior.restoreFocus) { this.previouslyFocusedElement = document.activeElement; } if (this.config.behavior.preventBodyScroll) { document.body.classList.add('modal-open'); } this.element.classList.remove('hidden'); await this.animateIn(); if (this.config.behavior.autoFocus) { this.setupFocusManagement(); } if (this.config.accessibility.screenReaderAnnouncements) { this.announceToScreenReader(`${this.config.accessibility.ariaLabel} opened`); } this.isOpen = true; } async hide() { if (!this.isOpen) return; await this.animateOut(); this.element.classList.add('hidden'); if (this.config.behavior.preventBodyScroll) { document.body.classList.remove('modal-open'); } if (this.config.behavior.restoreFocus && this.previouslyFocusedElement) { this.previouslyFocusedElement.focus(); this.previouslyFocusedElement = null; } if (this.config.accessibility.screenReaderAnnouncements) { this.announceToScreenReader(`${this.config.accessibility.ariaLabel} closed`); } this.isOpen = false; } async animateIn() { if (this.config.animation.reducedMotion || this.config.animation.entrance === 'none') { return; } const { entrance, duration, easing } = this.config.animation; this.element.style.transition = `all ${duration}ms ${easing}`; this.element.classList.add(`animate-${entrance}-in`); return new Promise(resolve => { setTimeout(() => { this.element.classList.remove(`animate-${entrance}-in`); resolve(); }, duration); }); } async animateOut() { if (this.config.animation.reducedMotion || this.config.animation.exit === 'none') { return; } const { exit, duration, easing } = this.config.animation; this.element.style.transition = `all ${duration}ms ${easing}`; this.element.classList.add(`animate-${exit}-out`); return new Promise(resolve => { setTimeout(() => { this.element.classList.remove(`animate-${exit}-out`); resolve(); }, duration); }); } setupFocusManagement() { this.focusableElements = Array.from(this.element.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')); if (this.focusableElements.length > 0) { this.focusableElements[0].focus(); } else { this.element.focus(); } if (this.config.accessibility.focusTrap) { this.setupFocusTrap(); } } setupFocusTrap() { const trapHandler = (e) => { const keyEvent = e; if (keyEvent.key !== 'Tab' || !this.isOpen) return; const firstFocusable = this.focusableElements[0]; const lastFocusable = this.focusableElements[this.focusableElements.length - 1]; if (keyEvent.shiftKey) { if (document.activeElement === firstFocusable) { keyEvent.preventDefault(); lastFocusable?.focus(); } } else { if (document.activeElement === lastFocusable) { keyEvent.preventDefault(); firstFocusable?.focus(); } } }; document.addEventListener('keydown', trapHandler); this.eventListeners.push({ element: document, event: 'keydown', handler: trapHandler }); } announceToScreenReader(message) { const announcement = document.createElement('div'); announcement.setAttribute('aria-live', 'polite'); announcement.setAttribute('aria-atomic', 'true'); announcement.className = 'sr-only'; announcement.textContent = message; document.body.appendChild(announcement); setTimeout(() => { document.body.removeChild(announcement); }, 1000); } validateVisibility() { const rect = this.element.getBoundingClientRect(); const computedStyle = window.getComputedStyle(this.element); const errors = []; const warnings = []; const isVisible = rect.width > 0 && rect.height > 0; const isInViewport = rect.top >= 0 && rect.bottom <= window.innerHeight; const hasCorrectZIndex = parseInt(computedStyle.zIndex) >= this.config.zIndex; const hasCorrectDisplay = computedStyle.display !== 'none'; const hasCorrectVisibility = computedStyle.visibility !== 'hidden'; if (!isVisible) errors.push('Modal is not visible (zero dimensions)'); if (!hasCorrectDisplay) errors.push('Modal has display: none'); if (!hasCorrectVisibility) errors.push('Modal has visibility: hidden'); if (!isInViewport) warnings.push('Modal is not fully in viewport'); if (!hasCorrectZIndex) warnings.push(`Modal z-index ${computedStyle.zIndex} is less than configured ${this.config.zIndex}`); let accessibilityScore = 100; if (!this.element.getAttribute('aria-label')) accessibilityScore -= 20; if (!this.element.getAttribute('role')) accessibilityScore -= 20; if (!this.element.getAttribute('aria-modal')) accessibilityScore -= 15; if (this.focusableElements.length === 0) accessibilityScore -= 25; if (errors.length > 0) accessibilityScore -= 20; return { isVisible, isInViewport, hasCorrectZIndex, hasCorrectDisplay, hasCorrectVisibility, accessibilityScore: Math.max(0, accessibilityScore), errors, warnings, }; } updateConfig(changes) { this.config = { ...this.config, ...changes }; if (changes.styling) this.setupStyling(); if (changes.accessibility) this.setupAccessibility(); if (changes.zIndex) this.setupZIndex(); } enableHighContrastMode() { this.element.classList.add('high-contrast-modal'); this.updateConfig({ ...this.config, styling: { ...this.config.styling, theme: 'high-contrast' }, }); } enableScreenReaderMode() { this.config.accessibility.screenReaderAnnouncements = true; this.config.accessibility.focusTrap = true; this.setupAccessibility(); } getState() { return { isOpen: this.isOpen, config: { ...this.config }, validation: this.validateVisibility(), }; } destroy() { this.eventListeners.forEach(({ element, event, handler }) => { element.removeEventListener(event, handler); }); this.eventListeners = []; if (this.isOpen) { this.hide(); } } } //# sourceMappingURL=UnifiedModal.js.map