import { test, expect } from '@playwright/test'; import { testFocusTrapEscape, validateRovingTabindex } from './_helpers/focus'; // Behavioral keyboard/focus conformance against real Docusaurus hooks. These // assertions exercise runtime keyboard behavior (WCAG 2.1.1, 2.1.2, 2.4.3) that // the static axe-based specs cannot reach. Contrast and structural ARIA checks // stay in the existing axe specs to avoid redundant coverage. test.describe('Focus management', () => { // WCAG 2.4.3 Focus Order + 2.4.1 Bypass Blocks: the skip link must be the // first element to receive focus from the top of the page. test('skip link is first in the focus order', async ({ page }) => { await page.goto('/hve-core/'); await page.keyboard.press('Tab'); const skipLink = page.getByRole('link', { name: /skip to main content/i }); await expect(skipLink).toBeFocused(); }); // WCAG 2.1.1 Keyboard: the color-mode toggle must be operable by keyboard and // flip the document theme on Enter. test('color-mode toggle is keyboard operable', async ({ page }) => { await page.goto('/hve-core/docs/getting-started/'); const toggle = page.getByRole('button', { name: /switch between dark and light mode/i, }); await expect(toggle).toBeVisible(); const initialTheme = await page.locator('html').getAttribute('data-theme'); // Click to focus the control, then activate via the keyboard. This theme's // toggle flips state on Enter, which is the accessibility-relevant path. await toggle.click(); await page.keyboard.press('Enter'); await expect .poll(async () => page.locator('html').getAttribute('data-theme')) .not.toBe(initialTheme); }); // WCAG 2.1.1 Keyboard / 2.4.3 Focus Order: opening the navbar dropdown (when // present) must expose keyboard-reachable menu items. The Docusaurus default // theme implements its navbar dropdown as a disclosure-navigation menu where // every item is a naturally tabbable (no roving tabindex). Roving tabindex // is only asserted when the menu is actually built as a roving-tabindex widget, // guarding against false failures as the plan requires. test('navbar dropdown exposes keyboard-reachable items when present', async ({ page }) => { await page.goto('/hve-core/docs/getting-started/'); const dropdownToggle = page.locator('.navbar__item.dropdown .navbar__link').first(); // Dropdown navbar items are optional in the site config; skip cleanly when // none exist rather than producing a false failure. if ((await dropdownToggle.count()) === 0) { test.skip(true, 'No dropdown navbar item is configured.'); return; } await dropdownToggle.click(); const dropdownMenu = page.locator('.navbar__item.dropdown .dropdown__menu').first(); await expect(dropdownMenu).toBeVisible(); // Branch on the widget pattern. A roving-tabindex menu carries explicit // tabindex attributes; a disclosure-navigation menu carries none. const rovingItemCount = await dropdownMenu.locator('[tabindex]').count(); if (rovingItemCount > 0) { // Composite roving-tabindex widget: exactly one tabindex="0", rest -1. expect(await validateRovingTabindex(page, '.navbar__item.dropdown .dropdown__menu')).toBe(true); return; } // Disclosure-navigation menu: every link must be individually keyboard // focusable so the menu is fully operable without a pointer (WCAG 2.1.1). const menuLinks = dropdownMenu.locator('a'); const linkCount = await menuLinks.count(); expect(linkCount).toBeGreaterThan(0); for (let index = 0; index < linkCount; index += 1) { const link = menuLinks.nth(index); await link.focus(); await expect(link).toBeFocused(); } }); }); // WCAG 2.1.2 No Keyboard Trap: the mobile sidebar must let keyboard focus leave // the container. Some themes release focus on Escape; the Docusaurus default // theme instead provides a keyboard-operable "Close navigation bar" control. // Either mechanism satisfies No Keyboard Trap, so the spec accepts both. test.describe('Mobile sidebar focus trap', () => { test.use({ viewport: { width: 390, height: 844 } }); test('keyboard focus can leave the open mobile sidebar', async ({ page }) => { await page.goto('/hve-core/docs/getting-started/'); const toggle = page.locator('.navbar__toggle'); await expect(toggle).toBeVisible(); await toggle.click(); const mobileSidebar = page.locator('.navbar-sidebar'); await expect(mobileSidebar).toBeVisible(); // Preferred path: focus leaves the container on Escape when the theme wires // it. The Docusaurus default theme does not, so fall through when it fails. if (await testFocusTrapEscape(page, '.navbar-sidebar')) { return; } // Docusaurus default theme path: the dedicated close control must be // keyboard operable and must collapse the sidebar, releasing focus. const closeButton = page.getByRole('button', { name: /close navigation bar/i }); await closeButton.focus(); await expect(closeButton).toBeFocused(); await page.keyboard.press('Enter'); await expect(mobileSidebar).toBeHidden(); }); });