From 0b0ed2339e1ec32e34d20daa18cadd4ef89d5f86 Mon Sep 17 00:00:00 2001 From: Marcy Sutton Date: Thu, 12 Mar 2015 15:35:02 -0700 Subject: [PATCH] fix(dialog): cross-browser layout, a11y issues fix(dialog): clickOutSideToClose defaults to false fix(dialog): a11y support for dialog types fix(dialog): modal dialog traps interaction fix(dialog): ensure aria-label is not empty feat(dialog): allow override of focus on open Closes https://github.com/angular/material/pull/1759, https://github.com/angular/material/issues/1415, https://github.com/angular/material/issues/1547, https://github.com/angular/material/issues/1892 Dialogs should require action to close. The option to override is still available but set to false by default. Alert dialogs require different roles and focus behaviors than confirmation dialogs; both are now supported. Closes #1340, https://github.com/angular/material/pull/1582, fixes #1282 --- .../dialog/demoBasicUsage/dialog1.tmpl.html | 24 +-- .../dialog/demoBasicUsage/script.js | 8 +- .../dialog/demoBasicUsage/style.css | 30 ++-- src/components/dialog/dialog.js | 121 +++++++++++-- src/components/dialog/dialog.spec.js | 160 +++++++++++++++++- src/core/services/aria/aria.js | 6 +- src/core/services/aria/aria.spec.js | 11 +- 7 files changed, 304 insertions(+), 56 deletions(-) diff --git a/src/components/dialog/demoBasicUsage/dialog1.tmpl.html b/src/components/dialog/demoBasicUsage/dialog1.tmpl.html index 1199f1d60a3..f9016c30519 100644 --- a/src/components/dialog/demoBasicUsage/dialog1.tmpl.html +++ b/src/components/dialog/demoBasicUsage/dialog1.tmpl.html @@ -1,19 +1,21 @@ - + Mango (Fruit) -

- The mango is a juicy stone fruit belonging to the genus Mangifera, consisting of numerous tropical fruiting trees, cultivated mostly for edible fruit. The majority of these species are found in nature as wild mangoes. They all belong to the flowering plant family Anacardiaceae. The mango is native to South and Southeast Asia, from where it has been distributed worldwide to become one of the most cultivated fruits in the tropics. -

+
+

+ The mango is a juicy stone fruit belonging to the genus Mangifera, consisting of numerous tropical fruiting trees, cultivated mostly for edible fruit. The majority of these species are found in nature as wild mangoes. They all belong to the flowering plant family Anacardiaceae. The mango is native to South and Southeast Asia, from where it has been distributed worldwide to become one of the most cultivated fruits in the tropics. +

- + Lush mango tree -

- The highest concentration of Mangifera genus is in the western part of Malesia (Sumatra, Java and Borneo) and in Burma and India. While other Mangifera species (e.g. horse mango, M. foetida) are also grown on a more localized basis, Mangifera indica—the "common mango" or "Indian mango"—is the only mango tree commonly cultivated in many tropical and subtropical regions. -

-

- It originated in Indian subcontinent (present day India and Pakistan) and Burma. It is the national fruit of India, Pakistan, and the Philippines, and the national tree of Bangladesh. In several cultures, its fruit and leaves are ritually used as floral decorations at weddings, public celebrations, and religious ceremonies. -

+

+ The highest concentration of Mangifera genus is in the western part of Malesia (Sumatra, Java and Borneo) and in Burma and India. While other Mangifera species (e.g. horse mango, M. foetida) are also grown on a more localized basis, Mangifera indica—the "common mango" or "Indian mango"—is the only mango tree commonly cultivated in many tropical and subtropical regions. +

+

+ It originated in Indian subcontinent (present day India and Pakistan) and Burma. It is the national fruit of India, Pakistan, and the Philippines, and the national tree of Bangladesh. In several cultures, its fruit and leaves are ritually used as floral decorations at weddings, public celebrations, and religious ceremonies. +

+
diff --git a/src/components/dialog/demoBasicUsage/script.js b/src/components/dialog/demoBasicUsage/script.js index a54168c0709..b22352e240a 100644 --- a/src/components/dialog/demoBasicUsage/script.js +++ b/src/components/dialog/demoBasicUsage/script.js @@ -4,18 +4,24 @@ angular.module('dialogDemo1', ['ngMaterial']) $scope.alert = ''; $scope.showAlert = function(ev) { + // Appending dialog to document.body to cover sidenav in docs app + // Modal dialogs should fully cover application + // to prevent interaction outside of dialog $mdDialog.show( $mdDialog.alert() + .parent(angular.element(document.body)) .title('This is an alert title') .content('You can specify some description text in here.') - .ariaLabel('Password notification') + .ariaLabel('Alert Dialog Demo') .ok('Got it!') .targetEvent(ev) ); }; $scope.showConfirm = function(ev) { + // Appending dialog to document.body to cover sidenav in docs app var confirm = $mdDialog.confirm() + .parent(angular.element(document.body)) .title('Would you like to delete your debt?') .content('All of the banks have agreed to forgive you your debts.') .ariaLabel('Lucky day') diff --git a/src/components/dialog/demoBasicUsage/style.css b/src/components/dialog/demoBasicUsage/style.css index 72e35fb7d4a..b73ae5edc07 100644 --- a/src/components/dialog/demoBasicUsage/style.css +++ b/src/components/dialog/demoBasicUsage/style.css @@ -1,5 +1,5 @@ .dialogDemo1 { - height:500px; + height: 500px; } .full { @@ -8,31 +8,29 @@ } .gap { - width:50px; + width: 50px; } - -.md-subheader { - background-color: #dcedc8; - margin: 0px; +md-dialog md-content.sticky-container { + padding: 0; } + md-dialog md-content.sticky-container .dialog-content { + padding: 0 24px 24px; + } -h2.md-subheader { - margin: 0px; - margin-left: -24px; - margin-right: -24px; - margin-top: -24px; +.md-subheader { + background-color: #dcedc8; + margin: 0px; } h2.md-subheader.md-sticky-clone { - margin-right:0px; - margin-top:0px; + margin-right: 0px; + margin-top: 0px; - box-shadow: 0px 2px 4px 0 rgba(0,0,0,0.16); + box-shadow: 0px 2px 4px 0 rgba(0,0,0,0.16); } h2 .md-subheader-content { - padding-left: 10px; + padding-left: 10px; } - diff --git a/src/components/dialog/dialog.js b/src/components/dialog/dialog.js index e1f01229df8..5b6d2191333 100644 --- a/src/components/dialog/dialog.js +++ b/src/components/dialog/dialog.js @@ -275,7 +275,8 @@ function MdDialogDirective($$rAF, $mdTheming) { * - `targetEvent` - `{DOMClickEvent=}`: A click's event object. When passed in as an option, * the location of the click will be used as the starting point for the opening animation * of the the dialog. - * - `scope` - `{object=}`: the scope to link the template / controller to. If none is specified, it will create a new isolate scope. + * - `scope` - `{object=}`: the scope to link the template / controller to. If none is specified, + * it will create a new isolate scope. * This scope will be destroyed when the dialog is removed unless `preserveScope` is set to true. * - `preserveScope` - `{boolean=}`: whether to preserve the scope when the element is removed. Default is false * - `disableParentScroll` - `{boolean=}`: Whether to disable scrolling while the dialog is open. @@ -286,13 +287,17 @@ function MdDialogDirective($$rAF, $mdTheming) { * close it. Default true. * - `escapeToClose` - `{boolean=}`: Whether the user can press escape to close the dialog. * Default true. + * - `focusOnOpen` - `{boolean=}`: An option to override focus behavior on open. Only disable if + * focusing some other way, as focus management is required for dialogs to be accessible. + * Defaults to true. * - `controller` - `{string=}`: The controller to associate with the dialog. The controller * will be injected with the local `$mdDialog`, which passes along a scope for the dialog. * - `locals` - `{object=}`: An object containing key/value pairs. The keys will be used as names * of values to inject into the controller. For example, `locals: {three: 3}` would inject * `three` into the controller, with the value 3. If `bindToController` is true, they will be - * copied to the controller instead. - * - `bindToController` - `bool`: bind the locals to the controller, instead of passing them in. These values will not be available until after initialization. + * copied to the controller instead. + * - `bindToController` - `bool`: bind the locals to the controller, instead of passing them in. + * These values will not be available until after initialization. * - `resolve` - `{object=}`: Similar to locals, except it takes promises as values, and the * dialog will not open until all of the promises resolve. * - `controllerAs` - `{string=}`: An alias to assign the controller to on the scope. @@ -348,7 +353,7 @@ function MdDialogProvider($$interimElementProvider) { return { template: [ '', - '', + '', '

{{ dialog.title }}

', '

{{ dialog.content }}

', '
', @@ -383,15 +388,24 @@ function MdDialogProvider($$interimElementProvider) { isolateScope: true, onShow: onShow, onRemove: onRemove, - clickOutsideToClose: true, + clickOutsideToClose: false, escapeToClose: true, targetEvent: null, + focusOnOpen: true, disableParentScroll: true, transformTemplate: function(template) { return '
' + template + '
'; } }; + function trapFocus(ev) { + var dialog = document.querySelector('md-dialog'); + + if (dialog && !dialog.contains(ev.target)) { + ev.stopImmediatePropagation(); + dialog.focus(); + } + } // On show method for dialogs function onShow(scope, element, options) { @@ -403,12 +417,10 @@ function MdDialogProvider($$interimElementProvider) { options.popInTarget = angular.element((options.targetEvent || {}).target); var closeButton = findCloseButton(); - configureAria(element.find('md-dialog')); - if (options.hasBackdrop) { // Fix for IE 10 - var computeFrom = (options.parent[0] == $document[0].body && $document[0].documentElement - && $document[0].scrollTop) ? angular.element($document[0].documentElement) : options.parent; + var computeFrom = (options.parent[0] == $document[0].body && $document[0].documentElement + && $document[0].documentElement.scrollTop) ? angular.element($document[0].documentElement) : options.parent; var parentOffset = computeFrom.prop('scrollTop'); options.backdrop = angular.element(''); $mdTheming.inherit(options.backdrop, options.parent); @@ -416,6 +428,18 @@ function MdDialogProvider($$interimElementProvider) { element.css('top', parentOffset +'px'); } + var role = 'dialog', + elementToFocus = closeButton; + + if (options.$type === 'alert') { + role = 'alertdialog'; + elementToFocus = element.find('md-content'); + } + + configureAria(element.find('md-dialog'), role, options); + + document.addEventListener('focus', trapFocus, true); + if (options.disableParentScroll) { options.lastOverflow = options.parent.css('overflow'); options.parent.css('overflow', 'hidden'); @@ -427,6 +451,9 @@ function MdDialogProvider($$interimElementProvider) { options.popInTarget && options.popInTarget.length && options.popInTarget ) .then(function() { + + applyAriaToSiblings(element, true); + if (options.escapeToClose) { options.rootElementKeyupCallback = function(e) { if (e.keyCode === $mdConstant.KEY_CODE.ESCAPE) { @@ -445,7 +472,10 @@ function MdDialogProvider($$interimElementProvider) { }; element.on('click', options.dialogClickOutsideCallback); } - closeButton.focus(); + + if (options.focusOnOpen) { + elementToFocus.focus(); + } }); @@ -478,6 +508,11 @@ function MdDialogProvider($$interimElementProvider) { if (options.clickOutsideToClose) { element.off('click', options.dialogClickOutsideCallback); } + + applyAriaToSiblings(element, false); + + document.removeEventListener('focus', trapFocus, true); + return dialogPopOut( element, options.parent, @@ -493,20 +528,72 @@ function MdDialogProvider($$interimElementProvider) { /** * Inject ARIA-specific attributes appropriate for Dialogs */ - function configureAria(element) { + function configureAria(element, role, options) { + element.attr({ - 'role': 'dialog' + 'role': role, + 'tabIndex': '-1' }); var dialogContent = element.find('md-content'); if (dialogContent.length === 0){ dialogContent = element; } - $mdAria.expectAsync(element, 'aria-label', function() { - var words = dialogContent.text().split(/\s+/); - if (words.length > 3) words = words.slice(0,3).concat('...'); - return words.join(' '); - }); + + var dialogId = element.attr('id') || ('dialog_' + $mdUtil.nextUid()); + dialogContent.attr('id', dialogId); + element.attr('aria-describedby', dialogId); + + if (options.ariaLabel) { + $mdAria.expect(element, 'aria-label', options.ariaLabel); + } + else { + $mdAria.expectAsync(element, 'aria-label', function() { + var words = dialogContent.text().split(/\s+/); + if (words.length > 3) words = words.slice(0,3).concat('...'); + return words.join(' '); + }); + } + } + /** + * Utility function to filter out raw DOM nodes + */ + function isNodeOneOf(elem, nodeTypeArray) { + if (nodeTypeArray.indexOf(elem.nodeName) !== -1) { + return true; + } + } + /** + * Walk DOM to apply or remove aria-hidden on sibling nodes + * and parent sibling nodes + * + * Prevents screen reader interaction behind modal window + * on swipe interfaces + */ + function applyAriaToSiblings(element, value) { + var attribute = 'aria-hidden'; + + // get raw DOM node + element = element[0]; + + function walkDOM(element) { + while (element.parentNode) { + if (element === document.body) { + return; + } + var children = element.parentNode.children; + for (var i = 0; i < children.length; i++) { + // skip over child if it is an ascendant of the dialog + // or a script or style tag + if (element !== children[i] && !isNodeOneOf(children[i], ['SCRIPT', 'STYLE'])) { + children[i].setAttribute(attribute, value); + } + } + + walkDOM(element = element.parentNode); + } + } + walkDOM(element); } function dialogPopIn(container, parentElement, clickElement) { diff --git a/src/components/dialog/dialog.spec.js b/src/components/dialog/dialog.spec.js index 80e07d97ef6..9cf467f87c3 100644 --- a/src/components/dialog/dialog.spec.js +++ b/src/components/dialog/dialog.spec.js @@ -41,22 +41,59 @@ describe('$mdDialog', function() { var title = angular.element(parent[0].querySelector('h2')); expect(title.text()).toBe('Title'); + var content = parent.find('p'); expect(content.text()).toBe('Hello world'); + var buttons = parent.find('md-button'); expect(buttons.length).toBe(1); expect(buttons.eq(0).text()).toBe('Next'); + var theme = parent.find('md-dialog').attr('md-theme'); expect(theme).toBe('some-theme'); - buttons.eq(0).triggerHandler('click'); $rootScope.$apply(); - parent.find('md-dialog').triggerHandler('transitionend'); + + var dialog = parent.find('md-dialog'); + dialog.triggerHandler('transitionend'); + expect(dialog.attr('role')).toBe('alertdialog'); + $rootScope.$apply(); expect(parent.find('h2').length).toBe(0); expect(resolved).toBe(true); })); + + + it('should focus `md-content` on open', inject(function($mdDialog, $rootScope, $document, $timeout, $mdConstant) { + TestUtil.mockElementFocus(this); + + var parent = angular.element('
'); + + $mdDialog.show( + $mdDialog.alert({ + template: + '' + + '' + + '

Muppets are the best

' + + '
' + + '
', + parent: parent + }) + ); + + $rootScope.$apply(); + $timeout.flush(); + + var container = angular.element(parent[0].querySelector('.md-dialog-container')); + container.triggerHandler('transitionend'); + $rootScope.$apply(); + + parent.find('md-dialog').triggerHandler('transitionend'); + $rootScope.$apply(); + + expect($document.activeElement).toBe(parent[0].querySelector('md-content')); + })); }); describe('#confirm()', function() { @@ -89,8 +126,10 @@ describe('$mdDialog', function() { var title = parent.find('h2'); expect(title.text()).toBe('Title'); + var content = parent.find('p'); expect(content.text()).toBe('Hello world'); + var buttons = parent.find('md-button'); expect(buttons.length).toBe(2); expect(buttons.eq(0).text()).toBe('Next'); @@ -98,13 +137,44 @@ describe('$mdDialog', function() { buttons.eq(1).triggerHandler('click'); $rootScope.$digest(); + $animate.triggerCallbacks(); - parent.find('md-dialog').triggerHandler('transitionend'); + var dialog = parent.find('md-dialog'); + dialog.triggerHandler('transitionend'); + expect(dialog.attr('role')).toBe('dialog'); $rootScope.$digest(); $animate.triggerCallbacks(); + expect(parent.find('h2').length).toBe(0); expect(rejected).toBe(true); })); + + it('should focus `md-button.dialog-close` on open', inject(function($mdDialog, $rootScope, $document, $timeout, $mdConstant) { + TestUtil.mockElementFocus(this); + + var parent = angular.element('
'); + $mdDialog.show({ + template: + '' + + '
' + + '' + + '
' + + '
', + parent: parent + }); + + $rootScope.$apply(); + $timeout.flush(); + + var container = angular.element(parent[0].querySelector('.md-dialog-container')); + container.triggerHandler('transitionend'); + $rootScope.$apply(); + + parent.find('md-dialog').triggerHandler('transitionend'); + $rootScope.$apply(); + + expect($document.activeElement).toBe(parent[0].querySelector('.dialog-close')); + })); }); describe('#build()', function() { @@ -287,30 +357,59 @@ describe('$mdDialog', function() { expect(parent[0].querySelectorAll('md-backdrop').length).toBe(0); })); - it('should focus `md-button.dialog-close` on open', inject(function($mdDialog, $rootScope, $document, $timeout, $mdConstant) { + it('should focusOnOpen == true', inject(function($mdDialog, $rootScope, $document, $timeout, $mdConstant) { TestUtil.mockElementFocus(this); - var parent = angular.element('
'); $mdDialog.show({ + focusOnOpen: true, + parent: parent, template: '' + '
' + - '' + + '
' + - '
', - parent: parent + '' }); $rootScope.$apply(); $timeout.flush(); + var container = angular.element(parent[0].querySelector('.md-dialog-container')); container.triggerHandler('transitionend'); $rootScope.$apply(); parent.find('md-dialog').triggerHandler('transitionend'); $rootScope.$apply(); + expect($document.activeElement).toBe(parent[0].querySelector('#focus-target')); + })); + + it('should focusOnOpen == false', inject(function($mdDialog, $rootScope, $document, $timeout, $mdConstant) { + TestUtil.mockElementFocus(this); - expect($document.activeElement).toBe(parent[0].querySelector('.dialog-close')); + var parent = angular.element('
'); + $mdDialog.show({ + focusOnOpen: false, + parent: parent, + template: + '' + + '
' + + '
' + + '
', + }); + + $rootScope.$apply(); + $timeout.flush(); + + var container = angular.element(parent[0].querySelector('.md-dialog-container')); + container.triggerHandler('transitionend'); + $rootScope.$apply(); + parent.find('md-dialog').triggerHandler('transitionend'); + $rootScope.$apply(); + + expect($document.activeElement).toBe(undefined); })); it('should focus the last `md-button` in md-actions open if no `.dialog-close`', inject(function($mdDialog, $rootScope, $document, $timeout, $mdConstant) { @@ -418,6 +517,49 @@ describe('$mdDialog', function() { expect(dialog.attr('aria-label')).not.toEqual(dialog.text()); expect(dialog.attr('aria-label')).toEqual('Some Other Thing'); })); + + it('should add an ARIA label if supplied through chaining', inject(function($mdDialog, $rootScope, $animate){ + var parent = angular.element('
'); + + $mdDialog.show( + $mdDialog.alert({ + parent: parent + }) + .ariaLabel('label') + ); + + $rootScope.$apply(); + angular.element(parent[0].querySelector('.md-dialog-container')).triggerHandler('transitionend'); + $rootScope.$apply(); + + var dialog = angular.element(parent[0].querySelector('md-dialog')); + expect(dialog.attr('aria-label')).toEqual('label'); + })); + + it('should apply aria-hidden to siblings', inject(function($mdDialog, $rootScope, $timeout) { + + var template = 'Hello'; + var parent = angular.element('
'); + parent.append('
'); + + $mdDialog.show({ + template: template, + parent: parent + }); + + $rootScope.$apply(); + $timeout.flush(); + + parent.find('md-dialog').triggerHandler('transitionend'); + $rootScope.$apply(); + + var dialog = angular.element(parent.find('md-dialog')); + expect(dialog.attr('aria-hidden')).toBe(undefined); + expect(dialog.parent().attr('aria-hidden')).toBe(undefined); + + var sibling = angular.element(parent[0].querySelector('.sibling')); + expect(sibling.attr('aria-hidden')).toBe('true'); + })); }); function hasConfigurationMethods(preset, methods) { diff --git a/src/core/services/aria/aria.js b/src/core/services/aria/aria.js index 93a5fff347c..f7aa3948c12 100644 --- a/src/core/services/aria/aria.js +++ b/src/core/services/aria/aria.js @@ -21,7 +21,11 @@ function AriaService($$rAF, $log, $window) { function expect(element, attrName, defaultValue) { var node = element[0]; - if (!node.hasAttribute(attrName) && !childHasAttribute(node, attrName)) { + // if node exists and neither it nor its children have the attribute + if (node && + ((!node.hasAttribute(attrName) || + node.getAttribute(attrName).length === 0) && + !childHasAttribute(node, attrName))) { defaultValue = angular.isString(defaultValue) ? defaultValue.trim() : ''; if (defaultValue.length) { diff --git a/src/core/services/aria/aria.spec.js b/src/core/services/aria/aria.spec.js index db7d334c37e..83de396ff2a 100644 --- a/src/core/services/aria/aria.spec.js +++ b/src/core/services/aria/aria.spec.js @@ -2,7 +2,7 @@ describe('$mdAria service', function() { beforeEach(module('material.core')); describe('expecting attributes', function(){ - it('should warn if element is missing text', inject(function($compile, $rootScope, $log, $mdAria) { + it('should warn if element is missing attribute', inject(function($compile, $rootScope, $log, $mdAria) { spyOn($log, 'warn'); var button = $compile('')($rootScope); @@ -11,6 +11,15 @@ describe('$mdAria service', function() { expect($log.warn).toHaveBeenCalled(); })); + it('should warn if element is missing attribute value', inject(function($compile, $rootScope, $log, $mdAria) { + spyOn($log, 'warn'); + var button = $compile('')($rootScope); + + $mdAria.expect(button, 'aria-label'); + + expect($log.warn).toHaveBeenCalled(); + })); + it('should not warn if child element has attribute', inject(function($compile, $rootScope, $log, $mdAria) { spyOn($log, 'warn'); var button = $compile('')($rootScope);