<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Accessibility on Tarragon</title><link>https://tarrragon.github.io/blog/tags/accessibility/</link><description>Recent content in Accessibility on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Sun, 26 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/accessibility/index.xml" rel="self" type="application/rss+xml"/><item><title>動態 DOM 移動時的 focus 管理</title><link>https://tarrragon.github.io/blog/report/focus-management-on-dom-move/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/focus-management-on-dom-move/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>JS 移動或隱藏 DOM 元素時、鍵盤 focus 的命運要主動處理 — 不處理會跑掉或停在不可見元素上、鍵盤使用者瞬間迷失方向。&lt;/strong> 多數動態 UI 的 focus 問題不是「某個元素該 focusable」、是「某個變動沒考慮 focus 該去哪」。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼-focus-管理需要主動處理">為什麼 focus 管理需要主動處理&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>鍵盤使用者依 focus 知道「現在在哪」。focus 變動有三種來源：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>來源&lt;/th>
 &lt;th>含義&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>使用者主動（Tab、Enter、方向鍵）&lt;/td>
 &lt;td>預期、無需處理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Focus 元素被移除&lt;/td>
 &lt;td>focus 跳到 body — 使用者迷失&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Focus 元素被 reparent&lt;/td>
 &lt;td>看瀏覽器、可能 focus 仍在元素上、可能掉失&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>第二、三類是 JS 變動 DOM 引起的副作用、開發者要主動處理。&lt;/p>
&lt;h3 id="三類-dom-變動對-focus-的影響">三類 DOM 變動對 focus 的影響&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>變動類型&lt;/th>
 &lt;th>Focus 行為&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>整節點 reparent（appendChild）&lt;/td>
 &lt;td>視瀏覽器、Chrome 多半保留 focus、Safari 可能掉&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>節點 remove&lt;/td>
 &lt;td>focus 跳到 body&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>節點 display: none&lt;/td>
 &lt;td>focus 跳到 body&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>節點 visibility: hidden&lt;/td>
 &lt;td>focus 仍在但元素不可見、使用者迷失&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每類有對應的處理 — 主要是「事前 save、事後 restore」。&lt;/p>
&lt;hr>
&lt;h2 id="搜尋頁的具體風險點">搜尋頁的具體風險點&lt;/h2>
&lt;h3 id="風險-1filter-slot-跨-viewport-切換">風險 1：Filter slot 跨 viewport 切換&lt;/h3>
&lt;p>&lt;strong>位置&lt;/strong>：matchMedia callback 的 &lt;code>place()&lt;/code> 函式。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">place&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">mql&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">matches&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">slot&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">appendChild&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">filter&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">else&lt;/span> &lt;span class="nx">drawer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">insertBefore&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">filter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">drawer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">firstChild&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>判讀&lt;/strong>：使用者鍵盤 focus 在 filter 內某個 checkbox、視窗 resize 跨過 1400px、&lt;code>appendChild&lt;/code> 把 filter 整個搬到別處。理論上 focus 跟著節點走、實際視瀏覽器。&lt;/p>
&lt;p>&lt;strong>症狀&lt;/strong>：使用者按 tab 進到 filter checkbox、調視窗寬度跨 breakpoint、focus 突然在 body 或其他位置。&lt;/p>
&lt;p>&lt;strong>第一個該查的&lt;/strong>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">place&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">activeBefore&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">activeElement&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">mql&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">matches&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">slot&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">appendChild&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">filter&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="k">else&lt;/span> &lt;span class="nx">drawer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">insertBefore&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">filter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">drawer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">firstChild&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 嘗試還原 focus
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">activeBefore&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">filter&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">contains&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">activeBefore&lt;/span>&lt;span class="p">))&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="nx">activeBefore&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">focus&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>activeElement&lt;/code> 在 reparent 前後仍指向同一個 DOM 節點（如果 focus 在 filter 內）。明確 &lt;code>.focus()&lt;/code> 確保視覺一致。&lt;/p>
&lt;h3 id="風險-2scope-filter-隱藏當前-focus-元素">風險 2：Scope filter 隱藏當前 focus 元素&lt;/h3>
&lt;p>&lt;strong>位置&lt;/strong>：scope filter 的 &lt;code>apply()&lt;/code>。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">items&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">classList&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">toggle&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;is-scope-filtered&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">!&lt;/span>&lt;span class="nx">show&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>判讀&lt;/strong>：若使用者 focus 在某個 result（例如標題連結）、切換 scope 後該 result 被隱藏（display: none）— focus 跳到 body。&lt;/p>
&lt;p>&lt;strong>症狀&lt;/strong>：使用者 tab 到 result、切 scope、focus 不見了。&lt;/p>
&lt;p>&lt;strong>第一個該查的&lt;/strong>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">apply&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">activeBefore&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">activeElement&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="c1">// ... 套用 scope filter
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">activeBefore&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">getComputedStyle&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">activeBefore&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">display&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="s1">&amp;#39;none&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 該元素被隱藏、focus 移到下一個可見的同類元素
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">var&lt;/span> &lt;span class="nx">nextResult&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">findNextVisibleResult&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">activeBefore&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">nextResult&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">nextResult&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">focus&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">else&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">focus&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="c1">// 沒有下一個就回到 search input
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>明確處理「focus 元素被隱藏時去哪」、不留給瀏覽器預設行為。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>JS 移動或隱藏 DOM 元素時、鍵盤 focus 的命運要主動處理 — 不處理會跑掉或停在不可見元素上、鍵盤使用者瞬間迷失方向。</strong> 多數動態 UI 的 focus 問題不是「某個元素該 focusable」、是「某個變動沒考慮 focus 該去哪」。</p>
<hr>
<h2 id="為什麼-focus-管理需要主動處理">為什麼 focus 管理需要主動處理</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>鍵盤使用者依 focus 知道「現在在哪」。focus 變動有三種來源：</p>
<table>
  <thead>
      <tr>
          <th>來源</th>
          <th>含義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>使用者主動（Tab、Enter、方向鍵）</td>
          <td>預期、無需處理</td>
      </tr>
      <tr>
          <td>Focus 元素被移除</td>
          <td>focus 跳到 body — 使用者迷失</td>
      </tr>
      <tr>
          <td>Focus 元素被 reparent</td>
          <td>看瀏覽器、可能 focus 仍在元素上、可能掉失</td>
      </tr>
  </tbody>
</table>
<p>第二、三類是 JS 變動 DOM 引起的副作用、開發者要主動處理。</p>
<h3 id="三類-dom-變動對-focus-的影響">三類 DOM 變動對 focus 的影響</h3>
<table>
  <thead>
      <tr>
          <th>變動類型</th>
          <th>Focus 行為</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>整節點 reparent（appendChild）</td>
          <td>視瀏覽器、Chrome 多半保留 focus、Safari 可能掉</td>
      </tr>
      <tr>
          <td>節點 remove</td>
          <td>focus 跳到 body</td>
      </tr>
      <tr>
          <td>節點 display: none</td>
          <td>focus 跳到 body</td>
      </tr>
      <tr>
          <td>節點 visibility: hidden</td>
          <td>focus 仍在但元素不可見、使用者迷失</td>
      </tr>
  </tbody>
</table>
<p>每類有對應的處理 — 主要是「事前 save、事後 restore」。</p>
<hr>
<h2 id="搜尋頁的具體風險點">搜尋頁的具體風險點</h2>
<h3 id="風險-1filter-slot-跨-viewport-切換">風險 1：Filter slot 跨 viewport 切換</h3>
<p><strong>位置</strong>：matchMedia callback 的 <code>place()</code> 函式。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">place</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">mql</span><span class="p">.</span><span class="nx">matches</span><span class="p">)</span> <span class="nx">slot</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">filter</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">else</span> <span class="nx">drawer</span><span class="p">.</span><span class="nx">insertBefore</span><span class="p">(</span><span class="nx">filter</span><span class="p">,</span> <span class="nx">drawer</span><span class="p">.</span><span class="nx">firstChild</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><strong>判讀</strong>：使用者鍵盤 focus 在 filter 內某個 checkbox、視窗 resize 跨過 1400px、<code>appendChild</code> 把 filter 整個搬到別處。理論上 focus 跟著節點走、實際視瀏覽器。</p>
<p><strong>症狀</strong>：使用者按 tab 進到 filter checkbox、調視窗寬度跨 breakpoint、focus 突然在 body 或其他位置。</p>
<p><strong>第一個該查的</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">place</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">var</span> <span class="nx">activeBefore</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">activeElement</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">mql</span><span class="p">.</span><span class="nx">matches</span><span class="p">)</span> <span class="nx">slot</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">filter</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">else</span> <span class="nx">drawer</span><span class="p">.</span><span class="nx">insertBefore</span><span class="p">(</span><span class="nx">filter</span><span class="p">,</span> <span class="nx">drawer</span><span class="p">.</span><span class="nx">firstChild</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="c1">// 嘗試還原 focus
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span>  <span class="k">if</span> <span class="p">(</span><span class="nx">activeBefore</span> <span class="o">&amp;&amp;</span> <span class="nx">filter</span><span class="p">.</span><span class="nx">contains</span><span class="p">(</span><span class="nx">activeBefore</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nx">activeBefore</span><span class="p">.</span><span class="nx">focus</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>activeElement</code> 在 reparent 前後仍指向同一個 DOM 節點（如果 focus 在 filter 內）。明確 <code>.focus()</code> 確保視覺一致。</p>
<h3 id="風險-2scope-filter-隱藏當前-focus-元素">風險 2：Scope filter 隱藏當前 focus 元素</h3>
<p><strong>位置</strong>：scope filter 的 <code>apply()</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">items</span><span class="p">.</span><span class="nx">forEach</span><span class="p">(</span><span class="kd">function</span> <span class="p">(</span><span class="nx">el</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">el</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nx">toggle</span><span class="p">(</span><span class="s1">&#39;is-scope-filtered&#39;</span><span class="p">,</span> <span class="o">!</span><span class="nx">show</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p><strong>判讀</strong>：若使用者 focus 在某個 result（例如標題連結）、切換 scope 後該 result 被隱藏（display: none）— focus 跳到 body。</p>
<p><strong>症狀</strong>：使用者 tab 到 result、切 scope、focus 不見了。</p>
<p><strong>第一個該查的</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">function</span> <span class="nx">apply</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">var</span> <span class="nx">activeBefore</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">activeElement</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="c1">// ... 套用 scope filter
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span>  <span class="k">if</span> <span class="p">(</span><span class="nx">activeBefore</span> <span class="o">&amp;&amp;</span> <span class="nx">getComputedStyle</span><span class="p">(</span><span class="nx">activeBefore</span><span class="p">).</span><span class="nx">display</span> <span class="o">===</span> <span class="s1">&#39;none&#39;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="c1">// 該元素被隱藏、focus 移到下一個可見的同類元素
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span>    <span class="kd">var</span> <span class="nx">nextResult</span> <span class="o">=</span> <span class="nx">findNextVisibleResult</span><span class="p">(</span><span class="nx">activeBefore</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">nextResult</span><span class="p">)</span> <span class="nx">nextResult</span><span class="p">.</span><span class="nx">focus</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">else</span> <span class="nx">input</span><span class="p">.</span><span class="nx">focus</span><span class="p">();</span>   <span class="c1">// 沒有下一個就回到 search input
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span>  <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>明確處理「focus 元素被隱藏時去哪」、不留給瀏覽器預設行為。</p>
<h3 id="風險-3pagefind-重繪結果時-focus-流失">風險 3：Pagefind 重繪結果時 focus 流失</h3>
<p><strong>位置</strong>：使用者改 query 時、pagefind 重新渲染結果列表。</p>
<p><strong>判讀</strong>：若使用者 tab 到第 1 個結果、修改 query、pagefind 替換整個結果列表 — 第 1 個結果被 remove、focus 跳到 body。</p>
<p><strong>症狀</strong>：使用者打字過程中、tab 順序時不時被打回起點。</p>
<p><strong>第一個該查的</strong>：這個情境較難解 — 框架管的 DOM 我們不能干預。可行的做法：</p>
<ul>
<li>使用者打字時通常在 input 上、focus 不在結果列表 — 影響面小</li>
<li>若真有需要、用 tabindex / aria-activedescendant 模擬 focus 但不實際 focus DOM</li>
</ul>
<h3 id="風險-4載入-pagefind-ui-時-focus-行為">風險 4：載入 pagefind UI 時 focus 行為</h3>
<p><strong>位置</strong>：頁面載入後 PagefindUI mount 約 200-500ms。</p>
<p><strong>判讀</strong>：使用者開啟搜尋頁、瀏覽器把 focus 放 body、使用者按 tab — 應該到搜尋輸入框。</p>
<p><strong>症狀</strong>：使用者開頁面立刻按 tab、focus 跳到網站其他部分（nav、其他 link）、不是搜尋框。</p>
<p><strong>第一個該查的</strong>：考慮頁面載入後自動 focus 搜尋輸入框（auto-focus）— 對搜尋頁是合理 UX、不是干擾。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">waitForElement</span><span class="p">(</span><span class="nx">searchRoot</span><span class="p">,</span> <span class="s1">&#39;.pagefind-ui__search-input&#39;</span><span class="p">,</span> <span class="kd">function</span> <span class="p">(</span><span class="nx">input</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">input</span><span class="p">.</span><span class="nx">focus</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><hr>
<h2 id="內在屬性比較四種-focus-處理策略">內在屬性比較：四種 focus 處理策略</h2>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>維護成本</th>
          <th>涵蓋情境</th>
          <th>風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>不處理（瀏覽器預設）</td>
          <td>低</td>
          <td>簡單情境</td>
          <td>focus 掉失常見</td>
      </tr>
      <tr>
          <td>Save / restore activeElement</td>
          <td>中</td>
          <td>DOM 移動、隱藏</td>
          <td>大多有效</td>
      </tr>
      <tr>
          <td>用 tabindex / aria-activedescendant 模擬 focus</td>
          <td>高</td>
          <td>框架管的 DOM</td>
          <td>較複雜、視框架行為</td>
      </tr>
      <tr>
          <td>Auto-focus 關鍵元素</td>
          <td>低</td>
          <td>頁面載入、modal 開啟</td>
          <td>使用者預期才適用</td>
      </tr>
  </tbody>
</table>
<p>選擇順序：<strong>簡單變動用 save / restore；framework 管的 DOM 用模擬 focus；關鍵元素用 auto-focus</strong>。</p>
<hr>
<h2 id="盤點-focus-影響的具體步驟">盤點 focus 影響的具體步驟</h2>
<p>對每個 JS 變動 DOM 的位置、列三個問題：</p>
<ol>
<li><strong>這個變動會 reparent / remove / hide 哪些元素？</strong></li>
<li><strong>這些元素有可能是當前 focus 嗎？</strong> （form input、checkbox、link 都是常見 focusable）</li>
<li><strong>若是、focus 該去哪？</strong> （restore / next sibling / 預設位置）</li>
</ol>
<p>回答完三題、變動前後加 save / restore 邏輯。</p>
<hr>
<h2 id="設計取捨dom-變動時的-focus-處理策略">設計取捨：DOM 變動時的 focus 處理策略</h2>
<p>四種做法、各自機會成本不同。這個專案選 A（save / restore activeElement）當預設、其他做法在特定情境合理。</p>
<h3 id="asave--restore-activeelement這個專案的預設">A：Save / restore activeElement（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：JS 變動 DOM 前 <code>var activeBefore = document.activeElement</code>、變動後 <code>activeBefore.focus()</code></li>
<li><strong>選 A 的理由</strong>：跨瀏覽器一致、簡單元件移動 / 顯隱都涵蓋</li>
<li><strong>適合</strong>：自家管的 DOM 變動（reparent、display: none、remove）</li>
<li><strong>代價</strong>：每個變動位置要顯式加 save / restore 邏輯（用 helper 包裝可一行）</li>
</ul>
<h3 id="b不處理依瀏覽器預設">B：不處理（依瀏覽器預設）</h3>
<ul>
<li><strong>機制</strong>：JS 變動 DOM、不主動處理 focus</li>
<li><strong>跟 A 的取捨</strong>：B 簡單、A 有額外邏輯；但 B 結果不一致（Chrome / Safari 不同）、多數預設是「focus 跳 body」、使用者迷失</li>
<li><strong>B 才合理的情境</strong>：純展示元素變動（沒有 focusable 子元素）— 不會發生 focus 掉失</li>
</ul>
<h3 id="c用-tabindex--aria-activedescendant-模擬-focus">C：用 tabindex / aria-activedescendant 模擬 focus</h3>
<ul>
<li><strong>機制</strong>：focus 物理上不動、用 attribute 標記「邏輯 focus」</li>
<li><strong>跟 A 的取捨</strong>：C 比 A 複雜、但能處理 framework 管的 DOM（無法 save / restore）</li>
<li><strong>C 比 A 好的情境</strong>：framework 持續重繪元素 identity、save / restore 失敗 — 用 attribute 表達 focus</li>
</ul>
<h3 id="dauto-focus-關鍵元素">D：Auto-focus 關鍵元素</h3>
<ul>
<li><strong>機制</strong>：頁面載入後 / modal 開啟後自動 focus 預期的第一個元素</li>
<li><strong>跟 A 的取捨</strong>：D 不依變動觸發、A 對應變動處理；D 適合「使用者預期」的初始 focus</li>
<li><strong>D 比 A 好的情境</strong>：搜尋頁載入 → focus search input、modal 開啟 → focus 第一個 input — 使用者預期的場景才用</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該檢查的位置</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>鍵盤使用者 tab 中途 focus 突然跳走</td>
          <td>該時點是否有 JS 變動 DOM</td>
      </tr>
      <tr>
          <td>Resize 視窗後 focus 不見</td>
          <td>matchMedia callback 內加 save / restore</td>
      </tr>
      <tr>
          <td>切 filter / mode 後 focus 在 body</td>
          <td>apply 函式內處理被隱藏元素的 focus</td>
      </tr>
      <tr>
          <td>開頁面立刻按 tab 跳到不對位置</td>
          <td>評估是否該 auto-focus 主要互動元素</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：JS 變動 DOM = focus 副作用。每個變動位置都該回答「focus 該去哪」、不留給瀏覽器預設。</p>
<p>跟 <a href="../loading-empty-end-state-distinction/">#57 Loading / Empty / End 三狀態</a> 共骨：兩者都是「狀態變動需要回答對應的 UX 問題」 — #57 講「使用者看到的訊號」、本卡講「鍵盤使用者的 focus 位置」。動態 UI 設計 = 狀態變動 + 狀態變動的 UX + 狀態變動的 a11y 三個維度同時設計。</p>
]]></content:encoded></item><item><title>Screen reader 與動態內容變動的 live region 設計</title><link>https://tarrragon.github.io/blog/report/aria-live-for-dynamic-content/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/aria-live-for-dynamic-content/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>動態內容變動對螢幕報讀軟體使用者預設不可見 — 要主動透過 aria-live region 把變動「廣播」給輔助技術。&lt;/strong> 沒 live region 的 UI 在視覺使用者眼裡很流暢、在 screen reader 使用者眼裡是「靜悄悄變了什麼但我不知道」。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼動態內容需要主動廣播">為什麼動態內容需要主動廣播&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>Screen reader 的工作模式：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>動作&lt;/th>
 &lt;th>screen reader 行為&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>頁面載入&lt;/td>
 &lt;td>朗讀整個 main 內容（或使用者導航位置）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>使用者按 tab&lt;/td>
 &lt;td>朗讀新 focus 元素&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>使用者按方向鍵&lt;/td>
 &lt;td>朗讀附近元素&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>DOM 變動但 focus 沒動&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>預設不朗讀&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>第四種是動態 UI 的常見情境 — 使用者在 search input 上、結果列表變動 — screen reader 預設不知道。&lt;/p>
&lt;p>&lt;code>aria-live&lt;/code> 屬性告訴 screen reader「這個區域內容變動時、主動朗讀變動」。沒 aria-live、變動就沉默。&lt;/p>
&lt;h3 id="三類動態變動">三類動態變動&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>變動類型&lt;/th>
 &lt;th>是否需要廣播&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>使用者主動觸發、focus 跟著走&lt;/td>
 &lt;td>否（focus 變動會朗讀新位置）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>使用者主動觸發、focus 沒動&lt;/td>
 &lt;td>是 — 用 &lt;code>aria-live=&amp;quot;polite&amp;quot;&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>重要警示（錯誤訊息、警告）&lt;/td>
 &lt;td>是 — 用 &lt;code>aria-live=&amp;quot;assertive&amp;quot;&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;code>polite&lt;/code> 等使用者當前朗讀完才宣告、&lt;code>assertive&lt;/code> 立刻打斷 — 多數場景用 polite。&lt;/p>
&lt;hr>
&lt;h2 id="搜尋頁的具體風險點">搜尋頁的具體風險點&lt;/h2>
&lt;h3 id="風險-1scope-filter-切換後沒提示">風險 1：Scope filter 切換後沒提示&lt;/h3>
&lt;p>&lt;strong>位置&lt;/strong>：scope filter 的 &lt;code>apply()&lt;/code> — 改變 result 顯示後沒有 aria 提示。&lt;/p>
&lt;p>&lt;strong>判讀&lt;/strong>：使用者切換 scope（標題 / 內文 / 全部）、UI 上結果數量變了 — screen reader 完全不知道。使用者可能繼續以為「189 筆結果」、實際只剩 4 筆。&lt;/p>
&lt;p>&lt;strong>症狀&lt;/strong>：screen reader 使用者切 scope 後、tab 到結果區、發現跟剛才不同、困惑。&lt;/p>
&lt;p>&lt;strong>第一個該查的&lt;/strong>：加 aria-live region 在 scope UI 旁邊、apply 後寫入訊息。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-html" data-lang="html">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;search-scope&amp;#34;&lt;/span> &lt;span class="na">role&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;radiogroup&amp;#34;&lt;/span> &lt;span class="na">aria-label&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;搜尋範圍&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="c">&amp;lt;!-- radios... --&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;search-scope-status&amp;#34;&lt;/span> &lt;span class="na">aria-live&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;polite&amp;#34;&lt;/span> &lt;span class="na">aria-atomic&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;true&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">apply&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="c1">// ... filter 邏輯
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">var&lt;/span> &lt;span class="nx">visible&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">items&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">filter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="o">!&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">classList&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">contains&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;is-scope-filtered&amp;#39;&lt;/span>&lt;span class="p">)).&lt;/span>&lt;span class="nx">length&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-scope-status&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">textContent&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;篩選後 &amp;#39;&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nx">visible&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s1">&amp;#39; 筆結果&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>aria-atomic=&amp;quot;true&amp;quot;&lt;/code> 確保整個訊息每次都完整朗讀（而非只朗讀差異）。&lt;/p>
&lt;h3 id="風險-2搜尋結果載入完成沒提示">風險 2：搜尋結果載入完成沒提示&lt;/h3>
&lt;p>&lt;strong>位置&lt;/strong>：使用者打字、pagefind 載入結果 — UI 上 result 出現、但 screen reader 不知道載入完成。&lt;/p>
&lt;p>&lt;strong>判讀&lt;/strong>：使用者打字結束、預期「結果出來了」— 但需要主動 tab 過去確認、不像視覺使用者一眼看到。&lt;/p>
&lt;p>&lt;strong>症狀&lt;/strong>：使用者打字後不知道結果是否準備好、不知道是否該 tab 過去。&lt;/p>
&lt;p>&lt;strong>第一個該查的&lt;/strong>：pagefind 自身可能已實作 aria-live；若未、加一個 region 在結果區上方。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-html" data-lang="html">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;search-results-status&amp;#34;&lt;/span> &lt;span class="na">aria-live&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;polite&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">new&lt;/span> &lt;span class="nx">MutationObserver&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">count&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">items&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">length&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">status&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">textContent&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">count&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s1">&amp;#39; 筆結果符合搜尋&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">}).&lt;/span>&lt;span class="nx">observe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">resultsRoot&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">childList&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span> &lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="風險-3filter-變動後沒提示">風險 3：Filter 變動後沒提示&lt;/h3>
&lt;p>&lt;strong>位置&lt;/strong>：使用者勾選 / 取消 filter checkbox、pagefind 自動更新結果。&lt;/p>
&lt;p>&lt;strong>判讀&lt;/strong>：勾選某個 tag、結果列表變動 — screen reader 看不到變動、若 focus 還在 checkbox 也沒朗讀。&lt;/p>
&lt;p>&lt;strong>症狀&lt;/strong>：螢幕報讀軟體使用者勾 filter、不知道有沒有效果。&lt;/p>
&lt;p>&lt;strong>第一個該查的&lt;/strong>：同上、aria-live region 反映「N 筆結果符合篩選」。&lt;/p>
&lt;h3 id="風險-4無結果訊息">風險 4：「無結果」訊息&lt;/h3>
&lt;p>&lt;strong>位置&lt;/strong>：搜尋字找不到任何結果。&lt;/p>
&lt;p>&lt;strong>判讀&lt;/strong>：頁面顯示「找不到 X 相關內容」、screen reader 若 focus 還在 input 不會朗讀。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>動態內容變動對螢幕報讀軟體使用者預設不可見 — 要主動透過 aria-live region 把變動「廣播」給輔助技術。</strong> 沒 live region 的 UI 在視覺使用者眼裡很流暢、在 screen reader 使用者眼裡是「靜悄悄變了什麼但我不知道」。</p>
<hr>
<h2 id="為什麼動態內容需要主動廣播">為什麼動態內容需要主動廣播</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>Screen reader 的工作模式：</p>
<table>
  <thead>
      <tr>
          <th>動作</th>
          <th>screen reader 行為</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>頁面載入</td>
          <td>朗讀整個 main 內容（或使用者導航位置）</td>
      </tr>
      <tr>
          <td>使用者按 tab</td>
          <td>朗讀新 focus 元素</td>
      </tr>
      <tr>
          <td>使用者按方向鍵</td>
          <td>朗讀附近元素</td>
      </tr>
      <tr>
          <td><strong>DOM 變動但 focus 沒動</strong></td>
          <td><strong>預設不朗讀</strong></td>
      </tr>
  </tbody>
</table>
<p>第四種是動態 UI 的常見情境 — 使用者在 search input 上、結果列表變動 — screen reader 預設不知道。</p>
<p><code>aria-live</code> 屬性告訴 screen reader「這個區域內容變動時、主動朗讀變動」。沒 aria-live、變動就沉默。</p>
<h3 id="三類動態變動">三類動態變動</h3>
<table>
  <thead>
      <tr>
          <th>變動類型</th>
          <th>是否需要廣播</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>使用者主動觸發、focus 跟著走</td>
          <td>否（focus 變動會朗讀新位置）</td>
      </tr>
      <tr>
          <td>使用者主動觸發、focus 沒動</td>
          <td>是 — 用 <code>aria-live=&quot;polite&quot;</code></td>
      </tr>
      <tr>
          <td>重要警示（錯誤訊息、警告）</td>
          <td>是 — 用 <code>aria-live=&quot;assertive&quot;</code></td>
      </tr>
  </tbody>
</table>
<p><code>polite</code> 等使用者當前朗讀完才宣告、<code>assertive</code> 立刻打斷 — 多數場景用 polite。</p>
<hr>
<h2 id="搜尋頁的具體風險點">搜尋頁的具體風險點</h2>
<h3 id="風險-1scope-filter-切換後沒提示">風險 1：Scope filter 切換後沒提示</h3>
<p><strong>位置</strong>：scope filter 的 <code>apply()</code> — 改變 result 顯示後沒有 aria 提示。</p>
<p><strong>判讀</strong>：使用者切換 scope（標題 / 內文 / 全部）、UI 上結果數量變了 — screen reader 完全不知道。使用者可能繼續以為「189 筆結果」、實際只剩 4 筆。</p>
<p><strong>症狀</strong>：screen reader 使用者切 scope 後、tab 到結果區、發現跟剛才不同、困惑。</p>
<p><strong>第一個該查的</strong>：加 aria-live region 在 scope UI 旁邊、apply 後寫入訊息。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;search-scope&#34;</span> <span class="na">role</span><span class="o">=</span><span class="s">&#34;radiogroup&#34;</span> <span class="na">aria-label</span><span class="o">=</span><span class="s">&#34;搜尋範圍&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="c">&lt;!-- radios... --&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;search-scope-status&#34;</span> <span class="na">aria-live</span><span class="o">=</span><span class="s">&#34;polite&#34;</span> <span class="na">aria-atomic</span><span class="o">=</span><span class="s">&#34;true&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">apply</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="c1">// ... filter 邏輯
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="kd">var</span> <span class="nx">visible</span> <span class="o">=</span> <span class="nx">items</span><span class="p">.</span><span class="nx">filter</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="o">!</span><span class="nx">el</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nx">contains</span><span class="p">(</span><span class="s1">&#39;is-scope-filtered&#39;</span><span class="p">)).</span><span class="nx">length</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-scope-status&#39;</span><span class="p">).</span><span class="nx">textContent</span> <span class="o">=</span> <span class="s1">&#39;篩選後 &#39;</span> <span class="o">+</span> <span class="nx">visible</span> <span class="o">+</span> <span class="s1">&#39; 筆結果&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>aria-atomic=&quot;true&quot;</code> 確保整個訊息每次都完整朗讀（而非只朗讀差異）。</p>
<h3 id="風險-2搜尋結果載入完成沒提示">風險 2：搜尋結果載入完成沒提示</h3>
<p><strong>位置</strong>：使用者打字、pagefind 載入結果 — UI 上 result 出現、但 screen reader 不知道載入完成。</p>
<p><strong>判讀</strong>：使用者打字結束、預期「結果出來了」— 但需要主動 tab 過去確認、不像視覺使用者一眼看到。</p>
<p><strong>症狀</strong>：使用者打字後不知道結果是否準備好、不知道是否該 tab 過去。</p>
<p><strong>第一個該查的</strong>：pagefind 自身可能已實作 aria-live；若未、加一個 region 在結果區上方。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;search-results-status&#34;</span> <span class="na">aria-live</span><span class="o">=</span><span class="s">&#34;polite&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(</span><span class="kd">function</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">var</span> <span class="nx">count</span> <span class="o">=</span> <span class="nx">items</span><span class="p">.</span><span class="nx">length</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">status</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="nx">count</span> <span class="o">+</span> <span class="s1">&#39; 筆結果符合搜尋&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}).</span><span class="nx">observe</span><span class="p">(</span><span class="nx">resultsRoot</span><span class="p">,</span> <span class="p">{</span> <span class="nx">childList</span><span class="o">:</span> <span class="kc">true</span> <span class="p">});</span></span></span></code></pre></div><h3 id="風險-3filter-變動後沒提示">風險 3：Filter 變動後沒提示</h3>
<p><strong>位置</strong>：使用者勾選 / 取消 filter checkbox、pagefind 自動更新結果。</p>
<p><strong>判讀</strong>：勾選某個 tag、結果列表變動 — screen reader 看不到變動、若 focus 還在 checkbox 也沒朗讀。</p>
<p><strong>症狀</strong>：螢幕報讀軟體使用者勾 filter、不知道有沒有效果。</p>
<p><strong>第一個該查的</strong>：同上、aria-live region 反映「N 筆結果符合篩選」。</p>
<h3 id="風險-4無結果訊息">風險 4：「無結果」訊息</h3>
<p><strong>位置</strong>：搜尋字找不到任何結果。</p>
<p><strong>判讀</strong>：頁面顯示「找不到 X 相關內容」、screen reader 若 focus 還在 input 不會朗讀。</p>
<p><strong>症狀</strong>：screen reader 使用者打字後沒任何回應、不知道是「無結果」還是「還在搜尋」。</p>
<p><strong>第一個該查的</strong>：把「無結果」訊息放 aria-live region 內、變動時自動朗讀。</p>
<hr>
<h2 id="live-region-的設計選擇">Live region 的設計選擇</h2>
<h3 id="polite-vs-assertive"><code>polite</code> vs <code>assertive</code></h3>
<table>
  <thead>
      <tr>
          <th>屬性</th>
          <th>行為</th>
          <th>適用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>aria-live=&quot;polite&quot;</code></td>
          <td>等使用者當前朗讀完才宣告</td>
          <td>多數動態變動</td>
      </tr>
      <tr>
          <td><code>aria-live=&quot;assertive&quot;</code></td>
          <td>立刻打斷使用者朗讀</td>
          <td>錯誤、警告、緊急訊息</td>
      </tr>
  </tbody>
</table>
<p>優先 polite — assertive 容易打斷使用者、感覺很突兀。</p>
<h3 id="aria-atomic"><code>aria-atomic</code></h3>
<table>
  <thead>
      <tr>
          <th>屬性</th>
          <th>行為</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>aria-atomic=&quot;false&quot;</code>（預設）</td>
          <td>只朗讀變動的部分</td>
      </tr>
      <tr>
          <td><code>aria-atomic=&quot;true&quot;</code></td>
          <td>整個 region 內容完整朗讀</td>
      </tr>
  </tbody>
</table>
<p>對「N 筆結果」這類固定格式訊息、用 <code>aria-atomic=&quot;true&quot;</code> 確保使用者聽到完整脈絡（不只朗讀數字變動）。</p>
<h3 id="aria-relevant"><code>aria-relevant</code></h3>
<p>預設只朗讀「新增 / 文字變動」、不朗讀「移除」。多數情境用預設即可。</p>
<hr>
<h2 id="內在屬性比較四種動態內容廣播策略">內在屬性比較：四種動態內容廣播策略</h2>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>涵蓋情境</th>
          <th>維護成本</th>
          <th>適用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>不處理（沉默）</td>
          <td>不適用</td>
          <td>0</td>
          <td>不適用</td>
      </tr>
      <tr>
          <td><code>aria-live=&quot;polite&quot;</code></td>
          <td>大多數動態變動</td>
          <td>低 — 加 div 與 textContent 寫入</td>
          <td>預設</td>
      </tr>
      <tr>
          <td><code>aria-live=&quot;assertive&quot;</code></td>
          <td>緊急訊息</td>
          <td>低</td>
          <td>錯誤 / 警告</td>
      </tr>
      <tr>
          <td><code>role=&quot;status&quot;</code> / <code>role=&quot;alert&quot;</code></td>
          <td>semantic 角色明確</td>
          <td>低</td>
          <td>純 status / alert 元素</td>
      </tr>
  </tbody>
</table>
<p>優先選 <code>aria-live=&quot;polite&quot;</code> + <code>aria-atomic=&quot;true&quot;</code>、廣覆蓋且不打擾。</p>
<hr>
<h2 id="live-region-的常見錯誤">Live region 的常見錯誤</h2>
<h3 id="1-動態建立-region">1. 動態建立 region</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">status</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">createElement</span><span class="p">(</span><span class="s1">&#39;div&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">status</span><span class="p">.</span><span class="nx">setAttribute</span><span class="p">(</span><span class="s1">&#39;aria-live&#39;</span><span class="p">,</span> <span class="s1">&#39;polite&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">status</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="s1">&#39;...&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">status</span><span class="p">);</span></span></span></code></pre></div><p>不會生效 — screen reader 在 region 出現「之後」變動才朗讀、region 從無到有的瞬間不算。</p>
<p>正確：region 在 HTML 預先存在、JS 只更新內容。</p>
<h3 id="2-全頁加一個共用-region">2. 全頁加一個共用 region</h3>
<p>可能導致訊息混淆 — 不同 source 的訊息共用同一個 region、難以追蹤。每個語意區域有自己的 region 較清楚。</p>
<h3 id="3-太頻繁的訊息">3. 太頻繁的訊息</h3>
<p>每次變動都廣播 — 使用者被 spam。Debounce + 重複內容跳過。</p>
<hr>
<h2 id="設計取捨動態內容廣播策略">設計取捨：動態內容廣播策略</h2>
<p>四種做法、各自機會成本不同。這個專案選 A（aria-live polite + aria-atomic）當預設、其他做法在特定情境合理。</p>
<h3 id="aaria-livepolite--aria-atomictrue這個專案的預設">A：<code>aria-live=&quot;polite&quot;</code> + <code>aria-atomic=&quot;true&quot;</code>（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：region 預先在 HTML、JS 寫入 textContent 觸發 polite 朗讀（等使用者當前朗讀完）</li>
<li><strong>選 A 的理由</strong>：覆蓋多數動態變動、不打擾使用者當前操作</li>
<li><strong>適合</strong>：搜尋結果數量變動、filter 切換、scope 改變等大多數 UI 變動</li>
<li><strong>代價</strong>：訊息要等使用者當前朗讀完才聽到（最多幾秒延遲）</li>
</ul>
<h3 id="baria-liveassertive">B：<code>aria-live=&quot;assertive&quot;</code></h3>
<ul>
<li><strong>機制</strong>：立刻打斷使用者當前朗讀、強制聽新訊息</li>
<li><strong>跟 A 的取捨</strong>：B 即時、A 禮貌；但 B 打斷感強、頻繁使用會讓使用者疲勞</li>
<li><strong>B 比 A 好的情境</strong>：真正緊急的訊息（錯誤 / 警告 / 安全提示）— 必須立刻知道</li>
</ul>
<h3 id="crolestatus--rolealert">C：<code>role=&quot;status&quot;</code> / <code>role=&quot;alert&quot;</code></h3>
<ul>
<li><strong>機制</strong>：用 semantic role 取代 aria-live、語意更明確</li>
<li><strong>跟 A 的取捨</strong>：C 跟 A 行為類似（status = polite、alert = assertive）、但 role 表達意圖更清楚</li>
<li><strong>C 比 A 好的情境</strong>：region 本身就是 status 或 alert 元素（語意對齊）</li>
</ul>
<h3 id="d不處理沉默">D：不處理（沉默）</h3>
<ul>
<li><strong>機制</strong>：DOM 變動不通知 screen reader</li>
<li><strong>成本特別高的原因</strong>：screen reader 使用者完全不知道有變動、UI 變得不可用</li>
<li><strong>D 才合理的情境</strong>：純視覺裝飾變動（背景動畫 / decorative）— 對 screen reader 使用者無意義</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該檢查的位置</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Screen reader 使用者反映「不知道有沒有發生事」</td>
          <td>找出該變動位置、加 aria-live region</td>
      </tr>
      <tr>
          <td>動態 UI 沒任何 aria-live</td>
          <td>列出所有 focus 不跟著走的變動、各自評估是否需要</td>
      </tr>
      <tr>
          <td>Live region 朗讀但聽起來只有片段</td>
          <td>加 <code>aria-atomic=&quot;true&quot;</code></td>
      </tr>
      <tr>
          <td>訊息太頻繁打擾</td>
          <td>Debounce、跳過重複</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：UI 上「使用者沒主動觸發但有變動」的位置、screen reader 預設沉默 — 用 aria-live region 把沉默變成可聽見。</p>
<p>跟 <a href="../loading-empty-end-state-distinction/">#57 Loading / Empty / End 三狀態的區分</a> 同源：兩者都是「狀態變動需要告知使用者」、aria-live 告訴的是 screen reader、#57 講的是視覺區分。<strong>完整的狀態變動 UX = 視覺區分 + aria-live 廣播</strong>。</p>
]]></content:encoded></item><item><title>Native HTML element 優先於 ARIA role 的取捨</title><link>https://tarrragon.github.io/blog/report/native-html-over-aria-role/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/native-html-over-aria-role/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>有 native HTML element 提供的語意與行為時、永遠優先用 native — ARIA role 是「沒有 native 對應時的 fallback」、不是設計起點。&lt;/strong> Native element 自帶 keyboard、focus、screen reader 行為；ARIA 是給作者宣告 semantic 的工具、需要作者自己補完所有行為。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼-native-永遠優先">為什麼 native 永遠優先&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>ARIA 規範自己有一條 first rule：&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>First Rule of ARIA&lt;/strong>: If you can use a native HTML element with the semantics and behavior you require already built in, instead of re-purposing an element and adding an ARIA role, do so.&lt;/p>&lt;/blockquote>
&lt;p>理由是 native element 提供「semantic + behavior」雙包裝：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>包裝層&lt;/th>
 &lt;th>Native element&lt;/th>
 &lt;th>ARIA role&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Semantic（screen reader 知道是什麼）&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>是&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>鍵盤行為&lt;/td>
 &lt;td>是（瀏覽器內建）&lt;/td>
 &lt;td>否（要作者自己寫）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Focus 行為&lt;/td>
 &lt;td>是（tab order、:focus）&lt;/td>
 &lt;td>否（要作者管 tabindex）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Form 整合&lt;/td>
 &lt;td>是（form submission、validation）&lt;/td>
 &lt;td>否&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>跨瀏覽器一致&lt;/td>
 &lt;td>高（標準行為）&lt;/td>
 &lt;td>中（看 screen reader 解讀）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>ARIA role 只貼 semantic 標籤、不送行為 — 用 ARIA 等於承擔「補完所有行為」的責任。&lt;/p>
