Skip to content
academia.sh

Lesson 10 / 27

Keyboard Access

The requirements for keyboard operability, comparing tab order with visual order, the boundary where focus containment turns into a trap, and the condition on single-character shortcuts.

Contents

The previous lesson’s rule audit left one row open: an element with the button role declared but not taking focus. The declaration was correct; the behavior was absent. That distinction is the subject of two different criteria, and the second is this lesson’s starting point.

Keyboard access is not only the problem of a minority who use a keyboard. Screen readers navigate a page through the keyboard interface; tools driven by voice commands and switch access use the same interface. A component that does not work with the keyboard works in none of these tools. A practical conclusion follows: keyboard testing is the cheapest, broadest test that can be run before trying any assistive technology.

What the Criterion Requires

Criterion 2.1.1 requires that every function of the content be operable through a keyboard interface. Two details of the wording matter. First, it says “keyboard interface”: not a physical keyboard, but every tool that uses that interface. Second, the criterion also requires that individual keystrokes not depend on specific timing; a key sequence that must be pressed at a particular speed does not meet the criterion.

The one exception is functions where the path of the input itself carries information — freehand drawing, for instance. Panning a map by dragging does not fall under this exception, because the same function can be provided with pan buttons too.

Criterion 2.1.2 is the other end of the chain: if a component can be entered by keyboard, it must be possible to exit it through the same interface. The two criteria together form a whole; the first secures entry, the second secures exit.

Tab Order Must Match Visual Order

Criterion 2.4.3 requires focusable elements to be navigated in an order that preserves meaning and operability. Tab order comes from document order; visual order comes from the presentation layer. When the two diverge, a keyboard user jumps back and forth across the screen.

On the measurement list page this divergence happens for two reasons: the filter panel was added to the markup later, after the table, and the presentation layer placed it above the table; and a positive tabindex value was written on the export button with the intent of making it “come first.”

// tab-order.mjs — comparing tab order with visual order (2.4.3)

// visual: [row, column] — where the presentation layer places it
const PAGE = [
  { name: "a.skip-link", doc: 1, tabindex: null, visual: [0, 1] },
  { name: "button#sort-date", doc: 2, tabindex: null, visual: [3, 1] },
  { name: "button#sort-temperature", doc: 3, tabindex: null, visual: [3, 2] },
  { name: "button.delete[T-01]", doc: 4, tabindex: null, visual: [4, 1] },
  { name: "input#filter-code", doc: 5, tabindex: null, visual: [1, 1] },
  { name: "input#filter-alert", doc: 6, tabindex: null, visual: [1, 2] },
  { name: "button#filter", doc: 7, tabindex: null, visual: [1, 3] },
  { name: "button#export", doc: 8, tabindex: 1, visual: [2, 1] },
];

// Rule established in M14/K04: ascending tabindex first, then document order
function tabOrder(items) {
  const positive = items.filter((o) => (o.tabindex ?? 0) > 0)
    .sort((a, b) => a.tabindex - b.tabindex || a.doc - b.doc);
  const zero = items.filter((o) => (o.tabindex ?? 0) === 0 || o.tabindex === null)
    .sort((a, b) => a.doc - b.doc);
  return [...positive, ...zero];
}

const visualOrder = (items) =>
  [...items].sort((a, b) => a.visual[0] - b.visual[0] || a.visual[1] - b.visual[1]);

function invertedPairs(items) {
  const tab = tabOrder(items).map((o) => o.name);
  const visual = visualOrder(items).map((o) => o.name);
  const pairs = [];
  for (let i = 0; i < visual.length; i++) {
    for (let j = i + 1; j < visual.length; j++) {
      if (tab.indexOf(visual[i]) > tab.indexOf(visual[j])) pairs.push([visual[i], visual[j]]);
    }
  }
  return pairs;
}

function report(title, items) {
  console.log(title);
  const tab = tabOrder(items);
  const visual = visualOrder(items);
  console.log("  no  tab order                visual order");
  for (let i = 0; i < tab.length; i++) {
    console.log(`  ${String(i + 1).padStart(2)}  ${tab[i].name.padEnd(24)}  ${visual[i].name}`);
  }
  const pairs = invertedPairs(items);
  console.log(`  inverted pair count: ${pairs.length}`);
  for (const [a, b] of pairs.slice(0, 4)) {
    console.log(`    "${a}" comes first visually, after "${b}" in tab order`);
  }
}

report("filter panel comes after the table in source, tabindex=1 present:", PAGE);

// Fix: filter panel is moved earlier in the source, positive tabindex is removed
const FIXED = PAGE.map((o) => {
  const updated = { ...o, tabindex: null };
  if (o.name.startsWith("input#filter") || o.name === "button#filter") updated.doc = o.doc - 3;
  if (o.name === "button#export") updated.doc = 5;
  if (o.name.startsWith("button#sort") || o.name.startsWith("button.delete")) updated.doc = o.doc + 4;
  return updated;
});

