From 3894b954daf46bf329d6d9937e5fd1b07406df62 Mon Sep 17 00:00:00 2001 From: Marcy Sutton Date: Wed, 3 Dec 2014 16:08:53 -0800 Subject: [PATCH 1/2] feat(ngAria): Roles added to custom inputs Adds missing roles: slider, radio, checkbox Closes #10012 --- src/ngAria/aria.js | 10 ++++++++++ test/ngAria/ariaSpec.js | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/ngAria/aria.js b/src/ngAria/aria.js index bfceebd59963..6cebb0a06949 100644 --- a/src/ngAria/aria.js +++ b/src/ngAria/aria.js @@ -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); diff --git a/test/ngAria/ariaSpec.js b/test/ngAria/ariaSpec.js index 905196340f8a..619639e96225 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_; @@ -188,6 +192,41 @@ describe('$aria', function() { }); }); + describe('roles for custom inputs', function() { + beforeEach(injectScopeAndCompiler); + + it('should add missing role="checkbox" to custom input', function() { + scope.$apply('val = true'); + compileInput('
'); + expect(element.attr('role')).toBe('checkbox'); + }); + it('should not add a role to a native checkbox', function() { + scope.$apply('val = true'); + compileInput(''); + expect(element.attr('role')).toBe(undefined); + }); + it('should add missing role="radio" to custom input', function() { + scope.$apply('val = true'); + compileInput('
'); + expect(element.attr('role')).toBe('radio'); + }); + it('should not add a role to a native radio button', function() { + scope.$apply('val = true'); + compileInput(''); + expect(element.attr('role')).toBe(undefined); + }); + it('should add missing role="slider" to custom input', function() { + scope.$apply('val = true'); + compileInput('
'); + expect(element.attr('role')).toBe('slider'); + }); + it('should not add a role to a native range input', function() { + scope.$apply('val = true'); + compileInput(''); + expect(element.attr('role')).toBe(undefined); + }); + }); + describe('aria-checked when disabled', function() { beforeEach(configAriaProvider({ ariaChecked: false From 7fafdf974c9d8a049318f260faed813961689179 Mon Sep 17 00:00:00 2001 From: Marcy Sutton Date: Wed, 3 Dec 2014 16:13:41 -0800 Subject: [PATCH 2/2] feat(ngAria): Adds button role to ng-click Closes #9254 --- docs/content/guide/accessibility.ngdoc | 10 +- src/ngAria/aria.js | 11 +- test/ngAria/ariaSpec.js | 155 +++++++++++++------------ 3 files changed, 96 insertions(+), 80 deletions(-) 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 6cebb0a06949..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}. @@ -317,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 619639e96225..8da1f83c7611 100644 --- a/test/ngAria/ariaSpec.js +++ b/test/ngAria/ariaSpec.js @@ -5,7 +5,7 @@ describe('$aria', function() { beforeEach(module('ngAria')); - afterEach(function(){ + afterEach(function() { dealoc(element); }); @@ -16,7 +16,7 @@ describe('$aria', function() { }); } - function compileInput(inputHtml) { + function compileElement(inputHtml) { element = $compile(inputHtml)(scope); scope.$digest(); } @@ -25,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'); @@ -34,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'); @@ -43,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'); @@ -51,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'); @@ -68,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(); }); }); @@ -80,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'); @@ -156,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'); }); @@ -195,34 +195,43 @@ 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() { - scope.$apply('val = true'); - compileInput('
'); + compileElement('
'); expect(element.attr('role')).toBe('checkbox'); }); + it('should not add a role to a native checkbox', function() { - scope.$apply('val = true'); - compileInput(''); + compileElement(''); expect(element.attr('role')).toBe(undefined); }); + it('should add missing role="radio" to custom input', function() { - scope.$apply('val = true'); - compileInput('
'); + compileElement('
'); expect(element.attr('role')).toBe('radio'); }); + it('should not add a role to a native radio button', function() { - scope.$apply('val = true'); - compileInput(''); + compileElement(''); expect(element.attr('role')).toBe(undefined); }); + it('should add missing role="slider" to custom input', function() { - scope.$apply('val = true'); - compileInput('
'); + compileElement('
'); expect(element.attr('role')).toBe('slider'); }); + it('should not add a role to a native range input', function() { - scope.$apply('val = true'); - compileInput(''); + compileElement(''); expect(element.attr('role')).toBe(undefined); }); }); @@ -234,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(); }); }); @@ -253,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'); @@ -262,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'); @@ -271,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'); @@ -280,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'); @@ -323,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'); @@ -332,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'); }); @@ -346,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(); }); }); @@ -355,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'"); @@ -363,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'"); @@ -371,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'"); @@ -379,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'"); @@ -387,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'); }); }); @@ -408,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(); }); }); @@ -423,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'); }); }); @@ -448,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(); }); }); @@ -511,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(); @@ -527,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
' + '
'); @@ -605,7 +614,7 @@ describe('$aria', function() { scope.someAction = function() {}; clickFn = spyOn(scope, 'someAction'); - compileInput('
'); + compileElement('
'); element.triggerHandler({type: 'keypress', keyCode: 32}); @@ -614,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 }); @@ -623,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 })); @@ -663,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(); }); });