Skip to content
academia.sh

Lesson 05 / 24

Focus and Keyboard Interaction

Determining the single element that receives input; focusability conditions, computing tab order, the bubbling behavior of focus events, the information key events carry, and a navigation layout leaving a single stop within a group.

Contents

The previous lesson said keyboard behaviors are among the behaviors that should not be cancelled. The reason is this: not all interaction with the page is built with a pointing device. The user moves between elements with the tab key, presses a button with space, moves between options with the arrow keys; a screen reader follows the same path.

At the center of this movement is a single concept: at any moment, at most one element in the document receives input. That element is called the active element, and it is the target of events coming from the keyboard. This lesson establishes how the active element is determined and what information keyboard events carry.

Focusability

Focus is the authority to receive input. Not every element can take focus. Whether an element can take focus depends on three conditions.

The first is natural focusability. Links, buttons, form fields, and controls take focus; this requires no additional declaration. A section carrying text or a list only gets focus when it is explicitly requested.

The second is visibility. An element hidden from the document cannot take focus; code that tries to focus a hidden element silently fails. This is the source of focus getting lost the moment an open panel closes.

The third is being enabled. A disabled control does not take focus; a keyboard user never sees it at all. A design consequence follows from this: disabling a button can also make its associated error message unreachable. The reason is not being able to stand on the element.

An element that takes focus is required to have a visible marker. WCAG criterion 2.4.7 requires a focused element to have a visible indicator. Code that removes the focus indicator from the style produces a situation similar to working with a cursor no one can see.

Tab Order

The tab key moves focus according to document order. This order can be changed with the tabindex attribute, and the sign of the value produces three different behaviors.

// tab.mjs — deriving tab order from document order and tabindex value
const elements = [
  { name: "a.skip-link",       tabindex: null, naturallyFocusable: true,  hidden: false, disabled: false },
  { name: "input#filter",      tabindex: null, naturallyFocusable: true,  hidden: false, disabled: false },
  { name: "button#applyFilter",tabindex: null, naturallyFocusable: true,  hidden: false, disabled: false },
  { name: "button#export",     tabindex: 1,    naturallyFocusable: true,  hidden: false, disabled: false },
  { name: "div#summary",       tabindex: -1,   naturallyFocusable: false, hidden: false, disabled: false },
  { name: "span.badge",        tabindex: null, naturallyFocusable: false, hidden: false, disabled: false },
  { name: "button.delete[T-01]", tabindex: null, naturallyFocusable: true, hidden: false, disabled: false },
  { name: "button.delete[T-02]", tabindex: null, naturallyFocusable: true, hidden: false, disabled: true  },
  { name: "button#submit",     tabindex: null, naturallyFocusable: true,  hidden: true,  disabled: false },
  { name: "div.row-container", tabindex: 0,    naturallyFocusable: false, hidden: false, disabled: false },
];

const focusable = (o) =>
  !o.hidden && !o.disabled && (o.naturallyFocusable || o.tabindex !== null);
const inTabOrder = (o) => focusable(o) && (o.tabindex === null ? true : o.tabindex >= 0);

const tabOrder = (list) => {
  const documentOrder = list.map((o, i) => ({ ...o, i })).filter(inTabOrder);
  const positive = documentOrder.filter((o) => (o.tabindex ?? 0) > 0)
    .sort((a, b) => a.tabindex - b.tabindex || a.i - b.i);
  const zero = documentOrder.filter((o) => (o.tabindex ?? 0) === 0);
  return [...positive, ...zero];
};

console.log("tab order:");
for (const [n, o] of tabOrder(elements).entries())
  console.log(`  ${n + 1}. ${o.name.padEnd(20)} tabindex=${o.tabindex ?? "none"}`);

