Phones: a panel announces it closed; the legend's resize drag owns its gesture

- MapControlMenu dispatches 'dxmaps:panel-close' (composed, bubbling), the twin of 'dxmaps:panel-open', when a
  panel closes by ✕, an outside tap or Esc, or is unmounted while open. A host can then hide its own chrome
  while a panel is up (DX Life's top scroll strip sat over the Share panel's ✕).
- The mobile legend's resize drag scrolled the page. Only the strip and the title rows carried
  touch-action:none, so a drag that began elsewhere in the 44px grab zone let the browser start panning
  before touchmove could cancel it, and its later touchmoves were not cancelable. touchstart in the grab zone
  now claims the gesture (preventDefault), and a touch that never becomes a drag is clicked on release
  (inputs keep their native tap). The height is written once per frame (rAF), and the last frame lands
  before the snap.
This commit is contained in:
Doxa Maps 2026-10-02 06:51:32 +00:00
parent a3225e072f
commit c535c0f031
2 changed files with 44 additions and 9 deletions

View file

@ -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 <path>,
// 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()

View file

@ -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)