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.
This commit is contained in:
Doxa Maps 2026-09-27 04:46:32 +00:00
parent 56fd4fba79
commit 667c6331c8
2 changed files with 88 additions and 6 deletions

View file

@ -65,13 +65,27 @@ useShadowStyles(`
.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 }
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'])
@ -96,7 +110,7 @@ defineEmits(['click'])
-->
<button
class="mcb"
:class="{ dark: isDark, active }"
:class="{ dark: isDark, active, 'mcb--square': shape === 'square' }"
:title="title"
:aria-label="title || undefined"
:disabled="disabled"

View file

@ -21,17 +21,40 @@
profiles that DO mount a geocoder.
-->
<script setup>
import { ref, provide } from 'vue'
import { computed, ref, provide } from 'vue'
import { useShadowStyles } from '../../composables/useShadowStyles.js'
defineProps({
const props = defineProps({
hasGeocoder: { type: Boolean, default: true },
/**
* 'round' (default) keeps the separate floating circles every map ships today.
* 'square' is the ENGINE'S OWN grouped card -- one white panel, 4px corners, a 2px ring and 1px
* dividers -- which is the look the Driver asked for by name. Measured from
* node_modules/maplibre-gl/dist/maplibre-gl.css, not invented:
* .maplibregl-ctrl-group { background:#fff; border-radius:4px }
* .maplibregl-ctrl-group:not(:empty) { box-shadow:0 0 0 2px rgba(0,0,0,.1) }
* .maplibregl-ctrl-group button { width:29px; height:29px; background:transparent; border:0 }
* .maplibregl-ctrl-group button+button { border-top:1px solid #ddd }
* ★ SQUARE IS NOT A RADIUS SWAP, which is why it lives here and not only on the button: round
* controls are separate boxes with a gap and a shadow each, and the engine's square form is ONE
* container that owns the background, the corners and the ring while its buttons own nothing. Set
* border-radius:0 on the buttons alone and you get squares floating 8px apart -- neither shape.
* ★ THE 29px IS DELIBERATELY NOT PORTED. Ours stay --map-btn-size (capped to 32px in this
* toolbar). 29px would shrink 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's controls, and that is a choice rather than an oversight.
*/
controls: { type: String, default: 'round' },
})
// The toolbar's element is provided to its controls: MapControlMenu measures it to place
// every menu at the toolbar's top, on its inner side, no taller than the control column.
const toolbarEl = ref(null)
provide('mapToolbar', { el: toolbarEl })
// The shape rides the EXISTING inject so every control reads ONE source of truth. The visual work is
// done by CSS descendant rules below -- nothing has to pass a matching prop to each button -- but
// MapControlMenu measures this column, and a square toolbar is SHORTER (gap:0), so a menu using
// matchToolbar gets a shorter box. Anything that needs to know can read it here instead of guessing.
provide('mapToolbar', { el: toolbarEl, shape: computed(() => (props.controls === 'square' ? 'square' : 'round')) })
useShadowStyles(`
.map-toolbar {
@ -54,6 +77,50 @@ useShadowStyles(`
.map-toolbar .mcb,
.map-toolbar .hbg-btn { width: 32px; height: 32px; }
.map-toolbar .hbg-ctrl { margin: 10px 0 0 0; } /* drop the 6px left offset so it lines up with the rest */
/* ── controls="square": the ENGINE'S grouped card, ported ───────────────────────────────── */
.map-toolbar--square {
gap: 0; /* the group is one panel, not a spaced column */
background: #fff;
border-radius: 4px;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1); /* .maplibregl-ctrl-group:not(:empty) */
}
/* The buttons own nothing: no corners, no shadow, no fill. The panel owns all three. */
.map-toolbar--square .mcb,
.map-toolbar--square .hbg-btn {
border-radius: 0;
box-shadow: none;
background: transparent;
}
/* button+button -> a 1px divider. Written on the toolbar's CHILDREN rather than on .mcb pairs
because some controls wrap their button in an element of their own (.hbg-ctrl), and a divider
that only knew about .mcb would vanish exactly where two different control types meet. */
.map-toolbar--square > * + * { border-top: 1px solid #ddd; }
/* .hbg-ctrl's 10px top margin would tear the panel apart; inside the group it has no business. */
.map-toolbar--square .hbg-ctrl { margin: 0; }
/* ★ OUTER CORNERS ONLY, AND NO overflow:hidden. Clipping would have been 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 (button:focus:first-child -> 4px 4px 0 0, :last-child
-> 0 0 4px 4px, :only-child -> inherit). Ported as their mechanism. MapControlMenu teleports out of
this element into the map area, so clipping would not have hidden a menu -- the focus ring is the
real cost, and it is the invisible kind. */
.map-toolbar--square > :first-child,
.map-toolbar--square > :first-child .mcb,
.map-toolbar--square > :first-child .hbg-btn { border-radius: 4px 4px 0 0; }
.map-toolbar--square > :last-child,
.map-toolbar--square > :last-child .mcb,
.map-toolbar--square > :last-child .hbg-btn { border-radius: 0 0 4px 4px; }
.map-toolbar--square > :only-child,
.map-toolbar--square > :only-child .mcb,
.map-toolbar--square > :only-child .hbg-btn { border-radius: 4px; }
/* ★ FORCED COLOURS, which our round buttons have never had. The engine ships this pair because a
rgba() ring and a #ddd divider both disappear when the user's own palette replaces them, leaving
one undifferentiated block where the controls were. */
@media (forced-colors: active) {
.map-toolbar--square { box-shadow: 0 0 0 2px ButtonText; }
.map-toolbar--square > * + * { border-top: 1px solid ButtonText; }
}
@media (max-width: 767px) {
/* Mobile (with geocoder): drop the toolbar well below the search bar so
it doesn't sit flush against the search pill. Search = top:10,
@ -67,7 +134,8 @@ useShadowStyles(`
</script>
<template>
<div ref="toolbarEl" class="map-toolbar" :class="{ 'map-toolbar--no-geocoder': !hasGeocoder }">
<div ref="toolbarEl" class="map-toolbar"
:class="{ 'map-toolbar--no-geocoder': !hasGeocoder, 'map-toolbar--square': controls === 'square' }">
<slot />
</div>
</template>