My WebDev Notes: A simple and accessible accordion

Habdul Hazeez - Apr 28 '20 - - Dev Community

The design pattern for the accordion is inspired by the first example in Sara Soueidan's article entitled: Accordion markup and the accordion keyboard navigation is based on code from W3C accordion example.

Check the accordion online: https://ziizium.github.io/my-webdev-notes/accordion/

Intoduction

An accordion is a graphical control element used for showing or hiding large amounts of content on a Web page. On a normal day, accordions are vertically stacked list of items that can be expanded or stretched to reveal the content associated with them.

Accordion gives control to people when it comes to reading a Web page content. The user can ignore the accordion or read its content by expanding it.

This simple but detailed post is about creating a usable and accessible accordion using HTML, CSS, and a lot of JavaScript (considering how small the accordion is). As stated earlier the accordion has to be accessible, therefore, we have to satisfy the following requirements:

  • The contents of the accordion must be readable without CSS.
  • The contents of the accordion must be accessible without JavaScript.
  • The user should be able to print the contents of the accordion.

In order to satisfy all three requirements mentioned above, we have to build the accordion with accessibility in mind and before every coding decision. We have to keep our users in mind and approach the development in a progressive enhancement manner.

This means we must start with semantic HTML, then we add some CSS that will not render the content of the accordion useless without it and finally we add JavaScript for the true accordion interactivity.

The HTML markup

As stated at the beginning of this post the design pattern for the accordion is inspired by an example from Sara Souiedan's post entitled: Accordion markup. The markup is giving in the image below.

An HTML code for a presumed accordion

When we convert this to code, users with CSS or JavaScript can access the content, then with JavaScript, we can convert it to the following markup accessible to users with a JavaScript-enabled browser:

An HTML code for a presumed accordion

The markup is giving in the snippet below:

    <header>
        <h1 id="h1" style="">Accordion</h1>
    </header>

    <main>
        <article class="accordion">
            <h2 class="accordion__title">First title</h2>
            <div class="accordion__panel">
                <p><!-- Put large text content here --></p>
            </div>
        </article>

        <article class="accordion">
            <h2 class="accordion__title">Second title</h2>
            <div class="accordion__panel">
                <p><!-- Put large text content here --></p>
            </div>
        </article>

        <article class="accordion">
            <h2 class="accordion__title">Third title</h2>
            <div class="accordion__panel">
                <p><!-- Put large text content here --></p>
            </div>
        </article>
    </main>
Enter fullscreen mode Exit fullscreen mode

When you load the file in your browser you'll get something similar to the image below:

HTML view of the accordion

This is our baseline experience and browsers with no support for CSS or JavaScript will have access to the accordion content.

The CSS and JavaScript code

Next, we need to add some basic styling to the elements on the page so that we have a better view of what we are working on.

 /* CSS reset */
* {
    padding: 0;
    margin: 0;
    box-sizing: border-box;
}
/* End of CSS reset */

/**
 * Cpsmetics styles just so you can see the
 * accordion on screen properly
 */
body {
    font-family: "Fira code", "Trebuchet Ms", Verdana, sans-serif;
}

header {
    padding: 1em;
    margin-bottom: 1em;
}

header > h1 {
    text-align: center;
    text-transform: uppercase;
    letter-spacing: 0.05em;
}

main {
    display: block;
    width: 100%;
}

@media screen and (min-width: 48em) {
    main {
        width: 70%;
        margin: 0 auto;
    }
}

p {
    font-family: Georgia, Helvetica, sans-serif;
    font-size: 1.2em;
    line-height: 1.618;
    margin: 0.5em 0;
}
/* End of Cosmetic styles */
Enter fullscreen mode Exit fullscreen mode

In its current state, the accordions are closer to each other and the contents are aligning with the headers, we need to change this. First, we apply some padding to push the content a little bit to the right, we change the background color and at the same time, we take care of overflow so that the content of one accordion won't affect the content of the subsequent accordion.

In the end, we add a margin between the edges of the accordions and some animation using CSS transitions so the accordion content can feel like sliding in and out of view. The next snippet will take care of this.

/**
 * The accordion panel is shown by default
 * and is hidden when the page loads the
 * JavaScript code.
*/
.accordion__panel {
    padding: 0 18px;
    background-color: #ffffff;
    overflow: hidden;
    transition: 0.6s ease-in-out;
    margin-bottom: 1em;
}
Enter fullscreen mode Exit fullscreen mode

When you reload your browser you will notice minor changes. Let's proceed.

Due to the way accordions work we need to hide the accordion panels before the user can expand or ignore it. We can not hide the panel by adding properties that will hide it directly to the accordion__panel class and later use JavaScript to remove these properties in order to show it because if we do this any user with JavaScript disabled in their browser will not be able to expand the panel and ultimately loose access to the accordion content.

