MapInfo can answer a map-control info button (MapInfoButton), fail-open

The "what can this map do for you?" button can now live IN a map's control
column instead of floating over it. This is the bridge only: nothing changes
until a map adds the control and its gallery tab sets trigger: 'control'.

- map-controls/MapInfoButton.vue (new): a MapControlButton with the info
  icon and the localized mapInfo.open label. Clicking it sends a bubbling,
  composed 'dxmaps:mapinfo-open' event out of the plugin's shadow root to
  the gallery pane. On mount it asks 'dxmaps:mapinfo-control' and shows
  ONLY if a MapInfo on the same pane answers (ack), so a standalone leaf or
  a partner page never shows a button that opens nothing. A MapInfo that
  mounts later broadcasts 'dxmaps:mapinfo-host' and the button asks again.
- info/MapInfo.vue: new prop trigger, 'floating' (default, unchanged) |
  'control'. In control mode it listens on its own pane: it acts only on
  events whose composedPath contains the pane, and stops them there. Its own
  floating button and pointer hide ONLY while the announced control is
  MEASURED usable: connected, a non-zero on-screen rect, and in no
  inert/aria-hidden subtree. It is re-measured by IntersectionObserver and
  ResizeObserver plus a 1.5 s fallback timer, never inferred from an unmount
  event. Escape and close return focus to the control when it is on screen,
  else to the floating button. aria-expanded is kept on the control.

Proved on a UPG-100 pilot served locally (gallery page, 1440, MapLibre and
Mapbox, en and ar), exactly one info button on screen at every step:
wall 1 floating; Map 1 control (it wins its hit test and sits inside the
toolbar column); opened from the control, Escape closes it with focus back
on the control; toolbar hidden 1 floating, shown again 1 control; tab away
and back 1; back to the wall 1 floating. 0 page errors. That pilot's wiring
is not in this commit.
This commit is contained in:
Doxa Maps 2026-09-30 03:11:40 +00:00
parent 766d829028
commit b4664aa67e
3 changed files with 154 additions and 5 deletions

View file

@ -74,6 +74,8 @@ and a change here can break other maps.
`import … from '@map/components/map-controls/MapControlButton.vue'`
- **MapControlMenu** — MapControlMenu.vue — the ONE menu every map control opens.
`import … from '@map/components/map-controls/MapControlMenu.vue'`
- **MapInfoButton** — MapInfoButton.vue — the map's "what can this map do for you?" control, IN the map's control column.
`import … from '@map/components/map-controls/MapInfoButton.vue'`
- **MapToolbar** — MapToolbar.vue — Positioned layout shell for map control buttons.
`import … from '@map/components/map-controls/MapToolbar.vue'`
- **ShareButton** — ShareButton.vue — Non-technical share modal for the map toolbar.

View file

