Skip to content

Commit aec58a2

Browse files
committed
fixup! fix(material/input): do not override existing aria-describedby value
Address feedback
1 parent b9cdcff commit aec58a2

File tree

9 files changed

+129
-22
lines changed

9 files changed

+129
-22
lines changed

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,10 @@ export class MatFormField implements AfterViewInit, OnDestroy, AfterContentCheck
585585
if (this._control) {
586586
let ids: string[] = [];
587587

588+
if (this._control.userAriaDescribedBy) {
589+
ids.push(...this._control.userAriaDescribedBy.split(' '));
590+
}
591+
588592
if (this._getDisplayedMessages() === 'hint') {
589593
const startHint = this._hintChildren ?
590594
this._hintChildren.find(hint => hint.align === 'start') : null;
@@ -601,7 +605,7 @@ export class MatFormField implements AfterViewInit, OnDestroy, AfterContentCheck
601605
ids.push(endHint.id);
602606
}
603607
} else if (this._errorChildren) {
604-
ids = this._errorChildren.map(error => error.id);
608+
ids.push(...this._errorChildren.map(error => error.id));
605609
}
606610

607611
this._control.setDescribedByIds(ids);

src/material-experimental/mdc-input/input.spec.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,39 @@ describe('MatMdcInput without forms', () => {
481481
expect(input.getAttribute('aria-describedby')).toBe(`initial ${hintId}`);
482482
}));
483483

