無障礙錯誤公告

問題說明

標準 HTML5 驗證會提供視覺回饋 (透過 :invalid 或 :user-invalid),但不會自動與 aria-invalid 等無障礙屬性同步。

如果使用標準 :invalid 樣式,當使用者透過 Tab 鍵切換至目前空白的必填欄位時,螢幕閱讀器可能會讀出「無效項目」。這會對使用輔助技術的使用者造成干擾,因為系統會在互動發生前就宣布錯誤。

解決方法

我們希望在套用視覺狀態 (:user-invalid) 的同一時間,套用程式輔助狀態 (aria-invalid="true")。由於 :user-invalid 依賴瀏覽器內部的「使用者互動」旗標,因此我們可以使用 JavaScript 檢查這個選取器是否在標準互動事件期間相符。

詳情請參閱 MDN aria-invalid。

導入策略

  1. 視覺層:使用 CSS :user-invalid 顯示邊框/圖示。
  2. 無障礙層:使用 aria-invalid 和 aria-errormessage 將狀態傳達給輔助技術 (AT)。
  3. 橋接視覺和無障礙功能層:建立輕量 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();

其他考量

  1. aria-live與aria-errormessage:

    • aria-errormessage 會將輸入內容連結至文字,但螢幕閱讀器可能不會在文字出現時立即朗讀 (只有在聚焦輸入內容時才會朗讀)。
    • 如果需要在錯誤出現時立即朗讀 (例如在模糊時),請考慮在錯誤訊息容器中加入 role="alert" 或 aria-live="polite",但請務必徹底測試,避免在使用者聚焦於欄位修正錯誤時「重複朗讀」。
  2. 國際化:

    • 請務必翻譯錯誤訊息 (#email-error) 的文字內容。邏輯維持不變。