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.

319 lines (274 loc) โ€ข 10.6 kB
/** * @moduleName: AnswerHandler * @version: 2.0.0 * @since: 2025-07-26 * @lastUpdated: 2025-07-26 * @projectSummary: Enhanced MCP Quiz Server - Answer Interaction Management * @techStack: TypeScript, DOM API, Event Delegation * @dependency: EventManagement, SettingsService, AppStore * @interModuleDependency: EventManagement for cleanup, SettingsService for configuration * @requirementsTraceability: * {@link Requirements.REQ_UI_002} (Dual View Mode System - Answer Selection) * {@link Requirements.REQ_EDU_001} (Educational Feedback System) * {@link Requirements.REQ_A11Y_002} (Keyboard Navigation and Accessibility) * @briefDescription: Handles all answer selection logic including click-to-select cards and instant submission * @methods: setupAnswerHandling, handleAnswerSelection, handleInstantSubmission, handleClickableCards * @contributors: GitHub Copilot * @examples: * - const handler = new AnswerHandler(store, settings); handler.setupAnswerHandling(container); * @vulnerabilitiesAssessment: DOM manipulation with proper sanitization, no sensitive data exposure */ import { SettingsService } from '../../services/SettingsService'; import { AppStore } from '../../store/AppStore'; import { EventManagement } from './EventManagement'; export class AnswerHandler { private store: AppStore; private settingsService: SettingsService; private currentSelectionTimeout: NodeJS.Timeout | null = null; constructor(store: AppStore, settingsService: SettingsService) { this.store = store; this.settingsService = settingsService; } /** * Set up answer handling for a quiz container * @param container - The container element containing quiz questions */ setupAnswerHandling(container: HTMLElement): void { const settings = this.settingsService.getSettings(); const state = this.store.getState(); const isSingleMode = state.ui.viewMode === 'single'; // Clear any existing timeouts if (this.currentSelectionTimeout) { clearTimeout(this.currentSelectionTimeout); this.currentSelectionTimeout = null; } // Clean up existing answer handlers EventManagement.cleanup(['answer-cards', 'radio-inputs']); // Use unified quiz mode setting (with fallback to legacy settings) const quizMode = settings.ui.quizMode || (settings.quiz.instantSubmission ? 'instant' : settings.ui.clickToSelectCards ? 'cards' : 'traditional'); console.log('๐ŸŽฎ Setting up answer handling for mode:', quizMode); if (quizMode === 'instant') { this.setupInstantSubmission(container); } else if (isSingleMode && quizMode === 'cards') { this.setupClickableCards(container); } else { this.setupTraditionalRadioButtons(container); } } /** * Set up instant submission answer cards */ private setupInstantSubmission(container: HTMLElement): void { EventManagement.delegate( container, '.quiz-option-card', 'click', (event, target) => this.handleInstantSubmission(event, target), 'answer-cards-instant' ); // Keyboard support for instant submission EventManagement.delegate( container, '.quiz-option-card', 'keydown', (event, target) => { if ((event as KeyboardEvent).key === 'Enter' || (event as KeyboardEvent).key === ' ') { event.preventDefault(); this.handleInstantSubmission(event, target); } }, 'answer-cards-instant-keyboard' ); } /** * Set up clickable answer cards (non-instant) */ private setupClickableCards(container: HTMLElement): void { EventManagement.delegate( container, '.quiz-option-card', 'click', (event, target) => this.handleClickableCard(event, target), 'answer-cards-clickable' ); // Keyboard support for clickable cards EventManagement.delegate( container, '.quiz-option-card', 'keydown', (event, target) => { if ((event as KeyboardEvent).key === 'Enter' || (event as KeyboardEvent).key === ' ') { event.preventDefault(); this.handleClickableCard(event, target); } }, 'answer-cards-clickable-keyboard' ); } /** * Set up traditional radio button handling */ private setupTraditionalRadioButtons(container: HTMLElement): void { EventManagement.delegate( container, 'input[type=\"radio\"]', 'change', event => this.handleRadioSelection(event), 'radio-inputs' ); } /** * Handle instant submission (click and immediate submit) */ private handleInstantSubmission(event: Event, target: Element): void { const questionId = target.getAttribute('data-question-id'); const answer = target.getAttribute('data-answer'); if (!questionId || !answer) { console.error('๐Ÿšจ Missing question ID or answer in instant submission'); return; } console.log('โšก Instant submission:', { questionId, answer }); // Update store with answer this.store.updateAnswer(questionId, answer); // Show visual feedback this.showAnswerFeedback(target, true); // Auto-advance if enabled const settings = this.settingsService.getSettings(); if (settings.ui.autoAdvanceQuestions) { this.currentSelectionTimeout = setTimeout(() => { this.triggerNextQuestion(); }, 1500); // 1.5 second delay for feedback } } /** * Handle clickable card selection (selection only, no submission) */ private handleClickableCard(event: Event, target: Element): void { const questionId = target.getAttribute('data-question-id'); const answer = target.getAttribute('data-answer'); if (!questionId || !answer) { console.error('๐Ÿšจ Missing question ID or answer in clickable card'); return; } console.log('๐Ÿ‘† Card selection:', { questionId, answer }); // Update store with answer this.store.updateAnswer(questionId, answer); // Update visual state of all cards for this question this.updateCardSelection(questionId, answer); // Show continue button if needed this.showContinueButton(); } /** * Handle traditional radio button selection */ private handleRadioSelection(event: Event): void { const radio = event.target as HTMLInputElement; const questionId = radio.name; const answer = radio.value; console.log('๐Ÿ“ป Radio selection:', { questionId, answer }); // Update store with answer this.store.updateAnswer(questionId, answer); // Show continue button if needed this.showContinueButton(); } /** * Update visual selection state for clickable cards */ private updateCardSelection(questionId: string, selectedAnswer: string): void { const cards = document.querySelectorAll(`[data-question-id=\"${questionId}\"]`); cards.forEach(card => { const cardAnswer = card.getAttribute('data-answer'); const isSelected = cardAnswer === selectedAnswer; // Update visual state if (isSelected) { card.classList.add('border-blue-500', 'bg-blue-50', 'dark:bg-blue-900/20', 'shadow-md'); card.classList.remove('border-gray-200', 'dark:border-gray-600'); card.setAttribute('aria-pressed', 'true'); } else { card.classList.remove('border-blue-500', 'bg-blue-50', 'dark:bg-blue-900/20', 'shadow-md'); card.classList.add('border-gray-200', 'dark:border-gray-600'); card.setAttribute('aria-pressed', 'false'); } // Update indicator const indicator = card.querySelector('.answer-indicator'); if (indicator) { if (isSelected) { indicator.classList.add('border-blue-500', 'bg-blue-500'); indicator.classList.remove('border-gray-300', 'dark:border-gray-500'); indicator.innerHTML = '<div class=\"w-2 h-2 bg-white rounded-full mx-auto mt-1\"></div>'; } else { indicator.classList.remove('border-blue-500', 'bg-blue-500'); indicator.classList.add('border-gray-300', 'dark:border-gray-500'); indicator.innerHTML = ''; } } }); } /** * Show visual feedback for answer selection */ private showAnswerFeedback(target: Element, isCorrect: boolean): void { // Add visual feedback classes if (isCorrect) { target.classList.add('bg-green-100', 'border-green-500', 'dark:bg-green-900/20'); } else { target.classList.add('bg-red-100', 'border-red-500', 'dark:bg-red-900/20'); } // Announce to screen readers const announcement = isCorrect ? 'Answer selected' : 'Answer selected'; this.announceToScreenReader(announcement); } /** * Show continue button for non-instant submission modes */ private showContinueButton(): void { const continueContainer = document.querySelector('.continue-button-container') as HTMLElement; const completeContainer = document.querySelector('.complete-quiz-container') as HTMLElement; if (continueContainer) { continueContainer.style.display = ''; } if (completeContainer) { completeContainer.style.display = ''; } } /** * Trigger navigation to next question (for auto-advance) */ private triggerNextQuestion(): void { // This will be handled by NavigationController const event = new CustomEvent('quiz:next-question'); document.dispatchEvent(event); } /** * Announce message to screen readers */ private announceToScreenReader(message: string): void { 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); // Remove after announcement setTimeout(() => { document.body.removeChild(announcement); }, 1000); } /** * Clean up all answer handlers */ cleanup(): void { if (this.currentSelectionTimeout) { clearTimeout(this.currentSelectionTimeout); this.currentSelectionTimeout = null; } EventManagement.componentCleanup('answer-'); console.log('๐Ÿงน AnswerHandler cleanup completed'); } }