Skip to content

Commit e2f0871

Browse files
author
Fernando Gonzalez Goncharov
committed
enhancement/2 - add number-input component
* add testing utils from @angular/cdk Closes #2
1 parent 3b1d0b3 commit e2f0871

13 files changed

Lines changed: 597 additions & 3 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<input (blur)="onBlur()" #input="ngModel" [min]="min" [max]="max"
2+
[attr.min]="min" [attr.max]="max" (ngModelChange)="onInputChange($event)"
3+
[ngModel]="displayValue" [disabled]="disabled"
4+
[placeholder]="placeholder || ''" placement="top"
5+
triggers="focus:blur"/>
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import {
2+
ComponentFixture,
3+
TestBed,
4+
fakeAsync,
5+
tick
6+
} from '@angular/core/testing';
7+
import { By } from '@angular/platform-browser';
8+
import { Component, Type } from '@angular/core';
9+
import { FormsModule, ReactiveFormsModule, FormControl } from '@angular/forms';
10+
11+
import { typeInElement, dispatchFakeEvent } from '../../../testing';
12+
import { NumberInputComponent } from './number-input.component';
13+
14+
@Component({
15+
template: `<rp-number-input
16+
[formControl]="control"
17+
[placeholder]="'Enter a number'"
18+
[min]="10"
19+
[max]="1000"></rp-number-input>
20+
`
21+
})
22+
export class SimpleNumberInputComponent {
23+
control = new FormControl();
24+
}
25+
26+
describe('[Component]: NumberInputComponent', () => {
27+
// Creates a test component fixture.
28+
function createComponent<T>(component: Type<T>) {
29+
TestBed.configureTestingModule({
30+
imports: [FormsModule, ReactiveFormsModule],
31+
declarations: [NumberInputComponent, component]
32+
});
33+
TestBed.compileComponents();
34+
35+
return TestBed.createComponent<T>(component);
36+
}
37+
38+
describe('forms integration', () => {
39+
let fixture: ComponentFixture<SimpleNumberInputComponent>;
40+
let input: HTMLInputElement;
41+
42+
beforeEach(() => {
43+
fixture = createComponent(SimpleNumberInputComponent);
44+
fixture.detectChanges();
45+
input = fixture.debugElement.query(By.css('input')).nativeElement;
46+
});
47+
48+
it('should update control value as user types with input value', () => {
49+
typeInElement('10', input);
50+
fixture.detectChanges();
51+
52+
expect(fixture.componentInstance.control.value).toEqual(
53+
10,
54+
`Expected control value to be updated as user types.`
55+
);
56+
57+
typeInElement('100', input);
58+
fixture.detectChanges();
59+
60+
expect(fixture.componentInstance.control.value).toEqual(
61+
100,
62+
`Expected control value to be updated as user types.`
63+
);
64+
});
65+
66+
it('should format input value on blur', fakeAsync(() => {
67+
typeInElement('300', input);
68+
dispatchFakeEvent(input, 'blur');
69+
fixture.detectChanges();
70+
tick();
71+
72+
expect(input.value).toEqual(
73+
'300.00',
74+
`Expected input value to be formatted on blur.`
75+
);
76+
77+
typeInElement('3000', input);
78+
dispatchFakeEvent(input, 'blur');
79+
fixture.detectChanges();
80+
tick();
81+
82+
expect(input.value).toEqual(
83+
'3,000.00',
84+
`Expected input value to be formatted on blur.`
85+
);
86+
}));
87+
88+
it('should fill input correctly if control value is set programatically', fakeAsync(() => {
89+
fixture.componentInstance.control.setValue(100);
90+
fixture.detectChanges();
91+
tick();
92+
93+
expect(input.value).toEqual(
94+
'100.00',
95+
`Expected input to fill with control current formatted value.`
96+
);
97+
98+
fixture.componentInstance.control.setValue(1000);
99+
fixture.detectChanges();
100+
tick();
101+
102+
expect(input.value).toEqual(
103+
'1,000.00',
104+
`Expected input to fill with control current formatted value.`
105+
);
106+
}));
107+
108+
it('should clear the input value if control value is reset programatically', fakeAsync(() => {
109+
typeInElement('200', input);
110+
fixture.detectChanges();
111+
tick();
112+
113+
fixture.componentInstance.control.reset();
114+
fixture.detectChanges();
115+
tick();
116+
117+
expect(input.value).toEqual(
118+
'',
119+
`Expected input value to be empty after control reset.`
120+
);
121+
}));
122+
123+
it('should mark the control as dirty as user types', () => {
124+
expect(fixture.componentInstance.control.dirty).toBe(
125+
false,
126+
`Expected control to start out pristine.`
127+
);
128+
129+
typeInElement('20', input);
130+
fixture.detectChanges();
131+
132+
expect(fixture.componentInstance.control.dirty).toBe(
133+
true,
134+
`Expected control to become dirty when the user types into the input.`
135+
);
136+
});
137+
138+
it('should not mark the control dirty when the value is set programmatically', () => {
139+
expect(fixture.componentInstance.control.dirty).toBe(
140+
false,
141+
`Expected control to start out pristine.`
142+
);
143+
144+
fixture.componentInstance.control.setValue('200');
145+
fixture.detectChanges();
146+
147+
expect(fixture.componentInstance.control.dirty).toBe(
148+
false,
149+
`Expected control to stay pristine if value is set programmatically.`
150+
);
151+
});
152+
153+
it('should clear input value on blur if invalid input was provided', fakeAsync(() => {
154+
typeInElement('invalid', input);
155+
fixture.detectChanges();
156+
tick();
157+
158+
dispatchFakeEvent(input, 'blur');
159+
fixture.detectChanges();
160+
tick();
161+
162+
expect(input.value).toEqual(
163+
'',
164+
`Expected input value to be cleared on blur.`
165+
);
166+
}));
167+
});
168+
});
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import {
2+
Component,
3+
EventEmitter,
4+
forwardRef,
5+
HostBinding,
6+
Input,
7+
OnInit,
8+
Output
9+
} from '@angular/core';
10+
import {
11+
AbstractControl,
12+
ControlValueAccessor,
13+
NG_VALIDATORS,
14+
NG_VALUE_ACCESSOR,
15+
ValidationErrors,
16+
Validator
17+
} from '@angular/forms';
18+
import { DecimalPipe } from '@angular/common';
19+
20+
export const NUMBER_INPUT_VALUE_ACCESSOR: any = {
21+
provide: NG_VALUE_ACCESSOR,
22+
useExisting: forwardRef(() => NumberInputComponent), // tslint:disable-line
23+
multi: true
24+
};
25+
26+
export const NUMBER_INPUT_VALIDATOR: any = {
27+
provide: NG_VALIDATORS,
28+
useExisting: forwardRef(() => NumberInputComponent), // tslint:disable-line
29+
multi: true
30+
};
31+
32+
@Component({
33+
selector: 'rp-number-input',
34+
templateUrl: './number-input.component.html',
35+
styles: [],
36+
providers: [DecimalPipe, NUMBER_INPUT_VALUE_ACCESSOR, NUMBER_INPUT_VALIDATOR]
37+
})
38+
export class NumberInputComponent
39+
implements OnInit, ControlValueAccessor, Validator {
40+
@HostBinding('class.number-input')
41+
numberInputClass = true;
42+
43+
@Input()
44+
placeholder: string;
45+
46+
@Input()
47+
min: number;
48+
49+
@Input()
50+
max: number;
51+
52+
@Output()
53+
blur: EventEmitter<void> = new EventEmitter();
54+
55+
onChange: () => void;
56+
57+
onTouched: () => void;
58+
59+
value: number | string;
60+
61+
displayValue: string;
62+
63+
disabled: boolean;
64+
65+
constructor(private decimalPipe: DecimalPipe) {}
66+
67+
ngOnInit() {}
68+
69+
writeValue(value: number): void {
70+
if (this.value !== value) {
71+
this.value = value;
72+
this._updateDisplayValue();
73+
}
74+
}
75+
76+
registerOnChange(fn: any): void {
77+
this.onChange = () => {
78+
if (fn) {
79+
fn(this.value);
80+
}
81+
};
82+
}
83+
84+
registerOnTouched(fn: any): void {
85+
this.onTouched = fn;
86+
}
87+
88+
/**
89+
* Validates the filter control
90+
*/
91+
validate(c: AbstractControl): ValidationErrors | any {
92+
return !isNaN(+this.value) &&
93+
(!this.min || this.value >= this.min) &&
94+
(!this.max || this.value <= this.max)
95+
? null
96+
: {
97+
numberInput: 'Invalid value specified.'
98+
};
99+
}
100+
101+
setDisabledState(isDisabled: boolean) {
102+
this.disabled = isDisabled;
103+
}
104+
105+
/**
106+
* Called when the selection changed
107+
* @param value
108+
*/
109+
onInputChange(value: string) {
110+
this.displayValue = value;
111+
let prepValue = value;
112+
if (/(\d+\.)+\d+,\d+/) {
113+
prepValue = prepValue.replace(/\./g, '');
114+
}
115+
prepValue = prepValue.replace(',', '.');
116+
const newValue = prepValue.match(/^\d+(\.\d+)?$/)
117+
? parseFloat(prepValue)
118+
: value
119+
? value
120+
: null;
121+
if (this.value !== newValue && this.onChange) {
122+
this.value = newValue;
123+
this.onChange();
124+
}
125+
}
126+
127+
/**
128+
* Called when the user removes focus from the field
129+
*/
130+
onBlur() {
131+
this._updateDisplayValue();
132+
}
133+
134+
private _updateDisplayValue() {
135+
this.displayValue = this.decimalPipe.transform(
136+
parseFloat(`${this.value}`),
137+
'.2-2'
138+
);
139+
}
140+
}
Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import { NgModule } from '@angular/core';
2+
import { CommonModule } from '@angular/common';
3+
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
4+
5+
import { NumberInputComponent } from './components/number-input/number-input.component';
6+
7+
const COMPONENTS = [NumberInputComponent];
28

