microsoft/hve-core
Publicmirrored fromhttps://github.com/microsoft/hve-coreAvailable
docs/docusaurus/e2e/_helpers/focus.ts
86lines · modecode
| 1 | import type { Page } from '@playwright/test'; |
| 2 | |
| 3 | // Shared behavioral focus helpers for keyboard/focus-management specs. These |
| 4 | // drive real keyboard interaction against the rendered Docusaurus DOM, covering |
| 5 | // WCAG criteria (2.1.1, 2.1.2, 2.4.3) that static axe-based scans cannot reach. |
| 6 | |
| 7 | /** A snapshot of the active element captured at one step of a focus traversal. */ |
| 8 | export interface FocusSnapshot { |
| 9 | tag: string | undefined; |
| 10 | text: string | undefined; |
| 11 | ariaLabel: string | null | undefined; |
| 12 | id: string | undefined; |
| 13 | } |
| 14 | |
| 15 | /** |
| 16 | * Drive Tab / Shift+Tab and snapshot the active element at each step. |
| 17 | * |
| 18 | * @param page - The Playwright page under test. |
| 19 | * @param direction - Traversal direction; 'forward' presses Tab, 'backward' presses Shift+Tab. |
| 20 | * @param count - Number of steps (snapshots) to collect. |
| 21 | * @returns The ordered sequence of active-element snapshots. |
| 22 | */ |
| 23 | export async function collectFocusOrder( |
| 24 | page: Page, |
| 25 | direction: 'forward' | 'backward' = 'forward', |
| 26 | count = 5, |
| 27 | ): Promise<FocusSnapshot[]> { |
| 28 | const sequence: FocusSnapshot[] = []; |
| 29 | for (let i = 0; i < count; i++) { |
| 30 | sequence.push( |
| 31 | await page.evaluate(() => { |
| 32 | const el = document.activeElement; |
| 33 | return { |
| 34 | tag: el?.tagName, |
| 35 | text: el?.textContent?.slice(0, 50), |
| 36 | ariaLabel: el?.getAttribute('aria-label'), |
| 37 | id: el?.id, |
| 38 | }; |
| 39 | }), |
| 40 | ); |
| 41 | await page.keyboard.press(direction === 'forward' ? 'Tab' : 'Shift+Tab'); |
| 42 | } |
| 43 | return sequence; |
| 44 | } |
| 45 | |
| 46 | /** |
| 47 | * Focus the first focusable element inside a container, press the escape key, |
| 48 | * and report whether focus left the container. |
| 49 | * |
| 50 | * @param page - The Playwright page under test. |
| 51 | * @param containerSelector - CSS selector for the container that should release focus. |
| 52 | * @param escapeKey - The key expected to release the trap (default 'Escape'). |
| 53 | * @returns True when the active element is no longer within the container. |
| 54 | */ |
| 55 | export async function testFocusTrapEscape( |
| 56 | page: Page, |
| 57 | containerSelector: string, |
| 58 | escapeKey = 'Escape', |
| 59 | ): Promise<boolean> { |
| 60 | const container = page.locator(containerSelector); |
| 61 | await container.locator('button, a, input').first().focus(); |
| 62 | await page.keyboard.press(escapeKey); |
| 63 | return await page.evaluate( |
| 64 | (sel) => document.activeElement?.closest(sel) === null, |
| 65 | containerSelector, |
| 66 | ); |
| 67 | } |
| 68 | |
| 69 | /** |
| 70 | * Validate a roving-tabindex container: exactly one element with tabindex="0" |
| 71 | * and every other tabindex element set to "-1". |
| 72 | * |
| 73 | * @param page - The Playwright page under test. |
| 74 | * @param containerSelector - CSS selector for the roving-tabindex container. |
| 75 | * @returns True when the container satisfies the roving-tabindex invariant. |
| 76 | */ |
| 77 | export async function validateRovingTabindex( |
| 78 | page: Page, |
| 79 | containerSelector: string, |
| 80 | ): Promise<boolean> { |
| 81 | const items = page.locator(`${containerSelector} [tabindex]`); |
| 82 | const count = await items.count(); |
| 83 | const zero = await page.locator(`${containerSelector} [tabindex="0"]`).count(); |
| 84 | const negOne = await page.locator(`${containerSelector} [tabindex="-1"]`).count(); |
| 85 | return zero === 1 && negOne === count - 1; |
| 86 | } |
| 87 | |