diff --git a/docs/content/guide/accessibility.ngdoc b/docs/content/guide/accessibility.ngdoc index 0865c6e0cfdc..51a3cd34955f 100644 --- a/docs/content/guide/accessibility.ngdoc +++ b/docs/content/guide/accessibility.ngdoc @@ -213,11 +213,13 @@ The default CSS for `ngHide`, the inverse method to `ngShow`, makes ngAria redun If `ng-click` or `ng-dblclick` is encountered, ngAria will add `tabindex="0"` if it isn't there already. -For `ng-click`, keypress will also be bound to `div` and `li` elements. You can turn this -functionality on or off with the `bindKeypress` configuration option. +To fix widespread accessibility problems with `ng-click` on div elements, ngAria will dynamically +bind keypress by default as long as the element isn't an anchor, button, input or textarea. +You can turn this functionality on or off with the `bindKeypress` configuration option. ngAria +will also add the `button` role to communicate to users of assistive technologies. -For `ng-dblclick`, you must manually add `ng-keypress` to non-interactive elements such as `div` -or `taco-button` to enable keyboard access. +For `ng-dblclick`, you must still manually add `ng-keypress` and role to non-interactive elements such +as `div` or `taco-button` to enable keyboard access.

Example

```html diff --git a/src/ngAria/aria.js b/src/ngAria/aria.js index bfceebd59963..91a96979d985 100644 --- a/src/ngAria/aria.js +++ b/src/ngAria/aria.js @@ -22,13 +22,13 @@ * * | Directive | Supported Attributes | * |---------------------------------------------|----------------------------------------------------------------------------------------| - * | {@link ng.directive:ngModel ngModel} | aria-checked, aria-valuemin, aria-valuemax, aria-valuenow, aria-invalid, aria-required | * | {@link ng.directive:ngDisabled ngDisabled} | aria-disabled | * | {@link ng.directive:ngShow ngShow} | aria-hidden | * | {@link ng.directive:ngHide ngHide} | aria-hidden | - * | {@link ng.directive:ngClick ngClick} | tabindex, keypress event | * | {@link ng.directive:ngDblclick ngDblclick} | tabindex | * | {@link module:ngMessages ngMessages} | aria-live | + * | {@link ng.directive:ngModel ngModel} | aria-checked, aria-valuemin, aria-valuemax, aria-valuenow, aria-invalid, aria-required, input roles | + * | {@link ng.directive:ngClick ngClick} | tabindex, keypress event, button role | * * Find out more information about each directive by reading the * {@link guide/accessibility ngAria Developer Guide}. @@ -193,6 +193,10 @@ ngAriaModule.directive('ngShow', ['$aria', function($aria) { return $aria.config(normalizedAttr) && !elem.attr(attr); } + function shouldAttachRole(role, elem) { + return !elem.attr('role') && (elem.attr('type') === role) && (elem[0].nodeName !== 'INPUT'); + } + function getShape(attr, elem) { var type = attr.type, role = attr.role; @@ -237,12 +241,18 @@ ngAriaModule.directive('ngShow', ['$aria', function($aria) { switch (shape) { case 'radio': case 'checkbox': + if (shouldAttachRole(shape, elem)) { + elem.attr('role', shape); + } if (shouldAttachAttr('aria-checked', 'ariaChecked', elem)) { scope.$watch(ngAriaWatchModelValue, shape === 'radio' ? getRadioReaction() : ngAriaCheckboxReaction); } break; case 'range': + if (shouldAttachRole(shape, elem)) { + elem.attr('role', 'slider'); + } if ($aria.config('ariaValue')) { if (attr.min && !elem.attr('aria-valuemin')) { elem.attr('aria-valuemin', attr.min); @@ -307,17 +317,22 @@ ngAriaModule.directive('ngShow', ['$aria', function($aria) { var fn = $parse(attr.ngClick, /* interceptorFn */ null, /* expensiveChecks */ true); return function(scope, elem, attr) { + var nodeBlackList = ['BUTTON', 'A', 'INPUT', 'TEXTAREA']; + function isNodeOneOf(elem, nodeTypeArray) { if (nodeTypeArray.indexOf(elem[0].nodeName) !== -1) { return true; } } + if (!elem.attr('role') && !isNodeOneOf(elem, nodeBlackList)) { + elem.attr('role', 'button'); + } if ($aria.config('tabindex') && !elem.attr('tabindex')) { elem.attr('tabindex', 0); } - if ($aria.config('bindKeypress') && !attr.ngKeypress && isNodeOneOf(elem, ['DIV', 'LI'])) { + if ($aria.config('bindKeypress') && !attr.ngKeypress && !isNodeOneOf(elem, nodeBlackList)) { elem.on('keypress', function(event) { if (event.keyCode === 32 || event.keyCode === 13) { scope.$apply(callback); diff --git a/test/ngAria/ariaSpec.js b/test/ngAria/ariaSpec.js index 905196340f8a..8da1f83c7611 100644 --- a/test/ngAria/ariaSpec.js +++ b/test/ngAria/ariaSpec.js @@ -5,6 +5,10 @@ describe('$aria', function() { beforeEach(module('ngAria')); + afterEach(function() { + dealoc(element); + }); + function injectScopeAndCompiler() { return inject(function(_$compile_, _$rootScope_) { $compile = _$compile_; @@ -12,7 +16,7 @@ describe('$aria', function() { }); } - function compileInput(inputHtml) { + function compileElement(inputHtml) { element = $compile(inputHtml)(scope); scope.$digest(); } @@ -21,7 +25,7 @@ describe('$aria', function() { beforeEach(injectScopeAndCompiler); it('should attach aria-hidden to ng-show', function() { - compileInput('
'); + compileElement('
'); scope.$apply('val = false'); expect(element.attr('aria-hidden')).toBe('true'); @@ -30,7 +34,7 @@ describe('$aria', function() { }); it('should attach aria-hidden to ng-hide', function() { - compileInput('
'); + compileElement('
'); scope.$apply('val = false'); expect(element.attr('aria-hidden')).toBe('false'); @@ -39,7 +43,7 @@ describe('$aria', function() { }); it('should not change aria-hidden if it is already present on ng-show', function() { - compileInput('
'); + compileElement('
'); expect(element.attr('aria-hidden')).toBe('userSetValue'); scope.$apply('val = true'); @@ -47,7 +51,7 @@ describe('$aria', function() { }); it('should not change aria-hidden if it is already present on ng-hide', function() { - compileInput('
'); + compileElement('
'); expect(element.attr('aria-hidden')).toBe('userSetValue'); scope.$apply('val = true'); @@ -64,10 +68,10 @@ describe('$aria', function() { it('should not attach aria-hidden', function() { scope.$apply('val = false'); - compileInput('
'); + compileElement('
'); expect(element.attr('aria-hidden')).toBeUndefined(); - compileInput('
'); + compileElement('
'); expect(element.attr('aria-hidden')).toBeUndefined(); }); }); @@ -76,7 +80,7 @@ describe('$aria', function() { beforeEach(injectScopeAndCompiler); it('should attach itself to input type="checkbox"', function() { - compileInput(''); + compileElement(''); scope.$apply('val = true'); expect(element.attr('aria-checked')).toBe('true'); @@ -152,25 +156,25 @@ describe('$aria', function() { it('should attach itself to role="radio"', function() { scope.$apply("val = 'one'"); - compileInput('
'); + compileElement('
'); expect(element.attr('aria-checked')).toBe('true'); }); it('should attach itself to role="checkbox"', function() { scope.val = true; - compileInput('
'); + compileElement('
'); expect(element.attr('aria-checked')).toBe('true'); }); it('should attach itself to role="menuitemradio"', function() { scope.val = 'one'; - compileInput('
'); + compileElement('
'); expect(element.attr('aria-checked')).toBe('true'); }); it('should attach itself to role="menuitemcheckbox"', function() { scope.val = true; - compileInput('
'); + compileElement('
'); expect(element.attr('aria-checked')).toBe('true'); }); @@ -188,6 +192,50 @@ describe('$aria', function() { }); }); + describe('roles for custom inputs', function() { + beforeEach(injectScopeAndCompiler); + + it('should add missing role="button" to custom input', function() { + compileElement('
'); + expect(element.attr('role')).toBe('button'); + }); + + it('should not add role="button" to anchor', function() { + compileElement(''); + expect(element.attr('role')).not.toBe('button'); + }); + + it('should add missing role="checkbox" to custom input', function() { + compileElement('
'); + expect(element.attr('role')).toBe('checkbox'); + }); + + it('should not add a role to a native checkbox', function() { + compileElement(''); + expect(element.attr('role')).toBe(undefined); + }); + + it('should add missing role="radio" to custom input', function() { + compileElement('
'); + expect(element.attr('role')).toBe('radio'); + }); + + it('should not add a role to a native radio button', function() { + compileElement(''); + expect(element.attr('role')).toBe(undefined); + }); + + it('should add missing role="slider" to custom input', function() { + compileElement('
'); + expect(element.attr('role')).toBe('slider'); + }); + + it('should not add a role to a native range input', function() { + compileElement(''); + expect(element.attr('role')).toBe(undefined); + }); + }); + describe('aria-checked when disabled', function() { beforeEach(configAriaProvider({ ariaChecked: false @@ -195,16 +243,16 @@ describe('$aria', function() { beforeEach(injectScopeAndCompiler); it('should not attach aria-checked', function() { - compileInput("
"); + compileElement("
"); expect(element.attr('aria-checked')).toBeUndefined(); - compileInput("
"); + compileElement("
"); expect(element.attr('aria-checked')).toBeUndefined(); - compileInput("
"); + compileElement("
"); expect(element.attr('aria-checked')).toBeUndefined(); - compileInput("
"); + compileElement("
"); expect(element.attr('aria-checked')).toBeUndefined(); }); }); @@ -214,7 +262,7 @@ describe('$aria', function() { it('should attach itself to input elements', function() { scope.$apply('val = false'); - compileInput(""); + compileElement(""); expect(element.attr('aria-disabled')).toBe('false'); scope.$apply('val = true'); @@ -223,7 +271,7 @@ describe('$aria', function() { it('should attach itself to textarea elements', function() { scope.$apply('val = false'); - compileInput(''); + compileElement(''); expect(element.attr('aria-disabled')).toBe('false'); scope.$apply('val = true'); @@ -232,7 +280,7 @@ describe('$aria', function() { it('should attach itself to button elements', function() { scope.$apply('val = false'); - compileInput(''); + compileElement(''); expect(element.attr('aria-disabled')).toBe('false'); scope.$apply('val = true'); @@ -241,7 +289,7 @@ describe('$aria', function() { it('should attach itself to select elements', function() { scope.$apply('val = false'); - compileInput(''); + compileElement(''); expect(element.attr('aria-disabled')).toBe('false'); scope.$apply('val = true'); @@ -284,7 +332,7 @@ describe('$aria', function() { beforeEach(injectScopeAndCompiler); it('should attach aria-invalid to input', function() { - compileInput(''); + compileElement(''); scope.$apply("txtInput='LTten'"); expect(element.attr('aria-invalid')).toBe('true'); @@ -293,7 +341,7 @@ describe('$aria', function() { }); it('should not attach itself if aria-invalid is already present', function() { - compileInput(''); + compileElement(''); scope.$apply("txtInput='LTten'"); expect(element.attr('aria-invalid')).toBe('userSetValue'); }); @@ -307,7 +355,7 @@ describe('$aria', function() { it('should not attach aria-invalid if the option is disabled', function() { scope.$apply("txtInput='LTten'"); - compileInput(''); + compileElement(''); expect(element.attr('aria-invalid')).toBeUndefined(); }); }); @@ -316,7 +364,7 @@ describe('$aria', function() { beforeEach(injectScopeAndCompiler); it('should attach aria-required to input', function() { - compileInput(''); + compileElement(''); expect(element.attr('aria-required')).toBe('true'); scope.$apply("val='input is valid now'"); @@ -324,7 +372,7 @@ describe('$aria', function() { }); it('should attach aria-required to textarea', function() { - compileInput(''); + compileElement(''); expect(element.attr('aria-required')).toBe('true'); scope.$apply("val='input is valid now'"); @@ -332,7 +380,7 @@ describe('$aria', function() { }); it('should attach aria-required to select', function() { - compileInput(''); + compileElement(''); expect(element.attr('aria-required')).toBe('true'); scope.$apply("val='input is valid now'"); @@ -340,7 +388,7 @@ describe('$aria', function() { }); it('should attach aria-required to ngRequired', function() { - compileInput(''); + compileElement(''); expect(element.attr('aria-required')).toBe('true'); scope.$apply("val='input is valid now'"); @@ -348,16 +396,16 @@ describe('$aria', function() { }); it('should not attach itself if aria-required is already present', function() { - compileInput(""); + compileElement(""); expect(element.attr('aria-required')).toBe('userSetValue'); - compileInput(""); + compileElement(""); expect(element.attr('aria-required')).toBe('userSetValue'); - compileInput(""); + compileElement(""); expect(element.attr('aria-required')).toBe('userSetValue'); - compileInput(""); + compileElement(""); expect(element.attr('aria-required')).toBe('userSetValue'); }); }); @@ -369,13 +417,13 @@ describe('$aria', function() { beforeEach(injectScopeAndCompiler); it('should not add the aria-required attribute', function() { - compileInput(""); + compileElement(""); expect(element.attr('aria-required')).toBeUndefined(); - compileInput(""); + compileElement(""); expect(element.attr('aria-required')).toBeUndefined(); - compileInput(""); + compileElement(""); expect(element.attr('aria-required')).toBeUndefined(); }); }); @@ -384,20 +432,20 @@ describe('$aria', function() { beforeEach(injectScopeAndCompiler); it('should attach itself to textarea', function() { - compileInput(''); + compileElement(''); expect(element.attr('aria-multiline')).toBe('true'); }); it('should attach itself role="textbox"', function() { - compileInput('
'); + compileElement('
'); expect(element.attr('aria-multiline')).toBe('true'); }); it('should not attach itself if aria-multiline is already present', function() { - compileInput(''); + compileElement(''); expect(element.attr('aria-multiline')).toBe('userSetValue'); - compileInput('
'); + compileElement('
'); expect(element.attr('aria-multiline')).toBe('userSetValue'); }); }); @@ -409,12 +457,12 @@ describe('$aria', function() { beforeEach(injectScopeAndCompiler); it('should not attach itself to textarea', function() { - compileInput(''); + compileElement(''); expect(element.attr('aria-multiline')).toBeUndefined(); }); it('should not attach itself role="textbox"', function() { - compileInput('
'); + compileElement('
'); expect(element.attr('aria-multiline')).toBeUndefined(); }); }); @@ -472,12 +520,12 @@ describe('$aria', function() { it('should not attach itself', function() { scope.$apply('val = 50'); - compileInput(''); + compileElement(''); expect(element.attr('aria-valuenow')).toBeUndefined(); expect(element.attr('aria-valuemin')).toBeUndefined(); expect(element.attr('aria-valuemax')).toBeUndefined(); - compileInput('
'); + compileElement('
'); expect(element.attr('aria-valuenow')).toBeUndefined(); expect(element.attr('aria-valuemin')).toBeUndefined(); expect(element.attr('aria-valuemax')).toBeUndefined(); @@ -488,32 +536,32 @@ describe('$aria', function() { beforeEach(injectScopeAndCompiler); it('should attach tabindex to role="checkbox", ng-click, and ng-dblclick', function() { - compileInput('
'); + compileElement('
'); expect(element.attr('tabindex')).toBe('0'); - compileInput('
'); + compileElement('
'); expect(element.attr('tabindex')).toBe('0'); - compileInput('
'); + compileElement('
'); expect(element.attr('tabindex')).toBe('0'); }); it('should not attach tabindex if it is already on an element', function() { - compileInput('
'); + compileElement('
'); expect(element.attr('tabindex')).toBe('userSetValue'); - compileInput('
'); + compileElement('
'); expect(element.attr('tabindex')).toBe('userSetValue'); - compileInput('
'); + compileElement('
'); expect(element.attr('tabindex')).toBe('userSetValue'); - compileInput('
'); + compileElement('
'); expect(element.attr('tabindex')).toBe('userSetValue'); }); it('should set proper tabindex values for radiogroup', function() { - compileInput('
' + + compileElement('
' + '
1
' + '
2
' + '
'); @@ -566,7 +614,7 @@ describe('$aria', function() { scope.someAction = function() {}; clickFn = spyOn(scope, 'someAction'); - compileInput('
'); + compileElement('
'); element.triggerHandler({type: 'keypress', keyCode: 32}); @@ -575,7 +623,7 @@ describe('$aria', function() { }); it('should update bindings when keypress handled', function() { - compileInput('
{{text}}
'); + compileElement('
{{text}}
'); expect(element.text()).toBe(''); spyOn(scope.$root, '$digest').andCallThrough(); element.triggerHandler({ type: 'keypress', keyCode: 13 }); @@ -584,22 +632,22 @@ describe('$aria', function() { }); it('should pass $event to ng-click handler as local', function() { - compileInput('
{{event.type}}' + - '{{event.keyCode}}
'); + compileElement('
{{event.type}}' + + '{{event.keyCode}}
'); expect(element.text()).toBe(''); element.triggerHandler({ type: 'keypress', keyCode: 13 }); expect(element.text()).toBe('keypress13'); }); it('should not bind keypress to elements not in the default config', function() { - compileInput(''); + compileElement(''); expect(element.text()).toBe(''); element.triggerHandler({ type: 'keypress', keyCode: 13 }); expect(element.text()).toBe(''); }); }); - describe('actions when bindKeypress set to false', function() { + describe('actions when bindKeypress is set to false', function() { beforeEach(configAriaProvider({ bindKeypress: false })); @@ -624,16 +672,16 @@ describe('$aria', function() { beforeEach(injectScopeAndCompiler); it('should not add a tabindex attribute', function() { - compileInput('
'); + compileElement('
'); expect(element.attr('tabindex')).toBeUndefined(); - compileInput('
'); + compileElement('
'); expect(element.attr('tabindex')).toBeUndefined(); - compileInput('
'); + compileElement('
'); expect(element.attr('tabindex')).toBeUndefined(); - compileInput('
'); + compileElement('
'); expect(element.attr('tabindex')).toBeUndefined(); }); });