Skip to content
This repository was archived by the owner on May 29, 2019. It is now read-only.

Commit a028d2a

Browse files
paulorbpachecowesleycho
authored andcommitted
feat(modal): trap focus in modal for tabbing
- Trap focus inside modal when tabbing while modal is in focus Closes #3689 Closes #4004 Fixes #738
1 parent 60e4316 commit a028d2a

File tree

3 files changed

+120
-11
lines changed

3 files changed

+120
-11
lines changed

src/modal/docs/demo.html

+1-1
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="$event.preventDefault(); selected.item = item">{{ item }}</a>
1010
</li>
1111
</ul>
1212
Selected: <b>{{ selected.item }}</b>

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();
@@ -207,7 +214,7 @@ angular.module('ui.bootstrap.modal', [])
207214
return topBackdropIndex;
208215
}
209216

210-
$rootScope.$watch(backdropIndex, function(newBackdropIndex){
217+
$rootScope.$watch(backdropIndex, function(newBackdropIndex) {
211218
if (backdropScope) {
212219
backdropScope.index = newBackdropIndex;
213220
}
@@ -281,15 +288,35 @@ angular.module('ui.bootstrap.modal', [])
281288
}
282289

283290
$document.bind('keydown', function (evt) {
284-
var modal;
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+
} else {
309+
if ($modalStack.isFocusInLastItem(evt)) {
310+
focusChanged = $modalStack.focusFirstFocusableElement();
311+
}
312+
}
285313

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-
});
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.length0) {
449+
if (modalWindow) {
450+
var modalDomE1 = modalWindow.value.modalDomEl;
451+
if (modalDomE1 && modalDomE1.length) {
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 $animate, $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

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

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

0 commit comments

Comments
 (0)