Zum Inhalt springen

Barrierefreie Formulare bauen: jenseits von semantischem HTML

Über die Formularsemantik hinaus: Accessibility-Muster für Validierung, Fehlerbehandlung, dynamische Felder und mehrstufige Formulare für alle.

5 Min. Lesezeit
Kommentierte Formularoberfläche mit ARIA-Labels, Fehlerzuordnungen und Indikatoren für das Fokusmanagement

Semantisches HTML liefert 60 % der Formular-Accessibility kostenlos mit. Die Elemente <label>, <fieldset> und <input> leisten schwere Arbeit, die ARIA-Attribute niemals vollständig ersetzen können. Aber Formulare in der Produktion brauchen Validierungsmeldungen, dynamische Felder, bedingte Abschnitte und mehrstufige Navigation, die semantisches HTML allein nicht abdecken kann.

Die verbleibenden 40 % erfordern bewusstes Engineering: korrekte ARIA-Attribute, Fokusmanagement, Ansagen über Live-Regionen und Tastatur-Navigationsmuster, die die meisten Komponentenbibliotheken falsch umsetzen.

Das Fundament: Labels und Zuordnungen

Jedes Input-Feld braucht einen zugänglichen Namen. Das <label>-Element stellt diesen bereit, aber die Zuordnung muss explizit sein – bloße Nähe funktioniert für Screenreader nicht.

htmlhtml
<!-- ❌ Implicit association only works visually -->
<div>
  Email
  <input type="email" />
</div>
 
<!-- ❌ Placeholder as label: disappears on focus -->
<input type="email" placeholder="Email address" />
htmlhtml
<!-- ✅ Explicit label association -->
<div>
  <label for="user-email">Email address</label>
  <input
    type="email"
    id="user-email"
    name="email"
    autocomplete="email"
    required
    aria-describedby="email-hint"
  />
  <p id="email-hint" class="hint-text">
    We'll use this for account recovery only.
  </p>
</div>

Das Attribut aria-describedby verknüpft ergänzende Informationen mit dem Input. Ein Screenreader kündigt an: "Email address, edit, required. We'll use this for account recovery only." Der Hinweistext liefert Kontext, ohne das Label zu überladen.

Fehlerbehandlung, die Screenreader verstehen

Bei Validierungsfehlern scheitern die meisten Formulare in Sachen Accessibility. Sehende Nutzer sehen roten Text; Screenreader-Nutzer brauchen programmatische Zuordnungen und Ansagen.

htmlhtml
<!-- ❌ Error message not associated with input -->
<label for="password">Password</label>
<input type="password" id="password" />
<span class="error" style="color: red;">
  Password must be at least 8 characters
</span>
htmlhtml
<!-- ✅ Error properly associated and announced -->
<label for="password">Password</label>
<input
  type="password"
  id="password"
  aria-invalid="true"
  aria-describedby="password-error password-hint"
  aria-required="true"
/>
<p id="password-error" class="error" role="alert">
  Password must be at least 8 characters
</p>
<p id="password-hint" class="hint">
  Include uppercase, lowercase, and a number.
</p>
tstypescript
// React component with proper error handling
interface FormFieldProps {
  id: string;
  label: string;
  type: string;
  error?: string;
  hint?: string;
  required?: boolean;
  value: string;
  onChange: (value: string) => void;
}
 
function FormField({
  id,
  label,
  type,
  error,
  hint,
  required,
  value,
  onChange,
}: FormFieldProps) {
  const errorId = `${id}-error`;
  const hintId = `${id}-hint`;
 
  const describedBy = [
    error ? errorId : null,
    hint ? hintId : null,
  ]
    .filter(Boolean)
    .join(" ");
 
  return (
    <div className="form-field">
      <label htmlFor={id}>
        {label}
        {required && <span aria-hidden="true"> *</span>}
      </label>
      <input
        id={id}
        type={type}
        value={value}
        onChange={(e) => onChange(e.target.value)}
        aria-invalid={error ? "true" : undefined}
        aria-describedby={describedBy || undefined}
        aria-required={required}
      />
      {error && (
        <p id={errorId} className="error" role="alert">
          {error}
        </p>
      )}
      {hint && (
        <p id={hintId} className="hint">
          {hint}
        </p>
      )}
    </div>
  );
}

Das role="alert" auf der Fehlermeldung erzeugt eine Live-Region, die Screenreader sofort ansagen, sobald sie erscheint. Das Attribut aria-invalid="true" signalisiert assistiven Technologien, dass das Feld Aufmerksamkeit braucht.

