Skip to content

Commit

Permalink
fix(material/input): preserve aria-describedby set externally
Browse files Browse the repository at this point in the history
Currently there are two sources of an `aria-describedby` for a `matInput`: the IDs of the hint/error message in the form field and any custom ones set through `aria-describedby`. This is insufficient, because the ID can also come from a direct DOM manupulation like in the `AriaDescriber`.

These changes tweak the logic to try and preserve them, because currently they get overwritten.

Fixes #30011.
  • Loading branch information
crisbeto committed Nov 14, 2024
1 parent d62c236 commit 9db29c9
Show file tree
Hide file tree
Showing 2 changed files with 34 additions and 2 deletions.
12 changes: 12 additions & 0 deletions src/material/input/input.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
24 changes: 22 additions & 2 deletions src/material/input/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -552,9 +555,26 @@ export class MatInput
*/
setDescribedByIds(ids: 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;

if (ids.length) {
element.setAttribute('aria-describedby', ids.join(' '));
if (toAssign.length) {
element.setAttribute('aria-describedby', toAssign.join(' '));
} else {
element.removeAttribute('aria-describedby');
}
Expand Down

0 comments on commit 9db29c9

Please sign in to comment.