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
JavaScript
/**
* 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;
}