Skip to main content

Forms and media

Every input needs a programmatically associated label, and every non-text element needs a text alternative — WCAG 1.1.1 and 3.3.2, covered properly by WebAIM's forms guide if you want the general version.

This app is unusual on both counts. It has eight labelled inputs in the entire components/ tree, and its primary content is a live camera image that no alt text can describe. Both of those change the advice.

Labels: MUI does it, hand-rolled markup mostly does too

If you use MUI's TextField, pass label and the association is done for you — components/molecules/MapButtons/LayersMenu/LayersMenu.tsx does this with label="Search Layers", and gets a <label for> plus a generated id at no cost.

For hand-rolled inputs, components/molecules/Notifications/AlertMessage/AlertMessage.tsx is the reference:

<label htmlFor="alert-lat" className={styles.coordInputLabel}>Latitude</label>
<input id="alert-lat" type="text" value={latInput}
placeholder="e.g. 37.7749"
onChange={e => setLatInput(e.target.value)}
onBlur={() => applyCoords(latInput, lngInput)} />

Note the placeholder is an example value, not the label — that is the right division. A placeholder disappears the moment someone types, so it can never be the only name a field has.

The gap: errors nobody hears

AlertMessage labels its fields correctly and then drops the other half of the contract:

{
coordError && <p className={styles.coordError}>{coordError}</p>;
}

The message is a bare paragraph. It is not referenced by aria-describedby, the inputs carry no aria-invalid, and there is no role="alert", so when a coordinate fails to parse a sighted user sees red text and a screen-reader user hears nothing at all — the field just silently refuses to take the value. This is the single most common form defect anywhere, and it is here.

The fix is three attributes:

<input id="alert-lat" aria-invalid={Boolean(coordError)}
aria-describedby={coordError ? 'alert-coord-error' : undefined}/>

{coordError && <p id="alert-coord-error" role="alert" className={styles.coordError}>{coordError}</p>}

aria-describedby takes a space-separated list of ids, so a field can point at a hint and an error at once. The field-wiring sandbox shows the correct and broken versions side by side.

Alt text for an image whose content you cannot know

A live camera frame changes every ten seconds. There is no honest description of its content, so the useful alt text names the source, and that is what atoms/IImage requires — alt is a non-optional prop on IImageProps, which is the cheapest possible enforcement, and callers pass the camera name (alt={cameraDetails.cn}).

components/molecules/Timelapse/PlaybackImage/PlaybackImage.tsx shows the two cases in eleven lines:

<img src={displayedUrl} alt="Playback frame"/>
{pendingImage && (
<img key={pendingImage.imageUrl} src={pendingImage.imageUrl} alt="" aria-hidden="true"
style={{ display: 'none' }} onLoad={handlePendingImageLoad} />
)}

The visible frame gets a name. The hidden preloader — which exists only to warm the cache — gets alt="" and aria-hidden, so it is absent from the accessibility tree entirely. That is the correct treatment for any image that is not content.

Filenames are not alt text

jsx-a11y/alt-text is set to error in eslint.config.js, and it passes every one of these:

alt="settings-video-icon"   // ExpandedImageButtons.tsx, three separate icons
alt="cam-movement-btn" // ExpandedImageButtons.tsx
alt="compass-icon" // ExpandedImageButtons.tsx
alt="play-icon" / alt="loop-icon" // PlaybackWrapper.tsx
alt={`layout-symbol-${item.label}`} // CameraOptionsMenuContent.tsx

The rule checks that the attribute exists, never that it says anything. All of these are icons inside a labelled control, which means the icon is decorative and the correct value is alt="" — otherwise a screen reader announces "settings-video-icon, Camera" and the operator hears the filename twice. Worse, ExpandedImageButtons uses the identical alt="settings-video-icon" for the camera, 360, and map icons, so the three toggles are indistinguishable by name.

Rule of thumb: if the icon sits inside a <button> that already has text or an aria-label, the icon's alt is "". Only a standalone image that is the content gets a description.

Canvases expose nothing

There is no <video> or <audio> anywhere in this app, so jsx-a11y/media-has-caption never fires and captions are not a concern here. What is a concern: two <canvas> elements — Timelapse/TimelapseCanvasPlayer and organisms/PeakFinderPanel — and a canvas has no built-in accessible content at all. Neither currently carries a role, an aria-label, or fallback children.

If you extend either, give the canvas an aria-label describing what it renders, and make sure every action available by dragging on it is also available from a labelled control nearby. The timelapse player already has real play/loop buttons; the peak finder's on-image interactions do not have a keyboard equivalent.

Next: Color and typography.