diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/ops/signal_forms.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/ops/signal_forms.ts index 67784658236..fb824457cdb 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/ops/signal_forms.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/ops/signal_forms.ts @@ -112,21 +112,40 @@ export class TcbNativeFieldOp extends TcbOp { checkUnsupportedFieldBindings(this.node, this.unsupportedBindingFields, this.tcb); - const expectedType = new TcbExpr(this.getExpectedTypeFromDomNode(this.node)); - const value = extractFieldValue(fieldBinding.value, this.tcb, this.scope); + const rawExpectedType = this.getExpectedTypeFromDomNode(this.node); - // Create a variable with the expected type and check that the field value is assignable, e.g. - // var t1 = null! as string | number; t1 = f().value()`. - const id = new TcbExpr(this.tcb.allocateId()); - const assignment = new TcbExpr(`${id.print()} = ${value.print()}`); - assignment.addParseSpanInfo(fieldBinding.valueSpan ?? fieldBinding.sourceSpan); + if (rawExpectedType === null) { + // For text-like elements, use an invariant check on the value signal. + // WritableSignal is invariant in T, so assigning it to a union of structural types + // gives us exact type matching: only Field or Field are accepted. + const signal = extractFieldValueSignal(fieldBinding.value, this.tcb, this.scope); + const id = new TcbExpr(this.tcb.allocateId()); + const unionType = new TcbExpr( + '{ (): string; set: (v: string) => void; } | { (): number | null; set: (v: number | null) => void; }', + ); + const assignment = new TcbExpr(`${id.print()} = ${signal.print()}`); + assignment.addParseSpanInfo(fieldBinding.valueSpan ?? fieldBinding.sourceSpan); + + this.scope.addStatement(declareVariable(id, unionType)); + this.scope.addStatement(assignment); + } else { + const expectedType = new TcbExpr(rawExpectedType); + const value = extractFieldValue(fieldBinding.value, this.tcb, this.scope); + + // Create a variable with the expected type and check that the field value is assignable, e.g. + // var t1 = null! as string | number; t1 = f().value()`. + const id = new TcbExpr(this.tcb.allocateId()); + const assignment = new TcbExpr(`${id.print()} = ${value.print()}`); + assignment.addParseSpanInfo(fieldBinding.valueSpan ?? fieldBinding.sourceSpan); + + this.scope.addStatement(declareVariable(id, expectedType)); + this.scope.addStatement(assignment); + } - this.scope.addStatement(declareVariable(id, expectedType)); - this.scope.addStatement(assignment); return null; } - private getExpectedTypeFromDomNode(node: TmplAstElement): string { + private getExpectedTypeFromDomNode(node: TmplAstElement): string | null { if (node.name === 'textarea' || node.name === 'select') { // `', + imports: [FormField] + }) + export class Comp { + f = form(signal(0)); + } + `, + ); + const diags = env.driveDiagnostics(); expect(diags.length).toBe(1); expect(extractMessage(diags[0])).toBe(`Type 'number' is not assignable to type 'string'.`); diff --git a/packages/forms/signals/src/directive/native.ts b/packages/forms/signals/src/directive/native.ts index 7a278e5a60b..a6b18a2ed64 100644 --- a/packages/forms/signals/src/directive/native.ts +++ b/packages/forms/signals/src/directive/native.ts @@ -74,6 +74,21 @@ export function getNativeControlValue( break; } + // For text-like elements, parse numeric values if the model is numeric. + if (element.tagName === 'INPUT' && element.type === 'text') { + modelValue ??= untracked(currentValue); + if (typeof modelValue === 'number' || modelValue === null) { + if (element.value === '') { + return {value: null}; + } + const parsed = Number(element.value); + if (Number.isNaN(parsed)) { + return {error: new NativeInputParseError() as WithoutFieldTree}; + } + return {value: parsed}; + } + } + // Default to reading the value as a string. return {value: element.value}; } @@ -121,6 +136,18 @@ export function setNativeControlValue(element: NativeFormControl, value: unknown } } + // For text-like elements, handle numeric and null values. + if (element.tagName === 'INPUT' && element.type === 'text') { + if (typeof value === 'number') { + element.value = isNaN(value) ? '' : String(value); + return; + } + if (value === null) { + element.value = ''; + return; + } + } + // Default to setting the value as a string. element.value = value as string; } diff --git a/packages/forms/signals/test/web/number_input.spec.ts b/packages/forms/signals/test/web/number_input.spec.ts index 9ce591b5836..ea9126d3333 100644 --- a/packages/forms/signals/test/web/number_input.spec.ts +++ b/packages/forms/signals/test/web/number_input.spec.ts @@ -162,6 +162,148 @@ describe('numeric inputs', () => { }); }); +describe('text input with numeric model', () => { + it('should render numeric model value as string', () => { + @Component({ + imports: [FormField], + template: ``, + }) + class TestCmp { + readonly data = signal(42); + readonly f = form(this.data); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const input = fixture.nativeElement.querySelector('input') as HTMLInputElement; + + expect(input.value).toBe('42'); + }); + + it('should update model as a number when user types a valid number', () => { + @Component({ + imports: [FormField], + template: ``, + }) + class TestCmp { + readonly data = signal(0); + readonly f = form(this.data); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const input = fixture.nativeElement.querySelector('input') as HTMLInputElement; + + act(() => { + input.value = '123'; + input.dispatchEvent(new Event('input')); + }); + + expect(fixture.componentInstance.f().value()).toBe(123); + expect(fixture.componentInstance.f().errors()).toEqual([]); + }); + + it('should produce a parse error when user types non-numeric text', () => { + @Component({ + imports: [FormField], + template: ``, + }) + class TestCmp { + readonly data = signal(42); + readonly f = form(this.data); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const input = fixture.nativeElement.querySelector('input') as HTMLInputElement; + + act(() => { + input.value = 'abc'; + input.dispatchEvent(new Event('input')); + }); + + expect(fixture.componentInstance.f().value()).toBe(42); + expect(fixture.componentInstance.f().errors()).toEqual([ + jasmine.objectContaining({kind: 'parse'}), + ]); + }); + + it('should set model to null when input is cleared', () => { + @Component({ + imports: [FormField], + template: ``, + }) + class TestCmp { + readonly data = signal(42); + readonly f = form(this.data); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const input = fixture.nativeElement.querySelector('input') as HTMLInputElement; + + act(() => { + input.value = ''; + input.dispatchEvent(new Event('input')); + }); + + expect(fixture.componentInstance.f().value()).toBeNull(); + expect(fixture.componentInstance.f().errors()).toEqual([]); + }); + + it('should render null model value as empty string', () => { + @Component({ + imports: [FormField], + template: ``, + }) + class TestCmp { + readonly data = signal(null); + readonly f = form(this.data); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const input = fixture.nativeElement.querySelector('input') as HTMLInputElement; + + expect(input.value).toBe(''); + expect(fixture.componentInstance.f().value()).toBeNull(); + }); + + it('should render NaN model value as empty string', () => { + @Component({ + imports: [FormField], + template: ``, + }) + class TestCmp { + readonly data = signal(NaN); + readonly f = form(this.data); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const input = fixture.nativeElement.querySelector('input') as HTMLInputElement; + + expect(input.value).toBe(''); + expect(fixture.componentInstance.f().value()).toEqual(NaN); + }); + + it('should update input when model is set programmatically', () => { + @Component({ + imports: [FormField], + template: ``, + }) + class TestCmp { + readonly data = signal(10); + readonly f = form(this.data); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const input = fixture.nativeElement.querySelector('input') as HTMLInputElement; + + expect(input.value).toBe('10'); + + act(() => { + fixture.componentInstance.data.set(99); + }); + + expect(input.value).toBe('99'); + }); +}); + function act(fn: () => T): T { try { return fn();