Skip to main content

Keyboard and focus

Everything reachable by mouse must be reachable by Tab, and everything focused must be visible. That is the whole of WCAG 2.1.1 and 2.4.7 for practical purposes; the WAI-ARIA Authoring Practices has the per-widget key maps if you need one.

The interesting part in this app is that focus is not always in this document. Operators work across a map view, image overlays, and an embedded archive — so "where is focus" has a real answer here that no generic guide covers.

Prove the rule to yourself before reading about it — put the mouse down and run the course:

⌨️ Focus Run

Five real controls stand between you and the alert. Keyboard only.

The rings you see are the course's own :focus-visible styling — the app gets its rings from MUI and per-component CSS, never a global outline.

There is no global focus ring

styles/global.css and styles/global.scss contain zero :focus or outline declarations. Focus styling comes from two places and nowhere else:

  • MUI components bring their own, which is why buttons built on IButton, Tabs, TextField and friends look focused without anyone writing CSS.
  • Twelve component-level stylesheets define their own — AreaScopeBar.module.scss, AIAlertModal.module.scss, LastMovedButton.module.scss, PeakFinderControls.module.scss, and others.

The consequence: a hand-rolled control gets no focus ring unless you write one. If you add a bare <button> with a CSS-module class, add a :focus-visible rule in the same file. The dark theme makes this worse than usual — the default UA outline is a thin black ring, which is close to invisible against --color-background-default: #000000.

.myControl:focus-visible {
outline: 2px solid var(--color-button-primary-focus); // #90CAF9, 7.0:1 on paper bg
outline-offset: 2px;
}

Use :focus-visible rather than :focus so the ring appears for keyboard users without flashing on every mouse click.

Keyboard shortcuts across an iframe boundary

components/molecules/ArchiveLink/useArchiveKeyboardBridge.ts is the most involved piece of keyboard code in the repo, and it exists because of a problem that only shows up in a real product: the image archive is an <iframe> with its own keyboard shortcuts, listening on its own document. Operators open it by clicking a menu item, which leaves focus in the host document — so pressing O does nothing, and the shortcut hint the app renders would be a lie.

The hook applies two mitigations in preference order. First, move focus into the frame so keys arrive with no interception at all:

frame.contentWindow?.focus();
// re-run on 'load' (internal navigations) and on 'mouseenter'

Second, if focus is still in the host, re-dispatch the event onto the iframe document using the iframe's own KeyboardEvent constructor, so instanceof checks inside the archive still pass.

Three details in that file are worth internalising, because they generalise to any global key handler you write here:

if (event.metaKey || event.ctrlKey || event.altKey) return; // browser chords are never ours
if (isEditableTarget(event.target)) return; // typing is not a shortcut
if (HOST_OWNED_KEYS.has(event.key)) {
/* Escape stays with the host */
}

isEditableTarget checks for INPUT, TEXTAREA, SELECT, and isContentEditable. Skip that guard and your shortcut eats the letter someone is typing into a search box.

tabIndex is rare here, and that is correct

There are exactly seven tabIndex attributes under components/. The default — let the DOM order decide — is almost always right, so treat each one as needing a reason.

The good reason is removing a duplicate stop. In components/molecules/AreaFilter/AreaFilterSection/AreaFilterSection.tsx, each area row is a real <button aria-pressed={isSelected}> that visually contains a MUI Checkbox:

<Checkbox
checked={isSelected}
tabIndex={-1}
disableRipple
size="small"
sx={{ ...drawerCheckboxSX, padding: 0, pointerEvents: 'none' }}
/>

The checkbox is decoration; the button carries the state. tabIndex={-1} plus pointerEvents: 'none' keeps it from becoming a second tab stop inside its own row. This is the pattern to copy when a control looks like a checkbox but behaves like a toggle button.

Never use a positive tabIndex. It jumps that element ahead of everything with tabIndex={0} and desynchronises tab order from visual order for the whole page. jsx-a11y/tabindex-no-positive warns on it, and the repo currently has none — keep it that way.

Focus that goes nowhere

Two live examples of the same class of bug.

Restoring focus after a control disappears. In components/molecules/MapButtons/LayersMenu/LayersMenu.tsx, clearing the search field destroys the clear button that was just clicked — so focus would fall to <body>:

onClick={() => {
setSearchTerm('');
setTimeout(() => inputRef.current?.focus(), 0);
}}

The setTimeout(…, 0) is not a hack for a race; it defers the focus() until after React has committed the render that unmounts the button. Any time an interaction removes the element that was focused, decide explicitly where focus goes next.

Losing focus to a throwaway element. The execCommand('copy') fallback in components/molecules/MapCoordinatesPopover/MapCoordinatesPopover.tsx appends an offscreen <textarea>, focuses it, copies, then removes it — and never restores focus. A keyboard user who copies a coordinate is returned to <body> and has to Tab from the top of the page. Capture document.activeElement before the swap and refocus it after; better, drop the fallback in browsers where navigator.clipboard.writeText exists, which is the path already tried first.

Dialogs: let MUI trap focus

Do not hand-roll a focus trap. MUI's Modal, Dialog, and Drawer already trap focus, restore it to the trigger on close, and handle Escapeatoms/Modal/ModalComponent.tsx and atoms/DraggableModalComponent both build on Modal for exactly this reason.

What MUI cannot do for you is make custom chrome operable. components/atoms/DraggableModalComponent/DraggableModalComponent.tsx accounts for 8 of the repo's a11y warnings on its own: the drag handle and all eight resize handles are divs with onMouseDown and nothing else, so the modal can be moved and resized only with a pointer. If you add drag or resize affordances, pair them with arrow-key handling on a focusable element, or accept that the feature is pointer-only and make sure nothing essential is behind it.

Next: Forms and media.