From c4f69218db3d36049f281ef47b1793f0737b056c Mon Sep 17 00:00:00 2001 From: Karan Mistry Date: Mon, 21 Apr 2025 15:36:24 +0530 Subject: [PATCH] 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 --- goldens/material/form-field/index.api.md | 6 +- goldens/material/input/index.api.md | 6 +- goldens/material/select/index.api.md | 6 +- src/material/chips/chip-grid.spec.ts | 77 ++++++++++++++++++++++++ src/material/form-field/form-field.html | 16 ++--- src/material/form-field/form-field.ts | 31 +++++++++- 6 files changed, 129 insertions(+), 13 deletions(-) diff --git a/goldens/material/form-field/index.api.md b/goldens/material/form-field/index.api.md index 927940c1be22..681b1a74faaf 100644 --- a/goldens/material/form-field/index.api.md +++ b/goldens/material/form-field/index.api.md @@ -79,6 +79,8 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte _forceDisplayInfixLabel(): boolean | 0; // (undocumented) _formFieldControl: MatFormFieldControl_2; + // (undocumented) + _formFieldControls: QueryList>; getConnectedOverlayOrigin(): ElementRef; getLabelId: i0.Signal; _getSubscriptMessageType(): 'error' | 'hint'; @@ -121,6 +123,8 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte ngOnDestroy(): void; // (undocumented) _notchedOutline: MatFormFieldNotchedOutline | undefined; + get otherFormFieldControls(): MatFormFieldControl_2[]; + get otherFormFieldControlsErrorState(): boolean; // (undocumented) _prefixChildren: QueryList; _refreshOutlineNotchWidth(): void; @@ -139,7 +143,7 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte // (undocumented) _textSuffixContainer: ElementRef; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } diff --git a/goldens/material/input/index.api.md b/goldens/material/input/index.api.md index d0ab82b1bab6..96d3b967f2ef 100644 --- a/goldens/material/input/index.api.md +++ b/goldens/material/input/index.api.md @@ -72,6 +72,8 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte _forceDisplayInfixLabel(): boolean | 0; // (undocumented) _formFieldControl: MatFormFieldControl; + // (undocumented) + _formFieldControls: QueryList>; getConnectedOverlayOrigin(): ElementRef; getLabelId: i0.Signal; _getSubscriptMessageType(): 'error' | 'hint'; @@ -114,6 +116,8 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte ngOnDestroy(): void; // (undocumented) _notchedOutline: MatFormFieldNotchedOutline | undefined; + get otherFormFieldControls(): MatFormFieldControl[]; + get otherFormFieldControlsErrorState(): boolean; // (undocumented) _prefixChildren: QueryList; _refreshOutlineNotchWidth(): void; @@ -132,7 +136,7 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte // (undocumented) _textSuffixContainer: ElementRef; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } diff --git a/goldens/material/select/index.api.md b/goldens/material/select/index.api.md index 084750dd1a86..d254a9950a6a 100644 --- a/goldens/material/select/index.api.md +++ b/goldens/material/select/index.api.md @@ -95,6 +95,8 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte _forceDisplayInfixLabel(): boolean | 0; // (undocumented) _formFieldControl: MatFormFieldControl_2; + // (undocumented) + _formFieldControls: QueryList>; getConnectedOverlayOrigin(): ElementRef; getLabelId: i0.Signal; _getSubscriptMessageType(): 'error' | 'hint'; @@ -137,6 +139,8 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte ngOnDestroy(): void; // (undocumented) _notchedOutline: MatFormFieldNotchedOutline | undefined; + get otherFormFieldControls(): MatFormFieldControl_2[]; + get otherFormFieldControlsErrorState(): boolean; // (undocumented) _prefixChildren: QueryList; _refreshOutlineNotchWidth(): void; @@ -155,7 +159,7 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte // (undocumented) _textSuffixContainer: ElementRef; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } diff --git a/src/material/chips/chip-grid.spec.ts b/src/material/chips/chip-grid.spec.ts index 476d19ef4c50..c04986f56c8b 100644 --- a/src/material/chips/chip-grid.spec.ts +++ b/src/material/chips/chip-grid.spec.ts @@ -867,6 +867,50 @@ describe('MatChipGrid', () => { }); }); + describe('error message when multiple form field controls', () => { + let fixture: ComponentFixture; + let errorTestComponent: ChipGridWithMultipleControls; + let containerEl: HTMLElement; + let chipGridEl: HTMLElement; + + beforeEach(fakeAsync(() => { + fixture = createComponent(ChipGridWithMultipleControls); + flush(); + fixture.detectChanges(); + + errorTestComponent = fixture.componentInstance; + containerEl = fixture.debugElement.query(By.css('mat-form-field'))!.nativeElement; + chipGridEl = fixture.debugElement.query(By.css('mat-chip-grid'))!.nativeElement; + })); + + it('should display an error message when the grid is touched and invalid when multiple controls in mat form field', fakeAsync(() => { + expect(errorTestComponent.chipCtrl.invalid) + .withContext('Expected form control to be invalid') + .toBe(true); + expect(containerEl.querySelectorAll('mat-error').length) + .withContext('Expected no error message') + .toBe(0); + + expect(containerEl.classList) + .withContext('Expected container not to have the invalid CSS class.') + .not.toContain('mat-form-field-invalid'); + + errorTestComponent.chipCtrl.markAsTouched(); + fixture.detectChanges(); + tick(); + + expect(containerEl.classList) + .withContext('Expected container to have the invalid CSS class.') + .toContain('mat-form-field-invalid'); + expect(containerEl.querySelectorAll('mat-error').length) + .withContext('Expected one error message to have been rendered.') + .toBe(1); + expect(chipGridEl.getAttribute('aria-invalid')) + .withContext('Expected aria-invalid to be set to "true".') + .toBe('true'); + })); + }); + describe('error messages', () => { let fixture: ComponentFixture; let errorTestComponent: ChipGridWithFormErrorMessages; @@ -1228,3 +1272,36 @@ class ChipGridWithRemove { this.chips.splice(event.chip.value, 1); } } + +@Component({ + template: ` + + + + + @for (i of chips; track i) { + + Chip {{i + 1}} + Remove + + } + + + + @if (chipCtrl.errors?.['required']) { + {{ 'Error occurs' }} + } + + + `, + standalone: false, +}) +class ChipGridWithMultipleControls { + chipCtrl = new FormControl(); + chips = [0, 1, 2, 3, 4]; +} diff --git a/src/material/form-field/form-field.html b/src/material/form-field/form-field.html index 2697be0d764c..6f8a6860a2fb 100644 --- a/src/material/form-field/form-field.html +++ b/src/material/form-field/form-field.html @@ -42,7 +42,7 @@ [class.mdc-text-field--outlined]="_hasOutline()" [class.mdc-text-field--no-label]="!_hasFloatingLabel()" [class.mdc-text-field--disabled]="_control.disabled" - [class.mdc-text-field--invalid]="_control.errorState" + [class.mdc-text-field--invalid]="_control.errorState || otherFormFieldControlsErrorState" (click)="_control.onContainerClick($event)" > @if (!_hasOutline() && !_control.disabled) { @@ -96,9 +96,8 @@
+ class="mat-mdc-form-field-subscript-wrapper mat-mdc-form-field-bottom-align" + [class.mat-mdc-form-field-subscript-dynamic-size]="subscriptSizing === 'dynamic'"> @let subscriptMessageType = _getSubscriptMessageType(); -
+
@switch (subscriptMessageType) { @case ('error') { diff --git a/src/material/form-field/form-field.ts b/src/material/form-field/form-field.ts index bed240595668..d0879ce0f9b5 100644 --- a/src/material/form-field/form-field.ts +++ b/src/material/form-field/form-field.ts @@ -149,7 +149,7 @@ interface MatFormFieldControl extends _MatFormFieldControl {} // Note that these classes reuse the same names as the non-MDC version, because they can be // considered a public API since custom form controls may use them to style themselves. // See https://github.com/angular/components/pull/20502#discussion_r486124901. - '[class.mat-form-field-invalid]': '_control.errorState', + '[class.mat-form-field-invalid]': '_control.errorState || otherFormFieldControlsErrorState', '[class.mat-form-field-disabled]': '_control.disabled', '[class.mat-form-field-autofilled]': '_control.autofilled', '[class.mat-form-field-appearance-fill]': 'appearance == "fill"', @@ -219,6 +219,9 @@ export class MatFormField }); @ContentChild(_MatFormFieldControl) _formFieldControl: MatFormFieldControl; + @ContentChildren(_MatFormFieldControl, {descendants: true}) _formFieldControls: QueryList< + MatFormFieldControl + >; @ContentChildren(MAT_PREFIX, {descendants: true}) _prefixChildren: QueryList; @ContentChildren(MAT_SUFFIX, {descendants: true}) _suffixChildren: QueryList; @ContentChildren(MAT_ERROR, {descendants: true}) _errorChildren: QueryList; @@ -327,12 +330,23 @@ export class MatFormField this._explicitFormFieldControl = value; } + /** Gets the other form field controls if any */ + get otherFormFieldControls(): MatFormFieldControl[] { + return this._formFieldControls.filter(control => control.id !== this._control.id); + } + + /** Gets the error state of other form field controls if any */ + get otherFormFieldControlsErrorState(): boolean { + return this.otherFormFieldControls.some(control => control.errorState); + } + private _destroyed = new Subject(); private _isFocused: boolean | null = null; private _explicitFormFieldControl: MatFormFieldControl; private _previousControl: MatFormFieldControl | null = null; private _previousControlValidatorFn: ValidatorFn | null = null; private _stateChanges: Subscription | undefined; + private _otherControlStateChanges: Subscription | undefined; private _valueChanges: Subscription | undefined; private _describedByChanges: Subscription | undefined; protected readonly _animationsDisabled = _animationsDisabled(); @@ -412,6 +426,7 @@ export class MatFormField ngOnDestroy() { this._outlineLabelOffsetResizeObserver?.disconnect(); this._stateChanges?.unsubscribe(); + this._otherControlStateChanges?.unsubscribe(); this._valueChanges?.unsubscribe(); this._describedByChanges?.unsubscribe(); this._destroyed.next(); @@ -466,6 +481,16 @@ export class MatFormField this._changeDetectorRef.markForCheck(); }); + if (this.otherFormFieldControls.length) { + this._otherControlStateChanges?.unsubscribe(); + this.otherFormFieldControls.map(control => { + const subscription = control.stateChanges.subscribe(() => + this._changeDetectorRef.markForCheck(), + ); + this._otherControlStateChanges?.add(subscription); + }); + } + // Updating the `aria-describedby` touches the DOM. Only do it if it actually needs to change. this._describedByChanges?.unsubscribe(); this._describedByChanges = control.stateChanges @@ -632,7 +657,9 @@ export class MatFormField /** Gets the type of subscript message to render (error or hint). */ _getSubscriptMessageType(): 'error' | 'hint' { - return this._errorChildren && this._errorChildren.length > 0 && this._control.errorState + return this._errorChildren && + this._errorChildren.length > 0 && + (this._control.errorState || this.otherFormFieldControlsErrorState) ? 'error' : 'hint'; }