Skip to content

Commit f899b5f

Browse files
crisbetokara
authored andcommitted
fix(input): hints not being read out by screen readers (#2856)
Fixes #2798.
1 parent d11673a commit f899b5f

File tree

3 files changed

+146
-6
lines changed

3 files changed

+146
-6
lines changed

src/lib/input/input-container.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,6 @@
3030
[class.md-warn]="dividerColor == 'warn'"></span>
3131
</div>
3232

33-
<div *ngIf="hintLabel != ''" class="md-hint">{{hintLabel}}</div>
33+
<div *ngIf="hintLabel != ''" [attr.id]="_hintLabelId" class="md-hint">{{hintLabel}}</div>
3434
<ng-content select="md-hint"></ng-content>
3535
</div>

src/lib/input/input-container.spec.ts

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ describe('MdInputContainer', function () {
4646
MdInputContainerWithValueBinding,
4747
MdInputContainerWithFormControl,
4848
MdInputContainerWithStaticPlaceholder,
49-
MdInputContainerMissingMdInputTestController
49+
MdInputContainerMissingMdInputTestController,
50+
MdInputContainerMultipleHintTestController,
51+
MdInputContainerMultipleHintMixedTestController
5052
],
5153
});
5254

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

276+
it('sets an id on hint labels', () => {
277+
let fixture = TestBed.createComponent(MdInputContainerHintLabelTestController);
278+
279+
fixture.componentInstance.label = 'label';
280+
fixture.detectChanges();
281+
282+
let hint = fixture.debugElement.query(By.css('.md-hint')).nativeElement;
283+
284+
expect(hint.getAttribute('id')).toBeTruthy();
285+
});
286+
274287
it('supports hint labels elements', () => {
275288
let fixture = TestBed.createComponent(MdInputContainerHintLabel2TestController);
276289
fixture.detectChanges();
@@ -285,6 +298,17 @@ describe('MdInputContainer', function () {
285298
expect(el.textContent).toBe('label');
286299
});
287300

301+
it('sets an id on the hint element', () => {
302+
let fixture = TestBed.createComponent(MdInputContainerHintLabel2TestController);
303+
304+
fixture.componentInstance.label = 'label';
305+
fixture.detectChanges();
306+
307+
let hint = fixture.debugElement.query(By.css('md-hint')).nativeElement;
308+
309+
expect(hint.getAttribute('id')).toBeTruthy();
310+
});
311+
288312
it('supports placeholder attribute', async(() => {
289313
let fixture = TestBed.createComponent(MdInputContainerPlaceholderAttrTestComponent);
290314
fixture.detectChanges();
@@ -404,6 +428,55 @@ describe('MdInputContainer', function () {
404428
const textarea: HTMLTextAreaElement = fixture.nativeElement.querySelector('textarea');
405429
expect(textarea).not.toBeNull();
406430
});
431+
432+
it('sets the aria-describedby when a hintLabel is set', () => {
433+
let fixture = TestBed.createComponent(MdInputContainerHintLabelTestController);
434+
435+
fixture.componentInstance.label = 'label';
436+
fixture.detectChanges();
437+
438+
let hint = fixture.debugElement.query(By.css('.md-hint')).nativeElement;
439+
let input = fixture.debugElement.query(By.css('input')).nativeElement;
440+
441+
expect(input.getAttribute('aria-describedby')).toBe(hint.getAttribute('id'));
442+
});
443+
444+
it('sets the aria-describedby to the id of the md-hint', () => {
445+
let fixture = TestBed.createComponent(MdInputContainerHintLabel2TestController);
446+
447+
fixture.componentInstance.label = 'label';
448+
fixture.detectChanges();
449+
450+
let hint = fixture.debugElement.query(By.css('.md-hint')).nativeElement;
451+
let input = fixture.debugElement.query(By.css('input')).nativeElement;
452+
453+
expect(input.getAttribute('aria-describedby')).toBe(hint.getAttribute('id'));
454+
});
455+
456+
it('sets the aria-describedby with multiple md-hint instances', () => {
457+
let fixture = TestBed.createComponent(MdInputContainerMultipleHintTestController);
458+
459+
fixture.componentInstance.startId = 'start';
460+
fixture.componentInstance.endId = 'end';
461+
fixture.detectChanges();
462+
463+
let input = fixture.debugElement.query(By.css('input')).nativeElement;
464+
465+
expect(input.getAttribute('aria-describedby')).toBe('start end');
466+
});
467+
468+
it('sets the aria-describedby when a hintLabel is set, in addition to a md-hint', () => {
469+
let fixture = TestBed.createComponent(MdInputContainerMultipleHintMixedTestController);
470+
471+
fixture.detectChanges();
472+
473+
let hintLabel = fixture.debugElement.query(By.css('.md-hint')).nativeElement;
474+
let endLabel = fixture.debugElement.query(By.css('.md-hint[align="end"]')).nativeElement;
475+
let input = fixture.debugElement.query(By.css('input')).nativeElement;
476+
let ariaValue = input.getAttribute('aria-describedby');
477+
478+
expect(ariaValue).toBe(`${hintLabel.getAttribute('id')} ${endLabel.getAttribute('id')}`);
479+
});
407480
});
408481

409482
@Component({
@@ -512,6 +585,28 @@ class MdInputContainerInvalidHint2TestController {}
512585
})
513586
class MdInputContainerInvalidHintTestController {}
514587

588+
@Component({
589+
template: `
590+
<md-input-container>
591+
<input mdInput>
592+
<md-hint align="start" [id]="startId">Hello</md-hint>
593+
<md-hint align="end" [id]="endId">World</md-hint>
594+
</md-input-container>`
595+
})
596+
class MdInputContainerMultipleHintTestController {
597+
startId: string;
598+
endId: string;
599+
}
600+
601+
@Component({
602+
template: `
603+
<md-input-container hintLabel="Hello">
604+
<input mdInput>
605+
<md-hint align="end">World</md-hint>
606+
</md-input-container>`
607+
})
608+
class MdInputContainerMultipleHintMixedTestController {}
609+
515610
@Component({
516611
template: `<md-input-container><input mdInput [(ngModel)]="model"></md-input-container>`
517612
})

src/lib/input/input-container.ts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,15 @@ export class MdPlaceholder {}
5858
host: {
5959
'class': 'md-hint',
6060
'[class.md-right]': 'align == "end"',
61+
'[attr.id]': 'id',
6162
}
6263
})
6364
export class MdHint {
6465
// Whether to align the hint label at the start or end of the line.
6566
@Input() align: 'start' | 'end' = 'start';
67+
68+
// Unique ID for the hint. Used for the aria-describedby on the input.
69+
@Input() id: string = `md-input-hint-${nextUniqueId++}`;
6670
}
6771

6872

@@ -77,9 +81,10 @@ export class MdHint {
7781
'[placeholder]': 'placeholder',
7882
'[disabled]': 'disabled',
7983
'[required]': 'required',
84+
'[attr.aria-describedby]': 'ariaDescribedby',
8085
'(blur)': '_onBlur()',
8186
'(focus)': '_onFocus()',
82-
'(input)': '_onInput()'
87+
'(input)': '_onInput()',
8388
}
8489
})
8590
export class MdInputDirective {
@@ -95,6 +100,9 @@ export class MdInputDirective {
95100
/** Whether the element is focused or not. */
96101
focused = false;
97102

103+
/** Sets the aria-describedby attribute on the input for improved a11y. */
104+
ariaDescribedby: string;
105+
98106
/** Whether the element is disabled. */
99107
@Input()
100108
get disabled() {
@@ -119,6 +127,7 @@ export class MdInputDirective {
119127
this._placeholderChange.emit(this._placeholder);
120128
}
121129
}
130+
122131
/** Whether the element is required. */
123132
@Input()
124133
get required() { return this._required; }
@@ -249,10 +258,13 @@ export class MdInputContainer implements AfterContentInit {
249258
get hintLabel() { return this._hintLabel; }
250259
set hintLabel(value: string) {
251260
this._hintLabel = value;
252-
this._validateHints();
261+
this._processHints();
253262
}
254263
private _hintLabel = '';
255264

265+
// Unique id for the hint label.
266+
_hintLabelId: string = `md-input-hint-${nextUniqueId++}`;
267+
256268
/** Text or the floating placeholder. */
257269
@Input()
258270
get floatingPlaceholder(): boolean { return this._floatingPlaceholder; }
@@ -270,11 +282,11 @@ export class MdInputContainer implements AfterContentInit {
270282
throw new MdInputContainerMissingMdInputError();
271283
}
272284

273-
this._validateHints();
285+
this._processHints();
274286
this._validatePlaceholders();
275287

276288
// Re-validate when things change.
277-
this._hintChildren.changes.subscribe(() => this._validateHints());
289+
this._hintChildren.changes.subscribe(() => this._processHints());
278290
this._mdInputChild._placeholderChange.subscribe(() => this._validatePlaceholders());
279291
}
280292

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

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

292305
/**
@@ -299,6 +312,14 @@ export class MdInputContainer implements AfterContentInit {
299312
}
300313
}
301314

315+
/**
316+
* Does any extra processing that is required when handling the hints.
317+
*/
318+
private _processHints() {
319+
this._validateHints();
320+
this._syncAriaDescribedby();
321+
}
322+
302323
/**
303324
* Ensure that there is a maximum of one of each `<md-hint>` alignment specified, with the
304325
* attribute being considered as `align="start"`.
@@ -322,4 +343,28 @@ export class MdInputContainer implements AfterContentInit {
322343
});
323344
}
324345
}
346+
347+
/**
348+
* Sets the child input's `aria-describedby` to a space-separated list of the ids
349+
* of the currently-specified hints, as well as a generated id for the hint label.
350+
*/
351+
private _syncAriaDescribedby() {
352+
let ids: string[] = [];
353+
let startHint = this._hintChildren ?
354+
this._hintChildren.find(hint => hint.align === 'start') : null;
355+
let endHint = this._hintChildren ?
356+
this._hintChildren.find(hint => hint.align === 'end') : null;
357+
358+
if (startHint) {
359+
ids.push(startHint.id);
360+
} else if (this._hintLabel) {
361+
ids.push(this._hintLabelId);
362+
}
363+
364+
if (endHint) {
365+
ids.push(endHint.id);
366+
}
367+
368+
this._mdInputChild.ariaDescribedby = ids.join(' ');
369+
}
325370
}

0 commit comments

Comments
 (0)