紀錄工作經驗、相關知識,解決技術相關問題。

HTML, 新手 Jamp Holes, 網站相關, 資訊相關

HTML Input 無障礙實作:aria-describedby 與 label 的計算優先權與動態錯誤綁定

HTML Input 無障礙實作:aria-describedby 與 label 的計算優先權與動態錯誤綁定 特色圖片

在日常專案開發中,當表單欄位除了主標題(<label>)之外,還需要附帶「密碼格式規則說明」、「字數上限提示」與「即時驗證錯誤紅字」時,必須仰賴 aria-describedby 建立程式化關聯。然而最近在審查多個前端專案的無障礙(Accessibility, A11y)實作時,我們發現許多工程師把 aria-describedby="pwd-hint pwd-error" 預先寫死在 <input> 上,並將寫好錯誤文字的 #pwd-error 設為 display: none——結果根據 W3C Accessible Name and Description Computation 1.2 (AccName 1.2) 規範,螢幕閱讀器竟在使用者剛聚焦、一個字都還沒打的時候,就把隱藏在 display: none 裡的錯誤訊息大聲唸了出來!這篇筆記將帶大家從 W3C AccName 1.2 底層計算規則出發,徹底搞懂 label、aria-labelledby 與 aria-describedby 的正確分工與動態綁定實作。


⚡ 快速簡單使用(30 秒複製即用範本)

以下提供符合 W3C AccName 1.2 與 WCAG 2.2 SC 3.3.1 (Error Identification) 標準的動態 aria-describedby 綁定範本(避免預先綁定隱藏錯誤節點導致誤報,並在驗證失敗時將錯誤 ID 排在第一位優先播報):

<div class="form-field">
  <label for="api-token">API 存取金鑰</label>
  <!-- 初始狀態:僅綁定長駐說明 #token-hint,不綁定錯誤容器 -->
  <input
    type="password"
    id="api-token"
    name="apiToken"
    aria-describedby="token-hint"
    aria-invalid="false"
    required
  >
  <!-- 長駐格式說明 -->
  <p id="token-hint" class="field-hint">
    金鑰須以 sk_live_ 開頭,總長度至少 24 個字元。
  </p>
  <!-- 動態錯誤容器:初始為空且加上 hidden,驗證失敗時才填入文字並掛入 aria-describedby -->
  <p id="token-error" class="field-error" hidden></p>
</div>

<script>
  const tokenInput = document.getElementById('api-token');
  const tokenError = document.getElementById('token-error');

  function validateApiToken(inputEl) {
    const val = inputEl.value.trim();
    const isValid = val.startsWith('sk_live_') && val.length >= 24;

    if (!isValid) {
      tokenError.textContent = '錯誤:金鑰格式不符,必須以 sk_live_ 開頭且滿 24 碼。';
      tokenError.hidden = false;
      inputEl.setAttribute('aria-invalid', 'true');
      // 關鍵:將 token-error 排在 token-hint 前面,讓螢幕閱讀器優先唸出錯誤原因!
      inputEl.setAttribute('aria-describedby', 'token-error token-hint');
    } else {
      tokenError.textContent = '';
      tokenError.hidden = true;
      inputEl.setAttribute('aria-invalid', 'false');
      inputEl.setAttribute('aria-describedby', 'token-hint');
    }
  }

  tokenInput.addEventListener('blur', (e) => validateApiToken(e.target));
</script>

一、核心速查:表單無障礙命名(Name)與描述(Description)屬性對照表

在無障礙樹(Accessibility Tree)中,每一個表單控制項都有兩個核心文字屬性:Accessible Name(無障礙名稱:回答「這個欄位是什麼?」) 與 Accessible Description(無障礙描述:回答「這個欄位有什麼填寫限制或錯誤?」)。下表整理了各屬性的計算優先權與職責邊界:

屬性 / 標籤歸屬維度與 W3C AccName 1.2 優先權螢幕閱讀器播報行為與特性典型適用場景
aria-labelledby="id1 id2"無障礙名稱 (Name)
(第 1 優先,最高)
直接覆蓋該元素上的 aria-label 與原生 <label for> 文字;支援串接多個元素 ID表格矩陣內的輸入框(同時組合「列標題 ID + 欄標題 ID」作為名稱)
aria-label="純文字"無障礙名稱 (Name)
(第 2 優先)
直接覆蓋原生 <label for> 文字;畫面上不可見無文字標籤之純圖示按鈕(若已有可見 <label> 切勿濫用,名稱一旦不包含可見文字就會違反 WCAG 2.5.3)
<label for="id">無障礙名稱 (Name)
(第 3 優先,最推薦)
作為標準欄位名稱播報,且提供滑鼠點擊文字自動聚焦 <input> 的原生互動一般的表單輸入框、核取方塊與下拉選單
aria-describedby="id1 id2"無障礙描述 (Description)
(描述維度主屬性)
依屬性內 ID 順序串接成一段描述文字,在名稱之外另行提供給輔助技術(實際播報時機依螢幕閱讀器而定)格式規則提示、字數限制、動態驗證錯誤訊息
aria-errormessage="id"錯誤訊息關聯
(須搭配 aria-invalid="true")
WAI-ARIA 1.2 規定 aria-invalid 為 false 時,使用者代理不得公開這個關聯;欄位無效時,被參照的錯誤元素必須保持可見WAI-ARIA 1.2 錯誤訊息語意化標記(實務上常與 aria-describedby 併用或擇一)

W3C AccName 1.2 表單欄位無障礙名稱 (Name) 與無障礙描述 (Description) 計算架構圖

圖:W3C AccName 1.2 表單欄位無障礙名稱(Accessible Name)三級優先權鏈與無障礙描述(aria-describedby)計算架構圖


二、深度拆解:aria-describedby 的三大底層規範與常見誤區

1. 為什麼 aria-describedby 指向 display: none 的錯誤元素會導致提前誤報?

在許多初學者的教學文章中,常會看到以下寫法:

<!-- ❌ 常見錯誤反模式:預先寫死 pwd-error 且內含錯誤文字 -->
<input type="password" id="pwd" aria-describedby="pwd-hint pwd-error">
<p id="pwd-hint">密碼至少 8 碼。</p>
<p id="pwd-error" style="display: none;">錯誤:密碼長度不足!</p>

寫這段程式碼的工程師以為:「既然 #pwd-error 設了 display: none,螢幕閱讀器應該就不會唸它吧?」

事實正好相反!根據 W3C AccName 1.2 §4.3.2 Computation steps (Step 2A) 的明確規範:

當一個節點是透過 aria-labelledby 或 aria-describedby 直接參照(Directly Referenced) 時,即使該節點設定了 display: none、visibility: hidden 或 HTML hidden 屬性,無障礙名稱與描述計算演算法依然會強制提取該隱藏節點內的文字!

AccName 1.2 在同一節的附註說明:輔助技術預設不會轉述隱藏資訊,但作者可以透過 aria-labelledby 或 aria-describedby 明確把隱藏文字納入名稱或描述,因此這個機制可以用來提供「不在畫面上顯示的補充說明」;但若套用在預先寫好錯誤文字的 #pwd-error 上,就會導致視障使用者第一次按 Tab 進入密碼框時,馬上被誤報「錯誤:密碼長度不足!」。

aria-describedby 指向隱藏錯誤元素 (display: none) 之誤報問題與正確動態綁定流程

圖:aria-describedby 預先指向 display: none 錯誤節點之誤報問題 vs. 標準動態綁定流程

兩種常見的解決思路:

  1. 動態清空文字法:在驗證通過或初始狀態下,確保 #pwd-error 的文字內容為空字串(textContent = ''),僅在驗證失敗時才寫入錯誤文字。
  2. 動態切換 aria-describedby ID 清單法(如開頭 30 秒範本):初始僅設定 aria-describedby="pwd-hint";當驗證失敗時,動態更新為 aria-describedby="pwd-error pwd-hint"。

2. 多重 ID 串接順序(IDREFS Order):把「錯誤訊息 ID」排在「提示說明 ID」前面!

aria-describedby 支援以空白分隔的多個 ID(例如 aria-describedby="id-a id-b")。
請記住:AccName 會依 ID 在 aria-describedby 屬性字串中出現的先後順序串接描述文字,而不是依它們在 HTML DOM 樹中的上下位置! 例如 DOM 中 #g1 在前、#g2 在後,aria-describedby="g2 g1" 算出的描述會是「第二段 第一段」。

當使用者填錯表單回到該欄位修正時,他最迫切想知道的是「哪裡填錯了」,而不是再聽一次冗長的填表說明。因此在動態掛載錯誤 ID 時,將 error-id 放在第一個位置(aria-describedby="error-id hint-id"),能讓視障使用者先聽到錯誤原因,不必等說明唸完。

3. 攤平為純文字字串(Flattened String):切勿在被參照元素內塞入唯一超連結

