Keyboard, Focus & Forms
Learning Objectives
- You can implement keyboard-operable forms with clear focus and error relationships.
- You can decide when to preserve focus and when to move it after an interaction.
- You can test a complete task without relying on pointer interaction.
A useful first manual test is to put the mouse aside. Can you reach the main content, identify the focused control, complete a form, understand an error, and continue? This exposes problems that are easy to miss while clicking directly on the desired element.
The date-range form below develops error relationships and focus one step at a time. The exercise applies them to a registration form with a different local rule.
The example is a complete Svelte component with local validation; follow the code to trace its behavior.
Native controls, focus styles, and tab order
Links, buttons, inputs, and selects already participate in keyboard interaction. Do not replace them with generic elements and then reimplement only their click behavior.
A visible focus style helps a keyboard user understand where an action will occur:
:focus-visible { outline: 3px solid #1247ad; outline-offset: 3px;}Choose a color that works against the actual adjacent surfaces. Do not remove an outline without providing an adequate replacement.
Positive tabindex values create a separate focus order that becomes difficult to maintain. Prefer the document’s normal order. Use tabindex="-1" for an element that needs programmatic focus but should not enter the normal Tab order.
Error messages and their associated controls
In a search form, an invalid date range can keep both proposed dates visible and
associate an explanation with the relevant control. This markup excerpt assumes
dateError is component state:
<label for="end-date">End date</label><input id="end-date" name="endDate" type="date" aria-invalid={dateError ? "true" : undefined} aria-describedby={dateError ? "date-error" : undefined}/>{#if dateError} <p id="date-error">{dateError}</p>{/if}aria-describedby associates the explanation; aria-invalid exposes the failed
state. Keep the values while the user corrects the range. A form with several
errors can additionally provide a summary with links to the affected controls.
If an explicit submission reveals a summary and the interface moves focus there, wait for the DOM update before focusing it:
import { tick } from "svelte";
// Inside the submit handler, after setting the error state:await tick();summary?.focus();Here summary is a reference bound to the rendered summary element. Give that
element a name and tabindex="-1" so it can receive programmatic focus. The
exercise combines these mechanisms into a complete recovery path; moving focus
on every input change would interrupt typing.
Keyboard recovery after an invalid date range
Figure 1 follows the focus decision after submission. The invalid path makes the summary discoverable and gives the user a route back to the field; the valid path leaves focus on the control that initiated the check.
In this complete DateRangeForm.svelte, both dates are required. The browser
exposes a date’s numeric time value through valueAsNumber, or NaN when no valid
date is present. Comparing those values rejects an end before the start without
relying on string ordering. The form checks a search proposal locally:
<script lang="ts"> import { tick } from "svelte"; let error = $state(""); let ready = $state(false); let summary: HTMLElement | undefined = $state(); let errorTarget = $state("range-start");
const submit = async (event: SubmitEvent) => { event.preventDefault(); const form = event.currentTarget as HTMLFormElement; const startDate = (form.elements.namedItem("startDate") as HTMLInputElement).valueAsNumber; const endDate = (form.elements.namedItem("endDate") as HTMLInputElement).valueAsNumber; ready = false; errorTarget = Number.isNaN(startDate) ? "range-start" : "range-end"; error = Number.isNaN(startDate) || Number.isNaN(endDate) ? "Choose both dates." : endDate < startDate ? "Choose an end date on or after the start." : ""; if (error) { await tick(); summary?.focus(); return; } ready = true; };</script>
<form onsubmit={submit} novalidate> {#if error} <section bind:this={summary} tabindex="-1" aria-labelledby="range-errors"> <h2 id="range-errors">Check the date range</h2> <a href={`#${errorTarget}`}>{error}</a> </section> {/if} <label for="range-start">Start date</label> <input id="range-start" name="startDate" type="date" required aria-invalid={Boolean(error) && errorTarget === "range-start"} aria-describedby={error && errorTarget === "range-start" ? "range-error" : undefined} /> <label for="range-end">End date</label> <input id="range-end" name="endDate" type="date" required aria-invalid={Boolean(error) && errorTarget === "range-end"} aria-describedby={error && errorTarget === "range-end" ? "range-error" : undefined} /> {#if error} <p id="range-error">{error}</p> {/if} <button type="submit">Check dates</button> <p role="status">{ready ? "The date range is ready to search." : ""}</p></form>
<style> :focus-visible { outline: 3px solid #1247ad; outline-offset: 3px; }</style>Trace a submission with start 2030-06-12 and end 2030-06-10. The summary
receives focus, both values remain, and Tab reaches the recovery link. Activating
that link returns focus to End date. After correcting the end to 2030-06-13
and activating the submit button again, the summary disappears, the ready
message appears, and focus stays on the button. Chapter 6.8 shows a browser
regression for this sequence.
aria-labelledby="range-errors" gives the summary the name of its heading.
This differs from aria-describedby, which adds an explanation to an already
named input. bind:this stores the summary element so the handler can focus it
after tick() has allowed Svelte to render it.
The controls keep the proposed dates; the handler reads their numeric values
when submitted, before awaiting the DOM update. required exposes that each
control needs a value; novalidate lets this handler present its own feedback
instead of the browser’s submission-time validation bubble.
No focus change happens while typing. The summary receives focus only after an explicit invalid submission. The example reports a local check, not a stored reservation; the server would independently validate an actual search or booking.
The registration exercise already supplies the error relationships and local rule. Repair its value preservation, summary focus, and recovery link, then repeat this invalid -> correction -> accepted sequence with its Email control.
The WAI form-notification guidance explains how feedback should identify errors and support correction.
Repair keyboard error recovery
0 / 25 points
Files in the editor
The editor below contains all 2 files for this exercise, although only one opens initially. Use the Files panel to expand the folders and select a filename. If the panel is collapsed, choose Show file tree in the editor toolbar.
- Edit
lib/Registration.svelte. - Keep unchanged
routes/workshop-registration/+page.svelte.
The page renders the form at /workshop-registration.
The grader supplies the Svelte 5/SvelteKit application and browser checks.
Editor paths are relative to the local client’s src/ directory; keep the
shorter paths shown here in the editor.
Task
Repair the supplied lib/Registration.svelte at /workshop-registration. It
already checks whether the email contains @; keep that deliberately local rule.
It currently clears invalid input, does not focus the new summary, and displays
summary text without a recovery link. Fix those three behaviors.
On invalid submission preserve the value, wait for the DOM update, and focus the
region named Check the form with tabindex -1. Its error link must use the text Enter an email address containing @. and
target the Email input (id="registration-email"). Keep the inline error associated using aria-describedby and mark the input
invalid. After keyboard correction, remove the summary and show
The local form is ready to submit.; preserve the new value and focus on the submit
button. Keep a visible keyboard focus indicator, such as an outline or an equivalent visible focus style. This is local validation, not a saved account.
The browser checks independently assess the three repairs. Supplied keyboard order, visible focus, error associations and local success behavior remain unscored safeguards. If this supplied baseline is broken, repair checks are blocked until it is restored; read the baseline feedback first. Once the baseline passes, each repair is assessed independently.
Suggested progression
Repair and observe one behavior at a time: preserve invalid text, focus the rendered summary, and activate its recovery link. Then complete a corrected submission without losing the initiating focus.
Check and submit
Choose Submit solution below the editor to run the grading checks and read the feedback. All editor files, including unchanged files and files you have not opened, are submitted together. Keep file paths and exported names as specified.
The browser checks award 10 points for preserving invalid input, 10 for focusing the rendered summary, and 5 for the recovery link and corrected retry. The link check starts from the summary so an automatic-focus defect does not erase credit for a working link. Feedback identifies the repair to revisit.
Focus after background updates and removal
If a background request updates a status message, moving focus to it can interrupt someone typing. Keep focus where the person is working and expose the update appropriately. The next chapter introduces live regions for this purpose.
Filtering while typing
0 / 5 points
Each keystroke updates a filtered list. Where should focus normally remain?
After a focused item is removed, focus may fall back to the document body. Choose a predictable successor, such as the next item or the control that opens the collection, to support continued keyboard navigation. Explain the choice in terms of the task.
Removing the focused item
0 / 5 points
A saved search is deleted while its Delete button has focus. What is a reasonable focus policy?
Check Your Understanding
- Why is a clickable
divnot automatically a button? - What is the purpose of
tabindex="-1"on an error summary that receives programmatic focus? - A Svelte handler makes a conditional error summary visible and then focuses it. Why should it await
tick()between those actions? - Why should asynchronous status changes usually not steal focus?
- What should happen to submitted input after a validation failure?