cswork / libraryOpen practice workspace

Articles

Accessible Menu Coding Interview: Arrow Keys, Focus, and Dismissal

Practice accessible menu coding interviews with arrow-key traces, focus ownership, disabled items, Escape, Tab, and outside-pointer dismissal decisions.

An accessible menu coding interview is a focus-management problem disguised as a small dropdown. Opening a panel is easy; the harder contract is deciding which command receives arrow-key focus, what a disabled command does, and where focus belongs after Escape, Tab, an outside click, or an action that opens a dialog.

The useful rule is: dismissal has a reason and a destination, not just an open = false assignment. Start with Design a Reusable Dropdown Menu Component. This article's action list, event traces, and acceptance tests are original preparation exercises, not reported employer questions. Platform guidance comes from W3C documentation checked October 1, 2026.

Is the prompt really asking for an ARIA menu?

Ask what the popup contains. Commands such as Open, Rename, Duplicate, and Delete fit an action menu. Choosing a saved value may call for a select or listbox. A row of ordinary website links may be a disclosure navigation. These are different interaction contracts, even when their screenshots look alike. W3C's disclosure navigation example explicitly distinguishes typical site navigation from the more demanding menu pattern.

For this exercise, choose one native Actions button opening four commands. There are no nested menus, editable search fields, or multi-selection. Duplicate is temporarily unavailable, but users can discover its existence. Arrow navigation wraps. Commands have stable IDs rather than positions that change when an item disappears. Those choices establish a manageable contract; they are not defaults for every dropdown.

Say what is outside scope. Positioning, viewport collisions, and portals matter, but the first working slice is a correct keyboard and focus loop. Do not spend the entire exercise measuring pixels while Escape loses the user's place. Conversely, do not describe keyboard support as sufficient proof of accessibility: names, visibility, announcements, and assistive-technology behavior still need review.

If the interviewer changes the prompt to a value picker, revisit the pattern before extending this code. A menu command performs an action; a selectable option represents a value. Reusing the CSS is reasonable. Silently reusing all roles and keyboard semantics is not.

What happens when the trigger opens the menu?

The W3C Menu Button pattern describes a button exposing the popup relationship and expanded state. Enter or Space opens the menu and focuses the first item; optional ArrowDown and ArrowUp openings can target the first and last items respectively. Use a visible label, aria-haspopup="menu", and an aria-expanded value that tracks the actual rendered panel.

Choose a single focus strategy. Here, real DOM focus moves into command elements. Each command has tabindex="-1"; the trigger remains an ordinary button. Opening renders the menu first, then focuses the requested command after the element exists. Setting activeId without calling focus changes application state, not the keyboard's target.

Be careful with native activation. A button already supports keyboard activation. If a keydown handler toggles the popup for Enter and the resulting click toggles it again, one key press can open and immediately close it. Let the native click open the panel, and handle only the additional arrow-key shortcuts explicitly. Prevent the arrow's default scrolling when you actually consume that key.

For an empty list, choose a deliberate outcome: keep the trigger focused and show an unavailable state, or present a non-menu message. Do not focus a nonexistent first element. When all commands are unavailable, that is not the same as having no commands; discoverability and activation remain separate concerns.

How should arrow keys treat unavailable commands?

The Menu and Menubar pattern separates traversal from activation: disabled menu items remain focusable but cannot activate, while separators do not receive focus. Up and Down move among menu items; Escape closes the menu. Tab leaves the composite widget rather than stepping through every command. State whether wrapping, Home/End, and typeahead are supported.

For our four commands, keep Duplicate in the arrow sequence and expose aria-disabled="true". A native disabled button cannot receive focus, so using the HTML disabled attribute would contradict this particular focus policy. The activation handler must still check availability; an ARIA attribute communicates state but does not enforce business logic.

Keep the movement helper independent of DOM operations:

function moveId(items, currentId, delta) {
  if (items.length === 0) return null;
  const index = items.findIndex(item => item.id === currentId);
  if (index < 0) return delta < 0 ? items.at(-1).id : items[0].id;
  return items[(index + delta + items.length) % items.length].id;
}

This helper expects only navigable commands, with separators already excluded. It intentionally does not filter unavailable commands. Its modulo expression is safe for the one-step deltas used here; arbitrary negative jumps would need normalized modulo. The caller updates the active ID and focuses that element. If a command is deleted, reconcile the ID against the new list before focusing anything.

Can you predict a complete keyboard trace?

Use stable IDs open, rename, duplicate, and delete. Start with the Actions button focused and the menu closed. The following is our original acceptance trace, not a quotation from a standard:

