Skip to content

Respond to user input with DOM events

You will add a form to the project-status page and use DOM events to validate its input and show a result without reloading the page.

Interactive web pages respond to actions such as submitting a form, activating a button, changing a field, or selecting an option. The browser reports each action as an event. JavaScript can listen for that event, read the current page data, and update the document.

What you will practice

  • Explain the relationship between the HTML source and the Document Object Model (DOM).
  • Select required page elements with document.querySelector and check that each result exists.
  • Register named callback functions with addEventListener.
  • Use the submit event and preventDefault to handle a form without a page reload.
  • Read and validate an input value, then update text with textContent.
  • Verify submit and reset behavior with a mouse and a keyboard.
  • New: The DOM, document, CSS selector strings, element objects, querySelector, events, event listeners, callback functions, the event object, preventDefault, input values, textContent, focus management, aria-invalid, and status live regions.
  • Reused: The project-status calculation, functions, conditions, template literals, strict mode, semantic HTML, form controls, browser developer tools, and Console error messages.

Starting point

Before you start

  • The completed web-interaction-lessons folder from the JavaScript foundations lesson.
  • index.html loads app.js with defer.
  • app.js calculates the required 60% status without a Console error.
  • VS Code and a current version of Edge, Chrome, or Firefox.
Current state
The project status is written only to the Console. The page cannot receive a project name or show a calculated result in the document.
First action
Open web-interaction-lessons in VS Code and replace the main element in index.html with the status form from the next section.
First checkpoint
The browser shows one labeled text field, a Show status button, a Reset button, and the initial status text in that order.
Help trigger
Use the recovery note or ask for help if a label does not focus its input, submitting reloads the page, the Console names null in a TypeError, or a keyboard cannot reach and operate both buttons.

You have completed the lesson when:

  • index.html contains a form with a connected label, text input, submit button, reset button, and status output;
  • the output uses role="status" so updates can be announced without moving focus;
  • app.js selects the form, input, and output with document.querySelector;
  • the script stops with a specific error if a required element is missing;
  • a named submit handler prevents the form’s default page submission;
  • submitting an empty or whitespace-only name shows Enter a project name., applies aria-invalid="true", and moves focus to the input;
  • submitting Community event page shows Community event page: 60% complete. Continue with required tasks. and removes aria-invalid;
  • resetting the form clears the input and restores the initial status text;
  • the submit and reset behavior works with keyboard controls; and
  • the final version runs without a JavaScript error in the Console.

Replace the existing main element in index.html with this markup. Keep the document structure and deferred script element from the previous lesson.

web-interaction-lessons/index.html — main element
<main>
<h1>Project status</h1>
<form id="status-form">
<div>
<label for="project-name">Project name</label>
<input
id="project-name"
name="projectName"
type="text"
aria-describedby="project-name-help"
>
<p id="project-name-help">Enter the name used in the status message.</p>
</div>
<div>
<button type="submit">Show status</button>
<button type="reset">Reset</button>
</div>
</form>
<p id="status-message" role="status">
Enter a project name and submit the form.
</p>
</main>

Each part has a specific role:

  • form groups the input and actions that produce one status result.
  • for="project-name" connects the visible label to the input with id="project-name".
  • name="projectName" gives the control a name for normal form data.
  • aria-describedby connects the input to its visible help text.
  • type="submit" gives the first button the form’s submit behavior.
  • type="reset" gives the second button the form’s reset behavior.
  • role="status" makes text updates available as a polite live status in supporting assistive technologies.

Do not replace the buttons with styled div or span elements. Native buttons provide keyboard activation, focus behavior, and button semantics without recreating those features in JavaScript.

  1. Save index.html and reload the page.
  2. Select the Project name label and confirm that the text field receives focus.
  3. Press Tab from the text field and confirm that focus moves to Show status, then Reset.
  4. Select Reset and confirm that the text field clears. The status text does not reset yet because the JavaScript listener does not exist.

If these checks fail, correct the HTML before you change app.js. JavaScript cannot repair a missing form structure without adding avoidable complexity.

Connect HTML to JavaScript through the DOM

Section titled “Connect HTML to JavaScript through the DOM”

When the browser parses HTML, it creates a Document Object Model, or DOM. The DOM represents the document as objects that JavaScript can inspect and change. The HTML file remains the source. The DOM is the browser’s current in-memory representation of that source and any later changes.

The browser supplies the document object as the entry point to the current DOM. document.querySelector(...) searches the document with a CSS selector string and returns the first matching element. It returns null when no element matches.

In app.js, keep the task data, calculateCompletion function, percentage calculation, and conditional status logic from the previous lesson. Remove the two final console.log calls. Add these selections after the status logic:

Add after the status-message condition
const statusForm = document.querySelector("#status-form");
const projectNameInput = document.querySelector("#project-name");
const statusOutput = document.querySelector("#status-message");

Each selector starts with # because it selects an element by its id value. The identifiers on the left describe how JavaScript will use the selected elements.

Add this guard after the three selections:

Check the selection results
if (!statusForm || !projectNameInput || !statusOutput) {
throw new Error("Required status form elements are missing.");
}

The logical NOT operator ! converts each selection result to a boolean and reverses it. An existing element becomes false after !. null becomes true.

The logical OR operator || makes the complete condition true when any required selection is missing. throw new Error(...) stops the script and places the specific message in the Console.

This guard turns a later error such as Cannot read properties of null into a message that identifies the missing interface contract.

Change "#status-form" to "#status-form-test", save, and reload. The Console must show Required status form elements are missing. Restore "#status-form", save, reload, and confirm that the error disappears.

Checkpoint: The script finds the required elements

What now works
All three querySelector calls return elements, and the deliberate selector error produces the specific missing-elements message.
Files changed
index.html, app.js
What remains
Listen for form submission, read the input, and update the status output.
Next action
Keep all three selectors restored and add the named submit handler after the missing-elements guard.
If it does not work
Compare each selector string with the matching HTML id. Include the # in the selector, but not in the HTML id value.

An event is an object that describes something that happened. An event listener connects one event type on one event target to a function.

Add this named function after the missing-elements guard:

Add a submit callback
function handleStatusSubmit(event) {
event.preventDefault();
console.log("Status form submitted.");
}

Then register the function as the form’s submit listener:

Register the submit listener
statusForm.addEventListener("submit", handleStatusSubmit);

addEventListener receives two arguments:

  1. "submit" names the event type.
  2. handleStatusSubmit supplies the callback function that the browser must call after that event occurs.

Pass the function name without parentheses. handleStatusSubmit() would call the function immediately while the script loads and pass its returned value instead of the callback.

When the event occurs, the browser calls the function and supplies the event object as the event argument. event.preventDefault() prevents the form’s default submission, so this local page does not reload or navigate.

  1. Save app.js and reload the page with the Console open.
  2. Select Show status. The Console shows Status form submitted. and the page does not reload.
  3. Focus the Project name input and press Enter. The same submit event runs.
  4. Confirm that the Console adds one message for each submission.

Listening to the form’s submit event covers both a submit-button activation and Enter-based form submission. A click listener on the button would describe only one route to the form action.

Replace the temporary handleStatusSubmit function with this complete version:

Replace handleStatusSubmit
function handleStatusSubmit(event) {
event.preventDefault();
const projectName = projectNameInput.value.trim();
if (projectName === "") {
projectNameInput.setAttribute("aria-invalid", "true");
statusOutput.textContent = "Enter a project name.";
projectNameInput.focus();
return;
}
projectNameInput.removeAttribute("aria-invalid");
statusOutput.textContent = `${projectName}: ${statusMessage}`;
}

The handler follows one input-to-output path:

  • projectNameInput.value reads the text field’s current string value.
  • .trim() removes whitespace from the start and end of the string.
  • projectName === "" detects an empty or whitespace-only result.
  • setAttribute exposes the invalid state to the DOM and supporting assistive technologies.
  • textContent replaces the output element’s text.
  • focus() returns keyboard focus to the field that needs correction.
  • return ends the handler before the success code runs.
  • The success path removes the invalid state and combines the project name with the calculated status.

Use textContent for this user-provided string. It inserts the value as text instead of parsing it as HTML. The input <strong>Test</strong>, for example, appears as those literal characters instead of creating an element.

Test Expected output Expected input state
Submit an empty field Enter a project name. Focus returns to the input and aria-invalid="true" is present
Submit spaces only Enter a project name. Same invalid result after trim()
Submit Community event page Community event page: 60% complete. Continue with required tasks. aria-invalid is absent
Submit <strong>Test</strong> The literal tag text followed by the status No strong element is created

Use the browser Elements or Inspector panel to confirm the aria-invalid attribute. Restore Community event page as the final successful test value.

The browser’s default reset behavior clears the input because the HTML does not define an initial value. Add a reset callback that also restores the JavaScript-managed output and invalid state:

Add after handleStatusSubmit
function handleStatusReset() {
projectNameInput.removeAttribute("aria-invalid");
statusOutput.textContent = "Enter a project name and submit the form.";
}

Register the reset listener after the submit listener:

Register both event listeners
statusForm.addEventListener("submit", handleStatusSubmit);
statusForm.addEventListener("reset", handleStatusReset);