Fokusmanagement bei der Validierung

Wenn ein Formular mehrere Fehler enthält, brauchen Nutzer einen klaren Weg, um sie zu beheben. Das Fokusmanagement führt sie systematisch durch jeden Fehler.

tstypescript
// ❌ No focus management: user doesn't know where to start
function handleSubmit(errors: Map<string, string>) {
  if (errors.size > 0) {
    // Errors shown visually, but no focus guidance
    setErrors(errors);
  }
}
tstypescript
// ✅ Focus the first error field and provide summary
function handleSubmitAccessible(
  errors: Map<string, string>,
  formRef: React.RefObject<HTMLFormElement | null>
) {
  if (errors.size === 0) return;
 
  // Update error state
  setErrors(errors);
 
  // Wait for DOM update, then focus error summary
  requestAnimationFrame(() => {
    const summary = formRef.current?.querySelector(
      "[data-error-summary]"
    ) as HTMLElement | null;
 
    if (summary) {
      summary.focus();
    } else {
      // Fallback: focus first invalid field
      const firstError = formRef.current?.querySelector(
        "[aria-invalid='true']"
      ) as HTMLElement | null;
      firstError?.focus();
    }
  });
}
 
// Error summary component
function ErrorSummary({ errors }: { errors: Map<string, string> }) {
  if (errors.size === 0) return null;
 
  return (
    <div
      data-error-summary
      role="alert"
      tabIndex={-1}
      className="error-summary"
    >
      <h2>There are {errors.size} errors in your submission</h2>
      <ul>
        {Array.from(errors.entries()).map(([fieldId, message]) => (
          <li key={fieldId}>
            <a href={`#${fieldId}`}>{message}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}

Die Fehlerzusammenfassung verlinkt direkt auf jedes problematische Feld. Ein Klick auf einen Link fokussiert das entsprechende Input-Feld und gibt den Nutzern einen klaren Weg durch alle Fehler.

Dynamische Felder und Live-Ansagen

Formulare mit Feldern zum Hinzufügen/Entfernen, bedingten Abschnitten oder Ladezuständen brauchen Live-Regionen, um Änderungen anzusagen, die sehende Nutzer visuell wahrnehmen.

tstypescript
function DynamicFieldList({
  fields,
  onAdd,
  onRemove,
}: {
  fields: string[];
  onAdd: () => void;
  onRemove: (index: number) => void;
}) {
  const [announcement, setAnnouncement] = useState("");
 
  function handleAdd() {
    onAdd();
    setAnnouncement(
      `Item ${fields.length + 1} added. ${fields.length + 1} items total.`
    );
  }
 
  function handleRemove(index: number) {
    onRemove(index);
    setAnnouncement(
      `Item ${index + 1} removed. ${fields.length - 1} items total.`
    );
 
    // Focus the previous field or the add button
    requestAnimationFrame(() => {
      const prevField = document.getElementById(
        `field-${Math.max(0, index - 1)}`
      );
      if (prevField) {
        prevField.focus();
      }
    });
  }
 
  return (
    <fieldset>
      <legend>Phone numbers</legend>
 
      {/* Screen-reader-only live region */}
      <div
        aria-live="polite"
        aria-atomic="true"
        className="sr-only"
      >
        {announcement}
      </div>
 
      {fields.map((field, index) => (
        <div key={index} className="dynamic-field">
          <label htmlFor={`field-${index}`}>
            Phone number {index + 1}
          </label>
          <input
            id={`field-${index}`}
            type="tel"
            defaultValue={field}
            autoComplete="tel"
          />
          <button
            type="button"
            onClick={() => handleRemove(index)}
            aria-label={`Remove phone number ${index + 1}`}
          >
            Remove
          </button>
        </div>
      ))}
 
      <button type="button" onClick={handleAdd}>
        Add phone number
      </button>
    </fieldset>
  );
}

Die Region aria-live="polite" kündigt Änderungen an, ohne den aktuellen Kontext des Nutzers zu unterbrechen. "assertive" würde sofort unterbrechen – für Fehler angemessen, aber für routinemäßige Aktualisierungen zu aggressiv.

Mehrstufige Formulare brauchen eine klare Fortschrittsanzeige, per Tastatur navigierbare Schritte und eine saubere Überschriftenhierarchie innerhalb jedes Schritts.

tstypescript
interface Step {
  id: string;
  label: string;
  completed: boolean;
}
 
function StepIndicator({
  steps,
  currentStep,
}: {
  steps: Step[];
  currentStep: number;
}) {
  return (
    <nav aria-label="Form progress">
      <ol className="step-indicator">
        {steps.map((step, index) => (
          <li
            key={step.id}
            aria-current={index === currentStep ? "step" : undefined}
          >
            <span className="step-number" aria-hidden="true">
              {step.completed ? "✓" : index + 1}
            </span>
            <span className={index === currentStep ? "current" : ""}>
              {step.label}
              {step.completed && (
                <span className="sr-only"> (completed)</span>
              )}
              {index === currentStep && (
                <span className="sr-only"> (current step)</span>
              )}
            </span>
          </li>
        ))}
      </ol>
    </nav>
  );
}
 
function MultiStepForm({ steps }: { steps: Step[] }) {
  const [currentStep, setCurrentStep] = useState(0);
  const stepRef = useRef<HTMLDivElement>(null);
 
  function navigateToStep(newStep: number) {
    setCurrentStep(newStep);
 
    // Focus the step heading after navigation
    requestAnimationFrame(() => {
      const heading = stepRef.current?.querySelector("h2");
      heading?.focus();
    });
  }
 
  return (
    <form>
      <StepIndicator steps={steps} currentStep={currentStep} />
 
      <div ref={stepRef}>
        <h2 tabIndex={-1}>
          Step {currentStep + 1} of {steps.length}:{" "}
          {steps[currentStep].label}
        </h2>
 
        {/* Step content renders here */}
      </div>
 
      <div className="step-navigation">
        {currentStep > 0 && (
          <button
            type="button"
            onClick={() => navigateToStep(currentStep - 1)}
          >
            Back to {steps[currentStep - 1].label}
          </button>
        )}
        {currentStep < steps.length - 1 ? (
          <button
            type="button"
            onClick={() => navigateToStep(currentStep + 1)}
          >
            Continue to {steps[currentStep + 1].label}
          </button>
        ) : (
          <button type="submit">Submit form</button>
        )}
      </div>
    </form>
  );
}

Das Fokussieren der Schrittüberschrift bei der Navigation orientiert Screenreader-Nutzer innerhalb des Formulars. Ohne diesen Schritt landen sie im Inhalt des neuen Schritts, ohne Kontext darüber, was sich geändert hat.

Accessibility-Tests in der Praxis

Automatisierte Tools erkennen etwa 30 % der Accessibility-Probleme. Der Rest erfordert manuelle Tests mit echter assistiver Technologie.

tstypescript
// Automated testing with jest and testing-library
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
 
describe("FormField accessibility", () => {
  it("associates error message with input", () => {
    render(
      <FormField
        id="email"
        label="Email"
        type="email"
        error="Invalid email address"
        value=""
        onChange={() => {}}
      />
    );
 
    const input = screen.getByLabelText("Email");
    expect(input).toHaveAttribute("aria-invalid", "true");
    expect(input).toHaveAccessibleDescription("Invalid email address");
  });
 
  it("moves focus to error summary on submit", async () => {
    const user = userEvent.setup();
    render(<ContactForm />);
 
    await user.click(screen.getByRole("button", { name: /submit/i }));
 
    const summary = screen.getByRole("alert");
    expect(summary).toHaveFocus();
  });
 
  it("announces dynamic field additions", async () => {
    const user = userEvent.setup();
    render(<DynamicFieldList fields={["555-0100"]} onAdd={() => {}} onRemove={() => {}} />);
 
    await user.click(
      screen.getByRole("button", { name: /add phone/i })
    );
 
    expect(screen.getByText(/2 items total/i)).toBeInTheDocument();
  });
});

Die wichtigsten Erkenntnisse

Barrierefreie Formulare gehen weit über das Hinzufügen von <label>-Elementen hinaus. Formulare in der Produktion brauchen Fehlerzuordnungen über aria-describedby, Ansagen über Live-Regionen für dynamische Änderungen, Fokusmanagement, das Nutzer durch Validierungsfehler führt, und mehrstufige Navigation, die orientiert statt desorientiert. Jede Formularinteraktion, die ein sehender Nutzer visuell wahrnimmt, muss ein programmatisches Äquivalent für assistive Technologien haben.

Testen Sie vor dem Ausliefern mindestens einmal mit einem Screenreader. VoiceOver (macOS), NVDA (Windows) oder JAWS decken Probleme auf, die kein Linting-Tool finden kann. Die fünf Minuten, die Sie damit verbringen, mit einem Screenreader durch Ihr Formular zu tabben, ersparen Nutzern, die täglich darauf angewiesen sind, stundenlange Frustration.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX