feat: add submenu opt-in for main navigation

This commit is contained in:
madrilene 2025-06-16 13:04:00 +02:00
parent 204d05b6d7
commit d9a5b018af
8 changed files with 280 additions and 55 deletions

View file

@ -68,7 +68,8 @@ export const navigation = {
ariaTop: 'Main',
ariaBottom: 'Complementary',
ariaPlatforms: 'Platforms',
drawerNav: false
drawerNav: false,
subMenu: false
};
export const themeSwitch = {
title: 'Theme',

View file

@ -1,21 +1,49 @@
<!-- toggle drawer menu in _data/meta.js -->
<!-- toggle drawer and sub menu in _data/meta.js -->
{% set drawerNav = meta.navigation.drawerNav %}
{% set subMenu = meta.navigation.subMenu %}
<nav id="mainnav" class="mainnav" aria-label="{{ meta.navigation.ariaTop }}">
<ul class="cluster" role="list" no-flash>
{% for item in navigation.top %}
<li>
<a
href="{{ item.url }}"
{{
helpers.getLinkActiveState(item.url,
page.url)
|
safe
}}
>{{ item.text }}</a
>
</li>
{% if item.submenu %}
<li class="relative">
<button
data-submenu-toggle
aria-expanded="false"
aria-labelledby="mainnav-{{ loop.index }}"
aria-controls="mainnav-{{ loop.index }}-sub"
>
{{ item.text }} {% svg "misc/chev-down" %}
</button>
<ul class="nav-sublist | cluster" id="mainnav-{{ loop.index }}-sub">
{% for subitem in item.submenu %}
<li>
<a
href="{{ subitem.url }}"
{{
helpers.getLinkActiveState(subitem.url, page.url)
| safe
}}
>{{ subitem.text }}</a
>
</li>
{% endfor %}
</ul>
</li>
{% else %}
<li>
<a
href="{{ item.url }}"
{{
helpers.getLinkActiveState(item.url,
page.url)
|
safe
}}
>{{ item.text }}</a
>
</li>
{% endif %}
{% endfor %}
</ul>
</nav>
@ -26,7 +54,7 @@
<!-- see also: https://kittygiraudel.com/2022/09/30/templating-in-html/ -->
<template id="burger-template">
<button type="button" aria-expanded="false" aria-controls="mainnav" class="cluster">
<button data-drawer-toggle class="cluster" type="button" aria-expanded="false" aria-controls="mainnav">
<span>{{ meta.navigation.navLabel }}</span>
<svg
@ -44,9 +72,7 @@
</svg>
</button>
</template>
{% endif %}
{% if drawerNav %}
{% css "local" %}
{% include "css/nav-main-drawer-cls.css" %}
{% endcss %}
@ -55,3 +81,9 @@
{% include "scripts/nav-drawer.js" %}
{% endjs %}
{% endif %}
{% if subMenu %}
{% js "defer" %}
{% include "scripts/nav-sub.js" %}
{% endjs %}
{% endif %}

View file

@ -4,7 +4,7 @@
inset-inline-end: 0;
}
.mainnav:has([aria-expanded='true']) {
.mainnav:has([data-drawer-toggle][aria-expanded='true']) {
--nav-position: fixed;
inset-inline-end: var(--gap);
}
@ -13,11 +13,13 @@
/* configuration */
--gutter: var(--space-xs);
--cluster-vertical-alignment: normal;
--cluster-wrap: wrap;
--cluster-direction: column;
--nav-list-background: var(--color-bg);
--nav-list-shadow: -5px 0 11px 0 hsl(0 0% 0% / 0.2);
--nav-list-layout: column;
--nav-list-height: 100dvh;
--nav-list-padding: var(--space-2xl) var(--space-s);
--nav-list-padding-block: var(--space-2xl);
--nav-list-padding-inline: var(--space-s);
--nav-list-position: fixed;
--nav-list-width: min(18rem, 100vw);
--nav-list-visibility: hidden;
@ -25,13 +27,11 @@
background: var(--nav-list-background);
box-shadow: var(--nav-list-shadow);
display: flex;
flex-direction: var(--nav-list-layout);
flex-wrap: wrap;
block-size: var(--nav-list-height);
list-style: none;
margin: 0;
padding: var(--nav-list-padding);
padding-block: var(--nav-list-padding-block);
padding-inline: var(--nav-list-padding-inline);
position: var(--nav-list-position);
inset-block-start: 0;
inset-inline-end: 0;
@ -48,7 +48,7 @@
}
@media (prefers-reduced-motion: no-preference) {
.mainnav ul {
.mainnav > ul {
--nav-list-transform: translateX(100%);
--nav-list-opacity: 1;
transform: var(--nav-list-transform);
@ -62,22 +62,29 @@
}
}
.mainnav [aria-expanded='true'] + ul {
--nav-list-visibility: visible;
--nav-list-transform: translateX(0);
--nav-list-opacity: 1;
}
.mainnav button {
.mainnav [data-drawer-toggle] {
--gutter: var(--space-2xs);
--cluster-vertical-alignment: center;
display: var(--nav-button-display, flex);
background: var(--color-bg);
display: var(--nav-button-display, inline-flex);
position: relative;
z-index: 2;
padding: var(--space-2xs) 0;
line-height: var(--leading-flat);
}
.mainnav [data-drawer-toggle][aria-expanded='true'] + ul {
--cluster-wrap: nowrap;
--nav-list-visibility: visible;
--nav-list-transform: translateX(0);
--nav-list-opacity: 1;
overflow-y: auto;
}
body:has([data-drawer-toggle][aria-expanded='true']) {
overflow: hidden;
}
.mainnav span {
font-weight: var(--font-bold);
text-transform: uppercase;
@ -95,15 +102,18 @@
transform: rotate(45deg);
}
.mainnav a {
.mainnav :is(a, [data-submenu-toggle]) {
/* configuration */
--nav-item-background: transparent;
--nav-item-text-color: var(--color-text);
--nav-item-padding: var(--space-xs) var(--space-2xs);
--nav-item-decoration-color: transparent;
--nav-item-font-size: var(--size-step-0);
--nav-item-font-weight: var(--font-bold);
background: var(--nav-item-background);
color: var(--nav-item-text-color);
font-size: var(--nav-item-font-size);
padding: var(--nav-item-padding);
display: block;
border-radius: var(--border-radius-medium);
@ -113,17 +123,68 @@
text-underline-offset: 0.2em;
}
.mainnav a:where(:hover, :focus) {
.mainnav:has(.nav-sublist) :is(a, [data-submenu-toggle]) {
font-weight: var(--nav-item-font-weight);
}
.mainnav [data-submenu-toggle] {
gap: var(--space-2xs);
display: flex;
inline-size: 100%;
align-items: center;
justify-content: space-between;
}
.mainnav [data-submenu-toggle] svg {
margin-inline-end: calc(var(--gap) - var(--nav-list-padding-inline));
}
.mainnav :is(a, [data-submenu-toggle]):hover {
--nav-item-background: transparent;
--nav-item-text-color: var(--color-text);
--nav-item-decoration-color: var(--color-text);
--nav-item-decoration-color: var(--color-bg-accent-2);
}
.mainnav [aria-current='page'],
.mainnav [data-state='active'] {
--nav-item-background: var(--color-primary);
--nav-item-text-color: var(--color-light);
--nav-item-decoration-color: transparent;
--nav-item-background: var(--color-bg);
--nav-item-text-color: var(--color-primary);
--nav-item-decoration-color: var(--color-primary);
}
/* current parent (if submenu) */
li:has(ul a[aria-current='page']) > [data-submenu-toggle] {
--nav-item-background: var(--color-bg);
--nav-item-text-color: var(--color-text);
--nav-item-decoration-color: var(--color-text);
}
/* sub menu */
.mainnav [data-submenu-toggle][aria-expanded='false'] + ul {
display: none;
}
.mainnav .nav-sublist {
--gutter: 0;
--cluster-direction: column;
--nav-sublist-position: relative;
--nav-sublist-background: var(--color-bg);
--nav-sublist-width: 100%;
--nav-list-visibility: visible;
--nav-list-opacity: 1;
--nav-list-padding-block: 0 var(--space-m);
--nav-list-padding-inline: 0;
box-shadow: none;
position: var(--nav-sublist-position);
inline-size: var(--nav-sublist-width);
block-size: auto;
background: var(--nav-sublist-background);
z-index: 2;
}
.mainnav .nav-sublist a {
--nav-item-font-weight: var(--font-regular);
}
@media screen(navigation) {
@ -132,10 +193,15 @@
--nav-button-display: none;
}
.mainnav :is(a, [data-submenu-toggle]) {
--nav-item-font-weight: var(--font-regular);
}
.mainnav ul {
--nav-list-layout: row;
--cluster-direction: row;
--nav-list-position: static;
--nav-list-padding: 0;
--nav-list-padding-block: 0;
--nav-list-padding-inline: 0;
--nav-list-height: auto;
--nav-list-width: 100%;
--nav-list-shadow: none;
@ -145,20 +211,42 @@
}
.mainnav [aria-current='page'],
.mainnav [data-state='active'] {
.mainnav [data-state='active'],
li:has(ul a[aria-current='page']) > [data-submenu-toggle] {
--nav-item-background: transparent;
--nav-item-text-color: var(--color-primary);
--nav-item-decoration-color: var(--color-primary);
}
.mainnav [data-submenu-toggle] {
inline-size: auto;
}
.mainnav [data-submenu-toggle] svg {
margin-inline-end: 0;
}
.mainnav .nav-sublist {
--nav-sublist-position: absolute;
--nav-sublist-background: var(--color-bg);
--nav-sublist-border: var(--color-text);
--nav-sublist-width: max-content;
--nav-list-padding-block: var(--space-xs);
--nav-list-padding-inline: var(--space-xs);
border: 3px solid var(--nav-sublist-border);
inset-block-start: var(--space-xl);
inset-inline-start: var(--space-2xs);
}
}
/* Repeat the settings to provide a different styling when JavaScript is disabled or drawerNav is set to false. The selector
assumes that the button doesnt exist without JS, making the list the first child within the navigation. */
.mainnav ul:first-child {
--nav-list-layout: row;
--cluster-direction: row;
--nav-list-position: static;
--nav-list-padding: 0;
--nav-list-padding-block: 0;
--nav-list-padding-inline: 0;
--nav-list-height: auto;
--nav-list-width: 100%;
--nav-list-shadow: none;
@ -178,3 +266,19 @@ assumes that the button doesnt exist without JS, making the list the first ch
.mainnav:has(ul:first-child) {
--nav-position: relative;
}
/* no JS fallback for sub menus */
@media (scripting: none) {
.mainnav ul:first-child ul.nav-sublist {
--cluster-direction: row;
--cluster-wrap: wrap;
}
}
@media (scripting: none) {
@media screen(navigation) {
.mainnav ul:first-child ul.nav-sublist {
--cluster-direction: column;
}
}
}

View file

@ -17,7 +17,8 @@ Can be any acceptable flexbox alignment value.
.cluster {
display: flex;
flex-wrap: wrap;
flex-direction: var(--cluster-direction, row);
flex-wrap: var(--cluster-wrap, wrap);
gap: var(--gutter, var(--space-s-l));
justify-content: var(--cluster-horizontal-alignment, flex-start);
align-items: var(--cluster-vertical-alignment, center);

View file

@ -3,29 +3,30 @@
const nav = document.querySelector('nav');
const list = nav.querySelector('ul');
const burgerClone = document.querySelector('#burger-template').content.cloneNode(true);
const button = burgerClone.querySelector('button');
const buttonDrawer = burgerClone.querySelector('button[data-drawer-toggle]');
list.style.setProperty('display', 'flex');
button.addEventListener('click', e => {
const isOpen = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', !isOpen);
buttonDrawer.addEventListener('click', e => {
const isOpenDrawer = buttonDrawer.getAttribute('aria-expanded') === 'true';
buttonDrawer.setAttribute('aria-expanded', !isOpenDrawer);
});
const disableMenu = () => {
button.setAttribute('aria-expanded', false);
buttonDrawer.setAttribute('aria-expanded', false);
};
// close on escape
nav.addEventListener('keyup', e => {
if (e.code === 'Escape') {
nav.addEventListener('keyup', event => {
if (event.code === 'Escape') {
disableMenu();
buttonDrawer.focus();
}
});
// close if clicked outside of event target
document.addEventListener('click', e => {
const isClickInsideElement = nav.contains(e.target);
document.addEventListener('click', event => {
const isClickInsideElement = nav.contains(event.target);
if (!isClickInsideElement) {
disableMenu();
}

View file

@ -0,0 +1,45 @@
const nav = document.querySelector('nav');
const navBreakpoint = '{{ designTokens.viewports.navigation }}';
// toggle submenu and aria-expanded on button click
nav.addEventListener('click', event => {
const buttonSub = event.target.closest('button[data-submenu-toggle]');
if (!buttonSub) return;
const isOpenSub = buttonSub.getAttribute('aria-expanded') === 'true';
// if above nav breakpoint, close any other open submenu first
if (window.innerWidth >= navBreakpoint && !isOpenSub) {
const openButton = nav.querySelector('button[data-submenu-toggle][aria-expanded="true"]');
if (openButton && openButton !== buttonSub) {
closeSubmenu(openButton);
}
}
buttonSub.setAttribute('aria-expanded', String(!isOpenSub));
const submenu = document.getElementById(buttonSub.getAttribute('aria-controls'));
submenu.hidden = isOpenSub;
});
// close on click outside nav
document.addEventListener('click', event => {
const openButton = nav.querySelector('button[data-submenu-toggle][aria-expanded="true"]');
if (openButton && !nav.contains(event.target)) {
closeSubmenu(openButton);
}
});
// close on ESC
document.addEventListener('keyup', event => {
if (event.code !== 'Escape') return;
const openButton = nav.querySelector('button[data-submenu-toggle][aria-expanded="true"]');
if (openButton) {
closeSubmenu(openButton);
}
});
function closeSubmenu(buttonSub) {
const submenu = document.getElementById(buttonSub.getAttribute('aria-controls'));
buttonSub.setAttribute('aria-expanded', 'false');
submenu.hidden = true;
}

View file

@ -0,0 +1,12 @@
<svg
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>

After

Width:  |  Height:  |  Size: 201 B

View file

@ -6,7 +6,9 @@ Edit your navigation items in `src/_data/navigation.js`.
You have two options for mobile navigation: by default, the navigation on small displays is converted to small pills that wrap. This does not require any additional JavaScript.
You can activate a drawer menu in `src/_data/meta.js`:
**Drawer Menu**
You can activate a drawer menu for mobile in `src/_data/meta.js`:
```js
navigation: {
@ -25,4 +27,31 @@ Adjust your menu breakpoint in `src/_data/designTokens/viewports.json`
"navigation": 662,
// ...
}
```
**Submenu**
You can activate submenus in `src/_data/meta.js`:
```js
navigation: {
// other settings
subMenu: true,
},
```
This includes the JavaScript for the submenu functionality. Add your submenu items to `src/_data/navigation.js` using this structure:
```js
{
text: 'Unlinked parent',
url: '#',
submenu: [
{
text: 'Sub Item',
url: '/sub-item/'
},
... more items
]
},
```