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
JavaScript
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