&lt;h3 id="何時-aria-是必要">何時 ARIA 是必要&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>情境&lt;/th>
 &lt;th>ARIA 必要&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Native element 有對應功能&lt;/td>
 &lt;td>否 — 用 native&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>需要 semantic 但沒 native 對應&lt;/td>
 &lt;td>是 — 用 ARIA role&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>加強 native element 的描述（aria-label）&lt;/td>
 &lt;td>是 — ARIA 補強、不取代&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>動態狀態（aria-expanded、aria-checked）&lt;/td>
 &lt;td>是 — native 表達不了&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>ARIA 的設計用途是「補強 native」、不是「取代 native」。&lt;/p>
&lt;hr>
&lt;h2 id="搜尋頁的具體風險點">搜尋頁的具體風險點&lt;/h2>
&lt;h3 id="風險-1scope-ui-用-div-roleradiogroup-而非-fieldset">風險 1：Scope UI 用 &lt;code>div role=&amp;quot;radiogroup&amp;quot;&lt;/code> 而非 &lt;code>fieldset&lt;/code>&lt;/h3>
&lt;p>&lt;strong>位置&lt;/strong>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-html" data-lang="html">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;search-scope&amp;#34;&lt;/span> &lt;span class="na">role&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;radiogroup&amp;#34;&lt;/span> &lt;span class="na">aria-label&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;搜尋範圍&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">label&lt;/span>&lt;span class="p">&amp;gt;&amp;lt;&lt;/span>&lt;span class="nt">input&lt;/span> &lt;span class="na">type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;radio&amp;#34;&lt;/span> &lt;span class="na">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;search-scope&amp;#34;&lt;/span> &lt;span class="na">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;all&amp;#34;&lt;/span> &lt;span class="na">checked&lt;/span>&lt;span class="p">&amp;gt;&amp;lt;&lt;/span>&lt;span class="nt">span&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>全部&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">span&lt;/span>&lt;span class="p">&amp;gt;&amp;lt;/&lt;/span>&lt;span class="nt">label&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="c">&amp;lt;!-- ... --&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>判讀&lt;/strong>：&lt;code>&amp;lt;div role=&amp;quot;radiogroup&amp;quot;&amp;gt;&lt;/code> 給 screen reader 看到「這是 radio group」、但作者要自己保證：&lt;/p>
&lt;ul>
&lt;li>鍵盤方向鍵在選項間切換&lt;/li>
&lt;li>Tab 行為符合 radiogroup 慣例（tab 進到 group、方向鍵在內切換、tab 出 group）&lt;/li>
&lt;li>aria-required / aria-invalid 等狀態同步&lt;/li>
&lt;/ul>
&lt;p>&lt;code>&amp;lt;fieldset&amp;gt;&amp;lt;legend&amp;gt;&lt;/code> 是 native element：&lt;/p>
&lt;ul>
&lt;li>自帶 group semantic&lt;/li>
&lt;li>legend 自動關聯為 group label&lt;/li>
&lt;li>內部 &lt;code>&amp;lt;input type=&amp;quot;radio&amp;quot; name=&amp;quot;X&amp;quot;&amp;gt;&lt;/code> 已是完整 radiogroup（HTML 內建）&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>症狀&lt;/strong>：screen reader 可能不認得自訂 radiogroup、無法用方向鍵切換。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>有 native HTML element 提供的語意與行為時、永遠優先用 native — ARIA role 是「沒有 native 對應時的 fallback」、不是設計起點。</strong> Native element 自帶 keyboard、focus、screen reader 行為；ARIA 是給作者宣告 semantic 的工具、需要作者自己補完所有行為。</p>
<hr>
<h2 id="為什麼-native-永遠優先">為什麼 native 永遠優先</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>ARIA 規範自己有一條 first rule：</p>
<blockquote>
<p><strong>First Rule of ARIA</strong>: If you can use a native HTML element with the semantics and behavior you require already built in, instead of re-purposing an element and adding an ARIA role, do so.</p></blockquote>
<p>理由是 native element 提供「semantic + behavior」雙包裝：</p>
<table>
  <thead>
      <tr>
          <th>包裝層</th>
          <th>Native element</th>
          <th>ARIA role</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Semantic（screen reader 知道是什麼）</td>
          <td>是</td>
          <td>是</td>
      </tr>
      <tr>
          <td>鍵盤行為</td>
          <td>是（瀏覽器內建）</td>
          <td>否（要作者自己寫）</td>
      </tr>
      <tr>
          <td>Focus 行為</td>
          <td>是（tab order、:focus）</td>
          <td>否（要作者管 tabindex）</td>
      </tr>
      <tr>
          <td>Form 整合</td>
          <td>是（form submission、validation）</td>
          <td>否</td>
      </tr>
      <tr>
          <td>跨瀏覽器一致</td>
          <td>高（標準行為）</td>
          <td>中（看 screen reader 解讀）</td>
      </tr>
  </tbody>
</table>
<p>ARIA role 只貼 semantic 標籤、不送行為 — 用 ARIA 等於承擔「補完所有行為」的責任。</p>
<h3 id="何時-aria-是必要">何時 ARIA 是必要</h3>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>ARIA 必要</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Native element 有對應功能</td>
          <td>否 — 用 native</td>
      </tr>
      <tr>
          <td>需要 semantic 但沒 native 對應</td>
          <td>是 — 用 ARIA role</td>
      </tr>
      <tr>
          <td>加強 native element 的描述（aria-label）</td>
          <td>是 — ARIA 補強、不取代</td>
      </tr>
      <tr>
          <td>動態狀態（aria-expanded、aria-checked）</td>
          <td>是 — native 表達不了</td>
      </tr>
  </tbody>
