From 82c6bc6c10965e4909f8e0b0bfabae74d27febde Mon Sep 17 00:00:00 2001 From: Ilia Karpov Date: Fri, 5 Dec 2025 15:18:54 +0500 Subject: [PATCH 1/4] feat(evo-quantity): add component --- .../evo-quantity/enums/evo-quantity-size.ts | 5 + .../evo-quantity/enums/input-mode.ts | 9 + .../evo-quantity/evo-quantity.component.html | 67 ++++++ .../evo-quantity/evo-quantity.component.scss | 108 +++++++++ .../evo-quantity.component.spec.ts | 214 +++++++++++++++++ .../evo-quantity/evo-quantity.component.ts | 225 ++++++++++++++++++ .../evo-quantity/evo-quantity.module.ts | 32 +++ .../src/lib/components/evo-quantity/index.ts | 1 + .../lib/components/evo-quantity/public-api.ts | 3 + projects/evo-ui-kit/src/public_api.ts | 1 + 10 files changed, 665 insertions(+) create mode 100644 projects/evo-ui-kit/src/lib/components/evo-quantity/enums/evo-quantity-size.ts create mode 100644 projects/evo-ui-kit/src/lib/components/evo-quantity/enums/input-mode.ts create mode 100644 projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.html create mode 100644 projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.scss create mode 100644 projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.spec.ts create mode 100644 projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.ts create mode 100644 projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.module.ts create mode 100644 projects/evo-ui-kit/src/lib/components/evo-quantity/index.ts create mode 100644 projects/evo-ui-kit/src/lib/components/evo-quantity/public-api.ts diff --git a/projects/evo-ui-kit/src/lib/components/evo-quantity/enums/evo-quantity-size.ts b/projects/evo-ui-kit/src/lib/components/evo-quantity/enums/evo-quantity-size.ts new file mode 100644 index 000000000..e4c72cc24 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/components/evo-quantity/enums/evo-quantity-size.ts @@ -0,0 +1,5 @@ +export enum EvoQuantitySize { + regular = 'regular', + small = 'small', + simple = 'simple', +} diff --git a/projects/evo-ui-kit/src/lib/components/evo-quantity/enums/input-mode.ts b/projects/evo-ui-kit/src/lib/components/evo-quantity/enums/input-mode.ts new file mode 100644 index 000000000..832c9d3e7 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/components/evo-quantity/enums/input-mode.ts @@ -0,0 +1,9 @@ +/** + * Режим ввода для компонента evo-quantity + */ +export enum InputMode { + /** Значение обновляется только при завершении ручного ввода (confirm/cancel) */ + MANUAL = 'MANUAL', + /** Значение обновляется при нажатии на кнопки +/- */ + PER_BUTTONS = 'PER_BUTTONS', +} diff --git a/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.html b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.html new file mode 100644 index 000000000..03e992b0e --- /dev/null +++ b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.html @@ -0,0 +1,67 @@ +
+
+
+ + + + + +
+ +
+ + + +
+
+ +
+ {{ pricePerOne | currency:'RUB':'symbol':'1.0-0':'ru' }}/шт. +
+
diff --git a/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.scss b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.scss new file mode 100644 index 000000000..e50971713 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.scss @@ -0,0 +1,108 @@ +@import '../../styles/mixins'; + +.evo-quantity { + $root: &; + + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + + &__wrap { + display: flex; + justify-content: space-between; + gap: 4px; + + width: 100%; + background: $color-white; + border-radius: 32px; + + // Regular (default) + &_size-regular { + height: 40px; + padding: 0 8px; + border: 1px solid $color-disabled; + } + + // Small + &_size-small { + height: 32px; + padding: 0 4px; + border: 1px solid $color-disabled; + } + + // Simple (как Small, но без border) + &_size-simple { + height: 32px; + padding: 0 4px; + border: none; + } + + &_error#{&}_size-regular, + &_error#{&}_size-small { + border-color: $color-error; + } + } + + &__control-wrap { + flex: 0 0 24px; + width: 24px; + height: 24px; + + align-self: center; + } + + &__control { + width: 24px; + height: 24px; + padding: 0; + + background: none; + border: none; + + &:disabled #{$root}__control-icon { + fill: $color-disabled; + } + } + + &__control-icon { + width: 100%; + height: 100%; + fill: $color-icon-dark; + } + + &__field { + flex: 1 1 auto; + min-width: 0; + + font-weight: 600; + font-size: 16px; + text-align: center; + border: none; + + &_error { + color: $color-error; + } + + &_disabled { + color: $color-disabled; + } + + -moz-appearance:textfield; /* Firefox */ + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + /* display: none; <- Crashes Chrome on hover */ + -webkit-appearance: none; + margin: 0; /* <-- Apparently some margin are still there even though it's hidden */ + } + } + + &__hint { + flex: 0 0 auto; + margin-top: 4px; + color: $color-caption-text; + + font-size: 12px; + line-height: 18px; + } +} diff --git a/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.spec.ts b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.spec.ts new file mode 100644 index 000000000..7e8b9105a --- /dev/null +++ b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.spec.ts @@ -0,0 +1,214 @@ +import {ComponentFixture, fakeAsync, TestBed, tick, waitForAsync} from '@angular/core/testing'; +import {EvoQuantityComponent} from './evo-quantity.component'; +import {FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {InputMode} from './enums/input-mode'; +import {EvoQuantitySize} from './enums/evo-quantity-size'; + +describe('EvoQuantityComponent', () => { + let component: EvoQuantityComponent; + let fixture: ComponentFixture; + let quantityEl: HTMLElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [FormsModule, ReactiveFormsModule], + declarations: [EvoQuantityComponent], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EvoQuantityComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + quantityEl = fixture.nativeElement.querySelector('.evo-quantity'); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set value through writeValue', () => { + component.writeValue(5); + fixture.detectChanges(); + expect(component.control.value).toBe(5); + }); + + it('should increment value on plus button click', fakeAsync(() => { + component.registerOnChange(() => {}); + component.registerOnTouched(() => {}); + component.writeValue(5); + component.step = 1; + fixture.detectChanges(); + + component.onChangeValueByStepClick(1); + tick(); + fixture.detectChanges(); + + expect(component.control.value).toBe(6); + })); + + it('should decrement value on minus button click', fakeAsync(() => { + component.registerOnChange(() => {}); + component.registerOnTouched(() => {}); + component.writeValue(5); + component.step = 1; + fixture.detectChanges(); + + component.onChangeValueByStepClick(-1); + tick(); + fixture.detectChanges(); + + expect(component.control.value).toBe(4); + })); + + it('should not go below min value', fakeAsync(() => { + component.registerOnChange(() => {}); + component.registerOnTouched(() => {}); + component.min = 0; + component.writeValue(0); + fixture.detectChanges(); + + component.onChangeValueByStepClick(-1); + tick(); + fixture.detectChanges(); + + expect(component.control.value).toBe(0); + })); + + it('should not go above max value', fakeAsync(() => { + component.registerOnChange(() => {}); + component.registerOnTouched(() => {}); + component.max = 10; + component.writeValue(10); + fixture.detectChanges(); + + component.onChangeValueByStepClick(1); + tick(); + fixture.detectChanges(); + + expect(component.control.value).toBe(10); + })); + + it('should disable control when setDisabledState is called with true', fakeAsync(() => { + component.setDisabledState(true); + tick(); + fixture.detectChanges(); + + expect(component.control.disabled).toBeTruthy(); + })); + + it('should enable control when setDisabledState is called with false', fakeAsync(() => { + component.setDisabledState(true); + tick(); + fixture.detectChanges(); + + component.setDisabledState(false); + tick(); + fixture.detectChanges(); + + expect(component.control.disabled).toBeFalsy(); + })); + + it('should emit delete event when delete button clicked', () => { + spyOn(component.delete, 'emit'); + component.onDeleteBtnClick(); + expect(component.delete.emit).toHaveBeenCalled(); + }); + + it('should not call onChange when writeValue is called', fakeAsync(() => { + const onChangeSpy = jasmine.createSpy('onChange'); + component.registerOnChange(onChangeSpy); + + component.writeValue(10); + tick(); + fixture.detectChanges(); + + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(component.control.value).toBe(10); + })); + + it('should call onChange on user interaction via step buttons', fakeAsync(() => { + const onChangeSpy = jasmine.createSpy('onChange'); + component.registerOnChange(onChangeSpy); + component.registerOnTouched(() => {}); + component.writeValue(5); + tick(); + fixture.detectChanges(); + + onChangeSpy.calls.reset(); + + component.onChangeValueByStepClick(1); + tick(); + fixture.detectChanges(); + + expect(onChangeSpy).toHaveBeenCalledWith(6); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + })); + + it('should start in PER_BUTTONS mode', () => { + expect(component.mode).toBe(InputMode.PER_BUTTONS); + }); + + it('should emit manualInputStart when focusing input', () => { + spyOn(component.manualInputStart, 'emit'); + component.registerOnTouched(() => {}); + component.onInputFocus(); + expect(component.manualInputStart.emit).toHaveBeenCalled(); + }); + + it('should emit manualInputEnd when finishing manual mode', () => { + spyOn(component.manualInputEnd, 'emit'); + component.registerOnChange(() => {}); + component.registerOnTouched(() => {}); + component.writeValue(5); + + // Start manual mode first + component.onInputFocus(); + fixture.detectChanges(); + + // Finish manual mode + component.onFinishManualModeBtnClick(); + fixture.detectChanges(); + + expect(component.manualInputEnd.emit).toHaveBeenCalled(); + }); + + it('should unsubscribe on destroy', () => { + const destroySpy = spyOn(component['destroy$'], 'next'); + component.ngOnDestroy(); + expect(destroySpy).toHaveBeenCalled(); + }); + + describe('size', () => { + it('should have regular size by default', () => { + expect(component.size).toBe(EvoQuantitySize.regular); + }); + + it('should apply size-regular class by default', () => { + expect(component.wrapClasses['evo-quantity__wrap_size-regular']).toBeTruthy(); + }); + + it('should apply size-small class when size is small', () => { + component.size = EvoQuantitySize.small; + expect(component.wrapClasses['evo-quantity__wrap_size-small']).toBeTruthy(); + }); + + it('should apply size-simple class when size is simple', () => { + component.size = EvoQuantitySize.simple; + expect(component.wrapClasses['evo-quantity__wrap_size-simple']).toBeTruthy(); + }); + + it('should include error class when control is invalid', () => { + component.control.setValidators(() => ({error: true})); + component.control.updateValueAndValidity(); + expect(component.wrapClasses['evo-quantity__wrap_error']).toBeTruthy(); + }); + + it('should not include error class when control is valid', () => { + component.writeValue(5); + expect(component.wrapClasses['evo-quantity__wrap_error']).toBeFalsy(); + }); + }); +}); diff --git a/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.ts b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.ts new file mode 100644 index 000000000..fd416087e --- /dev/null +++ b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.ts @@ -0,0 +1,225 @@ +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + forwardRef, + HostListener, + Inject, + InjectFlags, + Injector, + Input, + OnDestroy, + Output, + ViewChild, +} from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + FormControl, + NG_VALUE_ACCESSOR, + NgControl, + Validators, +} from '@angular/forms'; +import {Subject} from 'rxjs'; +import {takeUntil, tap} from 'rxjs/operators'; +import {InputMode} from './enums/input-mode'; +import {EvoQuantitySize} from './enums/evo-quantity-size'; + +@Component({ + selector: 'evo-quantity', + templateUrl: './evo-quantity.component.html', + styleUrls: ['./evo-quantity.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EvoQuantityComponent), + multi: true, + }, + ], +}) +export class EvoQuantityComponent implements ControlValueAccessor, AfterViewInit, OnDestroy { + @Input() min = 0; + @Input() max = Infinity; + @Input() step = 1; + @Input() pricePerOne: number; + @Input() isInputAllowed = false; + @Input() isDeletable = false; + @Input() size: EvoQuantitySize = EvoQuantitySize.regular; + + @Output() delete = new EventEmitter(); + @Output() manualInputStart = new EventEmitter(); // u may need to disable submit btn while manual mode is active + @Output() manualInputEnd = new EventEmitter(); + + @ViewChild('inputElement', {read: ElementRef}) inputElement: ElementRef; + + mode = InputMode.PER_BUTTONS; + readonly InputMode = InputMode; + + readonly control = new FormControl('', { + validators: [ + // arrow functions cuz this.min and this.max may change in runtime + (control) => Validators.min(this.min)(control), + (control) => Validators.max(this.max)(control), + ], + updateOn: 'change', + }); + private lastConfirmedValue: number; + private readonly destroy$ = new Subject(); + + private ngControl: NgControl; + private readonly cdr: ChangeDetectorRef; + + private onChange: (_: number) => void; + private onTouched: () => void; + + constructor(@Inject(Injector) private readonly injector: Injector) { + this.cdr = injector.get(ChangeDetectorRef); + } + + get parentFormControl(): AbstractControl | undefined { + if (!this.ngControl) { + this.ngControl = this.injector.get(NgControl, null, InjectFlags.Self | InjectFlags.Optional); + } + return this.ngControl?.control; + } + + get wrapClasses(): {[cssClass: string]: boolean} { + return { + 'evo-quantity__wrap_error': this.control.invalid, + [`evo-quantity__wrap_size-${this.size}`]: true, + }; + } + + ngAfterViewInit(): void { + this.parentFormControl?.statusChanges + .pipe( + tap(() => this.cdr.markForCheck()), + takeUntil(this.destroy$), + ) + .subscribe(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + @HostListener('document:keydown.escape', ['$event']) + onEscKeyDown(event: KeyboardEvent): void { + if (this.mode === InputMode.MANUAL) { + event.preventDefault(); + this.cancelManualMode(); + } + } + + @HostListener('document:keydown.enter', ['$event']) + onEnterKeyDown(event: KeyboardEvent): void { + if (this.mode === InputMode.MANUAL) { + event.preventDefault(); + this.finishManualMode(this.control.value); + } + } + + onClickOutside(): void { + if (this.mode === InputMode.MANUAL) { + this.cancelManualMode(); + } + } + + onInputFocus(): void { + this.startManualMode(); + } + + onFinishManualModeBtnClick(): void { + this.finishManualMode(this.control.value); + this.onTouched(); + } + + onChangeValueByStepClick(step: number): void { + const newValue = this.control.value + step; + this.confirmValue(this.getNearestValidValue(newValue)); + + this.onTouched(); + } + + onDeleteBtnClick(): void { + this.delete.emit(); + } + + writeValue(value: any): void { + this.lastConfirmedValue = value; + this.control.setValue(value); + } + + registerOnChange(fn: any): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + if (isDisabled) { + this.control.disable(); + } else { + this.control.enable(); + } + this.cdr.detectChanges(); + } + + private confirmValue(value: number): void { + this.control.setValue(value); + + if (value === this.lastConfirmedValue) { + return; + } + + this.lastConfirmedValue = value; + this.onChange(value); + } + + private startManualMode(): void { + if (this.mode === InputMode.MANUAL) { + return; + } + + this.manualInputStart.emit(); + + this.mode = InputMode.MANUAL; + this.onTouched(); + } + + private finishManualMode(value: number): void { + if (this.mode !== InputMode.MANUAL) { + return; + } + + this.inputElement?.nativeElement.blur(); + this.mode = InputMode.PER_BUTTONS; + + this.confirmValue(this.getNearestValidValue(value)); + + this.manualInputEnd.emit(); + } + + private cancelManualMode(): void { + this.finishManualMode(this.lastConfirmedValue); + } + + private getNearestValidValue(value: number): number { + if (this.min <= value && value <= this.max) { + return value; + } + + if (value < this.min) { + return this.min; + } + + return this.max; + } +} diff --git a/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.module.ts b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.module.ts new file mode 100644 index 000000000..34be23351 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.module.ts @@ -0,0 +1,32 @@ +import {CommonModule, registerLocaleData} from '@angular/common'; +import localeRu from '@angular/common/locales/ru'; +import {NgModule} from '@angular/core'; +import {ReactiveFormsModule} from '@angular/forms'; +import {EvoIconModule} from '../evo-icon'; +import {EvoUiKitModule} from '../../evo-ui-kit.module'; +import {EvoQuantityComponent} from './evo-quantity.component'; +import {iconMinus, iconDelete, iconPlus, iconCheckRounded} from '@evotor-dev/ui-kit/icons/system'; + +registerLocaleData(localeRu, 'ru'); + +@NgModule({ + declarations: [EvoQuantityComponent], + exports: [EvoQuantityComponent], + imports: [ + CommonModule, + ReactiveFormsModule, + EvoIconModule.forRoot([ + { + name: 'system', + shapes: { + minus: iconMinus, + plus: iconPlus, + delete: iconDelete, + 'check-rounded': iconCheckRounded, + }, + }, + ]), + EvoUiKitModule, + ], +}) +export class EvoQuantityModule {} diff --git a/projects/evo-ui-kit/src/lib/components/evo-quantity/index.ts b/projects/evo-ui-kit/src/lib/components/evo-quantity/index.ts new file mode 100644 index 000000000..7e1a213e3 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/components/evo-quantity/index.ts @@ -0,0 +1 @@ +export * from './public-api'; diff --git a/projects/evo-ui-kit/src/lib/components/evo-quantity/public-api.ts b/projects/evo-ui-kit/src/lib/components/evo-quantity/public-api.ts new file mode 100644 index 000000000..5bd8cc50c --- /dev/null +++ b/projects/evo-ui-kit/src/lib/components/evo-quantity/public-api.ts @@ -0,0 +1,3 @@ +export * from './evo-quantity.module'; +export * from './evo-quantity.component'; +export * from './enums/evo-quantity-size'; diff --git a/projects/evo-ui-kit/src/public_api.ts b/projects/evo-ui-kit/src/public_api.ts index 29a4f91b6..4cc72c5b1 100644 --- a/projects/evo-ui-kit/src/public_api.ts +++ b/projects/evo-ui-kit/src/public_api.ts @@ -71,6 +71,7 @@ export * from './lib/components/evo-navigation-button/index'; export * from './lib/components/evo-tooltip/index'; export * from './lib/components/evo-navigation-tabs/index'; export * from './lib/components/evo-link-button/index'; +export * from './lib/components/evo-quantity/index'; export * from './lib/pipes/index'; From f7360cc47de30776c65e1d8859515351abb21692 Mon Sep 17 00:00:00 2001 From: Ilia Karpov Date: Tue, 16 Dec 2025 14:48:23 +0500 Subject: [PATCH 2/4] feat(evo-quantity): add component --- .../evo-quantity/enums/evo-quantity-size.ts | 5 - .../evo-quantity/evo-quantity.component.scss | 18 +- .../evo-quantity.component.spec.ts | 394 +++++++++++++++++- .../evo-quantity/evo-quantity.component.ts | 7 +- .../lib/components/evo-quantity/public-api.ts | 3 +- 5 files changed, 391 insertions(+), 36 deletions(-) delete mode 100644 projects/evo-ui-kit/src/lib/components/evo-quantity/enums/evo-quantity-size.ts diff --git a/projects/evo-ui-kit/src/lib/components/evo-quantity/enums/evo-quantity-size.ts b/projects/evo-ui-kit/src/lib/components/evo-quantity/enums/evo-quantity-size.ts deleted file mode 100644 index e4c72cc24..000000000 --- a/projects/evo-ui-kit/src/lib/components/evo-quantity/enums/evo-quantity-size.ts +++ /dev/null @@ -1,5 +0,0 @@ -export enum EvoQuantitySize { - regular = 'regular', - small = 'small', - simple = 'simple', -} diff --git a/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.scss b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.scss index e50971713..7e32ab03d 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.scss +++ b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.scss @@ -17,29 +17,27 @@ background: $color-white; border-radius: 32px; - // Regular (default) - &_size-regular { + // Sizes + &_size-normal { height: 40px; padding: 0 8px; - border: 1px solid $color-disabled; } - // Small &_size-small { height: 32px; padding: 0 4px; + } + + // Themes + &_theme-default { border: 1px solid $color-disabled; } - // Simple (как Small, но без border) - &_size-simple { - height: 32px; - padding: 0 4px; + &_theme-borderless { border: none; } - &_error#{&}_size-regular, - &_error#{&}_size-small { + &_error#{&}_theme-default { border-color: $color-error; } } diff --git a/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.spec.ts b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.spec.ts index 7e8b9105a..8062c03a4 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.spec.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.spec.ts @@ -1,20 +1,27 @@ import {ComponentFixture, fakeAsync, TestBed, tick, waitForAsync} from '@angular/core/testing'; import {EvoQuantityComponent} from './evo-quantity.component'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; -import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {LOCALE_ID, NO_ERRORS_SCHEMA} from '@angular/core'; import {InputMode} from './enums/input-mode'; -import {EvoQuantitySize} from './enums/evo-quantity-size'; +import {By} from '@angular/platform-browser'; +import {registerLocaleData} from '@angular/common'; +import localeRu from '@angular/common/locales/ru'; + +registerLocaleData(localeRu); describe('EvoQuantityComponent', () => { + const CONTROL_BTN_SELECTOR = '.evo-quantity__control'; + const INPUT_SELECTOR = '.evo-quantity__field'; + let component: EvoQuantityComponent; let fixture: ComponentFixture; - let quantityEl: HTMLElement; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [FormsModule, ReactiveFormsModule], declarations: [EvoQuantityComponent], schemas: [NO_ERRORS_SCHEMA], + providers: [{provide: LOCALE_ID, useValue: 'ru'}], }).compileComponents(); })); @@ -22,7 +29,6 @@ describe('EvoQuantityComponent', () => { fixture = TestBed.createComponent(EvoQuantityComponent); component = fixture.componentInstance; fixture.detectChanges(); - quantityEl = fixture.nativeElement.querySelector('.evo-quantity'); }); it('should create', () => { @@ -42,7 +48,8 @@ describe('EvoQuantityComponent', () => { component.step = 1; fixture.detectChanges(); - component.onChangeValueByStepClick(1); + const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); + buttons[1].triggerEventHandler('click', null); tick(); fixture.detectChanges(); @@ -56,7 +63,8 @@ describe('EvoQuantityComponent', () => { component.step = 1; fixture.detectChanges(); - component.onChangeValueByStepClick(-1); + const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); + buttons[0].triggerEventHandler('click', null); tick(); fixture.detectChanges(); @@ -70,7 +78,8 @@ describe('EvoQuantityComponent', () => { component.writeValue(0); fixture.detectChanges(); - component.onChangeValueByStepClick(-1); + const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); + buttons[0].triggerEventHandler('click', null); tick(); fixture.detectChanges(); @@ -84,7 +93,8 @@ describe('EvoQuantityComponent', () => { component.writeValue(10); fixture.detectChanges(); - component.onChangeValueByStepClick(1); + const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); + buttons[1].triggerEventHandler('click', null); tick(); fixture.detectChanges(); @@ -139,7 +149,8 @@ describe('EvoQuantityComponent', () => { onChangeSpy.calls.reset(); - component.onChangeValueByStepClick(1); + const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); + buttons[1].triggerEventHandler('click', null); tick(); fixture.detectChanges(); @@ -154,7 +165,13 @@ describe('EvoQuantityComponent', () => { it('should emit manualInputStart when focusing input', () => { spyOn(component.manualInputStart, 'emit'); component.registerOnTouched(() => {}); - component.onInputFocus(); + component.isInputAllowed = true; + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css(INPUT_SELECTOR)); + input.nativeElement.dispatchEvent(new Event('focus')); + fixture.detectChanges(); + expect(component.manualInputStart.emit).toHaveBeenCalled(); }); @@ -182,24 +199,36 @@ describe('EvoQuantityComponent', () => { }); describe('size', () => { - it('should have regular size by default', () => { - expect(component.size).toBe(EvoQuantitySize.regular); + it('should have normal size by default', () => { + expect(component.size).toBe('normal'); }); - it('should apply size-regular class by default', () => { - expect(component.wrapClasses['evo-quantity__wrap_size-regular']).toBeTruthy(); + it('should apply size-normal class by default', () => { + expect(component.wrapClasses['evo-quantity__wrap_size-normal']).toBeTruthy(); }); it('should apply size-small class when size is small', () => { - component.size = EvoQuantitySize.small; + component.size = 'small'; expect(component.wrapClasses['evo-quantity__wrap_size-small']).toBeTruthy(); }); + }); + + describe('theme', () => { + it('should have default theme by default', () => { + expect(component.theme).toBe('default'); + }); + + it('should apply theme-default class by default', () => { + expect(component.wrapClasses['evo-quantity__wrap_theme-default']).toBeTruthy(); + }); - it('should apply size-simple class when size is simple', () => { - component.size = EvoQuantitySize.simple; - expect(component.wrapClasses['evo-quantity__wrap_size-simple']).toBeTruthy(); + it('should apply theme-borderless class when theme is borderless', () => { + component.theme = 'borderless'; + expect(component.wrapClasses['evo-quantity__wrap_theme-borderless']).toBeTruthy(); }); + }); + describe('error state', () => { it('should include error class when control is invalid', () => { component.control.setValidators(() => ({error: true})); component.control.updateValueAndValidity(); @@ -211,4 +240,333 @@ describe('EvoQuantityComponent', () => { expect(component.wrapClasses['evo-quantity__wrap_error']).toBeFalsy(); }); }); + + describe('keyboard events', () => { + it('should cancel manual mode on Escape key', fakeAsync(() => { + component.registerOnChange(() => {}); + component.registerOnTouched(() => {}); + component.writeValue(5); + component.isInputAllowed = true; + fixture.detectChanges(); + + component.onInputFocus(); + fixture.detectChanges(); + expect(component.mode).toBe(InputMode.MANUAL); + + component.control.setValue(10); + fixture.detectChanges(); + + const event = new KeyboardEvent('keydown', {key: 'Escape'}); + document.dispatchEvent(event); + tick(); + fixture.detectChanges(); + + expect(component.mode).toBe(InputMode.PER_BUTTONS); + expect(component.control.value).toBe(5); + })); + + it('should confirm manual mode on Enter key', fakeAsync(() => { + component.registerOnChange(() => {}); + component.registerOnTouched(() => {}); + component.writeValue(5); + component.isInputAllowed = true; + fixture.detectChanges(); + + component.onInputFocus(); + fixture.detectChanges(); + + component.control.setValue(10); + fixture.detectChanges(); + + const event = new KeyboardEvent('keydown', {key: 'Enter'}); + document.dispatchEvent(event); + tick(); + fixture.detectChanges(); + + expect(component.mode).toBe(InputMode.PER_BUTTONS); + expect(component.control.value).toBe(10); + })); + + it('should not react to Escape when not in manual mode', () => { + component.writeValue(5); + fixture.detectChanges(); + + const event = new KeyboardEvent('keydown', {key: 'Escape'}); + document.dispatchEvent(event); + fixture.detectChanges(); + + expect(component.mode).toBe(InputMode.PER_BUTTONS); + expect(component.control.value).toBe(5); + }); + }); + + describe('isDeletable', () => { + const DELETE_BTN_SELECTOR = '.evo-quantity__control evo-icon[shape="delete"]'; + const MINUS_BTN_SELECTOR = '.evo-quantity__control evo-icon[shape="minus"]'; + + it('should show delete button when isDeletable is true and value equals min', fakeAsync(() => { + component.isDeletable = true; + component.min = 1; + component.writeValue(1); + tick(); + component['cdr'].markForCheck(); + fixture.detectChanges(); + + const deleteBtn = fixture.debugElement.query(By.css(DELETE_BTN_SELECTOR)); + const minusBtn = fixture.debugElement.query(By.css(MINUS_BTN_SELECTOR)); + + expect(deleteBtn).toBeTruthy(); + expect(minusBtn).toBeFalsy(); + })); + + it('should show minus button when isDeletable is true but value is above min', fakeAsync(() => { + component.isDeletable = true; + component.min = 1; + component.writeValue(2); + tick(); + component['cdr'].markForCheck(); + fixture.detectChanges(); + + const deleteBtn = fixture.debugElement.query(By.css(DELETE_BTN_SELECTOR)); + const minusBtn = fixture.debugElement.query(By.css(MINUS_BTN_SELECTOR)); + + expect(deleteBtn).toBeFalsy(); + expect(minusBtn).toBeTruthy(); + })); + + it('should show minus button when isDeletable is false and value equals min', fakeAsync(() => { + component.isDeletable = false; + component.min = 1; + component.writeValue(1); + tick(); + component['cdr'].markForCheck(); + fixture.detectChanges(); + + const deleteBtn = fixture.debugElement.query(By.css(DELETE_BTN_SELECTOR)); + const minusBtn = fixture.debugElement.query(By.css(MINUS_BTN_SELECTOR)); + + expect(deleteBtn).toBeFalsy(); + expect(minusBtn).toBeTruthy(); + })); + + it('should emit delete event when delete button is clicked in DOM', fakeAsync(() => { + spyOn(component.delete, 'emit'); + component.isDeletable = true; + component.min = 1; + component.writeValue(1); + tick(); + component['cdr'].markForCheck(); + fixture.detectChanges(); + + const deleteBtn = fixture.debugElement.query(By.css(DELETE_BTN_SELECTOR)).parent; + deleteBtn.triggerEventHandler('click', null); + + expect(component.delete.emit).toHaveBeenCalled(); + })); + }); + + describe('disabled state in DOM', () => { + it('should disable minus button when value equals min', () => { + component.min = 0; + component.writeValue(0); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); + const minusBtn = buttons[0].nativeElement; + + expect(minusBtn.disabled).toBeTruthy(); + }); + + it('should disable plus button when value equals max', fakeAsync(() => { + component.max = 10; + component.writeValue(10); + tick(); + component['cdr'].markForCheck(); + fixture.detectChanges(); + + const plusBtnIcon = fixture.debugElement.query(By.css('evo-icon[shape="plus"]')); + const plusBtn = plusBtnIcon.parent.nativeElement; + + expect(plusBtn.disabled).toBeTruthy(); + })); + + it('should disable all buttons when control is disabled', fakeAsync(() => { + component.writeValue(5); + component.setDisabledState(true); + tick(); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); + buttons.forEach((btn) => { + expect(btn.nativeElement.disabled).toBeTruthy(); + }); + })); + + it('should have disabled class on input when control is disabled', fakeAsync(() => { + component.setDisabledState(true); + tick(); + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css(INPUT_SELECTOR)); + expect(input.nativeElement.classList).toContain('evo-quantity__field_disabled'); + })); + }); + + describe('isInputAllowed', () => { + it('should have readonly input when isInputAllowed is false', () => { + component.isInputAllowed = false; + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css(INPUT_SELECTOR)); + expect(input.nativeElement.readOnly).toBeTruthy(); + }); + + it('should not have readonly input when isInputAllowed is true', fakeAsync(() => { + component.isInputAllowed = true; + tick(); + component['cdr'].markForCheck(); + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css(INPUT_SELECTOR)); + expect(input.nativeElement.readOnly).toBeFalsy(); + })); + + it('should have readonly input when disabled even if isInputAllowed is true', fakeAsync(() => { + component.isInputAllowed = true; + component.setDisabledState(true); + tick(); + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css(INPUT_SELECTOR)); + expect(input.nativeElement.readOnly).toBeTruthy(); + })); + }); + + describe('pricePerOne hint', () => { + const HINT_SELECTOR = '.evo-quantity__hint'; + + it('should display hint when pricePerOne is set', fakeAsync(() => { + component.pricePerOne = 100; + tick(); + component['cdr'].markForCheck(); + fixture.detectChanges(); + + const hint = fixture.debugElement.query(By.css(HINT_SELECTOR)); + expect(hint).toBeTruthy(); + })); + + it('should not display hint when pricePerOne is not set', () => { + component.pricePerOne = undefined; + fixture.detectChanges(); + + const hint = fixture.debugElement.query(By.css(HINT_SELECTOR)); + expect(hint).toBeFalsy(); + }); + + it('should not display hint when control is disabled', fakeAsync(() => { + component.pricePerOne = 100; + component.setDisabledState(true); + tick(); + fixture.detectChanges(); + + const hint = fixture.debugElement.query(By.css(HINT_SELECTOR)); + expect(hint).toBeFalsy(); + })); + }); + + describe('MANUAL mode', () => { + const CHECK_BTN_SELECTOR = '.evo-quantity__control evo-icon[shape="check-rounded"]'; + const PLUS_BTN_SELECTOR = '.evo-quantity__control evo-icon[shape="plus"]'; + + it('should show check button instead of plus when in manual mode', () => { + component.registerOnTouched(() => {}); + component.isInputAllowed = true; + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css(INPUT_SELECTOR)); + input.nativeElement.dispatchEvent(new Event('focus')); + fixture.detectChanges(); + + const checkBtn = fixture.debugElement.query(By.css(CHECK_BTN_SELECTOR)); + const plusBtn = fixture.debugElement.query(By.css(PLUS_BTN_SELECTOR)); + + expect(checkBtn).toBeTruthy(); + expect(plusBtn).toBeFalsy(); + }); + + it('should hide minus button in manual mode', () => { + component.registerOnTouched(() => {}); + component.isInputAllowed = true; + component.writeValue(5); + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css(INPUT_SELECTOR)); + input.nativeElement.dispatchEvent(new Event('focus')); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); + expect(buttons.length).toBe(1); + }); + + it('should confirm value when check button is clicked', fakeAsync(() => { + const onChangeSpy = jasmine.createSpy('onChange'); + component.registerOnChange(onChangeSpy); + component.registerOnTouched(() => {}); + component.isInputAllowed = true; + component.writeValue(5); + tick(); + fixture.detectChanges(); + + onChangeSpy.calls.reset(); + + component.onInputFocus(); + component['cdr'].markForCheck(); + fixture.detectChanges(); + + component.control.setValue(10); + fixture.detectChanges(); + + const checkBtn = fixture.debugElement.query(By.css(CHECK_BTN_SELECTOR)).parent; + checkBtn.triggerEventHandler('click', null); + tick(); + fixture.detectChanges(); + + expect(component.mode).toBe(InputMode.PER_BUTTONS); + expect(onChangeSpy).toHaveBeenCalledWith(10); + })); + }); + + describe('onClickOutside', () => { + it('should cancel manual mode when clicking outside', fakeAsync(() => { + component.registerOnChange(() => {}); + component.registerOnTouched(() => {}); + component.isInputAllowed = true; + component.writeValue(5); + fixture.detectChanges(); + + component.onInputFocus(); + fixture.detectChanges(); + + component.control.setValue(10); + fixture.detectChanges(); + + component.onClickOutside(); + tick(); + fixture.detectChanges(); + + expect(component.mode).toBe(InputMode.PER_BUTTONS); + expect(component.control.value).toBe(5); + })); + + it('should not affect PER_BUTTONS mode when clicking outside', () => { + component.writeValue(5); + fixture.detectChanges(); + + component.onClickOutside(); + fixture.detectChanges(); + + expect(component.mode).toBe(InputMode.PER_BUTTONS); + expect(component.control.value).toBe(5); + }); + }); }); diff --git a/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.ts b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.ts index fd416087e..0fbd13fc1 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.ts @@ -26,7 +26,8 @@ import { import {Subject} from 'rxjs'; import {takeUntil, tap} from 'rxjs/operators'; import {InputMode} from './enums/input-mode'; -import {EvoQuantitySize} from './enums/evo-quantity-size'; +import {EvoQuantitySize} from './types/evo-quantity-size'; +import {EvoQuantityTheme} from './types/evo-quantity-theme'; @Component({ selector: 'evo-quantity', @@ -48,7 +49,8 @@ export class EvoQuantityComponent implements ControlValueAccessor, AfterViewInit @Input() pricePerOne: number; @Input() isInputAllowed = false; @Input() isDeletable = false; - @Input() size: EvoQuantitySize = EvoQuantitySize.regular; + @Input() size: EvoQuantitySize = 'normal'; + @Input() theme: EvoQuantityTheme = 'default'; @Output() delete = new EventEmitter(); @Output() manualInputStart = new EventEmitter(); // u may need to disable submit btn while manual mode is active @@ -91,6 +93,7 @@ export class EvoQuantityComponent implements ControlValueAccessor, AfterViewInit return { 'evo-quantity__wrap_error': this.control.invalid, [`evo-quantity__wrap_size-${this.size}`]: true, + [`evo-quantity__wrap_theme-${this.theme}`]: true, }; } diff --git a/projects/evo-ui-kit/src/lib/components/evo-quantity/public-api.ts b/projects/evo-ui-kit/src/lib/components/evo-quantity/public-api.ts index 5bd8cc50c..e4e4ac880 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-quantity/public-api.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-quantity/public-api.ts @@ -1,3 +1,4 @@ export * from './evo-quantity.module'; export * from './evo-quantity.component'; -export * from './enums/evo-quantity-size'; +export * from './types/evo-quantity-size'; +export * from './types/evo-quantity-theme'; From 9f412916ab38a21912213c3c9e7045be1abb5e83 Mon Sep 17 00:00:00 2001 From: Ilia Karpov Date: Tue, 16 Dec 2025 16:10:42 +0500 Subject: [PATCH 3/4] feat(evo-quantity): add component --- .../evo-quantity.component.spec.ts | 82 +++++++++++++++++++ .../evo-quantity/evo-quantity.component.ts | 42 +++++++++- 2 files changed, 120 insertions(+), 4 deletions(-) diff --git a/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.spec.ts b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.spec.ts index 8062c03a4..cd6200b42 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.spec.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.spec.ts @@ -569,4 +569,86 @@ describe('EvoQuantityComponent', () => { expect(component.control.value).toBe(5); }); }); + + describe('string coercion for numeric inputs', () => { + it('should coerce string min to number', () => { + (component as any).min = '5'; + expect(component.min).toBe(5); + expect(typeof component.min).toBe('number'); + }); + + it('should coerce string max to number', () => { + (component as any).max = '100'; + expect(component.max).toBe(100); + expect(typeof component.max).toBe('number'); + }); + + it('should coerce string step to number', () => { + (component as any).step = '2'; + expect(component.step).toBe(2); + expect(typeof component.step).toBe('number'); + }); + + it('should coerce string pricePerOne to number', () => { + (component as any).pricePerOne = '150'; + expect(component.pricePerOne).toBe(150); + expect(typeof component.pricePerOne).toBe('number'); + }); + + it('should use default value for invalid min', () => { + (component as any).min = 'invalid'; + expect(component.min).toBe(0); + }); + + it('should use default value for invalid max', () => { + (component as any).max = 'invalid'; + expect(component.max).toBe(Infinity); + }); + + it('should use default value for invalid step', () => { + (component as any).step = 'invalid'; + expect(component.step).toBe(1); + }); + + it('should correctly clamp value with string min/max after manual input', fakeAsync(() => { + component.registerOnChange(() => {}); + component.registerOnTouched(() => {}); + (component as any).min = '1'; + (component as any).max = '10'; + component.writeValue(5); + tick(); + fixture.detectChanges(); + + component.onInputFocus(); + fixture.detectChanges(); + + component.control.setValue(15); + fixture.detectChanges(); + + component.onFinishManualModeBtnClick(); + tick(); + fixture.detectChanges(); + + expect(component.control.value).toBe(10); + expect(typeof component.control.value).toBe('number'); + })); + + it('should correctly increment with string step', fakeAsync(() => { + component.registerOnChange(() => {}); + component.registerOnTouched(() => {}); + (component as any).step = '5'; + component.writeValue(10); + tick(); + component['cdr'].markForCheck(); + fixture.detectChanges(); + + const plusBtnIcon = fixture.debugElement.query(By.css('evo-icon[shape="plus"]')); + const plusBtn = plusBtnIcon.parent; + plusBtn.triggerEventHandler('click', null); + tick(); + fixture.detectChanges(); + + expect(component.control.value).toBe(15); + })); + }); }); diff --git a/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.ts b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.ts index 0fbd13fc1..ce61b3a12 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.ts @@ -23,6 +23,7 @@ import { NgControl, Validators, } from '@angular/forms'; +import {coerceNumberProperty, NumberInput} from '@angular/cdk/coercion'; import {Subject} from 'rxjs'; import {takeUntil, tap} from 'rxjs/operators'; import {InputMode} from './enums/input-mode'; @@ -43,10 +44,38 @@ import {EvoQuantityTheme} from './types/evo-quantity-theme'; ], }) export class EvoQuantityComponent implements ControlValueAccessor, AfterViewInit, OnDestroy { - @Input() min = 0; - @Input() max = Infinity; - @Input() step = 1; - @Input() pricePerOne: number; + @Input() + get min(): number { + return this._min; + } + set min(value: NumberInput) { + this._min = coerceNumberProperty(value, 0); + } + + @Input() + get max(): number { + return this._max; + } + set max(value: NumberInput) { + this._max = coerceNumberProperty(value, Infinity); + } + + @Input() + get step(): number { + return this._step; + } + set step(value: NumberInput) { + this._step = coerceNumberProperty(value, 1); + } + + @Input() + get pricePerOne(): number { + return this._pricePerOne; + } + set pricePerOne(value: NumberInput) { + this._pricePerOne = coerceNumberProperty(value); + } + @Input() isInputAllowed = false; @Input() isDeletable = false; @Input() size: EvoQuantitySize = 'normal'; @@ -78,6 +107,11 @@ export class EvoQuantityComponent implements ControlValueAccessor, AfterViewInit private onChange: (_: number) => void; private onTouched: () => void; + private _max = Infinity; + private _min = 0; + private _step = 1; + private _pricePerOne: number; + constructor(@Inject(Injector) private readonly injector: Injector) { this.cdr = injector.get(ChangeDetectorRef); } From c88ca769868327f2c0aac311c3de50a2993901c5 Mon Sep 17 00:00:00 2001 From: Ilia Karpov Date: Wed, 17 Dec 2025 13:53:45 +0500 Subject: [PATCH 4/4] feat(evo-quantity): add component --- .../evo-quantity.component.spec.ts | 448 +++++++++--------- .../evo-quantity/types/evo-quantity-size.ts | 3 + .../evo-quantity/types/evo-quantity-theme.ts | 1 + 3 files changed, 222 insertions(+), 230 deletions(-) create mode 100644 projects/evo-ui-kit/src/lib/components/evo-quantity/types/evo-quantity-size.ts create mode 100644 projects/evo-ui-kit/src/lib/components/evo-quantity/types/evo-quantity-theme.ts diff --git a/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.spec.ts b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.spec.ts index cd6200b42..9014dcfc5 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.spec.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.spec.ts @@ -35,167 +35,113 @@ describe('EvoQuantityComponent', () => { expect(component).toBeTruthy(); }); - it('should set value through writeValue', () => { - component.writeValue(5); - fixture.detectChanges(); - expect(component.control.value).toBe(5); + it('should unsubscribe on destroy', () => { + const destroySpy = spyOn(component['destroy$'], 'next'); + component.ngOnDestroy(); + expect(destroySpy).toHaveBeenCalled(); }); - it('should increment value on plus button click', fakeAsync(() => { - component.registerOnChange(() => {}); - component.registerOnTouched(() => {}); - component.writeValue(5); - component.step = 1; - fixture.detectChanges(); - - const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); - buttons[1].triggerEventHandler('click', null); - tick(); - fixture.detectChanges(); - - expect(component.control.value).toBe(6); - })); - - it('should decrement value on minus button click', fakeAsync(() => { - component.registerOnChange(() => {}); - component.registerOnTouched(() => {}); - component.writeValue(5); - component.step = 1; - fixture.detectChanges(); - - const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); - buttons[0].triggerEventHandler('click', null); - tick(); - fixture.detectChanges(); - - expect(component.control.value).toBe(4); - })); - - it('should not go below min value', fakeAsync(() => { - component.registerOnChange(() => {}); - component.registerOnTouched(() => {}); - component.min = 0; - component.writeValue(0); - fixture.detectChanges(); - - const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); - buttons[0].triggerEventHandler('click', null); - tick(); - fixture.detectChanges(); - - expect(component.control.value).toBe(0); - })); - - it('should not go above max value', fakeAsync(() => { - component.registerOnChange(() => {}); - component.registerOnTouched(() => {}); - component.max = 10; - component.writeValue(10); - fixture.detectChanges(); - - const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); - buttons[1].triggerEventHandler('click', null); - tick(); - fixture.detectChanges(); + describe('ControlValueAccessor', () => { + it('should set value through writeValue', () => { + component.writeValue(5); + fixture.detectChanges(); + expect(component.control.value).toBe(5); + }); - expect(component.control.value).toBe(10); - })); + it('should not call onChange when writeValue is called', fakeAsync(() => { + const onChangeSpy = jasmine.createSpy('onChange'); + component.registerOnChange(onChangeSpy); - it('should disable control when setDisabledState is called with true', fakeAsync(() => { - component.setDisabledState(true); - tick(); - fixture.detectChanges(); + component.writeValue(10); + tick(); + fixture.detectChanges(); - expect(component.control.disabled).toBeTruthy(); - })); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(component.control.value).toBe(10); + })); - it('should enable control when setDisabledState is called with false', fakeAsync(() => { - component.setDisabledState(true); - tick(); - fixture.detectChanges(); + it('should call onChange on user interaction via step buttons', fakeAsync(() => { + const onChangeSpy = jasmine.createSpy('onChange'); + component.registerOnChange(onChangeSpy); + component.registerOnTouched(() => {}); + component.writeValue(5); + tick(); + fixture.detectChanges(); - component.setDisabledState(false); - tick(); - fixture.detectChanges(); + onChangeSpy.calls.reset(); - expect(component.control.disabled).toBeFalsy(); - })); + const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); + buttons[1].triggerEventHandler('click', null); + tick(); + fixture.detectChanges(); - it('should emit delete event when delete button clicked', () => { - spyOn(component.delete, 'emit'); - component.onDeleteBtnClick(); - expect(component.delete.emit).toHaveBeenCalled(); + expect(onChangeSpy).toHaveBeenCalledWith(6); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + })); }); - it('should not call onChange when writeValue is called', fakeAsync(() => { - const onChangeSpy = jasmine.createSpy('onChange'); - component.registerOnChange(onChangeSpy); - - component.writeValue(10); - tick(); - fixture.detectChanges(); - - expect(onChangeSpy).not.toHaveBeenCalled(); - expect(component.control.value).toBe(10); - })); + describe('step buttons', () => { + it('should increment value on plus button click', fakeAsync(() => { + component.registerOnChange(() => {}); + component.registerOnTouched(() => {}); + component.writeValue(5); + component.step = 1; + fixture.detectChanges(); - it('should call onChange on user interaction via step buttons', fakeAsync(() => { - const onChangeSpy = jasmine.createSpy('onChange'); - component.registerOnChange(onChangeSpy); - component.registerOnTouched(() => {}); - component.writeValue(5); - tick(); - fixture.detectChanges(); + const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); + buttons[1].triggerEventHandler('click', null); + tick(); + fixture.detectChanges(); - onChangeSpy.calls.reset(); + expect(component.control.value).toBe(6); + })); - const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); - buttons[1].triggerEventHandler('click', null); - tick(); - fixture.detectChanges(); + it('should decrement value on minus button click', fakeAsync(() => { + component.registerOnChange(() => {}); + component.registerOnTouched(() => {}); + component.writeValue(5); + component.step = 1; + fixture.detectChanges(); - expect(onChangeSpy).toHaveBeenCalledWith(6); - expect(onChangeSpy).toHaveBeenCalledTimes(1); - })); + const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); + buttons[0].triggerEventHandler('click', null); + tick(); + fixture.detectChanges(); - it('should start in PER_BUTTONS mode', () => { - expect(component.mode).toBe(InputMode.PER_BUTTONS); + expect(component.control.value).toBe(4); + })); }); - it('should emit manualInputStart when focusing input', () => { - spyOn(component.manualInputStart, 'emit'); - component.registerOnTouched(() => {}); - component.isInputAllowed = true; - fixture.detectChanges(); - - const input = fixture.debugElement.query(By.css(INPUT_SELECTOR)); - input.nativeElement.dispatchEvent(new Event('focus')); - fixture.detectChanges(); - - expect(component.manualInputStart.emit).toHaveBeenCalled(); - }); + describe('min/max constraints', () => { + it('should not go below min value', fakeAsync(() => { + component.registerOnChange(() => {}); + component.registerOnTouched(() => {}); + component.min = 0; + component.writeValue(0); + fixture.detectChanges(); - it('should emit manualInputEnd when finishing manual mode', () => { - spyOn(component.manualInputEnd, 'emit'); - component.registerOnChange(() => {}); - component.registerOnTouched(() => {}); - component.writeValue(5); + const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); + buttons[0].triggerEventHandler('click', null); + tick(); + fixture.detectChanges(); - // Start manual mode first - component.onInputFocus(); - fixture.detectChanges(); + expect(component.control.value).toBe(0); + })); - // Finish manual mode - component.onFinishManualModeBtnClick(); - fixture.detectChanges(); + it('should not go above max value', fakeAsync(() => { + component.registerOnChange(() => {}); + component.registerOnTouched(() => {}); + component.max = 10; + component.writeValue(10); + fixture.detectChanges(); - expect(component.manualInputEnd.emit).toHaveBeenCalled(); - }); + const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); + buttons[1].triggerEventHandler('click', null); + tick(); + fixture.detectChanges(); - it('should unsubscribe on destroy', () => { - const destroySpy = spyOn(component['destroy$'], 'next'); - component.ngOnDestroy(); - expect(destroySpy).toHaveBeenCalled(); + expect(component.control.value).toBe(10); + })); }); describe('size', () => { @@ -241,63 +187,71 @@ describe('EvoQuantityComponent', () => { }); }); - describe('keyboard events', () => { - it('should cancel manual mode on Escape key', fakeAsync(() => { - component.registerOnChange(() => {}); - component.registerOnTouched(() => {}); - component.writeValue(5); - component.isInputAllowed = true; + describe('disabled state', () => { + it('should disable control when setDisabledState is called with true', fakeAsync(() => { + component.setDisabledState(true); + tick(); fixture.detectChanges(); - component.onInputFocus(); - fixture.detectChanges(); - expect(component.mode).toBe(InputMode.MANUAL); + expect(component.control.disabled).toBeTruthy(); + })); - component.control.setValue(10); + it('should enable control when setDisabledState is called with false', fakeAsync(() => { + component.setDisabledState(true); + tick(); fixture.detectChanges(); - const event = new KeyboardEvent('keydown', {key: 'Escape'}); - document.dispatchEvent(event); + component.setDisabledState(false); tick(); fixture.detectChanges(); - expect(component.mode).toBe(InputMode.PER_BUTTONS); - expect(component.control.value).toBe(5); + expect(component.control.disabled).toBeFalsy(); })); - it('should confirm manual mode on Enter key', fakeAsync(() => { - component.registerOnChange(() => {}); - component.registerOnTouched(() => {}); - component.writeValue(5); - component.isInputAllowed = true; + it('should disable minus button when value equals min', () => { + component.min = 0; + component.writeValue(0); fixture.detectChanges(); - component.onInputFocus(); - fixture.detectChanges(); + const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); + const minusBtn = buttons[0].nativeElement; - component.control.setValue(10); - fixture.detectChanges(); + expect(minusBtn.disabled).toBeTruthy(); + }); - const event = new KeyboardEvent('keydown', {key: 'Enter'}); - document.dispatchEvent(event); + it('should disable plus button when value equals max', fakeAsync(() => { + component.max = 10; + component.writeValue(10); tick(); + component['cdr'].markForCheck(); fixture.detectChanges(); - expect(component.mode).toBe(InputMode.PER_BUTTONS); - expect(component.control.value).toBe(10); + const plusBtnIcon = fixture.debugElement.query(By.css('evo-icon[shape="plus"]')); + const plusBtn = plusBtnIcon.parent.nativeElement; + + expect(plusBtn.disabled).toBeTruthy(); })); - it('should not react to Escape when not in manual mode', () => { + it('should disable all buttons when control is disabled', fakeAsync(() => { component.writeValue(5); + component.setDisabledState(true); + tick(); fixture.detectChanges(); - const event = new KeyboardEvent('keydown', {key: 'Escape'}); - document.dispatchEvent(event); + const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); + buttons.forEach((btn) => { + expect(btn.nativeElement.disabled).toBeTruthy(); + }); + })); + + it('should have disabled class on input when control is disabled', fakeAsync(() => { + component.setDisabledState(true); + tick(); fixture.detectChanges(); - expect(component.mode).toBe(InputMode.PER_BUTTONS); - expect(component.control.value).toBe(5); - }); + const input = fixture.debugElement.query(By.css(INPUT_SELECTOR)); + expect(input.nativeElement.classList).toContain('evo-quantity__field_disabled'); + })); }); describe('isDeletable', () => { @@ -349,7 +303,7 @@ describe('EvoQuantityComponent', () => { expect(minusBtn).toBeTruthy(); })); - it('should emit delete event when delete button is clicked in DOM', fakeAsync(() => { + it('should emit delete event when delete button is clicked', fakeAsync(() => { spyOn(component.delete, 'emit'); component.isDeletable = true; component.min = 1; @@ -365,53 +319,6 @@ describe('EvoQuantityComponent', () => { })); }); - describe('disabled state in DOM', () => { - it('should disable minus button when value equals min', () => { - component.min = 0; - component.writeValue(0); - fixture.detectChanges(); - - const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); - const minusBtn = buttons[0].nativeElement; - - expect(minusBtn.disabled).toBeTruthy(); - }); - - it('should disable plus button when value equals max', fakeAsync(() => { - component.max = 10; - component.writeValue(10); - tick(); - component['cdr'].markForCheck(); - fixture.detectChanges(); - - const plusBtnIcon = fixture.debugElement.query(By.css('evo-icon[shape="plus"]')); - const plusBtn = plusBtnIcon.parent.nativeElement; - - expect(plusBtn.disabled).toBeTruthy(); - })); - - it('should disable all buttons when control is disabled', fakeAsync(() => { - component.writeValue(5); - component.setDisabledState(true); - tick(); - fixture.detectChanges(); - - const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); - buttons.forEach((btn) => { - expect(btn.nativeElement.disabled).toBeTruthy(); - }); - })); - - it('should have disabled class on input when control is disabled', fakeAsync(() => { - component.setDisabledState(true); - tick(); - fixture.detectChanges(); - - const input = fixture.debugElement.query(By.css(INPUT_SELECTOR)); - expect(input.nativeElement.classList).toContain('evo-quantity__field_disabled'); - })); - }); - describe('isInputAllowed', () => { it('should have readonly input when isInputAllowed is false', () => { component.isInputAllowed = false; @@ -477,8 +384,14 @@ describe('EvoQuantityComponent', () => { describe('MANUAL mode', () => { const CHECK_BTN_SELECTOR = '.evo-quantity__control evo-icon[shape="check-rounded"]'; const PLUS_BTN_SELECTOR = '.evo-quantity__control evo-icon[shape="plus"]'; + const MINUS_BTN_SELECTOR = '.evo-quantity__control evo-icon[shape="minus"]'; - it('should show check button instead of plus when in manual mode', () => { + it('should start in PER_BUTTONS mode', () => { + expect(component.mode).toBe(InputMode.PER_BUTTONS); + }); + + it('should emit manualInputStart when focusing input', () => { + spyOn(component.manualInputStart, 'emit'); component.registerOnTouched(() => {}); component.isInputAllowed = true; fixture.detectChanges(); @@ -487,14 +400,25 @@ describe('EvoQuantityComponent', () => { input.nativeElement.dispatchEvent(new Event('focus')); fixture.detectChanges(); - const checkBtn = fixture.debugElement.query(By.css(CHECK_BTN_SELECTOR)); - const plusBtn = fixture.debugElement.query(By.css(PLUS_BTN_SELECTOR)); + expect(component.manualInputStart.emit).toHaveBeenCalled(); + }); - expect(checkBtn).toBeTruthy(); - expect(plusBtn).toBeFalsy(); + it('should emit manualInputEnd when finishing manual mode', () => { + spyOn(component.manualInputEnd, 'emit'); + component.registerOnChange(() => {}); + component.registerOnTouched(() => {}); + component.writeValue(5); + + component.onInputFocus(); + fixture.detectChanges(); + + component.onFinishManualModeBtnClick(); + fixture.detectChanges(); + + expect(component.manualInputEnd.emit).toHaveBeenCalled(); }); - it('should hide minus button in manual mode', () => { + it('should show only check button in manual mode (hide plus and minus)', () => { component.registerOnTouched(() => {}); component.isInputAllowed = true; component.writeValue(5); @@ -504,8 +428,13 @@ describe('EvoQuantityComponent', () => { input.nativeElement.dispatchEvent(new Event('focus')); fixture.detectChanges(); - const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); - expect(buttons.length).toBe(1); + const checkBtn = fixture.debugElement.query(By.css(CHECK_BTN_SELECTOR)); + const plusBtn = fixture.debugElement.query(By.css(PLUS_BTN_SELECTOR)); + const minusBtn = fixture.debugElement.query(By.css(MINUS_BTN_SELECTOR)); + + expect(checkBtn).toBeTruthy(); + expect(plusBtn).toBeFalsy(); + expect(minusBtn).toBeFalsy(); }); it('should confirm value when check button is clicked', fakeAsync(() => { @@ -536,8 +465,67 @@ describe('EvoQuantityComponent', () => { })); }); + describe('keyboard events', () => { + it('should cancel manual mode on Escape key', fakeAsync(() => { + component.registerOnChange(() => {}); + component.registerOnTouched(() => {}); + component.writeValue(5); + component.isInputAllowed = true; + fixture.detectChanges(); + + component.onInputFocus(); + fixture.detectChanges(); + expect(component.mode).toBe(InputMode.MANUAL); + + component.control.setValue(10); + fixture.detectChanges(); + + const event = new KeyboardEvent('keydown', {key: 'Escape'}); + document.dispatchEvent(event); + tick(); + fixture.detectChanges(); + + expect(component.mode).toBe(InputMode.PER_BUTTONS); + expect(component.control.value).toBe(5); + })); + + it('should confirm manual mode on Enter key', fakeAsync(() => { + component.registerOnChange(() => {}); + component.registerOnTouched(() => {}); + component.writeValue(5); + component.isInputAllowed = true; + fixture.detectChanges(); + + component.onInputFocus(); + fixture.detectChanges(); + + component.control.setValue(10); + fixture.detectChanges(); + + const event = new KeyboardEvent('keydown', {key: 'Enter'}); + document.dispatchEvent(event); + tick(); + fixture.detectChanges(); + + expect(component.mode).toBe(InputMode.PER_BUTTONS); + expect(component.control.value).toBe(10); + })); + + it('should not react to Escape when not in manual mode', () => { + component.writeValue(5); + fixture.detectChanges(); + + const event = new KeyboardEvent('keydown', {key: 'Escape'}); + document.dispatchEvent(event); + fixture.detectChanges(); + + expect(component.mode).toBe(InputMode.PER_BUTTONS); + expect(component.control.value).toBe(5); + }); + }); + describe('onClickOutside', () => { - it('should cancel manual mode when clicking outside', fakeAsync(() => { + it('should cancel manual mode after onClickOutside call', fakeAsync(() => { component.registerOnChange(() => {}); component.registerOnTouched(() => {}); component.isInputAllowed = true; @@ -558,7 +546,7 @@ describe('EvoQuantityComponent', () => { expect(component.control.value).toBe(5); })); - it('should not affect PER_BUTTONS mode when clicking outside', () => { + it('should not affect PER_BUTTONS mode after onClickOutside call', () => { component.writeValue(5); fixture.detectChanges(); @@ -570,7 +558,7 @@ describe('EvoQuantityComponent', () => { }); }); - describe('string coercion for numeric inputs', () => { + describe('string coercion', () => { it('should coerce string min to number', () => { (component as any).min = '5'; expect(component.min).toBe(5); diff --git a/projects/evo-ui-kit/src/lib/components/evo-quantity/types/evo-quantity-size.ts b/projects/evo-ui-kit/src/lib/components/evo-quantity/types/evo-quantity-size.ts new file mode 100644 index 000000000..05357691e --- /dev/null +++ b/projects/evo-ui-kit/src/lib/components/evo-quantity/types/evo-quantity-size.ts @@ -0,0 +1,3 @@ +import {EvoSize} from '../../../common/types'; + +export type EvoQuantitySize = Extract; diff --git a/projects/evo-ui-kit/src/lib/components/evo-quantity/types/evo-quantity-theme.ts b/projects/evo-ui-kit/src/lib/components/evo-quantity/types/evo-quantity-theme.ts new file mode 100644 index 000000000..e48223f51 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/components/evo-quantity/types/evo-quantity-theme.ts @@ -0,0 +1 @@ +export type EvoQuantityTheme = 'default' | 'borderless';