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

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

Closed
wants to merge 2 commits 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
2 changes: 1 addition & 1 deletion misc/demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
UI Bootstrap
</a>
<li class="dropdown" dropdown>
<a role="button" class="dropdown-toggle" dropdown-toggle>
<a href role="button" class="dropdown-toggle" dropdown-toggle>
Directives <b class="caret"></b>
</a>
<ul class="dropdown-menu">
Expand Down
4 changes: 2 additions & 2 deletions 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="selected.item = item">{{ item }}</a>
</li>
</ul>
Selected: <b>{{ selected.item }}</b>
Expand All @@ -21,4 +21,4 @@ <h3 class="modal-title">I'm a modal!</h3>
<button class="btn btn-default" ng-click="open('lg')">Large modal</button>
<button class="btn btn-default" ng-click="open('sm')">Small modal</button>
<div ng-show="selected">Selection from a modal: {{ selected }}</div>
</div>
</div>
51 changes: 51 additions & 0 deletions src/modal/modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ angular.module('ui.bootstrap.modal', ['ui.bootstrap.transition'])

var backdropDomEl, backdropScope;
var openedWindows = $$stackedMap.createNew();
var tababbleSelector = 'a[href], area[href], input:not([disabled]), button:not([disabled]),select:not([disabled]), textarea:not([disabled]), iframe, object, embed, *[tabindex], *[contenteditable]';
var $modalStack = {};

function backdropIndex() {
Expand Down Expand Up @@ -171,6 +172,7 @@ angular.module('ui.bootstrap.modal', ['ui.bootstrap.transition'])
body.toggleClass(OPENED_MODAL_CLASS, openedWindows.length() > 0);
checkRemoveBackdrop();
});
modalWindow.trapFocusDomEl.remove();
}

function checkRemoveBackdrop() {
Expand All @@ -183,6 +185,7 @@ angular.module('ui.bootstrap.modal', ['ui.bootstrap.transition'])
});
backdropDomEl = undefined;
backdropScope = undefined;
document.removeEventListener('focus', focusModal, true);
}
}

Expand Down Expand Up @@ -218,6 +221,16 @@ angular.module('ui.bootstrap.modal', ['ui.bootstrap.transition'])
}
}

function firstFocusable(domEl) {
var list = domEl.querySelectorAll(tababbleSelector);
return list[0];
}

function lastFocusable(domEl) {
var list = domEl.querySelectorAll(tababbleSelector);
return list[list.length - 1];
}

$document.bind('keydown', function (evt) {
var modal;

Expand All @@ -232,8 +245,36 @@ angular.module('ui.bootstrap.modal', ['ui.bootstrap.transition'])
}
});

function focusModal(evt) {
var modalDomEl, focusEl;
var inside = true;
var modal = openedWindows.top();
if (modal) {
modalDomEl = modal.value.modalDomEl;
inside = modalDomEl[0].contains(evt.target);
}
if(!inside){
var trapFocusDomEl = modal.value.trapFocusDomEl;
//explicitOriginalTarget for FF support
var relatedTarget = evt.relatedTarget || evt.explicitOriginalTarget;
var fromAddressBar = relatedTarget === null;
if(trapFocusDomEl[0] === evt.target || fromAddressBar){
focusEl = firstFocusable(modalDomEl[0]);
} else {
focusEl = lastFocusable(modalDomEl[0]);
}
if (focusEl) {
focusEl.focus();
} else {
modalDomEl[0].focus();
}
}
}

