From 41ad382c6346eb63ce31e4c31f6c5c310d5aacaa Mon Sep 17 00:00:00 2001 From: Kara Date: Wed, 16 Nov 2016 16:02:28 -0800 Subject: [PATCH] fix(select): add aria-owns property (#1898) --- src/lib/select/option.ts | 12 ++++ src/lib/select/select.spec.ts | 104 +++++++++++++++++++++++++++++++--- src/lib/select/select.ts | 34 +++++++---- 3 files changed, 131 insertions(+), 19 deletions(-) diff --git a/src/lib/select/option.ts b/src/lib/select/option.ts index 6e1dec65da0b..b629daca62b3 100644 --- a/src/lib/select/option.ts +++ b/src/lib/select/option.ts @@ -10,6 +10,12 @@ import { import {ENTER, SPACE} from '../core/keyboard/keycodes'; import {coerceBooleanProperty} from '../core/coersion/boolean-property'; +/** + * Option IDs need to be unique across components, so this counter exists outside of + * the component definition. + */ +let _uniqueIdCounter = 0; + @Component({ moduleId: module.id, selector: 'md-option', @@ -17,6 +23,7 @@ import {coerceBooleanProperty} from '../core/coersion/boolean-property'; 'role': 'option', '[attr.tabindex]': '_getTabIndex()', '[class.md-selected]': 'selected', + '[id]': 'id', '[attr.aria-selected]': 'selected.toString()', '[attr.aria-disabled]': 'disabled.toString()', '[class.md-option-disabled]': 'disabled', @@ -33,6 +40,11 @@ export class MdOption { /** Whether the option is disabled. */ private _disabled: boolean = false; + private _id: string = `md-select-option-${_uniqueIdCounter++}`; + + /** The unique ID of the option. */ + get id() { return this._id; } + /** The form value of the option. */ @Input() value: any; diff --git a/src/lib/select/select.spec.ts b/src/lib/select/select.spec.ts index 542a7753daf6..3e05ceffe0fa 100644 --- a/src/lib/select/select.spec.ts +++ b/src/lib/select/select.spec.ts @@ -1,6 +1,6 @@ import {TestBed, async, ComponentFixture} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; -import {Component, QueryList, ViewChild, ViewChildren} from '@angular/core'; +import {Component, DebugElement, QueryList, ViewChild, ViewChildren} from '@angular/core'; import {MdSelectModule} from './index'; import {OverlayContainer} from '../core/overlay/overlay-container'; import {MdSelect} from './select'; @@ -16,7 +16,7 @@ describe('MdSelect', () => { beforeEach(async(() => { TestBed.configureTestingModule({ imports: [MdSelectModule.forRoot(), ReactiveFormsModule, FormsModule], - declarations: [BasicSelect, NgModelSelect], + declarations: [BasicSelect, NgModelSelect, ManySelects], providers: [ {provide: OverlayContainer, useFactory: () => { overlayContainerElement = document.createElement('div'); @@ -547,17 +547,14 @@ describe('MdSelect', () => { }); describe('accessibility', () => { - let fixture: ComponentFixture; - - beforeEach(() => { - fixture = TestBed.createComponent(BasicSelect); - fixture.detectChanges(); - }); describe('for select', () => { + let fixture: ComponentFixture; let select: HTMLElement; beforeEach(() => { + fixture = TestBed.createComponent(BasicSelect); + fixture.detectChanges(); select = fixture.debugElement.query(By.css('md-select')).nativeElement; }); @@ -614,14 +611,16 @@ describe('MdSelect', () => { expect(select.getAttribute('tabindex')).toEqual('0'); }); - }); describe('for options', () => { + let fixture: ComponentFixture; let trigger: HTMLElement; let options: NodeListOf; beforeEach(() => { + fixture = TestBed.createComponent(BasicSelect); + fixture.detectChanges(); trigger = fixture.debugElement.query(By.css('.md-select-trigger')).nativeElement; trigger.click(); fixture.detectChanges(); @@ -673,6 +672,78 @@ describe('MdSelect', () => { }); + describe('aria-owns', () => { + let fixture: ComponentFixture; + let triggers: DebugElement[]; + let options: NodeListOf; + + beforeEach(() => { + fixture = TestBed.createComponent(ManySelects); + fixture.detectChanges(); + triggers = fixture.debugElement.queryAll(By.css('.md-select-trigger')); + + triggers[0].nativeElement.click(); + fixture.detectChanges(); + + options = + overlayContainerElement.querySelectorAll('md-option') as NodeListOf; + }); + + it('should set aria-owns properly', async(() => { + const selects = fixture.debugElement.queryAll(By.css('md-select')); + + expect(selects[0].nativeElement.getAttribute('aria-owns')) + .toContain(options[0].id, `Expected aria-owns to contain IDs of its child options.`); + expect(selects[0].nativeElement.getAttribute('aria-owns')) + .toContain(options[1].id, `Expected aria-owns to contain IDs of its child options.`); + + const backdrop = + overlayContainerElement.querySelector('.md-overlay-backdrop') as HTMLElement; + backdrop.click(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + triggers[1].nativeElement.click(); + + fixture.detectChanges(); + options = + overlayContainerElement.querySelectorAll('md-option') as NodeListOf; + expect(selects[1].nativeElement.getAttribute('aria-owns')) + .toContain(options[0].id, `Expected aria-owns to contain IDs of its child options.`); + expect(selects[1].nativeElement.getAttribute('aria-owns')) + .toContain(options[1].id, `Expected aria-owns to contain IDs of its child options.`); + }); + + })); + + it('should set the option id properly', async(() => { + let firstOptionID = options[0].id; + + expect(options[0].id) + .toContain('md-select-option', `Expected option ID to have the correct prefix.`); + expect(options[0].id).not.toEqual(options[1].id, `Expected option IDs to be unique.`); + + const backdrop = + overlayContainerElement.querySelector('.md-overlay-backdrop') as HTMLElement; + backdrop.click(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + triggers[1].nativeElement.click(); + + fixture.detectChanges(); + options = + overlayContainerElement.querySelectorAll('md-option') as NodeListOf; + expect(options[0].id) + .toContain('md-select-option', `Expected option ID to have the correct prefix.`); + expect(options[0].id).not.toEqual(firstOptionID, `Expected option IDs to be unique.`); + expect(options[0].id).not.toEqual(options[1].id, `Expected option IDs to be unique.`); + }); + + })); + + }); + }); }); @@ -720,6 +791,21 @@ class NgModelSelect { @ViewChildren(MdOption) options: QueryList; } +@Component({ + selector: 'many-selects', + template: ` + + one + two + + + three + four + + ` +}) +class ManySelects {} + /** * TODO: Move this to core testing utility until Angular has event faking diff --git a/src/lib/select/select.ts b/src/lib/select/select.ts index 6f3b762b5e5f..19ae9817c9da 100644 --- a/src/lib/select/select.ts +++ b/src/lib/select/select.ts @@ -35,8 +35,9 @@ import {ConnectedOverlayPositionChange} from '../core/overlay/position/connected '[attr.aria-label]': 'placeholder', '[attr.aria-required]': 'required.toString()', '[attr.aria-disabled]': 'disabled.toString()', - '[class.md-select-disabled]': 'disabled', '[attr.aria-invalid]': '_control?.invalid || "false"', + '[attr.aria-owns]': '_optionIds', + '[class.md-select-disabled]': 'disabled', '(keydown)': '_handleKeydown($event)', '(blur)': '_onBlur()' }, @@ -76,7 +77,10 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr _onChange: (value: any) => void; /** View -> model callback called when select has been touched */ - _onTouched: Function; + _onTouched = () => {}; + + /** The IDs of child options to be passed to the aria-owns attribute. */ + _optionIds: string = ''; /** The value of the select panel's transform-origin property. */ _transformOrigin: string = 'top'; @@ -130,17 +134,15 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr constructor(private _element: ElementRef, private _renderer: Renderer, @Optional() private _dir: Dir, @Optional() public _control: NgControl) { - this._control.valueAccessor = this; + if (this._control) { + this._control.valueAccessor = this; + } } ngAfterContentInit() { this._initKeyManager(); - this._listenToOptions(); - - this._changeSubscription = this.options.changes.subscribe(() => { - this._dropSubscriptions(); - this._listenToOptions(); - }); + this._resetOptions(); + this._changeSubscription = this.options.changes.subscribe(() => this._resetOptions()); } ngOnDestroy() { @@ -196,7 +198,7 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr * by the user. Part of the ControlValueAccessor interface required * to integrate with Angular's core forms API. */ - registerOnTouched(fn: Function): void { + registerOnTouched(fn: () => {}): void { this._onTouched = fn; } @@ -294,6 +296,13 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr }); } + /** Drops current option subscriptions and IDs and resets from scratch. */ + private _resetOptions(): void { + this._dropSubscriptions(); + this._listenToOptions(); + this._setOptionIds(); + } + /** Listens to selection events on each option. */ private _listenToOptions(): void { this.options.forEach((option: MdOption) => { @@ -313,6 +322,11 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr this._subscriptions = []; } + /** Records option IDs to pass to the aria-owns property. */ + private _setOptionIds() { + this._optionIds = this.options.map(option => option.id).join(' '); + } + /** When a new option is selected, deselects the others and closes the panel. */ private _onSelect(option: MdOption): void { this._selected = option;