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
text/typescript
/**
* @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');
}
}