Skip to content

Commit 519afc1

Browse files
authored
Merge pull request #291 from esuau/feat/number
feat(number): Add number input component
2 parents dac46c3 + c4a49d9 commit 519afc1

File tree

5 files changed

+430
-0
lines changed

5 files changed

+430
-0
lines changed

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export * from "./list-group/list-group.module";
1818
export * from "./loading/loading.module";
1919
export * from "./modal/modal.module";
2020
export * from "./notification/notification.module";
21+
export * from "./number-input/number.module";
2122
export * from "./pagination/pagination.module";
2223
export * from "./pill-input/pill-input.module";
2324
export * from "./placeholder/placeholder.module";
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { ComponentFixture, TestBed } from "@angular/core/testing";
2+
import { By } from "@angular/platform-browser";
3+
4+
import { Number } from "./number.component";
5+
import { FormsModule } from "@angular/forms";
6+
7+
describe("Number", () => {
8+
let component: Number;
9+
let fixture: ComponentFixture<Number>;
10+
let inputElement: HTMLInputElement;
11+
let containerElement: HTMLElement;
12+
let buttonUp: HTMLButtonElement;
13+
let buttonDown: HTMLButtonElement;
14+
let labelElement: HTMLDivElement;
15+
let helperTextElement: HTMLDivElement;
16+
17+
beforeEach(() => {
18+
TestBed.configureTestingModule({
19+
declarations: [Number],
20+
imports: [FormsModule],
21+
providers: []
22+
});
23+
});
24+
25+
beforeEach(() => {
26+
fixture = TestBed.createComponent(Number);
27+
component = fixture.componentInstance;
28+
inputElement = fixture.debugElement.query(By.css("input")).nativeElement;
29+
containerElement = fixture.debugElement.query(By.css(".bx--number")).nativeElement;
30+
});
31+
32+
it("should work", () => {
33+
expect(component instanceof Number).toBe(true);
34+
});
35+
36+
it("should bind input value", () => {
37+
component.value = 1;
38+
fixture.detectChanges();
39+
expect(inputElement.value).toEqual("1");
40+
});
41+
42+
it("should bind input min", () => {
43+
component.min = 0;
44+
fixture.detectChanges();
45+
expect(inputElement.min).toEqual("0");
46+
});
47+
48+
it("should bind input max", () => {
49+
component.max = 100;
50+
fixture.detectChanges();
51+
expect(inputElement.max).toEqual("100");
52+
});
53+
54+
it("should bind input disabled", () => {
55+
expect(inputElement.disabled).toEqual(false);
56+
component.disabled = true;
57+
fixture.detectChanges();
58+
expect(inputElement.disabled).toEqual(true);
59+
});
60+
61+
it("should bind input required", () => {
62+
component.required = true;
63+
fixture.detectChanges();
64+
expect(inputElement.required).toEqual(true);
65+
});
66+
67+
it("should bind input label", () => {
68+
component.label = "Number Input";
69+
fixture.detectChanges();
70+
labelElement = fixture.debugElement.query(By.css(".bx--label")).nativeElement;
71+
expect(labelElement.innerHTML.includes("Number Input")).toEqual(true);
72+
expect(containerElement.className.includes("bx--number--nolabel")).toEqual(false);
73+
74+
component.label = null;
75+
fixture.detectChanges();
76+
expect(fixture.debugElement.query(By.css(".bx--label"))).toBeNull();
77+
expect(containerElement.className.includes("bx--number--nolabel")).toEqual(true);
78+
});
79+
80+
it("should bind input helperText", () => {
81+
component.helperText = "Helper text here.";
82+
fixture.detectChanges();
83+
helperTextElement = fixture.debugElement.query(By.css(".bx--form__helper-text")).nativeElement;
84+
expect(containerElement.className.includes("bx--number--helpertext")).toEqual(true);
85+
expect(helperTextElement.innerHTML.includes("Helper text here.")).toEqual(true);
86+
});
87+
88+
it("should display control buttons", () => {
89+
buttonUp = fixture.debugElement.queryAll(By.css("button"))[0].nativeElement;
90+
buttonDown = fixture.debugElement.queryAll(By.css("button"))[1].nativeElement;
91+
expect(buttonUp.className.includes("bx--number__control-btn")).toEqual(true);
92+
expect(buttonUp.className.includes("up-icon")).toEqual(true);
93+
expect(buttonDown.className.includes("bx--number__control-btn")).toEqual(true);
94+
expect(buttonDown.className.includes("down-icon")).toEqual(true);
95+
});
96+
97+
it("should increment value when button up is clicked", () => {
98+
buttonUp = fixture.debugElement.query(By.css(".up-icon")).nativeElement;
99+
component.value = 1;
100+
buttonUp.click();
101+
fixture.detectChanges();
102+
expect(component.value).toEqual(2);
103+
});
104+
105+
it("should not increment value if max is reached", () => {
106+
buttonUp = fixture.debugElement.query(By.css(".up-icon")).nativeElement;
107+
component.value = 100;
108+
component.max = 100;
109+
buttonUp.click();
110+
fixture.detectChanges();
111+
expect(component.value).toEqual(100);
112+
});
113+
114+
it("should decrement value when button down is clicked", () => {
115+
buttonUp = fixture.debugElement.query(By.css(".down-icon")).nativeElement;
116+
component.value = 1;
117+
buttonUp.click();
118+
fixture.detectChanges();
119+
expect(component.value).toEqual(0);
120+
});
121+
122+
it("should not decrement value min is reached", () => {
123+
buttonUp = fixture.debugElement.query(By.css(".down-icon")).nativeElement;
124+
component.value = 0;
125+
component.min = 0;
126+
buttonUp.click();
127+
fixture.detectChanges();
128+
expect(component.value).toEqual(0);
129+
});
130+
131+
it("should have dark and light theme", () => {
132+
component.theme = "dark";
133+
fixture.detectChanges();
134+
expect(containerElement.className.includes("bx--number--light")).toEqual(false);
135+
component.theme = "light";
136+
fixture.detectChanges();
137+
expect(containerElement.className.includes("bx--number--light")).toEqual(true);
138+
});
139+
});
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { Component, Input, HostBinding, EventEmitter, Output } from "@angular/core";
2+
import { NG_VALUE_ACCESSOR, ControlValueAccessor } from "@angular/forms";
3+
import { isNullOrUndefined } from "util";
4+
5+
/**
6+
* Used to emit changes performed on number input components.
7+
* @export
8+
* @class NumberChange
9+
*/
10+
export class NumberChange {
11+
/**
12+
* Contains the `Number` that has been changed.
13+
* @type {Number}
14+
* @memberof NumberChange
15+
*/
16+
source: Number;
17+
/**
18+
* The value of the `Number` field encompassed in the `NumberChange` class.
19+
* @type {number}
20+
* @memberof NumberChange
21+
*/
22+
value: number;
23+
}
24+
25+
/**
26+
* @export
27+
* @class Number
28+
* @implements {ControlValueAccessor}
29+
*/
30+
@Component({
31+
selector: "ibm-number",
32+
template: `
33+
<div
34+
data-numberinput
35+
class="bx--number"
36+
[ngClass]="{
37+
'bx--number--light': theme === 'light',
38+
'bx--number--nolabel': !label,
39+
'bx--number--helpertext': helperText
40+
}">
41+
<label *ngIf="label" [for]="id" class="bx--label">{{label}}</label>
42+
<input
43+
type="number"
44+
[id]="id"
45+
[value]="value"
46+
[min]="min"
47+
[max]="max"
48+
[disabled]="disabled"
49+
[required]="required"/>
50+
<div class="bx--number__controls">
51+
<button
52+
class="bx--number__control-btn up-icon"
53+
(click)="onIncrement()">
54+
<svg width="10" height="5" viewBox="0 0 10 5">
55+
<path d="M0 5L5 .002 10 5z" fill-rule="evenodd" />
56+
</svg>
57+
</button>
58+
<button
59+
class="bx--number__control-btn down-icon"
60+
(click)="onDecrement()">
61+
<svg width="10" height="5" viewBox="0 0 10 5">
62+
<path d="M0 0l5 4.998L10 0z" fill-rule="evenodd" />
63+
</svg>
64+
</button>
65+
</div>
66+
<div *ngIf="helperText" class="bx--form__helper-text">{{helperText}}</div>
67+
</div>
68+
`,
69+
providers: [
70+
{
71+
provide: NG_VALUE_ACCESSOR,
72+
useExisting: Number,
73+
multi: true
74+
}
75+
]
76+
})
77+
export class Number implements ControlValueAccessor {
78+
/**
79+
* Variable used for creating unique ids for number input components.
80+
*/
81+
static numberCount = 0;
82+
83+
@HostBinding("class.bx--form-item") containerClass = true;
84+
85+
/**
86+
* `light` or `dark` number input theme.
87+
*/
88+
@Input() theme: "light" | "dark" = "dark";
89+
/**
90+
* Set to `true` for a disabled number input.
91+
*/
92+
@Input() disabled = false;
93+
/**
94+
* The unique id for the number component.
95+
*/
96+
@Input() id = `number-${Number.numberCount}`;
97+
/**
98+
* Reflects the required attribute of the `input` element.
99+
*/
100+
@Input() required: boolean;
101+
/**
102+
* Sets the value attribute on the `input` element.
103+
*/
104+
@Input() value = 0;
105+
/**
106+
* Sets the min attribute on the `input` element.
107+
*/
108+
@Input() min;
109+
/**
110+
* Sets the max attribute on the `input` element.
111+
*/
112+
@Input() max;
113+
/**
114+
* Sets the text inside the `label` tag.
115+
*/
116+
@Input() label;
117+
/**
118+
* Sets the optional helper text.
119+
*/
120+
@Input() helperText;
121+
/**
122+
* Emits event notifying other classes when a change in state occurs in the input.
123+
*/
124+
@Output() change = new EventEmitter<NumberChange>();
125+
126+
/**
127+
* Creates an instance of `Number`.
128+
* @memberof Number
129+
*/
130+
constructor() {
131+
Number.numberCount++;
132+
}
133+
134+
/**
135+
* This is the initial value set to the component
136+
* @param value The input value.
137+
*/
138+
public writeValue(value: any) {
139+
this.value = value;
140+
}
141+
142+
/**
143+
* Sets a method in order to propagate changes back to the form.
144+
* @param {any} fn
145+
* @memberof Number
146+
*/
147+
public registerOnChange(fn: any) {
148+
this.propagateChange = fn;
149+
}
150+
151+
/**
152+
* Registers a callback to be triggered when the control has been touched.
153+
* @param fn Callback to be triggered when the number input is touched.
154+
*/
155+
public registerOnTouched(fn: any) {
156+
this.onTouched = fn;
157+
}
158+
159+
/**
160+
* Called when number input is blurred. Needed to properly implement `ControlValueAccessor`.
161+
* @memberof Number
162+
*/
163+
onTouched: () => any = () => {};
164+
165+
/**
166+
* Method set in `registerOnChange` to propagate changes back to the form.
167+
* @memberof Number
168+
*/
169+
propagateChange = (_: any) => {};
170+
171+
/**
172+
* Adds 1 to the current `value`.
173+
*/
174+
onIncrement(): void {
175+
if (isNullOrUndefined(this.max) || this.value < this.max) {
176+
this.value++;
177+
this.emitChangeEvent();
178+
}
179+
}
180+
181+
/**
182+
* Subtracts 1 to the current `value`.
183+
*/
184+
onDecrement(): void {
185+
if (isNullOrUndefined(this.min) || this.value > this.min) {
186+
this.value--;
187+
this.emitChangeEvent();
188+
}
189+
}
190+
191+
/**
192+
* Creates a class of `NumberChange` to emit the change in the `Number`.
193+
*/
194+
emitChangeEvent(): void {
195+
let event = new NumberChange();
196+
event.source = this;
197+
event.value = this.value;
198+
this.change.emit(event);
199+
this.propagateChange(this.value);
200+
}
201+
202+
}

src/number-input/number.module.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// modules
2+
import { NgModule } from "@angular/core";
3+
import { FormsModule } from "@angular/forms";
4+
import { CommonModule } from "@angular/common";
5+
6+
// imports
7+
import { Number } from "./number.component";
8+
9+
@NgModule({
10+
declarations: [
11+
Number
12+
],
13+
exports: [
14+
Number
15+
],
16+
imports: [
17+
FormsModule,
18+
CommonModule
19+
]
20+
})
21+
export class NumberModule { }
22+
23+
export { Number };

0 commit comments

Comments
 (0)