UNPKG

mcp-web-ui

Version:

Ultra-lightweight vanilla JavaScript framework for MCP servers - Zero dependencies, perfect security, 2-3KB bundle size

829 lines (743 loc) 28.7 kB
/** * TodoListComponent - Advanced Todo List Implementation * * This component provides a complete todo list interface with: * - Add, edit, delete, and toggle completion * - Priority levels and categories * - Undo functionality with auto-timeout * - Smart animations and transitions * - Built-in form validation * - Accessibility features * - Mobile-responsive design * * SECURITY FEATURES: * - All user input is sanitized through BaseComponent * - XSS protection for todo text and categories * - Rate limiting on actions to prevent spam * - Secure form handling with validation * * AI INTEGRATION READY: * - Handles LLM-generated content safely * - Context-aware sanitization for different field types * - Extensive logging for debugging AI interactions * - Well-documented API for AI agents to understand * * Usage: * const todoList = new TodoListComponent(element, initialData, config); * * Config options: * - sessionToken: Authentication token * - pollInterval: How often to poll for updates (default: 2000ms) * - enableUndo: Enable undo functionality (default: true) * - undoTimeout: How long before undo expires (default: 5000ms) * - maxTodoLength: Maximum length for todo text (default: 500) * - allowCategories: Enable category support (default: true) */ class TodoListComponent extends BaseComponent { /** * Initialize TodoListComponent with additional todo-specific state * @param {HTMLElement} element - DOM element to attach to * @param {Array} data - Initial todo data * @param {Object} config - Configuration options */ constructor(element, data, config) { // Set up todo-specific configuration BEFORE calling super() // This is critical because BaseComponent constructor calls init() which calls render() const todoConfig = { enableUndo: true, undoTimeout: 5000, // 5 seconds maxTodoLength: 500, allowCategories: true, priorityLevels: ['low', 'medium', 'high', 'urgent'], defaultPriority: 'medium', ...config.todo }; // Call parent constructor - this will trigger init() and render() super(element, data, config); // Now set up instance properties this.todoConfig = todoConfig; // Form state management this.formState = { showAddForm: false, isSubmitting: false, newTodo: { text: '', priority: this.todoConfig.defaultPriority, category: '', dueDate: '' } }; // Undo system this.undoSystem = { actions: [], // Array of {id, type, originalState, timeoutId} pendingCompletes: new Set(), // Track items being completed maxUndoActions: 5 // Limit undo history }; this.log('INFO', 'TodoListComponent initialized with advanced features'); } /** * Render the complete todo list interface * This creates the HTML structure with secure templating */ render() { if (this.isDestroyed) return; this.element.innerHTML = this.html` <div class="component component-list"> ${this.trustedHtml(this.renderHeader())} ${this.trustedHtml(this.renderUndoNotifications())} ${this.trustedHtml(this.renderAddForm())} ${this.trustedHtml(this.renderTodoList())} ${this.trustedHtml(this.renderErrorMessage())} </div> `; // Focus management after render this.manageFocus(); } /** * Render the component header with statistics */ renderHeader() { const stats = this.calculateStats(); return this.html` <div class="todo-header"> <h2>Your Todos</h2> <div class="todo-stats"> <span class="stat-item"> <strong>${stats.total}</strong> total </span> <span class="stat-item"> <strong>${stats.completed}</strong> completed </span> <span class="stat-item"> <strong>${stats.pending}</strong> pending </span> ${stats.highPriority > 0 ? this.trustedHtml(` <span class="stat-item stat-urgent"> <strong>${stats.highPriority}</strong> high priority </span> `) : ''} </div> </div> `; } /** * Render undo notification system */ renderUndoNotifications() { // Defensive check: todoConfig and undoSystem might not be initialized yet during first render if (!this.todoConfig || !this.undoSystem || !this.todoConfig.enableUndo || this.undoSystem.actions.length === 0) { return ''; } return this.html` <div class="undo-container"> ${this.undoSystem.actions.map(action => ` <div class="undo-toast" data-undo-id="${action.id}"> <div class="undo-content"> <span class="undo-message">✓ ${this.getUndoMessage(action)}</span> <button class="undo-button" data-action="undo" data-undo-id="${action.id}"> Undo </button> </div> <div class="undo-progress"> <div class="undo-progress-bar"></div> </div> </div> `).join('')} </div> `; } /** * Render the add todo form */ renderAddForm() { // Defensive check: formState might not be initialized yet during first render if (!this.formState || !this.formState.showAddForm) { return this.html` <div class="add-todoodle-section"> <button class="btn-add-todo" data-action="show-form"> + Add New Todo </button> </div> `; } return this.html` <div class="add-todoodle-section"> <button class="btn-add-todo active" data-action="hide-form"> Cancel </button> <form class="add-form" data-form="add-todo"> <div class="form-row"> <div class="form-group"> <label for="todo-text">What needs to be done?</label> <input type="text" id="todo-text" name="text" class="form-input form-input-main" placeholder="Enter your todo..." value="${this.formState ? this.formState.newTodo.text : ''}" maxlength="${this.todoConfig ? this.todoConfig.maxTodoLength : 500}" required ${this.formState && this.formState.isSubmitting ? 'disabled' : ''} > </div> </div> <div class="form-row-secondary"> <div class="form-group"> <label for="todo-priority">Priority</label> <select id="todo-priority" name="priority" class="form-input form-input-small" ${this.formState && this.formState.isSubmitting ? 'disabled' : ''} > ${this.trustedHtml(this.todoConfig && this.todoConfig.priorityLevels ? this.todoConfig.priorityLevels.map(priority => ` <option value="${priority}" ${this.formState && this.formState.newTodo && this.formState.newTodo.priority === priority ? 'selected' : ''} > ${this.capitalizePriority(priority)} </option> `).join('') : '')} </select> </div> ${this.trustedHtml(this.todoConfig && this.todoConfig.allowCategories ? ` <div class="form-group"> <label for="todo-category">Category</label> <input type="text" id="todo-category" name="category" class="form-input form-input-small" placeholder="Optional category" value="${this.formState && this.formState.newTodo ? this.formState.newTodo.category : ''}" maxlength="50" ${this.formState && this.formState.isSubmitting ? 'disabled' : ''} > </div> ` : '')} <div class="form-group"> <label for="todo-due">Due Date</label> <input type="date" id="todo-due" name="dueDate" class="form-input form-input-small" value="${this.formState && this.formState.newTodo ? this.formState.newTodo.dueDate : ''}" ${this.formState && this.formState.isSubmitting ? 'disabled' : ''} > </div> </div> <div class="form-actions"> <button type="submit" class="btn-submit" ${this.formState && this.formState.isSubmitting ? 'disabled' : ''} > ${this.formState && this.formState.isSubmitting ? 'Adding...' : 'Add Todo'} </button> <button type="button" class="btn-cancel" data-action="hide-form" ${this.formState && this.formState.isSubmitting ? 'disabled' : ''} > Cancel </button> </div> </form> </div> `; } /** * Render the todo list with all items */ renderTodoList() { if (!this.data || this.data.length === 0) { return this.html` <div class="list-container"> <div class="empty-state"> <p>No todos yet! Add your first one above.</p> </div> </div> `; } // Sort todos by priority and completion status const sortedTodos = this.sortTodos(this.data); return this.html` <div class="list-container"> <div class="todo-items"> ${this.trustedHtml(sortedTodos.map(todo => this.renderTodoItem(todo)).join(''))} </div> </div> `; } /** * Render a single todo item with all controls * @param {Object} todo - Todo item data */ renderTodoItem(todo) { const isPending = this.undoSystem && this.undoSystem.pendingCompletes ? this.undoSystem.pendingCompletes.has(todo.id) : false; const isCompleted = todo.completed; const isOverdue = this.isOverdue(todo.dueDate); // Phase 1 Debug: Simple string return without any templating or sanitization return ` <div class="list-item ${isCompleted ? 'completed' : ''} ${isPending ? 'item-completing' : ''} priority-${todo.priority}" data-todo-id="${todo.id}"> <div class="item-checkbox"> <input type="checkbox" ${isCompleted ? 'checked' : ''} data-action="toggle" data-id="${todo.id}" ${isPending ? 'disabled' : ''} > </div> <div class="item-content" data-action="edit" data-id="${todo.id}"> <div class="item-main"> <span class="item-text">${todo.text}</span> </div> <div class="item-meta"> <span class="badge badge-priority priority-${todo.priority}"> ${this.capitalizePriority(todo.priority)} </span> ${todo.category ? ` <span class="badge badge-category"> ${todo.category} </span> ` : ''} ${todo.dueDate ? ` <span class="badge badge-due ${isOverdue ? 'overdue' : ''}"> ${this.formatDate(todo.dueDate)} </span> ` : ''} </div> </div> <div class="item-actions"> <button class="btn-delete" data-action="delete" data-id="${todo.id}" title="Delete todo" ${isPending ? 'disabled' : ''} > × </button> </div> </div> `; } /** * Render error message area */ renderErrorMessage() { return this.html` <div class="error-message" style="display: none;"></div> `; } /** * Bind all event listeners using secure event handling */ bindEvents() { // Form toggle events this.on('click', '[data-action="show-form"]', (e) => { this.showAddForm(); }); this.on('click', '[data-action="hide-form"]', (e) => { this.hideAddForm(); }); // Form submission this.on('submit', '[data-form="add-todo"]', async (e) => { e.preventDefault(); await this.handleAddTodo(e.target); }); // Form input changes (for real-time validation) this.on('input', '[name="text"]', (e) => { if (this.formState && this.formState.newTodo) { this.formState.newTodo.text = e.target.value; this.validateForm(); } }); this.on('change', '[name="priority"]', (e) => { if (this.formState && this.formState.newTodo) { this.formState.newTodo.priority = e.target.value; } }); if (this.todoConfig && this.todoConfig.allowCategories) { this.on('input', '[name="category"]', (e) => { if (this.formState && this.formState.newTodo) { this.formState.newTodo.category = e.target.value; } }); } this.on('change', '[name="dueDate"]', (e) => { if (this.formState && this.formState.newTodo) { this.formState.newTodo.dueDate = e.target.value; } }); // Todo actions this.on('change', '[data-action="toggle"]', async (e) => { const id = e.target.dataset.id; await this.handleToggleTodo(id, e.target.checked); }); this.on('click', '[data-action="delete"]', async (e) => { const id = e.target.dataset.id; await this.handleDeleteTodo(id); }); // Undo system if (this.todoConfig && this.todoConfig.enableUndo) { this.on('click', '[data-action="undo"]', (e) => { const undoId = e.target.dataset.undoId; this.handleUndo(undoId); }); } // Keyboard shortcuts this.on('keydown', 'input[name="text"]', (e) => { if (e.key === 'Enter' && e.ctrlKey) { // Ctrl+Enter to quickly add todo e.preventDefault(); const form = e.target.closest('form'); if (form) { form.dispatchEvent(new Event('submit')); } } else if (e.key === 'Escape') { // Escape to cancel form this.hideAddForm(); } }); } /** * Show the add todo form */ showAddForm() { this.formState.showAddForm = true; this.render(); // Focus the text input setTimeout(() => { const textInput = this.element.querySelector('input[name="text"]'); if (textInput) { textInput.focus(); } }, 50); } /** * Hide the add todo form and reset state */ hideAddForm() { this.formState.showAddForm = false; this.formState.isSubmitting = false; this.formState.newTodo = { text: '', priority: this.todoConfig.defaultPriority, category: '', dueDate: '' }; this.render(); } /** * Handle adding a new todo * @param {HTMLFormElement} form - The form element */ async handleAddTodo(form) { if (this.formState.isSubmitting) return; try { this.formState.isSubmitting = true; this.render(); // Show loading state const formData = new FormData(form); const todoData = { text: formData.get('text')?.trim() || '', priority: formData.get('priority') || this.todoConfig.defaultPriority, category: formData.get('category')?.trim() || '', dueDate: formData.get('dueDate') || '' }; // Validate form data if (!this.validateTodoData(todoData)) { throw new Error('Please fill in all required fields'); } // Submit to server await this.handleAction('add', todoData); // Success - hide form and reset this.hideAddForm(); this.log('INFO', 'Todo added successfully'); } catch (error) { this.formState.isSubmitting = false; this.render(); // Remove loading state this.handleError(error); } } /** * Handle toggling todo completion with undo support * @param {string} id - Todo ID * @param {boolean} completed - New completion status */ async handleToggleTodo(id, completed) { try { const todo = this.data.find(t => t.id === id); if (!todo) return; if (completed && this.todoConfig.enableUndo) { // Add to undo system before marking complete this.addUndoAction(id, 'complete', { ...todo }); this.undoSystem.pendingCompletes.add(id); this.render(); // Show pending state } await this.handleAction('toggle', { id, completed }); if (!completed) { // Remove from undo system if uncompleting this.removeUndoAction(id); this.undoSystem.pendingCompletes.delete(id); } } catch (error) { // Remove from pending if action failed this.undoSystem.pendingCompletes.delete(id); this.render(); this.handleError(error); } } /** * Handle deleting a todo * @param {string} id - Todo ID */ async handleDeleteTodo(id) { try { const todo = this.data.find(t => t.id === id); if (!todo) return; // Confirm deletion for important todos if (todo.priority === 'high' || todo.priority === 'urgent') { if (!confirm(`Are you sure you want to delete this ${todo.priority} priority todo?`)) { return; } } await this.handleAction('delete', { id }); this.log('INFO', `Todo deleted: ${id}`); } catch (error) { this.handleError(error); } } /** * Add an action to the undo system * @param {string} id - Todo ID * @param {string} type - Action type * @param {Object} originalState - Original todo state */ addUndoAction(id, type, originalState) { const undoId = Date.now() + Math.random(); const undoAction = { id: undoId, todoId: id, type, originalState, timeoutId: setTimeout(() => { this.removeUndoAction(undoId); }, this.todoConfig.undoTimeout) }; this.undoSystem.actions.push(undoAction); // Limit undo history if (this.undoSystem.actions.length > this.undoSystem.maxUndoActions) { const oldest = this.undoSystem.actions.shift(); if (oldest?.timeoutId) { clearTimeout(oldest.timeoutId); } } this.render(); } /** * Remove an undo action * @param {string} undoId - Undo action ID */ removeUndoAction(undoId) { const index = this.undoSystem.actions.findIndex(action => action.id === undoId); if (index !== -1) { const action = this.undoSystem.actions[index]; if (action.timeoutId) { clearTimeout(action.timeoutId); } this.undoSystem.actions.splice(index, 1); this.undoSystem.pendingCompletes.delete(action.todoId); this.render(); } } /** * Handle undo action * @param {string} undoId - Undo action ID */ async handleUndo(undoId) { const action = this.undoSystem.actions.find(a => a.id === undoId); if (!action) return; try { if (action.type === 'complete') { // Undo completion by toggling back to incomplete await this.handleAction('toggle', { id: action.todoId, completed: false }); } this.removeUndoAction(undoId); this.log('INFO', `Undid action: ${action.type}`); } catch (error) { this.handleError(error); } } /** * Calculate todo statistics */ calculateStats() { if (!this.data || this.data.length === 0) { return { total: 0, completed: 0, pending: 0, highPriority: 0 }; } return { total: this.data.length, completed: this.data.filter(todo => todo.completed).length, pending: this.data.filter(todo => !todo.completed).length, highPriority: this.data.filter(todo => ['high', 'urgent'].includes(todo.priority) && !todo.completed ).length }; } /** * Sort todos by priority and completion status * @param {Array} todos - Array of todos * @returns {Array} Sorted todos */ sortTodos(todos) { const priorityOrder = { urgent: 4, high: 3, medium: 2, low: 1 }; return [...todos].sort((a, b) => { // Incomplete todos first if (a.completed !== b.completed) { return a.completed ? 1 : -1; } // Then by priority const aPriority = priorityOrder[a.priority] || 1; const bPriority = priorityOrder[b.priority] || 1; return bPriority - aPriority; }); } /** * Validate todo data before submission * @param {Object} todoData - Todo data to validate * @returns {boolean} True if valid */ validateTodoData(todoData) { // Required fields if (!todoData.text || todoData.text.length === 0) { return false; } // Length limits if (todoData.text.length > this.todoConfig.maxTodoLength) { return false; } // Valid priority if (!this.todoConfig.priorityLevels.includes(todoData.priority)) { return false; } return true; } /** * Validate form in real-time */ validateForm() { const submitButton = this.element.querySelector('.btn-submit'); const textInput = this.element.querySelector('input[name="text"]'); if (submitButton && textInput) { const isValid = textInput.value.trim().length > 0 && textInput.value.length <= this.todoConfig.maxTodoLength; submitButton.disabled = !isValid || this.formState.isSubmitting; } } /** * Manage focus after render operations */ manageFocus() { // Defensive check: formState might not be initialized yet during first render if (!this.formState) return; // Preserve focus on form elements if they exist if (this.formState.showAddForm) { const textInput = this.element.querySelector('input[name="text"]'); if (textInput && document.activeElement !== textInput) { setTimeout(() => textInput.focus(), 50); } } } /** * Check if a todo is overdue * @param {string} dueDate - Due date string * @returns {boolean} True if overdue */ isOverdue(dueDate) { if (!dueDate) return false; const due = new Date(dueDate); const today = new Date(); today.setHours(0, 0, 0, 0); return due < today; } /** * Format date for display * @param {string} dateString - Date string * @returns {string} Formatted date */ formatDate(dateString) { if (!dateString) return ''; try { const date = new Date(dateString); const today = new Date(); const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1); if (date.toDateString() === today.toDateString()) { return 'Today'; } else if (date.toDateString() === tomorrow.toDateString()) { return 'Tomorrow'; } else { return date.toLocaleDateString(); } } catch { return dateString; } } /** * Capitalize priority for display * @param {string} priority - Priority level * @returns {string} Capitalized priority */ capitalizePriority(priority) { return priority.charAt(0).toUpperCase() + priority.slice(1); } /** * Get user-friendly undo message * @param {Object} action - Undo action * @returns {string} Undo message */ getUndoMessage(action) { switch (action.type) { case 'complete': return 'Todo completed'; case 'delete': return 'Todo deleted'; default: return 'Action completed'; } } /** * Enhanced cleanup for todo-specific resources */ destroy() { // Clear all undo timeouts this.undoSystem.actions.forEach(action => { if (action.timeoutId) { clearTimeout(action.timeoutId); } }); // Clear form state this.formState = null; this.undoSystem = null; this.todoConfig = null; // Call parent cleanup super.destroy(); } } // Export for module systems if (typeof module !== 'undefined' && module.exports) { module.exports = TodoListComponent; } // Make available globally for vanilla JS usage if (typeof window !== 'undefined') { window.TodoListComponent = TodoListComponent; }