Saltar al contenido

Creación de formularios accesibles: más allá del HTML semántico

Más allá de la semántica básica: patrones avanzados de accesibilidad para validación, errores, campos dinámicos y formularios de varios pasos.

6 min de lectura
Interfaz de formulario anotada que muestra etiquetas ARIA, asociaciones de errores e indicadores de gestión del foco

El HTML semántico te da gratis el 60% de la accesibilidad de un formulario. Los elementos <label>, <fieldset> e <input> hacen un trabajo pesado que los atributos ARIA jamás podrán reemplazar por completo. Pero los formularios en producción necesitan mensajes de validación, campos dinámicos, secciones condicionales y navegación de varios pasos que el HTML semántico por sí solo no puede manejar.

El 40% restante requiere ingeniería deliberada: atributos ARIA correctos, gestión del foco, anuncios en regiones dinámicas (live regions) y patrones de navegación por teclado que la mayoría de las bibliotecas de componentes implementan mal.

La base: etiquetas y asociaciones

Todo input necesita un nombre accesible. El elemento <label> lo proporciona, pero la asociación debe ser explícita: la proximidad por sí sola no funciona para los lectores de pantalla.

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>

El atributo aria-describedby vincula información complementaria al input. Un lector de pantalla anuncia: "Email address, edit, required. We'll use this for account recovery only." El texto de ayuda aporta contexto sin saturar la etiqueta.

Manejo de errores que los lectores de pantalla entienden

Los errores de validación son donde la mayoría de los formularios fallan en accesibilidad. Los usuarios videntes ven texto rojo; los usuarios de lectores de pantalla necesitan asociaciones y anuncios programáticos.

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

El role="alert" en el mensaje de error crea una región dinámica (live region) que los lectores de pantalla anuncian de inmediato cuando aparece. El atributo aria-invalid="true" le indica a la tecnología de asistencia que el campo necesita atención.

Gestión del foco en la validación

Cuando un formulario tiene varios errores, los usuarios necesitan un camino claro para corregirlos. La gestión del foco los guía a través de cada error de manera sistemática.

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

El resumen de errores enlaza directamente con cada campo problemático. Al hacer clic en un enlace, el foco se mueve al input correspondiente, dando a los usuarios un camino claro a través de todos los errores.

Campos dinámicos y anuncios en vivo

Los formularios con campos para agregar/eliminar, secciones condicionales o estados de carga necesitan regiones dinámicas (live regions) para anunciar los cambios que los usuarios videntes perciben visualmente.

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

La región aria-live="polite" anuncia los cambios sin interrumpir el contexto actual del usuario. Usar "assertive" interrumpiría de inmediato: apropiado para errores, pero demasiado agresivo para actualizaciones rutinarias.

Los formularios de varios pasos necesitan una indicación clara del progreso, pasos navegables por teclado y una jerarquía de encabezados adecuada dentro de cada paso.

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

Enfocar el encabezado del paso al navegar orienta a los usuarios de lectores de pantalla dentro del formulario. Sin esto, llegan al contenido del nuevo paso sin contexto sobre lo que cambió.

Pruebas de accesibilidad en la práctica

Las herramientas automatizadas detectan alrededor del 30% de los problemas de accesibilidad. El resto requiere pruebas manuales con tecnología de asistencia real.

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

Conclusiones clave

Los formularios accesibles van mucho más allá de agregar elementos <label>. Los formularios en producción necesitan asociaciones de errores mediante aria-describedby, anuncios en regiones dinámicas para los cambios dinámicos, gestión del foco que guíe a los usuarios a través de los errores de validación y navegación de varios pasos que oriente en lugar de desorientar. Toda interacción de un formulario que un usuario vidente percibe visualmente debe tener un equivalente programático para la tecnología de asistencia.

Prueba con un lector de pantalla al menos una vez antes de publicar. VoiceOver (macOS), NVDA (Windows) o JAWS revelarán problemas que ninguna herramienta de linting puede detectar. Los cinco minutos que dediques a recorrer tu formulario con el tabulador y un lector de pantalla evitarán horas de frustración a los usuarios que dependen de ellos a diario.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX