From 1a51f2416f668f4f13cf0cb1f5546fcd44ebaa64 Mon Sep 17 00:00:00 2001 From: Michael Prentice Date: Wed, 12 Jun 2019 04:55:01 -0400 Subject: [PATCH] fix(autocomplete): improve implementation of aria-activedescendant - allow screen readers to do more and us to do less - remove extra calls to announce the item that is visually focused - remove tests for these extra live announcements - give every option an id for use with `aria-activedescendant` - use the `selected` class for styling and finding the active option - implement recommendations from a11y guides - add the clear button to the tab order - change input type to `text` - always define a `name` attribute - when the popup isn't expanded - `aria-owns` and `aria-activedescendant` shouldn't be defined - when the autocomplete is disabled - `aria-autocomplete` and `aria-role` shouldn't be defined - `aria-haspopup` should be false - add md-autocomplete-suggestion class for styling instead of using `li` - add `md-autoselect` to the dialog demo for help w/ manual testing - remove overly verbose `aria-describedby` from basic demo - mark `md-icons` in `md-item-templates` of autocomplete demos as hidden - update demos to use `md-escape-options="clear"` for better a11y Fixes #11742 --- .../autocomplete/autocomplete-theme.scss | 4 +- src/components/autocomplete/autocomplete.scss | 4 +- .../autocomplete/autocomplete.spec.js | 426 ++++++++++++++++-- .../autocomplete/demoBasicUsage/index.html | 4 +- .../demoCustomTemplate/index.html | 3 +- .../demoCustomTemplate/style.global.css | 4 +- .../autocomplete/demoFloatingLabel/index.html | 23 +- .../demoInsideDialog/dialog.tmpl.html | 3 +- .../autocomplete/demoRepeatMode/index.html | 6 +- .../demoRepeatMode/style.global.css | 8 +- .../autocomplete/js/autocompleteController.js | 75 +-- .../autocomplete/js/autocompleteDirective.js | 38 +- src/core/util/util.js | 4 +- test/angular-material-mocks.js | 2 +- 14 files changed, 494 insertions(+), 110 deletions(-) diff --git a/src/components/autocomplete/autocomplete-theme.scss b/src/components/autocomplete/autocomplete-theme.scss index 03585333e0..aece493afd 100644 --- a/src/components/autocomplete/autocomplete-theme.scss +++ b/src/components/autocomplete/autocomplete-theme.scss @@ -49,10 +49,10 @@ md-autocomplete.md-THEME_NAME-theme { .md-autocomplete-suggestions-container.md-THEME_NAME-theme, .md-autocomplete-standard-list-container.md-THEME_NAME-theme { background: '{{background-hue-1}}'; - li { + .md-autocomplete-suggestion { color: '{{foreground-1}}'; &:hover, - &#selected_option { + &.selected { background: '{{background-500-0.18}}'; } } diff --git a/src/components/autocomplete/autocomplete.scss b/src/components/autocomplete/autocomplete.scss index b9302f4d3c..ddc9127449 100644 --- a/src/components/autocomplete/autocomplete.scss +++ b/src/components/autocomplete/autocomplete.scss @@ -174,7 +174,7 @@ md-autocomplete { input { border: 1px solid $border-color; } - li:focus { + .md-autocomplete-suggestion:focus { color: #fff; } } @@ -214,7 +214,7 @@ md-autocomplete { list-style: none; padding: 0; - li { + .md-autocomplete-suggestion { font-size: 14px; overflow: hidden; padding: 0 15px; diff --git a/src/components/autocomplete/autocomplete.spec.js b/src/components/autocomplete/autocomplete.spec.js index 6ef3052c5a..c97a873b32 100644 --- a/src/components/autocomplete/autocomplete.spec.js +++ b/src/components/autocomplete/autocomplete.spec.js @@ -1769,10 +1769,12 @@ describe('', function() { }); describe('Accessibility', function() { - var $timeout = null; + var $timeout = null, $mdConstant = null, $material = null; beforeEach(inject(function ($injector) { $timeout = $injector.get('$timeout'); + $material = $injector.get('$material'); + $mdConstant = $injector.get('$mdConstant'); })); it('should add the placeholder as the input\'s aria-label', function() { @@ -1795,6 +1797,393 @@ describe('', function() { expect(input.attr('aria-label')).toBe('placeholder'); }); + it('should set activeOption when autoselect is off', function() { + var template = + '' + + ' {{item.display}}' + + ''; + var scope = createScope(); + var element = compile(template, scope); + var ctrl = element.controller('mdAutocomplete'); + var ul = element.find('ul'); + var input = element.find('input'); + // Run our initial flush + $timeout.flush(); + + expect(ctrl.index).toBe(-1); + expect(ctrl.hidden).toBe(true); + expect(ctrl.activeOption).toBe(null); + expect(input[0].getAttribute('aria-owns')).toBe(null); + expect(input[0].getAttribute('aria-activedescendant')).toBe(null); + + // Focus the input + ctrl.focus(); + + // Update the scope + element.scope().searchText = 'ba'; + waitForVirtualRepeat(element); + + var suggestions = ul.find('li'); + expect(suggestions[0].classList).not.toContain('selected'); + + expect(ctrl.hidden).toBe(false); + + ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.DOWN_ARROW)); + $material.flushInterimElement(); + + expect(suggestions[0].classList).toContain('selected'); + + expect(ctrl.index).toBe(0); + expect(ctrl.hidden).toBe(false); + expect(ctrl.activeOption).toBe('md-option-' + ctrl.id + '-0'); + expect(input[0].getAttribute('aria-owns')).toBe('ul-' + ctrl.id); + expect(input[0].getAttribute('aria-activedescendant')).toBe('md-option-' + ctrl.id + '-0'); + }); + + it('should start from the end when up arrow is pressed', function() { + var template = + '' + + ' {{item.display}}' + + ''; + var scope = createScope(); + var element = compile(template, scope); + var ctrl = element.controller('mdAutocomplete'); + var ul = element.find('ul'); + var input = element.find('input'); + // Run our initial flush + $timeout.flush(); + + expect(ctrl.index).toBe(-1); + expect(ctrl.hidden).toBe(true); + expect(ctrl.activeOption).toBe(null); + expect(input[0].getAttribute('aria-owns')).toBe(null); + expect(input[0].getAttribute('aria-activedescendant')).toBe(null); + + // Focus the input + ctrl.focus(); + + // Update the scope + element.scope().searchText = 'ba'; + waitForVirtualRepeat(element); + + var suggestions = ul.find('li'); + expect(suggestions[0].classList).not.toContain('selected'); + + expect(ctrl.hidden).toBe(false); + + ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.UP_ARROW)); + $material.flushInterimElement(); + + expect(suggestions[1].classList).toContain('selected'); + + expect(ctrl.index).toBe(1); + expect(ctrl.hidden).toBe(false); + expect(ctrl.activeOption).toBe('md-option-' + ctrl.id + '-1'); + expect(input[0].getAttribute('aria-owns')).toBe('ul-' + ctrl.id); + expect(input[0].getAttribute('aria-activedescendant')).toBe('md-option-' + ctrl.id + '-1'); + }); + + it('should set activeOption when autoselect is on', function() { + var template = + '' + + ' {{item.display}}' + + ''; + var scope = createScope(); + var element = compile(template, scope); + var ctrl = element.controller('mdAutocomplete'); + var ul = element.find('ul'); + var input = element.find('input'); + // Run our initial flush + $timeout.flush(); + + expect(ctrl.index).toBe(0); + expect(ctrl.hidden).toBe(true); + expect(ctrl.activeOption).toBe(null); + expect(input[0].getAttribute('aria-owns')).toBe(null); + expect(input[0].getAttribute('aria-activedescendant')).toBe(null); + + // Focus the input + ctrl.focus(); + + // Update the scope + element.scope().searchText = 'ba'; + waitForVirtualRepeat(element); + + // Wait for the next tick when the values will be updated + $timeout.flush(); + + var suggestions = ul.find('li'); + expect(suggestions[0].classList).toContain('selected'); + expect(ctrl.activeOption).toBe('md-option-' + ctrl.id + '-0'); + expect(input[0].getAttribute('aria-owns')).toBe('ul-' + ctrl.id); + expect(input[0].getAttribute('aria-activedescendant')).toBe('md-option-' + ctrl.id + '-0'); + + expect(ctrl.hidden).toBe(false); + + ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.DOWN_ARROW)); + $material.flushInterimElement(); + + expect(suggestions[1].classList).toContain('selected'); + + expect(ctrl.index).toBe(1); + expect(ctrl.hidden).toBe(false); + expect(ctrl.activeOption).toBe('md-option-' + ctrl.id + '-1'); + }); + + it('should update activeOption when selection is cleared and autoselect is off', function() { + var template = + '' + + ' {{item.display}}' + + ''; + var scope = createScope(); + var element = compile(template, scope); + var ctrl = element.controller('mdAutocomplete'); + var ul = element.find('ul'); + var input = element.find('input'); + // Run our initial flush + $timeout.flush(); + + expect(ctrl.index).toBe(-1); + expect(ctrl.hidden).toBe(true); + expect(ctrl.activeOption).toBe(null); + expect(input[0].getAttribute('aria-owns')).toBe(null); + expect(input[0].getAttribute('aria-activedescendant')).toBe(null); + + // Focus the input + ctrl.focus(); + + // Update the scope + element.scope().searchText = 'ba'; + waitForVirtualRepeat(element); + + // Wait for the next tick when the values will be updated + $timeout.flush(); + + var suggestions = ul.find('li'); + expect(suggestions[0].classList).not.toContain('selected'); + expect(ctrl.activeOption).toBe(null); + expect(ctrl.hidden).toBe(false); + expect(input[0].getAttribute('aria-owns')).toBe('ul-' + ctrl.id); + expect(input[0].getAttribute('aria-activedescendant')).toBe(null); + + ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.DOWN_ARROW)); + $material.flushInterimElement(); + + expect(suggestions[0].classList).toContain('selected'); + expect(input[0].getAttribute('aria-activedescendant')).toBe('md-option-' + ctrl.id + '-0'); + + ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.ENTER)); + $material.flushInterimElement(); + expect(ctrl.hidden).toBe(true); + + ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.ESCAPE)); + $timeout.flush(); + + expect(ctrl.index).toBe(-1); + expect(ctrl.hidden).toBe(true); + expect(ctrl.activeOption).toBe(null); + expect(input[0].getAttribute('aria-owns')).toBe(null); + expect(input[0].getAttribute('aria-activedescendant')).toBe(null); + }); + + it('should update activeOption when selection is cleared and autoselect is on', function() { + var template = + '' + + ' {{item.display}}' + + ''; + var scope = createScope(); + var element = compile(template, scope); + var ctrl = element.controller('mdAutocomplete'); + var ul = element.find('ul'); + var input = element.find('input'); + // Run our initial flush + $timeout.flush(); + + expect(ctrl.index).toBe(0); + expect(ctrl.hidden).toBe(true); + expect(ctrl.activeOption).toBe(null); + expect(input[0].getAttribute('aria-owns')).toBe(null); + expect(input[0].getAttribute('aria-activedescendant')).toBe(null); + + // Focus the input + ctrl.focus(); + + // Update the scope + element.scope().searchText = 'ba'; + waitForVirtualRepeat(element); + + // Wait for the next tick when the values will be updated + $timeout.flush(); + + var suggestions = ul.find('li'); + expect(suggestions[0].classList).toContain('selected'); + expect(ctrl.activeOption).toBe('md-option-' + ctrl.id + '-0'); + expect(input[0].getAttribute('aria-owns')).toBe('ul-' + ctrl.id); + expect(input[0].getAttribute('aria-activedescendant')).toBe('md-option-' + ctrl.id + '-0'); + + expect(ctrl.hidden).toBe(false); + + ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.ENTER)); + $material.flushInterimElement(); + expect(ctrl.hidden).toBe(true); + + ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.ESCAPE)); + $timeout.flush(); + + expect(ctrl.index).toBe(0); + expect(ctrl.hidden).toBe(true); + expect(ctrl.activeOption).toBe('md-option-' + ctrl.id + '-0'); + expect(input[0].getAttribute('aria-owns')).toBe(null); + expect(input[0].getAttribute('aria-activedescendant')).toBe(null); + }); + + it('should always define the name attribute on the input', function() { + var template = + '' + + ' {{item.display}}' + + ''; + var scope = createScope(); + var element = compile(template, scope); + var ctrl = element.controller('mdAutocomplete'); + var input = element.find('input'); + // Run our initial flush + $timeout.flush(); + + expect(input[0].getAttribute('name')).toBe('input-' + ctrl.id); + }); + + it('should always define the name attribute on the input when floating label is enabled', + function() { + var template = + '' + + ' {{item.display}}' + + ''; + var scope = createScope(); + var element = compile(template, scope); + var ctrl = element.controller('mdAutocomplete'); + var input = element.find('input'); + // Run our initial flush + $timeout.flush(); + + expect(input[0].getAttribute('name')).toBe('fl-input-' + ctrl.id); + }); + + it('should set proper aria values and remove attributes on input when ng-disabled', function() { + var template = + '' + + ' {{item.display}}' + + ''; + var scope = createScope(); + var element = compile(template, scope); + var ctrl = element.controller('mdAutocomplete'); + var input = element.find('input'); + // Run our initial flush + $timeout.flush(); + + expect(input[0].getAttribute('role')).toBe(null); + expect(input[0].getAttribute('aria-autocomplete')).toBe(null); + expect(input[0].getAttribute('aria-owns')).toBe(null); + expect(input[0].getAttribute('aria-haspopup')).toBe('false'); + }); + + it('should set proper aria values and remove attributes on input when disabled', function() { + var template = + '' + + ' {{item.display}}' + + ''; + var scope = createScope(); + var element = compile(template, scope); + var ctrl = element.controller('mdAutocomplete'); + var input = element.find('input'); + // Run our initial flush + $timeout.flush(); + + expect(input[0].getAttribute('role')).toBe(null); + expect(input[0].getAttribute('aria-autocomplete')).toBe(null); + expect(input[0].getAttribute('aria-owns')).toBe(null); + expect(input[0].getAttribute('aria-haspopup')).toBe('false'); + }); + + it('should add IDs to each option', function() { + var template = + '' + + ' {{item.display}}' + + ''; + var scope = createScope(); + var element = compile(template, scope); + var ctrl = element.controller('mdAutocomplete'); + var ul = element.find('ul'); + + // Focus the input + ctrl.focus(); + + // Update the scope + element.scope().searchText = 'fo'; + waitForVirtualRepeat(element); + + var suggestions = ul.find('li'); + + expect(suggestions[0].getAttribute('id')).toContain('md-option-'); + }); + it('should add the input-aria-label as the input\'s aria-label', function() { var template = '', function() { expect(liveEl.textContent).toBe(scope.items[0].display + ' There are 3 matches available.'); }); - it('should announce the selection when using the arrow keys', function() { + it('should announce when an option is picked', function() { ctrl.focus(); waitForVirtualRepeat(); expect(ctrl.hidden).toBe(false); ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.DOWN_ARROW)); - - // Flush twice, because the display value will be resolved asynchronously and then the live-announcer will - // be triggered. - $timeout.flush(); $timeout.flush(); expect(ctrl.index).toBe(0); - expect(liveEl.textContent).toBe(scope.items[0].display); - - ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.DOWN_ARROW)); - - // Flush twice, because the display value will be resolved asynchronously and then the - // live-announcer will be triggered. - $timeout.flush(); - $timeout.flush(); - - expect(ctrl.index).toBe(1); - expect(liveEl.textContent).toBe(scope.items[1].display); - }); - - it('should announce when an option is selected', function() { - ctrl.focus(); - waitForVirtualRepeat(); - expect(ctrl.hidden).toBe(false); - ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.DOWN_ARROW)); - - // Flush twice, because the display value will be resolved asynchronously and then the - // live-announcer will be triggered. - $timeout.flush(); - $timeout.flush(); - - expect(ctrl.index).toBe(0); - expect(liveEl.textContent).toBe(scope.items[0].display); - ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.ENTER)); // Flush twice, because the display value will be resolved asynchronously and then the @@ -2033,6 +2391,7 @@ describe('', function() { $timeout.flush(); expect(liveEl.textContent).toBe(scope.items[0].display + ' ' + ctrl.selectedMessage); + expect(ctrl.hidden).toBe(true); }); it('should announce the count when matches change', function() { @@ -2133,6 +2492,7 @@ describe('', function() { element.remove(); })); + it('passes the value to the item watcher', inject(function($timeout) { var scope = createScope(); var itemValue = null; diff --git a/src/components/autocomplete/demoBasicUsage/index.html b/src/components/autocomplete/demoBasicUsage/index.html index e5ac536ef2..8bb8cd24d2 100644 --- a/src/components/autocomplete/demoBasicUsage/index.html +++ b/src/components/autocomplete/demoBasicUsage/index.html @@ -15,9 +15,9 @@ md-items="item in ctrl.querySearch(ctrl.searchText)" md-item-text="item.display" md-min-length="0" + md-escape-options="clear" placeholder="Ex. Alaska" - input-aria-labelledby="favoriteStateLabel" - input-aria-describedby="autocompleteDetailedDescription"> + input-aria-labelledby="favoriteStateLabel"> {{item.display}} diff --git a/src/components/autocomplete/demoCustomTemplate/index.html b/src/components/autocomplete/demoCustomTemplate/index.html index 0074a57a81..1540796902 100644 --- a/src/components/autocomplete/demoCustomTemplate/index.html +++ b/src/components/autocomplete/demoCustomTemplate/index.html @@ -13,13 +13,14 @@ md-items="item in ctrl.querySearch(ctrl.searchText)" md-item-text="item.name" md-min-length="0" + md-escape-options="clear" input-aria-label="Current Repository" placeholder="Pick an Angular repository" md-menu-class="autocomplete-custom-template" md-menu-container-class="custom-container"> - + {{item.name}}