The previous lesson left the Nómada Tasks page showing real data, but completely mute: a pretty poster you cannot interact with. An application is something else. It is a program that waits and reacts: somebody clicks, types into a field, presses a key, and the program responds. That working model is called event-driven programming, and it is the natural way of programming in the browser. In this lesson you will learn to register handlers with addEventListener, to read the information carried by the Event object, to remove handlers when they are no longer needed, to tell apart the events you will use most, and to avoid the most common trap of all: building controls with <div>s that leave out anyone who does not use a mouse. By the end, Marta will be able to mark a task as done with a click and Lucía will be able to see only her own.

Contents

  1. What an event is and what event-driven programming is
  2. The three ways of registering a handler
  3. addEventListener in depth
  4. The options: once, passive, capture, signal
  5. removeEventListener and the reference problem
  6. The Event object
  7. preventDefault and default actions
  8. Event reference table
  9. Keyboard events and shortcuts
  10. this inside a handler
  11. Accessibility: why a <div> with a click is not enough
  12. Nómada Tasks: mark as done and filter by assignee
  13. Common Mistakes and Tips
  14. Exercises
  15. Conclusion

  1. What an event is and what event-driven programming is

An event is a notification that something has happened. The browser generates it and it can come from a person (a click, a key, a scroll), from the browser itself (the page finished loading, an image failed) or from your code (the custom events of 06-04).

A handler (or listener) is a function you register so that it runs when a specific event happens on a specific element. And here is the conceptual key: you never call that function. You register it and forget about it. It is the browser that calls it.

This fits exactly with the event loop from 05-07. When you register a click handler on a button, nothing happens; the main thread stays free. When somebody clicks, the browser queues your function as a macrotask and the event loop will run it as soon as the call stack is empty.

flowchart LR
    A["Marta clicks<br/>a button"] --> B["The browser creates<br/>an Event object"]
    B --> C["It queues the handler<br/>in the task queue"]
    C --> D{"Call stack<br/>empty?"}
    D -- "No" --> D
    D -- "Yes" --> E["The event loop<br/>runs your function"]
    E --> F["The function modifies<br/>the model and the DOM"]

Out of this comes a practical consequence you already know from Module 5 but which now becomes tangible: if your handler takes a long time, the interface freezes. While your function runs, the thread is busy, and the browser can neither repaint nor attend to other clicks. A handler must do little work and do it fast; if it needs something expensive, that work is split up or deferred (Module 9).

  1. The three ways of registering a handler

Historically there have been three ways of connecting a function to an event. Only one is acceptable today, but it is worth recognizing the other two because they are everywhere.

Way 1: the HTML attribute onclick.

<!-- ✗ Do not do this -->
<button onclick="markDone(6)">Mark done</button>

The browser takes that string and evaluates it as code. It mixes markup with behavior, it forces markDone to be a global function (which breaks with the modules of 05-04, whose scope is not global), it is impossible to maintain and modern content security policies block it outright.

Way 2: the element.onclick property.

// ✗ Better than the previous one, but still limited
const button = document.querySelector('#mark-done');
button.onclick = () => console.log('click');
button.onclick = () => console.log('another click');   // ← it overwrites the previous one!
// Only the second one runs.

It is in JavaScript, which is already an improvement, but it allows only one handler per event and element. If your code registers one and a library registers another, one of the two silently disappears.

Way 3: addEventListener.

// ✓ The only correct way
const button = document.querySelector('#mark-done');
button.addEventListener('click', () => console.log('click'));
button.addEventListener('click', () => console.log('another click'));
// BOTH run, in the order they were registered.
Way Separates markup and logic Several handlers Options (once, capture…) Works with modules Verdict
onclick="…" attribute No No No No (requires a global function) Never
elem.onclick property Yes No No Yes Only in very old code
addEventListener Yes Yes Yes Yes Always

  1. addEventListener in depth

The signature is simple and always the same:

element.addEventListener(type, handler, options);
  • type: the name of the event as a string, without the on prefix: 'click', not 'onclick'.
  • handler: the function that will run. It receives one argument: the Event object.
  • options: an optional object we will see in the next section.
const button = document.querySelector('#mark-done');

button.addEventListener('click', function (event) {
  console.log('A click happened on', event.currentTarget.textContent);
});

The most repeated syntax mistake of the whole module is this one:

button.addEventListener('click', markDone());   // ✗ WRONG!

The parentheses call the function immediately and register whatever it returns (normally undefined) as the handler. What you want is to pass the function without calling it:

button.addEventListener('click', markDone);     // ✓ passes the reference

And what if you need to pass it arguments? Wrap it in an arrow, as you learned in 03-06:

button.addEventListener('click', () => markDone(6));   // ✓

  1. The options: once, passive, capture, signal

The third parameter accepts an object with these properties:

Option Type What it does
once boolean The handler runs only once and removes itself
passive boolean Promises the handler will not call preventDefault, which lets the browser scroll without waiting
capture boolean Registers the handler in the capture phase instead of the bubbling phase (lesson 06-04)
signal AbortSignal Lets you remove the handler by aborting a signal (lesson 06-04)

once is the most useful of the four day to day, and it replaces a pattern that used to be written by hand:

// Show the help only the first time the form is opened
form.addEventListener('focusin', showHelp, { once: true });

// Before 'once' you had to do this:
function showHelpOnce(event) {
  showHelp(event);
  event.currentTarget.removeEventListener('focusin', showHelpOnce);
}

passive matters in scroll and touch events (scroll, touchstart, wheel). Without it, the browser has to wait for your handler to finish to know whether it is going to cancel the scroll, which feels sluggish. By promising you will not, scrolling stays smooth:

document.addEventListener('scroll', onScroll, { passive: true });

In modern browsers, touchstart, touchmove and wheel on document are already passive by default.

capture and signal belong squarely to the next lesson; they are mentioned here so the table is complete.

  1. removeEventListener and the reference problem

To remove a handler you use removeEventListener with exactly the same three pieces of information it was registered with: the same type, the same function and the same capture value.

function onClick() { console.log('click'); }

button.addEventListener('click', onClick);
button.removeEventListener('click', onClick);   // ✓ removed

And here is the trap that costs hours:

button.addEventListener('click', () => console.log('click'));
button.removeEventListener('click', () => console.log('click'));
// ✗ It removes NOTHING. And it gives no error.

The two arrows have the same code, but they are two different function objects, exactly like {} !== {} in 01-07. removeEventListener compares by identity, finds no match and does nothing, silently.

The same trap shows up with bind, which returns a new function every time (04-02):

button.addEventListener('click', this.onClick.bind(this));
button.removeEventListener('click', this.onClick.bind(this));   // ✗ a different function

// ✓ Store the reference just once
this.boundOnClick = this.onClick.bind(this);
button.addEventListener('click', this.boundOnClick);
button.removeEventListener('click', this.boundOnClick);

When you have to remove a handler. If the element is removed from the DOM and nobody else holds a reference to it, the browser collects the element and its handlers: there is nothing to do. But if you registered the handler on window, on document or on an element that is still alive, and you no longer need it, remove it; otherwise your function and everything its closure captures (03-04) stay retained in memory. It is one of the classic memory leaks, and it has a lesson of its own: Memory Management.

  1. The Event object

Every time an event fires, the browser builds an object with all the information and passes it as the first argument to your handler. These are its essential properties:

Property What it holds
type The event type: 'click', 'keydown', 'submit'
target The element where the event originated (the deepest one)
currentTarget The element you registered the handler on
timeStamp Milliseconds since the document was created
isTrusted true if the user generated it; false if your code fired it
defaultPrevented true if somebody has already called preventDefault()

The distinction between target and currentTarget is the most important one in the table, and it is best seen with an example. This <li> contains a <button>, and the handler is on the <li>:

<li class="task" data-id="6">
  <span class="task__title">Carpentry workshop quote</span>
  <button class="task__action">Mark done</button>
</li>
const li = document.querySelector('[data-id="6"]');

li.addEventListener('click', (event) => {
  console.log('target:       ', event.target.tagName);
  console.log('currentTarget:', event.currentTarget.tagName);
});

// If you click the BUTTON:
// target:        BUTTON   ← where it really happened
// currentTarget: LI       ← where you are listening

// If you click the TITLE:
// target:        SPAN
// currentTarget: LI

That a click on the button triggers the <li>'s handler has an explanation —the event bubbles upward— that is the central topic of the next lesson. For now, hold on to the rule: currentTarget is your element; target is where the user actually clicked.

A warning: currentTarget only has a value during the execution of the handler. If you store the event and read it later (inside a setTimeout, for instance), it will be null.

button.addEventListener('click', (event) => {
  const element = event.currentTarget;            // ✓ store it now
  setTimeout(() => {
    console.log(event.currentTarget);             // null ✗
    console.log(element.textContent);             // ✓ works
  }, 100);
});

On top of that, each type of event contributes its own properties: a MouseEvent brings clientX, clientY and button; a KeyboardEvent brings key, code, ctrlKey and shiftKey; an InputEvent brings data. All of them inherit from Event, in a prototype hierarchy that is the one from 05-01 applied to the browser APIs.

  1. preventDefault and default actions

Many elements have behavior of their own built into the browser: a link navigates, a form submits and reloads the page, the space bar scrolls the document, the right button opens the context menu. preventDefault() cancels that behavior.

const link = document.querySelector('#help');
link.addEventListener('click', (event) => {
  event.preventDefault();           // ✓ the browser does NOT follow the link
  showHelpPanel();
});

The case you will use most is in 06-07: intercepting a form submission to process it with JavaScript instead of reloading the page.

form.addEventListener('submit', (event) => {
  event.preventDefault();           // without this, the page reloads and you lose everything
  // … create the task with the form's data
});

Two important limits:

  • Not every event is cancelable. If event.cancelable is false, preventDefault() does nothing. Custom events are only cancelable if you create them with { cancelable: true }.
  • If you registered the handler with { passive: true }, preventDefault() is ignored and the browser prints a warning in the console. It is logical: you promised you would not call it.

And do not confuse preventDefault() with stopPropagation(): the first cancels what the browser would do, the second stops the event's journey through the tree. They are completely different things, and the table that separates them is in lesson 06-04.

  1. Event reference table

These are the events that cover practically the whole of an application like Nómada Tasks:

Category Event When it fires Notes
Mouse click Press and release on the element Also fired by Enter/Space on a <button>
dblclick Two quick clicks Arrives after two clicks
mousedown / mouseup Press / release of the button Before the click
mouseenter / mouseleave The pointer enters/leaves the element They do not fire when moving onto a child
mouseover / mouseout The pointer enters/leaves, children included They fire many more times
contextmenu Right button Cancelable
Keyboard keydown A key is pressed Repeats if held down
keyup A key is released Only once
Focus focus / blur The element gains/loses focus They do not bubble (see 06-04)
focusin / focusout The same, but they do bubble Useful with delegation
Form input The value changes, on every keystroke Ideal for validating on the fly
change The value changes and is confirmed For text, on losing focus; for a select, on choosing
submit The form is submitted Fires on the <form>, not on the button
reset The form is reset Cancelable
Window DOMContentLoaded The DOM is built On document
load / resize / scroll Everything loaded / size change / scrolling On window

The difference between input and change deserves a demonstration, because they get confused daily:

const field = document.querySelector('#title');

field.addEventListener('input',  (e) => console.log('input:',  e.target.value));
field.addEventListener('change', (e) => console.log('change:', e.target.value));

// Marta types "Web" and clicks outside:
// input:  W
// input:  We
// input:  Web
// change: Web        ← only once, on confirming

And that of mouseenter versus mouseover:

li.addEventListener('mouseenter', () => console.log('enter'));  // once, on entering the <li>
li.addEventListener('mouseover',  () => console.log('over'));   // again on moving onto the <span>,
                                                                // again on moving onto the <button>…

For effects along the lines of "the mouse is over this card", always use mouseenter/mouseleave.

  1. Keyboard events and shortcuts

The KeyboardEvent object brings two similar properties that are not the same thing:

  • event.key: the character produced. It depends on the keyboard layout and on whether Shift is held: 'a', 'A', 'Enter', 'Escape', 'ArrowDown', ' ' (space).
  • event.code: the physical key pressed, independent of the layout: 'KeyA', 'Enter', 'Space'.

For shortcuts and commands use key; code only makes sense in video games, where the physical position of the key matters.

document.addEventListener('keydown', (event) => {
  // Escape closes the filter panel
  if (event.key === 'Escape') {
    closeFilters();
    return;
  }

  // Ctrl+K (or Cmd+K on a Mac) focuses the search field
  if (event.key === 'k' && (event.ctrlKey || event.metaKey)) {
    event.preventDefault();           // the browser has its own Ctrl+K
    document.querySelector('#search').focus();
  }
});

The available modifiers are ctrlKey, shiftKey, altKey and metaKey (the Command key on macOS and Windows on a PC). Checking ctrlKey || metaKey is the usual way of supporting both systems.

A very important accessibility detail: a global shortcut must not fire while the user is typing in a field. If Iván is typing the title of a task and presses k, he does not want the search to open:

document.addEventListener('keydown', (event) => {
  const typing = event.target.matches('input, textarea, select, [contenteditable]');
  if (typing) return;                // guard: shortcuts do not apply inside a field
  // … shortcuts
});

  1. this inside a handler

Here what you learned in 04-02 applies directly. When you register a traditional function as a handler, the browser invokes it with this pointing at the element where you registered the handler, that is, the same as event.currentTarget:

button.addEventListener('click', function (event) {
  console.log(this === event.currentTarget);   // true
  this.disabled = true;
});

With an arrow function, this is not reassigned: an arrow has no this of its own and takes the one from the scope where it was written (03-02). In a top-level module, that this is undefined:

button.addEventListener('click', (event) => {
  console.log(this);                  // undefined in a module
  console.log(event.currentTarget);   // ✓ the button, always
});

This is not a defect of arrows: it is exactly what makes them useful inside a class, where losing this in callbacks was the problem of 04-02.

class BoardController {
  constructor(board, container) {
    this.board = board;
    this.container = container;
  }

  connect() {
    // ✗ With a traditional function, 'this' becomes the button and this.board is undefined
    // button.addEventListener('click', function () { this.board.summary(); });

    // ✓ With an arrow, 'this' is still the controller
    this.container.addEventListener('click', (event) => {
      console.log(this.board.total);            // 6 ✓
      console.log(event.currentTarget.id);      // 'task-list' ✓
    });
  }
}

Practical rule: use arrows and event.currentTarget. That way this always means the same thing (your object) and you get the element from the event, which never lies. Save traditional functions for the specific case where you want the element's this and you are not inside a class.

  1. Accessibility: why a <div> with a click is not enough

This works with a mouse:

<!-- ✗ A broken control -->
<div class="button" onclick="markDone(6)">Mark done</div>

And it is broken for everything else. A <div>:

  • Does not receive focus with the Tab key. Anyone navigating by keyboard cannot reach it, and therefore can never activate it.
  • Does not respond to Enter or Space, which is how controls are activated with the keyboard.
  • Has no role. A screen reader announces "Mark done", without saying it is a button or that it can be activated.
  • Has no state: it cannot be disabled in a way that assistive technologies understand.

A <button> brings all of that out of the box:

<!-- ✓ A correct control -->
<button type="button" class="task__action" data-action="done">Mark done</button>
button.addEventListener('click', markDone);
// This handler fires with the mouse, with Enter and with Space. For free.

A direct comparison:

Requirement <div> with click <button>
Receives focus with Tab No (needs tabindex="0") Yes
Activates with Enter/Space No (has to be programmed) Yes
Announced as a button No (needs role="button") Yes
Supports disabled No Yes
Style controllable with CSS Yes Yes

The only argument in favor of the <div> was styling, and for many years now a <button> has been fully stylable (all: unset, appearance: none, or simply rewriting background, border and padding).

Two more rules for the rest of the module:

  • Every interactive element uses the tag that corresponds to it: <button> for actions, <a href> for navigating, <input>/<select> for entering data. If a click takes you to another page, it is a link; if it runs an action on this one, it is a button.
  • If the button only has an icon, it needs an accessible name: aria-label="Mark as done", or visually hidden text. A button with no text is a button with no name.
<button type="button" class="task__action" data-action="done"
        aria-label="Mark &quot;Carpentry workshop quote&quot; as done">✓</button>

  1. Nómada Tasks: mark as done and filter by assignee

Let's make the page interactive. We extend the HTML with a filter bar and one button per task:

<section class="board" aria-labelledby="board-title">
  <h2 id="board-title">Backlog</h2>

  <div class="filters" role="group" aria-label="Filter by assignee">
    <button type="button" class="filter" data-assignee="">All</button>
    <button type="button" class="filter" data-assignee="Marta">Marta</button>
    <button type="button" class="filter" data-assignee="Iván">Iván</button>
    <button type="button" class="filter" data-assignee="Lucía">Lucía</button>
  </div>

  <p id="summary" class="summary" role="status"></p>

  <ul id="task-list" class="task-list">
    <li class="task" data-id="1">
      <span class="task__title"></span>
      <span class="task__meta"></span>
      <button type="button" class="task__action" data-action="advance"></button>
    </li>
    <li class="task" data-id="3">
      <span class="task__title"></span>
      <span class="task__meta"></span>
      <button type="button" class="task__action" data-action="advance"></button>
    </li>
    <li class="task" data-id="6">
      <span class="task__title"></span>
      <span class="task__meta"></span>
      <button type="button" class="task__action" data-action="advance"></button>
    </li>
  </ul>
