<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: A11Y Practice</title>
    <description>The latest articles on DEV Community by A11Y Practice (@a11y_practice_be4b9244508).</description>
    <link>https://dev.to/a11y_practice_be4b9244508</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4140425%2F40a93fda-b6b9-4940-a9c9-64c391dfab46.png</url>
      <title>DEV Community: A11Y Practice</title>
      <link>https://dev.to/a11y_practice_be4b9244508</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/a11y_practice_be4b9244508"/>
    <language>en</language>
    <item>
      <title>Test Your Tabs: One Tab Stop, Arrow Keys, a Focusable Panel and No Up/Down</title>
      <dc:creator>A11Y Practice</dc:creator>
      <pubDate>Wed, 04 Nov 2026 08:12:10 +0000</pubDate>
      <link>https://dev.to/a11y_practice_be4b9244508/test-your-tabs-one-tab-stop-arrow-keys-a-focusable-panel-and-no-updown-3ba</link>
      <guid>https://dev.to/a11y_practice_be4b9244508/test-your-tabs-one-tab-stop-arrow-keys-a-focusable-panel-and-no-updown-3ba</guid>
      <description>&lt;p&gt;A tab list can look perfect and still hide a tab from the keyboard. In the &lt;a href="https://www.wasexamprep.com/go/e11-lab-devto" rel="noopener noreferrer"&gt;Full Tabs Pattern lab&lt;/a&gt; on wasexamprep.com, one broken case hard-codes &lt;code&gt;tabIndex={-1}&lt;/code&gt; on the second tab. A mouse can click it. Tab skips it. The arrow keys do nothing. That is a WCAG 2.1.1 Keyboard failure.&lt;/p&gt;

&lt;p&gt;The other broken case puts every tab in the Tab order. That is clumsy and it is not the &lt;a href="https://www.w3.org/WAI/ARIA/apg/patterns/tabs/" rel="noopener noreferrer"&gt;WAI-ARIA APG Tabs pattern&lt;/a&gt;, but every tab is still reachable and operable, so it passes 2.1.1. It is a best-practice deviation. The full write-up with the 2.1.1 reasoning, the tree evidence and the VoiceOver captures is on &lt;a href="https://www.wasexamprep.com/go/e11-blog-devto" rel="noopener noreferrer"&gt;the blog&lt;/a&gt;. This post is about locking the behavior in with tests, and about the mistakes those tests catch.&lt;/p&gt;

&lt;p&gt;One more thing makes this a good test case: the lab's fixed example changed this week. The panels got &lt;code&gt;tabindex="0"&lt;/code&gt;, and the handler stopped listening for ArrowUp and ArrowDown. Against the new component, the old test suite failed only once (the next Tab no longer left the widget, it landed on the panel). Nothing in it checked Up/Down, the panel's focus ring or Shift+Tab through the panel. The tests below fail on the old component and pass on the new one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contract
&lt;/h2&gt;

&lt;p&gt;For a horizontal tab list with automatic activation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Tab into the tab list lands on the selected tab. It is the only tab with &lt;code&gt;tabindex="0"&lt;/code&gt;; every other tab has &lt;code&gt;-1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Left and Right Arrow move focus and selection to the previous and next tab, and wrap at the ends.&lt;/li&gt;
&lt;li&gt;Home and End go to the first and last tab.&lt;/li&gt;
&lt;li&gt;ArrowUp and ArrowDown are not handled, so they keep scrolling the page (APG: a horizontal tab list does not listen for them).&lt;/li&gt;
&lt;li&gt;The next Tab moves out of the tab list onto the visible panel, because the panel has no focusable content and has &lt;code&gt;tabindex="0"&lt;/code&gt;. It needs a visible focus ring (2.4.7).&lt;/li&gt;
&lt;li&gt;Shift+Tab walks back: panel, then the selected tab - not the first tab.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The lab's handler, from &lt;code&gt;fixed-example.tsx&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleTabListKeyDown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KeyboardEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLDivElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;activeTab&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;nextIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ArrowRight&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nx"&gt;nextIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ArrowLeft&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nx"&gt;nextIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Home&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nx"&gt;nextIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;End&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nx"&gt;nextIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nl"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;selectTab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;nextIndex&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each tab gets &lt;code&gt;tabIndex={activeTab === tab.id ? 0 : -1}&lt;/code&gt; and &lt;code&gt;aria-selected={activeTab === tab.id}&lt;/code&gt;, and each panel gets &lt;code&gt;role="tabpanel"&lt;/code&gt;, &lt;code&gt;tabIndex={0}&lt;/code&gt;, &lt;code&gt;aria-labelledby&lt;/code&gt; and &lt;code&gt;hidden={activeTab !== tab.id}&lt;/code&gt;. &lt;code&gt;selectTab&lt;/code&gt; sets the state and calls &lt;code&gt;focus()&lt;/code&gt; on the new tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing Library + user-event
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;user.tab()&lt;/code&gt; follows the real Tab order, including &lt;code&gt;tabindex&lt;/code&gt; and &lt;code&gt;hidden&lt;/code&gt;, so it catches a wrong roving index or a missing panel stop. Put a button before and after the widget so you can prove exactly where focus goes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;setup&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;userEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Before&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FixedExample&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;After&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&amp;gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tablist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tablist&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Settings&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tab&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;within&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tablist&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tab&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;panel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tabpanel&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;panel&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;expectActive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HTMLElement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveFocus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aria-selected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tabindex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;has one Tab stop in the tablist, then the panel, then out&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;panel&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// "Before"&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;expectActive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Account&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Notifications&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toHaveAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tabindex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Security&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toHaveAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tabindex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// into the panel (no focusable content, so tabindex="0")&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Account&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toHaveFocus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;After&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})).&lt;/span&gt;&lt;span class="nf"&gt;toHaveFocus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ArrowUp and ArrowDown are not handled on a horizontal tablist&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tab&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Notifications&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;down&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;KeyboardEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keydown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ArrowDown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bubbles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cancelable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nf"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Notifications&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;dispatchEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;down&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;down&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;defaultPrevented&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// the page can still scroll&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keyboard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;{ArrowDown}{ArrowUp}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;expectActive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Notifications&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Tab returns to the last selected tab, not the first one&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tab&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Account&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keyboard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;{ArrowRight}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// panel&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// leave the widget&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// panel&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// tab&lt;/span&gt;
    &lt;span class="nf"&gt;expectActive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Notifications&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full file also covers Right with wrap, Left with wrap, Home/End, Shift+Tab from the panel and "all three panels stay in the DOM with &lt;code&gt;tabindex="0"&lt;/code&gt;, only one is exposed". The Up/Down test checks &lt;code&gt;defaultPrevented&lt;/code&gt; directly: "focus did not move" alone would also pass for a handler that swallows the key and blocks scrolling.&lt;/p&gt;

&lt;p&gt;The broken cases get tests too, written to the 2.1.1 line rather than to the APG:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Case 2: Notifications is never a Tab stop (2.1.1 failure)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tabIn&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setupBroken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;account&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tabIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Alert preferences&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Account&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;notifications&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tabIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Alert preferences&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Notifications&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;notifications&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// a mouse can select it&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;notifications&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aria-selected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;notifications&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tabindex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// still out of the Tab order&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;visited&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Element&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Before&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nx"&gt;visited&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activeElement&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;Element&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;visited&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;visited&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;notifications&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Its sibling asserts that in Case 1 Tab reaches Notifications and Enter selects it: no arrow keys, but operable.&lt;/p&gt;

&lt;p&gt;Run on 2026-11-01 with vitest 2.1.9 and jsdom, against &lt;code&gt;FixedExample&lt;/code&gt; and &lt;code&gt;BrokenExample&lt;/code&gt; copied from the lab: 10 of 10 pass. Against the pre-fix component, 4 fail: the panel stop, Shift+Tab, the panel &lt;code&gt;tabindex&lt;/code&gt; check and Up/Down. Setup notes: &lt;code&gt;toHaveFocus&lt;/code&gt; and &lt;code&gt;toBeVisible&lt;/code&gt; come from &lt;code&gt;@testing-library/jest-dom&lt;/code&gt; (import &lt;code&gt;@testing-library/jest-dom/vitest&lt;/code&gt; in a setup file), and you need &lt;code&gt;afterEach(cleanup)&lt;/code&gt; or the renders pile up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Playwright, against a real browser
&lt;/h2&gt;

&lt;p&gt;jsdom has no layout, no &lt;code&gt;:focus-visible&lt;/code&gt; and no CSS, so the focus ring can only be checked in a browser. The helper below answers one question: which tabs can a keyboard-only user focus? It tries every arrow, Home, End and plain Tab, and only counts tabs inside the tab list under test.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;keyboardReachableTabs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tablist&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Locator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;firstTab&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Locator&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;firstTab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Only count tabs inside THIS tablist (Tab can wander into the next widget).&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tablist&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activeElement&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;role&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tab&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;reached&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tablist&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tab&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ArrowRight&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ArrowLeft&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Home&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;End&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;press&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;firstTab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;press&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Tab&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;reached&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fixed-flow test ends with the panel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// One Tab press leaves the tablist and lands on the visible panel (tabindex="0").&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;press&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Tab&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;panel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tabpanel&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Security&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeFocused&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tabindex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// The ring is a box-shadow (Tailwind focus-visible:ring-2), not an outline.&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;:focus-visible&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getComputedStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;boxShadow&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/0px 0px 0px 2px/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// The next Tab leaves the widget; Shift+Tab walks back panel &amp;gt; selected tab.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;press&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Tab&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBeFocused&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;press&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Shift+Tab&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeFocused&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;press&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Shift+Tab&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Security&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeFocused&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And Case 2 is documented as a known bug, so the build stays green while the bug is visible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;broken Case 2: Notifications is unreachable (2.1.1)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// documents the bug: this assertion is expected to fail&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tablist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tablist&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Alert preferences&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;exact&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;keyboardReachableTabs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tablist&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tablist&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tab&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Account&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reached&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Account&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Notifications&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run against production on 2026-11-01 (Playwright 1.64.1, headless Chromium): 5 of 5 as expected. The reachability test maps to 2.1.1; the fixed-flow test checks the APG convention. Keep them separate so a failing build tells you which one broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Static &lt;code&gt;tabindex&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;0&lt;/code&gt; on the first tab, &lt;code&gt;-1&lt;/code&gt; on the rest, never updated. After a click, the unselected first tab keeps the Tab stop, and with no arrow handler the other tabs are mouse-only. Derive &lt;code&gt;tabIndex&lt;/code&gt; from the same state as &lt;code&gt;aria-selected&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two sources of truth.&lt;/strong&gt; &lt;code&gt;aria-selected&lt;/code&gt; from one variable, &lt;code&gt;tabIndex&lt;/code&gt; or the active style from another. One &lt;code&gt;activeTab&lt;/code&gt;, everything derived from it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State without focus.&lt;/strong&gt; Only calling &lt;code&gt;setActiveTab&lt;/code&gt; gives the new tab &lt;code&gt;tabindex="0"&lt;/code&gt; while focus stays on the old tab, which now has &lt;code&gt;-1&lt;/code&gt;. Call &lt;code&gt;focus()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No wrap.&lt;/strong&gt; &lt;code&gt;Math.min(index + 1, last)&lt;/code&gt; stops at the end. Use &lt;code&gt;(index + 1) % length&lt;/code&gt; and &lt;code&gt;(index - 1 + length) % length&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;preventDefault&lt;/code&gt; on every key.&lt;/strong&gt; Without &lt;code&gt;default: return&lt;/code&gt;, you swallow Tab and trap the user in the widget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handling Up/Down on a horizontal tab list.&lt;/strong&gt; It takes the page's scroll keys away. Leave them alone, or make the list vertical with &lt;code&gt;aria-orientation="vertical"&lt;/code&gt; and use Up/Down instead. Not a 2.1.1 failure, but not the pattern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A text-only panel with no Tab stop.&lt;/strong&gt; Without &lt;code&gt;tabindex="0"&lt;/code&gt;, Tab jumps from the tab straight past the content. And if you add the stop, add a visible focus ring with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manual activation with the automatic handler.&lt;/strong&gt; &lt;code&gt;order.indexOf(activeTab)&lt;/code&gt; is correct only when focus and selection always match. With manual activation (arrows move focus, Enter or Space selects), compute the index from &lt;code&gt;document.activeElement&lt;/code&gt; and move focus only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusting &lt;code&gt;focusable: true&lt;/code&gt;.&lt;/strong&gt; Chrome's accessibility tree reports it for a &lt;code&gt;tabindex="-1"&lt;/code&gt; tab. Focusable from script is not reachable with Tab. Press the keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Substring names in Playwright.&lt;/strong&gt; &lt;code&gt;getByRole("tablist", { name: "Settings" })&lt;/code&gt; also matched "Account settings" in the lab. Use &lt;code&gt;exact: true&lt;/code&gt;. Testing Library's string &lt;code&gt;name&lt;/code&gt; is exact by default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calling Case 1 a 2.1.1 failure.&lt;/strong&gt; Tab on every tab is reachable and operable. Report it as a best-practice deviation; save 2.1.1 for the tab nobody can reach.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  VoiceOver, from real captures only
&lt;/h2&gt;

&lt;p&gt;From the lab's VoiceOver capture docs (VoiceOver on macOS, captured 2026-06-12, before the panel fix; the tabs did not change). Broken Case 2, the only tab in the Tab order:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Account, selected, tab, 1 of 2, Alert preferences, tab group&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Fixed, Tab in, then Right Arrow twice:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Account, selected, tab, 1 of 3, Settings, tab group&lt;/p&gt;

&lt;p&gt;Notifications, selected, tab, 2 of 3&lt;/p&gt;

&lt;p&gt;Security, selected, tab, 3 of 3&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The wrap, Left Arrow, Home, End and the Tab into the panel were captured on 2026-11-01 on the live lab after the panel fix, with VoiceOver on Mac (macOS 26.6.2, VoiceOver 10, Chrome 156.0.8078.79). These are keyboard-focus announcements, not VoiceOver-cursor reading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Right Arrow from Security (wraps): "Account selected tab, 1 of 3"&lt;/li&gt;
&lt;li&gt;Left Arrow from Account (wraps): "Security selected tab, 3 of 3"&lt;/li&gt;
&lt;li&gt;Home: "Account selected tab, 1 of 3"&lt;/li&gt;
&lt;li&gt;End: "Security selected tab, 3 of 3"&lt;/li&gt;
&lt;li&gt;Tab from the Notifications tab into its panel: "âœ… Fixed Example region Notifications tab panel"&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Keep practicing
&lt;/h2&gt;

