From 8bcbe449400a8ffe65994f6c7ab5769e5fb0b7a4 Mon Sep 17 00:00:00 2001 From: Ilia Karpov Date: Fri, 5 Dec 2025 16:37:03 +0500 Subject: [PATCH 1/3] 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 | 72 ++++++ .../evo-quantity/evo-quantity.component.scss | 108 +++++++++ .../evo-quantity.component.spec.ts | 211 ++++++++++++++++ .../evo-quantity/evo-quantity.component.ts | 226 ++++++++++++++++++ .../src/lib/components/evo-quantity/index.ts | 1 + .../lib/components/evo-quantity/public-api.ts | 2 + projects/evo-ui-kit/src/public_api.ts | 1 + 9 files changed, 635 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/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..32539b336 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.html @@ -0,0 +1,72 @@ +
+
+
+ @if (mode === InputMode.PER_BUTTONS) { + + @if (control.value > min || !isDeletable) { + + } + @if (isDeletable && control.value <= min) { + + } + } +
+ +
+ @if (mode === InputMode.PER_BUTTONS) { + + } + @if (mode === InputMode.MANUAL) { + + } +
+
+ + @if (pricePerOne && !control.disabled) { +
+ {{ 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..2e21b2247 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.spec.ts @@ -0,0 +1,211 @@ +import {ComponentFixture, fakeAsync, TestBed, tick, waitForAsync} from '@angular/core/testing'; +import {EvoQuantityComponent} from './evo-quantity.component'; +import {FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {InputMode} from './enums/input-mode'; +import {EvoQuantitySize} from './enums/evo-quantity-size'; + +describe('EvoQuantityComponent', () => { + let component: EvoQuantityComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + EvoQuantityComponent, + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EvoQuantityComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + 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); + + component.onInputFocus(); + fixture.detectChanges(); + + 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..973a7ca4b --- /dev/null +++ b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.ts @@ -0,0 +1,226 @@ +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + forwardRef, + HostListener, + inject, + Injector, + Input, + OnDestroy, + Output, + ViewChild, +} from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + FormControl, + NG_VALUE_ACCESSOR, + NgControl, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import {CurrencyPipe, NgClass, registerLocaleData} from '@angular/common'; +import localeRu from '@angular/common/locales/ru'; +import {Subject} from 'rxjs'; + +import {takeUntil, tap} from 'rxjs/operators'; + +import {InputMode} from './enums/input-mode'; +import {EvoQuantitySize} from './enums/evo-quantity-size'; +import {EvoIconComponent} from '../evo-icon'; +import {EvoClickOutsideDirective} from '../../directives'; + +registerLocaleData(localeRu, 'ru'); + +@Component({ + selector: 'evo-quantity', + templateUrl: './evo-quantity.component.html', + styleUrls: ['./evo-quantity.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [CurrencyPipe, EvoIconComponent, EvoClickOutsideDirective, NgClass, ReactiveFormsModule], + 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 | undefined; + @Input() isInputAllowed = false; + @Input() isDeletable = false; + @Input() size: EvoQuantitySize = EvoQuantitySize.regular; + + @Output() delete = new EventEmitter(); + @Output() manualInputStart = new EventEmitter(); + @Output() manualInputEnd = new EventEmitter(); + + @ViewChild('inputElement', {read: ElementRef}) inputElement: ElementRef; + + mode = InputMode.PER_BUTTONS; + readonly InputMode = InputMode; + + readonly control = new FormControl(null, { + validators: [(control) => Validators.min(this.min)(control), (control) => Validators.max(this.max)(control)], + updateOn: 'change', + }); + + private readonly injector = inject(Injector); + private readonly cdr = inject(ChangeDetectorRef); + private readonly destroy$ = new Subject(); + + private lastConfirmedValue: number | undefined; + private ngControl: NgControl | null = null; + private onChange: ((_: number) => void) | undefined; + private onTouched: (() => void) | undefined; + + get parentFormControl(): AbstractControl | undefined { + if (!this.ngControl) { + this.ngControl = this.injector.get(NgControl, null, {self: true, optional: true}); + } + return this.ngControl?.control ?? undefined; + } + + get wrapClasses(): {[cssClass: string]: boolean} { + return { + 'evo-quantity__wrap_error': this.control.invalid ?? false, + [`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 currentValue = this.control.value ?? 0; + const newValue = currentValue + step; + this.confirmValue(this.getNearestValidValue(newValue)); + this.onTouched?.(); + } + + onDeleteBtnClick(): void { + this.delete.emit(); + } + + writeValue(value: number | null): void { + this.lastConfirmedValue = value ?? undefined; + this.control.setValue(value); + } + + registerOnChange(fn: (_: number) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): 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 | null): void { + if (this.mode !== InputMode.MANUAL) { + return; + } + + this.inputElement?.nativeElement.blur(); + this.mode = InputMode.PER_BUTTONS; + + this.confirmValue(this.getNearestValidValue(value ?? 0)); + this.manualInputEnd.emit(); + } + + private cancelManualMode(): void { + this.finishManualMode(this.lastConfirmedValue ?? null); + } + + 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/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..285939577 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/components/evo-quantity/public-api.ts @@ -0,0 +1,2 @@ +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 ff6bf57cd..6f023a5e9 100644 --- a/projects/evo-ui-kit/src/public_api.ts +++ b/projects/evo-ui-kit/src/public_api.ts @@ -62,6 +62,7 @@ export * from './lib/components/evo-navbar/index'; export * from './lib/components/evo-navigation-button/index'; export * from './lib/components/evo-tooltip/index'; export * from './lib/components/evo-link-button/index'; +export * from './lib/components/evo-quantity/index'; export * from './lib/pipes/index'; From 9ef4b93700de7ee935a6843838da3a3542e9e7b6 Mon Sep 17 00:00:00 2001 From: Ilia Karpov Date: Tue, 16 Dec 2025 13:25:27 +0500 Subject: [PATCH 2/3] 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 | 84 +++++++++++++++---- .../evo-quantity/evo-quantity.component.ts | 7 +- .../lib/components/evo-quantity/public-api.ts | 3 +- .../evo-quantity/types/evo-quantity-size.ts | 3 + .../evo-quantity/types/evo-quantity-theme.ts | 1 + 7 files changed, 85 insertions(+), 36 deletions(-) delete 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/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/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 2e21b2247..e1a86ce4b 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,10 +1,13 @@ import {ComponentFixture, fakeAsync, TestBed, tick, waitForAsync} from '@angular/core/testing'; import {EvoQuantityComponent} from './evo-quantity.component'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {provideHttpClient} from '@angular/common/http'; +import {By} from '@angular/platform-browser'; import {InputMode} from './enums/input-mode'; -import {EvoQuantitySize} from './enums/evo-quantity-size'; describe('EvoQuantityComponent', () => { + const CONTROL_BTN_SELECTOR = '.evo-quantity__control'; + let component: EvoQuantityComponent; let fixture: ComponentFixture; @@ -15,6 +18,7 @@ describe('EvoQuantityComponent', () => { ReactiveFormsModule, EvoQuantityComponent, ], + providers: [provideHttpClient()], }).compileComponents(); })); @@ -41,7 +45,9 @@ describe('EvoQuantityComponent', () => { component.step = 1; fixture.detectChanges(); - component.onChangeValueByStepClick(1); + const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); + const plusBtn = buttons[1]; // second button is plus + plusBtn.triggerEventHandler('click', null); tick(); fixture.detectChanges(); @@ -55,7 +61,9 @@ describe('EvoQuantityComponent', () => { component.step = 1; fixture.detectChanges(); - component.onChangeValueByStepClick(-1); + const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); + const minusBtn = buttons[0]; // first button is minus + minusBtn.triggerEventHandler('click', null); tick(); fixture.detectChanges(); @@ -110,11 +118,27 @@ describe('EvoQuantityComponent', () => { expect(component.control.disabled).toBeFalsy(); })); - it('should emit delete event when delete button clicked', () => { + it('should emit delete event when delete button clicked', waitForAsync(() => { spyOn(component.delete, 'emit'); - component.onDeleteBtnClick(); - expect(component.delete.emit).toHaveBeenCalled(); - }); + component.isDeletable = true; + component.min = 0; + component.writeValue(0); + fixture.detectChanges(); + + // Allow time for delete icon to load via XHR + fixture.whenStable().then(() => { + fixture.detectChanges(); + + // When isDeletable=true and value=min, left wrap has delete button (not minus) + const leftControlWrap = fixture.debugElement.query(By.css('.evo-quantity__control-wrap')); + const deleteBtn = leftControlWrap.query(By.css(CONTROL_BTN_SELECTOR)); + expect(deleteBtn).toBeTruthy(); + deleteBtn.nativeElement.click(); + fixture.detectChanges(); + + expect(component.delete.emit).toHaveBeenCalled(); + }); + })); it('should not call onChange when writeValue is called', fakeAsync(() => { const onChangeSpy = jasmine.createSpy('onChange'); @@ -153,7 +177,12 @@ 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('.evo-quantity__field')); + input.nativeElement.dispatchEvent(new Event('focus')); + expect(component.manualInputStart.emit).toHaveBeenCalled(); }); @@ -161,12 +190,19 @@ describe('EvoQuantityComponent', () => { spyOn(component.manualInputEnd, 'emit'); component.registerOnChange(() => {}); component.registerOnTouched(() => {}); + component.isInputAllowed = true; component.writeValue(5); + fixture.detectChanges(); - component.onInputFocus(); + // Enter manual mode via focus + const input = fixture.debugElement.query(By.css('.evo-quantity__field')); + input.nativeElement.dispatchEvent(new Event('focus')); fixture.detectChanges(); - component.onFinishManualModeBtnClick(); + // In manual mode, check button appears. Find all buttons and click the one in right control-wrap + const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); + const checkBtn = buttons[buttons.length - 1]; // check button is on the right side + checkBtn.triggerEventHandler('click', null); fixture.detectChanges(); expect(component.manualInputEnd.emit).toHaveBeenCalled(); @@ -179,24 +215,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(); }); + }); - it('should apply size-simple class when size is simple', () => { - component.size = EvoQuantitySize.simple; - expect(component.wrapClasses['evo-quantity__wrap_size-simple']).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 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(); 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 973a7ca4b..9a903bf74 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 @@ -30,7 +30,8 @@ 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'; import {EvoIconComponent} from '../evo-icon'; import {EvoClickOutsideDirective} from '../../directives'; @@ -58,7 +59,8 @@ export class EvoQuantityComponent implements ControlValueAccessor, AfterViewInit @Input() pricePerOne: number | undefined; @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(); @@ -94,6 +96,7 @@ export class EvoQuantityComponent implements ControlValueAccessor, AfterViewInit return { 'evo-quantity__wrap_error': this.control.invalid ?? false, [`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 285939577..1bb168383 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,2 +1,3 @@ export * from './evo-quantity.component'; -export * from './enums/evo-quantity-size'; +export * from './types/evo-quantity-size'; +export * from './types/evo-quantity-theme'; 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'; From c5cc54fc110d2db21660a581da85dead2b27f01c Mon Sep 17 00:00:00 2001 From: Ilia Karpov Date: Wed, 17 Dec 2025 16:43:54 +0500 Subject: [PATCH 3/3] feat(evo-quantity): add component --- .../evo-quantity.component.spec.ts | 703 ++++++++++++++---- .../evo-quantity/evo-quantity.component.ts | 44 +- 2 files changed, 580 insertions(+), 167 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 e1a86ce4b..e2a7efd35 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,24 +1,23 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ import {ComponentFixture, fakeAsync, TestBed, tick, waitForAsync} from '@angular/core/testing'; import {EvoQuantityComponent} from './evo-quantity.component'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; import {provideHttpClient} from '@angular/common/http'; +import {provideHttpClientTesting} from '@angular/common/http/testing'; import {By} from '@angular/platform-browser'; import {InputMode} from './enums/input-mode'; describe('EvoQuantityComponent', () => { const CONTROL_BTN_SELECTOR = '.evo-quantity__control'; + const INPUT_SELECTOR = '.evo-quantity__field'; let component: EvoQuantityComponent; let fixture: ComponentFixture; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [ - FormsModule, - ReactiveFormsModule, - EvoQuantityComponent, - ], - providers: [provideHttpClient()], + imports: [FormsModule, ReactiveFormsModule, EvoQuantityComponent], + providers: [provideHttpClient(), provideHttpClientTesting()], }).compileComponents(); })); @@ -32,186 +31,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)); - const plusBtn = buttons[1]; // second button is plus - plusBtn.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)); - const minusBtn = buttons[0]; // first button is minus - minusBtn.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(); - - 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(); + 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', waitForAsync(() => { - spyOn(component.delete, 'emit'); - component.isDeletable = true; - component.min = 0; - component.writeValue(0); - fixture.detectChanges(); + expect(onChangeSpy).toHaveBeenCalledWith(6); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + })); + }); - // Allow time for delete icon to load via XHR - fixture.whenStable().then(() => { + describe('step buttons', () => { + it('should increment value on plus button click', fakeAsync(() => { + component.registerOnChange(() => {}); + component.registerOnTouched(() => {}); + component.writeValue(5); + component.step = 1; fixture.detectChanges(); - // When isDeletable=true and value=min, left wrap has delete button (not minus) - const leftControlWrap = fixture.debugElement.query(By.css('.evo-quantity__control-wrap')); - const deleteBtn = leftControlWrap.query(By.css(CONTROL_BTN_SELECTOR)); - expect(deleteBtn).toBeTruthy(); - deleteBtn.nativeElement.click(); + const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); + const plusBtn = buttons[1]; // second button is plus + plusBtn.triggerEventHandler('click', null); + tick(); fixture.detectChanges(); - 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(); + expect(component.control.value).toBe(6); + })); - component.onChangeValueByStepClick(1); - 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)); + const minusBtn = buttons[0]; // first button is minus + minusBtn.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('.evo-quantity__field')); - input.nativeElement.dispatchEvent(new Event('focus')); - - 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.isInputAllowed = true; - component.writeValue(5); - fixture.detectChanges(); + component.onChangeValueByStepClick(-1); + tick(); + fixture.detectChanges(); - // Enter manual mode via focus - const input = fixture.debugElement.query(By.css('.evo-quantity__field')); - input.nativeElement.dispatchEvent(new Event('focus')); - fixture.detectChanges(); + expect(component.control.value).toBe(0); + })); - // In manual mode, check button appears. Find all buttons and click the one in right control-wrap - const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); - const checkBtn = buttons[buttons.length - 1]; // check button is on the right side - checkBtn.triggerEventHandler('click', null); - 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(); - }); + component.onChangeValueByStepClick(1); + 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', () => { @@ -256,4 +182,457 @@ describe('EvoQuantityComponent', () => { expect(component.wrapClasses['evo-quantity__wrap_error']).toBeFalsy(); }); }); + + describe('disabled state', () => { + 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 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('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', 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('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"]'; + const MINUS_BTN_SELECTOR = '.evo-quantity__control evo-icon[shape="minus"]'; + + 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(); + + const input = fixture.debugElement.query(By.css(INPUT_SELECTOR)); + input.nativeElement.dispatchEvent(new Event('focus')); + fixture.detectChanges(); + + expect(component.manualInputStart.emit).toHaveBeenCalled(); + }); + + 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 show only check button in manual mode (hide plus and minus)', () => { + 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 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(() => { + 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('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 after onClickOutside call', 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 after onClickOutside call', () => { + component.writeValue(5); + fixture.detectChanges(); + + component.onClickOutside(); + fixture.detectChanges(); + + expect(component.mode).toBe(InputMode.PER_BUTTONS); + expect(component.control.value).toBe(5); + }); + }); + + describe('string coercion', () => { + it('should coerce string min to number', () => { + component.min = '5'; + expect(component.min).toBe(5); + expect(typeof component.min).toBe('number'); + }); + + it('should coerce string max to number', () => { + component.max = '100'; + expect(component.max).toBe(100); + expect(typeof component.max).toBe('number'); + }); + + it('should coerce string step to number', () => { + component.step = '2'; + expect(component.step).toBe(2); + expect(typeof component.step).toBe('number'); + }); + + it('should coerce string pricePerOne to number', () => { + component.pricePerOne = '150'; + expect(component.pricePerOne).toBe(150); + expect(typeof component.pricePerOne).toBe('number'); + }); + + it('should use default value for invalid min', () => { + component.min = 'invalid'; + expect(component.min).toBe(0); + }); + + it('should use default value for invalid max', () => { + component.max = 'invalid'; + expect(component.max).toBe(Infinity); + }); + + it('should use default value for invalid step', () => { + component.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.min = '1'; + component.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.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 9a903bf74..034105c83 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 @@ -25,6 +25,7 @@ import { } from '@angular/forms'; import {CurrencyPipe, NgClass, registerLocaleData} from '@angular/common'; import localeRu from '@angular/common/locales/ru'; +import {coerceNumberProperty, NumberInput} from '@angular/cdk/coercion'; import {Subject} from 'rxjs'; import {takeUntil, tap} from 'rxjs/operators'; @@ -53,10 +54,38 @@ registerLocaleData(localeRu, 'ru'); ], }) export class EvoQuantityComponent implements ControlValueAccessor, AfterViewInit, OnDestroy { - @Input() min = 0; - @Input() max = Infinity; - @Input() step = 1; - @Input() pricePerOne: number | undefined; + @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'; @@ -85,6 +114,11 @@ export class EvoQuantityComponent implements ControlValueAccessor, AfterViewInit private onChange: ((_: number) => void) | undefined; private onTouched: (() => void) | undefined; + private _max = Infinity; + private _min = 0; + private _step = 1; + private _pricePerOne: number; + get parentFormControl(): AbstractControl | undefined { if (!this.ngControl) { this.ngControl = this.injector.get(NgControl, null, {self: true, optional: true}); @@ -94,7 +128,7 @@ export class EvoQuantityComponent implements ControlValueAccessor, AfterViewInit get wrapClasses(): {[cssClass: string]: boolean} { return { - 'evo-quantity__wrap_error': this.control.invalid ?? false, + 'evo-quantity__wrap_error': this.control.invalid, [`evo-quantity__wrap_size-${this.size}`]: true, [`evo-quantity__wrap_theme-${this.theme}`]: true, };