Skip to content

Commit 4f8ff65

Browse files
Adding tabbing navigation and accessibility to map (#270)
* Allow features to be focused * Adds crosshair, shows on keyboard movement * Shows crosshair on tab * Only run on moveend if there are queryable layers * Revert listener change * Adds tests for keyboard interaction * Remove close button from feature popup * Add bypass navigation * Query popup fix * Add feature count, move controls to bottom * Test update to consider skip buttons * Test update * Adds next and previous focus buttons * Add tests for keyboard interaction * Rename variables and add comments * Remove handlers on close Co-authored-by: Peter Rushforth <[email protected]>
1 parent cf8d242 commit 4f8ff65

File tree

14 files changed

+637
-26
lines changed

14 files changed

+637
-26
lines changed

.github/workflows/sync.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,5 @@ jobs:
2929
destination_folder: 'dist'
3030
user_email: [email protected]
3131
user_name: 'ahmadayubi'
32-
commit_msg: 'Sync MapML Build'
32+
commit_msg: '[AUTO] Sync MapML Build'
3333
destination_branch: main

src/mapml-viewer.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ export class MapViewer extends HTMLElement {
211211
this._attributionControl = this._map.attributionControl.setPrefix('<a href="https://www.w3.org/community/maps4html/" title="W3C Maps for HTML Community Group">Maps4HTML</a> | <a href="https://leafletjs.com" title="A JS library for interactive maps">Leaflet</a>');
212212

213213
this.setControls(false,false,true);
214+
this._crosshair = M.crosshair().addTo(this._map);
214215

215216
// Make the Leaflet container element programmatically identifiable
216217
// (https://github.com/Leaflet/Leaflet/issues/7193).

src/mapml.css

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,3 +389,41 @@ summary {
389389
.leaflet-container .leaflet-control-container {
390390
visibility: unset!important;
391391
}
392+
393+
.mapml-crosshair {
394+
margin: -18px 0 0 -18px;
395+
width: 36px;
396+
height: 36px;
397+
left: 50%;
398+
top: 50%;
399+
content: '';
400+
display: block;
401+
position: absolute;
402+
z-index: 10000;
403+
}
404+
405+
.mapml-popup-button {
406+
padding: 0 4px 0 4px;
407+
border: none;
408+
text-align: center;
409+
width: 18px;
410+
height: 14px;
411+
font: 16px/14px Tahoma, Verdana, sans-serif;
412+
color: #c3c3c3;
413+
text-decoration: none;
414+
font-weight: bold;
415+
background: transparent;
416+
white-space: nowrap;
417+
}
418+
419+
.mapml-focus-buttons {
420+
white-space: nowrap;
421+
display: inline;
422+
}
423+
424+
.mapml-feature-count {
425+
display:inline;
426+
white-space: nowrap;
427+
text-align: center;
428+
padding: 2px;
429+
}

src/mapml/handlers/QueryHandler.js

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,26 +151,29 @@ export var QueryHandler = L.Handler.extend({
151151
});
152152
f.addTo(map);
153153

154-
var c = document.createElement('iframe');
154+
let div = L.DomUtil.create("div", "mapml-popup-content"),
155+
c = L.DomUtil.create("iframe");
155156
c.csp = "script-src 'none'";
156157
c.style = "border: none";
157158
c.srcdoc = mapmldoc.querySelector('feature properties').innerHTML;
158-
159+
div.appendChild(c);
159160
// passing a latlng to the popup is necessary for when there is no
160161
// geometry / null geometry
161-
layer.bindPopup(c, popupOptions).openPopup(loc);
162+
layer.bindPopup(div, popupOptions).openPopup(loc);
162163
layer.on('popupclose', function() {
163164
map.removeLayer(f);
164165
});
165166
});
166167
}
167168
function handleOtherResponse(response, layer, loc) {
168169
return response.text().then(text => {
169-
var c = document.createElement('iframe');
170+
let div = L.DomUtil.create("div", "mapml-popup-content"),
171+
c = L.DomUtil.create("iframe");
170172
c.csp = "script-src 'none'";
171173
c.style = "border: none";
172174
c.srcdoc = text;
173-
layer.bindPopup(c, popupOptions).openPopup(loc);
175+
div.appendChild(c);
176+
layer.bindPopup(div, popupOptions).openPopup(loc);
174177
});
175178
}
176179
}

src/mapml/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import { QueryHandler } from './handlers/QueryHandler';
5252
import { ContextMenu } from './handlers/ContextMenu';
5353
import { Util } from './utils/Util';
5454
import { ReloadButton, reloadButton } from './control/ReloadButton';
55+
import { Crosshair, crosshair } from "./layers/Crosshair";
5556

5657
/* global L, Node */
5758
(function (window, document, undefined) {
@@ -627,4 +628,7 @@ M.mapMLStaticTileLayer = mapMLStaticTileLayer;
627628
M.DebugOverlay = DebugOverlay;
628629
M.debugOverlay = debugOverlay;
629630

631+
M.Crosshair = Crosshair;
632+
M.crosshair = crosshair;
633+
630634
}(window, document));

src/mapml/layers/Crosshair.js

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
export var Crosshair = L.Layer.extend({
2+
onAdd: function (map) {
3+
4+
//SVG crosshair design from https://github.com/xguaita/Leaflet.MapCenterCoord/blob/master/src/icons/MapCenterCoordIcon1.svg?short_path=81a5c76
5+
let svgInnerHTML = `<svg
6+
xmlns:dc="http://purl.org/dc/elements/1.1/"
7+
xmlns:cc="http://creativecommons.org/ns#"
8+
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
9+
xmlns:svg="http://www.w3.org/2000/svg"
10+
xmlns="http://www.w3.org/2000/svg"
11+
version="1.1"
12+
x="0px"
13+
y="0px"
14+
viewBox="0 0 99.999998 99.999998"
15+
xml:space="preserve">
16+
<g><circle
17+
r="3.9234731"
18+
cy="50.21946"
19+
cx="50.027821"
20+
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><path
21+
d="m 4.9734042,54.423642 31.7671398,0 c 2.322349,0 4.204185,-1.881836 4.204185,-4.204185 0,-2.322349 -1.881836,-4.204184 -4.204185,-4.204184 l -31.7671398,0 c -2.3223489,-2.82e-4 -4.20418433,1.881554 -4.20418433,4.204184 0,2.322631 1.88183543,4.204185 4.20418433,4.204185 z"
22+
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><path
23+
d="m 54.232003,5.1650429 c 0,-2.3223489 -1.881836,-4.20418433 -4.204184,-4.20418433 -2.322349,0 -4.204185,1.88183543 -4.204185,4.20418433 l 0,31.7671401 c 0,2.322349 1.881836,4.204184 4.204185,4.204184 2.322348,0 4.204184,-1.881835 4.204184,-4.204184 l 0,-31.7671401 z"
24+
style="fill:#000000;stroke:#ffffff;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1" /><path
25+
d="m 99.287826,50.219457 c 0,-2.322349 -1.881835,-4.204184 -4.204184,-4.204184 l -31.76714,0 c -2.322349,0 -4.204184,1.881835 -4.204184,4.204184 0,2.322349 1.881835,4.204185 4.204184,4.204185 l 31.76714,0 c 2.320658,0 4.204184,-1.881836 4.204184,-4.204185 z"
26+
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><path
27+
d="m 45.823352,95.27359 c 0,2.322349 1.881836,4.204184 4.204185,4.204184 2.322349,0 4.204184,-1.881835 4.204184,-4.204184 l 0,-31.76714 c 0,-2.322349 -1.881835,-4.204185 -4.204184,-4.204185 -2.322349,0 -4.204185,1.881836 -4.204185,4.204185 l 0,31.76714 z"
28+
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /></g></svg>
29+
`;
30+
31+
this._container = L.DomUtil.create("div", "mapml-crosshair", map._container);
32+
this._container.innerHTML = svgInnerHTML;
33+
this._mapFocused = false;
34+
this._isQueryable = false;
35+
36+
map.on("layerchange layeradd layerremove overlayremove", this._toggleEvents, this);
37+
L.DomEvent.on(map._container, "keydown keyup mousedown", this._onKey, this);
38+
39+
40+
this._addOrRemoveCrosshair();
41+
},
42+
43+
_toggleEvents: function () {
44+
if (this._hasQueryableLayer()) {
45+
this._map.on("viewreset move moveend", this._addOrRemoveCrosshair, this);
46+
} else {
47+
this._map.off("viewreset move moveend", this._addOrRemoveCrosshair, this);
48+
}
49+
this._addOrRemoveCrosshair();
50+
},
51+
52+
_addOrRemoveCrosshair: function (e) {
53+
if (this._hasQueryableLayer()) {
54+
this._container.style.visibility = null;
55+
} else {
56+
this._container.style.visibility = "hidden";
57+
}
58+
},
59+
60+
_hasQueryableLayer: function () {
61+
let layers = this._map.options.mapEl.layers;
62+
if (this._mapFocused) {
63+
for (let layer of layers) {
64+
if (layer.checked && layer._layer.queryable) {
65+
return true;
66+
}
67+
}
68+
}
69+
return false;
70+
},
71+
72+
_onKey: function (e) {
73+
//set mapFocused = true if arrow buttons are used
74+
if (["keydown", "keyup"].includes(e.type) && e.target.classList.contains("leaflet-container") && [32, 37, 38, 39, 40, 187, 189].includes(+e.keyCode)) {
75+
this._mapFocused = true;
76+
//set mapFocused = true if map is focued using tab
77+
} else if (e.type === "keyup" && e.target.classList.contains("leaflet-container") && +e.keyCode === 9) {
78+
this._mapFocused = true;
79+
// set mapFocused = false and close all popups if tab or escape is used
80+
} else if((e.type === "keyup" && e.target.classList.contains("leaflet-interactive") && +e.keyCode === 9) || +e.keyCode === 27){
81+
this._mapFocused = false;
82+
this._map.closePopup();
83+
// set mapFocused = false if any other key is pressed
84+
} else {
85+
this._mapFocused = false;
86+
}
87+
this._addOrRemoveCrosshair();
88+
},
89+
90+
});
91+
92+
93+
export var crosshair = function (options) {
94+
return new Crosshair(options);
95+
};

