Research Gallery: the language menu in the phone bar and at the bottom of the rail

The gallery fills the tab layout's two new slots (the end of the phone bar, and the bottom of the desktop
rail) with the shared ShellLanguageMenu, showing the language in effect. The menu first asks the host with a
cancelable doxa-language-request. A host that takes it changes the language itself and sets `language`.
Otherwise the gallery switches on its own, and that pick holds over a `language` the host never updates, until
the host changes it. A saved choice never outranks the host page's own language. EMBED.md shows the listener.
This commit is contained in:
Doxa Maps 2026-09-30 23:54:44 +00:00
parent 3f7d489409
commit 2520ecbc57
2 changed files with 59 additions and 12 deletions

View file

@ -13,7 +13,7 @@ tab rail nests every production map. It is built to drop onto somebody else's pa
|---|---|---|
| **Paste** | two lines: `<script>` + `<doxa-maps-spa>` | one `<iframe>` |
| **Sizing** | a block in your layout, `85vh` by default | whatever size you give the frame |
| **Language** | follows your page (`<html lang>`), or `language="…"` | `?lang=…` on the `src` |
| **Language** | follows your page (`<html lang>`), or `language="…"`; readers can switch it in the gallery (see Events) | `?lang=…` on the `src` |
| **Open on a map** | `tab="upg-100"` | `?tab=upg-100` |
| **Keyless maps** | `provider="maplibre"` | `?provider=maplibre` |
| **Your server must send** | CORS on the whole tree | CORS + `frame-ancestors` on the leaf page |
@ -78,6 +78,28 @@ document.querySelector('doxa-maps-spa')
.addEventListener('tab-change', (e) => console.log(e.detail[0].id, e.detail[0].label))
```
### The language menu: `doxa-language-request`
Readers can change the language from the gallery itself: a menu at the end of the phone bar, and at
the bottom of the rail on a desktop. Before switching, the gallery asks your page first by dispatching
a cancelable `doxa-language-request` event (it bubbles and crosses shadow roots). Its shape is
`e.detail.language`, not an array.
- **Your page owns the language** (it passes `language`, or its URL carries the locale): call
`preventDefault()` and change the language your own way. Then set the gallery's `language`, and the
gallery follows.
```js
const gallery = document.querySelector('doxa-maps-spa')
gallery.addEventListener('doxa-language-request', (e) => {
e.preventDefault()
switchMySiteTo(e.detail.language) // your router or i18n; then gallery.setAttribute('language', …)
})
```
- **You do nothing:** the gallery switches itself. The reader's pick then holds even over a `language`
you passed, until you change that attribute again. The gallery never writes your page's URL.
## A good guest on your page
The gallery is tested to leave its host page as it found it: it does not scroll your page, rewrite

View file

@ -112,6 +112,14 @@
</div>
</template>
<!-- THE LANGUAGE MENU OF A HEADER-OFF SHELL: the end of the layout's phone bar, and the bottom of the
desktop rail. ShellLanguageMenu first asks the host (a cancelable doxa-language-request). If the
host takes it, the host sets `language` and this gallery follows. If not, it emits 'switch' and the
gallery switches itself (pickLocale). `current` is uiLocale, so the select always has a matching
option. -->
<template #phone-end><ShellLanguageMenu :current="uiLocale" @switch="pickLocale" /></template>
<template #rail-end="{ collapsed }"><ShellLanguageMenu :current="uiLocale" :collapsed="collapsed" @switch="pickLocale" /></template>
<!-- EVERY tab mounts its plugin's REAL custom element — there is no iframe mode. The tag is
created imperatively into an EMPTY, Vue-unmanaged slot div (:ref, no children) after that
bundle's script is injected once, so Vue never fights the custom element's own shadow DOM.
@ -199,6 +207,7 @@
<script>
import { defineComponent, inject } from 'vue'
import VerticalTabsLayout from '@map/layouts/VerticalTabsLayout.vue'
import ShellLanguageMenu from '@map/components/ShellLanguageMenu.vue'
import BackToTopButton from '@map/components/BackToTopButton.vue'
import { SWITCHER_LANGUAGES, loadLocale, saveLocale, getLanguage } from '@map/layouts/app-wrapper-material/languages.js'
import { translate, LOCALES, hasKey } from './src/strings.js'
@ -778,7 +787,7 @@ export default defineComponent({
// (measured 2026-09-25). .vtl survives today only because it declares display:grid !important.
// Everything the layout needs (dir, lang, labels, …) is passed explicitly below.
inheritAttrs: false,
components: { VerticalTabsLayout, BackToTopButton, MapInfo },
components: { VerticalTabsLayout, BackToTopButton, MapInfo, ShellLanguageMenu },
// profile-config props the standalone leaf page + gallery hand every element. treeBase lets a
// host pin the tree root explicitly; the rest are accepted so the element is a well-behaved
// member of the bundler (profile/instanceId/assetsBase/tk/shareUrl/qa are ignored here — the
@ -879,6 +888,11 @@ export default defineComponent({
// The host page's own language (detectHostLocale), kept live by a MutationObserver on <html lang>
// — a host app that switches language at runtime (Nuxt i18n does) re-drives the whole gallery.
hostLang: detectHostLocale(),
// A language picked in the shell's menu in THIS session that no host took (see pickLocale). It
// outranks the `language` prop it was picked over, and a change to that prop clears it. A saved
// choice is never a source: loadLocale falls back to the browser's language, which would
// override a partner page's own <html lang>.
sessionPick: '',
localeOpen: false,
switcherLangs: SWITCHER_LANGUAGES,
// Curated tabs start from TAB_DEFS and are COMPLETED from the manifest (tag + scriptUrl) as
@ -1027,18 +1041,21 @@ export default defineComponent({
showLocaleSwitcher() { return this.showHeader },
// The active language's flag/native name for the toolbar toggle button.
activeLang() { return getLanguage(this.locale) || { code: this.locale, flag: '🌐', nativeName: this.locale } },
// The EFFECTIVE preview language pushed down to every tab. Precedence, most-authoritative first:
// 1. `language` prop / profile-config.language|locale|lang — the parent-page (inherited) value.
// 2. the manual switcher's data.locale (only meaningful when a header dropdown is present).
// Every tab's element receives THIS via profile-config.locale, so the SPA honors one language prop.
// 1. `language` prop / profile-config.language|locale|lang — what the host SAYS.
// 2. the gallery's own switcher choice — only while that switcher is actually offered.
// 3. the host page's language (?lang= / <html lang>, see detectHostLocale).
// 4. English.
// The language the HOST says: the `language` prop, or profile-config.language|locale|lang.
propLocale() {
return (this.language || this.cfg.language || this.cfg.locale || this.cfg.lang || '').trim()
},
// The EFFECTIVE preview language pushed down to every tab (each tab's element receives it via
// profile-config.locale). Precedence, most-authoritative first:
// 1. a pick from the shell's language menu in THIS session that no host took (sessionPick), which
// the watcher below clears as soon as the host's `language` changes;
// 2. `language` prop / profile-config.language|locale|lang: what the host SAYS;
// 3. the header-on switcher's choice, only while that switcher is actually offered;
// 4. the host page's language (?lang= / <html lang>, see detectHostLocale);
// 5. English.
effectiveLocale() {
const fromProp = (this.language || this.cfg.language || this.cfg.locale || this.cfg.lang || '').trim()
const fromSwitcher = this.showLocaleSwitcher ? this.locale : ''
return (fromProp || fromSwitcher || this.hostLang || 'en').split('-')[0].toLowerCase()
return (this.sessionPick || this.propLocale || fromSwitcher || this.hostLang || 'en').split('-')[0].toLowerCase()
},
// The locale the gallery's OWN words render in: the effective one when it ships, else English.
uiLocale() { return LOCALES.includes(this.effectiveLocale) ? this.effectiveLocale : 'en' },
@ -1096,6 +1113,8 @@ export default defineComponent({
},
watch: {
activeIndex() { this.helpOpen = false },
// The host changed its language: that is the newest word, so it replaces a menu pick made earlier.
propLocale() { this.sessionPick = '' },
// ★ A LANGUAGE SWITCH REMOUNTS, IT DOES NOT RE-SET THE ATTRIBUTE. Re-setting the visible tab's
// profile-config was the old path, and it relied on every nested map re-reading its whole config
// live. Two production maps do not: Big Picture's tStatic strings are resolved once (2/352 changed,
@ -1390,6 +1409,12 @@ export default defineComponent({
this.localeOpen = false
saveLocale(code)
},
// The language menu's 'switch': it fires only when no host cancelled doxa-language-request, so the
// gallery switches itself, and the pick holds over a `language` prop the host never updates.
pickLocale(code) {
this.setLocale(code)
this.sessionPick = code
},
// The shared rail emits the selected tab's `key` (its id); map it back to the index the
// pane-mount machinery below is keyed on.
//