Skip to content

Commit 41ad382

Browse files
authored
fix(select): add aria-owns property (#1898)
1 parent 70efee5 commit 41ad382

File tree

3 files changed

+131
-19
lines changed

3 files changed

+131
-19
lines changed

src/lib/select/option.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,20 @@ import {
1010
import {ENTER, SPACE} from '../core/keyboard/keycodes';
1111
import {coerceBooleanProperty} from '../core/coersion/boolean-property';
1212

13+
/**
14+
* Option IDs need to be unique across components, so this counter exists outside of
15+
* the component definition.
16+
*/
17+
let _uniqueIdCounter = 0;
18+
1319
@Component({
1420
moduleId: module.id,
1521
selector: 'md-option',
1622
host: {
1723
'role': 'option',
1824
'[attr.tabindex]': '_getTabIndex()',
1925
'[class.md-selected]': 'selected',
26+
'[id]': 'id',
2027
'[attr.aria-selected]': 'selected.toString()',
2128
'[attr.aria-disabled]': 'disabled.toString()',
2229
'[class.md-option-disabled]': 'disabled',
@@ -33,6 +40,11 @@ export class MdOption {
3340
/** Whether the option is disabled. */
3441
private _disabled: boolean = false;
3542

43+
private _id: string = `md-select-option-${_uniqueIdCounter++}`;
44+
45+
/** The unique ID of the option. */
46+
get id() { return this._id; }
47+
3648
/** The form value of the option. */
3749
@Input() value: any;
3850

src/lib/select/select.spec.ts

Lines changed: 95 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {TestBed, async, ComponentFixture} from '@angular/core/testing';
22
import {By} from '@angular/platform-browser';
3-
import {Component, QueryList, ViewChild, ViewChildren} from '@angular/core';
3+
import {Component, DebugElement, QueryList, ViewChild, ViewChildren} from '@angular/core';
44
import {MdSelectModule} from './index';
55
import {OverlayContainer} from '../core/overlay/overlay-container';
66
import {MdSelect} from './select';
@@ -16,7 +16,7 @@ describe('MdSelect', () => {
1616
beforeEach(async(() => {
1717
TestBed.configureTestingModule({
1818
imports: [MdSelectModule.forRoot(), ReactiveFormsModule, FormsModule],
19-
declarations: [BasicSelect, NgModelSelect],
19+
declarations: [BasicSelect, NgModelSelect, ManySelects],
2020
providers: [
2121
{provide: OverlayContainer, useFactory: () => {
2222
overlayContainerElement = document.createElement('div');
@@ -547,17 +547,14 @@ describe('MdSelect', () => {
547547
});
548548

549549
describe('accessibility', () => {
550-
let fixture: ComponentFixture<BasicSelect>;
551-
552-
beforeEach(() => {
553-
fixture = TestBed.createComponent(BasicSelect);
554-
fixture.detectChanges();
555-
});
556550

557551
describe('for select', () => {
552+
let fixture: ComponentFixture<BasicSelect>;
558553
let select: HTMLElement;
559554

560555
beforeEach(() => {
556+
fixture = TestBed.createComponent(BasicSelect);
557+
fixture.detectChanges();
561558
select = fixture.debugElement.query(By.css('md-select')).nativeElement;
562559
});
563560

@@ -614,14 +611,16 @@ describe('MdSelect', () => {
614611
expect(select.getAttribute('tabindex')).toEqual('0');
615612
});
616613

617-
618614
});
619615

620616
describe('for options', () => {
617+
let fixture: ComponentFixture<BasicSelect>;
621618
let trigger: HTMLElement;
622619
let options: NodeListOf<HTMLElement>;
623620

624621
beforeEach(() => {
622+
fixture = TestBed.createComponent(BasicSelect);
623+
fixture.detectChanges();
625624
trigger = fixture.debugElement.query(By.css('.md-select-trigger')).nativeElement;
626625
trigger.click();
627626
fixture.detectChanges();
@@ -673,6 +672,78 @@ describe('MdSelect', () => {
673672

674673
});
675674

675+
describe('aria-owns', () => {
676+
let fixture: ComponentFixture<ManySelects>;
677+
let triggers: DebugElement[];
678+
let options: NodeListOf<HTMLElement>;
679+
680+
beforeEach(() => {
681+
fixture = TestBed.createComponent(ManySelects);
682+
fixture.detectChanges();
683+
triggers = fixture.debugElement.queryAll(By.css('.md-select-trigger'));
684+
685+
triggers[0].nativeElement.click();
686+
fixture.detectChanges();
687+
688+
options =
689+
overlayContainerElement.querySelectorAll('md-option') as NodeListOf<HTMLElement>;
690+
});
691+
692+
it('should set aria-owns properly', async(() => {
693+
const selects = fixture.debugElement.queryAll(By.css('md-select'));
694+
695+
expect(selects[0].nativeElement.getAttribute('aria-owns'))
696+
.toContain(options[0].id, `Expected aria-owns to contain IDs of its child options.`);
697+
expect(selects[0].nativeElement.getAttribute('aria-owns'))
698+
.toContain(options[1].id, `Expected aria-owns to contain IDs of its child options.`);
699+
700+
const backdrop =
701+
overlayContainerElement.querySelector('.md-overlay-backdrop') as HTMLElement;
702+
backdrop.click();
703+
fixture.detectChanges();
704+
705+
fixture.whenStable().then(() => {
706+
triggers[1].nativeElement.click();
707+
708+
fixture.detectChanges();
709+
options =
710+
overlayContainerElement.querySelectorAll('md-option') as NodeListOf<HTMLElement>;
711+
expect(selects[1].nativeElement.getAttribute('aria-owns'))
712+
.toContain(options[0].id, `Expected aria-owns to contain IDs of its child options.`);
713+
expect(selects[1].nativeElement.getAttribute('aria-owns'))
714+
.toContain(options[1].id, `Expected aria-owns to contain IDs of its child options.`);
715+
});
716+
717+
}));
718+
719+
it('should set the option id properly', async(() => {
720+
let firstOptionID = options[0].id;
721+
722+
expect(options[0].id)
723+
.toContain('md-select-option', `Expected option ID to have the correct prefix.`);
724+
expect(options[0].id).not.toEqual(options[1].id, `Expected option IDs to be unique.`);
725+
726+
const backdrop =
727+
overlayContainerElement.querySelector('.md-overlay-backdrop') as HTMLElement;
728+
backdrop.click();
729+
fixture.detectChanges();
730+
731+
fixture.whenStable().then(() => {
732+
triggers[1].nativeElement.click();
733+
734+
fixture.detectChanges();
735+
options =
736+
overlayContainerElement.querySelectorAll('md-option') as NodeListOf<HTMLElement>;
737+
expect(options[0].id)
738+
.toContain('md-select-option', `Expected option ID to have the correct prefix.`);
739+
expect(options[0].id).not.toEqual(firstOptionID, `Expected option IDs to be unique.`);
740+
expect(options[0].id).not.toEqual(options[1].id, `Expected option IDs to be unique.`);
741+
});
742+
743+
}));
744+
745+
});
746+
676747
});
677748

678749
});
@@ -720,6 +791,21 @@ class NgModelSelect {
720791
@ViewChildren(MdOption) options: QueryList<MdOption>;
721792
}
722793

794+
@Component({
795+
selector: 'many-selects',
796+
template: `
797+
<md-select placeholder="First">
798+
<md-option value="one">one</md-option>
799+
<md-option value="two">two</md-option>
800+
</md-select>
801+
<md-select placeholder="Second">
802+
<md-option value="three">three</md-option>
803+
<md-option value="four">four</md-option>
804+
</md-select>
805+
`
806+
})
807+
class ManySelects {}
808+
723809

724810
/**
725811
* TODO: Move this to core testing utility until Angular has event faking

src/lib/select/select.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,9 @@ import {ConnectedOverlayPositionChange} from '../core/overlay/position/connected
3535
'[attr.aria-label]': 'placeholder',
3636
'[attr.aria-required]': 'required.toString()',
3737
'[attr.aria-disabled]': 'disabled.toString()',
38-
'[class.md-select-disabled]': 'disabled',
3938
'[attr.aria-invalid]': '_control?.invalid || "false"',
39+
'[attr.aria-owns]': '_optionIds',
40+
'[class.md-select-disabled]': 'disabled',
4041
'(keydown)': '_handleKeydown($event)',
4142
'(blur)': '_onBlur()'
4243
},
@@ -76,7 +77,10 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
7677
_onChange: (value: any) => void;
7778

7879
/** View -> model callback called when select has been touched */
79-
_onTouched: Function;
80+
_onTouched = () => {};
81+
82+
/** The IDs of child options to be passed to the aria-owns attribute. */
83+
_optionIds: string = '';
8084

8185
/** The value of the select panel's transform-origin property. */
8286
_transformOrigin: string = 'top';
@@ -130,17 +134,15 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
130134

131135
constructor(private _element: ElementRef, private _renderer: Renderer,
132136
@Optional() private _dir: Dir, @Optional() public _control: NgControl) {
133-
this._control.valueAccessor = this;
137+
if (this._control) {
138+
this._control.valueAccessor = this;
139+
}
134140
}
135141

136142
ngAfterContentInit() {
137143
this._initKeyManager();
138-
this._listenToOptions();
139-
140-
this._changeSubscription = this.options.changes.subscribe(() => {
141-
this._dropSubscriptions();
142-
this._listenToOptions();
143-
});
144+
this._resetOptions();
145+
this._changeSubscription = this.options.changes.subscribe(() => this._resetOptions());
144146
}
145147

146148
ngOnDestroy() {
@@ -196,7 +198,7 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
196198
* by the user. Part of the ControlValueAccessor interface required
197199
* to integrate with Angular's core forms API.
198200
*/
199-
registerOnTouched(fn: Function): void {
201+
registerOnTouched(fn: () => {}): void {
200202
this._onTouched = fn;
201203
}
202204

@@ -294,6 +296,13 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
294296
});
295297
}
296298

299+
/** Drops current option subscriptions and IDs and resets from scratch. */
300+
private _resetOptions(): void {
301+
this._dropSubscriptions();
302+
this._listenToOptions();
303+
this._setOptionIds();
304+
}
305+
297306
/** Listens to selection events on each option. */
298307
private _listenToOptions(): void {
299308
this.options.forEach((option: MdOption) => {
@@ -313,6 +322,11 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
313322
this._subscriptions = [];
314323
}
315324

325+
/** Records option IDs to pass to the aria-owns property. */
326+
private _setOptionIds() {
327+
this._optionIds = this.options.map(option => option.id).join(' ');
328+
}
329+
316330
/** When a new option is selected, deselects the others and closes the panel. */
317331
private _onSelect(option: MdOption): void {
318332
this._selected = option;

0 commit comments

Comments
 (0)