Interacción con el menú de selección

El problema

En el caso de los menús desplegables obligatorios (p.ej., "Elige un país"), la validación estándar marca el campo como no válido de inmediato si la opción predeterminada tiene un valor vacío. Esto puede crear ruido visual. Queremos mostrar el error solo si el usuario abre el menú y lo cierra sin elegir una opción, o si intenta enviar el formulario.

La solución

La pseudoclase :user-invalid funciona a la perfección con los elementos <select>. Respeta el flujo de interacción del usuario: simplemente cargar la página o enfocar o desenfocar sin realizar un cambio no cuenta como una interacción, por lo que el campo permanece neutral hasta que el usuario intenta realizar una selección de forma activa.

Estrategia de implementación

  1. Restricción de HTML: Usa un <select> con required. La primera opción debe tener value="" y, de ser posible, estar inhabilitada o estar oculta para forzar una elección válida.
  2. Comentarios visuales: Usa :user-invalid para aplicar diseño al borde del cuadro de selección.
  3. Momento: El navegador considera que el campo "interactuó" si el usuario cambia el valor (incluso de vuelta al estado no válido predeterminado) antes de desenfocar el control o cuando se envía el formulario.

Guía de implementación

1. Estructura HTML

La opción "marcador de posición" es clave aquí.

<form>
  <div class="field">
    <label for="country">Country</label>
    <select
      id="country"
      name="country"
      required
      aria-errormessage="country-error"
    >
      <option value="" disabled selected>Select a country...</option>
      <option value="us">United States</option>
      <option value="ca">Canada</option>
      <option value="uk">United Kingdom</option>
    </select>
    <div id="country-error" class="error-msg">
      Please select a country.
    </div>
  </div>
</form>

2. CSS

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

/*
  Only show error after the user visits the select menu.
*/
select:user-invalid {
  border-color: #d93025;
  background-color: #fce8e6;
}

select:user-invalid + .error-msg {
  display: block;
}

select:user-valid {
  border-color: #188038;
}

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. Comportamiento en dispositivos móviles: En los dispositivos móviles, el "desenfoque" puede ocurrir de manera diferente según el selector del SO. Se recomienda realizar pruebas en dispositivos reales.
  2. Accesibilidad: El :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);
});