Usability & Accessibility

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.

Figure 1. Keyboard recovery after an invalid date-range submission.

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

  1. Why is a clickable div not automatically a button?
  2. What is the purpose of tabindex="-1" on an error summary that receives programmatic focus?
  3. A Svelte handler makes a conditional error summary visible and then focuses it. Why should it await tick() between those actions?
  4. Why should asynchronous status changes usually not steal focus?
  5. What should happen to submitted input after a validation failure?