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..7e32ab03d --- /dev/null +++ b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.scss @@ -0,0 +1,106 @@ +@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; + + // Sizes + &_size-normal { + height: 40px; + padding: 0 8px; + } + + &_size-small { + height: 32px; + padding: 0 4px; + } + + // Themes + &_theme-default { + border: 1px solid $color-disabled; + } + + &_theme-borderless { + border: none; + } + + &_error#{&}_theme-default { + 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..e2a7efd35 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.spec.ts @@ -0,0 +1,638 @@ +/* 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(), provideHttpClientTesting()], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EvoQuantityComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should unsubscribe on destroy', () => { + const destroySpy = spyOn(component['destroy$'], 'next'); + component.ngOnDestroy(); + expect(destroySpy).toHaveBeenCalled(); + }); + + describe('ControlValueAccessor', () => { + it('should set value through writeValue', () => { + component.writeValue(5); + fixture.detectChanges(); + expect(component.control.value).toBe(5); + }); + + 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(); + + const buttons = fixture.debugElement.queryAll(By.css(CONTROL_BTN_SELECTOR)); + buttons[1].triggerEventHandler('click', null); + tick(); + fixture.detectChanges(); + + expect(onChangeSpy).toHaveBeenCalledWith(6); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + })); + }); + + describe('step buttons', () => { + 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); + })); + }); + + describe('min/max constraints', () => { + 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); + })); + }); + + describe('size', () => { + it('should have normal size by default', () => { + expect(component.size).toBe('normal'); + }); + + 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 = '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 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(); + 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(); + }); + }); + + 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 new file mode 100644 index 000000000..034105c83 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/components/evo-quantity/evo-quantity.component.ts @@ -0,0 +1,263 @@ +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 {coerceNumberProperty, NumberInput} from '@angular/cdk/coercion'; +import {Subject} from 'rxjs'; + +import {takeUntil, tap} from 'rxjs/operators'; + +import {InputMode} from './enums/input-mode'; +import {EvoQuantitySize} from './types/evo-quantity-size'; +import {EvoQuantityTheme} from './types/evo-quantity-theme'; +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() + 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'; + @Input() theme: EvoQuantityTheme = 'default'; + + @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; + + 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}); + } + return this.ngControl?.control ?? undefined; + } + + get wrapClasses(): {[cssClass: string]: boolean} { + return { + 'evo-quantity__wrap_error': this.control.invalid, + [`evo-quantity__wrap_size-${this.size}`]: true, + [`evo-quantity__wrap_theme-${this.theme}`]: 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..1bb168383 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/components/evo-quantity/public-api.ts @@ -0,0 +1,3 @@ +export * from './evo-quantity.component'; +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'; 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';