Valida la entrada después de la interacción

El problema

Mostrar errores de validación en el momento en que un usuario se enfoca en un campo y comienza a escribir es prematuro y distractor. Por ejemplo, cuando un usuario escribe una dirección de correo electrónico (p.ej., "usuario@gm") o una contraseña con requisitos complejos, el campo no es válido técnicamente hasta que se completa. El diseño estándar de :invalid hace que aparezca un estado de error de inmediato, lo que frustra al usuario.

La solución

La pseudoclase :user-invalid te permite aplazar el estado de error hasta que el usuario se haya "comprometido" con un valor (desenfocando el campo) o haya intentado enviar el formulario. Esto garantiza que los comentarios de validación se proporcionen solo después de que el usuario haya terminado de interactuar con el campo.

Estrategia de implementación

  1. Restricción de HTML: SÍ usa atributos estándar de HTML5, como type="email", pattern y required, para activar la lógica de validación integrada del navegador.
  2. Comentarios visuales: SÍ usa :user-invalid para aplicar el estilo de error solo después de la interacción.
  3. Refuerzo positivo: De manera opcional, SÍ usa :user-valid para dar un indicador verde de "éxito" una vez que se cumplan los requisitos.
  4. Recuperación correcta: En cuanto el usuario corrige la entrada a un formato válido, :user-invalid deja de coincidir y quita el estado de error de inmediato.

Guía de implementación

Caso de uso 1: Validación de correo electrónico

OBLIGATORIO: Usa atributos HTML5 estándar para los campos de correo electrónico. El mensaje de error se oculta de forma predeterminada y solo se muestra cuando el navegador determina que el usuario dejó el campo en un estado no válido.

<form>
  <div class="field">
    <label for="email">Email Address</label>
    <!-- MANDATORY: Place format hints above the input so autocomplete popovers don't cover them during editing -->
    <span id="email-hint" class="hint">Format: you@example.com</span>
    <!-- DO: Use standard HTML validation attributes like type="email" and required -->
    <input
      type="email"
      id="email"
      name="email"
      required
      autocomplete="email"
      aria-describedby="email-hint"
      aria-errormessage="email-error"
    >
    <div id="email-error" class="error-msg">
      <span aria-hidden="true">❌</span> Please enter a valid email address.
    </div>
  </div>
</form>
.hint {
  display: block;
  color: #5f6368;
  font-size: 0.85rem;
  margin-bottom: 0.25rem;
}

.error-msg {
  display: none;
  color: #d93025;
  font-size: 0.875rem;
  margin-top: 0.25rem;
}

/*
  DO: Only show error styles after user interaction.
  Use multiple indicators (border/background shift + icon/text) to avoid color-only states.
*/
input:user-invalid {
  border-color: #d93025;
  background-color: #fce8e6;
}

/* DO: Reveal the error message using the adjacent sibling selector */
input:user-invalid + .error-msg {
  display: block;
}

/* DO: Provide a clear success indication on :user-valid */
input:user-valid {
  border-color: #188038;
}

Caso de uso 2: Complejidad de la contraseña

OBLIGATORIO: Define la regla de complejidad con un patrón de búsqueda anticipada de regex en el atributo pattern. La lista de reglas se muestra arriba de la entrada para guiar al usuario y se destaca si hay un error.

<form>
  <div class="field">
    <label for="password">New Password</label>
    <!-- MANDATORY: Place hints and rules above the input so mobile keyboards do not obscure them -->
    <ul id="password-rules" class="rules-list">
      <li>At least 8 characters</li>
      <li>One uppercase letter</li>
      <li>One number</li>
      <li>One special character</li>
    </ul>
    <!-- DO: Use pattern and minlength for complex password validation
         DO: Match all constraints with lookaheads via pattern attribute
     -->
    <input
      type="password"
      id="password"
      autocomplete="new-password"
      required
      pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[\W_]).{8,}"
      minlength="8"
      aria-describedby="password-rules"
    >
  </div>
</form>
/* DO: State the default styling as neutral */
.rules-list { 
  color: #5f6368; 
  margin-bottom: 0.5rem;
}

/* DO: Show invalid state (After interaction): Error */
input:user-invalid {
  border-color: #d93025;
  background-color: #fce8e6;
}

/* DO: Highlight rules list when error is shown using the modern :has() selector */
.field:has(input:user-invalid) .rules-list {
  color: #d93025;
  font-weight: 600;
}

