From d25cde759696c16f8e36c3613176336dbf3cf278 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Sat, 22 Mar 2025 09:47:24 -0700 Subject: [PATCH] fix(material/form-field): preserve aria-describedby set externally across all form controls fix(material/form-field): preserve aria-describedby set externally across all form controls add describedbyids and use to preserve existing ids add describedByIds to input add comment Move better sync logic to formfield add describedByIds to other controls update api goldens tweak comment add more tests --- goldens/material/chips/index.api.md | 3 ++ goldens/material/datepicker/index.api.md | 1 + goldens/material/form-field/index.api.md | 1 + goldens/material/input/index.api.md | 1 + goldens/material/select/index.api.md | 1 + src/material/chips/chip-grid.ts | 8 +++++ src/material/chips/chip-input.spec.ts | 35 ++++++++++++++++++- src/material/chips/chip-input.ts | 11 ++++++ src/material/chips/chip-text-control.ts | 3 ++ .../datepicker/date-range-input.spec.ts | 14 +++++++- src/material/datepicker/date-range-input.ts | 11 ++++++ src/material/form-field/form-field-control.ts | 3 ++ src/material/form-field/form-field.ts | 20 ++++++++++- src/material/input/input.ts | 33 +++++++---------- src/material/select/select.spec.ts | 10 ++++++ src/material/select/select.ts | 11 ++++++ 16 files changed, 142 insertions(+), 24 deletions(-) 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