From f9f7e02d153ff6d9fe0325248ab29385a51fb292 Mon Sep 17 00:00:00 2001 From: Nicholas Heiner Date: Thu, 25 Aug 2016 12:16:19 -0400 Subject: [PATCH] fix(modal): improve ARIA support. - Add accessibility with multiple modals Closes #6203 --- .travis.yml | 2 +- package.json | 3 ++ src/modal/docs/demo.html | 20 ++++++++++++- src/modal/docs/demo.js | 31 ++++++++++++++++++-- src/modal/docs/readme.md | 2 +- src/modal/modal.js | 55 ++++++++++++++++++++++++++++++++++-- src/modal/test/modal.spec.js | 6 ++-- 7 files changed, 109 insertions(+), 10 deletions(-) diff --git a/.travis.yml b/.travis.yml index fba6738247..41beb93ade 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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 diff --git a/package.json b/package.json index dcab71c151..7c1c10d320 100644 --- a/package.json +++ b/package.json @@ -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": { @@ -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", @@ -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" }, diff --git a/src/modal/docs/demo.html b/src/modal/docs/demo.html index e7151eb4d9..616b1a0c03 100644 --- a/src/modal/docs/demo.html +++ b/src/modal/docs/demo.html @@ -1,4 +1,4 @@ -
+ diff --git a/src/modal/docs/demo.js b/src/modal/docs/demo.js index ad07089fbf..53b335dad1 100644 --- a/src/modal/docs/demo.js +++ b/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', @@ -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; @@ -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; }; diff --git a/src/modal/docs/readme.md b/src/modal/docs/readme.md index 83db1aca12..66b6f36803 100644 --- a/src/modal/docs/readme.md +++ b/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)`. diff --git a/src/modal/modal.js b/src/modal/modal.js index 1666152630..8c5a3df3ba 100644 --- a/src/modal/modal.js +++ b/src/modal/modal.js @@ -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() { @@ -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]'); @@ -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\']), ' + @@ -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); @@ -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(); } diff --git a/src/modal/test/modal.spec.js b/src/modal/test/modal.spec.js index 98d3d258c9..5ab1939f1a 100644 --- a/src/modal/test/modal.spec.js +++ b/src/modal/test/modal.spec.js @@ -526,7 +526,7 @@ describe('$uibModal', function() { var modal = open({template: '
Content
'}); $rootScope.$digest(); - expect(document.activeElement.tagName).toBe('DIV'); + expect(document.activeElement.tagName).toBe('BUTTON'); expect($document).toHaveModalsOpen(1); triggerKeyDown($document, 27); @@ -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: '
'}); + var modal = open({template: '
'}); $rootScope.$digest(); expect(angular.element('#auto-focus-element')).not.toHaveFocus(); expect(angular.element('#pre-focus-element')).toHaveFocus(); @@ -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');