UPG Neighbor: on a phone the list can be lowered to a bar and raised again

rega at 390 px: the Mobilize list, a bottom sheet over 54% of the map, had no way to put it down. A handle at the top of the sheet (both views, one shared choice) lowers it to a 56 px bar and raises it again, and the map frames the selection above whichever height it has.
This commit is contained in:
Doxa Maps 2026-09-29 03:38:03 +00:00
parent d9e9aa448c
commit a00c9af009
3 changed files with 28 additions and 8 deletions

View file

@ -8,7 +8,7 @@
* by the unreached population a campaign could reach; selecting one shows where to run it, whom it
* reaches, and what content already exists in the language. Data: nearData.js.
*/
import { inject, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { inject, ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { setProjectionOn } from '@map/composables/useMapProvider.js'
@ -125,7 +125,7 @@ function frame() {
// The panel covers the left 440px on wide screens, the bottom 55% on a phone: frame what you can see.
// In a right-to-left locale the panel sits on the right, so the wide padding moves with it.
const rtl = !!mapContainer.value && getComputedStyle(mapContainer.value).direction === 'rtl'
const padding = w > 760 ? { top: 60, bottom: 60, left: rtl ? 60 : 440, right: rtl ? 440 : 60 } : { top: 40, bottom: Math.round(h * 0.55) + 20, left: 30, right: 30 }
const padding = w > 760 ? { top: 60, bottom: 60, left: rtl ? 60 : 440, right: rtl ? 440 : 60 } : { top: 40, bottom: (nbState.sheetDown ? 56 : Math.round(h * 0.55)) + 20, left: 30, right: 30 }
try {
m.stop()
m.fitBounds([[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]], { padding, maxZoom: 5.5, duration: 900 })
@ -133,6 +133,8 @@ function frame() {
}
watch(() => [nbState.lang, data.value], () => { repaint(); frame() })
watch(list, () => repaint()) // the bubbles follow the search and the believers filter
// PHONE: the same handle as Mobilize; the list can be lowered to a bar and raised again.
function toggleSheet() { nbState.sheetDown = !nbState.sheetDown; nextTick(() => frame()) }
onMounted(async () => {
loadNearData(profileConfig).then((d) => { data.value = d; repaint(); frame() })
@ -217,7 +219,12 @@ useShadowStyles(`
.cp-note { padding:14px; color:#6b7280; font-size:13px; line-height:1.45; }
.cp-hover { position:absolute; z-index:25; pointer-events:none; background:rgba(0,27,68,.92); color:#fff; font-size:12px; line-height:1.3; padding:5px 8px; border-radius:6px; max-width:260px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.cp-maperr { position:absolute; inset-inline-end:12px; top:12px; z-index:15; background:#fff; border:1px solid #d9dee6; border-radius:8px; padding:8px 12px; font-size:13px; color:#6b7280; }
@media (max-width:760px) { .cp-panel { top:auto; inset-inline:6px; bottom:6px; width:auto; height:55%; } }
.cp-handle { display:none; align-items:center; justify-content:center; gap:8px; width:100%; min-height:40px; border:0; border-bottom:1px solid #eef0f4;
background:#fff; color:${NAVY}; font:600 12px/1 inherit; cursor:pointer; flex:0 0 auto; }
.cp-handle::before { content:''; width:36px; height:4px; border-radius:2px; background:#c9d1dd; }
.cp-handle:focus-visible { outline:2px solid ${NAVY}; outline-offset:-2px; }
@media (max-width:760px) { .cp-panel { top:auto; inset-inline:6px; bottom:6px; width:auto; height:55%; }
.cp-panel.down { height:56px; } .cp-panel.down > :not(.cp-handle) { display:none; } .cp-handle { display:flex; } }
`, 'upgn-campaigns')
</script>
@ -227,7 +234,8 @@ useShadowStyles(`
<div v-if="mapError" class="cp-maperr" role="status">{{ mapError }}</div>
<div v-if="hover" class="cp-hover" :style="{ left: hover.x + 12 + 'px', top: hover.y + 12 + 'px' }">{{ hover.text }}</div>
<aside class="cp-panel">
<aside class="cp-panel" :class="{ down: nbState.sheetDown }">
<button type="button" class="cp-handle" :aria-expanded="nbState.sheetDown ? 'false' : 'true'" @click="toggleSheet">{{ nbState.sheetDown ? $t('neighbor.mob.sheetShow') : $t('neighbor.mob.sheetHide') }}</button>
<template v-if="!active">
<div class="cp-head">
<p>{{ $t('neighbor.camp.intro') }}</p>

View file

@ -339,12 +339,16 @@ function frame() {
}
// The panel covers part of the map: the left 400px on wide screens, the bottom 55% on a phone.
// Frame the selection in the part you can see.
// In a right-to-left locale the panel sits on the right, so the wide padding moves with it.
// In a right-to-left locale the panel sits on the right, so the wide padding moves with it. On a phone
// the list is a bottom sheet (55% of the height, or a 56 px bar when the reader has put it down).
function framePadding() {
const el = mapContainer.value, w = el?.clientWidth || 0, h = el?.clientHeight || 0
const rtl = !!el && getComputedStyle(el).direction === 'rtl'
return w > 760 ? { top: 60, bottom: 60, left: rtl ? 60 : 400, right: rtl ? 400 : 60 } : { top: 40, bottom: Math.round(h * 0.55) + 20, left: 30, right: 30 }
return w > 760 ? { top: 60, bottom: 60, left: rtl ? 60 : 400, right: rtl ? 400 : 60 } : { top: 40, bottom: (nbState.sheetDown ? 56 : Math.round(h * 0.55)) + 20, left: 30, right: 30 }
}
// PHONE: the list covered 54% of the map with no way to put it down (rega, 390). A handle on the sheet
// lowers it to a bar and raises it again; the choice is shared with Campaigns.
function toggleSheet() { nbState.sheetDown = !nbState.sheetDown; nextTick(() => frame()) }
watch(() => [nbState.pgid, pFilter.value, data.value, eLevel.value], () => { repaint(); frame() })
@ -446,10 +450,17 @@ useShadowStyles(`
.mb-note { padding:14px; color:#6b7280; font-size:13px; line-height:1.45; }
.mb-maperr { position:absolute; inset-inline-end:12px; top:12px; z-index:15; background:#fff; border:1px solid #d9dee6; border-radius:8px; padding:8px 12px; font-size:13px; color:#6b7280; }
.mb-hover { position:absolute; z-index:25; pointer-events:none; background:rgba(0,27,68,.92); color:#fff; font-size:12px; line-height:1.3; padding:5px 8px; border-radius:6px; max-width:260px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.mb-handle { display:none; align-items:center; justify-content:center; gap:8px; width:100%; min-height:40px; border:0; border-bottom:1px solid #eef0f4;
background:#fff; color:${NAVY}; font:600 12px/1 inherit; cursor:pointer; flex:0 0 auto; }
.mb-handle::before { content:''; width:36px; height:4px; border-radius:2px; background:#c9d1dd; }
.mb-handle:focus-visible { outline:2px solid ${NAVY}; outline-offset:-2px; }
.mb-legend { position:absolute; inset-inline-end:12px; bottom:28px; z-index:15; background:rgba(255,255,255,.95); border:1px solid #d9dee6; border-radius:10px; padding:8px 10px; font-size:12px; }
.mb-legend div { display:flex; align-items:center; gap:7px; padding:2px 0; }
@media (max-width:760px) {
.mb-panel { top:auto; inset-inline:6px; bottom:6px; width:auto; height:55%; }
.mb-panel.down { height:56px; }
.mb-panel.down > :not(.mb-handle) { display:none; }
.mb-handle { display:flex; }
.mb-legend { top:8px; bottom:auto; inset-inline-end:8px; }
}
`, 'upgn-mobilize')
@ -467,7 +478,8 @@ useShadowStyles(`
<div v-for="e in [1, 2, 3]" :key="e"><span class="mb-dot" :style="{ background: E_COLORS[e] }"></span>{{ $t(E_SHORT[e]) }}</div>
</div>
<aside class="mb-panel">
<aside class="mb-panel" :class="{ down: nbState.sheetDown }">
<button type="button" class="mb-handle" :aria-expanded="nbState.sheetDown ? 'false' : 'true'" @click="toggleSheet">{{ nbState.sheetDown ? $t('neighbor.mob.sheetShow') : $t('neighbor.mob.sheetHide') }}</button>
<template v-if="!selected">
<div class="mb-head">
<div class="mb-kind" role="group" :aria-label="$t('neighbor.mob.kind')">

View file

@ -36,7 +36,7 @@ const asked = cfg.value.view || cfg.value.legacyElement
const initial = VIEWS.includes(asked) ? asked : (LEGACY[asked] || (cfg.value.lang ? 'campaigns' : 'mobilize'))
// Shared by both views, so a choice made in one is still there in the other.
const nbState = reactive({ view: initial, pgid: cfg.value.pgid || null, lang: cfg.value.lang || null, about: false, list: 'unreached' })
const nbState = reactive({ view: initial, pgid: cfg.value.pgid || null, lang: cfg.value.lang || null, about: false, list: 'unreached', sheetDown: false })
provide('nbState', nbState)
// "How it works" is a real dialog for the keyboard: focus moves to its close button when it opens, Tab