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
@@ -0,0 +1,80 @@
/**
* The filter row's error association.
*
* The timezone is fixed because the only invalid bound reachable in jsdom is a
* wall-clock time inside a spring-forward gap: jsdom applies the
* `datetime-local` value sanitization algorithm, so text that is merely
* incomplete never reaches the component at all — the input hands it back as
* the empty string, which is a bound the operator cleared.
*/
import { afterAll, expect, test, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import ActivityFilters, { NO_FILTERS } from "./ActivityFilters";
vi.stubEnv("TZ", "Europe/Paris");
afterAll(() => vi.unstubAllEnvs());
/** 02:30 does not exist on this date in Paris; the clock jumps 02:00 to 03:00. */
const GAP_WALL_TIME = "2026-03-29T02:30:00";
/** 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(" ");
}
function renderFilters() {
const applied: { current: unknown } = { current: null };
render(
<ActivityFilters
applied={NO_FILTERS}
isDisabled={false}
onApply={(filters) => (applied.current = filters)}
onClear={() => (applied.current = null)}
/>,
);
return applied;
}
test("a rejected bound marks its own input and describes it", () => {
const applied = renderFilters();
const since = screen.getByLabelText("Since") as HTMLInputElement;
const until = screen.getByLabelText("Until") as HTMLInputElement;
fireEvent.change(since, { target: { value: GAP_WALL_TIME } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
expect(since.getAttribute("aria-invalid")).toBe("true");
expect(describedText(since)).toContain("daylight saving");
// Refused means refused, and only the offending bound carries the mark.
expect(applied.current).toBeNull();
expect(until.getAttribute("aria-invalid")).toBeNull();
expect(until.getAttribute("aria-describedby")).toBeNull();
});
test("the mark moves to the other bound, and clears once both parse", () => {
renderFilters();
const since = screen.getByLabelText("Since") as HTMLInputElement;
const until = screen.getByLabelText("Until") as HTMLInputElement;
fireEvent.change(until, { target: { value: GAP_WALL_TIME } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
expect(since.getAttribute("aria-invalid")).toBeNull();
expect(describedText(until)).toContain("daylight saving");
fireEvent.change(until, { target: { value: "2026-03-29T04:30:00" } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
expect(until.getAttribute("aria-invalid")).toBeNull();
expect(until.getAttribute("aria-describedby")).toBeNull();
});
@@ -96,10 +96,24 @@ function optionBlocked(value: string): boolean | undefined {
return value === "allowed" ? false : undefined;
}
function boundError(label: string, reason: "unparseable" | "nonexistent"): string {
return reason === "unparseable"
? `${label} is not a complete date and time.`
: `${label} names a local time that does not exist — the clock jumps over it for daylight saving.`;
/** The message plus the bound it belongs to, so that input can point at it. */
interface BoundError {
field: "since" | "until";
message: string;
}
/** The editor is rendered once per page, so the message can hold a fixed id. */
const ERROR_ID = "activity-filter-error";
function boundError(field: "since" | "until", reason: "unparseable" | "nonexistent"): BoundError {
const label = field === "since" ? "Since" : "Until";
return {
field,
message:
reason === "unparseable"
? `${label} is not a complete date and time.`
: `${label} names a local time that does not exist — the clock jumps over it for daylight saving.`,
};
}
interface Props {
@@ -115,18 +129,22 @@ export default function ActivityFilters({ applied, isDisabled, onApply, onClear
const [blocked, setBlocked] = useState(blockedOption(applied.blocked));
const [since, setSince] = useState<DatetimeField>(() => datetimeField(applied.since));
const [until, setUntil] = useState<DatetimeField>(() => datetimeField(applied.until));
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<BoundError | null>(null);
/** True for the one bound the current message is about; nothing else is marked. */
const invalid = (field: BoundError["field"]): true | undefined =>
error !== null && error.field === field ? true : undefined;
function submit(event: FormEvent) {
event.preventDefault();
const sinceValue = resolveDatetimeField(since);
if (!sinceValue.ok) {
setError(boundError("Since", sinceValue.reason));
setError(boundError("since", sinceValue.reason));
return;
}
const untilValue = resolveDatetimeField(until);
if (!untilValue.ok) {
setError(boundError("Until", untilValue.reason));
setError(boundError("until", untilValue.reason));
return;
}
setError(null);
@@ -186,6 +204,8 @@ export default function ActivityFilters({ applied, isDisabled, onApply, onClear
type="datetime-local"
step={1}
value={since.text}
aria-invalid={invalid("since")}
aria-describedby={invalid("since") && ERROR_ID}
disabled={isDisabled}
onChange={(event) => setSince(editDatetimeField(since, event.target.value))}
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
@@ -197,6 +217,8 @@ export default function ActivityFilters({ applied, isDisabled, onApply, onClear
type="datetime-local"
step={1}
value={until.text}
aria-invalid={invalid("until")}
aria-describedby={invalid("until") && ERROR_ID}
disabled={isDisabled}
onChange={(event) => setUntil(editDatetimeField(until, event.target.value))}
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
@@ -221,8 +243,8 @@ export default function ActivityFilters({ applied, isDisabled, onApply, onClear
</div>
</form>
{error !== null && (
<p role="alert" {...stylex.props(styles.error)}>
{error}
<p id={ERROR_ID} role="alert" {...stylex.props(styles.error)}>
{error.message}
</p>
)}
</>