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

Commit

Permalink
fix(modal): improve ARIA support.
Browse files Browse the repository at this point in the history
- Add accessibility with multiple modals

Closes #6203
  • Loading branch information
nickheiner-usds authored and wesleycho committed Sep 24, 2016
1 parent a4bea6f commit f9f7e02
Show file tree
Hide file tree
Showing 7 changed files with 109 additions and 10 deletions.
2 changes: 1 addition & 1 deletion .travis.yml
Expand Up @@ -13,7 +13,7 @@ addons:
before_install:
- export DISPLAY=:99.0
- sh -e /etc/init.d/xvfb start
- npm install --quiet -g grunt-cli karma
- npm install --quiet -g karma

script: grunt
sudo: false
3 changes: 3 additions & 0 deletions package.json
Expand Up @@ -15,6 +15,7 @@
],
"main": "index.js",
"scripts": {
"demo": "grunt after-test && static dist -a 0.0.0.0 -H '{\"Cache-Control\": \"no-cache, must-revalidate\"}'",
"test": "grunt"
},
"repository": {
Expand All @@ -26,6 +27,7 @@
"angular-mocks": "1.5.8",
"angular-sanitize": "1.5.8",
"grunt": "^0.4.5",
"grunt-cli": "^1.2.0",
"grunt-contrib-concat": "^1.0.0",
"grunt-contrib-copy": "^1.0.0",
"grunt-contrib-uglify": "^1.0.1",
Expand All @@ -44,6 +46,7 @@
"load-grunt-tasks": "^3.3.0",
"lodash": "^4.1.0",
"marked": "^0.3.5",
"node-static": "^0.7.8",
"semver": "^5.0.1",
"shelljs": "^0.6.0"
},
Expand Down
20 changes: 19 additions & 1 deletion src/modal/docs/demo.html
@@ -1,4 +1,4 @@
<div ng-controller="ModalDemoCtrl as $ctrl">
<div ng-controller="ModalDemoCtrl as $ctrl" class="modal-demo">
<script type="text/ng-template" id="myModalContent.html">
<div class="modal-header">
<h3 class="modal-title" id="modal-title">I'm a modal!</h3>
Expand All @@ -16,11 +16,29 @@ <h3 class="modal-title" id="modal-title">I'm a modal!</h3>
<button class="btn btn-warning" type="button" ng-click="$ctrl.cancel()">Cancel</button>
</div>
</script>
<script type="text/ng-template" id="stackedModal.html">
<div class="modal-header">
<h3 class="modal-title" id="modal-title-{{name}}">The {{name}} modal!</h3>
</div>
<div class="modal-body" id="modal-body-{{name}}">
Having multiple modals open at once is probably bad UX but it's technically possible.
</div>
</script>