39
@NgModule({
4-
imports: [],
5-
declarations: [],
6-
exports: []
10+
imports: [CommonModule, FormsModule, ReactiveFormsModule],
11+
declarations: [...COMPONENTS],
12+
exports: [...COMPONENTS]
713
})
814
export class NgRocketPartsModule {}

projects/ng-rocketparts/src/public_api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
* Public API Surface of ng-rocketparts
33
*/
44

5+
export * from './lib/components/number-input/number-input.component';
56
export * from './lib/ng-rocketparts.module';
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {
10+
createFakeEvent,
11+
createKeyboardEvent,
12+
createMouseEvent,
13+
createTouchEvent
14+
} from './event-objects';
15+
16+
/** Utility to dispatch any event on a Node. */
17+
export function dispatchEvent(node: Node | Window, event: Event): Event {
18+
node.dispatchEvent(event);
19+
return event;
20+
}
21+
22+
/** Shorthand to dispatch a fake event on a specified node. */
23+
export function dispatchFakeEvent(
24+
node: Node | Window,
25+
type: string,
26+
canBubble?: boolean
27+
): Event {
28+
return dispatchEvent(node, createFakeEvent(type, canBubble));
29+
}
30+
31+
/** Shorthand to dispatch a keyboard event with a specified key code. */
32+
export function dispatchKeyboardEvent(
33+
node: Node,
34+
type: string,
35+
keyCode: number,
36+
target?: Element
37+
): KeyboardEvent {
38+
return dispatchEvent(
39+
node,
40+
createKeyboardEvent(type, keyCode, target)
41+
) as KeyboardEvent;
42+
}
43+
44+
/** Shorthand to dispatch a mouse event on the specified coordinates. */
45+
export function dispatchMouseEvent(
46+
node: Node,
47+
type: string,
48+
x = 0,
49+
y = 0,
50+
event = createMouseEvent(type, x, y)
51+
): MouseEvent {
52+
return dispatchEvent(node, event) as MouseEvent;
53+
}
54+
55+
/** Shorthand to dispatch a touch event on the specified coordinates. */
56+
export function dispatchTouchEvent(node: Node, type: string, x = 0, y = 0) {
57+
return dispatchEvent(node, createTouchEvent(type, x, y));
58+
}

0 commit comments

Comments
 (0)