Skip to main content

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/MenuItemcomponents/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: nonenot 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", and CameraMovementZoom uses 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.