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

fix(modal): Improve ARIA support #6203

Closed
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 .travis.yml
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't the CLI meant to be installed as a global? Not so sure this is a great change.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually prefer this approach. I like a modal where npm install gets you everything you need to work with a repo, and you can know that convention works consistently across projects, instead of needing to go digging through READMEs and travis files to find what global deps are needed. I also like that this specifies a version of grunt-cli to use. I've worked on projects where people document needing global deps but not which versions of those deps they use, which causes confusion when people are accidentally mismatching.

Anyway, if you don't like this, I can back it out.

"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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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