Skip to content

Commit a5aade2

Browse files
authored
fix(material/form-field): preserve aria-describedby set externally across all form controls (#30699)
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
1 parent 63e3c4a commit a5aade2

File tree

16 files changed

+142
-24
lines changed

16 files changed

+142
-24
lines changed

goldens/material/chips/index.api.md

+3
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ export class MatChipGrid extends MatChipSet implements AfterContentInit, AfterVi
172172
readonly controlType: string;
173173
// (undocumented)
174174
protected _defaultRole: string;
175+
get describedByIds(): string[];
175176
get disabled(): boolean;
176177
set disabled(value: boolean);
177178
get empty(): boolean;
@@ -250,6 +251,7 @@ export class MatChipInput implements MatChipTextControl, OnChanges, OnDestroy {
250251
// (undocumented)
251252
protected _chipGrid: MatChipGrid;
252253
clear(): void;
254+
get describedByIds(): string[];
253255
get disabled(): boolean;
254256
set disabled(value: boolean);
255257
disabledInteractive: boolean;
@@ -514,6 +516,7 @@ export class MatChipsModule {
514516

515517
// @public
516518
export interface MatChipTextControl {
519+
readonly describedByIds?: string[];
517520
empty: boolean;
518521
focus(): void;
519522
focused: boolean;

goldens/material/datepicker/index.api.md

+1
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,7 @@ export class MatDateRangeInput<D> implements MatFormFieldControl<DateRange<D>>,
566566
controlType: string;
567567
get dateFilter(): DateFilterFn<D>;
568568
set dateFilter(value: DateFilterFn<D>);
569+
get describedByIds(): string[];
569570
readonly disableAutomaticLabeling = true;
570571
get disabled(): boolean;
571572
set disabled(value: boolean);

goldens/material/form-field/index.api.md

+1
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ export type MatFormFieldAppearance = 'fill' | 'outline';
156156
export abstract class MatFormFieldControl<T> {
157157
readonly autofilled?: boolean;
158158
readonly controlType?: string;
159+
readonly describedByIds?: string[];
159160
readonly disableAutomaticLabeling?: boolean;
160161
readonly disabled: boolean;
161162
readonly empty: boolean;

goldens/material/input/index.api.md

+1
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ export class MatInput implements MatFormFieldControl_2<any>, OnChanges, OnDestro
152152
constructor(...args: unknown[]);
153153
autofilled: boolean;
154154
controlType: string;
155+
get describedByIds(): string[];
155156
protected _dirtyCheckNativeValue(): void;
156157
get disabled(): boolean;
157158
set disabled(value: BooleanInput);

goldens/material/select/index.api.md

+1
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ export class MatSelect implements AfterContentInit, OnChanges, OnDestroy, OnInit
266266
customTrigger: MatSelectTrigger;
267267
// (undocumented)
268268
protected _defaultOptions: MatSelectConfig | null;
269+
get describedByIds(): string[];
269270
protected readonly _destroy: Subject<void>;
270271
readonly disableAutomaticLabeling = true;
271272
disabled: boolean;

src/material/chips/chip-grid.ts

+8
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,14 @@ export class MatChipGrid
349349
this.stateChanges.next();
350350
}
351351

352+
/**
353+
* Implemented as part of MatFormFieldControl.
354+
* @docs-private
355+
*/
356+
get describedByIds(): string[] {
357+
return this._chipInput?.describedByIds || [];
358+
}
359+
352360
/**
353361
* Implemented as part of MatFormFieldControl.
354362
* @docs-private

src/material/chips/chip-input.spec.ts

+34-1
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,38 @@ describe('MatChipInput', () => {
156156
expect(inputNativeElement.classList).toContain('mat-mdc-chip-input');
157157
expect(inputNativeElement.classList).toContain('mdc-text-field__input');
158158
});
159+
160+
it('should set `aria-describedby` to the id of the mat-hint', () => {
161+
expect(inputNativeElement.getAttribute('aria-describedby')).toBeNull();
162+
163+
fixture.componentInstance.hint = 'test';
164+
fixture.changeDetectorRef.markForCheck();
165+
fixture.detectChanges();
166+
const hint = fixture.debugElement.query(By.css('mat-hint')).nativeElement;
167+
168+
expect(inputNativeElement.getAttribute('aria-describedby')).toBe(hint.getAttribute('id'));
169+
expect(inputNativeElement.getAttribute('aria-describedby')).toMatch(/^mat-mdc-hint-\w+\d+$/);
170+
});
171+
172+
it('should support user binding to `aria-describedby`', () => {
173+
inputNativeElement.setAttribute('aria-describedby', 'test');
174+
fixture.changeDetectorRef.markForCheck();
175+
fixture.detectChanges();
176+
177+
expect(inputNativeElement.getAttribute('aria-describedby')).toBe('test');
178+
});
179+
180+
it('should preserve aria-describedby set directly in the DOM', fakeAsync(() => {
181+
inputNativeElement.setAttribute('aria-describedby', 'custom');
182+
fixture.componentInstance.hint = 'test';
183+
fixture.changeDetectorRef.markForCheck();
184+
fixture.detectChanges();
185+
const hint = fixture.debugElement.query(By.css('mat-hint')).nativeElement;
186+
187+
expect(inputNativeElement.getAttribute('aria-describedby')).toBe(
188+
`${hint.getAttribute('id')} custom`,
189+
);
190+
}));
159191
});
160192

161193
describe('[addOnBlur]', () => {
@@ -291,7 +323,7 @@ describe('MatChipInput', () => {
291323

292324
@Component({
293325
template: `
294-
<mat-form-field>
326+
<mat-form-field [hintLabel]="hint">
295327
<mat-chip-grid #chipGrid [required]="required">
296328
<mat-chip-row>Hello</mat-chip-row>
297329
<input
@@ -311,6 +343,7 @@ class TestChipInput {
311343
placeholder = '';
312344
required = false;
313345
disabledInteractive = false;
346+
hint: string;
314347

315348
add(_: MatChipInputEvent) {}
316349
}

src/material/chips/chip-input.ts

+11
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,17 @@ export class MatChipInput implements MatChipTextControl, OnChanges, OnDestroy {
218218
this.inputElement.value = '';
219219
}
220220

221+
/**
222+
* Implemented as part of MatChipTextControl.
223+
* @docs-private
224+
*/
225+
get describedByIds(): string[] {
226+
const element = this._elementRef.nativeElement;
227+
const existingDescribedBy = element.getAttribute('aria-describedby');
228+
229+
return existingDescribedBy?.split(' ') || [];
230+
}
231+
221232
setDescribedByIds(ids: string[]): void {
222233
const element = this._elementRef.nativeElement;
223234

src/material/chips/chip-text-control.ts

+3
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ export interface MatChipTextControl {
2323
/** Focuses the text control. */
2424
focus(): void;
2525

26+
/** Gets the list of ids the input is described by. */
27+
readonly describedByIds?: string[];
28+
2629
/** Sets the list of ids the input is described by. */
2730
setDescribedByIds(ids: string[]): void;
2831
}

src/material/datepicker/date-range-input.spec.ts

+13-1
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ describe('MatDateRangeInput', () => {
170170
expect(rangeInput.getAttribute('aria-labelledby')).toBe(labelId);
171171
});
172172

173-
it('should point the range input aria-labelledby to the form field hint element', () => {
173+
it('should point the range input aria-describedby to the form field hint element', () => {
174174
const fixture = createComponent(StandardRangePicker);
175175
fixture.detectChanges();
176176
const labelId = fixture.nativeElement.querySelector('.mat-mdc-form-field-hint').id;
@@ -180,6 +180,18 @@ describe('MatDateRangeInput', () => {
180180
expect(rangeInput.getAttribute('aria-describedby')).toBe(labelId);
181181
});
182182

183+
it('should preserve aria-describedby set directly in the DOM', fakeAsync(() => {
184+
const fixture = createComponent(StandardRangePicker);
185+
const rangeInput = fixture.nativeElement.querySelector('.mat-date-range-input');
186+
187+
rangeInput.setAttribute('aria-describedby', 'custom');
188+
fixture.changeDetectorRef.markForCheck();
189+
fixture.detectChanges();
190+
const hint = fixture.nativeElement.querySelector('.mat-mdc-form-field-hint');
191+
192+
expect(rangeInput.getAttribute('aria-describedby')).toBe(`${hint.getAttribute('id')} custom`);
193+
}));
194+
183195
it('should not set aria-labelledby if the form field does not have a label', () => {
184196
const fixture = createComponent(RangePickerNoLabel);
185197
fixture.detectChanges();

src/material/datepicker/date-range-input.ts

+11
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,17 @@ export class MatDateRangeInput<D>
284284
this.ngControl = inject(ControlContainer, {optional: true, self: true}) as any;
285285
}
286286

287+
/**
288+
* Implemented as part of MatFormFieldControl.
289+
* @docs-private
290+
*/
291+
get describedByIds(): string[] {
292+
const element = this._elementRef.nativeElement;
293+
const existingDescribedBy = element.getAttribute('aria-describedby');
294+
295+
return existingDescribedBy?.split(' ') || [];
296+
}
297+
287298
/**
288299
* Implemented as a part of `MatFormFieldControl`.
289300
* @docs-private

src/material/form-field/form-field-control.ts

+3
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ export abstract class MatFormFieldControl<T> {
7575
*/
7676
readonly disableAutomaticLabeling?: boolean;
7777

78+
/** Gets the list of element IDs that currently describe this control. */
79+
readonly describedByIds?: string[];
80+
7881
/** Sets the list of element IDs that currently describe this control. */
7982
abstract setDescribedByIds(ids: string[]): void;
8083

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

+19-1
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,9 @@ export class MatFormField
316316
// Unique id for the hint label.
317317
readonly _hintLabelId = this._idGenerator.getId('mat-mdc-hint-');
318318

319+
// Ids obtained from the error and hint fields
320+
private _describedByIds: string[] | undefined;
321+
319322
/** Gets the current form field control */
320323
get _control(): MatFormFieldControl<any> {
321324
return this._explicitFormFieldControl || this._formFieldControl;
@@ -717,7 +720,22 @@ export class MatFormField
717720
ids.push(...this._errorChildren.map(error => error.id));
718721
}
719722

720-
this._control.setDescribedByIds(ids);
723+
const existingDescribedBy = this._control.describedByIds;
724+
let toAssign: string[];
725+
726+
// In some cases there might be some `aria-describedby` IDs that were assigned directly,
727+
// like by the `AriaDescriber` (see #30011). Attempt to preserve them by taking the previous
728+
// attribute value and filtering out the IDs that came from the previous `setDescribedByIds`
729+
// call. Note the `|| ids` here allows us to avoid duplicating IDs on the first render.
730+
if (existingDescribedBy) {
731+
const exclude = this._describedByIds || ids;
732+
toAssign = ids.concat(existingDescribedBy.filter(id => id && !exclude.includes(id)));
733+
} else {
734+
toAssign = ids;
735+
}
736+
737+
this._control.setDescribedByIds(toAssign);
738+
this._describedByIds = ids;
721739
}
722740
}
723741

src/material/input/input.ts

+12-21
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,6 @@ export class MatInput
114114
private _cleanupIosKeyup: (() => void) | undefined;
115115
private _cleanupWebkitWheel: (() => void) | undefined;
116116

117-
/** `aria-describedby` IDs assigned by the form field. */
118-
private _formFieldDescribedBy: string[] | undefined;
119-
120117
/** Whether the component is being rendered on the server. */
121118
readonly _isServer: boolean;
122119

@@ -554,28 +551,22 @@ export class MatInput
554551
* Implemented as part of MatFormFieldControl.
555552
* @docs-private
556553
*/
557-
setDescribedByIds(ids: string[]) {
554+
get describedByIds(): string[] {
558555
const element = this._elementRef.nativeElement;
559556
const existingDescribedBy = element.getAttribute('aria-describedby');
560-
let toAssign: string[];
561-
562-
// In some cases there might be some `aria-describedby` IDs that were assigned directly,
563-
// like by the `AriaDescriber` (see #30011). Attempt to preserve them by taking the previous
564-
// attribute value and filtering out the IDs that came from the previous `setDescribedByIds`
565-
// call. Note the `|| ids` here allows us to avoid duplicating IDs on the first render.
566-
if (existingDescribedBy) {
567-
const exclude = this._formFieldDescribedBy || ids;
568-
toAssign = ids.concat(
569-
existingDescribedBy.split(' ').filter(id => id && !exclude.includes(id)),
570-
);
571-
} else {
572-
toAssign = ids;
573-
}
574557

575-
this._formFieldDescribedBy = ids;
558+
return existingDescribedBy?.split(' ') || [];
559+
}
560+
561+
/**
562+
* Implemented as part of MatFormFieldControl.
563+
* @docs-private
564+
*/
565+
setDescribedByIds(ids: string[]) {
566+
const element = this._elementRef.nativeElement;
576567

577-
if (toAssign.length) {
578-
element.setAttribute('aria-describedby', toAssign.join(' '));
568+
if (ids.length) {
569+
element.setAttribute('aria-describedby', ids.join(' '));
579570
} else {
580571
element.removeAttribute('aria-describedby');
581572
}

src/material/select/select.spec.ts

+10
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,16 @@ describe('MatSelect', () => {
225225
expect(select.getAttribute('aria-describedby')).toBe('test');
226226
});
227227

228+
it('should preserve aria-describedby set directly in the DOM', fakeAsync(() => {
229+
select.setAttribute('aria-describedby', 'custom');
230+
fixture.componentInstance.hint = 'test';
231+
fixture.changeDetectorRef.markForCheck();
232+
fixture.detectChanges();
233+
const hint = fixture.debugElement.query(By.css('mat-hint')).nativeElement;
234+
235+
expect(select.getAttribute('aria-describedby')).toBe(`${hint.getAttribute('id')} custom`);
236+
}));
237+
228238
it('should be able to override the tabindex', () => {
229239
fixture.componentInstance.tabIndexOverride = 3;
230240
fixture.changeDetectorRef.markForCheck();

src/material/select/select.ts

+11
Original file line numberDiff line numberDiff line change
@@ -1449,6 +1449,17 @@ export class MatSelect
14491449
return value;
14501450
}
14511451

1452+
/**
1453+
* Implemented as part of MatFormFieldControl.
1454+
* @docs-private
1455+
*/
1456+
get describedByIds(): string[] {
1457+
const element = this._elementRef.nativeElement;
1458+
const existingDescribedBy = element.getAttribute('aria-describedby');
1459+
1460+
return existingDescribedBy?.split(' ') || [];
1461+
}
1462+
14521463
/**
14531464
* Implemented as part of MatFormFieldControl.
14541465
* @docs-private

0 commit comments

Comments
 (0)