From 86e49e12e521351144cc6a9440e65de02f32440c Mon Sep 17 00:00:00 2001 From: Marcy Sutton Date: Wed, 9 Dec 2015 14:13:39 -0800 Subject: [PATCH] fix(ngAria): Apply ARIA attrs correctly BREAKING CHANGE: Where appropriate, ngAria now applies ARIA to custom controls only, not native inputs. Because of this, support for `aria-multiline` on textareas has been removed. New support added for ngValue, ngChecked, and ngRequired, along with updated documentation. Closes #13078 Closes #11374 Closes #11830 --- docs/content/guide/accessibility.ngdoc | 47 ++++- src/ngAria/aria.js | 98 +++++----- test/ngAria/ariaSpec.js | 239 ++++++++----------------- 3 files changed, 173 insertions(+), 211 deletions(-) diff --git a/docs/content/guide/accessibility.ngdoc b/docs/content/guide/accessibility.ngdoc index 76f6fcfc498a..22ea6c752b3e 100644 --- a/docs/content/guide/accessibility.ngdoc +++ b/docs/content/guide/accessibility.ngdoc @@ -33,6 +33,9 @@ Currently, ngAria interfaces with the following directives: * {@link guide/accessibility#ngmodel ngModel} * {@link guide/accessibility#ngdisabled ngDisabled} + * {@link guide/accessibility#ngrequired ngRequired} + * {@link guide/accessibility#ngvaluechecked ngChecked} + * {@link guide/accessibility#ngvaluechecked ngValue} * {@link guide/accessibility#ngshow ngShow} * {@link guide/accessibility#nghide ngHide} * {@link guide/accessibility#ngclick ngClick} @@ -137,6 +140,27 @@ the keyboard. It is still up to **you** as a developer to **ensure custom contro accessible**. As a rule, any time you create a widget involving user interaction, be sure to test it with your keyboard and at least one mobile and desktop screen reader. +

ngValue and ngChecked

+ +To ease the transition between native inputs and custom controls, ngAria now supports +{@link ng/directive/ngValue ngValue} and {@link ng/directive/ngChecked ngChecked}. +The original directives were created for native inputs only, so ngAria extends +support to custom elements by managing `aria-checked` for accessibility. + +###Example + +```html + + +``` + +Becomes: + +```html + + +``` +

ngDisabled

The `disabled` attribute is only valid for certain elements such as `button`, `input` and @@ -148,18 +172,37 @@ custom controls to be more accessible. ###Example ```html - + ``` Becomes: ```html - + ``` >You can check whether a control is legitimately disabled for a screen reader by visiting [chrome://accessibility](chrome://accessibility) and inspecting [the accessibility tree](http://www.paciellogroup.com/blog/2015/01/the-browser-accessibility-tree/). +

ngRequired

+ +The boolean `required` attribute is only valid for native form controls such as `input` and +`textarea`. To properly indicate custom element directives such as `` or `` +as required, using ngAria with {@link ng/directive/ngRequired ngRequired} will also add +`aria-required`. This tells accessibility APIs when a custom control is required. + +###Example + +```html + +``` + +Becomes: + +```html + +``` +

ngShow

>The [ngShow](https://docs.angularjs.org/api/ng/directive/ngShow) directive shows or hides the diff --git a/src/ngAria/aria.js b/src/ngAria/aria.js index 036dd42b47de..ce531707f0c9 100644 --- a/src/ngAria/aria.js +++ b/src/ngAria/aria.js @@ -16,19 +16,23 @@ * * For ngAria to do its magic, simply include the module `ngAria` as a dependency. The following * directives are supported: - * `ngModel`, `ngDisabled`, `ngShow`, `ngHide`, `ngClick`, `ngDblClick`, and `ngMessages`. + * `ngModel`, `ngChecked`, `ngRequired`, `ngValue`, `ngDisabled`, `ngShow`, `ngHide`, `ngClick`, + * `ngDblClick`, and `ngMessages`. * * Below is a more detailed breakdown of the attributes handled by ngAria: * * | Directive | Supported Attributes | * |---------------------------------------------|----------------------------------------------------------------------------------------| + * | {@link ng.directive:ngModel ngModel} | aria-checked, aria-valuemin, aria-valuemax, aria-valuenow, aria-invalid, aria-required, input roles | * | {@link ng.directive:ngDisabled ngDisabled} | aria-disabled | + * | {@link ng.directive:ngRequired ngRequired} | aria-required | + * | {@link ng.directive:ngChecked ngChecked} | aria-checked | + * | {@link ng.directive:ngValue ngValue} | aria-checked | * | {@link ng.directive:ngShow ngShow} | aria-hidden | * | {@link ng.directive:ngHide ngHide} | aria-hidden | * | {@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 | + * | {@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}. @@ -90,7 +94,6 @@ function $AriaProvider() { ariaDisabled: true, ariaRequired: true, ariaInvalid: true, - ariaMultiline: true, ariaValue: true, tabindex: true, bindKeypress: true, @@ -108,11 +111,10 @@ function $AriaProvider() { * - **ariaDisabled** – `{boolean}` – Enables/disables aria-disabled tags * - **ariaRequired** – `{boolean}` – Enables/disables aria-required tags * - **ariaInvalid** – `{boolean}` – Enables/disables aria-invalid tags - * - **ariaMultiline** – `{boolean}` – Enables/disables aria-multiline tags * - **ariaValue** – `{boolean}` – Enables/disables aria-valuemin, aria-valuemax and aria-valuenow tags * - **tabindex** – `{boolean}` – Enables/disables tabindex tags - * - **bindKeypress** – `{boolean}` – Enables/disables keypress event binding on `<div>` and - * `<li>` elements with ng-click + * - **bindKeypress** – `{boolean}` – Enables/disables keypress event binding on `div` and + * `li` elements with ng-click * - **bindRoleForClick** – `{boolean}` – Adds role=button to non-interactive elements like `div` * using ng-click, making them more accessible to users of assistive technologies * @@ -151,15 +153,15 @@ function $AriaProvider() { * *```js * ngAriaModule.directive('ngDisabled', ['$aria', function($aria) { - * return $aria.$$watchExpr('ngDisabled', 'aria-disabled'); + * return $aria.$$watchExpr('ngDisabled', 'aria-disabled', nodeBlackList, false); * }]) *``` * Shown above, the ngAria module creates a directive with the same signature as the * traditional `ng-disabled` directive. But this ngAria version is dedicated to - * solely managing accessibility attributes. The internal `$aria` service is used to watch the - * boolean attribute `ngDisabled`. If it has not been explicitly set by the developer, - * `aria-disabled` is injected as an attribute with its value synchronized to the value in - * `ngDisabled`. + * solely managing accessibility attributes on custom elements. The internal `$aria` service is + * used to watch the boolean attribute `ngDisabled`. If it has not been explicitly set by the + * developer, `aria-disabled` is injected as an attribute with its value synchronized to the + * value in `ngDisabled`. * * Because ngAria hooks into the `ng-disabled` directive, developers do not have to do * anything to enable this feature. The `aria-disabled` attribute is automatically managed @@ -167,12 +169,15 @@ function $AriaProvider() { * * The full list of directives that interface with ngAria: * * **ngModel** + * * **ngChecked** + * * **ngRequired** + * * **ngDisabled** + * * **ngValue** * * **ngShow** * * **ngHide** * * **ngClick** * * **ngDblclick** * * **ngMessages** - * * **ngDisabled** * * Read the {@link guide/accessibility ngAria Developer Guide} for a thorough explanation of each * directive. @@ -198,13 +203,25 @@ ngAriaModule.directive('ngShow', ['$aria', function($aria) { .directive('ngHide', ['$aria', function($aria) { return $aria.$$watchExpr('ngHide', 'aria-hidden', [], false); }]) -.directive('ngModel', ['$aria', function($aria) { +.directive('ngValue', ['$aria', function($aria) { + return $aria.$$watchExpr('ngValue', 'aria-checked', nodeBlackList, false); +}]) +.directive('ngChecked', ['$aria', function($aria) { + return $aria.$$watchExpr('ngChecked', 'aria-checked', nodeBlackList, false); +}]) +.directive('ngRequired', ['$aria', function($aria) { + return $aria.$$watchExpr('ngRequired', 'aria-required', nodeBlackList, false); +}]) +.directive('ngModel', ['$aria', '$parse', function($aria, $parse) { - function shouldAttachAttr(attr, normalizedAttr, elem) { - return $aria.config(normalizedAttr) && !elem.attr(attr); + function shouldAttachAttr(attr, normalizedAttr, elem, allowBlacklistEls) { + return $aria.config(normalizedAttr) && !elem.attr(attr) && (allowBlacklistEls || !isNodeOneOf(elem, nodeBlackList)); } function shouldAttachRole(role, elem) { + // if element does not have role attribute + // AND element type is equal to role (if custom element has a type equaling shape) <-- remove? + // AND element is not INPUT return !elem.attr('role') && (elem.attr('type') === role) && (elem[0].nodeName !== 'INPUT'); } @@ -214,8 +231,7 @@ ngAriaModule.directive('ngShow', ['$aria', function($aria) { return ((type || role) === 'checkbox' || role === 'menuitemcheckbox') ? 'checkbox' : ((type || role) === 'radio' || role === 'menuitemradio') ? 'radio' : - (type === 'range' || role === 'progressbar' || role === 'slider') ? 'range' : - (type || role) === 'textbox' || elem[0].nodeName === 'TEXTAREA' ? 'multiline' : ''; + (type === 'range' || role === 'progressbar' || role === 'slider') ? 'range' : ''; } return { @@ -227,7 +243,7 @@ ngAriaModule.directive('ngShow', ['$aria', function($aria) { return { pre: function(scope, elem, attr, ngModel) { - if (shape === 'checkbox' && attr.type !== 'checkbox') { + if (shape === 'checkbox') { //Use the input[checkbox] $isEmpty implementation for elements with checkbox roles ngModel.$isEmpty = function(value) { return value === false; @@ -235,29 +251,18 @@ ngAriaModule.directive('ngShow', ['$aria', function($aria) { } }, post: function(scope, elem, attr, ngModel) { - var needsTabIndex = shouldAttachAttr('tabindex', 'tabindex', elem) - && !isNodeOneOf(elem, nodeBlackList); + var needsTabIndex = shouldAttachAttr('tabindex', 'tabindex', elem, false); function ngAriaWatchModelValue() { return ngModel.$modelValue; } - function getRadioReaction() { - if (needsTabIndex) { - needsTabIndex = false; - return function ngAriaRadioReaction(newVal) { - var boolVal = (attr.value == ngModel.$viewValue); - elem.attr('aria-checked', boolVal); - elem.attr('tabindex', 0 - !boolVal); - }; - } else { - return function ngAriaRadioReaction(newVal) { - elem.attr('aria-checked', (attr.value == ngModel.$viewValue)); - }; - } + function getRadioReaction(newVal) { + var boolVal = (attr.value == ngModel.$viewValue); + elem.attr('aria-checked', boolVal); } - function ngAriaCheckboxReaction() { + function getCheckboxReaction() { elem.attr('aria-checked', !ngModel.$isEmpty(ngModel.$viewValue)); } @@ -267,9 +272,9 @@ ngAriaModule.directive('ngShow', ['$aria', function($aria) { if (shouldAttachRole(shape, elem)) { elem.attr('role', shape); } - if (shouldAttachAttr('aria-checked', 'ariaChecked', elem)) { + if (shouldAttachAttr('aria-checked', 'ariaChecked', elem, false)) { scope.$watch(ngAriaWatchModelValue, shape === 'radio' ? - getRadioReaction() : ngAriaCheckboxReaction); + getRadioReaction : getCheckboxReaction); } if (needsTabIndex) { elem.attr('tabindex', 0); @@ -306,22 +311,17 @@ ngAriaModule.directive('ngShow', ['$aria', function($aria) { elem.attr('tabindex', 0); } break; - case 'multiline': - if (shouldAttachAttr('aria-multiline', 'ariaMultiline', elem)) { - elem.attr('aria-multiline', true); - } - break; } - if (ngModel.$validators.required && shouldAttachAttr('aria-required', 'ariaRequired', elem)) { - scope.$watch(function ngAriaRequiredWatch() { - return ngModel.$error.required; - }, function ngAriaRequiredReaction(newVal) { - elem.attr('aria-required', !!newVal); + if (!attr.hasOwnProperty('ngRequired') && ngModel.$validators.required + && shouldAttachAttr('aria-required', 'ariaRequired', elem, false)) { + // ngModel.$error.required is undefined on custom controls + attr.$observe('required', function() { + elem.attr('aria-required', !!attr['required']); }); } - if (shouldAttachAttr('aria-invalid', 'ariaInvalid', elem)) { + if (shouldAttachAttr('aria-invalid', 'ariaInvalid', elem, true)) { scope.$watch(function ngAriaInvalidWatch() { return ngModel.$invalid; }, function ngAriaInvalidReaction(newVal) { @@ -334,7 +334,7 @@ ngAriaModule.directive('ngShow', ['$aria', function($aria) { }; }]) .directive('ngDisabled', ['$aria', function($aria) { - return $aria.$$watchExpr('ngDisabled', 'aria-disabled', []); + return $aria.$$watchExpr('ngDisabled', 'aria-disabled', nodeBlackList, false); }]) .directive('ngMessages', function() { return { diff --git a/test/ngAria/ariaSpec.js b/test/ngAria/ariaSpec.js index 73b015f710c3..76592bd1decc 100644 --- a/test/ngAria/ariaSpec.js +++ b/test/ngAria/ariaSpec.js @@ -104,9 +104,19 @@ describe('$aria', function() { describe('aria-checked', function() { beforeEach(injectScopeAndCompiler); - it('should attach itself to input type="checkbox"', function() { + it('should not attach itself to native input type="checkbox"', function() { compileElement(''); + scope.$apply('val = true'); + expect(element.attr('aria-checked')).toBeUndefined(); + + scope.$apply('val = false'); + expect(element.attr('aria-checked')).toBeUndefined(); + }); + + it('should attach itself to custom checkbox', function() { + compileElement('
'); + scope.$apply('val = true'); expect(element.attr('aria-checked')).toBe('true'); @@ -114,31 +124,42 @@ describe('$aria', function() { expect(element.attr('aria-checked')).toBe('false'); }); - it('should handle checkbox with string model values using ng(True|False)Value', function() { - var element = $compile('' - )(scope); + it('should not handle native checkbox with ngChecked', function() { + var element = $compile('')(scope); - scope.$apply('val="yes"'); - expect(element.eq(0).attr('aria-checked')).toBe('true'); + scope.$apply('val = true'); + expect(element.attr('aria-checked')).toBeUndefined(); - scope.$apply('val="no"'); - expect(element.eq(0).attr('aria-checked')).toBe('false'); + scope.$apply('val = false'); + expect(element.attr('aria-checked')).toBeUndefined(); }); - it('should handle checkbox with integer model values using ngTrueValue', function() { - var element = $compile('')(scope); + it('should handle custom checkbox with ngChecked', function() { + var element = $compile('
')(scope); - scope.$apply('val=0'); + scope.$apply('val = true'); expect(element.eq(0).attr('aria-checked')).toBe('true'); - scope.$apply('val=1'); + scope.$apply('val = false'); expect(element.eq(0).attr('aria-checked')).toBe('false'); }); - it('should attach itself to input type="radio"', function() { + it('should not attach to native input type="radio"', function() { var element = $compile('' + - '')(scope); + '')(scope); + + scope.$apply("val='one'"); + expect(element.eq(0).attr('aria-checked')).toBeUndefined(); + expect(element.eq(1).attr('aria-checked')).toBeUndefined(); + + scope.$apply("val='two'"); + expect(element.eq(0).attr('aria-checked')).toBeUndefined(); + expect(element.eq(1).attr('aria-checked')).toBeUndefined(); + }); + + it('should attach to custom radio controls', function() { + var element = $compile('
' + + '
')(scope); scope.$apply("val='one'"); expect(element.eq(0).attr('aria-checked')).toBe('true'); @@ -149,22 +170,22 @@ describe('$aria', function() { expect(element.eq(1).attr('aria-checked')).toBe('true'); }); - it('should handle radios with integer model values', function() { - var element = $compile('' + - '')(scope); + it('should handle custom radios with integer model values', function() { + var element = $compile('
' + + '
')(scope); - scope.$apply('val=0'); + scope.$apply("val=0"); expect(element.eq(0).attr('aria-checked')).toBe('true'); expect(element.eq(1).attr('aria-checked')).toBe('false'); - scope.$apply('val=1'); + scope.$apply("val=1"); expect(element.eq(0).attr('aria-checked')).toBe('false'); expect(element.eq(1).attr('aria-checked')).toBe('true'); }); it('should handle radios with boolean model values using ngValue', function() { - var element = $compile('' + - '')(scope); + var element = $compile('
' + + '
')(scope); scope.$apply(function() { scope.valExp = true; @@ -179,24 +200,6 @@ describe('$aria', function() { expect(element.eq(1).attr('aria-checked')).toBe('true'); }); - it('should attach itself to role="radio"', function() { - scope.val = 'one'; - compileElement('
'); - expect(element.attr('aria-checked')).toBe('true'); - - scope.$apply("val = 'two'"); - expect(element.attr('aria-checked')).toBe('false'); - }); - - it('should attach itself to role="checkbox"', function() { - scope.val = true; - compileElement('
'); - expect(element.attr('aria-checked')).toBe('true'); - - scope.$apply('val = false'); - expect(element.attr('aria-checked')).toBe('false'); - }); - it('should attach itself to role="menuitemradio"', function() { scope.val = 'one'; compileElement('
'); @@ -217,8 +220,6 @@ describe('$aria', function() { it('should not attach itself if an aria-checked value is already present', function() { var element = [ - $compile("")(scope), - $compile("")(scope), $compile("
")(scope), $compile("
")(scope), $compile("
")(scope), @@ -297,52 +298,32 @@ describe('$aria', function() { describe('aria-disabled', function() { beforeEach(injectScopeAndCompiler); - it('should attach itself to input elements', function() { - scope.$apply('val = false'); - compileElement(""); - expect(element.attr('aria-disabled')).toBe('false'); - + they('should not attach itself to native $prop controls', { + input: '', + textarea: '', + select: '', + button: '' + }, function(tmpl) { + var element = $compile(tmpl)(scope); scope.$apply('val = true'); - expect(element.attr('aria-disabled')).toBe('true'); - }); - it('should attach itself to textarea elements', function() { - scope.$apply('val = false'); - compileElement(''); - expect(element.attr('aria-disabled')).toBe('false'); - - scope.$apply('val = true'); - expect(element.attr('aria-disabled')).toBe('true'); + expect(element.attr('disabled')).toBeDefined(); + expect(element.attr('aria-disabled')).toBeUndefined(); }); - it('should attach itself to button elements', function() { - scope.$apply('val = false'); - compileElement(''); + it('should attach itself to custom controls', function() { + compileElement('
'); expect(element.attr('aria-disabled')).toBe('false'); scope.$apply('val = true'); expect(element.attr('aria-disabled')).toBe('true'); - }); - - it('should attach itself to select elements', function() { - scope.$apply('val = false'); - compileElement(''); - expect(element.attr('aria-disabled')).toBe('false'); - scope.$apply('val = true'); - expect(element.attr('aria-disabled')).toBe('true'); }); it('should not attach itself if an aria-disabled attribute is already present', function() { - var element = [ - $compile("")(scope), - $compile("")(scope), - $compile("")(scope), - $compile("")(scope) - ]; + compileElement('
'); - scope.$apply('val = true'); - expectAriaAttrOnEachElement(element, 'aria-disabled', 'userSetValue'); + expect(element.attr('aria-disabled')).toBe('userSetValue'); }); @@ -367,15 +348,10 @@ describe('$aria', function() { beforeEach(injectScopeAndCompiler); it('should not attach aria-disabled', function() { - var element = [ - $compile("")(scope), - $compile("")(scope), - $compile("")(scope), - $compile("")(scope) - ]; + compileElement('
'); - scope.$apply('val = false'); - expectAriaAttrOnEachElement(element, 'aria-disabled', undefined); + scope.$apply('val = true'); + expect(element.attr('aria-disabled')).toBeUndefined(); }); }); @@ -391,6 +367,15 @@ describe('$aria', function() { expect(element.attr('aria-invalid')).toBe('false'); }); + it('should attach aria-invalid to custom controls', function() { + compileElement('
'); + scope.$apply("txtInput='LTten'"); + expect(element.attr('aria-invalid')).toBe('true'); + + scope.$apply("txtInput='morethantencharacters'"); + expect(element.attr('aria-invalid')).toBe('false'); + }); + it('should not attach itself if aria-invalid is already present', function() { compileElement(''); scope.$apply("txtInput='LTten'"); @@ -414,49 +399,28 @@ describe('$aria', function() { describe('aria-required', function() { beforeEach(injectScopeAndCompiler); - it('should attach aria-required to input', function() { + it('should not attach to input', function() { compileElement(''); - expect(element.attr('aria-required')).toBe('true'); - - scope.$apply("val='input is valid now'"); - expect(element.attr('aria-required')).toBe('false'); + expect(element.attr('aria-required')).toBeUndefined(); }); - it('should attach aria-required to textarea', function() { - compileElement(''); + it('should attach to custom controls with ngModel and required', function() { + compileElement('
'); expect(element.attr('aria-required')).toBe('true'); - - scope.$apply("val='input is valid now'"); - expect(element.attr('aria-required')).toBe('false'); }); - it('should attach aria-required to select', function() { - compileElement(''); - expect(element.attr('aria-required')).toBe('true'); - - scope.$apply("val='input is valid now'"); + it('should set aria-required to false when ng-required is false', function() { + compileElement("
"); expect(element.attr('aria-required')).toBe('false'); }); - it('should attach aria-required to ngRequired', function() { - compileElement(''); + it('should attach to custom controls with ngRequired', function() { + compileElement('
'); expect(element.attr('aria-required')).toBe('true'); - - scope.$apply("val='input is valid now'"); - expect(element.attr('aria-required')).toBe('false'); }); it('should not attach itself if aria-required is already present', function() { - compileElement(""); - expect(element.attr('aria-required')).toBe('userSetValue'); - - compileElement(""); - expect(element.attr('aria-required')).toBe('userSetValue'); - - compileElement(""); - expect(element.attr('aria-required')).toBe('userSetValue'); - - compileElement(""); + compileElement("
"); expect(element.attr('aria-required')).toBe('userSetValue'); }); }); @@ -471,53 +435,11 @@ describe('$aria', function() { compileElement(""); expect(element.attr('aria-required')).toBeUndefined(); - compileElement(""); - expect(element.attr('aria-required')).toBeUndefined(); - - compileElement(""); + compileElement("
"); expect(element.attr('aria-required')).toBeUndefined(); }); }); - describe('aria-multiline', function() { - beforeEach(injectScopeAndCompiler); - - it('should attach itself to textarea', function() { - compileElement(''); - expect(element.attr('aria-multiline')).toBe('true'); - }); - - it('should attach itself role="textbox"', function() { - compileElement('
'); - expect(element.attr('aria-multiline')).toBe('true'); - }); - - it('should not attach itself if aria-multiline is already present', function() { - compileElement(''); - expect(element.attr('aria-multiline')).toBe('userSetValue'); - - compileElement('
'); - expect(element.attr('aria-multiline')).toBe('userSetValue'); - }); - }); - - describe('aria-multiline when disabled', function() { - beforeEach(configAriaProvider({ - ariaMultiline: false - })); - beforeEach(injectScopeAndCompiler); - - it('should not attach itself to textarea', function() { - compileElement(''); - expect(element.attr('aria-multiline')).toBeUndefined(); - }); - - it('should not attach itself role="textbox"', function() { - compileElement('
'); - expect(element.attr('aria-multiline')).toBeUndefined(); - }); - }); - describe('aria-value', function() { beforeEach(injectScopeAndCompiler); @@ -636,13 +558,10 @@ describe('$aria', function() { }); it('should attach tabindex to custom inputs', function() { - compileElement('
'); - expect(element.attr('tabindex')).toBe('0'); - compileElement('
'); expect(element.attr('tabindex')).toBe('0'); - compileElement('
'); + compileElement('
'); expect(element.attr('tabindex')).toBe('0'); });