Understanding Event Propagation
When an event occurs on an element, it doesn't just trigger on that element alone. Events propagate through the DOM in three phases, like ripples in a pond. Imagine dropping a stone in water - the ripples spread outward from the point of impact. Similarly, events flow through the DOM tree in a predictable pattern.
The Three Event Phases
Events propagate through the DOM in three distinct phases, each offering different opportunities for handling events.
1. Capture Phase (Capturing)
The event travels down from the window to the target element, checking each ancestor along the way.
2. Target Phase
The event reaches the target element where it originated.
3. Bubble Phase (Bubbling)
The event bubbles up from the target element back to the window, visiting each ancestor again.
// Demonstrating all three phases
const button = document.querySelector('button');
const div = document.querySelector('div');
const body = document.body;
// Capture phase listener
window.addEventListener('click', () => {
console.log('Window capture');
}, true); // true = capture phase
body.addEventListener('click', () => {
console.log('Body capture');
}, true);
div.addEventListener('click', () => {
console.log('Div capture');
}, true);
// Target phase (both capture and bubble listeners trigger here)
button.addEventListener('click', () => {
console.log('Button clicked (target)');
});
// Bubble phase listener
div.addEventListener('click', () => {
console.log('Div bubble');
}); // false = bubble phase (default)
body.addEventListener('click', () => {
console.log('Body bubble');
});
window.addEventListener('click', () => {
console.log('Window bubble');
});
// When button is clicked, output will be:
// Window capture
// Body capture
// Div capture
// Button clicked (target)
// Div bubble
// Body bubble
// Window bubble
Interactive Propagation Visualizer
class PropagationVisualizer {
constructor(rootElement) {
this.root = rootElement;
this.logContainer = document.createElement('div');
this.logContainer.className = 'event-log';
this.root.appendChild(this.logContainer);
this.setupElements();
this.attachListeners();
}
setupElements() {
// Create nested structure
const html = `
Outer
Middle
Inner
`;
const container = document.createElement('div');
container.innerHTML = html;
this.root.insertBefore(container, this.logContainer);
}
attachListeners() {
const elements = this.root.querySelectorAll('[data-name]');
elements.forEach(element => {
const name = element.dataset.name;
// Capture phase
element.addEventListener('click', (e) => {
this.log(`${name} - CAPTURE`, 'capture');
element.classList.add('capture-highlight');
setTimeout(() => element.classList.remove('capture-highlight'), 500);
}, true);
// Bubble phase
element.addEventListener('click', (e) => {
this.log(`${name} - BUBBLE`, 'bubble');
element.classList.add('bubble-highlight');
setTimeout(() => element.classList.remove('bubble-highlight'), 500);
}, false);
});
// Add clear button
const clearButton = document.createElement('button');
clearButton.textContent = 'Clear Log';
clearButton.addEventListener('click', (e) => {
e.stopPropagation();
this.clearLog();
});
this.root.insertBefore(clearButton, this.logContainer);
}
log(message, phase) {
const entry = document.createElement('div');
entry.className = `log-entry ${phase}`;
entry.textContent = message;
this.logContainer.appendChild(entry);
// Auto-scroll to bottom
this.logContainer.scrollTop = this.logContainer.scrollHeight;
}
clearLog() {
this.logContainer.innerHTML = '';
}
}
// Initialize visualizer
new PropagationVisualizer(document.querySelector('#propagation-demo'));
Controlling Event Propagation
JavaScript provides several methods to control how events propagate through the DOM.
stopPropagation()
Prevents the event from propagating further in either capturing or bubbling phase.
// Stop propagation example
const inner = document.querySelector('.inner');
const outer = document.querySelector('.outer');
inner.addEventListener('click', (e) => {
console.log('Inner clicked');
e.stopPropagation(); // Event won't reach outer
});
outer.addEventListener('click', (e) => {
console.log('Outer clicked'); // This won't run if inner is clicked
});
// Practical example: Modal dialog
class Modal {
constructor() {
this.modal = document.querySelector('.modal');
this.overlay = document.querySelector('.modal-overlay');
this.closeButton = document.querySelector('.modal-close');
this.setupEventListeners();
}
setupEventListeners() {
// Close modal when clicking overlay
this.overlay.addEventListener('click', () => {
this.close();
});
// Prevent closing when clicking modal content
this.modal.addEventListener('click', (e) => {
e.stopPropagation();
});
// Close button
this.closeButton.addEventListener('click', () => {
this.close();
});
}
open() {
this.overlay.classList.add('active');
this.modal.classList.add('active');
}
close() {
this.overlay.classList.remove('active');
this.modal.classList.remove('active');
}
}
stopImmediatePropagation()
Stops the event from propagating and prevents other handlers on the same element from executing.
const button = document.querySelector('button');
// First handler
button.addEventListener('click', (e) => {
console.log('Handler 1');
e.stopImmediatePropagation(); // Stops here
});
// Second handler (won't execute)
button.addEventListener('click', (e) => {
console.log('Handler 2'); // Never logs
});
// Parent handler (also won't execute)
button.parentElement.addEventListener('click', (e) => {
console.log('Parent handler'); // Never logs
});
preventDefault()
Prevents the default browser action but doesn't stop propagation.
// Prevent form submission
const form = document.querySelector('form');
form.addEventListener('submit', (e) => {
e.preventDefault(); // Prevent page reload
// Custom form handling
const formData = new FormData(form);
submitFormAjax(formData);
});
// Prevent link navigation
const link = document.querySelector('a');
link.addEventListener('click', (e) => {
e.preventDefault(); // Prevent navigation
// Custom behavior
loadContentAjax(e.target.href);
});
// Allow propagation but prevent default
const checkbox = document.querySelector('input[type="checkbox"]');
checkbox.addEventListener('click', (e) => {
if (!userCanToggle()) {
e.preventDefault(); // Prevent checking/unchecking
showMessage('You cannot change this setting');
}
// Event still bubbles up to parent handlers
});
Event Delegation
Event delegation is a powerful pattern that takes advantage of event bubbling. Instead of attaching event listeners to multiple child elements, you attach a single listener to a parent element and handle events for all children.
Single Event Listener] --> B[Child 1] A --> C[Child 2] A --> D[Child 3] A --> E[Child 4] A --> F[... Child N] style A fill:#f96,stroke:#333,stroke-width:4px style B fill:#bbf,stroke:#333,stroke-width:2px style C fill:#bbf,stroke:#333,stroke-width:2px style D fill:#bbf,stroke:#333,stroke-width:2px style E fill:#bbf,stroke:#333,stroke-width:2px style F fill:#bbf,stroke:#333,stroke-width:2px
Basic Event Delegation
// Without delegation (inefficient)
const buttons = document.querySelectorAll('.item-button');
buttons.forEach(button => {
button.addEventListener('click', handleButtonClick);
});
// With delegation (efficient)
const container = document.querySelector('.items-container');
container.addEventListener('click', (e) => {
if (e.target.matches('.item-button')) {
handleButtonClick(e);
}
});
// Multiple element types
container.addEventListener('click', (e) => {
const target = e.target;
if (target.matches('.delete-button')) {
deleteItem(target.closest('.item'));
} else if (target.matches('.edit-button')) {
editItem(target.closest('.item'));
} else if (target.matches('.item-title')) {
toggleItemDetails(target.closest('.item'));
}
});
// Using closest() for nested elements
container.addEventListener('click', (e) => {
const item = e.target.closest('.item');
if (!item) return; // Clicked outside any item
const action = e.target.dataset.action;
if (action) {
handleItemAction(item, action);
}
});
Advanced Delegation Patterns
// Delegated event handler class
class DelegatedEventHandler {
constructor(root, handlers) {
this.root = root;
this.handlers = handlers;
this.setupDelegation();
}
setupDelegation() {
Object.keys(this.handlers).forEach(eventType => {
this.root.addEventListener(eventType, (e) => {
const handlers = this.handlers[eventType];
for (const [selector, handler] of Object.entries(handlers)) {
const target = e.target.closest(selector);
if (target && this.root.contains(target)) {
handler.call(target, e);
}
}
});
});
}
}
// Usage
const handler = new DelegatedEventHandler(document.body, {
click: {
'.button': function(e) {
console.log('Button clicked:', this.textContent);
},
'.menu-item': function(e) {
console.log('Menu item clicked:', this.dataset.value);
},
'a[data-ajax]': function(e) {
e.preventDefault();
loadContent(this.href);
}
},
mouseover: {
'.tooltip-trigger': function(e) {
showTooltip(this);
}
},
mouseout: {
'.tooltip-trigger': function(e) {
hideTooltip(this);
}
}
});
// Dynamic list with delegation
class DynamicList {
constructor(container) {
this.container = container;
this.items = [];
this.setupDelegation();
}
setupDelegation() {
this.container.addEventListener('click', (e) => {
const target = e.target;
const item = target.closest('.list-item');
if (!item) return;
if (target.matches('.delete-btn')) {
this.deleteItem(item.dataset.id);
} else if (target.matches('.edit-btn')) {
this.editItem(item.dataset.id);
} else if (target.matches('.checkbox')) {
this.toggleItem(item.dataset.id);
}
});
// Keyboard navigation
this.container.addEventListener('keydown', (e) => {
const item = e.target.closest('.list-item');
if (!item) return;
switch(e.key) {
case 'Delete':
this.deleteItem(item.dataset.id);
break;
case 'Enter':
if (e.target.matches('.editable')) {
this.saveEdit(item.dataset.id, e.target.value);
}
break;
case 'Escape':
if (e.target.matches('.editable')) {
this.cancelEdit(item.dataset.id);
}
break;
}
});
}
addItem(data) {
const id = Date.now();
const itemData = { id, ...data };
this.items.push(itemData);
const itemElement = this.createItemElement(itemData);
this.container.appendChild(itemElement);
}
createItemElement(data) {
const div = document.createElement('div');
div.className = 'list-item';
div.dataset.id = data.id;
div.innerHTML = `
${data.text}
`;
return div;
}
deleteItem(id) {
const index = this.items.findIndex(item => item.id == id);
if (index !== -1) {
this.items.splice(index, 1);
const element = this.container.querySelector(`[data-id="${id}"]`);
if (element) {
element.remove();
}
}
}
editItem(id) {
const element = this.container.querySelector(`[data-id="${id}"]`);
const content = element.querySelector('.content');
const input = document.createElement('input');
input.type = 'text';
input.className = 'editable';
input.value = content.textContent;
content.replaceWith(input);
input.focus();
}
saveEdit(id, newValue) {
const item = this.items.find(item => item.id == id);
if (item) {
item.text = newValue;
const element = this.container.querySelector(`[data-id="${id}"]`);
const input = element.querySelector('.editable');
const span = document.createElement('span');
span.className = 'content';
span.textContent = newValue;
input.replaceWith(span);
}
}
toggleItem(id) {
const item = this.items.find(item => item.id == id);
if (item) {
item.completed = !item.completed;
}
}
}
Real-World Delegation Examples
Infinite Scroll with Delegation
class InfiniteScroll {
constructor(container, options = {}) {
this.container = container;
this.options = {
threshold: 200,
itemsPerPage: 20,
...options
};
this.page = 1;
this.loading = false;
this.hasMore = true;
this.setupDelegation();
this.setupScrollListener();
this.loadInitialContent();
}
setupDelegation() {
this.container.addEventListener('click', (e) => {
const card = e.target.closest('.content-card');
if (!card) return;
if (e.target.matches('.like-button')) {
this.toggleLike(card.dataset.id);
} else if (e.target.matches('.share-button')) {
this.shareContent(card.dataset.id);
} else if (e.target.matches('.bookmark-button')) {
this.toggleBookmark(card.dataset.id);
}
});
// Handle image lazy loading
this.container.addEventListener('scroll', () => {
this.checkVisibleImages();
});
}
setupScrollListener() {
const scrollHandler = this.throttle(() => {
if (this.shouldLoadMore()) {
this.loadMoreContent();
}
}, 200);
window.addEventListener('scroll', scrollHandler);
}
shouldLoadMore() {
if (this.loading || !this.hasMore) return false;
const scrollPosition = window.innerHeight + window.scrollY;
const threshold = document.body.offsetHeight - this.options.threshold;
return scrollPosition >= threshold;
}
async loadMoreContent() {
if (this.loading) return;
this.loading = true;
this.showLoader();
try {
const data = await this.fetchContent(this.page);
this.renderContent(data.items);
this.page++;
this.hasMore = data.hasMore;
if (!this.hasMore) {
this.showEndMessage();
}
} catch (error) {
this.showError(error);
} finally {
this.loading = false;
this.hideLoader();
}
}
renderContent(items) {
const fragment = document.createDocumentFragment();
items.forEach(item => {
const card = this.createCard(item);
fragment.appendChild(card);
});
this.container.appendChild(fragment);
}
createCard(item) {
const div = document.createElement('div');
div.className = 'content-card';
div.dataset.id = item.id;
div.innerHTML = `
${item.title}
${item.description}
`;
return div;
}
checkVisibleImages() {
const images = this.container.querySelectorAll('img.lazy-image');
images.forEach(img => {
if (this.isElementInViewport(img)) {
this.loadImage(img);
}
});
}
isElementInViewport(el) {
const rect = el.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth
);
}
loadImage(img) {
const src = img.dataset.src;
if (src) {
img.src = src;
img.classList.remove('lazy-image');
delete img.dataset.src;
}
}
throttle(func, limit) {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
}
Interactive Table with Delegation
class DataTable {
constructor(container, options = {}) {
this.container = container;
this.data = options.data || [];
this.columns = options.columns || [];
this.sortColumn = null;
this.sortDirection = 'asc';
this.selectedRows = new Set();
this.render();
this.setupDelegation();
}
setupDelegation() {
this.container.addEventListener('click', (e) => {
const target = e.target;
// Header clicks for sorting
if (target.matches('th[data-sortable]')) {
this.handleSort(target.dataset.column);
}
// Row selection
if (target.matches('input[type="checkbox"].row-select')) {
this.handleRowSelect(target);
}
// Select all checkbox
if (target.matches('input[type="checkbox"].select-all')) {
this.handleSelectAll(target.checked);
}
// Action buttons
if (target.matches('[data-action]')) {
const row = target.closest('tr');
if (row) {
this.handleAction(target.dataset.action, row.dataset.id);
}
}
// Cell editing
if (target.matches('td[data-editable="true"]')) {
this.handleCellEdit(target);
}
});
// Handle cell editing completion
this.container.addEventListener('blur', (e) => {
if (e.target.matches('.cell-editor')) {
this.saveCellEdit(e.target);
}
}, true);
this.container.addEventListener('keydown', (e) => {
if (e.target.matches('.cell-editor')) {
if (e.key === 'Enter') {
this.saveCellEdit(e.target);
} else if (e.key === 'Escape') {
this.cancelCellEdit(e.target);
}
}
});
}
render() {
const table = document.createElement('table');
table.className = 'data-table';
// Create header
const thead = document.createElement('thead');
const headerRow = document.createElement('tr');
// Add select all checkbox
const selectAllTh = document.createElement('th');
selectAllTh.innerHTML = '';
headerRow.appendChild(selectAllTh);
// Add column headers
this.columns.forEach(column => {
const th = document.createElement('th');
th.textContent = column.label;
if (column.sortable) {
th.dataset.sortable = 'true';
th.dataset.column = column.field;
th.classList.add('sortable');
if (this.sortColumn === column.field) {
th.classList.add('sorted', this.sortDirection);
}
}
headerRow.appendChild(th);
});
// Add actions column
const actionsTh = document.createElement('th');
actionsTh.textContent = 'Actions';
headerRow.appendChild(actionsTh);
thead.appendChild(headerRow);
table.appendChild(thead);
// Create body
const tbody = document.createElement('tbody');
this.renderRows(tbody);
table.appendChild(tbody);
this.container.innerHTML = '';
this.container.appendChild(table);
}
renderRows(tbody) {
this.data.forEach(row => {
const tr = document.createElement('tr');
tr.dataset.id = row.id;
// Checkbox cell
const checkboxTd = document.createElement('td');
checkboxTd.innerHTML = `
`;
tr.appendChild(checkboxTd);
// Data cells
this.columns.forEach(column => {
const td = document.createElement('td');
td.textContent = row[column.field];
if (column.editable) {
td.dataset.editable = 'true';
td.dataset.field = column.field;
}
tr.appendChild(td);
});
// Actions cell
const actionsTd = document.createElement('td');
actionsTd.innerHTML = `
`;
tr.appendChild(actionsTd);
tbody.appendChild(tr);
});
}
handleSort(column) {
if (this.sortColumn === column) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.sortColumn = column;
this.sortDirection = 'asc';
}
this.data.sort((a, b) => {
const valueA = a[column];
const valueB = b[column];
if (typeof valueA === 'string') {
return this.sortDirection === 'asc'
? valueA.localeCompare(valueB)
: valueB.localeCompare(valueA);
} else {
return this.sortDirection === 'asc'
? valueA - valueB
: valueB - valueA;
}
});
this.render();
}
handleRowSelect(checkbox) {
const row = checkbox.closest('tr');
const id = row.dataset.id;
if (checkbox.checked) {
this.selectedRows.add(id);
} else {
this.selectedRows.delete(id);
}
this.updateSelectAllCheckbox();
}
handleSelectAll(checked) {
const checkboxes = this.container.querySelectorAll('.row-select');
checkboxes.forEach(checkbox => {
checkbox.checked = checked;
const row = checkbox.closest('tr');
const id = row.dataset.id;
if (checked) {
this.selectedRows.add(id);
} else {
this.selectedRows.delete(id);
}
});
}
updateSelectAllCheckbox() {
const selectAll = this.container.querySelector('.select-all');
const checkboxes = this.container.querySelectorAll('.row-select');
const checkedCount = this.selectedRows.size;
selectAll.checked = checkedCount === checkboxes.length;
selectAll.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length;
}
handleCellEdit(cell) {
if (cell.querySelector('.cell-editor')) return;
const currentValue = cell.textContent;
const input = document.createElement('input');
input.type = 'text';
input.className = 'cell-editor';
input.value = currentValue;
cell.textContent = '';
cell.appendChild(input);
input.focus();
input.select();
}
saveCellEdit(input) {
const cell = input.parentElement;
const newValue = input.value;
const row = cell.closest('tr');
const field = cell.dataset.field;
const id = row.dataset.id;
// Update data
const dataRow = this.data.find(r => r.id == id);
if (dataRow) {
dataRow[field] = newValue;
}
cell.textContent = newValue;
}
cancelCellEdit(input) {
const cell = input.parentElement;
const row = cell.closest('tr');
const field = cell.dataset.field;
const id = row.dataset.id;
const dataRow = this.data.find(r => r.id == id);
cell.textContent = dataRow ? dataRow[field] : '';
}
handleAction(action, id) {
switch(action) {
case 'edit':
console.log('Edit row:', id);
break;
case 'delete':
if (confirm('Delete this row?')) {
this.data = this.data.filter(row => row.id != id);
this.render();
}
break;
}
}
}
Performance Benefits of Delegation
Event delegation provides significant performance advantages, especially for dynamic content.
Without Delegation
- Multiple event listeners (one per element)
- Memory usage increases with element count
- Must re-attach listeners for new elements
- Poor performance with many elements
With Delegation
- Single event listener
- Constant memory usage
- Works automatically with new elements
- Excellent performance regardless of element count
// Performance comparison
class PerformanceTest {
constructor() {
this.container = document.querySelector('#test-container');
}
// Without delegation - poor performance
testWithoutDelegation(itemCount) {
console.time('Without delegation');
for (let i = 0; i < itemCount; i++) {
const button = document.createElement('button');
button.textContent = `Button ${i}`;
button.addEventListener('click', () => {
console.log(`Button ${i} clicked`);
});
this.container.appendChild(button);
}
console.timeEnd('Without delegation');
// Memory usage: itemCount event listeners
console.log(`Created ${itemCount} event listeners`);
}
// With delegation - excellent performance
testWithDelegation(itemCount) {
console.time('With delegation');
// Single event listener
this.container.addEventListener('click', (e) => {
if (e.target.matches('button')) {
console.log(`${e.target.textContent} clicked`);
}
});
// Create buttons without individual listeners
for (let i = 0; i < itemCount; i++) {
const button = document.createElement('button');
button.textContent = `Button ${i}`;
this.container.appendChild(button);
}
console.timeEnd('With delegation');
// Memory usage: 1 event listener
console.log('Created 1 event listener');
}
// Dynamic content handling
testDynamicContent() {
// With delegation - automatically works
this.container.addEventListener('click', (e) => {
if (e.target.matches('.dynamic-button')) {
console.log('Dynamic button clicked');
}
});
// Add buttons dynamically later
setTimeout(() => {
const button = document.createElement('button');
button.className = 'dynamic-button';
button.textContent = 'Added Later';
this.container.appendChild(button);
// No need to attach listener - delegation handles it!
}, 1000);
}
}
// Run tests
const test = new PerformanceTest();
test.testWithoutDelegation(1000); // Slower, more memory
test.testWithDelegation(1000); // Faster, less memory
Best Practices
- Use delegation for dynamic content: Perfect for lists, tables, and generated elements
- Check event target carefully: Use matches() and closest() for accurate targeting
- Stop propagation judiciously: Only when necessary, as it can break delegation
- Delegate at appropriate level: Not too high (document) or too low (loses benefit)
- Consider event frequency: High-frequency events may need throttling
// Best practices example
class BestPracticesDemo {
constructor(container) {
this.container = container;
this.setupDelegation();
}
setupDelegation() {
// Good: Delegate at appropriate container level
this.container.addEventListener('click', (e) => {
// Good: Use matches() for precise targeting
if (e.target.matches('.action-button')) {
this.handleAction(e.target);
}
// Good: Use closest() for nested elements
const item = e.target.closest('.list-item');
if (item) {
this.handleItemClick(item, e.target);
}
});
// Good: Separate high-frequency events
this.container.addEventListener('mousemove',
this.throttle(this.handleMouseMove.bind(this), 50)
);
// Good: Use passive listeners for scroll/touch
this.container.addEventListener('scroll',
this.handleScroll.bind(this),
{ passive: true }
);
}
handleItemClick(item, target) {
// Good: Check specific target within item
if (target.matches('.delete-button')) {
this.deleteItem(item);
} else if (target.matches('.edit-button')) {
this.editItem(item);
} else if (target.matches('.toggle-details')) {
this.toggleDetails(item);
}
}
// Good: Clean separation of concerns
deleteItem(item) {
if (confirm('Delete this item?')) {
item.remove();
this.updateItemCount();
}
}
editItem(item) {
const content = item.querySelector('.content');
this.makeEditable(content);
}
// Good: Helper method for common functionality
throttle(func, limit) {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
}
Practice Exercises
- Create a todo list that uses event delegation for all interactions
- Build a navigation menu that handles clicks through delegation
- Implement a data grid with sorting, filtering, and editing using delegation
- Create a comment system with nested replies using event delegation
Exercise Solutions (Try First!)
Click to see solutions
// 1. Todo List with Event Delegation
class TodoList {
constructor(container) {
this.container = container;
this.todos = [];
this.filter = 'all'; // all, active, completed
this.render();
this.setupDelegation();
}
setupDelegation() {
this.container.addEventListener('click', (e) => {
const target = e.target;
const todoItem = target.closest('.todo-item');
if (target.matches('.add-todo-btn')) {
this.handleAddTodo();
} else if (target.matches('.todo-checkbox')) {
this.toggleTodo(todoItem.dataset.id);
} else if (target.matches('.delete-todo')) {
this.deleteTodo(todoItem.dataset.id);
} else if (target.matches('.edit-todo')) {
this.startEdit(todoItem);
} else if (target.matches('.filter-btn')) {
this.setFilter(target.dataset.filter);
} else if (target.matches('.clear-completed')) {
this.clearCompleted();
}
});
this.container.addEventListener('keypress', (e) => {
if (e.target.matches('.new-todo-input') && e.key === 'Enter') {
this.handleAddTodo();
} else if (e.target.matches('.edit-input') && e.key === 'Enter') {
this.finishEdit(e.target);
}
});
this.container.addEventListener('blur', (e) => {
if (e.target.matches('.edit-input')) {
this.finishEdit(e.target);
}
}, true);
}
render() {
this.container.innerHTML = `
`;
this.renderTodos();
this.updateFooter();
}
renderTodos() {
const list = this.container.querySelector('.todo-list');
const filteredTodos = this.getFilteredTodos();
list.innerHTML = filteredTodos.map(todo => `
${todo.text}
`).join('');
}
handleAddTodo() {
const input = this.container.querySelector('.new-todo-input');
const text = input.value.trim();
if (text) {
this.todos.push({
id: Date.now(),
text: text,
completed: false
});
input.value = '';
this.renderTodos();
this.updateFooter();
}
}
toggleTodo(id) {
const todo = this.todos.find(t => t.id == id);
if (todo) {
todo.completed = !todo.completed;
this.renderTodos();
this.updateFooter();
}
}
deleteTodo(id) {
this.todos = this.todos.filter(t => t.id != id);
this.renderTodos();
this.updateFooter();
}
startEdit(todoItem) {
const textSpan = todoItem.querySelector('.todo-text');
const currentText = textSpan.textContent;
const input = document.createElement('input');
input.type = 'text';
input.className = 'edit-input';
input.value = currentText;
textSpan.replaceWith(input);
input.focus();
input.select();
}
finishEdit(input) {
const todoItem = input.closest('.todo-item');
const id = todoItem.dataset.id;
const newText = input.value.trim();
if (newText) {
const todo = this.todos.find(t => t.id == id);
if (todo) {
todo.text = newText;
}
}
this.renderTodos();
}
setFilter(filter) {
this.filter = filter;
this.renderTodos();
// Update active filter button
this.container.querySelectorAll('.filter-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.filter === filter);
});
}
getFilteredTodos() {
switch (this.filter) {
case 'active':
return this.todos.filter(t => !t.completed);
case 'completed':
return this.todos.filter(t => t.completed);
default:
return this.todos;
}
}
clearCompleted() {
this.todos = this.todos.filter(t => !t.completed);
this.renderTodos();
this.updateFooter();
}
updateFooter() {
const activeCount = this.todos.filter(t => !t.completed).length;
const countElement = this.container.querySelector('.todo-count');
countElement.textContent = `${activeCount} item${activeCount !== 1 ? 's' : ''} left`;
const clearButton = this.container.querySelector('.clear-completed');
clearButton.style.display = this.todos.some(t => t.completed) ? 'block' : 'none';
}
}
// 2. Navigation Menu with Delegation
class NavigationMenu {
constructor(container) {
this.container = container;
this.setupDelegation();
}
setupDelegation() {
this.container.addEventListener('click', (e) => {
const target = e.target;
// Handle main menu items
if (target.matches('.nav-link')) {
e.preventDefault();
this.handleNavClick(target);
}
// Handle dropdown toggles
if (target.matches('.dropdown-toggle')) {
e.preventDefault();
this.toggleDropdown(target);
}
// Handle submenu items
if (target.matches('.submenu-link')) {
e.preventDefault();
this.handleSubmenuClick(target);
}
// Close dropdowns when clicking outside
if (!target.closest('.dropdown')) {
this.closeAllDropdowns();
}
});
// Keyboard navigation
this.container.addEventListener('keydown', (e) => {
const target = e.target;
if (target.matches('.nav-link, .dropdown-toggle')) {
this.handleKeyboardNav(e, target);
}
});
// Close dropdowns on escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.closeAllDropdowns();
}
});
}
handleNavClick(link) {
// Remove active class from all links
this.container.querySelectorAll('.nav-link').forEach(l => {
l.classList.remove('active');
});
// Add active class to clicked link
link.classList.add('active');
// Handle navigation
const href = link.getAttribute('href');
console.log('Navigating to:', href);
}
toggleDropdown(toggle) {
const dropdown = toggle.closest('.dropdown');
const isOpen = dropdown.classList.contains('open');
// Close all other dropdowns
this.closeAllDropdowns();
// Toggle current dropdown
if (!isOpen) {
dropdown.classList.add('open');
toggle.setAttribute('aria-expanded', 'true');
// Focus first submenu item
const firstItem = dropdown.querySelector('.submenu-link');
if (firstItem) firstItem.focus();
}
}
closeAllDropdowns() {
this.container.querySelectorAll('.dropdown.open').forEach(dropdown => {
dropdown.classList.remove('open');
dropdown.querySelector('.dropdown-toggle').setAttribute('aria-expanded', 'false');
});
}
handleSubmenuClick(link) {
console.log('Submenu clicked:', link.textContent);
this.closeAllDropdowns();
}
handleKeyboardNav(e, target) {
const items = Array.from(this.container.querySelectorAll('.nav-link, .dropdown-toggle'));
const currentIndex = items.indexOf(target);
switch (e.key) {
case 'ArrowRight':
e.preventDefault();
const nextIndex = (currentIndex + 1) % items.length;
items[nextIndex].focus();
break;
case 'ArrowLeft':
e.preventDefault();
const prevIndex = (currentIndex - 1 + items.length) % items.length;
items[prevIndex].focus();
break;
case 'ArrowDown':
if (target.matches('.dropdown-toggle')) {
e.preventDefault();
this.toggleDropdown(target);
}
break;
case 'Enter':
case ' ':
e.preventDefault();
target.click();
break;
}
}
}
Summary
Event propagation and delegation are powerful concepts that enable efficient event handling:
- Events flow through three phases: capture, target, and bubble
- Use stopPropagation() to control event flow when needed
- Event delegation provides better performance and simpler code
- Perfect for dynamic content and large numbers of elements
- Use matches() and closest() for accurate event targeting
- Always consider delegation before attaching multiple listeners
Next up: We'll explore form events and validation in detail!