Prevent background items from receiving focus while modal overlay is covering them?
Asked Answered
R

10

17

I am working on making an overlay modal more accessible. It works essentially like this JSFiddle. When you open the modal, the focus doesn't properly go into the modal, and it continues to focus on other (hidden, background) items in the page.

You can see in my JSFiddle demo that I have already used aria-controls, aria-owns, aria-haspopup and even aria-flowto.

<button 
  aria-controls="two" 
  aria-owns="true" 
  aria-haspopup="true"
  aria-flowto="two"
  onclick="toggleTwo();"
  >
  TOGGLE DIV #2
</button>

However, while using MacOS VoiceOver, none of these do what I intend (though VoiceOver does respect the aria-hidden that I set on div two).

I know that I could manipulate the tabindex, however, values above 0 are bad for accessibility, so my only other option would be to manually find all focusable elements on the page and set them to tabindex=-1, which is not feasible on this large, complicated site.

Additionally, I've looked into manually intercepting and controlling tab behavior with Javascript, so that the focus is moved into the popup and wraps back to the top upon exiting the bottom, however, this has interfered with accessibility as well.

Radiomicrometer answered 10/7, 2017 at 17:15 Comment(1)
very well asked!Prine
J
5

Focus can be moved with the focus() method. I've updated the jsFiddle with the intended behavior. I tested this on JAWS on Windows and Chrome.

I've added a tabindex="-1" on the "two" div to allow it to be focusable with the focus method.

I split the toggle function into two functions, this can probably be refactored to fit your needs, but one function sets the aria-hidden attribute to true and moves the focus on the newly opened modal, and the other function does the reverse.

I removed the excessive aria attributes, the first rule of aria is to only use it when necessary. This can cause unexpected behavior if you're just mashing in aria.

To keep focus within the modal, unfortunately one of the best options is to set all other active elements to tabindex="-1" or aria-hidden="true". I've applied an alternative where an event listener is added to the last element in the modal upon tabbing. To be compliant, another listener must be added to the first element to move focus to the last element upon a shift+tab event.

Unfortunately, to my knowledge there isn't a cleaner answer than those above solutions to keeping focus within a modal.

Jaquenette answered 11/7, 2017 at 3:11 Comment(2)
The above fix did work when the modal is open. But when the modal is not open, the focus doesn't move to the divs after it, because you have written tabindex="-1". Can you pls help?Anywise
It looks like the document.getElementById('lastItemInModal') method is the issue. That event listener is still there and will try to place focus on the div even when the modal is disabled (I can still place focus in the modal when it is faded, which is another issue). You could make a boolean check to see if the modal is open first. Or add and remove that event listener depending on if the modal is open.Jaquenette
C
5

Use role = "dialog" aria-modal="true" on your modal popup

Cattleman answered 16/4, 2019 at 7:44 Comment(1)
This isn't enough for mobile SRs. You should also use inert attribute on the overlayed content.Gunk
R
3

In the future this could be solved with the inert attribute: https://github.com/WICG/inert/blob/7141197b35792d670524146dca7740ae8a83b4e8/explainer.md

Rasberry answered 29/8, 2019 at 10:24 Comment(1)
The inert attribute appears to be widely available today.Mercedes
I
2
  1. aria-disabled vs aria-hidden

First, note that aria-hidden is not intended to be used when the element is visible on the screen:

Indicates that the element and all of its descendants are not visible or perceivable to any user

The option you should use is aria-disabled

Indicates that the element is perceivable but disabled, so it is not editable or otherwise operable.

  1. on using tabindex

Removing a link from the tabindex is a WCAG failure if this link is still perceivable from a screenreader or clickable. It has to be used conjointly with aria-disabled or better the disabled attribute.

  1. Disabling mouse events using pointer-events css property

The easiest way to disable mouse events is by using the pointer-events css property:

 pointer-events: none;
  1. Disabling keyboard focus

The jQuery :focusable selecter is the easiest thing you could use

$("#div1 :focusable").attr("tabindex", -1);

sample code

$("#div1 :focusable")
.addClass("unfocus")
.attr("tabindex", -1)
.attr("disabled", true);

