doxa-maps/library/components/SideMenuDrawer.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

214 lines
6.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!--
SideMenuDrawer.vue — Slide-in side panel for the map application.
Overlaps only the map area (stays inside the custom element boundary),
so the staging environment's header and footer are never covered.
Layout:
• Drawer: 75% of the map width, slides in from the left
• Right 1/4: map remains visible and interactive
• No dimming backdrop — transparent click-catcher closes the drawer
when the user taps/clicks the exposed right-side map area
Props:
isOpen — controls visibility and slide animation
isDark — mirrors the app theme
Emits:
close — fired when X button or outside area is clicked
reload-map — fired when the bottom "Reload map" button is clicked
Slot (default):
Content area of the drawer — empty placeholder, populated by the
consuming app. Add navigation, filters, settings panels etc.
-->
<script setup>
import { useI18n } from 'vue-i18n'
import { useShadowStyles } from '../composables/useShadowStyles.js'
const { t } = useI18n()
useShadowStyles(`
/* ── Click-catcher — invisible, covers full map area when drawer is open ── */
.smd-catcher {
position: absolute;
inset: 0;
z-index: 1050;
background: transparent;
pointer-events: none;
opacity: 0;
transition: opacity 0.28s ease;
}
.smd-catcher.open {
pointer-events: all;
opacity: 1;
}
/* ── Drawer panel ── */
.smd-drawer {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 75%;
max-width: 420px;
z-index: 1051;
display: flex;
flex-direction: column;
transform: translateX(-100%);
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
background: #fff;
box-shadow: 2px 0 16px rgba(0,0,0,0.12);
pointer-events: all;
}
.smd-drawer.open {
transform: translateX(0);
}
/* Dark mode */
.smd-drawer.dark {
background: #3b463d;
box-shadow: 2px 0 20px rgba(0,0,0,0.5);
border-right: 1px solid rgba(255,255,255,0.10);
}
/* ── Drawer header bar ── */
.smd-header {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 12px 12px 0;
flex-shrink: 0;
height: 52px;
}
/* ── Close (X) button ── */
.smd-close {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: rgba(0,0,0,0.06);
color: #333;
font-size: 20px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
flex-shrink: 0;
}
.smd-close:hover { background: rgba(0,0,0,0.12); }
.smd-drawer.dark .smd-close {
background: rgba(255,255,255,0.10);
color: #fff;
}
.smd-drawer.dark .smd-close:hover { background: rgba(255,255,255,0.18); }
/* ── Scrollable content area ── */
.smd-content {
flex: 1 1 0;
overflow-y: auto;
padding: 16px 20px 24px;
color: #333;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.smd-drawer.dark .smd-content { color: #e0e0e0; }
/* ── Empty state placeholder ── */
.smd-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 10px;
opacity: 0.35;
user-select: none;
}
.smd-placeholder-icon { font-size: 36px; }
.smd-placeholder-text { font-size: 13px; font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; }
/* ── Reload map button ── */
.smd-footer {
flex-shrink: 0;
padding: 12px 20px 20px;
border-top: 1px solid rgba(0,0,0,0.07);
}
.smd-drawer.dark .smd-footer { border-top-color: rgba(255,255,255,0.08); }
.smd-reload-btn {
width: 100%;
padding: 10px 16px;
border-radius: 8px;
border: 1px solid rgba(0,0,0,0.12);
background: rgba(0,0,0,0.04);
color: #555;
font-size: 13px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: background 0.15s, color 0.15s;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.smd-reload-btn:hover { background: rgba(0,0,0,0.09); color: #222; }
.smd-drawer.dark .smd-reload-btn { border-color: rgba(255,255,255,0.12); background: rgba(255,255,255,0.06); color: #ccc; }
.smd-drawer.dark .smd-reload-btn:hover { background: rgba(255,255,255,0.12); color: #fff; }
/* ★ 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) {
.smd-drawer, .smd-backdrop, .smd-item { transition: none !important; animation: none !important; }
}
`, 'side-menu-drawer')
defineProps({
isOpen: { type: Boolean, default: false },
isDark: { type: Boolean, default: false }
})
defineEmits(['close', 'reload-map'])
</script>
<template>
<!-- Invisible click-catcher covers map area; clicking it closes the drawer -->
<div class="smd-catcher" :class="{ open: isOpen }" @click="$emit('close')" />
<!-- Drawer panel — slides in from left, stops propagation so catcher isn't triggered inside it -->
<div
class="smd-drawer"
:class="{ open: isOpen, dark: isDark }"
@click.stop
>
<div class="smd-header">
<button class="smd-close" :title="t('buttons.closeMenu')" @click="$emit('close')">×</button>
</div>
<div class="smd-content">
<!-- Default slot: add navigation links, filters, settings panels, etc. -->
<slot>
<!-- Placeholder shown until slot content is provided -->
<div class="smd-placeholder">
<span class="smd-placeholder-icon">☰</span>
<span class="smd-placeholder-text">{{ t('messages.menuComingSoon') }}</span>
</div>
</slot>
</div>
<!-- Reload map button — always at the bottom of the drawer -->
<div class="smd-footer">
<button class="smd-reload-btn" @click="$emit('reload-map')">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
{{ t('buttons.reloadMap') }}
</button>
</div>
</div>
</template>