484+
it('supports user binding to aria-describedby', fakeAsync(() => {
485+
let fixture = createComponent(MatInputWithSubscriptAndAriaDescribedBy);
486+
487+
fixture.componentInstance.label = 'label';
488+
fixture.detectChanges();
489+
490+
const hint = fixture.debugElement.query(By.css('.mat-mdc-form-field-hint'))!.nativeElement;
491+
const input = fixture.debugElement.query(By.css('input'))!.nativeElement;
492+
const hintId = hint.getAttribute('id');
493+
494+
expect(input.getAttribute('aria-describedby')).toBe(hintId);
495+
496+
fixture.componentInstance.userDescribedByValue = 'custom-error custom-error-two';
497+
fixture.detectChanges();
498+
expect(input.getAttribute('aria-describedby')).toBe(`custom-error custom-error-two ${hintId}`);
499+
500+
fixture.componentInstance.userDescribedByValue = 'custom-error';
501+
fixture.detectChanges();
502+
expect(input.getAttribute('aria-describedby')).toBe(`custom-error ${hintId}`);
503+
504+
fixture.componentInstance.showError = true;
505+
fixture.componentInstance.formControl.markAsTouched();
506+
fixture.componentInstance.formControl.setErrors({invalid: true});
507+
fixture.detectChanges();
508+
expect(input.getAttribute('aria-describedby')).toMatch(/^custom-error mat-mdc-error-\d+$/);
509+
510+
fixture.componentInstance.label = '';
511+
fixture.componentInstance.userDescribedByValue = '';
512+
fixture.componentInstance.showError = false;
513+
fixture.detectChanges();
514+
expect(input.hasAttribute('aria-describedby')).toBe(false);
515+
}));
516+
484517
it('sets the aria-describedby to the id of the mat-hint', fakeAsync(() => {
485518
let fixture = createComponent(MatInputHintLabel2TestController);
486519

@@ -1263,6 +1296,20 @@ class MatInputHintLabelTestController {
12631296
label: string = '';
12641297
}
12651298

1299+
@Component({
1300+
template: `
1301+
<mat-form-field [hintLabel]="label">
1302+
<input matInput [formControl]="formControl" [aria-describedby]="userDescribedByValue">
1303+
<mat-error *ngIf="showError">Some error</mat-error>
1304+
</mat-form-field>`
1305+
})
1306+
class MatInputWithSubscriptAndAriaDescribedBy {
1307+
label: string = '';
1308+
userDescribedByValue: string = '';
1309+
showError = false;
1310+
formControl = new FormControl();
1311+
}
1312+
12661313
@Component({template: `<mat-form-field><input matInput [type]="t"></mat-form-field>`})
12671314
class MatInputInvalidTypeTestController {
12681315
t = 'file';

src/material-experimental/mdc-input/input.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ import {MatInput as BaseMatInput} from '@angular/material/input';
3333
'[required]': 'required',
3434
'[attr.placeholder]': 'placeholder',
3535
'[attr.readonly]': 'readonly && !_isNativeSelect || null',
36-
'[attr.aria-describedby]': '_ariaDescribedby || null',
3736
'[attr.aria-invalid]': 'errorState',
3837
'[attr.aria-required]': 'required.toString()',
3938
},

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ export abstract class MatFormFieldControl<T> {
6363
*/
6464
readonly autofilled?: boolean;
6565

66+
/**
67+
* Value of `aria-describedby` that should be merged with the described-by ids
68+
* which are set by the form-field.
69+
*/
70+
readonly userAriaDescribedBy?: string;
71+
6672
/** Sets the list of element IDs that currently describe this control. */
6773
abstract setDescribedByIds(ids: string[]): void;
6874

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,10 @@ export class MatFormField extends _MatFormFieldMixinBase
506506
if (this._control) {
507507
let ids: string[] = [];
508508

509+
if (this._control.userAriaDescribedBy) {
510+
ids.push(...this._control.userAriaDescribedBy.split(' '));
511+
}
512+
509513
if (this._getDisplayedMessages() === 'hint') {
510514
const startHint = this._hintChildren ?
511515
this._hintChildren.find(hint => hint.align === 'start') : null;
@@ -522,7 +526,7 @@ export class MatFormField extends _MatFormFieldMixinBase
522526
ids.push(endHint.id);
523527
}
524528
} else if (this._errorChildren) {
525-
ids = this._errorChildren.map(error => error.id);
529+
ids.push(...this._errorChildren.map(error => error.id));
526530
}
527531

528532
this._control.setDescribedByIds(ids);

src/material/input/input.spec.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,39 @@ describe('MatInput without forms', () => {
571571
expect(input.getAttribute('aria-describedby')).toBe(`initial ${hintId}`);
572572
}));
573573

574+
it('supports user binding to aria-describedby', fakeAsync(() => {
575+
let fixture = createComponent(MatInputWithSubscriptAndAriaDescribedBy);
576+
577+
fixture.componentInstance.label = 'label';
578+
fixture.detectChanges();
579+
580+
const hint = fixture.debugElement.query(By.css('.mat-hint'))!.nativeElement;
581+
const input = fixture.debugElement.query(By.css('input'))!.nativeElement;
582+
const hintId = hint.getAttribute('id');
583+
584+
expect(input.getAttribute('aria-describedby')).toBe(hintId);
585+
586+
fixture.componentInstance.userDescribedByValue = 'custom-error custom-error-two';
587+
fixture.detectChanges();
588+
expect(input.getAttribute('aria-describedby')).toBe(`custom-error custom-error-two ${hintId}`);
589+
590+
fixture.componentInstance.userDescribedByValue = 'custom-error';
591+
fixture.detectChanges();
592+
expect(input.getAttribute('aria-describedby')).toBe(`custom-error ${hintId}`);
593+
594+
fixture.componentInstance.showError = true;
595+
fixture.componentInstance.formControl.markAsTouched();
596+
fixture.componentInstance.formControl.setErrors({invalid: true});
597+
fixture.detectChanges();
598+
expect(input.getAttribute('aria-describedby')).toMatch(/^custom-error mat-error-\d+$/);
599+
600+
fixture.componentInstance.label = '';
601+
fixture.componentInstance.userDescribedByValue = '';
602+
fixture.componentInstance.showError = false;
603+
fixture.detectChanges();
604+
expect(input.hasAttribute('aria-describedby')).toBe(false);
605+
}));
606+
574607
it('sets the aria-describedby to the id of the mat-hint', fakeAsync(() => {
575608
let fixture = createComponent(MatInputHintLabel2TestController);
576609

@@ -1777,6 +1810,20 @@ class MatInputHintLabelTestController {
17771810
label: string = '';
17781811
}
17791812

1813+
@Component({
1814+
template: `
1815+
<mat-form-field [hintLabel]="label">
1816+
<input matInput [formControl]="formControl" [aria-describedby]="userDescribedByValue">
1817+
<mat-error *ngIf="showError">Some error</mat-error>
1818+
</mat-form-field>`
1819+
})
1820+
class MatInputWithSubscriptAndAriaDescribedBy {
1821+
label: string = '';
1822+
userDescribedByValue: string = '';
1823+
showError = false;
1824+
formControl = new FormControl();
1825+
}
1826+
17801827
@Component({template: `<mat-form-field><input matInput [type]="t"></mat-form-field>`})
17811828
class MatInputInvalidTypeTestController {
17821829
t = 'file';

src/material/input/input.ts

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,18 @@ import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion';
1010
import {getSupportedInputTypes, Platform} from '@angular/cdk/platform';
1111
import {AutofillMonitor} from '@angular/cdk/text-field';
1212
import {
13-
Attribute,
13+
AfterViewInit,
1414
Directive,
1515
DoCheck,
1616
ElementRef,
17+
HostListener,
1718
Inject,
1819
Input,
1920
NgZone,
2021
OnChanges,
2122
OnDestroy,
2223
Optional,
2324
Self,
24-
HostListener,
25-
AfterViewInit,
2625
} from '@angular/core';
2726
import {FormGroupDirective, NgControl, NgForm} from '@angular/forms';
2827
import {
@@ -85,7 +84,6 @@ const _MatInputMixinBase: CanUpdateErrorStateCtor & typeof MatInputBase =
8584
'[disabled]': 'disabled',
8685
'[required]': 'required',
8786
'[attr.readonly]': 'readonly && !_isNativeSelect || null',
88-
'[attr.aria-describedby]': '_ariaDescribedby || null',
8987
'[attr.aria-invalid]': 'errorState',
9088
'[attr.aria-required]': 'required.toString()',
9189
},
@@ -98,9 +96,6 @@ export class MatInput extends _MatInputMixinBase implements MatFormFieldControl<
9896
private _inputValueAccessor: {value: any};
9997
private _previousPlaceholder: string | null;
10098

101-
/** The aria-describedby attribute on the input for improved a11y. */
102-
_ariaDescribedby: string;
103-
10499
/** Whether the component is being rendered on the server. */
105100
readonly _isServer: boolean;
106101

@@ -200,6 +195,12 @@ export class MatInput extends _MatInputMixinBase implements MatFormFieldControl<
200195
/** An object used to control when error messages are shown. */
201196
@Input() errorStateMatcher: ErrorStateMatcher;
202197

198+
/**
199+
* Implemented as part of MatFormFieldControl.
200+
* @docs-private
201+
*/
202+
@Input('aria-describedby') userAriaDescribedBy: string;
203+
203204
/**
204205
* Implemented as part of MatFormFieldControl.
205206
* @docs-private
@@ -241,8 +242,8 @@ export class MatInput extends _MatInputMixinBase implements MatFormFieldControl<
241242
ngZone: NgZone,
242243
// TODO: Remove this once the legacy appearance has been removed. We only need
243244
// to inject the form-field for determining whether the placeholder has been promoted.
244-
@Optional() @Inject(MAT_FORM_FIELD) private _formField?: MatFormField,
245-
@Attribute('aria-describedby') private _initialAriaDescribedBy?: string|null) {
245+
@Optional() @Inject(MAT_FORM_FIELD) private _formField?: MatFormField) {
246+
246247
super(_defaultErrorStateMatcher, _parentForm, _parentFormGroup, ngControl);
247248

248249
const element = this._elementRef.nativeElement;
@@ -441,13 +442,11 @@ export class MatInput extends _MatInputMixinBase implements MatFormFieldControl<
441442
* @docs-private
442443
*/
443444
setDescribedByIds(ids: string[]) {
444-
let value = ids.join(' ');
445-
// Append the describe-by ids from the form-field without discarding the initial
446-
// `aria-describedby` value that has been specified as static attribute.
447-
if (this._initialAriaDescribedBy != null) {
448-
value = `${this._initialAriaDescribedBy} ${value}`;
445+
if (ids.length) {
446+
this._elementRef.nativeElement.setAttribute('aria-describedby', ids.join(' '));
447+
} else {
448+
this._elementRef.nativeElement.removeAttribute('aria-describedby');
449449
}
450-
this._ariaDescribedby = value;
451450
}
452451

453452
/**

tools/public_api_guard/material/form-field.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export declare abstract class MatFormFieldControl<T> {
9696
readonly required: boolean;
9797
readonly shouldLabelFloat: boolean;
9898
readonly stateChanges: Observable<void>;
99+
readonly userAriaDescribedBy?: string;
99100
value: T | null;
100101
abstract onContainerClick(event: MouseEvent): void;
101102
abstract setDescribedByIds(ids: string[]): void;

tools/public_api_guard/material/input.d.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ export declare const MAT_INPUT_VALUE_ACCESSOR: InjectionToken<{
55
}>;
66

77
export declare class MatInput extends _MatInputMixinBase implements MatFormFieldControl<any>, OnChanges, OnDestroy, AfterViewInit, DoCheck, CanUpdateErrorState {
8-
_ariaDescribedby: string;
98
protected _disabled: boolean;
109
protected _elementRef: ElementRef<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>;
1110
protected _id: string;
@@ -37,10 +36,11 @@ export declare class MatInput extends _MatInputMixinBase implements MatFormField
3736
readonly stateChanges: Subject<void>;
3837
get type(): string;
3938
set type(value: string);
39+
userAriaDescribedBy: string;
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, _formField?: MatFormField | undefined, _initialAriaDescribedBy?: string | null | undefined);
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;
4646
protected _isBadInput(): boolean;
@@ -58,8 +58,8 @@ export declare class MatInput extends _MatInputMixinBase implements MatFormField
5858
static ngAcceptInputType_readonly: BooleanInput;
5959
static ngAcceptInputType_required: BooleanInput;
6060
static ngAcceptInputType_value: any;
61-
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, { optional: true; }, { attribute: "aria-describedby"; }]>;
61+
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"; "userAriaDescribedBy": "aria-describedby"; "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, { optional: true; }]>;
6363
}
6464

6565
export declare class MatInputModule {

0 commit comments

Comments
 (0)