560 px² 是一個門檻,不是一個實作。昨天把焦點外觀的面積算出來之後,剩下的問題其實更難:那個外框要畫在誰身上、在什麼時機出現、使用者按 Tab 之後焦點會落到哪裡、以及這一切要怎麼在 CI 裡被真的量到。先補一個編號上的更正:WCAG 2.2 定案版裡,2.4.11 是 Focus Not Obscured (Minimum)(AA,講的是焦點不被 sticky header 蓋住),2.4.12 是它的 Enhanced 版,而那個「至少等於 2 CSS 像素粗的周長面積」的可計算門檻寫在 2.4.13 Focus Appearance(AAA)。昨天用的是候選推薦稿時期的舊名字。門檻的數字沒變,96×40 的按鈕仍然要 560 px²,但引用時要引對條號,因為 AA 與 AAA 在合規對話裡是完全不同的重量。

📖 學

焦點是一個狀態機,不是一個樣式

把焦點想成 CSS 問題是大部分失敗的根源。焦點的本體是 document.activeElement,一個全域的、單一的、隨時被瀏覽器與你的程式碼共同爭奪的狀態。瀏覽器在三種時機動它:使用者按 Tab/Shift+Tab、使用者點擊某個可聚焦元素、以及元素被移出 DOM 或變成不可聚焦時的「掉落」。你的程式碼則透過 .focus() 動它。所有焦點 bug 都可以歸類成這兩股力量沒有協調好:該由你接手時你沒接(SPA 換頁),該還給瀏覽器時你不放(焦點陷阱),或是元素被卸載時焦點無聲掉回 <body>(React 條件渲染最常見)。

焦點掉回 body 這件事特別惡毒,因為它沒有任何視覺回饋。使用者按下 Tab,以為會去下一個控制項,結果從文件最開頭重新來過。螢幕報讀器使用者的體驗更糟:他剛剛還在第 8 個表單欄位,展開一個區塊之後被丟回頁面頂端。這種 bug 在滑鼠測試裡是 100% 看不見的,而昨天講過,報讀器自動化也撞不到它,因為虛擬游標根本不走 Tab 這條路。它只會出現在「有人真的用鍵盤操作」或「有人寫了測試去讀 activeElement」的時候。

場景一:SPA 路由切換

瀏覽器原生導覽會把焦點重設到文件開頭,client-side routing 不會。React Router、Vue Router、SvelteKit 換掉一整棵 DOM,activeElement 卻還停在那個已經被卸載的連結上——或者更常見的,掉回 <body>。使用者點了導覽列第三個連結,內容換了,但他按 Tab 得到的是「導覽列第四個連結」而不是新頁面的第一個互動元素。

修法有三個流派。送到 <h1> 是使用者測試裡接受度最高的:新頁面的主標題被聚焦,報讀器會唸出它,鍵盤使用者的下一次 Tab 從內容區開始。送到 skip link 或 <main> 保留了「先聽到跳過導覽的選項」的行為,適合導覽列很長的站。只用 route announcer(一個 visually hidden 的 aria-live 區域)完全不碰焦點,只宣告換頁了——這是 Next.js 內建的做法,好處是不會打斷正在打字的使用者,壞處是鍵盤使用者的 Tab 位置仍然錯亂。

實務上我會做前者加一個克制的 announcer:

// route-focus.ts
const announcer = document.getElementById('route-announcer')!;
 
export function onRouteChange(pageTitle: string) {
  document.title = `${pageTitle} — 站名`;
 
  const heading = document.querySelector<HTMLElement>('main h1');
  if (!heading) return;
 
  heading.setAttribute('tabindex', '-1');
  heading.focus();
  // 焦點離開後就把 tabindex 拿掉,避免這個節點永遠帶著一個怪屬性
  heading.addEventListener('blur', () => heading.removeAttribute('tabindex'), { once: true });
 
  // live region 只補「換頁了」這件事,不重複唸標題內容
  requestAnimationFrame(() => {
    announcer.textContent = '頁面已更新';
  });
}

tabindex="-1"<h1> 可以被程式聚焦但不進入 Tab 序,這是關鍵——你不希望使用者反覆 Tab 時每次都停在標題上。分工要講清楚:焦點負責操作軸(決定下一次 Tab 從哪裡開始),live region 負責語意軸(宣告發生了什麼)。兩者最容易出的錯是重複播報:焦點送到 <h1> 時報讀器已經會唸出標題文字,如果 announcer 又寫入同一句標題,使用者會聽到兩次。所以 announcer 的內容要刻意做得跟標題不同,或者乾脆只在「焦點沒動」的分支才寫入。

requestAnimationFrame 那一層不是裝飾。live region 的內容如果在節點剛插入 DOM 的同一個 frame 就寫進去,很多報讀器不會播報——它需要觀察到一次「內容變化」,而不是「一開始就有內容」。這個 announcer 節點必須是常駐在 DOM 裡的空殼,換頁時只換文字。

還有一個容易忽略的細節:heading.focus() 預設會捲動頁面。如果你的路由切換本身已經有捲動還原邏輯(回上一頁要回到原本的捲動位置),兩者會打架,這時用 focus({ preventScroll: true }) 再自己控捲動。

場景二:對話框與彈層

原生 <dialog>showModal() 免費給你的東西比多數人以為的多。它把元素推進 top layer(不用煩惱 z-index 打架)、給你 ::backdrop 可以樣式化、讓 Esc 觸發 cancelclose、把對話框以外的所有內容變成 inert(規格上叫 “blocked by a modal dialog”,效果等同 inert:不能點、不能聚焦、報讀器也讀不到),因此 focus trap 是免費的——Tab 走到最後一個元素會回到第一個,你不需要那三百行的 focus trap 函式庫。

它不免費給你的是兩件事。第一是初始焦點放哪。規格在 2023 年改過一輪(whatwg/html#8199),現在的演算法是:找對話框裡第一個 sequentially focusable 的後代;若對話框自身有 autofocus 則聚焦對話框自身;都沒有的話 fallback 到對話框元素本身,而不是像舊行為那樣重設到 <body>。到 2025 年底各家瀏覽器的行為已經相當一致。但「第一個可聚焦的後代」常常不是你要的——如果對話框第一個元素是關閉用的 ✕ 按鈕,使用者一打開就聚焦在關閉鈕上,體驗很怪。所以明確在你希望使用者先碰到的元素上放 autofocus

<dialog id="confirm-dialog" aria-labelledby="confirm-title">
  <h2 id="confirm-title">刪除這筆資料?</h2>
  <p>這個動作無法復原。</p>
  <form method="dialog">
    <button value="cancel" autofocus>取消</button>
    <button value="delete" class="danger">刪除</button>
  </form>
</dialog>

破壞性動作的對話框,autofocus 放在「取消」上,這樣使用者按 Enter 的預設結果是安全的。method="dialog" 的表單讓按鈕自動關閉對話框並把 value 寫進 dialog.returnValue,不需要任何 JS。

第二件不免費的是關閉後把焦點歸還給觸發元素。現代瀏覽器多半會還原到開啟前的聚焦元素,但別依賴:如果那個觸發按鈕在對話框開著的期間被卸載了(React 重繪、虛擬列表捲走、清單項目被刪掉),焦點就會掉回 body。自己記一份比較安全:

const dialog = document.getElementById('confirm-dialog') as HTMLDialogElement;
let opener: HTMLElement | null = null;
 
openBtn.addEventListener('click', () => {
  opener = document.activeElement as HTMLElement;
  dialog.showModal();
});
 
dialog.addEventListener('close', () => {
  // 觸發元素還在畫面上才還給它,否則退回一個穩定的錨點
  if (opener?.isConnected) opener.focus();
  else document.querySelector<HTMLElement>('main h1')?.focus();
  opener = null;
});
 
// 表單有未存變更時攔截 Esc
dialog.addEventListener('cancel', (e) => {
  if (formIsDirty()) {
    e.preventDefault();
    showUnsavedWarning();
  }
});

攔截 cancel 要非常克制。使用者按 Esc 期待東西會關掉,這是很強的肌肉記憶;擋下來卻不給明確回饋,就是製造一個心理上的鍵盤陷阱——技術上焦點沒被困住,體感上使用者出不去。只有真的有未儲存資料時才擋,而且擋下來的同時要把焦點送到那個警示訊息上。

如果你的彈層不是 modal(側邊抽屜、非阻斷式的浮層),showModal() 的那些好處都拿不到,得自己上 inert。這個屬性從 2023 年 4 月起就是 Baseline 的一員,四大引擎都支援,現在可以無條件使用:

function setBackgroundInert(on: boolean, panel: HTMLElement) {
  for (const el of document.body.children) {
    if (el !== panel) (el as HTMLElement).inert = on;
  }
}

inert 比手工 focus trap 好在它連游標點擊和報讀器虛擬游標一起擋掉,而 focus trap 只擋 Tab——後者的典型 bug 是報讀器使用者用虛擬游標「穿透」到背景內容,然後迷路。

場景三:複合元件的單一 tab stop

工具列、tabs、選單、樹狀清單、資料格。這類元件的共同原則是:整組只佔一個 tab stop,組內用方向鍵移動。理由很簡單,一個有 30 個按鈕的工具列如果每個都在 Tab 序裡,鍵盤使用者要按 30 次才能離開它。

實作有兩條路。Roving tabindex 是真的移動 DOM 焦點:組內只有一個元素 tabindex="0",其餘 -1,方向鍵時把 0 挪過去並 .focus()

function rovingTabindex(container: HTMLElement, itemSelector: string) {
  const items = () => [...container.querySelectorAll<HTMLElement>(itemSelector)];
 
  function focusAt(index: number) {
    const list = items();
    const next = (index + list.length) % list.length;
    list.forEach((el, i) => { el.tabIndex = i === next ? 0 : -1; });
    list[next].focus();
  }
 
  container.addEventListener('keydown', (e) => {
    const list = items();
    const i = list.indexOf(document.activeElement as HTMLElement);
    if (i < 0) return;
 
    switch (e.key) {
      case 'ArrowRight': case 'ArrowDown': focusAt(i + 1); break;
      case 'ArrowLeft':  case 'ArrowUp':   focusAt(i - 1); break;
      case 'Home': focusAt(0); break;
      case 'End':  focusAt(list.length - 1); break;
      default: return;
    }
    e.preventDefault();
  });
}

aria-activedescendant 則相反:DOM 焦點永遠留在容器上,靠容器的 aria-activedescendant 指向組內某個有 id 的元素,輔助技術會把它當成「焦點所在」。

取捨很清楚。Roving 的優勢是瀏覽器會自動把新聚焦的元素捲進可視範圍,:focus-visible 自然生效,報讀器的行為也最一致——因為那就是真的焦點。代價是每次移動都要改兩個節點的 tabindex,在幾千筆的虛擬滾動清單裡,被捲出 DOM 的那個 tabindex="0" 錨點消失時,整組會失去進入點,得額外處理。

aria-activedescendant 的存在理由是有些元件的 DOM 焦點不能離開。role="combobox" 就是典型:使用者要邊打字邊用方向鍵瀏覽建議清單,焦點一旦離開輸入框就收不到字了。這種情況沒得選。代價是你要自己做兩件瀏覽器本來會幫你做的事:把 active option 捲進視野(scrollIntoView({ block: 'nearest' })),以及自己畫「看起來像焦點」的樣式——因為真正的 :focus 在容器上,那個高亮的選項在 CSS 眼中只是一個普通的 [aria-selected="true"]。這一點直接影響昨天算的面積門檻:2.4.13 要求的是使用者看得到的焦點指示,aria-activedescendant 的視覺高亮同樣要撐到 560 px² 那個等級,而你的自動化腳本如果只讀 outline 的 computed style,會完全漏掉這一整類元件。

可見焦點的實作細節

:focus-visible 的啟發式常被誤解成「鍵盤 = 亮框、滑鼠 = 不亮框」,實際上瀏覽器判斷的是「使用者現在看起來需不需要這個提示」。文字輸入框永遠 match,即使用滑鼠點進去也一樣,因為使用者需要知道游標在哪。按鈕用滑鼠點不 match(他剛剛才用手指指過那個按鈕,不需要再告訴他一次)。而程式呼叫 .focus() 時,判斷依據是上一次互動的方式:如果使用者剛剛在按鍵盤,就 match;剛剛在用滑鼠,就不 match。這個規則對 SPA 換頁那段程式碼是好消息——滑鼠使用者點連結換頁時,<h1> 上不會突然冒出一個他不需要的框,鍵盤使用者則會看到。

樣式本身有三條硬規則。第一,outline: none 絕對不可以單獨出現,一定要有配套:

.btn:focus { outline: none; }             /* ❌ 這一行單獨存在就是 bug */
 
.btn:focus-visible {                      /* ✅ 拿掉之後要補回來 */
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
  border-radius: inherit;
}

第二,用 outline + outline-offset 而不是 box-shadow。理由有三個:outline 不參與 layout,不會撐開容器也不會被祖先的 overflow: hidden 裁掉,而 box-shadow 會——一個在卡片內緣的按鈕,focus ring 被裁掉一半是很常見的畫面。outline 現在也會跟著 border-radius 走,圓角按鈕不會配一個方框。最重要的是 Windows 高對比模式(forced-colors)會把 box-shadow 直接移除,outline 則保留下來——你如果只用 shadow 畫焦點,高對比模式的使用者會完全看不到焦點。outline-offset 的另一個作用是製造「按鈕本體」與「外框」之間的間隙,這個間隙讓外框在深色按鈕上也分得出來,同時把面積推過門檻:2px 外框貼齊只有 560 px²(剛好等於門檻),加 2px offset 之後是 592 px²,多了一點餘裕。

第三,背景色不確定時用雙色外框。這是最實用的一招:一內一外兩層,一深一淺,不管底下是什麼顏色,至少有一層能滿足 3:1。

.btn:focus-visible {
  outline: 2px solid var(--ring-inner, #fff);
  outline-offset: 2px;
  box-shadow: 0 0 0 4px var(--ring-outer, #000);  /* 外圈補色 */
}
 
@media (forced-colors: active) {
  .btn:focus-visible {
    outline-color: Highlight;   /* 交給系統色,box-shadow 反正會被移除 */
  }
}

這裡 box-shadow 是輔助而非主體——即使它在高對比模式被拿掉,outline 仍然獨立成立。全站的兜底可以直接寫 :focus-visible { outline: 2px solid Highlight; outline-offset: 2px; },用系統高亮色,成本幾乎為零。

怎麼在 CI 裡真的測到操作軸

昨天的結論是報讀器自動化測不到鍵盤,axe 也測不到。能測到的是 Playwright,因為它送的是真的按鍵事件,走的是真的 Tab 序。核心技巧是把每次 Tab 之後的 activeElement 序列化成一個字串,整串做快照比對——焦點順序就變成一個可以 diff 的東西。

import { test, expect, type Page } from '@playwright/test';
 
async function focusSignature(page: Page): Promise<string> {
  return page.evaluate(() => {
    // 穿透 shadow DOM:document.activeElement 只會給你 host
    let el: Element | null = document.activeElement;
    while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement;
    if (!el || el === document.body) return 'BODY';
 
    const node = el as HTMLElement;
    const name = node.getAttribute('aria-label')
      ?? node.textContent?.trim().replace(/\s+/g, ' ').slice(0, 30)
      ?? '';
    return `${node.tagName.toLowerCase()}${node.id ? '#' + node.id : ''} [${name}]`;
  });
}
 
test('首頁 Tab 順序不回歸', async ({ page }) => {
  await page.goto('/');
  const sequence: string[] = [];
  for (let i = 0; i < 20; i++) {
    await page.keyboard.press('Tab');
    sequence.push(await focusSignature(page));
  }
  expect(sequence.join('\n')).toMatchSnapshot('tab-order-home.txt');
});

穿透 shadow root 那三行是必要的:只要你用了任何 web component 或某些 UI 函式庫,document.activeElement 回給你的是 host 元素,整份快照會退化成一串一模一樣的 <my-button>。這個快照的價值不在第一次跑,而在有人加了一個 position: absolute 或改了 DOM 順序之後——diff 會直接告訴你焦點順序變了。

鍵盤陷阱的偵測是一個啟發式,不是證明。原理是按 N 次 Tab,觀察焦點簽章的分布:正常的頁面會走過許多不同的元素然後回到起點(或落到 BODY,表示焦點交還給瀏覽器 UI),被困住的頁面會在少數幾個簽章之間反覆循環。

test('定價頁沒有鍵盤陷阱', async ({ page }) => {
  await page.goto('/pricing');
  await page.keyboard.press('Tab');
  const first = await focusSignature(page);
 
  const seen = new Map<string, number>();
  let suspect: string | null = null;
 
  for (let i = 0; i < 200; i++) {
    await page.keyboard.press('Tab');
    const sig = await focusSignature(page);
    seen.set(sig, (seen.get(sig) ?? 0) + 1);
 
    if (sig === first || sig === 'BODY') break;          // 走完一圈,正常
    if ((seen.get(sig) ?? 0) >= 3 && seen.size <= 5) {   // 在極少數元素間打轉
      suspect = sig;
      break;
    }
  }
 
  expect(suspect, `疑似鍵盤陷阱,焦點反覆停在:${suspect}`).toBeNull();
});

要誠實面對這段的限制。seen.size <= 5 這個門檻是猜的,一個真的只有三個 tab stop 的頁面會誤報,得依頁面調整。而且 headless 環境裡按 Tab 不會離開頁面進到瀏覽器網址列,所以「焦點會不會逃出頁面」在測試裡跟真實瀏覽器不完全一樣,我們只能用「落到 BODY」當作交還的代理訊號。這個測試抓得到的是最經典的那種陷阱:第三方 iframe 廣告、自製 focus trap 忘了處理 Shift+Tab、CodeMirror 之類的編輯器吃掉 Tab 鍵。抓不到的是更微妙的情況。

對話框的正向測試比陷阱偵測可靠得多,因為預期行為是明確的:

test('刪除對話框:焦點循環、Esc 關閉、焦點歸還', async ({ page }) => {
  await page.goto('/items/42');
  const trigger = page.getByRole('button', { name: '刪除' });
  await trigger.click();
 
  const dialog = page.getByRole('dialog');
  await expect(dialog).toBeVisible();
  await expect(page.getByRole('button', { name: '取消' })).toBeFocused();  // autofocus 生效
 
  for (let i = 0; i < 12; i++) {
    await page.keyboard.press('Tab');
    const inside = await dialog.evaluate((d) => d.contains(document.activeElement));
    expect(inside, `第 ${i + 1} 次 Tab 後焦點跑出對話框`).toBe(true);
  }
 
  await page.keyboard.press('Escape');
  await expect(dialog).toBeHidden();
  await expect(trigger).toBeFocused();          // 焦點歸還給觸發元素
});

焦點外觀的面積實測有一個必須知道的陷阱:.focus() 程式聚焦,:focus-visible 不一定 match,於是你讀到的 computed outline-width 會是 0px,測試給你一個假的失敗。必須用鍵盤真的走到那個元素:

test('主要按鈕的焦點外觀達到 2.4.13 面積門檻', async ({ page }) => {
  await page.goto('/');
  const btn = page.getByRole('button', { name: '開始使用' });
 
  // 先程式聚焦定位,再用鍵盤退一格前進一格,讓 :focus-visible 真的生效
  await btn.focus();
  await page.keyboard.press('Shift+Tab');
  await page.keyboard.press('Tab');
  await expect(btn).toBeFocused();
 
  const result = await btn.evaluate((el) => {
    const cs = getComputedStyle(el);
    const w = parseFloat(cs.outlineWidth) || 0;
    const o = parseFloat(cs.outlineOffset) || 0;
    const r = el.getBoundingClientRect();
 
    const outer = (r.width + 2 * (w + o)) * (r.height + 2 * (w + o));
    const inner = (r.width + 2 * o) * (r.height + 2 * o);
    // 門檻=寬 2px 的周長面積
    const required = (r.width + 4) * (r.height + 4) - r.width * r.height;
 
    return {
      area: cs.outlineStyle === 'none' ? 0 : outer - inner,
      required,
      w, o, size: `${Math.round(r.width)}×${Math.round(r.height)}`,
    };
  });
 
  expect(result.area, `${result.size} 按鈕的焦點面積 ${result.area},需要 ${result.required}`)
    .toBeGreaterThanOrEqual(result.required);
});

96×40 的按鈕跑這段會得到 required = 560outline: 1px 算出 276 不過,2px + 2px offset 算出 592 通過,跟昨天手算的數字對得上。把它包成一個 helper,對所有 getByRole('button')getByRole('link') 跑一輪,就是一條真正擋得住回歸的門。但要記得它只看 outline:用 box-shadow 畫框的元件、以及 aria-activedescendant 那類自繪高亮的元件,這段程式碼會漏判,得另外處理或改用視覺回歸截圖。

誠實的邊界

自動化能證明的是「焦點沒有消失」「焦點沒有被困住」「焦點外框夠大」「順序沒有變」。它證明不了的是這幾件事。

焦點順序是否符合視覺順序(2.4.3)只能部分逼近。你可以把 Tab 序的每個元素的 getBoundingClientRect() 拿出來,檢查 top 大致遞增、同一列內 left 遞增,抓出那種被 order 或絕對定位搞亂的明顯錯誤。但多欄版面、卡片網格、RTL 語言都會誤報到讓人關掉這個檢查。它適合當 lint 提示,不適合當 CI 的紅燈。

焦點該送到哪裡才對使用者有幫助完全是人的判斷。刪除清單第五筆之後,焦點應該去第六筆、去第四筆、還是去清單容器並宣告「已刪除」?沒有任何工具能告訴你答案,只有實際用鍵盤操作一次才知道哪個順手。

焦點外框的對比度(2.4.13 的另一半,3:1 相對於相鄰未聚焦顏色)自動測非常困難,因為「相鄰顏色」是外框實際壓在的那些像素,可能是漸層、可能是圖片、可能是半透明疊層。截圖取樣可以做,但誤差大,實務上用「雙色外框」從設計層面繞過去比測它划算。

3.2.1 聚焦時不改變情境可以測一部分——聚焦後 URL 沒變、沒有新的 dialog 出現、表單沒有自動送出——但「情境改變」的定義比這三項寬得多。

1.4.13 懸停或聚焦產生的內容反而比想像中好測:tooltip 出現後按 Esc 能否關閉、滑鼠移到 tooltip 上它會不會消失,這兩件事 Playwright 都做得到。這是昨天列的四條 ❌ 裡面唯一一條可以靠 Playwright 大幅補回來的。剩下三條——2.1.2 靠陷阱偵測補到七成,2.4.3 補到五成,3.2.1 補到三成。

🧠 記

  • 焦點是全域狀態機,不是樣式。三種常見失效:SPA 換頁沒接手、focus trap 不放手、元素卸載時無聲掉回 <body>
  • SPA 路由:焦點送到 <h1 tabindex="-1"> 管操作軸,aria-live announcer 管語意軸,兩者內容不要重複;announcer 要常駐 DOM,用 requestAnimationFrame 延後寫入。
  • showModal() 免費給你 top layer、::backdrop、Esc 關閉、背景 inert 與原生 focus trap;不免費給你「初始焦點放對地方」(用 autofocus)與「觸發元素被卸載時的歸還」(自己記 opener 並檢查 isConnected)。
  • 非 modal 彈層用 inert,2023 年 4 月起已是 Baseline。它比 focus trap 好在連滑鼠與報讀器虛擬游標一起擋。
  • 複合元件整組一個 tab stop:roving tabindex 是真焦點(自動捲動、:focus-visible 自然生效),aria-activedescendant 用在焦點不能離開容器時(combobox),代價是捲動與焦點視覺都要自己畫。
  • outline 勝過 box-shadow:不參與 layout、不被 overflow: hidden 裁、跟隨 border-radius、在 forced-colors 下不會被移除。outline: none 沒有配套就是 bug。
  • 測試三件套:activeElement 序列快照抓順序回歸、Tab N 次看循環分布抓鍵盤陷阱、getBoundingClientRect + computed outline 算面積對照門檻。
  • 量焦點外觀前必須用鍵盤真的走到元素,.focus() 不觸發 :focus-visible,會量出假的失敗。
  • 純人工的部分:焦點順序是否符合視覺順序、焦點該落在哪裡才合理、外框對比度。

✍️ 實踐

挑一個你手上專案最複雜的頁面,花 20–30 分鐘建一條操作軸的防線。

前 10 分鐘:把上面的 focusSignature() 抄進 tests/helpers/focus.ts,記得保留穿透 shadow DOM 的那三行。寫第一個測試,對這個頁面按 20 次 Tab 並產生快照。跑一次,打開產生的 .txt 檔用眼睛讀過——這一步比測試本身有價值,你多半會在裡面看到一兩個不該出現在 Tab 序裡的東西(裝飾用的 <div tabindex="0">、隱藏但沒 inert 的側欄)。

中間 10 分鐘:找頁面上的一個對話框或下拉彈層,把那段「焦點循環 + Esc + 歸還」的測試套上去。如果它不是原生 <dialog>,八成會有一項失敗——通常是 Esc 之後焦點沒有回到觸發元素。修它,這是十行以內的改動。

最後 10 分鐘:對這個頁面的主要按鈕跑一次面積測試,把結果印出來。如果不過,在全站樣式加一條 :focus-visible { outline: 2px solid Highlight; outline-offset: 2px; } 再跑一次。把這三個測試放進 CI,操作軸就有了第一條會亮紅燈的線。

🔗 延伸學習

💬 問 AI

我要幫一個 [React / Vue / Svelte] 專案建立「焦點管理」的自動化測試,工具是 Playwright。
目前的狀況:
- 路由:[React Router v7 / Next.js App Router / ...]
- 對話框實作:[原生 <dialog> / Radix Dialog / 自製 focus trap]
- 有沒有複合元件:[tabs / 工具列 / combobox / 無]
 
請幫我做三件事:
1. 寫一個 focusSignature() helper,能穿透 shadow DOM,把 document.activeElement
   序列化成穩定、可做快照比對的字串(避免把會變動的內容如時間戳寫進簽章)。
2. 針對我的路由方案,寫出換頁時的焦點管理程式碼,並說明焦點與 aria-live
   announcer 各自負責什麼、內容要怎麼避免重複播報。
3. 寫一個測試,量出我指定的按鈕在鍵盤聚焦時的焦點外觀面積,對照 WCAG 2.2
   SC 2.4.13 的「2 CSS 像素周長面積」門檻。注意:必須用鍵盤事件抵達元素,
   不能只用 .focus(),否則 :focus-visible 不會 match。
 
最後請明確告訴我:這三個測試「測不到」哪些焦點問題,哪些只能人工走查。
不要給我一份宣稱能全自動涵蓋 WCAG 2.1.2 / 2.4.3 / 3.2.1 的方案。