From 05ed42de4fb52ec916b2fcc6e5a78d2d5ea164ad Mon Sep 17 00:00:00 2001 From: Marcy Sutton Date: Mon, 22 Sep 2014 16:00:56 -0700 Subject: [PATCH] fix(material-radio): Radio button a11y Closes #310 --- src/components/radioButton/radioButton.js | 44 +++++++++++++++---- .../radioButton/radioButton.spec.js | 7 ++- src/core/constant.js | 8 ++-- 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/src/components/radioButton/radioButton.js b/src/components/radioButton/radioButton.js index 8fd2632bafa..5c7fa3112b2 100644 --- a/src/components/radioButton/radioButton.js +++ b/src/components/radioButton/radioButton.js @@ -53,7 +53,7 @@ function materialRadioGroupDirective() { return { restrict: 'E', - controller: RadioGroupController, + controller: ['$element', RadioGroupController], require: ['materialRadioGroup', '?ngModel'], link: link }; @@ -65,12 +65,11 @@ function materialRadioGroupDirective() { }; function keydownListener(ev) { - - if (ev.which === Constant.KEY_CODE.LEFT_ARROW) { + if (ev.which === Constant.KEY_CODE.LEFT_ARROW || ev.which === Constant.KEY_CODE.UP_ARROW) { ev.preventDefault(); rgCtrl.selectPrevious(element); } - else if (ev.which === Constant.KEY_CODE.RIGHT_ARROW) { + else if (ev.which === Constant.KEY_CODE.RIGHT_ARROW || ev.which === Constant.KEY_CODE.DOWN_ARROW) { ev.preventDefault(); rgCtrl.selectNext(element); } @@ -85,8 +84,9 @@ function materialRadioGroupDirective() { .on('keydown', keydownListener); } - function RadioGroupController() { + function RadioGroupController($element) { this._radioButtonRenderFns = []; + this.$element = $element; } function createRadioGroupControllerProto() { @@ -122,6 +122,9 @@ function materialRadioGroupDirective() { }, selectPrevious : function(element) { return selectButton('previous', element); + }, + setActiveDescendant: function (radioId) { + this.$element.attr('aria-activedescendant', radioId); } }; } @@ -221,6 +224,8 @@ function materialRadioButtonDirective($aria) { function link(scope, element, attr, rgCtrl) { var lastChecked; + configureAria(element, scope); + rgCtrl.add(render); attr.$observe('value', render); @@ -228,10 +233,7 @@ function materialRadioButtonDirective($aria) { .on('click', listener) .on('$destroy', function() { rgCtrl.remove(render); - }) - .attr('role', 'radio'); - - $aria.expect(element, 'aria-label', element.text()); + }); function listener(ev) { if (element[0].hasAttribute('disabled')) return; @@ -250,10 +252,34 @@ function materialRadioButtonDirective($aria) { element.attr('aria-checked', checked); if (checked) { element.addClass(CHECKED_CSS); + rgCtrl.setActiveDescendant(element.attr('id')); } else { element.removeClass(CHECKED_CSS); } } + /** + * Inject ARIA-specific attributes appropriate for each radio button + */ + function configureAria( element, scope ){ + scope.ariaId = buildAriaID(); + + element.attr({ + 'id' : scope.ariaId, + 'role' : 'radio', + 'aria-checked' : 'false' + }); + + $aria.expect(element, 'aria-label', element.text()); + + /** + * Build a unique ID for each radio button that will be used with aria-activedescendant. + * Preserve existing ID if already specified. + * @returns {*|string} + */ + function buildAriaID() { + return attr.id || ( 'radio' + "_" + Util.nextUid() ); + } + } } } diff --git a/src/components/radioButton/radioButton.spec.js b/src/components/radioButton/radioButton.spec.js index 97b474ab1d3..ba7b6afa9b1 100644 --- a/src/components/radioButton/radioButton.spec.js +++ b/src/components/radioButton/radioButton.spec.js @@ -19,7 +19,7 @@ describe('radioButton', function() { expect(rbElements.eq(1).hasClass(CHECKED_CSS)).toEqual(true); })); - it('should set aria roles', inject(function($compile, $rootScope) { + it('should set roles', inject(function($compile, $rootScope) { var element = $compile('' + '' + @@ -31,7 +31,7 @@ describe('radioButton', function() { expect(rbGroupElement.find('material-radio-button').eq(0).attr('role')).toEqual('radio'); })); - it('should set aria-check attributes', inject(function($compile, $rootScope) { + it('should set aria attributes', inject(function($compile, $rootScope) { var element = $compile('' + '' + '' + @@ -45,6 +45,9 @@ describe('radioButton', function() { expect(rbElements.eq(0).attr('aria-checked')).toEqual('false'); expect(rbElements.eq(1).attr('aria-checked')).toEqual('true'); + + expect(element.attr('aria-activedescendant')).toEqual(rbElements.eq(1).attr('id')); + expect(element.attr('aria-activedescendant')).not.toEqual(rbElements.eq(0).attr('id')); })); it('should be operable via arrow keys', inject(function($compile, $rootScope) { diff --git a/src/core/constant.js b/src/core/constant.js index d0a3008d55a..5bd1a87ad95 100644 --- a/src/core/constant.js +++ b/src/core/constant.js @@ -1,9 +1,11 @@ var Constant = { KEY_CODE: { + ENTER: 13, ESCAPE: 27, SPACE: 32, - LEFT_ARROW: 37, - RIGHT_ARROW: 39, - ENTER: 13 + LEFT_ARROW : 37, + UP_ARROW : 38, + RIGHT_ARROW : 39, + DOWN_ARROW : 40 } };