Skip to content

Commit 57ce77d

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

File tree

9 files changed

+128
-22
lines changed

9 files changed

+128
-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
@@ -577,6 +577,10 @@ export class MatFormField implements AfterViewInit, OnDestroy, AfterContentCheck
577577
if (this._control) {
578578
let ids: string[] = [];
579579

580+
if (this._control.userAriaDescribedBy) {
581+
ids.push(...this._control.userAriaDescribedBy.split(' '));
582+
}
583+
580584
if (this._getDisplayedMessages() === 'hint') {
581585
const startHint = this._hintChildren ?
582586
this._hintChildren.find(hint => hint.align === 'start') : null;
@@ -593,7 +597,7 @@ export class MatFormField implements AfterViewInit, OnDestroy, AfterContentCheck
593597
ids.push(endHint.id);
594598
}
595599
} else if (this._errorChildren) {
596-
ids = this._errorChildren.map(error => error.id);
600+
ids.push(...this._errorChildren.map(error => error.id));
597601
}
598602

599603
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

@@ -1246,6 +1279,20 @@ class MatInputHintLabelTestController {
12461279
label: string = '';
12471280
}
12481281

1282+
@Component({
1283+
template: `
1284+
<mat-form-field [hintLabel]="label">
1285+
<input matInput [formControl]="formControl" [aria-describedby]="userDescribedByValue">
1286+
<mat-error *ngIf="showError">Some error</mat-error>
1287+
</mat-form-field>`
1288+
})
1289+
class MatInputWithSubscriptAndAriaDescribedBy {
1290+
label: string = '';
1291+
userDescribedByValue: string = '';
1292+
showError = false;
1293+
formControl = new FormControl();
1294+
}
1295+
12491296
@Component({template: `<mat-form-field><input matInput [type]="t"></mat-form-field>`})
12501297
class MatInputInvalidTypeTestController {
12511298
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
@@ -497,6 +497,10 @@ export class MatFormField extends _MatFormFieldMixinBase
497497
if (this._control) {
498498
let ids: string[] = [];
499499

500+
if (this._control.userAriaDescribedBy) {
501+
ids.push(...this._control.userAriaDescribedBy.split(' '));
502+
}
503+
500504
if (this._getDisplayedMessages() === 'hint') {
501505
const startHint = this._hintChildren ?
502506
this._hintChildren.find(hint => hint.align === 'start') : null;
@@ -513,7 +517,7 @@ export class MatFormField extends _MatFormFieldMixinBase
513517
ids.push(endHint.id);
514518
}
515519
} else if (this._errorChildren) {
516-
ids = this._errorChildren.map(error => error.id);
520+
ids.push(...this._errorChildren.map(error => error.id));
517521
}
518522

519523
this._control.setDescribedByIds(ids);

src/material/input/input.spec.ts

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

565+
it('supports user binding to aria-describedby', fakeAsync(() => {
566+
let fixture = createComponent(MatInputWithSubscriptAndAriaDescribedBy);
567+
568+
fixture.componentInstance.label = 'label';
569+
fixture.detectChanges();
570+
571+
const hint = fixture.debugElement.query(By.css('.mat-hint'))!.nativeElement;
572+
const input = fixture.debugElement.query(By.css('input'))!.nativeElement;
573+
const hintId = hint.getAttribute('id');
574+
575+
expect(input.getAttribute('aria-describedby')).toBe(hintId);
576+
577+
fixture.componentInstance.userDescribedByValue = 'custom-error custom-error-two';
578+
fixture.detectChanges();
579+
expect(input.getAttribute('aria-describedby')).toBe(`custom-error custom-error-two ${hintId}`);
580+
581+
fixture.componentInstance.userDescribedByValue = 'custom-error';
582+
fixture.detectChanges();
583+
expect(input.getAttribute('aria-describedby')).toBe(`custom-error ${hintId}`);
584+
585+
fixture.componentInstance.showError = true;
586+
fixture.componentInstance.formControl.markAsTouched();
587+
fixture.componentInstance.formControl.setErrors({invalid: true});
588+
fixture.detectChanges();
589+
expect(input.getAttribute('aria-describedby')).toMatch(/^custom-error mat-error-\d+$/);
590+
591+
fixture.componentInstance.label = '';
592+
fixture.componentInstance.userDescribedByValue = '';
593+
fixture.componentInstance.showError = false;
594+
fixture.detectChanges();
595+
expect(input.hasAttribute('aria-describedby')).toBe(false);
596+
}));
597+
565598
it('sets the aria-describedby to the id of the mat-hint', fakeAsync(() => {
566599
let fixture = createComponent(MatInputHintLabel2TestController);
567600

@@ -1756,6 +1789,20 @@ class MatInputHintLabelTestController {
17561789
label: string = '';
17571790
}
17581791

1792+
@Component({
1793+
template: `
1794+
<mat-form-field [hintLabel]="label">
1795+
<input matInput [formControl]="formControl" [aria-describedby]="userDescribedByValue">
1796+
<mat-error *ngIf="showError">Some error</mat-error>
1797+
</mat-form-field>`
1798+
})
1799+
class MatInputWithSubscriptAndAriaDescribedBy {
1800+
label: string = '';
1801+
userDescribedByValue: string = '';
1802+
showError = false;
1803+
formControl = new FormControl();
1804+
}
1805+
17591806
@Component({template: `<mat-form-field><input matInput [type]="t"></mat-form-field>`})
17601807
class MatInputInvalidTypeTestController {
17611808
t = 'file';

src/material/input/input.ts

Lines changed: 13 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 {
@@ -82,7 +81,6 @@ const _MatInputMixinBase: CanUpdateErrorStateCtor & typeof MatInputBase =
8281
'[disabled]': 'disabled',
8382
'[required]': 'required',
8483
'[attr.readonly]': 'readonly && !_isNativeSelect || null',
85-
'[attr.aria-describedby]': '_ariaDescribedby || null',
8684
'[attr.aria-invalid]': 'errorState',
8785
'[attr.aria-required]': 'required.toString()',
8886
},
@@ -94,9 +92,6 @@ export class MatInput extends _MatInputMixinBase implements MatFormFieldControl<
9492
protected _previousNativeValue: any;
9593
private _inputValueAccessor: {value: any};
9694

97-
/** The aria-describedby attribute on the input for improved a11y. */
98-
_ariaDescribedby: string = this._initialAriaDescribedBy || '';
99-
10095
/** Whether the component is being rendered on the server. */
10196
readonly _isServer: boolean;
10297

@@ -196,6 +191,12 @@ export class MatInput extends _MatInputMixinBase implements MatFormFieldControl<
196191
/** An object used to control when error messages are shown. */
197192
@Input() errorStateMatcher: ErrorStateMatcher;
198193

194+
/**
195+
* Implemented as part of MatFormFieldControl.
196+
* @docs-private
197+
*/
198+
@Input('aria-describedby') userAriaDescribedBy: string;
199+
199200
/**
200201
* Implemented as part of MatFormFieldControl.
201202
* @docs-private
@@ -234,8 +235,7 @@ export class MatInput extends _MatInputMixinBase implements MatFormFieldControl<
234235
_defaultErrorStateMatcher: ErrorStateMatcher,
235236
@Optional() @Self() @Inject(MAT_INPUT_VALUE_ACCESSOR) inputValueAccessor: any,
236237
private _autofillMonitor: AutofillMonitor,
237-
ngZone: NgZone,
238-
@Attribute('aria-describedby') private _initialAriaDescribedBy?: string|null) {
238+
ngZone: NgZone) {
239239

240240
super(_defaultErrorStateMatcher, _parentForm, _parentFormGroup, ngControl);
241241

@@ -416,13 +416,11 @@ export class MatInput extends _MatInputMixinBase implements MatFormFieldControl<
416416
* @docs-private
417417
*/
418418
setDescribedByIds(ids: string[]) {
419-
let value = ids.join(' ');
420-
// Append the describe-by ids from the form-field without discarding the initial
421-
// `aria-describedby` value that has been specified as static attribute.
422-
if (this._initialAriaDescribedBy != null) {
423-
value = `${this._initialAriaDescribedBy} ${value}`;
419+
if (ids.length) {
420+
this._elementRef.nativeElement.setAttribute('aria-describedby', ids.join(' '));
421+
} else {
422+
this._elementRef.nativeElement.removeAttribute('aria-describedby');
424423
}
425-
this._ariaDescribedby = value;
426424
}
427425

428426
/**

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export declare abstract class MatFormFieldControl<T> {
8888
readonly required: boolean;
8989
readonly shouldLabelFloat: boolean;
9090
readonly stateChanges: Observable<void>;
91+
readonly userAriaDescribedBy?: string;
9192
value: T | null;
9293
abstract onContainerClick(event: MouseEvent): void;
9394
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, _initialAriaDescribedBy?: string | null | undefined);
43+
ngControl: NgControl, _parentForm: NgForm, _parentFormGroup: FormGroupDirective, _defaultErrorStateMatcher: ErrorStateMatcher, inputValueAccessor: any, _autofillMonitor: AutofillMonitor, ngZone: NgZone);
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, { 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]>;
6363
}
6464

6565
export declare class MatInputModule {

0 commit comments

Comments
 (0)