form-schema-runtime

Labels, described-by wiring, invalid state, summary links, and focus behavior.

Documentation pages
On this page
  1. Label and Input Association
  2. Generated IDs
  3. aria-invalid
  4. aria-describedby
  5. Help Text
  6. Error Text
  7. Error Summary
  8. Focusing Invalid Fields
  9. Required Fields
  10. Keyboard Support
  11. Placeholder Is Not a Label
  12. Custom Renderer Responsibilities
  13. Practical Checks

Accessibility Guide

form-schema-runtime is built around native HTML controls and practical accessibility behavior. The runtime handles common wiring, but schema authors and custom renderer authors still have responsibilities.

Label and Input Association

Every built-in field renders a visible label associated with its native control.

{
  type: "text",
  name: "fullName",
  label: "Full name"
}

Labels come from schema text and are rendered safely. Do not rely on placeholders as labels.

Generated IDs

The runtime generates stable IDs from:

  • classPrefix
  • schema id
  • field name

Custom renderers receive these IDs through FieldRenderContext:

input.id = context.inputId;
label.htmlFor = context.inputId;

Use the provided IDs instead of inventing unrelated IDs.

aria-invalid

Invalid built-in controls receive:

aria-invalid="true"

Custom renderers should do the same:

input.setAttribute("aria-invalid", context.errors.length > 0 ? "true" : "false");

aria-describedby

Help text and error text are referenced only when present.

if (context.describedBy) {
  input.setAttribute("aria-describedby", context.describedBy);
}

Do not reference missing elements. The runtime's describedBy value already handles this.

Help Text

{
  type: "email",
  name: "email",
  label: "Email",
  helpText: "Use a work or account recovery email address."
}

Help text renders near the control and is referenced by aria-describedby.

Error Text

When validation fails, field-level error text renders near the field and is referenced by aria-describedby.

{
  type: "text",
  name: "employeeId",
  label: "Employee ID",
  pattern: "^[A-Z]{2}-[0-9]{5}$",
  validationMessages: {
    pattern: "Employee ID must look like IT-12345."
  }
}

Keep validation messages clear and specific.

Error Summary

When the form is invalid, the runtime renders a form-level error summary with links to invalid fields. The summary receives focus after submit or manual validation fails.

This helps keyboard and screen reader users understand all current problems without scanning the entire form.

Focusing Invalid Fields

Error summary links focus the invalid control:

First name: First name is required.

Clicking or activating the link moves focus to the field.

Required Fields

Required fields are communicated through:

  • visible label text using *
  • native required attributes where appropriate
  • validation messages
{
  type: "text",
  name: "firstName",
  label: "First name",
  required: true
}

Do not rely on color alone to communicate required state.

Keyboard Support

Built-in renderers use native controls:

  • input
  • textarea
  • select
  • checkbox input
  • radio input
  • button

Native controls preserve expected keyboard behavior. Prefer native controls over custom widgets.

Placeholder Is Not a Label

This is good:

{
  type: "text",
  name: "firstName",
  label: "First name",
  placeholder: "Ada"
}

This is not supported because the label is missing:

{
  type: "text",
  name: "firstName",
  placeholder: "First name"
}

Every field needs a visible label.

Custom Renderer Responsibilities

Custom renderers must preserve the accessibility contract:

const renderer: FieldRenderer = (context) => {
  const shell = document.createElement("div");
  shell.className = `${context.classPrefix}-field`;

  const label = document.createElement("label");
  label.className = `${context.classPrefix}-label`;
  label.htmlFor = context.inputId;
  label.textContent = context.field.required ? `${context.field.label} *` : context.field.label;

  const input = document.createElement("input");
  input.className = `${context.classPrefix}-control`;
  input.id = context.inputId;
  input.name = context.field.name;
  input.value = context.value == null ? "" : String(context.value);
  input.required = context.field.required ?? false;
  input.disabled = context.field.disabled ?? false;
  input.setAttribute("aria-invalid", context.errors.length > 0 ? "true" : "false");

  if (context.describedBy) {
    input.setAttribute("aria-describedby", context.describedBy);
  }

  context.events.listen(input, "input", () => context.setValue(input.value));
  context.events.listen(input, "blur", () => context.markTouched());

  shell.append(label, input);

  if (context.field.helpText) {
    const help = document.createElement("p");
    help.id = context.helpId;
    help.className = `${context.classPrefix}-help`;
    help.textContent = context.field.helpText;
    shell.append(help);
  }

  if (context.errors.length > 0) {
    const error = document.createElement("p");
    error.id = context.errorId;
    error.className = `${context.classPrefix}-error`;
    error.role = "alert";
    error.textContent = context.errors[0];
    shell.append(error);
  }

  return shell;
};

Checklist for custom renderers:

  • Use context.inputId.
  • Associate labels with controls.
  • Render help text with context.helpId when present.
  • Render error text with context.errorId when present.
  • Apply aria-invalid.
  • Apply aria-describedby only when non-empty.
  • Use native controls where possible.
  • Use context.events.listen() for cleanup.
  • Use textContent for schema-provided text.

Practical Checks

Before shipping a form:

  • Tab through all controls.
  • Submit an empty required form and confirm the summary appears.
  • Activate a summary link and confirm focus moves to the field.
  • Confirm help and error text are announced by assistive technology.
  • Confirm conditional fields appear and disappear without trapping focus.
  • Confirm custom renderers work without a mouse.