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'; }