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.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 5ab50e3082a..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 @@ -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; } @@ -1044,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/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..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; @@ -140,7 +141,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', @@ -1828,7 +1833,8 @@ describe('IgxTimePicker', () => { imports: [ NoopAnimationsModule, IgxTimePickerInFormComponent, - IgxTimePickerReactiveFormComponent + IgxTimePickerReactiveFormComponent, + IgxTimePickerAsyncValidatedComponent ] }).compileComponents(); })); @@ -1854,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(); @@ -2122,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 28b86c146c6..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 @@ -34,6 +34,7 @@ import { IgxInputDirective, IgxInputGroupComponent, IgxInputState, + toInputState, IgxLabelDirective, IgxPrefixDirective, IgxReadOnlyInputDirective, @@ -495,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; @@ -745,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); @@ -1104,11 +1103,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; @@ -1204,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'); } }