accessibilitywai-ariasection508

Properly hiding content from both keyboard and screen reader


We have a modal on a page that, when hidden, we want to not have keyboard users be able to tab into the content, nor have screen readers read.

To handle that, on the parent DIV, I've set up so that, when hidden, it has the following:

<div aria-hidden="true" tabindex="-1">
    [child HTML/content]
<div>

Unfortunately, this isn't working. You can still tab into the content and the content is read (at least via Chrome and using VoiceOver).

Ideall, we'd also set display: none-- which I may be able to do -- but at the moment we're dependant on some CSS transition animations so would need to set that programmatically after animation.

Prior to going that route, however, is there anything I am missing from my initial understanding that aria-hidden and the tabindex should be taking care of the issue?


Solution

  • Short Answer

    Using display:none without a transition would be the best option and would negate the need for aria-hidden.

    If you are required to have a transition then do the transition and then set the display: none property after the transition.

    Be careful for lost focus though if you transition is over 100ms, you have to do a lot of focus management to account for the delay in setting display:none.

    Longer Answer

    aria-hidden="true" removes an item and its children from the accessibility tree. However it does not stop children that can receive focus (i.e. an <input>) from receiving focus.

    tabindex="-1" will not remove focus from children elements that are already focusable.

    The simplest way to solve all problems would be to remove the transition and simply toggle the display property. This not only solves your focus problem but it also removes the need for aria-hidden, making things simpler.

    With that being said a transition may be part of your spec and unavoidable. If that is the case there are a few things to consider.

    In our discussions in the comments and within your question you mentioned using setTimeout to set the display property to none after the transition has completed.

    There is an issue with this approach depending on your design.

    If the next tab stop is within the area that is being hidden it is feasible that during the transition someone may navigate to an element within the area that is about to be hidden.

    If this were to happen focus on the page would be lost. Depending on the browser this could result in focus returning to the top of the page. This is something that would be highly frustrating and also probably constitute a fail under logical tab order / robustness in WCAG principles.

    What would be the best way to achieve an animation on hide?

    Because of the focus issue I would recommend the following process for hiding the content with a transition:-

    1. The second the button / code that causes the area to be hidden is activated (pre fade-out) set tabindex="-1" on all interactive elements within the <div> that is to be hidden (or if they are inputs set the disabled attribute).
    2. Start the transition by whatever means you are using (i.e. add the class to the item that will trigger the transition).
    3. After the transition is complete set the display: none on the item.
    4. Do the exact opposite if you wish to make the <div> visible again.

    By doing this you ensure that nobody can accidentally tab into the div and lose focus. This helps everyone who relies on keyboard for navigation, not just screen reader users.

    A very rough example of how to achieve this is below. It can be reused based on an ID of a container so hopefully will give you a good starting place to write something a little more robust (and less ugly! hehe)

    I have added comments to explain as best I can. I have set the transition to 2 seconds so you can inspect and see the order of things.

    Finally I have included some CSS and JS to account for people who have indicated they prefer reduced motion due to motion sensitivity. In this case the animation time is set to 0.

    Rough example accounting for hiding items to manage tabindex and restoring tabindex if made visible again.

    var content = document.getElementById('contentDiv');
    var btn = document.getElementById('btn_toggle');
    var animationDelay = 2000;
    
    //We should account for people with vestibular motion disorders etc. if they have indicated they prefer reduced motion. We set the animation time to 0 seconds.
    var motionQuery = matchMedia('(prefers-reduced-motion)');
    function handleReduceMotionChanged() {
      if (motionQuery.matches) {
        animationDelay = 0;
      } else { 
        animationDelay = 2000;
      }
    }
    motionQuery.addListener(handleReduceMotionChanged);
    handleReduceMotionChanged();
    
    
    
    //the main function for setting the tabindex to -1 for all children of a parent with given ID (and reversing the process)
    function hideOrShowAllInteractiveItems(parentDivID){  
      //a list of selectors for all focusable elements.
      var focusableItems = ['a[href]', 'area[href]', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', 'button:not([disabled])', '[tabindex]:not([disabled])', '[contenteditable=true]:not([disabled])'];
      
      //build a query string that targets the parent div ID and all children elements that are in our focusable items list.
      var queryString = "";
      for (i = 0, leni = focusableItems.length; i < leni; i++) {
        queryString += "#" + parentDivID + " " + focusableItems[i] + ", ";
      }
      queryString = queryString.replace(/,\s*$/, "");
          
      var focusableElements = document.querySelectorAll(queryString);      
      for (j = 0, lenj = focusableElements.length; j < lenj; j++) {
                
        var 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');
        }
      }
    }
    
    
    
    btn.addEventListener('click', function(){
      contentDiv.className = contentDiv.className !== 'show' ? 'show' : 'hide';
      if (contentDiv.className === 'show') {
         content.setAttribute('aria-hidden', false);
        setTimeout(function(){
          contentDiv.style.display = 'block';
          hideOrShowAllInteractiveItems('contentDiv');
        },0); 
      }
      if (contentDiv.className === 'hide') {
          content.setAttribute('aria-hidden', true);
          hideOrShowAllInteractiveItems('contentDiv');
        setTimeout(function(){
          contentDiv.style.display = 'none';
        },animationDelay); //using the animation delay set based on the users preferences.
      }
    });
    @keyframes in {
      0% { transform: scale(0); opacity: 0; visibility: hidden;  }
      100% { transform: scale(1); opacity: 1; visibility: visible; }
    }
    
    @keyframes out {
      0% { transform: scale(1); opacity: 1; visibility: visible; }
      100% { transform: scale(0); opacity: 0; visibility: hidden;  }
    }
    
    #contentDiv {
      background: grey;
      color: white;
      padding: 16px;
      margin-bottom: 10px;
    }
    
    #contentDiv.show {
      animation: in 2s ease both;
    }
    
    #contentDiv.hide {
      animation: out 2s ease both;
    }
    
    
    /*****We should account for people with vestibular motion disorders etc. if they have indicated they prefer reduced motion. ***/
    @media (prefers-reduced-motion) {
      #contentDiv.show,
      #contentDiv.hide{
        animation: none;
      }
    }
    <div id="contentDiv" class="show">
      <p>Some information to be hidden</p>
      <input />
      <button>a button</button>
      <button tabindex="1">a button with a positive tabindex that needs restoring</button>
    </div>
    
    <button id="btn_toggle"> Hide Div </button>