</table>
<p>ARIA 的設計用途是「補強 native」、不是「取代 native」。</p>
<hr>
<h2 id="搜尋頁的具體風險點">搜尋頁的具體風險點</h2>
<h3 id="風險-1scope-ui-用-div-roleradiogroup-而非-fieldset">風險 1：Scope UI 用 <code>div role=&quot;radiogroup&quot;</code> 而非 <code>fieldset</code></h3>
<p><strong>位置</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;search-scope&#34;</span> <span class="na">role</span><span class="o">=</span><span class="s">&#34;radiogroup&#34;</span> <span class="na">aria-label</span><span class="o">=</span><span class="s">&#34;搜尋範圍&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="p">&lt;</span><span class="nt">label</span><span class="p">&gt;&lt;</span><span class="nt">input</span> <span class="na">type</span><span class="o">=</span><span class="s">&#34;radio&#34;</span> <span class="na">name</span><span class="o">=</span><span class="s">&#34;search-scope&#34;</span> <span class="na">value</span><span class="o">=</span><span class="s">&#34;all&#34;</span> <span class="na">checked</span><span class="p">&gt;&lt;</span><span class="nt">span</span><span class="p">&gt;</span>全部<span class="p">&lt;/</span><span class="nt">span</span><span class="p">&gt;&lt;/</span><span class="nt">label</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="c">&lt;!-- ... --&gt;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div><p><strong>判讀</strong>：<code>&lt;div role=&quot;radiogroup&quot;&gt;</code> 給 screen reader 看到「這是 radio group」、但作者要自己保證：</p>
<ul>
<li>鍵盤方向鍵在選項間切換</li>
<li>Tab 行為符合 radiogroup 慣例（tab 進到 group、方向鍵在內切換、tab 出 group）</li>
<li>aria-required / aria-invalid 等狀態同步</li>
</ul>
<p><code>&lt;fieldset&gt;&lt;legend&gt;</code> 是 native element：</p>
<ul>
<li>自帶 group semantic</li>
<li>legend 自動關聯為 group label</li>
<li>內部 <code>&lt;input type=&quot;radio&quot; name=&quot;X&quot;&gt;</code> 已是完整 radiogroup（HTML 內建）</li>
</ul>
<p><strong>症狀</strong>：screen reader 可能不認得自訂 radiogroup、無法用方向鍵切換。</p>
<p><strong>第一個該查的</strong>：用 NVDA / VoiceOver 進入 radiogroup、按方向鍵看是否能切換。失敗則改用 fieldset。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">fieldset</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;search-scope&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="p">&lt;</span><span class="nt">legend</span><span class="p">&gt;</span>搜尋範圍<span class="p">&lt;/</span><span class="nt">legend</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">&lt;</span><span class="nt">label</span><span class="p">&gt;&lt;</span><span class="nt">input</span> <span class="na">type</span><span class="o">=</span><span class="s">&#34;radio&#34;</span> <span class="na">name</span><span class="o">=</span><span class="s">&#34;search-scope&#34;</span> <span class="na">value</span><span class="o">=</span><span class="s">&#34;all&#34;</span> <span class="na">checked</span><span class="p">&gt;</span> 全部<span class="p">&lt;/</span><span class="nt">label</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="p">&lt;</span><span class="nt">label</span><span class="p">&gt;&lt;</span><span class="nt">input</span> <span class="na">type</span><span class="o">=</span><span class="s">&#34;radio&#34;</span> <span class="na">name</span><span class="o">=</span><span class="s">&#34;search-scope&#34;</span> <span class="na">value</span><span class="o">=</span><span class="s">&#34;title&#34;</span><span class="p">&gt;</span> 標題<span class="p">&lt;/</span><span class="nt">label</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="p">&lt;</span><span class="nt">label</span><span class="p">&gt;&lt;</span><span class="nt">input</span> <span class="na">type</span><span class="o">=</span><span class="s">&#34;radio&#34;</span> <span class="na">name</span><span class="o">=</span><span class="s">&#34;search-scope&#34;</span> <span class="na">value</span><span class="o">=</span><span class="s">&#34;content&#34;</span><span class="p">&gt;</span> 內文<span class="p">&lt;/</span><span class="nt">label</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">&lt;/</span><span class="nt">fieldset</span><span class="p">&gt;</span></span></span></code></pre></div><p><code>name=&quot;search-scope&quot;</code> 同名讓三個 radio 自動成為 group、HTML 自帶方向鍵切換。</p>
<h3 id="風險-2用-div-onclick-取代-button">風險 2：用 <code>&lt;div onclick&gt;</code> 取代 <code>&lt;button&gt;</code></h3>
<p><strong>位置</strong>：自訂按鈕 UI（搜尋頁未必有、但常見 anti-pattern）。</p>
<p><strong>判讀</strong>：</p>
<ul>
<li><code>&lt;button&gt;</code> 自帶 enter / space 觸發、tab focus、disabled 狀態</li>
<li><code>&lt;div onclick&gt;</code> 只有 click 事件、鍵盤無法觸發、tab 不會 focus</li>
</ul>
<p><strong>症狀</strong>：鍵盤使用者無法操作該 UI。</p>
<p><strong>第一個該查的</strong>：找 <code>&lt;div onclick&gt;</code> / <code>&lt;span onclick&gt;</code> 的 pattern、改為 <code>&lt;button&gt;</code>。</p>
<h3 id="風險-3pagefind-自身的-aria-實作">風險 3：Pagefind 自身的 ARIA 實作</h3>
<p><strong>位置</strong>：Pagefind 的 <code>&lt;details&gt;&lt;summary&gt;</code> filter blocks。</p>
<p><strong>判讀</strong>：</p>
<ul>
<li><code>&lt;details&gt;</code> / <code>&lt;summary&gt;</code> 是 native element、自帶 expand / collapse、enter 切換</li>
<li>Pagefind 包了 <code>.pagefind-ui__filter-name</code> class 但底層仍是 native — 行為跟著</li>
<li>這是好的設計、不需要動</li>
</ul>
<p><strong>症狀</strong>：rare、native element 多半 OK。</p>
<p><strong>第一個該查的</strong>：確認 Pagefind 沒用 div+role 重新實作這些 — 從 source 看大致符合 native first principle。</p>
<h3 id="風險-4search-input-用-input-typesearch-還是-input-typetext">風險 4：Search input 用 <code>&lt;input type=&quot;search&quot;&gt;</code> 還是 <code>&lt;input type=&quot;text&quot;&gt;</code></h3>
<p><strong>位置</strong>：Pagefind 自身的 input。</p>
<p><strong>判讀</strong>：</p>
<ul>
<li><code>&lt;input type=&quot;search&quot;&gt;</code> 在 mobile 顯示「搜尋」鍵盤、自帶清除按鈕</li>
<li><code>&lt;input type=&quot;text&quot;&gt;</code> 純文字輸入</li>
</ul>
<p><strong>症狀</strong>：mobile 鍵盤不適配搜尋場景、額外清除 UI 自己做。</p>
<p><strong>第一個該查的</strong>：確認 Pagefind 用 <code>type=&quot;search&quot;</code>。從 pagefind-ui 渲染結果可看到 <code>type=&quot;text&quot;</code>、有自訂的清除按鈕 — 可考慮是否值得改。</p>
<hr>
<h2 id="內在屬性比較四種實作-radio-group-的方式">內在屬性比較：四種實作 radio group 的方式</h2>
<table>
  <thead>
      <tr>
          <th>實作</th>
          <th>鍵盤切換</th>
          <th>screen reader 認</th>
          <th>維護成本</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>&lt;fieldset&gt;&lt;legend&gt;</code> + <code>&lt;input type=&quot;radio&quot;&gt;</code> × N</td>
          <td>是 — HTML 內建</td>
          <td>是 — fieldset semantic</td>
          <td>低</td>
      </tr>
      <tr>
          <td><code>&lt;div role=&quot;radiogroup&quot;&gt;</code> + <code>&lt;input type=&quot;radio&quot;&gt;</code> × N</td>
          <td>是 — input radio 自帶</td>
          <td>部分 — div role 跟 input semantic 重複</td>
          <td>中</td>
      </tr>
      <tr>
          <td><code>&lt;div role=&quot;radiogroup&quot;&gt;</code> + <code>&lt;div role=&quot;radio&quot;&gt;</code> × N</td>
          <td>否 — 要自己寫</td>
          <td>是 — 但需作者完整實作 ARIA pattern</td>
          <td>高</td>
      </tr>
      <tr>
          <td>純自訂無 ARIA</td>
          <td>否</td>
          <td>否</td>
          <td>不適用</td>
      </tr>
  </tbody>
</table>
<p>優先順序：<strong>fieldset &gt; div role + native input &gt; div role + div role</strong>。</p>
<hr>
<h2 id="aria-使用的判斷流程">ARIA 使用的判斷流程</h2>
<p>每個 UI 元素開始實作前、走這個流程：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">1. 有沒有 native element 對應？
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">   是 → 用 native（fieldset、button、input、details / summary）
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">   否 → 進 2
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">2. 有沒有 ARIA pattern 對應？
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">   是 → 用 div + role + 完整 ARIA 屬性 + 自己寫鍵盤行為
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">   否 → 進 3
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">3. 用 div + 自己想 semantic
</span></span><span class="line"><span class="ln">10</span><span class="cl">   注意：可能 screen reader 不認得、需要充分測試</span></span></code></pre></div><p>多數情境停在 1 — native HTML 涵蓋常見 UI 模式。需要走到 2、3 的場景比想像中少。</p>
<hr>
<h2 id="設計取捨實作-ui-元素的策略">設計取捨：實作 UI 元素的策略</h2>
<p>四種做法、各自機會成本不同。這個專案永遠優先選 A（native HTML element）— 不夠用才退到 B / C / D。</p>
<h3 id="a純-native-html-element永遠的首選">A：純 native HTML element（永遠的首選）</h3>
<ul>
<li><strong>機制</strong>：用 <code>&lt;button&gt;</code>、<code>&lt;fieldset&gt;&lt;legend&gt;</code>、<code>&lt;details&gt;&lt;summary&gt;</code>、<code>&lt;input type=&quot;search&quot;&gt;</code> 等 native 元素</li>
<li><strong>選 A 的理由</strong>：semantic + 鍵盤 + focus + form 整合「四件套」自帶、跨瀏覽器一致、跨 screen reader 一致</li>
<li><strong>適合</strong>：所有 native 涵蓋的 UI 模式（按鈕、表單、disclosure、radio group）</li>
<li><strong>代價</strong>：受 native 視覺預設限制、客製樣式可能要對抗 UA 預設</li>
</ul>
<h3 id="bnative--aria-補強aria-label--aria-describedby--aria-expanded">B：Native + ARIA 補強（aria-label / aria-describedby / aria-expanded）</h3>
<ul>
<li><strong>機制</strong>：native element 加 ARIA 屬性補強 semantic 或表達動態狀態</li>
<li><strong>跟 A 的取捨</strong>：B 在 A 的基礎上加細節、不取代</li>
<li><strong>B 比 A 好的情境</strong>：native 已涵蓋主要功能、需要補額外資訊（label、描述）或動態狀態（expanded / pressed / checked）</li>
</ul>
<h3 id="cdiv-rolex--完整-aria-pattern--自寫鍵盤行為">C：<code>&lt;div role=&quot;X&quot;&gt;</code> + 完整 ARIA pattern + 自寫鍵盤行為</h3>
<ul>
<li><strong>機制</strong>：用 div 包成 semantic 元素、加 role + 完整 ARIA + JS 補鍵盤</li>
<li><strong>跟 A 的取捨</strong>：C 給更高客製彈性、A 拿成熟方案；C 維護成本高（要自己保證所有行為）</li>
<li><strong>C 比 A 好的情境</strong>：native 沒對應的 UI 模式（complex tree view、custom slider）— 必須自己定義 semantic</li>
</ul>
<h3 id="d純-div--自訂-semantic無-aria">D：純 div + 自訂 semantic（無 ARIA）</h3>
<ul>
<li><strong>機制</strong>：用 div 自己想 semantic、不加 role</li>
<li><strong>成本特別高的原因</strong>：screen reader 不認得、鍵盤無法操作、違反 a11y 標準</li>
<li><strong>D 是反模式</strong>：違反 a11y 標準（屬合規 / 法規層）— 純視覺裝飾元素（無互動）才能例外</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>Refactor 動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>用 <code>&lt;div role=&quot;X&quot;&gt;</code> 取代有 native 的 element</td>
          <td>評估改用 native、減少 ARIA 維護</td>
      </tr>
      <tr>
          <td>自訂 UI 鍵盤無法操作</td>
          <td>改用 native button / input、自帶鍵盤行為</td>
      </tr>
      <tr>
          <td>自訂 form 元素跟 form submission 不整合</td>
          <td>改用 native input、自動加入 form data</td>
      </tr>
      <tr>
          <td>Screen reader 不一致地解讀 ARIA</td>
          <td>改用 native、多數 screen reader 對 native 一致</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：ARIA 的 first rule 是「能用 native 就不用 ARIA」。Native element 是 50 年累積的瀏覽器 + 輔助技術知識結晶、不要繞道。</p>
]]></content:encoded></item><item><title>視覺輔助：對比度、放大、字型 zoom 的 layout 適配</title><link>https://tarrragon.github.io/blog/report/visual-aids-contrast-zoom-responsive/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/visual-aids-contrast-zoom-responsive/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>視覺輔助使用者跟一般使用者「看到的不是同一個 UI」 — 對比度、放大倍率、字型尺寸調整都會把版面變形。&lt;/strong> 設計時先盤點「在這些變形下、UI 還能用嗎」、不需要等到使用者反映。WCAG 提供量化標準、可以在開發階段驗證。&lt;/p>
&lt;blockquote>
&lt;p>本篇焦點：&lt;strong>視覺呈現面的 a11y&lt;/strong>（對比 / 放大 / 字型 zoom）。&lt;/p>
&lt;ul>
&lt;li>&lt;strong>鍵盤使用者的 a11y&lt;/strong>（focus indicator / tab 順序）由 &lt;a href="../keyboard-accessibility/">#52 鍵盤可達性&lt;/a> 處理&lt;/li>
&lt;li>&lt;strong>行動 / motor 使用者的 a11y&lt;/strong>（hit target / 點擊精準度）由 &lt;a href="../motor-accessibility-hit-target/">#53 Motor 可達性&lt;/a> 處理&lt;/li>
&lt;/ul>&lt;/blockquote>
&lt;hr>
&lt;h2 id="為什麼視覺輔助需要獨立盤點">為什麼視覺輔助需要獨立盤點&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>視覺輔助使用者的需求多元：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>情境&lt;/th>
 &lt;th>需求&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>色弱（colour blindness）&lt;/td>
 &lt;td>不依賴顏色區分資訊&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>低對比敏感&lt;/td>
 &lt;td>文字 vs 背景對比足夠&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>低視力（low vision）&lt;/td>
 &lt;td>字大、可放大、layout 不破&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>老花、暫時視覺受限&lt;/td>
 &lt;td>字大、清楚的視覺層次&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每類觸發不同的 CSS 行為。一個 UI 在標準視窗看起來 OK、放大 200% 後可能：&lt;/p>
&lt;ul>
&lt;li>字超出容器&lt;/li>
&lt;li>Absolute 定位元件跑到視窗外&lt;/li>
&lt;li>對比度被覆蓋（dark mode / 高對比模式）&lt;/li>
&lt;/ul>
&lt;p>WCAG（Web Content Accessibility Guidelines）提供量化標準（對比度 AA ≥ 4.5:1、放大 200% 不橫向 scroll）— 可在開發階段測量。&lt;/p>
&lt;h3 id="視覺呈現的三維度">視覺呈現的三維度&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>變形方式&lt;/th>
 &lt;th>開發階段檢查方法&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>色彩&lt;/td>
 &lt;td>dark mode / 高對比模式 / 色弱模擬&lt;/td>
 &lt;td>DevTools Contrast Ratio + Emulate Vision Deficiencies&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>整體 zoom&lt;/td>
 &lt;td>瀏覽器 zoom 200% / OS 放大鏡&lt;/td>
 &lt;td>Cmd + 5 次、macOS Zoom 4x&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>字型 zoom&lt;/td>
 &lt;td>OS Display Scale（只放大字型不放大 box）&lt;/td>
 &lt;td>OS 設定 Larger Text&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>三維度獨立、要分開檢查 — 一維度過 ≠ 全部過。&lt;/p>
&lt;hr>
&lt;h2 id="風險點-1搜尋結果-highlight-對比度">風險點 1：搜尋結果 highlight 對比度&lt;/h2>
&lt;p>&lt;strong>位置&lt;/strong>：Pagefind 高亮命中關鍵字（黃底）。&lt;/p>
&lt;p>&lt;strong>判讀&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>預設 &lt;code>--pagefind-ui-tag&lt;/code> = &lt;code>#eeeeee&lt;/code>（淺灰）— 文字 &lt;code>#393939&lt;/code>（深灰）、對比 ~9:1、合格&lt;/li>
&lt;li>但搜尋頁 dark mode 下、theme 可能讓文字變淺色 — 對淺底要驗證&lt;/li>
&lt;li>色弱使用者看不出哪個字是 highlight（若僅靠顏色區分）&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>WCAG 標準&lt;/strong>：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>等級&lt;/th>
 &lt;th>對比度要求&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>AA 一般文字&lt;/td>
 &lt;td>≥ 4.5:1&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AA 大字（≥ 18pt 或 14pt bold）&lt;/td>
 &lt;td>≥ 3:1&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AAA 一般文字&lt;/td>
 &lt;td>≥ 7:1&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>第一個該查的&lt;/strong>：用 Chrome DevTools 的 Contrast Ratio 工具量 highlight 區域的「背景 vs 文字」對比。不足則覆寫 &lt;code>--pagefind-ui-tag&lt;/code> 變數。&lt;/p>
&lt;p>&lt;strong>雙重保險&lt;/strong>：除了顏色、加 underline 或 bold 區分 highlight — 色弱使用者不靠顏色也能辨識。&lt;/p>
&lt;hr>
&lt;h2 id="風險點-2absolute-定位元件在放大模式下跑到視窗外">風險點 2：Absolute 定位元件在放大模式下跑到視窗外&lt;/h2>
&lt;p>&lt;strong>位置&lt;/strong>：&lt;code>.search-filter-slot { position: absolute; right: calc(100% + 2rem); }&lt;/code>。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>視覺輔助使用者跟一般使用者「看到的不是同一個 UI」 — 對比度、放大倍率、字型尺寸調整都會把版面變形。</strong> 設計時先盤點「在這些變形下、UI 還能用嗎」、不需要等到使用者反映。WCAG 提供量化標準、可以在開發階段驗證。</p>
<blockquote>
<p>本篇焦點：<strong>視覺呈現面的 a11y</strong>（對比 / 放大 / 字型 zoom）。</p>
<ul>
<li><strong>鍵盤使用者的 a11y</strong>（focus indicator / tab 順序）由 <a href="../keyboard-accessibility/">#52 鍵盤可達性</a> 處理</li>
<li><strong>行動 / motor 使用者的 a11y</strong>（hit target / 點擊精準度）由 <a href="../motor-accessibility-hit-target/">#53 Motor 可達性</a> 處理</li>
</ul></blockquote>
<hr>
<h2 id="為什麼視覺輔助需要獨立盤點">為什麼視覺輔助需要獨立盤點</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>視覺輔助使用者的需求多元：</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>需求</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>色弱（colour blindness）</td>
          <td>不依賴顏色區分資訊</td>
      </tr>
      <tr>
          <td>低對比敏感</td>
          <td>文字 vs 背景對比足夠</td>
      </tr>
      <tr>
          <td>低視力（low vision）</td>
          <td>字大、可放大、layout 不破</td>
      </tr>
      <tr>
          <td>老花、暫時視覺受限</td>
          <td>字大、清楚的視覺層次</td>
      </tr>
  </tbody>
</table>
<p>每類觸發不同的 CSS 行為。一個 UI 在標準視窗看起來 OK、放大 200% 後可能：</p>
<ul>
<li>字超出容器</li>
<li>Absolute 定位元件跑到視窗外</li>
<li>對比度被覆蓋（dark mode / 高對比模式）</li>
</ul>
<p>WCAG（Web Content Accessibility Guidelines）提供量化標準（對比度 AA ≥ 4.5:1、放大 200% 不橫向 scroll）— 可在開發階段測量。</p>
<h3 id="視覺呈現的三維度">視覺呈現的三維度</h3>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>變形方式</th>
          <th>開發階段檢查方法</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>色彩</td>
          <td>dark mode / 高對比模式 / 色弱模擬</td>
          <td>DevTools Contrast Ratio + Emulate Vision Deficiencies</td>
      </tr>
      <tr>
          <td>整體 zoom</td>
          <td>瀏覽器 zoom 200% / OS 放大鏡</td>
          <td>Cmd + 5 次、macOS Zoom 4x</td>
      </tr>
      <tr>
          <td>字型 zoom</td>
          <td>OS Display Scale（只放大字型不放大 box）</td>
          <td>OS 設定 Larger Text</td>
      </tr>
  </tbody>
</table>
<p>三維度獨立、要分開檢查 — 一維度過 ≠ 全部過。</p>
<hr>
<h2 id="風險點-1搜尋結果-highlight-對比度">風險點 1：搜尋結果 highlight 對比度</h2>
<p><strong>位置</strong>：Pagefind 高亮命中關鍵字（黃底）。</p>
<p><strong>判讀</strong>：</p>
<ul>
<li>預設 <code>--pagefind-ui-tag</code> = <code>#eeeeee</code>（淺灰）— 文字 <code>#393939</code>（深灰）、對比 ~9:1、合格</li>
<li>但搜尋頁 dark mode 下、theme 可能讓文字變淺色 — 對淺底要驗證</li>
<li>色弱使用者看不出哪個字是 highlight（若僅靠顏色區分）</li>
</ul>
<p><strong>WCAG 標準</strong>：</p>
<table>
  <thead>
      <tr>
          <th>等級</th>
          <th>對比度要求</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>AA 一般文字</td>
          <td>≥ 4.5:1</td>
      </tr>
      <tr>
          <td>AA 大字（≥ 18pt 或 14pt bold）</td>
          <td>≥ 3:1</td>
      </tr>
      <tr>
          <td>AAA 一般文字</td>
          <td>≥ 7:1</td>
      </tr>
  </tbody>
</table>
<p><strong>第一個該查的</strong>：用 Chrome DevTools 的 Contrast Ratio 工具量 highlight 區域的「背景 vs 文字」對比。不足則覆寫 <code>--pagefind-ui-tag</code> 變數。</p>
<p><strong>雙重保險</strong>：除了顏色、加 underline 或 bold 區分 highlight — 色弱使用者不靠顏色也能辨識。</p>
<hr>
<h2 id="風險點-2absolute-定位元件在放大模式下跑到視窗外">風險點 2：Absolute 定位元件在放大模式下跑到視窗外</h2>
<p><strong>位置</strong>：<code>.search-filter-slot { position: absolute; right: calc(100% + 2rem); }</code>。</p>
<p><strong>判讀</strong>：</p>
<ul>
<li>Absolute 定位相對 main 計算</li>
<li>使用者用 OS 螢幕放大鏡（macOS Zoom）放大 4x 看 main 中央</li>
<li>main 仍在視窗範圍、但 absolute filter 在 main 左外側 — 放大 4x 後可能完全跑到視窗左邊看不見</li>
</ul>
<p><strong>症狀</strong>：低視力使用者用放大鏡時、不知道 filter 存在、無法操作。</p>
<p><strong>第一個該查的</strong>：用 macOS 的 Zoom 功能（System Settings &gt; Accessibility &gt; Zoom）放大 4x、看 filter 是否仍在可達範圍。</p>
<p><strong>修正方向</strong>：</p>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>機制</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>放大模式 fallback 到 mobile layout</td>
          <td><code>@media</code> 偵測 prefers-reduced-motion / 高 zoom level</td>
      </tr>
      <tr>
          <td>Filter 移到頁面內 flow（不用 absolute）</td>
          <td>跟主要內容一起 reflow、不會跑外</td>
      </tr>
      <tr>
          <td>加 floating button「展開 filter」</td>
          <td>任何 zoom level 都可達</td>
      </tr>
  </tbody>
</table>
<p>詳細展開由 <a href="../minimum-necessary-scope-is-sanity-defense/">#43 最小必要範圍</a> + <a href="../runtime-measurement-unification/">#27 runtime 量測模式統一</a> 補充。</p>
<hr>
<h2 id="風險點-3字型放大-200-後-layout-破壞">風險點 3：字型放大 200% 後 layout 破壞</h2>
<p><strong>位置</strong>：所有寫死 px 高度的元素（H1、search input、filter slot padding）。</p>
<p><strong>判讀</strong>：</p>
<ul>
<li>使用者用瀏覽器 zoom（Cmd +）通常等比放大 — 字 + box 一起放大、layout 不破</li>
<li>但 OS Display Scale（macOS Display &gt; Larger Text）只放大字型不放大 box — 字撐爆寫死的 64px 高度</li>
</ul>
<p>當 H1 字撐到 80px、寫死 height: 64px 的 box — 字被裁切。</p>
<p><strong>症狀</strong>：低視力使用者開啟「文字放大」設定、UI 字被裁。</p>
<p><strong>第一個該查的</strong>：開瀏覽器 zoom 200%、看 layout 是否變橫向 scroll（破壞）或仍 reflow（OK）。</p>
<p><strong>WCAG 標準</strong>：1.4.4 Resize text — zoom 至 200% 時不需要橫向 scroll。</p>
<p><strong>修正方向</strong>：</p>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>機制</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>用 <code>min-height</code> 取代 <code>height</code></td>
          <td>box 可隨字撐高、不裁切</td>
      </tr>
      <tr>
          <td>用 <code>em</code> / <code>rem</code> 取代 <code>px</code></td>
          <td>跟字型一起 scale</td>
      </tr>
      <tr>
          <td>用 ResizeObserver 量字型實際高度寫回變數</td>
          <td>跟 <a href="../runtime-measurement-unification/">#27 runtime 量測模式統一</a> 同框架</td>
      </tr>
  </tbody>
</table>
<p>預設用 <code>min-height</code> + 相對單位、特殊精準對齊才用 ResizeObserver。</p>
<hr>
<h2 id="設計取捨layout-適應字型放大">設計取捨：layout 適應字型放大</h2>
<p>當「對齊精度」與「字型放大相容性」衝突、四種做法：</p>
<h3 id="a用-min-height--相對單位這個專案的預設">A：用 <code>min-height</code> + 相對單位（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：<code>min-height: 4rem</code>、box 隨字撐高、用 <code>em</code> / <code>rem</code> 跟著 scale</li>
<li><strong>選 A 的理由</strong>：字型放大時 layout 自然 reflow、不裁切</li>
<li><strong>適合</strong>：絕大多數 UI 元素、不需要極精準對齊</li>
<li><strong>代價</strong>：對齊精度受字型 metrics 影響、難以做 pixel-perfect 對齊</li>
</ul>
<h3 id="b寫死-height--resizeobserver-量測補償">B：寫死 <code>height</code> + ResizeObserver 量測補償</h3>
<ul>
<li><strong>機制</strong>：<code>height: 64px</code>、用 ResizeObserver 量實際渲染高度寫回 CSS 變數、其他依賴此值的元素跟著調</li>
<li><strong>跟 A 的取捨</strong>：B 達到 pixel-perfect 對齊、A 信任 reflow；B 多一層 JS 量測、A 純 CSS</li>
<li><strong>B 比 A 好的情境</strong>：對齊精度是 UX 核心（搜尋頁的視覺對齊）、字型可預期</li>
</ul>
<h3 id="c寫死-height--不處理字型放大">C：寫死 <code>height</code> + 不處理字型放大</h3>
<ul>
<li><strong>機制</strong>：<code>height: 64px</code>、不管字型放大</li>
<li><strong>成本特別高的原因</strong>：字型放大時 UI 被裁切、低視力使用者無法用</li>
<li><strong>C 才合理的情境</strong>：UI 不會被字型放大影響（純圖示、無文字）</li>
</ul>
<h3 id="d用-clampmin-ideal-max-限制字型大小">D：用 <code>clamp(min, ideal, max)</code> 限制字型大小</h3>
<ul>
<li><strong>機制</strong>：字型 <code>font-size: clamp(0.875rem, 1rem, 1.125rem)</code>、限制使用者放大範圍</li>
<li><strong>跟 A/B/C 的取捨</strong>：D 主動限制字型放大範圍、違反 WCAG 1.4.4</li>
<li><strong>D 是反模式</strong>：違反 WCAG 1.4.4 — 強制限制字型放大是反 a11y、低視力使用者完全無法調整</li>
</ul>
<hr>
<h2 id="開發階段檢查清單">開發階段檢查清單</h2>
<p>每個視覺輔助項目對應一個檢查動作：</p>
<table>
  <thead>
      <tr>
          <th>檢查</th>
          <th>動作</th>
          <th>WCAG 等級</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>對比度</td>
          <td>DevTools Inspect Element &gt; Contrast Ratio 看每個文字區域</td>
          <td>AA 必要</td>
      </tr>
      <tr>
          <td>色彩可辨</td>
          <td>DevTools Rendering &gt; Emulate Vision Deficiencies</td>
          <td>AA 建議</td>
      </tr>
      <tr>
          <td>Zoom 200%</td>
          <td>瀏覽器 Cmd + 5 次、看是否仍可用、無橫向 scroll</td>
          <td>AA 必要</td>
      </tr>
      <tr>
          <td>OS 字型放大</td>
          <td>macOS Display &gt; Text Size &gt; 大、看 layout</td>
          <td>AA 建議</td>
      </tr>
      <tr>
          <td>螢幕放大鏡</td>
          <td>macOS Zoom 4x、看絕對定位元件是否在可達範圍</td>
          <td>AA 建議</td>
      </tr>
  </tbody>
</table>
<p>每個 ~30 秒、開發完成前跑一輪、抓常見問題。</p>
<hr>
<h2 id="設計取捨色彩區分策略">設計取捨：色彩區分策略</h2>
<p>當資訊需要區分（hit / miss、selected / unselected）、四種做法：</p>
<h3 id="a顏色--形狀--位置雙重區分這個專案的預設">A：顏色 + 形狀 / 位置雙重區分（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：highlight 用黃底 + bold；selected radio 用色彩 + ✓ 圖示</li>
<li><strong>選 A 的理由</strong>：色弱使用者不靠顏色仍能辨識</li>
<li><strong>適合</strong>：絕大多數需要區分資訊的場景</li>
<li><strong>代價</strong>：UI 多一層視覺裝飾</li>
</ul>
<h3 id="b純顏色區分">B：純顏色區分</h3>
<ul>
<li><strong>機制</strong>：紅色 = 錯、綠色 = 對</li>
<li><strong>跟 A 的取捨</strong>：B 視覺乾淨、A 對色弱友善；B 違反 WCAG 1.4.1 Use of Color</li>
<li><strong>B 是反模式</strong>：違反 WCAG 1.4.1 Use of Color（合規層） — 色弱使用者完全無法區分對 / 錯</li>
</ul>
<h3 id="c純形狀--位置區分無顏色">C：純形狀 / 位置區分（無顏色）</h3>
<ul>
<li><strong>機制</strong>：用 ✓ / ✗ / 位置區分、不靠顏色</li>
<li><strong>跟 A 的取捨</strong>：C 對色彩無關、A 對視力正常使用者更直覺</li>
<li><strong>C 比 A 好的情境</strong>：列印 / 黑白渲染環境</li>
</ul>
<h3 id="d使用者可自訂顏色">D：使用者可自訂顏色</h3>
<ul>
<li><strong>機制</strong>：透過 CSS variable 讓使用者覆寫色彩</li>
<li><strong>跟 A 的取捨</strong>：D 提供無限彈性、實作成本高</li>
<li><strong>D 才合理的情境</strong>：core a11y 工具（如 reading mode）</li>
</ul>
<hr>
<h2 id="跟其他原則的關係">跟其他原則的關係</h2>
<table>
  <thead>
      <tr>
          <th>抽象層原則</th>
          <th>關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="../minimum-necessary-scope-is-sanity-defense/">#43 最小必要範圍</a></td>
          <td>字型放大下 layout 適配是「不依賴特定渲染條件」的應用</td>
      </tr>
      <tr>
          <td><a href="../single-source-of-truth/">#44 SSoT</a></td>
          <td>CSS 變數提供主題切換、變數住址唯一才能正確覆寫色彩</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該檢查的位置</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>色弱使用者反映找不到資訊</td>
          <td>DevTools Contrast Ratio + Emulate Vision Deficiencies</td>
      </tr>
      <tr>
          <td>低視力使用者反映 UI 跑到視窗外</td>
          <td>用螢幕放大鏡放 4x 確認 absolute 元件位置</td>
      </tr>
      <tr>
          <td>字型放大後 UI 破</td>
          <td>用瀏覽器 zoom 200% 與 OS text size 雙測</td>
      </tr>
      <tr>
          <td>Dark mode 下文字看不清</td>
          <td>該主題的對比度未驗證、補測</td>
      </tr>
      <tr>
          <td>「色弱使用者反正不多」當不做的理由</td>
          <td>視覺輔助使用者通常不會反映、只默默離開 — 量化檢查不靠使用者通報</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：視覺輔助使用者用的是「同一份程式、不同的 viewport / colour / scale」。WCAG 提供量化標準、開發階段可測 — 等使用者反映晚了。</p>
]]></content:encoded></item><item><title>鍵盤可達性：focus indicator、tab 順序、escape 路徑</title><link>https://tarrragon.github.io/blog/report/keyboard-accessibility/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/keyboard-accessibility/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>鍵盤使用者導航三要素：focus 可見、tab 順序合理、有 escape 路徑。&lt;/strong> 三者任一缺失、鍵盤使用者就卡住。視覺使用者看不到 focus 也能用滑鼠繼續、鍵盤使用者沒有 fallback。&lt;/p>
&lt;blockquote>
&lt;p>本篇焦點：&lt;strong>鍵盤可達性&lt;/strong>。&lt;/p>
&lt;ul>
&lt;li>&lt;strong>視覺呈現面的 a11y&lt;/strong>（對比 / 放大）由 &lt;a href="../visual-aids-contrast-zoom-responsive/">#40 視覺輔助&lt;/a> 處理&lt;/li>
&lt;li>&lt;strong>行動 / motor 使用者的 a11y&lt;/strong>（hit target）由 &lt;a href="../motor-accessibility-hit-target/">#53 Motor 可達性&lt;/a> 處理&lt;/li>
&lt;li>&lt;strong>DOM 移動時的 focus 處理&lt;/strong>由 &lt;a href="../focus-management-on-dom-move/">#37 focus management on DOM move&lt;/a> 處理（本篇處理「靜態 focus 設計」、#37 處理「動態 focus 移動」）&lt;/li>
&lt;/ul>&lt;/blockquote>
&lt;hr>
&lt;h2 id="為什麼鍵盤可達性需要獨立盤點">為什麼鍵盤可達性需要獨立盤點&lt;/h2>
&lt;h3 id="使用者類型">使用者類型&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>使用者&lt;/th>
 &lt;th>為什麼用鍵盤&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>全盲（screen reader 使用者）&lt;/td>
 &lt;td>完全靠鍵盤、滑鼠看不到游標位置&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>低視力&lt;/td>
 &lt;td>鍵盤比滑鼠精準（不需要瞄準）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Motor 障礙&lt;/td>
 &lt;td>鍵盤比滑鼠手部負擔小&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Power user&lt;/td>
 &lt;td>鍵盤比滑鼠快&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>最後一類占人口比例不小 — 鍵盤可達性對全體使用者都有價值、不只 a11y 使用者。&lt;/p>
&lt;h3 id="三要素的失敗模式">三要素的失敗模式&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>要素&lt;/th>
 &lt;th>失敗模式&lt;/th>
 &lt;th>後果&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Focus 可見&lt;/td>
 &lt;td>&lt;code>outline: 0&lt;/code> 移除預設 focus 但沒補替代&lt;/td>
 &lt;td>鍵盤使用者不知道 focus 在哪、迷失&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Tab 順序&lt;/td>
 &lt;td>順序跟視覺布局不一致&lt;/td>
 &lt;td>跳來跳去、迷失&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Escape 路徑&lt;/td>
 &lt;td>Modal 沒有 ESC 關閉&lt;/td>
 &lt;td>卡在 modal 出不來&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>三者都是「視覺使用者通常不會碰到、鍵盤使用者必碰」— 開發者用滑鼠測 100% OK、鍵盤使用者一進去就壞。&lt;/p>
&lt;hr>
&lt;h2 id="風險點-1focus-indicator-的可見度">風險點 1：Focus indicator 的可見度&lt;/h2>
&lt;p>&lt;strong>位置&lt;/strong>：tab focus 到 search input、scope radio、filter checkbox 等元素。&lt;/p>
&lt;p>&lt;strong>判讀&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>瀏覽器預設 focus outline（藍色 2px）&lt;/li>
&lt;li>某些 theme 用 &lt;code>outline: 0&lt;/code> 移除 — 鍵盤使用者迷失&lt;/li>
&lt;li>自訂 outline 要對比足夠（WCAG 2.4.7、AA 3:1 對比 + 至少 2px 寬）&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>症狀&lt;/strong>：鍵盤使用者 tab 過去看不到 focus 在哪、不知道下一個 enter 會激活誰。&lt;/p>
&lt;p>&lt;strong>第一個該查的&lt;/strong>：用 keyboard tab 過所有互動元素、確認每個都有可見 focus。&lt;/p>
&lt;p>&lt;strong>修正方向&lt;/strong>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-css" data-lang="css">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c">/* 預設 — 信任瀏覽器 outline */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c">/* 不寫 outline: 0 */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c">/* 客製 — 用 :focus-visible（只在鍵盤觸發時顯示、滑鼠點擊不顯示） */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="p">:&lt;/span>&lt;span class="nd">focus-visible&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">outline&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">2&lt;/span>&lt;span class="kt">px&lt;/span> &lt;span class="kc">solid&lt;/span> &lt;span class="kc">currentColor&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">outline-offset&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">2&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c">/* 移除 outline 必須補 box-shadow / border 等替代 */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="nt">button&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="nd">focus&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="k">outline&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="k">box-shadow&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span> &lt;span class="mi">0&lt;/span> &lt;span class="mi">0&lt;/span> &lt;span class="mi">3&lt;/span>&lt;span class="kt">px&lt;/span> &lt;span class="nf">var&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="n">focus&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="kc">color&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>:focus-visible&lt;/code> 是現代做法 — 滑鼠使用者不看到 outline（不會覺得「煩」）、鍵盤使用者看到 outline（必要的回饋）。&lt;/p>
&lt;h3 id="focus-indicator-的對比度">Focus indicator 的對比度&lt;/h3>
&lt;p>WCAG 2.4.11 要求 focus indicator 跟相鄰背景對比 ≥ 3:1：&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>鍵盤使用者導航三要素：focus 可見、tab 順序合理、有 escape 路徑。</strong> 三者任一缺失、鍵盤使用者就卡住。視覺使用者看不到 focus 也能用滑鼠繼續、鍵盤使用者沒有 fallback。</p>
<blockquote>
<p>本篇焦點：<strong>鍵盤可達性</strong>。</p>
<ul>
<li><strong>視覺呈現面的 a11y</strong>（對比 / 放大）由 <a href="../visual-aids-contrast-zoom-responsive/">#40 視覺輔助</a> 處理</li>
<li><strong>行動 / motor 使用者的 a11y</strong>（hit target）由 <a href="../motor-accessibility-hit-target/">#53 Motor 可達性</a> 處理</li>
<li><strong>DOM 移動時的 focus 處理</strong>由 <a href="../focus-management-on-dom-move/">#37 focus management on DOM move</a> 處理（本篇處理「靜態 focus 設計」、#37 處理「動態 focus 移動」）</li>
</ul></blockquote>
<hr>
<h2 id="為什麼鍵盤可達性需要獨立盤點">為什麼鍵盤可達性需要獨立盤點</h2>
<h3 id="使用者類型">使用者類型</h3>
<table>
  <thead>
      <tr>
          <th>使用者</th>
          <th>為什麼用鍵盤</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>全盲（screen reader 使用者）</td>
          <td>完全靠鍵盤、滑鼠看不到游標位置</td>
      </tr>
      <tr>
          <td>低視力</td>
          <td>鍵盤比滑鼠精準（不需要瞄準）</td>
      </tr>
      <tr>
          <td>Motor 障礙</td>
          <td>鍵盤比滑鼠手部負擔小</td>
      </tr>
      <tr>
          <td>Power user</td>
          <td>鍵盤比滑鼠快</td>
      </tr>
  </tbody>
</table>
<p>最後一類占人口比例不小 — 鍵盤可達性對全體使用者都有價值、不只 a11y 使用者。</p>
<h3 id="三要素的失敗模式">三要素的失敗模式</h3>
<table>
  <thead>
      <tr>
          <th>要素</th>
          <th>失敗模式</th>
          <th>後果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Focus 可見</td>
          <td><code>outline: 0</code> 移除預設 focus 但沒補替代</td>
          <td>鍵盤使用者不知道 focus 在哪、迷失</td>
      </tr>
      <tr>
          <td>Tab 順序</td>
          <td>順序跟視覺布局不一致</td>
          <td>跳來跳去、迷失</td>
      </tr>
      <tr>
          <td>Escape 路徑</td>
          <td>Modal 沒有 ESC 關閉</td>
          <td>卡在 modal 出不來</td>
      </tr>
  </tbody>
</table>
<p>三者都是「視覺使用者通常不會碰到、鍵盤使用者必碰」— 開發者用滑鼠測 100% OK、鍵盤使用者一進去就壞。</p>
<hr>
<h2 id="風險點-1focus-indicator-的可見度">風險點 1：Focus indicator 的可見度</h2>
<p><strong>位置</strong>：tab focus 到 search input、scope radio、filter checkbox 等元素。</p>
<p><strong>判讀</strong>：</p>
<ul>
<li>瀏覽器預設 focus outline（藍色 2px）</li>
<li>某些 theme 用 <code>outline: 0</code> 移除 — 鍵盤使用者迷失</li>
<li>自訂 outline 要對比足夠（WCAG 2.4.7、AA 3:1 對比 + 至少 2px 寬）</li>
</ul>
<p><strong>症狀</strong>：鍵盤使用者 tab 過去看不到 focus 在哪、不知道下一個 enter 會激活誰。</p>
<p><strong>第一個該查的</strong>：用 keyboard tab 過所有互動元素、確認每個都有可見 focus。</p>
<p><strong>修正方向</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c">/* 預設 — 信任瀏覽器 outline */</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c">/* 不寫 outline: 0 */</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c">/* 客製 — 用 :focus-visible（只在鍵盤觸發時顯示、滑鼠點擊不顯示） */</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">:</span><span class="nd">focus-visible</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="k">outline</span><span class="p">:</span> <span class="mi">2</span><span class="kt">px</span> <span class="kc">solid</span> <span class="kc">currentColor</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="k">outline-offset</span><span class="p">:</span> <span class="mi">2</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c">/* 移除 outline 必須補 box-shadow / border 等替代 */</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="nt">button</span><span class="p">:</span><span class="nd">focus</span> <span class="p">{</span> <span class="k">outline</span><span class="p">:</span> <span class="mi">0</span><span class="p">;</span> <span class="k">box-shadow</span><span class="p">:</span> <span class="mi">0</span> <span class="mi">0</span> <span class="mi">0</span> <span class="mi">3</span><span class="kt">px</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">focus</span><span class="o">-</span><span class="kc">color</span><span class="p">);</span> <span class="p">}</span></span></span></code></pre></div><p><code>:focus-visible</code> 是現代做法 — 滑鼠使用者不看到 outline（不會覺得「煩」）、鍵盤使用者看到 outline（必要的回饋）。</p>
<h3 id="focus-indicator-的對比度">Focus indicator 的對比度</h3>
<p>WCAG 2.4.11 要求 focus indicator 跟相鄰背景對比 ≥ 3:1：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="c">/* 較差 — 灰底 + 灰 outline、對比不足 */</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">button</span> <span class="p">{</span> <span class="k">background</span><span class="p">:</span> <span class="mh">#f0f0f0</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">.</span><span class="nc">button</span><span class="p">:</span><span class="nd">focus-visible</span> <span class="p">{</span> <span class="k">outline</span><span class="p">:</span> <span class="mi">2</span><span class="kt">px</span> <span class="kc">solid</span> <span class="mh">#cccccc</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c">/* 好 — 跟背景對比足夠 */</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">.</span><span class="nc">button</span><span class="p">:</span><span class="nd">focus-visible</span> <span class="p">{</span> <span class="k">outline</span><span class="p">:</span> <span class="mi">2</span><span class="kt">px</span> <span class="kc">solid</span> <span class="mh">#0066cc</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><hr>
<h2 id="風險點-2tab-順序與視覺布局的對齊">風險點 2：Tab 順序與視覺布局的對齊</h2>
<p><strong>位置</strong>：搜尋頁元素：H1 → search input → scope radio → results → filter sidebar。</p>
<p><strong>判讀</strong>：</p>
<p>預設 tab 順序 = DOM 順序。如果視覺布局跟 DOM 順序不一致（例如 sidebar 在右、但 DOM 在前）、鍵盤使用者體驗：</p>
<ul>
<li>Tab 1：H1（OK）</li>
<li>Tab 2：跑到 sidebar（視覺在右下、鍵盤跳過去）</li>
<li>Tab 3：search input（視覺在左上、鍵盤跳回來）</li>
</ul>
<p><strong>症狀</strong>：鍵盤使用者 tab 順序看似隨機、失去空間感。</p>
<p><strong>第一個該查的</strong>：用 keyboard tab 過所有互動元素、看 focus 移動順序是否符合視覺閱讀順序（左到右、上到下）。</p>
<p><strong>修正方向</strong>：</p>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>機制</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>DOM 順序對齊視覺順序</td>
          <td>改 HTML 結構讓 DOM 順序就是 tab 順序</td>
      </tr>
      <tr>
          <td>用 <code>tabindex</code> 調整順序</td>
          <td>顯式控制 tab 順序（風險：違反 DOM 順序、對 screen reader 仍依 DOM）</td>
      </tr>
      <tr>
          <td>Skip link 跳過長 navigation</td>
          <td>讓鍵盤使用者快速跳到主內容</td>
      </tr>
  </tbody>
</table>
<p>預設選「DOM 順序對齊視覺順序」 — 不需要 <code>tabindex</code>、對所有 a11y 工具都正確。</p>
<h3 id="skip-link-設計">Skip link 設計</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">body</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="p">&lt;</span><span class="nt">a</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;#main&#34;</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;skip-link&#34;</span><span class="p">&gt;</span>跳到主內容<span class="p">&lt;/</span><span class="nt">a</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">&lt;</span><span class="nt">nav</span><span class="p">&gt;</span>...<span class="p">&lt;/</span><span class="nt">nav</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="p">&lt;</span><span class="nt">main</span> <span class="na">id</span><span class="o">=</span><span class="s">&#34;main&#34;</span><span class="p">&gt;</span>...<span class="p">&lt;/</span><span class="nt">main</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">&lt;/</span><span class="nt">body</span><span class="p">&gt;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="p">.</span><span class="nc">skip-link</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="k">position</span><span class="p">:</span> <span class="kc">absolute</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="k">top</span><span class="p">:</span> <span class="mi">-40</span><span class="kt">px</span><span class="p">;</span>       <span class="c">/* 預設藏起來 */</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="k">left</span><span class="p">:</span> <span class="mi">0</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="k">background</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">bg</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="k">padding</span><span class="p">:</span> <span class="mi">8</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">.</span><span class="nc">skip-link</span><span class="p">:</span><span class="nd">focus</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="k">top</span><span class="p">:</span> <span class="mi">0</span><span class="p">;</span>            <span class="c">/* tab 到時顯示 */</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>第一個 tab 焦點 = skip link、鍵盤使用者可以選擇跳過 nav 直達主內容。</p>
<hr>
<h2 id="風險點-3modal--overlay-的-escape-路徑">風險點 3：Modal / overlay 的 escape 路徑</h2>
<p><strong>位置</strong>：Pagefind drawer 在 mobile 模式展開、filter sidebar 在某些 layout 是 modal-like。</p>
<p><strong>判讀</strong>：</p>
<p>鍵盤使用者進入 modal 後需要：</p>
<ol>
<li>按 ESC 可以關閉</li>
<li>Tab 順序限制在 modal 內（focus trap、不會 tab 到背景元素）</li>
<li>關閉 modal 後 focus 回到觸發元素</li>
</ol>
<p>任一缺失 = 卡住。</p>
<p><strong>症狀</strong>：鍵盤使用者打開 filter drawer 後 tab 跑到背景元素、不知道怎麼關 drawer。</p>
<p><strong>第一個該查的</strong>：開啟 modal / drawer / overlay、按 ESC 看會不會關、tab 看會不會跑到背景。</p>
<p><strong>修正方向</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">function</span> <span class="nx">openModal</span><span class="p">(</span><span class="nx">modal</span><span class="p">,</span> <span class="nx">trigger</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nx">modal</span><span class="p">.</span><span class="nx">showModal</span><span class="o">?</span><span class="p">.()</span> <span class="o">||</span> <span class="p">(</span><span class="nx">modal</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">display</span> <span class="o">=</span> <span class="s1">&#39;block&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="c1">// ESC 關閉
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span>  <span class="nx">modal</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;keydown&#39;</span><span class="p">,</span> <span class="kd">function</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">key</span> <span class="o">===</span> <span class="s1">&#39;Escape&#39;</span><span class="p">)</span> <span class="nx">closeModal</span><span class="p">(</span><span class="nx">modal</span><span class="p">,</span> <span class="nx">trigger</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="c1">// Focus trap（簡化版）
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span>  <span class="kd">var</span> <span class="nx">focusables</span> <span class="o">=</span> <span class="nx">modal</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;button, input, select, [tabindex]&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="nx">focusables</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">?</span><span class="p">.</span><span class="nx">focus</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="nx">modal</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;keydown&#39;</span><span class="p">,</span> <span class="kd">function</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">key</span> <span class="o">!==</span> <span class="s1">&#39;Tab&#39;</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="kd">var</span> <span class="nx">first</span> <span class="o">=</span> <span class="nx">focusables</span><span class="p">[</span><span class="mi">0</span><span class="p">];</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="kd">var</span> <span class="nx">last</span> <span class="o">=</span> <span class="nx">focusables</span><span class="p">[</span><span class="nx">focusables</span><span class="p">.</span><span class="nx">length</span> <span class="o">-</span> <span class="mi">1</span><span class="p">];</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">shiftKey</span> <span class="o">&amp;&amp;</span> <span class="nb">document</span><span class="p">.</span><span class="nx">activeElement</span> <span class="o">===</span> <span class="nx">first</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">      <span class="nx">e</span><span class="p">.</span><span class="nx">preventDefault</span><span class="p">();</span> <span class="nx">last</span><span class="p">.</span><span class="nx">focus</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">e</span><span class="p">.</span><span class="nx">shiftKey</span> <span class="o">&amp;&amp;</span> <span class="nb">document</span><span class="p">.</span><span class="nx">activeElement</span> <span class="o">===</span> <span class="nx">last</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">      <span class="nx">e</span><span class="p">.</span><span class="nx">preventDefault</span><span class="p">();</span> <span class="nx">first</span><span class="p">.</span><span class="nx">focus</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="kd">function</span> <span class="nx">closeModal</span><span class="p">(</span><span class="nx">modal</span><span class="p">,</span> <span class="nx">trigger</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">  <span class="nx">modal</span><span class="p">.</span><span class="nx">close</span><span class="o">?</span><span class="p">.()</span> <span class="o">||</span> <span class="p">(</span><span class="nx">modal</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">display</span> <span class="o">=</span> <span class="s1">&#39;none&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">  <span class="nx">trigger</span><span class="o">?</span><span class="p">.</span><span class="nx">focus</span><span class="p">();</span>  <span class="c1">// 焦點回觸發元素
</span></span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="c1"></span><span class="p">}</span></span></span></code></pre></div><p><strong>用 <code>&lt;dialog&gt;</code> 元素自動 trap</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">dialog</span> <span class="na">id</span><span class="o">=</span><span class="s">&#34;filter-modal&#34;</span><span class="p">&gt;</span>...<span class="p">&lt;/</span><span class="nt">dialog</span><span class="p">&gt;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">modal</span><span class="p">.</span><span class="nx">showModal</span><span class="p">();</span>  <span class="c1">// 自動 focus trap + ESC 處理
</span></span></span></code></pre></div><p><code>&lt;dialog&gt;</code> 是現代做法 — 鍵盤行為由瀏覽器處理、不需要手寫 trap 邏輯。</p>
<hr>
<h2 id="設計取捨focus-處理策略">設計取捨：focus 處理策略</h2>
<p>當需要客製 focus 視覺時、四種做法：</p>
<h3 id="a信任瀏覽器預設-outline這個專案的預設">A：信任瀏覽器預設 outline（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：完全不寫 <code>outline</code> 規則、瀏覽器藍色 outline 自動套用</li>
<li><strong>選 A 的理由</strong>：成本最低、跨瀏覽器一致、不會意外破壞</li>
<li><strong>適合</strong>：對 focus 視覺沒有強烈品牌需求</li>
<li><strong>代價</strong>：focus 看起來「不夠精緻」（瀏覽器預設不一定符合品牌風格）</li>
</ul>
<h3 id="b用-focus-visible-客製-outline">B：用 <code>:focus-visible</code> 客製 outline</h3>
<ul>
<li><strong>機制</strong>：<code>:focus-visible { outline: 2px solid var(--brand); }</code>、滑鼠點擊不顯示</li>
<li><strong>跟 A 的取捨</strong>：B 達到品牌一致性、滑鼠使用者不被「煩」；A 簡單但視覺一般</li>
<li><strong>B 比 A 好的情境</strong>：品牌設計嚴格要求 focus 視覺</li>
</ul>
<h3 id="c用-box-shadow-取代-outline">C：用 <code>box-shadow</code> 取代 outline</h3>
<ul>
<li><strong>機制</strong>：<code>:focus-visible { box-shadow: 0 0 0 3px var(--focus); outline: 0; }</code></li>
<li><strong>跟 B 的取捨</strong>：C 跟 outline 視覺差異是「跟著元素圓角」、適合圓角 UI；outline 永遠是矩形</li>
<li><strong>C 比 B 好的情境</strong>：圓角元素需要 focus 跟隨圓角</li>
</ul>
<h3 id="d完全移除-focus-indicator">D：完全移除 focus indicator</h3>
<ul>
<li><strong>機制</strong>：<code>*:focus { outline: 0; }</code>、不補替代</li>
<li><strong>成本特別高的原因</strong>：違反 WCAG 2.4.7、鍵盤使用者完全無法導航</li>
<li><strong>D 是反模式</strong>：違反 WCAG 2.4.7（合規層） — 即使品牌追求極簡、也該保留 focus indicator</li>
</ul>
<p>「邏輯 tab 順序」要素的詳細展開（DOM vs tabindex 的取捨、跟 mental model 對齊）見 <a href="../tab-order-mental-model-alignment/">#71 Tab Order = DOM Order = Mental Model 三者對齊</a>。</p>
<hr>
<h2 id="跟其他原則的關係">跟其他原則的關係</h2>
<table>
  <thead>
      <tr>
          <th>篇</th>
          <th>關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="../focus-management-on-dom-move/">#37 Focus management on DOM move</a></td>
          <td>互補 — 本篇處理「靜態 focus 設計」、#37 處理「DOM 移動時 focus 該怎麼跟」</td>
      </tr>
      <tr>
          <td><a href="../native-html-over-aria-role/">#39 Native HTML 優先於 ARIA role</a></td>
          <td>用 <code>&lt;button&gt;</code> / <code>&lt;dialog&gt;</code> / <code>&lt;input&gt;</code> 等 native element、自動獲得正確 keyboard 行為</td>
      </tr>
      <tr>
          <td><a href="../external-component-collaboration-layers/">#45 跟外部組件合作的層次</a></td>
          <td>客製 focus 樣式時、注意不要打破 framework 內部的 focus 邏輯</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="開發階段檢查清單">開發階段檢查清單</h2>
<table>
  <thead>
      <tr>
          <th>檢查</th>
          <th>動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Focus 可見</td>
          <td>拔掉滑鼠、只用鍵盤、tab 過所有互動元素、確認每個都有可見 focus</td>
      </tr>
      <tr>
          <td>Focus 對比</td>
          <td>DevTools Contrast Ratio 量 focus indicator 跟背景對比 ≥ 3:1</td>
      </tr>
      <tr>
          <td>Tab 順序</td>
          <td>tab 過去確認順序符合視覺閱讀順序</td>
      </tr>
      <tr>
          <td>ESC 關閉</td>
          <td>開啟 modal / drawer、按 ESC 看會不會關</td>
      </tr>
      <tr>
          <td>Focus trap</td>
          <td>開啟 modal、tab 看是否限制在 modal 內</td>
      </tr>
      <tr>
          <td>Focus return</td>
          <td>關閉 modal、看 focus 是否回觸發元素</td>
      </tr>
  </tbody>
</table>
<p>每個 ~30 秒、開發完成前跑一輪。</p>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該檢查的位置</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>鍵盤使用者反映「不知道 focus 在哪」</td>
          <td>確認沒有 <code>outline: 0</code> 沒補替代、用 <code>:focus-visible</code></td>
      </tr>
      <tr>
          <td>Tab 順序看起來隨機</td>
          <td>DOM 順序對齊視覺順序、必要時用 skip link</td>
      </tr>
      <tr>
          <td>Modal 開啟後鍵盤使用者卡住</td>
          <td>加 ESC 關閉 + focus trap、或改用 <code>&lt;dialog&gt;</code></td>
      </tr>
      <tr>
          <td>Modal 關閉後 focus 跑到頁面開頭</td>
          <td>關閉時手動 <code>trigger.focus()</code></td>
      </tr>
      <tr>
          <td>Focus 在 dark mode 看不清</td>
          <td>加對比度檢查（≥ 3:1）</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：鍵盤可達性的三要素都是「視覺使用者通常不會碰、鍵盤使用者必碰」 — 開發階段必須拔滑鼠測一輪、不能依賴使用者通報。</p>
]]></content:encoded></item><item><title>Motor 可達性：hit target、間距、誤點防護</title><link>https://tarrragon.github.io/blog/report/motor-accessibility-hit-target/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/motor-accessibility-hit-target/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>Hit target ≥ 44×44px、相鄰互動元素之間有間距、避免「精準瞄準」需求。&lt;/strong> Motor accessibility 處理的不是視覺、是「手能否準確點擊」 — 行動裝置使用者、年長使用者、motor 障礙使用者都受益。設計時優先擴大 padding、不是縮小視覺。&lt;/p>
&lt;blockquote>
&lt;p>本篇焦點：&lt;strong>motor 可達性&lt;/strong>。&lt;/p>
&lt;ul>
&lt;li>&lt;strong>視覺呈現面的 a11y&lt;/strong>由 &lt;a href="../visual-aids-contrast-zoom-responsive/">#40 視覺輔助&lt;/a> 處理&lt;/li>
&lt;li>&lt;strong>鍵盤使用者的 a11y&lt;/strong>由 &lt;a href="../keyboard-accessibility/">#52 鍵盤可達性&lt;/a> 處理&lt;/li>
&lt;/ul>&lt;/blockquote>
&lt;hr>
&lt;h2 id="為什麼-motor-可達性需要獨立盤點">為什麼 motor 可達性需要獨立盤點&lt;/h2>
&lt;h3 id="使用者類型">使用者類型&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>使用者&lt;/th>
 &lt;th>為什麼 hit target 重要&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>行動裝置使用者&lt;/td>
 &lt;td>手指比滑鼠粗、需要更大目標&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>年長使用者&lt;/td>
 &lt;td>手部精準度下降&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Motor 障礙使用者&lt;/td>
 &lt;td>Tremor / 手部協調困難&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>暫時受限使用者（拿東西單手操作、晃動環境）&lt;/td>
 &lt;td>短期內精準度下降&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>最後一類包含「正常使用者在某些情境」 — motor a11y 的設計對全體使用者都有價值。&lt;/p>
