Skip to content

Commit 84ed56e

Browse files
committed
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
1 parent 6a48ffd commit 84ed56e

File tree

5 files changed

+96
-0
lines changed

5 files changed

+96
-0
lines changed

src/material/chips/chip-grid.ts

+13
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ export class MatChipGrid
106106
*/
107107
private _ariaDescribedbyIds: string[] = [];
108108

109+
/**
110+
* List of element ids to propagate to the chipInput's aria-labelledby attribute.
111+
*/
112+
private _ariaLabelledbyIds: string[] = [];
113+
109114
/**
110115
* Function when touched. Set as part of ControlValueAccessor implementation.
111116
* @docs-private
@@ -311,6 +316,7 @@ export class MatChipGrid
311316
registerInput(inputElement: MatChipTextControl): void {
312317
this._chipInput = inputElement;
313318
this._chipInput.setDescribedByIds(this._ariaDescribedbyIds);
319+
this._chipInput.setLabelledByIds(this._ariaLabelledbyIds);
314320
}
315321

316322
/**
@@ -360,6 +366,13 @@ export class MatChipGrid
360366
this._chipInput?.setDescribedByIds(ids);
361367
}
362368

369+
setLabelledByIds(ids: string[]) {
370+
// We must keep this up to date to handle the case where ids are set
371+
// before the chip input is registered.
372+
this._ariaDescribedbyIds = ids;
373+
this._chipInput?.setLabelledByIds(ids);
374+
}
375+
363376
/**
364377
* Implemented as part of ControlValueAccessor.
365378
* @docs-private

src/material/chips/chip-input.ts

+12
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,18 @@ export class MatChipInput implements MatChipTextControl, OnChanges, OnDestroy {
219219
}
220220
}
221221

222+
setLabelledByIds(ids: string[]): void {
223+
const element = this._elementRef.nativeElement;
224+
225+
// Set the value directly in the DOM since this binding
226+
// is prone to "changed after checked" errors.
227+
if (ids.length) {
228+
element.setAttribute('aria-labelledby', ids.join(' '));
229+
} else {
230+
element.removeAttribute('aria-labelledby');
231+
}
232+
}
233+
222234
/** Checks whether a keycode is one of the configured separators. */
223235
private _isSeparatorKey(event: KeyboardEvent) {
224236
return !hasModifierKey(event) && new Set(this.separatorKeyCodes).has(event.keyCode);

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

+3
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,7 @@ export interface MatChipTextControl {
2525

2626
/** Sets the list of ids the input is described by. */
2727
setDescribedByIds(ids: string[]): void;
28+
29+
/** Sets the list of ids the input is labelled by. */
30+
setLabelledByIds(ids: string[]): void;
2831
}

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

+9
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ export abstract class MatFormFieldControl<T> {
6868
*/
6969
readonly userAriaDescribedBy?: string;
7070

71+
/**
72+
* Value of `aria-labelledby` that should be merged with the labelled-by ids
73+
* which are set by the form-field.
74+
*/
75+
readonly userAriaLabelledBy?: string;
76+
7177
/**
7278
* Whether to automatically assign the ID of the form field as the `for` attribute
7379
* on the `<label>` inside the form field. Set this to true to prevent the form
@@ -78,6 +84,9 @@ export abstract class MatFormFieldControl<T> {
7884
/** Sets the list of element IDs that currently describe this control. */
7985
abstract setDescribedByIds(ids: string[]): void;
8086

87+
/** Sets the list of element IDs that currently label this control. */
88+
abstract setLabelledByIds(ids: string[]): void;
89+
8190
/** Handles a click on the control's container. */
8291
abstract onContainerClick(event: MouseEvent): void;
8392
}

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

+59
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ export class MatFormField
325325
private _stateChanges: Subscription | undefined;
326326
private _valueChanges: Subscription | undefined;
327327
private _describedByChanges: Subscription | undefined;
328+
private _labelledByChanges: Subscription | undefined;
328329
protected readonly _animationsDisabled: boolean;
329330

330331
constructor(...args: unknown[]);
@@ -384,6 +385,7 @@ export class MatFormField
384385
this._stateChanges?.unsubscribe();
385386
this._valueChanges?.unsubscribe();
386387
this._describedByChanges?.unsubscribe();
388+
this._labelledByChanges?.unsubscribe();
387389
this._destroyed.next();
388390
this._destroyed.complete();
389391
}
@@ -449,6 +451,19 @@ export class MatFormField
449451
)
450452
.subscribe(() => this._syncDescribedByIds());
451453

454+
// Updating the `aria-labelledby` touches the DOM. Only do it if it actually needs to change.
455+
this._labelledByChanges?.unsubscribe();
456+
this._labelledByChanges = control.stateChanges
457+
.pipe(
458+
startWith([undefined, undefined] as const),
459+
map(() => [control.errorState, control.userAriaLabelledBy] as const),
460+
pairwise(),
461+
filter(([[prevErrorState, prevLabelledBy], [currentErrorState, currentLabelledBy]]) => {
462+
return prevErrorState !== currentErrorState || prevLabelledBy !== currentLabelledBy;
463+
}),
464+
)
465+
.subscribe(() => this._syncLabelledByIds());
466+
452467
this._valueChanges?.unsubscribe();
453468

454469
// Run change detection if the value changes.
@@ -493,12 +508,14 @@ export class MatFormField
493508
// Update the aria-described by when the number of errors changes.
494509
this._errorChildren.changes.subscribe(() => {
495510
this._syncDescribedByIds();
511+
this._syncLabelledByIds();
496512
this._changeDetectorRef.markForCheck();
497513
});
498514

499515
// Initial mat-hint validation and subscript describedByIds sync.
500516
this._validateHints();
501517
this._syncDescribedByIds();
518+
this._syncLabelledByIds();
502519
}
503520

504521
/** Throws an error if the form field's control is missing. */
@@ -622,6 +639,7 @@ export class MatFormField
622639
private _processHints() {
623640
this._validateHints();
624641
this._syncDescribedByIds();
642+
this._syncLabelledByIds();
625643
}
626644

627645
/**
@@ -691,6 +709,47 @@ export class MatFormField
691709
}
692710
}
693711

712+
/**
713+
* Sets the list of element IDs that describe the child control. This allows the control to update
714+
* its `aria-describedby` attribute accordingly.
715+
*/
716+
private _syncLabelledByIds() {
717+
if (this._control) {
718+
let ids: string[] = [];
719+
720+
// TODO(wagnermaciel): Remove the type check when we find the root cause of this bug.
721+
if (
722+
this._control.userAriaLabelledBy &&
723+
typeof this._control.userAriaLabelledBy === 'string'
724+
) {
725+
ids.push(...this._control.userAriaLabelledBy.split(' '));
726+
}
727+
728+
if (this._getDisplayedMessages() === 'hint') {
729+
const startHint = this._hintChildren
730+
? this._hintChildren.find(hint => hint.align === 'start')
731+
: null;
732+
const endHint = this._hintChildren
733+
? this._hintChildren.find(hint => hint.align === 'end')
734+
: null;
735+
736+
if (startHint) {
737+
ids.push(startHint.id);
738+
} else if (this._hintLabel) {
739+
ids.push(this._hintLabelId);
740+
}
741+
742+
if (endHint) {
743+
ids.push(endHint.id);
744+
}
745+
} else if (this._errorChildren) {
746+
ids.push(...this._errorChildren.map(error => error.id));
747+
}
748+
749+
this._control.setLabelledByIds(ids);
750+
}
751+
}
752+
694753
/**
695754
* Updates the horizontal offset of the label in the outline appearance. In the outline
696755
* appearance, the notched-outline and label are not relative to the infix container because

0 commit comments

Comments
 (0)