From 3a21aa2028d483185a75cf969d0e275f1409416f Mon Sep 17 00:00:00 2001 From: quality Date: Mon, 5 Oct 2026 05:31:22 -0400 Subject: [PATCH] test: drive useFocusTrap's Shift+Tab wrap and other-key return end to end tests/e2e/community-people.spec.js owns the PersonDialog lightbox, which mounts src/components/hooks/useFocusTrap.js. Two of that hook's arms were never executed in a browser by any spec in tests/e2e/: * useFocusTrap.js:38-40, the backwards wrap. No e2e spec pressed Shift+Tab at all, so forward tabbing short-circuited on event.shiftKey and never evaluated document.activeElement === first. A dialog that leaked focus to the page behind it on Shift+Tab is a WCAG 2.1.2 / 2.4.3 defect that shipped green. * useFocusTrap.js:33, the early return for every key that is neither Escape nor Tab. The listener is on document, so it observes every keystroke on the page; nothing asserted that an ordinary key leaves the dialog and its focus alone. tests/e2e/accessibility.spec.js cannot reach either: axe-core scans static markup and does not drive the keyboard. Both components report 100% unit line and region coverage, so this is an e2e-only gap. End-to-end regions for useFocusTrap.js move from 62.96% to 64.29%, and src files from 466/377/80.90% to 467/379/81.16%. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: quality --- tests/e2e/community-people.spec.js | 43 ++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/e2e/community-people.spec.js b/tests/e2e/community-people.spec.js index ada670b9..c1146873 100644 --- a/tests/e2e/community-people.spec.js +++ b/tests/e2e/community-people.spec.js @@ -218,6 +218,49 @@ test.describe('TAB roster profile dialog', () => { .toBe(true); }); + test('Shift+Tab from the first focusable element wraps to the last', async ({ + page, + }) => { + const { dialog } = await openProfile(page); + + // useFocusTrap mounts with focus on the close button, which is the first + // element its `button, a[href]` query matches. Backwards from there is the + // only way into the `event.shiftKey && activeElement === first` arm of + // src/components/hooks/useFocusTrap.js: forwards tabbing short-circuits on + // `event.shiftKey` and never evaluates it. Without this case the dialog + // could leak focus to the roster behind it on Shift+Tab and every existing + // assertion would still pass. + const focusable = dialog.locator('button, a[href]'); + const count = await focusable.count(); + expect(count).toBeGreaterThan(1); + + await expect(focusable.first()).toBeFocused(); + + await page.keyboard.press('Shift+Tab'); + + await expect(focusable.nth(count - 1)).toBeFocused(); + }); + + test('a key that is neither Escape nor Tab leaves the dialog alone', async ({ + page, + }) => { + const { dialog } = await openProfile(page); + + const focusable = dialog.locator('button, a[href]'); + await expect(focusable.first()).toBeFocused(); + + // The keydown listener is on `document`, so it sees every key pressed + // while the dialog is mounted, not only the two it acts on. This is the + // early `return` for everything else: an ordinary key must neither close + // the dialog nor move focus, which is what stops typing from behaving like + // Escape or Tab. + for (const key of ['a', 'ArrowDown', 'End']) { + await page.keyboard.press(key); + await expect(dialog).toBeVisible(); + await expect(focusable.first()).toBeFocused(); + } + }); + test('offers only absolute external profile links', async ({ page }) => { const { dialog } = await openProfile(page);