doxa-maps/library/components/BackToTopButton.vue
Doxa Maps e5140f0b41 A back-to-top that sits beside the hamburger circle, not on top of it
A back-to-top on mobile, next to the hamburger circle, for the people-group grid and the regions list. Both scroll their own container,
so the container is a prop and this component owns only the affordance.

It does not know where the circle is, and must not guess. .vtl-fab is 44px at a
12px inset today, and it lives in the layout outside every plugin's shadow root.
Hardcoding 64px here would put the two on top of each other the day that changes,
silently, on a phone. So the layout publishes --map-fab-beside alongside the
clearance it already published, and this reads it — falling back to the plain
inset when there is no circle, which is also the standalone case where nothing
sets it. The circle's geometry is stated once, by the component that owns it.

It also refuses to appear when it cannot work: visible tests that the container
actually scrolls, not only that scrollTop is large. An arrow that does nothing
when pressed is worse than no arrow, because the reader spends a tap finding out.
And it honours prefers-reduced-motion, since a long list jumping is exactly the
motion that rule exists for.

The failure mode here is a var NAME drifting between publisher and consumer:
nothing throws, the fallback applies, and the button quietly sits under the
circle again. Checked rather than assumed — every published name is consumed and
every consumed name is published.

NEEDS EYES: I cannot see 44px or a hover state. The logic is small; whether it
lands beside the circle rather than over it is a render question.
2026-09-28 17:40:35 +00:00

93 lines
3.7 KiB
Vue

<script setup>
/**
* BackToTopButton — a ↑ that appears once a long list has been scrolled, and sits NEXT TO the
* layout's floating hamburger circle rather than on top of it.
*
* The Driver asked for it "next to the hamburger circle on mobile", for the people-group grid and
* the regions dashboard list. Both scroll their own container, so the container is a prop: this
* component owns the affordance, never the data.
*
* ★ IT DOES NOT KNOW WHERE THE CIRCLE IS, AND MUST NOT GUESS. .vtl-fab lives in VerticalTabsLayout,
* outside every plugin's shadow root, and it is 44px at a 12px inset TODAY. Hardcoding 64px here
* would put this button on top of it the day that changes, silently, on a phone. The layout
* publishes --map-fab-beside while the circle is rendered; this reads it and falls back to the
* plain inset when there is no circle — which is also the standalone case, where nothing sets it.
*
* ★ AND IT REFUSES TO APPEAR WHEN IT CANNOT WORK. No target, or a target that does not scroll,
* means no button: a ↑ that does nothing when pressed is worse than no ↑, because the reader spends
* a tap finding out. That is why `visible` tests scrollTop AND scrollHeight, not scrollTop alone.
*/
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
const props = defineProps({
/** The scrolling element. Passed in, because the list belongs to the consumer, not to this. */
target: { type: Object, default: null },
/** How far down before it appears. */
threshold: { type: Number, default: 400 },
/** Every string passed in; this component writes no prose. */
labels: { type: Object, default: () => ({}) },
})
const scrolled = ref(0)
const scrollable = ref(false)
function measure() {
const el = props.target
if (!el) { scrolled.value = 0; scrollable.value = false; return }
scrolled.value = el.scrollTop || 0
// A container shorter than its own viewport cannot be scrolled to the top of.
scrollable.value = (el.scrollHeight || 0) - (el.clientHeight || 0) > props.threshold
}
const visible = computed(() => scrollable.value && scrolled.value > props.threshold)
function toTop() {
const el = props.target
if (!el) return
const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
el.scrollTo({ top: 0, behavior: reduce ? 'auto' : 'smooth' })
}
let bound = null
function bind(el) {
if (bound) { bound.removeEventListener('scroll', measure); bound = null }
if (el) { el.addEventListener('scroll', measure, { passive: true }); bound = el }
measure()
}
onMounted(() => bind(props.target))
watch(() => props.target, (el) => bind(el))
onBeforeUnmount(() => bind(null))
</script>
<template>
<button
v-if="visible"
class="btt"
type="button"
:aria-label="labels.backToTop || ''"
:title="labels.backToTop || ''"
@click="toTop"
>↑</button>
</template>
<style scoped>
/* Mirrors .vtl-fab's shape so the pair reads as one control group: same 44px circle, same inset,
same shadow. The inline offset is READ from the layout, never assumed — see the header. */
.btt {
position: absolute;
z-index: 15;
inset-inline-start: var(--map-fab-beside, calc(12px + env(safe-area-inset-left)));
bottom: calc(12px + env(safe-area-inset-bottom));
width: 44px; height: 44px;
display: flex; align-items: center; justify-content: center;
font: 18px/1 system-ui;
color: #3b463d; background: #fff;
border: 1px solid var(--vtl-border, #d6d9d2);
border-radius: 50%;
box-shadow: 0 2px 10px rgba(0, 0, 0, .18);
cursor: pointer;
}
.btt:hover { background: #d1d4cd; }
.btt:focus-visible { outline: 2px solid var(--vtl-accent, #3b463d); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .btt { transition: none; } }
</style>