Search code examples
htmlreactjsbuttonnestedaccessibility

Accessible nested button inside button?


I'm trying to build a button that looks like Zoom's button.

Zoom button inside button

Here there is a button to pick a device inside the camera button. I'd like to create something similar, where you have a button and another button that expands a picker inside it.

How can you create this in an accessible way?

If I nest buttons in React, it throws errors that you can't nest a button inside another. Zoom's equivalent would be:

<button>
  Stop Video
  <button>Pick Device</button>
</button>

which doesn't work. How would you create an interface like this so it stays accessible (and valid)?


Solution

  • Preword

    Don't nest interactive elements

    There is a reason that it isn't valid HTML to nest buttons or hyperlinks, it causes nightmares for knowing which action should be performed on a click (for a start) and for assistive technology this makes things even worse as it can confuse the accessibility tree as to what it should present to screen readers.

    The answer

    If you look carefully you will see they aren't actually nested, the "picker" button is placed on top of the other button.

    Now there is an issue here in terms of accessibility, click / tap target size.

    A button / interactive element should be no less than 44px by 44px

    So the Zoom example you gave fails this criteria. Additionally the tooltip that says "stop video" looks wrong if you have the picker selected as that should be the tooltip for the button that is currently hovered.

    So how could we create an accessible version of what you want?

    I would recommend having a large button with a 44 by 44 button placed on top to the right.

    This can easily be done with absolute positioning.

    To ensure that it is evident visually that the buttons are related I inset the second button by 2px.

    The below is not a complete example but I have given you a start.

    I added aria-expanded to the button that opens the sub menu, this gets toggled when the menu is opened.

    I also added the aria-haspopup attribute to let users know that this button opens a sub menu.

    I also added aria-controls to let assistive technology know the relationship between the button and the menu it opens.

    Finally you will see I added a <span> with some visually hidden text inside so that screen reader users know that the picker button opens the video controls.

    The example maintains logical tab order and is pretty accessible, but there are still things such as being able navigate the menu buttons with the arrow keys, closing the menu with Esc key and returning focus to the button that opened the menu etc. that you need to implement yourself. Oh and styling obviously!

    var mainButton = document.querySelector('.main-button');
    var menuToggle = document.querySelector('.sub-button');
    var menu = document.getElementById('controls');
    
    mainButton.addEventListener('click', function(){
    
      alert("clicked the main button");
    });
    
    menuToggle.addEventListener('click', function(){
    
      if(menu.classList.contains('open')){
           menu.classList.remove('open');
           menuToggle.setAttribute('aria-expanded', false);
       }else{
          menu.classList.add('open');
          menuToggle.setAttribute('aria-expanded', true);
       }
    });
    .container{
      position: relative;
      width: 144px;
      height: 48px;
    }
    .main-button{
       width: 144px;
       height: 48px;
       padding-right: 50px;
    }
    .sub-button{
       position: absolute;
       width: 44px;
       height: 44px;
       top:2px;
       right:2px;
    }
    .visually-hidden { 
        border: 0;
        padding: 0;
        margin: 0;
        position: absolute !important;
        height: 1px; 
        width: 1px;
        overflow: hidden;
        clip: rect(1px 1px 1px 1px); /* IE6, IE7 - a 0 height clip, off to the bottom right of the visible 1px box */
        clip: rect(1px, 1px, 1px, 1px); /*maybe deprecated but we need to support legacy browsers */
        clip-path: inset(50%); /*modern browsers, clip-path works inwards from each corner*/
        white-space: nowrap; /* added line to stop words getting smushed together (as they go onto seperate lines and some screen readers do not understand line feeds as a space */
    }
    #controls{
      display: none;
    }
    #controls.open{
      display: block;
    }
    <div class="container">
    <button class="main-button">Stop Video</button>
    <button class="sub-button" aria-expanded="false" aria-haspopup="true" aria-controls="controls">&#8964; <span class="visually-hidden">Pick Device</span></button>
    <ul id="controls"
          role="menu"
          aria-labelledby="sub-button">
          <li><button>Option 1</button></li>
          <li><button>Option 2</button></li>
          </ul>
    </div>