doxa-maps/library/components/map-controls/MapControlButton.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

134 lines
6.9 KiB
Vue

<!--
MapControlButton.vue — Shared base button for all map toolbar controls.
Renders a single round button with consistent light/dark mode styling.
Each specific button (ZoomIn, Location, etc.) wraps this component and
supplies its own icon slot + service logic.
Props:
isDark — mirrors the app theme; switches bg/border/shadow
title — tooltip text
active — optional pressed/active visual state (e.g. fullscreen on)
disabled — disables interaction
Emits:
click — forwarded from the native button element
-->
<script setup>
import { useShadowStyles } from '../../composables/useShadowStyles.js'
useShadowStyles(`
.mcb{
width:var(--map-btn-size,40px);height:var(--map-btn-size,40px);
background:#fff;
border:none;
border-radius:50%;
cursor:pointer;
/* Don't let a drag that starts near a button select its glyph (the old
"drag selects the emoji to copy" bug while grabbing the scroll edge). */
user-select:none;-webkit-user-select:none;
display:flex;align-items:center;justify-content:center;
/* Tight shadow only — the old 0 2px 8px spread bled to the map's clipped right
edge (overflow:hidden) and read as a "shadow wall" + made buttons look
over-padded. (coder 2026-06-24) */
box-shadow:0 1px 3px rgba(0,0,0,0.18);
transition:background-color 0.2s,box-shadow 0.2s,transform 0.1s;
color:#333;
flex-shrink:0;
outline:none;
/* No blue/grey selection box on click/tap (webkit tap highlight). */
-webkit-tap-highlight-color:transparent;
}
.mcb:hover:not(:disabled){background:#f0f0f0;box-shadow:0 2px 8px rgba(0,0,0,0.18),0 4px 16px rgba(0,0,0,0.12);}
.mcb:active:not(:disabled){transform:scale(0.93);}
.mcb:disabled{opacity:0.4;cursor:not-allowed;}
/* ★ KEYBOARD FOCUS WAS INVISIBLE (measured 2026-09-24). outline:none above is deliberate —
it kills the webkit focus box on click/tap, alongside -webkit-tap-highlight-color — but it
had no :focus-visible replacement, so a sighted keyboard user Tabbing onto ANY map control
in ANY bundle saw nothing at all. Found by tabbing through the regions element: of 30 focus
stops, 29 showed a 2px ring and one, button.mcb, showed outline: none with no text.
The accessible NAME was already fine (aria-label mirrors title, 2026-09-21), so screen
readers were never affected — only the people who navigate by eye and keyboard.
:focus-visible is the correct half of the pair: it never fires for a mouse click, so the
original reason for outline:none still holds and the mouse appearance is unchanged. Placed
before the .active/.dark rules so those still win on background/colour.
Consumers re-checked (RULE 2): ShareButton, ZoomInButton, ZoomOutButton, FullscreenButton,
HelpButton, ThemeToggleButton, LocationButton, map-controls/index.js, plus
my-upg-100-list/upg-100-list.vue and research-dashboard's DashboardDataTable. All of them
render this button icon-only, so none of them styled focus themselves and none can regress:
the rule only adds a ring where there was none. */
.mcb:focus-visible{outline:2px solid #1a73e8;outline-offset:2px;}
.mcb.dark:focus-visible{outline:2px solid #92b195;outline-offset:2px;}
.mcb.active{background:#e8f0fe;color:#1a73e8;}
/* dark */
.mcb.dark{background:#3b463d;color:#F3F3F1;border:1px solid rgba(255,255,255,0.14);box-shadow:0 1px 4px rgba(0,0,0,0.4);}
.mcb.dark:hover:not(:disabled){background:#4e594f;box-shadow:0 2px 8px rgba(0,0,0,0.5);}
.mcb.dark.active{background:#4e594f;color:#92b195;}
@media(max-width:767px){.mcb{width:36px;height:36px;}}
/* shape="square" on a STANDALONE button: their 4px corners, our fill and shadow kept.
A button inside .map-toolbar--square is overridden back to 0 by the toolbar's rules. */
.mcb--square{ border-radius:4px; }
/* ★ 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) {
.mcb-btn { transition: none !important; animation: none !important; }
}
`, 'map-control-button')
defineProps({
isDark: { type: Boolean, default: false },
title: { type: String, default: '' },
active: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
/**
* 'round' (default) is the 50% circle every map ships. 'square' is for a button used OUTSIDE a
* MapToolbar -- inside one, set controls="square" on the TOOLBAR instead and it styles its whole
* column, because the engine's square form is a grouped panel and a single button cannot be one.
* ★ A STANDALONE SQUARE KEEPS ITS OWN FILL AND SHADOW and takes only the 4px corners, which is
* what the engine gives a lone control: `button:focus:only-child { border-radius: inherit }` from
* a group whose radius is 4px. Inside a toolbar the panel owns the fill, so the toolbar's rules
* strip it back off -- the two rule sets compose rather than compete, and the toolbar wins by
* being the more specific selector.
*/
shape: { type: String, default: 'round' }
})
defineEmits(['click'])
</script>
<template>
<!--
★ aria-label MIRRORS title (2026-09-21). These buttons render an ICON via <slot/> and carried
ONLY `title`, so their accessible name was EMPTY: a screen reader announced "button" with no
indication of what it does. `title` is a tooltip, not an accessible name — it is unreliably
announced, and never surfaced to touch users at all.
Found while proving the research-dashboard zoom controls actually WORK (they do: a click
triggered 6 tile requests at zoom level 2). The probe that matched on aria-label found nothing
while the probe that fell back to title found both buttons — the discrepancy WAS the defect.
One binding here reaches all EIGHT control buttons (zoom in/out, fullscreen, location, help,
share, hamburger, theme) across SEVEN map bundles, because every one composes this base.
No caller has to change. This component does not set inheritAttrs:false, so a caller that
passes its own aria-label still wins via normal attribute fallthrough — no $attrs juggling
needed here, and `undefined` omits the attribute entirely when there is no title to mirror.
★ data-control (zoom-in, zoom-out, fullscreen, theme, location, help, share) is set by each
wrapper and falls through to this <button>. It is the UNTRANSLATED identity: title and
aria-label are localised, so a probe that finds a control by its text breaks in every other
locale. Query `.mcb[data-control="zoom-in"]`, never the title.
-->
<button
class="mcb"
:class="{ dark: isDark, active, 'mcb--square': shape === 'square' }"
:title="title"
:aria-label="title || undefined"
:disabled="disabled"
@click="$emit('click')"
>
<slot />
</button>
</template>