$("button").on("click", function(){
    $(".unfocus").attr("tabindex", 0)
    .removeClass("unfocus")
    .removeAttr("disabled");
});
.unfocus {
  pointer-events: none;
  
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="https://code.jquery.com/ui/1.12.0/jquery-ui.min.js"></script>
<div id="div1">
    <a href="">non clickable link</a>
    <div tabindex="0">
      non focusable div
    </div>
</div>

<div id="div2">
    <button>click here to restore other links</button>
</div>
Incontinent answered 11/7, 2017 at 18:46 Comment(0)
C
2

Make the first and the last focusable element of your modal react on event, resp. on pressing tab and shift+tab. As far as I tested, it works everywhere.

Example:

function createFocusCycle (first, last) {
first.addEventListener('keydown', function(e){
if (e.keyCode===9 && e.shiftKey) {
last.focus();
e.preventDefault();
}});
last.addEventListener('keydown', function(e){
if (e.keyCode===9) {
first.focus();
e.preventDefault();
}});
}

Naturally, you need to know what is the first and the last focusable element of your modal. Normally it shouldn't be too complicated. Otherwise if you don't know what are the first and last focusable elements of your modal, it's perhaps a sign that you are making a too complex UI.

Cashew answered 30/7, 2017 at 8:52 Comment(2)
this helped me. Gave me a clue to proceed further. Thanks!!Anywise
Where do you need help exactly ? Is there something you haven't understood in my answer ? Or do you have problems in trying to apply my solution ?Cashew
I
1

I found a very simple vanillaJS solution that should work in any modern browser:

const container=document.querySelector("#yourIDorwhatever")

//optional: needed only if the container element is not focusable already
container.setAttribute("tabindex","0")

container.addEventListener("focusout", (ev)=>{
  if (ev.relatedTarget && !container.contains(ev.relatedTarget)) container.focus()
})

The mode of operation is very simple:

  • makes the container focusable, if not already
  • adds an event listener to the focusout event which fires when the focus is about to go outside of the container
  • Checks if the next target of the focus is in fact outside of the container, and if so, then puts the focus back to the container itself

The last check is needed because the focusout event also fires when the focus moves from one element to the another within the container.

Note: the focus can leave the page, eg the address bar of the browser. This doesn't seem to be preventable - at least according to my testing in Chrome.

Insufflate answered 17/12, 2021 at 10:6 Comment(0)
M
0

I used this solution of focusguard element that focus on it moves the focus to the desired element, using JS.

Found it here: https://jsfiddle.net/dipish/F82Xj/

<p>Some sample <a href="#" tabindex="0">content</a> here...</p>
<p>Like, another <input type="text" value="input" /> element or a <button>button</button>...</p>

<!-- Random content above this comment -->
<!-- Special "focus guard" elements around your
if you manually set tabindex for your form elements, you should set tabindex for the focus guards as well -->
<div class="focusguard" id="focusguard-1" tabindex="1"></div>
<input id="firstInput" type="text" tabindex="2" />
<input type="text" tabindex="3" />
<input type="text" tabindex="4" />
<input type="text" tabindex="5" />
<input type="text" tabindex="6" />
<input id="lastInput" type="text" tabindex="7" />
<!-- focus guard in the end of the form -->
<div class="focusguard" id="focusguard-2" tabindex="8"></div>
<!-- Nothing underneath this comment -->

JQuery implementation:

$('#focusguard-2').on('focus', function() {
  $('#firstInput').focus();
});

$('#focusguard-1').on('focus', function() {
  $('#lastInput').focus();
});
Makeshift answered 19/2, 2021 at 17:10 Comment(0)
C
0

As far as I know, there is no native HTML aria support to get back the same focus when a modal is closed.

aria-modal is going to replace aria-hidden. It should used in combination with role="alertdialog". This www.w3.org/TR/wai-aria-practices-1.1 page explains what they do and offers a complex example. Inspired by this, I made a minimal snippet.

Never use tabindex higher than 0. tabindex="0" is set to the modals heading. So it gets focused with the tab key. The opening button is saved in a variable lastFocusedElement. When the modal is closed, the focus gets back to there.

window.onload = function () {
  var lastFocusedElement;

  // open dialog
  document.querySelector('#open-dialog').addEventListener('click', (e) => {
    document.querySelector('#dialog').classList.add('d-block');
    document.querySelector('#backdrop').classList.add('d-block');
    lastFocusedElement = e.currentTarget;
  });

  // close dialog and back to last focused element
  document.querySelector('#close-dialog').addEventListener('click', (e) => {
    document.querySelector('#dialog').classList.remove('d-block');
    document.querySelector('#backdrop').classList.remove('d-block');
    lastFocusedElement.focus();
  });
}
h2 { font-size: 1em }

.d-block {
  display: block !important;
}
.dialog {
  display: none;
  position: fixed;
  top: 1rem;
  width: 25rem;
  padding: 1rem;
  background: #fff;
  border: 1px solid #000;
  z-index: 1050;
  font-family: arial, sans-serif;
  font-size: .8em;
  
}
#backdrop {
  display: none;
  position: fixed;
  top: 0;
  left: 0;
  bottom: 0;
  right: 0;
  z-index: 1040;
  background: rgba(0, 0, 0, 0.5);
}
<label for="just-a-label">Just a label</label>
<button id="open-dialog" type="button" aria-labelledby="just-a-label">open dialog</button>

<div id="dialog" class="dialog" role="alertdialog" aria-modal="true" aria-labelledby="dialog-label" aria-describedby="dialog-desc">
    <h2 id="dialog-label" tabindex="0">PRESS TAB to get here</h2>
    <div id="dialog-desc">
      <p>Dialog Description.</p>
    </div>
    <div>
      <label for="formfield">
        <span>another formfield:</span>
        <input id="formfield" type="text">
      </label>
    </div>
    <hr>
    <div>
      <button id="close-dialog" type="button" tabindex="0">CLOSE (and focus back to open button)</button>
    </div>
</div>

<div id="backdrop"></div>
Clipclop answered 19/2, 2021 at 19:2 Comment(0)
F
0

I know it's a little late but that's how I resolve the issue of background focus on the modal. I will provide two solutions one for "talkback" and another one is for "Switch Access" which will work for the tab key too.

For Talkback:

function preventFocusOnBackground(ariaHide) {
   $("body > *").not("#modalId").attr("aria-hidden", ariaHide);
}

// when you close the modal
preventFocusOnBackground(false);
// when you open the modal
preventFocusOnBackground(true)

For Switch Access/Control copy/paste this code in your file:

var aria = aria || {};

aria.Utils = aria.Utils || {};

(function () {
  /*
  * When util functions move focus around, set this true so the focus 
    listener
  * can ignore the events.
  */
  aria.Utils.IgnoreUtilFocusChanges = false;

  aria.Utils.dialogOpenClass = 'has-dialog';

  /**
   * @desc Set focus on descendant nodes until the first focusable 
     element is
   *       found.
   * @param element
   *          DOM node for which to find the first focusable descendant.
   * @returns
   *  true if a focusable element is found and focus is set.
   */
   aria.Utils.focusFirstDescendant = function (element) {
     for (var i = 0; i < element.childNodes.length; i++) {
       var child = element.childNodes[i];
       if (aria.Utils.attemptFocus(child) ||
         aria.Utils.focusFirstDescendant(child)) {
         return true;
       }
     }
     return false;
   }; // end focusFirstDescendant

   /**
   * @desc Find the last descendant node that is focusable.
   * @param element
   *          DOM node for which to find the last focusable descendant.
   * @returns
   *  true if a focusable element is found and focus is set.
   */
   aria.Utils.focusLastDescendant = function (element) {
     for (var i = element.childNodes.length - 1; i >= 0; i--) {
       var child = element.childNodes[i];
       if (aria.Utils.attemptFocus(child) ||
         aria.Utils.focusLastDescendant(child)) {
         return true;
       }
     }
     return false;
   }; // end focusLastDescendant

   /**
   * @desc Set Attempt to set focus on the current node.
   * @param element
   *          The node to attempt to focus on.
   * @returns
   *  true if element is focused.
   */
   aria.Utils.attemptFocus = function (element) {
     if (!aria.Utils.isFocusable(element)) {
       return false;
     }

     aria.Utils.IgnoreUtilFocusChanges = true;
     try {
       element.focus();
     }
     catch (e) {
     }
     aria.Utils.IgnoreUtilFocusChanges = false;
     return (document.activeElement === element);
   }; // end attemptFocus

   /* Modals can open modals. Keep track of them with this array. */
   aria.OpenDialogList = aria.OpenDialogList || new Array(0);

   /**
   * @returns the last opened dialog (the current dialog)
   */
   aria.getCurrentDialog = function () {
     if (aria.OpenDialogList && aria.OpenDialogList.length) {
       return aria.OpenDialogList[aria.OpenDialogList.length - 1];
     }
   };

   aria.Utils.isFocusable = function(element) {
     return element.classList && element.classList.contains('focusable');
   }

   aria.closeCurrentDialog = function () {
     var currentDialog = aria.getCurrentDialog();
     if (currentDialog) {
       currentDialog.close();
       return true;
     }

     return false;
   };

   document.addEventListener('keyup', aria.handleEscape);

   /**
    * @constructor
    * @desc Dialog object providing modal focus management.
    *
    * Assumptions: The element serving as the dialog container is present 
      in the
    * DOM and hidden. The dialog container has role='dialog'.
    *
    * @param dialogId
    *          The ID of the element serving as the dialog container.
    * @param focusAfterClosed
    * Either the DOM node or the ID of the DOM node to focus 
    *  when the dialog closes.
    * @param focusFirst
    *          Optional parameter containing either the DOM node or the 
      ID of the
    *          DOM node to focus when the dialog opens. If not specified, the
    *          first focusable element in the dialog will receive focus.
   */
   aria.Dialog = function (dialogId, focusAfterClosed, focusFirst) {
     this.dialogNode = document.getElementById(dialogId);
     if (this.dialogNode === null) {
       throw new Error('No element found with id="' + dialogId + '".');
     }

     var validRoles = ['dialog', 'alertdialog'];
     var isDialog = (this.dialogNode.getAttribute('role') || '')
       .trim()
       .split(/\s+/g)
       .some(function (token) {
         return validRoles.some(function (role) {
         return token === role;
       });
     });
     if (!isDialog) {
       throw new Error(
       'Dialog() requires a DOM element with ARIA role of dialog or 
        alertdialog.');
     }

     // Wrap in an individual backdrop element if one doesn't exist
     // Native <dialog> elements use the ::backdrop pseudo-element, which
     // works similarly.
     var backdropClass = 'dialog-backdrop';
     if (this.dialogNode.parentNode.classList.contains(backdropClass)) {
       this.backdropNode = this.dialogNode.parentNode;
     }
     else {
       this.backdropNode = document.createElement('div');
       this.backdropNode.className = backdropClass;
       this.dialogNode.parentNode.insertBefore(this.backdropNode, 
       this.dialogNode);
       this.backdropNode.appendChild(this.dialogNode);
     }
     this.backdropNode.classList.add('active');

     // Disable scroll on the body element
     document.body.classList.add(aria.Utils.dialogOpenClass);

     if (typeof focusAfterClosed === 'string') {
       this.focusAfterClosed = document.getElementById(focusAfterClosed);
     }
     else if (typeof focusAfterClosed === 'object') {
       this.focusAfterClosed = focusAfterClosed;
     }
     else {
       throw new Error(
        'the focusAfterClosed parameter is required for the aria.Dialog 
         constructor.');
     }

     if (typeof focusFirst === 'string') {
       this.focusFirst = document.getElementById(focusFirst);
     }
     else if (typeof focusFirst === 'object') {
       this.focusFirst = focusFirst;
     }
     else {
       this.focusFirst = null;
     }

     // If this modal is opening on top of one that is already open,
     // get rid of the document focus listener of the open dialog.
     if (aria.OpenDialogList.length > 0) {
       aria.getCurrentDialog().removeListeners();
     }

     this.addListeners();
     aria.OpenDialogList.push(this);
     this.clearDialog();
     this.dialogNode.className = 'default_dialog'; // make visible

     if (this.focusFirst) {
       this.focusFirst.focus();
     }
     else {
       aria.Utils.focusFirstDescendant(this.dialogNode);
     }

     this.lastFocus = document.activeElement;
   }; // end Dialog constructor

   aria.Dialog.prototype.clearDialog = function () {
     Array.prototype.map.call(
       this.dialogNode.querySelectorAll('input'),
       function (input) {
         input.value = '';
       }
     );
   };

   /**
    * @desc
    *  Hides the current top dialog,
    *  removes listeners of the top dialog,
    *  restore listeners of a parent dialog if one was open under the one 
       that just closed,
    *  and sets focus on the element specified for focusAfterClosed.
   */
   aria.Dialog.prototype.close = function () {
     aria.OpenDialogList.pop();
     this.removeListeners();
     aria.Utils.remove(this.preNode);
     aria.Utils.remove(this.postNode);
     this.dialogNode.className = 'hidden';
     this.backdropNode.classList.remove('active');
     this.focusAfterClosed.focus();

     // If a dialog was open underneath this one, restore its listeners.
     if (aria.OpenDialogList.length > 0) {
       aria.getCurrentDialog().addListeners();
     }
     else {
       document.body.classList.remove(aria.Utils.dialogOpenClass);
     }
   }; // end close

   /**
    * @desc
    *  Hides the current dialog and replaces it with another.
    *
    * @param newDialogId
    *  ID of the dialog that will replace the currently open top dialog.
    * @param newFocusAfterClosed
    *  Optional ID or DOM node specifying where to place focus when the 
       new dialog closes.
    *  If not specified, focus will be placed on the element specified by 
       the dialog being replaced.
    * @param newFocusFirst
    *  Optional ID or DOM node specifying where to place focus in the new 
       dialog when it opens.
    *  If not specified, the first focusable element will receive focus.
   */
   aria.Dialog.prototype.replace = function (newDialogId, 
                                             newFocusAfterClosed,
                                             newFocusFirst) {
     var closedDialog = aria.getCurrentDialog();
     aria.OpenDialogList.pop();
     this.removeListeners();
     aria.Utils.remove(this.preNode);
     aria.Utils.remove(this.postNode);
     this.dialogNode.className = 'hidden';
     this.backdropNode.classList.remove('active');

     var focusAfterClosed = newFocusAfterClosed || this.focusAfterClosed;
     var dialog = new aria.Dialog(newDialogId, focusAfterClosed, 
                                  newFocusFirst);
   }; // end replace

   aria.Dialog.prototype.addListeners = function () {
     document.addEventListener('focus', this.trapFocus, true);
   }; // end addListeners

   aria.Dialog.prototype.removeListeners = function () {
     document.removeEventListener('focus', this.trapFocus, true);
   }; // end removeListeners

   aria.Dialog.prototype.trapFocus = function (event) {
     if (aria.Utils.IgnoreUtilFocusChanges) {
       return;
     }
     var currentDialog = aria.getCurrentDialog();
     if (currentDialog.dialogNode.contains(event.target)) {
       currentDialog.lastFocus = event.target;
     }
     else {
       aria.Utils.focusFirstDescendant(currentDialog.dialogNode);
       if (currentDialog.lastFocus == document.activeElement) {
         aria.Utils.focusLastDescendant(currentDialog.dialogNode);
       }
       currentDialog.lastFocus = document.activeElement;
     }
   }; // end trapFocus

   window.openDialog = function (dialogId, focusAfterClosed, focusFirst){
     var dialog = new aria.Dialog(dialogId, focusAfterClosed,focusFirst);
   };

   window.closeDialog = function (closeButton) {
     var topDialog = aria.getCurrentDialog();
     if (topDialog.dialogNode.contains(closeButton)) {
       topDialog.close();
     }
   }; // end closeDialog

   window.replaceDialog = function (newDialogId, newFocusAfterClosed,
                               newFocusFirst) {
     var topDialog = aria.getCurrentDialog();
     if (topDialog.dialogNode.contains(document.activeElement)) {
       topDialog.replace(newDialogId, newFocusAfterClosed,newFocusFirst);
     }
   }; // end replaceDialog

 }());

And call it where you open the modal like this:

openDialog('modalID', this);

Add these attributes in the modal div tag:

<div id="modalId" aria-modal="true" role="dialog">

Add "tabindex" attributes on all the elements where you want the focus. Like this:

<a href="#" onclick="resizeTextFixed(1.4);return false;" tabindex="1" 
 aria-label="Some text">A</a>

<a href="#" onclick="resizeTextFixed(1.2);return false;" tabindex="2" 
 aria-label="Some text">A</a>

Add "focusable" class to the first focusable element:

<div class="focuable"></div>

That's it.

Financier answered 18/11, 2021 at 13:42 Comment(0)
G
0

The combination of using role="dialog" (implicitly through the <dialog> or explicitly) and aria-modal="true" to inform and instruct screen readers and putting the inert attribute on all dialog siblings seems like the easiest and most sure fire way.

Gunk answered 8/7 at 14:11 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.