Files
shopdb-flask/frontend/src/components/AssetDetailTabs.vue

110 lines
3.8 KiB
Vue

<template>
<!-- The tab strip for an asset detail page. Presentational only: it owns the
strip and the keyboard model, never the content, so every detail page
gets the same behaviour without the strip knowing what an asset is.
It sits BELOW the hero and triage band on purpose. Those answer "is this
thing OK", which must not be behind a click - the strip partitions the
detail underneath and is not navigation. That distinction is what makes
tabs worth having here; a strip that duplicated the sidebar was tried and
rejected. -->
<div class="adt" role="tablist" :aria-label="label">
<button
v-for="(tab, index) in tabs"
:key="tab.key"
:id="`adt-${uid}-${tab.key}`"
class="adt-tab"
:class="{ 'adt-on': tab.key === modelValue }"
role="tab"
type="button"
:aria-selected="tab.key === modelValue ? 'true' : 'false'"
:aria-controls="`adtp-${uid}-${tab.key}`"
:tabindex="tab.key === modelValue ? 0 : -1"
:ref="el => setRef(el, index)"
@click="$emit('update:modelValue', tab.key)"
@keydown="onKey($event, index)"
>
{{ tab.label }}
<span v-if="tab.count != null" class="adt-count">{{ tab.count }}</span>
</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
// [{ key, label, count? }] - the caller decides which tabs exist, so a page
// can drop a tab it has nothing for rather than offering an empty one.
tabs: { type: Array, required: true },
modelValue: { type: String, required: true },
label: { type: String, default: 'Detail sections' },
})
const emit = defineEmits(['update:modelValue'])
// Ids have to be unique per instance: a page may show two of these, and
// duplicate ids would break the aria-controls pairing.
const uid = Math.random().toString(36).slice(2, 8)
const els = ref([])
function setRef(el, index) { if (el) els.value[index] = el }
// Roving tabindex, per the WAI-ARIA tabs pattern: arrows move between tabs,
// Home and End jump to the ends. Without this a keyboard user tabs through
// every tab button to reach the panel.
function onKey(event, index) {
const last = props.tabs.length - 1
let next = null
if (event.key === 'ArrowRight') next = index === last ? 0 : index + 1
else if (event.key === 'ArrowLeft') next = index === 0 ? last : index - 1
else if (event.key === 'Home') next = 0
else if (event.key === 'End') next = last
if (next === null) return
event.preventDefault()
emit('update:modelValue', props.tabs[next].key)
els.value[next]?.focus()
}
</script>
<style scoped>
.adt {
display: flex;
gap: 2px;
/* overflow-y MUST be stated. Per the spec, when one axis is not `visible`
the other computes from `visible` to `auto` - so `overflow-x: auto` alone
silently makes this scrollable vertically too, and the -1px margin the
tabs use to sit on the border is enough to trip it. The result is a tiny
vertical scrollbar inside the strip. */
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
border-bottom: 1px solid var(--border);
margin-bottom: 1.25rem;
}
.adt-tab {
/* Never shrink: a squeezed tab wraps its label, which grows the strip and is
what horizontal scrolling exists to avoid. */
flex: none;
font: inherit;
font-size: 0.875rem;
font-weight: 500;
white-space: nowrap;
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
padding: 0.7rem 0.9rem;
color: var(--text-light);
cursor: pointer;
}
.adt-tab:hover { color: var(--text); }
.adt-on { color: var(--primary); border-bottom-color: var(--primary); }
.adt-tab:focus-visible { outline: 2px solid var(--primary); outline-offset: -3px; border-radius: 3px; }
.adt-count {
font-size: 0.75rem;
color: var(--text-light);
margin-left: 0.35rem;
font-variant-numeric: tabular-nums;
}
</style>