From f899b5f0b28389f7c48aac3b34be2bda99cd43ef Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 1 Feb 2017 04:21:14 +0100 Subject: [PATCH] fix(input): hints not being read out by screen readers (#2856) Fixes #2798. --- src/lib/input/input-container.html | 2 +- src/lib/input/input-container.spec.ts | 97 ++++++++++++++++++++++++++- src/lib/input/input-container.ts | 53 +++++++++++++-- 3 files changed, 146 insertions(+), 6 deletions(-) diff --git a/src/lib/input/input-container.html b/src/lib/input/input-container.html index 9601f7d86505..50b76067a9c2 100644 --- a/src/lib/input/input-container.html +++ b/src/lib/input/input-container.html @@ -30,6 +30,6 @@ [class.md-warn]="dividerColor == 'warn'"> -
{{hintLabel}}
+
{{hintLabel}}
diff --git a/src/lib/input/input-container.spec.ts b/src/lib/input/input-container.spec.ts index 9ad0cb1ea51a..317a290a1793 100644 --- a/src/lib/input/input-container.spec.ts +++ b/src/lib/input/input-container.spec.ts @@ -46,7 +46,9 @@ describe('MdInputContainer', function () { MdInputContainerWithValueBinding, MdInputContainerWithFormControl, MdInputContainerWithStaticPlaceholder, - MdInputContainerMissingMdInputTestController + MdInputContainerMissingMdInputTestController, + MdInputContainerMultipleHintTestController, + MdInputContainerMultipleHintMixedTestController ], }); @@ -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(); @@ -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(); @@ -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({ @@ -512,6 +585,28 @@ class MdInputContainerInvalidHint2TestController {} }) class MdInputContainerInvalidHintTestController {} +@Component({ + template: ` + + + Hello + World + ` +}) +class MdInputContainerMultipleHintTestController { + startId: string; + endId: string; +} + +@Component({ + template: ` + + + World + ` +}) +class MdInputContainerMultipleHintMixedTestController {} + @Component({ template: `` }) diff --git a/src/lib/input/input-container.ts b/src/lib/input/input-container.ts index 5444848934d4..fabe76933612 100644 --- a/src/lib/input/input-container.ts +++ b/src/lib/input/input-container.ts @@ -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++}`; } @@ -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 { @@ -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() { @@ -119,6 +127,7 @@ export class MdInputDirective { this._placeholderChange.emit(this._placeholder); } } + /** Whether the element is required. */ @Input() get required() { return this._required; } @@ -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; } @@ -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()); } @@ -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(); } /** @@ -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 `` alignment specified, with the * attribute being considered as `align="start"`. @@ -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(' '); + } }