Il problema
Contrassegnare i campi obbligatori con uno stato di errore immediatamente dopo il caricamento della pagina può creare confusione. Idealmente, un campo obbligatorio dovrebbe apparire "non valido" solo se l'utente ha tentato di compilarlo e non è riuscito.
La soluzione
La pseudoclasse :user-invalid risolve perfettamente questo problema. Per un campo obbligatorio, non corrisponderà al caricamento pagina. La corrispondenza verrà trovata solo se:
- L'utente interagisce con il campo (ad esempio, digita un carattere e lo elimina) e poi lo abbandona (blur), lasciandolo vuoto.
- L'utente tenta di inviare il modulo mentre il campo è vuoto.
Strategia di implementazione
- Vincolo HTML: aggiungi l'attributo
requiredai tuoi input. - Feedback visivo: utilizza
:user-invalidper applicare uno stile rosso al bordo e mostrare un testo di aiuto "Obbligatorio". - Tempistica: affidati alla tempistica nativa del browser per il feedback visivo. Non sono più necessari gestori
onBlurper aggiungere una classetouched, anche se è ancora necessario un po' di JavaScript per sincronizzare gli attributi ARIA (vedi di seguito).
Guida all'implementazione
1. Struttura 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. Sincronizzazione dello stato di JavaScript
OBBLIGATORIO: poiché :user-invalid è uno stato visivo, DEVI fornire un bridge JavaScript per sincronizzare aria-invalid="true" in modo dinamico per le tecnologie per la disabilità quando un utente sfoca un campo non valido o tenta l'invio.
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');
}
});
Fallback e supporto del browser
Fallback e supporto del browser per :user-valid e :user-invalid
CSS per il fallback
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;
}
Fallback JavaScript
Utilizza un'utilità riutilizzabile che monitora lo stato dell'interazione utilizzando un WeakMap. In questo modo, eviti di inquinare il DOM con classi o attributi di dati "sporchi".
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);
Altre considerazioni
- Asterischi: è comunque una best practice indicare visivamente i campi obbligatori (ad es. con un asterisco
*) nell'etichetta, in modo che gli utenti sappiano cosa aspettarsi prima di interagire. - Pulsanti di invio: a differenza dei pulsanti
disabled, mantieni attivo il pulsante di invio. Se l'utente fa clic sul pulsante, il browser attiverà automaticamente:user-invalidsu tutti i campi obbligatori vuoti e metterà in evidenza il primo. Questo è un ottimo risultato per l'accessibilità e la UX. - Accessibilità: l'elemento
:user-invalidnativo non si sincronizza automaticamente con gli attributi ARIA. Aggiungi il seguente codice JavaScript per mantenerearia-invalidsincronizzato con lo stato visivo:
// 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);
});