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]>
89 lines
3.1 KiB
TypeScript
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>
|
|
)
|
|
}
|