The better approach is to write a CSS class that will hide the panel and then we can add this class to the accordion panel via JavaScript. Doing this any user who has JavaScript disabled in their browser will have access to the accordion content because JavaScript was unable to hide.

There are several ways to hide stuff in CSS. In our approach, we set the height and opacity of the panel to zero.

/* We hide it with JavaScript */
.accordion__panel.panel-js {
    max-height: 0;
    opacity: 0;
}
Enter fullscreen mode Exit fullscreen mode

Then we'll have to add this to the panel via JavaScript.

I made the assumption that you will use the format of the accordion HTML markup and the resulting JavaScript code in your projects and you won't like the variable declarations to mess up your codebase, therefore, all the code for our accordion will be placed in an Immediately Invoked Function Expression (IIFE). Doing this all the variables will only live inside the IIFE and won't pollute the global scope.

Create a script tag or a JavaScript file to save the JavaScript code and create an IIFE syntax as shown below:

(function () {
    // All JavaScript for the accordion should be inside this IIFE
})();
Enter fullscreen mode Exit fullscreen mode

Now, we can write code that will hide the panel. The approach is straight forward, we'll grab all the accordion panels and then add the .panel-js CSS code to each panel via the classList attribute.

/**
 * We hide the accordion panels with JavaScript
 */

let panels = document.getElementsByClassName('accordion__panel');

for (let i = 0; i < panels.length; i++) {
    panels[i].classList.add('panel-js');
}
Enter fullscreen mode Exit fullscreen mode

When you save your file and refresh your browser you will realize the panel is now hidden and all you'll see are the accordion titles.

Acoordion title

That view is boring, let's change it.

The approach we'll take is similar to how we hid the panels. First, we will grab all the accordion titles and we loop through the resulting NodeList and then we'll transform the accordion title to a button which will have a span element within it that will be the new accordion title. All this is inspired from the example taken from Sara's blog post.

As a refresher and to prevent you from scrolling to the beginning of this blog post, here is the image that we'll implement:

An HTML code for a presumed accordion

First, we grab all the accordion titles using document.getElementsByClassName, then we'll loop through the result and perform the following steps:

  • Create the button and span elements.
  • Create a text node from the accordion titles.
  • Append the text node to the newly created span elements.
  • Append the span element to the newly created button element.
  • Append the button to the accordion titles.
  • Delete the text in the accordion title since we already appended it to the newly created span element.
  • Set the button attributes.
  • Set the accordion panel attributes.

In code:

/**
 * We grab the accordion title and create
 * the button and span elements. The button
 * will serve as the accordion trigger and the
 * span element will contain the accordion title.
 *
 */

let accordionTitle = document.getElementsByClassName('accordion__title');

for (let i = 0; i < accordionTitle.length; i++) {

    // Create the button and span elements
    let button = document.createElement('button');
    let span = document.createElement('span');

    // We create a text node from the accordion title 
    let textNode = document.createTextNode(accordionTitle[i].innerHTML);

    // We append it to the newly created span element
    span.appendChild(textNode);

    // We append the span element to the newly created
    // button element
    button.appendChild(span);

    // Then we append the button to the accordion title
    accordionTitle[i].appendChild(button);

    // We delete the text in the accordion title
    // since we already grabbed it and appended it
    // to the newly created span element.
    button.previousSibling.remove();

    // Set the button attributes
    button.setAttribute('aria-controls', 'myID-' + i);
    button.setAttribute('aria-expanded', 'false');
    button.setAttribute('class', 'accordion__trigger');
    button.setAttribute('id', 'accordion' + i + 'id')

    // The next sibling of the accordion title
    // is the accordion panel. We need to attach the
    // corresponding attributes to it
    let nextSibling = accordionTitle[i].nextElementSibling;

    if (nextSibling.classList.contains('accordion__panel')) { // just to be sure
        // set the attributes
        nextSibling.setAttribute('id', 'myID-' + i);
        nextSibling.setAttribute('aria-labelled-by', button.getAttribute('id'));
        nextSibling.setAttribute('role', 'region');
    }

} // End of for() loop
Enter fullscreen mode Exit fullscreen mode

Save and refresh your browser. The titles are now HTML buttons and when you inspect a button with the Developer Tools you'll see the attributes we created.

The buttons are quite small because we have not styled them, let's change that!.

/**
 * This removes the inner border in Firefox
 * browser when the button recieves focus.
 * The selector is take from:
 *
 * https://snipplr.com/view/16931
 *
 */ 
