Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(a11y): modal focus-trap (Tab/Shift+Tab wrap) — v0.57.0

modalPrompt and modalConfirm now intercept Tab/Shift+Tab and wrap focus
between the first and last focusable elements within the dialog. If focus
escapes to a background element (e.g. user clicks a background link),
Tab pulls it back into the dialog.

A new exported handleFocusTrap(event, dialog) helper encapsulates the
logic so it can be reused in other dialogs (landing modals, command
palette) in follow-up PRs.

Closes #690.

+167 -2
+1
CHANGELOG.md
··· 8 8 ## [Unreleased] 9 9 10 10 ### Added 11 + - A11y: modal focus trap — `modalPrompt`/`modalConfirm` now wrap Tab/Shift+Tab within the dialog so keyboard users cannot tab out to background elements while a modal is open. A new exported `handleFocusTrap(event, dialog)` helper wraps focus between first/last focusable children and pulls focus back into the dialog if it escapes (e.g. user tabs to a background link). 8 new jsdom tests pin the contract (Tab wrap, Shift+Tab wrap, escape recovery, mid-tab no-op, non-Tab no-op, integration with modalPrompt + modalConfirm). Closes the last major keyboard-accessibility gap in the shared modal helper. (#690) 11 12 - A11y: landing-page icon-only buttons (theme toggle, user badge, search clear) now carry `aria-label` so screen readers can announce them — previously only `title` was set, which is unreliable for AT. The user badge also gets `role="button"` + `tabindex="0"` so keyboard users can reach it. Landing modals (username, folder, move) now declare `aria-labelledby` pointing at their title heading so modal opens announce context. (#690) 12 13 - A11y: slides Notes/Animations panel tabs now carry the full tablist semantics — `role="tablist"` on the container, `role="tab"` + `aria-selected` + `aria-controls` on each button, and `role="tabpanel"` + `aria-labelledby` on each content pane. The click handler keeps `aria-selected` in sync when tabs switch. 2 new jsdom regression tests pin the invariant. (#690) 13 14 - Slides mobile panel toggle: on phones (<=480px) the slides thumbnail panel was previously hidden with `display:none` and no way to bring it back, making slide navigation impossible. A new `btn-toggle-slide-panel` in the slides topbar now flips a `.slides-panel--mobile-open` class that renders the panel as a full-height overlay so users can jump to any slide. Tapping a thumbnail or hitting Escape closes the overlay. (#688)
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.52.0", 3 + "version": "0.57.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+59
src/lib/modal-dialog.ts
··· 35 35 cancel: HTMLButtonElement; 36 36 } 37 37 38 + /** 39 + * Returns all focusable elements within `root`, in document order. Used by the 40 + * focus trap to wrap Tab/Shift+Tab between first and last. 41 + */ 42 + function getFocusable(root: HTMLElement): HTMLElement[] { 43 + const selector = [ 44 + 'a[href]', 45 + 'button:not([disabled])', 46 + 'input:not([disabled]):not([type="hidden"])', 47 + 'select:not([disabled])', 48 + 'textarea:not([disabled])', 49 + '[tabindex]:not([tabindex="-1"])', 50 + ].join(','); 51 + return Array.from(root.querySelectorAll<HTMLElement>(selector)).filter( 52 + (el) => !el.hasAttribute('inert') && !el.hasAttribute('disabled'), 53 + ); 54 + } 55 + 56 + /** 57 + * Handles Tab/Shift+Tab focus wrapping inside `dialog`. Returns true if the 58 + * event was handled (consumers should stop further processing). Also forces 59 + * focus back into the dialog if it has escaped (e.g. user tabbed into a 60 + * background element). 61 + */ 62 + export function handleFocusTrap(e: KeyboardEvent, dialog: HTMLElement): boolean { 63 + if (e.key !== 'Tab') return false; 64 + const focusable = getFocusable(dialog); 65 + if (focusable.length === 0) { 66 + e.preventDefault(); 67 + dialog.focus(); 68 + return true; 69 + } 70 + const first = focusable[0]!; 71 + const last = focusable[focusable.length - 1]!; 72 + const active = document.activeElement as HTMLElement | null; 73 + const insideDialog = active !== null && dialog.contains(active); 74 + 75 + if (!insideDialog) { 76 + e.preventDefault(); 77 + first.focus(); 78 + return true; 79 + } 80 + if (e.shiftKey && active === first) { 81 + e.preventDefault(); 82 + last.focus(); 83 + return true; 84 + } 85 + if (!e.shiftKey && active === last) { 86 + e.preventDefault(); 87 + first.focus(); 88 + return true; 89 + } 90 + return false; 91 + } 92 + 38 93 function buildShell(opts: { 39 94 title: string; 40 95 message: string; ··· 129 184 } else if (e.key === 'Enter' && e.target === input) { 130 185 e.preventDefault(); 131 186 onOk(); 187 + } else if (handleFocusTrap(e, shell.dialog)) { 188 + e.stopPropagation(); 132 189 } 133 190 }; 134 191 ··· 178 235 if (e.key === 'Escape') { 179 236 e.stopPropagation(); 180 237 onCancel(); 238 + } else if (handleFocusTrap(e, shell.dialog)) { 239 + e.stopPropagation(); 181 240 } 182 241 }; 183 242
+106 -1
tests/modal-dialog.test.ts
··· 10 10 import { JSDOM } from 'jsdom'; 11 11 12 12 // Under test 13 - import { modalPrompt, modalConfirm } from '../src/lib/modal-dialog.js'; 13 + import { modalPrompt, modalConfirm, handleFocusTrap } from '../src/lib/modal-dialog.js'; 14 14 15 15 describe('modal-dialog', () => { 16 16 let dom: JSDOM; ··· 186 186 const ok = document.querySelector<HTMLButtonElement>('.modal-dialog-ok')!; 187 187 expect(document.activeElement).toBe(ok); 188 188 ok.click(); 189 + await p; 190 + }); 191 + }); 192 + 193 + // v0.57.0: focus trap — Tab/Shift+Tab must stay within the dialog 194 + describe('handleFocusTrap', () => { 195 + function makeDialog(): HTMLElement { 196 + const d = document.createElement('div'); 197 + d.setAttribute('role', 'dialog'); 198 + const btn1 = document.createElement('button'); 199 + btn1.textContent = 'first'; 200 + const input = document.createElement('input'); 201 + input.type = 'text'; 202 + const btn2 = document.createElement('button'); 203 + btn2.textContent = 'last'; 204 + d.appendChild(btn1); 205 + d.appendChild(input); 206 + d.appendChild(btn2); 207 + document.body.appendChild(d); 208 + return d; 209 + } 210 + 211 + it('wraps Tab from last focusable to first', () => { 212 + const dialog = makeDialog(); 213 + const [first, , last] = Array.from(dialog.children) as HTMLElement[]; 214 + last!.focus(); 215 + const ev = new window.KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }); 216 + const handled = handleFocusTrap(ev, dialog); 217 + expect(handled).toBe(true); 218 + expect(ev.defaultPrevented).toBe(true); 219 + expect(document.activeElement).toBe(first); 220 + }); 221 + 222 + it('wraps Shift+Tab from first focusable to last', () => { 223 + const dialog = makeDialog(); 224 + const [first, , last] = Array.from(dialog.children) as HTMLElement[]; 225 + first!.focus(); 226 + const ev = new window.KeyboardEvent('keydown', { 227 + key: 'Tab', shiftKey: true, bubbles: true, cancelable: true, 228 + }); 229 + const handled = handleFocusTrap(ev, dialog); 230 + expect(handled).toBe(true); 231 + expect(ev.defaultPrevented).toBe(true); 232 + expect(document.activeElement).toBe(last); 233 + }); 234 + 235 + it('pulls focus back into the dialog when it escaped outside', () => { 236 + const outsideBtn = document.createElement('button'); 237 + outsideBtn.textContent = 'outside'; 238 + document.body.appendChild(outsideBtn); 239 + const dialog = makeDialog(); 240 + const first = dialog.firstElementChild as HTMLElement; 241 + outsideBtn.focus(); 242 + const ev = new window.KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }); 243 + const handled = handleFocusTrap(ev, dialog); 244 + expect(handled).toBe(true); 245 + expect(document.activeElement).toBe(first); 246 + }); 247 + 248 + it('does not interfere with Tab in the middle of the tab order', () => { 249 + const dialog = makeDialog(); 250 + const [, middle] = Array.from(dialog.children) as HTMLElement[]; 251 + middle!.focus(); 252 + const ev = new window.KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }); 253 + const handled = handleFocusTrap(ev, dialog); 254 + expect(handled).toBe(false); 255 + expect(ev.defaultPrevented).toBe(false); 256 + }); 257 + 258 + it('returns false for non-Tab keys', () => { 259 + const dialog = makeDialog(); 260 + const ev = new window.KeyboardEvent('keydown', { key: 'a', bubbles: true }); 261 + expect(handleFocusTrap(ev, dialog)).toBe(false); 262 + }); 263 + 264 + it('modalPrompt traps Tab between input, OK, and Cancel', async () => { 265 + const p = modalPrompt({ title: 't', message: 'm' }); 266 + const input = document.querySelector<HTMLInputElement>('.modal-dialog-input')!; 267 + const cancel = document.querySelector<HTMLButtonElement>('.modal-dialog-cancel')!; 268 + const ok = document.querySelector<HTMLButtonElement>('.modal-dialog-ok')!; 269 + expect(document.activeElement).toBe(input); 270 + 271 + // Shift+Tab from input (first focusable) → wraps to last (ok) 272 + ok.focus(); // simulate reaching last 273 + const ev = new window.KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }); 274 + document.dispatchEvent(ev); 275 + // After wrap, activeElement should be input (first focusable) 276 + expect(document.activeElement).toBe(input); 277 + 278 + cancel.click(); 279 + await p; 280 + }); 281 + 282 + it('modalConfirm traps Tab between Cancel and OK', async () => { 283 + const p = modalConfirm({ title: 't', message: 'm' }); 284 + const cancel = document.querySelector<HTMLButtonElement>('.modal-dialog-cancel')!; 285 + const ok = document.querySelector<HTMLButtonElement>('.modal-dialog-ok')!; 286 + expect(document.activeElement).toBe(ok); 287 + 288 + // Tab from ok (last focusable) → wraps to cancel (first) 289 + const ev = new window.KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }); 290 + document.dispatchEvent(ev); 291 + expect(document.activeElement).toBe(cancel); 292 + 293 + cancel.click(); 189 294 await p; 190 295 }); 191 296 });