diff --git a/projects/components/src/number-input/number-input-appearance.ts b/projects/components/src/number-input/number-input-appearance.ts new file mode 100644 index 000000000..f6781ee6b --- /dev/null +++ b/projects/components/src/number-input/number-input-appearance.ts @@ -0,0 +1,4 @@ +export const enum NumberInputAppearance { + Underline = 'underline', + Border = 'border' +} diff --git a/projects/components/src/number-input/number-input.component.scss b/projects/components/src/number-input/number-input.component.scss new file mode 100644 index 000000000..0722b7522 --- /dev/null +++ b/projects/components/src/number-input/number-input.component.scss @@ -0,0 +1,31 @@ +@import 'font'; +@import 'color-palette'; + +.number-input { + @include body-2-regular($gray-9); + width: inherit; + height: inherit; + background: white; + text-align: center; +} + +.border { + border: 1px solid $gray-2; + border-radius: 6px; +} + +.underline { + border: none; + border-bottom: 1px solid $gray-2; +} + +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* Firefox */ +input[type='number'] { + -moz-appearance: textfield; +} diff --git a/projects/components/src/number-input/number-input.component.test.ts b/projects/components/src/number-input/number-input.component.test.ts new file mode 100644 index 000000000..e053493cb --- /dev/null +++ b/projects/components/src/number-input/number-input.component.test.ts @@ -0,0 +1,71 @@ +import { fakeAsync } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { createHostFactory } from '@ngneat/spectator/jest'; +import { NumberInputAppearance } from './number-input-appearance'; +import { NumberInputComponent } from './number-input.component'; + +describe('Number Input Component', () => { + const hostFactory = createHostFactory({ + component: NumberInputComponent, + imports: [FormsModule], + shallow: true + }); + + test('should apply disabled attribute when disabled', fakeAsync(() => { + const spectator = hostFactory(` + + `); + + spectator.tick(); + expect(spectator.query('input')!.getAttributeNames().includes('disabled')).toBe(true); + })); + + test('should apply border style correctly', fakeAsync(() => { + const spectator = hostFactory( + ` + + `, + { + hostProps: { + appearance: NumberInputAppearance.Border + } + } + ); + + spectator.tick(); + expect(spectator.query('input')?.classList).toContain('border'); + })); + + test('should apply underline style correctly', fakeAsync(() => { + const spectator = hostFactory( + ` + + `, + { + hostProps: { + appearance: NumberInputAppearance.Underline + } + } + ); + + spectator.tick(); + expect(spectator.query('input')?.classList).toContain('underline'); + })); + + test('should emit correct values on value change', fakeAsync(() => { + const spectator = hostFactory(` + + `); + + spectator.tick(); + spyOn(spectator.component.valueChange, 'emit'); + spectator.triggerEventHandler('input', 'ngModelChange', 7); + expect(spectator.component.valueChange.emit).toHaveBeenCalledWith(7); + + spectator.triggerEventHandler('input', 'ngModelChange', 15); + expect(spectator.component.valueChange.emit).toHaveBeenCalledWith(10); + + spectator.triggerEventHandler('input', 'ngModelChange', -1); + expect(spectator.component.valueChange.emit).toHaveBeenCalledWith(1); + })); +}); diff --git a/projects/components/src/number-input/number-input.component.ts b/projects/components/src/number-input/number-input.component.ts new file mode 100644 index 000000000..ee5b66559 --- /dev/null +++ b/projects/components/src/number-input/number-input.component.ts @@ -0,0 +1,58 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { NumberCoercer } from '@hypertrace/common'; +import { NumberInputAppearance } from './number-input-appearance'; + +@Component({ + selector: 'ht-number-input', + styleUrls: ['./number-input.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + ` +}) +export class NumberInputComponent { + @Input() + public value?: number; + + @Input() + public appearance: NumberInputAppearance = NumberInputAppearance.Border; + + @Input() + public disabled: boolean = false; + + @Input() + public minValue?: number; + + @Input() + public maxValue?: number; + + @Output() + public readonly valueChange: EventEmitter = new EventEmitter(); + + private readonly numberCoercer: NumberCoercer = new NumberCoercer(); + + private enforceMinMaxAndEmit(): void { + if (this.value !== undefined && this.maxValue !== undefined && this.value > this.maxValue) { + this.value = this.maxValue; + } + + if (this.value !== undefined && this.minValue !== undefined && this.value < this.minValue) { + this.value = this.minValue; + } + + this.valueChange.emit(this.numberCoercer.coerce(this.value)); + } + + public onValueChange(value?: number): void { + this.value = value; + + this.enforceMinMaxAndEmit(); + } +} diff --git a/projects/components/src/number-input/number-input.module.ts b/projects/components/src/number-input/number-input.module.ts new file mode 100644 index 000000000..74fb4d1ab --- /dev/null +++ b/projects/components/src/number-input/number-input.module.ts @@ -0,0 +1,11 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { NumberInputComponent } from './number-input.component'; + +@NgModule({ + imports: [CommonModule, FormsModule], + declarations: [NumberInputComponent], + exports: [NumberInputComponent] +}) +export class NumberInputModule {} diff --git a/projects/components/src/public-api.ts b/projects/components/src/public-api.ts index 60b815fe6..2ac3df788 100644 --- a/projects/components/src/public-api.ts +++ b/projects/components/src/public-api.ts @@ -124,6 +124,11 @@ export { InputAppearance } from './input/input-appearance'; export * from './input/input.component'; export * from './input/input.module'; +// Number Input +export { NumberInputAppearance } from './number-input/number-input-appearance'; +export * from './number-input/number-input.component'; +export * from './number-input/number-input.module'; + // Json Tree export { JsonViewerComponent } from './viewer/json-viewer/json-viewer.component'; export { JsonViewerModule } from './viewer/json-viewer/json-viewer.module';