angular/packages/forms/signals/test/web/number_input.spec.ts
Miles Malerba 30f0914754 feat(forms): support binding null to number input (#66917)
Supports binding `null` to a `<input type=number>`.

- Binding in `null` clears the input
- Binding in `NaN` also clears the input
- When the user clears the input, the model is set to `null`
- The model is _never_ set to `NaN` based on user interaction. It is
  either set to `null` if the user cleared the input, or is unchanged
  and a parse error added if the user entered an invalid number like
  "42e"

PR Close #66917
2026-02-13 12:11:06 -08:00

202 lines
6 KiB
TypeScript

/**
* @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, viewChildren} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {FormField, form} from '../../public_api';
describe('numeric inputs', () => {
describe('parsing logic', () => {
it('should not change the model when user enters un-parsable input', () => {
@Component({
imports: [FormField],
template: `<input type="number" [formField]="f" />`,
})
class TestCmp {
readonly data = signal<number>(42);
readonly f = form(this.data);
}
const fixture = act(() => TestBed.createComponent(TestCmp));
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
patchNumberInput(input);
expect(input.value).toBe('42');
act(() => {
input.value = '42e';
input.dispatchEvent(new Event('input'));
});
expect(fixture.componentInstance.f().value()).toBe(42);
expect(fixture.componentInstance.f().errors()).toEqual([
jasmine.objectContaining({kind: 'parse'}),
]);
act(() => {
input.value = '42e1';
input.dispatchEvent(new Event('input'));
});
expect(fixture.componentInstance.f().value()).toBe(420);
expect(fixture.componentInstance.f().errors()).toEqual([]);
});
it('should clear parse errors on one control when another control for the same field updates the model', () => {
@Component({
imports: [FormField],
template: `
<input id="input1" type="number" [formField]="f" />
<input id="input2" type="number" [formField]="f" />
`,
})
class TestCmp {
readonly data = signal<number>(5);
readonly f = form(this.data);
readonly bindings = viewChildren(FormField);
}
const fixture = act(() => TestBed.createComponent(TestCmp));
const input1 = fixture.nativeElement.querySelector('#input1') as HTMLInputElement;
const input2 = fixture.nativeElement.querySelector('#input2') as HTMLInputElement;
patchNumberInput(input1);
patchNumberInput(input2);
expect(input1.value).toBe('5');
expect(input2.value).toBe('5');
// Trigger parse error on input1
act(() => {
input1.value = '5e';
input1.dispatchEvent(new Event('input'));
});
expect(fixture.componentInstance.bindings()[0].errors()).toEqual([
jasmine.objectContaining({kind: 'parse'}),
]);
// Update model via input2
act(() => {
input2.value = '42';
input2.dispatchEvent(new Event('input'));
});
expect(fixture.componentInstance.bindings()[0].errors()).toEqual([]);
expect(fixture.componentInstance.data()).toBe(42);
expect(input1.value).toBe('42');
expect(input2.value).toBe('42');
});
});
describe('nullability', () => {
it('should initialize with null', () => {
@Component({
imports: [FormField],
template: `<input type="number" [formField]="f" />`,
})
class TestCmp {
readonly data = signal<number | null>(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();
expect(fixture.componentInstance.f().errors()).toEqual([]);
});
it('should initialize with NaN', () => {
@Component({
imports: [FormField],
template: `<input type="number" [formField]="f" />`,
})
class TestCmp {
readonly data = signal<number | null>(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);
// No parse errors if its `NaN` from the model
expect(fixture.componentInstance.f().errors()).toEqual([]);
});
it('should update model to null when user clears input', () => {
@Component({
imports: [FormField],
template: `<input type="number" [formField]="f" />`,
})
class TestCmp {
readonly data = signal<number | null>(NaN);
readonly f = form(this.data);
}
const fixture = act(() => TestBed.createComponent(TestCmp));
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
patchNumberInput(input);
act(() => {
input.value = '4';
input.dispatchEvent(new Event('input'));
});
expect(fixture.componentInstance.f().value()).toBe(4);
act(() => {
input.value = '';
input.dispatchEvent(new Event('input'));
});
expect(fixture.componentInstance.f().value()).toBeNull();
});
});
});
function act<T>(fn: () => T): T {
try {
return fn();
} finally {
TestBed.tick();
}
}
/**
* Patch a number input to make its validity work as it would if the user was actually typing.
*
* `validity.badInput` is updated when the user types in the `<input>`, but when we simulate it
* by setting the value and dispatching an event, that flag is not updated. To work around this
* we patch the input.
*/
function patchNumberInput(input: HTMLInputElement) {
let value = input.value;
Object.defineProperties(input, {
value: {
set: (v) => {
value = v;
},
get: () => {
const num = Number(value);
return Number.isNaN(num) ? '' : value;
},
},
valueAsNumber: {
get: () => Number(value),
set: (v) => {
value = String(v);
},
},
});
Object.defineProperties(input.validity, {
badInput: {get: () => Number.isNaN(Number(value))},
});
}