From 5e6a41739ab16c689d8b6944c35d5e12b5b99541 Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Wed, 19 Aug 2026 13:11:40 +0530 Subject: [PATCH 01/20] chore: add accessibility-ready e2e acceptance suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 60 tests in 8 spec files encoding the accessibility-ready acceptance criteria (see neve-pro-addon a11y.md / Trac #285870): - submenu keyboard/ARIA behavior incl. screen-reader activation path - focus visibility, tab order, stray tabindex - structure: single H1, duplicate IDs, landmark naming - form labels, link underline/hover/ambiguity - block pattern source lint + rendered checks - axe-core WCAG A/AA sweep over the reviewer's page types - skip link, prefers-reduced-motion, new-window warnings Own config (playwright.a11y.config.ts) running logged-out with no auth dependency; fixtures provisioned by bin/envs/a11y-ready (idempotent, reusable locally via WP-CLI); wired into the playwright.yml CI matrix as the a11y-ready env/spec pair, run via yarn test:a11y. Adds @axe-core/playwright dev dependency. Most tests fail against the unfixed theme by design (baseline 42 failed / 18 passed) — they describe the required end state and become the permanent regression gate once remediation lands. Co-Authored-By: Claude Fable 5 --- .github/workflows/playwright.yml | 6 + bin/envs/a11y-ready/fixtures.sh | 89 ++++++++ bin/envs/a11y-ready/start.sh | 8 + e2e-tests/playwright.a11y.config.ts | 38 ++++ e2e-tests/specs/a11y-ready/README.md | 60 ++++++ e2e-tests/specs/a11y-ready/a11y-utils.ts | 142 +++++++++++++ e2e-tests/specs/a11y-ready/axe.spec.ts | 72 +++++++ .../specs/a11y-ready/focus-visibility.spec.ts | 99 +++++++++ e2e-tests/specs/a11y-ready/forms.spec.ts | 90 ++++++++ e2e-tests/specs/a11y-ready/links.spec.ts | 146 +++++++++++++ .../a11y-ready/motion-context-window.spec.ts | 91 ++++++++ e2e-tests/specs/a11y-ready/patterns.spec.ts | 129 ++++++++++++ e2e-tests/specs/a11y-ready/structure.spec.ts | 121 +++++++++++ .../specs/a11y-ready/submenu-keyboard.spec.ts | 196 ++++++++++++++++++ package.json | 2 + yarn.lock | 46 +++- 16 files changed, 1329 insertions(+), 6 deletions(-) create mode 100644 bin/envs/a11y-ready/fixtures.sh create mode 100644 bin/envs/a11y-ready/start.sh create mode 100644 e2e-tests/playwright.a11y.config.ts create mode 100644 e2e-tests/specs/a11y-ready/README.md create mode 100644 e2e-tests/specs/a11y-ready/a11y-utils.ts create mode 100644 e2e-tests/specs/a11y-ready/axe.spec.ts create mode 100644 e2e-tests/specs/a11y-ready/focus-visibility.spec.ts create mode 100644 e2e-tests/specs/a11y-ready/forms.spec.ts create mode 100644 e2e-tests/specs/a11y-ready/links.spec.ts create mode 100644 e2e-tests/specs/a11y-ready/motion-context-window.spec.ts create mode 100644 e2e-tests/specs/a11y-ready/patterns.spec.ts create mode 100644 e2e-tests/specs/a11y-ready/structure.spec.ts create mode 100644 e2e-tests/specs/a11y-ready/submenu-keyboard.spec.ts diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index c3ffa1604f..265fa961b5 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -126,6 +126,8 @@ jobs: envs: "sample-data" - specs: "accessibility" envs: "sample-data" + - specs: "a11y-ready" + envs: "a11y-ready" # - specs: "woo-visual-regression" # envs: "woo-sample" runs-on: ubuntu-22.04 @@ -145,7 +147,11 @@ jobs: - name: Install Playwright Browsers run: npx playwright install --with-deps chromium - name: Run Playwright tests + if: matrix.specs != 'a11y-ready' run: yarn run test:playwright e2e-tests/specs/${{ matrix.specs }} + - name: Run a11y acceptance tests + if: matrix.specs == 'a11y-ready' + run: yarn run test:a11y - name: Upload trace file if: failure() uses: actions/upload-artifact@v4 diff --git a/bin/envs/a11y-ready/fixtures.sh b/bin/envs/a11y-ready/fixtures.sh new file mode 100644 index 0000000000..ccd53f977f --- /dev/null +++ b/bin/envs/a11y-ready/fixtures.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Fixtures for the accessibility-ready e2e suite (e2e-tests/specs/a11y-ready). +# +# Idempotent: safe to re-run; recreates fixture content by slug/name. +# Usage: +# Local install: bash bin/envs/a11y-ready/fixtures.sh /path/to/wp/root +# CI (docker): WP_CMD="wp --allow-root" bash bin/envs/a11y-ready/fixtures.sh +set -e + +WP_PATH=${1:-} +WP_CMD=${WP_CMD:-wp} +if [ -n "$WP_PATH" ]; then + WP_CMD="$WP_CMD --path=$WP_PATH" +fi + +echo "== a11y-ready fixtures ==" + +# ------------------------------------------------------------------ +# 1. Primary menu with two dropdown submenus (submenu keyboard tests) +# ------------------------------------------------------------------ +$WP_CMD menu delete "A11y Test" 2>/dev/null || true +MENU_ID=$($WP_CMD menu create "A11y Test" --porcelain) +HOME_URL=$($WP_CMD option get siteurl) +P1=$($WP_CMD menu item add-custom "$MENU_ID" "Products" "$HOME_URL/" --porcelain) +$WP_CMD menu item add-custom "$MENU_ID" "Product Alpha" "$HOME_URL/?fixture=alpha" --parent-id="$P1" --porcelain +$WP_CMD menu item add-custom "$MENU_ID" "Product Beta" "$HOME_URL/?fixture=beta" --parent-id="$P1" --porcelain +P2=$($WP_CMD menu item add-custom "$MENU_ID" "Company" "$HOME_URL/" --porcelain) +$WP_CMD menu item add-custom "$MENU_ID" "About Us" "$HOME_URL/?fixture=about" --parent-id="$P2" --porcelain +$WP_CMD menu item add-custom "$MENU_ID" "Contact" "$HOME_URL/?fixture=contact" --parent-id="$P2" --porcelain +$WP_CMD menu item add-custom "$MENU_ID" "Plain Item" "$HOME_URL/?fixture=plain" --porcelain +$WP_CMD menu location assign "$MENU_ID" primary + +# ------------------------------------------------------------------ +# 2. Post with open comments + an inline content link (focus/underline tests) +# ------------------------------------------------------------------ +OLD_POST=$($WP_CMD post list --post_type=post --name=a11y-comment-test --field=ID | head -1) +if [ -n "$OLD_POST" ]; then + $WP_CMD post delete "$OLD_POST" --force +fi +POST_ID=$($WP_CMD post create \ + --post_title="A11y Comment Test" \ + --post_name="a11y-comment-test" \ + --post_status=publish \ + --comment_status=open \ + --post_content="

Fixture post for comment form accessibility checks. It contains an inline content link for the underlined-links criterion, and a second paragraph so the excerpt renders.

Second paragraph of filler content.

" \ + --porcelain) +$WP_CMD comment create --comment_post_ID="$POST_ID" --comment_content="An approved fixture comment so the comment list renders." --comment_author="Fixture Tester" --comment_author_email=fixture@example.com --comment_approved=1 --porcelain + +# ------------------------------------------------------------------ +# 3. Page containing ALL Neve block patterns (pattern criteria tests) +# ------------------------------------------------------------------ +OLD_PAGE=$($WP_CMD post list --post_type=page --name=a11y-pattern-test --field=ID | head -1) +if [ -n "$OLD_PAGE" ]; then + $WP_CMD post delete "$OLD_PAGE" --force +fi +$WP_CMD eval ' +$dir = get_template_directory() . "/inc/compatibility/block-patterns/"; +$content = ""; +foreach ( glob( $dir . "*.php" ) as $file ) { + $p = include $file; + if ( is_array( $p ) && isset( $p["content"] ) ) { + $content .= $p["content"]; + } +} +$id = wp_insert_post( array( + "post_title" => "A11y Pattern Test", + "post_name" => "a11y-pattern-test", + "post_type" => "page", + "post_status" => "publish", + "post_content" => $content, +) ); +echo $id . "\n"; +' + +# ------------------------------------------------------------------ +# 4. Category with posts (archive H1 test) + pagination on the blog +# ------------------------------------------------------------------ +$WP_CMD term create category "A11y Cat" --slug=a11y-cat 2>/dev/null || true +for i in 1 2 3 4; do + SLUG="a11y-cat-post-$i" + OLD=$($WP_CMD post list --post_type=post --name="$SLUG" --field=ID | head -1) + if [ -z "$OLD" ]; then + $WP_CMD post create --post_title="A11y Cat Post $i" --post_name="$SLUG" --post_status=publish --post_content="

Filler post $i for archive and pagination fixtures.

" --porcelain | xargs -I{} $WP_CMD post term set {} category a11y-cat + fi +done +# Low per-page count so the blog paginates (pagination link-text criterion). +$WP_CMD option update posts_per_page 3 + +echo "== a11y-ready fixtures done ==" diff --git a/bin/envs/a11y-ready/start.sh b/bin/envs/a11y-ready/start.sh new file mode 100644 index 0000000000..3789d536f8 --- /dev/null +++ b/bin/envs/a11y-ready/start.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# CI environment for the accessibility-ready e2e suite. +# Same base as sample-data, plus deterministic a11y fixtures. +DIR="$(dirname "$0")" + +bash "$DIR/../sample-data/start.sh" + +WP_CMD="wp --allow-root" bash "$DIR/fixtures.sh" diff --git a/e2e-tests/playwright.a11y.config.ts b/e2e-tests/playwright.a11y.config.ts new file mode 100644 index 0000000000..19516addee --- /dev/null +++ b/e2e-tests/playwright.a11y.config.ts @@ -0,0 +1,38 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Config for the accessibility-ready acceptance suite (specs/a11y-ready). + * + * Separate from playwright.config.ts on purpose: + * - No auth setup dependency — every test runs logged OUT, because the admin + * bar changes the DOM (extra landmarks, IDs and tab stops) and the + * WordPress.org review is performed logged out. + * - Runs against any environment via the baseURL env var. + * + * These specs encode the accessibility-ready acceptance criteria from + * neve-pro-addon/a11y.md. Most of them FAIL until remediation lands — + * that is by design; fix until green. + */ +export default defineConfig({ + testDir: './specs/a11y-ready', + reporter: process.env.CI ? 'github' : 'list', + forbidOnly: !!process.env.CI, + workers: process.env.CI ? 6 : undefined, + retries: 0, + timeout: parseInt(process.env.TIMEOUT || '', 10) || 60_000, + fullyParallel: true, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], + use: { + baseURL: process.env.baseURL || 'http://localhost:8080', + headless: true, + ignoreHTTPSErrors: true, + trace: 'retain-on-failure', + }, +}); diff --git a/e2e-tests/specs/a11y-ready/README.md b/e2e-tests/specs/a11y-ready/README.md new file mode 100644 index 0000000000..b207e67eaf --- /dev/null +++ b/e2e-tests/specs/a11y-ready/README.md @@ -0,0 +1,60 @@ +# Accessibility-ready acceptance suite + +These specs encode the acceptance criteria for the WordPress.org +accessibility-ready re-review (see `neve-pro-addon/a11y.md` for the full +audit and plan, and Trac ticket #285870 for the review). + +**Most of these tests FAIL on purpose until remediation lands.** They +describe the required end state, not the current behavior. Fix until green; +once green, they become the permanent regression gate (a11y.md §12). + +## Running + +The suite has its own config — it runs **logged out** (the admin bar changes +the DOM) and needs no auth setup: + +```bash +# 1. Provision fixtures (idempotent) on the target install: +bash bin/envs/a11y-ready/fixtures.sh /path/to/wp/root + +# 2. Run the suite against it: +baseURL=http://your-site.test yarn test:a11y + +# A single file: +baseURL=http://your-site.test yarn test:a11y submenu-keyboard +``` + +In CI the `a11y-ready` env (`bin/envs/a11y-ready/start.sh`) provisions +sample data plus these fixtures, and the suite runs from the +`playwright.yml` matrix entry (`specs: a11y-ready`, `envs: a11y-ready`, +executed via `yarn test:a11y`). The whole remediation — fixes plus this +suite — lands on `development` in one go once everything is green, so the +matrix entry gates from the first merged commit without reddening interim +PRs. + +## Fixtures (bin/envs/a11y-ready/fixtures.sh) + +| Fixture | Used by | +|---|---| +| Menu "A11y Test" on the primary location, two parents with children | submenu-keyboard | +| Post `a11y-comment-test` (open comments, one approved comment, inline content link) | focus-visibility, forms, links, axe | +| Page `a11y-pattern-test` containing every Neve block pattern | patterns, axe | +| Category `a11y-cat` with posts; `posts_per_page=3` for pagination | structure, links, axe | + +## Spec map + +| File | a11y.md | Review criterion | +|---|---|---| +| submenu-keyboard.spec.ts | §5.3 | 4 — Controls (names, roles, states) + the NVDA activation bug | +| focus-visibility.spec.ts | §5.2/§5.8 | 3 — Keyboard navigation (visible focus, tab order, stray tabindex) | +| structure.spec.ts | §5.1/§5.5 | 2 — Landmarks, 6 — Headings, duplicate IDs (neve#4557) | +| forms.spec.ts | §5.4 | 5 — Labelled form fields | +| links.spec.ts | §5.6/§5.7 | 7 — Underlined links, 8 — Ambiguous link text | +| patterns.spec.ts | §6 | Pattern source lint + rendered checks | +| axe.spec.ts | §12.1 | Automated WCAG A/AA sweep incl. 9 — Contrast | +| motion-context-window.spec.ts | §8 | 1 — Skip link, 11 — Reduced motion, 14 — New windows | + +Not covered here (manual, §8 of a11y.md): reflow/zoom at 200–400%, text +spacing, screen-reader passes with NVDA/VoiceOver, and criterion 18 +(recommended plugins). The Pro mega menu gets its own suite in the +neve-pro-addon repo once its JS module exists (a11y.md §7.1). diff --git a/e2e-tests/specs/a11y-ready/a11y-utils.ts b/e2e-tests/specs/a11y-ready/a11y-utils.ts new file mode 100644 index 0000000000..87862bf3e1 --- /dev/null +++ b/e2e-tests/specs/a11y-ready/a11y-utils.ts @@ -0,0 +1,142 @@ +import { expect, Locator, Page, APIRequestContext } from '@playwright/test'; + +/** + * Shared helpers for the accessibility-ready acceptance suite. + * Criteria references point to neve-pro-addon/a11y.md. + */ + +/** + * Resolve a fixture permalink through the public REST API so the specs do + * not depend on the environment's permalink structure. + */ +export async function getPermalink( + request: APIRequestContext, + type: 'posts' | 'pages', + slug: string +): Promise { + const base = process.env.baseURL || 'http://localhost:8080'; + const res = await request.get( + `${base}/wp-json/wp/v2/${type}?slug=${slug}&_fields=link` + ); + const items = res.ok() ? await res.json() : []; + if (!Array.isArray(items) || items.length === 0) { + throw new Error( + `Fixture "${slug}" (${type}) not found. Run: bash bin/envs/a11y-ready/fixtures.sh ` + ); + } + return items[0].link; +} + +/** Resolve a category archive link by slug. */ +export async function getCategoryLink( + request: APIRequestContext, + slug: string +): Promise { + const base = process.env.baseURL || 'http://localhost:8080'; + const res = await request.get( + `${base}/wp-json/wp/v2/categories?slug=${slug}&_fields=link` + ); + const items = res.ok() ? await res.json() : []; + if (!Array.isArray(items) || items.length === 0) { + throw new Error( + `Fixture category "${slug}" not found. Run: bash bin/envs/a11y-ready/fixtures.sh ` + ); + } + return items[0].link; +} + +/** + * Approximate accessible name for every element matching `selector`. + * Good enough for uniqueness and "starts with visible text" checks on + * this theme's markup (aria-label > aria-labelledby > text > img alt > title). + */ +export async function collectAccessibleNames( + page: Page, + selector: string +): Promise { + return page.$$eval(selector, (els) => + els.map((el) => { + const label = el.getAttribute('aria-label'); + if (label) return label.trim(); + const labelledBy = el.getAttribute('aria-labelledby'); + if (labelledBy) { + return labelledBy + .split(/\s+/) + .map((id) => document.getElementById(id)?.textContent || '') + .join(' ') + .trim(); + } + const text = (el.textContent || '').trim(); + if (text) return text; + const imgAlt = el.querySelector('img[alt]'); + if (imgAlt) return (imgAlt.getAttribute('alt') || '').trim(); + return (el.getAttribute('title') || '').trim(); + }) + ); +} + +/** + * §5.8 / review row "Visible focus outline is browser default or 2px solid + * with 3:1 contrast". Focuses the element and asserts a real focus + * indicator: a browser-default ring ('auto'), an outline of >= 2px, or a + * box-shadow that appears on focus. + */ +export async function expectVisibleFocusIndicator( + page: Page, + target: Locator, + label: string +) { + const before = await target.evaluate((el) => { + const cs = getComputedStyle(el); + return { boxShadow: cs.boxShadow, outlineStyle: cs.outlineStyle }; + }); + await target.focus(); + await expect(target, `${label}: element must be focusable`).toBeFocused(); + const after = await target.evaluate((el) => { + const cs = getComputedStyle(el); + return { + outlineStyle: cs.outlineStyle, + outlineWidth: parseFloat(cs.outlineWidth || '0'), + boxShadow: cs.boxShadow, + }; + }); + const hasOutline = + after.outlineStyle === 'auto' || + (after.outlineStyle !== 'none' && after.outlineWidth >= 2); + const hasShadow = + after.boxShadow !== 'none' && after.boxShadow !== before.boxShadow; + expect( + hasOutline || hasShadow, + `${label}: focused element must show a visible focus indicator ` + + `(browser default or >=2px outline, or a focus box-shadow). ` + + `Got outline: ${after.outlineStyle} ${after.outlineWidth}px, ` + + `box-shadow: ${after.boxShadow}` + ).toBeTruthy(); +} + +/** Collect duplicate id attributes on the page. */ +export async function findDuplicateIds(page: Page): Promise { + return page.evaluate(() => { + const seen = new Map(); + document.querySelectorAll('[id]').forEach((el) => { + const id = el.id; + if (!id) return; + seen.set(id, (seen.get(id) || 0) + 1); + }); + return [...seen.entries()] + .filter(([, count]) => count > 1) + .map(([id, count]) => `#${id} x${count}`); + }); +} + +/** First visible submenu toggle in the desktop primary navigation. */ +export function desktopSubmenuToggles(page: Page): Locator { + return page.locator('.header--row .caret-wrap:visible'); +} + +/** The dropdown list a toggle controls (nearest ancestor li's sub-menu). */ +export function submenuFor(toggle: Locator): Locator { + return toggle + .locator('xpath=ancestor::li[1]//ul[contains(@class, "sub-menu")]') + .first(); +} diff --git a/e2e-tests/specs/a11y-ready/axe.spec.ts b/e2e-tests/specs/a11y-ready/axe.spec.ts new file mode 100644 index 0000000000..db1b168fc7 --- /dev/null +++ b/e2e-tests/specs/a11y-ready/axe.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import { getCategoryLink, getPermalink } from './a11y-utils'; + +/** + * Automated axe-core sweep — a11y.md §12.1 gate 1. + * + * Runs the WCAG A/AA rule set on the same page types the WordPress.org + * reviewer tested. Catches contrast, missing labels, duplicate IDs, + * landmark and alt-text violations mechanically. + * + * Baseline target after remediation: zero violations. + */ + +const TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']; + +function formatViolations(violations: any[]): string { + return violations + .map( + (v) => + `[${v.impact}] ${v.id}: ${v.help} (${v.nodes.length} nodes)\n` + + v.nodes + .slice(0, 3) + .map((n: any) => ` ${n.html.substring(0, 120)}`) + .join('\n') + ) + .join('\n'); +} + +async function runAxe(page) { + const results = await new AxeBuilder({ page }).withTags(TAGS).analyze(); + expect( + results.violations, + `axe violations:\n${formatViolations(results.violations)}` + ).toHaveLength(0); +} + +test('axe: front page', async ({ page }) => { + await page.goto('/'); + await runAxe(page); +}); + +test('axe: fixture post with comments', async ({ page, request }) => { + await page.goto(await getPermalink(request, 'posts', 'a11y-comment-test')); + await runAxe(page); +}); + +test('axe: category archive', async ({ page, request }) => { + await page.goto(await getCategoryLink(request, 'a11y-cat')); + await runAxe(page); +}); + +test('axe: block patterns page', async ({ page, request }) => { + await page.goto(await getPermalink(request, 'pages', 'a11y-pattern-test')); + await runAxe(page); +}); + +test('axe: search results page', async ({ page }) => { + await page.goto('/?s=fixture'); + await runAxe(page); +}); + +test('axe: 404 page', async ({ page }) => { + await page.goto('/a11y-this-page-does-not-exist/'); + await runAxe(page); +}); + +test('axe: front page at mobile viewport', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + await runAxe(page); +}); diff --git a/e2e-tests/specs/a11y-ready/focus-visibility.spec.ts b/e2e-tests/specs/a11y-ready/focus-visibility.spec.ts new file mode 100644 index 0000000000..f2baf6cd60 --- /dev/null +++ b/e2e-tests/specs/a11y-ready/focus-visibility.spec.ts @@ -0,0 +1,99 @@ +import { test, expect } from '@playwright/test'; +import { expectVisibleFocusIndicator, getPermalink } from './a11y-utils'; + +/** + * Criterion 3: Keyboard Navigation Support — a11y.md §5.2 / §5.8. + * + * Review rows failed on 7 of 8 pages: "All interactive elements have a + * visible focus outline" and "Visible focus outline is browser default or + * 2px solid with 3:1 contrast". The Post Comment button had a computed + * outline of none (verified live 2026-08-18). + */ + +test('Post Comment submit button has a visible focus indicator', async ({ + page, + request, +}) => { + const url = await getPermalink(request, 'posts', 'a11y-comment-test'); + await page.goto(url); + const submit = page.locator('#commentform [type=submit], #commentform #submit').first(); + await submit.scrollIntoViewIfNeeded(); + await expectVisibleFocusIndicator(page, submit, 'Post Comment button'); +}); + +test('comment form fields have a visible focus indicator', async ({ + page, + request, +}) => { + const url = await getPermalink(request, 'posts', 'a11y-comment-test'); + await page.goto(url); + for (const selector of ['#comment', '#author', '#email']) { + const field = page.locator(selector); + await field.scrollIntoViewIfNeeded(); + await expectVisibleFocusIndicator(page, field, `comment field ${selector}`); + } +}); + +test('search form controls have a visible focus indicator (404 page)', async ({ + page, +}) => { + await page.goto('/a11y-this-page-does-not-exist/'); + const input = page.locator('.search-form input[type=search]').first(); + const button = page.locator('.search-form [type=submit], .search-form button').first(); + await expectVisibleFocusIndicator(page, input, 'search input'); + await expectVisibleFocusIndicator(page, button, 'search submit button'); +}); + +test('header navigation links have a visible focus indicator', async ({ + page, +}) => { + await page.goto('/'); + const navLink = page.locator('.header--row .nav-ul a:visible').first(); + await expectVisibleFocusIndicator(page, navLink, 'primary nav link'); +}); + +test('no non-interactive element is focusable (stray tabindex)', async ({ + page, +}) => { + // Review: '
' on + // every archive item. Source: inc/views/partials/excerpt.php:51. + await page.goto('/'); + const strays = await page.$$eval( + '[tabindex]:not(a):not(button):not(input):not(select):not(textarea):not(iframe):not([role=button]):not([role=link]):not([role=menuitem]):not([contenteditable=true])', + (els) => + els + .filter((el) => parseInt(el.getAttribute('tabindex') || '-1', 10) >= 0) + .map( + (el) => + `<${el.tagName.toLowerCase()} class="${el.className}" tabindex="${el.getAttribute('tabindex')}">` + ) + ); + expect( + strays, + `non-interactive elements must not be in the tab order: ${strays.join(', ')}` + ).toHaveLength(0); +}); + +test('scroll-to-top button comes after the footer in the tab order', async ({ + page, +}) => { + // Review row "Tab order matches visual order" failed on 7 of 8 pages + // because the floating scroll-to-top is placed before the footer in + // the source (footer.php:27 vs :51). + await page.goto('/'); + const stt = page.locator('#scroll-to-top'); + test.skip((await stt.count()) === 0, 'scroll-to-top is disabled on this environment'); + const footerPrecedes = await page.evaluate(() => { + const button = document.getElementById('scroll-to-top'); + const footer = document.getElementById('site-footer'); + if (!button || !footer) return null; + return Boolean( + // eslint-disable-next-line no-bitwise + footer.compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING + ); + }); + expect( + footerPrecedes, + '#scroll-to-top must follow #site-footer in source order so footer links are tabbed first' + ).toBe(true); +}); diff --git a/e2e-tests/specs/a11y-ready/forms.spec.ts b/e2e-tests/specs/a11y-ready/forms.spec.ts new file mode 100644 index 0000000000..c312395203 --- /dev/null +++ b/e2e-tests/specs/a11y-ready/forms.spec.ts @@ -0,0 +1,90 @@ +import { test, expect } from '@playwright/test'; +import { getPermalink } from './a11y-utils'; + +/** + * Criterion 5: Labelled Form Fields — a11y.md §5.4. + * + * Review failures: search form on the search-results page and on the 404 + * page has no visible, persistent label (hidden as screen-reader-text). + * Also: the
diff --git a/header-footer-grid/templates/row-wrapper-mobile.php b/header-footer-grid/templates/row-wrapper-mobile.php index a83969599c..2ee4f8a35d 100644 --- a/header-footer-grid/templates/row-wrapper-mobile.php +++ b/header-footer-grid/templates/row-wrapper-mobile.php @@ -42,8 +42,7 @@ ?>
'; $search .= '