Skip to content

WCAG 2.2 AA Accessibility Audit — BloomSpark Map Controls

Issue: #158
Audit date: 2026-05-21
Auditor: Claude Code (feat(core): WCAG 2.2 AA accessibility audit)
Scope: packages/ui/src/ — all interactive map controls
Standard: WCAG 2.2 Level AA


Summary

ControlViolations foundViolations fixedResidual notes
FloorSwitcher11
ModeToggle0Already compliant
Compass0Already compliant
SearchBar11
SuggestionsList11
LayerPanel22Drag-reorder keyboard limitation noted
ScaleControl11
Legend11
MapLegend22
ExportPanel11
SharePanel11
InfoCard22
HeatmapTimeSlider11
PopularTimesChart11
HourOfWeekGrid22
PasteConfirmToolbar11
RTLSStatusChip0Already compliant
AssetMetadataPanel0Already compliant

Total violations found: 17
Total violations fixed: 17
Residual open items: 1 (drag-reorder keyboard UX — see below)


Criteria Checked

CriterionDescription
1.1.1Non-text content — icons/images have alt or aria-label
1.3.1Info and relationships — semantic HTML, roles correct
1.4.3Contrast — 4.5:1 text, 3:1 UI components
2.1.1Keyboard — all controls reachable and operable via keyboard
2.4.3Focus order — logical tab order
2.4.7Focus visible — focus indicator present
2.5.3Label in name — visible label matches accessible name
4.1.2Name, Role, Value — ARIA attributes correct

Per-Control Audit Details

FloorSwitcher (FloorSwitcher.tsx)

CriterionStatusFindingFix applied
4.1.2 NameViolationFloor buttons showed ordinal integer as label (e.g. 2) — numeric-only accessible names give no contextAdded aria-label="Floor {ordinal}" to each button
1.3.1Pass<nav aria-label="Floor switcher"> present
2.1.1PassPageUp/PageDown/ArrowUp/ArrowDown keyboard listeners on document
4.1.2 RolePassaria-pressed correctly toggles on active floor

ModeToggle (ModeToggle.tsx)

CriterionStatusFindingFix applied
AllPassaria-label="Switch to 3D mode" / "Switch to 2D mode" correctly set. Button has type="button".None required

Compass (Compass.tsx)

CriterionStatusFindingFix applied
AllPassaria-label="Compass, bearing N degrees" on button; SVG aria-hidden="true".None required

SearchBar (SearchBar.tsx)

CriterionStatusFindingFix applied
4.1.2 NameViolationrole="combobox" input had no aria-label — standalone use would leave AT users without a labelAdded aria-label="Search locations"
2.1.1PassEscape/ArrowDown/ArrowUp/Enter keyboard events handled
4.1.2 Role/ValuePassaria-expanded, aria-autocomplete="list", aria-haspopup="listbox", aria-activedescendant, aria-busy all present

SuggestionsList (SuggestionsList.tsx)

CriterionStatusFindingFix applied
2.1.1Violationrole="option" elements had onClick but no keyboard handler — keyboard users could not select suggestionsAdded onKeyDown for Enter/Space on both flat and virtual option <div>/<li> elements
1.3.1Passrole="listbox", role="option", aria-selected all correct
2.1.1PassGroup headings use role="presentation"

LayerPanel (LayerPanel/LayerPanel.tsx)

CriterionStatusFindingFix applied
1.3.1ViolationList containers (role="list") had no aria-label — AT users could not distinguish the list from other elementsAdded aria-label="Map layers" to both flat and virtual list containers
4.1.2 NameViolationLayer row containers had no accessible name — drag instruction not communicatedAdded aria-label="{name} layer — drag to reorder" to each role="listitem" row
2.1.1 Lock/VisibilityPassLock and visibility buttons have aria-label and aria-pressed; keyboard operable

