Files
technitium-console/src/ui/SubTabs.tsx
T
DenozordecandClaude Sonnet 5.5 87281a87d3
CI / check (push) Failing after 37s
CI / image (push) Skipped
Match the Technitium canvas: palette, shell, KPI strips, framed tables
The live UI did not follow the canvas because its base layer differed: dark
palette in hex (now the canvas oklch values), 13 px body text, flat Frame panels.

- Tokens: canvas palette, 14 px body, 1.4 line height; Frame without `dense`
  gets the 3 px ring; Panel takes a description line.
- Shell: 56 px brand button with version, 11 px group headings, user card at the
  foot, versions moved into the header status popover, Home / group / section
  crumbs, amber "Attention" status when blocking is off or paused.
- New KpiStrip and PageTabs; Dashboard KPIs in three frames and a Refresh button.
- Zones: KPI strip, tab with count, views, framed table with the canvas columns.
- Apps: Installed / App Store tabs and a grid instead of cards (store inline).
- Blocking: state banner and tab counts; one "Blocking" title.
- Settings: actions in the header, label-and-help rows with the control on the right.
- DHCP, Administration, DNS Client, Logs, Cache, About: subtitles, framed tables,
  KPI strips where the data exists; Login cover with the three-point list.
- docs/compose.shnt.example.yaml: production compose with the console image.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-27 12:47:27 +07:00

89 lines
3.1 KiB
TypeScript

import { plainClick, toTrail } from '../app/route'
import text from './text.module.css'
import styles from './SubTabs.module.css'
/*
The sub-navigation of a section: `View Logs · Query Logs`, the nine panes of
`Settings`, the six screens of `Administration`, `Leases · Scopes`.
## Why it exists, and where it was
It lived in the sidebar, nested under its section, and the design puts it
**under the title** instead, for a reason: the 60 px rail hides the labels below
1180 px, so nine sub-panes nested there run out of room. Taking them out of the
sidebar fixes that, and Administration and Logs get the same shape.
What the sidebar gets back is its own shape: **twelve entries, always the same
twelve**, instead of fourteen, eighteen or twenty-one depending on where you are.
## Links, not buttons
Each tab is a real `<a>` with the route it goes to, exactly like the sidebar: it
survives a modifier click, it can be copied, it can be opened in another tab. That
is what the routes existing as folders is for. A plain click is intercepted so
there is no reload.
`aria-current="page"` and not `aria-selected`: these are not the tabs of an ARIA
`tablist` (there is no panel switching under one heading, there is a page you
navigate to), and announcing a `tab` would promise arrow-key navigation that these
do not have. It is the same distinction `SectionIndex` documents, one step over:
there `location`, because the sections stay on the page; here `page`, because
each one is a different screen.
## A mark on a tab
`marks` puts a small dot after a tab's name, with its words read as part of the
link's name (`Lists, unsaved changes`). Two callers use it, both for unsaved
changes and with the same words: Blocking for Lists, and Settings for each pane
holding something its one save would send. The words are the caller's and the
caller declares them.
*/
export function SubTabs({
label,
section,
tabs,
active,
onChoose,
marks,
counts,
}: {
/** The accessible name of the navigation: "Settings sections". */
label: string
/** The section id, to build each tab's route. */
section: string
tabs: readonly string[]
active: string
onChoose: (tab: string) => void
/** A tab's mark: the words a screen reader hears after its name. */
marks?: Partial<Record<string, string>>
/** A small count after a tab's name, as the canvas draws it (`Rules 42`). */
counts?: Partial<Record<string, number>>
}) {
return (
<nav className={styles.bar} aria-label={label}>
{tabs.map((t) => (
<a
key={t}
className={styles.tab}
href={toTrail({ section, sub: t })}
aria-current={t === active ? 'page' : undefined}
onClick={(e) => {
if (!plainClick(e)) return
e.preventDefault()
onChoose(t)
}}
>
{t}
{counts?.[t] != null && <em className={styles.count}>{counts[t]}</em>}
{marks?.[t] != null && (
<>
<span className={text.sr}>, {marks[t]}</span>
<span className={styles.mark} aria-hidden="true" />
</>
)}
</a>
))}
</nav>
)
}