Event Handlers and Listeners

Managing Code Execution in Response to Events

Introduction to Event Handlers

In our previous lecture, we explored the DOM event model and the various types of events available in the browser. Now we'll dive deeper into event handlers and listeners—the code that executes when events occur.

Event handlers are the bridge between user interactions and your application logic. Understanding how to properly implement and manage them is crucial for creating responsive, interactive web applications.

Event Handler
A function that executes in response to a specific event
Event Listener
The connection between an event type, a target element, and a handler function
graph TD A[DOM Element] -->|"addEventListener('click', handler)"| B[Event Listener] B ---|Registered on| A B -->|Listens for| C[Event Type] B -->|Executes| D[Handler Function] E[User Action] -->|Triggers| C C -->|Activates| B classDef default fill:#f9f9f9,stroke:#333,stroke-width:1px;

Real-World Analogy: The Restaurant

Think of event handling like a restaurant's service system:

  • The customer is like the user interacting with your page
  • The table is like an element in your DOM
  • Pressing the service button is like triggering an event (e.g., a click)
  • The waiter is like the event listener, assigned to watch for the button press
  • The specific service provided is like the handler function that executes

Just as a restaurant might assign different waiters to different sections or have servers respond differently based on the type of service requested, you can set up multiple listeners for different events and handler functions to respond appropriately.

Registering Event Handlers

JavaScript provides multiple methods for registering event handlers. Let's explore them in detail:

1. addEventListener() Method

The modern, recommended way to register event handlers:

// Basic syntax
element.addEventListener(eventType, handlerFunction, options);

// Simple example
const button = document.getElementById('submit-button');
button.addEventListener('click', function(event) {
    console.log('Button clicked!');
    submitForm();
});

// Using a named function
function handleClick(event) {
    console.log('Button clicked!');
    submitForm();
}
button.addEventListener('click', handleClick);

The options Parameter

The third parameter to addEventListener() can be a boolean (for legacy support) or an options object with the following properties:

// Using options object
button.addEventListener('click', handleClick, {
    capture: false,  // Whether the event should be captured in capturing phase (default: false)
    once: true,      // Whether the listener should be invoked at most once (default: false)
    passive: true    // Whether the listener will never call preventDefault() (default: false)
});

Benefits of addEventListener()

  • Allows attaching multiple listeners to the same event
  • Provides options for fine-tuning event behavior
  • Supports both event bubbling and capturing phases
  • Cleanly separates HTML from JavaScript
  • Handlers can be easily removed with removeEventListener()

2. Event Handler Properties

A simpler but more limited approach using on-event properties:

// Basic syntax
element.oneventname = handlerFunction;

// Examples
const button = document.getElementById('submit-button');
button.onclick = function(event) {
    console.log('Button clicked!');
    submitForm();
};

const input = document.getElementById('username');
input.onchange = handleInputChange;

const form = document.getElementById('signup-form');
form.onsubmit = validateForm;

Limitations of Event Handler Properties

  • Can only assign one handler per event type per element
  • New assignments overwrite previous handlers
  • No support for capture phase or options
  • No built-in method for removing handlers (must set to null)
// Overwriting previous handler
button.onclick = function() { console.log('First handler'); };
button.onclick = function() { console.log('Second handler'); }; // First handler is lost

// Removing handler
button.onclick = null;

3. Inline Event Handlers (Not Recommended)

Defining handlers directly in HTML attributes:

<button onclick="handleClick(event)">Click Me</button>
<input onchange="console.log('Value changed to: ' + this.value)">
<form onsubmit="return validateForm(this)">

Avoid Inline Event Handlers

Inline event handlers have significant drawbacks:

  • Mix HTML structure with behavior (poor separation of concerns)
  • Limited access to closure variables
  • Poor maintainability and readability for complex applications
  • Can create security vulnerabilities if combined with user input
  • Limited to one handler per event
  • Cannot be easily removed programmatically

Handler Registration Comparison

Feature addEventListener() Event Properties Inline Attributes
Multiple handlers ✅ Supported ❌ Overwritten ❌ Limited to one
Capture phase ✅ Supported ❌ Not supported ❌ Not supported
Event options ✅ Supported ❌ Not supported ❌ Not supported
Separation of concerns ✅ Good ✅ Good ❌ Poor
Browser compatibility ✅ IE9+, all modern ✅ All browsers ✅ All browsers
Easy to remove ✅ removeEventListener() ✅ Set to null ❌ Requires DOM manipulation

Creating Effective Event Handlers

Writing good event handlers involves more than just registering functions. Here are important patterns and practices:

Handler Scope and 'this' Context

Understanding how this works in event handlers is crucial:

// In a regular function, 'this' refers to the element the listener is attached to
document.getElementById('button').addEventListener('click', function(event) {
    console.log(this); // Refers to the button element
    this.classList.toggle('active');
});

// Arrow functions don't have their own 'this' and inherit from the parent scope
const button = document.getElementById('button');
button.addEventListener('click', (event) => {
    console.log(this); // Does NOT refer to the button (likely window or undefined in strict mode)
    // button.classList.toggle('active'); // Need to use the variable instead
});

// In object methods, 'this' may need binding to maintain context
const controller = {
    count: 0,
    increment: function() {
        this.count++;
        this.updateUI();
    },
    updateUI: function() {
        document.getElementById('counter').textContent = this.count;
    },
    setupListeners: function() {
        // Without binding, 'this' would refer to the button, not the controller
        document.getElementById('increment-button').addEventListener('click', this.increment.bind(this));
        
        // Alternative using arrow function
        document.getElementById('increment-button').addEventListener('click', () => {
            this.increment();
        });
    }
};

Accessing Event Information

The event object provides crucial information about what happened:

document.getElementById('product-list').addEventListener('click', function(event) {
    // Get information about the event
    console.log('Event type:', event.type);
    console.log('Target element:', event.target);
    console.log('Current target:', event.currentTarget);
    
    // Mouse event information
    console.log('Click position:', event.clientX, event.clientY);
    
    // Get data from the clicked element
    if (event.target.classList.contains('buy-button')) {
        const productId = event.target.closest('.product-card').dataset.productId;
        const productName = event.target.closest('.product-card').querySelector('.product-name').textContent;
        const price = parseFloat(event.target.dataset.price);
        
        addToCart(productId, productName, price);
    }
});

Preventing Default Behavior

Many events have default browser actions that you might want to prevent:

// Prevent form submission to handle it with JavaScript
document.getElementById('signup-form').addEventListener('submit', function(event) {
    // Prevent the default form submission
    event.preventDefault();
    
    // Perform client-side validation
    if (validateForm()) {
        // Submit via AJAX instead
        submitFormAsync(this);
    }
});

// Prevent link navigation
document.querySelector('.tabs a').addEventListener('click', function(event) {
    event.preventDefault();
    // Show tab content instead of navigating
    showTab(this.getAttribute('href').substring(1));
});

// Prevent context menu on right-click
document.getElementById('custom-menu-area').addEventListener('contextmenu', function(event) {
    event.preventDefault();
    // Show custom context menu instead
    showCustomMenu(event.clientX, event.clientY);
});

Common Default Behaviors to Prevent

  • submit: Form submission and page reload
  • click on links: Page navigation
  • contextmenu: Browser's context menu
  • keydown: Browser shortcuts or text input
  • dragstart: Default drag behavior for images and links
  • touchstart/touchmove: Scrolling, zooming, or selection on mobile

Stopping Event Propagation

Preventing events from bubbling up to parent elements:

// Stop a click from bubbling up to parent elements
document.getElementById('delete-button').addEventListener('click', function(event) {
    // Stop the event from reaching parent handlers
    event.stopPropagation();
    
    // Show delete confirmation dialog
    if (confirm('Are you sure you want to delete this item?')) {
        deleteItem(this.dataset.itemId);
    }
});

// This parent handler won't receive clicks from the delete button
document.getElementById('item-container').addEventListener('click', function(event) {
    // This will be triggered for clicks on the container or any child
    // except for the delete button (due to stopPropagation above)
    selectItem(event.target.closest('.item'));
});

Use stopPropagation() Carefully

Stopping propagation can interfere with other event listeners in your application:

  • Event delegation patterns rely on bubbling
  • Analytics tracking may depend on events bubbling to document
  • Third-party libraries might expect events to propagate

Only use stopPropagation() when you have a specific reason to prevent an event from bubbling further.

Immediate Propagation

Stopping all handlers on the current element:

// Register multiple click handlers
const button = document.getElementById('multi-action-button');

// First handler
button.addEventListener('click', function(event) {
    console.log('First handler');
    
    // Stop other handlers from executing
    event.stopImmediatePropagation();
});

// Second handler (won't execute if first handler is triggered)
button.addEventListener('click', function(event) {
    console.log('Second handler'); // This won't run due to stopImmediatePropagation
});

The stopImmediatePropagation() method is stronger than stopPropagation(). It prevents the event from both bubbling up and from triggering any other handlers on the current element.

Advanced Handler Patterns

Beyond basic event registration, there are several advanced patterns that help create more maintainable and efficient code:

Event Delegation

Instead of attaching handlers to individual elements, attach a single handler to a parent element and use event.target to determine what was clicked:

// Instead of this (attaching to each button)
document.querySelectorAll('.delete-btn').forEach(button => {
    button.addEventListener('click', handleDelete);
});

// Use event delegation
document.getElementById('items-container').addEventListener('click', function(event) {
    // Check if a delete button was clicked
    if (event.target.classList.contains('delete-btn')) {
        handleDelete.call(event.target, event);
    }
    // Check if an edit button was clicked
    else if (event.target.classList.contains('edit-btn')) {
        handleEdit.call(event.target, event);
    }
    // Check if an item was clicked
    else if (event.target.closest('.item')) {
        selectItem(event.target.closest('.item'));
    }
});
graph TD A[Parent Container] --- B[Event Listener] C[Child Element 1] -.-> A D[Child Element 2] -.-> A E[Child Element 3] -.-> A F[User clicks Child] --> G[Event bubbles to Parent] G --> H[Handler uses event.target to identify Child] H --> I[Execute appropriate action for Child] classDef default fill:#f9f9f9,stroke:#333,stroke-width:1px;

Benefits of Event Delegation

  • Memory Efficiency: One handler instead of many
  • Dynamic Elements: Works with elements added after initial page load
  • Less Code: Simpler setup and maintenance
  • Cleaner Markup: No need to attach handlers to every element

Handling One-Time Events

Sometimes you want an event handler to run only once:

// Method 1: Using the 'once' option (modern browsers)
button.addEventListener('click', handleFirstClick, { once: true });

// Method 2: Removing the listener in the handler
button.addEventListener('click', function handleFirstClick(event) {
    console.log('First click!');
    // Do something
    
    // Remove this handler
    button.removeEventListener('click', handleFirstClick);
});

Example: Tutorial Overlay

const tutorialOverlay = document.getElementById('tutorial-overlay');
const startButton = document.getElementById('start-tutorial');

startButton.addEventListener('click', function() {
    // Show the tutorial overlay
    tutorialOverlay.classList.add('visible');
    
    // Listen for the first click anywhere to dismiss the overlay
    document.addEventListener('click', function dismissTutorial(event) {
        // Ignore clicks on the overlay itself
        if (event.target !== tutorialOverlay && !tutorialOverlay.contains(event.target)) {
            tutorialOverlay.classList.remove('visible');
            
            // Remove this one-time handler
            document.removeEventListener('click', dismissTutorial);
            
            // Set a flag in localStorage so we don't show the tutorial again
            localStorage.setItem('tutorialShown', 'true');
        }
    });
});

Debouncing and Throttling

Controlling the rate at which event handlers fire:

Debounce Function

// Execute a function after a delay, resetting the timer when the event fires again
function debounce(func, delay) {
    let timeoutId;
    
    return function(...args) {
        // Clear any existing timeout
        clearTimeout(timeoutId);
        
        // Set a new timeout
        timeoutId = setTimeout(() => {
            func.apply(this, args);
        }, delay);
    };
}

// Example: Search input that only triggers API calls after typing stops
const searchInput = document.getElementById('search-input');

// Create a debounced search function
const debouncedSearch = debounce(function(event) {
    const query = event.target.value;
    console.log('Searching for:', query);
    fetchSearchResults(query);
}, 500); // Wait 500ms after typing stops

// Attach the debounced handler
searchInput.addEventListener('input', debouncedSearch);

Throttle Function

// Execute a function at most once per specified time period
function throttle(func, limit) {
    let lastCall = 0;
    
    return function(...args) {
        const now = Date.now();
        
        if (now - lastCall >= limit) {
            lastCall = now;
            func.apply(this, args);
        }
    };
}

// Example: Scroll handler that updates UI at most every 100ms
const scrollHandler = throttle(function() {
    updateScrollBasedUI();
}, 100);

window.addEventListener('scroll', scrollHandler);

Debounce vs. Throttle: When To Use Each

Function Behavior Best For
debounce() Waits until activity stops before executing
  • Search inputs
  • Window resize handling
  • Form validation
  • Save buttons
throttle() Executes periodically during continuous activity
  • Scroll event handlers
  • Mouse move tracking
  • Game input
  • Infinite scrolling

Namespaced Event Handling

Creating organized, modular event handling systems:

// Define a controller object to organize related event handlers
const TodoListController = {
    // Element references
    elements: {
        list: document.getElementById('todo-list'),
        form: document.getElementById('todo-form'),
        input: document.getElementById('todo-input'),
        clearButton: document.getElementById('clear-completed')
    },
    
    // Initialize the controller
    init() {
        // Bind methods to preserve context
        this.handleNewTodo = this.handleNewTodo.bind(this);
        this.handleTodoClick = this.handleTodoClick.bind(this);
        this.handleClearCompleted = this.handleClearCompleted.bind(this);
        
        // Set up event listeners
        this.elements.form.addEventListener('submit', this.handleNewTodo);
        this.elements.list.addEventListener('click', this.handleTodoClick);
        this.elements.clearButton.addEventListener('click', this.handleClearCompleted);
        
        // Load initial data
        this.loadTodos();
    },
    
    // Event handlers
    handleNewTodo(event) {
        event.preventDefault();
        
        const todoText = this.elements.input.value.trim();
        if (todoText) {
            this.addTodo(todoText);
            this.elements.input.value = '';
        }
    },
    
    handleTodoClick(event) {
        const todoItem = event.target.closest('.todo-item');
        if (!todoItem) return;
        
        // Handle checkbox clicks
        if (event.target.type === 'checkbox') {
            this.toggleTodoComplete(todoItem.dataset.id, event.target.checked);
        }
        
        // Handle delete button clicks
        if (event.target.classList.contains('delete-btn')) {
            this.deleteTodo(todoItem.dataset.id);
        }
    },
    
    handleClearCompleted() {
        this.clearCompletedTodos();
    },
    
    // Business logic methods
    loadTodos() {
        // Load todos from localStorage
        const todos = JSON.parse(localStorage.getItem('todos') || '[]');
        todos.forEach(todo => this.renderTodo(todo));
    },
    
    addTodo(text) {
        const todo = {
            id: Date.now().toString(),
            text,
            completed: false
        };
        
        // Add to localStorage
        const todos = JSON.parse(localStorage.getItem('todos') || '[]');
        todos.push(todo);
        localStorage.setItem('todos', JSON.stringify(todos));
        
        // Add to UI
        this.renderTodo(todo);
    },
    
    // Other methods for rendering, toggling, deleting todos...
    
    // Clean up (important for single-page applications)
    destroy() {
        // Remove event listeners
        this.elements.form.removeEventListener('submit', this.handleNewTodo);
        this.elements.list.removeEventListener('click', this.handleTodoClick);
        this.elements.clearButton.removeEventListener('click', this.handleClearCompleted);
    }
};

Benefits of Namespaced/Controller Pattern

  • Organization: Related event handlers and logic kept together
  • Maintainability: Clear structure makes code easier to update
  • Reusability: Controllers can be instantiated multiple times
  • Clean Teardown: Centralized place to remove listeners when needed
  • Context Management: Consistent `this` binding across handlers

Managing Event Listeners

Properly managing the lifecycle of event listeners is crucial for performance and preventing memory leaks.

Removing Event Listeners

To properly remove event listeners, you need to reference the same function that was added:

// This WON'T work because they're different function instances
element.addEventListener('click', function() { console.log('Clicked'); });
element.removeEventListener('click', function() { console.log('Clicked'); }); // Won't remove!

// This WILL work - store a reference to the function
function handleClick() { console.log('Clicked'); }
element.addEventListener('click', handleClick);
element.removeEventListener('click', handleClick); // Successfully removed

// For anonymous functions, store the reference
const button = document.getElementById('my-button');
const clickHandler = function() { console.log('Button clicked'); };
button.addEventListener('click', clickHandler);

