From 10d72a09a1b95195ba98da5ab35b1687766d8b9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Nicaisse?= Date: Tue, 13 Oct 2026 21:37:37 +0200 Subject: [PATCH 1/2] feat: add keyboard access to markers, clusters and zoom controls Support Enter and Space activation, Escape dismissal, and visible focus indicators. Keep popup controls usable without activating their parent, and synchronize aria-expanded with popup visibility. Add a Storybook example with marker and cluster popups to inspect their keyboard behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/iotMapManager/css/map.css | 7 ++ src/iotMapManager/css/markers.css | 13 ++- src/iotMapManager/css/popup.css | 32 +++++-- src/iotMapManager/css/tabs.css | 6 +- src/iotMapManager/readme.md | 23 ++++- src/iotMapManager/src/iot-map-icons.ts | 60 ++++++++++++- src/iotMapManager/src/iot-map-manager.ts | 73 +++++++++++++++- src/stories/AccessibleNames.stories.js | 103 +++++++++++++++++++++++ 8 files changed, 302 insertions(+), 15 deletions(-) create mode 100644 src/stories/AccessibleNames.stories.js diff --git a/src/iotMapManager/css/map.css b/src/iotMapManager/css/map.css index 4726aa31..afe8b833 100644 --- a/src/iotMapManager/css/map.css +++ b/src/iotMapManager/css/map.css @@ -41,4 +41,11 @@ color: black; } +.leaflet-control-zoom a:focus-visible { + outline: 3px solid black; + outline-offset: 3px; + box-shadow: 0 0 0 6px white; + position: relative; + z-index: 1; +} diff --git a/src/iotMapManager/css/markers.css b/src/iotMapManager/css/markers.css index 24896cfe..ceb856ea 100644 --- a/src/iotMapManager/css/markers.css +++ b/src/iotMapManager/css/markers.css @@ -12,6 +12,16 @@ /* markers.css */ +.iotmap-divicon:focus-visible { + outline: 3px solid black; + outline-offset: 3px; + box-shadow: 0 0 0 6px white; +} + +.iotmap-divicon:focus-within { + z-index: 10000 !important; +} + .iotmap-markericon{ position: relative; width: 0px; @@ -169,7 +179,8 @@ fill-opacity: 0.5; } -.iotmap-clustericon:hover > .iotmap-clusteropacity{ +.iotmap-clustericon:hover > .iotmap-clusteropacity, +.iotmap-divicon:focus-within > .iotmap-clustericon > .iotmap-clusteropacity { fill-opacity: 1; } diff --git a/src/iotMapManager/css/popup.css b/src/iotMapManager/css/popup.css index daa3a193..ffcf0cec 100644 --- a/src/iotMapManager/css/popup.css +++ b/src/iotMapManager/css/popup.css @@ -25,6 +25,7 @@ height: 10px; width: 10px; font-size: 1px; + visibility: hidden; } .iotmap-marker-selected > .iotmap-marker-popup { @@ -92,10 +93,13 @@ height: 10px; width: 10px; font-size: 1px; + visibility: hidden; } -.iotmap-clustericon:hover > .iotmap-cluster-popup { +.iotmap-clustericon:hover > .iotmap-cluster-popup, +.iotmap-divicon:focus-within > .iotmap-clustericon > .iotmap-cluster-popup { + visibility: visible; position: absolute; bottom:-25px; left:-75px; @@ -116,7 +120,9 @@ z-index: 0; } -.iotmap-clustericon:hover > .iotmap-cluster-big-popup { +.iotmap-clustericon:hover > .iotmap-cluster-big-popup, +.iotmap-divicon:focus-within > .iotmap-clustericon > .iotmap-cluster-big-popup { + visibility: visible; position: absolute; bottom:-25px; left:-115px; @@ -137,25 +143,35 @@ z-index: 0; } -.iotmap-clustericon:hover > .iotmap-cluster-popup .iotmap-pop-up-title, .iotmap-cluster-big-popup .iotmap-pop-up-title { +.iotmap-clustericon:hover > .iotmap-cluster-popup .iotmap-pop-up-title, +.iotmap-divicon:focus-within > .iotmap-clustericon > .iotmap-cluster-popup .iotmap-pop-up-title, +.iotmap-cluster-big-popup .iotmap-pop-up-title { font-size: 20px; } -.iotmap-clustericon:hover > .iotmap-cluster-popup .iotmap-pop-up-title-icon, .iotmap-cluster-big-popup .iotmap-pop-up-title-icon { +.iotmap-clustericon:hover > .iotmap-cluster-popup .iotmap-pop-up-title-icon, +.iotmap-divicon:focus-within > .iotmap-clustericon > .iotmap-cluster-popup .iotmap-pop-up-title-icon, +.iotmap-cluster-big-popup .iotmap-pop-up-title-icon { font-size: 20px; margin-right: 15px; } -.iotmap-clustericon:hover > .iotmap-cluster-popup .iotmap-pop-up-bullet, .iotmap-cluster-big-popup .iotmap-pop-up-bullet { +.iotmap-clustericon:hover > .iotmap-cluster-popup .iotmap-pop-up-bullet, +.iotmap-divicon:focus-within > .iotmap-clustericon > .iotmap-cluster-popup .iotmap-pop-up-bullet, +.iotmap-cluster-big-popup .iotmap-pop-up-bullet { font-size: 12px; color: transparent; } -.iotmap-clustericon:hover > .iotmap-cluster-popup .iotmap-pop-up-body, .iotmap-cluster-big-popup .iotmap-pop-up-body { +.iotmap-clustericon:hover > .iotmap-cluster-popup .iotmap-pop-up-body, +.iotmap-divicon:focus-within > .iotmap-clustericon > .iotmap-cluster-popup .iotmap-pop-up-body, +.iotmap-cluster-big-popup .iotmap-pop-up-body { font-size: 12px; } - - +.iotmap-divicon.iotmap-popup-dismissed > .iotmap-clustericon > .iotmap-cluster-popup, +.iotmap-divicon.iotmap-popup-dismissed > .iotmap-clustericon > .iotmap-cluster-big-popup { + visibility: hidden; +} diff --git a/src/iotMapManager/css/tabs.css b/src/iotMapManager/css/tabs.css index ad8fe169..6db578a2 100644 --- a/src/iotMapManager/css/tabs.css +++ b/src/iotMapManager/css/tabs.css @@ -152,7 +152,11 @@ .iotmap-clustericon:hover > .iotmap-tab-top, .iotmap-clustericon:hover > .iotmap-tab-top-big, .iotmap-clustericon:hover > .iotmap-tab-top-big-left, -.iotmap-clustericon:hover > .iotmap-tab-top-big-right { +.iotmap-clustericon:hover > .iotmap-tab-top-big-right, +.iotmap-divicon:focus-within > .iotmap-clustericon > .iotmap-tab-top, +.iotmap-divicon:focus-within > .iotmap-clustericon > .iotmap-tab-top-big, +.iotmap-divicon:focus-within > .iotmap-clustericon > .iotmap-tab-top-big-left, +.iotmap-divicon:focus-within > .iotmap-clustericon > .iotmap-tab-top-big-right { visibility: hidden; font-size: 1px; } diff --git a/src/iotMapManager/readme.md b/src/iotMapManager/readme.md index d9a83c5b..12f73b4f 100644 --- a/src/iotMapManager/readme.md +++ b/src/iotMapManager/readme.md @@ -43,6 +43,27 @@ In your CSS file: If you use TypeScript, all types are defined in lib/index.d.ts +## Keyboard accessibility + +Keyboard interaction is enabled by default: + +- **Tab / Shift+Tab**: move between visible markers, clusters and controls. Focus has a visible outline. +- **Enter / Space** on the **+ / - zoom buttons**: zoom in or out while keeping focus on the button. Holding Shift preserves Leaflet's accelerated zoom. Disabled controls and zoom limits remain respected. +- **Enter / Space** on a marker: toggle its information popup, using the same selection and `onEltClick` behavior as a mouse click. +- **Focus a cluster**: show its information popup without zooming. The popup stays visible while navigating its links. +- **Enter / Space** on a manual or automatic cluster: perform the usual click action (zoom or spiderfy). Focus returns to the map so navigation can continue when the cluster disappears. +- **Escape**: close the focused marker or cluster popup and return focus to its icon. A dismissed cluster popup reappears after leaving and refocusing the cluster. + +Links and controls in visible popups retain their native keyboard and mouse actions without activating the enclosing marker or cluster. Hidden popups are excluded from keyboard navigation. Mouse selection, cluster hover and cluster expansion remain available. + +Markers with a popup and clusters expose `aria-expanded` on their icon container. It follows marker selection and cluster popup visibility (hover, focus and Escape dismissal), including icon regeneration. Markers without a popup do not receive this attribute. Opening a popup does not move focus into its content: use Tab to reach its links and controls. + +Include `iot-map-manager.css` for popup visibility and focus styling. These interactions do not make the entire map accessible; applications remain responsible for meaningful content and an accessible alternative representation. + +To validate these interactions manually, run `npm run storybook` from the repository root and open a marker story and the **Clusters** story. Use Tab / Shift+Tab to check the focus indicator, Enter / Space to activate markers and clusters, and Escape to dismiss their popups. Check that links in visible popups remain usable, hidden popup content is skipped, and mouse clicks and hover still work. +Also check the + / - zoom buttons with Tab, Enter and Space, including Shift activation and the minimum/maximum zoom limits. +The **AccessibleNames** story includes a marker popup with a link and a cluster popup with an aggregation link. Use it to inspect `aria-expanded`, navigate into the popup and dismiss it with Escape. + ## marker definition A marker is defined as follows: @@ -397,5 +418,3 @@ IotMapPathManager requires a configuration object and an IotMapManager ### IotMapAreaManager This class manages areas: - - diff --git a/src/iotMapManager/src/iot-map-icons.ts b/src/iotMapManager/src/iot-map-icons.ts index 296c8707..12076914 100644 --- a/src/iotMapManager/src/iot-map-icons.ts +++ b/src/iotMapManager/src/iot-map-icons.ts @@ -25,6 +25,62 @@ import poiSqrSelShadow from '../img/POI_SQR_SEL_Shadow.png' import poiSqrPinShadow from '../img/POI_SQR_PIN_Shadow.png' import usrShadow from '../img/USR_Shadow.png' +export function updatePopupExpanded (icon: HTMLElement, focused = icon.contains(icon.ownerDocument.activeElement)): void { + const marker = icon.querySelector(':scope > .iotmap-markericon') + const cluster = icon.querySelector(':scope > .iotmap-clustericon') + if (marker?.querySelector(':scope > .iotmap-marker-popup')) { + icon.setAttribute('aria-expanded', String(marker.classList.contains('iotmap-marker-selected'))) + } else if (cluster?.querySelector(':scope > .iotmap-cluster-popup, :scope > .iotmap-cluster-big-popup')) { + const expanded = !icon.classList.contains('iotmap-popup-dismissed') && (focused || cluster.matches(':hover')) + icon.setAttribute('aria-expanded', String(expanded)) + } else { + icon.removeAttribute('aria-expanded') + } +} + +const popupStateIcons = new WeakSet() + +class InteractiveDivIcon extends L.DivIcon { + createIcon (oldIcon?: HTMLElement): HTMLElement { + const dismissed = oldIcon?.classList.contains('iotmap-popup-dismissed') + const icon = super.createIcon(oldIcon) + // DivIcon resets className on redraw; keep an explicit dismissal until focus leaves. + if (dismissed && icon.contains(icon.ownerDocument.activeElement)) { + icon.classList.add('iotmap-popup-dismissed') + } + updatePopupExpanded(icon) + // Leaflet may reuse the container when regenerating its contents. + if (!popupStateIcons.has(icon)) { + popupStateIcons.add(icon) + icon.addEventListener('focusin', () => updatePopupExpanded(icon, true)) + icon.addEventListener('focusout', event => { + updatePopupExpanded(icon, event.relatedTarget instanceof Node && icon.contains(event.relatedTarget)) + }) + icon.addEventListener('mouseover', () => updatePopupExpanded(icon)) + icon.addEventListener('mouseout', () => updatePopupExpanded(icon)) + } + icon.querySelectorAll('.iotmap-marker-popup, .iotmap-cluster-popup, .iotmap-cluster-big-popup') + .forEach(popup => { + popup.addEventListener('click', event => { + if (event.target instanceof Element) { + const control = event.target.closest('a, button, input, select, textarea, [contenteditable], [role="button"]') + if (control && popup.contains(control)) { + // Preserve control actions without selecting or expanding the enclosing marker. + event.stopPropagation() + } + } + }) + popup.addEventListener('keypress', event => { + if (event.key === 'Enter') { + // Leaflet.markercluster listens for Enter on keypress as well as click. + event.stopPropagation() + } + }) + }) + return icon + } +} + /* eslint-disable quotes */ /** * Returns a DivIcon compatible with leaflet, representing all marker information (shape, tab, popup, size...) @@ -146,7 +202,7 @@ export function getManualClusterIcon (cluster: IotCluster, config: IotMapConfig) ` const clusterSize = config.clusters.size - return new L.DivIcon({ + return new InteractiveDivIcon({ html: html, className: 'iotmap-divicon', iconSize: L.point(clusterSize, clusterSize), @@ -564,7 +620,7 @@ function getMarkerDivIcon (marker: IotMarker, config: IotMapConfig, selected: bo const iconAnchor : L.Point = L.point(iconSize.x / 2, (!(marker.shape.anchored || selected)) ? iconSize.y / 2 : iconSize.y) // creating icon - return new L.DivIcon({ + return new InteractiveDivIcon({ className: 'iotmap-divicon', iconSize: iconSize, // size of the icon iconAnchor: iconAnchor, // point of the icon which will correspond to marker's location diff --git a/src/iotMapManager/src/iot-map-manager.ts b/src/iotMapManager/src/iot-map-manager.ts index f3b7a08f..7afb5f75 100644 --- a/src/iotMapManager/src/iot-map-manager.ts +++ b/src/iotMapManager/src/iot-map-manager.ts @@ -14,7 +14,7 @@ import * as L from 'leaflet' import 'leaflet.markercluster' import { IotMapConfig } from './iot-map-config' import { IotMapDisplay } from './iot-map-types' -import { getAutomaticClusterIcon } from './iot-map-icons' +import { getAutomaticClusterIcon, updatePopupExpanded } from './iot-map-icons' export class IotMapManager { private map: L.Map @@ -73,6 +73,77 @@ export class IotMapManager { .on('click', this.onClick.bind(this)) this.selectedElement = undefined + this.initKeyboardInteractions() + } + + private initKeyboardInteractions (): void { + const container = this.map.getContainer() + const getIcon = (target: EventTarget | null): HTMLElement | null => { + if (!(target instanceof Element)) { + return null + } + const icon = target.closest('.iotmap-divicon') + return icon?.querySelector('.iotmap-markericon, .iotmap-clustericon') ? icon : null + } + + const onKeyDown = (event: KeyboardEvent): void => { + const target = event.target + if (target instanceof HTMLElement && + target.matches('.leaflet-control-zoom-in, .leaflet-control-zoom-out') && + this.map.zoomControl?.getContainer()?.contains(target) && + (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault() + event.stopPropagation() + if (!event.repeat) { + // Preserve Leaflet's disabled states, zoom limits and Shift acceleration. + target.dispatchEvent(new MouseEvent('click', { + bubbles: true, + cancelable: true, + shiftKey: event.shiftKey + })) + } + return + } + const icon = getIcon(event.target) + if (!icon) { + return + } + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + // Restore focus before regenerating a popup that may contain the focused control. + icon.focus({ preventScroll: true }) + if (this.selectedElement?.getElement() === icon) { + this.unselectElement(this.selectedElement) + } + icon.classList.add('iotmap-popup-dismissed') + updatePopupExpanded(icon) + } else if (event.target === icon && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault() + event.stopPropagation() + if (!event.repeat) { + const isCluster = icon.querySelector('.iotmap-clustericon') !== null + // Use the existing click path, including Leaflet's zoom/spiderfy behavior. + icon.click() + if (isCluster) { + container.focus({ preventScroll: true }) + } + } + } + } + const onFocusOut = (event: FocusEvent): void => { + const icon = getIcon(event.target) + if (icon && (!(event.relatedTarget instanceof Node) || !icon.contains(event.relatedTarget))) { + icon.classList.remove('iotmap-popup-dismissed') + updatePopupExpanded(icon, false) + } + } + container.addEventListener('keydown', onKeyDown, true) + container.addEventListener('focusout', onFocusOut) + this.map.once('unload', () => { + container.removeEventListener('keydown', onKeyDown, true) + container.removeEventListener('focusout', onFocusOut) + }) } /** diff --git a/src/stories/AccessibleNames.stories.js b/src/stories/AccessibleNames.stories.js new file mode 100644 index 00000000..dc71ba55 --- /dev/null +++ b/src/stories/AccessibleNames.stories.js @@ -0,0 +1,103 @@ +import { + IotMapConfig, + IotMapManager, + IotMapMarkerManager, + IotMapUserMarkerManager, + IotMapPathManager, + IotMapAreaManager, +} from "iotmapmanager/index.ts"; +import "./stories.css"; + +const template = ` +

Use Tab to reach Sensor 1, then Enter or Space to open its popup. + Tab reaches the link; Escape closes the popup. Cluster popups open on focus or hover.

+
+
+

Sensor 1 details

+

Temperature: 32 degrees Celsius. Alert threshold: 30 degrees Celsius.

+
`; + +const markers = [ + { + id: "alone", + location: { lat: 44.895, lng: 4.87 }, + ariaLabel: "Sensor 1, temperature alert", + popup: { + title: "Sensor 1", + body: 'Temperature alert: 32 degrees Celsius.
View sensor details', + }, + }, + // close markers are grouped in an automatic cluster at the default zoom level + { id: "c1", location: { lat: 44.885, lng: 4.89 }, status: "alert" }, + { id: "c2", location: { lat: 44.8852, lng: 4.8903 }, status: "alert" }, + { id: "c3", location: { lat: 44.8854, lng: 4.8906 }, status: "alert" }, +]; + +const userMarker = { + location: { lat: 44.89, lng: 4.88 }, + direction: 45, + accuracy: 100, + ariaLabel: "Your position", +}; + +const path = { + id: "path", + points: [ + { lat: 44.88, lng: 4.87 }, + { lat: 44.882, lng: 4.875 }, + { lat: 44.881, lng: 4.88 }, + ], + positions: [{ lat: 44.882, lng: 4.875 }], + ariaLabel: "Delivery route", + iconsAriaLabel: { + start: "Route start", + end: "Route end", + position: "Intermediate stop", + }, +}; + +const area = { + id: "area", + points: [ + { lat: 44.9, lng: 4.895 }, + { lat: 44.9, lng: 4.9 }, + { lat: 44.896, lng: 4.9 }, + ], + ariaLabel: "Restricted area", +}; + +export default { + title: "AccessibleNames", + parameters: { + layout: "fullscreen", + }, +}; + +function update() { + const config = new IotMapConfig(); + config.setConfig({ + markerStatus: { + alert: { + ...config.markerStatus.alert, + url: "#sensor-details", + }, + }, + accessibility: { + clusterAriaLabel: (cluster) => `Group of ${cluster.childCount} elements`, + }, + }); + const mapManager = new IotMapManager(config); + mapManager.init("iotMap"); + new IotMapMarkerManager(mapManager, config).addMarkers(markers); + new IotMapUserMarkerManager(mapManager, config).addUserMarker(userMarker); + new IotMapPathManager(mapManager, config).addPath(path); + new IotMapAreaManager(mapManager, config).addArea(area); + removeEventListener("DOMContentLoaded", update); +} + +export const AccessibleNames = { + render: () => { + addEventListener("DOMContentLoaded", update); + return template; + }, +}; From 3688d2d4acd57c933dd3e7cd70e0285bcb77a0ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Nicaisse?= Date: Thu, 15 Oct 2026 14:06:49 +0200 Subject: [PATCH 2/2] feat: add optional aria labels to map elements Add an optional ariaLabel on markers, manual clusters, user markers, paths (line, additional lines and start/end/position icons) and areas, plus the accessibility.clusterAriaLabel config function for automatic clusters. Labels are set as DOM attributes and nothing changes when unset. Support accessible name updates and removal through marker, manual cluster and user marker update APIs, including bulk updates. Document the option and update semantics in the readme, add an AccessibleNames story and bump the library minor version to 2.7.0-alpha.1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 2 +- src/iotMapManager/package-lock.json | 4 +- src/iotMapManager/package.json | 2 +- src/iotMapManager/readme.md | 42 +++++++++++++ src/iotMapManager/src/iot-map-area.ts | 3 + .../src/iot-map-cluster-manager.ts | 5 ++ src/iotMapManager/src/iot-map-config.ts | 17 +++++- src/iotMapManager/src/iot-map-icons.ts | 59 ++++++++++++++++--- .../src/iot-map-marker-manager.ts | 5 ++ src/iotMapManager/src/iot-map-path.ts | 11 ++-- src/iotMapManager/src/iot-map-types.ts | 18 ++++++ .../src/iot-map-user-marker-manager.ts | 8 ++- test/angular/package-lock.json | 2 +- 13 files changed, 159 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index e400f840..de380761 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8198,7 +8198,7 @@ }, "src/iotMapManager": { "name": "iotmapmanager", - "version": "2.6.10-alpha.1", + "version": "2.7.0-alpha.1", "dev": true, "license": "MIT", "devDependencies": { diff --git a/src/iotMapManager/package-lock.json b/src/iotMapManager/package-lock.json index 23a342d8..3ee7abbb 100644 --- a/src/iotMapManager/package-lock.json +++ b/src/iotMapManager/package-lock.json @@ -1,12 +1,12 @@ { "name": "iotmapmanager", - "version": "2.6.10-alpha.1", + "version": "2.7.0-alpha.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "iotmapmanager", - "version": "2.6.10-alpha.1", + "version": "2.7.0-alpha.1", "license": "MIT", "devDependencies": { "@types/leaflet": "^1.9.21", diff --git a/src/iotMapManager/package.json b/src/iotMapManager/package.json index 503a5df4..e0bc1db2 100644 --- a/src/iotMapManager/package.json +++ b/src/iotMapManager/package.json @@ -1,6 +1,6 @@ { "name": "iotmapmanager", - "version": "2.6.10-alpha.1", + "version": "2.7.0-alpha.1", "description": "Manage markers, clusters, user marker or paths on IotMaps", "main": "dist/index.js", "types": "lib/index.d.ts", diff --git a/src/iotMapManager/readme.md b/src/iotMapManager/readme.md index 12f73b4f..33562f88 100644 --- a/src/iotMapManager/readme.md +++ b/src/iotMapManager/readme.md @@ -64,6 +64,48 @@ To validate these interactions manually, run `npm run storybook` from the reposi Also check the + / - zoom buttons with Tab, Enter and Space, including Shift activation and the minimum/maximum zoom limits. The **AccessibleNames** story includes a marker popup with a link and a cluster popup with an aggregation link. Use it to inspect `aria-expanded`, navigate into the popup and dismiss it with Escape. +## Accessible names + +Every element can be given an optional accessible name announced by assistive technologies. Nothing changes in the generated HTML when it is not provided. + +| Element | Property | +| --- | --- | +| Marker | `ariaLabel` | +| Manual cluster | `ariaLabel` | +| User marker | `ariaLabel` | +| Area | `ariaLabel` | +| Path | `ariaLabel` (main line), `additional[].ariaLabel` (additional lines), `iconsAriaLabel.start` / `.end` / `.position` (icons) | + +```typescript +const marker: IotMarker = { + id: 'sensor-1', + location: { lat: 44.88, lng: 4.88 }, + ariaLabel: 'Sensor 1, temperature alert' +} +``` + +Automatic clusters have no user data: provide a function in the configuration. It receives the cluster (`childCount`, `layer`, `aggregation`...) and is also used for manual clusters without their own `ariaLabel`. + +```typescript +const conf = new IotMapConfig() +conf.setConfig({ + accessibility: { + clusterAriaLabel: cluster => `Group of ${cluster.childCount} elements` + } +}) +``` + +Labels are set as DOM attributes, not HTML, and are refreshed whenever an icon is regenerated. Markers, clusters and icons keep their focusable button role; paths and areas get `role="img"`. Set the configuration function before adding elements: existing icons are not refreshed. + +Use `updateMarker`, `updateCluster` or `updateUserMarker` to change an existing element's `ariaLabel`; changing only this property also redraws the icon. The same behavior applies to `updateAllMarkers` and `updateAllClusters`. + +- Omit `ariaLabel` (or pass `undefined`) to keep the current name. +- Pass `ariaLabel: ''` to remove the name. For manual clusters, this also suppresses the configured `clusterAriaLabel` fallback. + +```typescript +markerManager.updateMarker('sensor-1', { ariaLabel: 'Sensor 1, normal temperature' }) +markerManager.updateMarker('sensor-1', { ariaLabel: '' }) +``` ## marker definition A marker is defined as follows: diff --git a/src/iotMapManager/src/iot-map-area.ts b/src/iotMapManager/src/iot-map-area.ts index bed1a11a..2cbc2b06 100644 --- a/src/iotMapManager/src/iot-map-area.ts +++ b/src/iotMapManager/src/iot-map-area.ts @@ -14,6 +14,7 @@ import { IotMapConfig } from './iot-map-config' import { IotArea } from './iot-map-types' import { IotMapManager } from './iot-map-manager' import { Polygon } from 'leaflet' +import { applyAriaLabel } from './iot-map-icons' /** * Class IotMapArea displaying a Area @@ -43,5 +44,7 @@ export class IotMapArea extends Polygon { this.data = area this.config = config this.map = map + // The SVG element only exists once the area is added to the map. + this.on('add', () => applyAriaLabel(this.getElement(), this.data.ariaLabel, 'img')) } } diff --git a/src/iotMapManager/src/iot-map-cluster-manager.ts b/src/iotMapManager/src/iot-map-cluster-manager.ts index bbea43ef..1e2c14a8 100644 --- a/src/iotMapManager/src/iot-map-cluster-manager.ts +++ b/src/iotMapManager/src/iot-map-cluster-manager.ts @@ -106,6 +106,11 @@ export class IotMapClusterManager { let htmlModificationNeeded = false let oldLayerName: string = null + if (params.ariaLabel !== undefined) { + currentClusterInfos.ariaLabel = params.ariaLabel + htmlModificationNeeded = true + } + // location modified if (params.location !== undefined) { currentClusterInfos.location = params.location diff --git a/src/iotMapManager/src/iot-map-config.ts b/src/iotMapManager/src/iot-map-config.ts index 2d5380d5..9d37c352 100644 --- a/src/iotMapManager/src/iot-map-config.ts +++ b/src/iotMapManager/src/iot-map-config.ts @@ -10,9 +10,17 @@ * Software description: web library for interactive maps providing Orange branded reusable markers, tabs, clusters and paths */ -import { MarkerStatus, MarkerTemplate, ShapeType, LayerTemplate, TabType } from './iot-map-types' +import { IotCluster, MarkerStatus, MarkerTemplate, ShapeType, LayerTemplate, TabType } from './iot-map-types' export class IotMapConfig { + /* + *** Accessibility: optional, nothing is added to the generated HTML when undefined + */ + accessibility: { + // Accessible name of a cluster without its own ariaLabel (mainly automatic clusters) + clusterAriaLabel?: (cluster: IotCluster) => string + } = {} + /* *** Default configuration definition : */ @@ -361,6 +369,13 @@ export class IotMapConfig { * */ public setConfig (newConfig: Partial): void { + /* + *** ACCESSIBILITY *** + */ + if (newConfig.accessibility?.clusterAriaLabel !== undefined) { + this.accessibility.clusterAriaLabel = newConfig.accessibility.clusterAriaLabel + } + /* *** MAP *** */ diff --git a/src/iotMapManager/src/iot-map-icons.ts b/src/iotMapManager/src/iot-map-icons.ts index 12076914..a161bed6 100644 --- a/src/iotMapManager/src/iot-map-icons.ts +++ b/src/iotMapManager/src/iot-map-icons.ts @@ -25,6 +25,44 @@ import poiSqrSelShadow from '../img/POI_SQR_SEL_Shadow.png' import poiSqrPinShadow from '../img/POI_SQR_PIN_Shadow.png' import usrShadow from '../img/USR_Shadow.png' +/** + * Sets or clears the accessible name provided by the library user. + * The name is set through the DOM, so it is never interpreted as HTML. + */ +export function applyAriaLabel (element: Element | null | undefined, label?: string, role?: string): void { + if (!element) { + return + } + if (label) { + element.setAttribute('aria-label', label) + if (role) { + element.setAttribute('role', role) + } + } else { + element.removeAttribute('aria-label') + } +} + +interface AccessibleDivIconOptions extends L.DivIconOptions { + ariaLabel?: string +} + +class AccessibleDivIcon extends L.DivIcon { + private readonly ariaLabel?: string + + constructor (options: AccessibleDivIconOptions) { + super(options) + this.ariaLabel = options.ariaLabel + } + + createIcon (oldIcon?: HTMLElement): HTMLElement { + const icon = super.createIcon(oldIcon) + // Leaflet reuses the element on setIcon, so a removed label must be cleared. + applyAriaLabel(icon, this.ariaLabel) + return icon + } +} + export function updatePopupExpanded (icon: HTMLElement, focused = icon.contains(icon.ownerDocument.activeElement)): void { const marker = icon.querySelector(':scope > .iotmap-markericon') const cluster = icon.querySelector(':scope > .iotmap-clustericon') @@ -40,7 +78,7 @@ export function updatePopupExpanded (icon: HTMLElement, focused = icon.contains( const popupStateIcons = new WeakSet() -class InteractiveDivIcon extends L.DivIcon { +class InteractiveDivIcon extends AccessibleDivIcon { createIcon (oldIcon?: HTMLElement): HTMLElement { const dismissed = oldIcon?.classList.contains('iotmap-popup-dismissed') const icon = super.createIcon(oldIcon) @@ -206,7 +244,8 @@ export function getManualClusterIcon (cluster: IotCluster, config: IotMapConfig) html: html, className: 'iotmap-divicon', iconSize: L.point(clusterSize, clusterSize), - iconAnchor: L.point(clusterSize / 2, clusterSize / 2) + iconAnchor: L.point(clusterSize / 2, clusterSize / 2), + ariaLabel: cluster.ariaLabel ?? config.accessibility.clusterAriaLabel?.(cluster) }) } @@ -258,11 +297,12 @@ export function getUserMarkerIcon (userMarker: IotUserMarker, config: IotMapConf html += imgShadow // creating icon - return new L.DivIcon({ + return new AccessibleDivIcon({ className: 'iotmap-divicon', iconSize: L.point(userMarkerSize, userMarkerSize), // size of the icon iconAnchor: L.point(userMarkerSize / 2, userMarkerSize / 2), // point of the icon which will correspond to marker's location - html: html + html: html, + ariaLabel: userMarker.ariaLabel }) } @@ -270,8 +310,9 @@ export function getUserMarkerIcon (userMarker: IotUserMarker, config: IotMapConf * Returns a DivIcon compatible with leaflet, representing a start, end or intermediate path icon * @param type - PathIconType = start, end or mid * @param config - config to use to display start / end / mid icons + * @param ariaLabel - (optional) accessible name of the icon */ -export function getPathIcon (type: PathIconType, config: IotMapConfig): L.DivIcon { +export function getPathIcon (type: PathIconType, config: IotMapConfig, ariaLabel?: string): L.DivIcon { let svg: string switch (type) { case PathIconType.start: { @@ -306,11 +347,12 @@ export function getPathIcon (type: PathIconType, config: IotMapConfig): L.DivIco const iconAnchor : L.Point = L.point(iconSize.x / 2, iconSize.y / 2) // creating icon - return new L.DivIcon({ + return new AccessibleDivIcon({ className: 'iotmap-divicon', iconSize: iconSize, // size of the icon iconAnchor: iconAnchor, // point of the icon which will correspond to marker's location - html: html + html: html, + ariaLabel: ariaLabel }) } @@ -624,7 +666,8 @@ function getMarkerDivIcon (marker: IotMarker, config: IotMapConfig, selected: bo className: 'iotmap-divicon', iconSize: iconSize, // size of the icon iconAnchor: iconAnchor, // point of the icon which will correspond to marker's location - html: html + html: html, + ariaLabel: marker.ariaLabel }) } diff --git a/src/iotMapManager/src/iot-map-marker-manager.ts b/src/iotMapManager/src/iot-map-marker-manager.ts index 55b759e7..222f23d9 100644 --- a/src/iotMapManager/src/iot-map-marker-manager.ts +++ b/src/iotMapManager/src/iot-map-marker-manager.ts @@ -96,6 +96,11 @@ export class IotMapMarkerManager { let htmlModificationNeeded = false let oldLayerName: string = null + if (params.ariaLabel !== undefined) { + currentMarkerInfos.ariaLabel = params.ariaLabel + htmlModificationNeeded = true + } + // popup modified if (params.popup !== undefined) { if (params.popup.title === null && params.popup.body === null) { // cmd to remove popup diff --git a/src/iotMapManager/src/iot-map-path.ts b/src/iotMapManager/src/iot-map-path.ts index f0b25f24..4845406a 100644 --- a/src/iotMapManager/src/iot-map-path.ts +++ b/src/iotMapManager/src/iot-map-path.ts @@ -13,7 +13,7 @@ import { IotMapConfig } from './iot-map-config' import { IotPath, PathIconType } from './iot-map-types' import { IotMapManager } from './iot-map-manager' -import { getPathIcon } from './iot-map-icons' +import { getPathIcon, applyAriaLabel } from './iot-map-icons' import { Polyline as leafletPolyline, Marker, marker } from 'leaflet' import 'leaflet-polylineoffset' @@ -49,6 +49,8 @@ export class IotMapPath extends leafletPolyline { this.data = path this.config = config this.map = map + // The SVG element only exists once the path is added to the map. + this.on('add', () => applyAriaLabel(this.getElement(), this.data.ariaLabel, 'img')) } /** @@ -56,7 +58,7 @@ export class IotMapPath extends leafletPolyline { */ public getStart (): Marker { this.start?.remove() - this.start = marker(this.data.points[0], { icon: getPathIcon(PathIconType.start, this.config), interactive: false }) + this.start = marker(this.data.points[0], { icon: getPathIcon(PathIconType.start, this.config, this.data.iconsAriaLabel?.start), interactive: false }) return this.start } @@ -65,7 +67,7 @@ export class IotMapPath extends leafletPolyline { */ public getEnd (): Marker { this.end?.remove() - this.end = marker(this.data.points[this.data.points.length - 1], { icon: getPathIcon(PathIconType.end, this.config), interactive: false }) + this.end = marker(this.data.points[this.data.points.length - 1], { icon: getPathIcon(PathIconType.end, this.config, this.data.iconsAriaLabel?.end), interactive: false }) return this.end } @@ -76,7 +78,7 @@ export class IotMapPath extends leafletPolyline { this.mids?.forEach(pos => pos.remove()) if (this.data.positions !== undefined) { this.data.positions.forEach(pos => { - const newMarker = marker(pos, { icon: getPathIcon(PathIconType.mid, this.config), interactive: false }) + const newMarker = marker(pos, { icon: getPathIcon(PathIconType.mid, this.config, this.data.iconsAriaLabel?.position), interactive: false }) this.mids.push(newMarker) }) } @@ -99,6 +101,7 @@ export class IotMapPath extends leafletPolyline { interactive: false, offset: IotMapPath.getOffset(path.line) }) + newPath.on('add', () => applyAriaLabel(newPath.getElement(), path.ariaLabel, 'img')) this.additionalPaths.push(newPath) }) } diff --git a/src/iotMapManager/src/iot-map-types.ts b/src/iotMapManager/src/iot-map-types.ts index f99f6abf..cee815a8 100644 --- a/src/iotMapManager/src/iot-map-types.ts +++ b/src/iotMapManager/src/iot-map-types.ts @@ -131,6 +131,7 @@ interface Inner { * @param inner - (optional) define inner elements (see Inner interface) * @param template - (optional) name of the template to apply * @param status - (optional) name of the status to apply + * @param ariaLabel - (optional) accessible name announced by assistive technologies */ export interface IotMarker extends GeolocMarker { id: string @@ -141,6 +142,7 @@ export interface IotMarker extends GeolocMarker { inner?: Inner template?: string status?: string + ariaLabel?: string } /** @@ -151,6 +153,7 @@ export interface IotMarker extends GeolocMarker { * @param contentLabel - label displayed as title of popup * @param childCount - number of markers clustered. Displayed as inner element * @param aggregation - array of markers types. Each type represented by a color, a number and a state (sing and plur) + * @param ariaLabel - (optional) accessible name announced by assistive technologies. Takes precedence over config.accessibility.clusterAriaLabel */ export interface IotCluster extends GeolocMarker { id: string @@ -158,6 +161,7 @@ export interface IotCluster extends GeolocMarker { contentLabel: string childCount: number colNumber?: number + ariaLabel?: string markersArea?: L.LatLngBounds aggregation: { singularState: string @@ -175,10 +179,12 @@ export interface IotCluster extends GeolocMarker { * * @param direction - (optional) clockwise angle describing the arrow direction * @param accuracy - (optional) define accuracy area radius around the user marker location. + * @param ariaLabel - (optional) accessible name announced by assistive technologies */ export interface IotUserMarker extends GeolocMarker { direction?: number accuracy?: number + ariaLabel?: string } /** @@ -256,6 +262,9 @@ export interface LayerTemplate { * additional.points - (mandatory) at least 2 points to define a path * additional.color - (mandatory) color used to display additional path * additional.number - (mandatory) line number (1..4 : 1 is full left, 4 full right in the direction of travel) + * additional.ariaLabel - (optional) accessible name of the additional path + * @param ariaLabel - (optional) accessible name of the main path line + * @param iconsAriaLabel - (optional) accessible names of the start, end and intermediate position icons */ export interface IotPath { id: string @@ -266,7 +275,14 @@ export interface IotPath { points: Location[] color: string line: number + ariaLabel?: string }[] + ariaLabel?: string + iconsAriaLabel?: { + start?: string + end?: string + position?: string + } } /** @@ -275,6 +291,7 @@ export interface IotPath { * @param points - (mandatory) at least 3 points to define an area * @param borderColor - (optional) color of the area border * @param fillColor - (optional) fill color of the area + * @param ariaLabel - (optional) accessible name announced by assistive technologies */ export interface IotArea { id: string @@ -282,4 +299,5 @@ export interface IotArea { color?: string fillColor?: string fillOpacity?: number + ariaLabel?: string } diff --git a/src/iotMapManager/src/iot-map-user-marker-manager.ts b/src/iotMapManager/src/iot-map-user-marker-manager.ts index edc65931..9e77e880 100644 --- a/src/iotMapManager/src/iot-map-user-marker-manager.ts +++ b/src/iotMapManager/src/iot-map-user-marker-manager.ts @@ -59,6 +59,11 @@ export class IotMapUserMarkerManager { if (this.userMarkerObject !== null) { let htmlModificationNeeded = false const userMarkerInfo = this.userMarkerObject.getData() + if (params.ariaLabel !== undefined) { + userMarkerInfo.ariaLabel = params.ariaLabel + htmlModificationNeeded = true + } + if (params.location !== undefined) { userMarkerInfo.location = params.location this.userMarkerObject.setPosition(userMarkerInfo.location) @@ -89,7 +94,8 @@ export class IotMapUserMarkerManager { const userMarker: IotUserMarker = { location: params.location, direction: params.direction, - accuracy: params.accuracy + accuracy: params.accuracy, + ariaLabel: params.ariaLabel } this.addUserMarker(userMarker) } diff --git a/test/angular/package-lock.json b/test/angular/package-lock.json index a1d84f78..11ec88fb 100644 --- a/test/angular/package-lock.json +++ b/test/angular/package-lock.json @@ -41,7 +41,7 @@ }, "../../src/iotMapManager": { "name": "iotmapmanager", - "version": "2.6.10-alpha.1", + "version": "2.7.0-alpha.1", "dev": true, "license": "MIT", "devDependencies": {