.accordion__title > button::-moz-focus-inner {
    border: none;
}

.accordion__title > button {
    color: #444444;
    background-color: #dddddd;
    padding: 18px;
    text-align: left;
    width: 100%;
    border-style: none;
    outline: none;
    transition: 0.4s;
}

.accordion__title > button > span {
    font-size: 1.5em;
}

/* The .active is dynamically added via JavaScript */
.accordion__title.active > button,
.accordion__title > button:hover {
    background-color: #bbbbbb;
}

.accordion__title > button:after {
    content: "\02795"; /* plus sign */ 
    font-size: 13px;
    color: #777777;
    float: right;
    margin-left: 5px;
}

/**
 * When the accordion is active we change
 * the plus sign to the minus sign.
 */
.accordion__title.active > button:after {
    content: "\02796";  /* minus sign */ 
}
Enter fullscreen mode Exit fullscreen mode

Save and refresh your browser. We have a better view!

A better view of the accordion

There is a tiny little problem. When you click the button nothing happens, that is because we have not created two things:

  • The CSS code that will allow show us the panel.
  • The JavaScript code that will dynamically add and remove this CSS code.

Let's start with the CSS. If you remember from the .panel-js CSS code, we hid the panel by setting the max_height and opacity to zero. Now, we have to do the reverse to reveal the panel and its content.

/**
 * When the user toggle to show the accordion
 * we increase its height and change the opacity.
*/
.accordion__panel.show {
    opacity: 1;
    max-height: 500px;
}
Enter fullscreen mode Exit fullscreen mode

The JavaScript to reveal the panel is a little bit tricky. We'll attach an event listener to all accordion titles and perform the following steps:

  • Add the .active CSS class that we declared earlier when styling the buttons.
  • Grab the accordion panel.
  • Hide or show the panel based on the user interaction.
  • Count the accordion title child elements.
  • We expect it to be a single button so we get the tag name via its index.
  • If the child element is one and in fact a button, we perform the following
    • Save the child element in a variable.
    • We get its aria-expanded value.
    • If the aria-expanded value is false we set it to true otherwise we set it to false.

The resulting JavaScript code:

for (let i = 0; i < accordionTitle.length; i++) {

    accordionTitle[i].addEventListener("click", function() {

        // Add the active class to the accordion title
        this.classList.toggle("active");

        // grab the accordion panel
        let accordionPanel = this.nextElementSibling;

        // Hide or show the panel
        accordionPanel.classList.toggle("show");

        // Just to be safe, the accordion title
        // must have a single child element which
        // is the button element, therefore, we count
        // the child element
        let childElementCount = this.childElementCount;

        // We get the tag name
        let childTagName = this.children[0].tagName;

        // Then we check its just a single element and
        // it's in fact a button element
        if (childElementCount === 1 &&  childTagName === "BUTTON") {

            // If the check passed, then we grab the button
            // element which is the only child of the accordion
            // title using the childNodes attribute
            let accordionButton = this.childNodes[0];

            // Grab and switch its aria-expanded value
            // based on user interaction
            let accordionButtonAttr = accordionButton.getAttribute('aria-expanded');

            if (accordionButtonAttr === "false") {
                accordionButton.setAttribute('aria-expanded', 'true');
            } else {
                accordionButton.setAttribute('aria-expanded', 'false');
            }

        }

    });

} // End of for() loop
Enter fullscreen mode Exit fullscreen mode

Save your file and refresh your browser. Now, click the button to reveal or hide the accordion panel and its content.

Completed accordion image

There you go our accordion is complete! Or is it?

There are two problems in this completed accordion:

  • The user can not navigate the accordion with their keyboard
  • The user can not print the content of the accordion

The first point is evident when you hit the Tab key on your keyboard the accordion button does not receive focus.

For the second point, when the user prints the accordion they will only see the accordion title in the printed document. A print preview is shown below in Chrome:

Print preview in Chrome

This is quite easy to fix but to enable the keyboard navigation is not straight forward. Let's start with it then we'll fix the printing issue later.

If we want the user to navigate through the accordion with their keyboard we'll have to listen for events specifically on the accordion buttons which have a class titled .accordion__trigger. When we select all elements with this class name, we'll get a NodeList in return.

This NodeList has to be converted to an array. Why? Because when the user navigates through the accordion with their keyboard we must calculate the location of the next accordion using the index location of the current accordion and the number of accordions on the Web page. By this, you should know we are going to need the indexOf operator to get the location of the current accordion and the length property which will return the number of accordions on the Web page.

The length property is available to the NodeList but the indexOf is not. Hence, the conversion.

