doxa-maps/library/components/map-controls/MapControlButton.vue
doxa-maps-88655c70 a5c5cf1910 give every map control an accessible name
The zoom buttons render an icon through a 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 — unreliably announced, and never
surfaced to touch users.

Found while proving the research-dashboard zoom controls actually work. They do: a click
triggered six tile requests at zoom level 2, so the open-work item is function-verified,
not just present. The probe that matched aria-label found nothing while the probe that
fell back to title found both buttons, and that discrepancy was the defect.

One binding in the shared MapControlButton reaches all eight control buttons — zoom in
and out, fullscreen, location, help, share, hamburger, theme — across seven map bundles,
because every one composes this base. No caller changes: the component does not set
inheritAttrs:false, so a caller passing its own aria-label still wins through normal
fallthrough, and undefined omits the attribute when there is no title to mirror.

Verified across all seven bundles: 34 control buttons, 0 without an accessible name.
2026-09-21 13:02:56 +00:00

90 lines
3.7 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;}
.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;}}
`, 'map-control-button')
defineProps({
isDark: { type: Boolean, default: false },
title: { type: String, default: '' },
active: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }
})
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.
-->
<button
class="mcb"
:class="{ dark: isDark, active }"
:title="title"
:aria-label="title || undefined"
:disabled="disabled"
@click="$emit('click')"
>
<slot />
</button>
</template>