diff --git a/src/mapml-viewer.js b/src/mapml-viewer.js index b3151cf88..3c5e9e100 100644 --- a/src/mapml-viewer.js +++ b/src/mapml-viewer.js @@ -219,7 +219,7 @@ export class MapViewer extends HTMLElement { this.setControls(false,false,true); this._crosshair = M.crosshair().addTo(this._map); - + if(M.options.featureIndexOverlayOption) this._featureIndexOverlay = M.featureIndexOverlay().addTo(this._map); // https://github.com/Maps4HTML/Web-Map-Custom-Element/issues/274 this.setAttribute('role', 'application'); // Make the Leaflet container element programmatically identifiable diff --git a/src/mapml.css b/src/mapml.css index a2fe41969..1610a4ea6 100644 --- a/src/mapml.css +++ b/src/mapml.css @@ -430,6 +430,7 @@ } /* Disable pointer events where they'd interfere with the intended action. */ +.mapml-feature-index-box, .leaflet-tooltip, .leaflet-crosshair *, .mapml-layer-item-settings .mapml-control-layers summary label, @@ -649,7 +650,7 @@ button.mapml-button:disabled, box-sizing: border-box; } -.mapml-layer-item, +.mapml-layer-item, .mapml-layer-grouped-extents, .mapml-layer-extent { background-color: #fff; @@ -751,7 +752,7 @@ label.mapml-layer-item-toggle { /* * Feature styles. */ - + .mapml-vector-container svg :is( [role="link"]:focus, [role="link"]:hover, @@ -803,3 +804,76 @@ label.mapml-layer-item-toggle { right: 0; } + + +/** +* Feature Index + */ +.mapml-feature-index-box { + margin: -8% 0 0 -8%; + width: 16%; + left: 50%; + top: 50%; + position: absolute; + z-index: 10000; + outline: 2px solid #fff; +} + +.mapml-feature-index-box:after{ + display: block; + content: ''; + padding-top: 100%; +} + +.mapml-feature-index-box > svg { + position: absolute; + width: 100%; + height: 100%; +} + +.mapml-feature-index { + outline: 1px solid #000000; + contain: content; + border-radius: 4px; + background-color: #fff; + cursor: default; + z-index: 1000; + position: absolute; + top: auto; + left: 50%; + -ms-transform: translateX(-50%); + transform: translateX(-50%); + bottom: 30px; + padding-top: 5px; + height: 92px; + width: 450px; + font-size: 16px; +} + +.mapml-feature-index-content > span{ + width: 140px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: inline-block; + padding-left: 5px; + padding-right: 5px; +} + +.mapml-feature-index-content > span > kbd{ + background-color: lightgrey; + padding-right: 4px; + padding-left: 4px; + border-radius: 4px; +} + +.mapml-feature-index-content > span > span{ + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: pre; + width: 1px; +} + diff --git a/src/mapml/features/featureGroup.js b/src/mapml/features/featureGroup.js index 88bfc661e..12122cf0a 100644 --- a/src/mapml/features/featureGroup.js +++ b/src/mapml/features/featureGroup.js @@ -59,7 +59,7 @@ export var FeatureGroup = L.FeatureGroup.extend({ * @private */ _handleFocus: function(e) { - if((e.keyCode === 9 || e.keyCode === 16) && e.type === "keydown"){ + if((e.keyCode === 9 || e.keyCode === 16 || e.keyCode === 27) && e.type === "keydown"){ let index = this._map.featureIndex.currentIndex; if(e.keyCode === 9 && e.shiftKey) { if(index === this._map.featureIndex.inBoundFeatures.length - 1) @@ -78,14 +78,17 @@ export var FeatureGroup = L.FeatureGroup.extend({ this._map.featureIndex.inBoundFeatures[0].path.setAttribute("tabindex", -1); this._map.featureIndex.inBoundFeatures[index].path.setAttribute("tabindex", 0); } + } else if(e.keyCode === 27 && this._map.options.mapEl.shadowRoot.activeElement.nodeName === "g"){ + this._map.featureIndex.currentIndex = 0; + this._map._container.focus(); } - } else if (!([9, 16, 13, 27].includes(e.keyCode))){ + } else if (!([9, 16, 13, 27, 49, 50, 51, 52, 53, 54, 55].includes(e.keyCode))){ this._map.featureIndex.currentIndex = 0; this._map.featureIndex.inBoundFeatures[0].path.focus(); } if(e.target.tagName.toUpperCase() !== "G") return; - if((e.keyCode === 9 || e.keyCode === 16 || e.keyCode === 13) && e.type === "keyup") { + if((e.keyCode === 9 || e.keyCode === 16 || e.keyCode === 13 || (e.keyCode >= 49 && e.keyCode <= 55)) && e.type === "keyup") { this.openTooltip(); } else if (e.keyCode === 13 || e.keyCode === 32){ this.closeTooltip(); diff --git a/src/mapml/handlers/FeatureIndex.js b/src/mapml/handlers/FeatureIndex.js index 0011fd51d..6682bee84 100644 --- a/src/mapml/handlers/FeatureIndex.js +++ b/src/mapml/handlers/FeatureIndex.js @@ -90,8 +90,7 @@ export var FeatureIndex = L.Handler.extend({ b.dist = Math.sqrt(Math.pow(bc.x - mc.x, 2) + Math.pow(bc.y - mc.y, 2)); return a.dist - b.dist; }); - - this.inBoundFeatures[0].path.setAttribute("tabindex", 0); + if(!M.options.featureIndexOverlayOption) this.inBoundFeatures[0].path.setAttribute("tabindex", 0); }, /** diff --git a/src/mapml/index.js b/src/mapml/index.js index 6d587d814..7f4ae9fca 100644 --- a/src/mapml/index.js +++ b/src/mapml/index.js @@ -61,6 +61,7 @@ import {AnnounceMovement} from "./handlers/AnnounceMovement"; import { FeatureIndex } from "./handlers/FeatureIndex"; import { Options } from "./options"; import "./keyboard"; +import {featureIndexOverlay, FeatureIndexOverlay} from "./layers/FeatureIndexOverlay"; /* global L, Node */ (function (window, document, undefined) { @@ -655,6 +656,9 @@ M.debugOverlay = debugOverlay; M.Crosshair = Crosshair; M.crosshair = crosshair; +M.FeatureIndexOverlay = FeatureIndexOverlay; +M.featureIndexOverlay = featureIndexOverlay; + M.Feature = Feature; M.feature = feature; diff --git a/src/mapml/layers/FeatureIndexOverlay.js b/src/mapml/layers/FeatureIndexOverlay.js new file mode 100644 index 000000000..9b48213b5 --- /dev/null +++ b/src/mapml/layers/FeatureIndexOverlay.js @@ -0,0 +1,210 @@ +export var FeatureIndexOverlay = L.Layer.extend({ + onAdd: function (map) { + let svgInnerHTML = ``; + + this._container = L.DomUtil.create("div", "mapml-feature-index-box", map._container); + this._container.innerHTML = svgInnerHTML; + + this._output = L.DomUtil.create("output", "mapml-feature-index", map._container); + this._output.setAttribute("role", "status"); + this._output.setAttribute("aria-live", "polite"); + this._output.setAttribute("aria-atomic", "true"); + this._body = L.DomUtil.create("span", "mapml-feature-index-content", this._output); + this._body.index = 0; + this._output.initialFocus = false; + map.on("layerchange layeradd layerremove overlayremove", this._toggleEvents, this); + map.on('moveend focus templatedfeatureslayeradd', this._checkOverlap, this); + map.on("keydown", this._onKeyDown, this); + this._addOrRemoveFeatureIndex(); + }, + + _calculateReticleBounds: function () { + let bounds = this._map.getPixelBounds(); + let center = bounds.getCenter(); + let wRatio = Math.abs(bounds.min.x - bounds.max.x) / (this._map.options.mapEl.width); + let hRatio = Math.abs(bounds.min.y - bounds.max.y) / (this._map.options.mapEl.height); + + let reticleDimension = (getComputedStyle(this._container).width).replace(/[^\d.]/g,''); + if((getComputedStyle(this._container).width).slice(-1) === "%") { + reticleDimension = reticleDimension * this._map.options.mapEl.width / 100; + } + let w = wRatio * reticleDimension / 2; + let h = hRatio * reticleDimension / 2; + let minPoint = L.point(center.x - w, center.y + h); + let maxPoint = L.point(center.x + w, center.y - h); + let b = L.bounds(minPoint, maxPoint); + return M.pixelToPCRSBounds(b,this._map.getZoom(),this._map.options.projection); + }, + + _checkOverlap: function (e) { + if(e.type === "focus") this._output.initialFocus = true; + if(!this._output.initialFocus) return; + if(this._output.popupClosed) { + this._output.popupClosed = false; + return; + } + + this._map.fire("mapkeyboardfocused"); + + let featureIndexBounds = this._calculateReticleBounds(); + let features = this._map.featureIndex.inBoundFeatures; + let index = 1; + let keys = Object.keys(features); + let body = this._body; + + body.innerHTML = ""; + body.index = 0; + + body.allFeatures = []; + keys.forEach(i => { + let layer = features[i].layer; + let layers = features[i].layer._layers; + let bounds = L.bounds(); + + if(layers) { + let keys = Object.keys(layers); + keys.forEach(j => { + if(!bounds) bounds = L.bounds(layer._layers[j]._bounds.min, layer._layers[j]._bounds.max); + bounds.extend(layer._layers[j]._bounds.min); + bounds.extend(layer._layers[j]._bounds.max); + }); + } else if(layer._bounds){ + bounds = L.bounds(layer._bounds.min, layer._bounds.max); + } + + if(featureIndexBounds.overlaps(bounds)){ + let label = features[i].path.getAttribute("aria-label"); + + if (index < 8){ + body.appendChild(this._updateOutput(label, index, index)); + } + if (index % 7 === 0 || index === 1) { + body.allFeatures.push([]); + } + body.allFeatures[Math.floor((index - 1) / 7)].push({label, index, layer}); + if (body.allFeatures[1] && body.allFeatures[1].length === 1){ + body.appendChild(this._updateOutput("More results", 0, 9)); + } + index += 1; + } + }); + this._addToggleKeys(); + }, + + _updateOutput: function (label, index, key) { + let span = document.createElement("span"); + span.setAttribute("data-index", index); + //", " adds a brief auditory pause when a screen reader is reading through the feature index + //also prevents names with numbers + key from being combined when read + span.innerHTML = `${key}` + " " + label + ", "; + return span; + }, + + _addToggleKeys: function () { + let allFeatures = this._body.allFeatures; + for(let i = 0; i < allFeatures.length; i++){ + if(allFeatures[i].length === 0) return; + if(allFeatures[i - 1]){ + let label = "Previous results"; + allFeatures[i].push({label}); + } + + if(allFeatures[i + 1] && allFeatures[i + 1].length > 0){ + let label = "More results"; + allFeatures[i].push({label}); + } + } + }, + + _onKeyDown: function (e){ + let body = this._body; + let key = e.originalEvent.keyCode; + if (key >= 49 && key <= 55){ + if(!body.allFeatures[body.index]) return; + let feature = body.allFeatures[body.index][key - 49]; + if (!feature) return; + let layer = feature.layer; + if (layer) { + this._map.featureIndex.currentIndex = feature.index - 1; + if (layer._popup){ + this._map.closePopup(); + layer.openPopup(); + } + else layer.options.group.focus(); + } + } else if(key === 56){ + this._newContent(body, -1); + } else if(key === 57){ + this._newContent(body, 1); + } + }, + + _newContent: function (body, direction) { + let index = body.firstChild.getAttribute("data-index"); + let newContent = body.allFeatures[Math.floor(((index - 1) / 7) + direction)]; + if(newContent && newContent.length > 0){ + body.innerHTML = ""; + body.index += direction; + for(let i = 0; i < newContent.length; i++){ + let feature = newContent[i]; + let index = feature.index ? feature.index : 0; + let key = i + 1; + if (feature.label === "More results") key = 9; + if (feature.label === "Previous results") key = 8; + body.appendChild(this._updateOutput(feature.label, index, key)); + } + } + }, + + _toggleEvents: function (){ + this._map.on("viewreset move moveend focus blur popupclose", this._addOrRemoveFeatureIndex, this); + + }, + + _addOrRemoveFeatureIndex: function (e) { + let features = this._body.allFeatures ? this._body.allFeatures.length : 0; + //Toggle aria-hidden attribute so screen reader rereads the feature index on focus + if (!this._output.initialFocus) { + this._output.setAttribute("aria-hidden", "true"); + } else if(this._output.hasAttribute("aria-hidden")){ + let obj = this; + setTimeout(function () { + obj._output.removeAttribute("aria-hidden"); + }, 100); + } + + if(e && e.type === "popupclose") { + this._output.setAttribute("aria-hidden", "true"); + this._output.popupClosed = true; + } else if (e && e.type === "focus") { + this._container.removeAttribute("hidden"); + if (features !== 0) this._output.classList.remove("mapml-screen-reader-output"); + } else if (e && e.originalEvent && e.originalEvent.type === 'pointermove') { + this._container.setAttribute("hidden", ""); + this._output.classList.add("mapml-screen-reader-output"); + } else if (e && e.target._popup) { + this._container.setAttribute("hidden", ""); + } else if (e && e.type === "blur") { + this._container.setAttribute("hidden", ""); + this._output.classList.add("mapml-screen-reader-output"); + this._output.initialFocus = false; + this._addOrRemoveFeatureIndex(); + } else if (this._map.isFocused && e) { + this._container.removeAttribute("hidden"); + if (features !== 0) { + this._output.classList.remove("mapml-screen-reader-output"); + } else { + this._output.classList.add("mapml-screen-reader-output"); + } + } else { + this._container.setAttribute("hidden", ""); + this._output.classList.add("mapml-screen-reader-output"); + } + + }, + +}); + +export var featureIndexOverlay = function (options) { + return new FeatureIndexOverlay(options); +}; \ No newline at end of file diff --git a/src/mapml/layers/MapLayer.js b/src/mapml/layers/MapLayer.js index 3ffb63cb4..bc078d8a1 100644 --- a/src/mapml/layers/MapLayer.js +++ b/src/mapml/layers/MapLayer.js @@ -1314,7 +1314,7 @@ export var MapMLLayer = L.Layer.extend({ content.focus(); - if(group) { + if(group && !M.options.featureIndexOverlayOption) { // e.target = this._map // Looks for keydown, more specifically tab and shift tab group.setAttribute("aria-expanded", "true"); @@ -1349,15 +1349,15 @@ export var MapMLLayer = L.Layer.extend({ if((focusEvent.originalEvent.keyCode === 13 && path[0].classList.contains("leaflet-popup-close-button")) || focusEvent.originalEvent.keyCode === 27 ){ L.DomEvent.stopPropagation(focusEvent); - map._container.focus(); map.closePopup(popup); + map._container.focus(); if(focusEvent.originalEvent.keyCode !== 27)map._popupClosed = true; } else if (isTab && path[0].classList.contains("leaflet-popup-close-button")){ map.closePopup(popup); } else if ((path[0].title==="Focus Map" || path[0].classList.contains("mapml-popup-content")) && isTab && shiftPressed){ + map.closePopup(popup); setTimeout(() => { //timeout needed so focus of the feature is done even after the keypressup event occurs L.DomEvent.stop(focusEvent); - map.closePopup(popup); map._container.focus(); }, 0); } diff --git a/src/mapml/layers/TemplatedFeaturesLayer.js b/src/mapml/layers/TemplatedFeaturesLayer.js index f0ea8e24e..d6d0b96f2 100644 --- a/src/mapml/layers/TemplatedFeaturesLayer.js +++ b/src/mapml/layers/TemplatedFeaturesLayer.js @@ -117,6 +117,7 @@ export var TemplatedFeaturesLayer = L.Layer.extend({ _pullFeatureFeed(this._getfeaturesUrl(), MAX_PAGES) .then(function() { map.addLayer(features); + map.fire("templatedfeatureslayeradd"); M.TemplatedFeaturesLayer.prototype._updateTabIndex(context); }) .catch(function (error) { console.log(error);}); diff --git a/src/mapml/options.js b/src/mapml/options.js index 72785bf38..c8ed01e23 100644 --- a/src/mapml/options.js +++ b/src/mapml/options.js @@ -1,4 +1,5 @@ export var Options = { + featureIndexOverlayOption: false, announceMovement: false, locale: { cmBack: "Back", diff --git a/src/web-map.js b/src/web-map.js index 1c142a325..117f93b4a 100644 --- a/src/web-map.js +++ b/src/web-map.js @@ -234,6 +234,8 @@ export class WebMap extends HTMLMapElement { this.setControls(false,false,true); this._crosshair = M.crosshair().addTo(this._map); + if(M.options.featureIndexOverlayOption) this._featureIndexOverlay = M.featureIndexOverlay().addTo(this._map); + if (this.hasAttribute('name')) { var name = this.getAttribute('name'); if (name) { diff --git a/test/e2e/core/featureIndexOverlay.html b/test/e2e/core/featureIndexOverlay.html new file mode 100644 index 000000000..417f9ab87 --- /dev/null +++ b/test/e2e/core/featureIndexOverlay.html @@ -0,0 +1,42 @@ + + + + + Feature Index Overlay + + + + + + + + + + + + + + + + + restaurants + italian + african + asian + cajun + mexican + indian + + + + + + + + + \ No newline at end of file diff --git a/test/e2e/core/featureIndexOverlay.test.js b/test/e2e/core/featureIndexOverlay.test.js new file mode 100644 index 000000000..d0e04cb12 --- /dev/null +++ b/test/e2e/core/featureIndexOverlay.test.js @@ -0,0 +1,170 @@ +describe("Feature Index Overlay test", ()=> { + beforeAll(async () => { + await page.goto(PATH + "featureIndexOverlay.html"); + }); + + afterAll(async function () { + await context.close(); + }); + + test("Feature index overlay and reticle shows on focus", async () => { + const hiddenOverlay = await page.$eval( + "div > output.mapml-feature-index", + (output) => output.classList.contains("mapml-screen-reader-output") + ); + const hiddenReticle = await page.$eval( + "div > div.mapml-feature-index-box", + (div) => div.hasAttribute("hidden") + ); + + await page.keyboard.press("Tab"); + await page.waitForTimeout(500); + const afterTabOverlay = await page.$eval( + "div > output.mapml-feature-index", + (output) => output.classList.contains("mapml-screen-reader-output") + ); + const afterTabReticle = await page.$eval( + "div > div.mapml-feature-index-box", + (div) => div.hasAttribute("hidden") + ); + + await expect(hiddenOverlay).toEqual(true); + await expect(hiddenReticle).toEqual(true); + await expect(afterTabOverlay).toEqual(false); + await expect(afterTabReticle).toEqual(false); + }); + + test("Feature index content is correct", async () => { + const spanCount = await page.$eval( + "div > output.mapml-feature-index > span", + (span) => span.childElementCount + ); + const firstFeature = await page.$eval( + "div > output.mapml-feature-index > span > span:nth-child(1)", + (span) => span.innerText + ); + const moreResults = await page.$eval( + "div > output.mapml-feature-index > span > span:nth-child(8)", + (span) => span.innerText + ); + + await expect(spanCount).toEqual(8); + await expect(firstFeature).toContain("1 Vermont"); + await expect(moreResults).toContain("9 More results"); + }); + + test("Feature index more results are correct", async () => { + await page.keyboard.press("9"); + await page.waitForTimeout(500); + + const spanCount = await page.$eval( + "div > output.mapml-feature-index > span", + (span) => span.childElementCount + ); + const firstFeature = await page.$eval( + "div > output.mapml-feature-index > span > span:nth-child(1)", + (span) => span.innerText + ); + const prevResults = await page.$eval( + "div > output.mapml-feature-index > span > span:nth-child(5)", + (span) => span.innerText + ); + + await expect(spanCount).toEqual(5); + await expect(firstFeature).toContain("1 Pennsylvania"); + await expect(prevResults).toContain("8 Previous results"); + }); + + test("Feature index previous results are correct", async () => { + await page.keyboard.press("8"); + const spanCount = await page.$eval( + "div > output.mapml-feature-index > span", + (span) => span.childElementCount + ); + + await expect(spanCount).toEqual(8); + }); + + test("Feature index content is correct on moveend", async () => { + await page.keyboard.press("ArrowRight"); + await page.waitForTimeout(1000); + const spanCount = await page.$eval( + "div > output.mapml-feature-index > span", + (span) => span.childElementCount + ); + const firstFeature = await page.$eval( + "div > output.mapml-feature-index > span > span:nth-child(1)", + (span) => span.innerText + ); + + await expect(spanCount).toEqual(2); + await expect(firstFeature).toContain("1 Maine"); + }); + + test("Feature index overlay is hidden when empty, reticle still visible", async () => { + await page.keyboard.press("ArrowUp"); + await page.waitForTimeout(1000); + + const overlay = await page.$eval( + "div > output.mapml-feature-index", + (output) => output.classList.contains("mapml-screen-reader-output") + ); + const reticle = await page.$eval( + "div > div.mapml-feature-index-box", + (div) => div.hasAttribute("hidden") + ); + + await expect(overlay).toEqual(true); + await expect(reticle).toEqual(false); + }); + + test("Popup test with templated features", async () => { + await page.mouse.click(10, 600); + await page.waitForTimeout(500); + await page.focus('#map2 > div'); + await page.waitForTimeout(500); + + await page.keyboard.press("ArrowRight"); + await page.waitForTimeout(1000); + await page.keyboard.press("Control+ArrowUp"); + await page.waitForTimeout(1000); + + await page.keyboard.press("1"); + await page.waitForTimeout(500); + + const popupCount = await page.$eval( + "#map2 > div > div.leaflet-pane.leaflet-map-pane > div.leaflet-pane.leaflet-popup-pane", + (popup) => popup.childElementCount + ); + const popupName = await page.$eval( + "#map2 > div > div.leaflet-pane.leaflet-map-pane > div.leaflet-pane.leaflet-popup-pane", + (popup) => popup.children[0].innerText + ); + + await expect(popupCount).toEqual(1); + await expect(popupName).toContain("Hareg Cafe & Variety"); + }); + + test("Opening another popup with index keys closes already open popup", async () => { + await page.keyboard.press("2"); + await page.waitForTimeout(500); + + const popupCount = await page.$eval( + "#map2 > div > div.leaflet-pane.leaflet-map-pane > div.leaflet-pane.leaflet-popup-pane", + (popup) => popup.childElementCount + ); + const popupName = await page.$eval( + "#map2 > div > div.leaflet-pane.leaflet-map-pane > div.leaflet-pane.leaflet-popup-pane", + (popup) => popup.children[0].innerText + ); + const overlay = await page.$eval( + "#map2 > div > output.mapml-feature-index", + (output) => output.classList.contains("mapml-screen-reader-output") + ); + + await expect(popupCount).toEqual(1); + await expect(popupName).toContain("Banditos"); + await expect(overlay).toEqual(false); + }); + +}); \ No newline at end of file diff --git a/test/e2e/data/tiles/cbmt/us_pop_density.mapml b/test/e2e/data/tiles/cbmt/us_pop_density.mapml index cb143e9d0..7304af562 100644 --- a/test/e2e/data/tiles/cbmt/us_pop_density.mapml +++ b/test/e2e/data/tiles/cbmt/us_pop_density.mapml @@ -3,6 +3,7 @@ US Population Density +