We'll use Array.prototype.slice.call() method to convert the NodeList to an array then we'll grab all accordions via their class name .accordion then loop through the result and perform the following steps:

  • Add an event listener to all accordions and we listen for the keydown event.
  • We get the target element which is the current element that has received the event.
  • We get the corresponding key that the user pressed on their keyboard.
  • We check if the user is using the PgUp or PgDn keys to navigate the accordion.
  • To be safe we make sure that the button truly has the .accordion__trigger class name then we perform the following steps:
    • We check if the user is using the arrow keys on their keyboard or if they are using it along with the Ctrl key then we perform the following steps:
      • Get the index of the currently active accordion.
      • Check the direction of the user arrow keys, if they are using the down key we set the value to 1 else we set it to -1.
      • Get the length of the array of accordion triggers.
      • Calculate the location of the next accordion.
      • Add a focus class to this accordion.
      • We prevent the default behavior of the buttons.
    • Else if the user is using the Home and End keys on their keyboard we do the following:
      • When the user presses the Home key we move focus to the first accordion.
      • When they press the End key we move focus to the last accordion.
      • We prevent the default behavior of the buttons.

All these steps converted to code is in the snippet below:

/**
 * The querySelectorAll method returns a NodeList
 * but we will like to loop through the triggers
 * at a later time so that we can add focus styles
 * to the accordion title that's why we convert
 * the resulting NodelIst into an array which will
 * allow us too used Array methods on it.
 */
let accordionTriggers = Array.prototype.slice.call(document.querySelectorAll('.accordion__trigger'));

for (let i = 0; i < accordion.length; i++) {

    accordion[i].addEventListener('keydown', function(event) {

    let target = event.target;

    let key = event.keyCode.toString();

     // 33 = Page Up, 34 = Page Down
    let ctrlModifier = (event.ctrlKey && key.match(/33|34/));

        if (target.classList.contains('accordion__trigger')) {
            // Up/ Down arrow and Control + Page Up/ Page Down keyboard operations
            // 38 = Up, 40 = Down
            if (key.match(/38|40/) || ctrlModifier) {

                let index = accordionTriggers.indexOf(target);

                let direction = (key.match(/34|40/)) ? 1 : -1;
                let length = accordionTriggers.length;

                let newIndex = (index + length + direction) % length;

                accordionTriggers[newIndex].focus();

                event.preventDefault();

            }

            else if (key.match(/35|36/)) {
              // 35 = End, 36 = Home keyboard operations
              switch (key) {
                // Go to first accordion
                case '36':
                  accordionTriggers[0].focus();
                  break;
                  // Go to last accordion
                case '35':
                  accordionTriggers[accordionTriggers.length - 1].focus();
                  break;
            }
                event.preventDefault();

            }
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

If you save your file and refresh your browser the keyboard navigation should work but you won't know the currently active accordion. The fix is simple, we have to add a focus style to the parent element of the currently active button (the accordion triggers) which is anh2 element. We remove the focus styles when the accordion is not active.

The CSS focus styles:

.accordion__title.focus {
    outline: 2px solid #79adfb;
}

.accordion__title.focus > button {
    background-color: #bbbbbb;
}
Enter fullscreen mode Exit fullscreen mode

The resulting JavaScript code:

// These are used to style the accordion when one of the buttons has focus
accordionTriggers.forEach(function (trigger) {

    // we add and remove the focus styles from the
    // h1 element via the parentElment attibuts
    trigger.addEventListener('focus', function (event) {
          trigger.parentElement.classList.add('focus');
    });

    trigger.addEventListener('blur', function (event) {
          trigger.parentElement.classList.remove('focus');
    });

});
Enter fullscreen mode Exit fullscreen mode

Final accordion image

To fix the print issue we have to revert the styles for the accordion panels to its initial state before it was hidden with JavaScript and some few modifications.

The reverted styles have to be placed in a media query targeting print media.

/**
* Print styles (Just in case your users
* decide to print the accordions content)
*/
@media print {
    .accordion__panel.panel-js {
        opacity: 1;
        max-height: 500px;
    }

    .accordion__title button {
        font-size: 0.7em;
        font-weight: bold;
        background-color: #ffffff;
    }

    .accordion__title button:after {
        content: ""; /* Delete the plus and minus signs */
    }
}
Enter fullscreen mode Exit fullscreen mode

The new print preview in Chrome:

Print preview in Chrome

With that, we are done with the accordion. The code is not perfect but it works and you can improve it.

The GitHub repo for this series:

GitHub logo ziizium / my-webdev-notes

Code snippets for series of articles on DEV about my experiments in web development

My WebDev Notes

This repositiory contains code snippets, and links for series of articles on DEV about my experiments in Web development.

List of articles






Have fun!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .