PageSourceSearch

https://hoist-learn.netlify.app/chunk-L1iByYXY.js

js hoist-learn.netlify.app collected 2026-10-03 10:55:50 UTC 15,219 bytes, 43 lines download raw bytes

1import{t as e}from"./chunk-DvNGtsAj.js";var o={id:`a11y-interaction`,moduleId:`accessibility`,trackId:`nfr`,title:`Focus, keyboard & live regions`,blurb:`Focus management, the standard keyboard patterns, announcements, and reduced motion.`,minutes:12,level:`working`,tldr:{model:`Focus is the keyboard user's cursor. Every rule follows: one tab stop per composite widget with arrows inside, focus moved deliberately on open and restored on close, and announcements via live regions.`,gotcha:"A live region must already exist, empty, in the DOM before you fill it — inserting an element that already carries `aria-live` with text inside frequently announces nothing at all."},blocks:[{kind:`prose`,md:`Semantics decide what your app **says**. This lesson is about what it **does** once someone starts operating it \u2014 and it is where most real applications fail, because the failures only appear when you put the mouse down.
2
3There is one concept underneath everything here. For a keyboard or screen-reader user, **focus is the cursor**: it is where you are, what happens next, and the only thing telling you the app changed. Every rule below is a consequence of taking that seriously.`},{kind:`heading`,text:`tabindex, and the only two values you should use`},{kind:`prose`,md:'- **`tabindex="0"`** puts an element in the tab order at its position in the DOM. Use it to make a custom widget reachable.\n- **`tabindex="-1"`** makes an element **programmatically focusable but not tab-reachable**. This is the workhorse: it is how you focus a heading, a dialog, or an error summary from JavaScript.\n- **`tabindex="1"`** and above create a **separate, prioritised tab order** that runs before every natural element on the page. It is essentially always a bug, and it compounds — one positive value anywhere reorders the entire document.\n\nTwo things that surprise people. Tab order follows the **DOM**, not the visual layout, so CSS `order`, `row-reverse` or grid placement can produce a focus sequence that jumps around the screen. And `disabled` on a native control removes it from the tab order entirely — which is why a disabled submit button can leave a user unable to find out *why* it is disabled. `aria-disabled="true"` keeps it focusable and announced as unavailable, which is often the kinder choice.'},{kind:`heading`,text:`Roving tabindex`},{kind:`prose`,md:'A composite widget — a toolbar, a tab list, a menu, a listbox, a grid — should be **one tab stop**, not twenty. Tab moves you *to* the widget and *past* it; the arrow keys move you *within* it. That is the platform convention on every OS, and users expect it.\n\nThe implementation is called **roving tabindex**: exactly one child has `tabindex="0"` at a time, every other child has `tabindex="-1"`, and the arrow-key handler moves both the `0` and the actual focus. Because the `0` roves, tabbing back into the widget later returns you where you left off.'},{kind:`code`,lang:`ts`,caption:`Roving tabindex, the whole idea`,code:`// One tab stop for the group; arrows move within it.
4tabs.forEach((t, i) => t.tabIndex = i === active ? 0 : -1);
5
6function onKeydown(e: KeyboardEvent) {
7  const next =
8    e.key === 'ArrowRight' ? (active + 1) % tabs.length :
9    e.key === 'ArrowLeft'  ? (active - 1 + tabs.length) % tabs.length :
10    e.key === 'Home' ? 0 :
11    e.key === 'End'  ? tabs.length - 1 : null;
12  if (next === null) return;
13  e.preventDefault();            // stop the arrow from scrolling the page
14  active = next;
15  tabs.forEach((t, i) => t.tabIndex = i === active ? 0 : -1);
16  tabs[active].focus();          // moving the tabindex is not moving focus
17}`},{kind:`callout`,tone:`gotcha`,title:`The alternative to arrow keys is not "no arrow keys"`,md:'The single most common failure is a custom widget where every item is `tabindex="0"`, so a user must press Tab forty times to get past a list they do not care about. The second most common is implementing arrows but forgetting `preventDefault()`, so each press also scrolls the page. The third is moving `tabindex` without calling `.focus()` — the widget then looks correct in the inspector and does nothing.'},{kind:`heading`,text:`The keyboard patterns worth memorising`},{kind:`prose`,md:'These come from the **WAI-ARIA Authoring Practices Guide**, which is the primary source to name in an interview. Three patterns cover most of what you will build.\n\n**Disclosure** (show/hide). A `<button>` with `aria-expanded="true"`/`"false"`, toggled by Enter or Space — which a real button gives you free. `aria-controls` pointing at the panel id is optional and inconsistently supported. Do not add arrow keys. Do not use a `div`. This is genuinely the whole pattern, and it covers accordions, dropdowns and "show more".\n\n**Modal dialog.** Focus moves **into** the dialog when it opens — to the dialog itself, its heading, or the first control, but never to a "Cancel" button. Focus is **trapped** while open: Tab from the last element wraps to the first, Shift+Tab from the first wraps to the last. **Escape closes it.** Everything behind it is inert — `<dialog>.showModal()` and the `inert` attribute do this natively, and doing it by hand means `aria-hidden` on the background *and* preventing it from being focused. On close, **focus returns to the element that opened it.**\n\n**Listbox / combobox.** Up and Down move the active option, Home and End jump to the ends, Enter selects, Escape closes. `aria-activedescendant` is the alternative to roving tabindex here: DOM focus stays on the input while `aria-activedescendant` names the id of the visually-active option, which is how a combobox keeps typing working while a list is navigated.'},{kind:`compare`,aLabel:`Key`,bLabel:`What it must do`,rows:[{label:`Tab`,a:`Between widgets`,b:`Never within a composite widget`},{label:`Arrows`,a:`Within a widget`,b:`With preventDefault so the page does not scroll`},{label:`Enter`,a:`Activate, submit`,b:`Buttons and links both`},{label:`Space`,a:`Activate a button`,b:`Toggles a checkbox; scrolls if unhandled`},{label:`Escape`,a:`Close, cancel`,b:`Dialogs, menus, popovers — always`},{label:`Home / End`,a:`First / last item`,b:`In any list-like widget`}]},{kind:`heading`,text:`Focus restoration, and the SPA route change`},{kind:`prose`,md:'Two focus bugs are almost universal in single-page apps.\n\n**The vanishing focus.** When you remove the focused element — closing a menu, deleting a row, swapping a view — focus falls back to `<body>`. The user\'s cursor is now nowhere: arrow keys scroll the document, Tab restarts from the top of the page, and a screen reader has lost its place. Always capture `document.activeElement` before the change and restore it after, or move focus somewhere deliberate.\n\n**The silent navigation.** A full page load resets focus and makes the screen reader announce the new page. A client-side route change does neither: the URL and the DOM change, and the user is told nothing at all. The fix is to move focus, on navigation, to the `<h1>` or the `<main>` of the new view, given `tabindex="-1"` so it can receive focus without joining the tab order.'},{kind:`code`,lang:`ts`,caption:`Restore what you took, announce what you changed`,code:`// Opening something modal
18const opener = document.activeElement as HTMLElement | null;
19dialog.showModal();
20// \u2026on close:
21opener?.focus();
22
23// Client-side route change
24router.events.subscribe(() => {
25  const h = document.querySelector('main h1') as HTMLElement | null;
26  h?.setAttribute('tabindex', '-1');
27  h?.focus();     // announces the new page and resets the tab position
28});`},{kind:`callout`,tone:`warn`,title:`Never remove the focus ring`,md:"`outline: none` with no replacement is the single most damaging line of CSS in accessibility, and it is usually added because the ring appeared on a mouse click. That is 
28what `:focus-visible` is for — the browser applies it on keyboard focus and withholds it on pointer focus, which is precisely the behaviour people were trying to hand-roll. Style it, make it high contrast against both the element and the background, and give it an `outline-offset`. If you cannot see where you are, nothing else on this page helps you."},{kind:`heading`,text:`Live regions: telling someone what changed`},{kind:`prose`,md:'A screen reader reads what is focused. If your app changes something **elsewhere** — a search result count, a toast, a validation error, "saved" — nothing is announced. A **live region** is a container the assistive technology watches, announcing changes to its contents without moving focus.\n\n- **`aria-live="polite"`** queues the announcement until the user is idle. This is the correct choice for nearly everything: status updates, result counts, save confirmations.\n- **`aria-live="assertive"`** interrupts immediately, cutting off whatever is being read. Reserve it for genuinely urgent things — a session about to expire, a submission that failed. Overusing it makes the app unusable, because it talks over the user.\n- The roles `role="status"` (implicitly polite) and `role="alert"` (implicitly assertive) are the shorthand, and are generally better supported.\n\nThis app uses the polite form on the caption in the explainer shell: `<p class="caption" aria-live="polite">`. Stepping through an animation changes the caption text, and the change is announced without stealing focus from the step controls — which is exactly the situation live regions exist for.'},{kind:`callout`,tone:`gotcha`,title:`The live-region rule everyone learns the hard way`,md:'**The container must exist in the DOM, empty, before you put anything in it.** Screen readers register live regions when they are added to the accessibility tree; inserting an element that already has `aria-live` on it, with text inside, frequently announces nothing at all. Render the empty container up front and change only its **text**. Two more: `aria-atomic="true"` makes the whole region re-read rather than just the changed part (right for "3 results" → "12 results"), and a region that updates on every keystroke will produce unusable noise, so debounce it. Announcements also cannot be tested by looking — they need a real screen reader.'},{kind:`heading`,text:`Motion`},{kind:`prose`,md:`Vestibular disorders are common, and large parallax, zoom or slide transitions can cause real nausea and migraine. The platform gives users a switch — an OS-level "reduce motion" setting — and exposes it to you as a media query. Honouring it costs almost nothing.
29
30Reduce means **reduce**, not remove: replace a slide or scale with a cross-fade, keep the state change legible, and never leave a user unsure whether anything happened. The global damping rule is the pragmatic starting point, and it is what this app ships in \`styles.scss\`.`},{kind:`code`,lang:`text`,caption:`styles.scss — the global damping baseline this app ships`,code:`@media (prefers-reduced-motion: reduce) {
31  *, *::before, *::after {
32    animation-duration: 0.01ms !important;
33    animation-iteration-count: 1 !important;
34    transition-duration: 0.01ms !important;
35    scroll-behavior: auto !important;
36  }
37}`},{kind:`code`,lang:`ts`,caption:`haptics.service.ts — the same preference gates vibration`,code:`private readonly reducedMotion =
38  matchMedia('(prefers-reduced-motion: reduce)').matches;
39
40fire(kind: Haptic): void {
41  if (!this.enabled() || !this.supported || this.reducedMotion) return;
42  navigator.vibrate(PATTERNS[kind]);
43}`},{kind:`prose`,md:`Note the second half: the preference is read as a signal about **physical motion in general**, not only CSS animation, so it silences haptics too. That is the kind of judgement the media query is asking for. The related WCAG requirements are worth knowing by name — **2.2.2 Pause, Stop, Hide** (anything moving for more than five seconds needs a control) and **2.3.1 Three Flashes** (nothing may flash more than three times per second, because it can trigger seizures).`},{kind:`callout`,tone:`tip`,title:`The two-minute audit you can run on anything`,md:`Put the mouse down. Tab through the page start to finish and check four things: **can you see where you are** at every stop; **can you reach everything** interactive; **can you escape** everything you entered — dialogs, menus, custom editors; and does the **order match the visual layout**. Then close a dialog and see where focus lands. This finds more real problems than any automated tool, because axe and Lighthouse can only detect roughly a third of WCAG failures — they cannot tell whether your alt text is accurate, whether focus went somewhere sensible, or whether the tab order makes sense.`},{kind:`callout`,tone:`interview`,title:`How this gets asked`,md:'The standard question is *"how would you make a modal dialog accessible?"* and it is a checklist you should be able to re
43cite cold: `role="dialog"` with `aria-modal="true"` (or the native `<dialog>`), an accessible name via `aria-labelledby` on its heading, **focus moved in** on open, **trapped** while open, **Escape** closes, background made `inert`, and **focus restored to the opener** on close. Then the follow-ups: *"what does `tabindex="-1"` do?"* (focusable by script, not by Tab), *"why is a positive tabindex bad?"* (it creates a competing tab order for the whole page), *"how do you announce something without moving focus?"* (a live region — polite by default, and it must exist before it fills), and *"how do you announce a route change in an SPA?"* (move focus to the new `<h1>`). Name the **APG** as your source and mention that automated tools catch only part of it, and you sound like someone who has shipped this.'},{kind:`takeaways`,items:[`Focus is the keyboard user’s cursor — every rule here follows from that.`,`Use only tabindex 0 and -1; a positive tabindex creates a competing tab order across the entire page.`,`tabindex="-1" means focusable by script but not by Tab, which is how you focus headings, dialogs and error summaries.`,`Tab order follows the DOM, not the visual layout, so CSS reordering can scramble it.`,`A composite widget is one tab stop with arrow keys inside it — implement roving tabindex, and remember to call focus(), not just move the attribute.`,`preventDefault on arrow keys, or the page scrolls underneath the widget.`,`A modal moves focus in, traps it, closes on Escape, makes the background inert, and restores focus to the opener.`,`Disclosure is a real button plus aria-expanded, and nothing else.`,`Client-side route changes announce nothing — move focus to the new h1 with tabindex="-1".`,`Never remove the focus ring; use :focus-visible, which already does what people hand-roll.`,`Live regions announce without moving focus: polite for almost everything, assertive only for urgent interruptions.`,`A live region must exist in the DOM before the content is inserted, or the change often goes unannounced.`,`Honour prefers-reduced-motion, and treat it as a statement about motion generally — this app uses it to gate haptics as well as animation.`,`Automated tools find roughly a third of issues; tabbing through the page yourself finds the rest.`]}],cards:e};export{o as A11Y_INTERACTION};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.