MapControlMenu: one menu for every map control — inside the map, top and height of the toolbar column, on its inner side, RTL-aware, phone and desktop; the share button is its first consumer

This commit is contained in:
swift-river542 2026-09-16 22:46:21 -05:00
parent 620cd54c3d
commit c229edc66e
3 changed files with 156 additions and 137 deletions

View file

@ -0,0 +1,138 @@
<!--
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 and its max height is the toolbar column's
height — every menu of a map shares the same band, whichever button opened it.
• 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 below the toolbar column's height (null = the column)
height (Number|null) — a fixed height instead of fitting the content
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 { 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;}
`, '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 },
})
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({})
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)
let maxHeight = Math.max(120, Math.min(tr.height, ar.height - top - edge))
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') }
watch(() => props.open, async (open) => {
if (!open) return
await nextTick()
place()
try { host.value?.dispatchEvent(new CustomEvent(OPEN_EVENT, { detail: el.value })) } 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(() => {
host.value = toolbar?.el?.value?.closest(AREA) || null
host.value?.addEventListener(OPEN_EVENT, onOtherOpened)
window.addEventListener('resize', onResize)
document.addEventListener('click', onClickOutside, true)
document.addEventListener('keydown', onKeyDown)
})
onBeforeUnmount(() => {
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="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>

View file

@ -21,12 +21,18 @@
profiles that DO mount a geocoder.
-->
<script setup>
import { ref, provide } from 'vue'
import { useShadowStyles } from '../../composables/useShadowStyles.js'
defineProps({
hasGeocoder: { type: Boolean, default: true },
})
// The toolbar's element is provided to its controls: MapControlMenu measures it to place
// every menu at the toolbar's top, on its inner side, no taller than the control column.
const toolbarEl = ref(null)
provide('mapToolbar', { el: toolbarEl })
useShadowStyles(`
.map-toolbar {
position: absolute;
@ -34,7 +40,7 @@ useShadowStyles(`
/* Sit the control column near the edge with a small symmetric padding (3px) —
same value works whether the toolbar is swapped to the left or right side, and
it lines up with the search bar's 3px side inset. coder 2026-06-24. */
right: 3px;
inset-inline-end: 3px; /* mirrors to the left on right-to-left pages */
display: flex;
flex-direction: column;
gap: var(--map-toolbar-gap, 8px);
@ -61,7 +67,7 @@ useShadowStyles(`
</script>
<template>
<div class="map-toolbar" :class="{ 'map-toolbar--no-geocoder': !hasGeocoder }">
<div ref="toolbarEl" class="map-toolbar" :class="{ 'map-toolbar--no-geocoder': !hasGeocoder }">
<slot />
</div>
</template>

View file

@ -21,11 +21,12 @@
- Re-click the share button
-->
<script setup>
import { ref, computed, inject, unref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { ref, computed, inject, unref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useShadowStyles } from '../../composables/useShadowStyles.js'
import { resolveShareUrl } from '../../utils/embedBase.js'
import MapControlButton from './MapControlButton.vue'
import MapControlMenu from './MapControlMenu.vue'
/* ------------------------------------------------------------------ */
/* Shadow-DOM styles */
@ -49,62 +50,11 @@ useShadowStyles(`
/* ---- popover shell ---- */
/* ---- popover shell (imitates SemanticTreeLegend .stl-inner) ---- */
.share-pop{
position:absolute;
background:#161b22;
border:1px solid #30363d;
border-radius:12px;
box-shadow:0 6px 24px rgba(0,0,0,0.45);
padding:0;
z-index:3000;
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:share-pop-in 0.18s ease-out forwards;
color:#e6edf3;
}
@keyframes share-pop-in{
to{opacity:1;transform:translateY(0) scale(1);}
}
.share-pop.light{
background:#ffffff;
border:1px solid #d8dee4;
box-shadow:0 6px 24px rgba(0,0,0,0.16);
color:#1f2328;
}
/* ---- header / close ---- */
/* ---- header (imitates .stl-titlebar: 32px min-height, slim) ---- */
.share-header{
display:flex;
align-items:center;
justify-content:space-between;
padding:8px 12px;
min-height:32px;
border-bottom:1px solid #21262d;
}
.share-pop.light .share-header{border-bottom:1px solid #d8dee4;}
.share-title{
font:600 10px ui-monospace,monospace;
color:#73A17F;
text-transform:uppercase;
letter-spacing:0.07em;
}
.share-pop.light .share-title{color:#3b463d;}
.share-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;
}
.share-close:hover{color:#c9d1d9;background:rgba(59,70,61,0.18);border-color:#73A17F;}
.share-pop.light .share-close{background:rgba(208,215,222,0.4);border-color:#d0d7de;color:#57606a;}
.share-pop.light .share-close:hover{color:#3b463d;background:rgba(59,70,61,0.12);border-color:#3b463d;}
.share-pop.light{color:#1f2328;}
/* ---- tabs ---- */
.share-tabs{
@ -362,15 +312,6 @@ useShadowStyles(`
(10px gap below search). We mirror MapToolbar exactly so the popover's
top edge aligns with the top of the '+' button.
right:52 = MapToolbar right(10) + button-width(36) + 6px breathing room. */
@media(max-width:767px){
.share-pop{
max-width:none;
max-height:calc(100vh - 96px);
overflow-y:auto;
-webkit-overflow-scrolling:touch;
z-index:9999;
}
}
`, 'share-button')
const { t, tm, rt } = useI18n()
@ -383,25 +324,6 @@ const props = defineProps({
/* ---- state ---- */
const rootEl = ref(null)
const isOpen = ref(false)
// The menu is rendered INSIDE the map area (same shadow root, teleported out of the toolbar) and
// positioned from its button's rectangle, so it: stays inside the map's walls, scrolls with the map,
// sits above every other map layer (z 3000) without lifting the toolbar, and keeps its place on resize.
const menuHost = ref(null)
const menuStyle = ref({})
function placeMenu() {
const area = menuHost.value, btn = btnEl.value?.$el || btnEl.value
if (!area || !btn) { menuStyle.value = {}; return }
const ar = area.getBoundingClientRect(), br = btn.getBoundingClientRect()
// Same rule on every width: right edge 8px left of the button, as wide as fits (max 340).
const right = Math.max(8, ar.right - br.left + 8)
const width = Math.max(200, Math.min(340, ar.width - right - 12))
let top = br.top - ar.top
const pop = popoverEl.value
if (pop) { const h = pop.offsetHeight; if (top + h > ar.height - 8) top = Math.max(8, ar.height - 8 - h) }
menuStyle.value = { position: 'absolute', top: `${Math.round(top)}px`, right: `${Math.round(right)}px`, width: `${Math.round(width)}px` }
}
watch(isOpen, async (open) => { if (open) { await nextTick(); placeMenu(); await nextTick(); placeMenu() } })
function onResize() { if (isOpen.value) placeMenu() }
const popoverEl = ref(null)
const btnEl = ref(null)
const activeTab = ref('link') // 'link' | 'embed'
@ -494,35 +416,8 @@ async function copyEmbed() {
}
}
/* ---- outside click / escape ---- */
function onClickOutside(e) {
if (!isOpen.value) return
const path = e.composedPath ? e.composedPath() : [e.target]
if (popoverEl.value && path.some(el => el === popoverEl.value)) return
if (btnEl.value && path.some(el => el === btnEl.value || el === btnEl.value.$el)) return
/* Only close when the click lands on the bare map canvas — never when the
user is interacting with the legend, toolbar, search bar, or any other
overlay UI. This keeps the share menu open across unrelated UI actions. */
const hitMapCanvas = path.some(el => el?.classList?.contains?.('mapboxgl-canvas'))
if (!hitMapCanvas) return
close()
}
function onKeyDown(e) {
if (e.key === 'Escape' && isOpen.value) close()
}
onMounted(() => {
document.addEventListener('click', onClickOutside, true)
window.addEventListener('resize', onResize)
menuHost.value = rootEl.value?.closest('.dsm-map-area, .rm-map-area, .dm-map-area, [data-map-area]') || null
document.addEventListener('keydown', onKeyDown)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside, true)
window.removeEventListener('resize', onResize)
document.removeEventListener('keydown', onKeyDown)
})
/* Placement, stacking, outside-click and Escape live in MapControlMenu (shared by every
control menu); this button only toggles `isOpen`. */
</script>
<template>
@ -545,33 +440,13 @@ onBeforeUnmount(() => {
</svg>
</MapControlButton>
<!-- =================== Popover (teleported into the map area) =================== -->
<Teleport :to="menuHost" :disabled="!menuHost">
<div
v-if="isOpen"
ref="popoverEl"
class="share-pop"
:class="{ light: !isDark }"
:style="menuStyle"
>
<!-- Header -->
<div class="share-header">
<span class="share-title">Share</span>
<button class="share-close" @click="close" aria-label="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>
<!-- =================== Menu (shared MapControlMenu owns placement + chrome) =================== -->
<MapControlMenu :open="isOpen" :title="t('buttons.shareEmbed')" :is-dark="isDark" @close="close">
<div ref="popoverEl" class="share-pop" :class="{ light: !isDark }">
<!-- The multiplication path — one line each, top of the menu -->
<ul class="share-steps">
<li v-for="(step, i) in tm('buttons.shareSteps')" :key="i">{{ rt(step) }}</li>
</ul>
<!-- Tabs -->
<div class="share-tabs">
<button
@ -657,6 +532,6 @@ onBeforeUnmount(() => {
</button>
</div>
</div>
</Teleport>
</MapControlMenu>
</div>
</template>