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';