console.log("");
report("filter panel moved earlier in source, tabindex removed:", FIXED);
filter panel comes after the table in source, tabindex=1 present:
  no  tab order                visual order
   1  button#export             a.skip-link
   2  a.skip-link               input#filter-code
   3  button#sort-date          input#filter-alert
   4  button#sort-temperature   button#filter
   5  button.delete[T-01]       button#export
   6  input#filter-code         button#sort-date
   7  input#filter-alert        button#sort-temperature
   8  button#filter             button.delete[T-01]
  inverted pair count: 13
    "a.skip-link" comes first visually, after "button#export" in tab order
    "input#filter-code" comes first visually, after "button#export" in tab order
    "input#filter-code" comes first visually, after "button#sort-date" in tab order
    "input#filter-code" comes first visually, after "button#sort-temperature" in tab order

filter panel moved earlier in source, tabindex removed:
  no  tab order                visual order
   1  a.skip-link               a.skip-link
   2  input#filter-code         input#filter-code
   3  input#filter-alert        input#filter-alert
   4  button#filter             button#filter
   5  button#export             button#export
   6  button#sort-date          button#sort-date
   7  button#sort-temperature   button#sort-temperature
   8  button.delete[T-01]       button.delete[T-01]
  inverted pair count: 0

Thirteen inverted pairs occur on a page of eight elements. The number is the product of two mistakes: the positive tabindex value alone produces seven pairs, and the wrong placement in source order produces the rest. The second report shows the direction of the fix — the order is corrected in the source, not in the presentation layer. Because the presentation layer determines the visual order, there is no consistent way other than making the source match it.

The inverted-pair count can be used as an audit measure: any value greater than zero means at least one user jumps backward on screen. If the delete button on every row of the measurement table lengthens the tab order, the roving tabindex pattern established in the Browser and the Web Platform course reduces the group to a single stop; this shortens the sequence without disturbing the order.

Focus Containment and the Trap

When the measurement entry form opens as a dialog, the page behind it must not remain accessible: the tab key must cycle within the dialog. This arrangement is called focus containment, and when set up incorrectly it turns into a trap that fails criterion 2.1.2. The script below models three setups as a state machine and audits each one by exploring which states are reachable.

// focus-trap.mjs — state machine for focus containment and keyboard-trap audit (2.1.2)

const BACKGROUND = ["input#filter-code", "button#filter", "button#new-measurement", "button.delete[T-01]"];
const DIALOG = ["input#date", "input#value", "button#save", "button#close"];
const KEYS = ["Tab", "Shift+Tab", "Escape"];

// type: "unconstrained" | "no-exit" | "correct"
function machine(type) {
  const hasExit = type !== "no-exit";
  return (d, key) => {
    if (!d.open) {
      const i = BACKGROUND.indexOf(d.focus);
      if (key === "Tab") return { open: false, focus: BACKGROUND[(i + 1) % BACKGROUND.length] };
      if (key === "Shift+Tab") return { open: false, focus: BACKGROUND[(i + BACKGROUND.length - 1) % BACKGROUND.length] };
      return d;
    }
    const ring = type === "unconstrained" ? [...DIALOG, ...BACKGROUND] : DIALOG;
    const i = ring.indexOf(d.focus);
    if (key === "Tab") return { open: true, focus: ring[(i + 1) % ring.length] };
    if (key === "Shift+Tab") return { open: true, focus: ring[(i + ring.length - 1) % ring.length] };
    if (key === "Escape" && hasExit) return { open: false, focus: "button#new-measurement" };
    return d;
  };
}

const key = (d) => (d.open ? "open" : "closed") + "|" + d.focus;

function check(type) {
  const next = machine(type);
  const start = { open: true, focus: DIALOG[0] };
  const seen = new Map([[key(start), start]]);
  const queue = [start];
  while (queue.length > 0) {
    const d = queue.shift();
    for (const k of KEYS) {
      const y = next(d, k);
      if (seen.has(key(y))) continue;
      seen.set(key(y), y);
      queue.push(y);
    }
  }
  const states = [...seen.values()];
  return {
    reached: states.length,
    leak: states.some((d) => d.open && BACKGROUND.includes(d.focus)),
    exit: states.some((d) => !d.open),
  };
}

console.log("setup          states reached  leak to background  keyboard exit  2.1.2");
for (const type of ["unconstrained", "no-exit", "correct"]) {
  const s = check(type);
  console.log(
    type.padEnd(14) +
      String(s.reached).padStart(14) +
      (s.leak ? "  yes" : "  no").padEnd(18) +
      (s.exit ? " yes" : " no").padEnd(17) +
      (s.exit ? "passed" : "FAILED"),
  );
}