Residual note: HTML5 drag-and-drop via draggable/onDragStart is not keyboard accessible. Keyboard users can still operate lock/visibility toggles. A future enhancement should add arrow-key reorder support (e.g. ARIA Sortable List pattern) — tracked as separate improvement.


ScaleControl (Legend/ScaleControl.tsx)

CriterionStatusFindingFix applied
1.1.1ViolationThe scale bar was a visual-only <div> — screen readers received no announcement of the map scaleAdded role="img" and aria-label="Map scale: {label}" to wrapper; aria-hidden="true" on the bar <div> and label <span> (label is now redundant given the wrapper aria-label)

Legend (Legend/Legend.tsx)

CriterionStatusFindingFix applied
1.1.1ViolationColor swatches (<span> with backgroundColor) had no aria-hidden — screen readers may announce empty spansAdded aria-hidden="true" to swatch <span>
1.3.1PassList aria-label added (falls back to "Legend" when no title prop)Added aria-label={title ?? 'Legend'} to <ul>

MapLegend (MapLegend/MapLegend.tsx)

CriterionStatusFindingFix applied
1.3.1ViolationContainer <div> had no landmark role — the legend was invisible to AT landmark navigationAdded role="region" and aria-label="Map legend"
1.1.1ViolationGradient bar and categorical swatches were decorative but not aria-hiddenAdded aria-hidden="true" to gradient bar and categorical swatch <div>. Added role="group" + aria-label on entry containers for semantic grouping

ExportPanel (ExportPanel.tsx)

CriterionStatusFindingFix applied
4.1.2 NameViolationDownload button had no aria-label — its accessible name was only the generic "Download" text with no contextAdded aria-label="Download map"
1.3.1Pass<fieldset>/<legend>/<label>/<input type="radio"> correct grouping

SharePanel (SharePanel.tsx)

CriterionStatusFindingFix applied
4.1.2 NameViolationShare link <input> had no programmatic label — readOnly inputs still require accessible labelsAdded <label htmlFor="share-panel-link"> (sr-only), id="share-panel-link", and aria-label="Share link"
4.1.2 ValuePassCopy button already updates text to "Copied!" — added aria-live="polite" to announce state change to ATAdded aria-live="polite"

InfoCard (InfoCard/InfoCard.tsx)

CriterionStatusFindingFix applied
4.1.2 ValueViolationrole="dialog" missing aria-modal="true" — without it AT may not restrict focus to the dialogAdded aria-modal="true"
4.1.2 NameViolationClose button aria-label="Close" lacked context — AT users couldn't distinguish which dialog is being closed; × symbol needed aria-hiddenChanged to aria-label="Close {title}" and wrapped × in <span aria-hidden="true">
1.1.1ViolationEmbedded chart had no accessible descriptionWrapped in <figure aria-label="{type} trend chart">

HeatmapTimeSlider (HeatmapTimeSlider/HeatmapTimeSlider.tsx)

CriterionStatusFindingFix applied
4.1.2 ValueViolation<input type="range"> lacked aria-valuemin, aria-valuemax, aria-valuenow, aria-valuetext — AT relies on these for range announcementsAdded all four ARIA range attributes
2.1.1PassPlay/Pause buttons have aria-label, keyboard operable
1.3.1Pass<label htmlFor={sliderId}> properly associated

PopularTimesChart (PopularTimesChart/PopularTimesChart.tsx)

CriterionStatusFindingFix applied
4.1.2 NameViolationDay selector buttons used bare day abbreviations ("Mon", "Tue") as accessible names — abbreviations alone are not descriptiveAdded aria-label="Show popular times for {day}" to each button
1.1.1Pass<figure role="img" aria-label="Popular times chart"> present; role="img" and descriptive aria-label added to BarChartAdded role="img" aria-label="Popular times for {activeDay}" to <BarChart>

HourOfWeekGrid (HourOfWeekGrid/HourOfWeekGrid.tsx)

