Modal needs to reliably trap focus so that Carbon users don't have to
See original GitHub issueThree test cases to show the problem:
-
https://codepen.io/team/carbon/pen/dZVoPj
- open the modal
- type tab a number of times, and focus will exit the modal (shift+tab exits modal and does not wrap)
-
https://www.carbondesignsystem.com/components/modal/code
- open any of the modals
- type tab until it looks as if “nothing” has focus
- now use the up/down arrow keys… the background scrolls…
-
https://www.carbondesignsystem.com/components/modal/code
- open any of the modals
- type shift+tab until it looks as if “nothing” has focus (keep typing shift+tab - it does not wrap)
- now use the up/down arrow keys… the background scrolls…
Related issues: https://github.com/IBM/carbon-components/issues/1075, https://github.com/IBM/carbon-components/issues/1474
Note that this also comes up in the forums from time to time, typically after accessibility testing.
Users of Carbon Modal eventually find out that traversing with tab or shift+tab escapes the modal. Carbon should take care of this in a robust way, so that all Carbon users get the proper behavior for free, and they don’t have to implement their own focus trap, or override Carbon code, to fix the problem.
One way to handle this is to handle the tab and shift+tab keys in a keydown event handler:
function confineTabToDialog(firstElement, lastElement) {
lastElement.keydown(function(evt) {
if(evt.keyCode === 9 && !evt.shiftKey) {
evt.preventDefault();
firstElement.focus();
}
});
firstElement.keydown(function(evt) {
if(evt.keyCode === 9 && evt.shiftKey) {
evt.preventDefault();
lastElement.focus();
}
});
}
You can specify a way for the Carbon Modal user to tell you which elements firstElement and lastElement are. (i.e. with props or class names). This is the easiest way to get the first tabbable element and the last tabbable element in the dialog.
Alternatively, you can do the work automatically for the Carbon Modal user, and loop through the Modal’s descendant elements to find the first and last tabbable element in the Modal. Note: Elements with a negative tab index are focusable, but not tabbable.
Here’s some code from the APG’s Modal Dialog example (found in their utils.js which is called from dialog.js):
aria.Utils.isFocusable = function (element) {
if (element.tabIndex > 0 || (element.tabIndex === 0 && element.getAttribute('tabIndex') !== null)) {
return true;
}
if (element.disabled) {
return false;
}
switch (element.nodeName) {
case 'A':
return !!element.href && element.rel != 'ignore';
case 'INPUT':
return element.type != 'hidden' && element.type != 'file';
case 'BUTTON':
case 'SELECT':
case 'TEXTAREA':
return true;
default:
return false;
}
};
Note: The APG isFocusable code may be missing certain cases of OBJECT and AREA, so the jQuery code below may cover more types of element.
Here is similar code from jQuery’s :tabbable and :focusable selectors in their jquery-ui.js file:
var tabbable = $.extend( $.expr[ ":" ], {
tabbable: function( element ) {
var tabIndex = $.attr( element, "tabindex" ),
hasTabindex = tabIndex != null;
return ( !hasTabindex || tabIndex >= 0 ) && $.ui.focusable( element, hasTabindex );
}
} );
$.ui.focusable = function( element, hasTabindex ) {
var map, mapName, img, focusableIfVisible, fieldset,
nodeName = element.nodeName.toLowerCase();
if ( "area" === nodeName ) {
map = element.parentNode;
mapName = map.name;
if ( !element.href || !mapName || map.nodeName.toLowerCase() !== "map" ) {
return false;
}
img = $( "img[usemap='#" + mapName + "']" );
return img.length > 0 && img.is( ":visible" );
}
if ( /^(input|select|textarea|button|object)$/.test( nodeName ) ) {
focusableIfVisible = !element.disabled;
if ( focusableIfVisible ) {
// Form controls within a disabled fieldset are disabled.
// However, controls within the fieldset's legend do not get disabled.
// Since controls generally aren't placed inside legends, we skip
// this portion of the check.
fieldset = $( element ).closest( "fieldset" )[ 0 ];
if ( fieldset ) {
focusableIfVisible = !fieldset.disabled;
}
}
} else if ( "a" === nodeName ) {
focusableIfVisible = element.href || hasTabindex;
} else {
focusableIfVisible = hasTabindex;
}
return focusableIfVisible && $( element ).is( ":visible" ) && visible( $( element ) );
};
Issue Analytics
- State:
- Created 5 years ago
- Comments:10 (10 by maintainers)
Top GitHub Comments
Even when looking for it on the page, I had a hard time locating the focus trap guidance, unfortunately.
I do think there is a good opportunity, at least in React, to provide this stuff by default. For Vanilla.js, would love to find a way to support it by default with an opt-out. As @carmacleod mentioned, we should try and aim for accessible by default. What kinds of things can we offer that could work here to accomplish that?
Very interesting, @asudoh - thanks.
So, I think you could go one better than Material Components if you create the focus trap by default, and provide an API called
disableFocusTrap
that has a comment saying something like, “Use this to disable the focus trap if you are using another way to trap focus, likeinert
(which would/will solve this problem completely, by the way 😉 or some other framework.”The current Material Components method allows accessibility failure by default, and hopes that the developer notices the focus trap api and understands why they need to use it (or find an alternative). Even their own dialog example does not implement the focus trap.
In case it’s helpful, here is the focus-trap code that they are using, and I see that this code uses yet another implementation of tabbable.
If you decide to implement it the same way as Material Components (i.e. trapFocus and releaseFocus), I would be ok with that, as long as your examples use it, and you have a nice obvious comment that says a focus trap is required for accessibility. 😃
By the way, I just noticed that your React Modal does not have
aria-modal="true"
- that’s a bug.