Skip to content
academia.sh

Lesson 04 / 25

Dropdown Lists

Comparing the native selection element with the hand-built combobox; separating the active-option declaration from real focus, testing the keyboard contract, and auditing the invariants of reachable states.

Contents

A radio group works when the options fit on screen. The “Subject” field in the catalog’s filter panel carries more than two hundred fifty values; not all of them can be shown on screen, and even if they were, they could not be navigated. This requires handing the selection work off to a closable list.

The pattern has two implementations, and the difference between them cannot be summarized in a single line: one arrives with a single element, the other requires hand-writing ten separate declarations. This lesson specifies both and counts the cost of the second.

The Problem the Pattern Solves

The dropdown list selects a single value from a set of options that is enumerable but crowded. It has two boundaries. At the lower boundary, the list is unnecessary if there are fewer than five options: four borrow-duration options stand on screen as a radio group, and the user sees all of them at a glance. At the upper boundary, if the options come from a set the user does not know in advance — like the author names across the entire catalog — search is required, not a list.

The list is also not the right component wherever multiple values need to be selected. A multi-select dropdown pattern does not show the user all of their selections at once; a checkbox group or a pattern that displays selections separately is used instead.

The Native Element First

The counterpart is the select element. What it brings: the combobox role, a name computed from the label element, an open/closed state declaration, the selected option declared as the value, grouping with optgroup, the entire keyboard contract, and type-ahead search.

It has a cost, and it has to be written into the specification: the appearance of the opened option list cannot be controlled from the presentation layer. The list is constructed outside the page’s rendering plane; the typeface, color, row height, and any extra information placed inside an option are not open to design decisions.

In the catalog’s “Subject” field, this cost is affordable. The results-ordering criterion, borrow-duration, and page-size selectors are all met with the native element. A hand-built component is chosen only if one of three requirements exists: showing extra information in the option row (record count, subtitle), filtering by typing, or fetching options from the server by search.

The Cost of the Custom Component

The declarations that have to be reproduced in a hand-built component are: role (role="combobox"), open/closed state (aria-expanded), the id of the controlled list (aria-controls), the list role (role="listbox"), option roles (role="option"), the active option’s declaration (aria-activedescendant), the selected option’s declaration (aria-selected), the autocomplete format (aria-autocomplete), the entire keyboard contract, and announcing the filter result.

<label for="subject">Subject</label>
<input id="subject" role="combobox" aria-expanded="true" aria-controls="subject-list"
       aria-activedescendant="subject-2" aria-autocomplete="list" autocomplete="off">
<ul id="subject-list" role="listbox" aria-label="Subject options">
  <li id="subject-1" role="option" aria-selected="false">Architecture</li>
  <li id="subject-2" role="option" aria-selected="true">Microbiology</li>
</ul>
<p role="status">2 subjects listed</p>

The distinctive part of this markup is the aria-activedescendant attribute. Keyboard focus never leaves the field; what navigates the list is not focus, it is a declaration. When the user presses an arrow key, the cursor in the field stays put, and in the tree, a different option is announced as active. This distinction preserves the field’s text-editing behavior; if focus actually moved to the list, continuing to type would be impossible.

The distinction’s consequence for design is that two separate indicators are required: a focus ring in the field, an active-option highlight in the list. If either is missing, the user cannot see either where they are or what will be selected.

Keyboard Contract

Key Behavior
Tab The field is a single tab stop; focus stays in the field even while the list is open. The list closes and focus moves out.
Arrow Down Opens the list and activates the first option if closed; moves to the next one if open.
Arrow Up Opens the list and activates the last option if closed; moves to the previous one if open.
Enter Makes the active option the value and closes the list.
Escape Closes the list and keeps the value if open; clears the field if closed.
Typing keys Opens the list and filters the options.
Home / End Moves the cursor within the text; does not belong to the list.

The arrow keys not wrapping at the ends is the point where this diverges from the radio group’s contract. Wrapping in a radio group does not lose the user, because the entire group is on screen. Wrapping in a long list throws the user to the other end of the list with a single keystroke and makes it impossible for them to understand where they are without reading.

The test below builds the contract as a state machine, checks it with key sequences, and in the last test scans every reachable state to verify three invariants.

// dropdown.test.mjs — the combobox's keyboard contract and invariants
// Run: node --test dropdown.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";

const SUBJECTS = ["Microeconomics", "Microbiology", "Music History", "Mathematics"];
const lower = (s) => s.toLocaleLowerCase("en-US");