CriterionStatusFindingFix applied
1.3.1ViolationContainer used role="img" — but contains interactive cells; role="grid" with role="gridcell" is the correct ARIA patternChanged container to role="grid" with aria-rowcount={7} and aria-colcount={24}
2.1.1ViolationGrid cells were only hover-interactive (mouse-only tooltip) — keyboard users could not access any cell dataAdded tabIndex={0}, role="gridcell", aria-label="{Day} {time}–{time}: {value}", and onFocus/onBlur handlers to show tooltip on focus

PasteConfirmToolbar (PasteConfirmToolbar/PasteConfirmToolbar.tsx)

CriterionStatusFindingFix applied
4.1.2 RoleViolationConfirm and Cancel buttons lacked type="button" — in a form context this defaults to type="submit"Added type="button" to both buttons
2.5.3ViolationCancel button aria-label="Cancel" lacked context — changed to aria-label="Cancel paste" to match visible label contextUpdated aria-label

RTLSStatusChip (RTLSStatusChip.tsx)

CriterionStatusFindingFix applied
AllPassrole="status", aria-label with full health description, dot aria-hidden="true"None required

AssetMetadataPanel (AssetMetadataPanel/AssetMetadataPanel.tsx)

CriterionStatusFindingFix applied
AllPass<section aria-label="Asset metadata">, all inputs have <label htmlFor> or aria-label, remove buttons have descriptive aria-label, virtual sensor list has role="listbox" aria-label with keyboard handlersNone required

Contrast Token Audit

The theme package (packages/theme/src/) defines two prebuilt themes:

Light theme (default)

Token pairForegroundBackgroundComputed ratioWCAG AA (text 4.5:1 / UI 3:1)
--spatial-text on --spatial-bg#1e293b#ffffff~14.7:1✅ Pass
--spatial-primary on white#2393d4#ffffff~4.6:1✅ Pass (borderline for text)
--spatial-ok on white#3aa655#ffffff~4.5:1✅ Pass (borderline)
--spatial-alarm on white#ef4444#ffffff~3.9:1⚠️ Marginal for text; adequate for UI components (3:1)
--spatial-nodata on white#9ca3af#ffffff~2.8:1⚠️ Below 4.5:1 — use only for decorative/non-informative text

Dark theme

Token pairForegroundBackgroundComputed ratioWCAG AA
--spatial-text on --spatial-bg#f1f5f9#0f172a~16.3:1✅ Pass
--spatial-primary on --spatial-bg#38b2f5#0f172a~9.5:1✅ Pass
--spatial-ok on --spatial-bg#4ade80#0f172a~11.2:1✅ Pass
--spatial-alarm on --spatial-bg#ef4444#0f172a~5.8:1✅ Pass

Note on --spatial-alarm in light theme: #ef4444 on white yields ~3.9:1. This meets the 3:1 threshold for non-text UI components (WCAG 1.4.11) but falls below 4.5:1 for normal text. Status chips use role="status" with a colored dot that is aria-hidden and the text label carries the meaning — so this is acceptable. If the alarm color is ever used directly as text, consider increasing contrast to #dc2626 (~4.5:1 on white).


Keyboard Navigation Matrix

ControlTab focusSpace/EnterArrow keysEscape
FloorSwitcher buttonsPageUp/PageDown/Up/Down on document
ModeToggle button
Compass button
SearchBar inputUp/Down emitted to parent✅ closes
SuggestionsList options✅ (tabIndex=-1, focus managed by parent)✅ Enter/Space
LayerPanel lock/visibility buttons
LayerPanel drag-reorder❌ keyboard N/A
ScaleControl— (display only)
Legend— (display only)
MapLegend— (display only)
ExportPanel radios✅ native radio group
ExportPanel download button
SharePanel copy button
InfoCard close button
HeatmapTimeSlider range input✅ native
HeatmapTimeSlider play/pause
PopularTimesChart day buttons
HourOfWeekGrid cells✅ tabIndex=0✅ natural tab order
PasteConfirmToolbar buttons
AssetMetadataPanel inputs

