The brief says of the no-selection chooser: "Keep the dropdowns as the
keyboard/screen-reader path." Pin clicking is mouse-only by
construction, so that path is load-bearing — and nothing had ever
pressed Tab. There was zero keyboard coverage across 42 probes.
The path itself is sound, and is now asserted: 30 distinct focus stops;
the chooser's region keys are real focusable buttons and Enter on one
selects it ("All Regions" to "Africa") with no mouse involved; the
SELECT VIEW strip carries role=tab on both buttons with exactly one
aria-selected.
The defect that fell out: of those 30 stops, 29 showed a 2px ring and
one showed outline:none — button.mcb, the shared MapControlButton. Its
line 38 sets outline:none deliberately, to kill 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. The accessible name was
already correct (aria-label mirrors title, fixed 2026-09-21), so screen
readers were never affected — only the people who navigate by eye and
keyboard, which is the very group outline:none was written for.
The fix is a :focus-visible rule plus a .dark variant. :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. Consumers re-checked
per RULE 2 — ShareButton, ZoomIn, ZoomOut, Fullscreen, Help,
ThemeToggle, Location, map-controls/index.js, my-upg-100-list and
research-dashboard's DashboardDataTable — all render the button
icon-only and none styled focus themselves, so the rule only adds a
ring where there was none. All five affected bundles rebuilt.
Proven both ways: Tab gives solid 2px rgb(26,115,232) with
:focus-visible true and the "Share" label intact; a real mouse click on
a fresh page gives outline:none with :focus-visible false.
Three of my own errors on the way, each caught before it shipped. I
first read a region key's name as "Africa315" and nearly fixed a
non-bug — the button already carries aria-label="Open the Africa
dashboard", which wins over textContent, and the label and count have a
real 7.5px flex gap. I put backticks in a CSS comment inside a template
literal, which ended the string and broke four of five builds;
check:sfc-parse caught it at once. And my mouse assertion failed twice
on correct behaviour: synthetic PointerEvents with a manual focus() do
not update the browser's pointer-modality heuristic, and clicking the
button that had just held keyboard focus leaves the page in keyboard
modality. Only a fresh page with no key ever pressed is a clean test.
Regression: 22 suites green at the time of commit, including new
keyboard and focus-ring suites.
107 lines
5.1 KiB
Vue
107 lines
5.1 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;}}
|
|
`, '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>
|