Skip to content

Commit d05ba60

Browse files
authored
fix(input): placeholder being read out twice by some screen readers (#10466)
Currently we hide the native placeholder using CSS while we mirror its value in the form field label, however because the value is still in the DOM, some screen readers will read it out twice: once for the label and once for the attribute. These changes remove the attribute from the DOM if it isn't supposed to be shown. Fixes #9721.
1 parent 4a3c960 commit d05ba60

File tree

7 files changed

+69
-19
lines changed

7 files changed

+69
-19
lines changed

goldens/size-test.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
cdk/drag-drop/all-directives: 154096
22
cdk/drag-drop/basic: 151353
33
material-experimental/mdc-chips/basic: 147607
4-
material-experimental/mdc-form-field/advanced: 220897
5-
material-experimental/mdc-form-field/basic: 220060
4+
material-experimental/mdc-form-field/advanced: 251959
5+
material-experimental/mdc-form-field/basic: 251127
66
material/autocomplete/without-optgroup: 210028
77
material/button-toggle/standalone: 119486
88
material/chips/basic: 162776

src/material/chips/chip-list.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ export class MatChipList extends _MatChipListMixinBase implements MatFormFieldCo
250250
* @docs-private
251251
*/
252252
get empty(): boolean {
253-
return (!this._chipInput || this._chipInput.empty) && this.chips.length === 0;
253+
return (!this._chipInput || this._chipInput.empty) && (!this.chips || this.chips.length === 0);
254254
}
255255

256256
/**
@@ -801,7 +801,7 @@ export class MatChipList extends _MatChipListMixinBase implements MatFormFieldCo
801801

802802
/** Checks whether any of the chips is focused. */
803803
private _hasFocusedChip() {
804-
return this.chips.some(chip => chip._hasFocus);
804+
return this.chips && this.chips.some(chip => chip._hasFocus);
805805
}
806806

807807
/** Syncs the list's state with the individual chips. */

src/material/form-field/form-field.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,8 @@ export class MatFormField extends _MatFormFieldMixinBase
411411
}
412412

413413
_shouldLabelFloat() {
414-
return this._canLabelFloat && (this._control.shouldLabelFloat || this._shouldAlwaysFloat);
414+
return this._canLabelFloat &&
415+
((this._control && this._control.shouldLabelFloat) || this._shouldAlwaysFloat);
415416
}
416417

417418
_hideControlPlaceholder() {

src/material/input/input.spec.ts

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -367,21 +367,30 @@ describe('MatInput without forms', () => {
367367
let fixture = createComponent(MatInputPlaceholderAttrTestComponent);
368368
fixture.detectChanges();
369369

370-
let inputEl = fixture.debugElement.query(By.css('input'))!.nativeElement;
371-
372370
expect(fixture.debugElement.query(By.css('label'))).toBeNull();
373-
expect(inputEl.placeholder).toBe('');
374371

375372
fixture.componentInstance.placeholder = 'Other placeholder';
376373
fixture.detectChanges();
377374

378375
let labelEl = fixture.debugElement.query(By.css('label'))!;
379376

380-
expect(inputEl.placeholder).toBe('Other placeholder');
381377
expect(labelEl).not.toBeNull();
382378
expect(labelEl.nativeElement.textContent).toBe('Other placeholder');
383379
}));
384380