&lt;p&gt;Try both broken cases yourself in the &lt;a href="https://www.wasexamprep.com/go/e11-lab-devto" rel="noopener noreferrer"&gt;Full Tabs Pattern lab&lt;/a&gt; on &lt;a href="https://www.wasexamprep.com" rel="noopener noreferrer"&gt;wasexamprep.com&lt;/a&gt;, then read &lt;a href="https://www.wasexamprep.com/go/e11-blog-devto" rel="noopener noreferrer"&gt;the full write-up&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Watch the full walkthrough and subscribe to the &lt;a href="https://www.youtube.com/@a11yinpractice" rel="noopener noreferrer"&gt;A11Y in Practice YouTube channel&lt;/a&gt; for more hands-on accessibility episodes.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A11Y in Practice and wasexamprep.com are independent educational resources. They are not affiliated with or endorsed by IAAP (International Association of Accessibility Professionals).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>react</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
    <item>
      <title>aria-selected on tabs: the selected tab that lies (WCAG 4.1.2)</title>
      <dc:creator>A11Y Practice</dc:creator>
      <pubDate>Wed, 28 Oct 2026 08:25:18 +0000</pubDate>
      <link>https://dev.to/a11y_practice_be4b9244508/aria-selected-on-tabs-the-selected-tab-that-lies-wcag-412-576g</link>
      <guid>https://dev.to/a11y_practice_be4b9244508/aria-selected-on-tabs-the-selected-tab-that-lies-wcag-412-576g</guid>
      <description>&lt;p&gt;The Notifications tab is open. Its panel is on screen: "Choose which email and push alerts you receive." Then VoiceOver lands on the other tab and says Account, selected, tab, 1 of 2, Alert preferences, tab group. On Notifications, the tab you are looking at, it says Notifications, tab, 2 of 2. No selected. The tab you see is not the tab the code says.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/939KZzpMFuk" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;WCAG 4.1.2 Name, Role, Value has three parts. In E07 (&lt;a href="https://youtu.be/PJh5PMdxHwA" rel="noopener noreferrer"&gt;A Custom Div Is Not a Checkbox&lt;/a&gt;), a custom control never told VoiceOver it was a checkbox - that was the role. This breakdown is the value. The name and the role are already right here (Account, tab, 1 of 2); the state is wrong.&lt;/p&gt;

&lt;p&gt;This breakdown uses the Selected State lab (E10): Case 1 (no &lt;code&gt;aria-selected&lt;/code&gt; at all) and Case 2 (&lt;code&gt;aria-selected&lt;/code&gt; hard-coded). The evidence comes from real accessibility-tree and VoiceOver captures.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Case 1 - &lt;code&gt;role="tab"&lt;/code&gt; with no &lt;code&gt;aria-selected&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The tab list is real: &lt;code&gt;role="tablist"&lt;/code&gt; with an &lt;code&gt;aria-label&lt;/code&gt;, and two buttons with &lt;code&gt;role="tab"&lt;/code&gt;. The active tab only gets a different background class. Nothing in the markup says which tab is selected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"tablist"&lt;/span&gt; &lt;span class="na"&gt;aria-label=&lt;/span&gt;&lt;span class="s"&gt;"Account settings"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"tab-bar"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt; &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"tab"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"selected-state-broken-case1-account-tab"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    Account
  &lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt;
    &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt;
    &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"tab"&lt;/span&gt;
    &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"selected-state-broken-case1-notifications-tab"&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    Notifications
  &lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The selection lives in a CSS class. Assistive technology cannot read a class name.&lt;/p&gt;

&lt;h3&gt;
  
  
  What VoiceOver announced (broken, Case 1)
&lt;/h3&gt;

&lt;p&gt;On first load, with Account visually active:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Account, selected, tab, 1 of 2, Account settings, tab group&lt;/p&gt;

&lt;p&gt;You are currently on a selected tab, 1 of 2.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That sounds right, but there is no &lt;code&gt;aria-selected&lt;/code&gt; in the markup. The capture notes say VoiceOver may announce selected here without a matching programmatic value in the tree. Do not build on it.&lt;/p&gt;

&lt;p&gt;After activating Notifications, with the Notifications panel visible:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Notifications, tab, 2 of 2&lt;/p&gt;

&lt;p&gt;You are currently on a tab, 2 of 2. To select this option, press Control-Option-Space.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It is asking you to select the tab you just selected.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the accessibility tree showed (broken, Case 1)
&lt;/h3&gt;

&lt;p&gt;Captured after activating Notifications:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;tablist "Account settings"&lt;/strong&gt;, multiselectable: false&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tab "Account"&lt;/strong&gt; - focusable: true, &lt;strong&gt;selected: false&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tab "Notifications"&lt;/strong&gt; - focusable: true, &lt;strong&gt;selected: false&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two tabs. Neither one is selected. The screen shows an open tab; the code exposes none. Without &lt;code&gt;aria-selected&lt;/code&gt;, the tree's selected state does not track the visual active tab.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case 2 - &lt;code&gt;aria-selected&lt;/code&gt; hard-coded
&lt;/h3&gt;

&lt;p&gt;This is the partial fix. Someone read "add aria-selected" and added it - as literal values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"tab"&lt;/span&gt;
  &lt;span class="na"&gt;aria-selected&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setCase2Active&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;account&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  Account
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"tab"&lt;/span&gt;
  &lt;span class="na"&gt;aria-selected&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setCase2Active&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;notifications&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  Notifications
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The click updates &lt;code&gt;case2Active&lt;/code&gt;, which switches the styling and the panel. &lt;code&gt;aria-selected&lt;/code&gt; never reads it. On first load that even looks correct, because Account is the starting tab. The bug only shows up when you switch.&lt;/p&gt;

&lt;h3&gt;
  
  
  What VoiceOver announced (broken, Case 2)
&lt;/h3&gt;

