Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -446,6 +447,53 @@ describe('IgxCheckbox', () => {
});
});

describe('IgxCheckboxComponent - Signal Forms', () => {
let fixture: ComponentFixture<CheckboxSignalFormComponent>;
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: `<igx-checkbox #rootCb>Root</igx-checkbox>
<div #indigoWrapper style="--ig-theme: indigo">
Expand Down Expand Up @@ -596,3 +644,19 @@ const dispatchCbEvent = (eventName, cbNativeElement, fixture) => {
cbNativeElement.dispatchEvent(new Event(eventName));
fixture.detectChanges();
};

@Component({
template: `<igx-checkbox #control [formField]="userForm.accepted">Accept</igx-checkbox>`,
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() });
});
}
35 changes: 12 additions & 23 deletions projects/igniteui-angular/combo/src/combo/combo.common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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<void>();
protected _onTouchedCallback: () => void = noop;
protected _onChangeCallback: (_: any) => void = noop;
Expand Down Expand Up @@ -1050,6 +1052,7 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh
/** @hidden @internal */
public ngOnInit() {
this.ngControl = this._injector!.get<NgControl>(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);
Expand All @@ -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();
}
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -3819,6 +3820,58 @@ describe('igxCombo', () => {
});
});

describe('IgxComboComponent - Signal Forms', () => {
let fixture: ComponentFixture<IgxComboSignalFormComponent>;
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: `
<igx-combo #combo [placeholder]="'Location'" [data]='items'
Expand Down Expand Up @@ -4287,3 +4340,23 @@ export class ComboWithIdComponent {
];
}
}

@Component({
template: `
<igx-combo #combo [formField]="userForm.towns" [data]="items" displayKey="field" valueKey="field">
<label igxLabel>Town</label>
</igx-combo>`,
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() });
});
}
Loading
Loading