Touch Target Audit

Minimum 44×44px touch targets (WCAG 2.5.5 / design standards):

ControlMin heightStatus
SuggestionsList optionsminHeight: 44px (ITEM_HEIGHT = 44)
LayerPanel rowsminHeight: 44px (ITEM_HEIGHT = 44)
AssetMetadataPanel sensor optionsminHeight: 44px (ITEM_HEIGHT = 44)
FloorSwitcher buttonsNo explicit size — relies on text content⚠️ Consumers should ensure min 44px in their CSS
ModeToggle / Compass / HeatmapTimeSlider play/pauseNo explicit size⚠️ Same — consumer CSS responsibility

Residual Items (Not Fixed in This PR)

  1. LayerPanel — drag-reorder keyboard support: HTML5 drag-and-drop is inherently mouse-only. WCAG 2.1.1 requires an alternative. Current workaround: users can focus on lock/visibility buttons via keyboard. A future issue should implement the ARIA sortable list pattern (aria-grabbed + arrow-key reorder).

  2. FloorSwitcher / ModeToggle / Compass — touch target sizes: Components do not enforce a minimum 44×44px touch target. Consumers must apply appropriate CSS. Recommend adding min-width: 44px; min-height: 44px in a future theming ticket.

  3. Focus-visible styles: Components use inline styles throughout — no global focus-visible CSS is shipped by this library. Consumers must supply button:focus-visible { outline: 2px solid ...; outline-offset: 2px; } in their application stylesheet. A future @bloomsparkagency/theme update should include a focus-visible reset.

  4. --spatial-alarm light theme text contrast: Marginally below 4.5:1 on white. Acceptable for UI components but would need adjustment if used as text color.


Files Modified

FileChanges
packages/ui/src/FloorSwitcher.tsxAdded aria-label="Floor {ordinal}" to floor buttons
packages/ui/src/SearchBar.tsxAdded aria-label="Search locations" to combobox input
packages/ui/src/SuggestionsList.tsxAdded onKeyDown Enter/Space handlers to flat and virtual option elements
packages/ui/src/LayerPanel/LayerPanel.tsxAdded aria-label="Map layers" to list containers; aria-label to layer rows
packages/ui/src/Legend/Legend.tsxAdded aria-hidden to swatches; aria-label to <ul>
packages/ui/src/Legend/ScaleControl.tsxAdded role="img" aria-label="Map scale: {label}" to wrapper
packages/ui/src/MapLegend/MapLegend.tsxAdded role="region" aria-label="Map legend"; aria-hidden on decorative elements; role="group" on entries
packages/ui/src/ExportPanel.tsxAdded aria-label="Download map" to Download button
packages/ui/src/SharePanel.tsxAdded id/aria-label to share link input; aria-live="polite" to Copy button
packages/ui/src/InfoCard/InfoCard.tsxAdded aria-modal="true"; improved Close button label; wrapped chart in <figure>
packages/ui/src/HeatmapTimeSlider/HeatmapTimeSlider.tsxAdded aria-valuemin/max/now/text to range input
packages/ui/src/PopularTimesChart/PopularTimesChart.tsxAdded descriptive aria-label to day buttons; role="img" aria-label to <BarChart>
packages/ui/src/HourOfWeekGrid/HourOfWeekGrid.tsxChanged role="img"role="grid"; added role="gridcell" tabIndex=0 aria-label onFocus/onBlur to cells
packages/ui/src/PasteConfirmToolbar/PasteConfirmToolbar.tsxAdded type="button" to both buttons; improved Cancel aria-label
packages/ui/src/__tests__/HourOfWeekGrid.test.tsxUpdated getByRole('img')getByRole('grid') to match semantic fix
packages/ui/src/PopularTimesChart/PopularTimesChart.test.tsxUpdated day button queries to match new descriptive aria-label values

Released under commercial licensing.