@ -43,7 +43,7 @@
* Usage (one per map, inside a positioned container):
* <MapInfo storage-key="upg-100" lead-key="…" :examples="[…]" :assets-base="assetsBase" />
*/
import { ref, reactive, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { ref, reactive, nextTick, onMounted, onBeforeUnmount, watch } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useOptionalT } from '@map/composables/useOptionalT.js'
import { tStatic, getRuntimeLocale } from '@map/i18n/index.js'
@ -68,6 +68,13 @@ const props = defineProps({
placement: { type: String, default: 'top-right', validator: (v) => ['top-right', 'top-left'].includes(v) },
/** The host's current locale, when it tracks one. */
locale: { type: String, default: '' },
/**
* 'floating' (default): this component draws its own button and pointer over its container.
* 'control': the map draws the button in its own control column (map-controls/MapInfoButton.vue) and
* this component answers it. Its own button stays as the FALLBACK, shown whenever that control is not
* actually on screen (see "control mode" below).
*/
trigger: { type: String, default: 'floating', validator: (v) => ['floating', 'control'].includes(v) },
})
// ── locale and text ──────────────────────────────────────────────────────────────────────────────
@ -132,8 +139,82 @@ async function hide() {
writeStore(SEEN + props.storageKey, '1')
pointing.value = false
await nextTick()
btnEl.value?.focus()
// Back to whichever button the reader can see: the map's own control when it is on screen, else ours.
const back = props.trigger === 'control' && controlShown.value && control.value ? control.value : btnEl.value
back?.focus()
}
// ── control mode ─────────────────────────────────────────────────────────────────────────────────
// ★ FAIL-OPEN: THERE IS ALWAYS EXACTLY ONE ⓘ. The map's control (MapInfoButton) announces itself and asks
// for the popup with composed events that bubble out of the plugin's shadow root to the pane this
// component sits in. Our own button is hidden ONLY while that control is MEASURED to be usable:
// connected, a non-zero rect on screen, and in no inert subtree (the composite marks its inactive pane
// inert, and an inert pane keeps its rects). It is re-measured by IntersectionObserver and ResizeObserver
// on the control, and by a slow fallback timer. It is NEVER inferred from an unmount event: a detached
// element cannot bubble anything to the pane. So on the UPG-100 wall (no map on screen), on a tab being
// switched away and back, or with the column hidden, our button is the one that shows.
const rootEl = ref(null)
const control = ref(null)
const controlShown = ref(false)
let pane = null, io = null, ro = null, timer = null
function inert(el) {
for (let n = el; n; n = n.parentNode || n.host) {
if (n.nodeType === 1 && (n.hasAttribute('inert') || n.getAttribute('aria-hidden') === 'true')) return true
}
return false
}
function measureControl() {
const c = control.value
let shown = false
if (c && c.isConnected && !inert(c)) {
const r = c.getBoundingClientRect()
shown = r.width > 0 && r.height > 0 && r.bottom > 0 && r.right > 0 && r.top < window.innerHeight && r.left < window.innerWidth
}
controlShown.value = shown
}
function adopt(el) {
if (!el || el === control.value) { measureControl(); return }
try { io?.disconnect(); ro?.disconnect() } catch { /* already gone */ }
control.value = el
try { io = new IntersectionObserver(measureControl); io.observe(el) } catch { io = null }
try { ro = new ResizeObserver(measureControl); ro.observe(el) } catch { ro = null }
el.setAttribute('aria-expanded', String(open.value))
measureControl()
}
// Only events that travelled through OUR pane; they stop here, so an outer host never sees them twice.
const ours = (e) => !!pane && typeof e.composedPath === 'function' && e.composedPath().includes(pane)
function onControl(e) {
if (!ours(e)) return
e.stopPropagation()
if (e.detail) e.detail.ack = true
adopt(e.detail && e.detail.el)
}
function onOpenRequest(e) {
if (!ours(e)) return
e.stopPropagation()
adopt(e.detail && e.detail.el)
if (!open.value) show()
}
onMounted(() => {
if (props.trigger !== 'control') return
pane = rootEl.value && rootEl.value.parentElement
if (!pane) return
pane.addEventListener('dxmaps:mapinfo-control', onControl)
pane.addEventListener('dxmaps:mapinfo-open', onOpenRequest)
timer = window.setInterval(measureControl, 1500)
// A control that mounted before we did asked into the void; tell it to ask again.
try { window.dispatchEvent(new CustomEvent('dxmaps:mapinfo-host')) } catch { /* no CustomEvent */ }
})
onBeforeUnmount(() => {
if (pane) {
pane.removeEventListener('dxmaps:mapinfo-control', onControl)
pane.removeEventListener('dxmaps:mapinfo-open', onOpenRequest)
}
try { io?.disconnect(); ro?.disconnect() } catch { /* already gone */ }
if (timer) window.clearInterval(timer)
})
watch(open, (v) => { control.value?.setAttribute('aria-expanded', String(v)) })
const floatingShown = () => props.trigger !== 'control' || !controlShown.value
// ★ aria-modal PROMISES THE READER THAT TAB STAYS INSIDE, so keep it there: Tab from the last control
// wraps to the first, Shift+Tab from the first to the last. The active element is read from the shadow
// root (document.activeElement would be the host element). Hidden controls are skipped.
@ -307,8 +388,8 @@ useShadowStyles(`
</script>
<template>
<div class="mi" :dir="isRtl() ? 'rtl' : 'ltr'" :lang="lang()">
<button ref="btnEl" type="button" class="mi-btn" :class="placement === 'top-left' ? 'mi-at-left' : 'mi-at-right'"
<div ref="rootEl" class="mi" :dir="isRtl() ? 'rtl' : 'ltr'" :lang="lang()">
<button v-show="floatingShown()" ref="btnEl" type="button" class="mi-btn" :class="placement === 'top-left' ? 'mi-at-left' : 'mi-at-right'"
:aria-label="t('mapInfo.open')" :title="t('mapInfo.open')" aria-haspopup="dialog" :aria-expanded="String(open)"
@click="show">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
@ -318,7 +399,7 @@ useShadowStyles(`
</svg>
</button>
<div v-if="pointing && !open" class="mi-pointer" :class="placement === 'top-left' ? 'mi-at-left' : 'mi-at-right'" aria-hidden="true">
<div v-if="pointing && !open && floatingShown()" class="mi-pointer" :class="placement === 'top-left' ? 'mi-at-left' : 'mi-at-right'" aria-hidden="true">
<span>{{ t('mapInfo.pointer') }}</span>
<svg viewBox="0 0 16 16" focusable="false">
<path v-if="placement === 'top-left'" d="M14 8H3M7 3.5 2.5 8 7 12.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />

View file

@ -0,0 +1,66 @@
<!--
MapInfoButton.vue — the map's "what can this map do for you?" control, IN the map's control column.
It opens the host's MapInfo popup and owns no popup of its own. The host (the gallery) mounts one
MapInfo per tab over the tab's pane, with trigger="control"; this button reaches it with a bubbling,
COMPOSED event, so the plugin's shadow root is no wall. The popup, its examples and its focus handling
stay in one place (library/components/info/MapInfo.vue).
★ IT SHOWS ONLY WHEN A HOST ANSWERS. A control that opens nothing is worse than no control: a
standalone leaf or a partner page has no MapInfo. So on mount the button ASKS ('dxmaps:mapinfo-control',
detail.ack = false), and a MapInfo in control mode on the SAME pane sets ack = true while the event is
being dispatched (dispatch is synchronous, so the answer is there when dispatchEvent returns). No answer:
the button stays hidden, and asks again when a host announces itself later ('dxmaps:mapinfo-host' on
window, for a MapInfo that mounted after the map).
★ HIDING IS NOT THE HOST'S SIGNAL. The host decides whether its floating ⓘ is needed by MEASURING this
button (connected, a non-zero on-screen rect, not inert), never by an unmount event: a detached element
cannot bubble anything to the pane. See MapInfo's control mode.
Props:
isDark — theme flag forwarded from parent
-->
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import MapControlButton from './MapControlButton.vue'
const { t } = useI18n()
defineProps({
isDark: { type: Boolean, default: false },
})
const hosted = ref(false)
const btn = ref(null)
const node = () => (btn.value && btn.value.$el) || null
function ask() {
const el = node()
if (!el) return
const detail = { el, ack: false }
try { el.dispatchEvent(new CustomEvent('dxmaps:mapinfo-control', { bubbles: true, composed: true, detail })) } catch { /* no CustomEvent */ }
hosted.value = detail.ack === true
}
function openInfo() {
const el = node()
if (!el) return
try { el.dispatchEvent(new CustomEvent('dxmaps:mapinfo-open', { bubbles: true, composed: true, detail: { el } })) } catch { /* no CustomEvent */ }
}
onMounted(() => {
ask()
window.addEventListener('dxmaps:mapinfo-host', ask)
})
onBeforeUnmount(() => { window.removeEventListener('dxmaps:mapinfo-host', ask) })
</script>
<template>
<MapControlButton v-show="hosted" ref="btn" data-control="map-info" :is-dark="isDark" :title="t('mapInfo.open')"
aria-haspopup="dialog" aria-expanded="false" @click="openInfo">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" />
<rect x="11" y="10" width="2" height="7" rx="1" fill="currentColor" />
<circle cx="12" cy="7" r="1.3" fill="currentColor" />
</svg>
</MapControlButton>
</template>