Skip to content

Commit c4f6921

Browse files
committed
fix(material/form-field): enhance error handling for multiple form field controls
Currently, when multiple mat form fields are used together, the error is taken for the first control only, this take into consideration other controls too if they exist Fixes #28887
1 parent 204b289 commit c4f6921

File tree

6 files changed

+129
-13
lines changed

6 files changed

+129
-13
lines changed

goldens/material/form-field/index.api.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte
7979
_forceDisplayInfixLabel(): boolean | 0;
8080
// (undocumented)
8181
_formFieldControl: MatFormFieldControl_2<any>;
82+
// (undocumented)
83+
_formFieldControls: QueryList<MatFormFieldControl_2<any>>;
8284
getConnectedOverlayOrigin(): ElementRef;
8385
getLabelId: i0.Signal<string | null>;
8486
_getSubscriptMessageType(): 'error' | 'hint';
@@ -121,6 +123,8 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte
121123
ngOnDestroy(): void;
122124
// (undocumented)
123125
_notchedOutline: MatFormFieldNotchedOutline | undefined;
126+
get otherFormFieldControls(): MatFormFieldControl_2<any>[];
127+
get otherFormFieldControlsErrorState(): boolean;
124128
// (undocumented)
125129
_prefixChildren: QueryList<MatPrefix>;
126130
_refreshOutlineNotchWidth(): void;
@@ -139,7 +143,7 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte
139143
// (undocumented)
140144
_textSuffixContainer: ElementRef<HTMLElement>;
141145
// (undocumented)
142-
static ɵcmp: i0.ɵɵComponentDeclaration<MatFormField, "mat-form-field", ["matFormField"], { "hideRequiredMarker": { "alias": "hideRequiredMarker"; "required": false; }; "color": { "alias": "color"; "required": false; }; "floatLabel": { "alias": "floatLabel"; "required": false; }; "appearance": { "alias": "appearance"; "required": false; }; "subscriptSizing": { "alias": "subscriptSizing"; "required": false; }; "hintLabel": { "alias": "hintLabel"; "required": false; }; }, {}, ["_labelChild", "_formFieldControl", "_prefixChildren", "_suffixChildren", "_errorChildren", "_hintChildren"], ["mat-label", "[matPrefix], [matIconPrefix]", "[matTextPrefix]", "*", "[matTextSuffix]", "[matSuffix], [matIconSuffix]", "mat-error, [matError]", "mat-hint:not([align='end'])", "mat-hint[align='end']"], true, never>;
146+
static ɵcmp: i0.ɵɵComponentDeclaration<MatFormField, "mat-form-field", ["matFormField"], { "hideRequiredMarker": { "alias": "hideRequiredMarker"; "required": false; }; "color": { "alias": "color"; "required": false; }; "floatLabel": { "alias": "floatLabel"; "required": false; }; "appearance": { "alias": "appearance"; "required": false; }; "subscriptSizing": { "alias": "subscriptSizing"; "required": false; }; "hintLabel": { "alias": "hintLabel"; "required": false; }; }, {}, ["_labelChild", "_formFieldControl", "_formFieldControls", "_prefixChildren", "_suffixChildren", "_errorChildren", "_hintChildren"], ["mat-label", "[matPrefix], [matIconPrefix]", "[matTextPrefix]", "*", "[matTextSuffix]", "[matSuffix], [matIconSuffix]", "mat-error, [matError]", "mat-hint:not([align='end'])", "mat-hint[align='end']"], true, never>;
143147
// (undocumented)
144148
static ɵfac: i0.ɵɵFactoryDeclaration<MatFormField, never>;
145149
}

goldens/material/input/index.api.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte
7272
_forceDisplayInfixLabel(): boolean | 0;
7373
// (undocumented)
7474
_formFieldControl: MatFormFieldControl<any>;
75+
// (undocumented)
76+
_formFieldControls: QueryList<MatFormFieldControl<any>>;
7577
getConnectedOverlayOrigin(): ElementRef;
7678
getLabelId: i0.Signal<string | null>;
7779
_getSubscriptMessageType(): 'error' | 'hint';
@@ -114,6 +116,8 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte
114116
ngOnDestroy(): void;
115117
// (undocumented)
116118
_notchedOutline: MatFormFieldNotchedOutline | undefined;
119+
get otherFormFieldControls(): MatFormFieldControl<any>[];
120+
get otherFormFieldControlsErrorState(): boolean;
117121
// (undocumented)
118122
_prefixChildren: QueryList<MatPrefix>;
119123
_refreshOutlineNotchWidth(): void;
@@ -132,7 +136,7 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte
132136
// (undocumented)
133137
_textSuffixContainer: ElementRef<HTMLElement>;
134138
// (undocumented)
135-
static ɵcmp: i0.ɵɵComponentDeclaration<MatFormField, "mat-form-field", ["matFormField"], { "hideRequiredMarker": { "alias": "hideRequiredMarker"; "required": false; }; "color": { "alias": "color"; "required": false; }; "floatLabel": { "alias": "floatLabel"; "required": false; }; "appearance": { "alias": "appearance"; "required": false; }; "subscriptSizing": { "alias": "subscriptSizing"; "required": false; }; "hintLabel": { "alias": "hintLabel"; "required": false; }; }, {}, ["_labelChild", "_formFieldControl", "_prefixChildren", "_suffixChildren", "_errorChildren", "_hintChildren"], ["mat-label", "[matPrefix], [matIconPrefix]", "[matTextPrefix]", "*", "[matTextSuffix]", "[matSuffix], [matIconSuffix]", "mat-error, [matError]", "mat-hint:not([align='end'])", "mat-hint[align='end']"], true, never>;
139+
static ɵcmp: i0.ɵɵComponentDeclaration<MatFormField, "mat-form-field", ["matFormField"], { "hideRequiredMarker": { "alias": "hideRequiredMarker"; "required": false; }; "color": { "alias": "color"; "required": false; }; "floatLabel": { "alias": "floatLabel"; "required": false; }; "appearance": { "alias": "appearance"; "required": false; }; "subscriptSizing": { "alias": "subscriptSizing"; "required": false; }; "hintLabel": { "alias": "hintLabel"; "required": false; }; }, {}, ["_labelChild", "_formFieldControl", "_formFieldControls", "_prefixChildren", "_suffixChildren", "_errorChildren", "_hintChildren"], ["mat-label", "[matPrefix], [matIconPrefix]", "[matTextPrefix]", "*", "[matTextSuffix]", "[matSuffix], [matIconSuffix]", "mat-error, [matError]", "mat-hint:not([align='end'])", "mat-hint[align='end']"], true, never>;
136140
// (undocumented)
137141
static ɵfac: i0.ɵɵFactoryDeclaration<MatFormField, never>;
138142
}

goldens/material/select/index.api.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte
9595
_forceDisplayInfixLabel(): boolean | 0;
9696
// (undocumented)
9797
_formFieldControl: MatFormFieldControl_2<any>;
98+
// (undocumented)
99+
_formFieldControls: QueryList<MatFormFieldControl_2<any>>;
98100
getConnectedOverlayOrigin(): ElementRef;
99101
getLabelId: i0.Signal<string | null>;
100102
_getSubscriptMessageType(): 'error' | 'hint';
@@ -137,6 +139,8 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte
137139
ngOnDestroy(): void;
138140
// (undocumented)
139141
_notchedOutline: MatFormFieldNotchedOutline | undefined;
142+
get otherFormFieldControls(): MatFormFieldControl_2<any>[];
143+
get otherFormFieldControlsErrorState(): boolean;
140144
// (undocumented)
141145
_prefixChildren: QueryList<MatPrefix>;
142146
_refreshOutlineNotchWidth(): void;
@@ -155,7 +159,7 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte
155159
// (undocumented)
156160
_textSuffixContainer: ElementRef<HTMLElement>;
157161
// (undocumented)
158-
static ɵcmp: i0.ɵɵComponentDeclaration<MatFormField, "mat-form-field", ["matFormField"], { "hideRequiredMarker": { "alias": "hideRequiredMarker"; "required": false; }; "color": { "alias": "color"; "required": false; }; "floatLabel": { "alias": "floatLabel"; "required": false; }; "appearance": { "alias": "appearance"; "required": false; }; "subscriptSizing": { "alias": "subscriptSizing"; "required": false; }; "hintLabel": { "alias": "hintLabel"; "required": false; }; }, {}, ["_labelChild", "_formFieldControl", "_prefixChildren", "_suffixChildren", "_errorChildren", "_hintChildren"], ["mat-label", "[matPrefix], [matIconPrefix]", "[matTextPrefix]", "*", "[matTextSuffix]", "[matSuffix], [matIconSuffix]", "mat-error, [matError]", "mat-hint:not([align='end'])", "mat-hint[align='end']"], true, never>;
162+
static ɵcmp: i0.ɵɵComponentDeclaration<MatFormField, "mat-form-field", ["matFormField"], { "hideRequiredMarker": { "alias": "hideRequiredMarker"; "required": false; }; "color": { "alias": "color"; "required": false; }; "floatLabel": { "alias": "floatLabel"; "required": false; }; "appearance": { "alias": "appearance"; "required": false; }; "subscriptSizing": { "alias": "subscriptSizing"; "required": false; }; "hintLabel": { "alias": "hintLabel"; "required": false; }; }, {}, ["_labelChild", "_formFieldControl", "_formFieldControls", "_prefixChildren", "_suffixChildren", "_errorChildren", "_hintChildren"], ["mat-label", "[matPrefix], [matIconPrefix]", "[matTextPrefix]", "*", "[matTextSuffix]", "[matSuffix], [matIconSuffix]", "mat-error, [matError]", "mat-hint:not([align='end'])", "mat-hint[align='end']"], true, never>;
159163
// (undocumented)
160164
static ɵfac: i0.ɵɵFactoryDeclaration<MatFormField, never>;
161165
}

src/material/chips/chip-grid.spec.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -867,6 +867,50 @@ describe('MatChipGrid', () => {
867867
});
868868
});
869869

