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

Commit d1434c9

Browse files
Subrabtford
Subra
authored andcommitted
feat(ngAria): add an ngAria module to make a11y easier
Conditionally adds various aria attributes to the built in directives. This module currently hooks into ng-show/hide, input, textarea and button as a basic level of support for a11y. Closes #5486 and #1600
1 parent 8b8f6f5 commit d1434c9

File tree

4 files changed

+791
-4
lines changed

4 files changed

+791
-4
lines changed

Gruntfile.js

+9-1
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,9 @@ module.exports = function(grunt) {
153153
},
154154
ngTouch: {
155155
files: { src: 'src/ngTouch/**/*.js' },
156+
},
157+
ngAria: {
158+
files: {src: 'src/ngAria/**/*.js'},
156159
}
157160
},
158161

@@ -220,6 +223,10 @@ module.exports = function(grunt) {
220223
dest: 'build/angular-cookies.js',
221224
src: util.wrap(files['angularModules']['ngCookies'], 'module')
222225
},
226+
aria: {
227+
dest: 'build/angular-aria.js',
228+
src: util.wrap(files['angularModules']['ngAria'], 'module')
229+
},
223230
"promises-aplus-adapter": {
224231
dest:'tmp/promises-aplus-adapter++.js',
225232
src:['src/ng/q.js','lib/promises-aplus/promises-aplus-test-adapter.js']
@@ -236,7 +243,8 @@ module.exports = function(grunt) {
236243
touch: 'build/angular-touch.js',
237244
resource: 'build/angular-resource.js',
238245
route: 'build/angular-route.js',
239-
sanitize: 'build/angular-sanitize.js'
246+
sanitize: 'build/angular-sanitize.js',
247+
aria: 'build/angular-aria.js'
240248
},
241249

242250

angularFiles.js

+9-3
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ var angularFiles = {
108108
'src/ngTouch/directive/ngClick.js',
109109
'src/ngTouch/directive/ngSwipe.js'
110110
],
111+
'ngAria': [
112+
'src/ngAria/aria.js'
113+
]
111114
},
112115

113116
'angularScenario': [
@@ -141,7 +144,8 @@ var angularFiles = {
141144
'test/ngRoute/**/*.js',
142145
'test/ngSanitize/**/*.js',
143146
'test/ngMock/*.js',
144-
'test/ngTouch/**/*.js'
147+
'test/ngTouch/**/*.js',
148+
'test/ngAria/*.js'
145149
],
146150

147151
'karma': [
@@ -175,7 +179,8 @@ var angularFiles = {
175179
'test/ngRoute/**/*.js',
176180
'test/ngResource/*.js',
177181
'test/ngSanitize/**/*.js',
178-
'test/ngTouch/**/*.js'
182+
'test/ngTouch/**/*.js',
183+
'test/ngAria/*.js'
179184
],
180185

181186
'karmaJquery': [
@@ -203,7 +208,8 @@ angularFiles['angularSrcModules'] = [].concat(
203208
angularFiles['angularModules']['ngRoute'],
204209
angularFiles['angularModules']['ngSanitize'],
205210
angularFiles['angularModules']['ngMock'],
206-
angularFiles['angularModules']['ngTouch']
211+
angularFiles['angularModules']['ngTouch'],
212+
angularFiles['angularModules']['ngAria']
207213
);
208214

209215
if (exports) {

src/ngAria/aria.js

+263
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
'use strict';
2+
3+
/**
4+
* @ngdoc module
5+
* @name ngAria
6+
* @description
7+
*
8+
* The `ngAria` module provides support for to embed aria tags that convey state or semantic information
9+
* about the application in order to allow assistive technologies to convey appropriate information to
10+
* persons with disabilities.
11+
*
12+
* <div doc-module-components="ngAria"></div>
13+
*
14+
* # Usage
15+
* To enable the addition of the aria tags, just require the module into your application and the tags will
16+
* hook into your ng-show/ng-hide, input, textarea, button, select and ng-required directives and adds the
17+
* appropriate aria-tags.
18+
*
19+
* Currently, the following aria tags are implemented:
20+
*
21+
* + aria-hidden
22+
* + aria-checked
23+
* + aria-disabled
24+
* + aria-required
25+
* + aria-invalid
26+
* + aria-multiline
27+
* + aria-valuenow
28+
* + aria-valuemin
29+
* + aria-valuemax
30+
* + tabindex
31+
*
32+
* You can disable individual aria tags by using the {@link ngAria.$ariaProvider#config config} method.
33+
*/
34+
35+
/* global -ngAriaModule */
36+
var ngAriaModule = angular.module('ngAria', ['ng']).
37+
provider('$aria', $AriaProvider);
38+
39+
/**
40+
* @ngdoc provider
41+
* @name $ariaProvider
42+
*
43+
* @description
44+
*
45+
* Used for configuring aria attributes.
46+
*
47+
* ## Dependencies
48+
* Requires the {@link ngAria `ngAria`} module to be installed.
49+
*/
50+
function $AriaProvider() {
51+
var config = {
52+
ariaHidden : true,
53+
ariaChecked: true,
54+
ariaDisabled: true,
55+
ariaRequired: true,
56+
ariaInvalid: true,
57+
ariaMultiline: true,
58+
ariaValue: true,
59+
tabindex: true
60+
};
61+
62+
/**
63+
* @ngdoc method
64+
* @name $ariaProvider#config
65+
*
66+
* @param {object} config object to enable/disable specific aria tags
67+
*
68+
* - **ariaHidden** – `{boolean}` – Enables/disables aria-hidden tags
69+
* - **ariaChecked** – `{boolean}` – Enables/disables aria-checked tags
70+
* - **ariaDisabled** – `{boolean}` – Enables/disables aria-disabled tags
71+
* - **ariaRequired** – `{boolean}` – Enables/disables aria-required tags
72+
* - **ariaInvalid** – `{boolean}` – Enables/disables aria-invalid tags
73+
* - **ariaMultiline** – `{boolean}` – Enables/disables aria-multiline tags
74+
* - **ariaValue** – `{boolean}` – Enables/disables aria-valuemin, aria-valuemax and aria-valuenow tags
75+
* - **tabindex** – `{boolean}` – Enables/disables tabindex tags
76+
*
77+
* @description
78+
* Enables/disables various aria tags
79+
*/
80+
this.config = function(newConfig) {
81+
config = angular.extend(config, newConfig);
82+
};
83+
84+
function dashCase(input) {
85+
return input.replace(/[A-Z]/g, function(letter, pos) {
86+
return (pos ? '-' : '') + letter.toLowerCase();
87+
});
88+
}
89+
90+
function watchAttr(attrName, ariaName) {
91+
var ariaDashName = dashCase(ariaName);
92+
return function(scope, elem, attr) {
93+
if (!config[ariaName] || elem.attr(ariaDashName)) {
94+
return;
95+
}
96+
var destroyWatcher = attr.$observe(attrName, function(newVal) {
97+
elem.attr(ariaDashName, !angular.isUndefined(newVal));
98+
});
99+
scope.$on('$destroy', destroyWatcher);
100+
};
101+
}
102+
103+
function watchExpr(attrName, ariaName, negate) {
104+
var ariaDashName = dashCase(ariaName);
105+
return function(scope, elem, attr) {
106+
if (config[ariaName] && !attr[ariaName]) {
107+
scope.$watch(attr[attrName], function(boolVal) {
108+
if (negate) {
109+
boolVal = !boolVal;
110+
}
111+
elem.attr(ariaDashName, boolVal);
112+
});
113+
}
114+
};
115+
}
116+
117+
function watchNgModelProperty (prop, watchFn) {
118+
var ariaAttrName = 'aria-' + prop,
119+
configName = 'aria' + prop[0].toUpperCase() + prop.substr(1);
120+
return function watchNgModelPropertyLinkFn(scope, elem, attr, ngModel) {
121+
if (!config[configName] || elem.attr(ariaAttrName) || !ngModel) {
122+
return;
123+
}
124+
scope.$watch(watchFn(ngModel), function(newVal) {
125+
elem.attr(ariaAttrName, !!newVal);
126+
});
127+
};
128+
}
129+
130+
this.$get = function() {
131+
return {
132+
watchExpr: watchExpr,
133+
ariaChecked: watchExpr('ngModel', 'ariaChecked'),
134+
ariaDisabled: watchExpr('ngDisabled', 'ariaDisabled'),
135+
ariaRequired: watchNgModelProperty('required', function(ngModel) {
136+
return function ngAriaModelWatch() {
137+
return ngModel.$error.required;
138+
};
139+
}),
140+
ariaInvalid: watchNgModelProperty('invalid', function(ngModel) {
141+
return function ngAriaModelWatch() {
142+
return ngModel.$invalid;
143+
};
144+
}),
145+
ariaValue: function(scope, elem, attr, ngModel) {
146+
if (config.ariaValue) {
147+
if (attr.min && !elem.attr('aria-valuemin')) {
148+
elem.attr('aria-valuemin', attr.min);
149+
}
150+
if (attr.max && !elem.attr('aria-valuemax')) {
151+
elem.attr('aria-valuemax', attr.max);
152+
}
153+
if (ngModel && !elem.attr('aria-valuenow')) {
154+
scope.$watch(function ngAriaModelWatch() {
155+
return ngModel.$modelValue;
156+
}, function ngAriaValueNowReaction(newVal) {
157+
elem.attr('aria-valuenow', newVal);
158+
});
159+
}
160+
}
161+
},
162+
radio: function(scope, elem, attr, ngModel) {
163+
if (config.ariaChecked && ngModel && !elem.attr('aria-checked')) {
164+
var needsTabIndex = config.tabindex && !elem.attr('tabindex');
165+
scope.$watch(function() {
166+
return ngModel.$modelValue;
167+
}, function(newVal) {
168+
elem.attr('aria-checked', newVal === attr.value);
169+
if (needsTabIndex) {
170+
elem.attr('tabindex', 0 - (newVal !== attr.value));
171+
}
172+
});
173+
}
174+
},
175+
multiline: function(scope, elem, attr) {
176+
if (config.ariaMultiline && !elem.attr('aria-multiline')) {
177+
elem.attr('aria-multiline', true);
178+
}
179+
},
180+
roleChecked: function(scope, elem, attr) {
181+
if (config.ariaChecked && attr.checked && !elem.attr('aria-checked')) {
182+
elem.attr('aria-checked', true);
183+
}
184+
},
185+
tabindex: function(scope, elem, attr) {
186+
if (config.tabindex && !elem.attr('tabindex')) {
187+
elem.attr('tabindex', 0);
188+
}
189+
}
190+
};
191+
};
192+
}
193+
194+
var ngAriaRequired = ['$aria', function($aria) {
195+
return {
196+
require: '?ngModel',
197+
link: $aria.ariaRequired
198+
};
199+
}];
200+
201+
var ngAriaTabindex = ['$aria', function($aria) {
202+
return $aria.tabindex;
203+
}];
204+
205+
ngAriaModule.directive('ngShow', ['$aria', function($aria) {
206+
return $aria.watchExpr('ngShow', 'ariaHidden', true);
207+
}])
208+
.directive('ngHide', ['$aria', function($aria) {
209+
return $aria.watchExpr('ngHide', 'ariaHidden', false);
210+
}])
211+
.directive('input', ['$aria', function($aria) {
212+
return {
213+
restrict: 'E',
214+
require: '?ngModel',
215+
link: function(scope, elem, attr, ngModel) {
216+
if (attr.type === 'checkbox') {
217+
$aria.ariaChecked(scope, elem, attr);
218+
} else if (attr.type === 'radio') {
219+
$aria.radio(scope, elem, attr, ngModel);
220+
} else if (attr.type === 'range') {
221+
$aria.ariaValue(scope, elem, attr, ngModel);
222+
}
223+
$aria.ariaInvalid(scope, elem, attr, ngModel);
224+
}
225+
};
226+
}])
227+
.directive('textarea', ['$aria', function($aria) {
228+
return {
229+
restrict: 'E',
230+
require: '?ngModel',
231+
link: function(scope, elem, attr, ngModel) {
232+
$aria.ariaInvalid(scope, elem, attr, ngModel);
233+
$aria.multiline(scope, elem, attr);
234+
}
235+
};
236+
}])
237+
.directive('ngRequired', ngAriaRequired)
238+
.directive('required', ngAriaRequired)
239+
.directive('ngDisabled', ['$aria', function($aria) {
240+
return $aria.ariaDisabled;
241+
}])
242+
.directive('role', ['$aria', function($aria) {
243+
return {
244+
restrict: 'A',
245+
require: '?ngModel',
246+
link: function(scope, elem, attr, ngModel) {
247+
if (attr.role === 'textbox') {
248+
$aria.multiline(scope, elem, attr);
249+
} else if (attr.role === 'progressbar' || attr.role === 'slider') {
250+
$aria.ariaValue(scope, elem, attr, ngModel);
251+
} else if (attr.role === 'checkbox' || attr.role === 'menuitemcheckbox') {
252+
$aria.roleChecked(scope, elem, attr);
253+
$aria.tabindex(scope, elem, attr);
254+
} else if (attr.role === 'radio' || attr.role === 'menuitemradio') {
255+
$aria.radio(scope, elem, attr, ngModel);
256+
} else if (attr.role === 'button') {
257+
$aria.tabindex(scope, elem, attr);
258+
}
259+
}
260+
};
261+
}])
262+
.directive('ngClick', ngAriaTabindex)
263+
.directive('ngDblclick', ngAriaTabindex);

0 commit comments

Comments
 (0)