Skip to content

Commit 82c6438

Browse files
marcysuttonMarcy Sutton
authored and
Marcy Sutton
committed
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 angular#13078 Closes angular#11374 Closes angular#11830
1 parent 52ea411 commit 82c6438

File tree

3 files changed

+190
-204
lines changed

3 files changed

+190
-204
lines changed

docs/content/guide/accessibility.ngdoc

+44-2
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ Currently, ngAria interfaces with the following directives:
3333

3434
* {@link guide/accessibility#ngmodel ngModel}
3535
* {@link guide/accessibility#ngdisabled ngDisabled}
36+
* {@link guide/accessibility#ngrequired ngRequired}
37+
* {@link guide/accessibility#ngvaluechecked ngChecked}
38+
* {@link guide/accessibility#ngvaluechecked ngValue}
3639
* {@link guide/accessibility#ngshow ngShow}
3740
* {@link guide/accessibility#nghide ngHide}
3841
* {@link guide/accessibility#ngclick ngClick}
@@ -137,6 +140,26 @@ the keyboard. It is still up to **you** as a developer to **ensure custom contro
137140
accessible**. As a rule, any time you create a widget involving user interaction, be sure to test
138141
it with your keyboard and at least one mobile and desktop screen reader.
139142

143+
<h2 id="ngvaluechecked">ngValue and ngChecked</h2>
144+
145+
To ease the transition between native inputs and custom controls, ngAria now supports
146+
[ngValue](https://docs.angularjs.org/api/ng/directive/ngValue) and [ngChecked](https://docs.angularjs.org/api/ng/directive/ngChecked). The original directives were created for native inputs only, so ngAria extends
147+
support to custom elements by managing `aria-checked` for accessibility.
148+
149+
###Example
150+
151+
```html
152+
<custom-checkbox ng-checked="val"></custom-checkbox>
153+
<custom-radio-button ng-value="val"></custom-radio-button>
154+
```
155+
156+
Becomes:
157+
158+
```html
159+
<custom-checkbox ng-checked="val" aria-checked="true"></custom-checkbox>
160+
<custom-radio-button ng-value="val" aria-checked="true"></custom-radio-button>
161+
```
162+
140163
<h2 id="ngdisabled">ngDisabled</h2>
141164

142165
The `disabled` attribute is only valid for certain elements such as `button`, `input` and
@@ -148,18 +171,37 @@ custom controls to be more accessible.
148171
###Example
149172

150173
```html
151-
<md-checkbox ng-disabled="disabled">
174+
<md-checkbox ng-disabled="disabled"></md-checkbox>
152175
```
153176

154177
Becomes:
155178

156179
```html
157-
<md-checkbox disabled aria-disabled="true">
180+
<md-checkbox disabled aria-disabled="true"></md-checkbox>
158181
```
159182

160183
>You can check whether a control is legitimately disabled for a screen reader by visiting
161184
[chrome://accessibility](chrome://accessibility) and inspecting [the accessibility tree](http://www.paciellogroup.com/blog/2015/01/the-browser-accessibility-tree/).
162185

186+
<h2 id="ngrequired">ngRequired</h2>
187+
188+
The boolean `required` attribute is only valid for native form controls such as `input` and
189+
`textarea`. To properly indicate custom element directives such as `<md-checkbox>` or `<custom-input>`
190+
as required, using ngAria with [ngRequired](https://docs.angularjs.org/api/ng/directive/input)
191+
will also add `aria-disabled`. This tells accessibility APIs when a custom control is required.
192+
193+
###Example
194+
195+
```html
196+
<md-checkbox ng-required="val"></md-checkbox>
197+
```
198+
199+
Becomes:
200+
201+
```html
202+
<md-checkbox ng-required="val" aria-required="true"></md-checkbox>
203+
```
204+
163205
<h2 id="ngshow">ngShow</h2>
164206

165207
>The [ngShow](https://docs.angularjs.org/api/ng/directive/ngShow) directive shows or hides the

src/ngAria/aria.js

+55-50
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,23 @@
1616
*
1717
* For ngAria to do its magic, simply include the module `ngAria` as a dependency. The following
1818
* directives are supported:
19-
* `ngModel`, `ngDisabled`, `ngShow`, `ngHide`, `ngClick`, `ngDblClick`, and `ngMessages`.
19+
* `ngModel`, `ngChecked`, `ngRequired`, `ngValue`, `ngDisabled`, `ngShow`, `ngHide`, `ngClick`,
20+
* `ngDblClick`, and `ngMessages`.
2021
*
2122
* Below is a more detailed breakdown of the attributes handled by ngAria:
2223
*
2324
* | Directive | Supported Attributes |
2425
* |---------------------------------------------|----------------------------------------------------------------------------------------|
26+
* | {@link ng.directive:ngModel ngModel} | aria-checked, aria-valuemin, aria-valuemax, aria-valuenow, aria-invalid, aria-required, input roles |
2527
* | {@link ng.directive:ngDisabled ngDisabled} | aria-disabled |
28+
* | {@link ng.directive:input ngRequired} | aria-required |
29+
* | {@link ng.directive:ngChecked ngChecked} | aria-checked |
30+
* | {@link ng.directive:ngValue ngValue} | aria-checked |
2631
* | {@link ng.directive:ngShow ngShow} | aria-hidden |
2732
* | {@link ng.directive:ngHide ngHide} | aria-hidden |
2833
* | {@link ng.directive:ngDblclick ngDblclick} | tabindex |
2934
* | {@link module:ngMessages ngMessages} | aria-live |
30-
* | {@link ng.directive:ngModel ngModel} | aria-checked, aria-valuemin, aria-valuemax, aria-valuenow, aria-invalid, aria-required, input roles |
31-
* | {@link ng.directive:ngClick ngClick} | tabindex, keypress event, button role |
35+
* | {@link ng.directive:ngClick ngClick} | tabindex, keypress event, button role |
3236
*
3337
* Find out more information about each directive by reading the
3438
* {@link guide/accessibility ngAria Developer Guide}.
@@ -90,7 +94,6 @@ function $AriaProvider() {
9094
ariaDisabled: true,
9195
ariaRequired: true,
9296
ariaInvalid: true,
93-
ariaMultiline: true,
9497
ariaValue: true,
9598
tabindex: true,
9699
bindKeypress: true,
@@ -108,11 +111,10 @@ function $AriaProvider() {
108111
* - **ariaDisabled** – `{boolean}` – Enables/disables aria-disabled tags
109112
* - **ariaRequired** – `{boolean}` – Enables/disables aria-required tags
110113
* - **ariaInvalid** – `{boolean}` – Enables/disables aria-invalid tags
111-
* - **ariaMultiline** – `{boolean}` – Enables/disables aria-multiline tags
112114
* - **ariaValue** – `{boolean}` – Enables/disables aria-valuemin, aria-valuemax and aria-valuenow tags
113115
* - **tabindex** – `{boolean}` – Enables/disables tabindex tags
114-
* - **bindKeypress** – `{boolean}` – Enables/disables keypress event binding on `&lt;div&gt;` and
115-
* `&lt;li&gt;` elements with ng-click
116+
* - **bindKeypress** – `{boolean}` – Enables/disables keypress event binding on `div` and
117+
* `li` elements with ng-click
116118
* - **bindRoleForClick** – `{boolean}` – Adds role=button to non-interactive elements like `div`
117119
* using ng-click, making them more accessible to users of assistive technologies
118120
*
@@ -151,28 +153,31 @@ function $AriaProvider() {
151153
*
152154
*```js
153155
* ngAriaModule.directive('ngDisabled', ['$aria', function($aria) {
154-
* return $aria.$$watchExpr('ngDisabled', 'aria-disabled');
156+
* return $aria.$$watchExpr('ngDisabled', 'aria-disabled', nodeBlackList, false);
155157
* }])
156158
*```
157159
* Shown above, the ngAria module creates a directive with the same signature as the
158160
* traditional `ng-disabled` directive. But this ngAria version is dedicated to
159-
* solely managing accessibility attributes. The internal `$aria` service is used to watch the
160-
* boolean attribute `ngDisabled`. If it has not been explicitly set by the developer,
161-
* `aria-disabled` is injected as an attribute with its value synchronized to the value in
162-
* `ngDisabled`.
161+
* solely managing accessibility attributes on custom elements. The internal `$aria` service is
162+
* used to watch the boolean attribute `ngDisabled`. If it has not been explicitly set by the
163+
* developer, `aria-disabled` is injected as an attribute with its value synchronized to the
164+
* value in `ngDisabled`.
163165
*
164166
* Because ngAria hooks into the `ng-disabled` directive, developers do not have to do
165167
* anything to enable this feature. The `aria-disabled` attribute is automatically managed
166168
* simply as a silent side-effect of using `ng-disabled` with the ngAria module.
167169
*
168170
* The full list of directives that interface with ngAria:
169171
* * **ngModel**
172+
* * **ngChecked**
173+
* * **ngRequired**
174+
* * **ngDisabled**
175+
* * **ngValue**
170176
* * **ngShow**
171177
* * **ngHide**
172178
* * **ngClick**
173179
* * **ngDblclick**
174180
* * **ngMessages**
175-
* * **ngDisabled**
176181
*
177182
* Read the {@link guide/accessibility ngAria Developer Guide} for a thorough explanation of each
178183
* directive.
@@ -198,13 +203,25 @@ ngAriaModule.directive('ngShow', ['$aria', function($aria) {
198203
.directive('ngHide', ['$aria', function($aria) {
199204
return $aria.$$watchExpr('ngHide', 'aria-hidden', [], false);
200205
}])
201-
.directive('ngModel', ['$aria', function($aria) {
206+
.directive('ngValue', ['$aria', function($aria) {
207+
return $aria.$$watchExpr('ngValue', 'aria-checked', nodeBlackList, false);
208+
}])
209+
.directive('ngChecked', ['$aria', function($aria) {
210+
return $aria.$$watchExpr('ngChecked', 'aria-checked', nodeBlackList, false);
211+
}])
212+
.directive('ngRequired', ['$aria', function($aria) {
213+
return $aria.$$watchExpr('ngRequired', 'aria-required', nodeBlackList, false);
214+
}])
215+
.directive('ngModel', ['$aria', '$parse', function($aria, $parse) {
202216

203-
function shouldAttachAttr(attr, normalizedAttr, elem) {
204-
return $aria.config(normalizedAttr) && !elem.attr(attr);
217+
function shouldAttachAttr(attr, normalizedAttr, elem, allowBlacklistEls) {
218+
return $aria.config(normalizedAttr) && !elem.attr(attr) && (allowBlacklistEls || !isNodeOneOf(elem, nodeBlackList));
205219
}
206220

207221
function shouldAttachRole(role, elem) {
222+
// if element does not have role attribute
223+
// AND element type is equal to role (if custom element has a type equaling shape) <-- remove?
224+
// AND element is not INPUT
208225
return !elem.attr('role') && (elem.attr('type') === role) && (elem[0].nodeName !== 'INPUT');
209226
}
210227

@@ -214,8 +231,7 @@ ngAriaModule.directive('ngShow', ['$aria', function($aria) {
214231

215232
return ((type || role) === 'checkbox' || role === 'menuitemcheckbox') ? 'checkbox' :
216233
((type || role) === 'radio' || role === 'menuitemradio') ? 'radio' :
217-
(type === 'range' || role === 'progressbar' || role === 'slider') ? 'range' :
218-
(type || role) === 'textbox' || elem[0].nodeName === 'TEXTAREA' ? 'multiline' : '';
234+
(type === 'range' || role === 'progressbar' || role === 'slider') ? 'range' : '';
219235
}
220236

221237
return {
@@ -227,38 +243,32 @@ ngAriaModule.directive('ngShow', ['$aria', function($aria) {
227243

228244
return {
229245
pre: function(scope, elem, attr, ngModel) {
230-
if (shape === 'checkbox' && attr.type !== 'checkbox') {
246+
if (shape === 'checkbox') {
231247
//Use the input[checkbox] $isEmpty implementation for elements with checkbox roles
232248
ngModel.$isEmpty = function(value) {
233249
return value === false;
234250
};
235251
}
236252
},
237253
post: function(scope, elem, attr, ngModel) {
238-
var needsTabIndex = shouldAttachAttr('tabindex', 'tabindex', elem)
239-
&& !isNodeOneOf(elem, nodeBlackList);
254+
var needsTabIndex = shouldAttachAttr('tabindex', 'tabindex', elem, false);
255+
var ngTrueValue = $parse(attr.ngTrueValue)(scope);
240256

241257
function ngAriaWatchModelValue() {
242258
return ngModel.$modelValue;
243259
}
244260

245261
function getRadioReaction() {
246-
if (needsTabIndex) {
247-
needsTabIndex = false;
248-
return function ngAriaRadioReaction(newVal) {
249-
var boolVal = (attr.value == ngModel.$viewValue);
250-
elem.attr('aria-checked', boolVal);
251-
elem.attr('tabindex', 0 - !boolVal);
252-
};
253-
} else {
254-
return function ngAriaRadioReaction(newVal) {
255-
elem.attr('aria-checked', (attr.value == ngModel.$viewValue));
256-
};
257-
}
262+
return function ngAriaRadioReaction(newVal) {
263+
var boolVal = (attr.value == ngModel.$viewValue);
264+
elem.attr('aria-checked', boolVal);
265+
};
258266
}
259-
260-
function ngAriaCheckboxReaction() {
261-
elem.attr('aria-checked', !ngModel.$isEmpty(ngModel.$viewValue));
267+
function getCheckboxReaction() {
268+
return function ngAriaCheckboxReaction(newVal) {
269+
var modelValue = attr.ngTrueValue ? ngModel.$viewValue == ngTrueValue : newVal;
270+
elem.attr('aria-checked', modelValue);
271+
};
262272
}
263273

264274
switch (shape) {
@@ -267,9 +277,9 @@ ngAriaModule.directive('ngShow', ['$aria', function($aria) {
267277
if (shouldAttachRole(shape, elem)) {
268278
elem.attr('role', shape);
269279
}
270-
if (shouldAttachAttr('aria-checked', 'ariaChecked', elem)) {
280+
if (shouldAttachAttr('aria-checked', 'ariaChecked', elem, false)) {
271281
scope.$watch(ngAriaWatchModelValue, shape === 'radio' ?
272-
getRadioReaction() : ngAriaCheckboxReaction);
282+
getRadioReaction() : getCheckboxReaction());
273283
}
274284
if (needsTabIndex) {
275285
elem.attr('tabindex', 0);
@@ -306,22 +316,17 @@ ngAriaModule.directive('ngShow', ['$aria', function($aria) {
306316
elem.attr('tabindex', 0);
307317
}
308318
break;
309-
case 'multiline':
310-
if (shouldAttachAttr('aria-multiline', 'ariaMultiline', elem)) {
311-
elem.attr('aria-multiline', true);
312-
}
313-
break;
314319
}
315320

316-
if (ngModel.$validators.required && shouldAttachAttr('aria-required', 'ariaRequired', elem)) {
317-
scope.$watch(function ngAriaRequiredWatch() {
318-
return ngModel.$error.required;
319-
}, function ngAriaRequiredReaction(newVal) {
320-
elem.attr('aria-required', !!newVal);
321+
if (!attr.hasOwnProperty('ngRequired') && ngModel.$validators.required
322+
&& shouldAttachAttr('aria-required', 'ariaRequired', elem, false)) {
323+
// ngModel.$error.required is undefined on custom controls
324+
scope.$watch(attr['required'], function ngAriaRequiredWatch() {
325+
elem.attr('aria-required', !!attr['required']);
321326
});
322327
}
323328

324-
if (shouldAttachAttr('aria-invalid', 'ariaInvalid', elem)) {
329+
if (shouldAttachAttr('aria-invalid', 'ariaInvalid', elem, true)) {
325330
scope.$watch(function ngAriaInvalidWatch() {
326331
return ngModel.$invalid;
327332
}, function ngAriaInvalidReaction(newVal) {
@@ -334,7 +339,7 @@ ngAriaModule.directive('ngShow', ['$aria', function($aria) {
334339
};
335340
}])
336341
.directive('ngDisabled', ['$aria', function($aria) {
337-
return $aria.$$watchExpr('ngDisabled', 'aria-disabled', []);
342+
return $aria.$$watchExpr('ngDisabled', 'aria-disabled', nodeBlackList, false);
338343
}])
339344
.directive('ngMessages', function() {
340345
return {

0 commit comments

Comments
 (0)