Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 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
8f16bab
fix: point customizer specs at the rebuilt toggle semantics
HardeepAsrani Sep 7, 2026
b866afc
fix: review-bot findings on caret state, Escape order and palette sync
HardeepAsrani Sep 8, 2026
c469ac6
Merge branch 'development' into feat/a11y
HardeepAsrani Sep 9, 2026
844e1cb
chore: bring frontend.js and the main stylesheet back under the old b…
HardeepAsrani Sep 9, 2026
0ed5203
fix: don't ship the cart drawer inert from PHP
HardeepAsrani Sep 16, 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.
7 changes: 3 additions & 4 deletions assets/js/src/frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { initBlog } from './blog.js';
import { initNavigation, repositionDropdowns } from './navigation.js';
import { HFG } from './hgf.js';
import { on } from '../utils.js';

function run() {
window.HFG = new HFG();
Expand All @@ -16,15 +17,13 @@ function onResizeDebouncedRun() {
/**
* Run JS on load.
*/
window.addEventListener('load', () => {
run();
});
on(window, 'load', run);

/**
* Do resize events debounced.
*/
let neveResizeTimeout;
window.addEventListener('resize', () => {
on(window, 'resize', () => {
clearTimeout(neveResizeTimeout);
neveResizeTimeout = setTimeout(onResizeDebouncedRun, 500);
});
23 changes: 11 additions & 12 deletions assets/js/src/frontend/blog.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* global NeveProperties,_wpCustomizeSettings,parent,Masonry,imagesLoaded */
import { httpGetAsync, isInView } from '../utils';
import { httpGetAsync, isInView, qs } from '../utils';

let masonryContainer = null,
page = 2;
Expand All @@ -11,7 +11,7 @@ const triggerSelector = '.infinite-scroll-trigger';
*/
export const initBlog = () => {
if (
document.querySelector('.blog.nv-index-posts') === null ||
qs('.blog.nv-index-posts') === null ||
typeof NeveProperties === 'undefined'
) {
return false;
Expand All @@ -29,14 +29,14 @@ const masonry = () => {
if (masonryStatus !== 'enabled' || masonryColumns < 2) {
return;
}
masonryContainer = document.querySelector(postWrapSelector);
masonryContainer = qs(postWrapSelector);

if (masonryContainer === null) {
return;
}

imagesLoaded(masonryContainer, () => {
const selector = `article.layout-${blogLayout}`;
const selector = 'article.layout-' + blogLayout;
window.nvMasonry = new Masonry(masonryContainer, {
itemSelector: selector,
columnWidth: selector,
Expand All @@ -51,12 +51,12 @@ const masonry = () => {
const infiniteScroll = () => {
if (
NeveProperties.infScroll !== 'enabled' ||
document.querySelector(postWrapSelector) === null
qs(postWrapSelector) === null
) {
return;
}

isInView(document.querySelector(triggerSelector), () => {
isInView(qs(triggerSelector), () => {
if (parent && parent.wp && parent.wp.customize) {
parent.wp.customize.requestChangesetUpdate().then(() => {
requestMorePosts();
Expand All @@ -72,30 +72,29 @@ const infiniteScroll = () => {
*
*/
const requestMorePosts = () => {
const doc = window.document;
const nP = window.NeveProperties;

const trigger = doc.querySelector(triggerSelector);
const trigger = qs(triggerSelector);
if (trigger === null) {
return;
}
const loader = doc.querySelector('.nv-loader');
const loader = qs('.nv-loader');
loader.style.display = 'block';

if (page > nP.maxPages) {
trigger.parentNode.removeChild(trigger);
trigger.remove();
loader.style.display = 'none';
return;
}
const blog = doc.querySelector(postWrapSelector);
const blog = qs(postWrapSelector);
const lang = nP.lang;
const baseUrl = nP.endpoint + page;
const url = lang ? baseUrl + '/' + lang : baseUrl;
const requestUrl = maybeParseUrlForCustomizer(url);
page++;

// Create an empty div that will be replaced with the new posts. Used to keep the order of the posts.
const postsPlaceholder = doc.createElement('div');
const postsPlaceholder = document.createElement('div');
blog.appendChild(postsPlaceholder);

httpGetAsync(
Expand Down
90 changes: 35 additions & 55 deletions assets/js/src/frontend/hgf.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
/* global NeveProperties CustomEvent */
/* global NeveProperties */
/* jshint esversion: 6 */
import {
addEvent,
addClass,
removeClass,
qs,
qsa,
emit,
NV_FOCUS_TRAP_START,
NV_FOCUS_TRAP_END,
} from '../utils.js';
Expand All @@ -16,37 +19,30 @@ const sidebarClasses = [
];

export const HFG = function () {
this.options = {
menuToggleDuration: 300,
};
this.init();
};

const toggleAria = (elements, add = true) => {
elements.forEach(function (element) {
if (!add) {
element.removeAttribute('aria-hidden');
return;
}
element.setAttribute('aria-hidden', 'true');
});
};
const toggleAria = (elements, add = true) =>
elements.forEach((element) =>
add
? element.setAttribute('aria-hidden', 'true')
: element.removeAttribute('aria-hidden')
);

/**
* Init mobile sidebar.
*
* @param {boolean} skipSidebar
*/
HFG.prototype.init = function (skipSidebar = false) {
const doc = window.document;
if (skipSidebar === false) {
const closeButtons = doc.querySelectorAll(closeNavSelector);
const closeButtons = qsa(closeNavSelector);
addEvent(closeButtons, 'click', () => {
this.toggleMenuSidebar(false);
});
}

const menuMobileToggleButtons = doc.querySelectorAll('.menu-mobile-toggle');
const menuMobileToggleButtons = qsa('.menu-mobile-toggle');
addEvent(menuMobileToggleButtons, 'click', (event) => {
this.toggleMenuSidebar(
!event.target.parentElement.classList.contains('is-active'),
Expand All @@ -57,15 +53,9 @@ HFG.prototype.init = function (skipSidebar = false) {
/**
* When click to outside of menu sidebar.
*/
const overlay = doc.querySelector('.header-menu-sidebar-overlay');
const overlay = qs('.header-menu-sidebar-overlay');
if (overlay) {
addEvent(
overlay,
'click',
function () {
this.toggleMenuSidebar(false);
}.bind(this)
);
addEvent(overlay, 'click', () => this.toggleMenuSidebar(false));
}
};

Expand All @@ -76,63 +66,53 @@ HFG.prototype.init = function (skipSidebar = false) {
* @param {Element} target
*/
HFG.prototype.toggleMenuSidebar = function (toggle, target = null) {
const doc = window.document;
const TOGGLE_CLASS_CONTAINER = '.menu-mobile-toggle';
const buttonsContainer = doc.querySelectorAll(TOGGLE_CLASS_CONTAINER);
removeClass(doc.body, sidebarClasses[1]);
const body = document.body;
const buttonsContainer = qsa('.menu-mobile-toggle');
removeClass(body, sidebarClasses[1]);

/**
* Elements to apply aria-hidden on
*/
const ariaShowOnToggle = doc.querySelectorAll(
'#header-menu-sidebar, .hfg-ov'
);
const ariaHideOnToggle = doc.querySelectorAll(
const ariaShowOnToggle = qsa('#header-menu-sidebar, .hfg-ov');
const ariaHideOnToggle = qsa(
'.neve-skip-link, #content, .scroll-to-top, #site-footer, .header--row'
);

if (
((typeof NeveProperties === 'undefined' ||
!NeveProperties.isCustomize) &&
doc.body.classList.contains(sidebarClasses[0])) ||
body.classList.contains(sidebarClasses[0])) ||
toggle === false
) {
const navClickaway = doc.querySelector('.nav-clickaway-overlay');
const navClickaway = qs('.nav-clickaway-overlay');
if (navClickaway !== null) {
navClickaway.parentNode.removeChild(navClickaway);
navClickaway.remove();
}
addClass(doc.body, sidebarClasses[1]);
removeClass(doc.body, sidebarClasses[0]);
addClass(body, sidebarClasses[1]);
removeClass(body, sidebarClasses[0]);
removeClass(buttonsContainer, sidebarClasses[2]);
// Remove the hiding class after 1 second.
setTimeout(
function () {
removeClass(doc.body, sidebarClasses[1]);
}.bind(this),
1000
);
setTimeout(function () {
removeClass(body, sidebarClasses[1]);
}, 1000);

/**
* Remove aria-hidden from elements outside the sidebar menu
*/
toggleAria(ariaHideOnToggle, false);
toggleAria(ariaShowOnToggle);
// Remove focus trap when closing.
doc.dispatchEvent(new CustomEvent(NV_FOCUS_TRAP_END));
emit(NV_FOCUS_TRAP_END);
} else {
addClass(doc.body, sidebarClasses[0]);
addClass(body, sidebarClasses[0]);
addClass(buttonsContainer, sidebarClasses[2]);
if (target) {
doc.dispatchEvent(
new CustomEvent(NV_FOCUS_TRAP_START, {
detail: {
container: doc.getElementById('header-menu-sidebar'),
close: closeNavSelector,
firstFocus: closeNavSelector + ',.menu-item a',
backFocus: target,
},
})
);
emit(NV_FOCUS_TRAP_START, {
container: document.getElementById('header-menu-sidebar'),
close: closeNavSelector,
firstFocus: closeNavSelector + ',.menu-item a',
backFocus: target,
});
}

/**
Expand Down
Loading
Loading