diff --git a/src/material/form-field/form-field.ts b/src/material/form-field/form-field.ts index 8b20dc2ccd02..c47904e7faa0 100644 --- a/src/material/form-field/form-field.ts +++ b/src/material/form-field/form-field.ts @@ -36,7 +36,7 @@ import {AbstractControlDirective} from '@angular/forms'; import {ThemePalette} from '@angular/material/core'; import {_IdGenerator} from '@angular/cdk/a11y'; import {Subject, Subscription, merge} from 'rxjs'; -import {takeUntil} from 'rxjs/operators'; +import {map, pairwise, takeUntil, filter, startWith} from 'rxjs/operators'; import {MAT_ERROR, MatError} from './directives/error'; import { FLOATING_LABEL_PARENT, @@ -328,6 +328,7 @@ export class MatFormField private _previousControl: MatFormFieldControl | null = null; private _stateChanges: Subscription | undefined; private _valueChanges: Subscription | undefined; + private _describedByChanges: Subscription | undefined; private _injector = inject(Injector); @@ -377,6 +378,7 @@ export class MatFormField ngOnDestroy() { this._stateChanges?.unsubscribe(); this._valueChanges?.unsubscribe(); + this._describedByChanges?.unsubscribe(); this._destroyed.next(); this._destroyed.complete(); } @@ -426,10 +428,22 @@ export class MatFormField this._stateChanges?.unsubscribe(); this._stateChanges = control.stateChanges.subscribe(() => { this._updateFocusState(); - this._syncDescribedByIds(); this._changeDetectorRef.markForCheck(); }); + // Updating the `aria-describedby` touches the DOM. Only do it if it actually needs to change. + this._describedByChanges?.unsubscribe(); + this._describedByChanges = control.stateChanges + .pipe( + startWith([undefined, undefined] as const), + map(() => [control.errorState, control.userAriaDescribedBy] as const), + pairwise(), + filter(([[prevErrorState, prevDescribedBy], [currentErrorState, currentDescribedBy]]) => { + return prevErrorState !== currentErrorState || prevDescribedBy !== currentDescribedBy; + }), + ) + .subscribe(() => this._syncDescribedByIds()); + this._valueChanges?.unsubscribe(); // Run change detection if the value changes. diff --git a/src/material/input/input.spec.ts b/src/material/input/input.spec.ts index 93ed2c7a723a..611cdc3320f2 100644 --- a/src/material/input/input.spec.ts +++ b/src/material/input/input.spec.ts @@ -643,6 +643,18 @@ describe('MatMdcInput without forms', () => { expect(input.getAttribute('aria-describedby')).toBe('start end'); })); + it('should preserve aria-describedby set directly in the DOM', fakeAsync(() => { + const fixture = createComponent(MatInputHintLabel2TestController); + const input = fixture.nativeElement.querySelector('input'); + input.setAttribute('aria-describedby', 'custom'); + fixture.componentInstance.label = 'label'; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + const hint = fixture.nativeElement.querySelector('.mat-mdc-form-field-hint'); + + expect(input.getAttribute('aria-describedby')).toBe(`${hint.getAttribute('id')} custom`); + })); + it('should set a class on the hint element based on its alignment', fakeAsync(() => { const fixture = createComponent(MatInputMultipleHintTestController); diff --git a/src/material/input/input.ts b/src/material/input/input.ts index 568b49c6e7bf..3be8e80a1ebe 100644 --- a/src/material/input/input.ts +++ b/src/material/input/input.ts @@ -111,6 +111,9 @@ export class MatInput private _webkitBlinkWheelListenerAttached = false; private _config = inject(MAT_INPUT_CONFIG, {optional: true}); + /** `aria-describedby` IDs assigned by the form field. */ + private _formFieldDescribedBy: string[] | undefined; + /** Whether the component is being rendered on the server. */ readonly _isServer: boolean; @@ -551,10 +554,29 @@ export class MatInput * @docs-private */ setDescribedByIds(ids: string[]) { - if (ids.length) { - this._elementRef.nativeElement.setAttribute('aria-describedby', ids.join(' ')); + const element = this._elementRef.nativeElement; + const existingDescribedBy = element.getAttribute('aria-describedby'); + let toAssign: string[]; + + // In some cases there might be some `aria-describedby` IDs that were assigned directly, + // like by the `AriaDescriber` (see #30011). Attempt to preserve them by taking the previous + // attribute value and filtering out the IDs that came from the previous `setDescribedByIds` + // call. Note the `|| ids` here allows us to avoid duplicating IDs on the first render. + if (existingDescribedBy) { + const exclude = this._formFieldDescribedBy || ids; + toAssign = ids.concat( + existingDescribedBy.split(' ').filter(id => id && !exclude.includes(id)), + ); + } else { + toAssign = ids; + } + + this._formFieldDescribedBy = ids; + + if (toAssign.length) { + element.setAttribute('aria-describedby', toAssign.join(' ')); } else { - this._elementRef.nativeElement.removeAttribute('aria-describedby'); + element.removeAttribute('aria-describedby'); } }