當瀏覽器計算 aria-describedby 的內容時,會將目標元素內的 DOM 子樹全部攤平(Flatten)為一段純文字字串。
如果你在 <p id="terms-hint">請參閱 <a href="/policy">隱私權政策</a></p> 中放了一個超連結,並用 <input aria-describedby="terms-hint"> 指向它:

  • 當焦點停留在 <input> 上時,螢幕閱讀器只會把「請參閱 隱私權政策」當成純文字唸過去,使用者無法在停留在 <input> 的狀態下直接觸發該連結(必須再按 Tab 鍵將焦點移出 <input> 到達 <a> 上才能點擊)。因此若說明內含互動連結,請確保該元素在視覺與 DOM 樹中保持可見且可被 Tab 獨立聚焦。

三、現代前端框架實務:使用 React useId() 打造零 ID 衝突之無障礙表單元件

在 React 或 Vue 等元件化架構中,如果將 <InputField> 元件內的 id="email-hint" 寫死,當同一個頁面渲染兩個 <InputField> 時就會發生 HTML id 重複衝突,導致 label for 與 aria-describedby 指錯對象。

利用 React 18 起原生提供的 useId() Hook,我們可以為每個元件實例自動產生唯一 ID 前綴,只要伺服器與用戶端渲染的元件樹相同,SSR(伺服器端渲染)輸出的 ID 與 Hydration 時就會一致,並自動組裝 aria-describedby:

import { useId } from 'react';

export function AccessibleTextField({
  label,
  hintText,
  errorText,
  required = false,
  ...inputProps
}) {
  // 產生 SSR 安全的唯一基礎 ID(實際字串格式由 React 決定,不要依賴其內容)
  const baseId = useId();
  const inputId = `${baseId}-input`;
  const hintId = `${baseId}-hint`;
  const errorId = `${baseId}-error`;

  // 動態組合 aria-describedby:有錯誤時優先播報 errorId,再播報 hintId
  const describedByIds = [
    errorText ? errorId : null,
    hintText ? hintId : null,
  ]
    .filter(Boolean)
    .join(' ');

  return (
    <div className="form-field">
      <label htmlFor={inputId}>
        {label}
        {required && <span aria-hidden="true"> *</span>}
      </label>

      <input
        id={inputId}
        required={required}
        aria-invalid={Boolean(errorText)}
        aria-describedby={describedByIds || undefined}
        {...inputProps}
      />

      {hintText && (
        <p id={hintId} className="field-hint">
          {hintText}
        </p>
      )}

      {errorText && (
        <p id={errorId} className="field-error" role="alert">
          {errorText}
        </p>
      )}
    </div>
  );
}

四、常見錯誤與踩坑排查 (Pitfalls & Troubleshooting)

  1. 在已有 <label for> 的輸入框上又畫蛇添足加上簡短的 aria-label:
    • 現象:HTML 寫了 <label for="tax-id">統一編號(選填,共 8 碼數字)</label>,工程師又在 <input id="tax-id" aria-label="統編"> 上加了 aria-label。
    • 後果:因為 aria-label 的優先權高於 <label for>,螢幕閱讀器只會唸出 "統編",把原本 <label> 裡的「選填,共 8 碼數字」完全覆蓋吞掉!同時因為無障礙名稱 "統編" 不包含可見文字 "統一編號",還會直接違反 WCAG 2.2 SC 2.5.3 (Label in Name),語音輸入使用者照著畫面說出「統一編號」時,可能無法對應到這個欄位。
    • 解法:只要有原生 <label for>,就絕對不要在 <input> 上重複設定 aria-label。
  2. aria-describedby 指向不存在或拼錯的 id(Broken ARIA Reference):
    • 當條件渲染(Conditional Rendering)把 <p id="hint"> 從 DOM 移除,卻忘了把 <input aria-describedby="hint"> 上的屬性一併移除時,自動化無障礙檢測工具(如 Deque axe-core)會在 aria-valid-attr-value 規則中把這個懸空參照列為需人工確認(needs review)的項目。請務必如上方 React 範例所示,當沒有對應元素時傳入 undefined 移除該屬性。

五、總結與官方規範參考文獻

掌握 <label> 與 aria-describedby 的無障礙名稱與描述計算機制,是打造企業級高合規表單的核心關鍵:

  • 名稱(Name)與描述(Description)職責分離:用原生 <label for> 定義欄位身分,用 aria-describedby 綁定格式規則與錯誤訊息。
  • 防範隱藏錯誤節點提前誤報:切勿讓 aria-describedby 常駐指向內含錯誤文字但設為 display: none 的元素;應採用條件渲染或動態切換 ID 清單。
  • 錯誤訊息優先播報與唯一 ID 管理:串接多個 ID 時將 errorId 排在 hintId 之前,並在元件化開發中善用 useId() 杜絕 ID 重複。

官方權威參考文獻

參考資料與延伸閱讀

發佈留言