Tab the PC detail page and add panel groups to the ADR-010 contract
This commit is contained in:
99
frontend/src/components/AssetDetailTabs.vue
Normal file
99
frontend/src/components/AssetDetailTabs.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<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-x: auto;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.adt-tab {
|
||||
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>
|
||||
@@ -2,7 +2,7 @@
|
||||
<!-- Generic renderer for ADR-010 get_asset_panels (Path A). A plugin declares
|
||||
a panel as JSON (title, endpoint, render mode, field map); this renders it
|
||||
on any asset detail page, so a plugin adds detail-page UI with no Vue. -->
|
||||
<template v-for="panel in panels" :key="panel.id">
|
||||
<template v-for="panel in groupedPanels" :key="panel.id">
|
||||
<div v-if="panelVisible(panel)" class="section-card">
|
||||
<!-- collapsible panels get a clickable header with a caret and, when the
|
||||
panel declares a status rollup, a green/yellow/red dot for the worst
|
||||
@@ -149,7 +149,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import api from '../api'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import {
|
||||
@@ -183,10 +183,29 @@ function visibleRows(panel) {
|
||||
|
||||
const props = defineProps({
|
||||
assetid: { type: [Number, String], default: null },
|
||||
// Which detail-page tab to render. Null renders every panel, which is what
|
||||
// the detail pages that have not adopted tabs still do - so this stays
|
||||
// additive and no existing page changes behaviour.
|
||||
group: { type: String, default: null },
|
||||
})
|
||||
|
||||
const panels = ref([])
|
||||
|
||||
// The server resolves every panel's group before we see it, so an unknown or
|
||||
// absent one is already 'details' by the time it arrives here. Filtering can
|
||||
// therefore be a plain equality check and nothing falls through the gap.
|
||||
const groupedPanels = computed(() =>
|
||||
props.group ? panels.value.filter((p) => p.group === props.group) : panels.value
|
||||
)
|
||||
|
||||
// Which groups actually have a panel with data, so a parent can hide a tab
|
||||
// rather than offering an empty one.
|
||||
const populatedGroups = computed(() => {
|
||||
const seen = new Set()
|
||||
panels.value.forEach((p) => { if (panelVisible(p)) seen.add(p.group || 'details') })
|
||||
return seen
|
||||
})
|
||||
|
||||
// Which tab each 'tabs' panel is showing, keyed by panel id. Kept here rather
|
||||
// than on the panel object because panels are replaced wholesale on refetch.
|
||||
const tabs = ref({})
|
||||
@@ -334,7 +353,7 @@ watch(() => props.assetid, load, { immediate: true })
|
||||
|
||||
// Let a parent detail page (for example PCDetail's header Refresh button)
|
||||
// trigger a panel reload without duplicating the fetch plumbing.
|
||||
defineExpose({ refresh: load })
|
||||
defineExpose({ refresh: load, populatedGroups })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user