Search code examples
javascripthtmlcssaccessibility

Two problems with tabbing an accessible pop-out navigation I use some advice on


I am trying to create an accessible navigation menu for a portfolio-style website. When the screen is less than a certain width (768px for this demo) the horizontal menu navigation disappears and is replaced with a 'burger'. The burger's parent div has a JavaScript onclick and onkeydown function so that when the burger is clicked or tapped, or a keyboard user focuses on it and uses presses spacebar or enter, a div 'sidenav' opens up from the side containing the vertical navigation menu.

However, if a keyboard user continues tabbing, the focus carries on down the page behind the navigation as if 'sidenav' was not there and when the tabbing eventually finds its way to the top of the sidenav, the first element it encounters is the close button and tabbing closes the menu, so there's no way of reaching the menu items.

So: Question 1: How can I trigger the focus to 'jump' to the newly opened navigation when the burger is keyed down?

Question 2: How can I make the 'close' button ignore tabbing and only work with the spacebar or enter key?

Here is a simplified version of my menu (you may need to go full screen to see the horizontal menu).

/* detect keyboard users */
function handleFirstTab(e) {
    if (e.keyCode === 9) { // the "I am a keyboard user" key
        document.body.classList.add('user-is-tabbing');
        window.removeEventListener('keydown', handleFirstTab);
    }
}
window.addEventListener('keydown', handleFirstTab);

/*  Open Sidenav 
-------------------*/
function openNav() {
    let element = document.querySelector('ul.menucontent');
    if (element.classList.contains('menucontent')) {
    element.classList.remove('menu-a');
    element.classList.add('menu-b');
    };
    let element3 = document.querySelector('div.sidenav');
    let element4 = document.querySelector('.closebtn');
    if (element3.classList.contains('sidenav')) {
    element3.style.width = "350px";
    element4.style.visibility = "visible";
    };
  document.getElementById('vmenu').focus();
}
function closeNav() {
    let element = document.querySelector('ul.menucontent');
    if (element.classList.contains('menucontent')) {
    element.classList.remove('menu-b');
    element.classList.add('menu-a');
    };
    let element3 = document.querySelector('div.sidenav');
    let element4 = document.querySelector('.closebtn');
    if (element3.classList.contains('sidenav')) {
    element3.style.width = "0";
    element4.style.visibility = "hidden";
    };
}
// Toggle content  
    for (const selector of [".toggle-btn",]) {
        const toggleButtons = [...document.querySelectorAll(selector)];
        for (const toggleButton of toggleButtons) {
            toggleButton.addEventListener('click', () => {
                toggleButtons.filter(b => b !== toggleButton).forEach(b => {
                    b.nextElementSibling.classList.remove('reveal-content');
                });
                toggleButton.nextElementSibling.classList.toggle('reveal-content');
            });
        };
    }
    for (const selectorTwo of [".close-btn",]) {
        const closeButtons = [...document.querySelectorAll(selectorTwo)];
        for (const closeButton of closeButtons) {
            closeButton.addEventListener('click', () => {
                closeButton.parentElement.classList.toggle('reveal-content');
            });
        };
    }
body.user-is-tabbing button > a:focus {
  border: none;
}
body:not(.user-is-tabbing) a:focus,
body:not(.user-is-tabbing) button:focus,
body:not(.user-is-tabbing) input:focus,
body:not(.user-is-tabbing) select:focus,
body:not(.user-is-tabbing) textarea:focus {
  outline: none;
}
.container-fluid, 
.container {
  margin-right: auto;
  margin-left: auto;
  width: 100%;
}
.d-block
.d-none {
  display: none;
}
.sidenav {
  height: 100%;
  width: 0;
  position: fixed;
  z-index: 996;
  top: 0;
  left: 0;
  background-color: #fff;
  overflow-x: hidden;
  transition: 0.5s;
  padding: 1rem 0 0;
  box-shadow: 0 2px 5px #acaaaa;
}
.trigram {
  position: relative;
  top: 0;
  left: 0;
  margin-bottom: 1rem;
  padding: 0;
  background: transparent;
  z-index: 995;
  width: 2rem;
}
.burger {
  position: relative;
  border-top: 0.15rem solid green;
  border-bottom: 0.15rem solid green;
  background: transparent;
  height: 1.5rem;
  width: 2rem;
}
.burger::after {
  position: absolute;
  content: "";
  border-top: 0.15rem solid green;
  top: 40%;
  left: 0;
  width: 2rem;
}
.sidemenu {
  position: relative;
  top: 5rem;
}
.mm ul {
  list-style: none;
}
.mm li {
  margin: 1rem 0;
  padding: 0 0 0 1rem;
}
.mm .menucontent.menu-a {
  display: none;
}
.mm .menucontent.menu-b {
  display: flex;
  display: -webkit-flex;
  flex-direction: column;
  justify-content: normal;
  margin: 0 0 1rem;
  padding: 0;
  position: relative;
  top: 0;
  z-index: 997;
  overflow-y: auto;
  }
