From c0245ca352ffb78369dcca6963d098fadecd5bb8 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 16 Sep 2026 19:20:45 +0300 Subject: [PATCH 1/2] fix(forms): Correct editor validity state A pending async validator painted the invalid style. `valid` is false while validators run, and every editor mapped `valid ? VALID : INVALID`, so the error style showed before the rule had answered. The decision now lives one level up: `NgControlAdapter` exposes a `status` accessor mirroring `FormControlStatus`, and `toInputState` maps it to `IgxInputState` for all seven call sites. `status` is derived rather than read from `NgControl.status`, which the Signal Forms interop throws from on statuses it does not recognize (#17621). `aria-required` stayed true after a conditional `required` rule turned off. Angular pushes the field's `required` into the directive input during the host view update pass, which runs after the adapter's root effect has already read the stale attribute. Writing the attribute from the `required` setter puts the final write after that read (#17620). A field with no rules showed the valid state after switching `[formField]`. The interop `NgControl` is created once per directive and reused across switches, so the sticky "this field had rules" flag leaked from the previous field. The flag is now cleared while the control is pristine and untouched, which is the state the new field is observed in. Switching to an already touched or dirty field keeps the stale flag; there is no public way to detect that switch (#17619). --- CHANGELOG.md | 5 + .../combo/src/combo/combo.common.ts | 9 +- .../core/src/core/ng-control-adapter.ts | 48 ++++++-- .../date-picker/date-picker.component.spec.ts | 6 +- .../src/date-picker/date-picker.component.ts | 9 +- .../date-range-picker.component.ts | 8 +- .../directives-input/input.directive.spec.ts | 106 +++++++++++++++++- .../directives-input/input.directive.ts | 71 +++++++----- .../select/src/select/select.component.ts | 11 +- .../time-picker/time-picker.component.spec.ts | 6 +- .../src/time-picker/time-picker.component.ts | 8 +- 11 files changed, 215 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d51df20e15..1e1b7e6ae47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ All notable changes for each version of this project will be documented in this - `igx-checkbox`, `igx-switch` and `igx-radio-group` now report `required` and `aria-required` for `Validators.requiredTrue`, as `igxInput` already did. - `igx-radio-group` implements `setDisabledState`, so `control.disable()` / `enable()` and the Signal Forms `disabled` rule reach the radio buttons. Buttons disabled in the template stay disabled after `enable()`. +### Bug Fixes + +- **Forms** + - `igxInput`, `igx-select`, `igx-combo`, `igx-simple-combo`, `igx-date-picker`, `igx-time-picker` and `igx-date-range-picker` no longer paint the invalid style while an async validator is pending. A control that has not answered yet renders in its initial state and only turns invalid once the validator resolves (#17621). + ## 22.2.0 ### New Features diff --git a/projects/igniteui-angular/combo/src/combo/combo.common.ts b/projects/igniteui-angular/combo/src/combo/combo.common.ts index fcf5435467c..72021114056 100644 --- a/projects/igniteui-angular/combo/src/combo/combo.common.ts +++ b/projects/igniteui-angular/combo/src/combo/combo.common.ts @@ -47,7 +47,7 @@ import { } from 'igniteui-angular/core'; import { IForOfState, IgxForOfDirective } from 'igniteui-angular/directives'; import { IgxIconService } from 'igniteui-angular/icon'; -import { IGX_INPUT_GROUP_TYPE, IgxInputDirective, IgxInputGroupComponent, IgxInputGroupType, IgxInputState, IgxHintDirective, IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; +import { IGX_INPUT_GROUP_TYPE, IgxInputDirective, IgxInputGroupComponent, IgxInputGroupType, IgxInputState, toInputState, IgxHintDirective, IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; import { IgxComboDropDownComponent } from './combo-dropdown.component'; import { IgxComboAPIService } from './combo.api'; import { @@ -1328,11 +1328,8 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh protected onStatusChanged = () => { if (this.control && this.control.touchedOrDirty && !this.control.disabled) { - if (this.control.hasValidators && (!this.collapsed || this.inputGroup.isFocused)) { - this.valid = this.control.valid ? IgxInputState.VALID : IgxInputState.INVALID; - } else { - this.valid = this.control.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; - } + const showSuccess = this.control.hasValidators && (!this.collapsed || this.inputGroup.isFocused); + this.valid = toInputState(this.control.status, showSuccess ? 'allowed' : 'suppressed'); } else { // B.P. 18 May 2021: IgxDatePicker does not reset its state upon resetForm #9526 this.valid = IgxInputState.INITIAL; diff --git a/projects/igniteui-angular/core/src/core/ng-control-adapter.ts b/projects/igniteui-angular/core/src/core/ng-control-adapter.ts index e2f3ce4a5b4..2d03cc17e62 100644 --- a/projects/igniteui-angular/core/src/core/ng-control-adapter.ts +++ b/projects/igniteui-angular/core/src/core/ng-control-adapter.ts @@ -8,6 +8,9 @@ export type NgControlBackend = 'observable' | 'signal'; /** Whether a control took a value written through `setValue`. */ export type ValueWriteResult = 'accepted' | 'ignored'; +/** Validation outcome of a control, mirroring `FormControlStatus`. */ +export type ControlStatus = 'valid' | 'invalid' | 'pending' | 'disabled'; + /** * Uniform access to the `NgControl` bound to a form control. * @@ -46,6 +49,23 @@ export class NgControlAdapter { return !!this.ngControl.invalid; } + public get pending(): boolean { + return !!this.ngControl.pending; + } + + /** Derived, not read from `status`: the Signal Forms interop throws on a status it does not know. */ + public get status(): ControlStatus { + if (this.disabled) { + return 'disabled'; + } + + if (this.invalid) { + return 'invalid'; + } + + return this.pending ? 'pending' : 'valid'; + } + public get touchedOrDirty(): boolean { const control = this.ngControl.control; return !!(control?.touched || control?.dirty); @@ -58,7 +78,7 @@ export class NgControlAdapter { */ public get hasValidators(): boolean { if (this.backend === 'signal') { - return this.required || this.noteErrors(); + return this.required || this.sawErrors; } const control = this.ngControl.control; @@ -93,10 +113,14 @@ export class NgControlAdapter { */ public get statusChanges(): Observable { if (this.backend === 'signal') { - return this.watch(() => [ - this.ngControl.valid, this.noteErrors(), this.ngControl.pending, this.required, - this.ngControl.disabled, this.ngControl.dirty, this.ngControl.touched - ]); + return this.watch(() => { + this.observeErrors(); + + return [ + this.ngControl.valid, this.pending, this.required, + this.ngControl.disabled, this.ngControl.dirty, this.ngControl.touched + ]; + }); } return this.ngControl.statusChanges!; @@ -137,19 +161,19 @@ export class NgControlAdapter { return 'accepted'; } - /** Remembers that the field had rules. Returns the current invalid or pending state. */ - private noteErrors(): boolean { - const hasErrors = this.invalid || !!this.ngControl.pending; - this.sawErrors ||= hasErrors; - - return hasErrors || this.sawErrors; + /** + * Records that the field has rules. A `[formField]` switch reuses the same interop + * `NgControl`, so an untouched, pristine control opens a new observation window. + */ + private observeErrors(): void { + this.sawErrors = this.touchedOrDirty && (this.sawErrors || this.invalid || this.pending); } // Signal-backed getters are reactive, so an effect over them replaces the missing observables. // A root effect runs before change detection, like an observable would; a view effect would // run after the host bindings were checked. `untracked` allows subscribing from within another // effect. `toObservable` is not used: it replays and lives until the environment is destroyed. - private watch(read: () => unknown[]): Observable { + private watch(read: () => unknown): Observable { return new Observable(subscriber => { const ref = untracked(() => effect(() => { read(); diff --git a/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.spec.ts b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.spec.ts index ab161667bc7..766d7132be3 100644 --- a/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.spec.ts +++ b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.spec.ts @@ -898,7 +898,11 @@ describe('IgxDatePicker', () => { set control(val: any) { this._control = val; }, - valid: true + valid: true, + // A real control keeps the two in sync; the adapter reads `invalid`. + get invalid() { + return !this.valid; + } }; mockInjector = jasmine.createSpyObj('Injector', { get: mockNgControl diff --git a/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.ts b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.ts index beb5c8329ac..3745e44fa7c 100644 --- a/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.ts +++ b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.ts @@ -36,7 +36,7 @@ import { IFormattingViews, IFormattingOptions } from 'igniteui-angular/calendar'; import { - IgxLabelDirective, IgxInputState, IgxInputGroupComponent, IgxPrefixDirective, IgxInputDirective, IgxSuffixDirective, + IgxLabelDirective, IgxInputState, toInputState, IgxInputGroupComponent, IgxPrefixDirective, IgxInputDirective, IgxSuffixDirective, IgxReadOnlyInputDirective } from 'igniteui-angular/input-group'; import { fromEvent, Subscription, noop, MonoTypeOperatorFunction } from 'rxjs'; @@ -836,11 +836,8 @@ export class IgxDatePickerComponent extends PickerBaseDirective implements Contr private updateValidity() { // B.P. 18 May 2021: IgxDatePicker does not reset its state upon resetForm #9526 if (this._control && !this.disabled && this._control.touchedOrDirty) { - if (this._control.hasValidators && this.inputGroup.isFocused) { - this.inputDirective.valid = this._control.valid ? IgxInputState.VALID : IgxInputState.INVALID; - } else { - this.inputDirective.valid = this._control.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; - } + const showSuccess = this._control.hasValidators && this.inputGroup.isFocused; + this.inputDirective.valid = toInputState(this._control.status, showSuccess ? 'allowed' : 'suppressed'); } else { this.inputDirective.valid = IgxInputState.INITIAL; } diff --git a/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.ts b/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.ts index 5ab50e3082a..f424f872b5f 100644 --- a/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.ts +++ b/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.ts @@ -73,6 +73,7 @@ import { IgxInputDirective, IgxInputGroupComponent, IgxInputState, + toInputState, IgxLabelDirective, IgxSuffixDirective, IgxPrefixDirective, @@ -940,11 +941,8 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective private setValidityState(inputDirective: IgxInputDirective, isFocused: boolean) { if (this._control && !this._control.disabled && this._control.touchedOrDirty) { - if (this._control.hasValidators && isFocused) { - inputDirective.valid = this._control.valid ? IgxInputState.VALID : IgxInputState.INVALID; - } else { - inputDirective.valid = this._control.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; - } + const showSuccess = this._control.hasValidators && isFocused; + inputDirective.valid = toInputState(this._control.status, showSuccess ? 'allowed' : 'suppressed'); } else { inputDirective.valid = IgxInputState.INITIAL; } diff --git a/projects/igniteui-angular/input-group/src/input-group/directives-input/input.directive.spec.ts b/projects/igniteui-angular/input-group/src/input-group/directives-input/input.directive.spec.ts index 807bb405327..309e989dee9 100644 --- a/projects/igniteui-angular/input-group/src/input-group/directives-input/input.directive.spec.ts +++ b/projects/igniteui-angular/input-group/src/input-group/directives-input/input.directive.spec.ts @@ -3,6 +3,7 @@ import { ComponentFixture, TestBed, fakeAsync, tick, waitForAsync } from '@angul import { FormsModule, UntypedFormBuilder, ReactiveFormsModule, Validators, UntypedFormControl, UntypedFormGroup, FormControl } from '@angular/forms'; import { FormField, disabled, form as signalForm, required, validate } from '@angular/forms/signals'; import { By } from '@angular/platform-browser'; +import { map, timer } from 'rxjs'; import { IgxInputGroupComponent } from '../input-group.component'; import { IgxInputDirective, IgxInputState } from './input.directive'; import { UIInteractions } from '../../../../test-utils/ui-interactions.spec'; @@ -25,6 +26,8 @@ const INPUT_GROUP_REQUIRED_CSS_CLASS = 'igx-input-group--required'; const INPUT_GROUP_VALID_CSS_CLASS = 'igx-input-group--valid'; const INPUT_GROUP_INVALID_CSS_CLASS = 'igx-input-group--invalid'; +const ASYNC_VALIDATION_DELAY = 2000; + describe('IgxInput', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ @@ -43,7 +46,8 @@ describe('IgxInput', () => { InputsWithSameNameAttributesComponent, ToggleRequiredWithNgModelInputComponent, InputReactiveFormComponent, - FileInputFormComponent + FileInputFormComponent, + AsyncValidatedInputComponent ] }).compileComponents(); })); @@ -926,6 +930,28 @@ describe('IgxInput', () => { expect(igxInput.valid).toBe(IgxInputState.INVALID); })); + + it('should not show the invalid state while an async validator is pending', fakeAsync(() => { + const fixture = TestBed.createComponent(AsyncValidatedInputComponent); + fixture.detectChanges(); + + const igxInput = fixture.componentInstance.igxInput; + const inputElement = fixture.debugElement.query(By.directive(IgxInputDirective)).nativeElement; + const inputGroupElement = fixture.debugElement.query(By.css('igx-input-group')).nativeElement; + + dispatchInputEvent('focus', inputElement, fixture); + UIInteractions.setInputElementValue(inputElement, 'taken', fixture); + dispatchInputEvent('blur', inputElement, fixture); + + expect(igxInput.valid).toBe(IgxInputState.INITIAL); + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + + tick(ASYNC_VALIDATION_DELAY); + fixture.detectChanges(); + + expect(igxInput.valid).toBe(IgxInputState.INVALID); + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(true); + })); }); describe('IgxInput - Signal Forms', () => { @@ -935,7 +961,11 @@ describe('IgxInput - Signal Forms', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [SignalFormComponent, CustomRuleSignalFormComponent] + imports: [ + SignalFormComponent, + CustomRuleSignalFormComponent, + SwitchFieldSignalFormComponent + ] }).compileComponents(); })); @@ -1007,6 +1037,39 @@ describe('IgxInput - Signal Forms', () => { UIInteractions.setInputElementValue(nativeInput, 'abcd', customFixture); expect(igxInput.valid).toBe(IgxInputState.VALID); }); + + it('should drop aria-required once a conditional required rule turns off', () => { + expect(input.getAttribute('aria-required')).toBe('true'); + + fixture.componentInstance.isRequired.set(false); + fixture.detectChanges(); + + expect(inputGroup.classList.contains(INPUT_GROUP_REQUIRED_CSS_CLASS)).toBe(false); + expect(input.getAttribute('aria-required')).toBe('false'); + }); + + it('should stay initial after switching to a field without rules', () => { + const switchFixture = TestBed.createComponent(SwitchFieldSignalFormComponent); + switchFixture.detectChanges(); + const debugInput = switchFixture.debugElement.query(By.directive(IgxInputDirective)); + const igxInput = debugInput.injector.get(IgxInputDirective); + const nativeInput = debugInput.nativeElement as HTMLInputElement; + const component = switchFixture.componentInstance; + + nativeInput.dispatchEvent(new Event('focus')); + nativeInput.dispatchEvent(new Event('blur')); + switchFixture.detectChanges(); + expect(igxInput.valid).toBe(IgxInputState.INVALID); + + component.useNote.set(true); + switchFixture.detectChanges(); + + nativeInput.dispatchEvent(new Event('focus')); + component.userForm.note().markAsTouched(); + switchFixture.detectChanges(); + + expect(igxInput.valid).toBe(IgxInputState.INITIAL); + }); }); @Component({ @@ -1493,8 +1556,9 @@ const dispatchInputEvent = (eventName, inputNativeElement, fixture) => { class SignalFormComponent { public model = signal({ firstName: '' }); public isDisabled = signal(false); + public isRequired = signal(true); public userForm = signalForm(this.model, (path) => { - required(path.firstName); + required(path.firstName, { when: () => this.isRequired() }); disabled(path.firstName, { when: () => this.isDisabled() }); }); } @@ -1516,3 +1580,39 @@ class CustomRuleSignalFormComponent { validate(path.code, ({ value }) => value().length < MIN_CODE_LENGTH ? { kind: 'short' } : undefined); }); } + +// Binds through a conditional so the `formField` directive is reused instead of recreated. +@Component({ + template: ` + + + + `, + changeDetection: ChangeDetectionStrategy.Eager, + imports: [IgxInputGroupComponent, IgxLabelDirective, IgxInputDirective, FormField] +}) +class SwitchFieldSignalFormComponent { + public model = signal({ name: '', note: '' }); + public useNote = signal(false); + public userForm = signalForm(this.model, (path) => { + required(path.name); + }); +} + +@Component({ + template: ` + + + + `, + changeDetection: ChangeDetectionStrategy.Eager, + imports: [IgxInputGroupComponent, IgxLabelDirective, IgxInputDirective, ReactiveFormsModule] +}) +class AsyncValidatedInputComponent { + @ViewChild(IgxInputDirective, { static: true }) + public igxInput: IgxInputDirective; + + public control = new FormControl('', { + asyncValidators: [() => timer(ASYNC_VALIDATION_DELAY).pipe(map(() => ({ taken: true })))] + }); +} diff --git a/projects/igniteui-angular/input-group/src/input-group/directives-input/input.directive.ts b/projects/igniteui-angular/input-group/src/input-group/directives-input/input.directive.ts index 035892a3981..2251060087d 100644 --- a/projects/igniteui-angular/input-group/src/input-group/directives-input/input.directive.ts +++ b/projects/igniteui-angular/input-group/src/input-group/directives-input/input.directive.ts @@ -1,7 +1,7 @@ import { AfterViewInit, ChangeDetectorRef, Directive, ElementRef, HostBinding, HostListener, Injector, Input, OnDestroy, Renderer2, booleanAttribute, inject } from '@angular/core'; import { NgControl, NgModel } from '@angular/forms'; import { Subscription } from 'rxjs'; -import { NgControlAdapter } from 'igniteui-angular/core'; +import { ControlStatus, NgControlAdapter } from 'igniteui-angular/core'; import { IgxInputGroupBase } from '../input-group.common'; const nativeValidationAttributes = [ @@ -20,6 +20,27 @@ export enum IgxInputState { INVALID, } +/** + * Whether an editor may paint the success state. + * + * @hidden @internal + */ +export type SuccessState = 'allowed' | 'suppressed'; + +/** + * Maps a control status to an editor state. A pending async rule has not answered + * yet, so it reads as initial rather than as an error. + * + * @hidden @internal + */ +export function toInputState(status: ControlStatus, success: SuccessState): IgxInputState { + if (status === 'invalid') { + return IgxInputState.INVALID; + } + + return status === 'valid' && success === 'allowed' ? IgxInputState.VALID : IgxInputState.INITIAL; +} + /** * The `igxInput` directive creates single- or multiline text elements, covering common scenarios when dealing with form inputs. * @@ -173,6 +194,7 @@ export class IgxInputDirective implements AfterViewInit, OnDestroy { @Input({ transform: booleanAttribute }) public set required(value: boolean) { this.nativeElement.required = this.inputGroup.isRequired = value; + this.updateAriaRequired(); } /** @@ -275,7 +297,7 @@ export class IgxInputDirective implements AfterViewInit, OnDestroy { this.inputGroup.isRequired = this.required; } - this.renderer.setAttribute(this.nativeElement, 'aria-required', this.required.toString()); + this.updateAriaRequired(); const elTag = this.nativeElement.tagName.toLowerCase(); if (elTag === 'textarea') { @@ -353,29 +375,27 @@ export class IgxInputDirective implements AfterViewInit, OnDestroy { * @internal */ protected updateValidityState() { - if (this.control) { - if (!this.disabled && this.control.touchedOrDirty) { - if (this.control.hasValidators) { - this.inputGroup.isRequired = this.control.required; - if (this.focused) { - this._valid = this.control.valid ? IgxInputState.VALID : IgxInputState.INVALID; - } else { - this._valid = this.control.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; - } - } else { - // If validator is dynamically cleared, reset label's required class(asterisk) and IgxInputState #10010 - this.inputGroup.isRequired = false; - this._valid = this.control.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; - } - } else { - this._valid = IgxInputState.INITIAL; - } - this.renderer.setAttribute(this.nativeElement, 'aria-required', this.required.toString()); - const ariaInvalid = this.valid === IgxInputState.INVALID; - this.renderer.setAttribute(this.nativeElement, 'aria-invalid', ariaInvalid.toString()); - } else { + if (!this.control) { this.checkNativeValidity(); + return; + } + + if (!this.disabled && this.control.touchedOrDirty) { + // Clearing the validators must drop the label's asterisk #10010 + this.inputGroup.isRequired = this.control.required; + const showSuccess = this.control.hasValidators && this.focused; + this._valid = toInputState(this.control.status, showSuccess ? 'allowed' : 'suppressed'); + } else { + this._valid = IgxInputState.INITIAL; } + + this.updateAriaRequired(); + const ariaInvalid = this.valid === IgxInputState.INVALID; + this.renderer.setAttribute(this.nativeElement, 'aria-invalid', ariaInvalid.toString()); + } + + private updateAriaRequired() { + this.renderer.setAttribute(this.nativeElement, 'aria-required', this.required.toString()); } /** @@ -472,9 +492,8 @@ export class IgxInputDirective implements AfterViewInit, OnDestroy { */ private checkNativeValidity() { if (!this.disabled && this._hasValidators()) { - this._valid = this.nativeElement.checkValidity() ? - this.focused ? IgxInputState.VALID : IgxInputState.INITIAL : - IgxInputState.INVALID; + const status = this.nativeElement.checkValidity() ? 'valid' : 'invalid'; + this._valid = toInputState(status, this.focused ? 'allowed' : 'suppressed'); } } diff --git a/projects/igniteui-angular/select/src/select/select.component.ts b/projects/igniteui-angular/select/src/select/select.component.ts index d22f5248aaf..3392aa6cf6d 100644 --- a/projects/igniteui-angular/select/src/select/select.component.ts +++ b/projects/igniteui-angular/select/src/select/select.component.ts @@ -39,7 +39,7 @@ import { } from 'igniteui-angular/core'; import { IgxSelectItemComponent } from './select-item.component'; import { IgxSelectBase } from './select.common'; -import { IgxHintDirective, IgxInputGroupType, IgxPrefixDirective, IGX_INPUT_GROUP_TYPE, IgxInputGroupComponent, IgxInputDirective, IgxInputState, IgxLabelDirective, IgxReadOnlyInputDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; +import { IgxHintDirective, IgxInputGroupType, IgxPrefixDirective, IGX_INPUT_GROUP_TYPE, IgxInputGroupComponent, IgxInputDirective, IgxInputState, toInputState, IgxLabelDirective, IgxReadOnlyInputDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; import { ToggleViewCancelableEventArgs, ToggleViewEventArgs, IgxToggleDirective } from 'igniteui-angular/directives'; import { IgxOverlayService, NgControlAdapter } from 'igniteui-angular/core'; import { IgxIconComponent } from 'igniteui-angular/icon'; @@ -575,13 +575,10 @@ export class IgxSelectComponent extends IgxDropDownComponent implements IgxSelec this.manageRequiredAsterisk(); if (this.control && !this.control.disabled && this.control.touchedOrDirty) { - if (this.control.hasValidators && this.inputGroup.isFocused) { - this.input.valid = this.control.valid ? IgxInputState.VALID : IgxInputState.INVALID; - } else { - // B.P. 18 May 2021: IgxDatePicker does not reset its state upon resetForm #9526 - this.input.valid = this.control.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; - } + const showSuccess = this.control.hasValidators && this.inputGroup.isFocused; + this.input.valid = toInputState(this.control.status, showSuccess ? 'allowed' : 'suppressed'); } else { + // B.P. 18 May 2021: IgxDatePicker does not reset its state upon resetForm #9526 this.input.valid = IgxInputState.INITIAL; } } diff --git a/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.spec.ts b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.spec.ts index a3e3409a233..9bc4d988ec8 100644 --- a/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.spec.ts +++ b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.spec.ts @@ -140,7 +140,11 @@ describe('IgxTimePicker', () => { set control(val: any) { this._control = val; }, - valid: true + valid: true, + // A real control keeps the two in sync; the adapter reads `invalid`. + get invalid() { + return !this.valid; + } }; mockInputDirective = { valid: 'mock', diff --git a/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.ts b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.ts index 28b86c146c6..d4d8dc432a0 100644 --- a/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.ts +++ b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.ts @@ -34,6 +34,7 @@ import { IgxInputDirective, IgxInputGroupComponent, IgxInputState, + toInputState, IgxLabelDirective, IgxPrefixDirective, IgxReadOnlyInputDirective, @@ -1104,11 +1105,8 @@ export class IgxTimePickerComponent extends PickerBaseDirective protected onStatusChanged() { if (this._control && !this._control.disabled && this._control.touchedOrDirty) { - if (this._control.hasValidators && this._inputGroup.isFocused) { - this.inputDirective.valid = this._control.valid ? IgxInputState.VALID : IgxInputState.INVALID; - } else { - this.inputDirective.valid = this._control.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; - } + const showSuccess = this._control.hasValidators && this._inputGroup.isFocused; + this.inputDirective.valid = toInputState(this._control.status, showSuccess ? 'allowed' : 'suppressed'); } else { // B.P. 18 May 2021: IgxDatePicker does not reset its state upon resetForm #9526 this.inputDirective.valid = IgxInputState.INITIAL; From 019fc7f2a4340e2770f324d447f679751f249977 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 16 Sep 2026 19:48:53 +0300 Subject: [PATCH 2/2] fix(forms): Map pending status on the blur path The time and date range pickers repaint validity on blur through a path of their own, which still mapped `!valid` to `INVALID`. `valid` is false while an async validator runs, so blurring painted the error style before the rule had answered - the bug the status-change path was already fixed for. --- .../date-range-picker.component.spec.ts | 78 ++++++++++++++++++- .../date-range-picker.component.ts | 29 +++---- .../time-picker/time-picker.component.spec.ts | 43 +++++++++- .../src/time-picker/time-picker.component.ts | 13 +--- 4 files changed, 134 insertions(+), 29 deletions(-) diff --git a/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.spec.ts b/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.spec.ts index 4a13f358fae..fd797635d94 100644 --- a/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.spec.ts +++ b/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.spec.ts @@ -15,7 +15,7 @@ import { IgxDateTimeEditorDirective } from '../../../directives/src/directives/d import { DateRangeType } from 'igniteui-angular/core'; import { IgxDateRangePickerComponent, IgxDateRangeEndComponent } from './public_api'; import { AutoPositionStrategy, IgxOverlayService } from 'igniteui-angular/core'; -import { Subject } from 'rxjs'; +import { map, Subject, timer } from 'rxjs'; import { AsyncPipe } from '@angular/common'; import { IgxAngularAnimationService } from 'igniteui-angular/core'; import { IgxPickerClearComponent, IgxPickerToggleComponent } from '../../../core/src/date-common/picker-icons.common'; @@ -50,6 +50,7 @@ const CSS_CLASS_CALENDAR_HEADER_TITLE = '.igx-calendar__header-year'; const CSS_CLASS_CALENDAR_SUBHEADER = '.igx-calendar-picker__dates'; const CSS_CLASS_CALENDAR_HEADER = '.igx-calendar__header'; const CSS_CLASS_CALENDAR_WRAPPER_VERTICAL = 'igx-calendar__wrapper--vertical'; +const ASYNC_VALIDATION_DELAY = 2000; describe('IgxDateRangePicker', () => { describe('Unit tests: ', () => { @@ -2344,6 +2345,48 @@ describe('IgxDateRangePicker', () => { }); }); +describe('IgxDateRangePicker - async validation', () => { + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, DateRangeAsyncValidatedComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DateRangeAsyncValidatedComponent); + fixture.detectChanges(); + }); + + const blur = (input: DebugElement) => { + input.nativeElement.focus(); + input.nativeElement.blur(); + fixture.detectChanges(); + }; + + it('should not paint the invalid state on blur while an async validator is pending', fakeAsync(() => { + const { single, twoInputs, singleControl, twoInputsControl } = fixture.componentInstance; + const inputs = fixture.debugElement.queryAll(By.css(CSS_CLASS_INPUT)); + const range = { start: new Date(2020, 0, 1), end: new Date(2020, 0, 5) }; + + singleControl.setValue(range); + twoInputsControl.setValue(range); + inputs.forEach(blur); + + expect(single.inputDirective.valid).toBe(IgxInputState.INITIAL); + expect(twoInputs.projectedInputs.first.inputDirective.valid).toBe(IgxInputState.INITIAL); + expect(twoInputs.projectedInputs.last.inputDirective.valid).toBe(IgxInputState.INITIAL); + + tick(ASYNC_VALIDATION_DELAY); + inputs.forEach(blur); + + expect(single.inputDirective.valid).toBe(IgxInputState.INVALID); + expect(twoInputs.projectedInputs.first.inputDirective.valid).toBe(IgxInputState.INVALID); + expect(twoInputs.projectedInputs.last.inputDirective.valid).toBe(IgxInputState.INVALID); + })); +}); + describe('IgxDateRangePicker - Signal Forms', () => { let fixture: ComponentFixture; let single: IgxDateRangePickerComponent; @@ -2763,3 +2806,36 @@ export class DateRangeSignalFormComponent { disabled(path.trip, { when: () => this.isDisabled() }); }); } + +@Component({ + template: ` + + + + + + + + + `, + changeDetection: ChangeDetectionStrategy.Eager, + imports: [ + IgxDateRangePickerComponent, + IgxDateRangeStartComponent, + IgxDateRangeEndComponent, + IgxInputDirective, + IgxDateTimeEditorDirective, + ReactiveFormsModule + ] +}) +export class DateRangeAsyncValidatedComponent { + @ViewChild('single', { read: IgxDateRangePickerComponent }) public single: IgxDateRangePickerComponent; + @ViewChild('twoInputs', { read: IgxDateRangePickerComponent }) public twoInputs: IgxDateRangePickerComponent; + + public singleControl = new UntypedFormControl(null, { asyncValidators: [this.pending] }); + public twoInputsControl = new UntypedFormControl(null, { asyncValidators: [this.pending] }); + + private pending() { + return timer(ASYNC_VALIDATION_DELAY).pipe(map(() => ({ taken: true }))); + } +} diff --git a/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.ts b/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.ts index f424f872b5f..70db3ebb920 100644 --- a/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.ts +++ b/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.ts @@ -1042,24 +1042,19 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective private updateValidityOnBlur() { this._focusedInput = null!; this.onTouchCallback(); - if (this._ngControl) { - if (this.hasProjectedInputs) { - this.projectedInputs.forEach(i => { - if (!this._ngControl.valid) { - i.updateInputValidity(IgxInputState.INVALID); - } else { - i.updateInputValidity(IgxInputState.INITIAL); - } - }); - } + if (!this._control) { + return; + } - if (this.inputDirective) { - if (!this._ngControl.valid) { - this.inputDirective.valid = IgxInputState.INVALID; - } else { - this.inputDirective.valid = IgxInputState.INITIAL; - } - } + // Blur never shows success, only the error. + const state = toInputState(this._control.status, 'suppressed'); + + if (this.hasProjectedInputs) { + this.projectedInputs.forEach(i => i.updateInputValidity(state)); + } + + if (this.inputDirective) { + this.inputDirective.valid = state; } } diff --git a/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.spec.ts b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.spec.ts index 9bc4d988ec8..1fc43189823 100644 --- a/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.spec.ts +++ b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.spec.ts @@ -15,7 +15,7 @@ import { DatePart } from '../../../core/src/date-common/public_api'; import { IgxDateTimeEditorDirective } from '../../../directives/src/directives/date-time-editor/date-time-editor.directive'; import { IgxItemListDirective, IgxTimeItemDirective } from './time-picker.directives'; import { IgxPickerClearComponent, IgxPickerToggleComponent } from '../../../core/src/date-common/public_api'; -import { Subscription } from 'rxjs'; +import { map, Subscription, timer } from 'rxjs'; import { registerLocaleData } from "@angular/common"; import localeJa from "@angular/common/locales/ja"; import localeBg from "@angular/common/locales/bg"; @@ -40,6 +40,7 @@ const CSS_CLASS_OVERLAY_WRAPPER = 'igx-overlay__wrapper'; const TIME_PICKER_TOGGLE_ICON = 'access_time'; const TIME_PICKER_CLEAR_ICON = 'clear'; const CSS_CLASS_TIME_PICKER_VERTICAL = '.igx-time-picker--vertical'; +const ASYNC_VALIDATION_DELAY = 2000; describe('IgxTimePicker', () => { let timePicker: IgxTimePickerComponent; @@ -1832,7 +1833,8 @@ describe('IgxTimePicker', () => { imports: [ NoopAnimationsModule, IgxTimePickerInFormComponent, - IgxTimePickerReactiveFormComponent + IgxTimePickerReactiveFormComponent, + IgxTimePickerAsyncValidatedComponent ] }).compileComponents(); })); @@ -1858,6 +1860,25 @@ describe('IgxTimePicker', () => { expect((timePicker as any).inputDirective.valid).toEqual(IgxInputState.INITIAL); })); + it('should not paint the invalid state on blur while an async validator is pending', fakeAsync(() => { + const fix = TestBed.createComponent(IgxTimePickerAsyncValidatedComponent); + fix.detectChanges(); + timePicker = fix.componentInstance.timePicker; + const input = fix.debugElement.query(By.css(CSS_CLASS_INPUT)).nativeElement; + + input.focus(); + fix.componentInstance.control.setValue(new Date(2012, 5, 3)); + input.blur(); + fix.detectChanges(); + + expect((timePicker as any).inputDirective.valid).toEqual(IgxInputState.INITIAL); + + tick(ASYNC_VALIDATION_DELAY); + fix.detectChanges(); + + expect((timePicker as any).inputDirective.valid).toEqual(IgxInputState.INVALID); + })); + it('should apply asterisk properly when required validator is set dynamically', () => { fixture = TestBed.createComponent(IgxTimePickerReactiveFormComponent); fixture.detectChanges(); @@ -2126,3 +2147,21 @@ class IgxTimePickerSignalFormComponent { disabled(path.time, { when: () => this.isDisabled() }); }); } + +@Component({ + template: ` + + + + `, + changeDetection: ChangeDetectionStrategy.Eager, + imports: [IgxTimePickerComponent, IgxLabelDirective, ReactiveFormsModule] +}) +export class IgxTimePickerAsyncValidatedComponent { + @ViewChild(IgxTimePickerComponent) + public timePicker: IgxTimePickerComponent; + + public control = new UntypedFormControl(null, { + asyncValidators: [() => timer(ASYNC_VALIDATION_DELAY).pipe(map(() => ({ taken: true })))] + }); +} diff --git a/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.ts b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.ts index d4d8dc432a0..3c24e927c41 100644 --- a/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.ts +++ b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.ts @@ -496,7 +496,6 @@ export class IgxTimePickerComponent extends PickerBaseDirective { hours: 1, minutes: 1, seconds: 1, fractionalSeconds: 1 }; private _statusChanges$!: Subscription; - private _ngControl: NgControl = null!; private _control: NgControlAdapter | null = null; private _onChangeCallback: (_: Date | string) => void = noop; private _onTouchedCallback: () => void = noop; @@ -746,8 +745,7 @@ export class IgxTimePickerComponent extends PickerBaseDirective /** @hidden */ public ngOnInit(): void { - this._ngControl = this._injector.get(NgControl, null); - this._control = NgControlAdapter.from(this._ngControl, this._injector); + this._control = NgControlAdapter.from(this._injector.get(NgControl, null), this._injector); this.minDropdownValue = this.setMinMaxDropdownValue('min', this.minDateValue); this.maxDropdownValue = this.setMinMaxDropdownValue('max', this.maxDateValue); this.setSelectedValue(this._dateValue); @@ -1202,12 +1200,9 @@ export class IgxTimePickerComponent extends PickerBaseDirective private updateValidityOnBlur() { this._onTouchedCallback(); - if (this._ngControl) { - if (!this._ngControl.valid) { - this.inputDirective.valid = IgxInputState.INVALID; - } else { - this.inputDirective.valid = IgxInputState.INITIAL; - } + if (this._control) { + // Blur never shows success, only the error. + this.inputDirective.valid = toInputState(this._control.status, 'suppressed'); } }