El problema
Marcar los campos obligatorios con un estado de error inmediatamente después de que se carga la página puede ser confuso. Idealmente, un campo obligatorio solo debería verse como "no válido" si el usuario intentó completarlo y no lo logró.
La solución
La pseudoclase :user-invalid resuelve esto a la perfección. En el caso de un campo obligatorio, no coincidirá cuando se cargue la página. Solo coincidirá si se cumplen las siguientes condiciones:
- El usuario interactúa con el campo (p.ej., escribe un carácter y lo borra) y, luego, lo abandona (desenfoca), dejándolo vacío.
- El usuario intenta enviar el formulario mientras el campo está vacío.
Estrategia de implementación
- Restricción de HTML: Agrega el atributo
requireda tus entradas. - Comentarios visuales: Usa
:user-invalidpara aplicar un estilo rojo al borde y mostrar un texto de ayuda "Obligatorio". - Sincronización: Confía en la sincronización nativa del navegador para obtener comentarios visuales. Ya no necesitas controladores
onBlurpara agregar una clasetouched, aunque todavía se requiere algo de JavaScript para sincronizar los atributos de ARIA (consulta más abajo).
Guía de implementación
1. Estructura HTML
<form id="feedback-form">
<div class="field">
<label for="full-name">Full Name</label>
<input
type="text"
id="full-name"
name="full-name"
required
aria-errormessage="name-error"
>
<!-- MANDATORY: Include an icon or distinct non-color indicator alongside error text -->
<div id="name-error" class="error-msg">
<span aria-hidden="true">❌</span> This field is required.
</div>
</div>
</form>
2. CSS
.error-msg {
display: none;
color: #d93025;
font-size: 0.875rem;
margin-top: 0.25rem;
}
/*
Only highlight empty required fields AFTER the user visits them.
MANDATORY: Provide multiple indicators (border shift + helper text/icon) to avoid color-only state communication.
*/
input:user-invalid {
border-color: #d93025;
background-color: #fce8e6;
}
input:user-invalid + .error-msg {
display: block;
}
/* Optional: Subtle indicator for required fields that are valid */
input:required:user-valid {
border-color: #188038;
border-width: 2px;
}
3. Sincronización de estado de JavaScript
OBLIGATORIO: Debido a que :user-invalid es un estado visual, DEBES proporcionar un puente de JavaScript para sincronizar aria-invalid="true" de forma dinámica para las tecnologías de accesibilidad cuando un usuario desenfoca un campo no válido o intenta enviarlo.
const form = document.getElementById('feedback-form');
const syncAriaInvalid = (input) => {
if (!input.checkValidity()) {
input.setAttribute('aria-invalid', 'true');
} else {
input.removeAttribute('aria-invalid');
}
};
// Sync on blur when a user finishes interacting
form.addEventListener('blur', (e) => {
if (e.target.matches('input[required]')) {
syncAriaInvalid(e.target);
}
}, true);
// Sync all required fields when submission is attempted
form.addEventListener('submit', () => {
form.querySelectorAll('input[required]').forEach(syncAriaInvalid);
});
// Remove error state immediately upon correction
form.addEventListener('input', (e) => {
if (e.target.matches('input[required]') && e.target.checkValidity()) {
e.target.removeAttribute('aria-invalid');
}
});
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
- Asteriscos: Sigue siendo una práctica recomendada indicar visualmente los campos obligatorios (p.ej., con un asterisco
*) en la etiqueta, para que los usuarios sepan qué esperar antes de interactuar. - Botones de envío: A diferencia de los botones
disabled, mantén habilitado el botón de envío. Si el usuario hace clic en él, el navegador activará automáticamente:user-invaliden todos los campos obligatorios vacíos y enfocará el primero. Esto es excelente para la accesibilidad y la UX. - Accesibilidad: El
:user-invalidnativo no se sincroniza automáticamente con los atributos de ARIA. Agrega el siguiente código JavaScript para manteneraria-invalidsincronizado 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);
});