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:
parent
766d829028
commit
b4664aa67e
3 changed files with 154 additions and 5 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
66
library/components/map-controls/MapInfoButton.vue
Normal file
66
library/components/map-controls/MapInfoButton.vue
Normal 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>
|
||||
Loading…
Reference in a new issue