問題說明
標準 HTML5 驗證會提供視覺回饋 (透過 :invalid 或 :user-invalid),但不會自動與 aria-invalid 等無障礙屬性同步。
如果使用標準 :invalid 樣式,當使用者透過 Tab 鍵切換至目前空白的必填欄位時,螢幕閱讀器可能會讀出「無效項目」。這會對使用輔助技術的使用者造成干擾,因為系統會在互動發生前就宣布錯誤。
解決方法
我們希望在套用視覺狀態 (:user-invalid) 的同一時間,套用程式輔助狀態 (aria-invalid="true")。由於 :user-invalid 依賴瀏覽器內部的「使用者互動」旗標,因此我們可以使用 JavaScript 檢查這個選取器是否在標準互動事件期間相符。
詳情請參閱 MDN aria-invalid。
導入策略
- 視覺層:使用 CSS
:user-invalid顯示邊框/圖示。 - 無障礙層:使用
aria-invalid和aria-errormessage將狀態傳達給輔助技術 (AT)。 - 橋接視覺和無障礙功能層:建立輕量 JavaScript 公用程式,監聽
blur和input事件,檢查元素是否符合:user-invalid,並據此更新 ARIA 屬性。
導入指南
1. HTML 結構
使用 aria-errormessage (或 aria-describedby,支援範圍更廣) 將輸入內容連結至錯誤訊息。
<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
使用原生虛擬類別 :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
由於沒有「UserInvalidChanged」事件,請連結至標準表單事件來檢查狀態。
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);
}
});
備用廣告和瀏覽器支援
功能偵測
您可以檢查 CSS 和 JavaScript 是否支援。
JavaScript 檢查:
if (!CSS.supports('selector(:user-invalid)')) {
// Fallback logic here
}
備用 CSS
為確保備用邏輯在視覺上與原生行為無法區分,您必須將錯誤樣式套用至虛擬類別和備用類別。
/* 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;
}
備用邏輯
如果缺少 :user-invalid,請使用 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();
其他考量
aria-live與aria-errormessage:aria-errormessage會將輸入內容連結至文字,但螢幕閱讀器可能不會在文字出現時立即朗讀 (只有在聚焦輸入內容時才會朗讀)。- 如果需要在錯誤出現時立即朗讀 (例如在模糊時),請考慮在錯誤訊息容器中加入
role="alert"或aria-live="polite",但請務必徹底測試,避免在使用者聚焦於欄位修正錯誤時「重複朗讀」。
國際化:
- 請務必翻譯錯誤訊息 (
#email-error) 的文字內容。邏輯維持不變。
- 請務必翻譯錯誤訊息 (