</section>

The CSS needed, briefly:

.filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.filter, .task__action {
  font: inherit; padding: 0.3rem 0.7rem; cursor: pointer;
  border: 1px solid var(--border); border-radius: 0.35rem; background: #fff;
}
.filter[aria-pressed="true"] { background: #1f2933; color: #fff; }
.task__action[disabled] { opacity: 0.45; cursor: not-allowed; }
.task[hidden] { display: none; }

And the controller. The key point of section 12 is that the model decides and the view obeys: the handler calls board.changeStatus(...), which applies rule R6 for transitions, and then repaints.

// js/view/controller.js
import { TODAY } from '../util/dates.js';
import { paintTask, paintSummary } from './paint.js';

/** Next status according to R6; null if the task is already finished. */
const NEXT = Object.freeze({ pending: 'in-progress', 'in-progress': 'done', done: null });

const LABEL = Object.freeze({
  pending: 'Start', 'in-progress': 'Mark done', done: 'Completed'
});

/** Refreshes the action button of an <li> according to the status of its task. */
function refreshButton(li, task) {
  const button = li.querySelector('.task__action');
  const next = NEXT[task.status];
  button.textContent = LABEL[task.status];
  button.disabled = next === null;                  // ✓ property, not setAttribute
  button.setAttribute('aria-label', `${LABEL[task.status]}: ${task.title}`);
}

export function connectActions(container, board, today = TODAY) {
  for (const li of container.querySelectorAll('li[data-id]')) {
    const task = board.findById(Number(li.dataset.id));
    if (task === null) continue;

    paintTask(li, task, today);
    refreshButton(li, task);

    const button = li.querySelector('.task__action');
    button.addEventListener('click', (event) => {
      const next = NEXT[task.status];
      if (next === null) return;

      try {
        board.changeStatus(task.id, next);            // the model validates (R6)
      } catch (error) {
        console.error(error.describe?.() ?? error.message);
        return;
      }

      paintTask(li, task, today);
      refreshButton(li, task);
      paintSummary(document.querySelector('#summary'), board, today);
      console.log(`${task.title} → ${task.status}`, event.timeStamp.toFixed(0), 'ms');
    });
  }
}

export function connectFilters(bar, container) {
  for (const button of bar.querySelectorAll('.filter')) {
    button.setAttribute('aria-pressed', button.dataset.assignee === '' ? 'true' : 'false');

    button.addEventListener('click', (event) => {
      const chosen = event.currentTarget.dataset.assignee;

      // Visual state of the buttons (aria-pressed communicates which one is active)
      for (const other of bar.querySelectorAll('.filter')) {
        other.setAttribute('aria-pressed', String(other === event.currentTarget));
      }

      // Hide or show each task. 'hidden' takes it out of the accessibility tree.
      for (const li of container.querySelectorAll('li[data-id]')) {
        li.hidden = chosen !== '' && li.dataset.assignee !== chosen;
      }
    });
  }
}
// js/app.js
import { Board } from './model/board.js';
import { createBacklog } from './data/backlog.js';
import { TODAY } from './util/dates.js';
import { paintSummary } from './view/paint.js';
import { connectActions, connectFilters } from './view/controller.js';

const board = new Board('Taller Nómada', createBacklog());

const list = document.querySelector('#task-list');
const bar = document.querySelector('.filters');
const summary = document.querySelector('#summary');

connectActions(list, board, TODAY);
connectFilters(bar, list);
paintSummary(summary, board, TODAY);

Try it: clicking "Start" on the carpentry task moves it to in-progress, the button changes to "Mark done", and the summary updates the open hours. On the second click it moves to done, the title gets a strikethrough, the button is disabled and the 45 open h drop to 40. All of it with rule R6 applied by the model, not by the view.

And now the snag. This code registers one handler for every button. With three tasks that is three; with the complete backlog of six it is six; with two hundred, two hundred. Worse still: in lesson 06-05 you will start creating the <li>s from JavaScript, and those new elements will have no handler, because connectActions has already run. You would have to reconnect after every change, with the risk of duplicating handlers.

There is a much better solution: a single handler on the <ul> that attends to the clicks of every button, present and future. It is called delegation, it rests on bubbling and on the closest() from 06-02, and it is the first thing you will see in the next lesson.

Common Mistakes and Tips

  • Calling the function when registering it: addEventListener('click', doIt()) registers the returned value, not the function. No parentheses, or wrapped in an arrow if you need arguments.
  • Writing the type with on: addEventListener('onclick', …) gives no error and never works, because there is no event called onclick. The type is 'click'.
  • Trying to remove an anonymous arrow. removeEventListener compares by identity: if you did not store the reference, you cannot remove it. Store the function in a constant, or use { once: true } or AbortController (06-04).
  • Confusing target with currentTarget. currentTarget is your element; target is where the click happened, which may be a child. If the button contains a <span> with the icon, target will be the <span>.
  • Storing the event to use it later. currentTarget is null outside the handler. Pull out what you need into variables before any setTimeout or await.
  • Forgetting preventDefault() on a submit. The page reloads, the state is lost and it looks as though "nothing works". It is mistake number one of lesson 06-07.
  • Confusing input with change. input fires on every keystroke; change only on confirming. To validate on the fly you want input; to react to a <select>, either of the two.
  • Using a <div> as a button. It breaks the keyboard and screen readers. <button type="button"> always; and with an explicit type, because inside a form the default value is submit.
  • Tip: name your handlers. onAdvanceClick as a named function shows up in stack traces and in the DevTools Event Listeners panel; an anonymous arrow shows up as (anonymous).
  • Tip: inspect the registered handlers. In the DevTools Elements panel there is an Event Listeners tab that shows every handler on an element and on its ancestors. It is the fastest way to discover duplicated handlers.

Exercises

Exercise 1 · A click counter with once

Write a snippet that registers two handlers on the "All" filter button: a normal one that counts the clicks and prints them, and another with { once: true } that prints "first click" and disappears. Check with four clicks that the first runs four times and the second once. Then add a third handler that you can remove from the console, and remove it.

Exercise 2 · Accessible keyboard shortcuts

Add these global shortcuts to the page, respecting the rule of not firing them while typing in a field:

  • 1, 2, 3 filter by Marta, Iván and Lucía respectively.
  • 0 clears the filter.
  • Escape clears the filter and returns focus to the first button in the bar.

Reuse the existing buttons instead of duplicating the filtering logic.

Exercise 3 · Preview on hover

When the pointer enters a task <li>, show in the summary paragraph a line with the title, the reviewer and the days left until the due date; on leaving, restore the general summary. Use the right events so that the message does not flicker when the mouse passes over the inner button, and explain why that pair of events is the right one.

Solutions

Exercise 1

const allButton = document.querySelector('.filter[data-assignee=""]');

let clicks = 0;
allButton.addEventListener('click', () => {
  clicks += 1;
  console.log('clicks:', clicks);
});

allButton.addEventListener('click', () => console.log('first click'), { once: true });

// Third handler, removable: the reference has to be stored
function notify(event) {
  console.log('notifying from', event.currentTarget.textContent);
}
allButton.addEventListener('click', notify);

// Later, from the console:
allButton.removeEventListener('click', notify);   // ✓ works: same reference

With four clicks the console shows clicks: 1, first click, clicks: 2, clicks: 3, clicks: 4. The order within a single click is the order of registration. The counter works because clicks lives in the handler's closure (03-04): every call sees and updates the same variable.

Exercise 2

const bar = document.querySelector('.filters');
const BY_KEY = { '1': 'Marta', '2': 'Iván', '3': 'Lucía', '0': '' };

function pressFilter(assignee) {
  bar.querySelector(`.filter[data-assignee="${assignee}"]`)?.click();
}

document.addEventListener('keydown', (event) => {
  // Guard: shortcuts do not apply while typing
  if (event.target.matches('input, textarea, select, [contenteditable]')) return;
  if (event.ctrlKey || event.metaKey || event.altKey) return;   // do not override system shortcuts

  if (Object.hasOwn(BY_KEY, event.key)) {
    event.preventDefault();
    pressFilter(BY_KEY[event.key]);
    return;
  }

  if (event.key === 'Escape') {
    pressFilter('');
    bar.querySelector('.filter').focus();
  }
});

The elegant part of this solution is button.click(): instead of duplicating the filtering logic, it simulates the click on the button that already has it, and so the visual state (aria-pressed) stays consistent without writing another line. The event generated by click() has isTrusted: false, but otherwise it is identical. The BY_KEY object is the lookup dictionary from 02-03, much cleaner than a chain of ifs. And returning focus after Escape is an essential courtesy: without it, anyone navigating by keyboard is left with no starting point.

Exercise 3

import { readableDate } from './util/dates.js';

const summary = document.querySelector('#summary');

for (const li of list.querySelectorAll('li[data-id]')) {
  const task = board.findById(Number(li.dataset.id));

  li.addEventListener('mouseenter', () => {
    summary.textContent =
      `${task.title} · reviewed by ${task.reviewer ?? 'nobody'} · ` +
      `due ${readableDate(task.dueDate)} (${task.daysLeft} days)`;
  });

  li.addEventListener('mouseleave', () => {
    paintSummary(summary, board, TODAY);
  });
}

The correct pair is mouseenter/mouseleave because they do not fire when moving between the element's children. With mouseover/mouseout the message would flicker: moving from the title <span> to the <button> would fire a mouseout (which would restore the summary) followed by a mouseover (which would put it back), dozens of times per second. mouseenter only fires when crossing the outer boundary of the <li>.

An accessibility note: this preview only exists for people using a mouse. To make it complete you would have to add the keyboard equivalents (focusin/focusout on a focusable element), and that fits with the events that do bubble, from the next lesson.

Conclusion

You now know how to make the page respond. An event is a notification the browser generates, a handler is a function you register so that it gets called for you, and the mechanism that makes it possible is the same event loop from 05-07: your handler is queued as a task and runs when the stack is free, from which follows the rule that a handler must be brief, because while it runs the interface is frozen.

Of the three historical ways of registering handlers —the onclick attribute, the element.onclick property and addEventListener— only the last is acceptable: it separates markup and logic, allows several handlers per event, works with modules and accepts options. You know those options: once for things that only happen once, passive for not blocking scrolling, and capture and signal, which unfold in the next lesson. And you know the removeEventListener trap: it compares by identity, so an anonymous arrow or an inline bind is impossible to remove; you have to store the reference, and you have to remove the handlers on window and document that are no longer used so as not to retain memory.

Of the Event object you have mastered the essentials: type, timeStamp, isTrusted, the crucial difference between target (where it happened) and currentTarget (where you are listening) —with the warning that the latter is emptied when you leave the handler—, and preventDefault() for canceling the browser's default action, which will be essential in the form's submit. You have the reference table of mouse, keyboard, focus and form events, with the distinctions that get confused most: input versus change, mouseenter versus mouseover, key versus code. You know that this in a traditional handler is the element and in an arrow is the one from the outer scope, and that the sensible rule inside a class is to use arrows and read the element from event.currentTarget. And you are clear, with no excuses, that a control is written with <button>: a <div> with a click receives no focus, does not respond to Enter or Space and is not announced as a button.

In Nómada Tasks that has produced js/view/controller.js with connectActions and connectFilters: each task advances pending → in-progress → done with rule R6 validated by the model, the button changes its text and is disabled once finished, the summary updates, and the filter bar uses aria-pressed and hidden to communicate the state to everybody.

And a problem has appeared that cannot be ignored: there is one handler per button. Six tasks, six handlers; and as soon as you start creating the <li>s from JavaScript, the new elements will be born with no handler. The solution is not to reconnect every time, but to understand how an event travels through the tree —capture, target and bubbling— and to place a single handler on the <ul> that attends to every button that exists now and in the future. That pattern, together with the tools for stopping an event's journey and with the custom events that will let you decouple the view from the model, is Propagation, Delegation and Custom Events.

JavaScript Course: From Beginner to Advanced

Module 1: Introduction to JavaScript

Module 2: Control Structures

Module 3: Functions

Module 4: Objects and Arrays

Module 5: Advanced Objects and Functions

Module 6: The Document Object Model (DOM)

Module 7: Browser APIs and Advanced Topics

Module 8: Testing and Debugging

Module 9: Performance and Optimization

Module 10: JavaScript Frameworks and Libraries

Module 11: Final Project

© Copyright 2026. All rights reserved