loading...
Cover image for Elementary Element Queries

Elementary Element Queries

renascent479 profile image Serena ・4 min read

Opinions expressed here are my own and do not necessarily reflect that of my employer

CSS Media queries are a great tool that made true responsive websites possible. But they do have limitations. Lets say you have a navigation menu that's designed to be a maximum of 700px wide. We also want it to be responsive so we'll add a media query breakpoint to reflow the layout for smaller screens.

.header {
  max-width: 700px;
  width: 100%;
  margin: 0 auto;
}

.nav {
  display: flex;
  flex-wrap: wrap;
}

a {
  flex: 1 0 auto;
  padding: 4px 8px;
}

@media (min-width: 700px) {
    .nav {
        /* Mobile specific styles */
    }
}

Looks great! Easy right? Actually, no.

What looks okay now, doesn’t necessarily work in multiple languages. When this menu is localized, we may get something like this:

Not very pretty. We could adjust our breakpoints to account for this locale, but then we're starting to head down the path of coding for edge cases. And when you're dealing with a dozen plus languages, this quickly becomes unmanageable. At this point, media queries aren't enough and we need a new trick in our toolkit.

Initiating the hack

Enter element queries. This is an idea that's been thrown around for many years - It takes the idea of a media query and applies it to a specific DOM element.

Unfortunately there's no native browser implementation at this moment, so we'll need to polyfill this with Javascript. The goal here is to determine if our menu has overflowed into a multi-line menu, then add a class that'll switch it to the a vertical layout menu.

So what would be the best way to detect when the nav is wrapped? One way would be to query the first and last elements and compare their relative top positions through the use of element.offsetTop. If they're not equivalent, then that means the flex element has wrapped.

const navItemElm = document.querySelectorAll('.nav-item');
const navItemElmFirst = navItemElm[0];
const navItemElmLast = navItemElm[navItemElm.length - 1];

//navElmFirst.offsetTop would yield a value like '8'
//navElmLast.offsetTop would yield a value greater than '8' if it wrapped

With those bits of knowledge, lets begin.

Beacon in place

We'll create a standard Javascript module with an event listener setup.

export default class NavToggle {
    constructor() {
        this.checkNavWrapStatus();
    }

    init() {
        this.bindWindowListeners();
        setTimeout(this.checkNavWrapStatus.bind(this), 500);
    }

    bindWindowListeners() {
        window.addEventListener('resize', this.checkNavWrapStatus);
    }

    checkNavWrapStatus() {}
}

Currently, all that's happening here is we have an event listener binded to the browser resize event. This is there to trigger the check whether the nav is wrapped or not, regardless of the initial browser size.

We can now put in the offsetTop snippet from earlier. If those two elements aren't equal, we throw in a is-wrapped state class on the nav.


export default class NavToggle {
...

    checkNavWrapStatus() {
        const navWrapperElm = document.querySelector('.nav');
        const navItemElm = document.querySelectorAll('.nav-item');
        const navItemElmFirst = navItemElm[0];
        const navItemElmLast = navItemElm[navItemElm.length - 1];

        if(navItemElmFirst.offsetTop !== navItemElmLast.offsetTop) {
            navWrapperElm.classList.add('is-wrapped');
        }
    }

}

Assuming I wrote everything correct, the is-wrapped will be added to the .nav element, and we can give it a new, vertical layout.

If you hold the information, you hold all the cards.

So what was the reason we put in that event listener? This is because we still have the case where the user may have started with a smaller browser window, but then expanded it enough that they're able to see the full, desktop menu.

The question is how can we know the user's viewport is large enough to support the full width nav when it's in the vertical state? It's a tough problem to figure out, but it's actually pretty logical when you think about it.

navWrapperElm.classList.remove('is-wrapped');
navWrapperElm.classList.add('is-wrapped');

What would you see when you run the code above? Absolutely nothing. The process happens fast enough that we don't see the nav layout changing.

Lets take advantage of this:

export default class NavToggle {
...

    checkNavWrapStatus() {
        ...
        navWrapperElm.classList.remove('is-wrapped');
        if(navItemElmFirst.offsetTop !== navItemElmLast.offsetTop) {
            navWrapperElm.classList.add('is-wrapped');
        }
    }

}

We remove the is-wrapped class, giving us the regular horizontal menu. If it's wrapped, it'll add the is-wrapped class back in, immediately switching the menu back to vertical layout with the user none the wiser.

Hack the planet

This was a very basic (and horrendously inefficient) implementation that was done in a lazy afternoon. In the future, I want to playing with Observers in place of the resize events to further optimize performance, as well as making it more modular to be used on any element.

Alternatively, if you want a more robust, ready-to-use, solution, there's also libraries such as EQCSS that come with many more features.

Till next time!

Discussion

pic
Editor guide
Collapse
moopet profile image
Ben Sinclair

Maybe you could hide the wrapped element then set its width ever-larger until it's all on one line, thereby finding out the breakpoint for this particular menu regardless of how it's filled. Then on window resize add the class dependent on this saved breakpoint size.

Maybe that's overkill also :)

Collapse
marconicolodi profile image
Collapse
val_baca profile image
Valentin Baca

Neat!

P.S. Love the Sombra gifs. boop

Collapse
schnubb profile image
Schnubb

Heart for the gifs, pony for the code! ;)