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