diff --git a/src/components/icon/icon.spec.js b/src/components/icon/icon.spec.js index 40e5a19f02..5f57e644a4 100644 --- a/src/components/icon/icon.spec.js +++ b/src/components/icon/icon.spec.js @@ -301,6 +301,16 @@ describe('MdIcon directive', function() { describe('with ARIA support', function() { + it('should apply "img" role by default', function() { + el = make(''); + expect(el.attr('role')).toEqual('img'); + }); + + it('should apply not replace current role', function() { + el = make(''); + expect(el.attr('role')).toEqual('presentation'); + }); + it('should apply aria-hidden="true" when parent has valid label', function() { el = make(''); expect(el.find('md-icon').attr('aria-hidden')).toEqual('true'); @@ -314,9 +324,17 @@ describe('MdIcon directive', function() { expect(el.find('md-icon').attr('aria-hidden')).toEqual('true'); }); - it('should apply aria-hidden="true" when parent has text content', function() { - el = make(''); - expect(el.find('md-icon').attr('aria-hidden')).toEqual('true'); + it('should not apply aria-hidden="true" when parent has valid label but invalid role', function() { + el = make(''); + expect(el.find('md-icon').attr('aria-hidden')).toBeUndefined(); + + el = make(' '+ + '
'+ + '
'+ + ' '+ + '
'); + + expect(el.find('md-icon').attr('aria-hidden')).toBeUndefined(); }); it('should apply aria-hidden="true" when aria-label is empty string', function() { @@ -339,6 +357,11 @@ describe('MdIcon directive', function() { el = make(''); expect(el.attr('aria-label')).toEqual('android'); }); + + it('should apply use alt text for aria-label value when not set', function() { + el = make(''); + expect(el.attr('aria-label')).toEqual('my android icon'); + }); }); }); diff --git a/src/components/icon/js/iconDirective.js b/src/components/icon/js/iconDirective.js index 7eb013e293..575336141b 100644 --- a/src/components/icon/js/iconDirective.js +++ b/src/components/icon/js/iconDirective.js @@ -218,20 +218,29 @@ function mdIconDirective($mdIcon, $mdTheming, $mdAria, $sce) { // If using a font-icon, then the textual name of the icon itself // provides the aria-label. - var label = attr.alt || attr.mdFontIcon || attr.mdSvgIcon || element.text(); var attrName = attr.$normalize(attr.$attr.mdSvgIcon || attr.$attr.mdSvgSrc || ''); - if ( !attr['aria-label'] ) { - - if (label !== '' && !parentsHaveText() ) { - - $mdAria.expect(element, 'aria-label', label); - $mdAria.expect(element, 'role', 'img'); - - } else if ( !element.text() ) { - // If not a font-icon with ligature, then - // hide from the accessibility layer. + /* Provide a default accessibility role of img */ + if (!attr.role) { + $mdAria.expect(element, 'role', 'img'); + /* manually update attr variable */ + attr.role = 'img'; + } + /* Don't process ARIA if already valid */ + if ( attr.role === "img" && !attr.ariaHidden && !$mdAria.hasAriaLabel(element) ) { + var iconName; + if (attr.alt) { + /* Use alt text by default if available */ + $mdAria.expect(element, 'aria-label', attr.alt); + } else if ($mdAria.parentHasAriaLabel(element, 2)) { + /* Parent has ARIA so we will assume it will describe the image */ + $mdAria.expect(element, 'aria-hidden', 'true'); + } else if (iconName = (attr.mdFontIcon || attr.mdSvgIcon || element.text())) { + /* Use icon name as aria-label */ + $mdAria.expect(element, 'aria-label', iconName); + } else { + /* No label found */ $mdAria.expect(element, 'aria-hidden', 'true'); } } @@ -247,21 +256,9 @@ function mdIconDirective($mdIcon, $mdTheming, $mdAria, $sce) { element.append(svg); }); } - }); } - function parentsHaveText() { - var parent = element.parent(); - if (parent.attr('aria-label') || parent.text()) { - return true; - } - else if(parent.parent().attr('aria-label') || parent.parent().text()) { - return true; - } - return false; - } - function prepareForFontIcon() { if (!attr.mdSvgIcon && !attr.mdSvgSrc) { if (attr.mdFontIcon) { diff --git a/src/core/services/aria/aria.js b/src/core/services/aria/aria.js index a14fd08cef..a680befef5 100644 --- a/src/core/services/aria/aria.js +++ b/src/core/services/aria/aria.js @@ -66,7 +66,9 @@ function MdAriaService($$rAF, $log, $window, $interpolate) { expectAsync: expectAsync, expectWithText: expectWithText, expectWithoutText: expectWithoutText, - getText: getText + getText: getText, + hasAriaLabel: hasAriaLabel, + parentHasAriaLabel: parentHasAriaLabel }; /** @@ -169,7 +171,152 @@ function MdAriaService($$rAF, $log, $window, $interpolate) { } } } - return hasAttr; } + + /** + * Check if expected element has aria label attribute + * @param element + */ + function hasAriaLabel(element) { + var node = angular.element(element)[0] || element; + + /* Check if compatible node type (ie: not HTML Document node) */ + if (!node.hasAttribute) { + return false; + } + + /* Check label or description attributes */ + return node.hasAttribute('aria-label') || node.hasAttribute('aria-labelledby') || node.hasAttribute('aria-describedby'); + } + + /** + * Check if expected element's parent has aria label attribute and has valid role and tagName + * @param element + * @param {optional} level Number of levels deep search should be performed + */ + function parentHasAriaLabel(element, level) { + level = level || 1; + var node = angular.element(element)[0] || element; + if (!node.parentNode) { + return false; + } + if (performCheck(node.parentNode)) { + return true; + } + level--; + if (level) { + return parentHasAriaLabel(node.parentNode, level); + } + return false; + + function performCheck(parentNode) { + if (!hasAriaLabel(parentNode)) { + return false; + } + /* Perform role blacklist check */ + if (parentNode.hasAttribute('role')) { + switch(parentNode.getAttribute('role').toLowerCase()) { + case 'command': + case 'definition': + case 'directory': + case 'grid': + case 'list': + case 'listitem': + case 'log': + case 'marquee': + case 'menu': + case 'menubar': + case 'note': + case 'presentation': + case 'separator': + case 'scrollbar': + case 'status': + case 'tablist': + return false; + } + } + /* Perform tagName blacklist check */ + switch(parentNode.tagName.toLowerCase()) { + case 'abbr': + case 'acronym': + case 'address': + case 'applet': + case 'audio': + case 'b': + case 'bdi': + case 'bdo': + case 'big': + case 'blockquote': + case 'br': + case 'canvas': + case 'caption': + case 'center': + case 'cite': + case 'code': + case 'col': + case 'data': + case 'dd': + case 'del': + case 'dfn': + case 'dir': + case 'div': + case 'dl': + case 'em': + case 'embed': + case 'fieldset': + case 'figcaption': + case 'font': + case 'h1': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': + case 'hgroup': + case 'html': + case 'i': + case 'ins': + case 'isindex': + case 'kbd': + case 'keygen': + case 'label': + case 'legend': + case 'li': + case 'map': + case 'mark': + case 'menu': + case 'object': + case 'ol': + case 'output': + case 'pre': + case 'presentation': + case 'q': + case 'rt': + case 'ruby': + case 'samp': + case 'small': + case 'source': + case 'span': + case 'status': + case 'strike': + case 'strong': + case 'sub': + case 'sup': + case 'svg': + case 'tbody': + case 'td': + case 'th': + case 'thead': + case 'time': + case 'tr': + case 'track': + case 'tt': + case 'ul': + case 'var': + return false; + } + return true; + } + } } diff --git a/src/core/services/aria/aria.spec.js b/src/core/services/aria/aria.spec.js index 0ad503e189..8692ceb113 100644 --- a/src/core/services/aria/aria.spec.js +++ b/src/core/services/aria/aria.spec.js @@ -113,6 +113,58 @@ describe('$mdAria service', function() { }); + describe('aria label checks', function() { + + it('should detect if element has valid aria-label string', inject(function($compile, $rootScope, $log, $mdAria) { + var container = $compile('
')($rootScope); + expect($mdAria.hasAriaLabel(container)).toBe(true); + })); + + it('should detect if element has valid aria-labelledby string', inject(function($compile, $rootScope, $log, $mdAria) { + var container = $compile('
')($rootScope); + expect($mdAria.hasAriaLabel(container)).toBe(true); + })); + + it('should detect if element has valid aria-describedby string', inject(function($compile, $rootScope, $log, $mdAria) { + var container = $compile('
')($rootScope); + expect($mdAria.hasAriaLabel(container)).toBe(true); + })); + + }); + + describe('aria label parent checks', function() { + + it('should detect if parent of element has valid aria label', inject(function($compile, $rootScope, $log, $mdAria) { + var container = $compile('')($rootScope); + var imgElement = container.find('img'); + expect($mdAria.hasAriaLabel(imgElement)).toBe(false); + expect($mdAria.parentHasAriaLabel(imgElement)).toBe(true); + })); + + it('should blacklist parent of element based on role', inject(function($compile, $rootScope, $log, $mdAria) { + var container = $compile('')($rootScope); + var imgElement = container.find('img'); + expect($mdAria.hasAriaLabel(imgElement)).toBe(false); + expect($mdAria.parentHasAriaLabel(imgElement)).toBe(false); + })); + + it('should blacklist parent of element based on tagName', inject(function($compile, $rootScope, $log, $mdAria) { + var container = $compile('')($rootScope); + var imgElement = container.find('img'); + expect($mdAria.hasAriaLabel(imgElement)).toBe(false); + expect($mdAria.parentHasAriaLabel(imgElement)).toBe(false); + })); + + it('should detect if second parent of element has valid aria label', inject(function($compile, $rootScope, $log, $mdAria) { + var container = $compile('')($rootScope); + var imgElement = container.find('img'); + expect($mdAria.hasAriaLabel(imgElement)).toBe(false); + expect($mdAria.parentHasAriaLabel(imgElement)).toBe(false); + expect($mdAria.parentHasAriaLabel(imgElement, 2)).toBe(true); + })); + + }); + describe('with disabled warnings', function() { beforeEach(module('material.core', function($mdAriaProvider) {