Junior Web Developer study materials
Junior Web Developer study resources

ReviewerPicker: a div-based listbox and a modal that traps nothing

Junior Web Developer · Put your knowledge to work in context.

All case studies

ReviewerPicker: a div-based listbox and a modal that traps nothing

Your team ships a design-system component called ReviewerPicker. Markup is a <div class="picker"> wrapper with tabindex="0" and role="button", a <span> showing the current value, and a <div class="menu"> holding 14 <div class="item"> rows. A click handler on document closes the menu on outside clicks; items respond only to click. The accessibility audit found: keyboard users can Tab to the wrapper but Enter and Space do nothing; VoiceOver announces "Reviewer picker, button" and never reads the options; two of the 14 reviewers are on leave and are rendered with class="disabled" only. The confirm dialog that follows is a <div> at z-index: 9999, shown by toggling display, but focus stays on the picker behind it and Tab walks the page underneath. At 200% browser zoom on a 1366×768 laptop the dialog's Cancel button sits below the fold with no way to scroll, while the page behind still scrolls. The picker starts with no selection and includes a Clear action.

1. Specify the markup and keyboard interaction that should replace ReviewerPicker, and explain which current ARIA attributes are actively harmful rather than merely incomplete.

Read the worked answer

Roles do not create behaviour, and here they misdescribe it. role="button" on the wrapper promises Enter/Space activation and a single action, but the widget is a selection control, so a screen reader user is told "button" and never learns that 14 options exist, which is selected, or which are unavailable. That is worse than no ARIA at all, because it creates a false contract.

Two acceptable replacements:

  1. Native <select> if options are static and plain text. Free keyboard support, mobile pickers, typeahead, and correct announcement. Prefer this; custom widgets need a reason.
  2. ARIA 1.2 combobox/listbox if rich rendering is required:
  • <button type="button" aria-haspopup="listbox" aria-expanded="false" aria-controls="menu-id"> displaying the current value or "No reviewer assigned"
  • <ul id="menu-id" role="listbox"> with <li role="option" aria-selected="true|false" aria-disabled="true">
  • Keep focus on the button and track the active option with aria-activedescendant, or move real focus using roving tabindex.

Keyboard model: Enter/Space/ArrowDown opens and lands on the selected option (or the first); ArrowUp/Down move; Home/End jump; printable characters do typeahead; Enter selects and closes; Escape closes without changing the value and returns focus to the button; Tab closes.

Disabled options must stay in the list with aria-disabled="true", be skipped by arrow keys, and never be selectable - removing them from the DOM would hide the fact that those reviewers exist. Finally, route the click handler and the key handler through one select(option) function so mouse and keyboard cannot diverge, and do not rely on a document-level handler as the only way to close.

2. Write the focus-management plan for the confirm dialog: at opening, while open, and at closing. Explain how `visibility:hidden`, `inert`, and `opacity:0` differ for assistive technology and keyboard users.

Read the worked answer

Opening. Move focus into the dialog deliberately - to the first focusable control, or to the dialog container itself with tabindex="-1" and an accessible name via aria-labelledby pointing at the heading. If the dialog is destructive, focus the least destructive action (Cancel), not Delete. Record document.activeElement before opening so you can restore it. The best option is a native <dialog> opened with showModal(), which gives you a top layer, an implicit focus scope, Escape handling, and makes the rest of the page inert without custom code.

While open. Focus must not escape: cycle Tab and Shift+Tab within the dialog, and pull focus back if it lands outside (a focusin listener on document is more reliable than intercepting Tab alone, because browser UI and screen reader shortcuts can jump focus). Escape closes. The background must be inert to pointer and to assistive tech, and background scrolling should be locked - but the dialog content itself must scroll, which is the fix for the 200% zoom problem: max-height: 90dvh; overflow: auto; with the close button in a non-scrolling header rather than centred with top: 50%.

Difference between the properties. display:none and visibility:hidden remove the element from both the accessibility tree and the tab order - that is why the dialog was fine when hidden, and why a background left merely transparent is not fine. opacity:0 keeps the element focusable and readable by screen readers, so Tab disappears into invisible controls. inert (or aria-hidden="true" as a fallback) removes a visible subtree from focus and the accessibility tree, which is exactly what you want for the page behind a dialog and for content behind a mobile drawer.

3. Diagnose the 200% zoom failure and give the CSS changes. Then describe how you would verify the whole picker-plus-dialog flow using only a keyboard and a screen reader.

Read the worked answer

The failure is not really a zoom bug; it is a layout that assumes viewport height. A dialog centred with fixed positioning plus top: 50%; transform: translateY(-50%) and no max-height will overflow once text doubles, and because the overlay has overflow: hidden the overflow is unreachable. At 200% zoom the CSS viewport is halved (roughly 683×384), so a dialog whose content needs 500 CSS px cannot fit.

Fixes:

  • Overlay: position: fixed; inset: 0; display: grid; place-items: center; padding: 16px; overflow: auto;
  • Dialog: max-height: calc(100dvh - 32px); display: flex; flex-direction: column; with overflow: auto on the body region only.
  • Header and footer (containing Cancel and Confirm) stay outside the scrolling region, so the actions are always reachable.
  • Avoid vh alone for the overlay; dvh handles mobile browser chrome, with a vh fallback declaration first.
  • Never set overflow: hidden on html/body as the only scroll lock; use overscroll-behavior or the top-layer behaviour of showModal().

Verification, no mouse: Tab from page load to the picker, confirm a visible focus ring, open with Enter, arrow through options, confirm the two on-leave reviewers are announced as unavailable and cannot be chosen, press Escape and confirm focus returns to the trigger and the value is unchanged. With NVDA or VoiceOver, confirm the expanded state, the option count, and the selected state are read. Re-run the whole flow at 200% and 400% browser zoom and confirm no content is clipped and every action is reachable by Tab. Check colour contrast of the focus ring and the disabled option separately - a disabled option is exempt from contrast minimums but still needs to be distinguishable.