Skip to content

Commit 7d511ba

Browse files
authored
fix(material/input): do not override existing aria-describedby value (#19587)
* fix(material/input): do not override existing aria-describedby value The form-field notifies controls whenever hints or errors have been displayed. It does this, so that controls like the input can refresh their `aria-describedby` value to point to the errors and hints. This currently has the downside of overriding existing `aria-describedby` values that have been manually set by developers. We could fix this by reading the initial value and merging it with the ids received from the form-field. * fixup! fix(material/input): do not override existing aria-describedby value Address feedback * fixup! fix(material/input): do not override existing aria-describedby value Add documentation
1 parent 79e4d28 commit 7d511ba

File tree

14 files changed

+190
-50
lines changed

14 files changed

+190
-50
lines changed

guides/creating-a-custom-form-field-control.md

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -339,20 +339,33 @@ controlType = 'example-tel-input';
339339

340340
#### `setDescribedByIds(ids: string[])`
341341

342-
This method is used by the `<mat-form-field>` to specify the IDs that should be used for the
343-
`aria-describedby` attribute of your component. The method has one parameter, the list of IDs, we
344-
just need to apply the given IDs to the element that represents our control.
342+
This method is used by the `<mat-form-field>` to set element ids that should be used for the
343+
`aria-describedby` attribute of your control. The ids are controlled through the form field
344+
as hints or errors are conditionally displayed and should be reflected in the control's
345+
`aria-describedby` attribute for an improved accessibility experience.
345346

346-
In our concrete example, these IDs would need to be applied to the group element.
347+
The `setDescribedByIds` method is invoked whenever the control's state changes. Custom controls
348+
need to implement this method and update the `aria-describedby` attribute based on the specified
349+
element ids. Below is an example that shows how this can be achieved.
350+
351+
Note that the method by default will not respect element ids that have been set manually on the
352+
control element through the `aria-describedby` attribute. To ensure that your control does not
353+
accidentally override existing element ids specified by consumers of your control, create an
354+
input called `userAriaDescribedby` like followed:
347355

348356
```ts
349-
setDescribedByIds(ids: string[]) {
350-
this.describedBy = ids.join(' ');
351-
}
357+
@Input('aria-describedby') userAriaDescribedBy: string;
352358
```
353359

354-
```html
355-
<div role="group" [formGroup]="parts" [attr.aria-describedby]="describedBy">
360+
The form field will then pick up the user specified `aria-describedby` ids and merge
361+
them with ids for hints or errors whenever `setDescribedByIds` is invoked.
362+
363+
```ts
364+
setDescribedByIds(ids: string[]) {
365+
const controlElement = this._elementRef.nativeElement
366+
.querySelector('.example-tel-input-container')!;
367+
controlElement.setAttribute('aria-describedby', ids.join(' '));
368+
}
356369
```
357370

358371
#### `onContainerClick(event: MouseEvent)`

src/components-examples/material-experimental/mdc-form-field/mdc-form-field-custom-control/example-tel-input-example.html

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
<div role="group" class="example-tel-input-container"
22
[formGroup]="parts"
3-
[attr.aria-labelledby]="_formField?.getLabelId()"
4-
[attr.aria-describedby]="describedBy">
3+
[attr.aria-labelledby]="_formField?.getLabelId()">
54
<input
65
class="example-tel-input-element"
76
formControlName="area" size="3"

src/components-examples/material-experimental/mdc-form-field/mdc-form-field-custom-control/form-field-custom-control-example.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ export class MyTelInput implements ControlValueAccessor, MatFormFieldControl<MyT
3838
errorState = false;
3939
controlType = 'example-tel-input';
4040
id = `example-tel-input-${MyTelInput.nextId++}`;
41-
describedBy = '';
4241
onChange = (_: any) => {};
4342
onTouched = () => {};
4443

@@ -50,6 +49,8 @@ export class MyTelInput implements ControlValueAccessor, MatFormFieldControl<MyT
5049

5150
get shouldLabelFloat() { return this.focused || !this.empty; }
5251

52+
@Input('aria-describedby') userAriaDescribedBy: string;
53+
5354
@Input()
5455
get placeholder(): string { return this._placeholder; }
5556
set placeholder(value: string) {
@@ -121,7 +122,9 @@ export class MyTelInput implements ControlValueAccessor, MatFormFieldControl<MyT
121122
}
122123

123124
setDescribedByIds(ids: string[]) {
124-
this.describedBy = ids.join(' ');
125+
const controlElement = this._elementRef.nativeElement
126+
.querySelector('.example-tel-input-container')!;
127+
controlElement.setAttribute('aria-describedby', ids.join(' '));
125128
}
126129

127130
onContainerClick(event: MouseEvent) {

src/components-examples/material/form-field/form-field-custom-control/example-tel-input-example.html

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
<div role="group" class="example-tel-input-container"
22
[formGroup]="parts"
3-
[attr.aria-labelledby]="_formField?.getLabelId()"
4-
[attr.aria-describedby]="describedBy">
3+
[attr.aria-labelledby]="_formField?.getLabelId()">
54
<input class="example-tel-input-element"
65
formControlName="area" size="3"
76
maxLength="3"

src/components-examples/material/form-field/form-field-custom-control/form-field-custom-control-example.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ export class MyTelInput
6565
focused = false;
6666
controlType = 'example-tel-input';
6767
id = `example-tel-input-${MyTelInput.nextId++}`;
68-
describedBy = '';
6968
onChange = (_: any) => {};
7069
onTouched = () => {};
7170

@@ -81,6 +80,8 @@ export class MyTelInput
8180
return this.focused || !this.empty;
8281
}
8382

83+
@Input('aria-describedby') userAriaDescribedBy: string;
84+
8485
@Input()
8586
get placeholder(): string {
8687
return this._placeholder;
@@ -185,7 +186,9 @@ export class MyTelInput
185186
}
186187

187188
setDescribedByIds(ids: string[]) {
188-
this.describedBy = ids.join(' ');
189+
const controlElement = this._elementRef.nativeElement
190+
.querySelector('.example-tel-input-container')!;
191+
controlElement.setAttribute('aria-describedby', ids.join(' '));
189192
}
190193

191194
onContainerClick() {

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

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

587+
if (this._control.userAriaDescribedBy) {
588+
ids.push(...this._control.userAriaDescribedBy.split(' '));
589+
}
590+
587591
if (this._getDisplayedMessages() === 'hint') {
588592
const startHint = this._hintChildren ?
589593
this._hintChildren.find(hint => hint.align === 'start') : null;
@@ -600,7 +604,7 @@ export class MatFormField implements AfterViewInit, OnDestroy, AfterContentCheck
600604
ids.push(endHint.id);
601605
}
602606
} else if (this._errorChildren) {
603-
ids = this._errorChildren.map(error => error.id);
607+
ids.push(...this._errorChildren.map(error => error.id));
604608
}
605609

606610
this._control.setDescribedByIds(ids);

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

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -474,10 +474,44 @@ describe('MatMdcInput without forms', () => {
474474
fixture.componentInstance.label = 'label';
475475
fixture.detectChanges();
476476

477-
let hint = fixture.debugElement.query(By.css('.mat-mdc-form-field-hint'))!.nativeElement;
478-
let input = fixture.debugElement.query(By.css('input'))!.nativeElement;
477+
const hint = fixture.debugElement.query(By.css('.mat-mdc-form-field-hint'))!.nativeElement;
478+
const input = fixture.debugElement.query(By.css('input'))!.nativeElement;
479+
const hintId = hint.getAttribute('id');
479480

480-
expect(input.getAttribute('aria-describedby')).toBe(hint.getAttribute('id'));
481+
expect(input.getAttribute('aria-describedby')).toBe(`initial ${hintId}`);
482+
}));
483+
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);
481515
}));
482516

483517
it('sets the aria-describedby to the id of the mat-hint', fakeAsync(() => {
@@ -1253,12 +1287,29 @@ class MatInputHintLabel2TestController {
12531287
}
12541288

12551289
@Component({
1256-
template: `<mat-form-field [hintLabel]="label"><input matInput></mat-form-field>`
1290+
template: `
1291+
<mat-form-field [hintLabel]="label">
1292+
<input matInput aria-describedby="initial">
1293+
</mat-form-field>`
12571294
})
12581295
class MatInputHintLabelTestController {
12591296
label: string = '';
12601297
}
12611298

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+
12621313
@Component({template: `<mat-form-field><input matInput [type]="t"></mat-form-field>`})
12631314
class MatInputInvalidTypeTestController {
12641315
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
@@ -507,6 +507,10 @@ export class MatFormField extends _MatFormFieldMixinBase
507507
if (this._control) {
508508
let ids: string[] = [];
509509

510+
if (this._control.userAriaDescribedBy) {
511+
ids.push(...this._control.userAriaDescribedBy.split(' '));
512+
}
513+
510514
if (this._getDisplayedMessages() === 'hint') {
511515
const startHint = this._hintChildren ?
512516
this._hintChildren.find(hint => hint.align === 'start') : null;
@@ -523,7 +527,7 @@ export class MatFormField extends _MatFormFieldMixinBase
523527
ids.push(endHint.id);
524528
}
525529
} else if (this._errorChildren) {
526-
ids = this._errorChildren.map(error => error.id);
530+
ids.push(...this._errorChildren.map(error => error.id));
527531
}
528532

529533
this._control.setDescribedByIds(ids);

src/material/input/input.spec.ts

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -564,10 +564,44 @@ describe('MatInput without forms', () => {
564564
fixture.componentInstance.label = 'label';
565565
fixture.detectChanges();
566566

567-
let hint = fixture.debugElement.query(By.css('.mat-hint'))!.nativeElement;
568-
let input = fixture.debugElement.query(By.css('input'))!.nativeElement;
567+
const hint = fixture.debugElement.query(By.css('.mat-hint'))!.nativeElement;
568+
const input = fixture.debugElement.query(By.css('input'))!.nativeElement;
569+
const hintId = hint.getAttribute('id');
569570

570-
expect(input.getAttribute('aria-describedby')).toBe(hint.getAttribute('id'));
571+
expect(input.getAttribute('aria-describedby')).toBe(`initial ${hintId}`);
572+
}));
573+
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);
571605
}));
572606

573607
it('sets the aria-describedby to the id of the mat-hint', fakeAsync(() => {
@@ -1767,12 +1801,29 @@ class MatInputHintLabel2TestController {
17671801
}
17681802

17691803
@Component({
1770-
template: `<mat-form-field [hintLabel]="label"><input matInput></mat-form-field>`
1804+
template: `
1805+
<mat-form-field [hintLabel]="label">
1806+
<input matInput aria-describedby="initial">
1807+
</mat-form-field>`
17711808
})
17721809
class MatInputHintLabelTestController {
17731810
label: string = '';
17741811
}
17751812

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+
17761827
@Component({template: `<mat-form-field><input matInput [type]="t"></mat-form-field>`})
17771828
class MatInputInvalidTypeTestController {
17781829
t = 'file';

0 commit comments

Comments
 (0)