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>
)}