Skip to content

Instantly share code, notes, and snippets.

@SteveJonesDev
Last active January 12, 2024 03:10
  • Star 10 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save SteveJonesDev/ce8bf0219e4ebe5582454022e429ef07 to your computer and use it in GitHub Desktop.
Accessible WordPress Navigation Menu
<div class="menu-container">
<button class="menu-button" aria-expanded="false" aria-controls="site-header-menu" aria-label="<?php esc_attr_e( 'Menu', 'textdomain' ); ?>"></button>
<div id="site-header-menu" class="site-header-menu">
<?php
wp_nav_menu(
array(
'theme_location' => 'primary',
'container' => 'nav',
'container_class' => 'main-navigation',
'container_id' => 'site-navigation',
'container_aria_label' => 'Primary Menu',
),
);
?>
</div>
</div>
document.addEventListener('DOMContentLoaded', function () {
// Getting main menu elements
const menuContainer = document.querySelector('.menu-container');
const menuToggle = menuContainer.querySelector('.menu-button');
const siteHeaderMenu = menuContainer.querySelector('#site-header-menu');
const siteNavigation = menuContainer.querySelector('#site-navigation');
// If the menu toggle button exists, set up its behaviors
if (menuToggle) {
// Initial ARIA attribute setup for accessibility
menuToggle.setAttribute('aria-expanded', 'false');
siteNavigation.setAttribute('aria-expanded', 'false');
// Event listener for main menu toggle button
menuToggle.addEventListener('click', function () {
// Toggle visual states for the button and menu
this.classList.toggle('toggled-on');
siteHeaderMenu.classList.toggle('toggled-on');
// Determine and set the new expanded state for ARIA
const isExpanded = this.getAttribute('aria-expanded') === 'true';
const newExpandedState = isExpanded ? 'false' : 'true';
// Update ARIA attributes
this.setAttribute('aria-expanded', newExpandedState);
siteNavigation.setAttribute('aria-expanded', newExpandedState);
});
}
// Set up dropdown toggle buttons for menu items with children
const menuItemsWithChildren = document.querySelectorAll(
'.menu-item-has-children > a'
);
menuItemsWithChildren.forEach(function (item) {
const linkText = item.textContent;
// Create the dropdown toggle button
const dropdownToggle = document.createElement('button');
dropdownToggle.className = 'dropdown-toggle';
dropdownToggle.setAttribute('aria-expanded', 'false');
// Set ARIA label for accessibility
dropdownToggle.setAttribute('aria-label', linkText + ' submenu');
// Insert the dropdown button after the menu item
item.insertAdjacentElement('afterend', dropdownToggle);
// Set up behavior when the dropdown button is clicked
dropdownToggle.addEventListener('click', function () {
// Determine the expanded state of the dropdown
const isExpanded = this.getAttribute('aria-expanded');
// Toggle the dropdown's expanded state
if (isExpanded === 'true') {
this.setAttribute('aria-expanded', 'false');
} else {
this.setAttribute('aria-expanded', 'true');
}
});
});
// Toggle dropdowns behavior
const dropdownToggles = siteHeaderMenu.querySelectorAll('.dropdown-toggle');
dropdownToggles.forEach(function (toggle) {
toggle.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation(); // Prevent event from bubbling
// Toggle the clicked dropdown
this.classList.toggle('toggled-on');
const nextSubMenu = this.nextElementSibling;
if (nextSubMenu && nextSubMenu.classList.contains('sub-menu')) {
nextSubMenu.classList.toggle('toggled-on');
}
// Update the ARIA expanded state of the dropdown
const isExpanded =
this.getAttribute('aria-expanded') === 'false'
? 'true'
: 'false';
this.setAttribute('aria-expanded', isExpanded);
// Close other dropdowns on the same level to avoid multiple open dropdowns
const siblingToggles = Array.from(
this.parentElement.parentElement.children
)
.map((el) => el.querySelector('.dropdown-toggle'))
.filter((el) => el !== null && el !== this);
siblingToggles.forEach((sibToggle) => {
sibToggle.classList.remove('toggled-on');
const sibSubMenu = sibToggle.nextElementSibling;
if (sibSubMenu && sibSubMenu.classList.contains('sub-menu')) {
sibSubMenu.classList.remove('toggled-on');
}
sibToggle.setAttribute('aria-expanded', 'false');
});
});
});
// Indicate that a menu has a sub-menu
const subMenus = document.querySelectorAll(
'.sub-menu .menu-item-has-children'
);
subMenus.forEach(function (subMenu) {
subMenu.parentElement.classList.add('has-sub-menu');
});
// Keyboard navigation setup for menu
const menuLinksAndDropdownToggles = document.querySelectorAll(
'.menu-item a, button.dropdown-toggle'
);
menuLinksAndDropdownToggles.forEach(function (element) {
element.addEventListener('keydown', function (e) {
const key = e.keyCode;
// Key handling for improved keyboard navigation
if (![27, 37, 38, 39, 40].includes(key)) {
return;
}
// Handle different keys for navigation
switch (key) {
case 27: // Escape: Close dropdown or main menu
e.preventDefault();
e.stopPropagation();
const parentDropdown =
this.closest('ul').previousElementSibling;
if (
parentDropdown &&
parentDropdown.classList.contains('dropdown-toggle') &&
parentDropdown.classList.contains('toggled-on')
) {
parentDropdown.focus();
parentDropdown.click();
} else if (!parentDropdown) {
// If no parent dropdown found, close the main menu.
if (
menuToggle &&
menuToggle.classList.contains('toggled-on')
) {
menuToggle.click();
menuToggle.focus();
}
}
break;
case 37: // Left arrow: Move focus to the previous item
e.preventDefault();
if (this.classList.contains('dropdown-toggle')) {
this.previousElementSibling.focus();
} else {
const prevSibling = this.parentElement.previousElementSibling;
if (
prevSibling &&
prevSibling.querySelector('button.dropdown-toggle')
) {
prevSibling
.querySelector('button.dropdown-toggle')
.focus();
} else if (
prevSibling &&
prevSibling.querySelector('a')
) {
prevSibling.querySelector('a').focus();
}
}
break;
case 39: // Right arrow: Move focus to the next item or enter a submenu
e.preventDefault();
if (
this.nextElementSibling &&
this.nextElementSibling.matches(
'button.dropdown-toggle'
)
) {
this.nextElementSibling.focus();
} else {
const nextSibling =
this.parentElement.nextElementSibling;
if (nextSibling) {
nextSibling.querySelector('a').focus();
}
}
if (
this.matches('ul.sub-menu .dropdown-toggle.toggled-on')
) {
this.parentElement
.querySelector('ul.sub-menu li:first-child a')
.focus();
}
break;
case 40: // Down arrow: Move focus to the next item or submenu
e.preventDefault();
if (this.nextElementSibling) {
const firstChildLink =
this.nextElementSibling.querySelector(
'li:first-child a'
);
if (firstChildLink) {
firstChildLink.focus();
}
} else {
const nextElem = this.parentElement.nextElementSibling;
if (nextElem) {
nextElem.querySelector('a').focus();
}
}
break;
case 38: // Up arrow: Move focus to the previous item or exit a submenu
e.preventDefault();
const prevElem = this.parentElement.previousElementSibling;
if (prevElem) {
prevElem.querySelector('a').focus();
} else {
const closestUl = this.closest('ul');
if (
closestUl &&
closestUl.previousElementSibling.matches(
'.dropdown-toggle.toggled-on'
)
) {
closestUl.previousElementSibling.focus();
}
}
break;
}
});
});
});
.menu-container:after {
display: table;
clear: both;
content: "";
}
.site-header-menu {
display: none;
font-size: 1rem;
clear: both;
}
.main-navigation ul {
margin: 0;
padding: 0;
list-style: none;
}
.main-navigation ul li {
margin-right: 40px;
margin-bottom: 10px;
min-height: 30px;
}
.main-navigation ul a,
.main-navigation ul a:visited {
border: none;
color: $black;
font-size: 16px;
font-weight: 400;
line-height: 22px;
position: relative;
text-decoration: none;
text-transform: uppercase;
}
.main-navigation ul a:hover {
border-bottom: 3px solid;
}
.main-navigation ul ul {
display: none;
margin-top: 0px;
margin-left: 25px;
}
.main-navigation ul ul li{
a{
font-size: 14px;
text-transform: none;
padding: 0;
margin-bottom: 10px;
border-bottom: solid 4px transparent;
padding-bottom: 2px;
}
&:last-child{
padding-bottom: 0;
a{
margin-bottom: 0;
}
}
&:hover > a {
background: #EEEEEE;
}
}
.main-navigation ul ul ul {
display: none;
margin-left: 25px;
}
.no-js .site-header-menu,
.site-header-menu.toggled-on {
display: block;
}
.site-header-menu.toggled-on {
margin-top: 10px;
}
.no-js .main-navigation ul,
.main-navigation ul .sub-menu.toggled-on {
display: block;
}
button.dropdown-toggle,
button.menu-button {
display: inline;
background-color: transparent;
border: 0;
-webkit-appearance: none;
-moz-appearance: none;
cursor: pointer;
content: "";
}
button.dropdown-toggle {
width: 25px;
height: 25px;
position: absolute;
right: 15px;
margin-left: 10px;
padding: 2px;
}
.dropdown-toggle:after,
.dropdown-toggle.toggled-on:after {
font-size: 1rem;
}
.menu-button {
width: 25px;
height: 25px;
float: right;
padding: 0 !important;
font-size: 1.35rem;
margin-top: 25px;
padding: 5px 5px 5px 5px;
}
.main-navigation a:focus,
button.dropdown-toggle:focus,
button.menu-button:focus {
outline: 1px solid $black;
outline-offset: 2px;
}
.no-js .menu-button {
display: none;
}
/* Plus symbol to expand sub-menu on mobile */
.dropdown-toggle:after {
content: "\002B";
}
/* Minus symbol to collapse sub-menu on mobile */
.dropdown-toggle.toggled-on:after {
content: "\2212";
}
/* 'Hamburger' or bars to expand menu on mobile*/
.menu-button:before {
content: "\f0c9";
font-family: "Font Awesome 6 Free"; font-weight: 400;
}
/* Times (x) to collapse menu on mobile*/
.menu-button.toggled-on:before {
content: "\f00d";
font-family: "Font Awesome 6 Free"; font-weight: 400;
}
.dropdown-toggle:after,
.dropdown-toggle.toggled-on:after,
.menu-button:before,
.menu-button.toggled-on:before {
font-weight: bold;
}
/* Screen readers */
.screen-readers {
position: absolute !important;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
word-break: normal !important;
overflow: hidden;
clip: rect(0 0 0 0);
}
/* Desktop media query */
@media only screen and (min-width: 768px) {
button.menu-button {
display: none;
}
.menu-container {
padding-top: 0;
padding-bottom: 0;
padding-left: 0;
}
.site-header-menu {
display: block;
margin-left: 10px;
float: right;
//margin-top: 30px;
clear: none;
}
.main-navigation ul {
position: relative;
float: left;
}
.main-navigation ul li {
position: relative;
float: left;
margin: 0;
padding: 10px;
min-height: 0px;
}
.no-js .main-navigation ul ul,
.main-navigation ul ul {
position: absolute;
display: none;
top: 100%;
left: 0;
padding: 0;
z-index: 999;
background: $white;
border-radius: 15px;
padding: 25px;
}
.no-js .main-navigation ul ul li,
.main-navigation ul ul li {
float: none;
width: 175px;
padding: 0;
padding-bottom: 15px;
}
.main-navigation ul .has-sub-menu > li {
padding-right: 40px;
}
.no-js .main-navigation ul ul ul,
.main-navigation ul ul ul {
top: -1px;
left: 100%;
margin-left: 0;
margin-top: -5px;
}
ul.sub-menu .dropdown-toggle {
position: absolute;
right: 10px;
top: 4px;
}
/* Arrow down */
.main-navigation ul .dropdown-toggle:after {
content: "\f078";
font-family: "Font Awesome 6 Free"; font-weight: 400;
font-size: .75rem;
}
/* Arrow right */
.main-navigation ul ul .dropdown-toggle:after {
content: "\f054";
font-family: "Font Awesome 6 Free"; font-weight: 400;
font-size: .75rem;
}
/* Arrow up */
.main-navigation ul .dropdown-toggle.toggled-on:after {
content: "\f077";
font-family: "Font Awesome 6 Free"; font-weight: 400;
}
/* Arrow left */
.main-navigation ul ul .dropdown-toggle.toggled-on:after {
content: "\f053";
font-family: "Font Awesome 6 Free"; font-weight: 400;
}
.main-navigation ul .dropdown-toggle:after,
.main-navigation ul ul .dropdown-toggle:after,
.main-navigation ul .dropdown-toggle.toggled-on:after,
.main-navigation ul ul .dropdown-toggle.toggled-on:after {
font-weight: bold;
}
button.dropdown-toggle {
width: auto;
height: auto;
position: inherit;
right: auto;
}
.main-navigation ul li:hover > ul {
display: block;
}
}
@ksascomm
Copy link

If you have a menu with two levels of fly-outs (ie a Child page with Grandchild pages), the Parent fly-out disappears when the Child's Flyout toggle button is clicked, as the ".toggled-on" class is transferred from the Parent to the Child fly-out. Any solutions for dealing with two levels of fly-outs so the Parent fly-out remains visible?

@SteveJonesDev
Copy link
Author

@ksascomm, the javascript has been updated to account for multi-level fly-outs.

@jsitesMM
Copy link

On line 63 of your CSS, there appears to be a SASS variable reference, $neutral_200

@SteveJonesDev
Copy link
Author

Thanks, @jsitesMM, I've fixed that.

@SteveJonesDev
Copy link
Author

Javascript updated to fix ESLint issues.

@BFTrick
Copy link

BFTrick commented Jan 11, 2024

I believe .focus() is deprecated: https://api.jquery.com/focus-shorthand/#focus-handler

Instead you're supposed to use .trigger( "focus" ): https://api.jquery.com/focus/#trigger2

@SteveJonesDev
Copy link
Author

@BFTrick, .focus() is a method in vanilla JavaScript. This JavaScript doesn't use jQuery.

@BFTrick
Copy link

BFTrick commented Jan 12, 2024

🤦

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment