Skip to content

Commit ebedb44

Browse files
Fix Modal: Focus is now trapped when modal is opened. It works in both directions with tab or shift+tab.
Refs: angular-ui#738, angular-ui#3689
1 parent 2332f14 commit ebedb44

File tree

3 files changed

+121
-12
lines changed

3 files changed

+121
-12
lines changed

src/modal/docs/demo.html

+2-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ <h3 class="modal-title">I'm a modal!</h3>
66
<div class="modal-body">
77
<ul>
88
<li ng-repeat="item in items">
9-
<a ng-click="selected.item = item">{{ item }}</a>
9+
<a href="#" ng-click="selected.item = item">{{ item }}</a>
1010
</li>
1111
</ul>
1212
Selected: <b>{{ selected.item }}</b>
@@ -22,4 +22,4 @@ <h3 class="modal-title">I'm a modal!</h3>
2222
<button class="btn btn-default" ng-click="open('sm')">Small modal</button>
2323
<button class="btn btn-default" ng-click="toggleAnimation()">Toggle Animation ({{ animationsEnabled }})</button>
2424
<div ng-show="selected">Selection from a modal: {{ selected }}</div>
25-
</div>
25+
</div>

src/modal/modal.js

+82-9
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,13 @@ angular.module('ui.bootstrap.modal', [])
196196
NOW_CLOSING_EVENT: 'modal.stack.now-closing'
197197
};
198198

199+
//Modal focus behavior
200+
var focusableElementList;
201+
var focusIndex = 0;
202+
var tababbleSelector = 'a[href], area[href], input:not([disabled]), ' +
203+
'button:not([disabled]),select:not([disabled]), textarea:not([disabled]), ' +
204+
'iframe, object, embed, *[tabindex], *[contenteditable=true]';
205+
199206
function backdropIndex() {
200207
var topBackdropIndex = -1;
201208
var opened = openedWindows.keys();
@@ -281,15 +288,35 @@ angular.module('ui.bootstrap.modal', [])
281288
}
282289

283290
$document.bind('keydown', function (evt) {
284-
var modal;
285-
286-
if (evt.which === 27) {
287-
modal = openedWindows.top();
288-
if (modal && modal.value.keyboard) {
289-
evt.preventDefault();
290-
$rootScope.$apply(function () {
291-
$modalStack.dismiss(modal.key, 'escape key press');
292-
});
291+
var modal = openedWindows.top();
292+
if (modal && modal.value.keyboard) {
293+
switch (evt.which){
294+
case 27:{
295+
evt.preventDefault();
296+
$rootScope.$apply(function () {
297+
$modalStack.dismiss(modal.key, 'escape key press');
298+
});
299+
break;
300+
}
301+
case 9:{
302+
$modalStack.loadFocusElementList(modal);
303+
var focusChanged = false;
304+
if (evt.shiftKey) {
305+
if($modalStack.isFocusInFirstItem(evt)){
306+
focusChanged = $modalStack.focusLastFocusableElement();
307+
}
308+
}
309+
else{
310+
if($modalStack.isFocusInLastItem(evt)){
311+
focusChanged = $modalStack.focusFirstFocusableElement();
312+
}
313+
}
314+
if(focusChanged){
315+
evt.preventDefault();
316+
evt.stopPropagation();
317+
}
318+
break;
319+
}
293320
}
294321
}
295322
});
@@ -338,6 +365,7 @@ angular.module('ui.bootstrap.modal', [])
338365
openedWindows.top().value.modalOpener = modalOpener;
339366
body.append(modalDomEl);
340367
body.addClass(OPENED_MODAL_CLASS);
368+
$modalStack.clearFocusListCache();
341369
};
342370

343371
function broadcastClosing(modalWindow, resultOrReason, closing) {
@@ -382,6 +410,51 @@ angular.module('ui.bootstrap.modal', [])
382410
}
383411
};
384412

