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:
parent
620cd54c3d
commit
c229edc66e
3 changed files with 156 additions and 137 deletions
138
library/components/map-controls/MapControlMenu.vue
Normal file
138
library/components/map-controls/MapControlMenu.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue