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.
This commit is contained in:
Doxa Maps 2026-09-28 16:58:51 +00:00
parent 3a4d4db48a
commit e5140f0b41
2 changed files with 99 additions and 2 deletions

View file

@ -0,0 +1,93 @@
<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>

View file

@ -98,9 +98,13 @@
the-end legend therefore ends UNDER the circle.
A plugin cannot know whether the circle is there, so the layout says so: --map-fab-clearance
is set ONLY while the button is actually rendered, and custom properties inherit through the
flattened tree, so it reaches into each shadow root. No circle, no reserved space. -->
flattened tree, so it reaches into each shadow root. No circle, no reserved space.
--map-fab-beside is the same fact for the OTHER axis: where a sibling control must start to
sit NEXT TO the circle rather than on top of it (12px inset + 44px button + 8px gap). A
consumer that hardcoded 64px would silently overlap the day the circle resizes; reading it
means the circle's geometry is stated once, here, by the component that owns it. -->
<div class="vtl" :class="{ 'nav-open': navOpen, 'rail-collapsed': railCollapsed, 'no-header': !header }"
:style="fabShown ? { '--map-fab-clearance': 'calc(56px + env(safe-area-inset-bottom))' } : null">
:style="fabShown ? { '--map-fab-clearance': 'calc(56px + env(safe-area-inset-bottom))', '--map-fab-beside': 'calc(64px + env(safe-area-inset-left))' } : null">
<header class="vtl-top" v-if="header">
<button class="vtl-menu" type="button" :aria-expanded="String(navOpen)" @click="navOpen = !navOpen"
:aria-label="label('toggleList')">☰</button>