doxa-maps/library/components/ViewSelect.vue
Doxa Maps e19697673d
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Doxa Maps — the map bundler, 7 production plugins and the Experimental shelf
2026-09-25 20:07:59 +00:00

101 lines
5 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).
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;' +
// ONE LINE PER TAB. Ported from the shell's own mobile rule (app-wrapper-material
// index.html, max-width:960px: white-space:nowrap + ellipsis) — the extraction that
// created this component copied the DESKTOP metrics verbatim but left that hardening
// behind, so the leaf and the shell disagreed on narrow viewports.
// MEASURED at 390px with the label "Prayer Resources": the fieldset is width-capped
// by the header, the TEXT wrapped inside the button (28 -> 43px) and the strip grew
// 45 -> 60px, while the shell held 45px. The row of tabs never wrapped; only the
// words inside one did. Ellipsis is the better failure than a taller header.
' white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }' +
'.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>