41

On my current project we have some modal panes that open up on certain actions. I am trying to get it so that when that modal pane is open you can't tab to an element outside of it. The jQuery UI dialog boxes and the Malsup jQuery block plugins seem to do this but I am trying to get just that one feature and apply it in my project and it's not immediately obvious to me how they are doing that.

I've seen that some people are of the opinion that tabbing shouldn't be disabled and I can see that point of view but I am being given the directive to disable it.

Christian Ziebarth
  • 753
  • 1
  • 7
  • 20
  • I know this is old, but accidentally came across this and want to warn that this should never be done as it breaks accessibility. You will create a nightmare for users who use keyboards to navigate pages. – rbrundritt Jun 02 '21 at 18:34
  • 1
    @rbrundritt Does it though? I came here to find a solution to that same problem: Tabbing after the last element in the modal _breaks_ keyboard navigation since it starts tabbing through a bunch of non-visible elements. – arcanemachine Jun 12 '21 at 16:11
  • If you lock the user into the modal, you have disabled a lot more than just the experience in your page. How does the user tab to the search bar, or the next browser tab if you locked them in. You end up breaking the standard user experience of the browser. Never do that. I swear, if I ever came across a site doing that I would abandon it and never go back. – rbrundritt Jun 14 '21 at 15:31
  • 1
    I think both rbrundritt & arcanemachine get close but don't really get to the heart of the issue. Disabling tabbing is bad and should never be done, full stop. Controlling the tab order, on the other hand, is desirable in some situations and modals are one of them. I've heard it called "roving tabindex" and also "modal keyboard trap". A temporary keyboard trap to ensure that you trap focus only while the modal is displayed and then restore focus to the previously-focused item when the modal is closed. – Steve Woodson Jul 09 '21 at 17:37
  • Reference links - "[roving tabindex](https://web.dev/control-focus-with-tabindex/#create-accessible-components-with-%22roving-tabindex%22)" and "[modal keyboard trap](https://developers.google.com/web/fundamentals/accessibility/focus/using-tabindex#modals_and_keyboard_traps)". – Steve Woodson Jul 09 '21 at 17:38

5 Answers5

36

This is just expanding on Christian answer, by adding the additional input types and also taking into consideration the shift+tab.

var inputs = $element.find('select, input, textarea, button, a').filter(':visible');
var firstInput = inputs.first();
var lastInput = inputs.last();

/*set focus on first input*/
firstInput.focus();

/*redirect last tab to first input*/
lastInput.on('keydown', function (e) {
   if ((e.which === 9 && !e.shiftKey)) {
       e.preventDefault();
       firstInput.focus();
   }
});

/*redirect first shift+tab to last input*/
firstInput.on('keydown', function (e) {
    if ((e.which === 9 && e.shiftKey)) {
        e.preventDefault();
        lastInput.focus();
    }
});
Alexander Puchkov
  • 5,913
  • 4
  • 34
  • 48
jfutch
  • 385
  • 3
  • 6
  • 2
    think you can use `:tabbable` – Omu Jun 03 '16 at 17:59
  • perfect! exactly what i needed! – Michal Shulman Nov 06 '16 at 07:26
  • 1
    How would I go about if the last element, i.e a button, were disabled at first? – LeFex Aug 17 '17 at 07:22
  • Disabled elements at the end of the list will break this solution. If `lastInput` is disabled, pressing tab on the second-to-last element will jump you out of the modal. Using `:tabbable` to collect tabbables will work around that by excluding the disabled button, but once you enable the button you have to do additional logic or users will be unable to tab into it. – Mark May 02 '18 at 19:09
  • Vanilla JS solution: https://uxdesign.cc/how-to-trap-focus-inside-modal-to-make-it-ada-compliant-6a50f9a70700 – thdoan Feb 22 '21 at 03:05
  • `:tabbable` is only available if you use the jQuery UI library. – Heretic Monkey Apr 13 '21 at 17:55
16

I was finally able to accomplish this at least somewhat by giving focus to the first form element within the modal pane when that modal pane is open and then if the Tab key is pressed while focus is on the last form element within the modal pane then the focus goes back to the first form element there rather than to the next element in the DOM that would otherwise receive focus. A lot of this scripting comes from jQuery: How to capture the TAB keypress within a Textbox:

$('#confirmCopy :input:first').focus();

$('#confirmCopy :input:last').on('keydown', function (e) { 
    if ($("this:focus") && (e.which == 9)) {
        e.preventDefault();
        $('#confirmCopy :input:first').focus();
    }
});

I may need to further refine this to check for the pressing of some other keys, such as arrow keys, but the basic idea is there.

Community
  • 1
  • 1
Christian Ziebarth
  • 753
  • 1
  • 7
  • 20
  • 1
    Sometimes you want to select your modal not by id but by class (so you could have one visible and multiple hidden modals at your page). In this case add also :visible into selector. Also do not forget that this last input could have tabindex=-1 so you will never tab there. – Cibo FATA8 Nov 27 '19 at 10:28
11

Good solutions by Christian and jfutch.

Its worth mentioning that there a few pitfalls with hijacking the tab keystroke:

  • the tabindex attribute might be set on some elements inside the modal pane in such a way that the dom order of elements does not follow the tab order. (Eg. setting tabindex="10" on the last tabbable element can make it first in the tab order)
  • If the user interacts with an element outside the modal that doesn't trigger the modal to close you can tab outside the modal window. (Eg. click the location bar and start tabbing back to the page, or open up page landmarks in a screenreader like VoiceOver & navigate to a different part of the page)
  • checking if elements are :visible will trigger a reflow if the dom is dirty
  • The document might not have a :focussed element. In chrome its possible to change the 'caret' position by clicking on a non-focussable element then pressing tab. Its possible that the user could set the caret position past the last tabbable element.

I think a more robust solution would be to 'hide' the rest of the page by setting tabindex to -1 on all tabbable content, then 'unhide' on close. This will keep the tab order inside the modal window and respect the order set by tabindex.

var focusable_selector = 'a[href], area[href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, *[tabindex], *[contenteditable]';

var hide_rest_of_dom = function( modal_selector ) {

    var hide = [], hide_i, tabindex,
        focusable = document.querySelectorAll( focusable_selector ),
        focusable_i = focusable.length,
        modal = document.querySelector( modal_selector ),
        modal_focusable = modal.querySelectorAll( focusable_selector );

    /*convert to array so we can use indexOf method*/
    modal_focusable = Array.prototype.slice.call( modal_focusable );
    /*push the container on to the array*/
    modal_focusable.push( modal );

    /*separate get attribute methods from set attribute methods*/
    while( focusable_i-- ) {
        /*dont hide if element is inside the modal*/
        if ( modal_focusable.indexOf(focusable[focusable_i]) !== -1 ) {
            continue;
        }
        /*add to hide array if tabindex is not negative*/
        tabindex = parseInt(focusable[focusable_i].getAttribute('tabindex'));
        if ( isNaN( tabindex ) ) {
            hide.push([focusable[focusable_i],'inline']);
        } else if ( tabindex >= 0 ) {
            hide.push([focusable[focusable_i],tabindex]);
        } 

    }

    /*hide the dom elements*/
    hide_i = hide.length;
    while( hide_i-- ) {
        hide[hide_i][0].setAttribute('data-tabindex',hide[hide_i][1]);
        hide[hide_i][0].setAttribute('tabindex',-1);
    }

};

To unhide the dom you would just query all elements with the 'data-tabindex' attribute & set the tabindex to the attribute value.

var unhide_dom = function() {

    var unhide = [], unhide_i, data_tabindex,
        hidden = document.querySelectorAll('[data-tabindex]'),
        hidden_i = hidden.length;

    /*separate the get and set attribute methods*/
    while( hidden_i-- ) {
        data_tabindex = hidden[hidden_i].getAttribute('data-tabindex');
        if ( data_tabindex !== null ) {
            unhide.push([hidden[hidden_i], (data_tabindex == 'inline') ? 0 : data_tabindex]);
        }
    }

    /*unhide the dom elements*/
    unhide_i = unhide.length;
    while( unhide_i-- ) {
        unhide[unhide_i][0].removeAttribute('data-tabindex');
        unhide[unhide_i][0].setAttribute('tabindex', unhide[unhide_i][1] ); 
    }

}

Making the rest of the dom hidden from aria when the modal is open is slightly easier. Cycle through all the relatives of the modal window & set the aria-hidden attribute to true.

var aria_hide_rest_of_dom = function( modal_selector ) {

    var aria_hide = [],
        aria_hide_i,
        modal_relatives = [],
        modal_ancestors = [],
        modal_relatives_i,
        ancestor_el,
        sibling, hidden,
        modal = document.querySelector( modal_selector );


    /*get and separate the ancestors from the relatives of the modal*/
    ancestor_el = modal;
    while ( ancestor_el.nodeType === 1 ) {
        modal_ancestors.push( ancestor_el );
        sibling = ancestor_el.parentNode.firstChild;
        for ( ; sibling ; sibling = sibling.nextSibling ) {
            if ( sibling.nodeType === 1 && sibling !== ancestor_el ) {
                modal_relatives.push( sibling );
            }
        }
        ancestor_el = ancestor_el.parentNode;
    }

    /*filter out relatives that aren't already hidden*/
    modal_relatives_i = modal_relatives.length;
    while( modal_relatives_i-- ) {

        hidden = modal_relatives[modal_relatives_i].getAttribute('aria-hidden');
        if ( hidden === null || hidden === 'false' ) {
            aria_hide.push([modal_relatives[modal_relatives_i]]);
        }

    }

    /*hide the dom elements*/
    aria_hide_i = aria_hide.length;
    while( aria_hide_i-- ) {

        aria_hide[aria_hide_i][0].setAttribute('data-ariahidden','false');
        aria_hide[aria_hide_i][0].setAttribute('aria-hidden','true');

    }       

};

Use a similar technique to unhide the aria dom elements when the modal closes. Here its better to remove the aria-hidden attribute rather than setting it to false as there might be some conflicting css visibility/display rules on the element that take precedence & implementation of aria-hidden in such cases is inconsistent across browsers (see https://www.w3.org/TR/2016/WD-wai-aria-1.1-20160721/#aria-hidden)

var aria_unhide_dom = function() {

    var unhide = [], unhide_i, data_ariahidden,
        hidden = document.querySelectorAll('[data-ariahidden]'),
        hidden_i = hidden.length;

    /*separate the get and set attribute methods*/
    while( hidden_i-- ) {
        data_ariahidden = hidden[hidden_i].getAttribute('data-ariahidden');
        if ( data_ariahidden !== null ) {
            unhide.push(hidden[hidden_i]);
        }
    }

    /*unhide the dom elements*/
    unhide_i = unhide.length;
    while( unhide_i-- ) {
        unhide[unhide_i].removeAttribute('data-ariahidden');
        unhide[unhide_i].removeAttribute('aria-hidden');
    }

}

Lastly I'd recommend calling these functions after an animation has ended on the element. Below is a abstracted example of calling the functions on transition_end.

I'm using modernizr to detect the transition duration on load. The transition_end event bubbles up the dom so it can fire more than once if more than one element is transitioning when the modal window opens, so check against the event.target before calling the hide dom functions.

/* this can be run on page load, abstracted from 
 * http://dbushell.com/2012/12/22/a-responsive-off-canvas-menu-with-css-transforms-and-transitions/
 */
var transition_prop = Modernizr.prefixed('transition'),
    transition_end = (function() {
        var props = {
            'WebkitTransition' : 'webkitTransitionEnd',
            'MozTransition'    : 'transitionend',
            'OTransition'      : 'oTransitionEnd otransitionend',
            'msTransition'     : 'MSTransitionEnd',
            'transition'       : 'transitionend'
        };
        return props.hasOwnProperty(transition_prop) ? props[transition_prop] : false;
    })();


/*i use something similar to this when the modal window is opened*/
var on_open_modal_window = function( modal_selector ) {

    var modal = document.querySelector( modal_selector ),
        duration = (transition_end && transition_prop) ? parseFloat(window.getComputedStyle(modal, '')[transition_prop + 'Duration']) : 0;

    if ( duration > 0 ) {
        $( document ).on( transition_end + '.modal-window', function(event) {
            /*check if transition_end event is for the modal*/
            if ( event && event.target === modal ) {
                hide_rest_of_dom();
                aria_hide_rest_of_dom();    
                /*remove event handler by namespace*/
                $( document ).off( transition_end + '.modal-window');
            }               
        } );
    } else {
        hide_rest_of_dom();
        aria_hide_rest_of_dom();
    }
}
niall.campbell
  • 409
  • 4
  • 5
6

I have just made few changes to Alexander Puchkov's solution, and made it a JQuery plugin. It solves the problem of dynamic DOM changes in the container. If any control add it to the container on conditional, this works.

(function($) {

    $.fn.modalTabbing = function() {

        var tabbing = function(jqSelector) {
            var inputs = $(jqSelector).find('select, input, textarea, button, a[href]').filter(':visible').not(':disabled');

            //Focus to first element in the container.
            inputs.first().focus();

            $(jqSelector).on('keydown', function(e) {
                if (e.which === 9) {

                    var inputs = $(jqSelector).find('select, input, textarea, button, a[href]').filter(':visible').not(':disabled');

                    /*redirect last tab to first input*/
                    if (!e.shiftKey) {
                        if (inputs[inputs.length - 1] === e.target) {
                            e.preventDefault();
                            inputs.first().focus();
                        }
                    }
                    /*redirect first shift+tab to last input*/
                    else {
                        if (inputs[0] === e.target) {
                            e.preventDefault();
                            inputs.last().focus();
                        }
                    }
                }
            });
        };

        return this.each(function() {
            tabbing(this);
        });

    };
})(jQuery);
stelioslogothetis
  • 9,371
  • 3
  • 28
  • 53
Rajesh Jinaga
  • 169
  • 2
  • 3
  • Thank you Rajesh! This is great and solved the exact problem I was having with dynamic DOM changes. – Matt. Jun 19 '17 at 17:47
  • I can confirm that this worked for us. Our modals did not set focus on load and you could tab all over the page behind the modal. Once we included the plug in code and made the call to $("#myModalDialogId").modalTabbing(); focus was set and tabbing was limited to the modal elements only. – Jeff Mergler Jul 24 '17 at 18:29
  • It works but when the modal is closed the tabbing in the page stops without clicking somewhere in the page. Any solution to that? – Scot's Scripts May 07 '20 at 01:24
1

For anyone coming into this more recently like I was, I've taken the approaches outlined above and I've simplified them a bit to make it a bit more digestible. Thanks to @niall.campbell for the suggested approach here.

The code below can be found in this CodeSandbox for further reference and for a working example

let tabData = [];

const modal = document.getElementById('modal');
preventTabOutside(modal);

// should be called when modal opens
function preventTabOutside(modal) {
  const tabbableElements = document.querySelectorAll(selector);
  tabData = Array.from(tabbableElements)
    // filter out any elements within the modal
    .filter((elem) => !modal.contains(elem))
    // store refs to the element and its original tabindex
    .map((elem) => {
      // capture original tab index, if it exists
      const tabIndex = elem.hasAttribute("tabindex")
        ? elem.getAttribute("tabindex")
        : null;
      // temporarily set the tabindex to -1
      elem.setAttribute("tabindex", -1);
      return { elem, tabIndex };
    });
}

// should be called when modal closes
function enableTabOutside() {
  tabData.forEach(({ elem, tabIndex }) => {
    if (tabIndex === null) {
      elem.removeAttribute("tabindex");
    } else {
      elem.setAttribute("tabindex", tabIndex);
    }
  });
  tabData = [];
}
tennisgent
  • 14,165
  • 9
  • 50
  • 48