// Later...
button.removeEventListener('click', clickHandler);

When to Remove Event Listeners

  • When an element is removed from the DOM
  • When a component is unmounted (in component-based architectures)
  • When switching between views in single-page applications
  • After one-time events complete
  • When detaching a behavior temporarily

Memory Leaks and Event Listeners

A common cause of memory leaks in web applications is forgotten event listeners:

Problematic Code (Memory Leak):

function createModal(content) {
    const modal = document.createElement('div');
    modal.className = 'modal';
    modal.innerHTML = `
        <div class="modal-content">
            <button class="close-btn">×</button>
            ${content}
        </div>
    `;
    
    // Add event listener
    const closeButton = modal.querySelector('.close-btn');
    closeButton.addEventListener('click', function() {
        // Remove from DOM, but listener is still in memory!
        modal.remove();
    });
    
    document.body.appendChild(modal);
    return modal;
}

Improved Code (Properly Cleaned Up):

function createModal(content) {
    const modal = document.createElement('div');
    modal.className = 'modal';
    modal.innerHTML = `
        <div class="modal-content">
            <button class="close-btn">×</button>
            ${content}
        </div>
    `;
    
    // Store reference to handler function
    const closeHandler = function() {
        // Remove the event listener first
        closeButton.removeEventListener('click', closeHandler);
        // Then remove from DOM
        modal.remove();
    };
    
    // Add event listener
    const closeButton = modal.querySelector('.close-btn');
    closeButton.addEventListener('click', closeHandler);
    
    document.body.appendChild(modal);
    
    // Return an object with methods for controlling the modal
    return {
        close: closeHandler,
        destroy: function() {
            closeButton.removeEventListener('click', closeHandler);
            modal.remove();
        }
    };
}

Monitoring and Debugging Event Listeners

Modern browsers provide tools to help debug and monitor event listeners:

Chrome DevTools Event Listener Debugging

To view attached event listeners in Chrome DevTools:

  1. Right-click an element and select "Inspect"
  2. In the Elements panel, click the "Event Listeners" tab
  3. You'll see all event listeners attached to the selected element and its ancestors

Note: You can filter by event type and toggle "Ancestors" to see only events directly on the element.

Custom Event Listener Tracker

// A simple utility to track event listeners for debugging
const EventTracker = {
    // Store all listeners
    listeners: [],
    
    // Wrap addEventListener
    addListener: function(element, type, handler, options) {
        element.addEventListener(type, handler, options);
        
        // Track this listener
        this.listeners.push({
            element,
            type,
            handler,
            options
        });
        
        console.log(`Added ${type} listener to`, element);
    },
    
    // Wrap removeEventListener
    removeListener: function(element, type, handler) {
        element.removeEventListener(type, handler);
        
        // Remove from tracking
        this.listeners = this.listeners.filter(listener => 
            !(listener.element === element && 
              listener.type === type && 
              listener.handler === handler)
        );
        
        console.log(`Removed ${type} listener from`, element);
    },
    
    // List all current listeners
    listListeners: function() {
        console.table(this.listeners.map(l => ({
            element: l.element.tagName + (l.element.id ? `#${l.element.id}` : ''),
            type: l.type,
            options: JSON.stringify(l.options)
        })));
        
        return this.listeners;
    },
    
    // Find listeners for a specific element
    findListenersForElement: function(element) {
        const elementListeners = this.listeners.filter(l => l.element === element);
        console.table(elementListeners.map(l => ({
            type: l.type,
            options: JSON.stringify(l.options)
        })));
        
        return elementListeners;
    }
};

// Usage
const button = document.getElementById('my-button');
EventTracker.addListener(button, 'click', () => console.log('Clicked!'), { once: true });
EventTracker.listListeners();

Practical Applications

Application 1: Advanced Form Validation with Error Handling

A comprehensive form validation system using multiple event types:

document.addEventListener('DOMContentLoaded', function() {
    const form = document.getElementById('signup-form');
    const formFields = form.querySelectorAll('input, select, textarea');
    
    // Set up validation on blur for each field
    formFields.forEach(field => {
        // Skip submit buttons
        if (field.type === 'submit') return;
        
        // Create error message container if it doesn't exist
        if (!field.nextElementSibling || !field.nextElementSibling.classList.contains('error-message')) {
            const errorMessage = document.createElement('div');
            errorMessage.className = 'error-message';
            field.parentNode.insertBefore(errorMessage, field.nextElementSibling);
        }
        
        // Add validation when field loses focus
        field.addEventListener('blur', function() {
            validateField(this);
        });
        
        // For text inputs, validate as typing (after initial blur)
        if (field.type === 'text' || field.type === 'email' || field.type === 'password') {
            field.addEventListener('input', function() {
                // Only validate during typing if the field has already been blurred once
                if (this.dataset.blurred) {
                    validateField(this);
                }
            });
        }
        
        // Mark fields as blurred after first blur
        field.addEventListener('blur', function() {
            this.dataset.blurred = 'true';
        });
    });
    
    // Handle form submission
    form.addEventListener('submit', function(event) {
        // Validate all fields
        let isValid = true;
        
        formFields.forEach(field => {
            if (field.type !== 'submit') {
                if (!validateField(field)) {
                    isValid = false;
                }
            }
        });
        
        if (!isValid) {
            event.preventDefault();
            
            // Show form-level error
            const formError = document.getElementById('form-error');
            formError.textContent = 'Please correct the errors in the form before submitting.';
            formError.classList.add('visible');
            
            // Focus the first invalid field
            form.querySelector('.invalid').focus();
        }
    });
    
    // Field validation function
    function validateField(field) {
        const errorElement = field.nextElementSibling;
        
        // Reset validation state
        field.classList.remove('valid', 'invalid');
        errorElement.textContent = '';
        
        // Get the value
        const value = field.value.trim();
        
        // Check required fields
        if (field.required && !value) {
            setInvalid(field, errorElement, 'This field is required');
            return false;
        }
        
        // Field-specific validation
        if (value) {
            switch (field.type) {
                case 'email':
                    const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
                    if (!emailPattern.test(value)) {
                        setInvalid(field, errorElement, 'Please enter a valid email address');
                        return false;
                    }
                    break;
                    
                case 'password':
                    // Check password complexity
                    if (field.id === 'password' && value.length < 8) {
                        setInvalid(field, errorElement, 'Password must be at least 8 characters long');
                        return false;
                    }
                    
                    // Check password confirmation
                    if (field.id === 'confirm-password') {
                        const password = document.getElementById('password').value;
                        if (value !== password) {
                            setInvalid(field, errorElement, 'Passwords do not match');
                            return false;
                        }
                    }
                    break;
                    
                case 'tel':
                    const phonePattern = /^\d{10,15}$/;
                    if (!phonePattern.test(value.replace(/\D/g, ''))) {
                        setInvalid(field, errorElement, 'Please enter a valid phone number');
                        return false;
                    }
                    break;
                    
                case 'select-one':
                    if (field.value === '') {
                        setInvalid(field, errorElement, 'Please select an option');
                        return false;
                    }
                    break;
            }
            
            // Field-specific validation based on name or data attributes
            if (field.name === 'username' || field.id === 'username') {
                if (value.length < 4) {
                    setInvalid(field, errorElement, 'Username must be at least 4 characters long');
                    return false;
                }
                
                // Check for spaces
                if (/\s/.test(value)) {
                    setInvalid(field, errorElement, 'Username cannot contain spaces');
                    return false;
                }
            }
            
            // Custom validation via data attributes
            if (field.dataset.minLength && value.length < parseInt(field.dataset.minLength)) {
                setInvalid(field, errorElement, `Must be at least ${field.dataset.minLength} characters long`);
                return false;
            }
            
            if (field.dataset.pattern) {
                const pattern = new RegExp(field.dataset.pattern);
                if (!pattern.test(value)) {
                    setInvalid(field, errorElement, field.dataset.errorMessage || 'Invalid format');
                    return false;
                }
            }
        }
        
        // If we made it here, field is valid
        setValid(field, errorElement);
        return true;
    }
    
    function setValid(field, errorElement) {
        field.classList.add('valid');
        field.classList.remove('invalid');
        errorElement.textContent = '';
        field.setCustomValidity('');
    }
    
    function setInvalid(field, errorElement, message) {
        field.classList.add('invalid');
        field.classList.remove('valid');
        errorElement.textContent = message;
        field.setCustomValidity(message);
    }
});

