diff --git a/.nvmrc b/.nvmrc index 33db3f0ea..2bd5a0a98 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -15.11.0 +22 diff --git a/package-lock.json b/package-lock.json index 4997940a2..08d75cd78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "grunt-prettier": "^2.2.0", "leaflet": "^1.9.4", "leaflet.locatecontrol": "^0.81.1", - "mapml-extension": "git+https://github.com/Maps4HTML/mapml-extension", + "mapml-extension": "github:Maps4HTML/mapml-extension#main", "media-query-parser": "^3.0.2", "media-query-solver": "^0.1.3", "path": "^0.12.7", @@ -1904,7 +1904,7 @@ }, "node_modules/mapml-extension": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/Maps4HTML/mapml-extension.git#534fd64c01b736d4ea0383dcf7a0cfefce68026b", + "resolved": "git+ssh://git@github.com/Maps4HTML/mapml-extension.git#24cede26c81f1f095dd7e24fba4304e6c9752f88", "dev": true, "license": "W3C" }, diff --git a/package.json b/package.json index 20e695d84..d22682a2f 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "grunt-prettier": "^2.2.0", "leaflet": "^1.9.4", "leaflet.locatecontrol": "^0.81.1", - "mapml-extension": "git+https://github.com/Maps4HTML/mapml-extension", + "mapml-extension": "github:Maps4HTML/mapml-extension#main", "media-query-parser": "^3.0.2", "media-query-solver": "^0.1.3", "path": "^0.12.7", diff --git a/src/mapml-viewer.js b/src/mapml-viewer.js index de2c02c14..5f0c51996 100644 --- a/src/mapml-viewer.js +++ b/src/mapml-viewer.js @@ -481,7 +481,6 @@ export class HTMLMapmlViewerElement extends HTMLElement { collapsed: true, mapEl: this }).addTo(this._map); - this._map.on('movestart', this._layerControl.collapse, this._layerControl); let scaleValue = M.options.announceScale; diff --git a/src/mapml.css b/src/mapml.css index 77a7ac794..7ac258a3e 100644 --- a/src/mapml.css +++ b/src/mapml.css @@ -95,6 +95,19 @@ the browser)' */ color: revert; } +.mapml-layer-item-legend-link { + display: inline-block; + margin-block-start: .25rem; +} + +.mapml-layer-item-legend-image { + display: block; + max-width: min(100%, 16rem); + height: auto; + border: 1px solid #e3e3e3; + border-radius: 2px; +} + .leaflet-top .leaflet-control { margin-top: 5px; } @@ -277,9 +290,6 @@ and: https://developer.mozilla.org/en-US/docs/Web/CSS/:fullscreen */ .leaflet-control-layers fieldset { margin: 0; padding: 0; - /* Invisible (white) default fieldset border adds ~1.6px, shifting controls - below when a single-layer control expands on hover; remove it. */ - border: 0; min-height: 44px; } @@ -307,6 +317,42 @@ and: https://developer.mozilla.org/en-US/docs/Web/CSS/:fullscreen */ background-size: 34px; } +/* When the panel is expanded, split the visual "box" into two: the toggle + icon keeps its own bordered white square (matching its collapsed look), + and the layer list becomes a separate bordered white box below it. The + container itself becomes a transparent layout wrapper. pointer-events: + none lets map drag/pan pass through the empty area beside the toggle + and beside/above the expanded list; children re-enable pointer-events. */ +.leaflet-control-layers-expanded { + background: transparent !important; + border: 0 !important; + box-shadow: none !important; + padding: 0 !important; + pointer-events: none; +} + +.leaflet-control-layers-expanded .leaflet-control-layers-toggle { + display: block; + margin-left: auto; + background-color: #fff; + border: 1px solid #e3e3e3; + border-radius: 4px; + box-shadow: rgb(0 0 0 / 30%) 0px 1px 4px -1px; + pointer-events: auto; +} + +.leaflet-control-layers-expanded .leaflet-control-layers-list { + display: block; + position: relative; + margin-top: 1px; + background-color: #fff; + border: 1px solid #e3e3e3; + border-radius: 4px; + box-shadow: rgb(0 0 0 / 30%) 0px 1px 4px -1px; + overflow: hidden; + pointer-events: auto; +} + /* Revert Leaflet styles that are causing misalignment. */ .leaflet-control-layers-selector { margin-top: revert; @@ -804,7 +850,7 @@ label.mapml-layer-item-toggle { padding-block-start: .25rem; padding-block-end: .25rem; padding-inline-start: .25rem; - padding-inline-end: 1rem; + display: inline-block; } .mapml-layer-item-settings > * { diff --git a/src/mapml/control/LayerControl.js b/src/mapml/control/LayerControl.js index b2f2849e9..9bb528387 100644 --- a/src/mapml/control/LayerControl.js +++ b/src/mapml/control/LayerControl.js @@ -29,34 +29,37 @@ export var LayerControl = Control.Layers.extend({ }, onAdd: function () { this._initLayout(); - // Adding event on layer control button + // Remove Leaflet's auto-open/close listeners: the control now opens and + // closes only via clicking the toggle icon. + DomEvent.off(this._container, 'mouseenter', this._expandSafely, this); + DomEvent.off(this._container, 'mouseleave', this.collapse, this); + this._map.off('click', this.collapse, this); + + // Replace the toggle anchor with a clone to strip Leaflet's built-in + // expand-only click/keydown handlers, then wire toggle behaviour. + const originalLink = this._layersLink; + const link = originalLink.cloneNode(true); + originalLink.parentNode.replaceChild(link, originalLink); + this._layersLink = link; DomEvent.on( - this._container.getElementsByTagName('a')[0], - 'keydown', - this._focusFirstLayer, - this._container - ); - // Suppress synthetic mouse events on touch-expand so the browser - // can't retarget the ghost click to the settings gear button. - DomEvent.on( - this._container.getElementsByTagName('a')[0], - 'touchend', - this._expandOnTouch, + link, + { + click: function (e) { + DomEvent.preventDefault(e); + this._toggle(); + }, + keydown: function (e) { + if (e.keyCode === 13) { + DomEvent.preventDefault(e); + this._toggle(); + } + } + }, this ); - // Collapse on any touch outside the control; movestart alone is - // unreliable because a plain tap doesn't pan the map. - this._outsideTouchHandler = (e) => { - if (!this._container.contains(e.target)) { - this._container._isExpanded = false; - this.collapse(e); - } - }; - this._map - .getContainer() - .addEventListener('touchstart', this._outsideTouchHandler, { - passive: true - }); + + // Adding event on layer control button + DomEvent.on(link, 'keydown', this._focusFirstLayer, this._container); DomEvent.on( this._container, 'contextmenu', @@ -73,23 +76,11 @@ export var LayerControl = Control.Layers.extend({ }, onRemove: function (map) { DomEvent.off( - this._container.getElementsByTagName('a')[0], + this._layersLink, 'keydown', this._focusFirstLayer, this._container ); - DomEvent.off( - this._container.getElementsByTagName('a')[0], - 'touchend', - this._expandOnTouch, - this - ); - if (this._outsideTouchHandler) { - map - .getContainer() - .removeEventListener('touchstart', this._outsideTouchHandler); - this._outsideTouchHandler = null; - } }, addOrUpdateOverlay: function (layer, name) { var alreadyThere = false; @@ -123,12 +114,11 @@ export var LayerControl = Control.Layers.extend({ _focusFirstLayer: function (e) { if ( e.key === 'Enter' && - this.className === - 'leaflet-control-layers leaflet-control leaflet-control-layers-expanded' + this.classList.contains('leaflet-control-layers-expanded') ) { - var elem = - this.children[1].children[2].children[0].children[0].children[0] - .children[0]; + var elem = this.querySelector( + '.leaflet-control-layers-overlays input.leaflet-control-layers-selector' + ); if (elem) setTimeout(() => elem.focus(), 0); } }, @@ -193,40 +183,26 @@ export var LayerControl = Control.Layers.extend({ return layercontrols; }, - //overrides collapse and conditionally collapses the panel - collapse: function (e) { - // if layer control is not expanded, return - if (!this._container.className.includes('expanded')) { - return; - } - // return if layer contextmenu is still open - if ( - !this._map.contextMenu._extentLayerMenu.hidden || - !this._map.contextMenu._layerMenu.hidden - ) { - return; - } - if ( - e.target.tagName === 'SELECT' || - (e.relatedTarget && - e.relatedTarget.parentElement && - (e.relatedTarget.className === 'mapml-contextmenu mapml-layer-menu' || - e.relatedTarget.parentElement.className === - 'mapml-contextmenu mapml-layer-menu')) || - (this._map && this._map.contextMenu._layerMenu.style.display === 'block') - ) - return this; - + // Only the close button (or programmatic callers) should collapse the panel. + collapse: function () { DomUtil.removeClass(this._container, 'leaflet-control-layers-expanded'); - if (e.originalEvent?.pointerType === 'touch') { - this._container._isExpanded = false; + this._container._isExpanded = false; + return this; + }, + _toggle: function () { + if (DomUtil.hasClass(this._container, 'leaflet-control-layers-expanded')) { + this.collapse(); + } else { + this._expandSafely(); } return this; }, - _expandOnTouch: function (e) { - DomEvent.preventDefault(e); + // Track expanded state so touch-device logic in _preventDefaultContextMenu + // and any callers can consult _isExpanded uniformly. + expand: function () { + Control.Layers.prototype.expand.call(this); this._container._isExpanded = true; - this.expand(); + return this; }, _preventDefaultContextMenu: function (e) { let latlng = this._map.mouseEventToLatLng(e); diff --git a/src/mapml/elementSupport/layers/createLayerControlForLayer.js b/src/mapml/elementSupport/layers/createLayerControlForLayer.js index 3a3a0a14d..f9b9ccaac 100644 --- a/src/mapml/elementSupport/layers/createLayerControlForLayer.js +++ b/src/mapml/elementSupport/layers/createLayerControlForLayer.js @@ -145,13 +145,38 @@ export var createLayerControlHTML = async function () { // to `.href` here. `rel="noopener noreferrer"` closes the // reverse-tabnabbing and referrer-leak side channels for the // author-supplied cross-origin destination. - var legendLink = document.createElement('a'); - legendLink.text = ' ' + this._layer._title; + layerItemName.innerText = this._layer._title; + + let legendControl = DomUtil.create( + 'details', + 'mapml-layer-item-legend mapml-control-layers', + layerItemSettings + ), + legendSummary = DomUtil.create('summary'), + legendLink = document.createElement('a'), + legendImage = document.createElement('img'); + + legendSummary.innerText = mapEl.locale.lmLegend; + legendControl.appendChild(legendSummary); + legendLink.href = this._layer._legendUrl; legendLink.target = '_blank'; legendLink.rel = 'noopener noreferrer'; legendLink.draggable = false; - layerItemName.appendChild(legendLink); + legendLink.className = 'mapml-layer-item-legend-link'; + + legendImage.src = this._layer._legendUrl; + legendImage.alt = `${this._layer._title} ${mapEl.locale.lmLegend}`; + legendImage.loading = 'lazy'; + legendImage.decoding = 'async'; + legendImage.className = 'mapml-layer-item-legend-image'; + legendImage.addEventListener('error', () => { + legendLink.textContent = mapEl.locale.lmOpenInNewTab; + legendImage.remove(); + }); + + legendLink.appendChild(legendImage); + legendControl.appendChild(legendLink); } else { // textContent (not innerHTML) so that a malicious layer title // cannot inject markup into the layer control. diff --git a/src/web-map.js b/src/web-map.js index 821f71b81..bde460bb8 100644 --- a/src/web-map.js +++ b/src/web-map.js @@ -523,7 +523,6 @@ export class HTMLWebMapElement extends HTMLMapElement { collapsed: true, mapEl: this }).addTo(this._map); - this._map.on('movestart', this._layerControl.collapse, this._layerControl); let scaleValue = M.options.announceScale; diff --git a/test/e2e/layers/layerLegend.html b/test/e2e/layers/layerLegend.html new file mode 100644 index 000000000..ea3425f1d --- /dev/null +++ b/test/e2e/layers/layerLegend.html @@ -0,0 +1,60 @@ + + + + + Layer Legend Control Tests + + + + + + + + + + + + + + + + + + + + + + + + -75.697193 45.421530 + + + + + + + diff --git a/test/e2e/layers/layerLegend.test.js b/test/e2e/layers/layerLegend.test.js new file mode 100644 index 000000000..6339da207 --- /dev/null +++ b/test/e2e/layers/layerLegend.test.js @@ -0,0 +1,93 @@ +import { test, expect, chromium } from '@playwright/test'; + +test.describe('Layer legend tests', () => { + let page; + let context; + + test.beforeAll(async () => { + context = await chromium.launchPersistentContext('', { slowMo: 250 }); + page = await context.newPage(); + await page.goto('layerLegend.html'); + await page.locator('mapml-viewer').hover(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test('Legend layer has legend details in layer settings', async () => { + // Get the first layer in the overlay list (the one with an img legend) + const layer = page + .locator('.leaflet-control-layers-overlays > fieldset') + .first(); + + // Open the settings for that layer and check that the legend details are present + const settings = layer.locator('.mapml-layer-item-settings'); + + // Check that the legend details, name, and link are present and correct + const legendDetails = settings.locator('details.mapml-layer-item-legend'); + await expect(legendDetails).toHaveCount(1); + await expect(legendDetails.locator('summary')).toHaveText('Legend'); + await expect( + legendDetails.locator('a.mapml-layer-item-legend-link') + ).toHaveAttribute( + 'href', + 'http://maps.geogratis.gc.ca/wms/toporama_en?SERVICE=WMS&REQUEST=GetLegendGraphic&LAYER=WMS-Toporama&VERSION=1.1&FORMAT=image/png' + ); + await expect( + legendDetails.locator('img.mapml-layer-item-legend-image') + ).toHaveAttribute( + 'src', + 'http://maps.geogratis.gc.ca/wms/toporama_en?SERVICE=WMS&REQUEST=GetLegendGraphic&LAYER=WMS-Toporama&VERSION=1.1&FORMAT=image/png' + ); + + // check that the legend details are the second details element in the settings + const secondDetails = settings.locator('> details').nth(1); + await expect(secondDetails).toHaveClass( + 'mapml-layer-item-legend mapml-control-layers' + ); + }); + + test('Layer without legend does not render legend details in settings', async () => { + // Get the second layer in the overlay list (the one without a legend) + const layer = page + .locator('.leaflet-control-layers-overlays > fieldset') + .nth(1); + + // check that the settings for that layer do not contain any legend details + const settings = layer.locator('.mapml-layer-item-settings'); + await expect( + settings.locator('details.mapml-layer-item-legend') + ).toHaveCount(0); + }); + + test('Layer with a non img legend renders a legend link', async () => { + // Get the third layer in the overlay list (the one with a non img legend) + const layer = page + .locator('.leaflet-control-layers-overlays > fieldset') + .nth(2); + + // check that the settings for that layer contain a legend link + const settings = layer.locator('.mapml-layer-item-settings'); + + // Check that the legend details, name, and link are present and correct + const legendDetails = settings.locator('details.mapml-layer-item-legend'); + await expect(legendDetails).toHaveCount(1); + await expect(legendDetails.locator('summary')).toHaveText('Legend'); + await expect( + legendDetails.locator('a.mapml-layer-item-legend-link') + ).toHaveAttribute('href', 'https://maps4html.org/web-map-doc/'); + + // not working, need to manually open the legend for it to update. + /*await expect( + legendDetails.locator('a.mapml-layer-item-legend-link') + ).toHaveText("Open Legend"); + */ + + // check that the legend details are the second details element in the settings + const secondDetails = settings.locator('> details').nth(1); + await expect(secondDetails).toHaveClass( + 'mapml-layer-item-legend mapml-control-layers' + ); + }); +}); diff --git a/test/e2e/mapml-viewer/localization.html b/test/e2e/mapml-viewer/localization.html index 3f70c5ef1..d8d96ae0e 100644 --- a/test/e2e/mapml-viewer/localization.html +++ b/test/e2e/mapml-viewer/localization.html @@ -4,11 +4,15 @@ localization.html -