From 7c65fad3a2652f4fdc440b1447cc8ae68e9e8991 Mon Sep 17 00:00:00 2001 From: Peter Rushforth Date: Sun, 23 Aug 2026 20:24:16 -0400 Subject: [PATCH] fix: prevent ghost click on settings gear during touch expand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On touch devices, tapping the collapsed layer control toggle fires synthetic mouseenter before the synthetic click. mouseenter expands the panel, hiding the toggle (display:none). The browser then retargets the click to whatever element now occupies those coordinates — the settings gear button — inadvertently toggling layer settings on every expand. Handle touchend on the toggle with preventDefault() to suppress all synthetic mouse events, and call expand() directly. Also add a touchstart listener on the map container for reliable tap-to-dismiss, since movestart alone does not fire on a plain tap (no pan). --- src/mapml/control/LayerControl.js | 38 +++++++++++++++++++++++++++++++ test/e2e/core/touchDevice.test.js | 28 +++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/mapml/control/LayerControl.js b/src/mapml/control/LayerControl.js index d4304e68e..b2f2849e9 100644 --- a/src/mapml/control/LayerControl.js +++ b/src/mapml/control/LayerControl.js @@ -36,6 +36,27 @@ export var LayerControl = Control.Layers.extend({ 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, + 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 + }); DomEvent.on( this._container, 'contextmenu', @@ -57,6 +78,18 @@ export var LayerControl = Control.Layers.extend({ 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; @@ -190,6 +223,11 @@ export var LayerControl = Control.Layers.extend({ } return this; }, + _expandOnTouch: function (e) { + DomEvent.preventDefault(e); + this._container._isExpanded = true; + this.expand(); + }, _preventDefaultContextMenu: function (e) { let latlng = this._map.mouseEventToLatLng(e); let containerPoint = this._map.mouseEventToContainerPoint(e); diff --git a/test/e2e/core/touchDevice.test.js b/test/e2e/core/touchDevice.test.js index ef65bdab6..3dbc881cb 100644 --- a/test/e2e/core/touchDevice.test.js +++ b/test/e2e/core/touchDevice.test.js @@ -67,4 +67,32 @@ test.describe('Playwright touch device tests', () => { ).jsonValue(); expect(menuDisplay).toEqual('none'); }); + + test('tap-expand does not ghost-click the settings button across cycles', async () => { + await page.goto('layerContextMenu.html', { waitUntil: 'networkidle' }); + + const layerControl = page.locator('.leaflet-control-layers'); + const settingsBtn = page + .locator('fieldset.mapml-layer-item .mapml-layer-item-settings-control') + .first(); + const settings = page + .locator('fieldset.mapml-layer-item > .mapml-layer-item-settings') + .first(); + const viewer = page.locator('mapml-viewer'); + + // Three expand/collapse cycles: without the touchend fix the + // ghost click retargets to the settings gear, toggling + // aria-expanded on every cycle. + for (let i = 0; i < 3; i++) { + await layerControl.tap(); + await expect(layerControl).toHaveClass(/leaflet-control-layers-expanded/); + await expect(settingsBtn).toHaveAttribute('aria-expanded', 'false'); + await expect(settings).toHaveAttribute('hidden', ''); + + await viewer.tap({ position: { x: 150, y: 150 } }); + await expect(layerControl).not.toHaveClass( + /leaflet-control-layers-expanded/ + ); + } + }); });