$modalStack.open = function (modalInstance, modal) {

var modalOpener = $document[0].activeElement;

openedWindows.add(modalInstance, {
deferred: modal.deferred,
modalScope: modal.scope,
Expand All @@ -251,6 +292,7 @@ angular.module('ui.bootstrap.modal', ['ui.bootstrap.transition'])
angularBackgroundDomEl.attr('backdrop-class', modal.backdropClass);
backdropDomEl = $compile(angularBackgroundDomEl)(backdropScope);
body.append(backdropDomEl);
document.addEventListener('focus', focusModal, true);
}

var angularDomEl = angular.element('<div modal-window></div>');
Expand All @@ -264,7 +306,14 @@ angular.module('ui.bootstrap.modal', ['ui.bootstrap.transition'])

var modalDomEl = $compile(angularDomEl)(modal.scope);
openedWindows.top().value.modalDomEl = modalDomEl;
openedWindows.top().value.modalOpener = modalOpener;
body.append(modalDomEl);

// &nbsp; for FF and Safari
var trapFocusDomEl = angular.element('<a class="sr-only sr-only-focusable" href>&nbsp;</a>');
openedWindows.top().value.trapFocusDomEl = trapFocusDomEl;
body.append(trapFocusDomEl);

body.addClass(OPENED_MODAL_CLASS);
};

Expand All @@ -273,6 +322,7 @@ angular.module('ui.bootstrap.modal', ['ui.bootstrap.transition'])
if (modalWindow) {
modalWindow.value.deferred.resolve(result);
removeModalWindow(modalInstance);
modalWindow.value.modalOpener.focus();
}
};

Expand All @@ -281,6 +331,7 @@ angular.module('ui.bootstrap.modal', ['ui.bootstrap.transition'])
if (modalWindow) {
modalWindow.value.deferred.reject(reason);
removeModalWindow(modalInstance);
modalWindow.value.modalOpener.focus();
}
};

Expand Down
99 changes: 99 additions & 0 deletions src/modal/test/modal.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,76 @@ describe('$modal', function () {
expect($document).toHaveModalsOpen(0);
});

it('should return to the element which had focus before the dialog is invoked', function () {
var link = '<a href>Link</a>';
var element = angular.element(link);
angular.element(document.body).append(element);
element.focus();
expect(document.activeElement.tagName).toBe('A');

var modal = open({template: '<div>Content<button>inside modal</button></div>'});
$timeout.flush();
expect(document.activeElement.tagName).toBe('DIV');
expect($document).toHaveModalsOpen(1);

triggerKeyDown($document, 27);
$timeout.flush();
$rootScope.$digest();

expect(document.activeElement.tagName).toBe('A');
expect($document).toHaveModalsOpen(0);

element.remove();
});

it('should support Tab and return focus to the first item in 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();
expect(document.activeElement.tagName).toBe('INPUT');
expect($document).toHaveModalsOpen(1);

dismiss(modal);
expect(document.activeElement.tagName).toBe('A');
expect($document).toHaveModalsOpen(0);

aElement.remove();
});

it('should support Shift+Tab and return focus to the last item in 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 previous link
aElement.focus();
expect(document.activeElement.tagName).toBe('BUTTON');
expect($document).toHaveModalsOpen(1);

triggerKeyDown($document, 27);
$timeout.flush();
$rootScope.$digest();

expect(document.activeElement.tagName).toBe('A');
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 @@ -606,5 +676,34 @@ describe('$modal', function () {
dismiss(modal2);
expect(body).not.toHaveClass('modal-open');
});

it('should return to the element which had focus before the dialog is invoked', function () {
var link = '<a href>Link</a>';
var element = angular.element(link);
angular.element(document.body).append(element);
element.focus();
expect(document.activeElement.tagName).toBe('A');

var modal1 = open({template: '<div>Modal1<button id="focus">inside modal1</button></div>'});
$timeout.flush();
document.getElementById('focus').focus();
expect(document.activeElement.tagName).toBe('BUTTON');
expect($document).toHaveModalsOpen(1);

var modal2 = open({template: '<div>Modal2</div>'});
$timeout.flush();
expect(document.activeElement.tagName).toBe('DIV');
expect($document).toHaveModalsOpen(2);

dismiss(modal2);
expect(document.activeElement.tagName).toBe('BUTTON');
expect($document).toHaveModalsOpen(1);

dismiss(modal1);
expect(document.activeElement.tagName).toBe('A');
expect($document).toHaveModalsOpen(0);

element.remove();
});
});
});