InputFocus after the inputMenu / action outcome
Enter on ActionsOpenMenu opens once
ArrowDownRenameNo command executes
ArrowDownDuplicateUnavailable state remains discoverable
Enter on DuplicateDuplicateNo duplication request is made
ArrowDownDeleteNo deletion occurs yet
ArrowDownOpenWrap policy is observable
EscapeActionsMenu closes; trigger reports collapsed

Now alter the trace: remove Rename while it owns focus. Choose the next surviving command, Duplicate, or another explicitly documented neighbor rule. Updating an array is not enough; the focused DOM node may have disappeared. A correct repair should not leave focus on the document body or accidentally activate the command that replaced the same array index.

Add a separator between Rename and Duplicate. The helper's input remains the same four commands, while the rendered list contains an extra visual row. This exposes index-based bugs immediately: a DOM child index and a command index no longer identify the same thing.

Finally, test Home and End if included, and repeated letters if typeahead is included. Typeahead needs a defined buffer lifetime and matching policy. It should not consume a text-input interaction inside a different widget. Omitting an optional feature with a clear boundary is stronger than implementing a half-working one and calling the menu complete.

Where should focus go when the menu closes?

Treat close operations as close(reason, destination). The direct-focus action-menu example is useful for examining focus movement and command activation. Our exercise extends that reasoning to four distinct dismissal paths:

Dismissal reasonDestination in this exerciseCommon implementation error
EscapeThe original Actions buttonHide panel without restoring a usable target
Tab or Shift+TabNext or previous page controlTrap focus or send every case forward
Outside pointerThe control the user clicked, if focusableSteal focus back to Actions
Command opens a dialogThe dialog's chosen initial targetRestore Actions after the dialog already focused

Escape abandons the popup, so returning to its opener makes the next action predictable. Tab is different: the user asked to continue through the page. For a menu rendered next to its trigger, a practical implementation can restore focus to the trigger during keydown and allow native sequential navigation to proceed. Verify both directions in the actual DOM. A portal changes physical order; it requires a tested tab-exit strategy, not an assumption about visual proximity.

For an outside pointer, dismiss without overriding the pointer's normal target. That includes clicking a text field the user intends to edit. Preventing the pointer default or focusing Actions afterward can make the field appear broken. If the outside target is not focusable and closing removes the current focused command, define a safe fallback rather than leaving focus stranded.

An action may transfer ownership. Rename might open an editing dialog, while Open might navigate to a new document. Let the destination workflow establish focus. Do not run an unconditional “restore opener” effect after every close; it can race the new dialog or focus a button that no longer exists.

How do outside clicks and cleanup fit the model?

An outside-event check must recognize both the trigger and popup as inside. If the popup is portaled, checking only wrapper.contains(event.target) will classify valid menu interactions as outside. With ordinary exposed DOM nodes, an event path check can include both references. Closed shadow roots require a different boundary contract; do not assume their internals appear in the external event path.

Attach the outside listener only while open, and remove it when closed or unmounted. Store the listener identity so cleanup removes the actual function that was added. An event-order trace is more useful than guessing: record pointerdown, focus movement, click, activation, and dismissal reason. A close on pointerdown may unmount a command before its click handler runs.

Choose one owner for command execution. A command should not execute once in a delegated parent handler and again in its own handler. Check availability at this owner, then hand off the action and close with the correct reason. Destructive commands may require confirmation elsewhere; the menu's keyboard code should not bypass that separate product contract.

For controlled state, the parent can reject or delay an open-state update. Therefore focus work must follow observed rendered state, not merely an onOpenChange request. If an async command is pending, decide whether reopening is allowed and whether its availability changes. Describe that as a product policy, not a universal accessibility requirement.

What tests make the answer defensible?

Test the movement helper with zero items, one item, a missing current ID, removal, and wrapping. Then test the DOM contract with real keys. The W3C keyboard-interface guidance emphasizes that ARIA does not implement keyboard behavior and that users need visible, understandable focus. A green highlight alone is not proof that the right element receives key events.

Our local fixture checks arrow traversal through the unavailable command, blocked activation, Escape restoration, Tab continuation, outside-pointer ownership, and focus transfer to a dialog. These are bounded browser checks, not a screen-reader compatibility matrix or accessibility certification. A production component still needs target-browser and assistive-technology testing, including zoom and high-contrast conditions.

Inspect the trigger's expanded state alongside the panel's visibility. Press keys quickly rather than waiting between every action. Close and reopen repeatedly to expose duplicate listeners. Remove the active item while open. Click inside a portaled popup if that architecture is supported. Check that a stale asynchronous focus request cannot reopen or refocus a closed menu.

Finish the interview by narrating one failure and its repaired invariant: “Outside clicks previously called the same close routine as Escape. I separated the reasons so clicking the next input now keeps focus there.” That explanation connects a concrete symptom, a small state-model change, and an observable test result.

Sources and Further Reading