&lt;p&gt;With Notifications focused and its panel visible:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Notifications, tab, 2 of 2&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And on Account, which is no longer the open tab:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Account, selected, tab, 1 of 2, Alert preferences, tab group&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  What the accessibility tree showed (broken, Case 2)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;tablist "Alert preferences"&lt;/strong&gt;, multiselectable: false&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tab "Account"&lt;/strong&gt; - focusable: true, &lt;strong&gt;selected: true&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tab "Notifications"&lt;/strong&gt; - focusable: true, &lt;strong&gt;focused: true&lt;/strong&gt;, &lt;strong&gt;selected: false&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tree agrees with VoiceOver, and both disagree with the screen. Focus moved. Selection didn't.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this fails exam-style
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;4.1.2 Name, Role, Value&lt;/strong&gt; (Level A) requires that the name and role of user interface components can be programmatically determined, that states the user can set can be programmatically set, and that changes to these items are available to assistive technologies. Which tab is selected is a state the user sets by clicking a tab. If the code never updates it, assistive technology is told the wrong thing.&lt;/li&gt;
&lt;li&gt;The WAS rule for this (Custom Widgets, "Bound ARIA States"): "States such as &lt;code&gt;aria-expanded&lt;/code&gt;, &lt;code&gt;aria-selected&lt;/code&gt;, &lt;code&gt;aria-checked&lt;/code&gt;, and &lt;code&gt;aria-pressed&lt;/code&gt; MUST update when the UI changes - stale values mislead screen reader users."&lt;/li&gt;
&lt;li&gt;Case 1 exposes &lt;strong&gt;no&lt;/strong&gt; selected tab: both tabs are &lt;code&gt;selected: false&lt;/code&gt; in the tree while one is visibly open.&lt;/li&gt;
&lt;li&gt;Case 2 exposes the &lt;strong&gt;wrong&lt;/strong&gt; selected tab. The attribute is present and its values are valid, so a quick check for the attribute finds it - but the value is stale.&lt;/li&gt;
&lt;li&gt;Exam signal for missing state: a tab list where no tab has &lt;code&gt;selected: true&lt;/code&gt; while one panel is visible. Exam signal for stale state: &lt;code&gt;selected: true&lt;/code&gt; on a tab that is not the open one, often next to &lt;code&gt;focused: true&lt;/code&gt; on the tab that is. In both cases, test &lt;strong&gt;after switching&lt;/strong&gt;, and check both tabs - not only the first one.&lt;/li&gt;
&lt;li&gt;Name and role problems (a control that does not expose what it is) are the E07 side of 4.1.2. &lt;code&gt;role="tabpanel"&lt;/code&gt; / &lt;code&gt;aria-controls&lt;/code&gt; and arrow-key navigation are separate topics (Panel Association and Full Tabs Pattern labs).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Bind &lt;code&gt;aria-selected&lt;/code&gt; to the same state that drives the active styling and the panel: &lt;code&gt;true&lt;/code&gt; on the active tab, &lt;code&gt;false&lt;/code&gt; on every other tab. The same fix repairs both cases. In React:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"tab"&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"selected-state-fixed-case1-account-tab"&lt;/span&gt;
  &lt;span class="na"&gt;aria-selected&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;case1Active&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;account&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setCase1Active&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;account&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  Account
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"tab"&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"selected-state-fixed-case1-notifications-tab"&lt;/span&gt;
  &lt;span class="na"&gt;aria-selected&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;case1Active&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;notifications&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setCase1Active&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;notifications&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  Notifications
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without a framework, update the attribute in the same place you switch the tab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;accountTab&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#selected-state-fixed-case1-account-tab&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;notificationsTab&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#selected-state-fixed-case1-notifications-tab&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;setActiveTab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;accountTab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aria-selected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;account&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;notificationsTab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aria-selected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;notifications&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;accountTab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setActiveTab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;account&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;notificationsTab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setActiveTab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;notifications&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not a CSS class. Not a hard-coded &lt;code&gt;true&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What changed in the tree (fixed)
&lt;/h3&gt;

&lt;p&gt;With the Notifications panel visible in both cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Case 1: &lt;strong&gt;tab "Account"&lt;/strong&gt; - focusable: true, &lt;strong&gt;selected: false&lt;/strong&gt;; &lt;strong&gt;tab "Notifications"&lt;/strong&gt; - focusable: true, &lt;strong&gt;selected: true&lt;/strong&gt;. The Notifications node lists &lt;code&gt;aria-selected&lt;/code&gt;: true, the Account node &lt;code&gt;aria-selected&lt;/code&gt;: false.&lt;/li&gt;
&lt;li&gt;Case 2: &lt;strong&gt;tab "Account"&lt;/strong&gt; - focusable: true, &lt;strong&gt;selected: false&lt;/strong&gt;; &lt;strong&gt;tab "Notifications"&lt;/strong&gt; - focusable: true, &lt;strong&gt;focused: true&lt;/strong&gt;, &lt;strong&gt;selected: true&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Contrast broken Case 2, where Account stayed &lt;strong&gt;selected: true&lt;/strong&gt; after Notifications became active.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Exactly one tab is selected, and it is the one on screen. The tree finally matches the page.&lt;/p&gt;

&lt;h3&gt;
  
  
  What VoiceOver announced (fixed)
&lt;/h3&gt;

