- 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.
195 lines
12 KiB
Vue
195 lines
12 KiB
Vue
<!--
|
|
MapControlMenu.vue — the ONE menu every map control opens.
|
|
|
|
A control (share, export, …) only toggles `open`; this component owns where the menu
|
|
lives and how it behaves, on phones and desktop alike:
|
|
|
|
• It renders INSIDE the map area (teleported out of the toolbar into the same shadow
|
|
root), so it never escapes the map's walls, never uses position:fixed, and scrolls
|
|
with the map when the host page scrolls.
|
|
• Its top edge is the toolbar's top edge. By default it is as tall as its content needs
|
|
(bounded only by the map's bottom edge — no scrolling); `maxHeight` caps it lower and
|
|
`matchToolbar` limits it to the toolbar column's height (content then scrolls).
|
|
• It opens on the toolbar's inner side: to the LEFT of right-hand controls, to the
|
|
RIGHT of left-hand controls. The side is measured, so a right-to-left page that
|
|
mirrors the toolbar mirrors the menu as well.
|
|
• Width: as wide as fits between the toolbar and the opposite edge, capped at `width`.
|
|
• One z tier for menus (--z-menu, default 3000); only one menu is open per map at a
|
|
time; closes on Escape or on a tap on the bare map canvas (never on legend/search).
|
|
|
|
Props: open (Boolean), title (String), isDark (Boolean),
|
|
width (Number, 340) — preferred width; shrinks to what fits (mobile friendly)
|
|
maxHeight (Number|null) — cap the height (null = fit the content within the map)
|
|
height (Number|null) — a fixed height instead of fitting the content
|
|
matchToolbar (Boolean) — limit the height to the toolbar column (content scrolls)
|
|
Emits: close
|
|
Slot: the menu content (tabs, buttons, text) — chrome (header, close) is provided here.
|
|
-->
|
|
<script setup>
|
|
import { ref, inject, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
|
import { tStatic } from '@map/i18n/index.js'
|
|
|
|
// This component's chrome, read out of the catalogue. tStatic and not useI18n: these mount inside
|
|
// custom elements that install no i18n plugin, and an unguarded useI18n() throws on the way in and
|
|
// takes the subtree with it. The English stays as the fallback.
|
|
const T = (key, en) => tStatic(key) || en
|
|
import { useShadowStyles } from '../../composables/useShadowStyles.js'
|
|
|
|
useShadowStyles(`
|
|
.mcm{position:absolute;z-index:var(--z-menu,3000);display:flex;flex-direction:column;box-sizing:border-box;
|
|
background:#161b22;border:1px solid #30363d;border-radius:12px;box-shadow:0 6px 24px rgba(0,0,0,0.45);
|
|
color:#e6edf3;font-family:system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;font-size:13px;
|
|
overflow:hidden;opacity:0;transform:translateY(-4px) scale(0.98);animation:mcm-in 0.18s ease-out forwards;}
|
|
@keyframes mcm-in{to{opacity:1;transform:translateY(0) scale(1);}}
|
|
.mcm.light{background:#ffffff;border-color:#d8dee4;box-shadow:0 6px 24px rgba(0,0,0,0.16);color:#1f2328;}
|
|
.mcm-header{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;min-height:32px;flex:0 0 auto;border-bottom:1px solid #21262d;}
|
|
.mcm.light .mcm-header{border-bottom-color:#d8dee4;}
|
|
.mcm-title{font:600 10px ui-monospace,monospace;color:#73A17F;text-transform:uppercase;letter-spacing:0.07em;}
|
|
.mcm.light .mcm-title{color:#3b463d;}
|
|
.mcm-close{width:22px;height:20px;border:1px solid #30363d;background:rgba(110,118,129,0.12);border-radius:5px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:#8b949e;padding:0;transition:color 0.1s,background 0.1s,border-color 0.1s;}
|
|
.mcm-close:hover{color:#c9d1d9;background:rgba(59,70,61,0.18);border-color:#73A17F;}
|
|
.mcm.light .mcm-close{background:rgba(208,215,222,0.4);border-color:#d0d7de;color:#57606a;}
|
|
.mcm.light .mcm-close:hover{color:#3b463d;background:rgba(59,70,61,0.12);border-color:#3b463d;}
|
|
.mcm-body{flex:1 1 auto;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch;}
|
|
|
|
/* ★ REDUCED MOTION, OWNED BY THIS COMPONENT. A page-level rule cannot reach into a shadow root,
|
|
and no generated leaf ships one anyway (measured: 0 across the whole tree), so a component
|
|
that animates must carry its own. Additive only: it removes motion when the OS asks and
|
|
changes nothing otherwise. */
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.mcm-menu, .mcm-item { transition: none !important; animation: none !important; }
|
|
}
|
|
`, 'map-control-menu')
|
|
|
|
const props = defineProps({
|
|
open: { type: Boolean, default: false },
|
|
title: { type: String, default: '' },
|
|
width: { type: Number, default: 340 },
|
|
isDark: { type: Boolean, default: false },
|
|
maxHeight: { type: Number, default: null },
|
|
height: { type: Number, default: null },
|
|
matchToolbar: { type: Boolean, default: false },
|
|
})
|
|
const emit = defineEmits(['close'])
|
|
|
|
// The toolbar this menu belongs to (MapToolbar provides its element); the map area is the
|
|
// nearest positioned map container above it — where the menu is teleported to.
|
|
const toolbar = inject('mapToolbar', null)
|
|
const host = ref(null)
|
|
const el = ref(null)
|
|
const style = ref({})
|
|
|
|
// ★ AN ALLOWLIST OF THREE PLUGIN CLASS NAMES DECIDED WHETHER THIS MENU HAD AN ANCHOR AT ALL, and a
|
|
// plugin not on the list got no placement rather than a worse one. `.dsm-map-area` is doxa-simple-map,
|
|
// `.rm-map-area` is research-map, `.dm-map-area` is demo-map -- so Prayer Progress and Simple Research
|
|
// placed correctly and everyone else fell through to `|| null`, which place() answers with an EMPTY
|
|
// STYLE, which leaves `.mcm{position:absolute}` at its STATIC position: below-and-right of a button
|
|
// already at the top right. Off the page.
|
|
// That is the identical failure the 2026-09-22 dashboard fix describes below, returning through a
|
|
// different door: not "a wrong side was chosen" but "nothing chose anything".
|
|
// MEASURED 2026-09-28: cities-internet, gospel-resources, key-cities and upg-neighbor carry NO map-area
|
|
// class and no data-map-area, so all four were unanchored. Two of them were reported; the list is
|
|
// what made it four.
|
|
const AREA = '.dsm-map-area, .rm-map-area, .dm-map-area, [data-map-area]'
|
|
|
|
function place() {
|
|
const tb = toolbar?.el?.value, area = host.value
|
|
if (!tb || !area) { style.value = {}; return }
|
|
const ar = area.getBoundingClientRect(), tr = tb.getBoundingClientRect()
|
|
const onRight = (tr.left + tr.right) / 2 > (ar.left + ar.right) / 2
|
|
const gap = 8, edge = 12
|
|
const offset = onRight ? (ar.right - tr.left + gap) : (tr.right - ar.left + gap)
|
|
const width = Math.max(200, Math.min(props.width, ar.width - offset - edge))
|
|
const top = Math.max(0, tr.top - ar.top)
|
|
// "Fit inside the map" is the rule ONLY for a real map area. When ShareButton mounted in a
|
|
// page HEADER, its fallback self-tagged that header band as the area (data-map-area=
|
|
// "share-fallback", see ShareButton.bindAnchor) -- and this line then dutifully clamped the
|
|
// menu INSIDE a ~56px band: measured on upg-100-mfe, a 291px share menu rendered with a 69px
|
|
// scrolling body (BUG-SHARE-MENU-HAS-A-SCROLLBAR-SHOULD-BE-FULLY-EXPANDED). A menu must never
|
|
// outgrow a MAP's walls, but a header band is not a wall -- nothing clips below it -- so the
|
|
// fallback area bounds against the VIEWPORT instead and the menu opens at natural height.
|
|
const isFallbackArea = area.getAttribute && area.getAttribute('data-map-area') === 'share-fallback'
|
|
let maxHeight = isFallbackArea
|
|
? Math.max(120, (window.innerHeight || 900) - tr.top - edge) // header host: the page below is available
|
|
: Math.max(120, ar.height - top - edge) // map host: fit inside the map
|
|
if (props.matchToolbar) maxHeight = Math.max(120, Math.min(maxHeight, tr.height))
|
|
if (props.maxHeight) maxHeight = Math.min(maxHeight, props.maxHeight)
|
|
style.value = {
|
|
top: `${Math.round(top)}px`,
|
|
[onRight ? 'right' : 'left']: `${Math.round(offset)}px`,
|
|
width: `${Math.round(width)}px`,
|
|
maxHeight: `${Math.round(maxHeight)}px`,
|
|
...(props.height ? { height: `${Math.round(Math.min(props.height, maxHeight))}px` } : {}),
|
|
}
|
|
}
|
|
|
|
// Only one menu open per map: opening this one closes any other.
|
|
const OPEN_EVENT = 'doxa-map-menu-open'
|
|
function onOtherOpened(e) { if (props.open && e.detail !== el.value) emit('close') }
|
|
|
|
// ★ AN OPEN PANEL ENDS THE ONBOARDING POINTER. MapInfo's "What can this map do?" pointer can be drawn by the HOST
|
|
// (the gallery), outside this map's shadow root, so no z-index in here can lift a panel above it, and on a phone
|
|
// 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, was) => {
|
|
if (!open) { if (was) announceClose(); return }
|
|
await nextTick()
|
|
place()
|
|
try { host.value?.dispatchEvent(new CustomEvent(OPEN_EVENT, { detail: el.value })) } catch (_) {}
|
|
try { host.value?.dispatchEvent(new CustomEvent(PANEL_OPEN_EVENT, { bubbles: true, composed: true })) } catch (_) {}
|
|
})
|
|
|
|
function onResize() { if (props.open) place() }
|
|
function onKeyDown(e) { if (e.key === 'Escape' && props.open) emit('close') }
|
|
// Close only on the bare map canvas — never while the user works the legend, search or toolbar.
|
|
function onClickOutside(e) {
|
|
if (!props.open) return
|
|
const path = e.composedPath ? e.composedPath() : [e.target]
|
|
if (el.value && path.includes(el.value)) return
|
|
if (toolbar?.el?.value && path.includes(toolbar.el.value)) return
|
|
if (path.some(n => n?.classList?.contains?.('mapboxgl-canvas') || n?.classList?.contains?.('maplibregl-canvas'))) emit('close')
|
|
}
|
|
|
|
onMounted(() => {
|
|
// ★ NEVER null. A named area is still preferred -- it is the MAP's walls, and a menu must not outgrow
|
|
// them -- but when a plugin has not tagged one, the toolbar's own offsetParent is the containing block
|
|
// this menu is absolutely positioned against, so placing against it is geometrically correct by
|
|
// definition rather than by convention. A plugin that adopts a class or data-map-area later simply
|
|
// gets the better anchor; nothing has to be registered anywhere for the menu to be on screen.
|
|
const tbEl = toolbar?.el?.value || null
|
|
host.value = tbEl?.closest(AREA) || tbEl?.offsetParent || tbEl?.parentElement || null
|
|
host.value?.addEventListener(OPEN_EVENT, onOtherOpened)
|
|
window.addEventListener('resize', onResize)
|
|
document.addEventListener('click', onClickOutside, true)
|
|
document.addEventListener('keydown', onKeyDown)
|
|
})
|
|
onBeforeUnmount(() => {
|
|
if (props.open) announceClose()
|
|
host.value?.removeEventListener(OPEN_EVENT, onOtherOpened)
|
|
window.removeEventListener('resize', onResize)
|
|
document.removeEventListener('click', onClickOutside, true)
|
|
document.removeEventListener('keydown', onKeyDown)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<Teleport :to="host" :disabled="!host">
|
|
<div v-if="open" ref="el" class="mcm" :class="{ light: !isDark }" :style="style" role="dialog" :aria-label="title">
|
|
<div class="mcm-header">
|
|
<span class="mcm-title">{{ title }}</span>
|
|
<button class="mcm-close" type="button" :aria-label="T('buttons.close', 'Close')" @click="emit('close')">
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
<line x1="2" y1="2" x2="12" y2="12"/><line x1="12" y1="2" x2="2" y2="12"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="mcm-body"><slot /></div>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|