microsoft/hve-core
Publicmirrored from https://github.com/microsoft/hve-coreAvailable
docs/docusaurus/e2e/focus-management.spec.ts
124lines · modecode
| 1 | import { test, expect } from '@playwright/test'; |
| 2 | import { testFocusTrapEscape, validateRovingTabindex } from './_helpers/focus'; |
| 3 | |
| 4 | // Behavioral keyboard/focus conformance against real Docusaurus hooks. These |
| 5 | // assertions exercise runtime keyboard behavior (WCAG 2.1.1, 2.1.2, 2.4.3) that |
| 6 | // the static axe-based specs cannot reach. Contrast and structural ARIA checks |
| 7 | // stay in the existing axe specs to avoid redundant coverage. |
| 8 | |
| 9 | test.describe('Focus management', () => { |
| 10 | // WCAG 2.4.3 Focus Order + 2.4.1 Bypass Blocks: the skip link must be the |
| 11 | // first element to receive focus from the top of the page. |
| 12 | test('skip link is first in the focus order', async ({ page }) => { |
| 13 | await page.goto('/hve-core/'); |
| 14 | |
| 15 | await page.keyboard.press('Tab'); |
| 16 | |
| 17 | const skipLink = page.getByRole('link', { name: /skip to main content/i }); |
| 18 | await expect(skipLink).toBeFocused(); |
| 19 | }); |
| 20 | |
| 21 | // WCAG 2.1.1 Keyboard: the color-mode toggle must be operable by keyboard and |
| 22 | // flip the document theme on Enter. |
| 23 | test('color-mode toggle is keyboard operable', async ({ page }) => { |
| 24 | await page.goto('/hve-core/docs/getting-started/'); |
| 25 | |
| 26 | const toggle = page.getByRole('button', { |
| 27 | name: /switch between dark and light mode/i, |
| 28 | }); |
| 29 | await expect(toggle).toBeVisible(); |
| 30 | |
| 31 | const initialTheme = await page.locator('html').getAttribute('data-theme'); |
| 32 | |
| 33 | // Click to focus the control, then activate via the keyboard. This theme's |
| 34 | // toggle flips state on Enter, which is the accessibility-relevant path. |
| 35 | await toggle.click(); |
| 36 | await page.keyboard.press('Enter'); |
| 37 | |
| 38 | await expect |
| 39 | .poll(async () => page.locator('html').getAttribute('data-theme')) |
| 40 | .not.toBe(initialTheme); |
| 41 | }); |
| 42 | |
| 43 | // WCAG 2.1.1 Keyboard / 2.4.3 Focus Order: opening the navbar dropdown (when |
| 44 | // present) must expose keyboard-reachable menu items. The Docusaurus default |
| 45 | // theme implements its navbar dropdown as a disclosure-navigation menu where |
| 46 | // every item is a naturally tabbable <a> (no roving tabindex). Roving tabindex |
| 47 | // is only asserted when the menu is actually built as a roving-tabindex widget, |
| 48 | // guarding against false failures as the plan requires. |
| 49 | test('navbar dropdown exposes keyboard-reachable items when present', async ({ page }) => { |
| 50 | await page.goto('/hve-core/docs/getting-started/'); |
| 51 | |
| 52 | const dropdownToggle = page.locator('.navbar__item.dropdown .navbar__link').first(); |
| 53 | |
| 54 | // Dropdown navbar items are optional in the site config; skip cleanly when |
| 55 | // none exist rather than producing a false failure. |
| 56 | if ((await dropdownToggle.count()) === 0) { |
| 57 | test.skip(true, 'No dropdown navbar item is configured.'); |
| 58 | return; |
| 59 | } |
| 60 | |
| 61 | await dropdownToggle.click(); |
| 62 | |
| 63 | const dropdownMenu = page.locator('.navbar__item.dropdown .dropdown__menu').first(); |
| 64 | await expect(dropdownMenu).toBeVisible(); |
| 65 | |
| 66 | // Branch on the widget pattern. A roving-tabindex menu carries explicit |
| 67 | // tabindex attributes; a disclosure-navigation menu carries none. |
| 68 | const rovingItemCount = await dropdownMenu.locator('[tabindex]').count(); |
| 69 | |
| 70 | if (rovingItemCount > 0) { |
| 71 | // Composite roving-tabindex widget: exactly one tabindex="0", rest -1. |
| 72 | expect(await validateRovingTabindex(page, '.navbar__item.dropdown .dropdown__menu')).toBe(true); |
| 73 | return; |
| 74 | } |
| 75 | |
| 76 | // Disclosure-navigation menu: every link must be individually keyboard |
| 77 | // focusable so the menu is fully operable without a pointer (WCAG 2.1.1). |
| 78 | const menuLinks = dropdownMenu.locator('a'); |
| 79 | const linkCount = await menuLinks.count(); |
| 80 | expect(linkCount).toBeGreaterThan(0); |
| 81 | |
| 82 | for (let index = 0; index < linkCount; index += 1) { |
| 83 | const link = menuLinks.nth(index); |
| 84 | await link.focus(); |
| 85 | await expect(link).toBeFocused(); |
| 86 | } |
| 87 | }); |
| 88 | }); |
| 89 | |
| 90 | // WCAG 2.1.2 No Keyboard Trap: the mobile sidebar must let keyboard focus leave |
| 91 | // the container. Some themes release focus on Escape; the Docusaurus default |
| 92 | // theme instead provides a keyboard-operable "Close navigation bar" control. |
| 93 | // Either mechanism satisfies No Keyboard Trap, so the spec accepts both. |
| 94 | test.describe('Mobile sidebar focus trap', () => { |
| 95 | test.use({ viewport: { width: 390, height: 844 } }); |
| 96 | |
| 97 | test('keyboard focus can leave the open mobile sidebar', async ({ page }) => { |
| 98 | await page.goto('/hve-core/docs/getting-started/'); |
| 99 | |
| 100 | const toggle = page.locator('.navbar__toggle'); |
| 101 | await expect(toggle).toBeVisible(); |
| 102 | |
| 103 | await toggle.click(); |
| 104 | |
| 105 | const mobileSidebar = page.locator('.navbar-sidebar'); |
| 106 | await expect(mobileSidebar).toBeVisible(); |
| 107 | |
| 108 | // Preferred path: focus leaves the container on Escape when the theme wires |
| 109 | // it. The Docusaurus default theme does not, so fall through when it fails. |
| 110 | if (await testFocusTrapEscape(page, '.navbar-sidebar')) { |
| 111 | return; |
| 112 | } |
| 113 | |
| 114 | // Docusaurus default theme path: the dedicated close control must be |
| 115 | // keyboard operable and must collapse the sidebar, releasing focus. |
| 116 | const closeButton = page.getByRole('button', { name: /close navigation bar/i }); |
| 117 | await closeButton.focus(); |
| 118 | await expect(closeButton).toBeFocused(); |
| 119 | |
| 120 | await page.keyboard.press('Enter'); |
| 121 | |
| 122 | await expect(mobileSidebar).toBeHidden(); |
| 123 | }); |
| 124 | }); |
| 125 | |