Application 2: Interactive Photo Gallery with Filtering

A photo gallery with event-driven filtering, zooming, and navigation:

document.addEventListener('DOMContentLoaded', function() {
    const gallery = {
        elements: {
            container: document.getElementById('gallery-container'),
            items: document.querySelectorAll('.gallery-item'),
            filters: document.querySelectorAll('.filter-button'),
            lightbox: document.getElementById('lightbox'),
            lightboxImg: document.getElementById('lightbox-image'),
            lightboxClose: document.getElementById('lightbox-close'),
            lightboxNext: document.getElementById('lightbox-next'),
            lightboxPrev: document.getElementById('lightbox-prev'),
            lightboxCaption: document.getElementById('lightbox-caption')
        },
        
        state: {
            currentFilter: 'all',
            currentIndex: 0,
            filteredItems: [],
            isTransitioning: false
        },
        
        init: function() {
            // Bind methods to preserve context
            this.handleFilterClick = this.handleFilterClick.bind(this);
            this.handleItemClick = this.handleItemClick.bind(this);
            this.closeLightbox = this.closeLightbox.bind(this);
            this.nextImage = this.nextImage.bind(this);
            this.prevImage = this.prevImage.bind(this);
            this.handleKeydown = this.handleKeydown.bind(this);
            
            // Initialize filtered items with all items
            this.state.filteredItems = Array.from(this.elements.items);
            
            // Set up event listeners
            this.elements.filters.forEach(filter => {
                filter.addEventListener('click', this.handleFilterClick);
            });
            
            this.elements.container.addEventListener('click', this.handleItemClick);
            
            this.elements.lightboxClose.addEventListener('click', this.closeLightbox);
            this.elements.lightboxNext.addEventListener('click', this.nextImage);
            this.elements.lightboxPrev.addEventListener('click', this.prevImage);
            
            // Prevent lightbox image click from closing the lightbox
            this.elements.lightboxImg.addEventListener('click', function(event) {
                event.stopPropagation();
            });
            
            // Close lightbox when clicking the background
            this.elements.lightbox.addEventListener('click', this.closeLightbox);
            
            // Keyboard navigation
            document.addEventListener('keydown', this.handleKeydown);
        },
        
        // Event Handlers
        handleFilterClick: function(event) {
            const filterValue = event.currentTarget.dataset.filter;
            
            // Update the active filter button
            this.elements.filters.forEach(btn => {
                btn.classList.toggle('active', btn === event.currentTarget);
            });
            
            // Apply the filter
            this.applyFilter(filterValue);
        },
        
        handleItemClick: function(event) {
            const item = event.target.closest('.gallery-item');
            if (!item) return;
            
            // Get all visible items (filtered)
            this.state.filteredItems = Array.from(this.elements.items).filter(item => 
                !item.classList.contains('hidden')
            );
            
            // Find the index of the clicked item
            this.state.currentIndex = this.state.filteredItems.indexOf(item);
            
            // Open lightbox with this image
            this.openLightbox(item);
        },
        
        handleKeydown: function(event) {
            if (!this.elements.lightbox.classList.contains('active')) return;
            
            switch (event.key) {
                case 'Escape':
                    this.closeLightbox();
                    break;
                case 'ArrowRight':
                    this.nextImage();
                    break;
                case 'ArrowLeft':
                    this.prevImage();
                    break;
            }
        },
        
        // Gallery Methods
        applyFilter: function(filterValue) {
            this.state.currentFilter = filterValue;
            
            this.elements.items.forEach(item => {
                if (filterValue === 'all' || item.dataset.category === filterValue) {
                    // Show the item with animation
                    item.classList.remove('hidden');
                    // Trigger reflow for animation
                    void item.offsetWidth;
                    item.classList.add('fade-in');
                    
                    setTimeout(() => {
                        item.classList.remove('fade-in');
                    }, 500);
                } else {
                    // Hide the item
                    item.classList.add('hidden');
                }
            });
            
            // Update filtered items list
            this.state.filteredItems = Array.from(this.elements.items).filter(item => 
                !item.classList.contains('hidden')
            );
            
            // Update gallery layout
            this.updateLayout();
        },
        
        updateLayout: function() {
            // Update container height or layout if needed
            // This could involve something like masonry layout initialization
            console.log('Layout updated with', this.state.filteredItems.length, 'items');
        },
        
        // Lightbox Methods
        openLightbox: function(item) {
            const imgSrc = item.querySelector('img').dataset.fullsize;
            const caption = item.querySelector('.caption').textContent;
            
            // Set the image and caption
            this.elements.lightboxImg.src = imgSrc;
            this.elements.lightboxCaption.textContent = caption;
            
            // Show the lightbox
            this.elements.lightbox.classList.add('active');
            
            // Disable scrolling on the body
            document.body.style.overflow = 'hidden';
        },
        
        closeLightbox: function() {
            this.elements.lightbox.classList.remove('active');
            
            // Re-enable scrolling
            document.body.style.overflow = '';
            
            // Clear image source after transition (to save memory)
            setTimeout(() => {
                this.elements.lightboxImg.src = '';
            }, 300);
        },
        
        nextImage: function() {
            if (this.state.isTransitioning) return;
            
            this.state.isTransitioning = true;
            
            // Move to next image
            this.state.currentIndex = (this.state.currentIndex + 1) % this.state.filteredItems.length;
            
            // Fade out current image
            this.elements.lightboxImg.classList.add('fade-out');
            
            setTimeout(() => {
                // Update to next image
                const nextItem = this.state.filteredItems[this.state.currentIndex];
                this.elements.lightboxImg.src = nextItem.querySelector('img').dataset.fullsize;
                this.elements.lightboxCaption.textContent = nextItem.querySelector('.caption').textContent;
                
                // Remove fade out class
                this.elements.lightboxImg.classList.remove('fade-out');
                this.state.isTransitioning = false;
            }, 300);
        },
        
        prevImage: function() {
            if (this.state.isTransitioning) return;
            
            this.state.isTransitioning = true;
            
            // Move to previous image
            this.state.currentIndex = (this.state.currentIndex - 1 + this.state.filteredItems.length) % this.state.filteredItems.length;
            
            // Fade out current image
            this.elements.lightboxImg.classList.add('fade-out');
            
            setTimeout(() => {
                // Update to previous image
                const prevItem = this.state.filteredItems[this.state.currentIndex];
                this.elements.lightboxImg.src = prevItem.querySelector('img').dataset.fullsize;
                this.elements.lightboxCaption.textContent = prevItem.querySelector('.caption').textContent;
                
                // Remove fade out class
                this.elements.lightboxImg.classList.remove('fade-out');
                this.state.isTransitioning = false;
            }, 300);
        },
        
        // Clean up method
        destroy: function() {
            // Remove event listeners
            this.elements.filters.forEach(filter => {
                filter.removeEventListener('click', this.handleFilterClick);
            });
            
            this.elements.container.removeEventListener('click', this.handleItemClick);
            
            this.elements.lightboxClose.removeEventListener('click', this.closeLightbox);
            this.elements.lightboxNext.removeEventListener('click', this.nextImage);
            this.elements.lightboxPrev.removeEventListener('click', this.prevImage);
            
            this.elements.lightbox.removeEventListener('click', this.closeLightbox);
            
            document.removeEventListener('keydown', this.handleKeydown);
        }
    };
    
    // Initialize the gallery
    gallery.init();
    
    // For single-page applications, provide a way to clean up
    // when navigating away from this page
    window.addEventListener('beforeunload', function() {
        gallery.destroy();
    });
});

Practical Exercise

Create a drag-and-drop task management board with the following features:

  1. Create three columns: "To Do", "In Progress", and "Completed"
  2. Allow users to create new task cards with a title, description, and priority
  3. Implement drag and drop functionality to move tasks between columns
  4. Add the ability to edit task details by double-clicking
  5. Implement a right-click context menu for additional actions (delete, duplicate, prioritize)
  6. Add keyboard accessibility (arrow keys to navigate, enter to select, etc.)
  7. Store tasks in localStorage to persist between page reloads
  8. Add filtering and sorting options for tasks
  9. Use event delegation for efficient event handling
  10. Properly manage all event listeners to prevent memory leaks

This exercise will give you practice with a wide range of event handlers and advanced patterns in a realistic project context.

Summary

In the next lecture, we'll explore event propagation in depth, including capturing and bubbling phases, and how to leverage event delegation for efficient DOM interaction.

Additional Resources