diff --git a/goldens/material/chips/index.api.md b/goldens/material/chips/index.api.md index a46a68404f9f..9ff35385222d 100644 --- a/goldens/material/chips/index.api.md +++ b/goldens/material/chips/index.api.md @@ -172,6 +172,7 @@ export class MatChipGrid extends MatChipSet implements AfterContentInit, AfterVi readonly controlType: string; // (undocumented) protected _defaultRole: string; + get describedByIds(): string[]; get disabled(): boolean; set disabled(value: boolean); get empty(): boolean; @@ -250,6 +251,7 @@ export class MatChipInput implements MatChipTextControl, OnChanges, OnDestroy { // (undocumented) protected _chipGrid: MatChipGrid; clear(): void; + get describedByIds(): string[]; get disabled(): boolean; set disabled(value: boolean); disabledInteractive: boolean; @@ -514,6 +516,7 @@ export class MatChipsModule { // @public export interface MatChipTextControl { + readonly describedByIds?: string[]; empty: boolean; focus(): void; focused: boolean; diff --git a/goldens/material/datepicker/index.api.md b/goldens/material/datepicker/index.api.md index 019ec7298abc..7b4c8db7b057 100644 --- a/goldens/material/datepicker/index.api.md +++ b/goldens/material/datepicker/index.api.md @@ -567,6 +567,7 @@ export class MatDateRangeInput implements MatFormFieldControl>, controlType: string; get dateFilter(): DateFilterFn; set dateFilter(value: DateFilterFn); + get describedByIds(): string[]; readonly disableAutomaticLabeling = true; get disabled(): boolean; set disabled(value: boolean); diff --git a/goldens/material/form-field/index.api.md b/goldens/material/form-field/index.api.md index d61325f57c02..4b8da20ee9b3 100644 --- a/goldens/material/form-field/index.api.md +++ b/goldens/material/form-field/index.api.md @@ -156,6 +156,7 @@ export type MatFormFieldAppearance = 'fill' | 'outline'; export abstract class MatFormFieldControl { readonly autofilled?: boolean; readonly controlType?: string; + readonly describedByIds?: string[]; readonly disableAutomaticLabeling?: boolean; readonly disabled: boolean; readonly empty: boolean; diff --git a/goldens/material/input/index.api.md b/goldens/material/input/index.api.md index da333f3049a1..68becd81a97e 100644 --- a/goldens/material/input/index.api.md +++ b/goldens/material/input/index.api.md @@ -152,6 +152,7 @@ export class MatInput implements MatFormFieldControl_2, OnChanges, OnDestro constructor(...args: unknown[]); autofilled: boolean; controlType: string; + get describedByIds(): string[]; protected _dirtyCheckNativeValue(): void; get disabled(): boolean; set disabled(value: BooleanInput); diff --git a/goldens/material/select/index.api.md b/goldens/material/select/index.api.md index 2dfac47d5448..a150e9c30ddc 100644 --- a/goldens/material/select/index.api.md +++ b/goldens/material/select/index.api.md @@ -266,6 +266,7 @@ export class MatSelect implements AfterContentInit, OnChanges, OnDestroy, OnInit customTrigger: MatSelectTrigger; // (undocumented) protected _defaultOptions: MatSelectConfig | null; + get describedByIds(): string[]; protected readonly _destroy: Subject; readonly disableAutomaticLabeling = true; disabled: boolean; diff --git a/src/material/chips/chip-grid.ts b/src/material/chips/chip-grid.ts index 9741ca8453e9..5dfe91e9233e 100644 --- a/src/material/chips/chip-grid.ts +++ b/src/material/chips/chip-grid.ts @@ -349,6 +349,14 @@ export class MatChipGrid this.stateChanges.next(); } + /** + * Implemented as part of MatFormFieldControl. + * @docs-private + */ + get describedByIds(): string[] { + return this._chipInput?.describedByIds || []; + } + /** * Implemented as part of MatFormFieldControl. * @docs-private diff --git a/src/material/chips/chip-input.spec.ts b/src/material/chips/chip-input.spec.ts index 36033b9e38e0..a44c66b87aad 100644 --- a/src/material/chips/chip-input.spec.ts +++ b/src/material/chips/chip-input.spec.ts @@ -155,6 +155,38 @@ describe('MatChipInput', () => { expect(inputNativeElement.classList).toContain('mat-mdc-chip-input'); expect(inputNativeElement.classList).toContain('mdc-text-field__input'); }); + + it('should set `aria-describedby` to the id of the mat-hint', () => { + expect(inputNativeElement.getAttribute('aria-describedby')).toBeNull(); + + fixture.componentInstance.hint = 'test'; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + const hint = fixture.debugElement.query(By.css('mat-hint')).nativeElement; + + expect(inputNativeElement.getAttribute('aria-describedby')).toBe(hint.getAttribute('id')); + expect(inputNativeElement.getAttribute('aria-describedby')).toMatch(/^mat-mdc-hint-\w+\d+$/); + }); + + it('should support user binding to `aria-describedby`', () => { + inputNativeElement.setAttribute('aria-describedby', 'test'); + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + expect(inputNativeElement.getAttribute('aria-describedby')).toBe('test'); + }); + + it('should preserve aria-describedby set directly in the DOM', fakeAsync(() => { + inputNativeElement.setAttribute('aria-describedby', 'custom'); + fixture.componentInstance.hint = 'test'; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + const hint = fixture.debugElement.query(By.css('mat-hint')).nativeElement; + + expect(inputNativeElement.getAttribute('aria-describedby')).toBe( + `${hint.getAttribute('id')} custom`, + ); + })); }); describe('[addOnBlur]', () => { @@ -289,7 +321,7 @@ describe('MatChipInput', () => { @Component({ template: ` - + Hello { expect(rangeInput.getAttribute('aria-labelledby')).toBe(labelId); }); - it('should point the range input aria-labelledby to the form field hint element', () => { + it('should point the range input aria-describedby to the form field hint element', () => { const fixture = createComponent(StandardRangePicker); fixture.detectChanges(); const labelId = fixture.nativeElement.querySelector('.mat-mdc-form-field-hint').id; @@ -179,6 +179,18 @@ describe('MatDateRangeInput', () => { expect(rangeInput.getAttribute('aria-describedby')).toBe(labelId); }); + it('should preserve aria-describedby set directly in the DOM', fakeAsync(() => { + const fixture = createComponent(StandardRangePicker); + const rangeInput = fixture.nativeElement.querySelector('.mat-date-range-input'); + + rangeInput.setAttribute('aria-describedby', 'custom'); + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + const hint = fixture.nativeElement.querySelector('.mat-mdc-form-field-hint'); + + expect(rangeInput.getAttribute('aria-describedby')).toBe(`${hint.getAttribute('id')} custom`); + })); + it('should not set aria-labelledby if the form field does not have a label', () => { const fixture = createComponent(RangePickerNoLabel); fixture.detectChanges(); diff --git a/src/material/datepicker/date-range-input.ts b/src/material/datepicker/date-range-input.ts index d3260df328d9..0d507fd3c24a 100644 --- a/src/material/datepicker/date-range-input.ts +++ b/src/material/datepicker/date-range-input.ts @@ -284,6 +284,17 @@ export class MatDateRangeInput this.ngControl = inject(ControlContainer, {optional: true, self: true}) as any; } + /** + * Implemented as part of MatFormFieldControl. + * @docs-private + */ + get describedByIds(): string[] { + const element = this._elementRef.nativeElement; + const existingDescribedBy = element.getAttribute('aria-describedby'); + + return existingDescribedBy?.split(' ') || []; + } + /** * Implemented as a part of `MatFormFieldControl`. * @docs-private diff --git a/src/material/form-field/form-field-control.ts b/src/material/form-field/form-field-control.ts index c828280f5565..afcaab9c69b0 100644 --- a/src/material/form-field/form-field-control.ts +++ b/src/material/form-field/form-field-control.ts @@ -75,6 +75,9 @@ export abstract class MatFormFieldControl { */ readonly disableAutomaticLabeling?: boolean; + /** Gets the list of element IDs that currently describe this control. */ + readonly describedByIds?: string[]; + /** Sets the list of element IDs that currently describe this control. */ abstract setDescribedByIds(ids: string[]): void; diff --git a/src/material/form-field/form-field.ts b/src/material/form-field/form-field.ts index bf553f9be85a..f45c1cc907b1 100644 --- a/src/material/form-field/form-field.ts +++ b/src/material/form-field/form-field.ts @@ -316,6 +316,9 @@ export class MatFormField // Unique id for the hint label. readonly _hintLabelId = this._idGenerator.getId('mat-mdc-hint-'); + // Ids obtained from the error and hint fields + private _describedByIds: string[] | undefined; + /** Gets the current form field control */ get _control(): MatFormFieldControl { return this._explicitFormFieldControl || this._formFieldControl; @@ -717,7 +720,22 @@ export class MatFormField ids.push(...this._errorChildren.map(error => error.id)); } - this._control.setDescribedByIds(ids); + const existingDescribedBy = this._control.describedByIds; + 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._describedByIds || ids; + toAssign = ids.concat(existingDescribedBy.filter(id => id && !exclude.includes(id))); + } else { + toAssign = ids; + } + + this._control.setDescribedByIds(toAssign); + this._describedByIds = ids; } } diff --git a/src/material/input/input.ts b/src/material/input/input.ts index 869b38abb0aa..b286d3aad979 100644 --- a/src/material/input/input.ts +++ b/src/material/input/input.ts @@ -114,9 +114,6 @@ export class MatInput private _cleanupIosKeyup: (() => void) | undefined; private _cleanupWebkitWheel: (() => void) | undefined; - /** `aria-describedby` IDs assigned by the form field. */ - private _formFieldDescribedBy: string[] | undefined; - /** Whether the component is being rendered on the server. */ readonly _isServer: boolean; @@ -554,28 +551,22 @@ export class MatInput * Implemented as part of MatFormFieldControl. * @docs-private */ - setDescribedByIds(ids: string[]) { + get describedByIds(): string[] { 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; + return existingDescribedBy?.split(' ') || []; + } + + /** + * Implemented as part of MatFormFieldControl. + * @docs-private + */ + setDescribedByIds(ids: string[]) { + const element = this._elementRef.nativeElement; - if (toAssign.length) { - element.setAttribute('aria-describedby', toAssign.join(' ')); + if (ids.length) { + element.setAttribute('aria-describedby', ids.join(' ')); } else { element.removeAttribute('aria-describedby'); } diff --git a/src/material/select/select.spec.ts b/src/material/select/select.spec.ts index 9c232cd14edd..3a1549b16974 100644 --- a/src/material/select/select.spec.ts +++ b/src/material/select/select.spec.ts @@ -226,6 +226,16 @@ describe('MatSelect', () => { expect(select.getAttribute('aria-describedby')).toBe('test'); }); + it('should preserve aria-describedby set directly in the DOM', fakeAsync(() => { + select.setAttribute('aria-describedby', 'custom'); + fixture.componentInstance.hint = 'test'; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + const hint = fixture.debugElement.query(By.css('mat-hint')).nativeElement; + + expect(select.getAttribute('aria-describedby')).toBe(`${hint.getAttribute('id')} custom`); + })); + it('should be able to override the tabindex', () => { fixture.componentInstance.tabIndexOverride = 3; fixture.changeDetectorRef.markForCheck(); diff --git a/src/material/select/select.ts b/src/material/select/select.ts index 005312acbe1b..e19a8c4142e4 100644 --- a/src/material/select/select.ts +++ b/src/material/select/select.ts @@ -1449,6 +1449,17 @@ export class MatSelect return value; } + /** + * Implemented as part of MatFormFieldControl. + * @docs-private + */ + get describedByIds(): string[] { + const element = this._elementRef.nativeElement; + const existingDescribedBy = element.getAttribute('aria-describedby'); + + return existingDescribedBy?.split(' ') || []; + } + /** * Implemented as part of MatFormFieldControl. * @docs-private