Proper websites, done properly

Part 5: Open and close

8 minute read time / 1026 words

Setting up our open and close functions, and making it as accessible as possible.

Opening the navigation

We can use the classname method again on the nav element to trigger the actual opening and closing of the menu items. Just a simple update to both the functions to make it happen.

this.container.classList.add(this.activeClass);

And for the close function:

this.container.classList.remove(this.activeClass);

If you check in your browser's dev tools and inspect the nav element, you should see that when you click the toggle button, your nav element's classList is updated with whatever you have set your activeClass argument to.

Let's first move our nav.njk include inside the header.njk include and out of the base.njk layout file.

<header class="site-head">
    <a href="{{ settings.domain }}">{{ settings.sitename }}</a>

    <p>
        I made this!
    </p>

    {% include 'nav.njk' %}
</header>

We're also going to need to update the nav to give us a bit extra. Update the button to give it a classname.

<button type="button" id="site-nav-toggle" class="nav-toggle">
    Menu (Currently <span class="context" id="site-nav-context">closed</span>)
</button>

All that's needed to make it hide and show on screen is to add a sprinkling of CSS to it all. First, let's set our header to relative position to allow us to position things inside it properly.

.site-head {
    position: relative;
}

We also don't want to see our toggle unless we're on a smaller screen.

Added bonus!

Native CSS nesting is available in all evergreen browsers as of approximately December 2023, so we can use it here without needing and pre- or post-processing in place.
.nav-toggle {
    display: none;

    @media (max-width: 767px) {
        display: block;
        position: relative;
        border-radius: 4px;
        background-color: #000;
        text-indent: 115%;
        white-space: nowrap;
        overflow: hidden;
        inline-size: 44px;
        block-size: 44px;
        border: 0;
        padding: 0;
        align-self: start;
        justify-self: end;
        cursor: pointer;

        .context,
        &::before,
        &::after {
            content: '';
            display: block;
            inline-size: 24px;
            block-size: 2px;
            background-color: #fff;
            position: absolute;
            inset-block-start: 50%;
            inset-inline-start: 50%;
            border-radius: 2px;
            transition: 0.25s translate ease, 0.25s rotate ease, 0.25s opacity ease;
        }

        .context {
            text-indent: 110%;
            white-space: nowrap;
            overflow: hidden;
            translate: -50% -50%;
        }

        &::before,
        &::after {
            rotate: 0deg;
        }

        &::before {
            translate: -50% -8px;
        }

        &::after {
            translate: -50% 6px;
        }

        &.is_active::before,
        &.is_active::after {
            translate: -50% -50%;
        }

        &.is_active::before {
            rotate: 45deg;
        }

        &.is_active::after {
            rotate: -45deg;
        }

        &.is_active .context {
            opacity: 0;
        }
    }
}

Double bonus!

We're using CSS logical properties here. Although not strictly needed, I'm making a conscious effort to use them because in doing so, you're preparing for future development now and allowing easier internationalisation should you need it!

Accessibility note

Be sure to make touch targets at least 44 x 44 CSS pixels minimum to meet WCAG 2.2 AAA standard

There's a lot more CSS here than needed to get the demo doing what we want, but this is a added nicety - the toggle will now have a pretty transition between open and closed states.

And finally, just a bit for the nav, nav list, and links inside the menu itself:

@media (max-width: 767px) {
    .nav {
        position: absolute;
        inset-block-start: calc(100% + 12px);
        inset-inline-start: 0;
        max-inline-size: calc(100% - 32px);
        background-color: #000;
        border-radius: 4px;
        opacity: 0;
        pointer-events: none;
        transition: 0.25s opacity ease, 0.25s translate ease;
        border: 2px solid #000;
        translate: 0 20px;
        z-index: 5;

        &.is_active {
            opacity: 1;
            pointer-events: auto;
            translate: 0 0;
        }
    }

    .nav ul {
        list-style: none;
        padding: 12px;
        margin-block: 0;
    }

    .nav a {
        color: #fff;
        text-decoration: none;
        transition: .25s color ease;

        &.is_active {
            color: #f00;
        }

        &:hover,
        &:focus {
            text-decoration: underline;
        }
    }
}

Adding context

Our menu toggle would be read out by screen readers as "Menu (Currently closed)" at the moment, which isn't very helpful when the menu is open. We're using the .context element as one of the lines in our burger menu style too, but it's here for another reason. We can help assitive technology users by adding a little context to the menu toggle.

What we need to do is get our class to switch between two states when the button is toggled. Our context element is already in place, so the next thing we need to do is add a couple more arguments into our constructor; one for the 'closed' state text, and one for the 'open' state text.

constructor (
    container,
    toggle,
    context,
    activeClass = 'is_active',
    openContextText = 'open',
    closeContextText = 'closed',
) {
    if (!container || !toggle || !context) {
        console.log('MobileNav. Exiting constructor. container:', container, 'toggle:', toggle, 'context:', context);
        return;
    }

    this.container = container;
    this.toggle = toggle;
    this.context = context;
    this.activeClass = activeClass;
    this.openContextText = openContextText;
    this.closeContextText = closeContextText;
}

Now we just need to update our _open() and _close() functions to alter the text based on the current state of the toggle. First, in the _open() function, add this line:

this.context.innerHTML = this.openContextText;

And in our _close() function, this one:

this.context.innerHTML = this.closeContextText;

Try it out. You'll now see that the context element changes between the words 'open' and 'closed' based on the menu state.

Make it even more accessible

We can make this even better too. Firstly, when our toggle is active and the menu is open we can set an ARIA attribute to let assistive software provide better context too.

Again in our _open() and _close() functions, we just need to add another line in each. In the _open() function:

this.toggle.setAttribute('aria-pressed', true);

And in the _close() function:

this.toggle.setAttribute('aria-pressed', false);

Open up /src/site/includes/nav.njk file and update the toggle button to the following:

<button
    type="button"
    id="site-nav-toggle"
    class="nav-toggle"
    aria-controls="main-nav"
    aria-pressed="false"
>
    Menu (Currently <span class="context" id="site-nav-context">closed</span>)
</button>

The aria-controls attribute tells assistive software what element on the page this button controls, so in this case our man nav element. We can also add an aria-label to our nav to provide an additional title too.

<nav id="site-nav" class="nav" aria-label="Site pages">...</nav>

We're starting to look in pretty good shape now. Our nav opens, closes, looks remotely good, and is fairly accessible too.