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

fix(modal): Captures focus in both directions - using tab & shift-tab #3689

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion src/modal/modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,65 @@ angular.module('ui.bootstrap.modal', [])
};
}])

.directive('enforceFocus',['$document', '$modalStack', function ($document, $modalStack) {
return {
link: function($scope, iElm) {
var body = $document.find('body').eq(0);
var trapFocusDomEl = angular.element('<div tabindex="0"></div>');
body.append(trapFocusDomEl);


function currentModal(){
var modal = $modalStack.getTop();
if (modal && modal.value) {
return modal.value.modalDomEl[0] === iElm[0];
}
}

//enforceFocus inside modal
function enforceFocus(evt) {
if (!currentModal()) {
return;
}
if (iElm[0] !== evt.target && !iElm[0].contains(evt.target)) {
iElm[0].focus();
}
}
$document[0].addEventListener('focus', enforceFocus, true);


//return lastFocusable element inside modal
function lastFocusable(domEl) {
var tababbleSelector = 'a[href], area[href], input:not([disabled]), button:not([disabled]),select:not([disabled]), textarea:not([disabled]), iframe, object, embed, *[tabindex], *[contenteditable]';
var list = domEl.querySelectorAll(tababbleSelector);
return list[list.length - 1];
}

var lastEl = lastFocusable(iElm[0]);
//focus lastElement when shitKey Tab first element
function shiftKeyTabTrap (evt) {
if (!currentModal()) {
return;
}
if(iElm[0] === evt.target && evt.shiftKey && evt.keyCode === 9){
lastEl.focus();
evt.preventDefault();
}
}
$document.bind('keydown', shiftKeyTabTrap);


$scope.$on('$destroy',function() {
//Remove trap
trapFocusDomEl.remove();
//Remove event listener
$document[0].removeEventListener('focus', enforceFocus, true);
$document.unbind('keydown', shiftKeyTabTrap);
});
}
};
}])

.directive('modalAnimationClass', [
function () {
return {
Expand Down Expand Up @@ -291,7 +350,7 @@ angular.module('ui.bootstrap.modal', [])
body.append(backdropDomEl);
}

var angularDomEl = angular.element('<div modal-window="modal-window"></div>');
var angularDomEl = angular.element('<div enforce-focus modal-window="modal-window"></div>');
angularDomEl.attr({
'template-url': modal.windowTemplateUrl,
'window-class': modal.windowClass,
Expand Down
68 changes: 68 additions & 0 deletions src/modal/test/modal.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,29 @@ describe('$modal', function () {
element.remove();
});

it('should support Tab and return focus to the dialog', function () {
var link = '<a href>Link</a>';
var aElement = angular.element(link);
angular.element(document.body).append(aElement);
aElement.focus();
expect(document.activeElement.tagName).toBe('A');

var modal = open({template: '<div>Content<input type="text"><button>inside modal</button></div>'});
$timeout.flush();

//Focus to last link
var lastAElement = angular.element(document.body).find('a[href]').last();
lastAElement.focus();
var $activeElement = angular.element(document.activeElement.tagName);
expect($activeElement).toHaveClass('modal');
expect($document).toHaveModalsOpen(1);

dismiss(modal);
expect($document).toHaveModalsOpen(0);

aElement.remove();
});

it('should resolve returned promise on close', function () {
var modal = open({template: '<div>Content</div>'});
close(modal, 'closed ok');
Expand Down Expand Up @@ -746,6 +769,51 @@ describe('$modal', function () {

element.remove();
});

it('should support Tab and return focus to the current dialog', function () {
var link = '<a href>Link</a>';
var currentModalID;
var aElement = angular.element(link);
angular.element(document.body).append(aElement);
aElement.focus();
expect(document.activeElement.tagName).toBe('A');

//Open modal1
var modal1 = open({template: '<div id="modal1">Content<input type="text"><button>inside modal1</button></div>'});
$timeout.flush();

//Focus to last link
var lastAElement = angular.element(document.body).find('a[href]').last();

//Focus outside of modal1
expect($document).toHaveModalsOpen(1);
lastAElement.focus();
currentModalID = document.activeElement.querySelector('div[id]').id;
expect(currentModalID).toBe('modal1');

//Open modal2
var modal2 = open({template: '<div id="modal2">Modal2</div>'});
$timeout.flush();

//Focus outside of modal2
expect($document).toHaveModalsOpen(2);
lastAElement.focus();
currentModalID = document.activeElement.querySelector('div[id]').id;
expect(currentModalID).toBe('modal2');
dismiss(modal2);

//Focus change to modal1
expect($document).toHaveModalsOpen(1);
currentModalID = document.activeElement.querySelector('div[id]').id;
expect(currentModalID).toBe('modal1');


dismiss(modal1);
expect($document).toHaveModalsOpen(0);

aElement.remove();
});

});

describe('modal.closing event', function() {
Expand Down