src/mapml/layers/FeatureLayer.js

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ export var MapMLFeatures = L.FeatureGroup.extend({
3636
}
3737
},
3838

39+
onAdd: function(map){
40+
L.FeatureGroup.prototype.onAdd.call(this, map);
41+
map.on("popupopen", this._attachSkipButtons, this);
42+
this._updateTabIndex();
43+
},
44+
3945
getEvents: function(){
4046
if(this._staticFeature){
4147
return {
@@ -47,6 +53,141 @@ export var MapMLFeatures = L.FeatureGroup.extend({
4753
};
4854
},
4955

56+
_updateTabIndex: function(){
57+
for(let feature in this._features){
58+
for(let path of this._features[feature]){
59+
if(path._path){
60+
if(path._path.getAttribute("d") !== "M0 0"){
61+
path._path.setAttribute("tabindex", 0);
62+
} else {
63+
path._path.removeAttribute("tabindex");
64+
}
65+
if(path._path.childElementCount === 0) {
66+
let title = document.createElement("title");
67+
title.innerText = "Feature";
68+
path._path.appendChild(title);
69+
}
70+
}
71+
}
72+
}
73+
},
74+
75+
_attachSkipButtons: function(e){
76+
if(!e.popup._source._path) return;
77+
if(!e.popup._container.querySelector('div[class="mapml-focus-buttons"]')){
78+
//add when popopen event happens instead
79+
let div = L.DomUtil.create("div", "mapml-focus-buttons");
80+
81+
// creates |< button, focuses map
82+
let mapFocusButton = L.DomUtil.create('a',"mapml-popup-button", div);
83+
mapFocusButton.href = '#';
84+
mapFocusButton.role = "button";
85+
mapFocusButton.title = "Focus Map";
86+
mapFocusButton.innerHTML = '|&#10094;';
87+
L.DomEvent.disableClickPropagation(mapFocusButton);
88+
L.DomEvent.on(mapFocusButton, 'click', L.DomEvent.stop);
89+
L.DomEvent.on(mapFocusButton, 'click', this._skipBackward, this);
90+
91+
// creates < button, focuses previous feature, if none exists focuses the current feature
92+
let previousButton = L.DomUtil.create('a', "mapml-popup-button", div);
93+
previousButton.href = '#';
94+
previousButton.role = "button";
95+
previousButton.title = "Previous Feature";
96+
previousButton.innerHTML = "&#10094;";
97+
L.DomEvent.disableClickPropagation(previousButton);
98+
L.DomEvent.on(previousButton, 'click', L.DomEvent.stop);
99+
L.DomEvent.on(previousButton, 'click', this._previousFeature, e.popup);
100+
101+
// static feature counter that 1/1
102+
let featureCount = L.DomUtil.create("p", "mapml-feature-count", div), currentFeature = 1;
103+
featureCount.innerText = currentFeature+"/1";
104+
//for(let feature of e.popup._source._path.parentNode.children){
105+
// if(feature === e.popup._source._path)break;
106+
// currentFeature++;
107+
//}
108+
//featureCount.innerText = currentFeature+"/"+e.popup._source._path.parentNode.childElementCount;
109+
110+
// creates > button, focuses next feature, if none exists focuses the current feature
111+
let nextButton = L.DomUtil.create('a', "mapml-popup-button", div);
112+
nextButton.href = '#';
113+
nextButton.role = "button";
114+
nextButton.title = "Next Feature";
115+
nextButton.innerHTML = "&#10095;";
116+
L.DomEvent.disableClickPropagation(nextButton);
117+
L.DomEvent.on(nextButton, 'click', L.DomEvent.stop);
118+
L.DomEvent.on(nextButton, 'click', this._nextFeature, e.popup);
119+
120+
// creates >| button, focuses map controls
121+
let controlFocusButton = L.DomUtil.create('a',"mapml-popup-button", div);
122+
controlFocusButton.href = '#';
123+
controlFocusButton.role = "button";
124+
controlFocusButton.title = "Focus Controls";
125+
controlFocusButton.innerHTML = '&#10095;|';
126+
L.DomEvent.disableClickPropagation(controlFocusButton);
127+
L.DomEvent.on(controlFocusButton, 'click', L.DomEvent.stop);
128+
L.DomEvent.on(controlFocusButton, 'click', this._skipForward, this);
129+
130+
let divider = L.DomUtil.create("hr");
131+
divider.style.borderTop = "1px solid #bbb";
132+
133+
e.popup._content.appendChild(divider);
134+
e.popup._content.appendChild(div);
135+
}
136+
137+
// When popup is open, what gets focused with tab needs to be done using JS as the DOM order is not in an accessibility friendly manner
138+
function focusFeature(focusEvent){
139+
if(focusEvent.originalEvent.path[0].title==="Focus Controls" && +focusEvent.originalEvent.keyCode === 9){
140+
L.DomEvent.stop(focusEvent);
141+
e.popup._source._path.focus();
142+
} else if(focusEvent.originalEvent.shiftKey && +focusEvent.originalEvent.keyCode === 9){
143+
e.target.closePopup(e.popup);
144+
L.DomEvent.stop(focusEvent);
145+
e.popup._source._path.focus();
146+
}
147+
}
148+
149+
function removeHandlers(removeEvent){
150+
if (removeEvent.popup === e.popup){
151+
e.target.off("keydown", focusFeature);
152+
e.target.off("popupclose", removeHandlers);
153+
}
154+
}
155+
// e.target = this._map
156+
// Looks for keydown, more specifically tab and shift tab
157+
e.target.on("keydown", focusFeature);
158+
159+
// if popup closes then the focusFeature handler can be removed
160+
e.target.on("popupclose", removeHandlers);
161+
},
162+
163+
_skipBackward: function(e){
164+
this._map.closePopup();
165+
this._map._container.focus();
166+
},
167+
168+
_previousFeature: function(e){
169+
this._map.closePopup();
170+
if(this._source._path.previousSibling){
171+
this._source._path.previousSibling.focus();
172+
} else {
173+
this._source._path.focus();
174+
}
175+
},
176+
177+
_nextFeature: function(e){
178+
this._map.closePopup();
179+
if(this._source._path.nextSibling){
180+
this._source._path.nextSibling.focus();
181+
} else {
182+
this._source._path.focus();
183+
}
184+
},
185+
186+
_skipForward: function(e){
187+
this._map.closePopup();
188+
this._map._controlContainer.focus();
189+
},
190+
50191
_handleMoveEnd : function(){
51192
let mapZoom = this._map.getZoom();
52193
if(mapZoom > this.zoomBounds.maxZoom || mapZoom < this.zoomBounds.minZoom){
@@ -62,6 +203,7 @@ export var MapMLFeatures = L.FeatureGroup.extend({
62203
this._map.getPixelBounds(),
63204
mapZoom,this._map.options.projection));
64205
this._removeCSS();
206+
this._updateTabIndex();
65207
},
66208

67209
//sets default if any are missing, better to only replace ones that are missing

0 commit comments

Comments
 (0)