doxa-maps/library/components/map-controls/MapInfoButton.vue
Doxa Maps d0de376f4a Big Picture header: rounded-square ⓘ left of a rounded-square Share
The dashboard header gains an info button ("What can this map do for you?") to
the left of Share. Both are rounded squares at the vertical tabs' radius
(--vtl-radius), matching the app's own roundedness; map controls stay round.

Built as a reusable, parameterized header-button form so other dashboards adopt
it by prop, not by copy:
- MapControlButton: new shape="rounded-square" (border-radius: var(--vtl-radius,8px)).
- ShareButton / MapInfoButton: a shape prop forwarded to MapControlButton;
  ShareButton also squares its rotating ring so a round ring never wraps a square.
  Defaults stay 'round' — every existing consumer is unchanged in behaviour.
- research-dashboard DashboardTopBar mounts the ⓘ and Share as one header-button
  group, sized to the bar with the 44px tap target kept for both. DOM order
  [ⓘ, Share] puts ⓘ at the logical start: left in English, right of Share in
  Arabic (the flex row reverses under rtl).
- doxa-maps-spa dashboard-plus-grid tab -> trigger:'control', so the tab's
  MapInfo is driven by the header ⓘ and the gallery's floating ⓘ is suppressed:
  exactly one ⓘ, fail-open. No new strings (reuses mapInfo.open, 11 locales).
2026-09-30 04:27:44 +00:00

82 lines
4.2 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()
defineProps({
isDark: { 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 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
}
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) })
}
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', ask)
})
onBeforeUnmount(() => { window.removeEventListener('dxmaps:mapinfo-host', ask) })
</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>