Markup and ARIA
Two rules cover almost everything, and MDN's
ARIA basics
covers the theory if you want it. Use the native element when one exists — <button> gets
focusability, Enter/Space activation, and the button role for free. Reach for ARIA only
when no native element fits, because a role is a promise: role="radio" tells a screen reader
arrow keys will work, and nothing enforces that but you.
The rest of this page is where this repo keeps that promise and where it doesn't.
The div-as-button backlog
This is the dominant pattern in the a11y warning list, and the navbar is the clearest case. The
60° / 360° / Map view toggles in components/organisms/Navbar/Navbar.tsx are MUI icons and plain
divs carrying the click:
<Tooltip title="360° view">
<div
onClick={() => onLocationViewToggled('wide')}
className={`${styles.navItem} ${styles.threeSixtyBtn}`}
role="button"
aria-label="Location View Toggle 360 View Button"
>
<ThreeSixtyIcon isDisabled={!visibleLocationViewPanels.wide} />
</div>
</Tooltip>
The role and the aria-label are there, so a screen reader announces a button. But there is no
tabIndex, so it is not reachable by keyboard at all, and no onKeyDown, so it could not be
activated even if it were. The control is mouse-only. That is
jsx-a11y/interactive-supports-focus plus jsx-a11y/click-events-have-key-events, both warnings.
The fix is not to add tabIndex={0} and a key handler — it is to delete the div and use a
<button>:
<Tooltip title="360° view">
<button
type="button"
className={styles.navItem}
onClick={() => onLocationViewToggled('wide')}
aria-label="Switch to 360 view"
aria-pressed={visibleLocationViewPanels.wide}
>
<ThreeSixtyIcon isDisabled={!visibleLocationViewPanels.wide} />
</button>
</Tooltip>
Note the label change too. "Location View Toggle 360 View Button" is what the element is; "Switch to 360 view" is what it does, and the role already supplies the word "button".
Roles that promise more than the code delivers
components/atoms/Dropdown/Dropdown.tsx is the worst offender and worth reading in full — it
is short. The menu opens on onMouseEnter and closes on onMouseLeave, so there is no keyboard
path to open it. The items are paragraphs:
<p
key={index}
onClick={() => handleItemClick(item)}
tabIndex={0}
role="menuitem"
className={styles.dropdownItem}
style={{ userSelect: 'none' }}
>
{item}
</p>
Every item is a tab stop, none of them has a key handler, and role="menuitem" is invalid outside
a role="menu" container. A keyboard user can focus each item and activate none of them. If you
need a menu, use MUI's Menu/MenuItem — components/molecules/CameraDetailsTileView/CameraOptionsMenu.tsx
does, and gets aria-haspopup, aria-controls, aria-expanded, and arrow keys with it.
components/molecules/CameraOptionsMenuContent/CameraOptionsMenuContent.tsx is closer. It
builds a role="radiogroup" from divs with role="radio", aria-checked, aria-label,
tabIndex={0}, and an Enter/Space handler — genuinely thoughtful. What it is missing is the
half of the radio contract that arrow keys own: a radio group is one tab stop, and
ArrowLeft/ArrowRight move both selection and focus. Here both radios are tab stops and the
arrow keys do nothing. The roving tabindex sandbox is a
working implementation of the pattern.
aria-hidden on something still focusable
components/molecules/CameraDetailsTileView/CameraDetailsTileView.tsx renders both tab panels
permanently and cross-fades between them:
<div className={[styles.tabPanel, tab === 0 ? styles.tabPanelActive : ''].join(' ')}
aria-hidden={tab !== 0}>
.tabPanel in the sibling .module.scss is opacity: 0; pointer-events: none — not
display: none. So the inactive panel is still in the DOM, its buttons and links are still in the
tab order, and it is marked aria-hidden="true". A keyboard user can Tab into content that
assistive technology has been told does not exist, and focus lands on an invisible control. That
is axe's aria-hidden-focus rule.
Either gate the panel on tab === n so it unmounts, or add inert to the hidden one, which
removes it from the tab order and the accessibility tree together. While you are there: the panels
have no role="tabpanel" and no aria-labelledby pointing back at their MUI Tab, so the tabs
and panels are not associated at all.
Live regions: mount first, write later
The camera count in components/molecules/AreaFilter/AreaScopeBar/AreaScopeBar.tsx is the model:
<span className={styles.count} role="status" aria-live="polite">
The element renders whenever the bar renders; only its text changes. That ordering is the whole
trick — an aria-live region only announces updates to a node that was already in the
accessibility tree. Render the container and its first message in the same commit and most screen
readers say nothing. atoms/Loader does the same thing with role="status" + aria-label="Loading"
aria-busy="true", andCameraMovementZoomuses it for the live zoom readout.
Use role="status" (polite) for anything the operator can finish reading later, and role="alert"
(assertive, interrupts) only for something that needs to stop them — a failed camera command, not
a saved setting.
Hiding decoration
aria-hidden="true" on a purely visual element is correct and cheap. The repo does this for the
freshness dot in ImageFreshnessBadge, the · separator in LastMovedButton, and the avatar
initials in atoms/UserAvatar — each of those repeats information already in the adjacent text.
The rule is simply: hide it if a screen reader announcing it would be noise, and never hide
anything focusable.
Next: Keyboard and focus.