Introduction to Event Propagation
In our previous lectures, we explored DOM events, handlers, and listener registration. Now we'll dive deeper into event propagation—the mechanism by which events travel through the DOM tree—and delegation, a powerful pattern that leverages propagation for efficient event handling.
Event propagation is a fundamental concept in the DOM event model. When an event occurs on an element, it doesn't just affect that specific element but also triggers handlers on containing elements in a specific order.
Key Concepts
- Event Propagation
- The process by which an event travels through the DOM tree
- Event Bubbling
- Events bubble up from the target element to the root of the document
- Event Capturing
- Events are captured down from the root to the target element
- Event Target
- The specific element where the event initially occurred
- Event Delegation
- Pattern of handling events at a higher level than the elements generating them
Real-World Analogy: The Stadium Wave
Think of event propagation like a "wave" in a sports stadium:
- Capturing phase: The wave starts at the top row (document) and travels down toward where it was initiated (target element)
- Target phase: The wave reaches the section where it started (the event target)
- Bubbling phase: The wave then travels back up from the starting section to the top row
Just as a person can participate in the wave when it passes their row going down OR up, event handlers can respond during either the capturing or bubbling phase (or both).
Event Flow: Capturing and Bubbling
When an event occurs on an element, the DOM event model processes it in three phases:
The Three Phases
- Capturing Phase: The event starts at the top of the DOM (the window and document) and travels down through each ancestor element until it reaches the target element.
- Target Phase: The event reaches the element that triggered it (the event target).
- Bubbling Phase: The event bubbles back up from the target element through its ancestors to the root of the document.
Visualizing Event Propagation
Consider this HTML structure:
<div id="outer">
<div id="middle">
<button id="inner">Click Me</button>
</div>
</div>
Let's attach event listeners to each element in both phases:
// Get elements
const outer = document.getElementById('outer');
const middle = document.getElementById('middle');
const inner = document.getElementById('inner');
// Capturing phase listeners (third parameter true)
outer.addEventListener('click', function(event) {
console.log('1. CAPTURE phase: Outer div');
}, true);
middle.addEventListener('click', function(event) {
console.log('2. CAPTURE phase: Middle div');
}, true);
inner.addEventListener('click', function(event) {
console.log('3. CAPTURE phase: Inner button');
}, true);
// Bubbling phase listeners (third parameter false or omitted)
outer.addEventListener('click', function(event) {
console.log('6. BUBBLE phase: Outer div');
});
middle.addEventListener('click', function(event) {
console.log('5. BUBBLE phase: Middle div');
});
inner.addEventListener('click', function(event) {
console.log('4. BUBBLE phase: Inner button');
});
When clicking the button, you'll see this sequence in the console:
1. CAPTURE phase: Outer div
2. CAPTURE phase: Middle div
3. CAPTURE phase: Inner button
4. BUBBLE phase: Inner button
5. BUBBLE phase: Middle div
6. BUBBLE phase: Outer div
Default Behavior
By default, event listeners are registered for the bubbling phase. To register a listener for the capturing phase, set the third parameter of addEventListener() to true.
// Bubbling phase listener (default)
element.addEventListener('click', handler);
// Capturing phase listener
element.addEventListener('click', handler, true);
// Using options object
element.addEventListener('click', handler, {
capture: true // Register for capturing phase
});
Controlling Event Propagation
Sometimes you may want to stop an event from continuing its journey through the DOM. JavaScript provides methods to control propagation:
stopPropagation()
Prevents an event from bubbling up to parent elements (or down during capturing):
middle.addEventListener('click', function(event) {
console.log('Middle div clicked');
// Stop the event from propagating further
event.stopPropagation();
// Parent handlers won't be triggered
});
// This won't run if clicking on middle or its children
outer.addEventListener('click', function(event) {
console.log('Outer div clicked'); // Won't execute if middle was clicked
});
stopImmediatePropagation()
More powerful than stopPropagation()—it prevents the event from triggering any other handlers, even on the same element:
// First handler
element.addEventListener('click', function(event) {
console.log('First handler');
event.stopImmediatePropagation();
});
// Second handler - won't execute
element.addEventListener('click', function(event) {
console.log('Second handler'); // This never runs
});
preventDefault()
Prevents the default browser action associated with the event, but doesn't stop propagation:
// Prevent form submission but allow event to propagate
form.addEventListener('submit', function(event) {
event.preventDefault(); // Form won't submit
console.log('Form submission prevented');
// Event still propagates to parent elements
});
// This will still run
document.body.addEventListener('submit', function(event) {
console.log('Body received submit event');
});
Common Misconception
It's important to understand that preventDefault() and stopPropagation() are completely independent:
preventDefault(): Stops the browser's default action but allows the event to continue propagatingstopPropagation(): Stops the event from traveling through the DOM but doesn't prevent default browser behavior
You may need to use both methods together for some scenarios:
link.addEventListener('click', function(event) {
// Prevent navigation
event.preventDefault();
// Also stop the event from bubbling up
event.stopPropagation();
// Custom behavior
showContent(this.getAttribute('href'));
});
Practical Example: Modal Dialog
Here's how event propagation is used in a modal dialog:
// HTML structure
// <div id="modal-overlay" class="modal-overlay">
// <div id="modal-content" class="modal-content">
// <button id="close-button">×</button>
// <div id="modal-body">...content...</div>
// </div>
// </div>
const overlay = document.getElementById('modal-overlay');
const content = document.getElementById('modal-content');
const closeButton = document.getElementById('close-button');
// Close the modal when clicking the overlay (outside content)
overlay.addEventListener('click', function(event) {
if (event.target === overlay) {
closeModal();
}
});
// Close button handler
closeButton.addEventListener('click', function(event) {
// This doesn't need stopPropagation since the overlay handler
// only cares if the target is the overlay itself
closeModal();
});
// Prevent clicks inside the content area from closing the modal
content.addEventListener('click', function(event) {
// Don't need stopPropagation here if the overlay handler
// checks event.target as shown above
});
Event Delegation Pattern
Event delegation is a powerful pattern that leverages event bubbling to handle events efficiently. Instead of attaching event listeners to specific elements, you attach a single listener to a parent element and use the event.target property to determine which child element triggered the event.
Benefits of Event Delegation
- Memory Efficiency: Fewer event listeners means less memory usage
- Dynamic Elements: Works with elements added to the DOM after initial setup
- Less Code: No need to attach/detach listeners when elements are added/removed
- Simplified Maintenance: Centralized event handling logic
Without Event Delegation
// Without delegation - inefficient for many elements
document.querySelectorAll('.menu-item').forEach(item => {
item.addEventListener('click', function(event) {
console.log('Item clicked:', this.textContent);
navigateToSection(this.dataset.section);
});
});
With Event Delegation
// With delegation - one listener handles all items
document.getElementById('menu').addEventListener('click', function(event) {
// Find the closest .menu-item that was clicked (or a child of it)
const menuItem = event.target.closest('.menu-item');
// If a menu item was clicked
if (menuItem) {
console.log('Item clicked:', menuItem.textContent);
navigateToSection(menuItem.dataset.section);
}
});
Advanced Delegation Example
// HTML structure
// <div id="task-list">
// <div class="task-item" data-id="1">
// <span class="task-title">Task 1</span>
// <div class="task-actions">
// <button class="edit-btn">Edit</button>
// <button class="delete-btn">Delete</button>
// <button class="complete-btn">Complete</button>
// </div>
// </div>
// <!-- More task items... -->
// </div>
const taskList = document.getElementById('task-list');
// One listener handles all task-related actions
taskList.addEventListener('click', function(event) {
// Find the task item containing the clicked element
const taskItem = event.target.closest('.task-item');
// If not clicking on a task item or its children, exit
if (!taskItem) return;
// Get the task ID
const taskId = taskItem.dataset.id;
// Handle different button clicks
if (event.target.classList.contains('edit-btn')) {
editTask(taskId);
} else if (event.target.classList.contains('delete-btn')) {
deleteTask(taskId);
} else if (event.target.classList.contains('complete-btn')) {
completeTask(taskId);
} else if (event.target.classList.contains('task-title')) {
showTaskDetails(taskId);
}
});
// Functions to handle various actions
function editTask(id) {
console.log('Editing task:', id);
// Implementation...
}
function deleteTask(id) {
console.log('Deleting task:', id);
// Implementation...
}
function completeTask(id) {
console.log('Marking task complete:', id);
// Implementation...
}
function showTaskDetails(id) {
console.log('Showing details for task:', id);
// Implementation...
}
The closest() Method
The closest() method is invaluable for event delegation. It traverses up the DOM tree from the target element, looking for the first ancestor that matches a specified selector:
// event.target might be a deeply nested element
// closest() helps find the relevant container element
const listItem = event.target.closest('li');
const card = event.target.closest('.card');
const tableRow = event.target.closest('tr');
// Checking if an element matches a selector
if (event.target.closest('.interactive-element')) {
// Handle interaction
}
This is more reliable than checking class names directly, as it handles cases where a child element of your target was actually clicked.
Advanced Delegation Techniques
Event delegation can be extended to create highly efficient and flexible event handling systems:
Multi-Level Delegation
Handling different types of elements at different levels of the DOM:
// Handle all clicks at the document level
document.addEventListener('click', function(event) {
// Navigation menu handling
if (event.target.closest('nav')) {
const menuItem = event.target.closest('.menu-item');
if (menuItem) {
navigateToSection(menuItem.dataset.section);
return;
}
}
// Card actions handling
if (event.target.closest('.card')) {
const card = event.target.closest('.card');
// Handle different buttons within the card
if (event.target.classList.contains('card-expand-btn')) {
expandCard(card);
} else if (event.target.classList.contains('card-delete-btn')) {
deleteCard(card);
} else {
// Clicking on the card itself
selectCard(card);
}
return;
}
// Form field validation
if (event.target.closest('form')) {
const input = event.target.closest('input, select, textarea');
if (input && event.type === 'blur') {
validateField(input);
}
}
});
Data Attributes for Actions
Using data attributes to specify the action to take:
// HTML structure
// <div id="dashboard">
// <button data-action="export" data-format="csv">Export CSV</button>
// <button data-action="export" data-format="pdf">Export PDF</button>
// <button data-action="refresh">Refresh Data</button>
// <button data-action="filter" data-filter="today">Today Only</button>
// <button data-action="filter" data-filter="week">This Week</button>
// </div>
document.getElementById('dashboard').addEventListener('click', function(event) {
// Find closest element with a data-action attribute
const actionElement = event.target.closest('[data-action]');
if (!actionElement) return;
// Get the specified action
const action = actionElement.dataset.action;
// Handle different actions
switch (action) {
case 'export':
const format = actionElement.dataset.format;
exportData(format);
break;
case 'refresh':
refreshData();
break;
case 'filter':
const filterType = actionElement.dataset.filter;
applyFilter(filterType);
break;
}
});
Multiple Event Types with Delegation
Handling various event types on the same container:
const productList = document.getElementById('product-list');
// Handle clicks
productList.addEventListener('click', function(event) {
const product = event.target.closest('.product');
if (!product) return;
if (event.target.classList.contains('add-to-cart')) {
addToCart(product.dataset.id);
} else if (event.target.classList.contains('view-details')) {
viewProductDetails(product.dataset.id);
} else {
// Clicking on the product itself
selectProduct(product);
}
});
// Handle hover events
productList.addEventListener('mouseover', function(event) {
const product = event.target.closest('.product');
if (product) {
showProductPreview(product);
}
});
productList.addEventListener('mouseout', function(event) {
const product = event.target.closest('.product');
if (product) {
hideProductPreview(product);
}
});
Binding Data to DOM Elements
Combining delegation with data binding for powerful UIs:
// When rendering data to the DOM, store references to the data objects
function renderUsers(users) {
const userList = document.getElementById('user-list');
userList.innerHTML = '';
users.forEach(user => {
const userElement = document.createElement('div');
userElement.className = 'user-card';
userElement.dataset.userId = user.id;
// Store reference to the full user object
userElement._userData = user;
userElement.innerHTML = `
<img src="${user.avatar}" alt="${user.name}" class="user-avatar">
<div class="user-info">
<h3>${user.name}</h3>
<p>${user.email}</p>
</div>
<div class="user-actions">
<button class="edit-btn">Edit</button>
<button class="delete-btn">Delete</button>
</div>
`;
userList.appendChild(userElement);
});
}
// Event delegation with data retrieval
document.getElementById('user-list').addEventListener('click', function(event) {
const userCard = event.target.closest('.user-card');
if (!userCard) return;
// Access the stored user data
const userData = userCard._userData;
if (event.target.classList.contains('edit-btn')) {
openUserEditor(userData);
} else if (event.target.classList.contains('delete-btn')) {
confirmUserDeletion(userData);
} else {
// Clicking on the card itself
showUserDetails(userData);
}
});
Events That Don't Bubble
While event delegation relies on bubbling, not all events bubble up the DOM tree. For these events, you'll need to attach listeners directly to the elements:
Common Non-Bubbling Events
| Event | Description | Alternative Approach |
|---|---|---|
focus |
Element receives focus | Use focusin (bubbles) instead |
blur |
Element loses focus | Use focusout (bubbles) instead |
mouseenter |
Mouse enters an element | Use mouseover (bubbles) instead |
mouseleave |
Mouse leaves an element | Use mouseout (bubbles) instead |
load |
Resource loading completes | Attach listeners directly to elements |
unload |
Page is being unloaded | Attach to window object directly |
resize |
Element size changes | Use ResizeObserver instead for elements |
scroll (sometimes) |
Element scrolling | Depends on browser implementation |
Using Bubbling Alternatives
// Instead of individual focus/blur handlers:
document.querySelectorAll('input').forEach(input => {
input.addEventListener('focus', handleFocus);
input.addEventListener('blur', handleBlur);
});
// Use bubbling events with delegation:
document.querySelector('form').addEventListener('focusin', function(event) {
if (event.target.tagName === 'INPUT') {
handleFocus.call(event.target, event);
}
});
document.querySelector('form').addEventListener('focusout', function(event) {
if (event.target.tagName === 'INPUT') {
handleBlur.call(event.target, event);
}
});
Event Behavior Differences
While focusin/focusout and mouseover/mouseout are bubbling alternatives to their non-bubbling counterparts, they don't behave exactly the same way:
mouseenter/mouseleaveonly fire when the mouse enters/leaves the element itself, not when entering/leaving child elementsmouseover/mouseoutfire when entering/leaving the element OR any of its children
Consider these differences when choosing which events to use.
Custom Event Delegation
You can create advanced event handling systems by combining event delegation with custom events:
Delegated Custom Events
// Set up a custom event system with delegation
const EventSystem = {
// Create and dispatch a custom event
trigger: function(element, eventName, detail = {}) {
const event = new CustomEvent(eventName, {
bubbles: true, // Enable bubbling for delegation
cancelable: true, // Allow preventing default behavior
detail: detail // Custom data to pass with the event
});
element.dispatchEvent(event);
},
// Set up delegated handling of custom events
delegate: function(container, eventName, selector, handler) {
container.addEventListener(eventName, function(event) {
// If this is a custom event, use event.target
// If it bubbled from a regular event, use event.detail.originalEvent.target
const target = event.detail.originalEvent ?
event.detail.originalEvent.target :
event.target;
// Find matching element
const matchedElement = target.closest(selector);
if (matchedElement) {
// Call handler with matched element as 'this'
handler.call(matchedElement, event, event.detail);
}
});
}
};
// Usage example
// Set up delegation for a custom event
EventSystem.delegate(document.body, 'item:select', '.list-item', function(event, detail) {
console.log('Item selected:', this);
console.log('Extra data:', detail);
});
// Trigger the custom event
const item = document.querySelector('.list-item');
EventSystem.trigger(item, 'item:select', {
index: 5,
value: 'Sample Item'
});
// You can also use it to create higher-level events from regular DOM events
document.querySelector('.product-list').addEventListener('click', function(event) {
if (event.target.closest('.buy-button')) {
const product = event.target.closest('.product');
// Create a custom semantic event that includes the original event
EventSystem.trigger(product, 'product:buy', {
originalEvent: event,
productId: product.dataset.id,
productName: product.querySelector('.product-name').textContent,
price: parseFloat(product.dataset.price)
});
// Prevent default behavior of the original event
event.preventDefault();
}
});
Benefits of Custom Event Delegation
- Semantic Events: Events describe what happened in your application domain, not just DOM interactions
- Decoupling: Components can listen for high-level events without knowing how they're triggered
- Testability: Easy to simulate events without mimicking exact DOM interactions
- Code Organization: Keeps related event handling together regardless of DOM structure
Real-World Applications
Application 1: Dynamic Data Table with Sorting, Editing, and Selection
document.addEventListener('DOMContentLoaded', function() {
const DataTable = {
elements: {
table: document.getElementById('data-table'),
tableBody: document.getElementById('data-body'),
searchInput: document.getElementById('search-input'),
sortButtons: document.querySelectorAll('.sort-header')
},
data: [],
sortField: 'id',
sortDirection: 'asc',
init: function(data) {
this.data = data;
this.render();
this.setupEventListeners();
},
setupEventListeners: function() {
// Table click delegation - handles multiple actions
this.elements.table.addEventListener('click', this.handleTableClick.bind(this));
// Sort header click delegation
document.addEventListener('click', this.handleSortClick.bind(this));
// Handle search input
this.elements.searchInput.addEventListener('input',
this.debounce(this.handleSearch.bind(this), 300));
// Listen for custom row events
this.elements.table.addEventListener('row:edited', this.handleRowEdited.bind(this));
},
handleTableClick: function(event) {
// Handle different button clicks within the table
// Edit button
if (event.target.closest('.edit-btn')) {
const row = event.target.closest('tr');
const itemId = row.dataset.id;
this.editRow(itemId, row);
return;
}
// Delete button
if (event.target.closest('.delete-btn')) {
const row = event.target.closest('tr');
const itemId = row.dataset.id;
this.deleteRow(itemId, row);
return;
}
// Row selection
const row = event.target.closest('tr');
if (row && !event.target.closest('button')) {
this.toggleRowSelection(row);
}
},
handleSortClick: function(event) {
const sortHeader = event.target.closest('.sort-header');
if (!sortHeader) return;
const field = sortHeader.dataset.field;
// Toggle direction if same field, otherwise default to ascending
if (field === this.sortField) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.sortField = field;
this.sortDirection = 'asc';
}
// Update UI to show sort direction
this.elements.sortButtons.forEach(btn => {
btn.classList.remove('sort-asc', 'sort-desc');
});
sortHeader.classList.add(this.sortDirection === 'asc' ? 'sort-asc' : 'sort-desc');
// Re-render with new sort
this.render();
},
handleSearch: function(event) {
const searchTerm = event.target.value.toLowerCase();
// Filter visible rows based on search term
const rows = this.elements.tableBody.querySelectorAll('tr');
rows.forEach(row => {
const text = row.textContent.toLowerCase();
row.classList.toggle('hidden', searchTerm && !text.includes(searchTerm));
});
},
handleRowEdited: function(event) {
// Handle custom event when a row is edited
const { itemId, newData } = event.detail;
// Update data
const itemIndex = this.data.findIndex(item => item.id == itemId);
if (itemIndex !== -1) {
this.data[itemIndex] = { ...this.data[itemIndex], ...newData };
// Re-render the specific row
const row = this.elements.tableBody.querySelector(`tr[data-id="${itemId}"]`);
if (row) {
this.renderRow(row, this.data[itemIndex]);
}
}
},
editRow: function(itemId, row) {
const item = this.data.find(item => item.id == itemId);
if (!item) return;
// Replace row content with editable fields
const cells = row.querySelectorAll('td:not(.actions)');
// Store original values for cancel
row._originalContent = row.innerHTML;
// Replace each cell with input
cells.forEach(cell => {
const field = cell.dataset.field;
if (!field) return;
// Current value
const value = item[field];
// Replace with input
cell.innerHTML = ``;
});
// Replace action buttons
const actionsCell = row.querySelector('.actions');
if (actionsCell) {
actionsCell.innerHTML = `
`;
// Add direct event listeners to new buttons
actionsCell.querySelector('.save-btn').addEventListener('click', () => {
this.saveRow(itemId, row);
});
actionsCell.querySelector('.cancel-btn').addEventListener('click', () => {
this.cancelEdit(row);
});
}
},
saveRow: function(itemId, row) {
// Collect values from inputs
const inputs = row.querySelectorAll('input');
const newData = {};
inputs.forEach(input => {
newData[input.name] = input.value;
});
// Dispatch custom event for row edited
const event = new CustomEvent('row:edited', {
bubbles: true,
detail: {
itemId: itemId,
newData: newData
}
});
row.dispatchEvent(event);
// Restore row to view mode
this.cancelEdit(row);
},
cancelEdit: function(row) {
// Restore original content
if (row._originalContent) {
row.innerHTML = row._originalContent;
delete row._originalContent;
}
},
deleteRow: function(itemId, row) {
if (confirm('Are you sure you want to delete this item?')) {
// Remove from data
this.data = this.data.filter(item => item.id != itemId);
// Remove row with animation
row.classList.add('fade-out');
// Remove after animation completes
setTimeout(() => {
row.remove();
}, 300);
}
},
toggleRowSelection: function(row) {
// Toggle selected class
row.classList.toggle('selected');
// Update "selected count" display if needed
const selectedCount = this.elements.tableBody.querySelectorAll('tr.selected').length;
const countDisplay = document.getElementById('selected-count');
if (countDisplay) {
countDisplay.textContent = selectedCount;
countDisplay.parentElement.classList.toggle('hidden', selectedCount === 0);
}
},
render: function() {
// Sort data first
const sortedData = [...this.data].sort((a, b) => {
const fieldA = a[this.sortField];
const fieldB = b[this.sortField];
// Handle different data types
if (typeof fieldA === 'number' && typeof fieldB === 'number') {
return this.sortDirection === 'asc' ? fieldA - fieldB : fieldB - fieldA;
} else {
const strA = String(fieldA).toLowerCase();
const strB = String(fieldB).toLowerCase();
return this.sortDirection === 'asc' ?
strA.localeCompare(strB) :
strB.localeCompare(strA);
}
});
// Clear table
this.elements.tableBody.innerHTML = '';
// Add rows
sortedData.forEach(item => {
const row = document.createElement('tr');
row.dataset.id = item.id;
this.renderRow(row, item);
this.elements.tableBody.appendChild(row);
});
},
renderRow: function(row, item) {
row.innerHTML = `
${item.id}
${item.name}
${item.category}
$${parseFloat(item.price).toFixed(2)}
${item.stock}
`;
},
// Utility method for debouncing
debounce: function(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
};
// Sample data
const sampleData = [
{ id: 1, name: 'Laptop', category: 'Electronics', price: 999.99, stock: 15 },
{ id: 2, name: 'Desk Chair', category: 'Furniture', price: 189.50, stock: 23 },
{ id: 3, name: 'Coffee Maker', category: 'Appliances', price: 79.99, stock: 42 },
{ id: 4, name: 'Headphones', category: 'Electronics', price: 149.99, stock: 30 },
{ id: 5, name: 'Desk Lamp', category: 'Lighting', price: 49.99, stock: 18 }
];
// Initialize
DataTable.init(sampleData);
});
Application 2: Interactive Multi-Step Form with Event Delegation
document.addEventListener('DOMContentLoaded', function() {
const MultiStepForm = {
elements: {
form: document.getElementById('multi-step-form'),
steps: document.querySelectorAll('.form-step'),
progressBar: document.querySelector('.progress-bar'),
stepIndicators: document.querySelectorAll('.step-indicator')
},
state: {
currentStep: 0,
formData: {},
isValid: false
},
init: function() {
// Set up event delegation for the entire form
this.elements.form.addEventListener('click', this.handleClick.bind(this));
// Form input handling with delegation
this.elements.form.addEventListener('input', this.handleInput.bind(this));
this.elements.form.addEventListener('change', this.handleChange.bind(this));
// Blur for validation
this.elements.form.addEventListener('focusout', this.handleBlur.bind(this));
// Setup submission handling
this.elements.form.addEventListener('submit', this.handleSubmit.bind(this));
// Initialize first step
this.showStep(0);
// Load any saved form data from localStorage
this.loadFormData();
},
handleClick: function(event) {
// Next button
if (event.target.classList.contains('next-btn')) {
this.nextStep();
}
// Previous button
if (event.target.classList.contains('prev-btn')) {
this.prevStep();
}
// Step indicator clicks
if (event.target.closest('.step-indicator')) {
const index = parseInt(event.target.closest('.step-indicator').dataset.step);
// Only allow clicking on completed steps or the next available step
if (index <= this.state.currentStep + 1 && index !== this.state.currentStep) {
this.goToStep(index);
}
}
// Help button clicks
if (event.target.classList.contains('help-btn')) {
const fieldName = event.target.dataset.field;
this.showFieldHelp(fieldName);
}
},
handleInput: function(event) {
// Update form data as user types
if (event.target.name) {
this.updateFormData(event.target);
}
// Real-time validation for certain fields
if (event.target.dataset.validateLive === 'true') {
this.validateField(event.target);
}
// Handle password strength meter
if (event.target.id === 'password') {
this.updatePasswordStrength(event.target.value);
}
},
handleChange: function(event) {
// Update form data for select boxes, checkboxes, radios
if (event.target.name) {
this.updateFormData(event.target);
}
// Validate field on change
this.validateField(event.target);
// Special handling for certain fields
if (event.target.id === 'country') {
this.updateStateOptions(event.target.value);
}
},
handleBlur: function(event) {
// Validate when field loses focus
if (event.target.tagName === 'INPUT' ||
event.target.tagName === 'SELECT' ||
event.target.tagName === 'TEXTAREA') {
// Mark field as visited
event.target.dataset.visited = 'true';
// Validate
this.validateField(event.target);
}
},
handleSubmit: function(event) {
event.preventDefault();
// Validate the current step before submission
if (!this.validateStep(this.state.currentStep)) {
return;
}
// If on the last step, submit the form
if (this.state.currentStep === this.elements.steps.length - 1) {
this.submitForm();
} else {
// Otherwise go to the next step
this.nextStep();
}
},
updateFormData: function(field) {
const name = field.name;
let value;
// Handle different input types
if (field.type === 'checkbox') {
value = field.checked;
} else if (field.type === 'radio') {
// Only update if checked
if (field.checked) {
value = field.value;
} else {
return; // Don't update if the radio isn't checked
}
} else {
value = field.value;
}
// Update the formData object
this.state.formData[name] = value;
// Save form data to localStorage
this.saveFormData();
},
validateField: function(field) {
// Skip validation if field hasn't been visited yet
if (!field.dataset.visited && !field.required) return true;
const name = field.name;
const value = field.value;
const errorElement = document.getElementById(`${name}-error`);
// Reset validation state
field.classList.remove('valid', 'invalid');
if (errorElement) errorElement.textContent = '';
// Required field validation
if (field.required && !value) {
this.setFieldInvalid(field, errorElement, 'This field is required');
return false;
}
// Email validation
if (field.type === 'email' && value) {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(value)) {
this.setFieldInvalid(field, errorElement, 'Please enter a valid email address');
return false;
}
}
// Password validation
if (field.id === 'password' && value) {
if (value.length < 8) {
this.setFieldInvalid(field, errorElement, 'Password must be at least 8 characters');
return false;
}
}
// Confirm password validation
if (field.id === 'confirm-password' && value) {
const password = document.getElementById('password').value;
if (value !== password) {
this.setFieldInvalid(field, errorElement, 'Passwords do not match');
return false;
}
}
// Custom validation rules from data attributes
if (field.dataset.minLength && value.length < parseInt(field.dataset.minLength)) {
this.setFieldInvalid(field, errorElement,
`Minimum length is ${field.dataset.minLength} characters`);
return false;
}
if (field.dataset.pattern) {
const pattern = new RegExp(field.dataset.pattern);
if (!pattern.test(value)) {
this.setFieldInvalid(field, errorElement, field.dataset.errorMsg || 'Invalid format');
return false;
}
}
// If we get here, the field is valid
field.classList.add('valid');
return true;
},
setFieldInvalid: function(field, errorElement, message) {
field.classList.add('invalid');
if (errorElement) {
errorElement.textContent = message;
}
},
validateStep: function(stepIndex) {
const step = this.elements.steps[stepIndex];
const fields = step.querySelectorAll('input, select, textarea');
let isValid = true;
// Validate each field in the step
fields.forEach(field => {
// Skip fields without a name attribute and buttons
if (!field.name || field.type === 'button' || field.type === 'submit') return;
// Mark as visited for validation
field.dataset.visited = 'true';
// Validate and update overall validity
if (!this.validateField(field)) {
isValid = false;
}
});
// Update step indicator
this.elements.stepIndicators[stepIndex].classList.toggle('step-error', !isValid);
this.elements.stepIndicators[stepIndex].classList.toggle('step-complete', isValid);
return isValid;
},
nextStep: function() {
// Validate current step before proceeding
if (!this.validateStep(this.state.currentStep)) {
// Show error message
const errorSummary = this.elements.steps[this.state.currentStep]
.querySelector('.error-summary');
if (errorSummary) {
errorSummary.textContent = 'Please correct the errors in this step before proceeding.';
errorSummary.classList.add('visible');
}
return;
}
// Move to next step if not on the last step
if (this.state.currentStep < this.elements.steps.length - 1) {
this.showStep(this.state.currentStep + 1);
}
},
prevStep: function() {
// Move to previous step if not on the first step
if (this.state.currentStep > 0) {
this.showStep(this.state.currentStep - 1);
}
},
goToStep: function(stepIndex) {
// Validate all steps before the target step
let canProceed = true;
for (let i = 0; i < stepIndex; i++) {
if (!this.validateStep(i)) {
canProceed = false;
// Jump to the first invalid step
this.showStep(i);
break;
}
}
if (canProceed) {
this.showStep(stepIndex);
}
},
showStep: function(stepIndex) {
// Hide all steps
this.elements.steps.forEach(step => {
step.classList.remove('active');
});
// Show the current step
this.elements.steps[stepIndex].classList.add('active');
// Update current step
this.state.currentStep = stepIndex;
// Update progress bar
const progress = ((stepIndex) / (this.elements.steps.length - 1)) * 100;
this.elements.progressBar.style.width = `${progress}%`;
// Update step indicators
this.elements.stepIndicators.forEach((indicator, index) => {
indicator.classList.toggle('active', index === stepIndex);
indicator.classList.toggle('completed', index < stepIndex);
});
// Scroll to top of form
this.elements.form.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Hide any error summaries
const errorSummary = this.elements.steps[stepIndex].querySelector('.error-summary');
if (errorSummary) {
errorSummary.classList.remove('visible');
}
// Focus the first field in the step
setTimeout(() => {
const firstInput = this.elements.steps[stepIndex]
.querySelector('input:not([type="hidden"]), select, textarea');
if (firstInput) {
firstInput.focus();
}
}, 300);
},
updatePasswordStrength: function(password) {
const strengthBar = document.getElementById('password-strength-bar');
const strengthText = document.getElementById('password-strength-text');
if (!strengthBar || !strengthText) return;
// Calculate password strength
let strength = 0;
// Length
if (password.length >= 8) strength += 1;
if (password.length >= 12) strength += 1;
// Complexity
if (/[A-Z]/.test(password)) strength += 1;
if (/[a-z]/.test(password)) strength += 1;
if (/[0-9]/.test(password)) strength += 1;
if (/[^A-Za-z0-9]/.test(password)) strength += 1;
// Calculate percentage (max strength is 6)
const percentage = Math.min(100, (strength / 6) * 100);
// Update UI
strengthBar.style.width = `${percentage}%`;
// Remove existing classes
strengthBar.classList.remove('weak', 'medium', 'strong');
// Add appropriate class
let strengthLabel = '';
if (percentage < 40) {
strengthBar.classList.add('weak');
strengthLabel = 'Weak';
} else if (percentage < 70) {
strengthBar.classList.add('medium');
strengthLabel = 'Medium';
} else {
strengthBar.classList.add('strong');
strengthLabel = 'Strong';
}
strengthText.textContent = strengthLabel;
},
updateStateOptions: function(country) {
const stateSelect = document.getElementById('state');
if (!stateSelect) return;
// Clear existing options
stateSelect.innerHTML = '';
// Add options based on selected country
let options = [];
switch (country) {
case 'US':
options = ['Alabama', 'Alaska', 'Arizona', 'Arkansas', 'California', '...'];
break;
case 'Canada':
options = ['Alberta', 'British Columbia', 'Manitoba', 'New Brunswick', '...'];
break;
case 'UK':
options = ['England', 'Scotland', 'Wales', 'Northern Ireland'];
break;
// Add more countries as needed
}
// Add options to select
options.forEach(option => {
const optionElement = document.createElement('option');
optionElement.value = option;
optionElement.textContent = option;
stateSelect.appendChild(optionElement);
});
// Show/hide state field based on country selection
const stateField = document.getElementById('state-field');
if (stateField) {
stateField.style.display = options.length ? 'block' : 'none';
}
},
showFieldHelp: function(fieldName) {
const helpText = {
'name': 'Enter your full legal name as it appears on official documents.',
'email': 'We\'ll use this email for account verification and communication.',
'password': 'Choose a strong password with at least 8 characters, including uppercase, lowercase, numbers, and special characters.',
// Add more help text as needed
};
const message = helpText[fieldName] || 'No help available for this field.';
// Show help in a tooltip or modal
alert(message); // Replace with your preferred UI component
},
saveFormData: function() {
// Save form data to localStorage
localStorage.setItem('formData', JSON.stringify(this.state.formData));
},
loadFormData: function() {
// Load saved form data
const savedData = localStorage.getItem('formData');
if (savedData) {
this.state.formData = JSON.parse(savedData);
// Fill in form fields
for (const [name, value] of Object.entries(this.state.formData)) {
const field = this.elements.form.querySelector(`[name="${name}"]`);
if (!field) continue;
if (field.type === 'checkbox') {
field.checked = value;
} else if (field.type === 'radio') {
const radio = this.elements.form.querySelector(`[name="${name}"][value="${value}"]`);
if (radio) radio.checked = true;
} else {
field.value = value;
}
}
}
},
submitForm: function() {
// Show loading state
const submitButton = this.elements.form.querySelector('button[type="submit"]');
if (submitButton) {
submitButton.disabled = true;
submitButton.innerHTML = ' Submitting...';
}
// In a real application, you would send data to a server
console.log('Form Data:', this.state.formData);
// Simulate AJAX request
setTimeout(() => {
// Show success message
this.elements.form.innerHTML = `
`;
// Clear saved form data
localStorage.removeItem('formData');
}, 1500);
}
};
// Initialize the form
MultiStepForm.init();
});
Practical Exercise
Build a complete UI component library with event delegation by implementing the following:
- Create a tabbed interface where clicking tab headers shows different content panels
- Implement an accordion component with expandable/collapsible sections
- Build a dropdown menu system with nested submenus
- Create a sortable, filterable data table with pagination
- Add a modal dialog system with different types of modals (alert, confirm, prompt)
- Implement a notification system for displaying alerts and messages
- Create a carousel/slider with touch support and automatic rotation
- Build a form validation system with field-specific error messages
For all components:
- Use event delegation to handle user interactions efficiently
- Support dynamic adding/removing of elements without reattaching listeners
- Implement proper event propagation control
- Add appropriate keyboard accessibility
- Use custom events to notify about component state changes
This exercise will give you practical experience with all aspects of event delegation and propagation in a realistic application context.
Summary
- Event propagation involves three phases: capturing (down), target, and bubbling (up)
- By default, event listeners are registered for the bubbling phase
- The
event.stopPropagation()method prevents an event from continuing its journey through the DOM - The
event.preventDefault()method stops the default browser action but doesn't affect propagation - Event delegation leverages bubbling to efficiently handle events by attaching listeners to parent elements
- The
event.targetproperty identifies the element that triggered the event - The
closest()method is essential for finding parent elements in delegation patterns - Some events like
focusandmouseenterdon't bubble naturally, but have bubbling alternatives - Custom events can be integrated with delegation for powerful application architecture
Understanding event propagation and mastering event delegation are crucial skills for building efficient, scalable JavaScript applications. These techniques help create cleaner code that performs better and works with dynamic content.