Skip to content

Commit 11bca6c

Browse files
committed
fix(wcag): Legend panel issues
* documentation: update accessibility best practices md file (work in progress) * geolocator: add aria-disabled to clear filters button when no filters selected * geolocator: updated loading status region for A11Y (aria-live) (ProgressBar) * global: updates to use unique ids * global styles: add css rule for aria-disabled icon buttons (buttonOutline) * global styles: add reusable style utility for visuallyHidden * legend layer: remove truncation from legend title * legend layer: remove tooltips from non interactive elements (legend title) * legend layer: add accessible code for loading layer * legend layer ctrl: fix to use aria-disabled instead of disabled attribute on IconButtons (for consistent focus management) * legend layer ctrl: improve performance by memoizing control action handlers and reading state values imperatively from the store within handlers * legend panel: fix fullscreen icon button focus management * legend panel: fix to allow both zoom icon buttons to retain focus after being pressed * legend panel: improve aria and semantic HTML implementation * legend panel: fix images that open a lightbox to use an interactive element (button) * legend panel: fix focus management on lightbox close (returns focus to triggering item) * legend panel fullscreen: update panel title to be more descriptive (read only) * panel and other modals: improve aria and semantic HTML implementation * panel and appBar and related: updated to use consistent ID naming conventions (mapId-containerType...) * switch: make label required for accessibility * switch: make focus indicators more noticeable * switch: add unique id to associate the label to the switch * use-light-box: update to use separate props for image alt test and focus management element ID * use-light-box (global): update to set alt text to "" where descriptive alt text is unavailable
1 parent a2f17d4 commit 11bca6c

37 files changed

+778
-286
lines changed

docs/app/accessibility.md

Lines changed: 169 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,185 @@
1-
## Accessibility
1+
# Accessibility
22

33
_Work in progress_
44

55
The viewer needs to be accessible for keyboard and screen reader. It's should follow WCAG 2.1 requirements: https://www.w3.org/TR/WCAG21
66

7-
React and accessibilty documentation: https://reactjs.org/docs/accessibility.html
7+
React and accessibility documentation: https://reactjs.org/docs/accessibility.html
88

99
We need to trap the navigation inside the map element: https://mui.com/base-ui/react-focus-trap and add a skip link to go over
1010

1111
Manage focus (Modal Dialogs or any full-screen tasks): https://www.npmjs.com/package/react-focus-on
1212

13-
Further documenation: https://simplyaccessible.com/article/react-a11y/
13+
Further documentation: https://simplyaccessible.com/article/react-a11y/
1414

