CodeForge

Angular 21 de Cero a Experto / Formularios

Estados y errores accesibles: el formulario que se oye

Teoría22 min20 XP

Validar los datos es la mitad del trabajo; la otra mitad es COMUNICAR al usuario qué está mal, cuándo y cómo. Un error que aparece demasiado pronto molesta; uno que no aparece frustra; uno que un lector de pantalla no anuncia excluye. Hoy dominas los estados del formulario (touched, dirty), muestras errores en el momento justo, y haces todo accesible —un formulario que cualquiera puede completar, también quien no ve la pantalla—.

Cuándo mostrar el error

Los estados de un control

Cada FormControl lleva la cuenta de su historia:

Mostrar el error en el momento justo

errores.component.ts
@Component({
selector: 'app-campo-nombre',
imports: [ReactiveFormsModule],
template: `
  <form [formGroup]="form">
    <label for="nombre">Nombre del gasto</label>
    <input id="nombre" formControlName="nombre" />

    @if (nombre.invalid && nombre.touched) {
      @if (nombre.errors?.['required']) {
        <p class="error">El nombre es obligatorio.</p>
      }
      @if (nombre.errors?.['minlength']) {
        <p class="error">Mínimo 2 caracteres.</p>
      }
    }
  </form>
`,
})
export class CampoNombreComponent {
private fb = inject(FormBuilder);
form = this.fb.group({
  nombre: ['', [Validators.required, Validators.minLength(2)]],
});
get nombre() { return this.form.controls.nombre; }
}

El @if (nombre.invalid && nombre.touched) es la clave: el bloque de errores solo aparece cuando el campo es inválido Y el usuario ya salió de él. Dentro, cada @if sobre errors?.['...'] muestra el mensaje específico de cada validador.

Hacerlo accesible: el formulario que se oye

Mostrar el error en pantalla no basta: quien usa un lector de pantalla necesita que el error se ANUNCIE y que el input diga que es inválido. Cuatro atributos ARIA lo logran:

accesible.component.ts
template: `
<label for="valor">Monto</label>
<input
  id="valor"
  type="number"
  formControlName="valor"
  [attr.aria-invalid]="valor.invalid && valor.touched"
  [attr.aria-describedby]="valor.invalid && valor.touched ? 'valor-error' : null" />

@if (valor.invalid && valor.touched) {
  <p id="valor-error" class="error" role="alert">
    El monto debe ser mayor que cero.
  </p>
}
`,

Enfocar el primer campo con error al enviar

Al intentar enviar un formulario inválido, la mejor experiencia —y la más accesible— es marcar todos los campos como touched (para que se muestren los errores) y llevar el foco al primer campo con problema:

enviar.ts
guardar() {
if (this.form.invalid) {
  this.form.markAllAsTouched();   // revela TODOS los errores de una vez
  // (opcional) enfocar el primer control inválido para el usuario de teclado
  return;
}
// ...guardar...
}

markAllAsTouched() es esencial: si el usuario pulsa "Guardar" sin haber tocado los campos, los errores no se mostrarían (siguen untouched). Marcarlos todos revela el panorama completo.

¿Por qué se muestra el error solo cuando el control es `invalid && touched`, y no solo `invalid`?

Mini-reto

Escribe el template accesible de un campo email: 1) <label for> asociado; 2) el input con aria-invalid y aria-describedby condicionales; 3) un @if (email.invalid && email.touched) con mensajes distintos para required y email (formato), el contenedor con id y role="alert". Añade el markAllAsTouched() en el método de envío.

Qué sigue

Tus formularios validan y comunican con claridad y accesibilidad. Falta un caso avanzado: los formularios DINÁMICOS, donde el número de campos cambia —agregar varios gastos de una vez, una lista de ítems que crece—. La próxima lección es FormArray, la herramienta para formularios que crecen y se encogen.