From ac71afaf55371629ded6a557af21fd16bd8fabf5 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sat, 3 Oct 2026 06:05:46 +0000 Subject: [PATCH 1/5] Share opens on "Share this page"; Embed is its own tab; a header menu opens beside its button (shareable pages P3) ShareButton: the first tab is "Share this page", the link to exactly what the reader is looking at. Where the page owns its address (urlOwner 'app' or 'host', on the map's own profile-config or any element above it across shadow roots) that is the page's own URL, read at open and at copy; on a partner's page it is the map's own page, as before. The second tab, "Embed", says in plain words what embedding is and why it helps, keeps the buttons.shareSteps list, then the paste hint, the sizes and the code. Copy/sizes labels come from the catalogue. MapControlMenu: a standalone anchor (a Share button outside any map toolbar) resolves where its menu lives when it opens, and places it below the button, toward the side with room, inside the viewport, at the readable width (340 or the viewport minus its edges), converting into the menu's real containing block. The render-time area tag that sent header menus far left and narrow is gone. Map toolbars keep their placement unchanged. English only for now (the new share.json keys are recorded as translation debt in check:i18n-parity). check:map-menu-anchor reads a resolution that lives in a named function, every named-area return must keep its structural fallback. --- internal/check-i18n-parity.mjs | 4 + internal/check-map-menu-anchor.mjs | 13 +- .../map-controls/MapControlMenu.vue | 79 +++++++++-- .../components/map-controls/ShareButton.vue | 134 ++++++++++-------- library/i18n/locales/en/share.json | 12 +- 5 files changed, 169 insertions(+), 73 deletions(-) diff --git a/internal/check-i18n-parity.mjs b/internal/check-i18n-parity.mjs index 68d526324..306ca4e19 100644 --- a/internal/check-i18n-parity.mjs +++ b/internal/check-i18n-parity.mjs @@ -68,6 +68,10 @@ const areas = readdirSync(join(ROOT, 'en')).filter((f) => f.endsWith('.json')) // translations, add the locale here with its count and lower it as they arrive. // Never raise a number; never add a locale that is already complete. const DEBT = { + // 2026-10-03, the Share menu's 10 new share.json keys (shareable pages P3), English only for now: the Driver + // parked localization ("just English for now"). tStatic falls back to the English text, so these locales show + // English until the translations arrive; lower each to 0 then, and empty the map again. + ar: 10, de: 10, es: 10, fr: 10, hi: 10, it: 10, pt: 10, ro: 10, ru: 10, zh: 10, } const fails = [] diff --git a/internal/check-map-menu-anchor.mjs b/internal/check-map-menu-anchor.mjs index b25a89aab..8424a5a84 100644 --- a/internal/check-map-menu-anchor.mjs +++ b/internal/check-map-menu-anchor.mjs @@ -39,7 +39,18 @@ const m = code.match(/host\.value\s*=\s*([^\n]+)/) if (!m) { fails.push('the area resolution moved — this gate cannot find `host.value = …` and would pass blind.') } else { - const expr = m[1] + let expr = m[1] + // The resolution may live in a named function (resolveHost(), shareable pages P3: a standalone Share button + // re-resolves at open time). Then judge THAT function's body, and fail if it cannot be found. + const fn = expr.match(/^\s*(\w+)\(\)/) + if (fn) { + const body = code.match(new RegExp('function\\s+' + fn[1] + '\\s*\\(\\)\\s*\\{([\\s\\S]*?)\\n\\}')) + // every return that resolves a named area must carry its structural fallback on the SAME expression + const rets = body ? [...body[1].matchAll(/return\s+([^\n]*closest\([^\n]*)/g)].map((r) => r[1]) : [] + expr = rets.length && rets.every((r) => /offsetParent|parentElement|document\.body/.test(r)) ? rets.join(' ;; ') : (rets.find((r) => !/offsetParent|parentElement|document\.body/.test(r)) || '') + if (!body) fails.push(`the area resolution calls ${fn[1]}(), and this gate cannot find that function to read it.`) + else if (!rets.length) fails.push(`${fn[1]}() returns no named-area resolution, so this gate would pass blind.`) + } if (!/offsetParent|parentElement|document\.body/.test(expr)) { fails.push('MapControlMenu resolves its anchor from a class allowlist with no structural fallback:\n' + ` ${expr.trim()}\n` diff --git a/library/components/map-controls/MapControlMenu.vue b/library/components/map-controls/MapControlMenu.vue index 4b667baa0..813857ef4 100644 --- a/library/components/map-controls/MapControlMenu.vue +++ b/library/components/map-controls/MapControlMenu.vue @@ -14,6 +14,11 @@ 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`. + • A STANDALONE anchor (ShareButton in a page header, no map toolbar: `standalone` on the + provided anchor) opens BELOW its button instead, toward the side with room, inside the + VIEWPORT, at `width` (the maps' readable 340) or the viewport minus its edges, whichever is + less. Where it lives and where it goes are resolved when it OPENS, and its coordinates are + converted into the block that really contains it (see placeBeside). • 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). @@ -95,23 +100,17 @@ 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 } + if (toolbar.standalone) return placeBeside(tb) 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 + // A menu never outgrows the MAP's walls. (A header button is not placed here: placeBeside bounds it by the + // viewport, so it opens at natural height; measured on upg-100-mfe, a header band as the wall gave a 291px + // menu a 69px scrolling body, BUG-SHARE-MENU-HAS-A-SCROLLBAR-SHOULD-BE-FULLY-EXPANDED.) + let maxHeight = Math.max(120, ar.height - top - edge) if (props.matchToolbar) maxHeight = Math.max(120, Math.min(maxHeight, tr.height)) if (props.maxHeight) maxHeight = Math.min(maxHeight, props.maxHeight) style.value = { @@ -123,6 +122,45 @@ function place() { } } +// ★ BESIDE ITS BUTTON, INSIDE THE VIEWPORT (a standalone anchor: a Share button in a page header). Below the +// button, toward the side with room (a button right of centre opens leftward, its end edges aligned; RTL headers +// mirror with no special case, because the side is measured), at the readable width the maps use, clamped inside +// the viewport, as tall as its content up to the viewport's bottom edge. +// ★ THE BLOCK THAT CONTAINS IT IS MEASURED, NOT ASSUMED. The menu is position:absolute, so its coordinates are +// relative to its containing block, which the HOST's markup decides: an unpositioned wrapper hands it to the +// header above. Measuring against one element and positioning against another is what sent the header menu far +// to the left (upg100, 2026-10-03). So the target point is computed in viewport coordinates and converted into +// the containing block's own (its padding edge, scrolled), whatever element that turns out to be. +function placeBeside(tb) { + const vw = document.documentElement.clientWidth || window.innerWidth || 1024 + const vh = window.innerHeight || 800 + const r = tb.getBoundingClientRect(), gap = 8, edge = 12 + const width = Math.min(props.width, Math.max(160, vw - 2 * edge)) + const onRight = (r.left + r.right) / 2 > vw / 2 + const x = Math.max(edge, Math.min(onRight ? r.right - width : r.left, vw - edge - width)) + const y = r.bottom + gap + let maxHeight = Math.max(120, vh - y - edge) + if (props.maxHeight) maxHeight = Math.min(maxHeight, props.maxHeight) + const o = blockOrigin(el.value) + style.value = { + top: `${Math.round(y - o.y)}px`, + left: `${Math.round(x - o.x)}px`, + width: `${Math.round(width)}px`, + maxHeight: `${Math.round(maxHeight)}px`, + ...(props.height ? { height: `${Math.round(Math.min(props.height, maxHeight))}px` } : {}), + } +} +// The viewport position of the origin an absolutely positioned element is placed from: its offsetParent's padding +// edge, scrolled. A static body (or none) means the initial containing block: the document origin. +function blockOrigin(m) { + const cb = m && m.offsetParent + if (!cb || ((cb === document.body || cb === document.documentElement) && getComputedStyle(cb).position === 'static')) { + return { x: -(window.scrollX || 0), y: -(window.scrollY || 0) } + } + const b = cb.getBoundingClientRect() + return { x: b.left + cb.clientLeft - cb.scrollLeft, y: b.top + cb.clientTop - cb.scrollTop } +} + // 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') } @@ -139,6 +177,7 @@ const announceClose = () => { try { host.value?.dispatchEvent(new CustomEvent(PA watch(() => props.open, async (open, was) => { if (!open) { if (was) announceClose(); return } + if (toolbar?.standalone) host.value = resolveHost() // layout is known now: where the menu lives await nextTick() place() try { host.value?.dispatchEvent(new CustomEvent(OPEN_EVENT, { detail: el.value })) } catch (_) {} @@ -156,15 +195,27 @@ function onClickOutside(e) { if (path.some(n => n?.classList?.contains?.('mapboxgl-canvas') || n?.classList?.contains?.('maplibregl-canvas'))) emit('close') } +// Where the menu lives (the Teleport target). A map control: the map area, else the toolbar's offset parent. A +// standalone button: a named area if its host declares one, else its offset parent, kept in the button's own +// (shadow) root so the menu keeps this root's styles; else the root's top element. +function resolveHost() { + const tbEl = toolbar?.el?.value || null + if (!tbEl) return null + if (!toolbar.standalone) return tbEl.closest(AREA) || tbEl.offsetParent || tbEl.parentElement || null + const root = tbEl.getRootNode(), mine = (n) => n && n.getRootNode() === root ? n : null + let top = tbEl; while (top.parentElement) top = top.parentElement + return mine(tbEl.closest(AREA)) || mine(tbEl.offsetParent) || (root === document ? document.body : top) +} +// The "one menu at a time" listener follows the host, which a standalone button re-resolves on every open. +watch(host, (h, was) => { was?.removeEventListener(OPEN_EVENT, onOtherOpened); h?.addEventListener(OPEN_EVENT, onOtherOpened) }) + 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) + host.value = resolveHost() window.addEventListener('resize', onResize) document.addEventListener('click', onClickOutside, true) document.addEventListener('keydown', onKeyDown) diff --git a/library/components/map-controls/ShareButton.vue b/library/components/map-controls/ShareButton.vue index a34e6a376..063b20ffe 100644 --- a/library/components/map-controls/ShareButton.vue +++ b/library/components/map-controls/ShareButton.vue @@ -2,10 +2,13 @@ ShareButton.vue — Non-technical share modal for the map toolbar. Redesigned to match consumer-grade UX from YouTube, Spotify, Canva, and - Google Maps. Two tabs: + Google Maps. Two tabs, share FIRST (shareable pages P3): - 1. "Share Link" — clean URL display + one-click copy + social share icons - 2. "Add to Your Website" — friendly embed guidance with size presets + 1. "Share this page" — the link to exactly what the reader is looking at (the page's own + address, with its tab, where the page owns its address) + one-click copy. It is what a + person who presses Share expects: something to send to someone. + 2. "Embed" — for putting the map on ANOTHER website: what embedding is and why it helps, the + multiplication steps, then the code with size presets. The word "iframe" never appears in any user-visible text. The embed tab leads with a plain-English description, then shows the code below. @@ -31,6 +34,7 @@ const T = (key, en) => tStatic(key) || en import { useI18n } from 'vue-i18n' import { useShadowStyles } from '../../composables/useShadowStyles.js' import { resolveShareUrl } from '../../utils/embedBase.js' +import { urlOwnerOf } from '../../composables/useViewAddress.js' import MapControlButton from './MapControlButton.vue' import MapControlMenu from './MapControlMenu.vue' @@ -51,7 +55,7 @@ useShadowStyles(` /* The ring itself rotates, so the colours travel around the button in every browser. */ @keyframes share-ring-turn{to{transform:rotate(360deg);}} @media(prefers-reduced-motion:reduce){.share-wrap::before{animation:none;}} -.share-steps{margin:10px 14px 6px;padding:0 0 0 15px;list-style:'- ';font:14px/1.55 system-ui,sans-serif;color:#c9d1d9;} +.share-steps{margin:0 0 12px;padding:0 0 0 15px;list-style:'- ';font:14px/1.55 system-ui,sans-serif;color:#c9d1d9;} .share-steps li{margin:0 0 3px;padding-inline-start:3px;} .share-steps li:last-child{font-weight:600;color:#92b195;} .share-pop.light .share-steps{color:#3b463d;} @@ -348,7 +352,8 @@ const rootEl = ref(null) const isOpen = ref(false) const popoverEl = ref(null) const btnEl = ref(null) -const activeTab = ref('link') // 'link' | 'embed' +const activeTab = ref('page') // 'page' | 'embed' +const pageUrl = ref('') // "Share this page": read when the menu opens (see pageLink) const linkCopied = ref(false) const embedCopied = ref(false) const embedSize = ref('medium') // 'small' | 'medium' | 'large' @@ -359,6 +364,9 @@ const sizePresets = { large: { label: 'Large', height: 800 } } +// The size names, each key written out so the catalogue gate sees it. +const sizeLabel = (k) => ({ small: T('share.size_small', 'Small'), medium: T('share.size_medium', 'Medium'), large: T('share.size_large', 'Large') })[k] || k + /* ---- computed ---- */ /* The standalone embed page this map is shared/iframed as — resolved by utils/embedBase.js (its header documents the full ladder). In short: @@ -387,11 +395,33 @@ const embedSnippet = computed(() => { return '' }) +/* ★ SHARE THIS PAGE. Where the page OWNS its address (useViewAddress: urlOwner 'app' on our own pages, + 'host' on DX Life), the address bar already names the exact view (the tab, and the filters), so the + link is the page's own URL, read when the menu opens and again at copy time, since the reader can + switch tabs in between. The owner is this map's own profile-config, or the nearest element above it, + across shadow roots, whose profile-config names one: a map nested in the gallery is told nothing, and + the gallery is the page. On a partner's page (no owner) the address is the partner's, so the link is + this map's own page, as before. */ +const cfgOf = (c) => { if (typeof c !== 'string') return c || null; try { return JSON.parse(c) } catch { return null } } +function pageOwner() { + if (urlOwnerOf(cfgOf(unref(profileConfig)))) return true + for (let n = rootEl.value; n; ) { + n = n.parentNode || n.host + if (n && n.nodeType === 1 && urlOwnerOf(cfgOf(n.profileConfig ?? n.getAttribute('profile-config')))) return true + } + return false +} +function pageLink() { + try { if (pageOwner() && window.location.href) return window.location.href } catch { /* no window */ } + return shareUrl.value +} + /* ---- actions ---- */ function toggle() { isOpen.value = !isOpen.value if (isOpen.value) { - activeTab.value = 'link' + activeTab.value = 'page' + pageUrl.value = pageLink() linkCopied.value = false embedCopied.value = false } @@ -425,7 +455,8 @@ async function copyText(text) { } async function copyLink() { - if (await copyText(shareUrl.value)) { + pageUrl.value = pageLink() + if (await copyText(pageUrl.value)) { linkCopied.value = true setTimeout(() => { linkCopied.value = false }, 2000) } @@ -477,39 +508,24 @@ async function copyEmbed() { const providedToolbar = inject('mapToolbar', null) const selfAnchor = ref(null) // Re-provide UNCONDITIONALLY so the value is stable for the child's inject(); `el` stays null when -// a real toolbar was found, and MapControlMenu keeps using that toolbar instead. -provide('mapToolbar', providedToolbar || { el: selfAnchor }) +// a real toolbar was found, and MapControlMenu keeps using that toolbar instead. `standalone` tells +// MapControlMenu this anchor is ONE button outside any map (a page header), placed beside the button +// inside the viewport, at open time (MapControlMenu.placeBeside). +provide('mapToolbar', providedToolbar || { el: selfAnchor, standalone: true }) const hasOwnAnchor = !providedToolbar /* - ★ THIS IS A FUNCTION REF, AND onMounted WOULD NOT HAVE WORKED — the hazard is worth naming, - because the fix silently does NOTHING if it is written the obvious way. - - MapControlMenu latches its area ONCE, in its own onMounted: - host.value = toolbar?.el?.value?.closest(AREA) || null - and place() bails forever after if that is null. MapControlMenu is a CHILD of this component, and - Vue runs a child's mounted hook BEFORE its parent's. So an onMounted here would set the anchor - one tick too late: the child would have already read `null`, latched `host = null`, and no later - assignment would revive it — the menu would go on opening at its static position and the fix - would look applied while changing nothing. - - A function ref is invoked during RENDER, before any mounted hook on either component, so the - anchor and the area tag are in place by the time the child looks for them. - - `data-map-area` is what MapControlMenu.AREA finds via closest(). It is tagged on the OFFSET - PARENT, not on .share-wrap: the wrapper is a 32px button, and an area that small would clamp the - menu to its 200px minimum jammed against the edge. The offset parent is the top bar, which spans - the row, so the menu has the full width to open leftward into. + ★ THE ANCHOR IS THE BUTTON, AND NOTHING IS DECIDED AT RENDER TIME (shareable pages P3). This used to tag + `el.offsetParent || el.parentElement` as the menu's area DURING RENDER (a function ref, so it ran before + MapControlMenu latched its area). Before layout offsetParent is null, so the tag fell on the button's + immediate parent: in a header with a narrow, unpositioned wrapper (upg100 measured .hdr-btns) the menu + measured against that wrapper, clamped to its 200px floor, and was positioned against the header above + it: far to the left, and narrow (the Driver's report, 2026-10-03). Now the button only names itself; + MapControlMenu resolves where the menu lives and where it goes when it OPENS, with layout known. */ function bindAnchor(el) { rootEl.value = el || null - if (!hasOwnAnchor || !el) { if (!el) selfAnchor.value = null; return } - const area = el.offsetParent || el.parentElement - // Never re-tag a host that already declares itself an area — that host owns the decision. - if (area && !area.closest('.dsm-map-area, .rm-map-area, .dm-map-area, [data-map-area]')) { - area.setAttribute('data-map-area', 'share-fallback') - } - selfAnchor.value = el + selfAnchor.value = hasOwnAnchor && el ? el : null } /* Placement, stacking, outside-click and Escape live in MapControlMenu (shared by every @@ -517,8 +533,8 @@ function bindAnchor(el) {