doxa-maps/library/components/ViewSelect.vue
doxa-maps-88655c70 bca3d58c3e
Some checks are pending
build-gate / build (push) Waiting to run
Regions element gets the Select View tabs, shared with UPG-100
The Regions bundle shipped only tab 1, so the Select View switcher the
UPG-100 list shows had nowhere to live. Adds the composite that carries
it over two panes -- Regions List and Region Dashboards -- plus the
Region Dashboards tab itself, which summarises a region or a country
from the same live endpoint and the same rollup tab 1 uses.

Clicking a region heading or a country row on tab 1 now forwards that
selection and swaps to tab 2 with it applied.

The switcher itself is extracted into library/components/ViewSelect.vue
rather than copied a third time: it had two implementations already, in
the app shell and hand-copied into upg-100-mfe. It takes the views and
the current id as props and knows nothing about maps or panes, so the
placement stays with each header. UPG-100 now renders from the shared
component and is unchanged on screen -- same 45px height, 13px labels,
8px radius.
2026-09-23 19:08:00 +00:00

92 lines
4.3 KiB
Vue

<!--
ViewSelect.vue — the "SELECT VIEW" switcher: a fieldset + legend wrapping a row of
underline-indicator tabs.
THE CANONICAL SOURCE is the app shell, layouts/app-wrapper-material/index.html
(`.viewsel` / `.seg`, a PrimeVue-style Fieldset). This component is that control
extracted VERBATIM — same structure (semantic <fieldset>/<legend>), same metrics
(8px radius, 10px/700/.7px-tracking uppercase legend, 13px/500 buttons with
6px 12px 7px padding), same 2px accent underline on the pressed tab.
WHY IT LIVES IN THE LIBRARY. It had two independent implementations before this
file existed — the shell's vanilla one and a hand-copied Vue port inside
my-upg-100-list/upg-100-mfe.vue — and a third consumer (regions-dashboard) was
about to copy it again. A control with three consumers and no map-specific
assumptions is what this library is for; a fourth divergent copy is not.
PARAMETERIZED, per library/components/WARNING.md — it takes props, not assumptions:
it knows nothing about maps, panes, bundles or scrolling. It renders the views it
is handed and reports which one was clicked. Positioning is the CALLER's job:
this component ships no `position`/`top`/`left`, so a header that wants it
absolutely centred styles `.viewsel` itself (as both the shell and the MFE do).
Props:
views — [{ id, label }, …] the views to offer, in order
current — id of the active view
legend — the fieldset's legend text (default "Select View")
label — accessible name for the tablist (default "Views")
Emits:
select(id) — a view's tab was clicked
-->
<script setup>
import { useShadowStyles } from '../composables/useShadowStyles.js'
defineProps({
views: { type: Array, default: () => [] },
current: { type: String, default: '' },
legend: { type: String, default: 'Select View' },
label: { type: String, default: 'Views' },
})
defineEmits(['select'])
// Child components' <style> blocks are dropped by defineCustomElement, so the CSS
// goes through useShadowStyles like every other library component.
//
// NO BACKTICKS ANYWHERE IN THIS STRING, including in comments: one stray backtick
// terminates the JS template literal and the build stops compiling. Hence string
// concatenation rather than one template literal (house style, AGENT-BRIEF.md).
useShadowStyles(
// Metrics copied from the shell's .viewsel; colours come from the ui-* custom
// properties the host element already declares, with the material-light palette
// as the fallback so this renders correctly even outside a themed host.
'.viewsel{ margin:0; padding:0 10px 2px;' +
' border:1px solid var(--ui-border,#d1d4cd); border-radius:8px;' +
' background:var(--ui-bg,#ffffff); }' +
// Legend default alignment is inline-start; the shell centres it.
'.viewsel > legend{ margin:0 auto; padding:0 6px; font-size:10px;' +
' text-transform:uppercase; letter-spacing:.7px;' +
' color:var(--ui-text-muted,#6d766e); font-weight:700; }' +
// Slim UNDERLINE indicator, not a filled button.
'.seg{ display:flex; gap:2px; }' +
'.seg button{ position:relative; display:flex; align-items:center; gap:6px;' +
' border:0; background:none; color:var(--ui-text-muted,#6d766e);' +
' padding:6px 12px 7px; font-size:13px; font-weight:500; cursor:pointer;' +
' transition:color .15s; font-family:inherit; }' +
'.seg button::after{ content:""; position:absolute; left:8px; right:8px; bottom:0;' +
' height:2px; border-radius:2px 2px 0 0; background:transparent;' +
' transition:background .15s; }' +
'.seg button:hover{ color:var(--ui-text,#3b463d); }' +
'.seg button:hover::after{ background:var(--ui-border,#d1d4cd); }' +
'.seg button[aria-pressed="true"]{ color:var(--ui-primary,#92b195); font-weight:600; }' +
'.seg button[aria-pressed="true"]::after{ background:var(--ui-primary,#92b195); }',
'view-select'
)
</script>
<template>
<fieldset class="viewsel">
<legend>{{ legend }}</legend>
<div class="seg" role="tablist" :aria-label="label">
<button
v-for="v in views"
:key="v.id"
type="button"
role="tab"
:aria-pressed="current === v.id ? 'true' : 'false'"
:aria-selected="current === v.id ? 'true' : 'false'"
@click="$emit('select', v.id)"
>{{ v.label }}</button>
</div>
</fieldset>
</template>