Skip to content

Commit

Permalink
feat(modal): trap focus in modal for tabbing
Browse files Browse the repository at this point in the history
- Trap focus inside modal when tabbing while modal is in focus

Closes angular-ui#3689
Closes angular-ui#4004
Fixes angular-ui#738
  • Loading branch information
paulorbpacheco authored and wesleycho committed Jul 28, 2015
1 parent 60e4316 commit a028d2a
Show file tree
Hide file tree
Showing 3 changed files with 120 additions and 11 deletions.
2 changes: 1 addition & 1 deletion src/modal/docs/demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ <h3 class="modal-title">I'm a modal!</h3>
<div class="modal-body">
<ul>
<li ng-repeat="item in items">
<a ng-click="selected.item = item">{{ item }}</a>
<a href="#" ng-click="$event.preventDefault(); selected.item = item">{{ item }}</a>
</li>
</ul>
Selected: <b>{{ selected.item }}</b>
Expand Down
91 changes: 82 additions & 9 deletions src/modal/modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,13 @@ angular.module('ui.bootstrap.modal', [])
NOW_CLOSING_EVENT: 'modal.stack.now-closing'
};

//Modal focus behavior
var focusableElementList;
var focusIndex = 0;
var tababbleSelector = 'a[href], area[href], input:not([disabled]), ' +
'button:not([disabled]),select:not([disabled]), textarea:not([disabled]), ' +
'iframe, object, embed, *[tabindex], *[contenteditable=true]';

function backdropIndex() {
var topBackdropIndex = -1;
var opened = openedWindows.keys();
Expand All @@ -207,7 +214,7 @@ angular.module('ui.bootstrap.modal', [])
return topBackdropIndex;
}

$rootScope.$watch(backdropIndex, function(newBackdropIndex){
$rootScope.$watch(backdropIndex, function(newBackdropIndex) {
if (backdropScope) {
backdropScope.index = newBackdropIndex;
}
Expand Down Expand Up @@ -281,15 +288,35 @@ angular.module('ui.bootstrap.modal', [])
}

$document.bind('keydown', function (evt) {
var modal;
var modal = openedWindows.top();
if (modal && modal.value.keyboard) {
switch (evt.which){
case 27: {
evt.preventDefault();
$rootScope.$apply(function () {
$modalStack.dismiss(modal.key, 'escape key press');
});
break;
}
case 9: {
$modalStack.loadFocusElementList(modal);
var focusChanged = false;
if (evt.shiftKey) {
if ($modalStack.isFocusInFirstItem(evt)) {
focusChanged = $modalStack.focusLastFocusableElement();
}
} else {
if ($modalStack.isFocusInLastItem(evt)) {
focusChanged = $modalStack.focusFirstFocusableElement();
}
}

if (evt.which === 27) {
modal = openedWindows.top();
if (modal && modal.value.keyboard) {
evt.preventDefault();
$rootScope.$apply(function () {
$modalStack.dismiss(modal.key, 'escape key press');
});
if (focusChanged) {
evt.preventDefault();
evt.stopPropagation();
}
break;
}
}
}
});
Expand Down Expand Up @@ -338,6 +365,7 @@ angular.module('ui.bootstrap.modal', [])
openedWindows.top().value.modalOpener = modalOpener;
body.append(modalDomEl);
body.addClass(OPENED_MODAL_CLASS);
$modalStack.clearFocusListCache();
};

function broadcastClosing(modalWindow, resultOrReason, closing) {
Expand Down Expand Up @@ -382,6 +410,51 @@ angular.module('ui.bootstrap.modal', [])
}
};

$modalStack.focusFirstFocusableElement = function() {
if (focusableElementList.length > 0) {
focusableElementList[0].focus();
return true;
}
return false;
};
$modalStack.focusLastFocusableElement = function() {
if (focusableElementList.length > 0) {
focusableElementList[focusableElementList.length - 1].focus();
return true;
}
return false;
};

$modalStack.isFocusInFirstItem = function(evt) {
if (focusableElementList.length > 0) {
return (evt.target || evt.srcElement) == focusableElementList[0];
}
return false;
};

$modalStack.isFocusInLastItem = function(evt) {
if (focusableElementList.length > 0) {
return (evt.target || evt.srcElement) == focusableElementList[focusableElementList.length - 1];
}
return false;
};

$modalStack.clearFocusListCache = function() {
focusableElementList = [];
focusIndex = 0;
};

$modalStack.loadFocusElementList = function(modalWindow) {
if (focusableElementList === undefined || !focusableElementList.length0) {
if (modalWindow) {
var modalDomE1 = modalWindow.value.modalDomEl;
if (modalDomE1 && modalDomE1.length) {
focusableElementList = modalDomE1[0].querySelectorAll(tababbleSelector);
}
}
}
};

return $modalStack;
}])

Expand Down
38 changes: 37 additions & 1 deletion src/modal/test/modal.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ describe('$modal', function () {
var $animate, $controllerProvider, $rootScope, $document, $compile, $templateCache, $timeout, $q;
var $modal, $modalProvider;

var triggerKeyDown = function (element, keyCode) {
var triggerKeyDown = function (element, keyCode, shiftKey) {
var e = $.Event('keydown');
e.srcElement = element[0];
e.which = keyCode;
e.shiftKey = shiftKey;
element.trigger(e);
};

Expand Down Expand Up @@ -344,6 +346,40 @@ describe('$modal', function () {
openAndCloseModalWithAutofocusElement();
openAndCloseModalWithAutofocusElement();
});

it('should change focus to first element when tab key was pressed', function() {
var initialPage = angular.element('<a href="#" id="cannot-get-focus-from-modal">Outland link</a>');
angular.element(document.body).append(initialPage);
initialPage.focus();

open({
template:'<a href="#" id="tab-focus-link"><input type="text" id="tab-focus-input1"/><input type="text" id="tab-focus-input2"/>' +
'<button id="tab-focus-button">Open me!</button>'
});
expect($document).toHaveModalsOpen(1);

var lastElement = angular.element(document.getElementById('tab-focus-button'));
lastElement.focus();
triggerKeyDown(lastElement, 9);
expect(document.activeElement.getAttribute('id')).toBe('tab-focus-link');
});

it('should change focus to last element when shift+tab key is pressed', function() {
var initialPage = angular.element('<a href="#" id="cannot-get-focus-from-modal">Outland link</a>');
angular.element(document.body).append(initialPage);
initialPage.focus();

open({
template:'<a href="#" id="tab-focus-link"><input type="text" id="tab-focus-input1"/><input type="text" id="tab-focus-input2"/>' +
'<button id="tab-focus-button">Open me!</button>'
});
expect($document).toHaveModalsOpen(1);

var lastElement = angular.element(document.getElementById('tab-focus-link'));
lastElement.focus();
triggerKeyDown(lastElement, 9, true);
expect(document.activeElement.getAttribute('id')).toBe('tab-focus-button');
});
});

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

0 comments on commit a028d2a

Please sign in to comment.