console.log("not in order:");
for (const o of elements.filter((o) => !inTabOrder(o))) {
  const reason = o.hidden ? "hidden" : o.disabled ? "disabled"
    : o.tabindex === -1 ? "tabindex=-1 (only focusable via script)" : "not focusable";
  console.log(`  ${o.name.padEnd(20)} ${reason}`);
}
tab order:
  1. button#export        tabindex=1
  2. a.skip-link          tabindex=none
  3. input#filter         tabindex=none
  4. button#applyFilter   tabindex=none
  5. button.delete[T-01]  tabindex=none
  6. div.row-container    tabindex=0
not in order:
  div#summary          tabindex=-1 (only focusable via script)
  span.badge           not focusable
  button.delete[T-02]  disabled
  button#submit        hidden

Three rules are visible in the output.

A value of zero places the element in the order without changing its position. A container that does not naturally take focus becomes a tab stop this way and keeps its place in document order.

A negative value takes the element out of the order but leaves it focusable. The tab key does not pass over it; a program can still move focus there. This is what makes it possible to give focus to an error summary or an open panel.

Positive values break the order. The output’s first line shows this: the export button is fourth in the document, first in tab order. The page’s skip link drops to second place. Positive values come before every other stop in the document; every component added to the page later has to rethink this ordering. The meaningful order WCAG criterion 2.4.3 asks for is achieved by getting the markup’s own order right — not with positive values.

Focus Events and Programmatic Focus

Focus change produces four events, defined as two pairs. As noted in the Event Model lesson, focus-gain and focus-loss events do not bubble; a bubbling pair carrying the same information is defined separately. Code that wants to watch focus change across every field of a form with a single listener has to either use the bubbling pair or register in capture mode; the rule set in the Event Delegation lesson applies here.

Focus events report both the element giving up and the element receiving focus. When a field is left, where focus went is read from this information, and two cases are distinguished: focus has moved to another field within the form, or the form has been left entirely. Running field validation only in the second case spares the user from being met with an error message at every field transition.

Two responsibilities arise when a program moves focus. First, the element focus moves to needs to be visible; the browser scrolls the focused element into the visible area, and this scroll can be perceived as a page jump. Second, focus must not be lost: when a focused element is removed from the tree, focus drops to the document’s root and the tab key starts its cycle over from the beginning. Code that deletes a row has to decide where focus should go before deleting — to the neighboring row, or to the list itself.

A third case requires containing focus. An open dialog blocks tabbing through to the page behind it; focus circles inside the dialog. WCAG criterion 2.1.2 places a bound while this arrangement is being built: a user who entered by keyboard must be able to leave by keyboard. A focus trap with no way out is an accessibility bug.

Key Events

A key press produces two events: when the key is pressed and when it is released. A key held down produces new press events at the operating system’s repeat interval; the repeat flag in the event object tells these events apart from the first press. A keyboard shortcut that increments a counter reads this flag depending on whether it wants to count the repeats.

The event object reports the key with two separate fields, and the distinction between them is critical.

The meaning field gives the value the key produces: a, Enter, ArrowDown, Escape. It changes according to the user’s keyboard layout and held modifiers; uppercase and lowercase are different values.

The position field gives the key’s physical place on the keyboard. It stays the same even if the layout changes.

The selection rule is: if a shortcut relies on a letter’s meaning, the meaning field is read; if it relies on the key’s place under the hand, the position field is read. Code relying on the meaning field lands on different keys on different layouts; code relying on the position field may not match the letter the user sees.

Deriving text input from key events is a separate mistake. In the composition mechanism used for Chinese, Japanese, and Korean input, the user presses more than one key to produce a single character; the field’s content does not match the keys during the intermediate stage. Likewise, pasting, voice input, and autofill produce no key event. The only reliable way to learn a field’s content is listening to input events; this is the subject of the Form Events lesson.

Group-Internal Navigation

If every row of the measurement table has a button, the tab key walks through every row one by one; in a hundred-row table, leaving the table takes a hundred key presses. The established solution is making the group a single tab stop and navigating within the group with the arrow keys. This layout is called roving tabindex: at any moment, a single element in the group has a tabindex value of zero, the rest are negative one; the arrow key carries both the focus and the zero value.

