<header>
<button class="mobile-menu-button hidden-desktop" aria-expanded="false" aria-controls="main-nav">Mobile menu</button>
<nav id="main-nav" class="hidden-mobile">
<ul>
<li><a href="/about-us">About us</a></li>
<li>
<button class="expand-button" aria-expanded="false" aria-controls="products-level-2">
Products
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="12" height="9" viewBox="0 0 12 9">
<polygon points="1 0, 11 0, 6 8"></polygon>
</svg>
</button>
<ul id="products-level-2" class="hidden">
<li><a href="/products/product1">Product 1</a></li>
<li><a href="/products/product2">Product 2</a></li>
<li>
<button class="expand-button" aria-expanded="false" aria-controls="products-level-3">Sub
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="12" height="9" viewBox="0 0 12 9">
<polygon points="1 0, 11 0, 6 8"></polygon>
</svg>
</button>
<ul id="products-level-3" class="hidden">
<li><a href="/products/reusable-product1">Sub 1</a></li>
<li><a href="/products/reusable-product2">Sub 2</a></li>
</ul>
</li>
</ul>
</li>
<li><a href="/insights">Insights</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>
</header
header {
font-family: Arial;
.mobile-menu-button {
@media (min-width: 48em) {
display: none;
}
}
nav {
transition: opacity 0.3s, visibility 0.3s;
&:not(.hidden-mobile) {
opacity: 1;
}
ul {
list-style: none;
padding-left: 0;
> li {
margin-left: 0;
> button {
appearance: none;
background: none;
border: 0;
+ ul {
transition: opacity 0.3s, visibility 0.3s;
}
&[aria-expanded="true"] > svg {
transform: rotate(180deg);
}
&[aria-expanded="false"] + ul {
opacity: 0;
height: 0;
}
}
}
}
> ul {
margin-right: -0.5rem;
margin-left: -0.5rem;
flex-wrap: wrap;
display: flex;
> li {
margin-right: 0.5rem;
margin-left: 0.5rem;
@media (max-width: 48em) {
width: 100%;
}
> a,
> button {
font-size: 1.25rem;
}
a,
button {
display: inline-block;
padding: 0.25rem;
color: inherit;
&[aria-current="page"] {
color: blue;
}
&[aria-current="true"] {
background-color: lightgrey;
}
&:hover,
&:focus-visible {
text-decoration: underline;
cursor: pointer;
}
}
a {
&:not(:hover):not(:focus-visible) {
text-decoration: none;
}
}
}
}
}
}
.hidden {
visibility: hidden;
}
.hidden-mobile {
@media (max-width: 48em) {
visibility: hidden;
opacity: 0;
height: 0;
}
}
View Compiled
class ExpandButtonFactory {
public static instances: ExpandButton[] = [];
static create(selector: string, customHiddenClass?: string) {
this.instances = [
...this.instances,
...Array.from(document.querySelectorAll(selector)).map((el: Element) => {
if (!(el instanceof HTMLElement)) {
throw new TypeError("No HTML element found.");
}
return new ExpandButton(el as HTMLElement, customHiddenClass);
})
];
}
static addWindowListener() {
window.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
ExpandButtonFactory.instances.forEach((expandButton: ExpandButton) => {
if (
document.activeElement
?.closest("ul")
?.closest("li")
?.contains(expandButton.el)
) {
expandButton.collapse();
expandButton.el.focus();
}
});
}
});
}
}
class ExpandButton {
el: HTMLElement;
#isAriaExpanded: boolean = false;
#ariaControlsElement: HTMLElement | null;
#firstActionElement: HTMLElement | null;
#hiddenClass: string = "hidden";
constructor(el: HTMLElement, customHiddenClass?: string) {
this.el = el;
this.isAriaExpanded = this.el.getAttribute("aria-expanded") === "true";
const ariaControlsAttr = this.el.getAttribute("aria-controls");
this.#ariaControlsElement = ariaControlsAttr
? document.getElementById(ariaControlsAttr)
: null;
if (!(this.#ariaControlsElement instanceof HTMLElement)) {
throw new TypeError("No referenced element found.");
}
this.#firstActionElement = this.#ariaControlsElement.querySelector(
"a[href]:not([disabled]), button:not([disabled])"
);
if (!(this.#firstActionElement instanceof HTMLElement)) {
throw new TypeError("No first actionable element found.");
}
if (customHiddenClass) {
this.#hiddenClass = customHiddenClass;
}
this.initListeners();
}
get isAriaExpanded() {
return this.#isAriaExpanded;
}
set isAriaExpanded(value) {
this.#isAriaExpanded = value;
this.el.setAttribute("aria-expanded", this.isAriaExpanded.toString());
if (this.isAriaExpanded) {
this.#ariaControlsElement?.classList.remove(this.#hiddenClass);
setTimeout(() => {
// focus on first actionable element within the ref element
this.#firstActionElement?.focus();
}, 10);
} else {
this.#ariaControlsElement?.classList.add(this.#hiddenClass);
}
}
initListeners() {
this.clickHandler();
this.collapseOnBlurHandler();
}
collapse() {
this.isAriaExpanded = false;
}
expand() {
this.isAriaExpanded = true;
}
toggle() {
this.isAriaExpanded = !this.isAriaExpanded;
}
clickHandler() {
this.el.addEventListener("mousedown", (e) => {
e.preventDefault();
this.toggle();
});
this.el.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === "Space") {
e.preventDefault();
this.toggle();
}
});
}
collapseOnBlurHandler() {
if (this.el.classList.contains("mobile-menu-button")) {
return;
}
(this.#ariaControlsElement as HTMLElement).addEventListener(
"focusout",
(e: Event) => {
const currentTarget = e.currentTarget as HTMLElement;
requestAnimationFrame(() => {
if (!currentTarget.contains(document.activeElement)) {
this.collapse();
}
});
}
);
}
}
const mobileMenuButton = ExpandButtonFactory.create(
".mobile-menu-button",
"hidden-mobile"
);
const menuItemButtons = ExpandButtonFactory.create(".expand-button");
ExpandButtonFactory.addWindowListener();
View Compiled
This Pen doesn't use any external CSS resources.