Skip to content
academia.sh

Lesson 10 / 25

Menus and Dropdown Panels

The distinction between the menu role and the dropdown panel, scanning three setups for focus leaks and closing behavior, and verifying the focus return point and the open-close cycle.

Contents

In the tab and accordion patterns, opened content stayed inside the page’s flow: it took up a place, pushed the content beneath it down, and focus moved on its own. The “My Account” control in the catalog’s top bar behaves differently — the list it opens sits above the page, covers the content beneath it, and closes when the user clicks outside it.

This is the first pattern where closing has to be specified as a behavior. Closing happens in more than one way, and where focus goes after each way is a separate decision. The lesson takes on both patterns together, because although they look alike on screen, their contracts are separate.

Two Patterns, Two Contracts

A dropdown panel is ordinary content that a button shows and hides. It can hold a link, a button, a field, even a whole form. It uses the same declaration as the accordion — aria-expanded — and its only difference is that the content sits above the page.

A menu is an application-like list of commands, and it is built with its own role set: role="menu", role="menuitem", role="menuitemcheckbox". These roles carry a contract, and the contract is restrictive: a menu holds only menu items, navigation is done with the arrow keys, and the whole menu is a single tab stop.

The deciding measure is the content. The list the “My Account” control opens — “My Loans,” “Settings,” “Sign Out” — is made of ordinary links and is a navigation list; its pattern is a dropdown panel. The menu role is used for lists whose items are actions rather than navigation, like the command list the “Export” button opens on the results table.

The wrong choice produces a silent cost. When the menu role is written on a list of links, the links are reported to the tree as menu items; reaching them from a link list becomes impossible, and the “open in new tab” expectation is not met.

The Native Element First

Neither has a native counterpart. The opening control is a button element; the dropdown panel writes aria-expanded and aria-controls, and the menu additionally declares aria-haspopup="menu".

<button id="account" aria-expanded="false" aria-controls="account-panel">My Account</button>
<div id="account-panel" hidden>
  <ul>
    <li><a href="/account/loans">My Loans</a></li>
    <li><a href="/account/settings">Settings</a></li>
  </ul>
  <form action="/sign-out" method="post"><button type="submit">Sign Out</button></form>
</div>

The panel’s content is ordinary markup; a list stays a list, a form stays a form. This is the reason the pattern is preferred over a menu: no structural meaning has to be reproduced by hand.

Closing Paths and the Focus Return

Closing happens four ways: running the opening button again, the Escape key, clicking outside the panel, and selecting an item inside the panel.

Where focus goes depends on the closing path, and the rule is this: on a closing the user initiated, focus returns to the opening button. This holds for closing with Escape and by running the button again. When a link inside the panel is selected, that is navigation and focus goes to the new page; a return is unnecessary.

Clicking outside is a special case. A user who clicks has already moved focus to where they clicked; returning focus to the opening button pulls it back from where they clicked. The rule: on an outside click, focus moves to the opening button only if it has settled nowhere else.

// menu.mjs — focus containment, closing, and the return point in a dropdown panel

const BACKGROUND = ["a#catalog", "button#account", "input#search"];
const CONTENT = ["a#my-loans", "a#settings", "button#sign-out"];
const TRIGGER = "button#account";
const KEYS = ["Tab", "Shift+Tab", "ArrowDown", "ArrowUp", "Escape", "Enter", "ClickOutside"];

// kind: "panel" (dropdown panel) | "menu" (menu role) | "broken"
function machine(kind) {
  const goBackground = (i, dir) => BACKGROUND[(i + dir + BACKGROUND.length) % BACKGROUND.length];
  return (d, t) => {
    if (!d.open) {
      const i = BACKGROUND.indexOf(d.focus);
      if (t === "Tab") return { open: false, focus: goBackground(i, 1) };
      if (t === "Shift+Tab") return { open: false, focus: goBackground(i, -1) };
      if ((t === "Enter" || t === "ArrowDown") && d.focus === TRIGGER)
        return { open: true, focus: CONTENT[0] };
      return d;
    }
    const j = CONTENT.indexOf(d.focus);
    if (kind === "menu") {
      if (t === "ArrowDown") return { open: true, focus: CONTENT[(j + 1) % CONTENT.length] };
      if (t === "ArrowUp")
        return { open: true, focus: CONTENT[(j + CONTENT.length - 1) % CONTENT.length] };
      if (t === "Tab") return { open: false, focus: goBackground(BACKGROUND.indexOf(TRIGGER), 1) };
      if (t === "Shift+Tab") return { open: false, focus: TRIGGER };
      if (t === "Escape") return { open: false, focus: TRIGGER };
      if (t === "ClickOutside") return { open: false, focus: TRIGGER };
      return d;
    }
    if (kind === "panel") {
      if (t === "Tab")
        return j === CONTENT.length - 1
          ? { open: false, focus: goBackground(BACKGROUND.indexOf(TRIGGER), 1) }
          : { open: true, focus: CONTENT[j + 1] };
      if (t === "Shift+Tab")
        return j === 0 ? { open: true, focus: TRIGGER } : { open: true, focus: CONTENT[j - 1] };
      if (t === "Escape") return { open: false, focus: TRIGGER };
      if (t === "ClickOutside") return { open: false, focus: TRIGGER };
      return d;
    }
    // broken: no escape, focus vanishes on outside click, Tab leaks to the background
    if (t === "Tab")
      return j === CONTENT.length - 1
        ? { open: true, focus: BACKGROUND[0] }
        : { open: true, focus: CONTENT[j + 1] };
    if (t === "Shift+Tab") return { open: true, focus: CONTENT[Math.max(j - 1, 0)] };
    if (t === "ClickOutside") return { open: false, focus: "(document root)" };
    return d;
  };
}

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

function audit(kind) {
  const next = machine(kind);
  const start = { open: false, focus: TRIGGER };
  const seen = new Map([[key(start), start]]);
  const queue = [start];
  const closings = [];       // [previousState, key, nextState]
  while (queue.length) {
    const d = queue.shift();
    for (const t of KEYS) {
      const y = next(d, t);
      if (d.open && !y.open) closings.push([d, t, y]);
      if (seen.has(key(y))) continue;
      seen.set(key(y), y);
      queue.push(y);
    }
  }
  const states = [...seen.values()];
  const viaEscape = closings.filter(([, t]) => t === "Escape");
  return {
    states: states.length,
    leak: states.some((d) => d.open && BACKGROUND.includes(d.focus) && d.focus !== TRIGGER),
    escapeExit: viaEscape.length > 0,
    escapeReturn: viaEscape.length > 0 && viaEscape.every(([, , y]) => y.focus === TRIGGER),
    focusLoss: closings.some(([, , y]) => !BACKGROUND.includes(y.focus) && y.focus !== TRIGGER),
    cycle: key(next(next(start, "Enter"), "Escape")) === key(start),
    innerStop: kind === "menu" ? 1 : CONTENT.length,
  };
}

const yn = (b) => (b ? "yes" : "no");
console.log("setup".padEnd(10) + "states".padEnd(9) + "inner stop".padEnd(12) +
  "leaks to background".padEnd(21) + "Escape exit".padEnd(13) + "returns to trigger".padEnd(20) +
  "focus loss".padEnd(12) + "open-close cycle");
for (const kind of ["panel", "menu", "broken"]) {
  const s = audit(kind);
  console.log(kind.padEnd(10) + String(s.states).padStart(6).padEnd(9) +
    String(s.innerStop).padStart(5).padEnd(12) +
    yn(s.leak).padEnd(21) + yn(s.escapeExit).padEnd(13) +
    yn(s.escapeReturn).padEnd(20) + yn(s.focusLoss).padEnd(12) +
    (s.cycle ? "closed" : "OPEN"));
}

// Focus trace for the correct setup
console.log("\nfocus trace in the dropdown panel setup:");
let d = { open: false, focus: TRIGGER };
const next = machine("panel");
console.log("  " + "start".padEnd(12) + "closed".padEnd(8) + "focus: " + d.focus);
for (const t of ["Enter", "Tab", "Tab", "Escape", "Tab"]) {
  d = next(d, t);
  console.log("  " + t.padEnd(12) + (d.open ? "open" : "closed").padEnd(8) + "focus: " + d.focus);
}
setup     states   inner stop  leaks to background  Escape exit  returns to trigger  focus loss  open-close cycle
panel          8       3       no                   yes          yes                 no          closed
menu           6       1       no                   yes          yes                 no          closed
broken         8       3       yes                  no           no                  yes         OPEN

focus trace in the dropdown panel setup:
  start       closed  focus: button#account
  Enter       open    focus: a#my-loans
  Tab         open    focus: a#settings
  Tab         open    focus: button#sign-out
  Escape      closed  focus: button#account
  Tab         closed  focus: input#search

Reading the Findings

The inner stop column separates the two patterns. In the dropdown panel, the content sits in the natural tab order and holds three stops; the menu uses the roving tabindex and the whole thing counts as one stop. This is the measure that names which pattern is built without looking at the markup.

The open-close cycle has to be closed. The last column tests whether a user focused on the button, who opens the panel and closes it with Escape, returns to exactly the same state. In both correct setups the cycle is closed; in the broken setup it is open, because the Escape key does nothing. A closed cycle means the user loses nothing by opening the panel by accident.

The broken setup carries three defects at once. While the panel is open, the Tab key leaks into the background controls: the user ends up focused on elements they cannot see, hidden beneath the panel. There is no exit with Escape — this is the subject of criterion 2.1.2, though it is not a full trap here, because Tab provides an escape. Clicking outside drops focus to the document root, and the user starts navigating from the very beginning.

The menu reaches six states, the panel eight. The difference comes from the panel’s ability to return to the opening button while open: Shift+Tab from the first item exits to the button and the panel stays open. In the menu, the same key closes the menu. Both are defined; which one is chosen is written into the specification.

Keyboard Contract

Dropdown panel:

Key Behavior
Enter / Space (on the button) Opens the panel; focus moves to the first item.
Down arrow (on the button) Opens the panel and moves focus to the first item.
Tab Advances through the panel’s items; closes the panel after the last item.
Escape Closes the panel; focus returns to the opening button.

Menu:

Key Behavior
Enter / Space / Down arrow (on the button) Opens the menu; focus moves to the first item.
Down arrow / Up arrow Moves between items; wraps at the ends.
Home / End Goes to the first and last item.
Escape Closes the menu; focus returns to the opening button.
Tab Closes the menu and moves focus to the next page stop.

In the menu pattern, the Tab key closing the menu is not a limitation but part of the definition: because the menu is a single stop, there is no tab order inside it to advance through.

Measurable Constraints

2.1.2 No Keyboard Trap. While the panel is open, it must be possible to exit with the keyboard. Escape is the shortest path out and is required in both patterns.

1.4.13 Content on Hover or Focus. If the panel opens on pointer hover, three conditions are required: it must be dismissible with Escape, the pointer must be able to move over the panel, and it must not disappear without the user taking an action. Menus that open on hover and close when the pointer moves away remove this criterion; a pattern also has to open with a click, because a hover state never occurs on touch input.

2.4.7 Focus Visible. The focus indicator on items inside the panel must also carry sufficient contrast against the panel’s own surface; a single-color ring tuned for the page surface can disappear on the panel surface.

1.4.11 Non-text Contrast. The panel’s border must meet 3:1 against the content beneath it so it does not blend in; a panel separated only by a shadow has no visible border.

2.5.8 Target Size. Panel items meet the 24-pixel measure; in narrow dropdown lists, the row height determines this measure.

Common Mistake

Writing the menu role onto a link list. Catching it is a structural check: if an element carrying role="menu" has a child that is not a menu item, the pattern is built wrong.

Leaving focus behind on close. If the panel closes while focus sits on an element that has been removed, focus drops to the document root. Catching it is the audit’s cycle test: if the open-close sequence does not return to the starting state, focus management is missing.

Tying Escape only to the panel. If Escape stops working once the user has moved focus back to the opening button, one of the closing paths is lost. The key is bound to the shared ancestor of the button and the panel.

Not hiding the panel from the tree. A panel hidden only in the presentation layer stays in the tree, and the user reads the items of a list they believe is closed.

Summary

  • A dropdown panel shows and hides ordinary content; a menu is for lists whose items are commands and carries a restrictive role contract.
  • Writing the menu role on a list of links turns the links into menu items in the tree and blocks reaching them from a link list.
  • The dropdown panel’s content sits in the natural tab order; the menu uses the roving tabindex and holds a single stop. This measure tells the two patterns apart.
  • On every closing the user initiates, focus returns to the opening button; closing by navigating from inside the panel needs no return.
  • The open-close cycle must be closed: an open-close sequence starting at the button must return to exactly the same state.
  • While the panel is open, focus must not leak to the background controls, and Escape must leave an exit in every case.

Next Step

The containers covered so far showed and hid content; none of them told the user where they are. In the catalog, position is information: the user is three levels down a subject branch and on the fourth page of the results list. Two components carry this information, and both rest on the same declaration — separating the current location from the rest. The next lesson specifies the breadcrumb and pagination: how position is declared, and why a page change requires a focus decision.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close