110 lines
3.8 KiB
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>
|