diff --git a/goldens/material/form-field/index.api.md b/goldens/material/form-field/index.api.md index d61325f57c02..a9ef59692e37 100644 --- a/goldens/material/form-field/index.api.md +++ b/goldens/material/form-field/index.api.md @@ -80,8 +80,8 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte // (undocumented) _formFieldControl: MatFormFieldControl_2<any>; getConnectedOverlayOrigin(): ElementRef; - _getDisplayedMessages(): 'error' | 'hint'; getLabelId: i0.Signal<string | null>; + _getSubscriptMessageType(): 'error' | 'hint'; _handleLabelResized(): void; // (undocumented) _hasFloatingLabel: i0.Signal<boolean>; diff --git a/goldens/material/input/index.api.md b/goldens/material/input/index.api.md index da333f3049a1..ebebf92f5181 100644 --- a/goldens/material/input/index.api.md +++ b/goldens/material/input/index.api.md @@ -73,8 +73,8 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte // (undocumented) _formFieldControl: MatFormFieldControl<any>; getConnectedOverlayOrigin(): ElementRef; - _getDisplayedMessages(): 'error' | 'hint'; getLabelId: i0.Signal<string | null>; + _getSubscriptMessageType(): 'error' | 'hint'; _handleLabelResized(): void; // (undocumented) _hasFloatingLabel: i0.Signal<boolean>; diff --git a/goldens/material/select/index.api.md b/goldens/material/select/index.api.md index 2dfac47d5448..7a4abc9a00da 100644 --- a/goldens/material/select/index.api.md +++ b/goldens/material/select/index.api.md @@ -96,8 +96,8 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte // (undocumented) _formFieldControl: MatFormFieldControl_2<any>; getConnectedOverlayOrigin(): ElementRef; - _getDisplayedMessages(): 'error' | 'hint'; getLabelId: i0.Signal<string | null>; + _getSubscriptMessageType(): 'error' | 'hint'; _handleLabelResized(): void; // (undocumented) _hasFloatingLabel: i0.Signal<boolean>; diff --git a/src/material/chips/chip-grid.spec.ts b/src/material/chips/chip-grid.spec.ts index d536692baebb..7b9357f6b2b5 100644 --- a/src/material/chips/chip-grid.spec.ts +++ b/src/material/chips/chip-grid.spec.ts @@ -983,7 +983,9 @@ describe('MatChipGrid', () => { errorTestComponent.formControl.markAsTouched(); fixture.detectChanges(); - expect(containerEl.querySelector('mat-error')!.getAttribute('aria-live')).toBe('polite'); + expect( + containerEl.querySelector('[aria-live]:has(mat-error)')!.getAttribute('aria-live'), + ).toBe('polite'); }); it('sets the aria-describedby on the input to reference errors when in error state', fakeAsync(() => { diff --git a/src/material/form-field/directives/error.ts b/src/material/form-field/directives/error.ts index a304e90a8171..9d474d478ad8 100644 --- a/src/material/form-field/directives/error.ts +++ b/src/material/form-field/directives/error.ts @@ -6,14 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import { - Directive, - ElementRef, - InjectionToken, - Input, - HostAttributeToken, - inject, -} from '@angular/core'; +import {Directive, InjectionToken, Input, inject} from '@angular/core'; import {_IdGenerator} from '@angular/cdk/a11y'; /** @@ -28,7 +21,6 @@ export const MAT_ERROR = new InjectionToken<MatError>('MatError'); selector: 'mat-error, [matError]', host: { 'class': 'mat-mdc-form-field-error mat-mdc-form-field-bottom-align', - 'aria-atomic': 'true', '[id]': 'id', }, providers: [{provide: MAT_ERROR, useExisting: MatError}], @@ -38,14 +30,5 @@ export class MatError { constructor(...args: unknown[]); - constructor() { - const ariaLive = inject(new HostAttributeToken('aria-live'), {optional: true}); - - // If no aria-live value is set add 'polite' as a default. This is preferred over setting - // role='alert' so that screen readers do not interrupt the current task to read this aloud. - if (!ariaLive) { - const elementRef = inject(ElementRef); - elementRef.nativeElement.setAttribute('aria-live', 'polite'); - } - } + constructor() {} } diff --git a/src/material/form-field/form-field.html b/src/material/form-field/form-field.html index f1a46075b46b..2697be0d764c 100644 --- a/src/material/form-field/form-field.html +++ b/src/material/form-field/form-field.html @@ -96,25 +96,33 @@ </div> <div - class="mat-mdc-form-field-subscript-wrapper mat-mdc-form-field-bottom-align" - [class.mat-mdc-form-field-subscript-dynamic-size]="subscriptSizing === 'dynamic'" + class="mat-mdc-form-field-subscript-wrapper mat-mdc-form-field-bottom-align" + [class.mat-mdc-form-field-subscript-dynamic-size]="subscriptSizing === 'dynamic'" > - @switch (_getDisplayedMessages()) { - @case ('error') { - <div class="mat-mdc-form-field-error-wrapper"> + @let subscriptMessageType = _getSubscriptMessageType(); + + <!-- + Use a single permanent wrapper for both hints and errors so aria-live works correctly, + as having it appear post render will not consistently work. We also do not want to add + additional divs as it causes styling regressions. + --> + <div aria-atomic="true" aria-live="polite" + [class.mat-mdc-form-field-error-wrapper]="subscriptMessageType === 'error'" + [class.mat-mdc-form-field-hint-wrapper]="subscriptMessageType === 'hint'" + > + @switch (subscriptMessageType) { + @case ('error') { <ng-content select="mat-error, [matError]"></ng-content> - </div> - } + } - @case ('hint') { - <div class="mat-mdc-form-field-hint-wrapper"> + @case ('hint') { @if (hintLabel) { <mat-hint [id]="_hintLabelId">{{hintLabel}}</mat-hint> } <ng-content select="mat-hint:not([align='end'])"></ng-content> <div class="mat-mdc-form-field-hint-spacer"></div> <ng-content select="mat-hint[align='end']"></ng-content> - </div> + } } - } + </div> </div> diff --git a/src/material/form-field/form-field.ts b/src/material/form-field/form-field.ts index 3cb238a35f07..831df953e94a 100644 --- a/src/material/form-field/form-field.ts +++ b/src/material/form-field/form-field.ts @@ -615,8 +615,8 @@ export class MatFormField return control && control[prop]; } - /** Determines whether to display hints or errors. */ - _getDisplayedMessages(): 'error' | 'hint' { + /** Gets the type of subscript message to render (error or hint). */ + _getSubscriptMessageType(): 'error' | 'hint' { return this._errorChildren && this._errorChildren.length > 0 && this._control.errorState ? 'error' : 'hint'; @@ -684,7 +684,7 @@ export class MatFormField ids.push(...this._control.userAriaDescribedBy.split(' ')); } - if (this._getDisplayedMessages() === 'hint') { + if (this._getSubscriptMessageType() === 'hint') { const startHint = this._hintChildren ? this._hintChildren.find(hint => hint.align === 'start') : null; diff --git a/src/material/input/input.spec.ts b/src/material/input/input.spec.ts index b019faca0fc0..4b29f4f3a61f 100644 --- a/src/material/input/input.spec.ts +++ b/src/material/input/input.spec.ts @@ -1266,11 +1266,13 @@ describe('MatMdcInput with forms', () => { .toBe(1); })); - it('should set the proper aria-live attribute on the error messages', fakeAsync(() => { + it('should be in a parent element with the an aria-live attribute to announce the error', fakeAsync(() => { testComponent.formControl.markAsTouched(); fixture.detectChanges(); - expect(containerEl.querySelector('mat-error')!.getAttribute('aria-live')).toBe('polite'); + expect( + containerEl.querySelector('[aria-live]:has(mat-error)')!.getAttribute('aria-live'), + ).toBe('polite'); })); it('sets the aria-describedby to reference errors when in error state', fakeAsync(() => {