413+
$modalStack.focusFirstFocusableElement = function(){
414+
if(focusableElementList.length > 0){
415+
focusableElementList[0].focus();
416+
return true;
417+
}
418+
return false;
419+
};
420+
$modalStack.focusLastFocusableElement = function(){
421+
if(focusableElementList.length > 0) {
422+
focusableElementList[focusableElementList.length-1].focus();
423+
return true;
424+
}
425+
return false;
426+
};
427+
428+
$modalStack.isFocusInFirstItem = function(evt){
429+
if(focusableElementList.length > 0){
430+
return (evt.target || evt.srcElement) == focusableElementList[0];
431+
}
432+
return false;
433+
};
434+
435+
$modalStack.isFocusInLastItem = function(evt){
436+
if(focusableElementList.length > 0){
437+
return (evt.target || evt.srcElement) == focusableElementList[focusableElementList.length-1];
438+
}
439+
return false;
440+
};
441+
442+
$modalStack.clearFocusListCache = function(){
443+
focusableElementList = [];
444+
focusIndex = 0;
445+
};
446+
447+
$modalStack.loadFocusElementList = function (modalWindow){
448+
if(focusableElementList === undefined || focusableElementList.length === 0){
449+
if(modalWindow){
450+
var modalDomE1 = modalWindow.value.modalDomEl;
451+
if(modalDomE1 && modalDomE1.length > 0) {
452+
focusableElementList = modalDomE1[0].querySelectorAll(tababbleSelector);
453+
}
454+
}
455+
}
456+
};
457+
385458
return $modalStack;
386459
}])
387460

src/modal/test/modal.spec.js

+37-1
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ describe('$modal', function () {
22
var $controllerProvider, $rootScope, $document, $compile, $templateCache, $timeout, $q;
33
var $modal, $modalProvider;
44

5-
var triggerKeyDown = function (element, keyCode) {
5+
var triggerKeyDown = function (element, keyCode, shiftKey) {
66
var e = $.Event('keydown');
7+
e.srcElement = element[0];
78
e.which = keyCode;
9+
e.shiftKey = shiftKey;
810
element.trigger(e);
911
};
1012

@@ -340,6 +342,40 @@ describe('$modal', function () {
340342
openAndCloseModalWithAutofocusElement();
341343
openAndCloseModalWithAutofocusElement();
342344
});
345+
346+
it('should change focus to first element when tab key was pressed', function() {
347+
var initialPage = angular.element('<a href="#" id="cannot-get-focus-from-modal">Outland link</a>');
348+
angular.element(document.body).append(initialPage);
349+
initialPage.focus();
350+
351+
open({
352+
template:'<a href="#" id="tab-focus-link"><input type="text" id="tab-focus-input1"/><input type="text" id="tab-focus-input2"/>' +
353+
'<button id="tab-focus-button">Open me!</button>'
354+
});
355+
expect($document).toHaveModalsOpen(1);
356+
357+
var lastElement = angular.element(document.getElementById('tab-focus-button'));
358+
lastElement.focus();
359+
triggerKeyDown(lastElement, 9);
360+
expect(document.activeElement.getAttribute('id')).toBe('tab-focus-link');
361+
});
362+
363+
it('should change focus to last element when shift+tab key is pressed', function() {
364+
var initialPage = angular.element('<a href="#" id="cannot-get-focus-from-modal">Outland link</a>');
365+
angular.element(document.body).append(initialPage);
366+
initialPage.focus();
367+
368+
open({
369+
template:'<a href="#" id="tab-focus-link"><input type="text" id="tab-focus-input1"/><input type="text" id="tab-focus-input2"/>' +
370+
'<button id="tab-focus-button">Open me!</button>'
371+
});
372+
expect($document).toHaveModalsOpen(1);
373+
374+
var lastElement = angular.element(document.getElementById('tab-focus-link'));
375+
lastElement.focus();
376+
triggerKeyDown(lastElement, 9, true);
377+
expect(document.activeElement.getAttribute('id')).toBe('tab-focus-button');
378+
});
343379
});
344380

345381
describe('default options can be changed in a provider', function () {

0 commit comments

Comments
 (0)