diff --git a/projects/ng-rocketparts/src/lib/components/number-input/number-input.component.html b/projects/ng-rocketparts/src/lib/components/number-input/number-input.component.html
new file mode 100644
index 0000000..3560aaa
--- /dev/null
+++ b/projects/ng-rocketparts/src/lib/components/number-input/number-input.component.html
@@ -0,0 +1,5 @@
+
diff --git a/projects/ng-rocketparts/src/lib/components/number-input/number-input.component.spec.ts b/projects/ng-rocketparts/src/lib/components/number-input/number-input.component.spec.ts
new file mode 100644
index 0000000..67ee40f
--- /dev/null
+++ b/projects/ng-rocketparts/src/lib/components/number-input/number-input.component.spec.ts
@@ -0,0 +1,168 @@
+import {
+ ComponentFixture,
+ TestBed,
+ fakeAsync,
+ tick
+} from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { Component, Type } from '@angular/core';
+import { FormsModule, ReactiveFormsModule, FormControl } from '@angular/forms';
+
+import { typeInElement, dispatchFakeEvent } from '../../../testing';
+import { NumberInputComponent } from './number-input.component';
+
+@Component({
+ template: `
+ `
+})
+export class SimpleNumberInputComponent {
+ control = new FormControl();
+}
+
+describe('[Component]: NumberInputComponent', () => {
+ // Creates a test component fixture.
+ function createComponent(component: Type) {
+ TestBed.configureTestingModule({
+ imports: [FormsModule, ReactiveFormsModule],
+ declarations: [NumberInputComponent, component]
+ });
+ TestBed.compileComponents();
+
+ return TestBed.createComponent(component);
+ }
+
+ describe('forms integration', () => {
+ let fixture: ComponentFixture;
+ let input: HTMLInputElement;
+
+ beforeEach(() => {
+ fixture = createComponent(SimpleNumberInputComponent);
+ fixture.detectChanges();
+ input = fixture.debugElement.query(By.css('input')).nativeElement;
+ });
+
+ it('should update control value as user types with input value', () => {
+ typeInElement('10', input);
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.control.value).toEqual(
+ 10,
+ `Expected control value to be updated as user types.`
+ );
+
+ typeInElement('100', input);
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.control.value).toEqual(
+ 100,
+ `Expected control value to be updated as user types.`
+ );
+ });
+
+ it('should format input value on blur', fakeAsync(() => {
+ typeInElement('300', input);
+ dispatchFakeEvent(input, 'blur');
+ fixture.detectChanges();
+ tick();
+
+ expect(input.value).toEqual(
+ '300.00',
+ `Expected input value to be formatted on blur.`
+ );
+
+ typeInElement('3000', input);
+ dispatchFakeEvent(input, 'blur');
+ fixture.detectChanges();
+ tick();
+
+ expect(input.value).toEqual(
+ '3,000.00',
+ `Expected input value to be formatted on blur.`
+ );
+ }));
+
+ it('should fill input correctly if control value is set programatically', fakeAsync(() => {
+ fixture.componentInstance.control.setValue(100);
+ fixture.detectChanges();
+ tick();
+
+ expect(input.value).toEqual(
+ '100.00',
+ `Expected input to fill with control current formatted value.`
+ );
+
+ fixture.componentInstance.control.setValue(1000);
+ fixture.detectChanges();
+ tick();
+
+ expect(input.value).toEqual(
+ '1,000.00',
+ `Expected input to fill with control current formatted value.`
+ );
+ }));
+
+ it('should clear the input value if control value is reset programatically', fakeAsync(() => {
+ typeInElement('200', input);
+ fixture.detectChanges();
+ tick();
+
+ fixture.componentInstance.control.reset();
+ fixture.detectChanges();
+ tick();
+
+ expect(input.value).toEqual(
+ '',
+ `Expected input value to be empty after control reset.`
+ );
+ }));
+
+ it('should mark the control as dirty as user types', () => {
+ expect(fixture.componentInstance.control.dirty).toBe(
+ false,
+ `Expected control to start out pristine.`
+ );
+
+ typeInElement('20', input);
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.control.dirty).toBe(
+ true,
+ `Expected control to become dirty when the user types into the input.`
+ );
+ });
+
+ it('should not mark the control dirty when the value is set programmatically', () => {
+ expect(fixture.componentInstance.control.dirty).toBe(
+ false,
+ `Expected control to start out pristine.`
+ );
+
+ fixture.componentInstance.control.setValue('200');
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.control.dirty).toBe(
+ false,
+ `Expected control to stay pristine if value is set programmatically.`
+ );
+ });
+
+ it('should clear input value on blur if invalid input was provided', fakeAsync(() => {
+ typeInElement('invalid', input);
+ fixture.detectChanges();
+ tick();
+
+ dispatchFakeEvent(input, 'blur');
+ fixture.detectChanges();
+ tick();
+
+ expect(input.value).toEqual(
+ '',
+ `Expected input value to be cleared on blur.`
+ );
+ }));
+ });
+});
diff --git a/projects/ng-rocketparts/src/lib/components/number-input/number-input.component.ts b/projects/ng-rocketparts/src/lib/components/number-input/number-input.component.ts
new file mode 100644
index 0000000..967f03a
--- /dev/null
+++ b/projects/ng-rocketparts/src/lib/components/number-input/number-input.component.ts
@@ -0,0 +1,143 @@
+import {
+ Component,
+ EventEmitter,
+ forwardRef,
+ HostBinding,
+ Input,
+ OnInit,
+ Output,
+ Inject,
+ LOCALE_ID
+} from '@angular/core';
+import {
+ AbstractControl,
+ ControlValueAccessor,
+ NG_VALIDATORS,
+ NG_VALUE_ACCESSOR,
+ ValidationErrors,
+ Validator
+} from '@angular/forms';
+import { formatNumber } from '@angular/common';
+
+export const NUMBER_INPUT_VALUE_ACCESSOR: any = {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => NumberInputComponent), // tslint:disable-line
+ multi: true
+};
+
+export const NUMBER_INPUT_VALIDATOR: any = {
+ provide: NG_VALIDATORS,
+ useExisting: forwardRef(() => NumberInputComponent), // tslint:disable-line
+ multi: true
+};
+
+@Component({
+ selector: 'ngr-number-input',
+ templateUrl: './number-input.component.html',
+ styles: [],
+ providers: [NUMBER_INPUT_VALUE_ACCESSOR, NUMBER_INPUT_VALIDATOR]
+})
+export class NumberInputComponent
+ implements OnInit, ControlValueAccessor, Validator {
+ @HostBinding('class.ngr-number-input')
+ numberInputClass = true;
+
+ @Input()
+ placeholder: string;
+
+ @Input()
+ min: number;
+
+ @Input()
+ max: number;
+
+ @Output()
+ blur: EventEmitter = new EventEmitter();
+
+ onChange: () => void;
+
+ onTouched: () => void;
+
+ value: number | string;
+
+ displayValue: string;
+
+ disabled: boolean;
+
+ constructor(@Inject(LOCALE_ID) private locale: string) {}
+
+ ngOnInit() {}
+
+ writeValue(value: number): void {
+ if (this.value !== value) {
+ this.value = value;
+ this._updateDisplayValue();
+ }
+ }
+
+ registerOnChange(fn: any): void {
+ this.onChange = () => {
+ if (fn) {
+ fn(this.value);
+ }
+ };
+ }
+
+ registerOnTouched(fn: any): void {
+ this.onTouched = fn;
+ }
+
+ /**
+ * Validates the filter control
+ */
+ validate(c: AbstractControl): ValidationErrors | any {
+ return !isNaN(+this.value) &&
+ (!this.min || this.value >= this.min) &&
+ (!this.max || this.value <= this.max)
+ ? null
+ : {
+ numberInput: 'Invalid value specified.'
+ };
+ }
+
+ setDisabledState(isDisabled: boolean) {
+ this.disabled = isDisabled;
+ }
+
+ /**
+ * Called when the selection changed
+ * @param value
+ */
+ onInputChange(value: string) {
+ this.displayValue = value;
+ let prepValue = value;
+
+ prepValue = prepValue.replace(',', '.');
+
+ const newValue = prepValue.match(/^\d+(\.\d+)?$/)
+ ? parseFloat(prepValue)
+ : value
+ ? value
+ : null;
+
+ if (this.value !== newValue && this.onChange) {
+ this.value = newValue;
+ this.onChange();
+ }
+ }
+
+ /**
+ * Called when the user removes focus from the field
+ */
+ onBlur() {
+ this._updateDisplayValue();
+ }
+
+ private _updateDisplayValue() {
+ const value = parseFloat(`${this.value}`);
+
+ this.displayValue = Number.isNaN(value)
+ ? ''
+ : formatNumber(value, this.locale, '.2-2');
+ }
+}
diff --git a/projects/ng-rocketparts/src/lib/components/number-input/number-input.module.spec.ts b/projects/ng-rocketparts/src/lib/components/number-input/number-input.module.spec.ts
new file mode 100644
index 0000000..76ed2a5
--- /dev/null
+++ b/projects/ng-rocketparts/src/lib/components/number-input/number-input.module.spec.ts
@@ -0,0 +1,13 @@
+import { NumberInputModule } from './number-input.module';
+
+describe('NumberInputModule', () => {
+ let numberInputModule: NumberInputModule;
+
+ beforeEach(() => {
+ numberInputModule = new NumberInputModule();
+ });
+
+ it('should create an instance', () => {
+ expect(numberInputModule).toBeTruthy();
+ });
+});
diff --git a/projects/ng-rocketparts/src/lib/components/number-input/number-input.module.ts b/projects/ng-rocketparts/src/lib/components/number-input/number-input.module.ts
new file mode 100644
index 0000000..c58dbd2
--- /dev/null
+++ b/projects/ng-rocketparts/src/lib/components/number-input/number-input.module.ts
@@ -0,0 +1,12 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+
+import { NumberInputComponent } from './number-input.component';
+
+@NgModule({
+ imports: [CommonModule, FormsModule, ReactiveFormsModule],
+ declarations: [NumberInputComponent],
+ exports: [NumberInputComponent]
+})
+export class NumberInputModule {}
diff --git a/projects/ng-rocketparts/src/lib/ng-rocketparts.module.ts b/projects/ng-rocketparts/src/lib/ng-rocketparts.module.ts
index 7944e05..191e416 100644
--- a/projects/ng-rocketparts/src/lib/ng-rocketparts.module.ts
+++ b/projects/ng-rocketparts/src/lib/ng-rocketparts.module.ts
@@ -1,9 +1,15 @@
import { NgModule } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+
+import { NumberInputModule } from './components/number-input/number-input.module';
import { MaybeAsyncPipe } from './pipes/maybe-async.pipe';
+const MODULES = [NumberInputModule];
+
@NgModule({
- imports: [],
+ imports: [CommonModule, FormsModule, ReactiveFormsModule],
declarations: [MaybeAsyncPipe],
- exports: [MaybeAsyncPipe]
+ exports: [...MODULES, MaybeAsyncPipe]
})
export class NgRocketPartsModule {}
diff --git a/projects/ng-rocketparts/src/public_api.ts b/projects/ng-rocketparts/src/public_api.ts
index fc30cbe..7dfe2e2 100644
--- a/projects/ng-rocketparts/src/public_api.ts
+++ b/projects/ng-rocketparts/src/public_api.ts
@@ -2,5 +2,6 @@
* Public API Surface of ng-rocketparts
*/
+export * from './lib/components/number-input/number-input.module';
export * from './lib/pipes/maybe-async.pipe';
export * from './lib/ng-rocketparts.module';
diff --git a/projects/ng-rocketparts/src/testing/dispatch-events.ts b/projects/ng-rocketparts/src/testing/dispatch-events.ts
new file mode 100755
index 0000000..589ce82
--- /dev/null
+++ b/projects/ng-rocketparts/src/testing/dispatch-events.ts
@@ -0,0 +1,65 @@
+/**
+ * @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.io/license
+ */
+
+import {
+ createFakeEvent,
+ createKeyboardEvent,
+ createMouseEvent,
+ createTouchEvent
+} from './event-objects';
+
+/** Utility to dispatch any event on a Node. */
+export function dispatchEvent(node: Node | Window, event: Event): Event {
+ node.dispatchEvent(event);
+ return event;
+}
+
+/** Shorthand to dispatch a fake event on a specified node. */
+export function dispatchFakeEvent(
+ node: Node | Window,
+ type: string,
+ canBubble?: boolean
+): Event {
+ return dispatchEvent(node, createFakeEvent(type, canBubble));
+}
+
+/** Shorthand to dispatch a keyboard event with a specified key code. */
+export function dispatchKeyboardEvent(
+ node: Node,
+ type: string,
+ keyCode: number,
+ target?: Element
+): KeyboardEvent {
+ return dispatchEvent(
+ node,
+ createKeyboardEvent(type, keyCode, target)
+ ) as KeyboardEvent;
+}
+
+interface DispatchMouseEventConfig {
+ node: Node;
+ type: string;
+ x: number;
+ y: number;
+ event: MouseEvent;
+}
+/** Shorthand to dispatch a mouse event on the specified coordinates. */
+export function dispatchMouseEvent({
+ node,
+ type,
+ x = 0,
+ y = 0,
+ event = createMouseEvent(type, x, y)
+}: DispatchMouseEventConfig): MouseEvent {
+ return dispatchEvent(node, event) as MouseEvent;
+}
+
+/** Shorthand to dispatch a touch event on the specified coordinates. */
+export function dispatchTouchEvent(node: Node, type: string, x = 0, y = 0) {
+ return dispatchEvent(node, createTouchEvent(type, x, y));
+}
diff --git a/projects/ng-rocketparts/src/testing/element-focus.ts b/projects/ng-rocketparts/src/testing/element-focus.ts
new file mode 100755
index 0000000..76270a5
--- /dev/null
+++ b/projects/ng-rocketparts/src/testing/element-focus.ts
@@ -0,0 +1,19 @@
+/**
+ * @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.io/license
+ */
+
+import { dispatchFakeEvent } from './dispatch-events';
+
+/**
+ * Patches an elements focus and blur methods to emit events consistently and predictably.
+ * This is necessary, because some browsers, like IE11, will call the focus handlers asynchronously,
+ * while others won't fire them at all if the browser window is not focused.
+ */
+export function patchElementFocus(element: HTMLElement) {
+ element.focus = () => dispatchFakeEvent(element, 'focus');
+ element.blur = () => dispatchFakeEvent(element, 'blur');
+}
diff --git a/projects/ng-rocketparts/src/testing/event-objects.ts b/projects/ng-rocketparts/src/testing/event-objects.ts
new file mode 100755
index 0000000..f88b527
--- /dev/null
+++ b/projects/ng-rocketparts/src/testing/event-objects.ts
@@ -0,0 +1,100 @@
+/**
+ * @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.io/license
+ */
+
+/** Creates a browser MouseEvent with the specified options. */
+export function createMouseEvent(type: string, x = 0, y = 0, button = 0) {
+ const event = document.createEvent('MouseEvent');
+
+ event.initMouseEvent(
+ type,
+ true /* canBubble */,
+ false /* cancelable */,
+ window /* view */,
+ 0 /* detail */,
+ x /* screenX */,
+ y /* screenY */,
+ x /* clientX */,
+ y /* clientY */,
+ false /* ctrlKey */,
+ false /* altKey */,
+ false /* shiftKey */,
+ false /* metaKey */,
+ button /* button */,
+ null /* relatedTarget */
+ );
+
+ // `initMouseEvent` doesn't allow us to pass the `buttons` and
+ // defaults it to 0 which looks like a fake event.
+ Object.defineProperty(event, 'buttons', { get: () => 1 });
+
+ return event;
+}
+
+/** Creates a browser TouchEvent with the specified pointer coordinates. */
+export function createTouchEvent(type: string, pageX = 0, pageY = 0) {
+ // In favor of creating events that work for most of the browsers, the event is created
+ // as a basic UI Event. The necessary details for the event will be set manually.
+ const event = document.createEvent('UIEvent');
+ const touchDetails = { pageX, pageY };
+
+ event.initUIEvent(type, true, true, window, 0);
+
+ // Most of the browsers don't have a "initTouchEvent" method that can be used to define
+ // the touch details.
+ Object.defineProperties(event, {
+ touches: { value: [touchDetails] },
+ targetTouches: { value: [touchDetails] },
+ changedTouches: { value: [touchDetails] }
+ });
+
+ return event;
+}
+
+/** Dispatches a keydown event from an element. */
+export function createKeyboardEvent(
+ type: string,
+ keyCode: number,
+ target?: Element,
+ key?: string
+) {
+ const event = document.createEvent('KeyboardEvent') as any;
+ // Firefox does not support `initKeyboardEvent`, but supports `initKeyEvent`.
+ const initEventFn = (event.initKeyEvent || event.initKeyboardEvent).bind(
+ event
+ );
+ const originalPreventDefault = event.preventDefault;
+
+ initEventFn(type, true, true, window, 0, 0, 0, 0, 0, keyCode);
+
+ // Webkit Browsers don't set the keyCode when calling the init function.
+ // See related bug https://bugs.webkit.org/show_bug.cgi?id=16735
+ Object.defineProperties(event, {
+ keyCode: { get: () => keyCode },
+ key: { get: () => key },
+ target: { get: () => target }
+ });
+
+ // IE won't set `defaultPrevented` on synthetic events so we need to do it manually.
+ event.preventDefault = function() {
+ Object.defineProperty(event, 'defaultPrevented', { get: () => true });
+ return originalPreventDefault.apply(this, arguments);
+ };
+
+ return event;
+}
+
+/** Creates a fake event object with any desired event type. */
+export function createFakeEvent(
+ type: string,
+ canBubble = false,
+ cancelable = true
+) {
+ const event = document.createEvent('Event');
+ event.initEvent(type, canBubble, cancelable);
+ return event;
+}
diff --git a/projects/ng-rocketparts/src/testing/index.ts b/projects/ng-rocketparts/src/testing/index.ts
new file mode 100755
index 0000000..676ca90
--- /dev/null
+++ b/projects/ng-rocketparts/src/testing/index.ts
@@ -0,0 +1,9 @@
+/**
+ * @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.io/license
+ */
+
+export * from './public-api';
diff --git a/projects/ng-rocketparts/src/testing/mock-ng-zone.ts b/projects/ng-rocketparts/src/testing/mock-ng-zone.ts
new file mode 100755
index 0000000..82348aa
--- /dev/null
+++ b/projects/ng-rocketparts/src/testing/mock-ng-zone.ts
@@ -0,0 +1,37 @@
+/**
+ * @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.io/license
+ */
+
+import { EventEmitter, Injectable, NgZone } from '@angular/core';
+
+/**
+ * Mock synchronous NgZone implementation that can be used
+ * to flush out `onStable` subscriptions in tests.
+ *
+ * via: https://github.com/angular/angular/blob/master/packages/core/testing/src/ng_zone_mock.ts
+ * @docs-private
+ */
+@Injectable()
+export class MockNgZone extends NgZone {
+ onStable: EventEmitter = new EventEmitter(false);
+
+ constructor() {
+ super({ enableLongStackTrace: false });
+ }
+
+ run(fn: Function): any {
+ return fn();
+ }
+
+ runOutsideAngular(fn: Function): any {
+ return fn();
+ }
+
+ simulateZoneExit(): void {
+ this.onStable.emit(null);
+ }
+}
diff --git a/projects/ng-rocketparts/src/testing/public-api.ts b/projects/ng-rocketparts/src/testing/public-api.ts
new file mode 100755
index 0000000..8197910
--- /dev/null
+++ b/projects/ng-rocketparts/src/testing/public-api.ts
@@ -0,0 +1,14 @@
+/**
+ * @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.io/license
+ */
+
+export * from './dispatch-events';
+export * from './event-objects';
+export * from './type-in-element';
+export * from './wrapped-error-message';
+export * from './mock-ng-zone';
+export * from './element-focus';
diff --git a/projects/ng-rocketparts/src/testing/type-in-element.ts b/projects/ng-rocketparts/src/testing/type-in-element.ts
new file mode 100755
index 0000000..a6448f0
--- /dev/null
+++ b/projects/ng-rocketparts/src/testing/type-in-element.ts
@@ -0,0 +1,21 @@
+/**
+ * @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.io/license
+ */
+
+import { dispatchFakeEvent } from './dispatch-events';
+
+/**
+ * Focuses an input, sets its value and dispatches
+ * the `input` event, simulating the user typing.
+ * @param value Value to be set on the input.
+ * @param element Element onto which to set the value.
+ */
+export function typeInElement(value: string, element: HTMLInputElement) {
+ element.focus();
+ element.value = value;
+ dispatchFakeEvent(element, 'input');
+}
diff --git a/projects/ng-rocketparts/src/testing/wrapped-error-message.ts b/projects/ng-rocketparts/src/testing/wrapped-error-message.ts
new file mode 100755
index 0000000..d930513
--- /dev/null
+++ b/projects/ng-rocketparts/src/testing/wrapped-error-message.ts
@@ -0,0 +1,16 @@
+/**
+ * @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.io/license
+ */
+
+/**
+ * Gets a RegExp used to detect an angular wrapped error message.
+ * See https://github.com/angular/angular/issues/8348
+ */
+export function wrappedErrorMessage(e: Error) {
+ const escapedMessage = e.message.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&');
+ return new RegExp(escapedMessage);
+}