Tab the PC detail page, and let a panel say which tab it belongs to
The page had grown to roughly ten cards in one flow, and three more surfaces were wanted on it: installed software, live metrics, and the service controls. Stacking those into the same column would have buried what the page is for. Tabs were mocked and rejected on 2026-08-28, on the grounds that the strip duplicated the sidebar and cost hero space. That objection is answered by where the strip sits: the hero and the triage band stay above it and never move, so "is this machine OK" is still answered without a click, and the strip partitions the detail underneath rather than navigating anywhere. WHY A PANEL DECLARES ITS OWN GROUP Most cards on a detail page are not the page's. They arrive through the ADR-010 get_asset_panels hook, so a page that sorted panels into tabs by knowing their ids would defeat the hook it is built on. Panels now carry an optional group instead, and the page only knows the five group names. Resolution happens server-side, in the asset-panels route, so that an absent or misspelled group becomes 'details' before the client ever sees it. The client renders known groups only; an unrecognised value that survived to it would be a panel that exists and is displayed nowhere. Contract 0.28.0 -> 0.29.0, additive - every existing plugin keeps working untouched, and the endpoints status panel is deliberately left ungrouped so the default path is exercised by a shipped plugin and not only by tests. A tab is offered only when something fills it. An empty tab reads as broken rather than as "this site does not collect that", so the strip is built from the groups that actually returned data, and a tab that disappears on refetch falls back to Details rather than leaving the page blank. AssetDetailTabs is core, not a PCDetail component: machines, printers, network devices and measuring tools all have the same shape, and the shared asset-detail shell they need starts here. Scope: product
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