mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
Merge b1b7ae4294 into 06b004ec5c
This commit is contained in:
commit
4bdc8a5d8f
5 changed files with 237 additions and 5 deletions
|
|
@ -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));');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ export function nativeControlCreate(
|
|||
if (!updateMode) {
|
||||
return;
|
||||
}
|
||||
input.value = parent.state().controlValue() as string;
|
||||
setNativeControlValue(input, parent.state().controlValue());
|
||||
},
|
||||
parent.destroyRef,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
202
packages/forms/signals/test/web/select_numeric.spec.ts
Normal file
202
packages/forms/signals/test/web/select_numeric.spec.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue