diff --git a/documentation/reference-capability-exemplars.md b/documentation/reference-capability-exemplars.md index 9b4ce4281..d389b951d 100644 --- a/documentation/reference-capability-exemplars.md +++ b/documentation/reference-capability-exemplars.md @@ -56,6 +56,7 @@ Generated from 29 bundle(s). | `poster-design` | `poster-sizes` | `single` | | `poster-design` | `poster-styles` | `single` | | `prayer-walk-planner` | `prayer-walk-planner` | `single` | +| `regions-dashboard` | `country-poster-sections` | `single` | | `regions-dashboard` | `region-dashboards` | `single` | | `regions-dashboard` | `regions-list` | `single` | diff --git a/library/components/poster/PosterSectionView.vue b/library/components/poster/PosterSectionView.vue index 50dccfd5a..c7cf74aa9 100644 --- a/library/components/poster/PosterSectionView.vue +++ b/library/components/poster/PosterSectionView.vue @@ -1,7 +1,7 @@ + + diff --git a/plugin-designs/regions-dashboard/regions-list.vue b/plugin-designs/regions-dashboard/regions-list.vue index b6e660a57..61da5969c 100644 --- a/plugin-designs/regions-dashboard/regions-list.vue +++ b/plugin-designs/regions-dashboard/regions-list.vue @@ -44,7 +44,6 @@ import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js' import { SHADOW_BOX_SIZING_RESET } from './src/shadowReset.js' import { splitLastWord } from './src/useLastWordHighlight.js' import { useRegionsI18n } from '@map/i18n/useRegionsI18n.js' -import { countryName } from '@map/utils/countryName.js' import { useOptionalT } from '@map/composables/useOptionalT.js' // ar renders right-to-left. RTL_LOCALES is the shared set and :dir on our own root is the // house pattern (doxa-simple-map.vue:18, :246, :1346) — an EMBEDDED element must not reach for @@ -60,20 +59,14 @@ import { DXLIFE_TOKENS } from './dxlife-tokens.js' // and the no-selection chooser. The requirement: a toggle at the bottom of each region section // that opens that region's country posters. // -// NOTHING HERE IS REBUILT. PosterWall is already the shared, fully parameterised grid that -// my-upg-100-list/poster-wall.vue and country-posters/country-poster-wall.vue both render, and -// it already owns the card layout, the "Show on map" header button, the Print Options footer -// and the client-side raster. This file supplies only data and a toggle — the same division of -// labour country-poster-wall.vue states in its own header ("this profile supplies only its -// data and taxonomy"). -import PosterWall from '@map/components/poster/PosterWall.vue' +// NOTHING HERE IS REBUILT. Each region's posters are the library's +// (library/elements/posterSectionElement.js), the same section, cards and print options as the Country Posters +// wall and the Regions dashboard's third view, so the list is composed from the shared element rather than +// drawing a wall of its own. This file supplies only the toggle and the region. import RegionSectionCard from '@map/components/regions/RegionSectionCard.vue' -import posterData from '@map/data/country-posters.json' -import { createPeopleGroupRecords } from '@map/lists/peopleGroupRecords.js' -import { makeCountrySheet, largeSheet, posterWithheld, rowsWithheld } from '@map/packet/countrySheet.js' -import { isPhonePrint } from '@map/composables/printInPlace.js' -import { keyedError } from '@map/i18n/keyedError.js' import { resolveAssetsBase } from '@map/utils/embedBase.js' +import { registerPosterSectionElement } from '@map/elements/posterSectionElement.js' +import { regionKey } from '@map/components/poster/types/countryPosterType.js' import { onBeforeUnmount } from 'vue' import { cyrillicHeading } from '@map/styles/portedGlobals.js' import { hostElement } from '@map/utils/hostElement.js' @@ -276,44 +269,32 @@ async function loadRegions() { } } -// ─── COUNTRY POSTERS, per region ───────────────────────────────────────────────────────── -// One wall per region, opened by the footer toggle. instanceId is suffixed with the region -// slug because PosterWall scopes its CustomEvent by instanceId so several embeds on one page -// never cross-talk (PosterWall.vue:40) — and here there genuinely are several. -const POSTER_EVENT = 'doxa-regions:show-country-on-map' +// ─── COUNTRY POSTERS, per region ───────────────────────────────────────────── +// One poster section per region, opened by the footer toggle: the library's . +// Its scope is the region's key through the country type's own regionKey, so the list and the element can never +// spell a region two ways. It is not collapsible (the toggle already opens and shuts it) and shows two cards a row, +// on a phone as well. +// ⚠ The element files countries under the poster data's region (library/data/country-posters.json), so this list +// shows exactly that data's membership for each region. +registerPosterSectionElement() +const SECTION_EVENT = 'doxa-country-posters:show-on-map' // Read off the injected config the same way every other value in this file is (see the note // above cfg) — a bare default here would make two embeds on one page answer each other's // poster events. const instanceId = computed(() => cfg.value.instanceId || 'regions-list') const posterAssetsBase = computed(() => resolveAssetsBase(cfg.value)) - -// ★ THE PRINT ROWS, AND THE SHEET DRAWN FROM THEM. Supplying `records` is what makes the Prayer -// List, Card Deck and Prayer Packet buttons appear on each country (PosterWall hides them when a -// wall cannot supply rows, rather than failing at click time). One fetch per language, cached in -// the provider and shared by every country in every region, so opening five regions is still one -// request. The same provider feeds the sheet, so the printed map and the printed list can never -// disagree about which groups are in a country. -const countryRecords = createPeopleGroupRecords({ - apiBase: () => prayBaseUrl.value, - lang: () => languageCode.value, - profileConfig: injectedCfg, +const sectionAttrs = (region) => ({ + type: 'country', + scope: regionKey(region.value || region.label), + locale: i18nLocale.value || languageCode.value || 'en', + collapsible: 'false', + columns: '2', + // Two posters a row on a phone too, as this list has always shown them: 150 px lets two cards fit at 390. + 'min-card-px': '150', + 'assets-base': posterAssetsBase.value, + // The same people-groups service this list reads its regions from, so a card's list and deck match it. + 'api-base-url': prayBaseUrl.value, }) -// Page 1 of a country packet, and the country poster, drawn at the click. Nothing is stored: no -// country sheet ships, and the packet's default lift would ask for posters/country-.html and -// 404 on all 175. -const sheetForRecord = makeCountrySheet({ - records: countryRecords, - assetsBase: () => posterAssetsBase.value, - // The same list the Country Posters wall computes its series from, so this tab's printed - // "map N of M" agrees with the wall's print and with the country's thumbnail. - countries: posterData.countries || [], -}) -// ★ THE POSTER IS ENGLISH BY RECORDED DECISION (series.json english_only_note), so the generator gets -// the DATA's own record, never the card: the card's label is localised (localisedPoster below), and -// handing it over printed "Pray for السودان" beside an English "Countries: Sudan" in Arabic. The -// Country Posters wall resolves the record by id the same way (country-poster-wall.vue, byId). -const POSTER_BY_ID = new Map((posterData.countries || []).map(c => [c.id, c])) -const countrySheet = (card, format) => sheetForRecord(POSTER_BY_ID.get(card && card.id) || card, format) const openPosters = ref(new Set()) function togglePosters(region) { @@ -323,74 +304,17 @@ function togglePosters(region) { } const postersOpen = (region) => openPosters.value.has(region.slug) -// The poster dataset is keyed by ISO-3 (`iso3`); the regions rollup keys by `code`. Same -// codes, so the join is direct. -const POSTER_BY_ISO3 = new Map((posterData.countries || []).map(c => [String(c.iso3 || '').toUpperCase(), c])) - -// Every country in the region gets a card, in PosterWall's own shape. A country with no -// generated poster is NOT dropped — it is passed with a reason, which is what the wall's -// `unsupported` slot is for, so the grid never silently loses a country. -// ★ EACH CARD NAMED IN THE READER'S LANGUAGE. item.label is the card's accessible name, the print -// HUD's title, and the title on the printed list, deck and packet — and country-posters.json carries -// it in English only, so every card was English in every locale. Same fix as the country-posters -// wall, and the same two caveats travel with it: -// · ENGLISH KEEPS THE DATA'S OWN NAMES. Intl's English differs from them for 32 of these 175 -// countries, and countryName.js records that swap as a product decision rather than a refactor. -// · THE SHEET GENERATOR STILL GETS THE ORIGINAL RECORD. The poster itself is English by recorded -// decision, so only the card's label is localised — postersFor's result feeds the wall, while -// countrySheet (above) swaps the card back for the json's own record, by id, before generating. -// The i18n locale REF is read, not a snapshot, so a runtime language switch renames the cards. -function localisedPoster(rec) { - const locale = i18nLocale.value || 'en' - if (/^en\b/i.test(locale)) return rec - const name = countryName(rec.iso3, locale) - return name && name !== rec.iso3 ? { ...rec, label: name } : rec -} - -function postersFor(region) { - return (region.countries || []).map((c) => { - const rec = POSTER_BY_ISO3.get(String(c.code || '').toUpperCase()) - if (rec) return localisedPoster(rec) - return { - id: `country-${String(c.code || '').toLowerCase()}`, - iso3: c.code, - label: c.name, - count: c.count, - unsupportedReason: t('No poster has been generated for this country yet.'), - } - }) -} - -// ★ THE REASON IS TRANSLATED, THE PRESENCE OF ONE IS STILL THE DATA'S. The record carries its own -// English note ("India has 855 people groups — too many…"), and returning it put one untranslated -// sentence on the wall in every locale. The record still DECIDES whether a country is unsupported; -// only the wording comes from the catalogue now. messages.countryPosterTooMany exists in all -// eleven, and needs no number in it: the card already shows the count directly above. -// India is the only country in the dataset carrying one (855 people groups, no sheet); China is -// fine at 3x4. My own not-yet reason, for a country with no record at all, is already through t(). -// ★ A COUNTRY TOO LARGE FOR PAPER (countrySheet.js's LARGE_SHEETS; India) prints as one large sheet through the same -// makeCountrySheet, so its record's "too many" note no longer makes it unsupported here. It is DESKTOP-ONLY: on a phone -// its card says so and offers no rows, rather than failing (the Country Posters wall applies the same rule, same table). -const ON_PHONE = isPhonePrint() -const phoneBlocked = (c) => ON_PHONE && Boolean(largeSheet(c)?.desktopOnly) -const posterUnsupported = (c) => (posterWithheld(c) ? tKey('messages.countryPosterTooMany') : phoneBlocked(c) ? tKey('poster.phoneLimitAll') - : c.unsupportedReason && !largeSheet(c) ? tKey('messages.countryPosterTooMany') : null) -// A blocked card's rows REJECT with the notice, never [] ("no people groups" on a card that has hundreds). -// A withheld whole-country poster (countrySheet.js WITHHELD: India) withholds its list, packet and cards too. -const posterRows = (item) => (rowsWithheld(item) ? Promise.reject(keyedError('messages.countryPosterTooMany', 'Too many people groups to fit on one poster legibly — a solution is in progress.')) : phoneBlocked(item) ? Promise.reject(keyedError('poster.phoneLimitAll', 'This needs a desktop or laptop computer.')) : countryRecords({ type: 'country', values: [item.iso3] })) - -// One region's wall is already inside a region section, so it needs no second level of -// collapsing and no grouping: everything in it is that one region. -const posterGroupBy = () => 'countries' - -// PosterWall's "Show on map" dispatches a window CustomEvent carrying listId (the poster id, +// A poster card's "Show on map" dispatches a window CustomEvent carrying listId (the poster id, // e.g. "country-chn") or null when the user toggles the same card off. Mapping that id back to // the country record and reusing selectCountry() is what makes the button open the dashboard // tab for that country: the requirement is that the view-on-map button opens the country // dashboard page for that country. +// The library's poster sections name themselves 'poster-section-country-' (PosterSectionView.vue). In the +// Regions composite this list is always mounted, so it also answers the third view's sections (Country Posters): +// ONE handler for both, which keeps the region context in the selection. function onPosterShowOnMap(ev) { const d = ev?.detail || {} - if (!String(d.instanceId || '').startsWith(instanceId.value)) return + if (!String(d.instanceId || '').startsWith('poster-section-country-')) return if (!d.listId) return // a deselect is not a navigation const iso3 = String(d.listId).replace(/^country-/, '').toUpperCase() for (const region of regions.value) { @@ -407,8 +331,8 @@ function onPosterShowOnMap(ev) { // already had this watcher (line 541); this tab was simply missed. watch(languageCode, () => { loadRegions() }) -onMounted(() => { if (typeof window !== 'undefined') window.addEventListener(POSTER_EVENT, onPosterShowOnMap) }) -onBeforeUnmount(() => { if (typeof window !== 'undefined') window.removeEventListener(POSTER_EVENT, onPosterShowOnMap) }) +onMounted(() => { if (typeof window !== 'undefined') window.addEventListener(SECTION_EVENT, onPosterShowOnMap) }) +onBeforeUnmount(() => { if (typeof window !== 'undefined') window.removeEventListener(SECTION_EVENT, onPosterShowOnMap) }) onMounted(loadRegions) @@ -583,32 +507,7 @@ useShadowStyles( @select-country="(c) => selectCountry(c, region)" > diff --git a/plugin-designs/regions-dashboard/regions-mfe.vue b/plugin-designs/regions-dashboard/regions-mfe.vue index 2efe33d2f..34de2472b 100644 --- a/plugin-designs/regions-dashboard/regions-mfe.vue +++ b/plugin-designs/regions-dashboard/regions-mfe.vue @@ -24,7 +24,7 @@ * listens for, and scrolls to tab 2. That is the same seam upg-100-mfe uses for the poster * wall's 'show-on-map', and it keeps each pane under its own ProfileLoader/Pinia. */ -import { ref, computed, inject, onMounted, onBeforeUnmount } from 'vue' +import { ref, computed, inject, onMounted, onBeforeUnmount, watch, nextTick } from 'vue' import { usePaneConfig } from '@map/composables/usePaneConfig.js' import { buildPaneColumn, PANE_GRID_STYLES } from '@map/layouts/pane-wrapper/pane-wrapper.js' import { useShadowStyles } from '@map/composables/useShadowStyles.js' @@ -47,8 +47,11 @@ import MapInfoButton from '@map/components/map-controls/MapInfoButton.vue' // them to 97 and fail the gate. The moment someone adds these three strings to all locales, // the strip localises with no code change here. import { useRegionsI18n } from '@map/i18n/useRegionsI18n.js' +// The third view's label is the gallery's own tab name for country posters (tabs.countryPosters, all eleven locales). +import { useOptionalT } from '@map/composables/useOptionalT.js' const { t } = useRegionsI18n() +const tKey = useOptionalT('') const cfg = inject('profileConfig', ref(null)) const uiStore = inject('uiStore', null) const isDark = computed(() => !!uiStore?.isDark) @@ -70,6 +73,8 @@ const spec = computed(() => ({ panes: [ { id: 'list', label: t('Regions List'), at: [0, 0], config: { profile: 'regions-list' } }, { id: 'dashboard', label: t('Region Dashboards'), at: [1, 0], config: { profile: 'region-dashboards' } }, + // THE THIRD VIEW: country posters, one collapsible section per region (the library's ). + { id: 'posters', label: tKey('tabs.countryPosters'), at: [2, 0], config: { profile: 'country-poster-sections' } }, ], // The View switcher lives in this element's own header, so the layout's "… below ↓" pill // would be a second, redundant control (same call upg-100-mfe makes). @@ -146,6 +151,13 @@ function go(id) { column?.scrollTo(id) } +// On a phone the three tabs scroll sideways (see the 600px rule below): keep the current one in view. +const hdr = ref(null) +watch(view, () => nextTick(() => { + const b = hdr.value && hdr.value.querySelector('.seg button[aria-pressed="true"]') + if (b && b.scrollIntoView) b.scrollIntoView({ inline: 'center', block: 'nearest' }) +})) + // ── tab 1 -> tab 2: forward the selection, then swap the visible pane ─────── const SELECT_EVENT = 'doxa-regions-dashboard:select' @@ -169,6 +181,10 @@ function onCountrySelected(e) { forwardSelection({ regionSlug: d.region?.slug || '', countrySlug: d.slug || '' }) } +// A card's "Show on map" in the third view (Country Posters) opens that country's dashboard: the Regions list pane +// (always mounted here) answers every poster section's event with its own selectCountry(), which arrives above as +// 'country-selected'. One handler for both views (regions-list.vue onPosterShowOnMap). + onMounted(() => { if (!stage.value) return column = buildPaneColumn(spec.value, { host: stage.value }) @@ -234,6 +250,14 @@ useShadowStyles(SHADOW_BOX_SIZING_RESET + PANE_GRID_STYLES + '\n' + // ON A PHONE the centred view switcher (about 290 px at 390) leaves no room for two buttons at one end, so the // ⓘ takes the header's START edge, where the floating ⓘ sat before; Share keeps the end. '@media (max-width: 600px){ .rmfe .hdr .hdr-btns > :first-child{ position:absolute; inset-inline-start:12px; top:50%; transform:translateY(-50%); } }' + + // THREE VIEWS DO NOT FIT A PHONE'S HEADER (measured at 390: the tabs need about 390 px and ran under the corner + // buttons). The switcher keeps the room between the two 36 px buttons (12 + 36 + 8 on each side) and its tab row + // scrolls sideways, every label whole; the current tab is scrolled into view on each change. A fieldset is + // min-content wide by default, hence min-width:0. Desktop is unchanged. + '@media (max-width: 600px){ .rmfe .hdr .viewsel{ min-width:0; max-width:calc(100% - 112px); }' + + ' .rmfe .hdr .viewsel .seg{ overflow-x:auto; scrollbar-width:none; scroll-snap-type:x proximity; -webkit-overflow-scrolling:touch; }' + + ' .rmfe .hdr .viewsel .seg::-webkit-scrollbar{ display:none; }' + + ' .rmfe .hdr .viewsel .seg button{ flex:0 0 auto; scroll-snap-align:center; } }' + // Stage: the pane-wrapper column mounts here (its .pane-col is absolute). '.rmfe .stage{ position:relative; flex:1 1 auto; min-height:0; }' + '.pane > .pane-mount > *{ position:absolute; inset:0; width:100%; height:100%; display:block; }', @@ -243,7 +267,7 @@ useShadowStyles(SHADOW_BOX_SIZING_RESET + PANE_GRID_STYLES + '\n' +