Skip to content
This repository was archived by the owner on Apr 12, 2024. It is now read-only.

fix(ngAria): Apply ARIA attrs correctly #13483

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions docs/content/guide/accessibility.ngdoc
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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.

<h2 id="ngvaluechecked">ngValue and ngChecked</h2>

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
<custom-checkbox ng-checked="val"></custom-checkbox>
<custom-radio-button ng-value="val"></custom-radio-button>
```

Becomes:

```html
<custom-checkbox ng-checked="val" aria-checked="true"></custom-checkbox>
<custom-radio-button ng-value="val" aria-checked="true"></custom-radio-button>
```

<h2 id="ngdisabled">ngDisabled</h2>

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

```html
<md-checkbox ng-disabled="disabled">
<md-checkbox ng-disabled="disabled"></md-checkbox>
```

Becomes:

```html
<md-checkbox disabled aria-disabled="true">
<md-checkbox disabled aria-disabled="true"></md-checkbox>
```

>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/).

<h2 id="ngrequired">ngRequired</h2>

The boolean `required` attribute is only valid for native form controls such as `input` and
`textarea`. To properly indicate custom element directives such as `<md-checkbox>` or `<custom-input>`
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
<md-checkbox ng-required="val"></md-checkbox>
```

Becomes:

```html
<md-checkbox ng-required="val" aria-required="true"></md-checkbox>
```

<h2 id="ngshow">ngShow</h2>

>The [ngShow](https://docs.angularjs.org/api/ng/directive/ngShow) directive shows or hides the
Expand Down
98 changes: 49 additions & 49 deletions src/ngAria/aria.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
Expand Down Expand Up @@ -90,7 +94,6 @@ function $AriaProvider() {
ariaDisabled: true,
ariaRequired: true,
ariaInvalid: true,
ariaMultiline: true,
ariaValue: true,
tabindex: true,
bindKeypress: true,
Expand All @@ -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 `&lt;div&gt;` and
* `&lt;li&gt;` 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
*
Expand Down Expand Up @@ -151,28 +153,31 @@ 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
* simply as a silent side-effect of using `ng-disabled` with the ngAria module.
*
* 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.
Expand All @@ -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');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@marcysutton, why are we only checking against input ? Shouldn't all native controls (e.g. textarea, select, button) be excluded ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that line should be replaced with isNodeOneOf(elem, nodeBlackList)). It has already gone through the getShape function but that only checks type and role.

}

Expand All @@ -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 {
Expand All @@ -227,37 +243,26 @@ 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;
};
}
},
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() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This wrapper function doesn't seem to be necessary here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was added to be consumed in a watch function on line 282.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you need to call a function that returns the actual function ? Just use ngAriaCheckboxReaction directly.
(The same is true for ngAriaRadioReaction - unless I'm missing something 😃)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it!

elem.attr('aria-checked', !ngModel.$isEmpty(ngModel.$viewValue));
}

Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down
Loading