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