El problema
La validación estándar de HTML5 proporciona comentarios visuales (a través de :invalid o :user-invalid), pero no se sincroniza automáticamente con los atributos de accesibilidad, como aria-invalid.
Si usas el diseño estándar de :invalid, es posible que los lectores de pantalla anuncien "Entrada no válida" en el momento en que un usuario presione la tecla Tab para ingresar a un campo obligatorio que esté vacío. Esto crea una experiencia disruptiva para los usuarios que usan tecnologías de accesibilidad, ya que el error se anuncia antes de que se produzca la interacción.
La solución
Queremos que el estado programático (aria-invalid="true") se aplique en el mismo momento en que se aplica el estado visual (:user-invalid). Dado que :user-invalid se basa en el indicador interno "user-interacted" del navegador, podemos usar JavaScript para verificar que este selector coincida durante los eventos de interacción estándar.
Consulta MDN aria-invalid para obtener más detalles.
Estrategia de implementación
- Capa visual: Usa CSS
:user-invalidpara mostrar bordes o íconos. - Capa de accesibilidad: Usa
aria-invalidyaria-errormessagepara comunicar el estado a la tecnología de accesibilidad (AT). - Crea un puente entre la capa visual y la de accesibilidad: Crea una utilidad de JavaScript ligera que escuche los eventos
bluryinput, verifique si el elemento coincide con:user-invalidy actualice los atributos ARIA según corresponda.
Guía de implementación
1. Estructura HTML
Vincula tu entrada a su mensaje de error con aria-errormessage (o aria-describedby para obtener una compatibilidad más amplia).
<form>
<div class="field">
<label for="email">Email</label>
<input
type="email"
id="email"
required
aria-errormessage="email-error"
>
<span id="email-error" class="error-msg">
Please enter a valid email address.
</span>
</div>
</form>
2. CSS
Controla la visibilidad del mensaje de error con la pseudoclase nativa :user-invalid.
.error-msg {
display: none;
color: #d93025;
}
/* Show error message when input is user-invalid */
input:user-invalid ~ .error-msg {
display: block;
}
/* Optional: Visual cues on the input itself */
input:user-invalid {
border-color: #d93025;
}
3. JavaScript
Como no hay un evento "UserInvalidChanged", conéctate a los eventos de formulario estándar para verificar el estado.
const updateAriaState = (event) => {
const input = event.target;
if (!input.matches?.('input, textarea, select')) return;
// Check if the browser currently considers this input "user-invalid"
const isUserInvalid = input.matches(':user-invalid');
if (isUserInvalid) {
input.setAttribute('aria-invalid', 'true');
} else {
input.removeAttribute('aria-invalid');
}
};
// Listen on the document to handle dynamically added fields.
// 'blur' and 'focus' do not bubble, so we must use the capture phase (true).
document.addEventListener('blur', updateAriaState, true);
document.addEventListener('focus', updateAriaState, true);
// Also update on input if we've already shown the error,
// so the error clears immediately when fixed.
document.addEventListener('input', (event) => {
const input = event.target;
if (!input.matches?.('input, textarea, select')) return;
const hasAriaInvalid = input.hasAttribute('aria-invalid');
const ariaInvalid = input.getAttribute('aria-invalid');
if (hasAriaInvalid && ariaInvalid === 'true') {
updateAriaState(event);
}
});
Resguardo y compatibilidad con navegadores
Detección de funciones
Puedes verificar la compatibilidad en CSS y JavaScript.
Verificación de JavaScript:
if (!CSS.supports('selector(:user-invalid)')) {
// Fallback logic here
}
CSS para resguardo
Para garantizar que la lógica de resguardo sea visualmente indistinguible del comportamiento nativo, debes aplicar los estilos de error tanto a la pseudoclase como a la clase de resguardo.
/* Apply error styles to both native selector and fallback class */
input:user-invalid,
input.user-invalid-fallback {
border-color: #d93025;
background-color: #fce8e6;
}
/* Show error message for both cases */
input:user-invalid ~ .error-msg,
input.user-invalid-fallback ~ .error-msg {
display: block;
}
Lógica de resguardo
Si falta :user-invalid, haz un seguimiento manual del estado de interacción con un WeakMap.
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' && input.matches?.('form')) {
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.matches?.('input, textarea, select')) 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 = () => {
if (CSS.supports('selector(:user-invalid)')) return;
document.addEventListener('blur', handleEvent, true); // Capture phase required
document.addEventListener('input', handleEvent, true);
document.addEventListener('change', handleEvent, true);
document.addEventListener('reset', handleEvent, true); // Capture resets
};
return { init };
})();
// Initialize globally
UserInvalidFallback.init();
Otros factores que considerar
aria-livevs.aria-errormessage:aria-errormessageconecta la entrada al texto, pero es posible que los lectores de pantalla no lo anuncien inmediatamente después de que aparezca (solo cuando se enfoca la entrada).- Si necesitas un anuncio inmediato cuando aparece el error (p.ej., cuando se pierde el foco), considera agregar
role="alert"oaria-live="polite"al contenedor del mensaje de error, pero realiza pruebas exhaustivas para evitar un "anuncio doble" cuando el usuario se enfoca en el campo para corregirlo.
Internacionalización:
- Asegúrate de que el contenido de texto de tu mensaje de error (
#email-error) esté traducido. La lógica sigue siendo la misma.
- Asegúrate de que el contenido de texto de tu mensaje de error (