Reputation: 753
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.
Upvotes: 41
Views: 40228
Reputation: 14201
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 = [];
}
Upvotes: 1
Reputation: 169
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);
Upvotes: 6
Reputation: 419
Good solutions by Christian and jfutch.
Its worth mentioning that there a few pitfalls with hijacking the tab keystroke:
:visible
will trigger a reflow if the dom is dirtyI 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();
}
}
Upvotes: 11
Reputation: 385
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();
}
});
Upvotes: 36
Reputation: 753
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.
Upvotes: 16