doxa-maps/library/components/map-controls/MapControlButton.vue
Doxa Maps 667c6331c8 Square map controls, as an option beside the round ones
The Driver asked for square controls by name. The fact that shaped this:
the square look is not a DX-life design at all. Their CountryMap.vue adds
NavigationControl + FullscreenControl and overrides nothing, so what he
admired is the ENGINE'S default grouped card -- which makes the target
published and exact instead of a matter of taste. Every value here is read
from node_modules/maplibre-gl/dist/maplibre-gl.css, and the check compares
ours against that file rather than against my memory of it.

SQUARE IS NOT A RADIUS SWAP, which is why it lives on the TOOLBAR. Round
controls are separate floating circles with a gap and a shadow each; the
engine's square form is ONE panel that owns the background, the 4px corners
and the 2px ring while its buttons own nothing and are divided by 1px lines.
Setting border-radius:0 on the buttons alone gives squares floating 8px
apart -- neither shape. So MapToolbar takes controls="round"|"square", and
MapControlButton keeps a shape prop only for a button used outside a
toolbar, where a lone control gets the engine's 4px.

THE 29px IS DELIBERATELY NOT PORTED. Ours stay --map-btn-size, capped to
32px in this toolbar. 29px would cut the touch target below what this
project has already ruled on twice, so the variant takes the shape and
leaves the size. It is therefore not pixel-identical to the engine, and
that is a choice, stated.

No overflow:hidden. Clipping was the quick way to round the panel, but it
also clips a focus ring on any inner button, and the engine does not do it
-- it rounds the first and last buttons instead. Ported as their mechanism.
MapControlMenu teleports out of the toolbar, so clipping would not have
hidden a menu; the focus ring was the real cost, and the invisible kind.

Forced colours ship with it, which the round buttons never had: an rgba()
ring and a #ddd divider both vanish under a replaced palette, leaving one
undifferentiated block where the controls were.

BLAST RADIUS: round is the default and none of the nine MapToolbar
consumers passes controls=, so every map renders exactly as before.
Asserted, not claimed -- opting one consumer in turns the check red.

The regions dashboard is NOT switched. Its map already uses the engine's
native controls, ported from DX-life, so it already looks like this;
replacing them with ours would risk clone parity to arrive at the same
picture.

One near-miss, recorded because of how it hid: the first cut added a second
:class to a <button> that already had one, and Vue rejects a duplicate
attribute, so the SFC did not parse. check:sfc-parse said so IMMEDIATELY --
and I had piped it through `head -2`, which printed its counting line and
cut off the FAIL beneath. The block count fell 133 -> 132 in that same
output, I noticed, and then checked script TAGS rather than parseability and
talked myself out of it. Two separate signals, both read past. The two
bindings are merged into one; the count is 133 again.
2026-09-27 06:04:07 +00:00

121 lines
6.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;}}
/* 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; }
`, '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.
-->
<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>