admin: associate inline field errors with their inputs

network assignment rows, activity filters, and the settings password pair now mark the offending input with aria-invalid and point it at the error text with aria-describedby. the prefix validator returns the row and field it is about, and any row mutation clears a message that named a position. settings numeric fields carry aria-invalid on an unparseable value.
This commit is contained in:
2026-08-29 12:08:19 +02:00
parent 317d5dd4f8
commit 59d6be98f8
8 changed files with 295 additions and 27 deletions
@@ -13,6 +13,9 @@ import { styles as config } from "./styles";
const DARK = "@media (prefers-color-scheme: dark)";
/** The form is rendered once per page, so the message can hold a fixed id. */
const MISMATCH_ID = "web.password_mismatch";
const styles = stylex.create({
form: {
marginTop: "1rem",
@@ -175,12 +178,15 @@ function FieldRow({
}
if (def.kind === "number") {
const numeric = value as number;
// An empty or unparseable number reads back as NaN. The form already refuses
// to submit on it; this is what says so to a screen reader.
return (
<div {...stylex.props(styles.field)}>
<FieldLabel id={id} text={def.key} restart={restart} />
<input
id={id}
type="number"
aria-invalid={Number.isNaN(numeric) || undefined}
value={Number.isNaN(numeric) ? "" : numeric}
onChange={(e) => onChange(e.target.valueAsNumber)}
{...stylex.props(styles.fieldInput, shared.focusRing)}
@@ -286,6 +292,8 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
id="web.password"
type="password"
autoComplete="new-password"
aria-invalid={passwordsMismatch || undefined}
aria-describedby={passwordsMismatch ? MISMATCH_ID : undefined}
value={password}
onChange={(e) => setPassword(e.target.value)}
{...stylex.props(styles.fieldInput, shared.focusRing)}
@@ -299,6 +307,8 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
id="web.password_confirm"
type="password"
autoComplete="new-password"
aria-invalid={passwordsMismatch || undefined}
aria-describedby={passwordsMismatch ? MISMATCH_ID : undefined}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
{...stylex.props(styles.fieldInput, shared.focusRing)}
@@ -311,7 +321,7 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
</p>
)}
{passwordsMismatch && (
<p {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
<p id={MISMATCH_ID} {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
Passwords do not match.
</p>
)}
@@ -176,12 +176,52 @@ test("enum and boolean fields diff as their own types", async () => {
expect(putBodies[0]).toEqual({ logging: { level: "debug", hide_domains: true } });
});
/** The text of the elements an input points at with `aria-describedby`. */
function describedText(input: HTMLElement): string {
const ids = input.getAttribute("aria-describedby");
if (ids === null) throw new Error("input has no aria-describedby");
return ids
.split(/\s+/)
.map((id) => {
const node = document.getElementById(id);
if (node === null) throw new Error(`aria-describedby names missing element ${id}`);
return node.textContent ?? "";
})
.join(" ");
}
test("clearing a number field disables Save instead of sending NaN", async () => {
await openSystem();
const cache = screen.getByRole("group", { name: "Cache" });
fireEvent.change(within(cache).getByLabelText("size"), { target: { value: "" } });
const size = within(cache).getByLabelText("size");
fireEvent.change(size, { target: { value: "" } });
expect(saveButton().disabled).toBe(true);
// The refusal is on the field itself, not only on the Save button.
expect(size.getAttribute("aria-invalid")).toBe("true");
fireEvent.change(size, { target: { value: "512" } });
expect(size.getAttribute("aria-invalid")).toBeNull();
});
test("the mismatch message is attached to both password inputs", async () => {
await openSystem();
const web = screen.getByRole("group", { name: "Web" });
const passwordInput = within(web).getByLabelText("password");
const confirmInput = within(web).getByLabelText("confirm password");
fireEvent.change(passwordInput, { target: { value: "hunter2" } });
for (const input of [passwordInput, confirmInput]) {
expect(input.getAttribute("aria-invalid")).toBe("true");
expect(describedText(input)).toBe("Passwords do not match.");
}
fireEvent.change(confirmInput, { target: { value: "hunter2" } });
for (const input of [passwordInput, confirmInput]) {
expect(input.getAttribute("aria-invalid")).toBeNull();
expect(input.getAttribute("aria-describedby")).toBeNull();
}
});
test("password flow: note shown, confirm required, PUT sends web.password, no restart notice", async () => {