// roving.mjs — focus management that leaves a single tab stop within a group
const rows = ["T-01", "T-02", "T-03", "T-04"];
let active = 0;          // the group's single tab stop
let focus = "outside group"; // the element currently holding focus

const stops = () => rows.map((name, i) => `${name}:${i === active ? 0 : -1}`).join(" ");

function key(name) {
  const result = { key: name, prevented: false };
  if (focus === "outside group") {
    if (name === "Tab") focus = rows[active];   // entering the group: the browser's own behavior
    return result;
  }
  switch (name) {
    case "ArrowDown": active = (active + 1) % rows.length; break;
    case "ArrowUp":   active = (active - 1 + rows.length) % rows.length; break;
    case "Home":      active = 0; break;
    case "End":       active = rows.length - 1; break;
    case "Tab":       focus = "outside group"; return result;   // default behavior is kept
    default:          return result;                             // no work done, no cancellation requested
  }
  focus = rows[active];
  result.prevented = true;   // the arrow keys' scrolling behavior is cancelled
  return result;
}

console.log("start:", focus.padEnd(14), "|", stops());
for (const name of ["Tab", "ArrowDown", "ArrowDown", "End", "Home", "ArrowUp", "x", "Tab"]) {
  const result = key(name);
  console.log(
    result.key.padEnd(9),
    "focus:", focus.padEnd(14),
    "| prevented:", String(result.prevented).padEnd(5),
    "|", stops(),
  );
}
start: outside group  | T-01:0 T-02:-1 T-03:-1 T-04:-1
Tab       focus: T-01           | prevented: false | T-01:0 T-02:-1 T-03:-1 T-04:-1
ArrowDown focus: T-02           | prevented: true  | T-01:-1 T-02:0 T-03:-1 T-04:-1
ArrowDown focus: T-03           | prevented: true  | T-01:-1 T-02:-1 T-03:0 T-04:-1
End       focus: T-04           | prevented: true  | T-01:-1 T-02:-1 T-03:-1 T-04:0
Home      focus: T-01           | prevented: true  | T-01:0 T-02:-1 T-03:-1 T-04:-1
ArrowUp   focus: T-04           | prevented: true  | T-01:-1 T-02:-1 T-03:-1 T-04:0
x         focus: T-04           | prevented: false | T-01:-1 T-02:-1 T-03:-1 T-04:0
Tab       focus: outside group  | prevented: false | T-01:-1 T-02:-1 T-03:-1 T-04:0

Four behaviors read from the output. Entering the group is the browser’s own tab behavior, and it is not cancelled. The arrow keys carry focus, and cancellation is requested only for these keys; the arrow keys’ page-scrolling behavior has been replaced with group-internal navigation. The x line before the last shows that cancellation is not requested for keys that are not handled — typing, shortcuts, and browser behaviors are unaffected. The last tab key leaves the group, and the group’s stop stays on the most-recently-used row; the user picks up where they left off when they return.

Summary

  • At any moment, at most one element in the document receives input; focusability depends on natural focus-taking, visibility, and being enabled.
  • A tabindex of zero places an element in order at its document position, a negative value takes it out of order but leaves it open to programmatic focus, positive values cut ahead of the entire order and break the layout.
  • Focus events’ non-bubbling pair is only seen at the target; delegation uses the bubbling pair or capture mode. Events report both the element giving up and the one receiving focus.
  • If a focused element is removed from the tree, focus starts over from the root; delete and close operations need to decide ahead of time where focus should go.
  • Key events report meaning and physical position in separate fields; text input cannot be derived from key events.
  • In the roving tabindex layout, there is a single stop in the group, the arrow keys carry both focus and the stop together, and the default behavior is kept for unhandled keys.

Next Step

This lesson said the only reliable way to learn text input is not key events, and left the real way for later. The form on the page does not only collect text: a measurement value is a number, a date comes from a calendar control, a station code is chosen from a list. Every control reports its value at different moments — some at every keystroke, some when the field is left. On top of this there is submission: the browser collects the fields, tests the constraints, and builds the request. The next lesson covers this flow, which event reports which moment, and where validation belongs.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close