Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 169 additions & 3 deletions docs/app/accessibility.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,185 @@
## Accessibility
# Accessibility

_Work in progress_

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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WIP. Will review once content is closer to being finalized.


React and accessibilty documentation: https://reactjs.org/docs/accessibility.html
React and accessibility documentation: https://reactjs.org/docs/accessibility.html

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

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

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

Additional tools:
https://mn.gov/mnit/about-mnit/accessibility/maps/
https://microsoftedge.microsoft.com/addons/detail/axe-devtools-web-access/kcenlimkmjjkdfcaleembgmldmnnlfkn
https://accessibilityinsights.io/downloads/
https://github.com/dequelabs/axe-core

Reference:
https://www.w3.org/WAI/WCAG22/quickref/?currentsidebar=%23col_overview&versions=2.1&levels=aaa#consistent-navigation

## Best Practices

_Work in progress_

### 1- Use unique and valid element IDs

[update this once/if there's a helper function in place]

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.

- ensure ids are always unique
- use kebab-case (as much as possible)
- layer paths can generate invalid ids

Suggested format (goes from least specific to most specific)

```typescript
// Start by prefixing ids with the following
${mapId}-${containerType}-[element]
${mapId}-${containerType}-${panelId}-[element]

// If a unique ID is required
// uniqueId could be generated from:
// const id = useId();
// const id = generateId(8);
${mapId}-${containerType}-[element]-${uniqueId}
${mapId}-${containerType}-${panelId}-[element]-${uniqueId}

```

### 2- Use descriptive ARIA labels

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.

```typescript

// bad
aria-label="Toggle visibility"

// better
aria-label="Toggle visibility - Confederation to 1914"
```

### 3- Use Semantic Elements for Interactions

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.

### 4- Use IconButton for buttons without labels

The IconButton component in GeoView has built-in accessibility support through its implementation at ui/icon-button/icon-button.tsx:

### 5- Validate HTML Output and WCAG level 2.1 AA

From the browsers's developer tools, copy a section of generated code from a map, or a map component, and validate it

- W3C.org validator
- AI tool

### 6- ARIA best practices

- aria-pressed: Use aria-pressed rather than changing labels dynamically (can be confusing to screen readers
- aria-controls:
- ...

### 7- Focus management: Use aria-disabled instead of disabled on UI elements that toggle between enabled/disabled states

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.

- Use aria-disabled instead of disabled
- Style the aria-disabled element to look like it would if disabled
- Add early return in event handler to prevent action when aria-disabled is true

```typescript
const handleClick = (e) => {
if (isDisabled) return;
// real logic
};

<button
aria-disabled={isDisabled}
onClick={handleClick}
>
Submit
</button>
```

### 8- Focus management: Avoid removing buttons from the DOM

- Causes focus management issues
- Add example here

### 9- Focus management: Restore focus when removing elements from the DOM

- If removing elements from the DOM, ensure that focus is placed somewhere that is logical to keyboard users.

### 10- Handle esc key

- Use “handleEscapeKey”

### 11- Announce loading states and progress updates using ARIA live regions

```typescript
{/* WCAG - ARIA live region for screen reader announcements */}
<Box sx={sxClasses.visuallyHidden} role="status" aria-live="polite" aria-atomic="true">
{statusMessage}
</Box>

{isLoading && (
<Box sx={sxClasses.progressBar}>
<ProgressBar aria-label={t('geolocator.loadingResults') || undefined} />
</Box>
)}
```

## Quick Testing Tips and Notes

_Work in progress_

### WCAG SC 1.4.4 - Resize text

- Set browser zoom to 200%

### WCAG SC 1.4.5 - Images of Text

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.

[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.)

### WCAG SC 1.4.10 - Reflow

- Resize browser window to 1280 pixels wide and set zoom to 400%

## Limitations

_Work in progress_

### 1 - Global

English layer names embedded in French UI may lack lang="en" (and vice versa). This is often defensible:

- Content owner's responsibility
- Some technical data may not always have a translation and It wouldn't be feasible to detect the language or origin

[Reference 1](https://laws.justice.gc.ca/eng/regulations/SOR-2021-241/nifnev.html)

[Reference 2](https://www.canada.ca/en/employment-social-development/programs/accessible-canada/regulations-summary-act/amendment.html)

### 2 - Legend Panel

#### WCAG SC 1.1.1 Non-text Content — Known limitation

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.

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.

#### WCAG SC 1.3.1 Info and Relationships — Known limitation

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.

**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.

**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.
15 changes: 11 additions & 4 deletions packages/geoview-core/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,15 @@
"guide": "Guide",
"fullScreen": "Full screen",
"processing": "Processing __param__ element(s) of __param__",
"overview": "Overview",
"clickEnlarge": "Click to enlarge",
"show": "Show",
"hide": "Hide",
"toggle": "Toggle",
"none": "No",
"clearSearch": "Clear search",
"enable": "Enable",
"disable": "Disable"
"disable": "Disable",
"panelLabel": "{{title}} panel"
},
"mapnav": {
"arianavbar": "Vertical button group for map navigation",
Expand Down Expand Up @@ -105,6 +106,7 @@
},
"legend": {
"title": "Legend",
"titleFullScreen": "Legend - Overview (read only)",
"removeLayer": "Remove layer",
"zoomTo": "Zoom to layer",
"addLayer": "Add a layer",
Expand All @@ -119,7 +121,11 @@
"legendInstructions": "Legend Instructions",
"noLayersAdded": "No layers added to the map",
"noLayersAddedDescription": "Add layers to the map by clicking on the 'Layers' button and adding the layers you want to display.",
"selectLayerAndScroll": "Show in Layers panel"
"selectLayerAndScroll": "Show in Layers panel",
"layerLoadingDescriptive": "Loading layer {{layerName}}",
"layerLoadedDescriptive": "Layer {{layerName}} loaded successfully",
"layerErrorDescriptive": "Error loading layer {{layerName}}",
"layerOutOfRangeScreenReader": "(visibility limited by scale)"
},
"layers": {
"title": "Layers",
Expand Down Expand Up @@ -397,7 +403,8 @@
"province": "Province",
"category": "Category",
"clearFilters": "Clear filters",
"noFilter": "No filter"
"noFilter": "No filter",
"loadingResults": "Loading search results"
},
"hovertooltip": {
"alticon": "Selected feature icon"
Expand Down
15 changes: 11 additions & 4 deletions packages/geoview-core/public/locales/fr/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,18 @@
"openFullscreen": "Ouvrir en plein écran",
"closeFullscreen": "Fermer le plein écran",
"openGuide": "Ouvrir le guide",
"overview": "Aperçu",
"guide": "Guide",
"fullScreen": "Plein écran",
"processing": "Traitement de __param__ element(s) sur __param__",
"clickEnlarge": "Cliquer pour agrandir",
"show": "Afficher",
"hide": "Masquer",
"toggle": "Basculer",
"none": "Aucun",
"clearSearch": "Effacer la recherche",
"enable": "Activer",
"disable": "Désactiver"
"disable": "Désactiver",
"panelLabel": "Panneau de {{title}}"
},
"mapnav": {
"arianavbar": "Groupe de buttons vertical pour navigation sur la carte",
Expand Down Expand Up @@ -105,6 +106,7 @@
},
"legend": {
"title": "Légende",
"titleFullScreen": "Légende - Aperçu (en lecture seule)",
"removeLayer": "Retirer la couche",
"zoomTo": "Zoom sur la couche",
"addLayer": "Ajouter une couche",
Expand All @@ -119,7 +121,11 @@
"legendInstructions": "Legend Instructions",
"noLayersAdded": "Aucune couche ajoutée",
"noLayersAddedDescription": "Ajoutez des couches à la carte en cliquant sur le bouton 'Couches' et en sélectionnant les couches que vous souhaitez afficher.",
"selectLayerAndScroll": "Voir dans le panneau Couches"
"selectLayerAndScroll": "Voir dans le panneau Couches",
"layerLoadingDescriptive": "Chargement de la couche {{layerName}}",
"layerLoadedDescriptive": "La couche {{layerName}} a été chargée avec succès",
"layerErrorDescriptive": "Erreur lors du chargement de la couche {{layerName}}",
"layerOutOfRangeScreenReader": "(visibilité limitée par l'échelle)"
},
"layers": {
"title": "Couches",
Expand Down Expand Up @@ -397,7 +403,8 @@
"province": "Province",
"category": "Catégorie",
"clearFilters": "Effacer les filtres",
"noFilter": "Aucun Filtre"
"noFilter": "Aucun Filtre",
"loadingResults": "Chargement des résultats de recherche"
},
"hovertooltip": {
"alticon": "Symbol de l'élément sélectionné"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { Dispatch, SetStateAction } from 'react';

import type { TypeButtonPanel } from '@/ui/panel/panel-types';
import type { ButtonPanelType } from './app-bar';
import { CONTAINER_TYPE } from '@/core/utils/constant';

import type { ButtonPanelType } from './app-bar';

export const helpOpenClosePanelByIdState = (
buttonId: string,
setterCallback: Dispatch<SetStateAction<ButtonPanelType>>,
Expand Down
33 changes: 25 additions & 8 deletions packages/geoview-core/src/core/components/app-bar/app-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type { TypeButtonPanel, TypePanelProps } from '@/ui/panel/panel-types';
import ExportButton from '@/core/components/export/export-modal-button';
import {
useUIActiveFocusItem,
useUIActiveTrapGeoView,
useUIAppbarComponents,
useUIActiveAppBarTab,
useUIHiddenTabs,
Expand All @@ -38,7 +39,7 @@ import Version from './buttons/version';
import Share from './buttons/share';
import { getSxClasses } from './app-bar-style';
import { enforceArrayOrder, helpClosePanelById, helpOpenPanelById } from './app-bar-helper';
import { CONTAINER_TYPE, TIMEOUT } from '@/core/utils/constant';
import { CONTAINER_TYPE, LIGHTBOX_SELECTORS, TIMEOUT } from '@/core/utils/constant';
import type { TypeValidAppBarCoreProps } from '@/api/types/map-schema-types';
import { DEFAULT_APPBAR_CORE, DEFAULT_APPBAR_TABS_ORDER } from '@/api/types/map-schema-types';
import { camelCase, handleEscapeKey } from '@/core/utils/utilities';
Expand Down Expand Up @@ -85,6 +86,7 @@ export function AppBar(props: AppBarProps): JSX.Element {
const { tabId, isOpen, isFocusTrapped } = useUIActiveAppBarTab();
const hiddenTabs = useUIHiddenTabs();
const { hideClickMarker } = useMapStoreActions();
const activeTrapGeoView = useUIActiveTrapGeoView();

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

Expand Down Expand Up @@ -363,15 +365,25 @@ export function AppBar(props: AppBarProps): JSX.Element {
.map((panelName: string) => {
// Get the button panel configuration for this panel name
const buttonPanel = buttonPanels[panelName];

// Only render if the button is explicitly set to visible
if (buttonPanel?.button.visible !== undefined && buttonPanel?.button.visible) {
// WCAG - Compute ARIA attributes before rendering
const isPanelOpen: boolean = tabId === buttonPanel.button.id && isOpen;
const expandedState: 'true' | 'false' = isPanelOpen ? 'true' : 'false';
const ariaControls: string | undefined = activeTrapGeoView ? undefined : getButtonElementId(buttonPanel.button.id!, '-panel');
const ariaExpanded: 'true' | 'false' | undefined = activeTrapGeoView ? undefined : expandedState;

return (
<ListItem key={buttonPanel.button.id}>
<IconButton
id={getButtonElementId(buttonPanel.button.id!, '-panel-btn')}
aria-controls={getButtonElementId(buttonPanel.button.id!, '-panel')}
aria-label={t(buttonPanel.button['aria-label'])}
aria-expanded={tabId === buttonPanel.button.id && isOpen ? 'true' : 'false'}
// In WCAG mode, panels are treated as dialogs because they are focus-trapped, so we set aria-haspopup to dialog to indicate that.
aria-haspopup={activeTrapGeoView ? 'dialog' : undefined}
// In default mode, panels are treated as regions, so we use aria-controls and aria-expanded to indicate the relationship and state.
aria-controls={ariaControls}
aria-expanded={ariaExpanded}
tooltipPlacement="right"
className={`buttonFilled ${tabId === buttonPanel.button.id && isOpen ? 'active' : ''}`}
size="small"
Expand Down Expand Up @@ -408,8 +420,6 @@ export function AppBar(props: AppBarProps): JSX.Element {
<ListItem>
<ExportButton
id={`${mapId}-${CONTAINER_TYPE.APP_BAR}-export-modal-btn`}
ariaControls={`${mapId}-export-modal`}
ariaExpanded={activeModalId === DEFAULT_APPBAR_CORE.EXPORT}
className={` buttonFilled ${activeModalId === DEFAULT_APPBAR_CORE.EXPORT ? 'active' : ''}`}
/>
</ListItem>
Expand Down Expand Up @@ -439,11 +449,18 @@ export function AppBar(props: AppBarProps): JSX.Element {
button={buttonPanel.button}
onOpen={buttonPanel.onOpen}
onClose={hideClickMarker}
onKeyDown={(event: KeyboardEvent) =>
onKeyDown={(event: KeyboardEvent) => {
// Early exit if lightbox is handling ESC
if (event.key === 'Escape') {
const isLightboxOpen = document.querySelector(LIGHTBOX_SELECTORS.ROOT) !== null;
if (isLightboxOpen) {
return;
}
}
handleEscapeKey(event.key, getButtonElementId(buttonPanel.button?.id ?? '', '-panel-btn'), isFocusTrapped, () => {
setActiveAppBarTab(buttonPanel.button?.id ?? '', false, false);
})
}
});
}}
onGeneralClose={() => {
handleGeneralCloseClicked(buttonPanel.button?.id ?? '');
}}
Expand Down
Loading
Loading