Skip to content

Commit

Permalink
fix(select): add aria-owns property (#1898)
Browse files Browse the repository at this point in the history
  • Loading branch information
kara authored Nov 17, 2016
1 parent 70efee5 commit 41ad382
Show file tree
Hide file tree
Showing 3 changed files with 131 additions and 19 deletions.
12 changes: 12 additions & 0 deletions src/lib/select/option.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,20 @@ 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',
host: {
'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',
Expand All @@ -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;

Expand Down
104 changes: 95 additions & 9 deletions src/lib/select/select.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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');
Expand Down Expand Up @@ -547,17 +547,14 @@ describe('MdSelect', () => {
});

describe('accessibility', () => {
let fixture: ComponentFixture<BasicSelect>;

beforeEach(() => {
fixture = TestBed.createComponent(BasicSelect);
fixture.detectChanges();
});

describe('for select', () => {
let fixture: ComponentFixture<BasicSelect>;
let select: HTMLElement;

beforeEach(() => {
fixture = TestBed.createComponent(BasicSelect);
fixture.detectChanges();
select = fixture.debugElement.query(By.css('md-select')).nativeElement;
});

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


});

describe('for options', () => {
let fixture: ComponentFixture<BasicSelect>;
let trigger: HTMLElement;
let options: NodeListOf<HTMLElement>;

beforeEach(() => {
fixture = TestBed.createComponent(BasicSelect);
fixture.detectChanges();
trigger = fixture.debugElement.query(By.css('.md-select-trigger')).nativeElement;
trigger.click();
fixture.detectChanges();
Expand Down Expand Up @@ -673,6 +672,78 @@ describe('MdSelect', () => {

});

describe('aria-owns', () => {
let fixture: ComponentFixture<ManySelects>;
let triggers: DebugElement[];
let options: NodeListOf<HTMLElement>;

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<HTMLElement>;
});

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<HTMLElement>;
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<HTMLElement>;
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.`);
});

}));

});

});

});
Expand Down Expand Up @@ -720,6 +791,21 @@ class NgModelSelect {
@ViewChildren(MdOption) options: QueryList<MdOption>;
}

@Component({
selector: 'many-selects',
template: `
<md-select placeholder="First">
<md-option value="one">one</md-option>
<md-option value="two">two</md-option>
</md-select>
<md-select placeholder="Second">
<md-option value="three">three</md-option>
<md-option value="four">four</md-option>
</md-select>
`
})
class ManySelects {}


/**
* TODO: Move this to core testing utility until Angular has event faking
Expand Down
34 changes: 24 additions & 10 deletions src/lib/select/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()'
},
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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) => {
Expand All @@ -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;
Expand Down

0 comments on commit 41ad382

Please sign in to comment.