const initial = () => ({ focusInField: true, open: false, active: null, text: "", value: null });
const options = (d) => SUBJECTS.filter((k) => lower(k).startsWith(lower(d.text)));

function key(d, t) {
  const s = options(d);
  if (t === "ArrowDown") {
    if (!d.open) return { ...d, open: true, active: s.length ? 0 : null };
    if (d.active === null) return { ...d, active: s.length ? 0 : null };
    return { ...d, active: Math.min(d.active + 1, s.length - 1) };   // no wraparound
  }
  if (t === "ArrowUp") {
    if (!d.open) return { ...d, open: true, active: s.length ? s.length - 1 : null };
    if (d.active === null) return { ...d, active: s.length ? s.length - 1 : null };
    return { ...d, active: Math.max(d.active - 1, 0) };
  }
  if (t === "Enter") {
    if (d.open && d.active !== null)
      return { ...d, open: false, active: null, text: s[d.active], value: s[d.active] };
    return d;
  }
  if (t === "Escape") {
    if (d.open) return { ...d, open: false, active: null };          // close the list
    return { ...d, text: "", value: null };                          // clear the field
  }
  if (t === "Tab") return { ...d, open: false, active: null, focusInField: false };
  if (t.startsWith("type:")) return { ...d, text: d.text + t.slice(5), open: true, active: null };
  return d;
}

const apply = (d, keys) => keys.reduce(key, d);

test("when closed, ArrowDown opens the list and makes the first option active", () => {
  const d = apply(initial(), ["ArrowDown"]);
  assert.equal(d.open, true);
  assert.equal(options(d)[d.active], "Microeconomics");
});

test("when closed, ArrowUp opens the list and makes the last option active", () => {
  const d = apply(initial(), ["ArrowUp"]);
  assert.equal(options(d)[d.active], "Mathematics");
});

test("ArrowDown does not wrap to the start at the last option", () => {
  const d = apply(initial(), ["ArrowDown", "ArrowDown", "ArrowDown", "ArrowDown", "ArrowDown"]);
  assert.equal(d.active, 3);
  assert.equal(options(d)[d.active], "Mathematics");
});

test("Enter makes the active option the value and closes the list", () => {
  const d = apply(initial(), ["ArrowDown", "ArrowDown", "Enter"]);
  assert.equal(d.value, "Microbiology");
  assert.equal(d.open, false);
  assert.equal(d.active, null);
});

test("typing opens the list and filters it, the active option resets", () => {
  const d = apply(initial(), ["type:M", "type:i"]);
  assert.deepEqual(options(d), ["Microeconomics", "Microbiology"]);
  assert.equal(d.active, null);
});

test("Escape closes the list first, clears the field on the second press", () => {
  const first = apply(initial(), ["type:M", "type:i", "ArrowDown", "Escape"]);
  assert.equal(first.open, false);
  assert.equal(first.text, "Mi");
  const second = apply(first, ["Escape"]);
  assert.equal(second.text, "");
  assert.equal(second.value, null);
});

test("Tab closes the list and takes focus out of the field", () => {
  const d = apply(initial(), ["ArrowDown", "Tab"]);
  assert.equal(d.open, false);
  assert.equal(d.focusInField, false);
});

test("with no match, the list opens but there is no active option", () => {
  const d = apply(initial(), ["type:Z"]);
  assert.deepEqual(options(d), []);
  const e = apply(d, ["ArrowDown"]);
  assert.equal(e.active, null);
});

test("invariants hold across every reachable state, and focus stays in the field", () => {
  const KEYS = ["ArrowDown", "ArrowUp", "Enter", "Escape", "type:M", "type:i", "type:a"];
  const stateKey = (d) => [d.open, d.active, d.text, d.value].join("|");
  const seen = new Map([[stateKey(initial()), initial()]]);
  const queue = [initial()];
  while (queue.length) {
    const d = queue.shift();
    for (const t of KEYS) {
      const y = key(d, t);
      if (y.text.length > 3) continue;               // scan length is capped
      if (seen.has(stateKey(y))) continue;
      seen.set(stateKey(y), y);
      queue.push(y);
    }
  }
  for (const d of seen.values()) {
    assert.equal(d.focusInField, true, "focus left the field");
    if (!d.open) assert.equal(d.active, null, "active option remained in a closed list");
    if (d.active !== null) {
      assert.ok(d.open, "list is closed while an active option exists");
      assert.ok(d.active < options(d).length, "active option is not in the list");
    }
  }
  console.log(`  states scanned: ${seen.size}`);
});
  states scanned: 90
