diff --git a/documentation/reference-library-index.md b/documentation/reference-library-index.md
index 3a4a2338b..c037a3d39 100644
--- a/documentation/reference-library-index.md
+++ b/documentation/reference-library-index.md
@@ -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.
diff --git a/library/components/info/MapInfo.vue b/library/components/info/MapInfo.vue
index 77452351c..06cd3ec56 100644
--- a/library/components/info/MapInfo.vue
+++ b/library/components/info/MapInfo.vue
@@ -43,7 +43,7 @@
* Usage (one per map, inside a positioned container):
*
*/
-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(`
-