870+
describe('error message when multiple form field controls', () => {
871+
let fixture: ComponentFixture<ChipGridWithMultipleControls>;
872+
let errorTestComponent: ChipGridWithMultipleControls;
873+
let containerEl: HTMLElement;
874+
let chipGridEl: HTMLElement;
875+
876+
beforeEach(fakeAsync(() => {
877+
fixture = createComponent(ChipGridWithMultipleControls);
878+
flush();
879+
fixture.detectChanges();
880+
881+
errorTestComponent = fixture.componentInstance;
882+
containerEl = fixture.debugElement.query(By.css('mat-form-field'))!.nativeElement;
883+
chipGridEl = fixture.debugElement.query(By.css('mat-chip-grid'))!.nativeElement;
884+
}));
885+
886+
it('should display an error message when the grid is touched and invalid when multiple controls in mat form field', fakeAsync(() => {
887+
expect(errorTestComponent.chipCtrl.invalid)
888+
.withContext('Expected form control to be invalid')
889+
.toBe(true);
890+
expect(containerEl.querySelectorAll('mat-error').length)
891+
.withContext('Expected no error message')
892+
.toBe(0);
893+
894+
expect(containerEl.classList)
895+
.withContext('Expected container not to have the invalid CSS class.')
896+
.not.toContain('mat-form-field-invalid');
897+
898+
errorTestComponent.chipCtrl.markAsTouched();
899+
fixture.detectChanges();
900+
tick();
901+
902+
expect(containerEl.classList)
903+
.withContext('Expected container to have the invalid CSS class.')
904+
.toContain('mat-form-field-invalid');
905+
expect(containerEl.querySelectorAll('mat-error').length)
906+
.withContext('Expected one error message to have been rendered.')
907+
.toBe(1);
908+
expect(chipGridEl.getAttribute('aria-invalid'))
909+
.withContext('Expected aria-invalid to be set to "true".')
910+
.toBe('true');
911+
}));
912+
});
913+
870914
describe('error messages', () => {
871915
let fixture: ComponentFixture<ChipGridWithFormErrorMessages>;
872916
let errorTestComponent: ChipGridWithFormErrorMessages;
@@ -1228,3 +1272,36 @@ class ChipGridWithRemove {
12281272
this.chips.splice(event.chip.value, 1);
12291273
}
12301274
}
1275+
1276+
@Component({
1277+
template: `
1278+
<mat-form-field>
1279+
<input
1280+
matInput
1281+
type="text"
1282+
placeholder="New item..."
1283+
[matChipInputFor]="chipGrid"
1284+
/>
1285+
1286+
<mat-chip-grid #chipGrid required [formControl]="chipCtrl">
1287+
@for (i of chips; track i) {
1288+
<mat-chip-row [value]="i" >
1289+
Chip {{i + 1}}
1290+
<span matChipRemove>Remove</span>
1291+
</mat-chip-row>
1292+
}
1293+
</mat-chip-grid>
1294+
1295+
<mat-error data-test-id="errors">
1296+
@if (chipCtrl.errors?.['required']) {
1297+
{{ 'Error occurs' }}
1298+
}
1299+
</mat-error>
1300+
</mat-form-field>
1301+
`,
1302+
standalone: false,
1303+
})
1304+
class ChipGridWithMultipleControls {
1305+
chipCtrl = new FormControl();
1306+
chips = [0, 1, 2, 3, 4];
1307+
}

src/material/form-field/form-field.html

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
[class.mdc-text-field--outlined]="_hasOutline()"
4343
[class.mdc-text-field--no-label]="!_hasFloatingLabel()"
4444
[class.mdc-text-field--disabled]="_control.disabled"
45-
[class.mdc-text-field--invalid]="_control.errorState"
45+
[class.mdc-text-field--invalid]="_control.errorState || otherFormFieldControlsErrorState"
4646
(click)="_control.onContainerClick($event)"
4747
>
4848
@if (!_hasOutline() && !_control.disabled) {
@@ -96,20 +96,20 @@
9696
</div>
9797

9898
<div
99-
class="mat-mdc-form-field-subscript-wrapper mat-mdc-form-field-bottom-align"
100-
[class.mat-mdc-form-field-subscript-dynamic-size]="subscriptSizing === 'dynamic'"
101-
>
99+
class="mat-mdc-form-field-subscript-wrapper mat-mdc-form-field-bottom-align"
100+
[class.mat-mdc-form-field-subscript-dynamic-size]="subscriptSizing === 'dynamic'">
102101
@let subscriptMessageType = _getSubscriptMessageType();
103102

104103
<!--
105104
Use a single permanent wrapper for both hints and errors so aria-live works correctly,
106105
as having it appear post render will not consistently work. We also do not want to add
107106
additional divs as it causes styling regressions.
108107
-->
109-
<div aria-atomic="true" aria-live="polite"
110-
[class.mat-mdc-form-field-error-wrapper]="subscriptMessageType === 'error'"
111-
[class.mat-mdc-form-field-hint-wrapper]="subscriptMessageType === 'hint'"
112-
>
108+
<div
109+
aria-atomic="true"
110+
aria-live="polite"
111+
[class.mat-mdc-form-field-error-wrapper]="subscriptMessageType === 'error'"
112+
[class.mat-mdc-form-field-hint-wrapper]="subscriptMessageType === 'hint'">
113113
@switch (subscriptMessageType) {
114114
@case ('error') {
115115
<ng-content select="mat-error, [matError]"></ng-content>

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

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ interface MatFormFieldControl<T> extends _MatFormFieldControl<T> {}
149149
// Note that these classes reuse the same names as the non-MDC version, because they can be
150150
// considered a public API since custom form controls may use them to style themselves.
151151
// See https://github.com/angular/components/pull/20502#discussion_r486124901.
152-
'[class.mat-form-field-invalid]': '_control.errorState',
152+
'[class.mat-form-field-invalid]': '_control.errorState || otherFormFieldControlsErrorState',
153153
'[class.mat-form-field-disabled]': '_control.disabled',
154154
'[class.mat-form-field-autofilled]': '_control.autofilled',
155155
'[class.mat-form-field-appearance-fill]': 'appearance == "fill"',
@@ -219,6 +219,9 @@ export class MatFormField
219219
});
220220

221221
@ContentChild(_MatFormFieldControl) _formFieldControl: MatFormFieldControl<any>;
222+
@ContentChildren(_MatFormFieldControl, {descendants: true}) _formFieldControls: QueryList<
223+
MatFormFieldControl<any>
224+
>;
222225
@ContentChildren(MAT_PREFIX, {descendants: true}) _prefixChildren: QueryList<MatPrefix>;
223226
@ContentChildren(MAT_SUFFIX, {descendants: true}) _suffixChildren: QueryList<MatSuffix>;
224227
@ContentChildren(MAT_ERROR, {descendants: true}) _errorChildren: QueryList<MatError>;
@@ -327,12 +330,23 @@ export class MatFormField
327330
this._explicitFormFieldControl = value;
328331
}
329332

333+
/** Gets the other form field controls if any */
334+
get otherFormFieldControls(): MatFormFieldControl<any>[] {
335+
return this._formFieldControls.filter(control => control.id !== this._control.id);
336+
}
337+
338+
/** Gets the error state of other form field controls if any */
339+
get otherFormFieldControlsErrorState(): boolean {
340+
return this.otherFormFieldControls.some(control => control.errorState);
341+
}
342+
330343
private _destroyed = new Subject<void>();
331344
private _isFocused: boolean | null = null;
332345
private _explicitFormFieldControl: MatFormFieldControl<any>;
333346
private _previousControl: MatFormFieldControl<unknown> | null = null;
334347
private _previousControlValidatorFn: ValidatorFn | null = null;
335348
private _stateChanges: Subscription | undefined;
349+
private _otherControlStateChanges: Subscription | undefined;
336350
private _valueChanges: Subscription | undefined;
337351
private _describedByChanges: Subscription | undefined;
338352
protected readonly _animationsDisabled = _animationsDisabled();
@@ -412,6 +426,7 @@ export class MatFormField
412426
ngOnDestroy() {
413427
this._outlineLabelOffsetResizeObserver?.disconnect();
414428
this._stateChanges?.unsubscribe();
429+
this._otherControlStateChanges?.unsubscribe();
415430
this._valueChanges?.unsubscribe();
416431
this._describedByChanges?.unsubscribe();
417432
this._destroyed.next();
@@ -466,6 +481,16 @@ export class MatFormField
466481
this._changeDetectorRef.markForCheck();
467482
});
468483

484+
if (this.otherFormFieldControls.length) {
485+
this._otherControlStateChanges?.unsubscribe();
486+
this.otherFormFieldControls.map(control => {
487+
const subscription = control.stateChanges.subscribe(() =>
488+
this._changeDetectorRef.markForCheck(),
489+
);
490+
this._otherControlStateChanges?.add(subscription);
491+
});
492+
}
493+
469494
// Updating the `aria-describedby` touches the DOM. Only do it if it actually needs to change.
470495
this._describedByChanges?.unsubscribe();
471496
this._describedByChanges = control.stateChanges
@@ -632,7 +657,9 @@ export class MatFormField
632657

633658
/** Gets the type of subscript message to render (error or hint). */
634659
_getSubscriptMessageType(): 'error' | 'hint' {
635-
return this._errorChildren && this._errorChildren.length > 0 && this._control.errorState
660+
return this._errorChildren &&
661+
this._errorChildren.length > 0 &&
662+
(this._control.errorState || this.otherFormFieldControlsErrorState)
636663
? 'error'
637664
: 'hint';
638665
}

0 commit comments

Comments
 (0)