Day 19 · W3 · 無障礙線 · 難度 ★★★☆☆
本系列由 AI 協作撰寫。 內容、技術判斷、程式碼由 light-design 數位顧問團隊與 Claude 共同產出,最終由作者驗證後 publish。完整協作模式與把關方式見 Day 01。
空表單按下送出,焦點跳到「姓名」那一格。
跳得很準。而我一行都沒寫。
昨天量頁籤的焦點順序,今天換表單。我原本以為「送出之後焦點要跳到第一個沒填的欄位」會是最容易漏掉的那件事,實測下來它是自己會動的那件。
真正沒人做的是另一半。
一句話主軸:required 換來的焦點跳轉是免費的,但它只買到一半。
先講一件我自己都沒預期的事。六個頁面掃過去找 <form>:
/ 1 個 頁首搜尋框
/about-us 1 個 頁首搜尋框
/pricing 1 個 頁首搜尋框
/contact 1 個 頁首搜尋框
/works 1 個 頁首搜尋框
/blog 1 個 頁首搜尋框
聯絡表單不在裡面。它在一個對話框裡,要按下「預約諮詢」才會被掛進畫面。
這件事等一下會變成重點,先記著。
把對話框打開,五個欄位全部留空,按送出:
焦點落點 <input type="text"> 姓名 ← 跳了
aria-live 容器 0 個
aria-invalid="true" 0 個
DOM 裡的錯誤文字 0 筆
焦點跳得完全正確:停在第一個沒填的必填欄位上。
而畫面上(更精確地說,DOM 裡)沒有任何一個字說明它為什麼停在那裡。
原因在這一行沒有出現的屬性:這個表單沒有 novalidate,欄位帶著原生 required。所以整套驗證是瀏覽器接管的:它擋下送出、聚焦第一個無效欄位、彈出一個小泡泡。
那個泡泡從來不進 DOM。抓不到、改不了樣式、aria-live 播不了、下一個互動就消失。
瀏覽器在這裡像一個只會擋人的門衛:它確實把你攔下來了,也確實把你帶到出問題的那一格,但它不說為什麼。
對應的檢測碼是 GN2330300E,WCAG 3.3.3,AA 級。條文原文:
提供文字描述以指明未完成的必填欄位,並建立可以讓使用者跳到出錯之處的機制
讀慢一點:「文字描述」和「跳轉機制」,中間那個字是「並」。
我做到了第二件,第一件沒有。而做到的那件還是瀏覽器代勞的。
這條規則我四月就寫進工具裡了,但當時的實作只檢查焦點跳轉那一半。一個焦點默默移動、畫面什麼都沒說的表單,在那個版本裡是通過的。
八月底重新把每條規則的實作跟碼表原文對一遍,才發現這條檢查得比條文要求的少。補上第二個義務之後,同一個表單的判定就從通過變成不通過 —— 表單一個字都沒改,變的是規則終於問完了整句話。
我做了一個測試檔,同一組欄位寫五遍,每遍的驗證方式不同:

五種寫法裡,只有最後一種同時滿足碼表的兩個義務。
狀態二跟狀態三共用同一段驗證程式,差別只有最後那一行 focus()。少那一行,焦點就留在送出鍵上,使用者被告知「有欄位沒填」,卻得自己回頭找是哪一格。
狀態四最兇:加了 novalidate 卻忘了補驗證。實測按下送出,網址直接變成:
?name=&email=
空表單真的送出去了。
最直覺的補法是加 novalidate,自己接管全部驗證。但那條路有代價,等一下說。
有一個更省的做法:required 照留,只把「講清楚」那半接回來。 瀏覽器發現欄位無效時會先派發一個 invalid 事件,攔住它就好:
form.addEventListener('invalid', (e) => {
e.preventDefault(); // 壓掉原生泡泡,改用自己的文字
document.getElementById(e.target.id + '-err').hidden = false;
e.target.setAttribute('aria-invalid', 'true');
if (!first) first = e.target; // invalid 依文件順序派發
if (queued) return;
queued = true;
setTimeout(() => {
summary.hidden = false;
summary.textContent = '有 ' + n + ' 個必填欄位尚未填寫';
first.focus();
first = null; queued = false;
}, 0);
}, true);
瀏覽器照樣擋下送出,所以空表單不會產生真資料;錯誤文字進了 DOM,role="alert" 會被報讀出來;焦點自己送到第一個錯欄。兩個義務都成立。
兩個坑是寫這段時實際踩到的:
invalid 事件不會冒泡,必須用捕獲階段監聽,也就是第三個參數要給 true
setTimeout 的下一輪。第一版我用 queueMicrotask,焦點被瀏覽器自己的聚焦蓋過去,跑到第二個欄位上,量了才發現回到開頭那件事:表單在對話框裡,初始畫面上不存在。
同一頁掃兩次,差別只在有沒有讓工具去點那顆按鈕:
不點 9 條,表單相關 0 條
點了 11 條
GN2330300E 焦點有跳,但沒有文字描述
GN3330602E 對話框內表單 5 個欄位,請人工走一次流程
點按鈕這件事預設是關的。 理由不是做不到,是在別人的正式網站上亂點按鈕,可能觸發預約、扣款、寄信、送出一筆分析事件。這跟 Day 11 講的偽陽性是同一種取捨:一個檢查工具造成的副作用,比它漏抓一條還難交代。
同一個理由也決定了工具敢不敢按送出鍵。看回上面那張表:狀態二、三、四全是 caveat,因為它們都帶 novalidate ——探針拒絕對這種表單空送,那一按在正式站上就是狀態四那個結果,一筆真的空記錄。
所以這條規則能自動判定的,只有還在用瀏覽器原生驗證的那一種。一旦你自己接手驗證,也就是真的有可能寫錯的時候,機器只能說「我不敢按,請人工走一次」。

判得動的那一種,錯法只有一種;可能寫錯的那一種,判不動。
這是能力邊界,不是漏掉。工具在這裡說「我沒檢查」,比說「沒問題」誠實。
為了把上面那些數字量準,我發現規則跟探針各有一個 bug,兩個都是把「我沒檢查」變成了沉默。
第一個:那條規則報完第一個有問題的表單之後就整條停掉了。判斷「探針不敢按」的分支寫的是繼續看下一個,判斷「這個表單有問題」的分支寫的卻是結束。一頁上有搜尋框、聯絡表單、訂閱表單很正常,第一個出事,後面的就靜音了。
消失的不是失敗,是那些 caveat。報告從「這三個表單我沒檢查」變成什麼都不說。
第二個更隱蔽。探針點完按鈕會確認「這一下有沒有把我導到別頁」,導走了就退回來。它比對的是頁面載入時記下的網址 —— 但在整站掃描的共用瀏覽器路徑上,這個頁面已經被前面幾個探針動過,其中一個會按下「跳至主要內容」,網址早就多了個 #main-content。
於是第一次點擊還沒發生,「已經被導走」就已經成立。三個候選按鈕全部被退回去,對話框裡的表單一個都探不到。
任何有跳至主要內容連結的頁面都中。網站做得越無障礙,這條壞得越穩定。
兩個都修掉了,並補了測試:先把 bug 放回去確認新測試會紅,再拿掉確認轉綠。修完對自家六頁重跑,只有那一頁多了一則失敗跟一則 caveat,其餘五頁一個字都沒動。
required 免費送你焦點跳轉,但碼表要的是兩件事。 中間那個字是「並」。invalid 事件是最省的補法:原生驗證照留,只把文字描述接回來。記得用捕獲階段、記得把聚焦排到下一輪。明天 Day 20:檢查工具講完了,換那份出貨給 AI 的說明書。CLI 能跑,不等於 AI 會用對。 那份說明書真正在做事的不是「怎麼用」那幾節,是那些「不准」。