feat(ui): E31 S4b — the admin pages behind one rail row, Configuration as a sectioned list, Statistics as tiles and bars
- NavRailGroup: one "Admin" rail row opening a panel of the admin pages (links, so a middle-click still opens a tab); the rail fits 133% zoom without a hidden scroll - sysconfig.SECTIONS orders the config groups under four headings (every group in exactly one, test-held); the page shows them as Settings' side list (Tabs `group` headings); the lone registration group reads "Registration" under "Access" - StatTiles / StatTile / StatBars: the Statistics overview's figures as tiles, the per-operation quota use as horizontal bars (a zero draws none)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useId, useRef, type KeyboardEvent } from "react";
|
||||
import { Fragment, useId, useRef, type KeyboardEvent } from "react";
|
||||
import { panelId, tabId } from "./tabPanel";
|
||||
import { type LucideIcon } from "lucide-react";
|
||||
import CountBadge from "./CountBadge";
|
||||
@@ -24,6 +24,9 @@ export interface TabDef {
|
||||
badge?: number; // optional count pill (e.g. pending requests); hidden when 0/undefined
|
||||
icon?: LucideIcon; // optional leading icon (the vertical settings rail)
|
||||
testId?: string; // optional data-testid, to preserve existing e2e hooks
|
||||
/** The vertical rail's heading this tab sits under (E31: Configuration's sixteen groups under four).
|
||||
* Drawn above the first tab of each run; tabs of one heading stay together. */
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export default function Tabs({
|
||||
@@ -87,40 +90,52 @@ export default function Tabs({
|
||||
onKeyDown={onKeyDown}
|
||||
className={wrapCls}
|
||||
>
|
||||
{tabs.map((tb) => {
|
||||
{tabs.map((tb, i) => {
|
||||
const on = tb.id === active;
|
||||
const Icon = tb.icon;
|
||||
const heading = vertical && tb.group && tb.group !== tabs[i - 1]?.group ? tb.group : null;
|
||||
return (
|
||||
<button
|
||||
key={tb.id}
|
||||
ref={(node) => {
|
||||
if (node) refs.current.set(tb.id, node);
|
||||
else refs.current.delete(tb.id);
|
||||
}}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={tabId(base, tb.id)}
|
||||
aria-selected={on}
|
||||
aria-controls={idBase ? panelId(idBase) : undefined}
|
||||
tabIndex={tb.id === stop ? 0 : -1}
|
||||
data-testid={tb.testId}
|
||||
onClick={() => onChange(tb.id)}
|
||||
className={
|
||||
tone === "media"
|
||||
? `touch-target flex-1 items-center justify-center whitespace-nowrap rounded px-2 py-1 text-2xs ${
|
||||
on ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10"
|
||||
}`
|
||||
: `pill touch-target items-center rounded-lg px-3 text-sm ${
|
||||
vertical
|
||||
? "inline-flex shrink-0 gap-1.5 py-1.5 sm:flex sm:w-full sm:justify-start sm:gap-2 sm:py-2 sm:text-left"
|
||||
: "inline-flex gap-1.5 py-1.5"
|
||||
} ${on ? "font-medium shadow-md shadow-accent/20" : "state-layer"}`
|
||||
}
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4 shrink-0" />}
|
||||
{tb.label}
|
||||
{tb.badge != null && tb.badge > 0 && <CountBadge count={tb.badge} active={on} />}
|
||||
</button>
|
||||
<Fragment key={tb.id}>
|
||||
{/* A heading, not a tab: no role a tab list would have to own, its text still read. Only
|
||||
on the side rail; the phone's scrolling row has no room for it. */}
|
||||
{heading && (
|
||||
<div
|
||||
role="presentation"
|
||||
className="hidden px-3 pt-3 pb-1 text-2xs font-semibold tracking-wide text-muted uppercase first:pt-1 sm:block"
|
||||
>
|
||||
{heading}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
ref={(node) => {
|
||||
if (node) refs.current.set(tb.id, node);
|
||||
else refs.current.delete(tb.id);
|
||||
}}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={tabId(base, tb.id)}
|
||||
aria-selected={on}
|
||||
aria-controls={idBase ? panelId(idBase) : undefined}
|
||||
tabIndex={tb.id === stop ? 0 : -1}
|
||||
data-testid={tb.testId}
|
||||
onClick={() => onChange(tb.id)}
|
||||
className={
|
||||
tone === "media"
|
||||
? `touch-target flex-1 items-center justify-center whitespace-nowrap rounded px-2 py-1 text-2xs ${
|
||||
on ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10"
|
||||
}`
|
||||
: `pill touch-target items-center rounded-lg px-3 text-sm ${
|
||||
vertical
|
||||
? "inline-flex shrink-0 gap-1.5 py-1.5 sm:flex sm:w-full sm:justify-start sm:gap-2 sm:py-2 sm:text-left"
|
||||
: "inline-flex gap-1.5 py-1.5"
|
||||
} ${on ? "font-medium shadow-md shadow-accent/20" : "state-layer"}`
|
||||
}
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4 shrink-0" />}
|
||||
{tb.label}
|
||||
{tb.badge != null && tb.badge > 0 && <CountBadge count={tb.badge} active={on} />}
|
||||
</button>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user