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.
144 lines
4.2 KiB
Vue
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>
|