{"id":"l-a11y-3-2","courseId":"web-accessibility","lang":"en","kind":"lesson","summary":"Move keyboard focus deliberately when content changes without a page load, using the native dialog element, the inert attribute and focus management on route changes.","html":"<p>On a traditional website, the browser looks after focus for you. Every click on a link loads a new page, focus resets to the top, and the screen reader announces the new title. Ticketly is a single-page app with modals, drawers and client-side routing, so none of that happens. When your code changes the screen, your code decides where focus goes. If it doesn’t decide, focus stays wherever it was, often on something now hidden, covered or deleted.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">The modal that traps nobody</h2>\n<p>Ticketly’s “Book now” button opens a booking modal: a <code dir=\"ltr\">div</code> with a dark overlay. Watch what happens with a keyboard. The modal appears, but focus is still on “Book now” behind it. Tab moves to the next festival link on the page underneath. Samir’s screen reader says nothing about a modal; to him, nothing happened.</p>\n<p>A correct modal dialog needs five behaviours:</p>\n<ol>\n<li>Focus <strong>moves into</strong> the dialog when it opens.</li>\n<li>Tab and Shift+Tab <strong>stay inside</strong> it while it is open.</li>\n<li>The page behind is <strong>hidden from assistive technology</strong>.</li>\n<li><strong>Escape</strong> closes it.</li>\n<li>Focus <strong>returns</strong> to the control that opened it.</li>\n</ol>\n<p>For years this took a focus-trap library and fifty lines of code. Today the native <code dir=\"ltr\">&lt;dialog&gt;</code> element opened with <code dir=\"ltr\">showModal()</code> does all five.</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"html\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">ticketly/booking.html</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">button</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> type</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"button\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> id</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"book\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>Book now&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">button</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">dialog</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> id</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"booking\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> aria-labelledby</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"booking-title\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">h2</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> id</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"booking-title\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>Book Friday pass&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">h2</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">label</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> for</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"qty\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>Quantity&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">label</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">input</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> id</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"qty\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> type</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"number\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> min</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"1\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> max</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"6\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> value</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"1\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">button</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> type</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"button\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> id</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"confirm\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>Confirm&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">button</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">button</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> type</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"button\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> id</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"close\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>Close&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">button</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">dialog</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span></code></pre></div>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"1\"><div class=\"code-head\"><span class=\"code-title\">ticketly/booking.js</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> dialog</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> document.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">querySelector</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'#booking'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">document.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">querySelector</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'#book'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">addEventListener</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'click'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, () </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> dialog.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">showModal</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">());</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">document.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">querySelector</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'#close'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">addEventListener</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'click'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, () </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> dialog.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">close</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">());</span></span></code></pre></div>\n<p><code dir=\"ltr\">showModal()</code> places the dialog in the <strong>top layer</strong>, above everything, and makes the rest of the document <strong>inert</strong>: unfocusable, unclickable and absent from the accessibility tree. Focus goes to the element with <code dir=\"ltr\">autofocus</code> if there is one, otherwise to the first focusable element inside (here the quantity field), otherwise to the dialog itself. Escape closes it. When it closes, the browser returns focus to the element that had it before, the “Book now” button. The <code dir=\"ltr\">::backdrop</code> pseudo-element styles the overlay.</p>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 680 190\" role=\"img\" aria-labelledby=\"l-a11y-3-2-t1\"><defs><marker id=\"arrow-l-a11y-3-2-1\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0 0 L10 5 L0 10 z\" class=\"d-arrowhead\"/></marker></defs>\n  <title id=\"l-a11y-3-2-t1\">Focus starts on the Book now button. showModal moves it to the first field in the dialog, where Tab cycles within the dialog while the page is inert. Close or Escape returns focus to the Book now button.</title>\n  <rect class=\"d-box\" x=\"20\" y=\"60\" width=\"150\" height=\"60\" rx=\"10\"/>\n  <text class=\"d-label\" x=\"95\" y=\"95\" text-anchor=\"middle\">\"Book now\"</text>\n  <rect class=\"d-box-primary\" x=\"270\" y=\"40\" width=\"200\" height=\"100\" rx=\"10\"/>\n  <text class=\"d-label-strong\" x=\"370\" y=\"70\" text-anchor=\"middle\">dialog (top layer)</text>\n  <text class=\"d-label\" x=\"370\" y=\"96\" text-anchor=\"middle\">Quantity → Confirm</text>\n  <text class=\"d-label\" x=\"370\" y=\"118\" text-anchor=\"middle\">→ Close → Quantity</text>\n  <path class=\"d-arrow\" d=\"M170 75 L265 65\" marker-end=\"url(#arrow-l-a11y-3-2-1)\"/>\n  <text class=\"d-code\" x=\"215\" y=\"55\" text-anchor=\"middle\">showModal()</text>\n  <path class=\"d-arrow\" d=\"M265 120 L170 108\" marker-end=\"url(#arrow-l-a11y-3-2-1)\"/>\n  <text class=\"d-code\" x=\"215\" y=\"140\" text-anchor=\"middle\">close / Esc</text>\n  <rect class=\"d-box-warn\" x=\"520\" y=\"60\" width=\"140\" height=\"60\" rx=\"10\"/>\n  <text class=\"d-label\" x=\"590\" y=\"88\" text-anchor=\"middle\">Page behind</text>\n  <text class=\"d-label-muted\" x=\"590\" y=\"108\" text-anchor=\"middle\">inert</text>\n  <text class=\"d-label-muted\" x=\"340\" y=\"180\" text-anchor=\"middle\">Tab never leaves the dialog while it is open</text>\n</svg><figcaption>Focus during a dialog’s life</figcaption></figure>\n<aside class=\"callout callout-mistake\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\"/><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/></svg><span><span class=\"callout-kind\">Common mistake</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Calling show() instead of showModal()</span></p>\n<p><code dir=\"ltr\">dialog.show()</code> opens a <strong>non-modal</strong> dialog: no top layer, no inert background, no Escape handling. It is right for things like a toolbar popup that users work alongside the page. For anything with an overlay that blocks the page, use <code dir=\"ltr\">showModal()</code>. Also avoid toggling the <code dir=\"ltr\">open</code> attribute directly; it behaves like <code dir=\"ltr\">show()</code>.</p>\n</aside>\n<p>Give every dialog a name with <code dir=\"ltr\">aria-labelledby</code> pointing at its heading. When it opens, NVDA reads “Book Friday pass, dialog, Quantity, spin button, 1”, which tells Samir exactly where he is. If the dialog’s first field isn’t the best starting point, say a long terms dialog, put <code dir=\"ltr\">autofocus</code> on the element that should receive focus, such as its Close button, or its heading made focusable with <code dir=\"ltr\">tabindex=&quot;-1&quot;</code> so reading starts at the top.</p>\n<h2 id=\"sec-2\" tabindex=\"-1\">inert for everything else</h2>\n<p>Not every hidden panel is a dialog. Ticketly’s mobile menu slides off-screen when closed, but its links are still in the DOM and still focusable, so keyboard users tab through eight invisible links. The <code dir=\"ltr\">inert</code> attribute switches a whole subtree off: no focus, no clicks, no accessibility tree.</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"html\" data-index=\"2\"><div class=\"code-head\"><span class=\"code-title\">HTML</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">nav</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> id</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"mobile-menu\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> class</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"drawer\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> inert</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>…&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">nav</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span></code></pre></div>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"3\"><div class=\"code-head\"><span class=\"code-title\">JavaScript</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">function</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> setMenuOpen</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">open</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) {</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  menu.inert </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> !</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">open;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  menu.classList.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toggle</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'drawer--open'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, open);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  toggle.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">setAttribute</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'aria-expanded'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">String</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(open));</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span></code></pre></div>\n<p><code dir=\"ltr\">inert</code> has been supported in all major browsers since 2023. Use it for closed drawers, off-screen carousel slides and content behind a custom overlay you can’t convert to <code dir=\"ltr\">&lt;dialog&gt;</code>.</p>\n<h2 id=\"sec-3\" tabindex=\"-1\">Focus after route changes and deletions</h2>\n<p>When Ticketly’s router swaps the home page for the checkout page, the link the user activated disappears, and focus falls back to <code dir=\"ltr\">&lt;body&gt;</code>. The next Tab starts from the top of the document, and the screen reader announces nothing. Do what a page load would have done:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"4\"><div class=\"code-head\"><span class=\"code-title\">ticketly/router.js</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">function</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> afterNavigate</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">() {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> heading</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> document.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">querySelector</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'main h1'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  document.title </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> `${</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">heading</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">.</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">textContent</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">} · Ticketly`</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  heading.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">setAttribute</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'tabindex'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'-1'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  heading.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">focus</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span></code></pre></div>\n<p>Focusing the <code dir=\"ltr\">h1</code> announces the new page’s name and puts the user at the start of its content. <code dir=\"ltr\">tabindex=&quot;-1&quot;</code> makes the heading focusable from script without adding a Tab stop. Some frameworks do part of this for you; check what yours does before adding your own.</p>\n<p>The same principle applies to deletions. When a user removes a ticket from the basket, the “Remove” button they pressed is gone. Move focus somewhere sensible, such as the next item’s Remove button or the basket heading, instead of letting it drop to the top of the page.</p>\n<aside class=\"callout callout-tip\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5\"/><path d=\"M9 18h6\"/><path d=\"M10 22h4\"/></svg><span><span class=\"callout-kind\">Tip</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Test with your eyes on document.activeElement</span></p>\n<p>In DevTools, create a Live Expression for <code dir=\"ltr\">document.activeElement</code>. As you open and close dialogs or navigate, you can watch exactly where focus is, which is much faster than guessing from the screen.</p>\n</aside>\n<p>Native elements and a few lines of focus management cover most of Ticketly. When there is no native element, you need ARIA, which is where the next lesson goes.</p>\n","toc":[{"id":"sec-1","text":"The modal that traps nobody","level":2},{"id":"sec-2","text":"inert for everything else","level":2},{"id":"sec-3","text":"Focus after route changes and deletions","level":2}],"takeaways":["When your script changes what is on screen, it is responsible for where focus goes; the browser only manages focus on full page loads.","A dialog opened with showModal() moves focus inside, makes the rest of the page inert, closes on Escape, and returns focus to the opener when it closes.","The inert attribute removes a whole subtree from focus, clicks and the accessibility tree, which is ideal for closed drawers and off-screen panels.","After a client-side route change, update document.title and move focus to the new page’s h1 (with tabindex=“-1”)."],"further":[{"title":"MDN: The Dialog element","url":"https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog"},{"title":"MDN: HTMLElement inert property","url":"https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/inert"},{"title":"W3C APG: Dialog (Modal) Pattern","url":"https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/"}],"quiz":[{"q":"<p>A keyboard user presses Enter on “Book now”, which runs this code. Where is focus afterwards?</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">JavaScript</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">bookButton.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">addEventListener</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'click'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, () </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  document.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">querySelector</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'.modal'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">).hidden </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> false</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">; </span><span style=\"color:#6E7781;--shiki-dark:#8B949E\">// a div styled as a modal</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">});</span></span></code></pre></div>\n","options":[{"text":"Still on the “Book now” button, behind the modal","why":"Correct. Showing a div moves nothing. The user’s next Tab continues through the page behind the overlay, and a screen reader may not mention the modal at all."},{"text":"On the first input inside the modal, because modals receive focus automatically","why":"Only the native dialog element opened with showModal() moves focus. A div with hidden removed is just more content."},{"text":"On the body element, because the click moved focus away","why":"Activating a button doesn’t move focus away from it. Focus only changes if your code changes it."}],"answer":0},{"q":"<p>Which of these does <code dir=\"ltr\">dialog.showModal()</code> NOT do for you?</p>","options":[{"text":"Make content outside the dialog inert","why":"showModal() puts the dialog in the top layer and makes the rest of the document inert, so Tab can’t escape to the page behind."},{"text":"Close the dialog when the user presses Escape","why":"Modal dialogs close on Escape by default, firing a cancel event you can listen to."},{"text":"Give the dialog an accessible name","why":"Correct. You still need to name it, typically with aria-labelledby pointing at its heading, so screen readers announce “Book Friday pass, dialog”."},{"text":"Move focus into the dialog","why":"showModal() focuses an element with autofocus, otherwise the first focusable element inside, otherwise the dialog itself."}],"answer":2},{"q":"<p>Ticketly’s mobile menu slides off-screen when closed, but Tab still visits its eight links while they are invisible. What is the simplest robust fix?</p>","options":[{"text":"Add <code dir=\"ltr\">aria-hidden=&quot;true&quot;</code> to the menu when it is closed","why":"aria-hidden hides content from assistive technology but leaves it focusable, so keyboard users still tab into invisible links, now with no announcement."},{"text":"Add <code dir=\"ltr\">tabindex=&quot;-1&quot;</code> to each of the eight links","why":"It works until someone adds a ninth link, and you must remember to undo it on open. inert does the whole subtree in one attribute."},{"text":"Set the <code dir=\"ltr\">inert</code> attribute on the menu while it is closed","why":"Correct. inert removes the whole subtree from the tab order, pointer events and the accessibility tree, and removing the attribute restores everything."}],"answer":2},{"q":"<p>After a client-side navigation from the home page to “/checkout”, what should Ticketly’s router do for screen reader users?</p>","options":[{"text":"Nothing; screen readers detect URL changes automatically","why":"Screen readers react to full page loads. A pushState navigation changes the DOM silently, and focus stays on the link that was clicked, which may no longer exist."},{"text":"Reload the page so the browser announces it","why":"That throws away the benefits of client-side routing. Managing focus and the title gives the same result without a reload."},{"text":"Move focus to the first link in the site header","why":"That makes the user re-tab through the whole header on every navigation, and doesn’t announce what page they are on."},{"text":"Update document.title and move focus to the checkout page’s h1, which has tabindex=“-1”","why":"Correct. The user hears the new heading, Tab continues from the top of the new content, and the window title matches the page."}],"answer":3}],"exercise":{"kind":"web","mode":"dom","starter":{"html":"<main>\n  <h1>Harbour Lights: Friday</h1>\n  <p>Standing tickets, SAR 180 each.</p>\n  <button type=\"button\" id=\"book\">Book now</button>\n  <a href=\"#faq\">Festival FAQ</a>\n</main>\n\n<div class=\"modal\" id=\"booking\" hidden>\n  <h2 id=\"booking-title\">Book Friday pass</h2>\n  <label for=\"qty\">Quantity</label>\n  <input id=\"qty\" type=\"number\" min=\"1\" max=\"6\" value=\"1\">\n  <button type=\"button\" id=\"confirm\">Confirm</button>\n  <button type=\"button\" id=\"close\">Close</button>\n</div>","css":".modal { border: 0; border-radius: 12px; padding: 16px 20px; box-shadow: 0 10px 40px rgb(0 0 0 / 0.3); }\ndiv.modal:not([hidden]) { position: fixed; inset: 10% 10% auto; background: #fff; }\n.modal::backdrop { background: rgb(20 16 40 / 0.6); }\n.modal h2 { margin-block-start: 0; }\n.modal input { display: block; margin-block: 4px 12px; font: inherit; }\n","js":"const modal = document.querySelector('#booking');\n\ndocument.querySelector('#book').addEventListener('click', () => {\n  modal.hidden = false;\n});\n\ndocument.querySelector('#close').addEventListener('click', () => {\n  modal.hidden = true;\n});\n"},"solution":{"html":"<main>\n  <h1>Harbour Lights: Friday</h1>\n  <p>Standing tickets, SAR 180 each.</p>\n  <button type=\"button\" id=\"book\">Book now</button>\n  <a href=\"#faq\">Festival FAQ</a>\n</main>\n\n<dialog class=\"modal\" id=\"booking\" aria-labelledby=\"booking-title\">\n  <h2 id=\"booking-title\">Book Friday pass</h2>\n  <label for=\"qty\">Quantity</label>\n  <input id=\"qty\" type=\"number\" min=\"1\" max=\"6\" value=\"1\">\n  <button type=\"button\" id=\"confirm\">Confirm</button>\n  <button type=\"button\" id=\"close\">Close</button>\n</dialog>","css":".modal { border: 0; border-radius: 12px; padding: 16px 20px; box-shadow: 0 10px 40px rgb(0 0 0 / 0.3); }\ndiv.modal:not([hidden]) { position: fixed; inset: 10% 10% auto; background: #fff; }\n.modal::backdrop { background: rgb(20 16 40 / 0.6); }\n.modal h2 { margin-block-start: 0; }\n.modal input { display: block; margin-block: 4px 12px; font: inherit; }\n","js":"const modal = document.querySelector('#booking');\n\ndocument.querySelector('#book').addEventListener('click', () => {\n  modal.showModal();\n});\n\ndocument.querySelector('#close').addEventListener('click', () => {\n  modal.close();\n});\n"},"checks":[{"id":"named-dialog","test":"const d = $('#booking'); if (!d || d.tagName !== 'DIALOG') return false; const ids = (d.getAttribute('aria-labelledby') || '').split(/\\s+/).filter(Boolean); const viaLabelledby = ids.some((id) => document.getElementById(id)?.textContent.includes('Book Friday pass')); return viaLabelledby || /Book Friday pass/.test(d.getAttribute('aria-label') || '');"},{"id":"opens-modal","test":"const b = $('#book'); b.focus(); await click(b); const d = $('#booking'); return !!d.open && d.matches(':modal');"},{"id":"focus-inside","test":"return $('#booking').contains(document.activeElement) && document.activeElement !== document.body;"},{"id":"page-inert","test":"const inside = document.activeElement; $('#book').focus(); const escaped = document.activeElement === $('#book'); inside.focus(); return !escaped;"},{"id":"focus-returns","test":"const d = $('#booking'); if (d.tagName !== 'DIALOG' || !d.open || !d.contains(document.activeElement)) return false; await click('#close'); await tick(30); return !d.open && document.activeElement === $('#book');"}],"title":"Turn the booking modal into a real dialog","prompt":"<p>Ticketly’s booking modal is a <code dir=\"ltr\">div</code> that appears when you press <strong>Book now</strong>. Focus stays behind it, the page underneath stays reachable, and screen readers aren’t told a dialog opened.</p>\n<p>Convert <code dir=\"ltr\">#booking</code> into a native <code dir=\"ltr\">&lt;dialog&gt;</code> (keep the id) with an accessible name taken from its heading “Book Friday pass”. Open it as a <strong>modal</strong> from the Book now button and close it from the Close button. When it closes, focus should be back on Book now.</p>\n","hints":["Change the div to <dialog> and remove the hidden attribute; a closed dialog is hidden already.","Name it with aria-labelledby=“booking-title”.","Use modal.showModal() to open and modal.close() to close. The browser moves focus in, makes the page inert, and restores focus on close."],"explanation":"<p><code dir=\"ltr\">showModal()</code> does the work that used to need a focus-trap library: it moves focus to the first focusable element (the quantity field), makes everything outside the dialog inert so neither Tab nor a screen reader’s virtual cursor can wander behind it, closes on Escape, and returns focus to Book now when the dialog closes. <code dir=\"ltr\">aria-labelledby</code> makes NVDA announce “Book Friday pass, dialog” on open. Notice that the JavaScript barely changed; the improvement comes from choosing the right element and method.</p>\n","checkLabels":{"named-dialog":"#booking is a <dialog> named “Book Friday pass”","opens-modal":"Book now opens it as a modal dialog","focus-inside":"Focus moves inside the dialog when it opens","page-inert":"The page behind can’t receive focus while the dialog is open","focus-returns":"Close closes the dialog and focus returns to Book now"}},"words":729,"readMinutes":3}