- 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)
136 lines
4.2 KiB
TypeScript
136 lines
4.2 KiB
TypeScript
import type { ReactNode } from "react";
|
||
import Tooltip from "./Tooltip";
|
||
import { FocusSafeButton } from "./FocusSafeButton";
|
||
import { SURFACE } from "./surfaces";
|
||
import { SectionLabel } from "./Headings";
|
||
|
||
// Shared form/settings primitives, factored out of the many panels that each re-declared
|
||
// them (Settings, Config, Stats, admin pages, Setup wizard). Visual contract unchanged.
|
||
|
||
/** Pill on/off switch — the bare control. In a settings list use `SwitchRow`, whose whole row toggles;
|
||
* a row laid out by hand points a `<label htmlFor={id}>` at it. `label` is the accessible name (the
|
||
* visible row text), required: the control has no inner text to be named by. The button is its own box
|
||
* around the 36×20 track: 24 tall on a mouse (the B1 floor) and the 44 touch target on a coarse pointer
|
||
* (A3), a real box rather than an invisible hit area. */
|
||
export function Switch({
|
||
checked,
|
||
onChange,
|
||
label,
|
||
disabled,
|
||
id,
|
||
}: {
|
||
checked: boolean;
|
||
onChange: (v: boolean) => void;
|
||
label: string;
|
||
disabled?: boolean;
|
||
id?: string;
|
||
}) {
|
||
// FocusSafeButton: a switch is disabled by its caller while the change it made is saving, and a
|
||
// natively disabled switch that holds the focus throws it to <body>.
|
||
return (
|
||
<FocusSafeButton
|
||
type="button"
|
||
id={id}
|
||
role="switch"
|
||
aria-checked={checked}
|
||
aria-label={label}
|
||
disabled={disabled}
|
||
onClick={() => onChange(!checked)}
|
||
className="group inline-flex h-6 min-w-9 shrink-0 items-center justify-center rounded-full disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:min-h-(--touch-target) pointer-coarse:min-w-(--touch-target)"
|
||
>
|
||
<span
|
||
className={`relative h-5 w-9 rounded-full transition ${checked ? "bg-accent" : "bg-border"}`}
|
||
>
|
||
<span
|
||
className={`absolute top-0.5 w-4 h-4 rounded-full shadow transition-all ${
|
||
checked ? "left-4.5 bg-accent-fg" : "left-0.5 bg-white"
|
||
}`}
|
||
/>
|
||
</span>
|
||
</FocusSafeButton>
|
||
);
|
||
}
|
||
|
||
/** A titled settings block. Default = a plain block with an uppercase caption (the section's `h2`);
|
||
* `card` wraps it in a glass card (the admin pages' style). */
|
||
export function Section({
|
||
title,
|
||
card,
|
||
children,
|
||
}: {
|
||
title: string;
|
||
card?: boolean;
|
||
children: ReactNode;
|
||
}) {
|
||
return (
|
||
<div className={card ? `${SURFACE.panel} p-4 mb-4` : "mb-6"}>
|
||
<SectionLabel className={card ? "mb-3" : "mb-2"}>{title}</SectionLabel>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** A label that reveals an explanatory caption on hover (dotted underline) when `hint` is set.
|
||
* No-op styling when there's no hint, so it's safe to use unconditionally. */
|
||
export function HintLabel({ hint, children }: { hint?: string; children: ReactNode }) {
|
||
return (
|
||
<Tooltip hint={hint ?? ""}>
|
||
<span
|
||
className={
|
||
hint
|
||
? "underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help"
|
||
: ""
|
||
}
|
||
>
|
||
{children}
|
||
</span>
|
||
</Tooltip>
|
||
);
|
||
}
|
||
|
||
/** A settings row whose control is a switch: the whole row is a `<label>`, so its text toggles the switch
|
||
* as the switch does (the iOS / Material settings row, owner decision F12). A `<button>` is labelable, so
|
||
* a click on the text is forwarded to it; a click on the switch itself is not forwarded twice. */
|
||
export function SwitchRow({
|
||
label,
|
||
hint,
|
||
checked,
|
||
onChange,
|
||
disabled,
|
||
}: {
|
||
label: string;
|
||
hint?: string;
|
||
checked: boolean;
|
||
onChange: (v: boolean) => void;
|
||
disabled?: boolean;
|
||
}) {
|
||
return (
|
||
<label
|
||
className={`flex items-center justify-between gap-3 py-1.5 text-sm select-none ${
|
||
disabled ? "cursor-not-allowed" : "cursor-pointer"
|
||
}`}
|
||
>
|
||
<HintLabel hint={hint}>{label}</HintLabel>
|
||
<Switch label={label} checked={checked} onChange={onChange} disabled={disabled} />
|
||
</label>
|
||
);
|
||
}
|
||
|
||
/** A settings row: a (hint-able) label on the left, a control on the right. */
|
||
export function SettingRow({
|
||
label,
|
||
hint,
|
||
children,
|
||
}: {
|
||
label: string;
|
||
hint?: string;
|
||
children: ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="flex items-center justify-between gap-3 py-1.5 text-sm">
|
||
<HintLabel hint={hint}>{label}</HintLabel>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|