1515
Additional tools:
1616
https://mn.gov/mnit/about-mnit/accessibility/maps/
1717
https://microsoftedge.microsoft.com/addons/detail/axe-devtools-web-access/kcenlimkmjjkdfcaleembgmldmnnlfkn
1818
https://accessibilityinsights.io/downloads/
1919
https://github.com/dequelabs/axe-core
20+
21+
Reference:
22+
https://www.w3.org/WAI/WCAG22/quickref/?currentsidebar=%23col_overview&versions=2.1&levels=aaa#consistent-navigation
23+
24+
## Best Practices
25+
26+
_Work in progress_
27+
28+
### 1- Use unique and valid element IDs
29+
30+
[update this once/if there's a helper function in place]
31+
32+
Every element id in the DOM must be unique. Duplicate IDs break label-input associations (htmlFor), invalidate ARIA relationships (aria-labelledby, aria-describedby), and cause screen readers to behave unpredictably.
33+
34+
- ensure ids are always unique
35+
- use kebab-case (as much as possible)
36+
- layer paths can generate invalid ids
37+
38+
Suggested format (goes from least specific to most specific)
39+
40+
```typescript
41+
// Start by prefixing ids with the following
42+
${mapId}-${containerType}-[element]
43+
${mapId}-${containerType}-${panelId}-[element]
44+
45+
// If a unique ID is required
46+
// uniqueId could be generated from:
47+
// const id = useId();
48+
// const id = generateId(8);
49+
${mapId}-${containerType}-[element]-${uniqueId}
50+
${mapId}-${containerType}-${panelId}-[element]-${uniqueId}
51+
52+
```
53+
54+
### 2- Use descriptive ARIA labels
55+
56+
ARIA labels should clearly describe an element's purpose, not just its type or location. Generic labels like "button" or "link" are redundant since screen readers already announce the element's role — and repeating the same label across multiple elements (e.g., several "Learn more" buttons) leaves keyboard and screen reader users with no way to distinguish between them. Each label should convey enough context to make sense in isolation.
57+
58+
```typescript
59+
60+
// bad
61+
aria-label="Toggle visibility"
62+
63+
// better
64+
aria-label="Toggle visibility - Confederation to 1914"
65+
```
66+
67+
### 3- Use Semantic Elements for Interactions
68+
69+
Clickable `<div>` and `<span>` elements are inaccessible by default — they receive no keyboard focus, emit no semantic role, and are invisible to assistive technologies. Native `<button>` and `<a>` elements come with built-in keyboard support, appropriate ARIA roles, and browser-managed focus behaviour at no extra cost. In MUI, use Button, IconButton, and Link components over attaching onClick handlers to arbitrary elements.
70+
71+
### 4- Use IconButton for buttons without labels
72+
73+
The IconButton component in GeoView has built-in accessibility support through its implementation at ui/icon-button/icon-button.tsx:
74+
75+
### 5- Validate HTML Output and WCAG level 2.1 AA
76+
77+
From the browsers's developer tools, copy a section of generated code from a map, or a map component, and validate it
78+
79+
- W3C.org validator
80+
- AI tool
81+
82+
### 6- ARIA best practices
83+
84+
- aria-pressed: Use aria-pressed rather than changing labels dynamically (can be confusing to screen readers
85+
- aria-controls:
86+
- ...
87+
88+
### 7- Focus management: Use aria-disabled instead of disabled on UI elements that toggle between enabled/disabled states
89+
90+
When a button has keyboard focus and becomes disabled on press, focus is lost and jumps unpredictably to another element, disorienting keyboard users who lose track of their position in the interface.
91+
92+
- Use aria-disabled instead of disabled
93+
- Style the aria-disabled element to look like it would if disabled
94+
- Add early return in event handler to prevent action when aria-disabled is true
95+
96+
```typescript
97+
const handleClick = (e) => {
98+
if (isDisabled) return;
99+
// real logic
100+
};
101+
102+
<button
103+
aria-disabled={isDisabled}
104+
onClick={handleClick}
105+
>
106+
Submit
107+
</button>
108+
```
109+
110+
### 8- Focus management: Avoid removing buttons from the DOM
111+
112+
- Causes focus management issues
113+
- Add example here
114+
115+
### 9- Focus management: Restore focus when removing elements from the DOM
116+
117+
- If removing elements from the DOM, ensure that focus is placed somewhere that is logical to keyboard users.
118+
119+
### 10- Handle esc key
120+
121+
- Use “handleEscapeKey”
122+
123+
### 11- Announce loading states and progress updates using ARIA live regions
124+
125+
```typescript
126+
{/* WCAG - ARIA live region for screen reader announcements */}
127+
<Box sx={sxClasses.visuallyHidden} role="status" aria-live="polite" aria-atomic="true">
128+
{statusMessage}
129+
</Box>
130+
131+
{isLoading && (
132+
<Box sx={sxClasses.progressBar}>
133+
<ProgressBar aria-label={t('geolocator.loadingResults') || undefined} />
134+
</Box>
135+
)}
136+
```
137+
138+
## Quick Testing Tips and Notes
139+
140+
_Work in progress_
141+
142+
### WCAG SC 1.4.4 - Resize text
143+
144+
- Set browser zoom to 200%
145+
146+
### WCAG SC 1.4.5 - Images of Text
147+
148+
This does not include text that is part of a picture that contains significant other visual content. Examples of such pictures include graphs, screenshots, and diagrams which visually convey important information through more than just text.
149+
150+
[Reference](https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-text-presentation.html#:~:text=1.4.5%20Images%20of%20Text,to%20the%20information%20being%20conveyed.)
151+
152+
### WCAG SC 1.4.10 - Reflow
153+
154+
- Resize browser window to 1280 pixels wide and set zoom to 400%
155+
156+
## Limitations
157+
158+
_Work in progress_
159+
160+
### 1 - Global
161+
162+
English layer names embedded in French UI may lack lang="en" (and vice versa). This is often defensible:
163+
164+
- Content owner's responsibility
165+
- Some technical data may not always have a translation and It wouldn't be feasible to detect the language or origin
166+
167+
[Reference 1](https://laws.justice.gc.ca/eng/regulations/SOR-2021-241/nifnev.html)
168+
169+
[Reference 2](https://www.canada.ca/en/employment-social-development/programs/accessible-canada/regulations-summary-act/amendment.html)
170+
171+
### 2 - Legend Panel
172+
173+
#### WCAG SC 1.1.1 Non-text Content — Known limitation
174+
175+
Images that appear in the legend panel (and their corresponding light boxes) do not have descriptive text available for them. Therefore, they have been made to use empty alt attributes.
176+
177+
The legend symbol images cannot be programmatically described at this time. See SC 1.3.1 — Known limitation. Fixing that issue would resolve this limitation as well.
178+
179+
#### WCAG SC 1.3.1 Info and Relationships — Known limitation
180+
181+
The legend symbol images cannot be programmatically described at this time. The visual relationship between each symbol's appearance and its map class (e.g. colour, size, shape encoding magnitude) is not available to AT users.
182+
183+
**What this means for users in practice** — AT users are not blocked from operating the legend (they can show/hide classes by name), but they cannot independently interpret what the map symbols look like. This is a partial conformance gap rather than a complete barrier to use.
184+
185+
**What would unblock the fix** — if the symbology data driving each legend image is available in the layer configuration (e.g. colour hex, symbol type, size range), those values could be used to auto-generate sr-only descriptions programmatically without manual authoring per symbol. That might be worth investigating as a future enhancement.

packages/geoview-core/public/locales/en/translation.json

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,15 @@
2020
"guide": "Guide",
2121
"fullScreen": "Full screen",
2222
"processing": "Processing __param__ element(s) of __param__",
23-
"overview": "Overview",
2423
"clickEnlarge": "Click to enlarge",
2524
"show": "Show",
2625
"hide": "Hide",
26+
"toggle": "Toggle",
2727
"none": "No",
2828
"clearSearch": "Clear search",
2929
"enable": "Enable",
30-
"disable": "Disable"
30+
"disable": "Disable",
31+
"panelLabel": "{{title}} panel"
3132
},
3233
"mapnav": {
3334
"arianavbar": "Vertical button group for map navigation",
@@ -105,6 +106,7 @@
105106
},
106107
"legend": {
107108
"title": "Legend",
109+
"titleFullScreen": "Legend - Overview (read only)",
108110
"removeLayer": "Remove layer",
109111
"zoomTo": "Zoom to layer",
110112
"addLayer": "Add a layer",
@@ -119,7 +121,11 @@
119121
"legendInstructions": "Legend Instructions",
120122
"noLayersAdded": "No layers added to the map",
121123
"noLayersAddedDescription": "Add layers to the map by clicking on the 'Layers' button and adding the layers you want to display.",
122-
"selectLayerAndScroll": "Show in Layers panel"
124+
"selectLayerAndScroll": "Show in Layers panel",
125+
"layerLoadingDescriptive": "Loading layer {{layerName}}",
126+
"layerLoadedDescriptive": "Layer {{layerName}} loaded successfully",
127+
"layerErrorDescriptive": "Error loading layer {{layerName}}",
128+
"layerOutOfRangeScreenReader": "(visibility limited by scale)"
123129
},
124130
"layers": {
125131
"title": "Layers",
@@ -397,7 +403,8 @@
397403
"province": "Province",
398404
"category": "Category",
399405
"clearFilters": "Clear filters",
400-
"noFilter": "No filter"
406+
"noFilter": "No filter",
407+
"loadingResults": "Loading search results"
401408
},
402409
"hovertooltip": {
403410
"alticon": "Selected feature icon"

packages/geoview-core/public/locales/fr/translation.json

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,18 @@
1717
"openFullscreen": "Ouvrir en plein écran",
1818
"closeFullscreen": "Fermer le plein écran",
1919
"openGuide": "Ouvrir le guide",
20-
"overview": "Aperçu",
2120
"guide": "Guide",
2221
"fullScreen": "Plein écran",
2322
"processing": "Traitement de __param__ element(s) sur __param__",
2423
"clickEnlarge": "Cliquer pour agrandir",
2524
"show": "Afficher",
2625
"hide": "Masquer",
26+
"toggle": "Basculer",
2727
"none": "Aucun",
2828
"clearSearch": "Effacer la recherche",
2929
"enable": "Activer",
30-
"disable": "Désactiver"
30+
"disable": "Désactiver",
31+
"panelLabel": "Panneau de {{title}}"
3132
},
3233
"mapnav": {
3334
"arianavbar": "Groupe de buttons vertical pour navigation sur la carte",
@@ -105,6 +106,7 @@
105106
},
106107
"legend": {
107108
"title": "Légende",
109+
"titleFullScreen": "Légende - Aperçu (en lecture seule)",
108110
"removeLayer": "Retirer la couche",
109111
"zoomTo": "Zoom sur la couche",
110112
"addLayer": "Ajouter une couche",
@@ -119,7 +121,11 @@
119121
"legendInstructions": "Legend Instructions",
120122
"noLayersAdded": "Aucune couche ajoutée",
121123
"noLayersAddedDescription": "Ajoutez des couches à la carte en cliquant sur le bouton 'Couches' et en sélectionnant les couches que vous souhaitez afficher.",
122-
"selectLayerAndScroll": "Voir dans le panneau Couches"
124+
"selectLayerAndScroll": "Voir dans le panneau Couches",
125+
"layerLoadingDescriptive": "Chargement de la couche {{layerName}}",
126+
"layerLoadedDescriptive": "La couche {{layerName}} a été chargée avec succès",
127+
"layerErrorDescriptive": "Erreur lors du chargement de la couche {{layerName}}",
128+
"layerOutOfRangeScreenReader": "(visibilité limitée par l'échelle)"
123129
},
124130
"layers": {
125131
"title": "Couches",
@@ -397,7 +403,8 @@
397403
"province": "Province",
398404
"category": "Catégorie",
399405
"clearFilters": "Effacer les filtres",
400-
"noFilter": "Aucun Filtre"
406+
"noFilter": "Aucun Filtre",
407+
"loadingResults": "Chargement des résultats de recherche"
401408
},
402409
"hovertooltip": {
403410
"alticon": "Symbol de l'élément sélectionné"

packages/geoview-core/src/core/components/app-bar/app-bar-helper.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { Dispatch, SetStateAction } from 'react';
2+
23
import type { TypeButtonPanel } from '@/ui/panel/panel-types';
3-
import type { ButtonPanelType } from './app-bar';
44
import { CONTAINER_TYPE } from '@/core/utils/constant';
55

6+
import type { ButtonPanelType } from './app-bar';
7+
68
export const helpOpenClosePanelByIdState = (
79
buttonId: string,
810
setterCallback: Dispatch<SetStateAction<ButtonPanelType>>,

packages/geoview-core/src/core/components/app-bar/app-bar.tsx

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type { TypeButtonPanel, TypePanelProps } from '@/ui/panel/panel-types';
2121
import ExportButton from '@/core/components/export/export-modal-button';
2222
import {
2323
useUIActiveFocusItem,
24+
useUIActiveTrapGeoView,
2425
useUIAppbarComponents,
2526
useUIActiveAppBarTab,
2627
useUIHiddenTabs,
@@ -38,7 +39,7 @@ import Version from './buttons/version';
3839
import Share from './buttons/share';
3940
import { getSxClasses } from './app-bar-style';
4041
import { enforceArrayOrder, helpClosePanelById, helpOpenPanelById } from './app-bar-helper';
41-
import { CONTAINER_TYPE, TIMEOUT } from '@/core/utils/constant';
42+
import { CONTAINER_TYPE, LIGHTBOX_SELECTORS, TIMEOUT } from '@/core/utils/constant';
4243
import type { TypeValidAppBarCoreProps } from '@/api/types/map-schema-types';
4344
import { DEFAULT_APPBAR_CORE, DEFAULT_APPBAR_TABS_ORDER } from '@/api/types/map-schema-types';
4445
import { camelCase, handleEscapeKey } from '@/core/utils/utilities';
@@ -85,6 +86,7 @@ export function AppBar(props: AppBarProps): JSX.Element {
8586
const { tabId, isOpen, isFocusTrapped } = useUIActiveAppBarTab();
8687
const hiddenTabs = useUIHiddenTabs();
8788
const { hideClickMarker } = useMapStoreActions();
89+
const activeTrapGeoView = useUIActiveTrapGeoView();
8890

8991
const geoviewElement = useAppGeoviewHTMLElement().querySelector('[id^="mapTargetElement-"]') as HTMLElement;
9092

@@ -363,15 +365,25 @@ export function AppBar(props: AppBarProps): JSX.Element {
363365
.map((panelName: string) => {
364366
// Get the button panel configuration for this panel name
365367
const buttonPanel = buttonPanels[panelName];
368+
366369
// Only render if the button is explicitly set to visible
367370
if (buttonPanel?.button.visible !== undefined && buttonPanel?.button.visible) {
371+
// WCAG - Compute ARIA attributes before rendering
372+
const isPanelOpen: boolean = tabId === buttonPanel.button.id && isOpen;
373+
const expandedState: 'true' | 'false' = isPanelOpen ? 'true' : 'false';
374+
const ariaControls: string | undefined = activeTrapGeoView ? undefined : getButtonElementId(buttonPanel.button.id!, '-panel');
375+
const ariaExpanded: 'true' | 'false' | undefined = activeTrapGeoView ? undefined : expandedState;
376+
368377
return (
369378
<ListItem key={buttonPanel.button.id}>
370379
<IconButton
371380
id={getButtonElementId(buttonPanel.button.id!, '-panel-btn')}
372-
aria-controls={getButtonElementId(buttonPanel.button.id!, '-panel')}
373381
aria-label={t(buttonPanel.button['aria-label'])}
374-
aria-expanded={tabId === buttonPanel.button.id && isOpen ? 'true' : 'false'}
382+
// In WCAG mode, panels are treated as dialogs because they are focus-trapped, so we set aria-haspopup to dialog to indicate that.
383+
aria-haspopup={activeTrapGeoView ? 'dialog' : undefined}
384+
// In default mode, panels are treated as regions, so we use aria-controls and aria-expanded to indicate the relationship and state.
385+
aria-controls={ariaControls}
386+
aria-expanded={ariaExpanded}
375387
tooltipPlacement="right"
376388
className={`buttonFilled ${tabId === buttonPanel.button.id && isOpen ? 'active' : ''}`}
377389
size="small"
@@ -408,8 +420,6 @@ export function AppBar(props: AppBarProps): JSX.Element {
408420
<ListItem>
409421
<ExportButton
410422
id={`${mapId}-${CONTAINER_TYPE.APP_BAR}-export-modal-btn`}
411-
ariaControls={`${mapId}-export-modal`}
412-
ariaExpanded={activeModalId === DEFAULT_APPBAR_CORE.EXPORT}
413423
className={` buttonFilled ${activeModalId === DEFAULT_APPBAR_CORE.EXPORT ? 'active' : ''}`}
414424
/>
415425
</ListItem>
@@ -439,11 +449,18 @@ export function AppBar(props: AppBarProps): JSX.Element {
439449
button={buttonPanel.button}
440450
onOpen={buttonPanel.onOpen}
441451
onClose={hideClickMarker}
442-
onKeyDown={(event: KeyboardEvent) =>
452+
onKeyDown={(event: KeyboardEvent) => {
453+
// Early exit if lightbox is handling ESC
454+
if (event.key === 'Escape') {
455+
const isLightboxOpen = document.querySelector(LIGHTBOX_SELECTORS.ROOT) !== null;
456+
if (isLightboxOpen) {
457+
return;
458+
}
459+
}
443460
handleEscapeKey(event.key, getButtonElementId(buttonPanel.button?.id ?? '', '-panel-btn'), isFocusTrapped, () => {
444461
setActiveAppBarTab(buttonPanel.button?.id ?? '', false, false);
445-
})
446-
}
462+
});
463+
}}
447464
onGeneralClose={() => {
448465
handleGeneralCloseClicked(buttonPanel.button?.id ?? '');
449466
}}

0 commit comments

Comments
 (0)