` 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/lib/components/textarea/textarea.component.ts b/src/lib/components/textarea/textarea.component.ts
new file mode 100644
index 00000000..46dfc60f
--- /dev/null
+++ b/src/lib/components/textarea/textarea.component.ts
@@ -0,0 +1,142 @@
+import { ChangeDetectionStrategy, Component, Input, Output, EventEmitter, forwardRef, inject, Injector, OnInit } from '@angular/core';
+import { ControlValueAccessor, NG_VALUE_ACCESSOR, NgControl } from '@angular/forms';
+import { NgClass } from '@angular/common';
+import { Textarea } from 'primeng/textarea';
+import { IconField } from 'primeng/iconfield';
+import { InputIcon } from 'primeng/inputicon';
+
+export type TextareaSize = 'small' | 'base' | 'large' | 'xlarge';
+
+@Component({
+ selector: 'ui-textarea',
+ standalone: true,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [Textarea, IconField, InputIcon, NgClass],
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => TextareaComponent),
+ multi: true,
+ },
+ ],
+ template: `
+ @if (showClear) {
+
+
+
+
+ } @else {
+
+ }
+ `,
+})
+export class TextareaComponent 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() placeholder = '';
+ @Input() size: TextareaSize = 'base';
+ @Input() readonly = false;
+ @Input() showClear = false;
+ @Input() fluid = false;
+ @Input() autoResize = false;
+ @Input() rows = 3;
+ @Input() cols?: number;
+
+ disabled = false;
+
+ get invalid(): boolean {
+ return this._ngControl?.invalid ?? false;
+ }
+
+ @Output() onResize = new EventEmitter<{ height: string }>();
+ @Output() onClear = new EventEmitter();
+
+ modelValue = '';
+
+ private _onChange: (value: string) => void = () => {};
+
+ get primeSize(): 'small' | 'large' | undefined {
+ if (this.size === 'small') return 'small';
+ if (this.size === 'large') return 'large';
+ return undefined;
+ }
+
+ get sizeClass(): Record {
+ return { 'p-textarea-xlg': this.size === 'xlarge' };
+ }
+
+ onInput(event: Event): void {
+ const value = (event.target as HTMLTextAreaElement).value;
+ this.modelValue = value;
+ this._onChange(value);
+ }
+
+ onTouched: () => void = () => {};
+
+ clearValue(): void {
+ this.modelValue = '';
+ this._onChange('');
+ this.onClear.emit();
+ }
+
+ writeValue(value: string): void {
+ this.modelValue = value ?? '';
+ }
+
+ registerOnChange(fn: (value: string) => 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/map-tokens.ts b/src/prime-preset/map-tokens.ts
index 09d9449d..2f84e02f 100644
--- a/src/prime-preset/map-tokens.ts
+++ b/src/prime-preset/map-tokens.ts
@@ -9,6 +9,8 @@ import { checkboxCss } from './tokens/components/checkbox';
import { inputtextCss } from './tokens/components/inputtext';
import { progressspinnerCss } from './tokens/components/progressspinner';
import { tagCss } from './tokens/components/tag';
+import { inputnumberCss } from './tokens/components/inputnumber';
+import { textareaCss } from './tokens/components/textarea';
import { tooltipCss } from './tokens/components/tooltip';
const presetTokens: Preset = {
@@ -36,10 +38,18 @@ const presetTokens: Preset = {
...(tokens.components.inputtext as unknown as ComponentsDesignTokens['inputtext']),
css: inputtextCss,
},
+ inputnumber: {
+ ...(tokens.components.inputnumber as unknown as ComponentsDesignTokens['inputnumber']),
+ css: inputnumberCss,
+ },
tag: {
...(tokens.components.tag as unknown as ComponentsDesignTokens['tag']),
css: tagCss,
},
+ textarea: {
+ ...(tokens.components.textarea as unknown as ComponentsDesignTokens['textarea']),
+ css: textareaCss,
+ },
tooltip: {
...(tokens.components.tooltip as unknown as ComponentsDesignTokens['tooltip']),
css: tooltipCss,
diff --git a/src/prime-preset/tokens/components/inputnumber.ts b/src/prime-preset/tokens/components/inputnumber.ts
new file mode 100644
index 00000000..98d8d4dc
--- /dev/null
+++ b/src/prime-preset/tokens/components/inputnumber.ts
@@ -0,0 +1,54 @@
+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;
+}
+
+.p-inputnumber-horizontal .p-inputnumber-increment-button {
+ border-left: none;
+}
+
+/* ─── Focus ─── */
+.p-inputnumber .p-inputnumber-input:enabled:focus {
+ box-shadow: 0 0 0 ${dt('inputtext.focusRing.width')} ${dt('inputtext.focusRing.color')};
+ z-index: 1;
+ position: relative;
+}
+
+/* ─── 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;
+}
+
+.p-floatlabel:has(.p-inputnumber) label {
+ z-index: 2;
+}
+
+/* ─── 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/components/textarea.ts b/src/prime-preset/tokens/components/textarea.ts
new file mode 100644
index 00000000..4619309c
--- /dev/null
+++ b/src/prime-preset/tokens/components/textarea.ts
@@ -0,0 +1,58 @@
+export const textareaCss = ({ dt }: { dt: (token: string) => string }): string => `
+
+/* --- Base --- */
+.p-textarea {
+ border-width: ${dt('textarea.extend.borderWidth')};
+ line-height: ${dt('fonts.lineHeight.250')};
+ min-height: ${dt('textarea.extend.minHeight')};
+}
+
+/* --- Sizes --- */
+.p-textarea.p-textarea-xlg {
+ font-size: ${dt('textarea.extend.extXlg.fontSize')};
+ padding: ${dt('textarea.extend.extXlg.paddingY')} ${dt('textarea.extend.extXlg.paddingX')};
+}
+
+/* --- States --- */
+.p-textarea:enabled:read-only {
+ background: ${dt('textarea.extend.readonlyBackground')};
+ color: ${dt('textarea.color')};
+}
+
+.p-textarea:disabled {
+ background: ${dt('textarea.disabledBackground')};
+ color: ${dt('textarea.disabledColor')};
+ opacity: 1;
+}
+
+/* --- Focus --- */
+.p-textarea:enabled:focus {
+ box-shadow: 0 0 0 ${dt('textarea.focusRing.width')} ${dt('textarea.focusRing.color')};
+}
+
+/* --- Invalid + Focus --- */
+.p-textarea.p-invalid:focus {
+ border-color: ${dt('textarea.invalidBorderColor')};
+ box-shadow: 0 0 0 ${dt('textarea.focusRing.width')} ${dt('focusRing.extend.invalid')};
+}
+
+/* --- ClearButton (showClear) --- */
+.p-iconfield:has(.p-textarea) {
+ display: block;
+ width: fit-content;
+}
+
+.p-iconfield:has(.p-textarea) .p-textarea {
+ padding-right: ${dt('form.padding.700')};
+}
+
+.p-iconfield:has(.p-textarea) .p-inputicon {
+ top: ${dt('form.padding.500')};
+ transform: none;
+ font-size: ${dt('textarea.extend.iconSize')};
+ width: ${dt('textarea.extend.iconSize')};
+ height: ${dt('textarea.extend.iconSize')};
+ cursor: pointer;
+}
+
+`;
diff --git a/src/prime-preset/tokens/tokens.json b/src/prime-preset/tokens/tokens.json
index a28b400a..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}"
}
@@ -4703,7 +4703,12 @@
"readonlyBackground": "{form.readonlyBackground}",
"borderWidth": "{form.borderWidth}",
"iconSize": "{form.icon.300}",
- "minHeight": "{form.size.900}"
+ "minHeight": "{form.size.900}",
+ "extXlg": {
+ "fontSize": "{fonts.fontSize.300}",
+ "paddingX": "{form.padding.300}",
+ "paddingY": "{form.padding.500}"
+ }
},
"root": {
"background": "{form.background}",
@@ -4720,8 +4725,8 @@
"placeholderColor": "{form.placeholderColor}",
"invalidPlaceholderColor": "{form.invalidPlaceholderColor}",
"shadow": "0",
- "paddingX": "{form.paddingX}",
- "paddingY": "{form.paddingY}",
+ "paddingX": "{form.padding.300}",
+ "paddingY": "{form.padding.300}",
"borderRadius": "{form.borderRadius.200}",
"transitionDuration": "{form.transitionDuration}",
"focusRing": {
@@ -4730,17 +4735,17 @@
"color": "{form.focusRing.color}",
"offset": "{form.focusRing.offset}",
"shadow": "0"
+ },
+ "sm": {
+ "fontSize": "{fonts.fontSize.300}",
+ "paddingX": "{form.padding.300}",
+ "paddingY": "{form.padding.200}"
+ },
+ "lg": {
+ "fontSize": "{fonts.fontSize.300}",
+ "paddingX": "{form.padding.300}",
+ "paddingY": "{form.padding.400}"
}
- },
- "sm": {
- "fontSize": "{fonts.fontSize.300}",
- "paddingX": "{form.padding.200}",
- "paddingY": "{form.padding.200}"
- },
- "lg": {
- "fontSize": "{fonts.fontSize.300}",
- "paddingX": "{form.padding.400}",
- "paddingY": "{form.padding.400}"
}
},
"tieredmenu": {
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 };
diff --git a/src/stories/components/textarea/examples/textarea-autoresize.component.ts b/src/stories/components/textarea/examples/textarea-autoresize.component.ts
new file mode 100644
index 00000000..ee12d1f7
--- /dev/null
+++ b/src/stories/components/textarea/examples/textarea-autoresize.component.ts
@@ -0,0 +1,48 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { StoryObj } from '@storybook/angular';
+import { TextareaComponent } from '../../../../lib/components/textarea/textarea.component';
+
+export const template = `
+
+
+
+`;
+const styles = '';
+
+@Component({
+ selector: 'app-textarea-autoresize',
+ standalone: true,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [TextareaComponent, FormsModule],
+ template,
+ styles,
+})
+export class TextareaAutoResizeComponent {
+ value = '';
+}
+
+export const AutoResize: StoryObj = {
+ render: () => ({
+ template: ``,
+ }),
+ parameters: {
+ controls: { disable: true },
+ docs: {
+ description: {
+ story: 'Режим авторасширения — поле увеличивается по высоте по мере ввода текста.',
+ },
+ source: {
+ language: 'ts',
+ code: `@Component({
+ template: \`${template}\`,
+})`,
+ },
+ },
+ },
+};
diff --git a/src/stories/components/textarea/examples/textarea-disabled.component.ts b/src/stories/components/textarea/examples/textarea-disabled.component.ts
new file mode 100644
index 00000000..225fb177
--- /dev/null
+++ b/src/stories/components/textarea/examples/textarea-disabled.component.ts
@@ -0,0 +1,45 @@
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { StoryObj } from '@storybook/angular';
+import { TextareaComponent } from '../../../../lib/components/textarea/textarea.component';
+
+export const Disabled: StoryObj = {
+ name: 'Disabled',
+ render: (args) => {
+ const control = new FormControl({ value: 'Текст в заблокированном поле', disabled: true });
+ return {
+ props: { ...args, control },
+ template: ``,
+ };
+ },
+ decorators: [
+ (story: any) => ({
+ ...story(),
+ moduleMetadata: {
+ imports: [TextareaComponent, ReactiveFormsModule],
+ },
+ }),
+ ],
+ parameters: {
+ controls: { disable: true },
+ docs: {
+ description: { story: 'Отключённое состояние — управляется через FormControl.' },
+ source: {
+ language: 'ts',
+ code: `
+import { Component } from '@angular/core';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { TextareaComponent } from '@cdek-it/angular-ui-kit';
+
+@Component({
+ standalone: true,
+ imports: [TextareaComponent, ReactiveFormsModule],
+ template: \`\`,
+})
+export class DisabledExample {
+ control = new FormControl({ value: '', disabled: true });
+}
+ `,
+ },
+ },
+ },
+};
diff --git a/src/stories/components/textarea/examples/textarea-float-label.component.ts b/src/stories/components/textarea/examples/textarea-float-label.component.ts
new file mode 100644
index 00000000..d780a5dc
--- /dev/null
+++ b/src/stories/components/textarea/examples/textarea-float-label.component.ts
@@ -0,0 +1,119 @@
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+import { NgIf } from '@angular/common';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { FloatLabel } from 'primeng/floatlabel';
+import { Textarea } from 'primeng/textarea';
+import { IconField } from 'primeng/iconfield';
+import { InputIcon } from 'primeng/inputicon';
+import { StoryObj } from '@storybook/angular';
+
+@Component({
+ selector: 'app-textarea-float-label',
+ standalone: true,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [Textarea, FloatLabel, ReactiveFormsModule, NgIf, IconField, InputIcon],
+ template: `
+
+
+ @if (showClear) {
+
+
+
+
+ } @else {
+
+ }
+
+
+
+ `,
+})
+export class TextareaFloatLabelComponent {
+ control = new FormControl('');
+ @Input() label = 'Комментарий';
+ @Input() required = false;
+ @Input() showClear = false;
+}
+
+export const FloatLabelStory: StoryObj = {
+ name: 'FloatLabel',
+ render: (args) => ({
+ props: { label: args['label'], required: args['required'], showClear: args['showClear'] },
+ template: ``,
+ }),
+ args: {
+ label: 'Комментарий',
+ required: false,
+ },
+ argTypes: {
+ label: {
+ control: 'text',
+ description: 'Текст плавающей метки',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: "'Комментарий'" },
+ type: { summary: 'string' },
+ },
+ },
+ required: {
+ control: 'boolean',
+ description: 'Показывает маркер обязательного поля `*` рядом с меткой',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: 'false' },
+ type: { summary: 'boolean' },
+ },
+ },
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Интеграция с `p-floatlabel variant="in"` — плавающая метка внутри поля. `required` добавляет красный маркер `*`. Требует нативный `