.closebtn {
  border-bottom: none;
  font-size: 2.25rem;
  margin: 0;
  position: absolute;
  top: 2rem;
  right: 2rem;
  z-index: 998;
}
@media only screen and (min-width: 768px){
  .d-none {
    display: none;
  }
  .d-md-block {
    display: block;
  }
  .trigram {
    display: none;
  }
  .mm .menucontent.menu-a {
    position: relative;
    padding: 0;
    margin: 0 auto;
    white-space: nowrap;
    display: flex;
  }
  .mm .menucontent.menu-a,
  .mm .menucontent.menu-b {
    flex-direction: row;
    justify-content: center;
  }
  .mm li {
    padding: 0 0.5rem;
  }
   .main-menu-container {
    border-top: 2px solid green;
    border-bottom: 2px solid green;
    padding: 0.5rem 0;
    margin: 1rem 0;
  }
}
<div id="sidenav" class="sidenav">
    <div id="closebtn" class="closebtn">
        <a href="javascript:void(0)" onclick="closeNav()" onkeydown="closeNav()" role="button" tabindex="0" aria-label="close navigation">&times;</a>
    </div>
    <div id="vmenu" class="sidemenu d-md-none mm">
        <nav aria-label="Main Navigation" class="menuouter ">
            <ul class="menucontent menu-a" role="menubar">
                <li class="item-101 default current active single top-level" role="none" tabindex="-1">
                    <a href="#" title="Side menu Home" class="icon-home">Side menu Home</a>
                </li>
                <li class="item-128 single" role="none" tabindex="-1">
                    <a href="#" title="Side menu page 2">Side menu page 2</a>
                </li>
            </ul>
        </nav>
    </div>
</div>
<div class="container-fluid menu-outer d-block d-md-none">
    <div id="trigram" class="trigram" role="button" tabindex="0" aria-label="open navigation" aria-controls="sidenav" aria-haspopup="true" onclick="openNav()" onkeydown="openNav()">
        <div class="burger" style="cursor:pointer" >&nbsp;</div>
    </div>
</div>
<div class="container-fluid d-none d-md-block">
    <div class="main-menu-container">
        <div id="hmenu" class="row d-none d-md-block main-menu mm">
            <nav aria-label="Main Navigation" class="menuouter ">
                <ul class="menucontent menu-a" role="menubar">
                    <li class="item-101 default current active single top-level" role="none" tabindex="-1">
                        <a href="#" title="Horizontal menu Home" class="icon-home">Horizontal menu Home</a>
                    </li>
                    <li class="item-128 single" role="none" tabindex="-1">
                        <a href="#" title="Horizontal menu page 2">Horizontal menu page 2</a>
                    </li>
                </ul>
            </nav>
        </div>
    </div>
</div>
<div>This is some text. It has a link in it: <a href="#">This is the first link</a></div>
<div>Here is some more text with more links. It has a link in it: <a href="#">This is the second link</a>. Integer mauris sem, convallis ut, consequat in, sollicitudin sed, leo.</div>
<div>Sed lacus velit, consequat in, ultricies sit amet, malesuada et, diam. Integer mauris sem, convallis ut, consequat in, sollicitudin sed, leo. <a href="#">This is the third link </a>Cras purus elit, hendrerit ut, egestas eget, sagittis at, nulla. Integer justo dui, faucibus dictum, convallis sodales, accumsan id, risus. Aenean risus. Vestibulum scelerisque placerat sem.</div>


