Skip to content

Commit

Permalink
fix: inputs with extends TuiControl has CD problems for `writeValue…
Browse files Browse the repository at this point in the history
…` of empty value
  • Loading branch information
nsbarsukov committed Jan 23, 2025
1 parent b2081d7 commit f2089b5
Show file tree
Hide file tree
Showing 3 changed files with 124 additions and 24 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {ChangeDetectionStrategy, Component} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {TuiInputNumber} from '@taiga-ui/kit';
import {TuiRoot, TuiTextfield} from '@taiga-ui/core';

describe('InputNumber | Form control is patched with empty string', () => {
@Component({
standalone: true,
imports: [FormsModule, TuiInputNumber, TuiRoot, TuiTextfield],
template: `
<tui-root>
<tui-textfield>
<label tuiLabel>Label</label>
<input
tuiInputNumber
[(ngModel)]="value"
/>
</tui-textfield>
<br />
<button
id="42"
(click)="value = 42"
>
Click me to set "42"
</button>
<button
id="null"
(click)="value = null"
>
Click me to set "null"
</button>
</tui-root>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class Test {
protected value: number | null = null;
}

beforeEach(() => {
cy.mount(Test);
cy.get('[tuiInputNumber]').as('textfield');
cy.get('@textfield').type('123');
});

it('Patch value with not-empty value', () => {
cy.get('#42').click();

cy.get('@textfield').should('have.value', '42');
cy.get('tui-textfield').compareSnapshot({
name: 'input-number-write-value-42',
cypressScreenshotOptions: {padding: 8},
});
});

it('Patch value with empty value', () => {
cy.get('#null').click();

cy.get('@textfield').should('have.value', '');
cy.get('tui-textfield').compareSnapshot({
name: 'input-number-write-value-empty-string',
cypressScreenshotOptions: {padding: 8},
});
});

it('Patch not empty value after textfield was already patched with empty string', () => {
cy.get('#null').click();
cy.get('#42').click();

cy.get('@textfield').should('have.value', '42');
cy.get('tui-textfield').compareSnapshot({
name: 'input-number-write-value-42-after-empty',
cypressScreenshotOptions: {padding: 8},
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import {
ChangeDetectionStrategy,
Component,
computed,
effect,
ElementRef,
inject,
Input,
Output,
Renderer2,
signal,
TemplateRef,
ViewChild,
Expand All @@ -25,7 +27,12 @@ import {
} from '@maskito/core';
import {maskitoGetCountryFromNumber, maskitoPhoneOptionsGenerator} from '@maskito/phone';
import {tuiAsControl, TuiControl} from '@taiga-ui/cdk/classes';
import {CHAR_PLUS, EMPTY_QUERY, TUI_DEFAULT_MATCHER} from '@taiga-ui/cdk/constants';
import {
CHAR_PLUS,
EMPTY_QUERY,
TUI_ALLOW_SIGNAL_WRITES,
TUI_DEFAULT_MATCHER,
} from '@taiga-ui/cdk/constants';
import {TuiActiveZone} from '@taiga-ui/cdk/directives/active-zone';
import {
TuiAutoFocus,
Expand All @@ -36,7 +43,6 @@ import {tuiInjectElement, tuiIsInputEvent} from '@taiga-ui/cdk/utils/dom';
import {tuiDirectiveBinding} from '@taiga-ui/cdk/utils/miscellaneous';
import {TuiButton} from '@taiga-ui/core/components/button';
import {TuiDataList, TuiOption} from '@taiga-ui/core/components/data-list';
import {TuiIcon} from '@taiga-ui/core/components/icon';
import {
TUI_TEXTFIELD_OPTIONS,
TuiTextfield,
Expand Down Expand Up @@ -78,7 +84,6 @@ const NOT_FORM_CONTROL_SYMBOLS = /[^+\d]/g;
TuiChevron,
TuiDataList,
TuiFlagPipe,
TuiIcon,
TuiTextfield,
TuiTextfieldContent,
TuiTitle,
Expand All @@ -98,14 +103,15 @@ const NOT_FORM_CONTROL_SYMBOLS = /[^+\d]/g;
'[attr.readonly]': 'readOnly() || null',
'[attr.inputmode]': '!ios && open() ? "none" : null',
'[disabled]': 'disabled()',
'[value]': 'masked()',
'(blur)': 'onTouched()',
'(input)': 'onChange(unmasked)',
'(input)': 'masked.set($event.target.value)',
'(click)': 'open.set(false)',
'(beforeinput.capture)': 'onPaste($event)',
},
})
export class TuiInputPhoneInternational extends TuiControl<string> {
private readonly render = inject(Renderer2);

@ViewChildren(TuiOption, {read: ElementRef})
protected readonly list: QueryList<ElementRef<HTMLButtonElement>> = EMPTY_QUERY;

Expand All @@ -127,11 +133,13 @@ export class TuiInputPhoneInternational extends TuiControl<string> {
computed(() => this.computeMask(this.code(), this.metadata())),
);

protected readonly masked = computed(
() =>
maskitoTransform(this.value(), this.mask() || MASKITO_DEFAULT_OPTIONS) ||
this.el.value,
);
protected readonly masked = signal(this.el.value);

protected valueChangeEffect = effect(() => {
this.onChange(this.unmask(this.masked()));
// Host binding `host: {'[value]': 'masked()'}` has change detection problem with empty string
this.render.setProperty(this.el, 'value', this.masked());
}, TUI_ALLOW_SIGNAL_WRITES);

protected readonly filtered = computed(() =>
this.countries()
Expand All @@ -157,10 +165,13 @@ export class TuiInputPhoneInternational extends TuiControl<string> {
)
.subscribe((active) => {
const prefix = `${tuiGetCallingCode(this.code(), this.metadata())} `;
const fallback = active ? this.el.value || prefix : this.el.value;

this.search.set('');
this.el.value = this.el.value === prefix ? '' : fallback;
this.masked.update((value) => {
const fallback = active ? value || prefix : value;

return value === prefix ? '' : fallback;
});
});

@Input()
Expand All @@ -184,12 +195,6 @@ export class TuiInputPhoneInternational extends TuiControl<string> {
this.dropdown.set(template);
}

protected get unmasked(): string {
const value = this.el.value.replaceAll(NOT_FORM_CONTROL_SYMBOLS, '');

return value === tuiGetCallingCode(this.code(), this.metadata()) ? '' : value;
}

protected onPaste(event: Event): void {
const metadata = this.metadata();
const data = tuiIsInputEvent(event) && event.data;
Expand All @@ -212,15 +217,22 @@ export class TuiInputPhoneInternational extends TuiControl<string> {

protected onItemClick(code: TuiCountryIsoCode): void {
this.el.focus();
this.el.value = this.unmasked;
this.open.set(false);
this.code.set(code);
this.search.set('');
this.el.value = maskitoTransform(
this.el.value || tuiGetCallingCode(code, this.metadata()),
this.mask() || MASKITO_DEFAULT_OPTIONS,
this.masked.set(
maskitoTransform(
this.value() || tuiGetCallingCode(code, this.metadata()),
this.mask() || MASKITO_DEFAULT_OPTIONS,
),
);
}

public override writeValue(unmasked: string): void {

Check failure on line 231 in projects/experimental/components/input-phone-international/input-phone-international.component.ts

View workflow job for this annotation

GitHub Actions / Lint

Member writeValue should be declared before all protected decorated set, protected decorated get definitions
super.writeValue(unmasked);
this.masked.set(
maskitoTransform(this.value() ?? '', this.mask() || MASKITO_DEFAULT_OPTIONS),
);
this.onChange(this.unmasked);
}

private computeMask(
Expand All @@ -242,4 +254,10 @@ export class TuiInputPhoneInternational extends TuiControl<string> {
plugins: [...plugins, maskitoInitialCalibrationPlugin()],
};
}

private unmask(maskedValue: string): string {
const value = maskedValue.replaceAll(NOT_FORM_CONTROL_SYMBOLS, '');

return value === tuiGetCallingCode(this.code(), this.metadata()) ? '' : value;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
effect,
inject,
Input,
Renderer2,
signal,
ViewEncapsulation,
} from '@angular/core';
Expand Down Expand Up @@ -52,7 +53,6 @@ const DEFAULT_MAX_LENGTH = 18;
],
hostDirectives: [TuiWithTextfield, MaskitoDirective],
host: {
'[value]': 'textfieldValue()',
'[disabled]': 'disabled()',
'[attr.inputMode]': 'inputMode()',
'[attr.maxLength]': 'maxLength()',
Expand All @@ -65,6 +65,8 @@ const DEFAULT_MAX_LENGTH = 18;
},
})
export class TuiInputNumber extends TuiControl<number | null> {
private readonly el = tuiInjectElement<HTMLInputElement>();
private readonly render = inject(Renderer2);
private readonly isIOS = inject(TUI_IS_IOS);
private readonly numberFormat = toSignal(inject(TUI_NUMBER_FORMAT), {
initialValue: TUI_DEFAULT_NUMBER_FORMAT,
Expand All @@ -84,6 +86,8 @@ export class TuiInputNumber extends TuiControl<number | null> {
});

protected readonly onChangeEffect = effect(() => {
// Host binding `host: {'[value]': 'textfieldValue()'}` has change detection problem with empty string
this.render.setProperty(this.el, 'value', this.textfieldValue());
const value = maskitoParseNumber(
this.textfieldValue(),
this.numberFormat().decimalSeparator,
Expand Down

0 comments on commit f2089b5

Please sign in to comment.