doxa-maps/library/components/map-controls/HamburgerButton.vue
Doxa Maps 36f2818b37 Every animating library component now honours prefers-reduced-motion itself
17 of 25 library components that ship motion carried no reduced-motion rule, so
they animated regardless of the reader's OS setting.

The reason it cannot be fixed once, centrally: a @media (prefers-reduced-motion)
rule in the host document does not cross a shadow boundary, and these render
inside one. Measured while surveying: NO generated leaf page ships such a rule
at all — 0 occurrences across the whole built tree — so there was not even a
page rule to inherit. Each component owns its own.

CSS-ONLY AND ADDITIVE. Nothing but new @media blocks; no existing rule changed.
Verified by diff: 0 real removals across 17 files. The two lines git reports as
removed are a trailing comma moving as a new string was chained onto an existing
concatenation, and both original rules are still present — checked by grepping
for them, not by reading the diff.

WHERE MOTION CARRIES MEANING, it is kept rather than hidden:
  · SemanticTreeLegend's spinner MEANS "loading". Stopped dead it is a static
    ring that says nothing, so it is slowed to a step crawl (2.4s, steps(8)) —
    visibly working, no smooth spin.
  · LegendProgressBar's only motion is a hover filter, so it simply stops; the
    bar's FILL is geometry, not animation, and still reports its value.

The rule goes where each component's styles actually go, and that varies more
than expected: 13 append into a useShadowStyles template literal, 3 into
string-concatenated sheets, and VerticalTabsLayout into its VTL_SHADOW_CSS
constant AND its duplicate <style> block, because its own header says the two
copies must stay identical.

check:reduced-motion asserts both halves, each mutation-tested to fail before I
trusted a pass: strip the rule from an animating component and it fails; move a
rule from the injected sheet into a <style> block and it fails too, because on a
useShadowStyles component that rule would never reach the shadow root. Both
mutated files restored byte-identical.

Two of my own errors caught by tooling rather than by review, and worth naming:
my first patch asserted useShadowStyles existed in every target and RAISED on
VerticalTabsLayout instead of silently skipping it — the assert-the-anchor habit
from the last registration failure working as intended. Then I appended CSS
after the label argument on two concat-built sheets, corrupting the injection
key; check:shadow-css-parses named both files and the fix was to insert before
the label. A gate I did not write caught what I got wrong.

Registered in all three places and verified by re-reading the parsed
package.json.
2026-09-29 01:38:07 +00:00

144 lines
4.2 KiB
Vue

<!--
HamburgerButton.vue — Toggle button for the side menu drawer.
Rendered as a Mapbox custom control added to 'top-left', so it floats
naturally to the RIGHT of the geocoder search bar using Mapbox's own
float:left layout — no absolute positioning or CSS offset hacks needed.
Props:
map — live mapboxgl.Map instance (required once map is ready)
isOpen — reflects the current drawer state; animates the lines to an X
isDark — mirrors the app theme
Emits:
toggle — fired on click; parent owns the open/close state
-->
<script setup>
import { onMounted, onBeforeUnmount, watch } from 'vue'
import { useShadowStyles } from '../../composables/useShadowStyles.js'
useShadowStyles(`
/* ── Mapbox ctrl wrapper — zero margin so button sits flush next to geocoder ── */
.hbg-ctrl { margin: 10px 0 0 6px !important; line-height: 0; }
/* ── Button ── */
.hbg-btn {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
background: #fff;
box-shadow: 0 1px 4px rgba(0,0,0,0.14), 0 2px 8px rgba(0,0,0,0.10);
transition: background 0.2s, box-shadow 0.2s, transform 0.1s;
padding: 0;
outline: none;
}
.hbg-btn:hover { background: #f0f0f0; }
.hbg-btn:active { transform: scale(0.93); }
/* Dark mode */
.hbg-btn.dark {
background: #3b463d;
border: 1px solid rgba(255,255,255,0.14);
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
}
.hbg-btn.dark:hover { background: #4e594f; }
/* The three lines */
.hbg-line {
width: 16px;
height: 2px;
border-radius: 1px;
background: #333;
transition: transform 0.25s ease, opacity 0.25s ease, width 0.25s ease;
transform-origin: center;
pointer-events: none;
}
.hbg-btn.dark .hbg-line { background: rgba(255,255,255,0.85); }
/* Animate to X when open */
.hbg-btn.open .hbg-line:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.hbg-btn.open .hbg-line:nth-child(2) { opacity: 0; width: 0; }
.hbg-btn.open .hbg-line:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
@media (max-width: 767px) {
.hbg-ctrl { margin: 7px 0 0 5px !important; }
.hbg-btn { width: 36px; height: 36px; gap: 4px; }
.hbg-line { width: 14px; }
}
/* ★ REDUCED MOTION, OWNED BY THIS COMPONENT. A page-level rule cannot reach into a shadow root,
and no generated leaf ships one anyway (measured: 0 across the whole tree), so a component
that animates must carry its own. Additive only: it removes motion when the OS asks and
changes nothing otherwise. */
@media (prefers-reduced-motion: reduce) {
.hb-btn, .hb-bar { transition: none !important; animation: none !important; }
}
`, 'hamburger-button')
const props = defineProps({
map: { type: Object, default: null },
isOpen: { type: Boolean, default: false },
isDark: { type: Boolean, default: false }
})
const emit = defineEmits(['toggle'])
// Imperative DOM refs — lives inside the Mapbox control container (shadow DOM)
let _container = null
let _btn = null
let _control = null
function _updateBtn() {
if (!_btn) return
const classes = ['hbg-btn']
if (props.isOpen) classes.push('open')
if (props.isDark) classes.push('dark')
_btn.className = classes.join(' ')
_btn.title = props.isOpen ? 'Close menu' : 'Open menu'
}
onMounted(() => {
if (!props.map) return
// Outer wrapper needs mapboxgl-ctrl so Mapbox applies float:left + margin
_container = document.createElement('div')
_container.className = 'mapboxgl-ctrl hbg-ctrl'
_btn = document.createElement('button')
_btn.type = 'button'
_btn.innerHTML =
'<span class="hbg-line"></span>' +
'<span class="hbg-line"></span>' +
'<span class="hbg-line"></span>'
_btn.addEventListener('click', () => emit('toggle'))
_container.appendChild(_btn)
_updateBtn()
_control = {
onAdd: () => _container,
onRemove: () => { try { _container.remove() } catch {} }
}
props.map.addControl(_control, 'top-left')
})
onBeforeUnmount(() => {
try {
if (props.map && _control) props.map.removeControl(_control)
} catch {}
})
watch(() => props.isOpen, _updateBtn)
watch(() => props.isDark, _updateBtn)
</script>
<!-- Renderless — all DOM is created imperatively inside the Mapbox control -->
<template></template>