Skip to content

Commit

Permalink
fix(input): hints not being read out by screen readers
Browse files Browse the repository at this point in the history
Adds the `aria-describedby` to inputs within `md-input-container`, which allows screen readers to pick them up and present them to the user.

Fixes angular#2798.
  • Loading branch information
crisbeto committed Jan 29, 2017
1 parent 3b6cab0 commit 7dbee19
Show file tree
Hide file tree
Showing 3 changed files with 146 additions and 6 deletions.
2 changes: 1 addition & 1 deletion src/lib/input/input-container.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,6 @@
[class.md-warn]="dividerColor == 'warn'"></span>
</div>

<div *ngIf="hintLabel != ''" class="md-hint">{{hintLabel}}</div>
<div *ngIf="hintLabel != ''" [attr.id]="_hintLabelId" class="md-hint">{{hintLabel}}</div>
<ng-content select="md-hint"></ng-content>
</div>
97 changes: 96 additions & 1 deletion src/lib/input/input-container.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ describe('MdInputContainer', function () {
MdInputContainerWithValueBinding,
MdInputContainerWithFormControl,
MdInputContainerWithStaticPlaceholder,
MdInputContainerMissingMdInputTestController
MdInputContainerMissingMdInputTestController,
MdInputContainerMultipleHintTestController,
MdInputContainerMultipleHintMixedTestController
],
});

Expand Down Expand Up @@ -271,6 +273,17 @@ describe('MdInputContainer', function () {
expect(fixture.debugElement.query(By.css('.md-hint'))).not.toBeNull();
});

it('sets an id on hint labels', () => {
let fixture = TestBed.createComponent(MdInputContainerHintLabelTestController);

fixture.componentInstance.label = 'label';
fixture.detectChanges();

let hint = fixture.debugElement.query(By.css('.md-hint')).nativeElement;

expect(hint.getAttribute('id')).toBeTruthy();
});

it('supports hint labels elements', () => {
let fixture = TestBed.createComponent(MdInputContainerHintLabel2TestController);
fixture.detectChanges();
Expand All @@ -285,6 +298,17 @@ describe('MdInputContainer', function () {
expect(el.textContent).toBe('label');
});

it('sets an id on the hint element', () => {
let fixture = TestBed.createComponent(MdInputContainerHintLabel2TestController);

fixture.componentInstance.label = 'label';
fixture.detectChanges();

let hint = fixture.debugElement.query(By.css('md-hint')).nativeElement;

expect(hint.getAttribute('id')).toBeTruthy();
});

it('supports placeholder attribute', async(() => {
let fixture = TestBed.createComponent(MdInputContainerPlaceholderAttrTestComponent);
fixture.detectChanges();
Expand Down Expand Up @@ -404,6 +428,55 @@ describe('MdInputContainer', function () {
const textarea: HTMLTextAreaElement = fixture.nativeElement.querySelector('textarea');
expect(textarea).not.toBeNull();
});

it('sets the aria-describedby when a hintLabel is set', () => {
let fixture = TestBed.createComponent(MdInputContainerHintLabelTestController);

fixture.componentInstance.label = 'label';
fixture.detectChanges();

let hint = fixture.debugElement.query(By.css('.md-hint')).nativeElement;
let input = fixture.debugElement.query(By.css('input')).nativeElement;

expect(input.getAttribute('aria-describedby')).toBe(hint.getAttribute('id'));
});

it('sets the aria-describedby to the id of the md-hint', () => {
let fixture = TestBed.createComponent(MdInputContainerHintLabel2TestController);

fixture.componentInstance.label = 'label';
fixture.detectChanges();

let hint = fixture.debugElement.query(By.css('.md-hint')).nativeElement;
let input = fixture.debugElement.query(By.css('input')).nativeElement;

expect(input.getAttribute('aria-describedby')).toBe(hint.getAttribute('id'));
});

it('sets the aria-describedby with multiple md-hint instances', () => {
let fixture = TestBed.createComponent(MdInputContainerMultipleHintTestController);

fixture.componentInstance.startId = 'start';
fixture.componentInstance.endId = 'end';
fixture.detectChanges();

let input = fixture.debugElement.query(By.css('input')).nativeElement;

expect(input.getAttribute('aria-describedby')).toBe('start end');
});

it('sets the aria-describedby when a hintLabel is set, in addition to a md-hint', () => {
let fixture = TestBed.createComponent(MdInputContainerMultipleHintMixedTestController);

fixture.detectChanges();

let hintLabel = fixture.debugElement.query(By.css('.md-hint')).nativeElement;
let endLabel = fixture.debugElement.query(By.css('.md-hint[align="end"]')).nativeElement;
let input = fixture.debugElement.query(By.css('input')).nativeElement;
let ariaValue = input.getAttribute('aria-describedby');

expect(ariaValue).toBe(`${hintLabel.getAttribute('id')} ${endLabel.getAttribute('id')}`);
});
});

@Component({
Expand Down Expand Up @@ -512,6 +585,28 @@ class MdInputContainerInvalidHint2TestController {}
})
class MdInputContainerInvalidHintTestController {}

@Component({
template: `
<md-input-container>
<input mdInput>
<md-hint align="start" [id]="startId">Hello</md-hint>
<md-hint align="end" [id]="endId">World</md-hint>
</md-input-container>`
})
class MdInputContainerMultipleHintTestController {
startId: string;
endId: string;
}

@Component({
template: `
<md-input-container hintLabel="Hello">
<input mdInput>
<md-hint align="end">World</md-hint>
</md-input-container>`
})
class MdInputContainerMultipleHintMixedTestController {}

@Component({
template: `<md-input-container><input mdInput [(ngModel)]="model"></md-input-container>`
})
Expand Down
53 changes: 49 additions & 4 deletions src/lib/input/input-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,15 @@ export class MdPlaceholder {}
host: {
'class': 'md-hint',
'[class.md-right]': 'align == "end"',
'[attr.id]': 'id',
}
})
export class MdHint {
// Whether to align the hint label at the start or end of the line.
@Input() align: 'start' | 'end' = 'start';

// Unique ID for the hint. Used for the aria-describedby on the input.
@Input() id: string = `md-input-hint-${nextUniqueId++}`;
}


Expand All @@ -77,9 +81,10 @@ export class MdHint {
'[placeholder]': 'placeholder',
'[disabled]': 'disabled',
'[required]': 'required',
'[attr.aria-describedby]': 'ariaDescribedby',
'(blur)': '_onBlur()',
'(focus)': '_onFocus()',
'(input)': '_onInput()'
'(input)': '_onInput()',
}
})
export class MdInputDirective {
Expand All @@ -95,6 +100,9 @@ export class MdInputDirective {
/** Whether the element is focused or not. */
focused = false;

/** Sets the aria-describedby attribute on the input for improved a11y. */
ariaDescribedby: string;

/** Whether the element is disabled. */
@Input()
get disabled() {
Expand All @@ -119,6 +127,7 @@ export class MdInputDirective {
this._placeholderChange.emit(this._placeholder);
}
}

/** Whether the element is required. */
@Input()
get required() { return this._required; }
Expand Down Expand Up @@ -249,10 +258,13 @@ export class MdInputContainer implements AfterContentInit {
get hintLabel() { return this._hintLabel; }
set hintLabel(value: string) {
this._hintLabel = value;
this._validateHints();
this._processHints();
}
private _hintLabel = '';

// Unique id for the hint label.
_hintLabelId: string = `md-input-hint-${nextUniqueId++}`;

/** Text or the floating placeholder. */
@Input()
get floatingPlaceholder(): boolean { return this._floatingPlaceholder; }
Expand All @@ -270,11 +282,11 @@ export class MdInputContainer implements AfterContentInit {
throw new MdInputContainerMissingMdInputError();
}

this._validateHints();
this._processHints();
this._validatePlaceholders();

// Re-validate when things change.
this._hintChildren.changes.subscribe(() => this._validateHints());
this._hintChildren.changes.subscribe(() => this._processHints());
this._mdInputChild._placeholderChange.subscribe(() => this._validatePlaceholders());
}

Expand All @@ -287,6 +299,7 @@ export class MdInputContainer implements AfterContentInit {
/** Whether the input has a placeholder. */
_hasPlaceholder() { return !!(this._mdInputChild.placeholder || this._placeholderChild); }

/** Focuses the underlying input. */
_focusInput() { this._mdInputChild.focus(); }

/**
Expand All @@ -299,6 +312,14 @@ export class MdInputContainer implements AfterContentInit {
}
}

/**
* Does any extra processing that is required when handling the hints.
*/
private _processHints() {
this._validateHints();
this._syncAriaDescribedby();
}

/**
* Ensure that there is a maximum of one of each `<md-hint>` alignment specified, with the
* attribute being considered as `align="start"`.
Expand All @@ -322,4 +343,28 @@ export class MdInputContainer implements AfterContentInit {
});
}
}

/**
* Sets the child input's `aria-describedby` to a space-separated list of the ids
* of the currently-specified hints, as well as a generated id for the hint label.
*/
private _syncAriaDescribedby() {
let ids: string[] = [];
let startHint = this._hintChildren ?
this._hintChildren.find(hint => hint.align === 'start') : null;
let endHint = this._hintChildren ?
this._hintChildren.find(hint => hint.align === 'end') : null;

if (startHint) {
ids.push(startHint.id);
} else if (this._hintLabel) {
ids.push(this._hintLabelId);
}

if (endHint) {
ids.push(endHint.id);
}

this._mdInputChild.ariaDescribedby = ids.join(' ');
}
}

0 comments on commit 7dbee19

Please sign in to comment.