&lt;h3 id="失敗模式">失敗模式&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>失敗&lt;/th>
 &lt;th>表現&lt;/th>
 &lt;th>影響範圍&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Hit target &amp;lt; 24px&lt;/td>
 &lt;td>行動裝置上難點&lt;/td>
 &lt;td>多數行動使用者&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>相鄰互動元素間距不足&lt;/td>
 &lt;td>誤觸隔壁&lt;/td>
 &lt;td>手指粗 / motor 障礙者&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>需要精準 drag / pinch&lt;/td>
 &lt;td>部分 motor 障礙者無法&lt;/td>
 &lt;td>motor 障礙者&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>短時間內需多次精準操作&lt;/td>
 &lt;td>tremor 使用者無法&lt;/td>
 &lt;td>tremor 使用者&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="wcag-標準">WCAG 標準&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>標準&lt;/th>
 &lt;th>要求&lt;/th>
 &lt;th>等級&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>2.5.5 Target Size&lt;/td>
 &lt;td>互動元素 ≥ 44×44 CSS px&lt;/td>
 &lt;td>AAA&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2.5.8 Target Size (Minimum)&lt;/td>
 &lt;td>互動元素 ≥ 24×24 CSS px（除非有間距足夠的等價替代）&lt;/td>
 &lt;td>AA（WCAG 2.2 新增）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2.5.7 Dragging Movements&lt;/td>
 &lt;td>拖拽動作有單擊替代&lt;/td>
 &lt;td>AA（WCAG 2.2 新增）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>WCAG 2.2 把 motor a11y 從 AAA 拉到部分 AA — 顯示這類問題的重要性提升。&lt;/p>
&lt;hr>
&lt;h2 id="風險點-1hit-target-太小">風險點 1：Hit target 太小&lt;/h2>
&lt;p>&lt;strong>位置&lt;/strong>：scope UI 的 radio buttons、filter checkbox。&lt;/p>
&lt;p>&lt;strong>判讀&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>WCAG 2.5.5（AAA）建議互動元素 hit target ≥ 44×44px&lt;/li>
&lt;li>Native &lt;code>&amp;lt;input type=&amp;quot;radio&amp;quot;&amp;gt;&lt;/code> 在桌面 ~13×13px、行動裝置 24×24px&lt;/li>
&lt;li>label 包住 input + 文字、整個 label 可點 — 提升 hit target&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>症狀&lt;/strong>：行動裝置使用者點擊精準度不足、誤點旁邊選項。&lt;/p>
&lt;p>&lt;strong>第一個該查的&lt;/strong>：量 label 整體（含 padding）的高度與寬度。&lt;/p>
&lt;p>&lt;strong>修正方向&lt;/strong>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-css" data-lang="css">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c">/* 較差 — input 視覺很小、label 文字緊鄰 */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="nt">label&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="k">display&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">inline-block&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="nt">input&lt;/span>&lt;span class="o">[&lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;radio&amp;#34;&lt;/span>&lt;span class="o">]&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="k">width&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">13&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="k">height&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">13&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c">/* 好 — label 整個區域可點、padding 撐到 44px */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="nt">label&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">display&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">inline-flex&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">align-items&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">center&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">padding&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mf">0.625&lt;/span>&lt;span class="kt">rem&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="kt">rem&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c">/* 約 44px 高 */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">cursor&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">pointer&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="nt">input&lt;/span>&lt;span class="o">[&lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;radio&amp;#34;&lt;/span>&lt;span class="o">]&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="k">margin-right&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mf">0.5&lt;/span>&lt;span class="kt">rem&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>關鍵不是把 input 視覺變大、是把可點區域擴大（padding）— 視覺保持精緻、可點區域達標。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>Hit target ≥ 44×44px、相鄰互動元素之間有間距、避免「精準瞄準」需求。</strong> Motor accessibility 處理的不是視覺、是「手能否準確點擊」 — 行動裝置使用者、年長使用者、motor 障礙使用者都受益。設計時優先擴大 padding、不是縮小視覺。</p>
<blockquote>
<p>本篇焦點：<strong>motor 可達性</strong>。</p>
<ul>
<li><strong>視覺呈現面的 a11y</strong>由 <a href="../visual-aids-contrast-zoom-responsive/">#40 視覺輔助</a> 處理</li>
<li><strong>鍵盤使用者的 a11y</strong>由 <a href="../keyboard-accessibility/">#52 鍵盤可達性</a> 處理</li>
</ul></blockquote>
<hr>
<h2 id="為什麼-motor-可達性需要獨立盤點">為什麼 motor 可達性需要獨立盤點</h2>
<h3 id="使用者類型">使用者類型</h3>
<table>
  <thead>
      <tr>
          <th>使用者</th>
          <th>為什麼 hit target 重要</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>行動裝置使用者</td>
          <td>手指比滑鼠粗、需要更大目標</td>
      </tr>
      <tr>
          <td>年長使用者</td>
          <td>手部精準度下降</td>
      </tr>
      <tr>
          <td>Motor 障礙使用者</td>
          <td>Tremor / 手部協調困難</td>
      </tr>
      <tr>
          <td>暫時受限使用者（拿東西單手操作、晃動環境）</td>
          <td>短期內精準度下降</td>
      </tr>
  </tbody>
</table>
<p>最後一類包含「正常使用者在某些情境」 — motor a11y 的設計對全體使用者都有價值。</p>
<h3 id="失敗模式">失敗模式</h3>
<table>
  <thead>
      <tr>
          <th>失敗</th>
          <th>表現</th>
          <th>影響範圍</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Hit target &lt; 24px</td>
          <td>行動裝置上難點</td>
          <td>多數行動使用者</td>
      </tr>
      <tr>
          <td>相鄰互動元素間距不足</td>
          <td>誤觸隔壁</td>
          <td>手指粗 / motor 障礙者</td>
      </tr>
      <tr>
          <td>需要精準 drag / pinch</td>
          <td>部分 motor 障礙者無法</td>
          <td>motor 障礙者</td>
      </tr>
      <tr>
          <td>短時間內需多次精準操作</td>
          <td>tremor 使用者無法</td>
          <td>tremor 使用者</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="wcag-標準">WCAG 標準</h2>
<table>
  <thead>
      <tr>
          <th>標準</th>
          <th>要求</th>
          <th>等級</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>2.5.5 Target Size</td>
          <td>互動元素 ≥ 44×44 CSS px</td>
          <td>AAA</td>
      </tr>
      <tr>
          <td>2.5.8 Target Size (Minimum)</td>
          <td>互動元素 ≥ 24×24 CSS px（除非有間距足夠的等價替代）</td>
          <td>AA（WCAG 2.2 新增）</td>
      </tr>
      <tr>
          <td>2.5.7 Dragging Movements</td>
          <td>拖拽動作有單擊替代</td>
          <td>AA（WCAG 2.2 新增）</td>
      </tr>
  </tbody>
</table>
<p>WCAG 2.2 把 motor a11y 從 AAA 拉到部分 AA — 顯示這類問題的重要性提升。</p>
<hr>
<h2 id="風險點-1hit-target-太小">風險點 1：Hit target 太小</h2>
<p><strong>位置</strong>：scope UI 的 radio buttons、filter checkbox。</p>
<p><strong>判讀</strong>：</p>
<ul>
<li>WCAG 2.5.5（AAA）建議互動元素 hit target ≥ 44×44px</li>
<li>Native <code>&lt;input type=&quot;radio&quot;&gt;</code> 在桌面 ~13×13px、行動裝置 24×24px</li>
<li>label 包住 input + 文字、整個 label 可點 — 提升 hit target</li>
</ul>
<p><strong>症狀</strong>：行動裝置使用者點擊精準度不足、誤點旁邊選項。</p>
<p><strong>第一個該查的</strong>：量 label 整體（含 padding）的高度與寬度。</p>
<p><strong>修正方向</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c">/* 較差 — input 視覺很小、label 文字緊鄰 */</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="nt">label</span> <span class="p">{</span> <span class="k">display</span><span class="p">:</span> <span class="kc">inline-block</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="nt">input</span><span class="o">[</span><span class="nt">type</span><span class="o">=</span><span class="s2">&#34;radio&#34;</span><span class="o">]</span> <span class="p">{</span> <span class="k">width</span><span class="p">:</span> <span class="mi">13</span><span class="kt">px</span><span class="p">;</span> <span class="k">height</span><span class="p">:</span> <span class="mi">13</span><span class="kt">px</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c">/* 好 — label 整個區域可點、padding 撐到 44px */</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="nt">label</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="k">display</span><span class="p">:</span> <span class="kc">inline-flex</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="k">align-items</span><span class="p">:</span> <span class="kc">center</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="k">padding</span><span class="p">:</span> <span class="mf">0.625</span><span class="kt">rem</span> <span class="mi">1</span><span class="kt">rem</span><span class="p">;</span>  <span class="c">/* 約 44px 高 */</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="k">cursor</span><span class="p">:</span> <span class="kc">pointer</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="nt">input</span><span class="o">[</span><span class="nt">type</span><span class="o">=</span><span class="s2">&#34;radio&#34;</span><span class="o">]</span> <span class="p">{</span> <span class="k">margin-right</span><span class="p">:</span> <span class="mf">0.5</span><span class="kt">rem</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><p>關鍵不是把 input 視覺變大、是把可點區域擴大（padding）— 視覺保持精緻、可點區域達標。</p>
<hr>
<h2 id="風險點-2相鄰互動元素間距不足">風險點 2：相鄰互動元素間距不足</h2>
<p><strong>位置</strong>：filter checkbox 列、scope radio 列。</p>
<p><strong>判讀</strong>：</p>
<p>兩個 hit target 緊鄰、即使各自達 44px、相鄰時仍可能誤觸 — WCAG 2.5.8 要求「目標之間有足夠間距」。</p>
<p><strong>症狀</strong>：使用者想點 A 但點到旁邊的 B。</p>
<p><strong>修正方向</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c">/* 加 gap 確保相鄰元素間距 */</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="p">.</span><span class="nc">filter-list</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="k">display</span><span class="p">:</span> <span class="kc">flex</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="k">flex-direction</span><span class="p">:</span> <span class="kc">column</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="k">gap</span><span class="p">:</span> <span class="mi">8</span><span class="kt">px</span><span class="p">;</span>  <span class="c">/* 至少 8px 間距 */</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c">/* 或用 padding 撐開 */</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="p">.</span><span class="nc">filter-list</span> <span class="nt">label</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="k">padding</span><span class="p">:</span> <span class="mf">0.625</span><span class="kt">rem</span> <span class="mi">1</span><span class="kt">rem</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="k">margin-bottom</span><span class="p">:</span> <span class="mi">4</span><span class="kt">px</span><span class="p">;</span>  <span class="c">/* 加總間距達 8px+ */</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>預設 8px 間距 — 比視覺需求多一點、避免誤觸。</p>
<hr>
<h2 id="風險點-3需要精準-drag--pinch-的操作">風險點 3：需要精準 drag / pinch 的操作</h2>
<p><strong>位置</strong>：搜尋頁未實作 drag 互動、但若未來加（例如拖拽結果排序、pinch 縮放圖片）。</p>
<p><strong>判讀</strong>：</p>
<p>WCAG 2.5.7（AA）要求 drag 動作有單擊替代 — 例如「拖拽排序」要有「上移 / 下移」按鈕作為替代。</p>
<p><strong>症狀</strong>：motor 障礙使用者無法完成 drag 操作。</p>
<p><strong>修正方向</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="c">&lt;!-- 主互動：drag --&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">&lt;</span><span class="nt">li</span> <span class="na">draggable</span><span class="o">=</span><span class="s">&#34;true&#34;</span><span class="p">&gt;</span>項目 A<span class="p">&lt;/</span><span class="nt">li</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c">&lt;!-- 必須提供：button 替代 --&gt;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">&lt;</span><span class="nt">li</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  項目 A
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="p">&lt;</span><span class="nt">button</span> <span class="na">aria-label</span><span class="o">=</span><span class="s">&#34;上移&#34;</span><span class="p">&gt;</span>↑<span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="p">&lt;</span><span class="nt">button</span> <span class="na">aria-label</span><span class="o">=</span><span class="s">&#34;下移&#34;</span><span class="p">&gt;</span>↓<span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">&lt;/</span><span class="nt">li</span><span class="p">&gt;</span></span></span></code></pre></div><p>對搜尋頁當前實作不適用、但未來加互動時的預警。</p>
<hr>
<h2 id="設計取捨擴大-hit-target-的策略">設計取捨：擴大 hit target 的策略</h2>
<p>當「視覺精緻度」與「hit target 大小」衝突、四種做法：</p>
<h3 id="a視覺保持小padding-擴大可點區這個專案的預設">A：視覺保持小、padding 擴大可點區（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：input 視覺 13px、label padding 撐到 44px</li>
<li><strong>選 A 的理由</strong>：視覺精緻 + a11y 達標、兩全</li>
<li><strong>適合</strong>：絕大多數互動元素</li>
<li><strong>代價</strong>：UI 整體高度增加（每行 44px+）</li>
</ul>
<h3 id="b視覺直接放大到-44px">B：視覺直接放大到 44px</h3>
<ul>
<li><strong>機制</strong>：input width: 44px; height: 44px;</li>
<li><strong>跟 A 的取捨</strong>：B 視覺粗、A 視覺精緻；B 在「需要清楚看到」的情境（年長使用者）有價值</li>
<li><strong>B 比 A 好的情境</strong>：使用者主要是年長者、視覺辨識比精緻重要</li>
</ul>
<h3 id="c視覺小不擴-padding不滿足-a11y">C：視覺小、不擴 padding（不滿足 a11y）</h3>
<ul>
<li><strong>機制</strong>：input 13px、label 緊鄰文字、無 padding</li>
<li><strong>成本特別高的原因</strong>：行動使用者誤點、motor 障礙者無法用、違反 WCAG 2.5.8</li>
<li><strong>C 才合理的情境</strong>：純 desktop 應用 + 確認使用者群不含行動 / motor — 通常不該假設</li>
</ul>
<h3 id="d用-hover-area-擴大命中hover-才放大">D：用 hover area 擴大命中（hover 才放大）</h3>
<ul>
<li><strong>機制</strong>：預設視覺小、hover 時擴大可點區</li>
<li><strong>跟 A 的取捨</strong>：D 在 desktop 視覺精緻、hover 反饋也好；行動裝置沒有 hover、D 失敗</li>
<li><strong>D 比 A 好的情境</strong>：純 desktop 工具</li>
</ul>
<hr>
<h2 id="設計取捨誤點防護機制">設計取捨：誤點防護機制</h2>
<p>對「誤點代價高」的操作（刪除 / 提交 / 付款）、四種做法：</p>
<h3 id="a直接觸發--後續-undo這個專案的預設若有此類操作">A：直接觸發 + 後續 undo（這個專案的預設、若有此類操作）</h3>
<ul>
<li><strong>機制</strong>：點擊立刻執行、提供 undo 機制（例如 toast「已刪除、5 秒內可復原」）</li>
<li><strong>選 A 的理由</strong>：常見操作流暢、誤點有救</li>
<li><strong>適合</strong>：可逆操作（刪除、移動、隱藏）</li>
<li><strong>代價</strong>：實作 undo 機制需要儲狀態</li>
</ul>
<h3 id="b點擊--確認對話框">B：點擊 → 確認對話框</h3>
<ul>
<li><strong>機制</strong>：點擊出 confirm dialog「確定要 X 嗎？」</li>
<li><strong>跟 A 的取捨</strong>：B 防誤點更強、A 流程更順；B 的成本是「正常使用者也要多一步」</li>
<li><strong>B 比 A 好的情境</strong>：不可逆操作（永久刪除、付款）</li>
</ul>
<h3 id="c長按觸發">C：長按觸發</h3>
<ul>
<li><strong>機制</strong>：需要長按 1 秒才觸發、誤點不會</li>
<li><strong>跟 A/B 的取捨</strong>：C 對 motor 障礙不友善（需要持續按）、且不直觀</li>
<li><strong>C 是反模式</strong>：對 motor 障礙不友善（需要持續按 1 秒）— 不直觀、違反 a11y 預期互動</li>
</ul>
<h3 id="d拖到確認區">D：拖到「確認區」</h3>
<ul>
<li><strong>機制</strong>：滑動到特定區域才觸發（iOS 拖刪除）</li>
<li><strong>跟 A/B 的取捨</strong>：D 對非典型互動使用者不直觀、違反 WCAG 2.5.7（需 button 替代）</li>
<li><strong>D 才合理的情境</strong>：搭配 button 替代（drag + button 兩種途徑都行）</li>
</ul>
<hr>
<h2 id="開發階段檢查清單">開發階段檢查清單</h2>
<table>
  <thead>
      <tr>
          <th>檢查</th>
          <th>動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Hit target ≥ 44px</td>
          <td>DevTools Box Model 量 interactive 元素的 padding box</td>
      </tr>
      <tr>
          <td>相鄰元素間距 ≥ 8px</td>
          <td>DevTools 看 gap / margin</td>
      </tr>
      <tr>
          <td>行動裝置實測</td>
          <td>DevTools Device Mode + 實機測試</td>
      </tr>
      <tr>
          <td>不可逆操作有確認</td>
          <td>點擊「刪除」看是否有 confirm</td>
      </tr>
      <tr>
          <td>Drag 操作有 button 替代</td>
          <td>任何 drag 互動都有對應 button</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="跟其他原則的關係">跟其他原則的關係</h2>
<table>
  <thead>
      <tr>
          <th>篇</th>
          <th>關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="../visual-aids-contrast-zoom-responsive/">#40 視覺輔助</a></td>
          <td>互補 — 視覺面 vs 操作面、不同使用者群</td>
      </tr>
      <tr>
          <td><a href="../keyboard-accessibility/">#52 鍵盤可達性</a></td>
          <td>互補 — 鍵盤是 motor a11y 的一個面向（鍵盤精準度 &gt; 滑鼠 &gt; 觸控）、本篇處理觸控 / 點擊面</td>
      </tr>
      <tr>
          <td><a href="../native-html-over-aria-role/">#39 Native HTML 優先於 ARIA role</a></td>
          <td>用 native button / input 自動獲得合理 hit area、不需自行設計</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該檢查的位置</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>行動使用者反映誤點</td>
          <td>量 hit target、&lt; 44px 加 padding</td>
      </tr>
      <tr>
          <td>「我這個介面只給 desktop 用」</td>
          <td>行動使用者比例可能比想像高、量化驗證</td>
      </tr>
      <tr>
          <td>Drag 互動沒有 button 替代</td>
          <td>加 button、達 WCAG 2.5.7</td>
      </tr>
      <tr>
          <td>不可逆操作沒有 confirm</td>
          <td>加 confirm dialog</td>
      </tr>
      <tr>
          <td>Filter list 元素緊鄰、容易誤觸</td>
          <td>加 gap ≥ 8px</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：Motor a11y 是「手能否準確點擊」 — 不只給 motor 障礙使用者、行動使用者 / 年長者 / 暫時受限使用者都受益。預設 padding 擴 44px、間距 8px、不可逆操作加 confirm — 這些是基礎、不是優化。</p>
]]></content:encoded></item><item><title>Tab Order = DOM Order = Mental Model 三者對齊</title><link>https://tarrragon.github.io/blog/report/tab-order-mental-model-alignment/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/tab-order-mental-model-alignment/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;blockquote>
&lt;p>Tab 順序 = DOM 順序 = 使用者 mental model 的互動順序、三者該對齊。&lt;/p>&lt;/blockquote>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>軸&lt;/th>
 &lt;th>由什麼決定&lt;/th>
 &lt;th>該對齊到什麼&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>DOM 順序&lt;/td>
 &lt;td>HTML / template 結構&lt;/td>
 &lt;td>Mental model 的互動順序&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Tab 順序&lt;/td>
 &lt;td>DOM 順序（除非 tabindex 強制覆寫）&lt;/td>
 &lt;td>DOM 順序&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Mental model 順序&lt;/td>
 &lt;td>使用者預期「先做 X 再做 Y」的流程&lt;/td>
 &lt;td>UI 設計意圖&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>三者偏差的後果：&lt;/p>
&lt;ul>
&lt;li>DOM ≠ mental model：視覺 / tab 順序跟使用者期望不一致、a11y 體驗差&lt;/li>
&lt;li>DOM ≠ tab order（用 &lt;code>tabindex &amp;gt; 0&lt;/code>）：DOM 改變時 tab 順序維護成本爆炸（#52 反模式）&lt;/li>
&lt;li>全對齊：DOM 簡單、tab 自然、a11y 預設正確&lt;/li>
&lt;/ul>
&lt;p>要解決不對齊、&lt;strong>優先重排 DOM&lt;/strong>、不要用 &lt;code>tabindex&lt;/code> 強制覆寫。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼三者該對齊到-dom-順序">為什麼三者該對齊到 DOM 順序&lt;/h2>
&lt;h3 id="tab-順序跟-dom-順序綁定是-spec-規定">Tab 順序跟 DOM 順序綁定是 spec 規定&lt;/h3>
&lt;p>HTML5 spec：tabbable elements 預設依 source order（DOM 順序）navigate。要改變只能用 &lt;code>tabindex&lt;/code> 覆寫。&lt;/p>
&lt;p>&lt;code>tabindex&lt;/code> 三種值：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>tabindex&lt;/th>
 &lt;th>行為&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>0&lt;/code> 或不寫&lt;/td>
 &lt;td>跟 DOM 順序、可 tab 到（依元素本身的 tabbability）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>-1&lt;/code>&lt;/td>
 &lt;td>不能 tab 到、但可被 &lt;code>.focus()&lt;/code> 程式 focus&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>&amp;gt; 0&lt;/code>（如 &lt;code>1&lt;/code>、&lt;code>2&lt;/code>）&lt;/td>
 &lt;td>強制覆寫順序、所有 &lt;code>&amp;gt; 0&lt;/code> 的元素先 tab、按數值升序&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;code>tabindex &amp;gt; 0&lt;/code> 反模式（同 &lt;a href="../keyboard-accessibility/">#52 鍵盤可達性&lt;/a>）：&lt;/p>
&lt;ul>
&lt;li>全頁面只要有任何元素用 &lt;code>tabindex &amp;gt; 0&lt;/code>、整個 tab 順序變混亂（其他 &lt;code>0&lt;/code> / 不寫的元素都被推到後面）&lt;/li>
&lt;li>維護成本：DOM 改了、所有 &lt;code>tabindex &amp;gt; 0&lt;/code> 的數值都要重排&lt;/li>
&lt;li>A11y：screen reader 跟視覺使用者體驗到不同順序&lt;/li>
&lt;/ul>
&lt;p>唯一合法用法：要把元素「移出 tab cycle」用 &lt;code>tabindex=&amp;quot;-1&amp;quot;&lt;/code>（例如 modal 開啟時鎖住背景）。&lt;/p>
&lt;h3 id="mental-model-順序由-ui-設計決定">Mental model 順序由 UI 設計決定&lt;/h3>
&lt;p>互動式 UI 隱含一個流程：使用者預期「先做 X 再做 Y」。例如：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>UI 類型&lt;/th>
 &lt;th>預期 mental model 順序&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>搜尋頁&lt;/td>
 &lt;td>1. 打 query → 2. 篩選範圍 → 3. 看結果 → 4. 載入更多&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>表單&lt;/td>
 &lt;td>從上到下、必填欄位先、subtmit 在最後&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Wizard&lt;/td>
 &lt;td>Step 1 → Step 2 → Step 3 → Submit&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>商品列表&lt;/td>
 &lt;td>1. Sort / filter → 2. 看商品 → 3. 加入購物車&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Modal&lt;/td>
 &lt;td>Modal 內容 → primary action → secondary action → close&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>設計者腦中有這個順序、寫 HTML 時要把它具體化成 DOM 順序。&lt;strong>DOM 順序就是把 mental model 寫進 code 的方式&lt;/strong>。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<blockquote>
<p>Tab 順序 = DOM 順序 = 使用者 mental model 的互動順序、三者該對齊。</p></blockquote>
<table>
  <thead>
      <tr>
          <th>軸</th>
          <th>由什麼決定</th>
          <th>該對齊到什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>DOM 順序</td>
          <td>HTML / template 結構</td>
          <td>Mental model 的互動順序</td>
      </tr>
      <tr>
          <td>Tab 順序</td>
          <td>DOM 順序（除非 tabindex 強制覆寫）</td>
          <td>DOM 順序</td>
      </tr>
      <tr>
          <td>Mental model 順序</td>
          <td>使用者預期「先做 X 再做 Y」的流程</td>
          <td>UI 設計意圖</td>
      </tr>
  </tbody>
</table>
<p>三者偏差的後果：</p>
<ul>
<li>DOM ≠ mental model：視覺 / tab 順序跟使用者期望不一致、a11y 體驗差</li>
<li>DOM ≠ tab order（用 <code>tabindex &gt; 0</code>）：DOM 改變時 tab 順序維護成本爆炸（#52 反模式）</li>
<li>全對齊：DOM 簡單、tab 自然、a11y 預設正確</li>
</ul>
<p>要解決不對齊、<strong>優先重排 DOM</strong>、不要用 <code>tabindex</code> 強制覆寫。</p>
<hr>
<h2 id="為什麼三者該對齊到-dom-順序">為什麼三者該對齊到 DOM 順序</h2>
<h3 id="tab-順序跟-dom-順序綁定是-spec-規定">Tab 順序跟 DOM 順序綁定是 spec 規定</h3>
<p>HTML5 spec：tabbable elements 預設依 source order（DOM 順序）navigate。要改變只能用 <code>tabindex</code> 覆寫。</p>
<p><code>tabindex</code> 三種值：</p>
<table>
  <thead>
      <tr>
          <th>tabindex</th>
          <th>行為</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>0</code> 或不寫</td>
          <td>跟 DOM 順序、可 tab 到（依元素本身的 tabbability）</td>
      </tr>
      <tr>
          <td><code>-1</code></td>
          <td>不能 tab 到、但可被 <code>.focus()</code> 程式 focus</td>
      </tr>
      <tr>
          <td><code>&gt; 0</code>（如 <code>1</code>、<code>2</code>）</td>
          <td>強制覆寫順序、所有 <code>&gt; 0</code> 的元素先 tab、按數值升序</td>
      </tr>
  </tbody>
</table>
<p><code>tabindex &gt; 0</code> 反模式（同 <a href="../keyboard-accessibility/">#52 鍵盤可達性</a>）：</p>
<ul>
<li>全頁面只要有任何元素用 <code>tabindex &gt; 0</code>、整個 tab 順序變混亂（其他 <code>0</code> / 不寫的元素都被推到後面）</li>
<li>維護成本：DOM 改了、所有 <code>tabindex &gt; 0</code> 的數值都要重排</li>
<li>A11y：screen reader 跟視覺使用者體驗到不同順序</li>
</ul>
<p>唯一合法用法：要把元素「移出 tab cycle」用 <code>tabindex=&quot;-1&quot;</code>（例如 modal 開啟時鎖住背景）。</p>
<h3 id="mental-model-順序由-ui-設計決定">Mental model 順序由 UI 設計決定</h3>
<p>互動式 UI 隱含一個流程：使用者預期「先做 X 再做 Y」。例如：</p>
<table>
  <thead>
      <tr>
          <th>UI 類型</th>
          <th>預期 mental model 順序</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>搜尋頁</td>
          <td>1. 打 query → 2. 篩選範圍 → 3. 看結果 → 4. 載入更多</td>
      </tr>
      <tr>
          <td>表單</td>
          <td>從上到下、必填欄位先、subtmit 在最後</td>
      </tr>
      <tr>
          <td>Wizard</td>
          <td>Step 1 → Step 2 → Step 3 → Submit</td>
      </tr>
      <tr>
          <td>商品列表</td>
          <td>1. Sort / filter → 2. 看商品 → 3. 加入購物車</td>
      </tr>
      <tr>
          <td>Modal</td>
          <td>Modal 內容 → primary action → secondary action → close</td>
      </tr>
  </tbody>
</table>
<p>設計者腦中有這個順序、寫 HTML 時要把它具體化成 DOM 順序。<strong>DOM 順序就是把 mental model 寫進 code 的方式</strong>。</p>
<hr>
<h2 id="多面向常見不對齊-case">多面向：常見不對齊 case</h2>
<h3 id="面向-1filter-在-search-input-之前這次任務的-case">面向 1：Filter 在 search input 之前（這次任務的 case）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="c">&lt;!-- DOM 順序：scope 先 → search input 後 --&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;search-scope&#34;</span><span class="p">&gt;</span>...<span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">id</span><span class="o">=</span><span class="s">&#34;search&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>  <span class="c">&lt;!-- pagefind input 在裡面 --&gt;</span></span></span></code></pre></div><p>Tab 順序：scope radios → search input。但 mental model 是「先打字再篩選」、Tab 應該先到 input。</p>
<p><strong>修法</strong>：DOM 重排、把 scope 移到 #search 之後。視覺位置由 CSS <code>position: absolute</code> 控制、不受 DOM 順序影響。</p>
<h3 id="面向-2submit-按鈕在-form-中間">面向 2：Submit 按鈕在 form 中間</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">form</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="p">&lt;</span><span class="nt">input</span> <span class="na">name</span><span class="o">=</span><span class="s">&#34;email&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">&lt;</span><span class="nt">button</span> <span class="na">type</span><span class="o">=</span><span class="s">&#34;submit&#34;</span><span class="p">&gt;</span>送出<span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;</span>  <span class="c">&lt;!-- 太早 --&gt;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="p">&lt;</span><span class="nt">textarea</span> <span class="na">name</span><span class="o">=</span><span class="s">&#34;message&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">textarea</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">&lt;/</span><span class="nt">form</span><span class="p">&gt;</span></span></span></code></pre></div><p>Tab 順序：email → submit → textarea。使用者打完 email 按 Enter 就送出、textarea 還沒填。</p>
<p><strong>修法</strong>：submit 移到所有 input 之後。</p>
<h3 id="面向-3logo--nav-在主要-cta-之前">面向 3：Logo / nav 在主要 CTA 之前</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">header</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="p">&lt;</span><span class="nt">a</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/&#34;</span><span class="p">&gt;</span>Logo<span class="p">&lt;/</span><span class="nt">a</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">&lt;</span><span class="nt">nav</span><span class="p">&gt;</span>... 5 個 links ...<span class="p">&lt;/</span><span class="nt">nav</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">&lt;/</span><span class="nt">header</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">&lt;</span><span class="nt">main</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="p">&lt;</span><span class="nt">button</span><span class="p">&gt;</span>主要 CTA<span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;</span>  <span class="c">&lt;!-- 使用者要按這個 --&gt;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">&lt;/</span><span class="nt">main</span><span class="p">&gt;</span></span></span></code></pre></div><p>Tab 順序：6 個 nav links → CTA。使用者要 tab 6 次才到 CTA。</p>
<p><strong>修法</strong>：考慮加 「skip to main content」link（A11y 標準做法）— <code>&lt;a href=&quot;#main-content&quot; class=&quot;skip-link&quot;&gt;</code>。第一個 tab 就跳過 nav 到 main。</p>
<h3 id="面向-4modal-開啟時-background-仍-tabbable">面向 4：Modal 開啟時 background 仍 tabbable</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;background-content&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="p">&lt;</span><span class="nt">a</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;...&#34;</span><span class="p">&gt;</span>某連結<span class="p">&lt;/</span><span class="nt">a</span><span class="p">&gt;</span>  <span class="c">&lt;!-- 仍可 tab 到 --&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">role</span><span class="o">=</span><span class="s">&#34;dialog&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="p">&lt;</span><span class="nt">input</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="p">&lt;</span><span class="nt">button</span><span class="p">&gt;</span>確認<span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div><p>Tab 順序：背景連結 → modal input → confirm。使用者 tab 出 modal 跑回背景。</p>
<p><strong>修法</strong>：modal 開啟時、用 <code>inert</code> attribute（modern）或所有背景元素設 <code>tabindex=&quot;-1&quot;</code>（傳統）把它們踢出 cycle。<code>&lt;dialog&gt;</code> native 自動處理。</p>
<hr>
<h2 id="不對齊的修法優先重排-dom">不對齊的修法：優先重排 DOM</h2>
<h3 id="第一順位重排-dom">第一順位：重排 DOM</h3>
<p>把元素照 mental model 順序排在 HTML / template 裡。視覺位置如果跟 DOM 順序不同、用 CSS <code>order</code>（flex / grid）、<code>position: absolute</code>、<code>grid-template-areas</code> 控制。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="c">&lt;!-- DOM 順序對齊 mental model：input → scope → drawer --&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">id</span><span class="o">=</span><span class="s">&#34;search&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;search-scope&#34;</span><span class="p">&gt;</span>...<span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="c">/* 視覺：scope 浮在 input 跟 drawer 之間（跟 DOM 順序無關） */</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">search-shell</span> <span class="p">{</span> <span class="k">position</span><span class="p">:</span> <span class="kc">relative</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">.</span><span class="nc">search-scope</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">position</span><span class="p">:</span> <span class="kc">absolute</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="k">top</span><span class="p">:</span> <span class="nb">calc</span><span class="p">(</span><span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">input</span><span class="o">-</span><span class="n">h</span><span class="p">)</span> <span class="o">+</span> <span class="mi">8</span><span class="kt">px</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><strong>Tab 順序自然對齊 DOM、視覺位置由 CSS 獨立控制</strong> — 兩個維度解耦、不互相影響。</p>
<h3 id="第二順位js-動態移動-dom">第二順位：JS 動態移動 DOM</h3>
<p>如果元素因為 framework 限制無法 hard-coded 在對的位置（例如某 vendor library 強制 mount 點）、用 JS 在 mount 後 reparent 元素到對的位置。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// PagefindUI mount 後、把 scope 移到 input 跟 drawer 之間（如果 framework 允許）
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kr">const</span> <span class="nx">scope</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-scope&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kr">const</span> <span class="nx">drawer</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__drawer&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nx">drawer</span><span class="p">.</span><span class="nx">parentElement</span><span class="p">.</span><span class="nx">insertBefore</span><span class="p">(</span><span class="nx">scope</span><span class="p">,</span> <span class="nx">drawer</span><span class="p">);</span></span></span></code></pre></div><p>風險：framework 重渲染可能 reparent 回去（<a href="../coexisting-with-framework-managed-dom/">#5 framework-managed DOM</a>）。要驗證穩定性。</p>
<h3 id="第三順位不推薦tabindex-強制">第三順位（不推薦）：tabindex 強制</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">input</span> <span class="na">tabindex</span><span class="o">=</span><span class="s">&#34;1&#34;</span> <span class="na">name</span><span class="o">=</span><span class="s">&#34;search&#34;</span><span class="p">&gt;</span>  <span class="c">&lt;!-- 反模式：tabindex &gt; 0 --&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">tabindex</span><span class="o">=</span><span class="s">&#34;2&#34;</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;search-scope&#34;</span><span class="p">&gt;</span>...<span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div><p>只在前兩種都做不到時用。維護成本高、a11y 跟設計工具支援差。</p>
<hr>
<h2 id="不該套用本原則的情境">不該套用本原則的情境</h2>
<p>「DOM = tab = mental model 三者對齊」原則在多數情境成立、但有合理例外：</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>為什麼不該強制對齊</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>純展示頁面（無互動）</td>
          <td>沒 mental model 順序可言、預設 DOM 順序就好</td>
      </tr>
      <tr>
          <td>動態生成 list 元素</td>
          <td>List 元素數量不固定、tab order 跟著 DOM 自然走是對的</td>
      </tr>
      <tr>
          <td>模糊的 mental model</td>
          <td>當 UI 設計沒明確流程、DOM 自然順序通常已經夠用</td>
      </tr>
      <tr>
          <td>Framework 不允許重排</td>
          <td>接受次優、加 explicit hint 告知使用者</td>
      </tr>
  </tbody>
</table>
<p>四類共同特徵：<strong>沒有清楚的「使用者該先做 X 再做 Y」流程</strong> — 本原則建立在「有 mental model 可對齊」上、沒有時自然不適用。</p>
<hr>
<h2 id="跟其他抽象層原則的關係">跟其他抽象層原則的關係</h2>
<table>
  <thead>
      <tr>
          <th>原則</th>
          <th>跟本卡的關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="../keyboard-accessibility/">#52 鍵盤可達性</a></td>
          <td>本卡是 #52「邏輯 tab 順序」要素的展開、含 tabindex &gt; 0 反模式詳解</td>
      </tr>
      <tr>
          <td><a href="../ease-of-writing-vs-intent-alignment/">#67 寫作便利度跟意圖對齊反相關</a></td>
          <td>DOM 順序便利（先寫先 render）、mental model 對齊需要刻意設計 — 反相關</td>
      </tr>
      <tr>
          <td><a href="../minimum-necessary-scope-is-sanity-defense/">#43 最小必要範圍</a></td>
          <td>tabindex &gt; 0 是「擴張範圍」反模式 — 一個 tabindex &gt; 0 影響整頁 tab 順序</td>
      </tr>
      <tr>
          <td><a href="../native-html-over-aria-role/">#39 native HTML &gt; ARIA</a></td>
          <td>Native HTML 元素自帶正確 tab 行為、不需要 ARIA tabindex 補</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="對應的實作篇">對應的實作篇</h2>
<ul>
<li>搜尋頁 scope filter 在 search input 之前的 tab 順序問題 — Checkpoint 1 retrospective 找到（<a href="../verification-timeline-checkpoints/">#68</a> dogfooding）</li>
<li>任何「先選範圍再操作」vs「先操作再選範圍」的 UI 設計 — 都該檢視 tab order 是否對齊</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>寫了 <code>tabindex=&quot;1&quot;</code> 或更大的數字</td>
          <td>換重排 DOM、避免 tabindex &gt; 0</td>
      </tr>
      <tr>
          <td>Tab 順序跟「使用者會先做什麼」感覺反</td>
          <td>列 mental model 流程、檢查 DOM 順序</td>
      </tr>
      <tr>
          <td>做 a11y review 才發現 tab 順序怪</td>
          <td>Checkpoint 1 沒列鍵盤使用 case、補進開工前清單</td>
      </tr>
      <tr>
          <td>用 JS reparent 元素改順序、framework 改回來</td>
          <td>重新評估架構、把元素放在 framework 邊界外</td>
      </tr>
      <tr>
          <td>內心 OS：「視覺位置是 X、所以 DOM 也該在 X」</td>
          <td>視覺跟 DOM 解耦才是對的設計</td>
      </tr>
      <tr>
          <td>看到 <code>tabindex=&quot;-1&quot;</code> 在不該被 tab 的元素上</td>
          <td>合理使用（modal 背景 / 先 focus 後 reveal）</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：DOM 順序是寫進 code 的 mental model、tab 順序是使用者體驗的 mental model — 兩者該由「重排 DOM」對齊、不該由「tabindex」強制。視覺位置跟 DOM 順序解耦（用 CSS 控制）、讓兩者各自獨立優化。</p>
]]></content:encoded></item><item><title>Accessibility and Focus — A11y 三道防線</title><link>https://tarrragon.github.io/blog/skills/frontend-with-playwright/accessibility-and-focus/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/frontend-with-playwright/accessibility-and-focus/</guid><description>&lt;p>A11y 三道防線：靜態（鍵盤可達性三要素）、動態（focus 跟 aria-live）、優先 Native HTML &amp;gt; ARIA。鍵盤 / 視覺 / motor / 認知都納入。&lt;/p>
&lt;p>適用：寫互動 UI、JS reparent / hide 元素、自製 component（modal / dropdown / tabs）、客製外部組件後檢查 a11y。
不適用：純後端 / 純資料流（沒有使用者直接互動）。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>自包含聲明&lt;/strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋鍵盤可達性三要素、focus management 模板、aria-live 設計、native HTML 優先原則。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="何時參閱本文件">何時參閱本文件&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>該做的第一件事&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>自製 modal / dropdown / tabs / accordion&lt;/td>
 &lt;td>先看有沒有 &lt;code>&amp;lt;dialog&amp;gt;&lt;/code> / &lt;code>&amp;lt;details&amp;gt;&lt;/code> 能用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>JS reparent 或 hide 元素&lt;/td>
 &lt;td>保存 focus、操作後還原&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>動態變動內容（搜尋結果、filter 切換、status 訊息）&lt;/td>
 &lt;td>加 &lt;code>aria-live&lt;/code> region&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>使用者反映「鍵盤跑掉」「Tab 順序怪」&lt;/td>
 &lt;td>檢查 visible focus indicator + tab order&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>即將寫 &lt;code>role=&amp;quot;button&amp;quot;&lt;/code> &lt;code>role=&amp;quot;dialog&amp;quot;&lt;/code> 等 ARIA role&lt;/td>
 &lt;td>停 — 看 native HTML 能不能用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>行動裝置誤點&lt;/td>
 &lt;td>檢查 hit target 大小（最小 44×44 px）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="為什麼-a11y-是預設不是補丁">為什麼 a11y 是預設不是補丁&lt;/h2>
&lt;p>A11y 不是「完整功能後再加上」、是&lt;strong>設計時就決定的結構&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>用 &lt;code>&amp;lt;button&amp;gt;&lt;/code> vs &lt;code>&amp;lt;div onclick&amp;gt;&lt;/code> → 鍵盤 / focus / a11y tree 自帶 vs 全部要自己補&lt;/li>
&lt;li>modal 用 &lt;code>&amp;lt;dialog&amp;gt;&lt;/code> vs 自己組 → focus trap / escape / scrollable / inert 自帶 vs 全部要自己補&lt;/li>
&lt;li>動態內容變動有 aria-live vs 沒 → screen reader 知道 vs 不知道&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>事後補 a11y 比事前設計貴 5-10 倍&lt;/strong>。寫之前先選對結構、後續成本低。&lt;/p>
&lt;hr>
&lt;h2 id="防線-1靜態鍵盤可達性三要素">防線 1：靜態鍵盤可達性三要素&lt;/h2>
&lt;p>鍵盤使用者要能用、三個元素缺一不可：&lt;/p>
&lt;h3 id="要素-1visible-focus-indicator">要素 1：Visible focus indicator&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-css" data-lang="css">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c">/* 反例：去掉預設 focus outline */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nt">button&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="nd">focus&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="k">outline&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">none&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c">/* 對例：可見的 focus indicator */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="nt">button&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="nd">focus-visible&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="k">outline&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">2&lt;/span>&lt;span class="kt">px&lt;/span> &lt;span class="kc">solid&lt;/span> &lt;span class="nf">var&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="n">focus&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="kc">color&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="k">outline-offset&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">2&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>:focus-visible&lt;/code>（鍵盤 focus）跟 &lt;code>:focus&lt;/code>（含滑鼠 click 後）區分 — 滑鼠使用者不需要看到 outline、鍵盤使用者必須看到。&lt;/p>
&lt;h3 id="要素-2邏輯-tab-順序">要素 2：邏輯 Tab 順序&lt;/h3>
&lt;p>Tab 順序預設由 DOM tree 決定。如果視覺順序跟 DOM 順序不同（例如用 CSS grid 重排），考慮：&lt;/p>
&lt;ul>
&lt;li>重排 DOM 順序對齊視覺&lt;/li>
&lt;li>用 &lt;code>tabindex=&amp;quot;0&amp;quot;&lt;/code> 讓元素可 focus（不要用 &amp;gt; 0）&lt;/li>
&lt;li>不要用 &lt;code>tabindex=&amp;quot;-1&amp;quot;&lt;/code> 跳過該 focus 的元素&lt;/li>
&lt;/ul>
&lt;h3 id="要素-3modal--drawer-有-escape-路徑">要素 3：Modal / drawer 有 escape 路徑&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">dialog&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">addEventListener&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;keydown&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">e&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">e&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">key&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="s1">&amp;#39;Escape&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">dialog&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">close&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>或用 &lt;code>&amp;lt;dialog&amp;gt;&lt;/code> native — &lt;code>Escape&lt;/code> 自帶。&lt;/p></description><content:encoded><![CDATA[<p>A11y 三道防線：靜態（鍵盤可達性三要素）、動態（focus 跟 aria-live）、優先 Native HTML &gt; ARIA。鍵盤 / 視覺 / motor / 認知都納入。</p>
<p>適用：寫互動 UI、JS reparent / hide 元素、自製 component（modal / dropdown / tabs）、客製外部組件後檢查 a11y。
不適用：純後端 / 純資料流（沒有使用者直接互動）。</p>
<blockquote>
<p><strong>自包含聲明</strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋鍵盤可達性三要素、focus management 模板、aria-live 設計、native HTML 優先原則。</p></blockquote>
<hr>
<h2 id="何時參閱本文件">何時參閱本文件</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的第一件事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>自製 modal / dropdown / tabs / accordion</td>
          <td>先看有沒有 <code>&lt;dialog&gt;</code> / <code>&lt;details&gt;</code> 能用</td>
      </tr>
      <tr>
          <td>JS reparent 或 hide 元素</td>
          <td>保存 focus、操作後還原</td>
      </tr>
      <tr>
          <td>動態變動內容（搜尋結果、filter 切換、status 訊息）</td>
          <td>加 <code>aria-live</code> region</td>
      </tr>
      <tr>
          <td>使用者反映「鍵盤跑掉」「Tab 順序怪」</td>
          <td>檢查 visible focus indicator + tab order</td>
      </tr>
      <tr>
          <td>即將寫 <code>role=&quot;button&quot;</code> <code>role=&quot;dialog&quot;</code> 等 ARIA role</td>
          <td>停 — 看 native HTML 能不能用</td>
      </tr>
      <tr>
          <td>行動裝置誤點</td>
          <td>檢查 hit target 大小（最小 44×44 px）</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="為什麼-a11y-是預設不是補丁">為什麼 a11y 是預設不是補丁</h2>
