diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index f88235b7db..133e976917 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" - specs: "editor" @@ -147,7 +149,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/accessibility.txt b/accessibility.txt new file mode 100644 index 0000000000..ee44cf217b --- /dev/null +++ b/accessibility.txt @@ -0,0 +1,82 @@ +# Accessibility Statement + +Neve aims to be usable by everyone, regardless of ability or the +technology used to browse the web. The theme targets the WordPress.org +accessibility-ready guidelines and the Web Content Accessibility +Guidelines (WCAG) 2.2 at level AA for everything the theme renders: +navigation, headers and footers built with the header/footer builder, +forms, archives, comments, and WooCommerce storefront templates. + +Accessibility is treated as a shipping requirement, not an add-on: an +automated accessibility test suite gates every change in continuous +integration, and interactive components (menus, dialogs, disclosures) +are built on native elements with the appropriate ARIA states. + +# Quick Information + +Accessibility audit completed: Yes +Most recent audit date: 2026-08-25 +Tested to standard and conformance level: WCAG 2.2 AA (partially supports) +Audited by: Themeisle (internal), with an independent WordPress.org +accessibility-ready review in progress (Themes Trac #285870) + +# Testing Tools and Methodology + +- Automated: an axe-core scan (WCAG 2.0/2.1/2.2 A and AA rule sets) + and a Playwright end-to-end accessibility suite run in continuous + integration on every change, covering keyboard operation of menus and + disclosures, focus visibility and order, landmark and heading + structure, form labeling, and duplicate-id regressions across the + page types a reviewer tests (front page, posts, archives, search, + 404, shop). +- Manual: keyboard-only passes; reflow checks at 320 px-equivalent + (400% zoom) and 200% zoom; WCAG 1.4.12 text-spacing overrides; + operating-system reduced-motion verification; contrast checks of the + default palette. +- Assistive technology: VoiceOver (macOS) and NVDA (Windows) passes on + navigation, forms, and commerce flows before accessibility-affecting + releases. + +# Screen Reader Text Class + +The theme ships the standard WordPress `.screen-reader-text` class for +content that should be announced by assistive technology without being +painted on screen. It uses the WordPress core clip pattern +(`clip-path: inset(50%)` with `word-wrap: normal`), and focusable +elements carrying the class become visible when they receive keyboard +focus. Theme and child-theme authors can add the class to any element +that needs a spoken-only label. + +# Accessibility Features + +- A "Skip to content" link is the first focusable element and moves + focus to the main content area. +- All menus are fully keyboard operable: submenu toggles are native + buttons with `aria-expanded` and unique accessible names, submenus + open with Enter/Space and close with Escape (focus returns to the + toggle), and hover-opened submenus can also be dismissed with Escape + without moving the pointer. +- Focus is always visible: the theme never suppresses the focus + indicator, and interactive controls keep a minimum 24×24 px target. +- Landmarks (banner, navigation, main, contentinfo) are unique and + labeled; repeated navigation landmarks carry distinguishing names. +- Default colors meet WCAG AA contrast, links in content are + underlined (not distinguished by color alone), and every page type + provides a top-level heading. +- Users who set "reduce motion" in their operating system get instant + transitions instead of sliding panels, animated submenus, or smooth + scrolling. +- The mobile menu and modal surfaces trap focus while open and close + with Escape. + +# Accessibility Help Contact + +For help using the theme with assistive technology, post in the Neve +support forum: https://wordpress.org/support/theme/neve/ — or contact +Themeisle support: https://themeisle.com/contact/ + +# Where to Report Issues + +Accessibility problems can be reported (and tracked) on the theme's +GitHub repository: https://github.com/Codeinwp/neve/issues — or in the +support forum above. Reports are triaged like functional bugs. diff --git a/assets/js/src/frontend/navigation.js b/assets/js/src/frontend/navigation.js index 7d4671c807..e7f9f3116b 100644 --- a/assets/js/src/frontend/navigation.js +++ b/assets/js/src/frontend/navigation.js @@ -95,20 +95,129 @@ function handleScrollLinks() { } /** - * Handle dropdowns on mobile devices. + * Handle submenu dropdown toggles (desktop and mobile). + * + * The toggles are native buttons, so click covers mouse, Enter, Space and + * assistive-technology activation with a single code path and a single + * open state (`dropdown-open`), mirrored to aria-expanded. */ +let openCaretCount = 0; + function handleMobileDropdowns() { - const carets = document.querySelectorAll('.caret-wrap'); - addEvent(carets, 'click', openCarrets); + // Per-element guard: re-inits (e.g. customizer partial refreshes) must + // bind new carets without stacking listeners on surviving ones. + document + .querySelectorAll('.caret-wrap:not([data-nv-bound])') + .forEach((caret) => { + caret.dataset.nvBound = '1'; + caret.addEventListener('click', (e) => toggleCaret(e, caret)); + }); + // Sidebar carets can render pre-expanded (neve_first_level_expanded). + openCaretCount = openCarets().length; + // Document-level guard is on
so a second bundle (customizer + // preview) cannot double-register the handlers below. + if (document.body.dataset.nvCaretKeys) { + return; + } + document.body.dataset.nvCaretKeys = '1'; + // Escape closes the open submenu and returns focus to its toggle. + // stopImmediatePropagation keeps the sidebar focus trap (also a + // document keydown listener) from closing the whole menu on the same + // press; the next Escape reaches it. + document.addEventListener('keydown', (event) => { + if (event.key !== 'Escape' || openCaretCount === 0) { + return; + } + const openCaret = openCarets().find((caret) => + caret.closest('li').contains(event.target) + ); + if (!openCaret) { + return; + } + event.preventDefault(); + event.stopImmediatePropagation(); + setCaretState(openCaret, false); + openCaret.focus(); + }); + // WCAG 1.4.13: submenus revealed by pure CSS :hover must also be + // dismissable without moving the pointer. Escape sets a body class + // the stylesheet uses to hide :hover submenus; the pointer leaving + // the hovered item re-arms hover for the next one. + document.addEventListener('keydown', (event) => { + if (event.key !== 'Escape') { + return; + } + const hovered = document.querySelector('.menu-item-has-children:hover'); + if (!hovered) { + return; + } + document.body.classList.add('nv-hover-off'); + hovered.addEventListener( + 'mouseleave', + () => document.body.classList.remove('nv-hover-off'), + { once: true } + ); + }); + // Close a desktop submenu when keyboard focus leaves its menu item. + // Sidebar toggles (.navbar-toggle) are exempt to keep the sidebar's + // tap-to-toggle behavior and the neve_first_level_expanded default. + document.addEventListener('focusout', (event) => { + if (openCaretCount === 0) { + return; + } + openCarets().forEach((caret) => { + if ( + !caret.classList.contains('navbar-toggle') && + !caret.closest('li').contains(event.relatedTarget) + ) { + setCaretState(caret, false); + } + }); + }); } -function openCarrets(e, caret) { +function openCarets() { + return [...document.querySelectorAll(`.caret-wrap.${strings[0]}`)]; +} + +function toggleCaret(e, caret) { e.preventDefault(); e.stopPropagation(); + const open = !caret.classList.contains(strings[0]); + setCaretState(caret, open); + if (open) { + createNavOverlay( + document.querySelectorAll(`.${strings[0]}`), + strings[0] + ); + } +} + +function setCaretState(caret, open) { + if (caret.classList.contains(strings[0]) === open) { + return; + } + openCaretCount += open ? 1 : -1; const subMenu = caret.parentNode.parentNode.querySelector('.sub-menu'); - toggleClass(caret, strings[0]); - toggleClass(subMenu, strings[0]); - createNavOverlay(document.querySelectorAll(`.${strings[0]}`), strings[0]); + const applyClass = open ? addClass : removeClass; + applyClass(caret, strings[0]); + if (subMenu !== null) { + applyClass(subMenu, strings[0]); + } + caret.setAttribute('aria-expanded', open ? 'true' : 'false'); + if (!open && openCaretCount === 0) { + removeNavOverlay(); + } +} + +/** + * Remove the click-away overlay if present. + */ +function removeNavOverlay() { + const overlay = document.querySelector(`.${strings[2]}`); + if (overlay !== null) { + overlay.parentNode.removeChild(overlay); + } } /** @@ -177,7 +286,14 @@ function startFocusTrap(event) { if (escKey) { event.preventDefault(); focusTrapDetails.backFocus.focus(); - window.HFG.toggleMenuSidebar(false); + // Containers other than the menu sidebar (header search) pass + // their own close routine; closing the sidebar would leave them + // open. + if (typeof focusTrapDetails.onClose === 'function') { + focusTrapDetails.onClose(); + } else { + window.HFG.toggleMenuSidebar(false); + } document.dispatchEvent(new CustomEvent(NV_FOCUS_TRAP_END)); } if (!shiftKey && tabKey && lastEl === activeEl) { @@ -201,10 +317,25 @@ function handleSearch() { const navSearch = doc.querySelectorAll('.nv-nav-search') || [], navItem = doc.querySelectorAll('.menu-item-nav-search') || [], close = doc.querySelectorAll('.close-responsive-search') || []; + syncSearchAria(); + const closeSearch = () => { + removeClass(navItem, strings[1]); + syncSearchAria(); + removeNavOverlay(); + doc.dispatchEvent(new CustomEvent(NV_FOCUS_TRAP_END)); + }; addEvent(navItem, 'click', (e, searchItem) => { e.preventDefault(); e.stopPropagation(); toggleClass(searchItem, strings[1]); + syncSearchAria(); + if (!searchItem.classList.contains(strings[1])) { + // Second activation of the trigger closes the panel: end the + // trap too, or a stale trap keeps eating Tab and Escape. + removeNavOverlay(); + doc.dispatchEvent(new CustomEvent(NV_FOCUS_TRAP_END)); + return; + } createNavOverlay(searchItem, strings[1]); doc.dispatchEvent( new CustomEvent(NV_FOCUS_TRAP_START, { @@ -212,7 +343,14 @@ function handleSearch() { container: searchItem.querySelector('.nv-nav-search'), close: '.close-responsive-search', firstFocus: '.search-field', - backFocus: searchItem, + // Escape focuses backFocus: must be the trigger + // button — the wrapper div is not focusable and + // would drop focus to . + backFocus: + searchItem.querySelector( + '.nv-search,.nv-nav-search-icon' + ) || searchItem, + onClose: closeSearch, }, }) ); @@ -222,12 +360,28 @@ function handleSearch() { }); addEvent(close, 'click', (e) => { e.preventDefault(); - removeClass(navItem, strings[1]); - const overlay = doc.querySelector(`.${strings[2]}`); - if (overlay === null) { - return; + const item = e.target.closest('.menu-item-nav-search'); + closeSearch(); + const trigger = + item && item.querySelector('.nv-search,.nv-nav-search-icon'); + if (trigger) { + trigger.focus(); + } + }); +} + +/** + * Mirror the search dropdown open state onto its trigger button. + */ +function syncSearchAria() { + document.querySelectorAll('.menu-item-nav-search').forEach((item) => { + const trigger = item.querySelector('.nv-search,.nv-nav-search-icon'); + if (trigger) { + trigger.setAttribute( + 'aria-expanded', + String(item.classList.contains(strings[1])) + ); } - overlay.parentNode.removeChild(overlay); }); } @@ -343,7 +497,14 @@ function createNavOverlay(item, classToRemove) { primaryNav.parentNode.insertBefore(navClickaway, primaryNav); navClickaway.addEventListener('click', () => { + // setCaretState owns class + aria + count for toggles; removeClass + // covers the non-caret users of the overlay (header search). + openCarets().forEach((caret) => setCaretState(caret, false)); removeClass(item, classToRemove); - navClickaway.parentNode.removeChild(navClickaway); + syncSearchAria(); + removeNavOverlay(); + // The search panel may have an active focus trap; a no-op when + // none is running. + document.dispatchEvent(new CustomEvent(NV_FOCUS_TRAP_END)); }); } diff --git a/assets/js/src/scroll-to-top.js b/assets/js/src/scroll-to-top.js index 3672abe1bd..6c2f8a962e 100644 --- a/assets/js/src/scroll-to-top.js +++ b/assets/js/src/scroll-to-top.js @@ -17,9 +17,14 @@ function scrollTopSafe(to) { } function runScroll() { + const reducedMotion = window.matchMedia( + '(prefers-reduced-motion: reduce)' + ).matches; const smoothScrollFeature = 'scrollBehavior' in document.documentElement.style; - if (!smoothScrollFeature) { + if (reducedMotion) { + window.scrollTo(0, 0); + } else if (!smoothScrollFeature) { scrollTopSafe(0); } else { window.scrollTo({ @@ -31,7 +36,9 @@ function runScroll() { const scrollButton = document.getElementById('scroll-to-top'); if (content) { scrollButton.blur(); - content.focus(); + // preventScroll: #content became focusable (skip-link target) and a + // plain focus() cancels the smooth scroll above mid-flight. + content.focus({ preventScroll: true }); } } function scrollToTop() { @@ -44,12 +51,6 @@ function scrollToTop() { runScroll(); }); - element.addEventListener('keydown', function (event) { - if (event.key === 'Enter') { - runScroll(); - } - }); - window.addEventListener('scroll', function () { const yScrollPos = window.scrollY; const offset = neveScrollOffset.offset; diff --git a/assets/scss/components/compat/woocommerce/_breadcrumbs.scss b/assets/scss/components/compat/woocommerce/_breadcrumbs.scss index 347c9fc45e..8ccbb0610f 100644 --- a/assets/scss/components/compat/woocommerce/_breadcrumbs.scss +++ b/assets/scss/components/compat/woocommerce/_breadcrumbs.scss @@ -14,6 +14,9 @@ a { color: var(--nv-secondary-accent); + // Color alone can't distinguish these links from the crumb text + // (owner palettes rarely reach the 3:1 link-vs-text contrast). + text-decoration: underline; } .nv-breadcrumb-delimiter { diff --git a/assets/scss/components/compat/woocommerce/_checkout.scss b/assets/scss/components/compat/woocommerce/_checkout.scss index af210cff56..fd968d5947 100644 --- a/assets/scss/components/compat/woocommerce/_checkout.scss +++ b/assets/scss/components/compat/woocommerce/_checkout.scss @@ -174,7 +174,6 @@ .select2-container { &.select2-container--open { - outline: 0; box-shadow: 0 0 3px 0 var(--nv-secondary-accent); --formfieldbordercolor: var(--nv-secondary-accent); } diff --git a/assets/scss/components/compat/woocommerce/_nav-cart.scss b/assets/scss/components/compat/woocommerce/_nav-cart.scss index aa95139f03..10402202da 100644 --- a/assets/scss/components/compat/woocommerce/_nav-cart.scss +++ b/assets/scss/components/compat/woocommerce/_nav-cart.scss @@ -165,21 +165,22 @@ $cart-width: 360px; } } -// Mobile: tapping the cart icon toggles `cart-dropdown-open` (see -// navigation.js). The dropdown appearance is already defined above, so we only -// need to reveal it here — reusing the same element and styles as the desktop. +// `cart-dropdown-open` reveals the dropdown at every width: mobile taps +// toggle it (see navigation.js) and the pro booster's auto-expand adds it +// after an AJAX add — the base .nv-nav-cart is display:none, so inline +// visibility/opacity alone can never reveal it. +.responsive-nav-cart.dropdown.cart-dropdown-open .nv-nav-cart { + display: block; + opacity: 1; + visibility: visible; +} + @media (max-width: 959px) { .responsive-nav-cart.dropdown .nv-nav-cart:not(.cart-off-canvas) { width: min(#{$cart-width}, calc(100vw - #{2 * $spacing-md})); max-width: calc(100vw - #{2 * $spacing-md}); } - - .responsive-nav-cart.dropdown.cart-dropdown-open .nv-nav-cart { - display: block; - opacity: 1; - visibility: visible; - } } @mixin nav-cart--laptop() { @@ -193,7 +194,10 @@ $cart-width: 360px; &:hover, &:focus-within { - .nv-nav-cart { + // The off-canvas drawer is a dialog: it must open on + // activation only, never because the icon received focus + // (WCAG 3.2.1) — its open state is .cart-open. + .nv-nav-cart:not(.cart-off-canvas) { opacity: 1; visibility: visible; } diff --git a/assets/scss/components/compat/woocommerce/_sidebar.scss b/assets/scss/components/compat/woocommerce/_sidebar.scss index 4249c25ce4..79f2823997 100644 --- a/assets/scss/components/compat/woocommerce/_sidebar.scss +++ b/assets/scss/components/compat/woocommerce/_sidebar.scss @@ -13,6 +13,11 @@ background-color: var(--nv-site-bg); transform: translateX(-100%); + // Full-height slide-in — snap for reduced-motion users. + @media (prefers-reduced-motion: reduce) { + transition: none; + } + &.sidebar-open { transform: translateX(0); } diff --git a/assets/scss/components/elements/_form-elements.scss b/assets/scss/components/elements/_form-elements.scss index ac70d5f33d..da9cc9b1f2 100644 --- a/assets/scss/components/elements/_form-elements.scss +++ b/assets/scss/components/elements/_form-elements.scss @@ -7,19 +7,32 @@ margin: 0 !important; } +// Forms with a visible, persistent label (404 / no-results pages): the +// label sits on its own line above the field. +.search-form.nv-search-labeled { + flex-wrap: wrap; + + label { + flex-basis: 100%; + margin-bottom: 8px; + } +} + .search-form { display: flex; max-width: 100%; line-height: 1; --primarybtnbg: var(--formfieldbgcolor); --primarybtnhoverbg: var(--formfieldbgcolor); - --primarybtncolor: var(--formfieldbordercolor); - --primarybtnhovercolor: var(--formfieldbordercolor); + // The button label must meet text contrast; the border color is a + // non-text color and was near-invisible as text. + --primarybtncolor: var(--formfieldcolor); + --primarybtnhovercolor: var(--formfieldcolor); svg { fill: var(--formfieldcolor); width: var(--formfieldfontsize); - opacity: 0.5; + opacity: 0.7; height: auto; } diff --git a/assets/scss/components/elements/_mega-menu.scss b/assets/scss/components/elements/_mega-menu.scss index 2958b4caf8..7ec599042d 100644 --- a/assets/scss/components/elements/_mega-menu.scss +++ b/assets/scss/components/elements/_mega-menu.scss @@ -76,15 +76,15 @@ } } - .neve-mega-menu:hover, - .neve-mega-menu:focus { - - > .sub-menu { - display: flex; - opacity: 1; - visibility: visible; - pointer-events: all; - } + // .dropdown-open is the JS/assistive-tech open state (the toggle + // button). No :focus-within here: it would keep the panel painted + // after Escape/toggle-close while focus is still on the toggle. + .neve-mega-menu:hover > .sub-menu, + .neve-mega-menu > .sub-menu.dropdown-open { + display: flex; + opacity: 1; + visibility: visible; + pointer-events: all; } .neve-mega-menu { @@ -92,7 +92,10 @@ } .neve-mega-menu .neve-mm-col > .sub-menu { - visibility: visible; + // inherit, not visible: the column lists must follow the + // panel's hidden state or their links stay tabbable (and + // focusable off-screen) while the mega menu is closed. + visibility: inherit; position: relative; left: initial; right: initial; diff --git a/assets/scss/components/elements/blog/_pagination.scss b/assets/scss/components/elements/blog/_pagination.scss index 14a9bf7f45..fcb2acaae8 100644 --- a/assets/scss/components/elements/blog/_pagination.scss +++ b/assets/scss/components/elements/blog/_pagination.scss @@ -42,4 +42,11 @@ ul.page-numbers { background: var(--nv-primary-accent); color: var(--nv-text-dark-bg); } + + // Hover/focus need a visible, non-color-only state. + a:hover, + a:focus { + text-decoration: underline; + color: var(--nv-secondary-accent); + } } diff --git a/assets/scss/components/elements/form-elements/_inputs.scss b/assets/scss/components/elements/form-elements/_inputs.scss index 24d62c7875..ba13df6286 100644 --- a/assets/scss/components/elements/form-elements/_inputs.scss +++ b/assets/scss/components/elements/form-elements/_inputs.scss @@ -20,17 +20,14 @@ textarea { -webkit-appearance: none; -moz-appearance: none; appearance: none; - outline: none; resize: vertical; } input:read-write, select, -textarea, -[tabindex="-1"] { +textarea { &:focus { - outline: 0; box-shadow: 0 0 3px 0 var(--nv-secondary-accent); --formfieldbordercolor: var(--nv-secondary-accent); } @@ -46,7 +43,9 @@ button { ::placeholder { color: inherit; - opacity: 0.5; + // 0.7 of the default text color blends to ~5.6:1 on the site + // background (0.5 blended to 3.1:1, below the 4.5:1 text minimum). + opacity: 0.7; } select { diff --git a/assets/scss/components/elements/navigation/_nav-menu.scss b/assets/scss/components/elements/navigation/_nav-menu.scss index d4e9336ba5..e5d590c8e2 100644 --- a/assets/scss/components/elements/navigation/_nav-menu.scss +++ b/assets/scss/components/elements/navigation/_nav-menu.scss @@ -5,8 +5,11 @@ visibility: visible; } +// Scoped to the properties that actually animate (icon flip, hover color); +// a bare `transition: 0.3s ease` means `all` and would animate the focus +// outline in from 0, hiding it for the first frames. .caret { - transition: 0.3s ease; + transition: transform 0.3s ease, color 0.3s ease; } .dd-title { @@ -51,9 +54,12 @@ display: block; position: relative; + // position/padding were previously inline CSS from Nav_Walker. > .wrap { display: flex; align-items: center; + position: relative; + padding: 0 4px; } &.nv-active > .wrap { @@ -110,9 +116,41 @@ } } +// Explicit rule (not @extend, which hoists the selector to the top of the +// file) placed after .nav-ul so the open state wins over the base +// `.nav-ul .sub-menu` hidden state on source order. This is the single +// open state used by mouse, keyboard and assistive tech. .sub-menu.dropdown-open { + opacity: 1; + visibility: visible; +} + +// The submenu toggles are native buttons; strip UA button styling so they +// render exactly like the previous inline element. The 24px minimum is +// the WCAG 2.5.8 touch-target size — the walker's inline negative margins +// cancel it out visually. Sidebar carets override this with their own +// larger padding/margin pair. +.nav-ul button.caret-wrap { + background: none; + border: 0; + padding: 0; + color: inherit; + font: inherit; + cursor: pointer; + min-width: 24px; + min-height: 24px; + justify-content: center; + align-items: center; + margin-top: -8px; + margin-bottom: -8px; + // The links are position: relative and would otherwise paint over the + // 3px the button overlaps them by, obscuring part of the touch target. + position: relative; +} - @extend %show-dropdown; +// Previously inline CSS from Nav_Walker::get_accessibility_style(). +.nav-ul:not(.menu-mobile):not(.neve-mega-menu) > li > .wrap > a { + padding-top: 1px; } // === Inside Sidebar === // diff --git a/assets/scss/components/elements/navigation/_nav-search.scss b/assets/scss/components/elements/navigation/_nav-search.scss index 47ce51cb6a..620fe8ffe8 100644 --- a/assets/scss/components/elements/navigation/_nav-search.scss +++ b/assets/scss/components/elements/navigation/_nav-search.scss @@ -25,9 +25,19 @@ } } +// The triggers are native buttons now; strip UA button styling. +button.nv-search, +button.nv-nav-search-icon { + background: none; + border: 0; + padding: 0; + color: inherit; + font: inherit; + cursor: pointer; +} + .menu-item-nav-search { cursor: pointer; - outline: 0; .nv-icon:hover { color: var(--hovercolor); diff --git a/assets/scss/components/elements/navigation/_nav-toggle.scss b/assets/scss/components/elements/navigation/_nav-toggle.scss index 1127c95342..b291e4d863 100644 --- a/assets/scss/components/elements/navigation/_nav-toggle.scss +++ b/assets/scss/components/elements/navigation/_nav-toggle.scss @@ -18,10 +18,6 @@ box-shadow: none; display: flex; align-items: center; - - &:focus { - outline: 1px solid; - } } .icon-bar { diff --git a/assets/scss/components/hfg/frontend/layout/_footer.scss b/assets/scss/components/hfg/frontend/layout/_footer.scss index d081164e34..aab4597587 100644 --- a/assets/scss/components/hfg/frontend/layout/_footer.scss +++ b/assets/scss/components/hfg/frontend/layout/_footer.scss @@ -2,6 +2,13 @@ position: relative; z-index: 11; + // Links inside the copyright text must not rely on color alone (WCAG 1.4.1). + // .cr is the free-theme credit block; footer_copyright is the Pro component. + .builder-item--footer_copyright a, + .builder-item.cr a { + text-decoration: underline; + } + .item--inner { width: 100%; diff --git a/assets/scss/components/main/_a11y.scss b/assets/scss/components/main/_a11y.scss index f96792f699..813254d920 100644 --- a/assets/scss/components/main/_a11y.scss +++ b/assets/scss/components/main/_a11y.scss @@ -1,3 +1,15 @@ +// Keyboard focus visibility (accessibility-ready criterion 3) relies on +// the browser-default focus ring: the defaults are two-tone so they stay +// visible on any background the customizer can produce. No rule needed — +// what matters is that nothing suppresses the ring (no `outline: none`, +// no `transition: all` animating it in from zero). + +// Programmatic focus targets (e.g. the skip-link destination #content) +// are not interactive; a ring around the whole main area is noise. +[tabindex="-1"]:focus-visible { + outline: none; +} + .show-on-focus { position: absolute; width: 1px; @@ -15,11 +27,56 @@ } } +// Core's clip pattern — the legacy left: -10000px variant can force +// horizontal scroll in RTL and never reveals focusable elements (core +// and plugins put skip links on this class expecting :focus to show). .screen-reader-text { - position: absolute; - left: -10000px; - top: auto; - width: 1px; + border: 0; + clip: rect(1px, 1px, 1px, 1px); + clip-path: inset(50%); height: 1px; + width: 1px; + margin: -1px; overflow: hidden; + padding: 0; + position: absolute !important; + word-wrap: normal !important; + + &:focus { + background-color: var(--nv-site-bg); + clip: auto !important; + clip-path: none; + color: var(--nv-text-color); + display: block; + height: auto; + width: auto; + left: 5px; + top: 5px; + line-height: normal; + padding: $spacing-xs $spacing-sm; + text-decoration: none; + z-index: 100000; + } +} + +// Escape dismisses hover-revealed submenus (WCAG 1.4.13) — navigation.js +// sets the class while the pointer stays on the item. Keyboard-opened +// panels (.dropdown-open) are unaffected; !important outranks the pro +// submenu animations' inline :hover rules too. +.nv-hover-off li:hover > .sub-menu:not(.dropdown-open) { + opacity: 0 !important; + visibility: hidden !important; +} + +// Sliding panels and the page shift they cause are significant motion; +// show/hide instantly for users who ask the OS for reduced motion +// (opacity-only fades elsewhere are left alone). +@media (prefers-reduced-motion: reduce) { + + .wrapper, + .tcb, + .header-menu-sidebar, + .hfg-ov { + transition: none !important; + } } diff --git a/assets/scss/components/main/_typography.scss b/assets/scss/components/main/_typography.scss index 3dffa59a74..4f0f14b12d 100644 --- a/assets/scss/components/main/_typography.scss +++ b/assets/scss/components/main/_typography.scss @@ -41,9 +41,20 @@ a { cursor: pointer; text-decoration: var(--linkdeco); + // Hover pairs the accent shift with a non-color cue: underlined text + // links drop their underline. Focus keeps the underline — the W2 + // focus ring is its visible indicator. + &:hover { + text-decoration: none; + } +} + +// Button-like links manage their own text color per state; forcing the +// accent onto them breaks contrast (e.g. dark accent on a dark button). +a:not([class*="button"]) { + &:hover, &:focus { - opacity: 0.9; color: var(--nv-secondary-accent); } } @@ -58,6 +69,16 @@ a { } } +// Classed links in prose need the same non-color distinction; button-like +// and block-component links keep their own affordance. +.entry-content, +.nv-comment-content { + + a[class]:not([class*="button"]):not([class*="wp-block"]) { + --linkdeco: underline; + } +} + ins { text-decoration: none; } diff --git a/assets/scss/elements/_mega-menu.scss b/assets/scss/elements/_mega-menu.scss index 4ed8277493..d338e8325b 100644 --- a/assets/scss/elements/_mega-menu.scss +++ b/assets/scss/elements/_mega-menu.scss @@ -172,8 +172,10 @@ } } + // li:focus never matches (focus is on the link/toggle inside); the + // keyboard open state is the toggle's .dropdown-open class. .neve-mega-menu:hover > .sub-menu, - .neve-mega-menu:focus > .sub-menu { + .neve-mega-menu > .sub-menu.dropdown-open { opacity: 1; visibility: visible; pointer-events: all; diff --git a/bin/envs/a11y-ready/fixtures.sh b/bin/envs/a11y-ready/fixtures.sh new file mode 100644 index 0000000000..ccbad9f69a --- /dev/null +++ b/bin/envs/a11y-ready/fixtures.sh @@ -0,0 +1,175 @@ +#!/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/?fixture=products" --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/?fixture=company" --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 +# Footer location too: duplicate-id bug (neve#4557) only renders when a +# footer menu exists — without it the landmark/duplicate-id tests are vacuous. +# The footer BUILDER must also contain the footer-menu component, on desktop +# and mobile (two renders of the component = the duplicate-id case). +$WP_CMD menu location assign "$MENU_ID" footer +# Top-bar too, and a header with search + palette-switch + secondary menu on +# top of the defaults — otherwise those components never render and their +# landmark/state/focus coverage is vacuous. +$WP_CMD menu location assign "$MENU_ID" top-bar +# Button component with open-in-new-tab on: the "(opens in a new tab)" +# announcement test is vacuous without a rendered _blank button. +$WP_CMD theme mod set button_base_text_setting 'Fixture Button' +$WP_CMD theme mod set button_base_link_setting "$HOME_URL/?fixture=button" +$WP_CMD theme mod set button_base_new_tab 1 +$WP_CMD theme mod set hfg_header_layout_v2 '{"desktop":{"top":{"left":[{"id":"secondary-menu"}],"c-left":[],"center":[],"c-right":[],"right":[{"id":"button_base"}]},"main":{"left":[{"id":"logo"}],"c-left":[],"center":[],"c-right":[],"right":[{"id":"primary-menu"},{"id":"header_search_responsive"},{"id":"header_palette_switch"}]},"bottom":{"left":[],"c-left":[],"center":[],"c-right":[],"right":[]}},"mobile":{"top":{"left":[],"c-left":[],"center":[],"c-right":[],"right":[]},"main":{"left":[{"id":"logo"}],"c-left":[],"center":[],"c-right":[],"right":[{"id":"nav-icon"},{"id":"header_search_responsive"}]},"bottom":{"left":[],"c-left":[],"center":[],"c-right":[],"right":[]},"sidebar":[{"id":"primary-menu"}]}}' +$WP_CMD theme mod set hfg_footer_layout_v2 '{"desktop":{"top":{"left":[],"c-left":[],"center":[],"c-right":[],"right":[]},"main":{"left":[],"c-left":[],"center":[],"c-right":[],"right":[]},"bottom":{"left":[],"c-left":[{"id":"footer_copyright"},{"id":"footer-menu"}],"center":[],"c-right":[],"right":[]}},"mobile":{"top":{"left":[],"c-left":[],"center":[],"c-right":[],"right":[]},"main":{"left":[],"c-left":[],"center":[],"c-right":[],"right":[]},"bottom":{"left":[],"c-left":[{"id":"footer_copyright"},{"id":"footer-menu"}],"center":[],"c-right":[],"right":[]}}}' + +# ------------------------------------------------------------------ +# 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 + +# Featured images on the archive posts — without them no thumbnails render +# and the thumbnail-link tests (title attr, accessible name) are vacuous. +for i in 1 2 3; do + SLUG="a11y-cat-post-$i" + PID=$($WP_CMD post list --post_type=post --name="$SLUG" --field=ID | head -1) + if [ -n "$PID" ] && [ -z "$($WP_CMD post meta get "$PID" _thumbnail_id 2>/dev/null)" ]; then + THEME_DIR=$($WP_CMD eval "echo get_template_directory();") + $WP_CMD media import "$THEME_DIR/assets/img/patterns/neve-patterns-$((i + 9)).jpg" --post_id="$PID" --featured_image --porcelain + fi +done + +# ------------------------------------------------------------------ +# 5. W3 showcase page: every element the color/contrast workstream +# touches, on one page, for before/after comparison. +# ------------------------------------------------------------------ +OLD_W3=$($WP_CMD post list --post_type=page --name=a11y-w3-showcase --field=ID | head -1) +if [ -n "$OLD_W3" ]; then + $WP_CMD post delete "$OLD_W3" --force +fi +$WP_CMD eval ' +// Raw form markup needs unfiltered_html, which anonymous CLI lacks. +$admins = get_users( array( "role" => "administrator", "number" => 1, "fields" => "ID" ) ); +if ( ! empty( $admins ) ) { + wp_set_current_user( $admins[0] ); +} +$home = esc_url( home_url( "/" ) ); +$cat = esc_url( home_url( "/category/a11y-cat/" ) ); +$content = <<Fixture page showing every element the W3 color/contrast workstream changes. Compare this page before and after W3 lands.
+ +Plain inline link: hover me — today the color does not change (secondary accent equals primary accent).
+Link with a class attribute (escapes the current underline rule): classed link — not underlined today.
+ +Neve primary should darken its background on hover; Neve secondary should fill with the light background. The default variation is styled by WordPress core, not the theme.
+ +nv-c-1 purple text — currently below 4.5:1 on white.
+nv-c-2 red text — currently below 4.5:1 on white.
+Secondary accent (the hover color) as text — today identical to the primary accent.
+ +Pagination renders on the blog page and the A11y Cat archive: no hover/focus style and ambiguous link names today.
+HTML; +$id = wp_insert_post( array( + "post_title" => "A11y W3 Showcase", + "post_name" => "a11y-w3-showcase", + "post_type" => "page", + "post_status" => "publish", + "post_content" => $content, +) ); +echo $id . "\n"; +' + +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..f3f3c8ea08 --- /dev/null +++ b/e2e-tests/specs/a11y-ready/a11y-utils.ts @@ -0,0 +1,135 @@ +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