Solution

  • After a lot of code fiddling to customise the two answers above to my specific requirements - with many thanks for the guidance from Graham Ritchie - and also from AVD who provided a similar answer but less code - I managed to get the code to work exactly as I want, including using only CSS classes and no IDs to avoid potential duplicate IDs.

    // detect keyboard users
    function handleFirstTab(e) {
        if (e.key === "Tab") { // the "I am a keyboard user" key
            document.body.classList.add('user-is-tabbing');
            window.removeEventListener('keydown', handleFirstTab);
        }
    }
    window.addEventListener('keydown', handleFirstTab);
    /////////////////////HIDING ALL OTHER CONTENT FROM SCREEN READERS///////////////////////
    /// `var` changed to `let`
    let content = document.querySelector('.mainContent');
    let menuBtn = document.querySelector('.open-menu');
    let closeMenuBtn = document.querySelector('.close-menu');
    let openSidenav = document.querySelector('.sidenav');
    let menu = document.querySelector('.menu');
    let focusableItems = ['a[href]', 'area[href]', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', 'button:not([disabled])', '[tabindex]:not([disabled])', '[contenteditable=true]:not([disabled])'];
    
    //the main function for setting the tabindex to -1 for all children of a parent with given ID (and reversing the process)
    function hideOrShowAllInteractiveItems(mainContent){
    
    //build a query string that targets the parent div ID and all children elements that are in our focusable items list.
    let queryString = "";
    for (i = 0, leni = focusableItems.length; i < leni; i++) {
        queryString += "." + mainContent + " " + focusableItems[i] + ", ";
    }
    queryString = queryString.replace(/,\s*$/, "");
        
        let focusableElements = document.querySelectorAll(queryString);
        for (j = 0, lenj = focusableElements.length; j < lenj; j++) {
    
            let el = focusableElements[j];
            if(!el.hasAttribute('data-modified')){ // we use the 'data-modified' attribute to track all items that we have applied a tabindex to (as we can't use tabindex itself).
    
            // we haven't modified this element so we grab the tabindex if it has one and store it for use later when we want to restore.
                if(el.hasAttribute('tabindex')){
                    el.setAttribute('data-oldTabIndex', el.getAttribute('tabindex'));
                }
    
                el.setAttribute('data-modified', true);
                el.setAttribute('tabindex', '-1'); // add `tabindex="-1"` to all items to remove them from the focus order.
    
                } 
            else {
                //we have modified this item so we want to revert it back to the original state it was in.
                el.removeAttribute('tabindex');
                if(el.hasAttribute('data-oldtabindex')){
                    el.setAttribute('tabindex', el.getAttribute('data-oldtabindex'));
                    el.removeAttribute('data-oldtabindex');
                }
                el.removeAttribute('data-modified');
            }
        }
    }
    
    let globalLets = {};
    
    function openMenu(){
        menu.classList.add("open");
        openSidenav.classList.add('open-sidenav');
        menuBtn.setAttribute('aria-expanded', true);
        
        //get all the focusable items in our menu and keep track of the button that opened the menu for when we close it again.
        setFocus(menuBtn, 'menu');
        
        content.setAttribute("aria-hidden", true);
    }
    
    function closeMenu(){
        //close menu
        //unhide the main content
        content.removeAttribute("aria-hidden");
        //hide the menu
        menu.classList.remove("open");
        openSidenav.classList.remove('open-sidenav');
        // set `aria-expanded` - important for screen reader users.
        menuBtn.setAttribute('aria-expanded', false);
        //set focus back to the button that opened the menu if we can
        if (globalLets.beforeOpen) {
            globalLets.beforeOpen.focus();
        }
    }
    
    //toggle the menu
    menuBtn.addEventListener('click', function(){
    //use our function to add the relevant `tabindex="-1"` to all interactive elements outside of the menu.
    hideOrShowAllInteractiveItems('mainContent');
    //check if the menu is open, if it is close it and reverse everything.
    openMenu();
    });
    
    closeMenuBtn.addEventListener('click', function(){
    //use our function to add the relevant `tabindex="-1"` to all interactive elements outside of the menu.
    hideOrShowAllInteractiveItems('mainContent');
    //check if the menu is open, if it is close it and reverse everything.
    closeMenu();
    });
    
    ////// Additional Javascript to close the pop-out menu and revert hidden elements if a user increases the screen width, e.g. by rotating a screen from portrait to landscape, without closing the pop-out menu first.//////
    
    window.addEventListener('resize', wideScreen);
    function wideScreen() {
        let ww = window.matchMedia("(min-width: 992px)");
        if (ww.matches) {
            let outer = document.querySelector('.mainContent');
            if (outer.hasAttribute("aria-hidden", true)) { 
            hideOrShowAllInteractiveItems('mainContent');
            closeMenu();
            }
        }
    }
    //////////////////////////////////TRAPPING FOCUS//////////////////////////////////
    
    let setFocus = function (item, className) { //we pass in the button that activated the menu and the className of the menu list, your menu must have a unique className for this to work.
    
        className = "sidenav" || "content"; //defaults to class 'content' in case of error ("content" being the class on the <main> element.)
        globalLets.beforeOpen = item; //we store the button that was pressed before the modal opened in a global letiable so we can return focus to it on modal close.
    
        let findItems = [];
        for (i = 0, len = focusableItems.length; i < len; i++) {
            findItems.push('.' + className + " " + focusableItems[i]); //add every focusable item to an array.
        }
        // finally add the open button to our list of focusable items as it sits outside our menu list. 
        
    
        let findString = findItems.join(", ");
        globalLets.canFocus = Array.prototype.slice.call(document.querySelectorAll(findString)); 
        if (globalLets.canFocus.length > 0) {
            globalLets.canFocus[0].focus(); //***set the focus to the first focusable element within the modal
            globalLets.lastItem = globalLets.canFocus[globalLets.canFocus.length - 1]; //we also store the last focusable item within the modal so we can keep focus within the modal. 
        }
    }
    
    //listen for keypresses and intercept both the Esc key (to close the menu) and tab and shift tab while the menu is open so we can manage focus.
    document.onkeydown = function (evt) {
        evt = evt || window.event;
        if (evt.key === "Escape") {
            //unhide the main content - exactly the same as in the btn event listener.
         hideOrShowAllInteractiveItems('mainContent');
         closeMenu();
        }
      if (menu.classList.contains('open') && evt.key == "Tab") { //global letiable to check any modal is open and key is the tab key
            if (evt.shiftKey) { //also pressing shift key
                if (document.activeElement == globalLets.canFocus[0]) { //the current element is the same as the first focusable element
                    evt.preventDefault();
                    globalLets.lastItem.focus(); //we focus the last focusable element as we are reverse tabbing through the items.
                }
            } else {
            console.log(document.activeElement, globalLets.lastItem);
                if (document.activeElement == globalLets.lastItem) { //when tabbing forward we look for the last tabbable element 
                    evt.preventDefault();
                    
                    globalLets.canFocus[0].focus(); //move the focus to the first tabbable element.
                }
            }
        }
    };
    body.user-is-tabbing button > a:focus {
      border: none;
    }
    body:not(.user-is-tabbing) a:focus,
    body:not(.user-is-tabbing) button:focus,
    body:not(.user-is-tabbing) input:focus,
    body:not(.user-is-tabbing) select:focus,
    body:not(.user-is-tabbing) textarea:focus {
      outline: none;
    }
    .menu{
    display: none;
    }
    .menu.open{
      display: block;
    }
    .sidenav {
      display: none;
      width: 0;
      background-color: #fff;
      box-shadow: 0 2px 5px #acaaaa;
    }
    .sidenav.open-sidenav {
      display: block;
      position: fixed;
      height: 100%;
      top: 0;
      left: 0;
      width: 150px;
    }
    <div class="sidenav"><!-- the sidenav is hidden and invisible until the 'open' button is clicked/pressed -->
        <button class="close-menu">Close Menu</button>
        <!-- the close button is inside the sidenav but separate from the menu as the menu is dynamically created -->
        <ul class="menu">
            <li><a href="https://google.com">Google</a></li>
            <li><a href="https://bbc.co.uk">BBC</a></li>
            <li><a href="https://msn.com">MSN</a></li>
        </ul>
    </div>
    <div class="mainContent">
        <main>
            <p>Some information</p>
            <button class="open-menu">Menu</button><!-- the open button is within the main content -->
            <p>Some more information</p>
            <input />
            <button>a button</button>
        </main>
        <footer>
            <button tabindex="0">a button with a positive tabindex that needs restoring</button>
        </footer>
    </div>