Guía detallada

Form Labels Implementation Guide

Ver contenido

Guía de Implementación de Etiquetas: Referencia Técnica Completa

Introducción

Esta guía completa cubre técnicas avanzadas de etiquetado de formularios para cumplimiento WCAG. Desde asociaciones básicas de etiquetas hasta patrones de formularios complejos, aprenderás las implementaciones específicas requeridas para formularios accesibles en todos los casos de uso.

Métodos de Etiquetado

1. El Elemento Label (Preferido)

El elemento <label> es el método principal para etiquetar controles de formulario:

<!-- Método 1: Asociación explícita con for/id -->
<label for="usuario">Nombre de Usuario</label>
<input type="text" id="usuario" name="usuario">

<!-- Método 2: Asociación implícita (envolviendo) -->
<label>
  Nombre de Usuario
  <input type="text" name="usuario">
</label>

Cuándo usar cada método:

Método Pros Contras Mejor Para
Explícito (for/id) Diseño más flexible Requiere IDs únicos Mayoría de formularios
Implícito (envolver) No necesita ID Opciones de estilo limitadas Formularios simples

2. Etiquetado ARIA

Para casos donde <label> no es adecuado:

<!-- aria-label: Proporciona etiqueta invisible -->
<input type="search" aria-label="Buscar productos">

<!-- aria-labelledby: Referencia texto visible -->
<h2 id="encabezado-contacto">Información de Contacto</h2>
<input type="text" aria-labelledby="encabezado-contacto etiqueta-nombre">
<span id="etiqueta-nombre">Nombre Completo</span>

<!-- aria-describedby: Descripción adicional -->
<label for="password">Contraseña</label>
<input
  type="password"
  id="password"
  aria-describedby="requisitos-password"
>
<p id="requisitos-password">
  Debe tener al menos 8 caracteres con un número.
</p>

3. Prioridad de Etiquetado

Los lectores de pantalla usan este orden de prioridad para nombres accesibles:

1. aria-labelledby (mayor prioridad)
2. aria-label
3. elemento <label> (for/id o envolviendo)
4. atributo title (menor prioridad, evitar)

Patrones de Controles de Formulario

Inputs de Texto

<!-- Input de texto estándar -->
<div class="form-group">
  <label for="nombre-completo">
    Nombre Completo
    <span class="required" aria-hidden="true">*</span>
  </label>
  <input
    type="text"
    id="nombre-completo"
    name="nombre-completo"
    required
    aria-required="true"
    autocomplete="name"
  >
</div>

<!-- Input con texto de ayuda -->
<div class="form-group">
  <label for="email">Correo Electrónico</label>
  <input
    type="email"
    id="email"
    name="email"
    aria-describedby="email-ayuda"
    autocomplete="email"
  >
  <p id="email-ayuda" class="help-text">
    Nunca compartiremos tu correo con nadie.
  </p>
</div>

<!-- Input con error -->
<div class="form-group has-error">
  <label for="telefono">Número de Teléfono</label>
  <input
    type="tel"
    id="telefono"
    name="telefono"
    aria-invalid="true"
    aria-describedby="telefono-error"
  >
  <p id="telefono-error" class="error-text" role="alert">
    Por favor ingresa un número de teléfono válido.
  </p>
</div>

Selectores Desplegables

<!-- Select estándar -->
<div class="form-group">
  <label for="pais">País</label>
  <select id="pais" name="pais" autocomplete="country">
    <option value="">-- Selecciona un país --</option>
    <option value="mx">México</option>
    <option value="es">España</option>
    <option value="ar">Argentina</option>
  </select>
</div>

<!-- Opciones agrupadas -->
<div class="form-group">
  <label for="zona-horaria">Zona Horaria</label>
  <select id="zona-horaria" name="zona-horaria">
    <optgroup label="América">
      <option value="cdmx">Ciudad de México</option>
      <option value="bog">Bogotá</option>
    </optgroup>
    <optgroup label="Europa">
      <option value="mad">Madrid</option>
      <option value="lon">Londres</option>
    </optgroup>
  </select>
</div>

Checkboxes y Radio Buttons

<!-- Checkbox único -->
<div class="form-group">
  <input type="checkbox" id="newsletter" name="newsletter">
  <label for="newsletter">Suscribirme al boletín</label>
</div>

<!-- Grupo de checkboxes -->
<fieldset>
  <legend>Preferencias de Notificación</legend>
  <div class="checkbox-group">
    <input type="checkbox" id="notif-email" name="notificaciones" value="email">
    <label for="notif-email">Notificaciones por correo</label>
  </div>
  <div class="checkbox-group">
    <input type="checkbox" id="notif-sms" name="notificaciones" value="sms">
    <label for="notif-sms">Notificaciones por SMS</label>
  </div>
  <div class="checkbox-group">
    <input type="checkbox" id="notif-push" name="notificaciones" value="push">
    <label for="notif-push">Notificaciones push</label>
  </div>
</fieldset>

<!-- Grupo de radio buttons -->
<fieldset>
  <legend>Método de Envío</legend>
  <div class="radio-group">
    <input type="radio" id="estandar" name="envio" value="estandar">
    <label for="estandar">Estándar (5-7 días) - Gratis</label>
  </div>
  <div class="radio-group">
    <input type="radio" id="express" name="envio" value="express">
    <label for="express">Express (2-3 días) - $9.99</label>
  </div>
  <div class="radio-group">
    <input type="radio" id="urgente" name="envio" value="urgente">
    <label for="urgente">Urgente - $24.99</label>
  </div>
</fieldset>

Textareas

<div class="form-group">
  <label for="mensaje">Mensaje</label>
  <textarea
    id="mensaje"
    name="mensaje"
    rows="5"
    aria-describedby="mensaje-info"
  ></textarea>
  <p id="mensaje-info" class="help-text">
    Máximo 500 caracteres.
  </p>
</div>

Patrones de Formularios Complejos

Campos Agrupados con Fieldset

<!-- Campos de dirección agrupados -->
<fieldset>
  <legend>Dirección de Envío</legend>

  <div class="form-group">
    <label for="calle">Dirección</label>
    <input type="text" id="calle" name="calle" autocomplete="street-address">
  </div>

  <div class="form-row">
    <div class="form-group">
      <label for="ciudad">Ciudad</label>
      <input type="text" id="ciudad" name="ciudad" autocomplete="address-level2">
    </div>
    <div class="form-group">
      <label for="estado">Estado/Provincia</label>
      <select id="estado" name="estado" autocomplete="address-level1">
        <option value="">Seleccionar...</option>
        <!-- opciones de estado -->
      </select>
    </div>
    <div class="form-group">
      <label for="cp">Código Postal</label>
      <input type="text" id="cp" name="cp" autocomplete="postal-code">
    </div>
  </div>
</fieldset>

Inputs de Fecha

<!-- Input de fecha único -->
<div class="form-group">
  <label for="fecha-nacimiento">Fecha de Nacimiento</label>
  <input
    type="date"
    id="fecha-nacimiento"
    name="fecha-nacimiento"
    autocomplete="bday"
  >
</div>

<!-- Fecha multi-campo (navegadores antiguos) -->
<fieldset>
  <legend>Fecha de Nacimiento</legend>
  <div class="date-group">
    <div class="form-group">
      <label for="nac-mes">Mes</label>
      <select id="nac-mes" name="nac-mes" autocomplete="bday-month">
        <option value="">--</option>
        <option value="1">Enero</option>
        <!-- ... -->
      </select>
    </div>
    <div class="form-group">
      <label for="nac-dia">Día</label>
      <input
        type="number"
        id="nac-dia"
        name="nac-dia"
        min="1"
        max="31"
        autocomplete="bday-day"
      >
    </div>
    <div class="form-group">
      <label for="nac-anio">Año</label>
      <input
        type="number"
        id="nac-anio"
        name="nac-anio"
        min="1900"
        max="2024"
        autocomplete="bday-year"
      >
    </div>
  </div>
</fieldset>

Campos de Contraseña

