From 84ed56ed999f6a9a50108019323ea2804ac9f473 Mon Sep 17 00:00:00 2001 From: Joy Serquina <serquina@google.com> Date: Fri, 31 Jan 2025 23:39:08 +0000 Subject: [PATCH] fix(material/chips): creates default aria-labelledby or placeholder for chips input Updates Angular Components Chips input so that when they are used together that the input if there is no aria-label, defaults to adding an aria-labelledby the mat-label of the mat-form-field mat-label to improve accessibility for Voice Control. Fixes b/380092814 --- src/material/chips/chip-grid.ts | 13 ++++ src/material/chips/chip-input.ts | 12 ++++ src/material/chips/chip-text-control.ts | 3 + src/material/form-field/form-field-control.ts | 9 +++ src/material/form-field/form-field.ts | 59 +++++++++++++++++++ 5 files changed, 96 insertions(+) diff --git a/src/material/chips/chip-grid.ts b/src/material/chips/chip-grid.ts index 9e0132d0e5d2..f670ec6f859c 100644 --- a/src/material/chips/chip-grid.ts +++ b/src/material/chips/chip-grid.ts @@ -106,6 +106,11 @@ export class MatChipGrid */ private _ariaDescribedbyIds: string[] = []; + /** + * List of element ids to propagate to the chipInput's aria-labelledby attribute. + */ + private _ariaLabelledbyIds: string[] = []; + /** * Function when touched. Set as part of ControlValueAccessor implementation. * @docs-private @@ -311,6 +316,7 @@ export class MatChipGrid registerInput(inputElement: MatChipTextControl): void { this._chipInput = inputElement; this._chipInput.setDescribedByIds(this._ariaDescribedbyIds); + this._chipInput.setLabelledByIds(this._ariaLabelledbyIds); } /** @@ -360,6 +366,13 @@ export class MatChipGrid this._chipInput?.setDescribedByIds(ids); } + setLabelledByIds(ids: string[]) { + // We must keep this up to date to handle the case where ids are set + // before the chip input is registered. + this._ariaDescribedbyIds = ids; + this._chipInput?.setLabelledByIds(ids); + } + /** * Implemented as part of ControlValueAccessor. * @docs-private diff --git a/src/material/chips/chip-input.ts b/src/material/chips/chip-input.ts index 6c6c68be4063..499762ba569a 100644 --- a/src/material/chips/chip-input.ts +++ b/src/material/chips/chip-input.ts @@ -219,6 +219,18 @@ export class MatChipInput implements MatChipTextControl, OnChanges, OnDestroy { } } + setLabelledByIds(ids: string[]): void { + const element = this._elementRef.nativeElement; + + // Set the value directly in the DOM since this binding + // is prone to "changed after checked" errors. + if (ids.length) { + element.setAttribute('aria-labelledby', ids.join(' ')); + } else { + element.removeAttribute('aria-labelledby'); + } + } + /** Checks whether a keycode is one of the configured separators. */ private _isSeparatorKey(event: KeyboardEvent) { return !hasModifierKey(event) && new Set(this.separatorKeyCodes).has(event.keyCode); diff --git a/src/material/chips/chip-text-control.ts b/src/material/chips/chip-text-control.ts index 5cc80f363ddd..3798e04f6e9a 100644 --- a/src/material/chips/chip-text-control.ts +++ b/src/material/chips/chip-text-control.ts @@ -25,4 +25,7 @@ export interface MatChipTextControl { /** Sets the list of ids the input is described by. */ setDescribedByIds(ids: string[]): void; + + /** Sets the list of ids the input is labelled by. */ + setLabelledByIds(ids: string[]): void; } diff --git a/src/material/form-field/form-field-control.ts b/src/material/form-field/form-field-control.ts index c828280f5565..95cc0e97f3f2 100644 --- a/src/material/form-field/form-field-control.ts +++ b/src/material/form-field/form-field-control.ts @@ -68,6 +68,12 @@ export abstract class MatFormFieldControl<T> { */ readonly userAriaDescribedBy?: string; + /** + * Value of `aria-labelledby` that should be merged with the labelled-by ids + * which are set by the form-field. + */ + readonly userAriaLabelledBy?: string; + /** * Whether to automatically assign the ID of the form field as the `for` attribute * on the `<label>` inside the form field. Set this to true to prevent the form @@ -78,6 +84,9 @@ export abstract class MatFormFieldControl<T> { /** Sets the list of element IDs that currently describe this control. */ abstract setDescribedByIds(ids: string[]): void; + /** Sets the list of element IDs that currently label this control. */ + abstract setLabelledByIds(ids: string[]): void; + /** Handles a click on the control's container. */ abstract onContainerClick(event: MouseEvent): void; } diff --git a/src/material/form-field/form-field.ts b/src/material/form-field/form-field.ts index 0ea473c78d41..d3f130bf804b 100644 --- a/src/material/form-field/form-field.ts +++ b/src/material/form-field/form-field.ts @@ -325,6 +325,7 @@ export class MatFormField private _stateChanges: Subscription | undefined; private _valueChanges: Subscription | undefined; private _describedByChanges: Subscription | undefined; + private _labelledByChanges: Subscription | undefined; protected readonly _animationsDisabled: boolean; constructor(...args: unknown[]); @@ -384,6 +385,7 @@ export class MatFormField this._stateChanges?.unsubscribe(); this._valueChanges?.unsubscribe(); this._describedByChanges?.unsubscribe(); + this._labelledByChanges?.unsubscribe(); this._destroyed.next(); this._destroyed.complete(); } @@ -449,6 +451,19 @@ export class MatFormField ) .subscribe(() => this._syncDescribedByIds()); + // Updating the `aria-labelledby` touches the DOM. Only do it if it actually needs to change. + this._labelledByChanges?.unsubscribe(); + this._labelledByChanges = control.stateChanges + .pipe( + startWith([undefined, undefined] as const), + map(() => [control.errorState, control.userAriaLabelledBy] as const), + pairwise(), + filter(([[prevErrorState, prevLabelledBy], [currentErrorState, currentLabelledBy]]) => { + return prevErrorState !== currentErrorState || prevLabelledBy !== currentLabelledBy; + }), + ) + .subscribe(() => this._syncLabelledByIds()); + this._valueChanges?.unsubscribe(); // Run change detection if the value changes. @@ -493,12 +508,14 @@ export class MatFormField // Update the aria-described by when the number of errors changes. this._errorChildren.changes.subscribe(() => { this._syncDescribedByIds(); + this._syncLabelledByIds(); this._changeDetectorRef.markForCheck(); }); // Initial mat-hint validation and subscript describedByIds sync. this._validateHints(); this._syncDescribedByIds(); + this._syncLabelledByIds(); } /** Throws an error if the form field's control is missing. */ @@ -622,6 +639,7 @@ export class MatFormField private _processHints() { this._validateHints(); this._syncDescribedByIds(); + this._syncLabelledByIds(); } /** @@ -691,6 +709,47 @@ export class MatFormField } } + /** + * Sets the list of element IDs that describe the child control. This allows the control to update + * its `aria-describedby` attribute accordingly. + */ + private _syncLabelledByIds() { + if (this._control) { + let ids: string[] = []; + + // TODO(wagnermaciel): Remove the type check when we find the root cause of this bug. + if ( + this._control.userAriaLabelledBy && + typeof this._control.userAriaLabelledBy === 'string' + ) { + ids.push(...this._control.userAriaLabelledBy.split(' ')); + } + + if (this._getDisplayedMessages() === 'hint') { + const startHint = this._hintChildren + ? this._hintChildren.find(hint => hint.align === 'start') + : null; + const endHint = this._hintChildren + ? this._hintChildren.find(hint => hint.align === 'end') + : null; + + if (startHint) { + ids.push(startHint.id); + } else if (this._hintLabel) { + ids.push(this._hintLabelId); + } + + if (endHint) { + ids.push(endHint.id); + } + } else if (this._errorChildren) { + ids.push(...this._errorChildren.map(error => error.id)); + } + + this._control.setLabelledByIds(ids); + } + } + /** * Updates the horizontal offset of the label in the outline appearance. In the outline * appearance, the notched-outline and label are not relative to the infix container because