diff --git a/CHANGELOG.md b/CHANGELOG.md index 928db2f64f7..8d1d2db0172 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes for each version of this project will be documented in this file. +## Unreleased + +### New Features + +- **Forms** + - `igxInput`, `igx-checkbox`, `igx-switch`, `igx-radio-group`, `igx-select`, `igx-combo`, `igx-simple-combo`, `igx-date-picker`, `igx-time-picker` and `igx-date-range-picker` now work with Angular Signal Forms (`[formField]`). Validity, touched, dirty, disabled and required state are read from the signal-backed control; reactive and template-driven forms are unchanged. + ## 22.2.0 ### New Features @@ -36,7 +43,7 @@ All notable changes for each version of this project will be documented in this - **Theming** - Component structural styles are now **scoped and tree-shakable** — they ship inside each component's own bundle instead of a single global, all-or-nothing theme stylesheet. An app now pays for CSS only for the components it actually imports. - - Design tokens for all four design systems (Material, Bootstrap, Fluent, Indigo) × light/dark are emitted **once per theme** into the global preset (e.g. `igniteui-angular.css`). As a result of those changes, the pre-built theme files are roughly **half the size** (~49% smaller raw, ~58% smaller gzip). + - Design tokens for all four design systems (Material, Bootstrap, Fluent, Indigo) × light/dark are emitted **once per theme** into the global preset (e.g. `igniteui-angular.css`). As a result of those changes, the pre-built theme files are roughly **half the size** (~49% smaller raw, ~58% smaller gzip). - Finalized the migration to the `tokens()` mixin as the single way to apply a component theme, replacing the individual per-component wrapper mixins (`avatar()`, `dialog()`, `checkbox()`, `tabs()`, etc.) across the rest of the library, following the same pattern already introduced for the Grid family in 22.0.0. `tokens()` supports two modes. Its default mode is `global`; add `$mode: 'scoped'` when the theme must emit the component-local variables consumed by the component's structural stylesheet: diff --git a/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.spec.ts b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.spec.ts index 87c8e7d894b..0214fec2128 100644 --- a/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.spec.ts +++ b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.spec.ts @@ -1,6 +1,7 @@ -import { Component, ViewChild, ElementRef, inject, ChangeDetectionStrategy } from '@angular/core'; -import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { Component, ViewChild, ElementRef, inject, ChangeDetectionStrategy, signal } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { UntypedFormBuilder, FormsModule, ReactiveFormsModule, Validators, NgForm } from '@angular/forms'; +import { FormField, disabled, form as signalForm, required } from '@angular/forms/signals'; import { By } from '@angular/platform-browser'; import { IgxCheckboxComponent } from './checkbox.component'; @@ -446,6 +447,53 @@ describe('IgxCheckbox', () => { }); }); +describe('IgxCheckboxComponent - Signal Forms', () => { + let fixture: ComponentFixture; + let instance: IgxCheckboxComponent; + let host: HTMLElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, CheckboxSignalFormComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CheckboxSignalFormComponent); + fixture.detectChanges(); + instance = fixture.componentInstance.control; + host = fixture.debugElement.query(By.css('igx-checkbox')).nativeElement; + }); + + it('should initialize and reflect the required rule', () => { + expect(instance.required).toBe(true); + expect(instance.invalid).toBe(false); + expect(instance.nativeElement.getAttribute('aria-required')).toEqual('true'); + }); + + it('should become invalid once touched while unchecked', () => { + dispatchCbEvent('blur', host, fixture); + expect(instance.invalid).toBe(true); + expect(host.classList.contains('igx-checkbox--invalid')).toBe(true); + + dispatchCbEvent('click', host, fixture); + expect(instance.checked).toBe(true); + expect(fixture.componentInstance.model().accepted).toBe(true); + expect(instance.invalid).toBe(false); + expect(host.classList.contains('igx-checkbox--invalid')).toBe(false); + }); + + it('should follow the disabled rule', () => { + fixture.componentInstance.isDisabled.set(true); + fixture.detectChanges(); + expect(instance.disabled).toBe(true); + + fixture.componentInstance.isDisabled.set(false); + fixture.detectChanges(); + expect(instance.disabled).toBe(false); + }); +}); + @Component({ template: `Root
@@ -596,3 +644,19 @@ const dispatchCbEvent = (eventName, cbNativeElement, fixture) => { cbNativeElement.dispatchEvent(new Event(eventName)); fixture.detectChanges(); }; + +@Component({ + template: `Accept`, + changeDetection: ChangeDetectionStrategy.Eager, + imports: [IgxCheckboxComponent, FormField] +}) +class CheckboxSignalFormComponent { + @ViewChild('control', { static: true }) public control: IgxCheckboxComponent; + + public model = signal({ accepted: false }); + public isDisabled = signal(false); + public userForm = signalForm(this.model, (path) => { + required(path.accepted); + disabled(path.accepted, { when: () => this.isDisabled() }); + }); +} diff --git a/projects/igniteui-angular/combo/src/combo/combo.common.ts b/projects/igniteui-angular/combo/src/combo/combo.common.ts index 0d61396c6af..fcf5435467c 100644 --- a/projects/igniteui-angular/combo/src/combo/combo.common.ts +++ b/projects/igniteui-angular/combo/src/combo/combo.common.ts @@ -24,7 +24,7 @@ import { ViewChildren, inject } from '@angular/core'; -import { AbstractControl, ControlValueAccessor, NgControl } from '@angular/forms'; +import { ControlValueAccessor, NgControl } from '@angular/forms'; import { caseSensitive } from '@igniteui/material-icons-extended'; import { noop, Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @@ -42,7 +42,8 @@ import { ComboResourceStringsEN, IComboResourceStrings, getCurrentResourceStrings, - onResourceChangeHandle + onResourceChangeHandle, + NgControlAdapter } from 'igniteui-angular/core'; import { IForOfState, IgxForOfDirective } from 'igniteui-angular/directives'; import { IgxIconService } from 'igniteui-angular/icon'; @@ -983,6 +984,7 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh protected _defaultResourceStrings = getCurrentResourceStrings(ComboResourceStringsEN); protected _valid = IgxInputState.INITIAL; protected ngControl: NgControl = null!; + private control: NgControlAdapter | null = null; protected destroy$ = new Subject(); protected _onTouchedCallback: () => void = noop; protected _onChangeCallback: (_: any) => void = noop; @@ -1050,6 +1052,7 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh /** @hidden @internal */ public ngOnInit() { this.ngControl = this._injector!.get(NgControl, null); + this.control = NgControlAdapter.from(this.ngControl, this._injector!); this.selectionService.set(this.id, new Set()); this._iconService?.addSvgIconFromText(caseSensitive.name, caseSensitive.value, 'imx-icons'); this.computedStyles = this.document.defaultView!.getComputedStyle(this.elementRef.nativeElement); @@ -1058,8 +1061,8 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh /** @hidden @internal */ public ngAfterViewInit(): void { this.filteredData = [...this.data!]; - if (this.ngControl) { - this.ngControl.statusChanges!.pipe(takeUntil(this.destroy$)).subscribe(this.onStatusChanged); + if (this.control) { + this.control.statusChanges.pipe(takeUntil(this.destroy$)).subscribe(this.onStatusChanged); this.manageRequiredAsterisk(); this.cdr.detectChanges(); } @@ -1324,11 +1327,11 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh } protected onStatusChanged = () => { - if (this.ngControl && this.isTouchedOrDirty && !this.ngControl.disabled) { - if (this.hasValidators && (!this.collapsed || this.inputGroup.isFocused)) { - this.valid = this.ngControl.valid ? IgxInputState.VALID : IgxInputState.INVALID; + 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.ngControl.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; + this.valid = this.control.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; } } else { // B.P. 18 May 2021: IgxDatePicker does not reset its state upon resetForm #9526 @@ -1346,14 +1349,6 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh } } - private get isTouchedOrDirty(): boolean { - return (this.ngControl.control!.touched || this.ngControl.control!.dirty); - } - - private get hasValidators(): boolean { - return (!!this.ngControl.control!.validator || !!this.ngControl.control!.asyncValidator); - } - /** if there is a valueKey - map the keys to data items, else - just return the keys */ protected convertKeysToItems(keys: any[]) { if (this.valueKey === null || this.valueKey === undefined) { @@ -1422,13 +1417,7 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh } protected get required(): boolean { - if (this.ngControl && this.ngControl.control && this.ngControl.control.validator) { - // Run the validation with empty object to check if required is enabled. - const error = this.ngControl.control.validator({} as AbstractControl); - return error && error.required; - } - - return false; + return this.control?.required ?? false; } public abstract get filteredData(): any[] | null; diff --git a/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts b/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts index 20ed6074bab..e0a893ef25b 100644 --- a/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts +++ b/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts @@ -1,9 +1,10 @@ import { AsyncPipe } from '@angular/common'; -import { AfterViewInit, ChangeDetectorRef, Component, DebugElement, ElementRef, Injectable, Injector, OnDestroy, OnInit, ViewChild, inject, ChangeDetectionStrategy, provideZonelessChangeDetection } from '@angular/core'; +import { AfterViewInit, ChangeDetectorRef, Component, DebugElement, ElementRef, Injectable, Injector, OnDestroy, OnInit, ViewChild, inject, ChangeDetectionStrategy, provideZonelessChangeDetection, signal } from '@angular/core'; import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { FormsModule, NgForm, NgModel, ReactiveFormsModule, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; +import { FormField, disabled, form as signalForm, required } from '@angular/forms/signals'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { BehaviorSubject, Observable, firstValueFrom } from 'rxjs'; @@ -3819,6 +3820,58 @@ describe('igxCombo', () => { }); }); +describe('IgxComboComponent - Signal Forms', () => { + let fixture: ComponentFixture; + let combo: IgxComboComponent; + let inputGroup: HTMLElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, IgxComboSignalFormComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(IgxComboSignalFormComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + inputGroup = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUTGROUP)).nativeElement; + }); + + it('should initialize and reflect the required rule', () => { + expect(inputGroup.classList.contains(CSS_CLASS_INPUT_GROUP_REQUIRED)).toBe(true); + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + }); + + it('should become invalid once touched without a selection', () => { + combo.onBlur(); + fixture.detectChanges(); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + expect(inputGroup.classList.contains(CSS_CLASS_INPUT_GROUP_INVALID)).toBe(true); + + combo.select(['Maine']); + fixture.detectChanges(); + expect(fixture.componentInstance.model().towns).toEqual(['Maine']); + + combo.onBlur(); + fixture.detectChanges(); + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(inputGroup.classList.contains(CSS_CLASS_INPUT_GROUP_INVALID)).toBe(false); + }); + + it('should follow the disabled rule', () => { + fixture.componentInstance.isDisabled.set(true); + fixture.detectChanges(); + expect(combo.disabled).toBe(true); + + fixture.componentInstance.isDisabled.set(false); + fixture.detectChanges(); + expect(combo.disabled).toBe(false); + }); +}); + @Component({ template: ` + + `, + changeDetection: ChangeDetectionStrategy.Eager, + imports: [IgxComboComponent, IgxLabelDirective, FormField] +}) +class IgxComboSignalFormComponent { + @ViewChild('combo', { read: IgxComboComponent, static: true }) public combo: IgxComboComponent; + + public items = [{ field: 'Connecticut' }, { field: 'Maine' }, { field: 'Vermont' }]; + public model = signal<{ towns: string[] | null }>({ towns: null }); + public isDisabled = signal(false); + public userForm = signalForm(this.model, (path) => { + required(path.towns); + disabled(path.towns, { when: () => this.isDisabled() }); + }); +} diff --git a/projects/igniteui-angular/core/src/core/ng-control-adapter.ts b/projects/igniteui-angular/core/src/core/ng-control-adapter.ts new file mode 100644 index 00000000000..bbbee83d5ff --- /dev/null +++ b/projects/igniteui-angular/core/src/core/ng-control-adapter.ts @@ -0,0 +1,142 @@ +import { effect, EnvironmentInjector, Injector, untracked } from '@angular/core'; +import { AbstractControl, NgControl, TouchedChangeEvent, Validators } from '@angular/forms'; +import { filter, Observable } from 'rxjs'; + +/** Source of the state behind an `NgControl`. */ +export type NgControlBackend = 'observable' | 'signal'; + +/** Whether a control took a value written through `setValue`. */ +export type ValueWriteResult = 'accepted' | 'ignored'; + +/** + * Uniform access to the `NgControl` bound to a form control. + * + * Reactive and template-driven forms provide an `NgControl` backed by an + * `AbstractControl` with observables. Signal Forms (`[formField]`) provide + * an interop `NgControl` backed by signals, without `statusChanges`, + * `valueChanges`, `validator` or `markAsTouched`. + * + * @hidden @internal + */ +export class NgControlAdapter { + public readonly backend: NgControlBackend; + + private readonly envInjector: EnvironmentInjector; + + /** Wraps `ngControl`, or returns `null` when there is none. */ + public static from(ngControl: NgControl | null, injector: Injector): NgControlAdapter | null { + return ngControl ? new NgControlAdapter(ngControl, injector) : null; + } + + constructor(private readonly ngControl: NgControl, injector: Injector) { + this.envInjector = injector.get(EnvironmentInjector); + this.backend = 'statusChanges' in ngControl ? 'observable' : 'signal'; + } + + public get disabled(): boolean { + return !!this.ngControl.disabled; + } + + public get valid(): boolean { + return !!this.ngControl.valid; + } + + public get invalid(): boolean { + return !!this.ngControl.invalid; + } + + public get touchedOrDirty(): boolean { + const control = this.ngControl.control; + return !!(control?.touched || control?.dirty); + } + + /** Signal Forms expose no validator list, only `required` and the current errors. */ + public get hasValidators(): boolean { + if (this.backend === 'signal') { + return this.required || this.invalid; + } + + const control = this.ngControl.control; + return !!(control?.validator || control?.asyncValidator); + } + + public get required(): boolean { + if (this.backend === 'signal') { + return !!this.ngControl.control?.hasValidator(Validators.required); + } + + const validator = this.ngControl.control?.validator; + if (!validator) { + return false; + } + + // Probe with an empty control so `required` is detected regardless of the current value. + return !!validator({} as AbstractControl)?.required; + } + + /** + * Emits when validity, disabled, dirty or pending state changes. + * Signal Forms `submit()` only marks fields touched, so touched changes count too + * or the errors would never surface. + */ + public get statusChanges(): Observable { + if (this.backend === 'signal') { + return this.watch(() => [ + this.ngControl.valid, this.ngControl.invalid, this.ngControl.pending, + this.ngControl.disabled, this.ngControl.dirty, this.ngControl.touched + ]); + } + + return this.ngControl.statusChanges!; + } + + public get touchedChanges(): Observable { + if (this.backend === 'signal') { + return this.watch(() => [this.ngControl.touched]); + } + + return this.ngControl.control!.events.pipe(filter(e => e instanceof TouchedChangeEvent)); + } + + public get valueChanges(): Observable { + if (this.backend === 'signal') { + return this.watch(() => [this.ngControl.value]); + } + + return this.ngControl.valueChanges!; + } + + /** No-op for Signal Forms: `[formField]` tracks touch through blur and `registerOnTouched`. */ + public markAsTouched(): void { + this.ngControl.control?.markAsTouched?.(); + } + + /** + * Signal Forms ignore the write: they read the value from the view + * (DOM or `ControlValueAccessor`), so the caller must update that instead. + */ + public setValue(value: unknown): ValueWriteResult { + const control = this.ngControl.control; + if (!control?.setValue) { + return 'ignored'; + } + + control.setValue(value); + return 'accepted'; + } + + // 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 { + return new Observable(subscriber => { + const ref = untracked(() => effect(() => { + read(); + untracked(() => subscriber.next()); + }, { injector: this.envInjector })); + + return () => ref.destroy(); + }); + } +} diff --git a/projects/igniteui-angular/core/src/public_api.ts b/projects/igniteui-angular/core/src/public_api.ts index 8c14403f201..1eaeeca5d3c 100644 --- a/projects/igniteui-angular/core/src/public_api.ts +++ b/projects/igniteui-angular/core/src/public_api.ts @@ -7,6 +7,7 @@ export * from './core/types'; export * from './core/selection'; export * from './core/edit-provider'; export * from './core/touch'; +export * from './core/ng-control-adapter'; // Grid actions tokens export * from './grid-column-actions/token'; 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 a1bc7a5dfc5..ab161667bc7 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 @@ -1,5 +1,6 @@ import { ComponentFixture, fakeAsync, flush, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { UntypedFormControl, UntypedFormGroup, FormsModule, NgForm, ReactiveFormsModule, Validators } from '@angular/forms'; +import { FormField, disabled, form as signalForm, required } from '@angular/forms/signals'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { UIInteractions } from '../../../test-utils/ui-interactions.spec'; import { @@ -13,7 +14,7 @@ import { OverlayCancelableEventArgs, OverlayClosingEventArgs, OverlayEventArgs, OverlaySettings, WEEKDAYS } from 'igniteui-angular/core'; -import { ChangeDetectorRef, Component, DebugElement, ElementRef, EventEmitter, Injector, provideZonelessChangeDetection, QueryList, Renderer2, ViewChild, ChangeDetectionStrategy } from '@angular/core'; +import { ChangeDetectorRef, Component, DebugElement, ElementRef, EventEmitter, Injector, provideZonelessChangeDetection, QueryList, Renderer2, ViewChild, ChangeDetectionStrategy, signal } from '@angular/core'; import { By } from '@angular/platform-browser'; import { PickerCalendarOrientation, PickerHeaderOrientation, PickerInteractionMode } from '../../../core/src/date-common/types'; import { DatePart } from '../../../core/src/date-common/public_api'; @@ -1704,6 +1705,55 @@ describe('IgxDatePicker', () => { })); }); }); + +describe('IgxDatePickerComponent - Signal Forms', () => { + let fixture: ComponentFixture; + let picker: IgxDatePickerComponent; + let inputGroup: HTMLElement; + let input: HTMLInputElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, IgxDatePickerSignalFormComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(IgxDatePickerSignalFormComponent); + fixture.detectChanges(); + picker = fixture.componentInstance.picker; + inputGroup = fixture.debugElement.query(By.css('igx-input-group')).nativeElement; + input = fixture.debugElement.query(By.css('.igx-input-group__input')).nativeElement; + }); + + it('should initialize and reflect the required rule', () => { + expect(inputGroup.classList.contains(CSS_CLASS_INPUT_GROUP_REQUIRED)).toBe(true); + expect(inputGroup.classList.contains(CSS_CLASS_INPUT_GROUP_INVALID)).toBe(false); + }); + + it('should become invalid once touched without a value', () => { + input.dispatchEvent(new Event('focus')); + input.dispatchEvent(new Event('blur')); + fixture.detectChanges(); + expect(inputGroup.classList.contains(CSS_CLASS_INPUT_GROUP_INVALID)).toBe(true); + + fixture.componentInstance.model.set({ date: new Date(2012, 5, 3) }); + fixture.detectChanges(); + expect(picker.value).toEqual(new Date(2012, 5, 3)); + expect(inputGroup.classList.contains(CSS_CLASS_INPUT_GROUP_INVALID)).toBe(false); + }); + + it('should follow the disabled rule', () => { + fixture.componentInstance.isDisabled.set(true); + fixture.detectChanges(); + expect(picker.disabled).toBe(true); + expect(inputGroup.classList.contains('igx-input-group--disabled')).toBe(true); + + fixture.componentInstance.isDisabled.set(false); + fixture.detectChanges(); + expect(picker.disabled).toBe(false); + }); +}); @Component({ template: ` @@ -1851,3 +1901,22 @@ export class IgxDatePickerReactiveFormComponent { this.form.disable(); } } + +@Component({ + template: ` + + + `, + changeDetection: ChangeDetectionStrategy.Eager, + imports: [IgxDatePickerComponent, IgxLabelDirective, FormField] +}) +class IgxDatePickerSignalFormComponent { + @ViewChild('picker', { read: IgxDatePickerComponent, static: true }) public picker: IgxDatePickerComponent; + + public model = signal<{ date: Date | null }>({ date: null }); + public isDisabled = signal(false); + public userForm = signalForm(this.model, (path) => { + required(path.date); + disabled(path.date, { when: () => this.isDisabled() }); + }); +} 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 02e82923ebb..beb5c8329ac 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 @@ -64,7 +64,8 @@ import { DatePartDeltas, DatePart, isDateInRanges, - I18N_FORMATTER + I18N_FORMATTER, + NgControlAdapter } from 'igniteui-angular/core'; import { IDatePickerValidationFailedEventArgs } from './date-picker.common'; import { IgxIconComponent } from 'igniteui-angular/icon'; @@ -471,6 +472,7 @@ export class IgxDatePickerComponent extends PickerBaseDirective implements Contr private _overlayId: string = ''; private _value!: Date | string; private _ngControl: NgControl = null!; + private _control: NgControlAdapter | null = null; private _statusChanges$!: Subscription; private _calendar!: IgxCalendarComponent; private _calendarContainer?: HTMLElement; @@ -521,13 +523,7 @@ export class IgxDatePickerComponent extends PickerBaseDirective implements Contr /** @hidden @internal */ public get required(): boolean { - if (this._ngControl && this._ngControl.control && this._ngControl.control.validator) { - // Run the validation with empty object to check if required is enabled. - const error = this._ngControl.control.validator({} as AbstractControl); - return error && error.required; - } - - return false; + return this._control?.required ?? false; } /** @hidden @internal */ @@ -759,6 +755,7 @@ export class IgxDatePickerComponent extends PickerBaseDirective implements Contr /** @hidden @internal */ public ngOnInit(): void { this._ngControl = this._injector.get(NgControl, null); + this._control = NgControlAdapter.from(this._ngControl, this._injector); } /** @hidden @internal */ @@ -779,10 +776,9 @@ export class IgxDatePickerComponent extends PickerBaseDirective implements Contr } }); - if (this._ngControl) { - this._statusChanges$ = - this._ngControl.statusChanges!.subscribe(this.onStatusChanged.bind(this)); - if (this._ngControl.control!.validator) { + if (this._control) { + this._statusChanges$ = this._control.statusChanges.subscribe(this.onStatusChanged.bind(this)); + if (this._control.hasValidators) { this.inputGroup.isRequired = this.required; this.cdr.detectChanges(); } @@ -839,25 +835,17 @@ 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._ngControl && !this.disabled && this.isTouchedOrDirty) { - if (this.hasValidators && this.inputGroup.isFocused) { - this.inputDirective.valid = this._ngControl.valid ? IgxInputState.VALID : IgxInputState.INVALID; + 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._ngControl.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; + this.inputDirective.valid = this._control.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; } } else { this.inputDirective.valid = IgxInputState.INITIAL; } } - private get isTouchedOrDirty(): boolean { - return (this._ngControl.control!.touched || this._ngControl.control!.dirty); - } - - private get hasValidators(): boolean { - return (!!this._ngControl.control!.validator || !!this._ngControl.control!.asyncValidator); - } - private onStatusChanged = () => { this.disabled = this._ngControl.disabled!; this.updateValidity(); diff --git a/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker-inputs.common.ts b/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker-inputs.common.ts index 6b6ee4eaa04..4dd2a104995 100644 --- a/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker-inputs.common.ts +++ b/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker-inputs.common.ts @@ -1,4 +1,4 @@ -import { Component, ContentChild, Pipe, PipeTransform, Directive, inject, ChangeDetectionStrategy } from '@angular/core'; +import { Component, ContentChild, Pipe, PipeTransform, Directive, inject, ChangeDetectionStrategy, Injector } from '@angular/core'; import { NgControl } from '@angular/forms'; import { IgxInputDirective, @@ -8,7 +8,7 @@ import { IgxSuffixDirective } from 'igniteui-angular/input-group'; import { IgxButtonDirective, IgxDateTimeEditorDirective } from 'igniteui-angular/directives'; -import { isDate, DateRange, DateTimeUtil, BaseFormatter, I18N_FORMATTER } from 'igniteui-angular/core'; +import { isDate, DateRange, DateTimeUtil, BaseFormatter, I18N_FORMATTER, NgControlAdapter } from 'igniteui-angular/core'; import { IgxIconComponent } from 'igniteui-angular/icon'; import { NgTemplateOutlet } from '@angular/common'; @@ -70,6 +70,8 @@ export class IgxDateRangeInputsBaseComponent extends IgxInputGroupComponent { @ContentChild(NgControl) protected ngControl!: NgControl; + private injector = inject(Injector); + /** @hidden @internal */ public get nativeElement() { return this.element.nativeElement; @@ -82,9 +84,9 @@ export class IgxDateRangeInputsBaseComponent extends IgxInputGroupComponent { /** @hidden @internal */ public updateInputValue(value: Date) { - if (this.ngControl) { - this.ngControl.control!.setValue(value); - } else { + // A control that ignores the write reads the value from the editor instead. + const write = NgControlAdapter.from(this.ngControl, this.injector)?.setValue(value); + if (write !== 'accepted') { this.dateTimeEditor.value = value; } } 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 3efc986a445..150c65c567c 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 @@ -1,9 +1,10 @@ import { ComponentFixture, TestBed, fakeAsync, tick, waitForAsync, flush } from '@angular/core/testing'; -import { Component, OnInit, ViewChild, DebugElement, ChangeDetectionStrategy, inject, ChangeDetectorRef, ElementRef } from '@angular/core'; +import { Component, OnInit, ViewChild, DebugElement, ChangeDetectionStrategy, inject, ChangeDetectorRef, ElementRef, signal } from '@angular/core'; import { IgxInputDirective, IgxInputGroupComponent, IgxInputState, IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective } from '../../../input-group/src/public_api'; import { CustomDateRange, DateRange, PickerCalendarOrientation, PickerHeaderOrientation, PickerInteractionMode } from '../../../core/src/date-common/types'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { FormsModule, ReactiveFormsModule, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms'; +import { FormField, disabled, form as signalForm, required } from '@angular/forms/signals'; import { By } from '@angular/platform-browser'; import { ControlsFunction } from '../../../test-utils/controls-functions.spec'; import { UIInteractions } from '../../../test-utils/ui-interactions.spec'; @@ -2315,6 +2316,60 @@ describe('IgxDateRangePicker', () => { }); }); +describe('IgxDateRangePicker - Signal Forms', () => { + let fixture: ComponentFixture; + let single: IgxDateRangePickerComponent; + let twoInputs: IgxDateRangePickerComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, DateRangeSignalFormComponent] + }).compileComponents(); + })); + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeSignalFormComponent); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + single = fixture.componentInstance.single; + twoInputs = fixture.componentInstance.twoInputs; + })); + + it('should initialize and reflect the required rule', () => { + const inputGroups = fixture.debugElement.queryAll(By.css('.igx-input-group')); + expect(inputGroups.length).toBe(3); + inputGroups.forEach(g => expect(g.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_REQUIRED)).toBe(true)); + }); + + it('should become invalid once touched without a value', fakeAsync(() => { + fixture.componentInstance.userForm.range().markAsTouched(); + fixture.componentInstance.userForm.trip().markAsTouched(); + fixture.detectChanges(); + 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); + + const range = { start: new Date(2020, 0, 1), end: new Date(2020, 0, 5) }; + fixture.componentInstance.model.set({ range, trip: range }); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + expect(single.value).toEqual(range); + expect(single.inputDirective.valid).toBe(IgxInputState.INITIAL); + expect(twoInputs.projectedInputs.first.inputDirective.valid).toBe(IgxInputState.INITIAL); + })); + + it('should follow the disabled rule', fakeAsync(() => { + fixture.componentInstance.isDisabled.set(true); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + expect(single.disabled).toBe(true); + expect(twoInputs.disabled).toBe(true); + })); +}); + @Component({ selector: 'igx-date-range-test', template: '', @@ -2611,3 +2666,41 @@ export class DateRangeReactiveFormComponent { this.form.disable(); } } + +@Component({ + template: ` + + + + + + + + + + + `, + changeDetection: ChangeDetectionStrategy.Eager, + imports: [ + IgxDateRangePickerComponent, + IgxDateRangeStartComponent, + IgxDateRangeEndComponent, + IgxInputDirective, + IgxLabelDirective, + IgxDateTimeEditorDirective, + FormField + ] +}) +export class DateRangeSignalFormComponent { + @ViewChild('single', { read: IgxDateRangePickerComponent }) public single: IgxDateRangePickerComponent; + @ViewChild('twoInputs', { read: IgxDateRangePickerComponent }) public twoInputs: IgxDateRangePickerComponent; + + public model = signal<{ range: DateRange | null; trip: DateRange | null }>({ range: null, trip: null }); + public isDisabled = signal(false); + public userForm = signalForm(this.model, (path) => { + required(path.range); + required(path.trip); + disabled(path.range, { when: () => this.isDisabled() }); + disabled(path.trip, { when: () => this.isDisabled() }); + }); +} 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 3edfb2216d4..ce888b44f7e 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 @@ -62,6 +62,7 @@ import { DateTimeUtil, IgxPickerActionsDirective, isDateInRanges, + NgControlAdapter, PickerCalendarOrientation, THEME_TOKEN, ThemeToken @@ -604,12 +605,7 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective } private get required(): boolean { - if (this._ngControl && this._ngControl.control && this._ngControl.control.validator) { - const error = this._ngControl.control.validator({} as AbstractControl); - return (error && error.required) ? true : false; - } - - return false; + return this._control?.required ?? false; } private get calendar(): IgxCalendarComponent { @@ -642,6 +638,7 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective private _originalValue!: DateRange | null; private _overlayId: string = ''; private _ngControl!: NgControl; + private _control: NgControlAdapter | null = null; private _statusChanges$!: Subscription; private _calendar!: IgxCalendarComponent; private _calendarContainer?: HTMLElement; @@ -863,6 +860,7 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective /** @hidden */ public ngOnInit(): void { this._ngControl = this._injector.get(NgControl, null); + this._control = NgControlAdapter.from(this._ngControl, this._injector); } /** @hidden */ @@ -877,8 +875,8 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective this.setRequiredToInputs(); - if (this._ngControl) { - this._statusChanges$ = this._ngControl.statusChanges!.subscribe(this.onStatusChanged.bind(this)); + if (this._control) { + this._statusChanges$ = this._control.statusChanges.subscribe(this.onStatusChanged.bind(this)); } // delay invocations until the current change detection cycle has completed @@ -941,25 +939,17 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective }; private setValidityState(inputDirective: IgxInputDirective, isFocused: boolean) { - if (this._ngControl && !this._ngControl.disabled && this.isTouchedOrDirty) { - if (this.hasValidators && isFocused) { - inputDirective.valid = this._ngControl.valid ? IgxInputState.VALID : IgxInputState.INVALID; + 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._ngControl.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; + inputDirective.valid = this._control.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; } } else { inputDirective.valid = IgxInputState.INITIAL; } } - private get isTouchedOrDirty(): boolean { - return (this._ngControl.control!.touched || this._ngControl.control!.dirty); - } - - private get hasValidators(): boolean { - return (!!this._ngControl.control!.validator || !!this._ngControl.control!.asyncValidator); - } - private handleSelection(selectionData: Date[]): void { let newValue = this.extractRange(selectionData); if (!newValue.start && !newValue.end) { @@ -1309,6 +1299,11 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective const _value = this.value ? this.toRangeOfDates(this.value) : null; start.updateInputValue(_value?.start || null!); end.updateInputValue(_value?.end || null!); + + // `validate()` reads the editors, which Signal Forms validate before writing to; re-run it. + if (this._control?.backend === 'signal') { + this.onValidatorChange(); + } } } diff --git a/projects/igniteui-angular/directives/src/directives/checkbox/checkbox-base.directive.ts b/projects/igniteui-angular/directives/src/directives/checkbox/checkbox-base.directive.ts index 07c1aa393e9..729f9d89081 100644 --- a/projects/igniteui-angular/directives/src/directives/checkbox/checkbox-base.directive.ts +++ b/projects/igniteui-angular/directives/src/directives/checkbox/checkbox-base.directive.ts @@ -1,6 +1,6 @@ -import { Directive, EventEmitter, HostListener, HostBinding, Input, Output, ViewChild, ElementRef, ChangeDetectorRef, booleanAttribute, inject, AfterViewInit } from '@angular/core'; -import { NgControl, Validators } from '@angular/forms'; -import { IBaseEventArgs } from 'igniteui-angular/core'; +import { Directive, EventEmitter, HostListener, HostBinding, Input, Output, ViewChild, ElementRef, ChangeDetectorRef, booleanAttribute, inject, AfterViewInit, Injector } from '@angular/core'; +import { NgControl } from '@angular/forms'; +import { IBaseEventArgs, NgControlAdapter } from 'igniteui-angular/core'; import { noop, Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @@ -21,6 +21,7 @@ let nextId = 0; export class CheckboxBaseDirective implements AfterViewInit { protected cdr = inject(ChangeDetectorRef); public ngControl = inject(NgControl, { optional: true, self: true }); + private control = NgControlAdapter.from(this.ngControl, inject(Injector)); /** * An event that is emitted after the checkbox state is changed. @@ -262,18 +263,13 @@ export class CheckboxBaseDirective implements AfterViewInit { * @internal */ public ngAfterViewInit() { - if (this.ngControl) { - this.ngControl.statusChanges! + if (this.control) { + this.control.statusChanges .pipe(takeUntil(this.destroy$)) .subscribe(this.updateValidityState.bind(this)); - if ( - this.ngControl.control!.validator || - this.ngControl.control!.asyncValidator - ) { - this._required = this.ngControl.control!.hasValidator( - Validators.required - ); + if (this.control.hasValidators) { + this._required = this.control.required; this.cdr.detectChanges(); } } @@ -415,14 +411,10 @@ export class CheckboxBaseDirective implements AfterViewInit { * @internal */ protected updateValidityState() { - if (this.ngControl) { - if ( - !this.disabled && - !this.readonly && - (this.ngControl.control!.touched || this.ngControl.control!.dirty) - ) { + if (this.control) { + if (!this.disabled && !this.readonly && this.control.touchedOrDirty) { // the control is not disabled and is touched or dirty - this.invalid = this.ngControl.invalid!; + this.invalid = this.control.invalid; } else { // if the control is untouched, pristine, or disabled, its state is initial. This is when the user did not interact // with the checkbox or when the form/control is reset diff --git a/projects/igniteui-angular/input-group/README.md b/projects/igniteui-angular/input-group/README.md index 6d35c287a93..49e8bea0503 100644 --- a/projects/igniteui-angular/input-group/README.md +++ b/projects/igniteui-angular/input-group/README.md @@ -19,6 +19,8 @@ A walkthrough of how to get started can be found [here](https://www.infragistics ``` +`igxInput` works with template-driven forms (`ngModel`), reactive forms (`formControlName`) and Signal Forms (`[formField]`). + ### Elements The following directives could be wrapped in an container - igxInput, igxLabel, igxPrefix, igxSuffix or igxHint. 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 6122bca06c3..bc18cf8ce2b 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 @@ -1,6 +1,7 @@ -import { Component, ViewChild, ViewChildren, QueryList, DebugElement, inject, ChangeDetectionStrategy } from '@angular/core'; -import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { Component, ViewChild, ViewChildren, QueryList, DebugElement, inject, ChangeDetectionStrategy, signal } from '@angular/core'; +import { ComponentFixture, TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; import { FormsModule, UntypedFormBuilder, ReactiveFormsModule, Validators, UntypedFormControl, UntypedFormGroup, FormControl } from '@angular/forms'; +import { FormField, disabled, form as signalForm, required } from '@angular/forms/signals'; import { By } from '@angular/platform-browser'; import { IgxInputGroupComponent } from '../input-group.component'; import { IgxInputDirective, IgxInputState } from './input.directive'; @@ -927,6 +928,72 @@ describe('IgxInput', () => { })); }); +describe('IgxInput - Signal Forms', () => { + let fixture: ComponentFixture; + let inputGroup: HTMLElement; + let input: HTMLInputElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [SignalFormComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SignalFormComponent); + fixture.detectChanges(); + inputGroup = fixture.debugElement.query(By.css('igx-input-group')).nativeElement; + input = fixture.debugElement.query(By.directive(IgxInputDirective)).nativeElement; + }); + + it('should initialize and reflect the required rule', () => { + expect(inputGroup.classList.contains(INPUT_GROUP_REQUIRED_CSS_CLASS)).toBe(true); + expect(input.getAttribute('aria-required')).toBe('true'); + expect(inputGroup.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + }); + + it('should become invalid once touched with an empty value', () => { + input.dispatchEvent(new Event('focus')); + input.dispatchEvent(new Event('blur')); + fixture.detectChanges(); + + expect(inputGroup.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(true); + expect(input.getAttribute('aria-invalid')).toBe('true'); + + fixture.componentInstance.model.set({ firstName: 'Bobby' }); + fixture.detectChanges(); + + expect(inputGroup.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + expect(inputGroup.classList.contains(INPUT_GROUP_FILLED_CSS_CLASS)).toBe(true); + }); + + it('should follow the disabled rule', () => { + expect(inputGroup.classList.contains(INPUT_GROUP_DISABLED_CSS_CLASS)).toBe(false); + + fixture.componentInstance.isDisabled.set(true); + fixture.detectChanges(); + + expect(inputGroup.classList.contains(INPUT_GROUP_DISABLED_CSS_CLASS)).toBe(true); + expect(input.disabled).toBe(true); + + fixture.componentInstance.isDisabled.set(false); + fixture.detectChanges(); + + expect(inputGroup.classList.contains(INPUT_GROUP_DISABLED_CSS_CLASS)).toBe(false); + }); + + it('should reset the invalid state when the field is reset', () => { + input.dispatchEvent(new Event('blur')); + fixture.detectChanges(); + expect(inputGroup.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(true); + + fixture.componentInstance.userForm.firstName().reset(); + fixture.detectChanges(); + + expect(inputGroup.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + }); +}); + @Component({ template: `
@@ -1398,3 +1465,21 @@ const dispatchInputEvent = (eventName, inputNativeElement, fixture) => { inputNativeElement.dispatchEvent(new Event(eventName)); fixture.detectChanges(); }; + +@Component({ + template: ` + + + + `, + changeDetection: ChangeDetectionStrategy.Eager, + imports: [IgxInputGroupComponent, IgxLabelDirective, IgxInputDirective, FormField] +}) +class SignalFormComponent { + public model = signal({ firstName: '' }); + public isDisabled = signal(false); + public userForm = signalForm(this.model, (path) => { + required(path.firstName); + disabled(path.firstName, { when: () => this.isDisabled() }); + }); +} 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 72d00489ff7..035892a3981 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,11 +1,7 @@ -import { AfterViewInit, ChangeDetectorRef, Directive, ElementRef, HostBinding, HostListener, Input, OnDestroy, Renderer2, booleanAttribute, inject } from '@angular/core'; -import { - AbstractControl, - NgControl, - NgModel, - TouchedChangeEvent -} from '@angular/forms'; -import { filter, Subscription } from 'rxjs'; +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 { IgxInputGroupBase } from '../input-group.common'; const nativeValidationAttributes = [ @@ -57,6 +53,7 @@ export class IgxInputDirective implements AfterViewInit, OnDestroy { protected element = inject>(ElementRef); protected cdr = inject(ChangeDetectorRef); protected renderer = inject(Renderer2); + private control = NgControlAdapter.from(this.ngControl, inject(Injector)); /** * Sets/gets whether the `"igx-input-group__input"` class is added to the host element. @@ -187,11 +184,7 @@ export class IgxInputDirective implements AfterViewInit, OnDestroy { * ``` */ public get required() { - let validation; - if (this.ngControl && (this.ngControl.control!.validator || this.ngControl.control!.asyncValidator)) { - validation = this.ngControl.control!.validator!({} as AbstractControl); - } - return validation && validation.required || this.nativeElement.hasAttribute('required'); + return this.control?.required || this.nativeElement.hasAttribute('required'); } /** * @hidden @@ -210,9 +203,7 @@ export class IgxInputDirective implements AfterViewInit, OnDestroy { @HostListener('blur') public onBlur() { this.inputGroup.isFocused = false; - if (this.ngControl?.control) { - this.ngControl.control.markAsTouched(); - } + this.control?.markAsTouched(); this.updateValidityState(); } /** @hidden @internal */ @@ -249,9 +240,14 @@ export class IgxInputDirective implements AfterViewInit, OnDestroy { /** @hidden @internal */ public clear() { - this.ngControl?.control?.setValue(''); + const write = this.control?.setValue(''); this.nativeElement.value = null!; this._fileNames = ''; + + // A control that ignores the write reads the value from the DOM instead. + if (write === 'ignored') { + this.nativeElement.dispatchEvent(new Event('input')); + } } /** @hidden @internal */ @@ -292,22 +288,10 @@ export class IgxInputDirective implements AfterViewInit, OnDestroy { this.isInput = true; } - if (this.ngControl) { - this._statusChanges$ = this.ngControl.statusChanges!.subscribe( - this.onStatusChanged.bind(this) - ); - - this._valueChanges$ = this.ngControl.valueChanges!.subscribe( - this.onValueChanged.bind(this) - ); - - if (this.ngControl.control) { - this._touchedChanges$ = this.ngControl.control.events - .pipe(filter(e => e instanceof TouchedChangeEvent)) - .subscribe( - this.updateValidityState.bind(this) - ); - } + if (this.control) { + this._statusChanges$ = this.control.statusChanges.subscribe(this.onStatusChanged.bind(this)); + this._valueChanges$ = this.control.valueChanges.subscribe(this.onValueChanged.bind(this)); + this._touchedChanges$ = this.control.touchedChanges.subscribe(this.updateValidityState.bind(this)); } this.cdr.detectChanges(); @@ -369,21 +353,19 @@ export class IgxInputDirective implements AfterViewInit, OnDestroy { * @internal */ protected updateValidityState() { - if (this.ngControl) { - if (!this.disabled && this.isTouchedOrDirty) { - if (this.hasValidators) { - // Run the validation with empty object to check if required is enabled. - const error = this.ngControl.control!.validator!({} as AbstractControl); - this.inputGroup.isRequired = error && error.required; + 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.ngControl.valid ? IgxInputState.VALID : IgxInputState.INVALID; + this._valid = this.control.valid ? IgxInputState.VALID : IgxInputState.INVALID; } else { - this._valid = this.ngControl.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; + 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.ngControl.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; + this._valid = this.control.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; } } else { this._valid = IgxInputState.INITIAL; @@ -396,14 +378,6 @@ export class IgxInputDirective implements AfterViewInit, OnDestroy { } } - private get isTouchedOrDirty(): boolean { - return (this.ngControl.control!.touched || this.ngControl.control!.dirty); - } - - private get hasValidators(): boolean { - return (!!this.ngControl.control!.validator || !!this.ngControl.control!.asyncValidator); - } - /** * Gets whether the igxInput has a placeholder. * diff --git a/projects/igniteui-angular/radio/src/radio/radio-group/radio-group.directive.spec.ts b/projects/igniteui-angular/radio/src/radio/radio-group/radio-group.directive.spec.ts index 39f827412f6..6d2a0c0cf7a 100644 --- a/projects/igniteui-angular/radio/src/radio/radio-group/radio-group.directive.spec.ts +++ b/projects/igniteui-angular/radio/src/radio/radio-group/radio-group.directive.spec.ts @@ -1,7 +1,8 @@ -import { ChangeDetectionStrategy, Component, ComponentRef, OnInit, ViewChild, ViewContainerRef, inject } from '@angular/core'; -import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { ChangeDetectionStrategy, Component, ComponentRef, OnInit, ViewChild, ViewContainerRef, inject, signal } from '@angular/core'; +import { ComponentFixture, TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; import { IgxRadioGroupDirective } from './radio-group.directive'; import { FormsModule, ReactiveFormsModule, UntypedFormGroup, UntypedFormBuilder, FormGroup, FormControl } from '@angular/forms'; +import { FormField, form as signalForm, required } from '@angular/forms/signals'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { By } from '@angular/platform-browser'; @@ -708,6 +709,46 @@ describe('IgxRadioGroupDirective', () => { }); }); +describe('IgxRadioGroupDirective - Signal Forms', () => { + let fixture: ComponentFixture; + let radioGroup: IgxRadioGroupDirective; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, RadioGroupSignalFormComponent] + }).compileComponents(); + })); + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(RadioGroupSignalFormComponent); + fixture.detectChanges(); + tick(); + radioGroup = fixture.componentInstance.radioGroup; + })); + + it('should initialize and reflect the required rule', () => { + expect(radioGroup.required).toBe(true); + expect(radioGroup.invalid).toBe(false); + expect(radioGroup.radioButtons.first.required).toBe(true); + }); + + it('should become invalid once touched without a selection', fakeAsync(() => { + const domRadio = fixture.debugElement.query(By.css('igx-radio')).nativeElement; + + dispatchRadioEvent('blur', domRadio, fixture); + tick(); + expect(radioGroup.invalid).toBe(true); + expect(domRadio.classList.contains('igx-radio--invalid')).toBe(true); + + radioGroup.radioButtons.first.select(); + fixture.detectChanges(); + tick(); + expect(fixture.componentInstance.model().season).toBe('Winter'); + expect(radioGroup.invalid).toBe(false); + expect(domRadio.classList.contains('igx-radio--invalid')).toBe(false); + })); +}); + @Component({ template: ` @@ -988,3 +1029,23 @@ const dispatchRadioEvent = (eventName, radioNativeElement, fixture) => { radioNativeElement.dispatchEvent(new Event(eventName)); fixture.detectChanges(); }; + +@Component({ + template: ` + + @for (season of seasons; track season) { + {{ season }} + } + `, + changeDetection: ChangeDetectionStrategy.Eager, + imports: [IgxRadioComponent, IgxRadioGroupDirective, FormField] +}) +class RadioGroupSignalFormComponent { + @ViewChild('group', { read: IgxRadioGroupDirective, static: true }) public radioGroup: IgxRadioGroupDirective; + + public seasons = ['Winter', 'Spring', 'Summer', 'Autumn']; + public model = signal({ season: '' }); + public userForm = signalForm(this.model, (path) => { + required(path.season); + }); +} diff --git a/projects/igniteui-angular/radio/src/radio/radio-group/radio-group.directive.ts b/projects/igniteui-angular/radio/src/radio/radio-group/radio-group.directive.ts index 60cb45870fd..6516d14f736 100644 --- a/projects/igniteui-angular/radio/src/radio/radio-group/radio-group.directive.ts +++ b/projects/igniteui-angular/radio/src/radio/radio-group/radio-group.directive.ts @@ -13,12 +13,13 @@ import { effect, signal, inject, - ElementRef + ElementRef, + Injector } from '@angular/core'; -import { ControlValueAccessor, NgControl, Validators } from '@angular/forms'; -import { fromEvent, noop, Subject, takeUntil } from 'rxjs'; +import { ControlValueAccessor, NgControl } from '@angular/forms'; +import { fromEvent, noop, Subject, Subscription, takeUntil } from 'rxjs'; import { IgxRadioComponent } from '../radio.component'; -import { isLeftToRight } from 'igniteui-angular/core'; +import { isLeftToRight, NgControlAdapter } from 'igniteui-angular/core'; import { IChangeCheckboxEventArgs } from 'igniteui-angular/directives'; /** * Determines the Radio Group alignment @@ -61,6 +62,8 @@ let nextId = 0; }) export class IgxRadioGroupDirective implements ControlValueAccessor, OnDestroy, DoCheck { public ngControl = inject(NgControl, { optional: true, self: true }); + private control = NgControlAdapter.from(this.ngControl, inject(Injector)); + private _statusChanges$?: Subscription; private cdr = inject(ChangeDetectorRef); private readonly _element = inject>(ElementRef); @@ -505,15 +508,16 @@ export class IgxRadioGroupDirective implements ControlValueAccessor, OnDestroy, // the OnInit of the NgModel occurs after the OnInit of this class. this._isInitialized.set(true); - if (this.ngControl) { - this.ngControl.statusChanges! + if (this.control) { + // Runs inside an effect, so subscribe once. + this._statusChanges$ ??= this.control.statusChanges .pipe(takeUntil(this.destroy$)) .subscribe(() => { this.invalid = false; }); - if (this.ngControl.control!.validator || this.ngControl.control!.asyncValidator) { - this._required = this.ngControl?.control?.hasValidator(Validators.required)!; + if (this.control.hasValidators) { + this._required = this.control.required; } this._radioButtons().forEach((button) => { diff --git a/projects/igniteui-angular/select/src/select/select.component.spec.ts b/projects/igniteui-angular/select/src/select/select.component.spec.ts index 3212df317b0..60d00c0b06e 100644 --- a/projects/igniteui-angular/select/src/select/select.component.spec.ts +++ b/projects/igniteui-angular/select/src/select/select.component.spec.ts @@ -1,7 +1,8 @@ -import { Component, ViewChild, DebugElement, OnInit, ElementRef, inject, ChangeDetectorRef, DOCUMENT, Injector, ChangeDetectionStrategy } from '@angular/core'; +import { Component, ViewChild, DebugElement, OnInit, ElementRef, inject, ChangeDetectorRef, DOCUMENT, Injector, ChangeDetectionStrategy, signal } from '@angular/core'; import { NgStyle } from '@angular/common'; -import { TestBed, tick, fakeAsync, waitForAsync, discardPeriodicTasks } from '@angular/core/testing'; +import { ComponentFixture, TestBed, tick, fakeAsync, waitForAsync, discardPeriodicTasks } from '@angular/core/testing'; import { FormsModule, UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators, ReactiveFormsModule, NgForm, NgControl } from '@angular/forms'; +import { FormField, disabled, form as signalForm, required } from '@angular/forms/signals'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; @@ -2726,6 +2727,56 @@ describe('igxSelect', () => { }); }); +describe('IgxSelect - Signal Forms', () => { + let fixture: ComponentFixture; + let select: IgxSelectComponent; + let inputGroup: HTMLElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, IgxSelectSignalFormComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(IgxSelectSignalFormComponent); + fixture.detectChanges(); + select = fixture.componentInstance.select; + inputGroup = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP)).nativeElement; + }); + + it('should initialize and reflect the required rule', () => { + expect(inputGroup.classList.contains(CSS_CLASS_INPUT_GROUP_REQUIRED)).toBe(true); + expect(select.input.nativeElement.getAttribute('aria-required')).toEqual('true'); + expect(select.input.valid).toEqual(IgxInputState.INITIAL); + }); + + it('should become invalid once touched without a value', () => { + select.onBlur(); + fixture.detectChanges(); + expect(select.input.valid).toEqual(IgxInputState.INVALID); + expect(inputGroup.classList.contains(CSS_CLASS_INPUT_GROUP_INVALID)).toBe(true); + + fixture.componentInstance.model.set({ option: 'Option 2' }); + fixture.detectChanges(); + select.onBlur(); + fixture.detectChanges(); + expect(select.value).toEqual('Option 2'); + expect(select.input.valid).toEqual(IgxInputState.INITIAL); + expect(inputGroup.classList.contains(CSS_CLASS_INPUT_GROUP_INVALID)).toBe(false); + }); + + it('should follow the disabled rule', () => { + fixture.componentInstance.isDisabled.set(true); + fixture.detectChanges(); + expect(select.disabled).toBe(true); + + fixture.componentInstance.isDisabled.set(false); + fixture.detectChanges(); + expect(select.disabled).toBe(false); + }); +}); + describe('igxSelect ControlValueAccessor Unit', () => { let select: IgxSelectComponent; it('Should correctly implement interface methods', () => { @@ -3231,3 +3282,26 @@ class IgxSelectWithIdComponent { public items: string[] = ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5']; } + +@Component({ + template: ` + + + @for (item of items; track item) { + {{ item }} + } + `, + changeDetection: ChangeDetectionStrategy.Eager, + imports: [IgxSelectComponent, IgxSelectItemComponent, IgxLabelDirective, FormField] +}) +class IgxSelectSignalFormComponent { + @ViewChild('select', { read: IgxSelectComponent, static: true }) public select: IgxSelectComponent; + + public items = ['Option 1', 'Option 2', 'Option 3']; + public model = signal({ option: '' }); + public isDisabled = signal(false); + public userForm = signalForm(this.model, (path) => { + required(path.option); + disabled(path.option, { when: () => this.isDisabled() }); + }); +} diff --git a/projects/igniteui-angular/select/src/select/select.component.ts b/projects/igniteui-angular/select/src/select/select.component.ts index 96dad6e6757..d22f5248aaf 100644 --- a/projects/igniteui-angular/select/src/select/select.component.ts +++ b/projects/igniteui-angular/select/src/select/select.component.ts @@ -25,7 +25,7 @@ import { ViewEncapsulation } from '@angular/core'; import { NgTemplateOutlet } from '@angular/common'; -import { AbstractControl, ControlValueAccessor, NgControl, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { ControlValueAccessor, NgControl, NG_VALUE_ACCESSOR } from '@angular/forms'; import { noop } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @@ -41,7 +41,7 @@ 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 { ToggleViewCancelableEventArgs, ToggleViewEventArgs, IgxToggleDirective } from 'igniteui-angular/directives'; -import { IgxOverlayService } from 'igniteui-angular/core'; +import { IgxOverlayService, NgControlAdapter } from 'igniteui-angular/core'; import { IgxIconComponent } from 'igniteui-angular/icon'; import { IgxSelectItemNavigationDirective } from './select-navigation.directive'; import { IGX_DROPDOWN_BASE, IgxDropDownComponent, IgxDropDownItemBaseDirective, ISelectionEventArgs, Navigate } from 'igniteui-angular/drop-down'; @@ -279,6 +279,7 @@ export class IgxSelectComponent extends IgxDropDownComponent implements IgxSelec public override height!: string; private ngControl: NgControl = null!; + private control: NgControlAdapter | null = null; private _overlayDefaults!: OverlaySettings; private _value: any; private _type: IgxInputGroupType | null = null; @@ -509,6 +510,7 @@ export class IgxSelectComponent extends IgxDropDownComponent implements IgxSelec */ public override ngOnInit() { this.ngControl = this._injector.get(NgControl, null); + this.control = NgControlAdapter.from(this.ngControl, this._injector); } /** @@ -517,8 +519,8 @@ export class IgxSelectComponent extends IgxDropDownComponent implements IgxSelec public override ngAfterViewInit() { super.ngAfterViewInit(); - if (this.ngControl) { - this.ngControl.statusChanges!.pipe(takeUntil(this.destroy$)).subscribe(this.onStatusChanged.bind(this)); + if (this.control) { + this.control.statusChanges.pipe(takeUntil(this.destroy$)).subscribe(this.onStatusChanged.bind(this)); this.manageRequiredAsterisk(); } @@ -572,26 +574,18 @@ export class IgxSelectComponent extends IgxDropDownComponent implements IgxSelec protected onStatusChanged() { this.manageRequiredAsterisk(); - if (this.ngControl && !this.ngControl.disabled && this.isTouchedOrDirty) { - if (this.hasValidators && this.inputGroup.isFocused) { - this.input.valid = this.ngControl.valid ? IgxInputState.VALID : IgxInputState.INVALID; + 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.ngControl.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; + this.input.valid = this.control.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; } } else { this.input.valid = IgxInputState.INITIAL; } } - private get isTouchedOrDirty(): boolean { - return (this.ngControl.control!.touched || this.ngControl.control!.dirty); - } - - private get hasValidators(): boolean { - return (!!this.ngControl.control!.validator || !!this.ngControl.control!.asyncValidator); - } - protected override navigate(direction: Navigate, currentIndex?: number) { if (this.collapsed && this.selectedItem) { this.navigateItem(this.selectedItem.itemIndex); @@ -601,12 +595,7 @@ export class IgxSelectComponent extends IgxDropDownComponent implements IgxSelec protected manageRequiredAsterisk(): void { const hasRequiredHTMLAttribute = this.elementRef.nativeElement.hasAttribute('required'); - let isRequired = false; - - if (this.ngControl && this.ngControl.control!.validator) { - const error = this.ngControl.control!.validator({} as AbstractControl); - isRequired = !!(error && error.required); - } + const isRequired = this.control?.required ?? false; this.inputGroup.isRequired = isRequired; diff --git a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts index 98a3a07a68c..e1ff4301ae7 100644 --- a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts +++ b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts @@ -1,7 +1,8 @@ import { AsyncPipe } from '@angular/common'; -import { AfterViewInit, ChangeDetectorRef, Component, DOCUMENT, DebugElement, ElementRef, Injector, OnDestroy, OnInit, ViewChild, inject, ChangeDetectionStrategy } from '@angular/core'; +import { AfterViewInit, ChangeDetectorRef, Component, DOCUMENT, DebugElement, ElementRef, Injector, OnDestroy, OnInit, ViewChild, inject, ChangeDetectionStrategy, signal } from '@angular/core'; import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { FormControl, FormGroup, FormsModule, NgForm, ReactiveFormsModule, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; +import { FormField, disabled, form as signalForm, required } from '@angular/forms/signals'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { IgxSelectionAPIService, PlatformUtil } from 'igniteui-angular/core'; @@ -3157,6 +3158,58 @@ describe('IgxSimpleCombo', () => { }); }); +describe('IgxSimpleComboComponent - Signal Forms', () => { + let fixture: ComponentFixture; + let combo: IgxSimpleComboComponent; + let inputGroup: HTMLElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, IgxSimpleComboSignalFormComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(IgxSimpleComboSignalFormComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + inputGroup = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUTGROUP)).nativeElement; + }); + + it('should initialize and reflect the required rule', () => { + expect(inputGroup.classList.contains(CSS_CLASS_INPUT_GROUP_REQUIRED)).toBe(true); + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + }); + + it('should become invalid once touched without a selection', () => { + combo.onBlur(); + fixture.detectChanges(); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + expect(inputGroup.classList.contains(CSS_CLASS_INPUT_GROUP_INVALID)).toBe(true); + + combo.select('Maine'); + fixture.detectChanges(); + expect(fixture.componentInstance.model().town).toEqual('Maine'); + + combo.onBlur(); + fixture.detectChanges(); + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(inputGroup.classList.contains(CSS_CLASS_INPUT_GROUP_INVALID)).toBe(false); + }); + + it('should follow the disabled rule', () => { + fixture.componentInstance.isDisabled.set(true); + fixture.detectChanges(); + expect(combo.disabled).toBe(true); + + fixture.componentInstance.isDisabled.set(false); + fixture.detectChanges(); + expect(combo.disabled).toBe(false); + }); +}); + @Component({ template: ` @@ -3790,3 +3843,23 @@ export class IgxSimpleComboTabBehaviorTestComponent implements OnInit { ]; } } + +@Component({ + template: ` + + + `, + changeDetection: ChangeDetectionStrategy.Eager, + imports: [IgxSimpleComboComponent, IgxLabelDirective, FormField] +}) +class IgxSimpleComboSignalFormComponent { + @ViewChild('combo', { read: IgxSimpleComboComponent, static: true }) public combo: IgxSimpleComboComponent; + + public items = [{ field: 'Connecticut' }, { field: 'Maine' }, { field: 'Vermont' }]; + public model = signal<{ town: string | null }>({ town: null }); + public isDisabled = signal(false); + public userForm = signalForm(this.model, (path) => { + required(path.town); + disabled(path.town, { when: () => this.isDisabled() }); + }); +} diff --git a/projects/igniteui-angular/switch/src/switch/switch.component.spec.ts b/projects/igniteui-angular/switch/src/switch/switch.component.spec.ts index 0d08701e6bb..2c7b5b4500f 100644 --- a/projects/igniteui-angular/switch/src/switch/switch.component.spec.ts +++ b/projects/igniteui-angular/switch/src/switch/switch.component.spec.ts @@ -1,6 +1,7 @@ -import { Component, ViewChild, inject, ChangeDetectionStrategy } from '@angular/core'; -import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { Component, ViewChild, inject, ChangeDetectionStrategy, signal } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { UntypedFormBuilder, FormsModule, ReactiveFormsModule, Validators, NgForm } from '@angular/forms'; +import { FormField, disabled, form as signalForm, required } from '@angular/forms/signals'; import { By } from '@angular/platform-browser'; import { IgxSwitchComponent } from './switch.component'; @@ -303,6 +304,53 @@ describe('IgxSwitch', () => { }); }); +describe('IgxSwitchComponent - Signal Forms', () => { + let fixture: ComponentFixture; + let instance: IgxSwitchComponent; + let host: HTMLElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, SwitchSignalFormComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SwitchSignalFormComponent); + fixture.detectChanges(); + instance = fixture.componentInstance.control; + host = fixture.debugElement.query(By.css('igx-switch')).nativeElement; + }); + + it('should initialize and reflect the required rule', () => { + expect(instance.required).toBe(true); + expect(instance.invalid).toBe(false); + expect(instance.nativeElement.getAttribute('aria-required')).toEqual('true'); + }); + + it('should become invalid once touched while unchecked', () => { + dispatchCbEvent('blur', host, fixture); + expect(instance.invalid).toBe(true); + expect(host.classList.contains('igx-switch--invalid')).toBe(true); + + dispatchCbEvent('click', host, fixture); + expect(instance.checked).toBe(true); + expect(fixture.componentInstance.model().accepted).toBe(true); + expect(instance.invalid).toBe(false); + expect(host.classList.contains('igx-switch--invalid')).toBe(false); + }); + + it('should follow the disabled rule', () => { + fixture.componentInstance.isDisabled.set(true); + fixture.detectChanges(); + expect(instance.disabled).toBe(true); + + fixture.componentInstance.isDisabled.set(false); + fixture.detectChanges(); + expect(instance.disabled).toBe(false); + }); +}); + @Component({ template: `Init`, changeDetection: ChangeDetectionStrategy.Eager, @@ -395,3 +443,19 @@ const dispatchCbEvent = (eventName, switchNativeElement, fixture) => { switchNativeElement.dispatchEvent(new Event(eventName)); fixture.detectChanges(); }; + +@Component({ + template: `Accept`, + changeDetection: ChangeDetectionStrategy.Eager, + imports: [IgxSwitchComponent, FormField] +}) +class SwitchSignalFormComponent { + @ViewChild('control', { static: true }) public control: IgxSwitchComponent; + + public model = signal({ accepted: false }); + public isDisabled = signal(false); + public userForm = signalForm(this.model, (path) => { + required(path.accepted); + disabled(path.accepted, { when: () => this.isDisabled() }); + }); +} 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 f353b8e34af..a3e3409a233 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 @@ -1,6 +1,7 @@ -import { Component, ViewChild, DebugElement, EventEmitter, QueryList, ElementRef, Injector, ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core'; +import { Component, ViewChild, DebugElement, EventEmitter, QueryList, ElementRef, Injector, ChangeDetectorRef, ChangeDetectionStrategy, signal } from '@angular/core'; import { TestBed, fakeAsync, tick, ComponentFixture, waitForAsync } from '@angular/core/testing'; import { UntypedFormControl, UntypedFormGroup, FormsModule, NgForm, ReactiveFormsModule, Validators } from '@angular/forms'; +import { FormField, disabled, form as signalForm, required } from '@angular/forms/signals'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { IgxTimePickerComponent, IgxTimePickerValidationFailedEventArgs } from './time-picker.component'; @@ -1947,6 +1948,55 @@ describe('IgxTimePicker', () => { }); }); +describe('IgxTimePickerComponent - Signal Forms', () => { + let fixture: ComponentFixture; + let picker: IgxTimePickerComponent; + let inputGroup: HTMLElement; + let input: HTMLInputElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, IgxTimePickerSignalFormComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(IgxTimePickerSignalFormComponent); + fixture.detectChanges(); + picker = fixture.componentInstance.picker; + inputGroup = fixture.debugElement.query(By.css('igx-input-group')).nativeElement; + input = fixture.debugElement.query(By.css('.igx-input-group__input')).nativeElement; + }); + + it('should initialize and reflect the required rule', () => { + expect(inputGroup.classList.contains(CSS_CLASS_INPUT_GROUP_REQUIRED)).toBe(true); + expect(inputGroup.classList.contains(CSS_CLASS_INPUT_GROUP_INVALID)).toBe(false); + }); + + it('should become invalid once touched without a value', () => { + input.dispatchEvent(new Event('focus')); + input.dispatchEvent(new Event('blur')); + fixture.detectChanges(); + expect(inputGroup.classList.contains(CSS_CLASS_INPUT_GROUP_INVALID)).toBe(true); + + fixture.componentInstance.model.set({ time: new Date(2012, 5, 3, 10, 30) }); + fixture.detectChanges(); + expect(picker.value).toEqual(new Date(2012, 5, 3, 10, 30)); + expect(inputGroup.classList.contains(CSS_CLASS_INPUT_GROUP_INVALID)).toBe(false); + }); + + it('should follow the disabled rule', () => { + fixture.componentInstance.isDisabled.set(true); + fixture.detectChanges(); + expect(picker.disabled).toBe(true); + expect(inputGroup.classList.contains('igx-input-group--disabled')).toBe(true); + + fixture.componentInstance.isDisabled.set(false); + fixture.detectChanges(); + expect(picker.disabled).toBe(false); + }); +}); + @Component({ template: ` @@ -2053,3 +2103,22 @@ export class IgxTimePickerReactiveFormComponent { this.form.disable(); } } + +@Component({ + template: ` + + + `, + changeDetection: ChangeDetectionStrategy.Eager, + imports: [IgxTimePickerComponent, IgxLabelDirective, FormField] +}) +class IgxTimePickerSignalFormComponent { + @ViewChild('picker', { read: IgxTimePickerComponent, static: true }) public picker: IgxTimePickerComponent; + + public model = signal<{ time: Date | null }>({ time: null }); + public isDisabled = signal(false); + public userForm = signalForm(this.model, (path) => { + required(path.time); + disabled(path.time, { when: () => this.isDisabled() }); + }); +} 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 c0beee6fbf1..28b86c146c6 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 @@ -63,7 +63,7 @@ import { IgxButtonDirective } from 'igniteui-angular/directives'; import { IgxDateTimeEditorDirective } from 'igniteui-angular/directives'; import { IgxToggleDirective } from 'igniteui-angular/directives'; import { ITimePickerResourceStrings, TimePickerResourceStringsEN } from 'igniteui-angular/core'; -import { IBaseEventArgs, isEqual, isDate, PlatformUtil, IBaseCancelableBrowserEventArgs } from 'igniteui-angular/core'; +import { IBaseEventArgs, isEqual, isDate, PlatformUtil, IBaseCancelableBrowserEventArgs, NgControlAdapter } from 'igniteui-angular/core'; import { IgxTextSelectionDirective } from 'igniteui-angular/directives'; import { TimeFormatPipe, TimeItemPipe } from './time-picker.pipes'; @@ -451,13 +451,7 @@ export class IgxTimePickerComponent extends PickerBaseDirective } private get required(): boolean { - if (this._ngControl && this._ngControl.control && this._ngControl.control.validator) { - // Run the validation with empty object to check if required is enabled. - const error = this._ngControl.control.validator({} as AbstractControl); - return !!(error && error.required); - } - - return false; + return this._control?.required ?? false; } private get dialogOverlaySettings(): OverlaySettings { @@ -502,6 +496,7 @@ export class IgxTimePickerComponent extends PickerBaseDirective private _statusChanges$!: Subscription; private _ngControl: NgControl = null!; + private _control: NgControlAdapter | null = null; private _onChangeCallback: (_: Date | string) => void = noop; private _onTouchedCallback: () => void = noop; private _onValidatorChange: () => void = noop; @@ -751,6 +746,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.minDropdownValue = this.setMinMaxDropdownValue('min', this.minDateValue); this.maxDropdownValue = this.setMinMaxDropdownValue('max', this.maxDateValue); this.setSelectedValue(this._dateValue); @@ -772,8 +768,8 @@ export class IgxTimePickerComponent extends PickerBaseDirective } }); - if (this._ngControl) { - this._statusChanges$ = this._ngControl.statusChanges!.subscribe(this.onStatusChanged.bind(this)); + if (this._control) { + this._statusChanges$ = this._control.statusChanges.subscribe(this.onStatusChanged.bind(this)); this._inputGroup.isRequired = this.required; this.cdr.detectChanges(); } @@ -1107,11 +1103,11 @@ export class IgxTimePickerComponent extends PickerBaseDirective } protected onStatusChanged() { - if (this._ngControl && !this._ngControl.disabled && this.isTouchedOrDirty) { - if (this.hasValidators && this._inputGroup.isFocused) { - this.inputDirective.valid = this._ngControl.valid ? IgxInputState.VALID : IgxInputState.INVALID; + 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._ngControl.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; + this.inputDirective.valid = this._control.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; } } else { // B.P. 18 May 2021: IgxDatePicker does not reset its state upon resetForm #9526 @@ -1128,14 +1124,6 @@ export class IgxTimePickerComponent extends PickerBaseDirective this._customResourceStrings = this._resourceStrings ? Object.assign({}, this._defaultResourceStrings, this._resourceStrings) : null!; } - private get isTouchedOrDirty(): boolean { - return (this._ngControl.control!.touched || this._ngControl.control!.dirty); - } - - private get hasValidators(): boolean { - return (!!this._ngControl.control!.validator || !!this._ngControl.control!.asyncValidator); - } - private setMinMaxDropdownValue(type: string, time: Date): Date { let delta: number; diff --git a/skills/igniteui-angular-components/references/form-controls.md b/skills/igniteui-angular-components/references/form-controls.md index e873b4e885c..2634e6c804f 100644 --- a/skills/igniteui-angular-components/references/form-controls.md +++ b/skills/igniteui-angular-components/references/form-controls.md @@ -17,6 +17,7 @@ - [Slider](#slider) - [Autocomplete](#autocomplete) - [Reactive Forms Integration](#reactive-forms-integration) +- [Signal Forms Integration](#signal-forms-integration) - [Key Rules](#key-rules) ## Input Group @@ -289,6 +290,63 @@ export class MyFormComponent { } ``` +## Signal Forms Integration + +The same controls bind to Angular Signal Forms through `[formField]`. Required, disabled, touched and validity state flow from the field; no `ReactiveFormsModule` is needed. + +```typescript +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { FormField, disabled, form, required, submit } from '@angular/forms/signals'; +import { IGX_INPUT_GROUP_DIRECTIVES } from 'igniteui-angular/input-group'; +import { IgxSelectComponent, IgxSelectItemComponent } from 'igniteui-angular/select'; +import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; + +@Component({ + selector: 'app-signup', + imports: [FormField, IGX_INPUT_GROUP_DIRECTIVES, IgxSelectComponent, IgxSelectItemComponent, IgxCheckboxComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + + + @for (error of signup.name().errors(); track error.kind) { + {{ error.message }} + } + + + + + @for (r of roles; track r) { + {{ r }} + } + + + Accept terms + + + ` +}) +export class SignupComponent { + model = signal({ name: '', role: '', terms: false }); + roles = ['Admin', 'User']; + + signup = form(this.model, (path) => { + required(path.name, { message: 'Name is required' }); + required(path.role); + required(path.terms); + disabled(path.role, { when: ({ valueOf }) => valueOf(path.name) === '' }); + }); + + onSubmit(event: Event) { + event.preventDefault(); + submit(this.signup, async () => undefined); + } +} +``` + +`submit()` marks every field touched, so invalid controls show their error state the same way a reactive `markAllAsTouched()` does. + ## Key Rules - **Always check `app.config.ts` first** — add `provideAnimations()` before using Combo, Select, Date Picker, or any overlay component