` wrapper
diff --git a/src/lib/components/inputnumber/inputnumber.component.ts b/src/lib/components/inputnumber/inputnumber.component.ts
new file mode 100644
index 00000000..2a1dafe0
--- /dev/null
+++ b/src/lib/components/inputnumber/inputnumber.component.ts
@@ -0,0 +1,134 @@
+import { Component, Input, Output, EventEmitter, forwardRef, inject, Injector, OnInit } from '@angular/core';
+import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR, NgControl } from '@angular/forms';
+import { NgClass } from '@angular/common';
+import { InputNumber } from 'primeng/inputnumber';
+import { SharedModule } from 'primeng/api';
+
+export type InputNumberSize = 'small' | 'base' | 'large' | 'xlarge';
+export type InputNumberButtonLayout = 'stacked' | 'horizontal' | 'vertical';
+
+@Component({
+ selector: 'input-number',
+ standalone: true,
+ imports: [InputNumber, SharedModule, FormsModule, NgClass],
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => InputNumberComponent),
+ multi: true,
+ },
+ ],
+ template: `
+
+ @if (!incrementButtonIcon) {
+
+
+
+ }
+ @if (!decrementButtonIcon) {
+
+
+
+ }
+
+ `,
+})
+export class InputNumberComponent implements ControlValueAccessor, OnInit {
+ private readonly _injector = inject(Injector);
+ private _ngControl: NgControl | null = null;
+
+ ngOnInit(): void {
+ this._ngControl = this._injector.get(NgControl, null, { self: true, optional: true });
+ }
+
+ @Input() size: InputNumberSize = 'base';
+ @Input() showButtons = false;
+ @Input() buttonLayout: InputNumberButtonLayout = 'stacked';
+ @Input() mode = 'decimal';
+ @Input() currency: string | undefined;
+ @Input() locale: string | undefined;
+ @Input() placeholder = '';
+ @Input() readonly = false;
+ @Input() fluid = false;
+ @Input() min: number | undefined;
+ @Input() max: number | undefined;
+ @Input() step = 1;
+ @Input() prefix: string | undefined;
+ @Input() suffix: string | undefined;
+ @Input() minFractionDigits: number | undefined;
+ @Input() maxFractionDigits: number | undefined;
+ @Input() useGrouping = true;
+ @Input() incrementButtonIcon: string | undefined;
+ @Input() decrementButtonIcon: string | undefined;
+
+ disabled = false;
+
+ get invalid(): boolean {
+ return this._ngControl?.invalid ?? false;
+ }
+
+ @Output() onInput = new EventEmitter<{ value: number | null }>();
+
+ modelValue: number | null = null;
+
+ private _onChange: (value: number | null) => void = () => {};
+ onTouched: () => void = () => {};
+
+ get inputSizeClass(): string {
+ if (this.size === 'small') return 'p-inputtext-sm';
+ if (this.size === 'large' || this.size === 'xlarge') return 'p-inputtext-lg';
+ return '';
+ }
+
+ get sizeClass(): Record
{
+ return { 'p-inputnumber-xlg': this.size === 'xlarge' };
+ }
+
+ onModelChange(value: number | null): void {
+ this.modelValue = value;
+ this._onChange(value);
+ this.onInput.emit({ value });
+ }
+
+ writeValue(value: number | null): void {
+ this.modelValue = value ?? null;
+ }
+
+ registerOnChange(fn: (value: number | null) => void): void {
+ this._onChange = fn;
+ }
+
+ registerOnTouched(fn: () => void): void {
+ this.onTouched = fn;
+ }
+
+ setDisabledState(isDisabled: boolean): void {
+ this.disabled = isDisabled;
+ }
+}
diff --git a/src/prime-preset/tokens/components/inputnumber.ts b/src/prime-preset/tokens/components/inputnumber.ts
new file mode 100644
index 00000000..ee036c31
--- /dev/null
+++ b/src/prime-preset/tokens/components/inputnumber.ts
@@ -0,0 +1,44 @@
+export const inputnumberCss = ({ dt }: { dt: (token: string) => string }): string => `
+
+/* ─── Кнопки увеличения/уменьшения ─── */
+.p-inputnumber-button {
+ border-width: ${dt('inputnumber.extend.borderWidth')};
+}
+
+.p-inputnumber-horizontal .p-inputnumber-button {
+ min-height: ${dt('inputnumber.extend.extButton.height')};
+ border: ${dt('inputnumber.extend.borderWidth')} solid ${dt('inputnumber.button.borderColor')};
+}
+
+.p-inputnumber-horizontal .p-inputnumber-decrement-button {
+ border-right: none;
+}
+
+/* ─── Focus ─── */
+.p-inputnumber .p-inputnumber-input:enabled:focus {
+ box-shadow: 0 0 0 ${dt('inputtext.focusRing.width')} ${dt('inputtext.focusRing.color')};
+}
+
+/* ─── Invalid + Focus ─── */
+.p-inputnumber.p-invalid .p-inputnumber-input:focus {
+ border-color: ${dt('inputtext.root.invalidBorderColor')};
+ box-shadow: 0 0 0 1px ${dt('inputtext.root.invalidBorderColor')};
+}
+
+/* ─── Disabled состояние ─── */
+.p-inputnumber-horizontal:has(.p-inputnumber-input:disabled) .p-inputnumber-button {
+ background: ${dt('inputtext.root.disabledBackground')};
+ color: ${dt('inputtext.root.disabledColor')};
+}
+
+/* ─── FloatLabel: кнопки на полную высоту поля ─── */
+.p-floatlabel:has(.p-inputnumber-horizontal) .p-inputnumber-button {
+ align-self: stretch;
+}
+
+/* ─── Extra Large ─── */
+.p-inputnumber.p-inputnumber-xlg .p-inputnumber-input {
+ font-size: ${dt('inputtext.extend.extXlg.fontSize')};
+ padding: ${dt('inputtext.extend.extXlg.paddingY')} ${dt('inputtext.extend.extXlg.paddingX')};
+}
+`;
diff --git a/src/prime-preset/tokens/tokens.json b/src/prime-preset/tokens/tokens.json
index 3bad67d2..a66e83c3 100644
--- a/src/prime-preset/tokens/tokens.json
+++ b/src/prime-preset/tokens/tokens.json
@@ -3091,7 +3091,7 @@
"transitionDuration": "{form.transitionDuration}"
},
"button": {
- "width": "{form.width.300}",
+ "width": "{form.size.600}",
"borderRadius": "{form.borderRadius.200}",
"verticalPadding": "{form.padding.300}"
}
diff --git a/src/stories/components/inputnumber/examples/inputnumber-buttons.component.ts b/src/stories/components/inputnumber/examples/inputnumber-buttons.component.ts
new file mode 100644
index 00000000..9f774228
--- /dev/null
+++ b/src/stories/components/inputnumber/examples/inputnumber-buttons.component.ts
@@ -0,0 +1,55 @@
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { StoryObj } from '@storybook/angular';
+import { InputNumberComponent } from '../../../../lib/components/inputnumber/inputnumber.component';
+
+type Story = StoryObj;
+
+export const Buttons: Story = {
+ name: 'Buttons',
+ render: () => {
+ const control = new FormControl(null);
+ return {
+ props: { control },
+ template: `
+
+ `,
+ };
+ },
+ decorators: [
+ (story: any) => ({
+ ...story(),
+ moduleMetadata: {
+ imports: [InputNumberComponent, ReactiveFormsModule],
+ },
+ }),
+ ],
+ parameters: {
+ controls: { disable: true },
+ docs: {
+ description: {
+ story: 'Числовое поле с кнопками увеличения/уменьшения в горизонтальной раскладке. Кастомные SVG-иконки +/− используются по умолчанию.',
+ },
+ source: {
+ language: 'ts',
+ code: `
+import { Component } from '@angular/core';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { InputNumberComponent } from '@cdek-it/angular-ui-kit';
+
+@Component({
+ standalone: true,
+ imports: [InputNumberComponent, ReactiveFormsModule],
+ template: \`\`,
+})
+export class ButtonsExample {
+ control = new FormControl(null);
+}
+ `,
+ },
+ },
+ },
+};
diff --git a/src/stories/components/inputnumber/examples/inputnumber-currency.component.ts b/src/stories/components/inputnumber/examples/inputnumber-currency.component.ts
new file mode 100644
index 00000000..6bb38c49
--- /dev/null
+++ b/src/stories/components/inputnumber/examples/inputnumber-currency.component.ts
@@ -0,0 +1,55 @@
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { StoryObj } from '@storybook/angular';
+import { InputNumberComponent } from '../../../../lib/components/inputnumber/inputnumber.component';
+
+type Story = StoryObj;
+
+export const Currency: Story = {
+ name: 'Currency',
+ render: () => {
+ const control = new FormControl(null);
+ return {
+ props: { control },
+ template: `
+
+ `,
+ };
+ },
+ decorators: [
+ (story: any) => ({
+ ...story(),
+ moduleMetadata: {
+ imports: [InputNumberComponent, ReactiveFormsModule],
+ },
+ }),
+ ],
+ parameters: {
+ controls: { disable: true },
+ docs: {
+ description: {
+ story: 'Форматирование значения как валюты через `suffix`. Режим `mode="currency"` не используется из-за известного бага PrimeNG с кареткой.',
+ },
+ source: {
+ language: 'ts',
+ code: `
+import { Component } from '@angular/core';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { InputNumberComponent } from '@cdek-it/angular-ui-kit';
+
+@Component({
+ standalone: true,
+ imports: [InputNumberComponent, ReactiveFormsModule],
+ template: \`\`,
+})
+export class CurrencyExample {
+ control = new FormControl(null);
+}
+ `,
+ },
+ },
+ },
+};
diff --git a/src/stories/components/inputnumber/examples/inputnumber-disabled.component.ts b/src/stories/components/inputnumber/examples/inputnumber-disabled.component.ts
new file mode 100644
index 00000000..a2d3f495
--- /dev/null
+++ b/src/stories/components/inputnumber/examples/inputnumber-disabled.component.ts
@@ -0,0 +1,55 @@
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { StoryObj } from '@storybook/angular';
+import { InputNumberComponent } from '../../../../lib/components/inputnumber/inputnumber.component';
+
+type Story = StoryObj;
+
+export const Disabled: Story = {
+ name: 'Disabled',
+ render: () => {
+ const control = new FormControl({ value: 42, disabled: true });
+ return {
+ props: { control },
+ template: `
+
+ `,
+ };
+ },
+ decorators: [
+ (story: any) => ({
+ ...story(),
+ moduleMetadata: {
+ imports: [InputNumberComponent, ReactiveFormsModule],
+ },
+ }),
+ ],
+ parameters: {
+ controls: { disable: true },
+ docs: {
+ description: {
+ story: 'Отключённое состояние — поле и кнопки недоступны для взаимодействия.',
+ },
+ source: {
+ language: 'ts',
+ code: `
+import { Component } from '@angular/core';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { InputNumberComponent } from '@cdek-it/angular-ui-kit';
+
+@Component({
+ standalone: true,
+ imports: [InputNumberComponent, ReactiveFormsModule],
+ template: \`\`,
+})
+export class DisabledExample {
+ control = new FormControl({ value: 42, disabled: true });
+}
+ `,
+ },
+ },
+ },
+};
diff --git a/src/stories/components/inputnumber/examples/inputnumber-float-label.component.ts b/src/stories/components/inputnumber/examples/inputnumber-float-label.component.ts
new file mode 100644
index 00000000..532093fb
--- /dev/null
+++ b/src/stories/components/inputnumber/examples/inputnumber-float-label.component.ts
@@ -0,0 +1,82 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { StoryObj } from '@storybook/angular';
+import { InputNumber } from 'primeng/inputnumber';
+import { FloatLabel } from 'primeng/floatlabel';
+import { SharedModule } from 'primeng/api';
+
+const template = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+const styles = '';
+
+@Component({
+ selector: 'app-inputnumber-float-label',
+ standalone: true,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [InputNumber, FloatLabel, ReactiveFormsModule, SharedModule],
+ template,
+ styles,
+})
+export class InputNumberFloatLabelComponent {
+ control = new FormControl(null);
+}
+
+export const FloatLabelStory: StoryObj = {
+ name: 'FloatLabel',
+ render: () => ({
+ template: ``,
+ }),
+ parameters: {
+ controls: { disable: true },
+ docs: {
+ description: {
+ story:
+ 'Интеграция с `p-floatlabel` — плавающая метка внутри поля. Требует нативный `p-inputNumber` как прямой дочерний элемент `p-floatlabel`.',
+ },
+ source: {
+ language: 'ts',
+ code: `
+import { Component } from '@angular/core';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { InputNumber } from 'primeng/inputnumber';
+import { FloatLabel } from 'primeng/floatlabel';
+import { SharedModule } from 'primeng/api';
+
+@Component({
+ standalone: true,
+ imports: [InputNumber, FloatLabel, ReactiveFormsModule, SharedModule],
+ template: \`
+
+
+
+
+
+
+
+
+
+
+
+ \`,
+})
+export class InputNumberFloatLabelExample {
+ control = new FormControl(null);
+}
+ `,
+ },
+ },
+ },
+};
diff --git a/src/stories/components/inputnumber/inputnumber.stories.ts b/src/stories/components/inputnumber/inputnumber.stories.ts
new file mode 100644
index 00000000..1f2d9d20
--- /dev/null
+++ b/src/stories/components/inputnumber/inputnumber.stories.ts
@@ -0,0 +1,288 @@
+import { Meta, StoryObj, moduleMetadata } from '@storybook/angular';
+import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
+import { InputNumberComponent } from '../../../lib/components/inputnumber/inputnumber.component';
+import { InputNumberFloatLabelComponent, FloatLabelStory } from './examples/inputnumber-float-label.component';
+import { Currency } from './examples/inputnumber-currency.component';
+import { Buttons } from './examples/inputnumber-buttons.component';
+import { Disabled } from './examples/inputnumber-disabled.component';
+
+type InputNumberArgs = InputNumberComponent & { disabled: boolean; invalid: boolean };
+
+const meta: Meta = {
+ title: 'Components/Form/InputNumber',
+ component: InputNumberComponent,
+ tags: ['autodocs'],
+ decorators: [
+ moduleMetadata({
+ imports: [
+ InputNumberComponent,
+ ReactiveFormsModule,
+ InputNumberFloatLabelComponent,
+ ],
+ }),
+ ],
+ parameters: {
+ designTokens: { prefix: '--p-inputnumber' },
+ docs: {
+ description: {
+ component: `Числовое поле ввода с поддержкой форматирования, валюты и кнопок увеличения/уменьшения.
+
+\`\`\`typescript
+import { InputNumberComponent } from '@cdek-it/angular-ui-kit';
+\`\`\``,
+ },
+ },
+ },
+ argTypes: {
+ // ── Props ────────────────────────────────────────────────
+ size: {
+ control: 'select',
+ options: ['small', 'base', 'large', 'xlarge'],
+ description: 'Размер компонента',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: "'base'" },
+ type: { summary: "'small' | 'base' | 'large' | 'xlarge'" },
+ },
+ },
+ placeholder: {
+ control: 'text',
+ description: 'Подсказка при пустом поле',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: "''" },
+ type: { summary: 'string' },
+ },
+ },
+ showButtons: {
+ control: 'boolean',
+ description: 'Отображает кнопки увеличения/уменьшения',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: 'false' },
+ type: { summary: 'boolean' },
+ },
+ },
+ buttonLayout: {
+ control: 'select',
+ options: ['stacked', 'horizontal', 'vertical'],
+ description: 'Расположение кнопок',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: "'stacked'" },
+ type: { summary: "'stacked' | 'horizontal' | 'vertical'" },
+ },
+ },
+ mode: {
+ control: 'select',
+ options: ['decimal', 'currency'],
+ description: 'Режим форматирования',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: "'decimal'" },
+ type: { summary: "'decimal' | 'currency'" },
+ },
+ },
+ currency: {
+ control: 'text',
+ description: 'ISO 4217 код валюты (при `mode="currency"`)',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: 'undefined' },
+ type: { summary: 'string' },
+ },
+ },
+ locale: {
+ control: 'text',
+ description: 'Локаль для форматирования',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: 'undefined' },
+ type: { summary: 'string' },
+ },
+ },
+ disabled: {
+ control: 'boolean',
+ description: 'Отключает взаимодействие — управляется через FormControl',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: 'false' },
+ type: { summary: 'boolean' },
+ },
+ },
+ invalid: {
+ control: 'boolean',
+ description: 'Невалидное состояние — управляется через FormControl',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: 'false' },
+ type: { summary: 'boolean' },
+ },
+ },
+ readonly: {
+ control: 'boolean',
+ description: 'Только для чтения',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: 'false' },
+ type: { summary: 'boolean' },
+ },
+ },
+ fluid: {
+ control: 'boolean',
+ description: 'Растягивает поле на всю ширину контейнера',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: 'false' },
+ type: { summary: 'boolean' },
+ },
+ },
+ min: {
+ control: 'number',
+ description: 'Минимальное значение',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: 'undefined' },
+ type: { summary: 'number' },
+ },
+ },
+ max: {
+ control: 'number',
+ description: 'Максимальное значение',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: 'undefined' },
+ type: { summary: 'number' },
+ },
+ },
+ step: {
+ control: 'number',
+ description: 'Шаг изменения значения',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: '1' },
+ type: { summary: 'number' },
+ },
+ },
+ useGrouping: {
+ control: 'boolean',
+ description: 'Использовать разделитель групп разрядов',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: 'true' },
+ type: { summary: 'boolean' },
+ },
+ },
+ prefix: {
+ control: 'text',
+ description: 'Текст перед значением',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: 'undefined' },
+ type: { summary: 'string' },
+ },
+ },
+ suffix: {
+ control: 'text',
+ description: 'Текст после значения',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: 'undefined' },
+ type: { summary: 'string' },
+ },
+ },
+ minFractionDigits: {
+ control: 'number',
+ description: 'Минимальное количество знаков после запятой',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: 'undefined' },
+ type: { summary: 'number' },
+ },
+ },
+ maxFractionDigits: {
+ control: 'number',
+ description: 'Максимальное количество знаков после запятой',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: 'undefined' },
+ type: { summary: 'number' },
+ },
+ },
+ // Hidden computed props
+ modelValue: { table: { disable: true } },
+ inputSizeClass: { table: { disable: true } },
+ sizeClass: { table: { disable: true } },
+
+ // ── Events ───────────────────────────────────────────────
+ onInput: {
+ control: false,
+ description: 'Событие при изменении значения',
+ table: {
+ category: 'Events',
+ type: { summary: 'EventEmitter<{ value: number | null }>' },
+ },
+ },
+ },
+ args: {
+ size: 'base',
+ placeholder: 'Введите число...',
+ showButtons: false,
+ buttonLayout: 'stacked',
+ mode: 'decimal',
+ disabled: false,
+ invalid: false,
+ readonly: false,
+ fluid: false,
+ step: 1,
+ useGrouping: true,
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+// ── Default ──────────────────────────────────────────────────────────────────
+export const Default: Story = {
+ name: 'Default',
+ render: (args) => {
+ const parts: string[] = [];
+
+ if (args.size && args.size !== 'base') parts.push(`size="${args.size}"`);
+ if (args.placeholder) parts.push(`placeholder="${args.placeholder}"`);
+ if (args.showButtons) parts.push(`[showButtons]="true"`);
+ if (args.buttonLayout && args.buttonLayout !== 'stacked') parts.push(`buttonLayout="${args.buttonLayout}"`);
+ if (args.mode && args.mode !== 'decimal') parts.push(`mode="${args.mode}"`);
+ if (args.currency) parts.push(`currency="${args.currency}"`);
+ if (args.locale) parts.push(`locale="${args.locale}"`);
+ if (args.readonly) parts.push(`[readonly]="true"`);
+ if (args.fluid) parts.push(`[fluid]="true"`);
+ if (args.min != null) parts.push(`[min]="${args.min}"`);
+ if (args.max != null) parts.push(`[max]="${args.max}"`);
+ if (args.step && args.step !== 1) parts.push(`[step]="${args.step}"`);
+ if (args.prefix) parts.push(`prefix="${args.prefix}"`);
+ if (args.suffix) parts.push(`suffix="${args.suffix}"`);
+ if (args.minFractionDigits != null) parts.push(`[minFractionDigits]="${args.minFractionDigits}"`);
+ if (args.maxFractionDigits != null) parts.push(`[maxFractionDigits]="${args.maxFractionDigits}"`);
+ if (!args.useGrouping) parts.push(`[useGrouping]="false"`);
+
+ const validators = [];
+ if (args.invalid) validators.push(Validators.required);
+
+ const control = new FormControl({ value: null, disabled: args.disabled }, validators);
+
+ const template = ``;
+
+ return { props: { ...args, control }, template };
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Базовый пример компонента. Используйте Controls для интерактивного изменения пропсов.',
+ },
+ },
+ },
+};
+
+// ── Re-exports from example components ────────────────────────────────────
+export { Currency, Buttons, Disabled, FloatLabelStory as FloatLabel };