diff --git a/projects/cdk/classes/control.ts b/projects/cdk/classes/control.ts index 4c25eae9ee71..14eba1137a1c 100644 --- a/projects/cdk/classes/control.ts +++ b/projects/cdk/classes/control.ts @@ -13,7 +13,7 @@ import type {ControlValueAccessor, FormControlStatus} from '@angular/forms'; import {NgControl, NgModel} from '@angular/forms'; import {EMPTY_FUNCTION} from '@taiga-ui/cdk/constants'; import {TUI_FALLBACK_VALUE} from '@taiga-ui/cdk/tokens'; -import {tuiIsPresent, tuiProvide, tuiPure} from '@taiga-ui/cdk/utils'; +import {tuiProvide, tuiPure} from '@taiga-ui/cdk/utils'; import { delay, distinctUntilChanged, @@ -27,25 +27,24 @@ import { import {TuiValueTransformer} from './value-transformer'; +const FLAGS = {self: true, optional: true}; + /** * Basic ControlValueAccessor class to build form components upon */ @Directive() export abstract class TuiControl implements ControlValueAccessor { + private readonly fallback = inject(TUI_FALLBACK_VALUE, FLAGS) as T; private readonly refresh$ = new Subject(); private readonly pseudoInvalid = signal(null); - private readonly internal = signal( - inject(TUI_FALLBACK_VALUE, {self: true, optional: true}) as T, - ); + private readonly internal = signal(this.fallback); protected readonly control = inject(NgControl, {self: true}); protected readonly destroyRef = inject(DestroyRef); protected readonly cdr = inject(ChangeDetectorRef); - protected readonly transformer = inject>(TuiValueTransformer, { - optional: true, - }); + protected readonly transformer = inject(TuiValueTransformer, FLAGS); - public readonly value = computed(() => this.internal()); + public readonly value = computed(() => this.internal() ?? this.fallback); public readonly readOnly = signal(false); public readonly touched = signal(false); public readonly status = signal(undefined); @@ -72,16 +71,12 @@ export abstract class TuiControl implements ControlValueAccessor { delay(0), startWith(null), map(() => this.control.control), - filter(tuiIsPresent), + filter(Boolean), distinctUntilChanged(), - switchMap((control) => - merge(control.valueChanges, control.statusChanges), - ), + switchMap((c) => merge(c.valueChanges, c.statusChanges)), takeUntilDestroyed(this.destroyRef), ) - .subscribe(() => { - this.update(); - }); + .subscribe(() => this.update()); } @Input('readOnly') diff --git a/projects/cdk/observables/control-value.ts b/projects/cdk/observables/control-value.ts index 77448960814c..7f699729162c 100644 --- a/projects/cdk/observables/control-value.ts +++ b/projects/cdk/observables/control-value.ts @@ -1,5 +1,3 @@ -/// - import type {AbstractControl, AbstractControlDirective} from '@angular/forms'; import {Observable, startWith} from 'rxjs'; @@ -9,17 +7,7 @@ import {Observable, startWith} from 'rxjs'; export function tuiControlValue( control: AbstractControl | AbstractControlDirective | null, ): Observable { - return new Observable((subscriber) => { - if (!control?.valueChanges) { - throw new TuiValueChangesException(); - } - - return control.valueChanges.pipe(startWith(control.value)).subscribe(subscriber); - }); -} - -export class TuiValueChangesException extends Error { - constructor() { - super(ngDevMode ? 'Control does not have valueChanges' : ''); - } + return new Observable((subscriber) => + control?.valueChanges?.pipe(startWith(control.value)).subscribe(subscriber), + ); } diff --git a/projects/cdk/observables/test/control-value.spec.ts b/projects/cdk/observables/test/control-value.spec.ts index 138665faae8e..8cea8175df26 100644 --- a/projects/cdk/observables/test/control-value.spec.ts +++ b/projects/cdk/observables/test/control-value.spec.ts @@ -1,8 +1,6 @@ import {fakeAsync} from '@angular/core/testing'; -import type {AbstractControl} from '@angular/forms'; import {FormControl} from '@angular/forms'; import {tuiControlValue} from '@taiga-ui/cdk'; -import {tuiSwitchNgDevMode} from '@taiga-ui/testing'; import {skip} from 'rxjs'; describe('tuiControlValue', () => { @@ -31,38 +29,4 @@ describe('tuiControlValue', () => { expect(actual).toBe('test'); })); - - describe('dev mode', () => { - beforeEach(() => tuiSwitchNgDevMode(true)); - - it('throws an error if there is no valueChanges', fakeAsync(() => { - let actual = ''; - - tuiControlValue({} as AbstractControl).subscribe({ - next: () => {}, - error: (err: unknown) => { - actual = (err as Error).message; - }, - }); - - expect(actual).toBe('Control does not have valueChanges'); - })); - - afterEach(() => tuiSwitchNgDevMode(false)); - }); - - describe('production mode', () => { - it('throws an error if there is no valueChanges', fakeAsync(() => { - let actual = ''; - - tuiControlValue({} as AbstractControl).subscribe({ - next: () => {}, - error: (err: unknown) => { - actual = (err as Error).message; - }, - }); - - expect(actual).toBe(''); - })); - }); }); diff --git a/projects/cdk/tokens/fallback-value.ts b/projects/cdk/tokens/fallback-value.ts index 4bf661ad1e7b..cfb2e997a88a 100644 --- a/projects/cdk/tokens/fallback-value.ts +++ b/projects/cdk/tokens/fallback-value.ts @@ -1,7 +1,7 @@ import type {ValueProvider} from '@angular/core'; -import {InjectionToken} from '@angular/core'; +import {tuiCreateToken} from '@taiga-ui/cdk/utils/miscellaneous'; -export const TUI_FALLBACK_VALUE = new InjectionToken(''); +export const TUI_FALLBACK_VALUE = tuiCreateToken(null); export function tuiFallbackValueProvider(useValue: T): ValueProvider { return { diff --git a/projects/cdk/utils/miscellaneous/count-filled-controls.ts b/projects/cdk/utils/miscellaneous/count-filled-controls.ts new file mode 100644 index 000000000000..626623bbf1e2 --- /dev/null +++ b/projects/cdk/utils/miscellaneous/count-filled-controls.ts @@ -0,0 +1,23 @@ +import type {AbstractControl} from '@angular/forms'; +import {FormArray, FormGroup} from '@angular/forms'; +import {tuiToInt} from '@taiga-ui/cdk/utils/math'; + +import {tuiIsControlEmpty} from './is-control-empty'; + +export function tuiCountFilledControls(control: AbstractControl): number { + if (control instanceof FormArray) { + return control.controls.reduce( + (acc, nestedControl) => acc + tuiCountFilledControls(nestedControl), + 0, + ); + } + + if (control instanceof FormGroup) { + return Object.values(control.controls).reduce( + (acc, nestedControl) => acc + tuiCountFilledControls(nestedControl), + 0, + ); + } + + return tuiToInt(!tuiIsControlEmpty(control)); +} diff --git a/projects/cdk/utils/miscellaneous/index.ts b/projects/cdk/utils/miscellaneous/index.ts index ac63f8d800c0..97c5d926d047 100644 --- a/projects/cdk/utils/miscellaneous/index.ts +++ b/projects/cdk/utils/miscellaneous/index.ts @@ -2,6 +2,7 @@ export * from './array-remove'; export * from './array-shallow-equals'; export * from './array-toggle'; export * from './change-date-separator'; +export * from './count-filled-controls'; export * from './create-token'; export * from './default-sort'; export * from './directive-binding'; @@ -10,6 +11,7 @@ export * from './distance-between-touches'; export * from './ease-in-out-quad'; export * from './flat-length'; export * from './get-original-array-from-query-list'; +export * from './is-control-empty'; export * from './is-falsy'; export * from './is-number'; export * from './is-object'; diff --git a/projects/cdk/utils/miscellaneous/is-control-empty.ts b/projects/cdk/utils/miscellaneous/is-control-empty.ts new file mode 100644 index 000000000000..2dc3c2753059 --- /dev/null +++ b/projects/cdk/utils/miscellaneous/is-control-empty.ts @@ -0,0 +1,5 @@ +import type {AbstractControl} from '@angular/forms'; + +export function tuiIsControlEmpty({value = null}: AbstractControl): boolean { + return value === null || value === '' || (Array.isArray(value) && !value.length); +} diff --git a/projects/core/components/textfield/select.directive.ts b/projects/core/components/textfield/select.directive.ts index 66a5627c2902..3d7179b76114 100644 --- a/projects/core/components/textfield/select.directive.ts +++ b/projects/core/components/textfield/select.directive.ts @@ -13,7 +13,9 @@ import {TuiTextfieldBase, TuiTextfieldDirective} from './textfield.directive'; selector: 'select[tuiTextfield]', imports: [CommonModule], templateUrl: './select.template.html', - changeDetection: ChangeDetectionStrategy.OnPush, + // We want this template to follow change detection to parent textfield. + // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection + changeDetection: ChangeDetectionStrategy.Default, providers: [tuiProvide(TuiTextfieldDirective, TuiSelect)], hostDirectives: [TuiNativeValidator, TuiAppearance], host: { @@ -50,7 +52,7 @@ export class TuiSelect extends TuiTextfieldBase { } protected get value(): string { - return this.textfield.stringify(this.control.value); + return this.textfield.stringify(this.control.value ?? ''); } protected async onCopy(): Promise { diff --git a/projects/core/styles/components/textfield.less b/projects/core/styles/components/textfield.less index 2feb1d0669bd..18af22a86d68 100644 --- a/projects/core/styles/components/textfield.less +++ b/projects/core/styles/components/textfield.less @@ -53,14 +53,12 @@ tui-textfield { } &::before { - margin-inline-start: -0.25rem; - margin-inline-end: 0.5rem; + margin: 0 0.5rem 0 -0.125rem; font-size: 1rem; } &::after { - margin-inline-end: -0.175rem; - margin-inline-start: 0.25rem; + margin: 0 -0.175rem 0 0.25rem; font-size: 1rem; } @@ -90,13 +88,11 @@ tui-textfield { } &::before { - margin-inline-start: -0.125rem; - margin-inline-end: 0.375rem; + margin: 0 0.375rem 0 -0.125rem; } &::after { - margin-inline-start: 0.375rem; - margin-inline-end: -0.125rem; + margin: 0 -0.125rem 0 0.375rem; } input, @@ -220,7 +216,7 @@ tui-textfield { } } - label:defined { + label:not([data-orientation='vertical']) { .transition(all); .text-overflow(); @@ -231,8 +227,8 @@ tui-textfield { } label:defined, - input::placeholder, - select._empty { + input:defined::placeholder, + select:defined._empty { color: var(--tui-text-secondary); } diff --git a/projects/kit/components/segmented/segmented.component.ts b/projects/kit/components/segmented/segmented.component.ts index 13fcfee75e0e..6bdbf3f73811 100644 --- a/projects/kit/components/segmented/segmented.component.ts +++ b/projects/kit/components/segmented/segmented.component.ts @@ -22,7 +22,7 @@ import {TuiSegmentedDirective} from './segmented.directive'; @Component({ standalone: true, selector: 'tui-segmented', - template: '', + template: '', styleUrls: ['./segmented.style.less'], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, @@ -53,7 +53,7 @@ export class TuiSegmented implements OnChanges { } public update(activeItemIndex: number): void { - if (activeItemIndex === this.activeItemIndex) { + if (activeItemIndex === this.activeItemIndex || activeItemIndex < 0) { return; } diff --git a/projects/kit/components/segmented/segmented.directive.ts b/projects/kit/components/segmented/segmented.directive.ts index dcd1168aee6d..20e99e496699 100644 --- a/projects/kit/components/segmented/segmented.directive.ts +++ b/projects/kit/components/segmented/segmented.directive.ts @@ -1,20 +1,11 @@ -import {isPlatformBrowser} from '@angular/common'; import type {AfterContentChecked, AfterContentInit, QueryList} from '@angular/core'; -import { - ContentChildren, - DestroyRef, - Directive, - ElementRef, - inject, - PLATFORM_ID, -} from '@angular/core'; -import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; -import {NgControl} from '@angular/forms'; +import {ContentChildren, Directive, ElementRef, inject} from '@angular/core'; +import {NgControl, RadioControlValueAccessor} from '@angular/forms'; import {RouterLinkActive} from '@angular/router'; import {EMPTY_QUERY} from '@taiga-ui/cdk/constants'; import {tuiControlValue, tuiQueryListChanges} from '@taiga-ui/cdk/observables'; import {tuiInjectElement} from '@taiga-ui/cdk/utils/dom'; -import {filter, merge, mergeAll, switchMap} from 'rxjs'; +import {map, switchMap} from 'rxjs'; import {TuiSegmented} from './segmented.component'; @@ -28,29 +19,26 @@ export class TuiSegmentedDirective implements AfterContentChecked, AfterContentI @ContentChildren(NgControl, {descendants: true}) private readonly controls: QueryList = EMPTY_QUERY; + @ContentChildren(RadioControlValueAccessor, {descendants: true}) + private readonly radios: QueryList = EMPTY_QUERY; + @ContentChildren(RouterLinkActive) private readonly links: QueryList = EMPTY_QUERY; @ContentChildren(RouterLinkActive, {read: ElementRef}) private readonly elements: QueryList> = EMPTY_QUERY; - private readonly destroyRef = inject(DestroyRef); private readonly component = inject(TuiSegmented); private readonly el = tuiInjectElement(); - // TODO: Debug prerender - private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); - public ngAfterContentInit(): void { tuiQueryListChanges(this.controls) .pipe( - switchMap((controls) => merge(controls.map((c) => tuiControlValue(c)))), - mergeAll(), - filter(() => this.isBrowser), - takeUntilDestroyed(this.destroyRef), + switchMap(() => tuiControlValue(this.controls.first)), + map((value) => this.radios.toArray().findIndex((c) => c.value === value)), ) - .subscribe(() => { - this.update(this.el.querySelector(':checked')); + .subscribe((index) => { + this.component.update(index); }); } @@ -61,11 +49,7 @@ export class TuiSegmentedDirective implements AfterContentChecked, AfterContentI } protected update(target: Element | null): void { - const index = this.getIndex(target); - - if (index >= 0) { - this.component.update(index); - } + this.component.update(this.getIndex(target)); } private get linkIndex(): number {