diff --git a/packages/forms/signals/src/directive/control_native.ts b/packages/forms/signals/src/directive/control_native.ts index d90537886b3..3cba243ca06 100644 --- a/packages/forms/signals/src/directive/control_native.ts +++ b/packages/forms/signals/src/directive/control_native.ts @@ -81,7 +81,7 @@ export function nativeControlCreate( if (!updateMode) { return; } - input.value = parent.state().controlValue() as string; + setNativeControlValue(input, parent.state().controlValue()); }, parent.destroyRef, ); diff --git a/packages/forms/signals/src/directive/native.ts b/packages/forms/signals/src/directive/native.ts index 6b299e9a58f..2a4570afc57 100644 --- a/packages/forms/signals/src/directive/native.ts +++ b/packages/forms/signals/src/directive/native.ts @@ -74,6 +74,21 @@ export function getNativeControlValue( return {value: element.valueAsNumber}; } break; + case 'select-one': + // We can read a `number` or a `string` from a elements, parse numeric values if the model is numeric. @@ -136,6 +151,17 @@ export function setNativeControlValue(element: NativeFormControl, value: unknown setNativeNumberControlValue(element, value); return; } + break; + case 'select-one': + // This input type can receive a `number`, `null`, or `string`. + if (typeof value === 'number') { + element.value = isNaN(value) ? '' : String(value); + return; + } else if (value === null) { + element.value = ''; + return; + } + break; } // For text-like elements, handle numeric and null values. diff --git a/packages/forms/signals/test/web/select_numeric.spec.ts b/packages/forms/signals/test/web/select_numeric.spec.ts new file mode 100644 index 00000000000..2cf7fa28f95 --- /dev/null +++ b/packages/forms/signals/test/web/select_numeric.spec.ts @@ -0,0 +1,202 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Component, signal} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {FormField, form} from '../../public_api'; + +describe('select with numeric model', () => { + it('should render initial number value by selecting the matching option', () => { + @Component({ + imports: [FormField], + template: ` + + `, + }) + class TestCmp { + readonly data = signal(2); + readonly f = form(this.data); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const select = fixture.nativeElement.querySelector('select') as HTMLSelectElement; + + expect(select.value).toBe('2'); + expect(fixture.componentInstance.f().value()).toBe(2); + }); + + it('should update model as a number when user selects an option', () => { + @Component({ + imports: [FormField], + template: ` + + `, + }) + class TestCmp { + readonly data = signal(1); + readonly f = form(this.data); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const select = fixture.nativeElement.querySelector('select') as HTMLSelectElement; + + act(() => { + select.value = '3'; + select.dispatchEvent(new Event('input')); + }); + + expect(fixture.componentInstance.f().value()).toBe(3); + expect(typeof fixture.componentInstance.f().value()).toBe('number'); + 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 select = fixture.nativeElement.querySelector('select') as HTMLSelectElement; + + expect(select.value).toBe(''); + expect(fixture.componentInstance.f().value()).toBeNull(); + }); + + it('should update model to null when user selects the empty option', () => { + @Component({ + imports: [FormField], + template: ` + + `, + }) + class TestCmp { + readonly data = signal(1); + readonly f = form(this.data); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const select = fixture.nativeElement.querySelector('select') as HTMLSelectElement; + + act(() => { + select.value = ''; + select.dispatchEvent(new Event('input')); + }); + + expect(fixture.componentInstance.f().value()).toBeNull(); + expect(fixture.componentInstance.f().errors()).toEqual([]); + }); + + it('should update select 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 select = fixture.nativeElement.querySelector('select') as HTMLSelectElement; + + expect(select.value).toBe('10'); + + act(() => { + fixture.componentInstance.data.set(30); + }); + + expect(select.value).toBe('30'); + expect(fixture.componentInstance.f().value()).toBe(30); + }); + + 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 select = fixture.nativeElement.querySelector('select') as HTMLSelectElement; + + expect(select.value).toBe(''); + expect(fixture.componentInstance.f().value()).toEqual(NaN); + }); + + it('should preserve string model type when model is a string', () => { + @Component({ + imports: [FormField], + template: ` + + `, + }) + class TestCmp { + readonly data = signal('a'); + readonly f = form(this.data); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const select = fixture.nativeElement.querySelector('select') as HTMLSelectElement; + + act(() => { + select.value = 'b'; + select.dispatchEvent(new Event('input')); + }); + + expect(fixture.componentInstance.f().value()).toBe('b'); + expect(typeof fixture.componentInstance.f().value()).toBe('string'); + }); +}); + +function act(fn: () => T): T { + try { + return fn(); + } finally { + TestBed.tick(); + } +}