From bd7f2400bdbc36fda2636174d59d4868e3d2c887 Mon Sep 17 00:00:00 2001 From: Kara Date: Mon, 23 Jan 2017 12:22:20 -0800 Subject: [PATCH] feat(autocomplete): add screenreader support (#2729) --- src/lib/autocomplete/autocomplete-trigger.ts | 8 +- src/lib/autocomplete/autocomplete.html | 2 +- src/lib/autocomplete/autocomplete.spec.ts | 90 ++++++++++++++++++++ src/lib/autocomplete/autocomplete.ts | 9 ++ 4 files changed, 107 insertions(+), 2 deletions(-) diff --git a/src/lib/autocomplete/autocomplete-trigger.ts b/src/lib/autocomplete/autocomplete-trigger.ts index 4e87b4eea70b..586e882ee5fb 100644 --- a/src/lib/autocomplete/autocomplete-trigger.ts +++ b/src/lib/autocomplete/autocomplete-trigger.ts @@ -21,9 +21,15 @@ export const MD_AUTOCOMPLETE_PANEL_OFFSET = 6; @Directive({ selector: 'input[mdAutocomplete], input[matAutocomplete]', host: { + 'role': 'combobox', + 'autocomplete': 'off', + 'aria-autocomplete': 'list', + 'aria-multiline': 'false', + '[attr.aria-activedescendant]': 'activeOption?.id', + '[attr.aria-expanded]': 'panelOpen.toString()', + '[attr.aria-owns]': 'autocomplete?.id', '(focus)': 'openPanel()', '(keydown)': '_handleKeydown($event)', - 'autocomplete': 'off' } }) export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy { diff --git a/src/lib/autocomplete/autocomplete.html b/src/lib/autocomplete/autocomplete.html index 97727158af0c..84b73b818e56 100644 --- a/src/lib/autocomplete/autocomplete.html +++ b/src/lib/autocomplete/autocomplete.html @@ -1,5 +1,5 @@ \ No newline at end of file diff --git a/src/lib/autocomplete/autocomplete.spec.ts b/src/lib/autocomplete/autocomplete.spec.ts index c2d540df343d..a431cfce3092 100644 --- a/src/lib/autocomplete/autocomplete.spec.ts +++ b/src/lib/autocomplete/autocomplete.spec.ts @@ -391,6 +391,96 @@ describe('MdAutocomplete', () => { }); + describe('aria', () => { + let fixture: ComponentFixture; + let input: HTMLInputElement; + + beforeEach(() => { + fixture = TestBed.createComponent(SimpleAutocomplete); + fixture.detectChanges(); + + input = fixture.debugElement.query(By.css('input')).nativeElement; + }); + + it('should set role of input to combobox', () => { + expect(input.getAttribute('role')) + .toEqual('combobox', 'Expected role of input to be combobox.'); + }); + + it('should set role of autocomplete panel to listbox', () => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + const panel = fixture.debugElement.query(By.css('.md-autocomplete-panel')).nativeElement; + + expect(panel.getAttribute('role')) + .toEqual('listbox', 'Expected role of the panel to be listbox.'); + }); + + it('should set aria-autocomplete to list', () => { + expect(input.getAttribute('aria-autocomplete')) + .toEqual('list', 'Expected aria-autocomplete attribute to equal list.'); + }); + + it('should set aria-multiline to false', () => { + expect(input.getAttribute('aria-multiline')) + .toEqual('false', 'Expected aria-multiline attribute to equal false.'); + }); + + it('should set aria-activedescendant based on the active option', () => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + expect(input.hasAttribute('aria-activedescendant')) + .toBe(false, 'Expected aria-activedescendant to be absent if no active item.'); + + const DOWN_ARROW_EVENT = new FakeKeyboardEvent(DOWN_ARROW) as KeyboardEvent; + fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); + fixture.detectChanges(); + + expect(input.getAttribute('aria-activedescendant')) + .toEqual(fixture.componentInstance.options.first.id, + 'Expected aria-activedescendant to match the active item after 1 down arrow.'); + + fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); + fixture.detectChanges(); + + expect(input.getAttribute('aria-activedescendant')) + .toEqual(fixture.componentInstance.options.toArray()[1].id, + 'Expected aria-activedescendant to match the active item after 2 down arrows.'); + }); + + it('should set aria-expanded based on whether the panel is open', async(() => { + expect(input.getAttribute('aria-expanded')) + .toBe('false', 'Expected aria-expanded to be false while panel is closed.'); + + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + expect(input.getAttribute('aria-expanded')) + .toBe('true', 'Expected aria-expanded to be true while panel is open.'); + + fixture.componentInstance.trigger.closePanel(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(input.getAttribute('aria-expanded')) + .toBe('false', 'Expected aria-expanded to be false when panel closes again.'); + }); + })); + + it('should set aria-owns based on the attached autocomplete', () => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + const panel = fixture.debugElement.query(By.css('.md-autocomplete-panel')).nativeElement; + + expect(input.getAttribute('aria-owns')) + .toEqual(panel.getAttribute('id'), 'Expected aria-owns to match attached autocomplete.'); + }); + + }); + }); @Component({ diff --git a/src/lib/autocomplete/autocomplete.ts b/src/lib/autocomplete/autocomplete.ts index bb2abbca20b4..85f5df0f870b 100644 --- a/src/lib/autocomplete/autocomplete.ts +++ b/src/lib/autocomplete/autocomplete.ts @@ -8,6 +8,12 @@ import { } from '@angular/core'; import {MdOption} from '../core'; +/** + * Autocomplete IDs need to be unique across components, so this counter exists outside of + * the component definition. + */ +let _uniqueAutocompleteIdCounter = 0; + @Component({ moduleId: module.id, selector: 'md-autocomplete, mat-autocomplete', @@ -20,5 +26,8 @@ export class MdAutocomplete { @ViewChild(TemplateRef) template: TemplateRef; @ContentChildren(MdOption) options: QueryList; + + /** Unique ID to be used by autocomplete trigger's "aria-owns" property. */ + id: string = `md-autocomplete-${_uniqueAutocompleteIdCounter++}`; }