Search code examples
htmlaccessibilitywai-ariawcag

"[aria-hidden="true"] elements contain focusable descendents"; best way to fix that?


I have a simple accordion:

<div>
  <button aria-expanded='false' aria-controls='content'>
    Open accordion
  </button>
  <div id='content' aria-hidden='true'>
    This the accordion's hidden content.
  </div>
</div>

I use Javascript to open/close the accordion and set the aria-* tags.

Everything works fine but when I add links inside the content:

<div>
  <button aria-expanded='false' aria-controls='content'>
    Open accordion
  </button>
  <div id='content' aria-hidden='true'>
    This is the accordion's hidden content.
    <a href='https://www.google.com'>Go to Google</a>
  </div>
</div>

Lighthouse gives me this:

[aria-hidden="true"] elements contain focusable descendents

It seems to me that adding tabindex='-1' to the a tag solves the issue:

<a tabindex='-1' href='https://www.google.com'>Go to Google</a>

However, that makes the element "non-tabbable", which is not good for accessibility.

I could use JavaScript to manually query for all a tags inside an accordion's content but I am not even sure if this really solves the "issue" or is a dirty work-around? Are there any better alternatives?


Solution

  • Why are you getting this warning?

    This warning shows despite your use of aria-hidden="true" the majority of screen readers will ignore aria-hidden="true" on a parent item if the children can receive focus (to account for developer mistakes, JavaScript not loading correctly etc.).

    How to fix it?

    I am assuming that you are using something like a slide-up animation or similar which is why you don't simply use display: none on the content.

    If you aren't using an animation then just display: none the content when you have aria-hidden="true" applied (@Adam has shown you the perfect way to do that in his answer).

    Better yet you could just straight up use display: none on the div as this would hide all the content from a screen reader anyway so you wouldn't need to use WAI-ARIA there!

    I am using an animation / transition so can't just hide all the content.

    If you are using an animation, opacity changes etc., that stop you simply hiding the entire content then what you can do is to hide the focusable content within the content <div>.

    We can do this using CSS when you have aria-hidden="true" applied, then unhide the focusable elements when when you change or remove aria-hidden="true".

    Also to avoid "layout jank" when we unhide the elements again I would suggest using visibility: hidden rather than display: none as that leaves the space allocated for the hidden items and still hides them from screen readers etc.

    #content[aria-hidden=true] a[href],
    #content[aria-hidden=true] area[href], 
    #content[aria-hidden=true] input:not([disabled]), 
    #content[aria-hidden=true] select:not([disabled]), 
    #content[aria-hidden=true] textarea:not([disabled]), 
    #content[aria-hidden=true] button:not([disabled]), 
    #content[aria-hidden=true] [tabindex]:not([disabled]), 
    #content[aria-hidden=true] [contenteditable=true]:not([disabled]){
        visibility: hidden;
    }
    

    A quick Demo

    The easiest way to see what I mean is with a quick demo, press the "Toggle aria-hidden on" button and it will hide all of the focusable elements in the #content <div>.

    var btn = document.querySelector('#toggle-aria');
    var contentDiv = document.querySelector('#content');
    
    
    btn.addEventListener('click', function(){
        var self = this;
        if(contentDiv.getAttribute('aria-hidden') == "false"){
          self.innerHTML = "Toggle aria-hidden off";
          contentDiv.setAttribute('aria-hidden', "true"); 
        }else{
          self.innerHTML = "Toggle aria-hidden on";
          contentDiv.setAttribute('aria-hidden', "false"); 
        }
    });
    #content{
    padding: 20px;
    border: 1px solid #333;
    }
    
    #content[aria-hidden=true] a[href],
    #content[aria-hidden=true] area[href], 
    #content[aria-hidden=true] input:not([disabled]), 
    #content[aria-hidden=true] select:not([disabled]), 
    #content[aria-hidden=true] textarea:not([disabled]), 
    #content[aria-hidden=true] button:not([disabled]), 
    #content[aria-hidden=true] [tabindex]:not([disabled]), 
    #content[aria-hidden=true] [contenteditable=true]:not([disabled]){
        visibility: hidden;
    }
    <br/><br/><button id="toggle-aria">Toggle aria-hidden on</button>
    
    <p>When aria-hidden is set to true on the below div all focusable elements should disappear.</p>
    
    <hr/>
    
    <div id="content" aria-hidden="false">
        <a href="https://google.com">To Google</a><br/><br/>
        <label>An input
          <input />
        </label><br/><br/>
        <label>A Select 
          <select>
            <option>option 1</option>
            <option>option 2</option>
            <option>option 3</option>
          </select>
         </label><br/><br/>
         <label>A textarea
          <textarea></textarea>
        </label><br/><br/>
        <button>A button</button><br/><br/>
        <div tabindex="0">A fake button with tabindex</div><br/><br/>
        <div contenteditable="true">A div that is cotnent editable</div><br/><br/>
        <p>Only the labels and this paragraph should be the only things left showing when you toggle the aria-hidden to true</p>
        
    </div>
    <br/><br/>
    <button>I am a button purely so you can see there is nothing focusable within the #content div if you "Tab"</button>

    Important note: The second you press the "open accordion" button you should remove aria-hidden before any animations etc. This is because screen reader users will then try and Tab (or navigate via screen reader shortcuts) into the content. If you remove aria-hidden="true" too late they may skip right past all the content!