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:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user