diff --git a/library/components/legends/LegendMobile.vue b/library/components/legends/LegendMobile.vue index 6e869b176..bd07c5141 100644 --- a/library/components/legends/LegendMobile.vue +++ b/library/components/legends/LegendMobile.vue @@ -602,7 +602,12 @@ const SNAP_MIN_PX = 24 // the .dragging class flag). const sheetRef = ref(null) const isDragging = ref(false) -const drag = { pending: null, active: false, startY: 0, startH: 0, startState: '', containerH: 0, maxH: 0, lastH: 0 } +const drag = { pending: null, active: false, startY: 0, startH: 0, startState: '', containerH: 0, maxH: 0, lastH: 0, tapTarget: null } +// One height write per frame while dragging (the latest finger position wins), never one per touchmove. +let _dragRaf = 0 +function flushDragHeight() { _dragRaf = 0; uiStore.setLegendHeight(`${drag.lastH}px`) } +// A control the reader may need the browser's own handling for (focus, a keyboard): never taken over. +const NATIVE_TAP = /^(INPUT|SELECT|TEXTAREA)$/ let lastDragEndAt = 0 // A drag that starts on a button (caret / close X / strip) can still be followed // by a synthesized click on release in some browsers — ignore it. @@ -724,6 +729,21 @@ function onSheetTouchStart(event) { if (!t || !sheet) return const inGrabZone = (t.clientY - sheet.getBoundingClientRect().top) <= GRAB_ZONE_PX if (!inGrabZone) return + // ★ THE HANDLE OWNS THE GESTURE FROM THE FIRST TOUCH (2026-10-02, the Driver's phone: a resize drag scrolled the + // page). Only the strip and the title rows carry touch-action:none, so a drag that began anywhere else in the + // 44px grab zone let the browser start panning before our touchmove could cancel it, and from then on its + // touchmoves were not cancelable: the page scrolled while the sheet resized. preventDefault on touchstart + // claims the gesture for the whole grab zone, whatever touch-action says. It also swallows the browser's own + // click, so a touch that never becomes a drag is turned back into one on release (tapTarget.click()), and + // inputs keep their native tap. + // The element to click on release: the nearest HTML element (a tap on a button's SVG icon lands on a , + // which has no click()), so the button's own @click runs. + let target = (event.composedPath && event.composedPath()[0]) || event.target + while (target && typeof target.click !== 'function') target = target.parentNode + if (!(target && NATIVE_TAP.test(target.tagName || '')) && event.cancelable) { + event.preventDefault() + drag.tapTarget = target + } else drag.tapTarget = null drag.pending = 'grab' drag.active = false drag.startY = t.clientY @@ -748,15 +768,25 @@ function onSheetTouchMove(event) { drag.active = true isDragging.value = true // .dragging → transition:none, same flush as the height } - event.preventDefault() // no page/content scroll while resizing - const h = Math.round(Math.min(drag.maxH, Math.max(COLLAPSED_PX, drag.startH + dy))) - drag.lastH = h - uiStore.setLegendHeight(`${h}px`) + if (event.cancelable) event.preventDefault() // no page/content scroll while resizing + drag.lastH = Math.round(Math.min(drag.maxH, Math.max(COLLAPSED_PX, drag.startH + dy))) + if (!_dragRaf) { + if (typeof requestAnimationFrame === 'function') _dragRaf = requestAnimationFrame(flushDragHeight) + else flushDragHeight() + } } -function onSheetTouchEnd() { +function onSheetTouchEnd(event) { drag.pending = null - if (!drag.active) return // a tap — the @click handlers own it + const tapTarget = drag.tapTarget + drag.tapTarget = null + if (!drag.active) { + // A tap. The @click handlers own it; if touchstart took the browser's click, hand it back (not on a cancel). + if (tapTarget && !(event && event.type === 'touchcancel') && typeof tapTarget.click === 'function') tapTarget.click() + return + } + // The last frame's height lands before the snap reads it. + if (_dragRaf) { if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(_dragRaf); flushDragHeight() } drag.active = false isDragging.value = false // transition back on BEFORE the snap so it animates lastDragEndAt = Date.now() diff --git a/library/components/map-controls/MapControlMenu.vue b/library/components/map-controls/MapControlMenu.vue index 533d7d772..4b667baa0 100644 --- a/library/components/map-controls/MapControlMenu.vue +++ b/library/components/map-controls/MapControlMenu.vue @@ -132,9 +132,13 @@ function onOtherOpened(e) { if (props.open && e.detail !== el.value) emit('close // it sat over the Share panel's ✕ (measured 2026-10-02, cities-with-internet). So every panel says it opened, as // a composed event that leaves the shadow root, and MapInfo puts its pointer away (library/components/info/MapInfo.vue). const PANEL_OPEN_EVENT = 'dxmaps:panel-open' +// ...and its twin when it closes (✕, an outside tap, Esc, or unmounted while open), so a HOST can hide its own chrome +// while a panel is up: DX Life's top scroll strip sat over the Share panel's ✕. +const PANEL_CLOSE_EVENT = 'dxmaps:panel-close' +const announceClose = () => { try { host.value?.dispatchEvent(new CustomEvent(PANEL_CLOSE_EVENT, { bubbles: true, composed: true })) } catch (_) {} } -watch(() => props.open, async (open) => { - if (!open) return +watch(() => props.open, async (open, was) => { + if (!open) { if (was) announceClose(); return } await nextTick() place() try { host.value?.dispatchEvent(new CustomEvent(OPEN_EVENT, { detail: el.value })) } catch (_) {} @@ -166,6 +170,7 @@ onMounted(() => { document.addEventListener('keydown', onKeyDown) }) onBeforeUnmount(() => { + if (props.open) announceClose() host.value?.removeEventListener(OPEN_EVENT, onOtherOpened) window.removeEventListener('resize', onResize) document.removeEventListener('click', onClickOutside, true)