The reset handler does not call preventDefault. The form’s native reset action must still clear the input. The listener adds the two updates that the browser cannot infer from the form controls.

Test this sequence:

  1. Submit an empty value and confirm the invalid state.
  2. Enter Community event page and submit it.
  3. Activate Reset with the keyboard.
  4. Confirm that the input clears, aria-invalid is absent, and the initial status text returns.

Checkpoint: The form responds to submit and reset events

What now works
Mouse and keyboard submission select the correct validation path without a reload, and reset restores the input, invalid state, and initial output.
Files changed
index.html, app.js
What remains
Review the complete event flow and run the final self-check.
Next action
Compare app.js with the complete program, then run every self-check without editing the event code between tests.
If it does not work
Fix the first Console error, confirm that each listener is registered after its function exists, and test one event at a time.

Your final app.js must contain this program:

web-interaction-lessons/app.js — required result
"use strict";
// Stable project data
const completedTasks = 3;
const totalTasks = 5;
const hasBlockingIssue = false;
function calculateCompletion(completed, total) {
if (total === 0) {
return 0;
}
return Math.round((completed / total) * 100);
}
const completionPercentage = calculateCompletion(
completedTasks,
totalTasks,
);
let statusMessage = `${completionPercentage}% complete. Continue with required tasks.`;
if (hasBlockingIssue) {
statusMessage = "Blocked: resolve the current issue.";
} else if (completionPercentage === 100) {
statusMessage = "Ready for review.";
}
const statusForm = document.querySelector("#status-form");
const projectNameInput = document.querySelector("#project-name");
const statusOutput = document.querySelector("#status-message");
if (!statusForm || !projectNameInput || !statusOutput) {
throw new Error("Required status form elements are missing.");
}
function handleStatusSubmit(event) {
event.preventDefault();
const projectName = projectNameInput.value.trim();
if (projectName === "") {
projectNameInput.setAttribute("aria-invalid", "true");
statusOutput.textContent = "Enter a project name.";
projectNameInput.focus();
return;
}
projectNameInput.removeAttribute("aria-invalid");
statusOutput.textContent = `${projectName}: ${statusMessage}`;
}
function handleStatusReset() {
projectNameInput.removeAttribute("aria-invalid");
statusOutput.textContent = "Enter a project name and submit the form.";
}
statusForm.addEventListener("submit", handleStatusSubmit);
statusForm.addEventListener("reset", handleStatusReset);

The complete event flow is:

HTML source
-> browser creates DOM objects
-> querySelector returns required elements
-> user submits the form
-> browser creates a submit event
-> handleStatusSubmit receives the event
-> handler reads and validates input.value
-> handler assigns statusOutput.textContent
-> browser renders the changed DOM text

The JavaScript changes the DOM object. It does not rewrite the saved index.html file. Reloading creates a new DOM from the saved source and runs app.js again.

Self-check

Complete these checks against the required result.

  1. Select the Project name label and confirm that focus moves to its connected input.
  2. Explain how the HTML source, DOM, document object, and selected element objects relate to each other.
  3. Confirm that each querySelector string matches one id in index.html and that the missing-elements guard is restored.
  4. Submit an empty value and confirm the visible error, input focus, and aria-invalid="true" state.
  5. Submit Community event page with the button and with Enter, then confirm the exact 60% output without a reload.
  6. Submit <strong>Test</strong> and confirm that textContent displays literal text instead of creating a strong element.
  7. Activate Reset with the keyboard and confirm that the input, status text, and invalid state return to their starting values.
  8. Confirm that both addEventListener calls pass a function reference without parentheses.
  9. Reload the final page and confirm that no red JavaScript error remains in the Console.

Reference patterns for choice and action controls

Section titled “Reference patterns for choice and action controls”

The required task-tracker result is complete before this section. Read the relevant pattern when a later project asks you to read one choice from a form or connect a separate button to one action.

A native select element lets the user choose one value from a list. Connect its visible label in the same way as a text field:

Example choice control
<label for="project-area">Project area</label>
<select id="project-area" name="projectArea">
<option value="">Choose an area</option>
<option value="content">Content</option>
<option value="development">Development</option>
<option value="quality">Quality</option>
</select>

The visible option text tells the user what they can choose. The value is the stable string that JavaScript reads. The first option uses an empty value so the program can distinguish the instruction from a completed choice.

Select the element and include it in the missing-elements guard:

Select the choice control
const projectAreaSelect = document.querySelector("#project-area");
if (!projectAreaSelect) {
throw new Error("The project-area choice is missing.");
}

Read projectAreaSelect.value inside the form’s submit handler. A form-control value is a string. Validate the empty string before using a selected value:

Read and validate the selected value
const projectAreaId = projectAreaSelect.value;
if (projectAreaId === "") {
statusOutput.textContent = "Choose a project area.";
projectAreaSelect.focus();
return;
}

The selected string can identify one record after you learn arrays and objects. Read Find one record by a stable identity before you connect a choice to a record.

Check: Use only the keyboard to open the choices, select each real option, and submit the form. Confirm that the empty option follows the invalid path and each real option produces its documented value.

Use a native button with type="button" for an action that is not form submission or form reset:

Example independent action
<button id="pause-project" type="button">Pause project</button>

Select the button, check the selection, define one named callback, and register the listener once:

Connect one independent button action
const pauseButton = document.querySelector("#pause-project");
if (!pauseButton) {
throw new Error("The pause control is missing.");
}
function handlePauseClick() {
statusOutput.textContent = "The project is paused.";
}
pauseButton.addEventListener("click", handlePauseClick);

The native button produces a click event for pointer activation and keyboard activation with Enter or Space. You do not need a separate keyboard event for the same button action. type="button" prevents this control from submitting a form when it appears inside one.

Register a listener once when the selected button remains in the DOM. Do not register the same listener inside a render function that runs after every state change. If rendering replaces a control, the new element needs its own listener and a deliberate focus result.

For several independent actions, make one action work and pass its mouse and keyboard checks before you connect the next action. Give each action one event handler or one documented routing rule.

Check: Activate the button once with a pointer, Enter, and Space. Each activation must produce exactly one result. Reload the page and confirm that one activation still produces one result.

Keep an independent button unavailable until ready

Section titled “Keep an independent button unavailable until ready”

If an action needs state that does not exist yet, save its button with disabled in the HTML:

Button unavailable on initial load
<button id="pause-project" type="button" disabled>Pause project</button>

disabled is a Boolean attribute. Its presence makes the native button unavailable to pointer and keyboard activation and removes it from the Tab order. The attribute does not need a value. Its absence makes the button available again.

After the program has selected and checked pauseButton, choose one of these operations when application state changes:

Change native button availability
pauseButton.setAttribute("disabled", ""); // Make the action unavailable.
pauseButton.removeAttribute("disabled"); // Make the action available.

These lines show two alternative results. Do not run both for the same state. In a render function, use a condition based on current state to choose one. Keep the button element and its click listener stable while its availability changes. If disabling a focused button is possible, move focus to another available control with a useful next action.

Check if you try this reference pattern: Start with the disabled pause-project button and its listener from the previous section. Reload: Tab skips the button and a pointer click has no effect. In the Console, run pauseButton.removeAttribute("disabled"). Activate it once with a pointer, Enter, and Space in separate tests; each activation produces one result. Run pauseButton.setAttribute("disabled", "") to restore the unavailable state.

When an action hides the focused control and shows a different section, move focus to the new section’s heading after rendering. Give the heading tabindex="-1" so JavaScript can focus it without adding it to the normal Tab order:

A heading that can receive programmatic focus
<h2 id="result-heading" tabindex="-1">Result</h2>
Focus after the section becomes visible
resultSection.hidden = false;
resultHeading.focus();

Keep the heading’s focus outline visible. The heading tells a keyboard or screen-reader user where the new content begins. Tab then continues to the next available control in source order. Do not call focus() while the heading is still hidden.

Symptom Likely cause Focused check
Cannot read properties of null A selector did not match an element, or the guard is missing Compare the selector with the HTML id and restore the guard
The page reloads on submit The handler does not call event.preventDefault() Confirm the call is the first statement in the submit handler
The handler runs during page load The listener calls the function instead of passing it Remove () from handleStatusSubmit in addEventListener
The output never changes The code updates the wrong element or property Confirm statusOutput selects #status-message and assigns textContent
Enter does nothing in the input The listener uses a button click instead of form submit Register submit on statusForm
Reset clears only the input The reset listener is missing or misspelled Confirm the reset event name and handleStatusReset reference
A choice always uses the instruction option The handler does not read the selected element’s current value Log the selected value inside the submit handler and test one real option
An independent button submits the form The button has the default submit type Add type="button" to the independent action
One activation runs the same action several times The listener is registered during repeated rendering Register the stable control’s listener once outside the render function

Use one symptom to select one check. Do not rewrite the complete program before the first error has a known cause.

The required lesson is complete when the form works with mouse and keyboard input, both validation paths work, reset restores the interface, and the Console remains free of JavaScript errors.

Continue to Manage and render interface state to store several project records in JavaScript and render the complete interface from one state value.

If you stop here, leave yourself this resume note: The form now connects DOM events to direct text updates. Next, open the interface-state lesson and separate stored data from the function that renders it.