Skip to content
This repository has been archived by the owner on Sep 5, 2024. It is now read-only.

Commit

Permalink
fix(select): make aria compliant, read value in screen readers
Browse files Browse the repository at this point in the history
closes #3891, closes #4914, closes #4977
  • Loading branch information
rschmukler committed Nov 30, 2015
1 parent 3dc0f14 commit c64dba0
Show file tree
Hide file tree
Showing 2 changed files with 67 additions and 70 deletions.
135 changes: 65 additions & 70 deletions src/components/select/select.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ function SelectDirective($mdSelect, $mdUtil, $mdTheming, $mdAria, $compile, $par
element.append(angular.element('<md-content>').append(element.contents()));
}


// Add progress spinner for md-options-loading
if (attr.mdOnOpen) {

Expand Down Expand Up @@ -173,17 +174,18 @@ function SelectDirective($mdSelect, $mdUtil, $mdTheming, $mdAria, $compile, $par
// Use everything that's left inside element.contents() as the contents of the menu
var multiple = angular.isDefined(attr.multiple) ? 'multiple' : '';
var selectTemplate = '' +
'<div class="md-select-menu-container">' +
'<div class="md-select-menu-container" aria-hidden="true">' +

This comment has been minimized.

Copy link
@marcysutton

marcysutton Nov 30, 2015

Contributor

What is this hiding?

This comment has been minimized.

Copy link
@marcysutton

marcysutton Nov 30, 2015

Contributor

Looks like aria-hidden="true" is wrapped around the options, which are required child roles for listbox. Although they are visually hidden when the control is closed, they should probably be included in the accessibility tree (so leave them unhidden with ARIA). That will hopefully eliminate the Chrome Accessibility and aXe audit failures for [Severe] Elements with ARIA roles must ensure required owned elements are present (1), but I don't know if those audits expect them to be direct descendants.

'<md-select-menu {0}>{1}</md-select-menu>' +
'</div>';

selectTemplate = $mdUtil.supplant(selectTemplate, [multiple, element.html()]);
element.empty().append(valueEl);
element.append(selectTemplate);

attr.tabindex = attr.tabindex || '0';

return function postLink(scope, element, attr, ctrls) {
var isDisabled;
var isDisabled, ariaLabelBase;

// Remove event ngModel's blur listener for touched and untouched
// we will do it ourself.
Expand Down Expand Up @@ -218,7 +220,7 @@ function SelectDirective($mdSelect, $mdUtil, $mdTheming, $mdAria, $compile, $par

var selectContainer, selectScope, selectMenuCtrl;

createSelect();
getSelect();
$mdTheming(element);

if (attr.name && formCtrl) {
Expand All @@ -241,6 +243,7 @@ function SelectDirective($mdSelect, $mdUtil, $mdTheming, $mdAria, $compile, $par
ngModelCtrl.$render = function() {
originalRender();
syncLabelText();
syncAriaLabel();
inputCheckValue();
};

Expand Down Expand Up @@ -290,15 +293,17 @@ function SelectDirective($mdSelect, $mdUtil, $mdTheming, $mdAria, $compile, $par
};

scope.$$postDigest(function() {
setAriaLabel();
initAriaLabel();
syncLabelText();
syncAriaLabel();
});

function setAriaLabel() {
var labelText = element.attr('placeholder');
function initAriaLabel() {
var labelText = element.attr('aria-label') || element.attr('placeholder');
if (!labelText && containerCtrl && containerCtrl.label) {
labelText = containerCtrl.label.text();
}
ariaLabelBase = labelText;
$mdAria.expect(element, 'aria-label', labelText);
}

Expand All @@ -311,6 +316,11 @@ function SelectDirective($mdSelect, $mdUtil, $mdTheming, $mdAria, $compile, $par
}
}

function syncAriaLabel() {
if (!ariaLabelBase) return;
element.attr('aria-label', ariaLabelBase + ': ' + selectMenuCtrl.selectedLabels({mode: 'aria'}));

This comment has been minimized.

Copy link
@marcysutton

marcysutton Nov 30, 2015

Contributor

I like this. It announces a clear, consistent label when you interact with the control. On OSX the options are announced as text, which isn't good....but I don't think there's an easy way to address it without changing the DOM hierarchy to have options be direct descendants of role="listbox". It works fine in JAWS and NVDA.

}

var deregisterWatcher;
attr.$observe('ngMultiple', function(val) {
if (deregisterWatcher) deregisterWatcher();
Expand All @@ -324,12 +334,14 @@ function SelectDirective($mdSelect, $mdUtil, $mdTheming, $mdAria, $compile, $par
} else {
element.removeAttr('multiple');
}
element.attr('aria-multiselectable', multiple ? 'true' : 'false');
if (selectContainer) {
selectMenuCtrl.setMultiple(multiple);
originalRender = ngModelCtrl.$render;
ngModelCtrl.$render = function() {
originalRender();
syncLabelText();
syncAriaLabel();
inputCheckValue();
};
ngModelCtrl.$render();
Expand Down Expand Up @@ -365,9 +377,11 @@ function SelectDirective($mdSelect, $mdUtil, $mdTheming, $mdAria, $compile, $par


var ariaAttrs = {
role: 'combobox',
'aria-expanded': 'false'
role: 'listbox',
'aria-expanded': 'false',
'aria-multiselectable': attr.multiple !== undefined && !attr.ngMultiple ? 'true' : 'false'
};

if (!element[0].hasAttribute('id')) {
ariaAttrs.id = 'select_' + $mdUtil.nextUid();
}
Expand All @@ -377,10 +391,6 @@ function SelectDirective($mdSelect, $mdUtil, $mdTheming, $mdAria, $compile, $par
$mdSelect
.destroy()
.finally(function() {
if ( selectContainer ) {
selectContainer.remove();
}

if (containerCtrl) {
containerCtrl.setFocused(false);
containerCtrl.setHasValue(false);
Expand All @@ -398,18 +408,15 @@ function SelectDirective($mdSelect, $mdUtil, $mdTheming, $mdAria, $compile, $par
}

// Create a fake select to find out the label value
function createSelect() {
selectContainer = angular.element(selectTemplate);
var selectEl = selectContainer.find('md-select-menu');
selectEl.data('$ngModelController', ngModelCtrl);
selectEl.data('$mdSelectController', mdSelectCtrl);
selectScope = scope.$new();
$mdTheming.inherit(selectContainer, element);
function getSelect() {
selectContainer = angular.element(
element[0].querySelector('.md-select-menu-container')
);
selectScope = selectContainer.scope();
if (element.attr('md-container-class')) {
var value = selectContainer[0].getAttribute('class') + ' ' + element.attr('md-container-class');
selectContainer[0].setAttribute('class', value);
}
selectContainer = $compile(selectContainer)(selectScope);
selectMenuCtrl = selectContainer.find('md-select-menu').controller('mdSelectMenu');
}

Expand All @@ -434,8 +441,9 @@ function SelectDirective($mdSelect, $mdUtil, $mdTheming, $mdAria, $compile, $par
}
}

function openSelect() {
function openSelect(e) {
selectScope.isOpen = true;
element.attr('aria-expanded', 'true');

$mdSelect.show({
scope: selectScope,
Expand All @@ -449,6 +457,7 @@ function SelectDirective($mdSelect, $mdUtil, $mdTheming, $mdAria, $compile, $par
loadingAsync: attr.mdOnOpen ? scope.$eval(attr.mdOnOpen) || true : false
}).finally(function() {
selectScope.isOpen = false;
element.attr('aria-expanded', 'false');
ngModelCtrl.$setTouched();
});
}
Expand All @@ -460,7 +469,8 @@ function SelectMenuDirective($parse, $mdUtil, $mdTheming) {

return {
restrict: 'E',
require: ['mdSelectMenu', '?ngModel'],
require: ['mdSelectMenu', '^ngModel'],
scope: true,
controller: SelectMenuController,
link: {pre: preLink}
};
Expand All @@ -475,15 +485,6 @@ function SelectMenuDirective($parse, $mdUtil, $mdTheming) {
element.on('click', clickListener);
element.on('keypress', keyListener);
if (ngModel) selectCtrl.init(ngModel);
configureAria();

function configureAria() {
element.attr({
'id': 'select_menu_' + $mdUtil.nextUid(),
'role': 'listbox',
'aria-multiselectable': (selectCtrl.isMultiple ? 'true' : 'false')
});
}

function keyListener(e) {
if (e.keyCode == 13 || e.keyCode == 32) {
Expand Down Expand Up @@ -623,12 +624,19 @@ function SelectMenuDirective($parse, $mdUtil, $mdTheming) {
self.setMultiple(self.isMultiple);
};

self.selectedLabels = function() {
self.selectedLabels = function(opts) {
opts = opts || {};
var mode = opts.mode || 'html';
var selectedOptionEls = $mdUtil.nodesToArray($element[0].querySelectorAll('md-option[selected]'));
if (selectedOptionEls.length) {
return selectedOptionEls.map(function(el) {
return el.innerHTML;
}).join(', ');
var mapFn;

if (mode == 'html') {
mapFn = function(el) { return el.innerHTML; };
} else if (mode == 'aria') {
mapFn = function(el) { return el.hasAttribute('aria-label') ? el.getAttribute('aria-label') : el.textContent; };
}
return selectedOptionEls.map(mapFn).join(', ');
} else {
return '';
}
Expand Down Expand Up @@ -844,7 +852,7 @@ function SelectProvider($$interimElementProvider) {
});

/* @ngInject */
function selectDefaultOptions($mdSelect, $mdConstant, $mdUtil, $window, $q, $$rAF, $animateCss, $animate) {
function selectDefaultOptions($mdSelect, $mdConstant, $mdUtil, $window, $q, $$rAF, $animateCss, $animate, $document) {
var ERRROR_TARGET_EXPECTED = "$mdSelect.show() expected a target element in options.target but got '{0}'!";
var animator = $mdUtil.dom.animator;

Expand All @@ -869,7 +877,7 @@ function SelectProvider($$interimElementProvider) {
// For navigation $destroy events, do a quick, non-animated removal,
// but for normal closes (from clicks, etc) animate the removal

return (opts.$destroy === true) ? detachAndClean() : animateRemoval().then( detachAndClean );
return (opts.$destroy === true) ? cleanElement() : animateRemoval().then( cleanElement );

/**
* For normal closes (eg clicks), animate the removal.
Expand All @@ -881,14 +889,14 @@ function SelectProvider($$interimElementProvider) {
}

/**
* Detach the element and cleanup prior changes
* Clean the element up to a closed state
*/
function detachAndClean() {
configureAria(opts.target, false);
function cleanElement() {

element.attr('opacity', 0);
element.removeClass('md-active');
detachElement(element, opts);
element.attr('aria-hidden', 'true');
element[0].style.display = 'none';

announceClosed(opts);

Expand All @@ -906,12 +914,12 @@ function SelectProvider($$interimElementProvider) {

watchAsyncLoad();
sanitizeAndConfigure(scope, opts);
configureAria(opts.target);

opts.hideBackdrop = showBackdrop(scope, element, opts);

return showDropDown(scope, element, opts)
.then(function(response) {
element.attr('aria-hidden', 'false');
opts.alreadyOpen = true;
opts.cleanupInteraction = activateInteraction();
opts.cleanupResizing = activateResizing();
Expand Down Expand Up @@ -998,7 +1006,7 @@ function SelectProvider($$interimElementProvider) {
if (options.disableParentScroll) options.restoreScroll();

delete options.restoreScroll;
}
};
}

/**
Expand Down Expand Up @@ -1059,7 +1067,7 @@ function SelectProvider($$interimElementProvider) {
// Disable resizing handlers
window.off('resize', debouncedOnResize);
window.off('orientationchange', debouncedOnResize);
}
};
}

/**
Expand All @@ -1078,7 +1086,7 @@ function SelectProvider($$interimElementProvider) {
delete opts.loadingAsync;
}).then(function() {
$$rAF(positionAndFocusMenu);
})
});
}
}

Expand All @@ -1099,12 +1107,12 @@ function SelectProvider($$interimElementProvider) {
// Escape to close
// Cycling of options, and closing on enter
dropDown.on('keydown', onMenuKeyDown);
dropDown.on('mouseup', checkCloseMenu);
dropDown.on('click', checkCloseMenu);

return function cleanupInteraction() {
opts.backdrop && opts.backdrop.off('click', onBackdropClick);
dropDown.off('keydown', onMenuKeyDown);
dropDown.off('mouseup', checkCloseMenu);
dropDown.off('click', checkCloseMenu);

element.removeClass('md-clickable');
opts.isRemoved = true;
Expand All @@ -1123,14 +1131,14 @@ function SelectProvider($$interimElementProvider) {

function onMenuKeyDown(ev) {
var keyCodes = $mdConstant.KEY_CODE;
ev.preventDefault();
ev.stopPropagation();

switch (ev.keyCode) {
case keyCodes.UP_ARROW:
return focusPrevOption();
break;
case keyCodes.DOWN_ARROW:
return focusNextOption();
break;
case keyCodes.SPACE:
case keyCodes.ENTER:
var option = $mdUtil.getClosest(ev.target, 'md-option');
Expand Down Expand Up @@ -1190,11 +1198,13 @@ function SelectProvider($$interimElementProvider) {
}

function checkCloseMenu(ev) {
if (ev && ( ev.type == 'mouseup') && (ev.currentTarget != dropDown[0])) return;
if (ev && ( ev.type == 'click') && (ev.currentTarget != dropDown[0])) return;
if ( mouseOnScrollbar() ) return;

var option = $mdUtil.getClosest(ev.target, 'md-option');
if (option && option.hasAttribute && !option.hasAttribute('disabled')) {
ev.preventDefault();
ev.stopPropagation();
if (!selectCtrl.isMultiple) {
opts.restoreFocus = true;

Expand Down Expand Up @@ -1224,14 +1234,6 @@ function SelectProvider($$interimElementProvider) {

}

/**
*
*/
function configureAria(element, isExpanded) {
isExpanded = angular.isUndefined(isExpanded) ? 'true' : 'false';
element && element.attr('aria-expanded', isExpanded);
}

/**
* To notify listeners that the Select menu has closed,
* trigger the [optional] user-defined expression
Expand All @@ -1245,23 +1247,15 @@ function SelectProvider($$interimElementProvider) {
}
}

/**
* Use browser to remove this element without triggering a $destroy event
*/
function detachElement(element, opts) {
if (element[0].parentNode === opts.parent[0]) {
opts.parent[0].removeChild(element[0]);
}
}

/**
* Calculate the
*/
function calculateMenuPositions(scope, element, opts) {
var optionNodes,
var
containerNode = element[0],
targetNode = opts.target[0].firstElementChild, // target the label
parentNode = opts.parent[0],
targetNode = opts.target[0].children[1], // target the label
parentNode = $document[0].body,
selectNode = opts.selectEl[0],
contentNode = opts.contentEl[0],
parentRect = parentNode.getBoundingClientRect(),
Expand Down Expand Up @@ -1329,6 +1323,7 @@ function SelectProvider($$interimElementProvider) {
opts.focusedNode = focusedNode;

// Get the selectMenuRect *after* max-width is possibly set above
containerNode.style.display = 'block';
var selectMenuRect = selectNode.getBoundingClientRect();
var centeredRect = getOffsetRect(centeredNode);

Expand Down Expand Up @@ -1367,7 +1362,7 @@ function SelectProvider($$interimElementProvider) {
} else {
left = (targetRect.left + centeredRect.left - centeredRect.paddingLeft) + 2;
top = Math.floor(targetRect.top + targetRect.height / 2 - centeredRect.height / 2 -
centeredRect.top + contentNode.scrollTop) + 2;
centeredRect.top + contentNode.scrollTop) + 5;

transformOrigin = (centeredRect.left + targetRect.width / 2) + 'px ' +
(centeredRect.top + centeredRect.height / 2 - contentNode.scrollTop) + 'px 0px';
Expand Down
Loading

0 comments on commit c64dba0

Please sign in to comment.