Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(material/chips): creates default aria-labelledby or placeholder for chips input #30430

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/material/chips/chip-grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -311,6 +316,7 @@ export class MatChipGrid
registerInput(inputElement: MatChipTextControl): void {
this._chipInput = inputElement;
this._chipInput.setDescribedByIds(this._ariaDescribedbyIds);
this._chipInput.setLabelledByIds(this._ariaLabelledbyIds);
}

/**
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/material/chips/chip-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions src/material/chips/chip-text-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
9 changes: 9 additions & 0 deletions src/material/form-field/form-field-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
59 changes: 59 additions & 0 deletions src/material/form-field/form-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]);
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -622,6 +639,7 @@ export class MatFormField
private _processHints() {
this._validateHints();
this._syncDescribedByIds();
this._syncLabelledByIds();
}

/**
Expand Down Expand Up @@ -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
Expand Down
Loading