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(`