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
@@ -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 () => {