&lt;p&gt;Case 2, with Account active:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Account, selected, tab, 1 of 2, Alert preferences, tab group&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The hint on the active Account tab (Case 1):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You are currently on a selected tab, 1 of 2.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The capture also records a state-only reading for Case 2 (the lab's evidence caption places it on the active Notifications tab):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;selected&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To be honest about the evidence: the lab capture does not include a full VoiceOver announcement for the active Notifications tab after the fix, so this post does not quote one. The proof for the fix is the tree above - &lt;code&gt;selected: true&lt;/code&gt; on exactly the visible tab. While Account is active, the inactive Notifications tab reads:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Notifications, tab, 2 of 2&lt;/p&gt;

&lt;p&gt;You are currently on a tab, 2 of 2. To select this option, press Control-Option-Space.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Behaviors to verify
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Exactly one tab in each tab list has &lt;code&gt;aria-selected="true"&lt;/code&gt;; every other tab has &lt;code&gt;aria-selected="false"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aria-selected&lt;/code&gt; reads the same state that drives the active styling and the visible panel - no literal &lt;code&gt;true&lt;/code&gt; or &lt;code&gt;false&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Switch tabs, then check the accessibility tree: &lt;code&gt;selected: true&lt;/code&gt; must follow the visible tab. Then move VoiceOver to both tabs, not just the first one, and confirm only the open tab is announced as selected. Do not trust a first-load listen.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Practice it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Video: &lt;a href="https://youtu.be/939KZzpMFuk" rel="noopener noreferrer"&gt;https://youtu.be/939KZzpMFuk&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Short: &lt;a href="https://youtube.com/shorts/l2yr49-N8b8" rel="noopener noreferrer"&gt;https://youtube.com/shorts/l2yr49-N8b8&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Lab: &lt;a href="https://www.wasexamprep.com/go/e10-lab-devto" rel="noopener noreferrer"&gt;Selected State lab&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Companion write-up: &lt;a href="https://www.wasexamprep.com/go/e10-blog-devto" rel="noopener noreferrer"&gt;https://www.wasexamprep.com/go/e10-blog-devto&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Takeaway: present is not correct. A tab's &lt;code&gt;aria-selected&lt;/code&gt; has to follow the tab that is actually open. Switch tabs, and check the tree, not just your ears.&lt;/p&gt;

&lt;p&gt;If this was useful, subscribe to &lt;strong&gt;A11Y in Practice&lt;/strong&gt; on YouTube (&lt;a href="https://www.youtube.com/@a11yinpractice" rel="noopener noreferrer"&gt;@a11yinpractice&lt;/a&gt;) for more hands-on accessibility episodes, and visit &lt;a href="https://www.wasexamprep.com" rel="noopener noreferrer"&gt;wasexamprep.com&lt;/a&gt; for WAS study notes and labs.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Independent study notes - not affiliated with IAAP or W3C.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>wcag</category>
      <category>aria</category>
      <category>webdev</category>
    </item>
    <item>
      <title>th scope row vs col: table headers that sound fine but are wrong (WCAG 1.3.1)</title>
      <dc:creator>A11Y Practice</dc:creator>
      <pubDate>Wed, 21 Oct 2026 07:51:26 +0000</pubDate>
      <link>https://dev.to/a11y_practice_be4b9244508/th-scope-row-vs-col-table-headers-that-sound-fine-but-are-wrong-wcag-131-3437</link>
      <guid>https://dev.to/a11y_practice_be4b9244508/th-scope-row-vs-col-table-headers-that-sound-fine-but-are-wrong-wcag-131-3437</guid>
      <description>&lt;p&gt;"Jane Doe" on its own is a word in a box. Is she the instructor, a student, a guest? In a properly built table, VoiceOver says Instructor Jane Doe, column 2 of 3 - the header is what turns the word into a fact. Lose that relationship in the code and the table is just a grid of words, no matter how clear it looks on screen.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/cTSw8wnJwcM" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;This breakdown uses the Table Headers lab (E09): Case 1 (&lt;code&gt;&amp;lt;th&amp;gt;&lt;/code&gt; without &lt;code&gt;scope&lt;/code&gt;) and Case 2 (&lt;code&gt;scope="row"&lt;/code&gt; on column headers). Both use real header cells and both sound fine in VoiceOver. The evidence comes from real accessibility-tree and VoiceOver captures.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Case 1 - &lt;code&gt;&amp;lt;th&amp;gt;&lt;/code&gt; without &lt;code&gt;scope&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The course table has a proper &lt;code&gt;thead&lt;/code&gt; with three &lt;code&gt;&amp;lt;th&amp;gt;&lt;/code&gt; cells, but none of them says which direction it labels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;table&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"table-headers-broken-case1-course-table"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;thead&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;th&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"table-headers-broken-case1-course-header"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Course&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;th&amp;gt;&lt;/span&gt;Instructor&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;th&amp;gt;&lt;/span&gt;Room&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/thead&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;tbody&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;Intro to WAS&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;Jane Doe&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;101&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
    &lt;span class="c"&gt;&amp;lt;!-- ARIA Patterns / Sam Lee / 204, Keyboard Testing / Alex Kim / 115 --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/tbody&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/table&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What the accessibility tree showed (broken)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;table ""&lt;/strong&gt; &amp;gt; &lt;strong&gt;rowgroup ""&lt;/strong&gt; &amp;gt; &lt;strong&gt;row ""&lt;/strong&gt; with &lt;strong&gt;columnheader "Course"&lt;/strong&gt;, &lt;strong&gt;columnheader "Instructor"&lt;/strong&gt; and &lt;strong&gt;columnheader "Room"&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Focused &lt;strong&gt;columnheader "Course"&lt;/strong&gt;: Role columnheader, Focusable: false - and &lt;strong&gt;no Scope property&lt;/strong&gt; in the Chrome pane, because there is no &lt;code&gt;scope&lt;/code&gt; in the HTML&lt;/li&gt;
&lt;li&gt;Data rows are &lt;strong&gt;row ""&lt;/strong&gt; nodes with &lt;strong&gt;cell&lt;/strong&gt; children (&lt;strong&gt;cell "Intro to WAS"&lt;/strong&gt;, &lt;strong&gt;cell "Jane Doe"&lt;/strong&gt;, &lt;strong&gt;cell "101"&lt;/strong&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Chrome inferred the column headers from position in &lt;code&gt;thead&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What VoiceOver announced (broken)
&lt;/h3&gt;

&lt;p&gt;Moving through the table with Control-Option and the arrow keys:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;table, 3 columns, 4 rows&lt;/p&gt;

&lt;p&gt;Course Course, column 1 of 3&lt;/p&gt;

&lt;p&gt;row 2 of 4 Course Intro to WAS, column 1 of 3&lt;/p&gt;

&lt;p&gt;Instructor Jane Doe, column 2 of 3&lt;/p&gt;

&lt;p&gt;end of table&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Inferred, not declared
&lt;/h3&gt;

&lt;p&gt;The guess landed, but it was a guess. The W3C WAI Tables Tutorial accepts top-row &lt;code&gt;&amp;lt;th&amp;gt;&lt;/code&gt; without &lt;code&gt;scope&lt;/code&gt; only for small tables with clearly distinct columns; &lt;code&gt;scope&lt;/code&gt; is needed when data is ambiguous or the table is larger. The WAS rule is simpler: put &lt;code&gt;scope&lt;/code&gt; on every &lt;code&gt;&amp;lt;th&amp;gt;&lt;/code&gt; whenever you can.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case 2 - &lt;code&gt;scope="row"&lt;/code&gt; on column headers
&lt;/h3&gt;

&lt;p&gt;This is the partial fix. Someone read "add scope" and added it - with the wrong value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;thead&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;th&lt;/span&gt; &lt;span class="na"&gt;scope=&lt;/span&gt;&lt;span class="s"&gt;"row"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"table-headers-broken-case2-course-header"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Course&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;th&lt;/span&gt; &lt;span class="na"&gt;scope=&lt;/span&gt;&lt;span class="s"&gt;"row"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Instructor&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;th&lt;/span&gt; &lt;span class="na"&gt;scope=&lt;/span&gt;&lt;span class="s"&gt;"row"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Room&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/thead&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What the accessibility tree showed (broken, Case 2)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;The header row now exposes &lt;strong&gt;rowheader "Course"&lt;/strong&gt;, &lt;strong&gt;rowheader "Instructor"&lt;/strong&gt; and &lt;strong&gt;rowheader "Room"&lt;/strong&gt; - not &lt;strong&gt;columnheader&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Focused &lt;strong&gt;rowheader "Course"&lt;/strong&gt;: Role rowheader. Chrome's pane shows no Scope property, so the &lt;code&gt;scope="row"&lt;/code&gt; itself is read from the markup.&lt;/li&gt;
&lt;li&gt;The data &lt;strong&gt;row&lt;/strong&gt; / &lt;strong&gt;cell&lt;/strong&gt; structure is unchanged&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code now claims each of those cells labels its own row, not the column below it. The page shows one relationship; the code declares another.&lt;/p&gt;

&lt;h3&gt;
  
  
  What VoiceOver announced (broken, Case 2)
&lt;/h3&gt;

&lt;p&gt;The lab's capture notes record VoiceOver for Case 2 as identical to Case 1, so it was not re-recorded. By ear, nothing changed. The failure only shows up in the tree.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this fails exam-style
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1.3.1 Info and Relationships&lt;/strong&gt; (Level A) requires that relationships conveyed visually can be programmatically determined.&lt;/li&gt;
&lt;li&gt;Case 2 declares the &lt;strong&gt;wrong&lt;/strong&gt; relationship: the visual table has column headers, the code has row headers.&lt;/li&gt;
&lt;li&gt;Case 1 relies on &lt;strong&gt;browser inference&lt;/strong&gt;. It happens to work here, but the association is inferred, not declared - which is why the WAS habit is explicit &lt;code&gt;scope&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Exam signal for a wrong scope: &lt;strong&gt;rowheader&lt;/strong&gt; in the tree, confirmed by &lt;code&gt;scope="row"&lt;/code&gt; in the markup. Exam signal for missing versus correct scope on a simple &lt;code&gt;thead&lt;/code&gt; table: the &lt;strong&gt;HTML&lt;/strong&gt;, not Chrome's pane.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;caption&amp;gt;&lt;/code&gt; and &lt;code&gt;headers&lt;/code&gt; / &lt;code&gt;id&lt;/code&gt; are separate topics (Table Captions and Complex Relationships labs).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Give every column header &lt;code&gt;scope="col"&lt;/code&gt;. One attribute, on each &lt;code&gt;&amp;lt;th&amp;gt;&lt;/code&gt; in the header row. The same fix repairs both cases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;thead&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;th&lt;/span&gt; &lt;span class="na"&gt;scope=&lt;/span&gt;&lt;span class="s"&gt;"col"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"table-headers-fixed-case1-course-header"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Course&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;th&lt;/span&gt; &lt;span class="na"&gt;scope=&lt;/span&gt;&lt;span class="s"&gt;"col"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Instructor&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;th&lt;/span&gt; &lt;span class="na"&gt;scope=&lt;/span&gt;&lt;span class="s"&gt;"col"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Room&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/thead&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In React / JSX the attribute keeps its name. When headers run down the side instead, &lt;code&gt;scope="row"&lt;/code&gt; belongs on the first cell of each data row (illustrative, not part of this lab):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;th&lt;/span&gt; &lt;span class="na"&gt;scope=&lt;/span&gt;&lt;span class="s"&gt;"row"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Intro to WAS&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;Jane Doe&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;101&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What changed in the tree (fixed)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Both fixed cases expose &lt;strong&gt;columnheader "Course"&lt;/strong&gt;, &lt;strong&gt;columnheader "Instructor"&lt;/strong&gt; and &lt;strong&gt;columnheader "Room"&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Case 2: the three &lt;strong&gt;rowheader&lt;/strong&gt; nodes from the broken version are &lt;strong&gt;columnheader&lt;/strong&gt; again; data rows unchanged&lt;/li&gt;
&lt;li&gt;Case 1: the tree is &lt;strong&gt;identical to broken Case 1&lt;/strong&gt;. Chrome shows no Scope property for &lt;code&gt;scope="col"&lt;/code&gt; (live capture 2026-06-15). For missing versus correct scope, read the markup.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What VoiceOver announced (fixed)
&lt;/h3&gt;

&lt;p&gt;Case 1:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;table, 3 columns, 4 rows&lt;/p&gt;

&lt;p&gt;Course, column 1 of 3&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Contrast broken Case 1, where the same header read Course Course, column 1 of 3.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Course Intro to WAS, column 1 of 3&lt;/p&gt;

&lt;p&gt;You are currently on a selectable text, inside of a cell.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To be honest about the evidence: Instructor Instructor, column 2 of 3 and Room Room, column 3 of 3 still double on the fixed table, so the doubling is not a reliable pass or fail signal.&lt;/p&gt;

&lt;p&gt;Case 2:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;table, 3 columns, 4 rows&lt;/p&gt;

&lt;p&gt;You are currently on a selectable text, inside of a column header.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Behaviors to verify
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Every header cell is a &lt;code&gt;&amp;lt;th&amp;gt;&lt;/code&gt;, not a styled &lt;code&gt;&amp;lt;td&amp;gt;&lt;/code&gt;, and its text describes the data it labels.&lt;/li&gt;
&lt;li&gt;Every column header has &lt;code&gt;scope="col"&lt;/code&gt;; every row header has &lt;code&gt;scope="row"&lt;/code&gt;; no &lt;code&gt;scope="row"&lt;/code&gt; sits on column labels in &lt;code&gt;thead&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;In the accessibility tree, column labels are &lt;strong&gt;columnheader&lt;/strong&gt; and row labels are &lt;strong&gt;rowheader&lt;/strong&gt;. Then move through a data row with a screen reader and confirm each cell is read with the right header. Do not trust your ears alone.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Practice it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Video: &lt;a href="https://youtu.be/cTSw8wnJwcM" rel="noopener noreferrer"&gt;https://youtu.be/cTSw8wnJwcM&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Short: &lt;a href="https://youtube.com/shorts/y65zZN_R23k" rel="noopener noreferrer"&gt;https://youtube.com/shorts/y65zZN_R23k&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Lab: &lt;a href="https://www.wasexamprep.com/labs/table/table-headers" rel="noopener noreferrer"&gt;Table Headers lab&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Companion write-up: &lt;a href="https://www.wasexamprep.com/blog/table-without-headers-is-a-grid-of-words" rel="noopener noreferrer"&gt;https://www.wasexamprep.com/blog/table-without-headers-is-a-grid-of-words&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Takeaway: a table without declared headers is just a grid of words. Put &lt;code&gt;scope&lt;/code&gt; on every &lt;code&gt;th&lt;/code&gt;, and check the tree, not just your ears.&lt;/p&gt;

&lt;p&gt;If this was useful, subscribe to &lt;strong&gt;A11Y in Practice&lt;/strong&gt; on YouTube (&lt;a href="https://www.youtube.com/@a11yinpractice" rel="noopener noreferrer"&gt;@a11yinpractice&lt;/a&gt;) for more hands-on accessibility episodes, and visit &lt;a href="https://www.wasexamprep.com" rel="noopener noreferrer"&gt;wasexamprep.com&lt;/a&gt; for WAS study notes and labs.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A11Y in Practice and wasexamprep.com are independent educational resources. They are not affiliated with or endorsed by IAAP (International Association of Accessibility Professionals).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>wcag</category>
      <category>html</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A Skip Link That Doesn't Land Is Not a Bypass: WCAG 2.4.1 in Practice</title>
      <dc:creator>A11Y Practice</dc:creator>
      <pubDate>Wed, 14 Oct 2026 11:29:55 +0000</pubDate>
      <link>https://dev.to/a11y_practice_be4b9244508/a-skip-link-that-doesnt-land-is-not-a-bypass-wcag-241-in-practice-3oa</link>
      <guid>https://dev.to/a11y_practice_be4b9244508/a-skip-link-that-doesnt-land-is-not-a-bypass-wcag-241-in-practice-3oa</guid>
      <description>&lt;p&gt;You press Tab on a page and VoiceOver says &lt;strong&gt;link, Home, Primary, navigation&lt;/strong&gt;. Not the content. On a real site that header can hold dozens of links, and keyboard and screen reader users have to tab through it on every page load. Add a skip link and the problem looks solved. But if its &lt;code&gt;href&lt;/code&gt; points at an id that doesn't exist, activation goes nowhere.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/uuevKPHlgDk" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;WCAG &lt;strong&gt;2.4.1 Bypass Blocks&lt;/strong&gt; (Level A) requires a way to skip blocks of content that repeat across pages. A skip link only counts if it actually moves the user past the repeated block.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways it breaks
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No skip link.&lt;/strong&gt; The first focusable node is &lt;code&gt;link "Home"&lt;/code&gt; inside &lt;code&gt;navigation "Primary"&lt;/code&gt;. You only reach &lt;code&gt;main&lt;/code&gt; after every nav link. There's no mechanism at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skip link with a mismatched target.&lt;/strong&gt; This one looks fixed. The link is first in Tab order and visible on focus, and VoiceOver announces it correctly: &lt;em&gt;visited, link, Skip to main content&lt;/em&gt;. But the code looks like this:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"#main"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Skip to main content&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;header&amp;gt;&lt;/span&gt;Study Lab&lt;span class="nt"&gt;&amp;lt;/header&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;nav&lt;/span&gt; &lt;span class="na"&gt;aria-label=&lt;/span&gt;&lt;span class="s"&gt;"Primary"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;…&lt;span class="nt"&gt;&amp;lt;/nav&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;main&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"skip-to-content-broken-case2-main"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;…&lt;span class="nt"&gt;&amp;lt;/main&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's no element with &lt;code&gt;id="main"&lt;/code&gt;. After activation, focus is still inside the navigation. The problem isn't what the link says. It's where it goes, and you only see that in the accessibility tree, not in the announcement.&lt;/p&gt;

&lt;p&gt;Auditors test activation, not presence: press Enter and check where focus goes. Landmarks like &lt;code&gt;nav&lt;/code&gt; and &lt;code&gt;main&lt;/code&gt; help screen reader users, but sighted keyboard users have no landmark shortcut, so a broken skip link is still a defect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Make the skip link the first focusable element, make its &lt;code&gt;href&lt;/code&gt; fragment match a real id, and give the target &lt;code&gt;tabindex="-1"&lt;/code&gt; so it can take programmatic focus:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"skip-link"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"#main-content"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Skip to main content&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;header&amp;gt;&lt;/span&gt;…&lt;span class="nt"&gt;&amp;lt;/header&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;nav&lt;/span&gt; &lt;span class="na"&gt;aria-label=&lt;/span&gt;&lt;span class="s"&gt;"Primary"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;…&lt;span class="nt"&gt;&amp;lt;/nav&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;main&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"main-content"&lt;/span&gt; &lt;span class="na"&gt;tabindex=&lt;/span&gt;&lt;span class="s"&gt;"-1"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;…&lt;span class="nt"&gt;&amp;lt;/main&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In React/JSX that's &lt;code&gt;tabIndex={-1}&lt;/code&gt;. &lt;code&gt;-1&lt;/code&gt; lets &lt;code&gt;main&lt;/code&gt; take focus from script or fragment navigation without adding it to the Tab order.&lt;/p&gt;

&lt;p&gt;Hide it until it's focused:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.skip-link&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;absolute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;-9999px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.skip-link&lt;/span&gt;&lt;span class="nd"&gt;:focus&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;z-index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Behaviors to verify
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The first Tab on the page reaches the skip link, and it's visible when focused.&lt;/li&gt;
&lt;li&gt;Pressing Enter moves focus into the main content, so the next thing read is content, not navigation.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;href&lt;/code&gt; fragment matches an id that exists exactly once, and the target has &lt;code&gt;tabindex="-1"&lt;/code&gt;. Check it in the accessibility tree and with a screen reader before shipping.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Practice it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Video: &lt;a href="https://youtu.be/uuevKPHlgDk" rel="noopener noreferrer"&gt;https://youtu.be/uuevKPHlgDk&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Short: &lt;a href="https://youtube.com/shorts/gnGrnoXHhCI" rel="noopener noreferrer"&gt;https://youtube.com/shorts/gnGrnoXHhCI&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Companion write-up: &lt;a href="https://www.wasexamprep.com/blog/skip-link-is-not-a-bypass" rel="noopener noreferrer"&gt;https://www.wasexamprep.com/blog/skip-link-is-not-a-bypass&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Takeaway: a skip link that doesn't land is not a bypass. Test activation, not presence.&lt;/p&gt;

&lt;p&gt;If this was useful, subscribe to &lt;strong&gt;A11Y in Practice&lt;/strong&gt; on YouTube (&lt;a href="https://www.youtube.com/@a11yinpractice" rel="noopener noreferrer"&gt;@a11yinpractice&lt;/a&gt;) for more hands-on accessibility episodes, and visit &lt;a href="https://www.wasexamprep.com" rel="noopener noreferrer"&gt;wasexamprep.com&lt;/a&gt; for WAS study notes and labs.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A11Y in Practice and wasexamprep.com are independent educational resources. They are not affiliated with or endorsed by IAAP (International Association of Accessibility Professionals).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>wcag</category>
      <category>webdev</category>
      <category>html</category>
    </item>
    <item>
      <title>A Custom Div Is Not a Checkbox: WCAG 4.1.2 Name, Role, Value</title>
      <dc:creator>A11Y Practice</dc:creator>
      <pubDate>Wed, 07 Oct 2026 08:05:22 +0000</pubDate>
      <link>https://dev.to/a11y_practice_be4b9244508/a-custom-div-is-not-a-checkbox-wcag-412-name-role-value-m2c</link>
      <guid>https://dev.to/a11y_practice_be4b9244508/a-custom-div-is-not-a-checkbox-wcag-412-name-role-value-m2c</guid>
      <description>&lt;p&gt;You build a toggle that looks like a checkbox. You style two shipping options as buttons. Sighted users get it. Then you open VoiceOver.&lt;/p&gt;

&lt;p&gt;On the custom toggle, from a real capture: &lt;strong&gt;Email notifications (custom)&lt;/strong&gt; - and the hint: you are currently on a &lt;strong&gt;button, group&lt;/strong&gt;. No checkbox role. No checked state. Beside it, the native control announces as a &lt;strong&gt;checkbox&lt;/strong&gt;. Same job. Completely different exposure.&lt;/p&gt;

&lt;p&gt;WCAG &lt;strong&gt;4.1.2 Name, Role, Value&lt;/strong&gt; says the role and value must be programmatically determinable. Native checkbox, radio, and fieldset give you that for free. A click-only &lt;code&gt;div&lt;/code&gt; or plain &lt;code&gt;button&lt;/code&gt;s that fake selection fail 4.1.2 - missing roles, missing value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this trips people up
&lt;/h2&gt;

&lt;p&gt;Two failure patterns show up a lot:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Custom "checkbox" with no widget role&lt;/strong&gt; - a clickable &lt;code&gt;div&lt;/code&gt; that looks finished. In the accessibility tree you may only get StaticText. Tab can skip it entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buttons faking a radio group&lt;/strong&gt; - sibling buttons with no &lt;code&gt;radiogroup&lt;/code&gt; parent and no "1 of 2" / "2 of 2". They look like a choice group. VoiceOver hears two independent buttons.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What to do instead
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Prefer &lt;strong&gt;native&lt;/strong&gt; checkbox / radio / fieldset when you can&lt;/li&gt;
&lt;li&gt;When you must build custom: &lt;code&gt;role="checkbox"&lt;/code&gt; + focusability + &lt;code&gt;aria-checked&lt;/code&gt;, or &lt;code&gt;role="radiogroup"&lt;/code&gt; with &lt;code&gt;role="radio"&lt;/code&gt; on each option&lt;/li&gt;
&lt;li&gt;Extra ARIA is not the goal - the correct widget roles are. Then verify with a screen reader&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Practice it
&lt;/h2&gt;

&lt;p&gt;I published a walkthrough on A11Y in Practice with broken vs fixed paths on the same free lab:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Video: &lt;a href="https://www.youtube.com/watch?v=PJh5PMdxHwA" rel="noopener noreferrer"&gt;https://www.youtube.com/watch?v=PJh5PMdxHwA&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Lab: &lt;a href="https://wasexamprep.com/labs/custom-controls/control-roles" rel="noopener noreferrer"&gt;https://wasexamprep.com/labs/custom-controls/control-roles&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Write-up: &lt;a href="https://www.wasexamprep.com/blog/custom-div-is-not-a-checkbox" rel="noopener noreferrer"&gt;https://www.wasexamprep.com/blog/custom-div-is-not-a-checkbox&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Short: &lt;a href="https://www.youtube.com/shorts/zz-ob1zySyY" rel="noopener noreferrer"&gt;https://www.youtube.com/shorts/zz-ob1zySyY&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Part of WAS Exam Prep / WAS Lab study notes (independent; not affiliated with IAAP or W3C).&lt;/p&gt;

&lt;p&gt;Takeaway: a custom &lt;code&gt;div&lt;/code&gt; that looks like a checkbox is not a checkbox until assistive tech gets the role and value - prefer native HTML when you can.&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>wcag</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Static Text Is Not a Status Message: WCAG 4.1.3 Live Regions</title>
      <dc:creator>A11Y Practice</dc:creator>
      <pubDate>Wed, 30 Sep 2026 17:03:24 +0000</pubDate>
      <link>https://dev.to/a11y_practice_be4b9244508/static-text-is-not-a-status-message-wcag-413-live-regions-clk</link>
      <guid>https://dev.to/a11y_practice_be4b9244508/static-text-is-not-a-status-message-wcag-413-live-regions-clk</guid>
      <description>&lt;p&gt;You click &lt;strong&gt;Add item&lt;/strong&gt;. The cart count ticks from zero to one. You click &lt;strong&gt;Save&lt;/strong&gt;. The status flips to Saved. Sighted users see it instantly. On that button with VoiceOver, what do you hear?&lt;/p&gt;

&lt;p&gt;Often: still the button hint - and nothing about the cart or Saved. Static text on the page is not a status message.&lt;/p&gt;

&lt;p&gt;WCAG &lt;strong&gt;4.1.3 Status Messages&lt;/strong&gt; is about announcing that kind of update &lt;strong&gt;without moving focus&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this trips people up
&lt;/h2&gt;

&lt;p&gt;Two failure patterns show up a lot:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No live region at all&lt;/strong&gt; - the DOM updates, the screen updates, assistive tech stays quiet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The trap&lt;/strong&gt; - &lt;code&gt;aria-live&lt;/code&gt; (or &lt;code&gt;role="status"&lt;/code&gt;) is on a wrapper, but the &lt;strong&gt;changing text sits outside&lt;/strong&gt; that node. The live region never sees a relevant update.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What to put on the changing node
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;aria-live="polite"&lt;/code&gt; and/or &lt;code&gt;role="status"&lt;/code&gt; on the element whose &lt;strong&gt;text content&lt;/strong&gt; actually changes&lt;/li&gt;
&lt;li&gt;Prefer polite for non-urgent cart/status UI; keep assertive for rare urgent cases&lt;/li&gt;
&lt;li&gt;Do not assume "I added aria-live somewhere nearby" is enough - verify with a screen reader&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Practice it
&lt;/h2&gt;

&lt;p&gt;I published a walkthrough on A11Y in Practice with broken vs fixed paths on the same free lab:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Video: &lt;a href="https://www.youtube.com/watch?v=td4DaFK8AYw" rel="noopener noreferrer"&gt;https://www.youtube.com/watch?v=td4DaFK8AYw&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Lab: &lt;a href="https://wasexamprep.com/labs/live-regions/polite-live-regions" rel="noopener noreferrer"&gt;https://wasexamprep.com/labs/live-regions/polite-live-regions&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Short: &lt;a href="https://www.youtube.com/shorts/4sUPQ2snbHU" rel="noopener noreferrer"&gt;https://www.youtube.com/shorts/4sUPQ2snbHU&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Part of WAS Exam Prep / WAS Lab study notes (independent; not affiliated with IAAP or W3C).&lt;/p&gt;

&lt;p&gt;Takeaway: put the live region on the &lt;strong&gt;changing text node&lt;/strong&gt;, not on a silent wrapper.&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>wcag</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Focusable Is Not Keyboard-Operable: WCAG 2.1.1 on Custom Controls</title>
      <dc:creator>A11Y Practice</dc:creator>
      <pubDate>Thu, 24 Sep 2026 05:20:33 +0000</pubDate>
      <link>https://dev.to/a11y_practice_be4b9244508/focusable-is-not-keyboard-operable-wcag-211-on-custom-controls-1hm4</link>
      <guid>https://dev.to/a11y_practice_be4b9244508/focusable-is-not-keyboard-operable-wcag-211-on-custom-controls-1hm4</guid>
      <description>&lt;p&gt;You Tab to a custom checkbox. The focus ring shows up. You press Space - nothing. Click with the mouse and it toggles.&lt;/p&gt;

&lt;p&gt;So is it accessible?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WCAG 2.1.1 Keyboard&lt;/strong&gt; says the &lt;em&gt;functionality&lt;/em&gt; must be available from a keyboard. Landing focus on the control is not enough.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why tools miss it
&lt;/h3&gt;

&lt;p&gt;In the broken lab case, the accessibility tree still exposes a checkbox with the right name and checked state. VoiceOver can announce it like a normal checkbox. The gap is the missing key handler (&lt;code&gt;onClick&lt;/code&gt; only).&lt;/p&gt;

&lt;p&gt;Same story for a custom radiogroup where every option has &lt;code&gt;tabIndex={0}&lt;/code&gt; and arrow keys do nothing: the tree looks mostly fine; keyboard testing does not.&lt;/p&gt;

&lt;h3&gt;
  
  
  What to verify manually
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Checkbox / switch: Space toggles&lt;/li&gt;
&lt;li&gt;Radiogroup: arrows move selection; preferably one tab stop (roving tabindex)&lt;/li&gt;
&lt;li&gt;Do not trust "focusable + role" alone&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Practice it
&lt;/h3&gt;

&lt;p&gt;I published a walkthrough on &lt;strong&gt;A11Y in Practice&lt;/strong&gt; with broken vs fixed paths on the same free lab:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Video: &lt;a href="https://youtu.be/7B1GsZny4pc" rel="noopener noreferrer"&gt;https://youtu.be/7B1GsZny4pc&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Lab: &lt;a href="https://wasexamprep.com/labs/custom-controls/keyboard-operation" rel="noopener noreferrer"&gt;https://wasexamprep.com/labs/custom-controls/keyboard-operation&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Short: &lt;a href="https://youtube.com/shorts/Fii5VUN9GhI" rel="noopener noreferrer"&gt;https://youtube.com/shorts/Fii5VUN9GhI&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Blog: &lt;a href="https://wasexamprep.com/blog/focusable-is-not-keyboard-operable" rel="noopener noreferrer"&gt;https://wasexamprep.com/blog/focusable-is-not-keyboard-operable&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Part of WAS Exam Prep / WAS Lab study notes (independent; not affiliated with IAAP or W3C).&lt;/p&gt;

&lt;p&gt;If this helped, the takeaway I keep repeating: &lt;strong&gt;roles name the widget. Keys make it usable.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>wcag</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
