doxa-maps/library/components/map-controls/MapInfoButton.vue
Doxa Maps d03b24e613 Prayer Progress carries its own "What can this map do?", so the ⓘ works wherever the map is embedded
On a partner page or a standalone preset page the ⓘ control rendered nothing, because only the
gallery answered it and the ⓘ's text lived in the gallery's settings. Now the map carries its info
(plugin-designs/doxa-simple-map/src/mapInfo.js) and a host only adds placement:

- MapInfoButton gains an opt-in `standalone` prop. When no host answers and the map carries info, it
  asks the map for a popup of its own (the 'standalone' event). If a real host appears later, the
  map's popup is dropped and the host takes over. Never inside the gallery (its panes carry
  data-doxa-gallery): there the gallery alone decides, its own info:false included.
- MapInfo gains `popupOnly` (with trigger 'control'): no floating button, since the map's control is
  the only button; it answers that control from inside the map, points at it once per page load,
  and opens the same popup (the bottom sheet on phones). It announces itself as local, so the
  control can tell it from a real host.
- Prayer Progress mounts that popup beside its control column, only when asked.

Per preset: the text describes the prayer map, so a map that shows the Prayer tab has info (the full
profile and the prayer preset); the engagement-only and adoption-only presets have none and show no
ⓘ, as before. A host's profile-config info:false removes the ⓘ. Every other MapInfoButton consumer
is unchanged (standalone is off by default).

doxa-simple-map grows by 20,590 bytes raw (5,699 gzipped), for compiling MapInfo. Rebuilt
doxa-simple-map and doxa-maps-spa, then tree + SRI.
2026-09-30 06:15:11 +00:00

109 lines
5.8 KiB
Vue

<!--
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 control 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:
nothing renders (not even a hidden button), and it asks again when a host announces itself later
('dxmaps:mapinfo-host' on window, for a MapInfo that mounted after the map). Use it inside a MapToolbar.
★ 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, inject, nextTick, getCurrentInstance, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import MapControlButton from './MapControlButton.vue'
const { t } = useI18n()
const props = defineProps({
isDark: { type: Boolean, default: false },
// The map carries its own info, so the control may stand ALONE: when no host answers (an embed on a
// partner page, a standalone preset page), it asks its map to mount a popup of its own (the
// 'standalone' event) instead of rendering nothing. Never inside the gallery: there the gallery alone
// decides, its own info:false included. Off by default, so every other consumer is unchanged.
standalone: { type: Boolean, default: false },
// 'round' (default) is the map-control form. 'rounded-square' is the dashboard HEADER form (the ⓘ
// sits in a header bar beside Share, not in a map's control column) — forwarded to MapControlButton.
shape: { type: String, default: 'round' },
})
// ★ AN UNANSWERED CONTROL ADDS NO CHILD TO THE COLUMN. The button renders only once a host has answered
// (v-if, not v-show): a display:none child is still a child, so it would take the square toolbar's
// :last-child corners (MapToolbar's controls="square") from the last button a reader can see. So the
// QUESTION is asked from the toolbar's own element (provided by MapToolbar as mapToolbar.el), or from this
// component's placeholder's parent, and once answered the rendered button announces ITSELF, so the host
// measures and focuses the real element.
const emit = defineEmits(['standalone'])
const toolbar = inject('mapToolbar', null)
const self = getCurrentInstance()
const hosted = ref(false)
const btn = ref(null)
const node = () => (btn.value && btn.value.$el) || null
const origin = () => node() || (toolbar && toolbar.el && toolbar.el.value) || (self && self.proxy && self.proxy.$el && self.proxy.$el.parentNode) || null
function announce(from, el) {
const detail = { el, ack: false }
try { from.dispatchEvent(new CustomEvent('dxmaps:mapinfo-control', { bubbles: true, composed: true, detail })) } catch { /* no CustomEvent */ }
return detail.ack === true
}
// Inside the gallery (its panes carry data-doxa-gallery), across shadow roots.
function inGallery(el) {
for (let n = el; n; n = n.parentNode || n.host) if (n.nodeType === 1 && n.hasAttribute('data-doxa-gallery')) return true
return false
}
// The map's own popup is up: it answers from the map, so this control is hosted by it.
let local = false
function ask() {
const from = origin()
if (!from) return
const was = hosted.value
hosted.value = announce(from, node())
if (hosted.value && !was) nextTick(() => { const el = node(); if (el) announce(el, el) })
// Nobody answered and the map carries its info: ask the map for its own popup. When it mounts, it
// announces itself ('dxmaps:mapinfo-host', detail.local), and the question above is asked again.
if (!hosted.value && props.standalone && !local && !inGallery(from)) { local = true; emit('standalone', true) }
}
// A host appeared. The map's own popup asks us again; any OTHER host (the gallery's) takes over, so the
// map's popup is dropped first: it sits inside the map and would answer before the gallery could.
function onHost(e) {
if (local && !(e && e.detail && e.detail.local)) {
local = false; hosted.value = false; emit('standalone', false); nextTick(ask); return
}
ask()
}
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(() => {
nextTick(ask)
window.addEventListener('dxmaps:mapinfo-host', onHost)
})
onBeforeUnmount(() => {
window.removeEventListener('dxmaps:mapinfo-host', onHost)
if (local) emit('standalone', false)
})
</script>
<template>
<MapControlButton v-if="hosted" ref="btn" data-control="map-info" :is-dark="isDark" :shape="shape" :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>