<p>A11y 不是「完整功能後再加上」、是<strong>設計時就決定的結構</strong>：</p>
<ul>
<li>用 <code>&lt;button&gt;</code> vs <code>&lt;div onclick&gt;</code> → 鍵盤 / focus / a11y tree 自帶 vs 全部要自己補</li>
<li>modal 用 <code>&lt;dialog&gt;</code> vs 自己組 → focus trap / escape / scrollable / inert 自帶 vs 全部要自己補</li>
<li>動態內容變動有 aria-live vs 沒 → screen reader 知道 vs 不知道</li>
</ul>
<p><strong>事後補 a11y 比事前設計貴 5-10 倍</strong>。寫之前先選對結構、後續成本低。</p>
<hr>
<h2 id="防線-1靜態鍵盤可達性三要素">防線 1：靜態鍵盤可達性三要素</h2>
<p>鍵盤使用者要能用、三個元素缺一不可：</p>
<h3 id="要素-1visible-focus-indicator">要素 1：Visible focus indicator</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="c">/* 反例：去掉預設 focus outline */</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nt">button</span><span class="p">:</span><span class="nd">focus</span> <span class="p">{</span> <span class="k">outline</span><span class="p">:</span> <span class="kc">none</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c">/* 對例：可見的 focus indicator */</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nt">button</span><span class="p">:</span><span class="nd">focus-visible</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="k">outline</span><span class="p">:</span> <span class="mi">2</span><span class="kt">px</span> <span class="kc">solid</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">focus</span><span class="o">-</span><span class="kc">color</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="k">outline-offset</span><span class="p">:</span> <span class="mi">2</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>:focus-visible</code>（鍵盤 focus）跟 <code>:focus</code>（含滑鼠 click 後）區分 — 滑鼠使用者不需要看到 outline、鍵盤使用者必須看到。</p>
<h3 id="要素-2邏輯-tab-順序">要素 2：邏輯 Tab 順序</h3>
<p>Tab 順序預設由 DOM tree 決定。如果視覺順序跟 DOM 順序不同（例如用 CSS grid 重排），考慮：</p>
<ul>
<li>重排 DOM 順序對齊視覺</li>
<li>用 <code>tabindex=&quot;0&quot;</code> 讓元素可 focus（不要用 &gt; 0）</li>
<li>不要用 <code>tabindex=&quot;-1&quot;</code> 跳過該 focus 的元素</li>
</ul>
<h3 id="要素-3modal--drawer-有-escape-路徑">要素 3：Modal / drawer 有 escape 路徑</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">dialog</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;keydown&#39;</span><span class="p">,</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">key</span> <span class="o">===</span> <span class="s1">&#39;Escape&#39;</span><span class="p">)</span> <span class="nx">dialog</span><span class="p">.</span><span class="nx">close</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>或用 <code>&lt;dialog&gt;</code> native — <code>Escape</code> 自帶。</p>
<hr>
<h2 id="防線-2動態-a11y">防線 2：動態 a11y</h2>
<h3 id="focus-management-on-dom-move">Focus management on DOM move</h3>
<p>JS reparent / hide 元素時、focus 會跑掉（落到 body）。需要保存與還原：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">function</span> <span class="nx">moveFilter</span><span class="p">(</span><span class="nx">targetSlot</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kr">const</span> <span class="nx">filter</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.filter&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kr">const</span> <span class="nx">focused</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">activeElement</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="kr">const</span> <span class="nx">wasFilterFocused</span> <span class="o">=</span> <span class="nx">filter</span><span class="p">.</span><span class="nx">contains</span><span class="p">(</span><span class="nx">focused</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="nx">targetSlot</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">filter</span><span class="p">);</span>  <span class="c1">// reparent
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">wasFilterFocused</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">focused</span><span class="p">.</span><span class="nx">focus</span><span class="p">();</span>  <span class="c1">// 還原 focus
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span>  <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h3 id="aria-live-廣播動態變動">aria-live 廣播動態變動</h3>
<p>Screen reader 預設不會朗讀「DOM 變動」、要明確告訴它：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="c">&lt;!-- polite：等使用者操作完才朗讀（搜尋結果數量、filter 切換） --&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">aria-live</span><span class="o">=</span><span class="s">&#34;polite&#34;</span> <span class="na">aria-atomic</span><span class="o">=</span><span class="s">&#34;true&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  顯示 12 筆結果
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c">&lt;!-- assertive：立刻打斷朗讀（錯誤訊息、緊急狀態） --&gt;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">aria-live</span><span class="o">=</span><span class="s">&#34;assertive&#34;</span> <span class="na">role</span><span class="o">=</span><span class="s">&#34;alert&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  搜尋失敗、請重試
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div><p><code>aria-atomic=&quot;true&quot;</code> 整段重讀（不只朗讀變動的部分）。</p>
<h3 id="範例搜尋結果區">範例：搜尋結果區</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;results&#34;</span> <span class="na">aria-live</span><span class="o">=</span><span class="s">&#34;polite&#34;</span> <span class="na">aria-atomic</span><span class="o">=</span><span class="s">&#34;false&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="p">&lt;</span><span class="nt">p</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;status&#34;</span><span class="p">&gt;</span>顯示 <span class="p">&lt;</span><span class="nt">span</span> <span class="na">id</span><span class="o">=</span><span class="s">&#34;count&#34;</span><span class="p">&gt;</span>12<span class="p">&lt;/</span><span class="nt">span</span><span class="p">&gt;</span> 筆結果<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">&lt;</span><span class="nt">ul</span><span class="p">&gt;</span>...<span class="p">&lt;/</span><span class="nt">ul</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div><p>JS 更新 <code>#count</code> 的 textContent 時、screen reader 朗讀「顯示 12 筆結果」。</p>
<hr>
<h2 id="防線-3native-html--aria">防線 3：Native HTML &gt; ARIA</h2>
<h3 id="為什麼優先-native">為什麼優先 Native</h3>
<table>
  <thead>
      <tr>
          <th>元素</th>
          <th>Native 自帶</th>
          <th>ARIA 補強需要</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>&lt;button&gt;</code></td>
          <td>Tab focus、Enter/Space 觸發、a11y role、disabled 狀態</td>
          <td><code>role=&quot;button&quot;</code> + tabindex + keydown listener + aria-disabled</td>
      </tr>
      <tr>
          <td><code>&lt;dialog&gt;</code></td>
          <td>Modal focus trap、Escape 關閉、<code>::backdrop</code>、<code>inert</code> 外層</td>
          <td><code>role=&quot;dialog&quot;</code> + aria-modal + 自寫 focus trap + Escape handler + inert polyfill</td>
      </tr>
      <tr>
          <td><code>&lt;details&gt;</code></td>
          <td>Toggle 展開、鍵盤、a11y</td>
          <td><code>role=&quot;region&quot;</code> + aria-expanded + 自寫 click handler + keyboard support</td>
      </tr>
      <tr>
          <td><code>&lt;fieldset&gt;+&lt;legend&gt;</code></td>
          <td>群組 a11y、screen reader 讀 legend</td>
          <td><code>role=&quot;radiogroup&quot;</code> + aria-labelledby</td>
      </tr>
      <tr>
          <td><code>&lt;input type=&quot;...&quot;&gt;</code></td>
          <td>各種 input 的 native UX、validation、a11y</td>
          <td>全部自寫</td>
      </tr>
  </tbody>
</table>
<h3 id="何時用-aria">何時用 ARIA</h3>
<p>ARIA 是補強、不是替代：</p>
<ul>
<li>用 native 但 a11y tree 還不夠（標 aria-label / aria-describedby 補語意）</li>
<li>真的沒有 native 元素（complex composite widget、tabs、tree）</li>
<li>動態變動需要廣播（aria-live）</li>
</ul>
<h3 id="範例自製-toggle-還是-native-checkbox">範例：自製 toggle 還是 native checkbox</h3>
<p><strong>錯</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;toggle&#34;</span> <span class="na">role</span><span class="o">=</span><span class="s">&#34;switch&#34;</span> <span class="na">tabindex</span><span class="o">=</span><span class="s">&#34;0&#34;</span> <span class="na">aria-checked</span><span class="o">=</span><span class="s">&#34;false&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="p">&lt;</span><span class="nt">span</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;track&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">span</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">&lt;</span><span class="nt">script</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nx">toggle</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;click&#39;</span><span class="p">,</span> <span class="p">...);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nx">toggle</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;keydown&#39;</span><span class="p">,</span> <span class="nx">e</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">key</span> <span class="o">===</span> <span class="s1">&#39;Enter&#39;</span> <span class="o">||</span> <span class="nx">e</span><span class="p">.</span><span class="nx">key</span> <span class="o">===</span> <span class="s1">&#39; &#39;</span><span class="p">)</span> <span class="p">...;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">&lt;/</span><span class="nt">script</span><span class="p">&gt;</span></span></span></code></pre></div><p><strong>對</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">label</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;toggle&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="p">&lt;</span><span class="nt">input</span> <span class="na">type</span><span class="o">=</span><span class="s">&#34;checkbox&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">&lt;</span><span class="nt">span</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;track&#34;</span> <span class="na">aria-hidden</span><span class="o">=</span><span class="s">&#34;true&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">span</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="p">&lt;</span><span class="nt">span</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;visually-hidden&#34;</span><span class="p">&gt;</span>啟用 dark mode<span class="p">&lt;/</span><span class="nt">span</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">&lt;/</span><span class="nt">label</span><span class="p">&gt;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">toggle</span> <span class="nt">input</span> <span class="p">{</span> <span class="k">position</span><span class="p">:</span> <span class="kc">absolute</span><span class="p">;</span> <span class="k">opacity</span><span class="p">:</span> <span class="mi">0</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">toggle</span> <span class="nt">input</span><span class="p">:</span><span class="nd">checked</span> <span class="o">+</span> <span class="p">.</span><span class="nc">track</span> <span class="p">{</span> <span class="k">background</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">brand</span><span class="p">);</span> <span class="p">}</span></span></span></code></pre></div><p>Native checkbox 自帶 keyboard / focus / state、CSS 把它隱藏、視覺用 <code>.track</code> 呈現。</p>
<hr>
<h2 id="視覺--motor-a11y">視覺 / Motor a11y</h2>
<h3 id="視覺輔助">視覺輔助</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c">/* 對比度 */</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="p">:</span><span class="nd">root</span> <span class="p">{</span> <span class="nv">--text</span><span class="p">:</span> <span class="mh">#1a202c</span><span class="p">;</span> <span class="nv">--bg</span><span class="p">:</span> <span class="mh">#fff</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c">/* WCAG AA: 普通文字 4.5:1、大文字 3:1 */</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c">/* 字型放大時不破版 */</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">.</span><span class="nc">container</span> <span class="p">{</span> <span class="k">max-width</span><span class="p">:</span> <span class="mi">60</span><span class="kt">ch</span><span class="p">;</span> <span class="p">}</span>  <span class="c">/* ch 跟字型同步 */</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">.</span><span class="nc">text</span> <span class="p">{</span> <span class="k">font-size</span><span class="p">:</span> <span class="mi">1</span><span class="kt">rem</span><span class="p">;</span> <span class="k">line-height</span><span class="p">:</span> <span class="mf">1.6</span><span class="p">;</span> <span class="p">}</span>  <span class="c">/* rem 跟使用者設定同步 */</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c">/* prefers-reduced-motion */</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">@</span><span class="k">media</span> <span class="o">(</span><span class="nt">prefers-reduced-motion</span><span class="o">:</span> <span class="nt">reduce</span><span class="o">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="o">*</span> <span class="p">{</span> <span class="k">animation-duration</span><span class="p">:</span> <span class="mf">0.01</span><span class="kt">ms</span> <span class="cp">!important</span><span class="p">;</span> <span class="k">transition-duration</span><span class="p">:</span> <span class="mf">0.01</span><span class="kt">ms</span> <span class="cp">!important</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h3 id="motor--hit-target">Motor / Hit target</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="c">/* 觸控 hit target 最小 44×44 px (WCAG AAA) */</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nt">button</span><span class="o">,</span> <span class="nt">a</span><span class="o">,</span> <span class="o">[</span><span class="nt">role</span><span class="o">=</span><span class="s2">&#34;button&#34;</span><span class="o">]</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">min-height</span><span class="p">:</span> <span class="mi">44</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">min-width</span><span class="p">:</span> <span class="mi">44</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c">/* 兩個 hit target 之間留 8px+ 間距、避免誤點 */</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">.</span><span class="nc">toolbar</span> <span class="o">&gt;</span> <span class="o">*</span> <span class="o">+</span> <span class="o">*</span> <span class="p">{</span> <span class="k">margin-left</span><span class="p">:</span> <span class="mi">8</span><span class="kt">px</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><hr>
<h2 id="wrong-vs-right-對照">Wrong vs Right 對照</h2>
<h3 id="範例-1自製-dropdown">範例 1：自製 dropdown</h3>
<p><strong>錯</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;dropdown&#34;</span> <span class="na">tabindex</span><span class="o">=</span><span class="s">&#34;0&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="p">&lt;</span><span class="nt">span</span><span class="p">&gt;</span>選單<span class="p">&lt;/</span><span class="nt">span</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;menu&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;item&#34;</span><span class="p">&gt;</span>選項 1<span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;item&#34;</span><span class="p">&gt;</span>選項 2<span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div><p>問題：no native focus、no keyboard、no a11y role、screen reader 不知道是 menu。</p>
<p><strong>對</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">button</span> <span class="na">aria-haspopup</span><span class="o">=</span><span class="s">&#34;menu&#34;</span> <span class="na">aria-expanded</span><span class="o">=</span><span class="s">&#34;false&#34;</span> <span class="na">aria-controls</span><span class="o">=</span><span class="s">&#34;menu1&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  選單
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">&lt;</span><span class="nt">ul</span> <span class="na">id</span><span class="o">=</span><span class="s">&#34;menu1&#34;</span> <span class="na">role</span><span class="o">=</span><span class="s">&#34;menu&#34;</span> <span class="na">hidden</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="p">&lt;</span><span class="nt">li</span> <span class="na">role</span><span class="o">=</span><span class="s">&#34;menuitem&#34;</span><span class="p">&gt;&lt;</span><span class="nt">button</span><span class="p">&gt;</span>選項 1<span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;&lt;/</span><span class="nt">li</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="p">&lt;</span><span class="nt">li</span> <span class="na">role</span><span class="o">=</span><span class="s">&#34;menuitem&#34;</span><span class="p">&gt;&lt;</span><span class="nt">button</span><span class="p">&gt;</span>選項 2<span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;&lt;/</span><span class="nt">li</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">&lt;/</span><span class="nt">ul</span><span class="p">&gt;</span></span></span></code></pre></div><p>或如果是「選擇一個」 → <code>&lt;select&gt;</code> native。</p>
<h3 id="範例-2filter-切換沒-a11y-broadcast">範例 2：filter 切換沒 a11y broadcast</h3>
<p><strong>錯</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">button</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;click&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">r</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">display</span> <span class="o">=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">dataset</span><span class="p">.</span><span class="nx">tag</span> <span class="o">===</span> <span class="nx">currentFilter</span> <span class="o">?</span> <span class="s1">&#39;block&#39;</span> <span class="o">:</span> <span class="s1">&#39;none&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">// screen reader 不知道結果變了
</span></span></span></code></pre></div><p><strong>對</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;results&#34;</span> <span class="na">aria-live</span><span class="o">=</span><span class="s">&#34;polite&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="p">&lt;</span><span class="nt">p</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;status&#34;</span><span class="p">&gt;</span>顯示 <span class="p">&lt;</span><span class="nt">span</span> <span class="na">id</span><span class="o">=</span><span class="s">&#34;count&#34;</span><span class="p">&gt;</span>12<span class="p">&lt;/</span><span class="nt">span</span><span class="p">&gt;</span> 筆結果（filter: <span class="p">&lt;</span><span class="nt">span</span> <span class="na">id</span><span class="o">=</span><span class="s">&#34;filter&#34;</span><span class="p">&gt;</span>全部<span class="p">&lt;/</span><span class="nt">span</span><span class="p">&gt;</span>）<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">button</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;click&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="c1">// ... filter logic
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="nb">document</span><span class="p">.</span><span class="nx">getElementById</span><span class="p">(</span><span class="s1">&#39;count&#39;</span><span class="p">).</span><span class="nx">textContent</span> <span class="o">=</span> <span class="nx">visibleCount</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nb">document</span><span class="p">.</span><span class="nx">getElementById</span><span class="p">(</span><span class="s1">&#39;filter&#39;</span><span class="p">).</span><span class="nx">textContent</span> <span class="o">=</span> <span class="nx">currentFilter</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="c1">// aria-live 自動朗讀
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><h3 id="範例-3js-移動元素-focus-跑掉">範例 3：JS 移動元素 focus 跑掉</h3>
<p><strong>錯</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// resize 時把 filter 從 mobile drawer 移到 desktop sidebar
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nx">mediaQuery</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;change&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">mediaQuery</span><span class="p">.</span><span class="nx">matches</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">sidebar</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">filter</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">drawer</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">filter</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1">// 如果 filter 內的某個 input 有 focus、reparent 後 focus 落到 body
</span></span></span></code></pre></div><p><strong>對</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nx">mediaQuery</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;change&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kr">const</span> <span class="nx">focused</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">activeElement</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kr">const</span> <span class="nx">wasInFilter</span> <span class="o">=</span> <span class="nx">filter</span><span class="p">.</span><span class="nx">contains</span><span class="p">(</span><span class="nx">focused</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">mediaQuery</span><span class="p">.</span><span class="nx">matches</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">sidebar</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">filter</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">drawer</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">filter</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">wasInFilter</span><span class="p">)</span> <span class="nx">focused</span><span class="p">.</span><span class="nx">focus</span><span class="p">();</span>  <span class="c1">// 還原 focus
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><hr>
<h2 id="自檢清單dogfooding">自檢清單（dogfooding）</h2>
<p>寫互動 UI 時：</p>
<ul>
<li><input disabled="" type="checkbox"> 用 <code>&lt;button&gt;</code> <code>&lt;dialog&gt;</code> <code>&lt;details&gt;</code> <code>&lt;fieldset&gt;</code> 取代自製 ARIA 結構？</li>
<li><input disabled="" type="checkbox"> visible focus indicator 沒被 <code>outline: none</code> 拿掉？</li>
<li><input disabled="" type="checkbox"> Tab 順序符合視覺順序（沒用 <code>tabindex &gt; 0</code>）？</li>
<li><input disabled="" type="checkbox"> Modal / drawer 有 Escape 關閉路徑？</li>
<li><input disabled="" type="checkbox"> JS reparent / hide 時保存與還原 focus？</li>
<li><input disabled="" type="checkbox"> 動態變動內容用 <code>aria-live</code> 廣播？</li>
<li><input disabled="" type="checkbox"> 對比度 ≥ 4.5:1（普通文字）？</li>
<li><input disabled="" type="checkbox"> Hit target ≥ 44×44 px？</li>
<li><input disabled="" type="checkbox"> <code>prefers-reduced-motion</code> 時關掉動畫？</li>
</ul>
<hr>
<h2 id="延伸閱讀">延伸閱讀</h2>
<p>對應的事後檢討（在 <code>content/report/</code>）：</p>
<ul>
<li><a href="/blog/report/focus-management-on-dom-move/" data-link-title="動態 DOM 移動時的 focus 管理" data-link-desc="Filter slot 跨 viewport 搬節點、scope filter 隱藏結果 — 這類 DOM 變動會讓鍵盤 focus 跑掉或停在不可見位置。本文盤點動態 DOM 對 focus 的影響與檢查方法。">focus-management-on-dom-move</a> — 動態 DOM 移動時的 focus 管理</li>
<li><a href="/blog/report/aria-live-for-dynamic-content/" data-link-title="Screen reader 與動態內容變動的 live region 設計" data-link-desc="Scope filter 切換、結果數量變動 — screen reader 使用者看不到視覺變動、需要 aria-live region 主動朗讀。本文盤點 live region 的設計選擇與適用情境。">aria-live-for-dynamic-content</a> — Screen reader 與動態內容變動的 live region 設計</li>
<li><a href="/blog/report/native-html-over-aria-role/" data-link-title="Native HTML element 優先於 ARIA role 的取捨" data-link-desc="用 `&lt;fieldset&gt;&lt;legend&gt;` 比 `&lt;div role=&#34;radiogroup&#34;&gt;` 安全、用 `&lt;button&gt;` 比 `&lt;div role=&#34;button&#34;&gt;` 直接 — native element 自帶完整無障礙語意與行為。本文盤點 ARIA role 是 fallback、不是 default。">native-html-over-aria-role</a> — Native HTML element 優先於 ARIA role 的取捨</li>
<li><a href="/blog/report/keyboard-accessibility/" data-link-title="鍵盤可達性：focus indicator、tab 順序、escape 路徑" data-link-desc="鍵盤使用者用 tab / shift&#43;tab 導航、enter / space 激活、esc 退出。三件事決定可不可用：focus 是否可見、tab 順序是否合理、modal / overlay 有沒有 escape 路徑。本文盤點搜尋頁的鍵盤 a11y 風險點。">keyboard-accessibility</a> — 鍵盤可達性：focus indicator、tab 順序、escape 路徑</li>
<li><a href="/blog/report/motor-accessibility-hit-target/" data-link-title="Motor 可達性：hit target、間距、誤點防護" data-link-desc="Hit target 太小會讓行動裝置使用者誤點、motor 障礙使用者更甚。WCAG AAA 建議 ≥ 44×44px、間距足夠避免誤觸。本文展開 hit target 設計與相關 motor a11y 風險點。">motor-accessibility-hit-target</a> — Motor 可達性：hit target、間距、誤點防護</li>
<li><a href="/blog/report/visual-aids-contrast-zoom-responsive/" data-link-title="視覺輔助：對比度、放大、字型 zoom 的 layout 適配" data-link-desc="色弱、低對比敏感、低視力使用者跟一般使用者「看到的不是同一個 UI」 — 對比度足夠嗎、絕對定位元件在放大模式下是否可達、字型放大 200% 後 layout 還好嗎。本文盤點視覺呈現面的 a11y 風險點。">visual-aids-contrast-zoom-responsive</a> — 視覺輔助：對比度、放大、字型 zoom 的 layout 適配</li>
</ul>
<hr>
<p><strong>Last Updated</strong>: 2026-04-26
<strong>Version</strong>: 0.1.0</p>
]]></content:encoded></item><item><title>Frontend with Playwright — 框架無關的前端開發 + Playwright 驗證</title><link>https://tarrragon.github.io/blog/skills/frontend-with-playwright/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/frontend-with-playwright/</guid><description>&lt;h2 id="這個資料夾是什麼">這個資料夾是什麼&lt;/h2>
&lt;p>&lt;code>frontend-with-playwright&lt;/code> 是一套前端開發協議 skill，原生位置在 &lt;a href="https://github.com/tarrragon/blog/tree/main/.claude/skills/frontend-with-playwright">&lt;code>.claude/skills/frontend-with-playwright/&lt;/code>&lt;/a> 供 Claude runtime 呼叫；這份是&lt;strong>同內容的文章版本&lt;/strong>，讓人類讀者也能直接在 blog 閱讀。&lt;/p>
&lt;p>原則框架無關 — 適用 vanilla HTML/CSS/JS、Vue、React、jQuery — 因為核心是「DOM / CSS / JS 三者的本質行為」加上「Playwright 用 live DOM 量測驗證」、不依賴特定框架的渲染機制。源頭是 &lt;a href="https://tarrragon.github.io/blog/report/" data-link-title="Report — 開發過程的事後檢討" data-link-desc="blog 開發過程中、把實際遇到的版型 / 整合 / 框架共處等情境、整理成『應該怎麼做、沒這樣做會有什麼麻煩』的事後檢討。每篇皆為正向指引、幫助下一輪同類任務跳過反覆試錯。">&lt;code>content/report/&lt;/code>&lt;/a> 累積的 50+ 篇事後檢討、由本 skill 的六份 reference 萃取對應六個情境的協議步驟。&lt;/p>
&lt;h2 id="閱讀順序">閱讀順序&lt;/h2>
&lt;h3 id="場景-1第一次接觸">場景 1：第一次接觸&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>順序&lt;/th>
 &lt;th>檔案&lt;/th>
 &lt;th>目的&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/skill/" data-link-title="Frontend with Playwright — SKILL 入口" data-link-desc="框架無關的前端開發 &amp;#43; Playwright 驗證 SKILL 入口：三大支柱、六大原則速查、六份情境 reference 的觸發路由。">SKILL.md&lt;/a>&lt;/td>
 &lt;td>三大支柱 + 六大原則速查、觸發路由表&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2&lt;/td>
 &lt;td>依情境挑一份 reference（見下表）&lt;/td>
 &lt;td>把原則翻譯成可套用的協議步驟、模板與範例&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3&lt;/td>
 &lt;td>該 reference 結尾的 self-check checklist&lt;/td>
 &lt;td>自評有沒有按協議走&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="場景-2已熟悉協議想直接解決當前任務">場景 2：已熟悉協議、想直接解決當前任務&lt;/h3>
&lt;p>直接依觸發情境跳對應 reference：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>觸發情境&lt;/th>
 &lt;th>reference&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>要寫 CSS 規則、需要先確認 DOM 結構 / selector 該怎麼寫&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/dom-topology-first/" data-link-title="DOM Topology First — 寫 CSS 前先確認 DOM 結構" data-link-desc="frontend-with-playwright reference：寫 CSS 前用 playwright/DevTools 量真實 DOM、selector 三維度設計、起點四選一、idempotency 兩選一。">dom-topology-first&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>不確定 selector 該多寬、命中其他元素&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/dom-topology-first/" data-link-title="DOM Topology First — 寫 CSS 前先確認 DOM 結構" data-link-desc="frontend-with-playwright reference：寫 CSS 前用 playwright/DevTools 量真實 DOM、selector 三維度設計、起點四選一、idempotency 兩選一。">dom-topology-first&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>不確定值該寫進 CSS 還是 JS、CSS layers / variable / class toggle 取捨&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/css-js-boundary/" data-link-title="CSS / JS Boundary — CSS / JS 邊界與 specificity 處理" data-link-desc="frontend-with-playwright reference：CSS-only vs JS-assisted 判準、class toggle 取代 inline style、CSS layers 取代 specificity 戰、variable 單一定義位置、檔案拆分。">css-js-boundary&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>用 &lt;code>!important&lt;/code> / inline style 解 specificity&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/css-js-boundary/" data-link-title="CSS / JS Boundary — CSS / JS 邊界與 specificity 處理" data-link-desc="frontend-with-playwright reference：CSS-only vs JS-assisted 判準、class toggle 取代 inline style、CSS layers 取代 specificity 戰、variable 單一定義位置、檔案拆分。">css-js-boundary&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>要用 playwright 驗證 layout / 假設 / 互動&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/playwright-in-loop/" data-link-title="Playwright in the Development Loop — 開發循環的三個位置" data-link-desc="frontend-with-playwright reference：Playwright 三個位置（假設 / 行為 / 互動驗證）的 evaluate 範例、寫成 layout test 的時機與模板、最低門檻 setup。">playwright-in-loop&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Layout bug 第 2 次出現、想寫成測試&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/playwright-in-loop/" data-link-title="Playwright in the Development Loop — 開發循環的三個位置" data-link-desc="frontend-with-playwright reference：Playwright 三個位置（假設 / 行為 / 互動驗證）的 evaluate 範例、寫成 layout test 的時機與模板、最低門檻 setup。">playwright-in-loop&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>客製 UI 被 framework 還原、不知道該注入到哪&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/framework-coexistence/" data-link-title="Framework Coexistence — 跟 framework-managed DOM 共處" data-link-desc="frontend-with-playwright reference：framework 邊界辨識、JS 操作四級安全度、客製 UI 注入到邊界外、外部組件四層合作（公共介面 → 邊界 → 邊界 DOM → 內部結構）。">framework-coexistence&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>要客製外部組件（pagefind / vendor library）&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/framework-coexistence/" data-link-title="Framework Coexistence — 跟 framework-managed DOM 共處" data-link-desc="frontend-with-playwright reference：framework 邊界辨識、JS 操作四級安全度、客製 UI 注入到邊界外、外部組件四層合作（公共介面 → 邊界 → 邊界 DOM → 內部結構）。">framework-coexistence&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>使用者反映卡頓、CPU 100%、scroll lag、resize jank&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/reactive-performance/" data-link-title="Reactive Performance — Reactive 效能盤點與優化" data-link-desc="frontend-with-playwright reference：MutationObserver 三維度、polling → observer、iteration / regex 成本、layout reflow、resource 載入時序、reactive listener 盤點協議。">reactive-performance&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>要設計 MutationObserver / event listener 範圍&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/reactive-performance/" data-link-title="Reactive Performance — Reactive 效能盤點與優化" data-link-desc="frontend-with-playwright reference：MutationObserver 三維度、polling → observer、iteration / regex 成本、layout reflow、resource 載入時序、reactive listener 盤點協議。">reactive-performance&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>要驗收鍵盤 / screen reader / motor / 視覺 a11y&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/accessibility-and-focus/" data-link-title="Accessibility and Focus — A11y 三道防線" data-link-desc="frontend-with-playwright reference：鍵盤可達性三要素、focus management on DOM move、aria-live 動態廣播、Native HTML &amp;gt; ARIA、視覺 / motor a11y。">accessibility-and-focus&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>JS reparent 後 focus 跑掉、aria-live 沒朗讀&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/accessibility-and-focus/" data-link-title="Accessibility and Focus — A11y 三道防線" data-link-desc="frontend-with-playwright reference：鍵盤可達性三要素、focus management on DOM move、aria-live 動態廣播、Native HTML &amp;gt; ARIA、視覺 / motor a11y。">accessibility-and-focus&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>設計 filter / sort / count 操作、source 是分批 / streaming&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/" data-link-title="Data Flow and Filter Composition — Filter × Source 層錯位與五策略" data-link-desc="frontend-with-playwright reference：Filter / sort / count / transform stream 操作的層錯位識別 &amp;#43; 五策略合成。原則跨前端 / 後端 / 演算法 / DB 通用、playwright 驗證模板。">data-flow-and-filter-composition&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>「Load more 後畫面閃但內容沒變」的 silent 缺口&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/" data-link-title="Data Flow and Filter Composition — Filter × Source 層錯位與五策略" data-link-desc="frontend-with-playwright reference：Filter / sort / count / transform stream 操作的層錯位識別 &amp;#43; 五策略合成。原則跨前端 / 後端 / 演算法 / DB 通用、playwright 驗證模板。">data-flow-and-filter-composition&lt;/a>（層錯位）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Backend / 演算法 / map-reduce 的 post-filter 漏項&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/" data-link-title="Data Flow and Filter Composition — Filter × Source 層錯位與五策略" data-link-desc="frontend-with-playwright reference：Filter / sort / count / transform stream 操作的層錯位識別 &amp;#43; 五策略合成。原則跨前端 / 後端 / 演算法 / DB 通用、playwright 驗證模板。">data-flow-and-filter-composition&lt;/a>（跨領域同結構）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每份 reference 自包含：讀任一份不需要回頭讀其他 reference。&lt;/p></description><content:encoded><![CDATA[<h2 id="這個資料夾是什麼">這個資料夾是什麼</h2>
<p><code>frontend-with-playwright</code> 是一套前端開發協議 skill，原生位置在 <a href="https://github.com/tarrragon/blog/tree/main/.claude/skills/frontend-with-playwright"><code>.claude/skills/frontend-with-playwright/</code></a> 供 Claude runtime 呼叫；這份是<strong>同內容的文章版本</strong>，讓人類讀者也能直接在 blog 閱讀。</p>
<p>原則框架無關 — 適用 vanilla HTML/CSS/JS、Vue、React、jQuery — 因為核心是「DOM / CSS / JS 三者的本質行為」加上「Playwright 用 live DOM 量測驗證」、不依賴特定框架的渲染機制。源頭是 <a href="/blog/report/" data-link-title="Report — 開發過程的事後檢討" data-link-desc="blog 開發過程中、把實際遇到的版型 / 整合 / 框架共處等情境、整理成『應該怎麼做、沒這樣做會有什麼麻煩』的事後檢討。每篇皆為正向指引、幫助下一輪同類任務跳過反覆試錯。"><code>content/report/</code></a> 累積的 50+ 篇事後檢討、由本 skill 的六份 reference 萃取對應六個情境的協議步驟。</p>
<h2 id="閱讀順序">閱讀順序</h2>
<h3 id="場景-1第一次接觸">場景 1：第一次接觸</h3>
<table>
  <thead>
      <tr>
          <th>順序</th>
          <th>檔案</th>
          <th>目的</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td><a href="/blog/skills/frontend-with-playwright/skill/" data-link-title="Frontend with Playwright — SKILL 入口" data-link-desc="框架無關的前端開發 &#43; Playwright 驗證 SKILL 入口：三大支柱、六大原則速查、六份情境 reference 的觸發路由。">SKILL.md</a></td>
          <td>三大支柱 + 六大原則速查、觸發路由表</td>
      </tr>
      <tr>
          <td>2</td>
          <td>依情境挑一份 reference（見下表）</td>
          <td>把原則翻譯成可套用的協議步驟、模板與範例</td>
      </tr>
      <tr>
          <td>3</td>
          <td>該 reference 結尾的 self-check checklist</td>
          <td>自評有沒有按協議走</td>
      </tr>
  </tbody>
</table>
<h3 id="場景-2已熟悉協議想直接解決當前任務">場景 2：已熟悉協議、想直接解決當前任務</h3>
<p>直接依觸發情境跳對應 reference：</p>
<table>
  <thead>
      <tr>
          <th>觸發情境</th>
          <th>reference</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>要寫 CSS 規則、需要先確認 DOM 結構 / selector 該怎麼寫</td>
          <td><a href="/blog/skills/frontend-with-playwright/dom-topology-first/" data-link-title="DOM Topology First — 寫 CSS 前先確認 DOM 結構" data-link-desc="frontend-with-playwright reference：寫 CSS 前用 playwright/DevTools 量真實 DOM、selector 三維度設計、起點四選一、idempotency 兩選一。">dom-topology-first</a></td>
      </tr>
      <tr>
          <td>不確定 selector 該多寬、命中其他元素</td>
          <td><a href="/blog/skills/frontend-with-playwright/dom-topology-first/" data-link-title="DOM Topology First — 寫 CSS 前先確認 DOM 結構" data-link-desc="frontend-with-playwright reference：寫 CSS 前用 playwright/DevTools 量真實 DOM、selector 三維度設計、起點四選一、idempotency 兩選一。">dom-topology-first</a></td>
      </tr>
      <tr>
          <td>不確定值該寫進 CSS 還是 JS、CSS layers / variable / class toggle 取捨</td>
          <td><a href="/blog/skills/frontend-with-playwright/css-js-boundary/" data-link-title="CSS / JS Boundary — CSS / JS 邊界與 specificity 處理" data-link-desc="frontend-with-playwright reference：CSS-only vs JS-assisted 判準、class toggle 取代 inline style、CSS layers 取代 specificity 戰、variable 單一定義位置、檔案拆分。">css-js-boundary</a></td>
      </tr>
      <tr>
          <td>用 <code>!important</code> / inline style 解 specificity</td>
          <td><a href="/blog/skills/frontend-with-playwright/css-js-boundary/" data-link-title="CSS / JS Boundary — CSS / JS 邊界與 specificity 處理" data-link-desc="frontend-with-playwright reference：CSS-only vs JS-assisted 判準、class toggle 取代 inline style、CSS layers 取代 specificity 戰、variable 單一定義位置、檔案拆分。">css-js-boundary</a></td>
      </tr>
      <tr>
          <td>要用 playwright 驗證 layout / 假設 / 互動</td>
          <td><a href="/blog/skills/frontend-with-playwright/playwright-in-loop/" data-link-title="Playwright in the Development Loop — 開發循環的三個位置" data-link-desc="frontend-with-playwright reference：Playwright 三個位置（假設 / 行為 / 互動驗證）的 evaluate 範例、寫成 layout test 的時機與模板、最低門檻 setup。">playwright-in-loop</a></td>
      </tr>
      <tr>
          <td>Layout bug 第 2 次出現、想寫成測試</td>
          <td><a href="/blog/skills/frontend-with-playwright/playwright-in-loop/" data-link-title="Playwright in the Development Loop — 開發循環的三個位置" data-link-desc="frontend-with-playwright reference：Playwright 三個位置（假設 / 行為 / 互動驗證）的 evaluate 範例、寫成 layout test 的時機與模板、最低門檻 setup。">playwright-in-loop</a></td>
      </tr>
      <tr>
          <td>客製 UI 被 framework 還原、不知道該注入到哪</td>
          <td><a href="/blog/skills/frontend-with-playwright/framework-coexistence/" data-link-title="Framework Coexistence — 跟 framework-managed DOM 共處" data-link-desc="frontend-with-playwright reference：framework 邊界辨識、JS 操作四級安全度、客製 UI 注入到邊界外、外部組件四層合作（公共介面 → 邊界 → 邊界 DOM → 內部結構）。">framework-coexistence</a></td>
      </tr>
      <tr>
          <td>要客製外部組件（pagefind / vendor library）</td>
          <td><a href="/blog/skills/frontend-with-playwright/framework-coexistence/" data-link-title="Framework Coexistence — 跟 framework-managed DOM 共處" data-link-desc="frontend-with-playwright reference：framework 邊界辨識、JS 操作四級安全度、客製 UI 注入到邊界外、外部組件四層合作（公共介面 → 邊界 → 邊界 DOM → 內部結構）。">framework-coexistence</a></td>
      </tr>
      <tr>
          <td>使用者反映卡頓、CPU 100%、scroll lag、resize jank</td>
          <td><a href="/blog/skills/frontend-with-playwright/reactive-performance/" data-link-title="Reactive Performance — Reactive 效能盤點與優化" data-link-desc="frontend-with-playwright reference：MutationObserver 三維度、polling → observer、iteration / regex 成本、layout reflow、resource 載入時序、reactive listener 盤點協議。">reactive-performance</a></td>
      </tr>
      <tr>
          <td>要設計 MutationObserver / event listener 範圍</td>
          <td><a href="/blog/skills/frontend-with-playwright/reactive-performance/" data-link-title="Reactive Performance — Reactive 效能盤點與優化" data-link-desc="frontend-with-playwright reference：MutationObserver 三維度、polling → observer、iteration / regex 成本、layout reflow、resource 載入時序、reactive listener 盤點協議。">reactive-performance</a></td>
      </tr>
      <tr>
          <td>要驗收鍵盤 / screen reader / motor / 視覺 a11y</td>
          <td><a href="/blog/skills/frontend-with-playwright/accessibility-and-focus/" data-link-title="Accessibility and Focus — A11y 三道防線" data-link-desc="frontend-with-playwright reference：鍵盤可達性三要素、focus management on DOM move、aria-live 動態廣播、Native HTML &gt; ARIA、視覺 / motor a11y。">accessibility-and-focus</a></td>
      </tr>
      <tr>
          <td>JS reparent 後 focus 跑掉、aria-live 沒朗讀</td>
          <td><a href="/blog/skills/frontend-with-playwright/accessibility-and-focus/" data-link-title="Accessibility and Focus — A11y 三道防線" data-link-desc="frontend-with-playwright reference：鍵盤可達性三要素、focus management on DOM move、aria-live 動態廣播、Native HTML &gt; ARIA、視覺 / motor a11y。">accessibility-and-focus</a></td>
      </tr>
      <tr>
          <td>設計 filter / sort / count 操作、source 是分批 / streaming</td>
          <td><a href="/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/" data-link-title="Data Flow and Filter Composition — Filter × Source 層錯位與五策略" data-link-desc="frontend-with-playwright reference：Filter / sort / count / transform stream 操作的層錯位識別 &#43; 五策略合成。原則跨前端 / 後端 / 演算法 / DB 通用、playwright 驗證模板。">data-flow-and-filter-composition</a></td>
      </tr>
      <tr>
          <td>「Load more 後畫面閃但內容沒變」的 silent 缺口</td>
          <td><a href="/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/" data-link-title="Data Flow and Filter Composition — Filter × Source 層錯位與五策略" data-link-desc="frontend-with-playwright reference：Filter / sort / count / transform stream 操作的層錯位識別 &#43; 五策略合成。原則跨前端 / 後端 / 演算法 / DB 通用、playwright 驗證模板。">data-flow-and-filter-composition</a>（層錯位）</td>
      </tr>
      <tr>
          <td>Backend / 演算法 / map-reduce 的 post-filter 漏項</td>
          <td><a href="/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/" data-link-title="Data Flow and Filter Composition — Filter × Source 層錯位與五策略" data-link-desc="frontend-with-playwright reference：Filter / sort / count / transform stream 操作的層錯位識別 &#43; 五策略合成。原則跨前端 / 後端 / 演算法 / DB 通用、playwright 驗證模板。">data-flow-and-filter-composition</a>（跨領域同結構）</td>
      </tr>
  </tbody>
</table>
<p>每份 reference 自包含：讀任一份不需要回頭讀其他 reference。</p>
<h2 id="跟-requirement-protocol-的關係">跟 requirement-protocol 的關係</h2>
<p><a href="/blog/skills/requirement-protocol/" data-link-title="Requirement Protocol — 需求確認到實作的對話協議" data-link-desc="從需求確認到實作的對話協議：模糊指令澄清、可決定 vs 該確認、失敗 2 次轉折、覆寫成本告知、revert checkpoint、漸進驗證、工具切換時機。六大原則 &#43; 五份情境 reference。">requirement-protocol</a> 是上層的「對話協議」（澄清需求、失敗轉折、覆寫成本、工具切換時機）；本 skill 是下層的「前端執行協議」（DOM / CSS / JS / Playwright 的具體做法）。</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>該讀哪個 skill</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>不確定該怎麼跟使用者溝通、需求模糊、失敗該怎麼轉折</td>
          <td>requirement-protocol</td>
      </tr>
      <tr>
          <td>知道要做什麼、不確定前端該怎麼實作驗證</td>
          <td>frontend-with-playwright（本 skill）</td>
      </tr>
  </tbody>
</table>
<p>兩個 skill 的 <code>playwright</code> 段落互補：requirement-protocol 講「何時切」、本 skill 講「切了之後具體寫什麼 query」。</p>
<h2 id="與-blog-專案其他資料的關係">與 blog 專案其他資料的關係</h2>
<table>
  <thead>
      <tr>
          <th>位置</th>
          <th>角色</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>.claude/skills/frontend-with-playwright/</code></td>
          <td>實際 skill — Claude runtime 呼叫的檔案來源</td>
      </tr>
      <tr>
          <td><code>content/skills/frontend-with-playwright/</code>（本處）</td>
          <td>文章版本 — 人類讀者在 blog 閱讀</td>
      </tr>
      <tr>
          <td><a href="/blog/report/" data-link-title="Report — 開發過程的事後檢討" data-link-desc="blog 開發過程中、把實際遇到的版型 / 整合 / 框架共處等情境、整理成『應該怎麼做、沒這樣做會有什麼麻煩』的事後檢討。每篇皆為正向指引、幫助下一輪同類任務跳過反覆試錯。"><code>content/report/</code></a></td>
          <td>50+ 篇事後檢討、本 skill 的素材來源；reference 結尾連回對應篇</td>
      </tr>
      <tr>
          <td><a href="/blog/skills/requirement-protocol/" data-link-title="Requirement Protocol — 需求確認到實作的對話協議" data-link-desc="從需求確認到實作的對話協議：模糊指令澄清、可決定 vs 該確認、失敗 2 次轉折、覆寫成本告知、revert checkpoint、漸進驗證、工具切換時機。六大原則 &#43; 五份情境 reference。"><code>content/skills/requirement-protocol/</code></a></td>
          <td>上層對話協議 skill</td>
      </tr>
  </tbody>
</table>
<h2 id="last-updated">Last Updated</h2>
<p>2026-04-26 — v0.2.0 接入 #55-#68 系列：新增第 7 份 reference <code>data-flow-and-filter-composition</code>（Filter × Source 層錯位 + 五策略 + 跨前端 / 後端 / 演算法 / DB 範例）；強調原則跨領域通用、不只前端。</p>
<p>歷史版本：</p>
<ul>
<li>2026-04-26 — v0.1.0 初版：六份 references 對應「DOM topology / CSS-JS 邊界 / Playwright 三位置 / framework 共處 / Reactive 效能 / A11y」六個情境</li>
</ul>
]]></content:encoded></item></channel></rss>