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
@@ -4,7 +4,15 @@ import * as stylex from "@stylexjs/stylex";
import { clientPrefixesPutMutation } from "@/lib/queries";
import type { ClientPrefix, Group } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
import {
firstProblem,
initPrefixEditor,
isDirty,
prefixEditorReducer,
toInputs,
type PrefixEditorAction,
type PrefixProblem,
} from "./prefixEditor";
import InlineError from "@/lib/InlineError";
import AuthorityGate from "@/features/configuration/AuthorityGate";
import Select from "@/ui/Select";
@@ -16,6 +24,9 @@ interface Props {
groups: Group[];
}
/** The editor is rendered once per page, so the message can hold a fixed id. */
const VALIDATION_ID = "network-assignments-validation";
const styles = stylex.create({
section: {
marginTop: "2.5rem",
@@ -162,12 +173,24 @@ function AssignmentsTable({ prefixes }: { prefixes: ClientPrefix[] }) {
function AssignmentsEditor({ prefixes, groups }: Props) {
const queryClient = useQueryClient();
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
const [state, dispatch] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
const [validation, setValidation] = useState<string | null>(null);
const [state, apply] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
const [validation, setValidation] = useState<PrefixProblem | null>(null);
const dirty = isDirty(state);
const fallbackGroupId = defaultGroupId(groups);
const groupOptions = groups.map((group) => ({ value: String(group.id), label: group.name }));
// A problem names a row by position, and every action here can move, add or
// delete a position. The message describes the rows Save read, so it dies
// with them rather than drifting onto whatever row inherits the index.
const dispatch = (action: PrefixEditorAction) => {
setValidation(null);
apply(action);
};
/** True for the one input the current message is about; nothing else is marked. */
const invalid = (index: number, field: PrefixProblem["field"]): true | undefined =>
validation !== null && validation.index === index && validation.field === field ? true : undefined;
const save = () => {
const problem = firstProblem(state.rows);
setValidation(problem);
@@ -188,6 +211,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
<input
type="text"
aria-label={`Range ${index + 1}`}
aria-invalid={invalid(index, "prefix")}
aria-describedby={invalid(index, "prefix") && VALIDATION_ID}
placeholder="192.168.1.0/24"
value={row.prefix}
onChange={(event) =>
@@ -208,6 +233,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
type="text"
inputMode="numeric"
aria-label={`Priority for range ${index + 1}`}
aria-invalid={invalid(index, "priority")}
aria-describedby={invalid(index, "priority") && VALIDATION_ID}
placeholder="100"
value={row.priority}
onChange={(event) =>
@@ -227,8 +254,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
</ul>
)}
{validation !== null && (
<p role="alert" {...stylex.props(styles.validation)}>
{validation}
<p id={VALIDATION_ID} role="alert" {...stylex.props(styles.validation)}>
{validation.message}
</p>
)}
<InlineError error={mutation.error} />
@@ -251,10 +278,7 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
{dirty && (
<button
type="button"
onClick={() => {
setValidation(null);
dispatch({ type: "reset", prefixes });
}}
onClick={() => dispatch({ type: "reset", prefixes })}
{...stylex.props(shared.button, shared.focusRing)}
>
Discard changes