<div class="form-group">
  <label for="nueva-password">Nueva Contraseña</label>
  <div class="password-wrapper">
    <input
      type="password"
      id="nueva-password"
      name="nueva-password"
      aria-describedby="requisitos-password"
      autocomplete="new-password"
      minlength="8"
    >
    <button
      type="button"
      aria-label="Mostrar contraseña"
      aria-pressed="false"
      onclick="togglePasswordVisibility(this)"
    >
      <svg aria-hidden="true"><!-- ícono de ojo --></svg>
    </button>
  </div>
  <ul id="requisitos-password" class="requirements-list">
    <li>Al menos 8 caracteres</li>
    <li>Al menos una letra mayúscula</li>
    <li>Al menos un número</li>
    <li>Al menos un carácter especial</li>
  </ul>
</div>

Implementaciones Específicas por Framework

React

// Componente de formulario accesible
function ContactForm() {
  const [formData, setFormData] = useState({});
  const [errors, setErrors] = useState({});

  return (
    <form onSubmit={handleSubmit}>
      <div className="form-group">
        <label htmlFor="nombre">Nombre</label>
        <input
          type="text"
          id="nombre"
          name="nombre"
          value={formData.nombre || ''}
          onChange={handleChange}
          aria-invalid={errors.nombre ? 'true' : 'false'}
          aria-describedby={errors.nombre ? 'nombre-error' : undefined}
        />
        {errors.nombre && (
          <span id="nombre-error" className="error" role="alert">
            {errors.nombre}
          </span>
        )}
      </div>

      {/* Grupo de radio con legend */}
      <fieldset>
        <legend>Método de Contacto Preferido</legend>
        {['email', 'telefono', 'sms'].map((metodo) => (
          <div key={metodo} className="radio-option">
            <input
              type="radio"
              id={`contacto-${metodo}`}
              name="metodoContacto"
              value={metodo}
              checked={formData.metodoContacto === metodo}
              onChange={handleChange}
            />
            <label htmlFor={`contacto-${metodo}`}>
              {metodo.charAt(0).toUpperCase() + metodo.slice(1)}
            </label>
          </div>
        ))}
      </fieldset>

      <button type="submit">Enviar</button>
    </form>
  );
}

Vue.js

<template>
  <form @submit.prevent="handleSubmit">
    <div class="form-group">
      <label :for="'email-' + uid">Correo Electrónico</label>
      <input
        :id="'email-' + uid"
        v-model="form.email"
        type="email"
        :aria-invalid="errors.email ? 'true' : 'false'"
        :aria-describedby="errors.email ? 'email-error-' + uid : undefined"
      />
      <span
        v-if="errors.email"
        :id="'email-error-' + uid"
        class="error"
        role="alert"
      >
        {{ errors.email }}
      </span>
    </div>

    <fieldset>
      <legend>Tipo de Suscripción</legend>
      <div
        v-for="opcion in opcionesSuscripcion"
        :key="opcion.value"
        class="radio-option"
      >
        <input
          :id="'sub-' + opcion.value + '-' + uid"
          v-model="form.suscripcion"
          type="radio"
          name="suscripcion"
          :value="opcion.value"
        />
        <label :for="'sub-' + opcion.value + '-' + uid">
          {{ opcion.label }}
        </label>
      </div>
    </fieldset>

    <button type="submit">Suscribirse</button>
  </form>
</template>

<script setup>
import { ref, computed } from 'vue'

const uid = computed(() => Math.random().toString(36).substr(2, 9))
const form = ref({ email: '', suscripcion: '' })
const errors = ref({})
</script>

Testing de Etiquetas de Formulario

Testing Automatizado

// Cypress con axe-core
describe('Accesibilidad de Formulario', () => {
  beforeEach(() => {
    cy.visit('/contacto');
    cy.injectAxe();
  });

  it('no debe tener violaciones de etiquetas', () => {
    cy.checkA11y(null, {
      rules: {
        'label': { enabled: true },
        'label-title-only': { enabled: true },
        'form-field-multiple-labels': { enabled: true }
      }
    });
  });

  it('debe asociar etiquetas con inputs', () => {
    cy.get('label[for="email"]').should('exist');
    cy.get('#email').should('exist');
    cy.get('label[for="email"]').click();
    cy.get('#email').should('be.focused');
  });
});

Comandos de Testing con Lectores de Pantalla

Probando asociaciones de etiquetas:

VoiceOver (macOS):
- Tab al input → Debe anunciar texto de etiqueta
- VO + F → Leer controles de formulario
- VO + Command + J → Saltar al siguiente control

NVDA (Windows):
- F → Saltar al siguiente campo de formulario
- NVDA + F7 → Lista de elementos (seleccionar Campos de formulario)
- Tab → Navegar y escuchar etiquetas

JAWS (Windows):
- F → Saltar al siguiente campo de formulario
- Insert + F5 → Lista de controles de formulario
- Tab → Navegar con anuncio de etiqueta

Checklist de Testing Manual

## Auditoría de Etiquetas de Formulario

### Para Cada Input:
- [ ] Tiene etiqueta asociada (verificar en panel de accesibilidad DevTools)
- [ ] La etiqueta es descriptiva y clara
- [ ] Click en etiqueta enfoca el input
- [ ] Campos requeridos están indicados
- [ ] Mensajes de error están asociados con inputs

### Para Grupos de Campos:
- [ ] Campos relacionados agrupados con <fieldset>
- [ ] Grupos tienen elemento <legend>
- [ ] Grupos de radio/checkbox están correctamente etiquetados

### Para Etiquetas Ocultas:
- [ ] Usa clase sr-only (no display:none)
- [ ] Aún es anunciada por lectores de pantalla
- [ ] Contexto proporcionado via aria-label o aria-labelledby

Manejo de Errores y Validación

Mensajes de Error Accesibles

<div class="form-group" :class="{ 'has-error': hasError }">
  <label for="email">Correo Electrónico</label>
  <input
    type="email"
    id="email"
    name="email"
    :aria-invalid="hasError"
    :aria-describedby="hasError ? 'email-error' : null"
  >
  <p
    v-if="hasError"
    id="email-error"
    class="error-message"
    role="alert"
  >
    Por favor ingresa un correo electrónico válido.
  </p>
</div>

Anuncios de Validación en Vivo

// Anunciar errores de validación a lectores de pantalla
function announceError(fieldId, message) {
  const liveRegion = document.getElementById('live-announcements');
  liveRegion.textContent = `Error: ${message}`;

  // Limpiar después del anuncio
  setTimeout(() => {
    liveRegion.textContent = '';
  }, 1000);
}
<div
  id="live-announcements"
  aria-live="polite"
  aria-atomic="true"
  class="sr-only"
></div>

Resumen de Mejores Prácticas

Hacer

  1. Siempre usar elementos <label> cuando sea posible
  2. Usar IDs únicos para cada control de formulario
  3. Agrupar campos relacionados con <fieldset> y <legend>
  4. Proporcionar texto de etiqueta claro y descriptivo
  5. Asociar mensajes de error con sus inputs
  6. Probar con lectores de pantalla reales

No Hacer

  1. No usar placeholders como etiquetas
  2. No ocultar etiquetas con display:none
  3. No confiar solo en proximidad visual
  4. No usar IDs duplicados
  5. No olvidar indicadores de campos requeridos
  6. No saltarse fieldset/legend para grupos de radio/checkbox

Artículos Relacionados


Referencias

  1. W3C - WCAG 2.2 SC 1.3.1 Información y Relaciones
  2. W3C - WCAG 2.2 SC 3.3.2 Etiquetas o Instrucciones
  3. W3C - Tutorial de Formularios WAI
  4. WebAIM - Creando Formularios Accesibles
  5. W3C - ARIA en HTML

Artículos relacionados

Versión relacionada

Introducción

Form Labels Explained

Las etiquetas de formulario son descripciones de texto que identifican el propósito de los campos de formulario

Hub de categoría

Hub

Accessible Components Hub

Construir componentes web accesibles requiere entender tanto los aspectos visuales como programáticos de las interfaces de usuario