381+
it('should not render the native placeholder when its value is mirrored in the label',
382+
fakeAsync(() => {
383+
const fixture = createComponent(MatInputPlaceholderAttrTestComponent);
384+
fixture.componentInstance.placeholder = 'Enter a name';
385+
fixture.detectChanges();
386+
387+
const inputEl = fixture.debugElement.query(By.css('input')).nativeElement;
388+
const labelEl = fixture.debugElement.query(By.css('label'));
389+
390+
expect(inputEl.hasAttribute('placeholder')).toBe(false);
391+
expect(labelEl.nativeElement.textContent).toContain('Enter a name');
392+
}));
393+
385394
it('supports placeholder element', fakeAsync(() => {
386395
let fixture = createComponent(MatInputPlaceholderElementTestComponent);
387396
fixture.detectChanges();
@@ -911,8 +920,8 @@ describe('MatInput without forms', () => {
911920

912921
expect(container.classList).toContain('mat-form-field-hide-placeholder');
913922
expect(container.classList).not.toContain('mat-form-field-should-float');
914-
expect(label.textContent).toBe('Label');
915-
expect(input.getAttribute('placeholder')).toBe('Placeholder');
923+
expect(label.textContent.trim()).toBe('Label');
924+
expect(input.hasAttribute('placeholder')).toBe(false);
916925

917926
input.value = 'Value';
918927
fixture.detectChanges();
@@ -981,6 +990,12 @@ describe('MatInput without forms', () => {
981990
expect(label.classList).not.toContain('mat-form-field-empty');
982991
}));
983992

993+
it('should not throw when there is a default ngIf on the label element', fakeAsync(() => {
994+
expect(() => {
995+
createComponent(MatInputWithDefaultNgIf).detectChanges();
996+
}).not.toThrow();
997+
}));
998+
984999
});
9851000

9861001
describe('MatInput with forms', () => {
@@ -2221,3 +2236,18 @@ class CustomMatInputAccessor {
22212236
set value(_value: any) {}
22222237
private _value = null;
22232238
}
2239+
2240+
2241+
// Note that the DOM structure is slightly weird, but it's
2242+
// testing a specific g3 issue. See the discussion on #10466.
2243+
@Component({
2244+
template: `
2245+
<mat-form-field appearance="outline">
2246+
<mat-label *ngIf="true">My Label</mat-label>
2247+
<ng-container *ngIf="true">
2248+
<input matInput>
2249+
</ng-container>
2250+
</mat-form-field>
2251+
`
2252+
})
2253+
class MatInputWithDefaultNgIf {}

src/material/input/input.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
ErrorStateMatcher,
3131
mixinErrorState,
3232
} from '@angular/material/core';
33-
import {MatFormFieldControl} from '@angular/material/form-field';
33+
import {MatFormFieldControl, MatFormField} from '@angular/material/form-field';
3434
import {Subject} from 'rxjs';
3535
import {getMatInputUnsupportedTypeError} from './input-errors';
3636
import {MAT_INPUT_VALUE_ACCESSOR} from './input-value-accessor';
@@ -77,7 +77,10 @@ const _MatInputMixinBase: CanUpdateErrorStateCtor & typeof MatInputBase =
7777
// Native input properties that are overwritten by Angular inputs need to be synced with
7878
// the native input element. Otherwise property bindings for those don't work.
7979
'[attr.id]': 'id',
80-
'[attr.placeholder]': 'placeholder',
80+
'[attr.placeholder]': '_getPlaceholderAttribute()',
81+
// At the time of writing, we have a lot of customer tests that look up the input based on its
82+
// placeholder. Since we sometimes omit the placeholder attribute from the DOM to prevent screen
83+
// readers from reading it twice, we have to keep it somewhere in the DOM for the lookup.
8184
'[attr.data-placeholder]': 'placeholder',
8285
'[disabled]': 'disabled',
8386
'[required]': 'required',
@@ -233,8 +236,9 @@ export class MatInput extends _MatInputMixinBase implements MatFormFieldControl<
233236
_defaultErrorStateMatcher: ErrorStateMatcher,
234237
@Optional() @Self() @Inject(MAT_INPUT_VALUE_ACCESSOR) inputValueAccessor: any,
235238
private _autofillMonitor: AutofillMonitor,
236-
ngZone: NgZone) {
237-
239+
ngZone: NgZone,
240+
// @breaking-change 8.0.0 `_formField` parameter to be made required.
241+
@Optional() private _formField?: MatFormField) {
238242
super(_defaultErrorStateMatcher, _parentForm, _parentFormGroup, ngControl);
239243

240244
const element = this._elementRef.nativeElement;
@@ -350,6 +354,16 @@ export class MatInput extends _MatInputMixinBase implements MatFormFieldControl<
350354
// FormsModule or ReactiveFormsModule, because Angular forms also listens to input events.
351355
}
352356

357+
/** Determines the value of the native `placeholder` attribute that should be used in the DOM. */
358+
_getPlaceholderAttribute() {
359+
// If we're hiding the native placeholder, it should also be cleared from the DOM, otherwise
360+
// screen readers will read it out twice: once from the label and once from the attribute.
361+
// TODO: can be removed once we get rid of the `legacy` style for the form field, because it's
362+
// the only one that supports promoting the placeholder to a label.
363+
const formField = this._formField;
364+
return (!formField || !formField._hideControlPlaceholder()) ? this.placeholder : undefined;
365+
}
366+
353367
/** Does some manual dirty checking on the native input `value` property. */
354368
protected _dirtyCheckNativeValue() {
355369
const newValue = this._elementRef.nativeElement.value;

src/material/input/testing/input-harness.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,12 @@ export class MatInputHarness extends MatFormFieldControlHarness {
7171

7272
/** Gets the placeholder of the input. */
7373
async getPlaceholder(): Promise<string> {
74-
// The "placeholder" property of the native input is never undefined.
75-
return (await (await this.host()).getProperty('placeholder'))!;
74+
const host = await this.host();
75+
const [nativePlaceholder, fallback] = await Promise.all([
76+
host.getProperty('placeholder'),
77+
host.getAttribute('data-placeholder')
78+
]);
79+
return nativePlaceholder || fallback || '';
7680
}
7781

7882
/** Gets the id of the input. */

tools/public_api_guard/material/input.d.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,10 @@ export declare class MatInput extends _MatInputMixinBase implements MatFormField
4040
get value(): string;
4141
set value(value: string);
4242
constructor(_elementRef: ElementRef<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>, _platform: Platform,
43-
ngControl: NgControl, _parentForm: NgForm, _parentFormGroup: FormGroupDirective, _defaultErrorStateMatcher: ErrorStateMatcher, inputValueAccessor: any, _autofillMonitor: AutofillMonitor, ngZone: NgZone);
43+
ngControl: NgControl, _parentForm: NgForm, _parentFormGroup: FormGroupDirective, _defaultErrorStateMatcher: ErrorStateMatcher, inputValueAccessor: any, _autofillMonitor: AutofillMonitor, ngZone: NgZone, _formField?: MatFormField | undefined);
4444
protected _dirtyCheckNativeValue(): void;
4545
_focusChanged(isFocused: boolean): void;
46+
_getPlaceholderAttribute(): string | undefined;
4647
protected _isBadInput(): boolean;
4748
protected _isNeverEmpty(): boolean;
4849
_onInput(): void;
@@ -59,7 +60,7 @@ export declare class MatInput extends _MatInputMixinBase implements MatFormField
5960
static ngAcceptInputType_required: BooleanInput;
6061
static ngAcceptInputType_value: any;
6162
static ɵdir: i0.ɵɵDirectiveDefWithMeta<MatInput, "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", ["matInput"], { "disabled": "disabled"; "id": "id"; "placeholder": "placeholder"; "required": "required"; "type": "type"; "errorStateMatcher": "errorStateMatcher"; "value": "value"; "readonly": "readonly"; }, {}, never>;
62-
static ɵfac: i0.ɵɵFactoryDef<MatInput, [null, null, { optional: true; self: true; }, { optional: true; }, { optional: true; }, null, { optional: true; self: true; }, null, null]>;
63+
static ɵfac: i0.ɵɵFactoryDef<MatInput, [null, null, { optional: true; self: true; }, { optional: true; }, { optional: true; }, null, { optional: true; self: true; }, null, null, { optional: true; }]>;
6364
}
6465

6566
export declare class MatInputModule {

0 commit comments

Comments
 (0)