This commit is contained in:
Sonu Kapoor 2026-05-23 09:07:15 +00:00 committed by GitHub
commit 4bdc8a5d8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 237 additions and 5 deletions

View file

@ -3015,9 +3015,9 @@ describe('type check blocks', () => {
expect(block).toContain('_t2.field = (((this).f));');
});
it('should generate a string field for a select', () => {
it('should generate a string | number | null field for a select', () => {
const block = tcb('<select [formField]="f"></select>', [FieldMock]);
expect(block).toContain('var _t1 = null! as string;');
expect(block).toContain('var _t1 = null! as string | number | null;');
expect(block).toContain('_t1 = ((this).f)().value();');
expect(block).toContain('var _t2 = null! as i0.FormField;');
expect(block).toContain('_t2.field = (((this).f));');

View file

@ -169,11 +169,15 @@ export class TcbNativeFieldOp extends TcbOp {
}
private getExpectedTypeFromDomNode(node: Element): string | null {
if (node.name === 'textarea' || node.name === 'select') {
// `<textarea>` and `<select>` are always strings.
if (node.name === 'textarea') {
return 'string';
}
if (node.name === 'select') {
// A <select> can bind to a string, number, or null model.
return 'string | number | null';
}
if (node.name !== 'input') {
return this.getUnsupportedType();
}

View file

@ -88,7 +88,7 @@ export function nativeControlCreate(
if (!updateMode) {
return;
}
input.value = parent.state().controlValue() as string;
setNativeControlValue(input, parent.state().controlValue());
},
parent.destroyRef,
);

View file

@ -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 <select>. Prefer whichever is consistent
// with the current type.
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<NativeInputParseError>};
}
return {value: parsed};
}
break;
}
// For text-like <input> 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 <input> elements, handle numeric and null values.

View file

@ -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: `
<select [formField]="f">
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
</select>
`,
})
class TestCmp {
readonly data = signal<number>(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: `
<select [formField]="f">
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
</select>
`,
})
class TestCmp {
readonly data = signal<number>(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: `
<select [formField]="f">
<option value="">-- select --</option>
<option value="1">One</option>
<option value="2">Two</option>
</select>
`,
})
class TestCmp {
readonly data = signal<number | null>(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: `
<select [formField]="f">
<option value="">-- select --</option>
<option value="1">One</option>
<option value="2">Two</option>
</select>
`,
})
class TestCmp {
readonly data = signal<number | null>(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: `
<select [formField]="f">
<option value="10">Ten</option>
<option value="20">Twenty</option>
<option value="30">Thirty</option>
</select>
`,
})
class TestCmp {
readonly data = signal<number>(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: `
<select [formField]="f">
<option value="">-- select --</option>
<option value="1">One</option>
</select>
`,
})
class TestCmp {
readonly data = signal<number | null>(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: `
<select [formField]="f">
<option value="a">A</option>
<option value="b">B</option>
</select>
`,
})
class TestCmp {
readonly data = signal<string>('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<T>(fn: () => T): T {
try {
return fn();
} finally {
TestBed.tick();
}
}