// Key sequence in the correct setup
console.log("\nfocus trace in the correct setup:");
let d = { open: true, focus: DIALOG[0] };
const next = machine("correct");
console.log("  " + "start".padEnd(18) + " " + "dialog open".padEnd(14) + " focus: " + d.focus);
for (const k of ["Tab", "Tab", "Tab", "Tab", "Shift+Tab", "Escape", "Tab"]) {
  d = next(d, k);
  console.log(`  ${k.padEnd(18)} ${(d.open ? "dialog open" : "dialog closed").padEnd(14)} focus: ${d.focus}`);
}
setup          states reached  leak to background  keyboard exit  2.1.2
unconstrained             12  yes              yes             passed
no-exit                    4  no               no              FAILED
correct                    8  no               yes             passed

focus trace in the correct setup:
  start              dialog open    focus: input#date
  Tab                dialog open    focus: input#value
  Tab                dialog open    focus: button#save
  Tab                dialog open    focus: button#close
  Tab                dialog open    focus: input#date
  Shift+Tab          dialog open    focus: button#close
  Escape             dialog closed  focus: button#new-measurement
  Tab                dialog closed  focus: button.delete[T-01]

The three rows separate three distinct defects. The unconstrained setup passes the criterion — an exit exists — but while the dialog is open, focus leaks to the four stops in the background. The user focuses on elements they cannot see, covered by the dialog; this is not a conformance failure, but a defect that breaks usability. The no-exit setup reaches only four states: there is no way out of the dialog at all. This is a keyboard trap by definition, and it fails criterion 2.1.2. The correct setup reaches all eight states, with no leak and an exit.

The last two lines of the focus trace show a separate rule: when the dialog closes, focus returns to the button that opened it. If it does not, focus falls to the root of the document and the user starts navigating from the very beginning. This behavior was established in the Browser and the Web Platform course as the responsibility that arises when a program moves focus; here it becomes a requirement of the criterion.

Containment itself is not set up in only one way. Capturing the tab key and cycling focus around a ring is one way; hiding the content outside the dialog from the tree and taking it out of interaction is another. The second is preferred, because it establishes containment not only against the tab key but also against the screen reader’s own navigation commands — in a dialog where only the tab key is captured, a user can jump to the background from the landmark list.

Focus Visibility

In an interface operated by keyboard, the location of focus must be visible at every moment; criterion 2.4.7 requires this. Removing the focus indicator from the presentation layer fails the criterion in a single line. The indicator can be changed: its thickness, color, and shape are design decisions. It cannot be removed.

There are two additional conditions. The indicator must carry enough contrast to be distinguished from adjacent colors — this is the subject of the non-text contrast criterion and will be computed in the contrast lesson. A focused element must also not remain obscured: if a bar stuck to the bottom of the page or a header pinned to the top completely covers the focused element, the indicator exists but is not visible.

Shortcuts

Writing a single-letter shortcut to jump to the filter field on the measurement list seems reasonable. Criterion 2.1.4 imposes at least one of three conditions on shortcuts like this: the shortcut must be possible to turn off, it must be possible to remap so the user uses it together with a modifier key, or it must be active only while the relevant component has focus.

The rationale shows up in two usage patterns. Surrounding speech can be transcribed for a user dictating text, and single letters get interpreted as shortcuts; a user who has difficulty pressing a single key on the keyboard triggers a shortcut unintentionally. On the measurement entry form, this means lines get deleted while typing in the description field.

The workable decision is this: single-character shortcuts are bound to the focused component, not to the document as a whole. Row navigation with arrow keys in the measurement table works while the table has focus; when the table does not have focus, the same keys scroll the page. Shortcuts bound to the document as a whole are written with a modifier key, and the user is left a way to turn them off.

Summary

  • 2.1.1 requires every function to be operable from a keyboard interface without depending on keystroke timing; 2.1.2 requires every place entered to be exitable through the same interface.
  • Tab order comes from the source, visual order from the presentation layer; a divergence produces inverted pairs, and the fix is made in the source, not the presentation layer.
  • Positive tabindex values push ahead of the whole order by themselves, raising the inverted-pair count on their own.
  • Focus containment gives different results across three setups: an unconstrained setup lets focus leak to the background, a no-exit setup is a keyboard trap, and a correct setup returns focus to the control that opened it on close.
  • The focus indicator can be changed in form but not removed; it must also carry enough contrast and must not be obscured by another element.
  • Single-character shortcuts must be possible to turn off, to remap, or active only while the component has focus.

Next Step

This lesson computed where focus goes but did not ask what the user learns once focus arrives there. How is it announced that a new context has opened when the dialog opens? When the filter is applied, how does the number of rows remaining in the table reach a user who cannot see the screen? The next lesson takes up the announcement mechanisms: the polite/assertive distinction in live regions, the queuing of announcements, and the navigation planes that landmarks and headings form.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close