Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5e6a417
chore: add accessibility-ready e2e acceptance suite
HardeepAsrani Aug 19, 2026
dd7ace8
fix: make navigation submenus keyboard and screen-reader operable
HardeepAsrani Aug 19, 2026
eac74f6
fix: restore visible keyboard focus indicators
HardeepAsrani Aug 19, 2026
17a3c2e
fix: meet color-contrast and hover-distinction requirements in defaults
HardeepAsrani Aug 19, 2026
0d4ac30
fix: make bundled block patterns meet accessibility-ready criteria
HardeepAsrani Aug 19, 2026
9046265
chore: exercise thumbnail and footer-menu bugs in a11y fixtures
HardeepAsrani Aug 20, 2026
e70e449
fix: structure, landmarks, forms and control names for accessibility-…
HardeepAsrani Aug 20, 2026
7bfea98
fix: honor prefers-reduced-motion and announce new-tab links
HardeepAsrani Aug 20, 2026
05e136d
fix: keyboard close behavior for mega menu and header search
HardeepAsrani Aug 21, 2026
3c127a9
fix: off-canvas cart dialog semantics, module fallback and palette sw…
HardeepAsrani Aug 23, 2026
a477ce4
fix: unique nav landmarks, class-based divider and cart reveals
HardeepAsrani Aug 24, 2026
734e733
fix: phpcs-clean embedded php in the button template
HardeepAsrani Aug 24, 2026
caa14c4
fix: phpstan-clean thumbnail aria-label and room for the a11y nav bundle
HardeepAsrani Aug 24, 2026
7bd8043
Merge branch 'development' into feat/a11y
HardeepAsrani Aug 24, 2026
5debe91
fix: underline WooCommerce breadcrumb links
HardeepAsrani Aug 24, 2026
19d313f
fix: reduced-motion guards, core screen-reader-text pattern, budgets
HardeepAsrani Aug 24, 2026
e968123
fix: Escape dismisses hover-opened submenus (WCAG 1.4.13)
HardeepAsrani Aug 24, 2026
2f71c66
fix: navigation-widgets html5 support
HardeepAsrani Aug 24, 2026
1ec0533
chore: ship accessibility.txt and true up theme metadata
HardeepAsrani Aug 24, 2026
818d888
fix: underline footer copyright links (WCAG 1.4.1)
HardeepAsrani Aug 25, 2026
fd7437e
fix: free-theme copyright selector, core clip fallback (review)
Soare-Robert-Daniel Aug 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
82 changes: 82 additions & 0 deletions accessibility.txt
Original file line number Diff line number Diff line change
@@ -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.
191 changes: 176 additions & 15 deletions assets/js/src/frontend/navigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <body> 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);
}
}

/**
Expand Down Expand Up @@ -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) {
Expand All @@ -201,18 +317,40 @@ 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, {
detail: {
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 <body>.
backFocus:
searchItem.querySelector(
'.nv-search,.nv-nav-search-icon'
) || searchItem,
onClose: closeSearch,
},
})
);
Expand All @@ -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);
});
}

Expand Down Expand Up @@ -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));
});
}
17 changes: 9 additions & 8 deletions assets/js/src/scroll-to-top.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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() {
Expand All @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions assets/scss/components/compat/woocommerce/_breadcrumbs.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading