Files
siftlode/frontend/src/components/ui/form.tsx
T
peter be156fc52b 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)
2026-10-02 00:07:00 +02:00

136 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}