<button type="button" class="btn btn-default" ng-click="$ctrl.open()">Open me!</button>
<button type="button" class="btn btn-default" ng-click="$ctrl.open('lg')">Large modal</button>
<button type="button" class="btn btn-default" ng-click="$ctrl.open('sm')">Small modal</button>
<button type="button"
class="btn btn-default"
ng-click="$ctrl.open('sm', '.modal-parent')">
Modal appended to a custom parent
</button>
<button type="button" class="btn btn-default" ng-click="$ctrl.toggleAnimation()">Toggle Animation ({{ $ctrl.animationsEnabled }})</button>
<button type="button" class="btn btn-default" ng-click="$ctrl.openComponentModal()">Open a component modal!</button>
<button type="button" class="btn btn-default" ng-click="$ctrl.openMultipleModals()">
Open multiple modals at once
</button>
<div ng-show="$ctrl.selected">Selection from a modal: {{ $ctrl.selected }}</div>
<div class="modal-parent">
</div>
</div>
31 changes: 29 additions & 2 deletions src/modal/docs/demo.js
@@ -1,10 +1,12 @@
angular.module('ui.bootstrap.demo').controller('ModalDemoCtrl', function ($uibModal, $log) {
angular.module('ui.bootstrap.demo').controller('ModalDemoCtrl', function ($uibModal, $log, $document) {
var $ctrl = this;
$ctrl.items = ['item1', 'item2', 'item3'];

$ctrl.animationsEnabled = true;

$ctrl.open = function (size) {
$ctrl.open = function (size, parentSelector) {
var parentElem = parentSelector ?
angular.element($document[0].querySelector('.modal-demo ' + parentSelector)) : undefined;
var modalInstance = $uibModal.open({
animation: $ctrl.animationsEnabled,
ariaLabelledBy: 'modal-title',
Expand All @@ -13,6 +15,7 @@ angular.module('ui.bootstrap.demo').controller('ModalDemoCtrl', function ($uibMo
controller: 'ModalInstanceCtrl',
controllerAs: '$ctrl',
size: size,
appendTo: parentElem,
resolve: {
items: function () {
return $ctrl.items;
Expand Down Expand Up @@ -45,6 +48,30 @@ angular.module('ui.bootstrap.demo').controller('ModalDemoCtrl', function ($uibMo
});
};

$ctrl.openMultipleModals = function () {
$uibModal.open({
animation: $ctrl.animationsEnabled,
ariaLabelledBy: 'modal-title-bottom',
ariaDescribedBy: 'modal-body-bottom',
templateUrl: 'stackedModal.html',
size: 'sm',
controller: function($scope) {
$scope.name = 'bottom';
}
});

$uibModal.open({
animation: $ctrl.animationsEnabled,
ariaLabelledBy: 'modal-title-top',
ariaDescribedBy: 'modal-body-top',
templateUrl: 'stackedModal.html',
size: 'sm',
controller: function($scope) {
$scope.name = 'top';
}
});
};

$ctrl.toggleAnimation = function () {
$ctrl.animationsEnabled = !$ctrl.animationsEnabled;
};
Expand Down
2 changes: 1 addition & 1 deletion src/modal/docs/readme.md
@@ -1,5 +1,5 @@
`$uibModal` is a service to create modal windows.
Creating modals is straightforward: create a template, a controller and reference them when using `$uibModal`.
Creating modals is straightforward: create a template and controller, and reference them when using `$uibModal`.

The `$uibModal` service has only one method: `open(options)`.

Expand Down
55 changes: 53 additions & 2 deletions src/modal/modal.js
Expand Up @@ -163,7 +163,7 @@ angular.module('ui.bootstrap.modal', ['ui.bootstrap.stackedMap', 'ui.bootstrap.p
// {@link Attribute#$observe} on it. For more details please see {@link TableColumnResize}.
scope.$isRendered = true;

// Deferred object that will be resolved when this modal is render.
// Deferred object that will be resolved when this modal is rendered.
var modalRenderDeferObj = $q.defer();
// Resolve render promise post-digest
scope.$$postDigest(function() {
Expand Down Expand Up @@ -196,7 +196,7 @@ angular.module('ui.bootstrap.modal', ['ui.bootstrap.stackedMap', 'ui.bootstrap.p

/**
* If something within the freshly-opened modal already has focus (perhaps via a
* directive that causes focus). then no need to try and focus anything.
* directive that causes focus) then there's no need to try to focus anything.
*/
if (!($document[0].activeElement && element[0].contains($document[0].activeElement))) {
var inputWithAutofocus = element[0].querySelector('[autofocus]');
Expand Down Expand Up @@ -254,6 +254,7 @@ angular.module('ui.bootstrap.modal', ['ui.bootstrap.stackedMap', 'ui.bootstrap.p
};
var topModalIndex = 0;
var previousTopOpenedModal = null;
var ARIA_HIDDEN_ATTRIBUTE_NAME = 'data-bootstrap-modal-aria-hidden-count';

//Modal focus behavior
var tabbableSelector = 'a[href], area[href], input:not([disabled]):not([tabindex=\'-1\']), ' +
Expand Down Expand Up @@ -555,25 +556,74 @@ angular.module('ui.bootstrap.modal', ['ui.bootstrap.stackedMap', 'ui.bootstrap.p

openedWindows.top().value.modalDomEl = angularDomEl;
openedWindows.top().value.modalOpener = modalOpener;

applyAriaHidden(angularDomEl);

function applyAriaHidden(el) {
if (!el || el[0].tagName === 'BODY') {
return;
}

getSiblings(el).forEach(function(sibling) {
var elemIsAlreadyHidden = sibling.getAttribute('aria-hidden') === 'true',
ariaHiddenCount = parseInt(sibling.getAttribute(ARIA_HIDDEN_ATTRIBUTE_NAME), 10);

if (!ariaHiddenCount) {
ariaHiddenCount = elemIsAlreadyHidden ? 1 : 0;
}

sibling.setAttribute(ARIA_HIDDEN_ATTRIBUTE_NAME, ariaHiddenCount + 1);
sibling.setAttribute('aria-hidden', 'true');
});

return applyAriaHidden(el.parent());

function getSiblings(el) {
var children = el.parent() ? el.parent().children() : [];

return Array.prototype.filter.call(children, function(child) {
return child !== el[0];
});
}
}
};

function broadcastClosing(modalWindow, resultOrReason, closing) {
return !modalWindow.value.modalScope.$broadcast('modal.closing', resultOrReason, closing).defaultPrevented;
}

function unhideBackgroundElements() {
Array.prototype.forEach.call(
document.querySelectorAll('[' + ARIA_HIDDEN_ATTRIBUTE_NAME + ']'),
function(hiddenEl) {
var ariaHiddenCount = parseInt(hiddenEl.getAttribute(ARIA_HIDDEN_ATTRIBUTE_NAME), 10),
newHiddenCount = ariaHiddenCount - 1;
hiddenEl.setAttribute(ARIA_HIDDEN_ATTRIBUTE_NAME, newHiddenCount);

if (!newHiddenCount) {
hiddenEl.removeAttribute(ARIA_HIDDEN_ATTRIBUTE_NAME);
hiddenEl.removeAttribute('aria-hidden');
}
}
);
}

$modalStack.close = function(modalInstance, result) {
var modalWindow = openedWindows.get(modalInstance);
unhideBackgroundElements();
if (modalWindow && broadcastClosing(modalWindow, result, true)) {
modalWindow.value.modalScope.$$uibDestructionScheduled = true;
modalWindow.value.deferred.resolve(result);
removeModalWindow(modalInstance, modalWindow.value.modalOpener);
return true;
}

return !modalWindow;
};

$modalStack.dismiss = function(modalInstance, reason) {
var modalWindow = openedWindows.get(modalInstance);
unhideBackgroundElements();
if (modalWindow && broadcastClosing(modalWindow, reason, false)) {
modalWindow.value.modalScope.$$uibDestructionScheduled = true;
modalWindow.value.deferred.reject(reason);
Expand All @@ -596,6 +646,7 @@ angular.module('ui.bootstrap.modal', ['ui.bootstrap.stackedMap', 'ui.bootstrap.p

$modalStack.modalRendered = function(modalInstance) {
var modalWindow = openedWindows.get(modalInstance);
$modalStack.focusFirstFocusableElement($modalStack.loadFocusElementList(modalWindow));
if (modalWindow) {
modalWindow.value.renderDeferred.resolve();
}
Expand Down
6 changes: 3 additions & 3 deletions src/modal/test/modal.spec.js
Expand Up @@ -526,7 +526,7 @@ describe('$uibModal', function() {

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

triggerKeyDown($document, 27);
Expand Down Expand Up @@ -656,7 +656,7 @@ describe('$uibModal', function() {
it('should not focus on the element that has autofocus attribute when the modal is opened and something in the modal already has focus and the animations have finished', function() {
function openAndCloseModalWithAutofocusElement() {

var modal = open({template: '<div><input type="text" id="auto-focus-element" autofocus><input type="text" id="pre-focus-element" focus-me></div>'});
var modal = open({template: '<div><input type="text" id="pre-focus-element" focus-me><input type="text" id="auto-focus-element" autofocus></div>'});
$rootScope.$digest();
expect(angular.element('#auto-focus-element')).not.toHaveFocus();
expect(angular.element('#pre-focus-element')).toHaveFocus();
Expand Down Expand Up @@ -698,7 +698,7 @@ describe('$uibModal', function() {
$rootScope.$digest();
$animate.flush();

expect(document.activeElement.tagName).toBe('DIV');
expect(document.activeElement.tagName).toBe('INPUT');

close(modal, 'closed ok');

Expand Down

0 comments on commit f9f7e02

Please sign in to comment.