✔ when closed, ArrowDown opens the list and makes the first option active (0.369792ms)
✔ when closed, ArrowUp opens the list and makes the last option active (0.049834ms)
✔ ArrowDown does not wrap to the start at the last option (0.061833ms)
✔ Enter makes the active option the value and closes the list (0.041791ms)
✔ typing opens the list and filters it, the active option resets (0.286583ms)
✔ Escape closes the list first, clears the field on the second press (0.056916ms)
✔ Tab closes the list and takes focus out of the field (0.042209ms)
✔ with no match, the list opens but there is no active option (0.049791ms)
✔ invariants hold across every reachable state, and focus stays in the field (0.932875ms)
ℹ tests 9
ℹ suites 0
ℹ pass 9
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 35.087417

The duration values vary from run to run. The number of states scanned, though, depends on the typing length being capped at three characters; when the cap is removed, the state space is infinite, because the user can type as many characters as they want.

Reading the Invariants

The last test checks three rules, and all three are places where the screen and the tree can diverge.

No active option can remain in a closed list. If the list is closed without clearing the aria-activedescendant value, an option that no longer exists in the tree appears active. This leads the user to think they are still inside the list after closing it.

The active option has to be in the list. When the list shortens after filtering, the old index becomes invalid. In the test, this is prevented by the type: keys resetting the active option; without the reset, the declaration would point to an id that no longer exists and would drop silently.

Focus does not leave the field. All ninety states satisfy the condition. This follows from the pattern’s definition: an implementation where focus moves to the list is not the aria-activedescendant pattern, it is a different pattern, and in that case the ability to filter by typing is lost.

Measurable Constraints

4.1.2 Name, Role, Value. The field’s name, its combobox role, its open/closed state, and its selected value have to be programmatically readable. In a hand-built component, all four are written separately.

2.4.7 Focus Visible. Real focus is in the field; its indicator has to be present in the field. The active-option highlight does not replace this indicator, it is added to it.

1.4.11 Non-text Contrast. The active-option highlight has to separate from neighboring rows by at least 3:1. A highlight built only with a light gray surface does not satisfy this measure.

1.4.13 Content on Hover or Focus. The opened list is content that appears on focus, and it has to satisfy three conditions: it has to be dismissible with Escape, the pointer has to be able to move over the list, and it must not disappear without the user taking an action.

Announcing the filter result. How many options remain in a list filtered by typing is conveyed with a polite live region. The announcement is sent not on every keystroke but after typing stops; otherwise, the announcement queue prevents the user from hearing what they type.

Common Mistake

Focus moving to the list. Catching it means trying to type while the list is open: if the character typed does not go to the field, focus has shifted. In this pattern, the fix is not moving focus back, it is switching to the aria-activedescendant declaration.

The open/closed state not being updated. The aria-expanded value staying false while the list is visible on screen is the tree contradicting the screen. Catching it is a uniqueness and consistency check: the list’s visibility is compared against the declared state.

The native element being abandoned for the sake of appearance. The hand-built component’s cost is ten declarations, and each is a separate source of error. Catching it happens at the specification stage: if none of the requirements — extra information in the option row, filtering by typing, or fetching from the server — exists, the native element is the decision.

Summary

  • A dropdown list is for option sets that are enumerable but do not fit on screen; a radio group is used for fewer than five options, and search is used for sets that are not known in advance.
  • The select element brings the role, name, state, value, and keyboard contract together; its cost is that the opened list’s appearance cannot be controlled from the presentation layer.
  • The hand-built component reproduces ten separate declarations; it is chosen only when inline extra information, filtering by typing, or fetching from the server is required.
  • In the aria-activedescendant pattern, focus does not leave the field; what navigates the list is a declaration, and this is why two separate visual indicators are required.
  • Arrow keys do not wrap at the ends; wrapping behavior belongs only to radio groups whose entirety sits on screen.
  • No active-option declaration can remain in a closed list, and the active option always has to be in the filtered list; resetting the declaration after filtering guarantees this.

Next Step

The four components so far were each taken up on their own: a button, a field, a group, a list. The borrowing form, by contrast, carries all of them together and raises new questions. By what measure are fields grouped? When a form is split into three steps, how is the step change announced, and where does focus go? If submission fails, does the user lose what they typed? The next lesson specifies the form as a whole and shows where components that are correct individually can work incorrectly together.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close