/* DO: Add success indications for :user-valid state */
input:user-valid {
  border-color: #188038;
}

/* DO: Hide rules once satisfied */
.field:has(input:user-valid) .rules-list {
  display: none;
}

Resguardo y compatibilidad con navegadores

Resguardos y compatibilidad de navegadores para :user-valid y :user-invalid

CSS para resguardo

input:user-invalid,
input.user-invalid-fallback {
  border-color: #d93025;
  background-color: #fce8e6;
}

input:user-invalid + .error-msg,
input.user-invalid-fallback + .error-msg {
  display: block;
}

Resguardo de JavaScript

Usa una utilidad reutilizable que haga un seguimiento del estado de interacción con un WeakMap. Esto evita contaminar el DOM con clases o atributos de datos "sucios".

const UserInvalidFallback = (() => {
  const dirtyState = new WeakMap();

  const updateState = (input) => {
    const isValid = input.checkValidity();

    // Update both visual and ARIA state
    input.classList.toggle('user-invalid-fallback', !isValid);
    input.classList.toggle('user-valid-fallback', isValid);

    if (!isValid) {
      input.setAttribute('aria-invalid', 'true');
    } else {
      input.removeAttribute('aria-invalid');
    }
  };

  const handleEvent = (event) => {
    const input = event.target;

    if (event.type === 'reset') {
      const controls = input.elements || [];
      for (const control of controls) {
        dirtyState.delete(control);
        control.classList.remove('user-invalid-fallback');
        control.classList.remove('user-valid-fallback');
        control.removeAttribute('aria-invalid');
      }
      return;
    }

    if (!input.checkValidity) return;

    if (event.type === 'input' || event.type === 'change') {
      const state = dirtyState.get(input) || { hasInteracted: false, hasBlurred: false };
      state.hasInteracted = true;
      dirtyState.set(input, state);
      if (state.hasBlurred) {
        updateState(input);
      }
    } else if (event.type === 'blur') {
      const state = dirtyState.get(input) || { hasInteracted: false, hasBlurred: false };
      state.hasBlurred = true;
      dirtyState.set(input, state);
      if (state.hasInteracted) {
        updateState(input);
      }
    }
  };

  const init = (root = document) => {
    if (CSS.supports('selector(:user-invalid)')) return;

    root.addEventListener('blur', handleEvent, true); // Capture phase
    root.addEventListener('input', handleEvent);
    root.addEventListener('change', handleEvent);
    root.addEventListener('reset', handleEvent, true); // Capture resets
  };

  return { init };
})();

// Initialize for a specific form
const form = document.querySelector('#demo-form');
UserInvalidFallback.init(form);

Otros factores que considerar

  1. Accesibilidad:
    • OBLIGATORIO: Usa aria-describedby para vincular la lista de reglas a la entrada.
    • NO ocultes las listas de reglas por completo hasta que la entrada sea válida; los usuarios necesitan saber qué escribir.
  2. Límites de atributos de patrón: OBLIGATORIO: El atributo pattern realiza una coincidencia completa (^...$ implícita). Asegúrate de que la regex de tu contraseña tenga en cuenta toda la cadena.
  3. Rigurosidad de la validación: TEN EN CUENTA que la validación predeterminada de type="email" del navegador es bastante permisiva (p.ej., es posible que se apruebe user@localserver). Si necesitas una validación más estricta, es posible que debas usar una biblioteca de validación más sólida o una función de validación personalizada junto con type="email".
  4. Administración del enfoque: OBLIGATORIO: Si un usuario envía el formulario con un campo no válido, el navegador enfocará automáticamente el primer campo no válido. Tus estilos :user-invalid se aplicarán de inmediato porque un intento de envío se considera una interacción.
  5. Experiencia de ARIA coherente: El elemento :user-invalid nativo no se sincroniza automáticamente con los atributos de ARIA. Agrega el siguiente código JavaScript para mantener aria-invalid sincronizado con el estado visual:
// Sync aria-invalid with the CSS :user-invalid state
const syncAria = (el) => {
  el.setAttribute?.('aria-invalid', el.matches(':user-invalid') ? 'true' : 'false');
};

// Update on blur (to show error) and input (to clear it)
document.addEventListener('blur', (e) => syncAria(e.target), true);
document.addEventListener('input', (e) => {
  if (e.target.hasAttribute('aria-invalid')) syncAria(e.target);
});