iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Modern Web

前端不寫 Python,照樣 ship 一把網頁無障礙 CLI系列 第 18

Day 18:我把三顆按鈕從 Tab 順序裡拿掉,鍵盤才算走得對

  • 分享至 

  • xImage
  •  

Day 18 · W3 · 無障礙線 · 難度 ★★★☆☆

本系列由 AI 協作撰寫。 內容、技術判斷、程式碼由 light-design 數位顧問團隊與 Claude 共同產出,最終由作者驗證後 publish。完整協作模式與把關方式見 Day 01

我的部落格頁面上有四個分類頁籤:全部、產品與服務、產業洞察、教學指南。

Tab 鍵從頭按到尾,51 個停留點,這排頁籤只佔其中一站。另外三顆按不到。

那不是漏做。是我自己把它們拿掉的。

一句話主軸:把控制項移出 Tab 序列可以是對的,判斷標準不是鍵盤走不走得到,是拿掉之後有沒有別的鍵接手。

51 站裡,四顆頁籤只吃掉一站

昨天量深色對比的那支腳本,今天拿來記路徑。同一頁按 51 次 Tab,前八站是這樣:

 1  跳至主要內容
 2  回到首頁
 3  頁首搜尋框
 4  執行搜尋
 5  開啟頁首主選單
 6  頁籤「全部」        ← 四顆只停這一顆
 7  文章列表面板
 8  第一篇文章

四顆頁籤,Tab 只認第六站那一顆。第七站直接進到列表面板,另外三顆完全沒出現在路徑上。

第七站那個面板本身是一個停留點,不是文章連結。它掛了 tabindex="0",所以從頁籤按一次 Tab,焦點會先落在面板容器上,再按一次才進到第一篇文章。這一站看起來多餘,作用是讓使用者切完頁籤之後,下一次 Tab 就直接進到剛切出來的內容,而不是繼續往頁面其他地方跑。

如果四顆都留在序列裡,這頁會是 54 站。

被拿掉的那三顆,換成方向鍵在管:

→     全部 → 產品與服務 → 產業洞察 → 教學指南 → 全部(循環)
←     全部 → 教學指南(往回也循環)
End   跳到教學指南
Home  跳回全部

一顆都沒少,只是換一組鍵。而且 aria-selected 跟著焦點走,方向鍵按到哪,內容就切到哪。

部落格頁面的鍵盤路徑圖:Tab 鍵共 51 個停留點,第六站是四顆分類頁籤中的「全部」,第七站直接跳到文章列表面板,另外三顆頁籤「產品與服務」「產業洞察」「教學指南」不在 Tab 路徑上,改由左右方向鍵、Home 與 End 在群組內循環移動

上面那條是 Tab 的路徑,下面那條是方向鍵的路徑。四顆頁籤在第二條線上。

這件事有個現成的比喻:Tab 是換樓層,方向鍵是同一層裡換房間。電梯不會替你在每個房間都停一次。

tabindex 的三個值,差別不在能不能聚焦

tabindex="0"    進入 Tab 序列,順序照文件順序走
tabindex="-1"   離開 Tab 序列,但程式仍然可以 .focus() 它
tabindex="1"    插到所有 0 前面(會打亂全頁順序,不要用)

最常被誤讀的是 -1。它不是「不能聚焦」,是「Tab 到不了,但程式送得進去」。

上面那四顆頁籤就是這樣:三顆掛 -1,方向鍵的處理程式再用 .focus() 把焦點送過去。對使用者來說焦點確實移動了,只是不經過 Tab。

一個 0,其餘 -1

這個做法叫 roving tabindex。整組控制項裡永遠只有一個 0,焦點走到哪,0 就跟到哪:

const tabs = [...tablist.querySelectorAll('[role=tab]')];
const select = (i) => {
  tabs.forEach((t, n) => {
    t.setAttribute('aria-selected', String(n === i));
    t.tabIndex = n === i ? 0 : -1;          // 0 跟著焦點走
  });
  tabs[i].focus();
};
tablist.addEventListener('keydown', (e) => {
  const cur = tabs.indexOf(document.activeElement);
  if (cur < 0) return;
  const map = { ArrowRight: cur + 1, ArrowLeft: cur - 1, Home: 0, End: tabs.length - 1 };
  if (!(e.key in map)) return;
  e.preventDefault();
  select((map[e.key] + tabs.length) % tabs.length);   // 取餘數 = 循環
});

十二行。我站上那組就是這個,一字不差。

這個行為不是我發明的,是 ARIA 的撰寫實務指南對頁籤群組的建議:

When focus moves into the tab list, places focus on the active tab element.
When the tab list contains the focus, moves focus to the next element in the page tab sequence outside the tablist.

要標清楚:那份指南是撰寫實務建議,不是規範。 WCAG 2.1.1 要求的是「所有功能都能用鍵盤操作」,它沒有規定要用哪一組鍵。所以「四顆都 Tab 得到」跟「一顆 Tab 加方向鍵」在規範層面都成立,差別在後者是這個元件被普遍接受的操作方式。

做一半的那一種,比沒做更糟

我做了一個測試檔,四個一模一樣的按鈕群組,只有實作程度不同。四種狀態各按一次 Tab、各按一次方向鍵:

頁籤群組四種實作狀態的對照表:狀態一沒有任何 role,Tab 停 4 次、方向鍵沒反應;狀態二有 role 但沒接方向鍵,Tab 只停 1 次、方向鍵沒反應,三顆按鈕從鍵盤消失;狀態三 role 與方向鍵都做,Tab 停 1 次、左右循環與 Home End 正常;狀態四沒有 role 也沒有命名線索,Tab 停 4 次、方向鍵沒反應

狀態二是唯一一個「按鈕從鍵盤上消失」的狀態,而它比狀態一多寫了程式碼。

狀態二跟狀態三的差別,只有前面那十二行。

少掉之後:tabindex="-1" 把三顆推出 Tab 序列,而沒有任何東西接手。它們在鍵盤上直接消失了。

沒做的話,至少四顆都 Tab 得到,操作起來囉唆但走得完。做一半,那三顆就真的按不到了。

而且這件事用滑鼠測不出來。四種狀態拿滑鼠點,行為完全一樣:點哪顆切哪顆。要看出差別得把手從滑鼠上拿開,按 Tab 到那排按鈕,再按方向鍵。狀態二會停在原地,什麼都不動 —— 沒有錯誤訊息,沒有視覺提示,就是不動。

宣告 role 是承諾,接方向鍵才是履行。 兩件事分開寫在不同檔案,只做前一件不會有任何錯誤訊息。

我的規則本來把這件事判成缺陷

GN1210101E 是條很直接的規則:看到互動元件掛 tabindex="-1",就報「已從鍵盤 Tab 序列移除,無法以鍵盤聚焦」。

按這個邏輯,我站上那三顆頁籤會被報成三則失敗。

2026-04-30 那次修正加了一份豁免名單,十個角色:

tab            menuitem    menuitemcheckbox    menuitemradio
treeitem       option      radio               gridcell
columnheader   rowheader

這些角色在 ARIA 的撰寫實務指南裡本來就用 roving,掛 -1 是正確做法。

問題是,豁免的判準是 role 宣告,不是實際的鍵盤行為。回頭看那四個狀態:狀態二宣告了 role="tab",方向鍵沒接,三條相關規則全部放行。

這是刻意的取捨,不是沒想到。Day 11 講過偽陽性的代價:一個網站可能有幾十個 roving 元件,全部誤報成「鍵盤不可達」,使用者第一個動作是關掉整條規則,然後真的不可達的那些也一起消失。

放過一個做一半的,比讓人關掉整條規則划算。

要抓狀態二,得真的按方向鍵看有沒有反應。那是探針的工作,不是靜態規則的工作。

認不認得出來,看的是名字

狀態一跟狀態四的 HTML 幾乎一樣,四顆按鈕、同樣的文字。差別只在容器的 class:

狀態一  class="filters row"   →  報一則 caveat
狀態四  class="grp row"       →  完全沒反應

規則靠兩種線索認頁籤群組:容器的 class 帶 tab filter category pill segmented 這類字,或子元素帶 aria-selected data-tab aria-controls。兩種都沒有就認不出來。

這是已知邊界。 一排四顆短標籤按鈕,跟一排四顆普通按鈕,在原始碼上長得一模一樣。要判斷「這在畫面上是頁籤」,得看畫面,看不到就只能猜。

所以狀態一報的是 caveat 不是 fail,訊息裡寫的是「疑似頁籤群組」。我原本以為這種靠命名猜的規則會很吵,實際跑下來它只在有線索的時候開口,沒線索就閉嘴。安靜得有點過頭。

送驗那次踩到的是同一個屬性的另一種用法

今天掃自家站,這批焦點順序相關的檢測碼全部是 0。翻回 2026-05-12 送驗當時那份報告,是這樣:

CS2240700E ×3   「跳至主要內容」按 Enter 後,焦點沒有跳到目標
GN1240301E ×5   觸發「開啟選單」展開後,下一個焦點沒有進到那個容器裡

第一條的原因是:同頁錨點連結只會捲動畫面,不會移動焦點。目標容器如果不是原生可聚焦的元素,捲過去之後焦點還留在原地,下一次 Tab 又從頭開始。

修法是給目標容器加 tabindex="-1"。加了之後它不會進 Tab 序列,但瀏覽器跳錨點時就找得到可以聚焦的目標,焦點跟著捲動一起過去。

第二條是同一件事的另一個場景:漢堡選單按鈕展開之後,選單內容才被塞進畫面,而焦點還留在按鈕上。使用者按下一個 Tab,不一定會進到剛展開的那些連結裡 —— 要看那段內容在原始碼裡的位置在按鈕前面還是後面。展開的東西在畫面上跟按鈕相鄰,在文件順序上不一定相鄰。

又是 -1,這次反過來用。 前面是把序列裡的東西拿出來,這裡是讓本來就不在序列裡的東西,可以被程式聚焦。同一個屬性,兩個方向。

2026-05-21 重掃,兩條都是 0。

今天的重點

  • 鍵盤可操作不等於每個元件都要 Tab 得到。 WCAG 沒有規定用哪一組鍵。
  • 移出 Tab 序列要有別的鍵接手,沒接手就是讓元件從鍵盤上消失。
  • roving 是宣告加行為兩半,只做前半比完全不做更糟。 少寫的那十二行不會噴任何錯誤。
  • 規則靠 role 宣告放行是取捨。 誤報會讓人關掉整條規則,代價比放過一個做一半的高。
  • tabindex="-1" 有兩種用途:把東西移出序列,或讓不在序列裡的東西能被程式聚焦。

明天 Day 19:頁籤的順序看完了,換表單裡的落點。空表單按下送出,焦點確實跳到了第一個必填欄位 —— 我的表單焦點跳對了,畫面上卻沒有一個字說明原因,而那個跳轉根本不是我寫的。


上一篇
Day 17:我一行 CSS 都沒改,同一個顏色在深色模式下變成不合格
下一篇
Day 19:我的表單焦點跳對了,畫面上卻沒有一個字說明原因
系列文
前端不寫 Python,照樣 ship 一把網頁無障礙 CLI21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言