Skip to content

fix(input): hints not being read out by screen readers #2856

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 1, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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(' ');
}
}