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:
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,TextFieldand 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 Escape — atoms/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.