diff --git a/.gitignore b/.gitignore
index 875d7dc9..863dd859 100644
--- a/.gitignore
+++ b/.gitignore
@@ -58,4 +58,4 @@ src/assets/components/themes
.claude/*
-.playwright-mcp/*
\ No newline at end of file
+.playwright-mcp/*
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..43042a87
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,3 @@
+# Project Rules
+
+Основные правила и запреты — в `.claude/skills/generate-component/references/red-lines.md`.
diff --git a/src/lib/components/inputmask/inputmask.component.ts b/src/lib/components/inputmask/inputmask.component.ts
new file mode 100644
index 00000000..8abc2102
--- /dev/null
+++ b/src/lib/components/inputmask/inputmask.component.ts
@@ -0,0 +1,107 @@
+import { ChangeDetectionStrategy, Component, DestroyRef, inject, Injector, Input, OnInit, Output, EventEmitter } from '@angular/core';
+import { ControlValueAccessor, FormControl, NgControl, ReactiveFormsModule } from '@angular/forms';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { InputMask } from 'primeng/inputmask';
+
+export type InputMaskSize = 'small' | 'base' | 'large' | 'xlarge';
+
+
+@Component({
+ selector: 'input-mask',
+ standalone: true,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [InputMask, ReactiveFormsModule],
+ host: {
+ style: 'display: block',
+ '[class.input-mask-xlg]': 'size === "xlarge"',
+ },
+ template: `
+
+ `,
+})
+export class InputMaskComponent implements ControlValueAccessor, OnInit {
+ private readonly _injector = inject(Injector);
+ private readonly destroyRef = inject(DestroyRef);
+ private _ngControl: NgControl | null = null;
+
+ readonly control = new FormControl(null);
+
+ @Input() mask = '';
+ @Input() slotChar = '_';
+ @Input() autoClear = true;
+ @Input() showClear = false;
+ @Input() unmask = false;
+ @Input() placeholder = '';
+ @Input() size: InputMaskSize = 'base';
+ @Input() readonly = false;
+ @Input() fluid = false;
+ @Input() characterPattern = '[A-Za-z]';
+ @Input() keepBuffer = false;
+ @Input() autocomplete = '';
+
+ @Output() onComplete = new EventEmitter();
+ @Output() onFocusEvent = new EventEmitter();
+ @Output() onBlurEvent = new EventEmitter();
+ @Output() onInputEvent = new EventEmitter();
+ @Output() onKeydownEvent = new EventEmitter();
+ @Output() onClearEvent = new EventEmitter();
+
+ private _onChange: (value: string | null) => void = () => {};
+ private _onTouched: () => void = () => {};
+
+ ngOnInit(): void {
+ this._ngControl = this._injector.get(NgControl, null, { self: true, optional: true });
+
+ this.control.valueChanges
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe(v => this._onChange(v));
+ }
+
+ get invalid(): boolean {
+ return this._ngControl?.invalid ?? false;
+ }
+
+ get primeSize(): 'small' | 'large' | undefined {
+ if (this.size === 'small') return 'small';
+ if (this.size === 'large' || this.size === 'xlarge') return 'large';
+ return undefined;
+ }
+
+ writeValue(value: string | null): void {
+ this.control.setValue(value ?? null, { emitEvent: false });
+ }
+
+ registerOnChange(fn: (value: string | null) => void): void {
+ this._onChange = fn;
+ }
+
+ registerOnTouched(fn: () => void): void {
+ this._onTouched = fn;
+ }
+
+ setDisabledState(isDisabled: boolean): void {
+ isDisabled ? this.control.disable({ emitEvent: false }) : this.control.enable({ emitEvent: false });
+ }
+}
diff --git a/src/prime-preset/map-tokens.ts b/src/prime-preset/map-tokens.ts
index 16a3568d..4d14b9ba 100644
--- a/src/prime-preset/map-tokens.ts
+++ b/src/prime-preset/map-tokens.ts
@@ -7,6 +7,7 @@ import { avatarCss } from './tokens/components/avatar';
import { buttonCss } from './tokens/components/button';
import { cardCss } from './tokens/components/card';
import { checkboxCss } from './tokens/components/checkbox';
+import { inputmaskCss } from './tokens/components/inputmask';
import { inputtextCss } from './tokens/components/inputtext';
import { progressspinnerCss } from './tokens/components/progressspinner';
import { tagCss } from './tokens/components/tag';
@@ -14,6 +15,7 @@ import { timelineCss } from './tokens/components/timeline';
import { tooltipCss } from './tokens/components/tooltip';
import { megamenuCss } from './tokens/components/megamenu';
import { selectCss } from './tokens/components/select';
+import { messageCss } from './tokens/components/message';
const presetTokens: Preset = {
primitive: tokens.primitive as unknown as AuraBaseDesignTokens['primitive'],
@@ -48,6 +50,9 @@ const presetTokens: Preset = {
...(tokens.components.inputtext as unknown as ComponentsDesignTokens['inputtext']),
css: inputtextCss,
},
+ inputmask: {
+ css: inputmaskCss,
+ },
tag: {
...(tokens.components.tag as unknown as ComponentsDesignTokens['tag']),
css: tagCss,
diff --git a/src/prime-preset/tokens/components/inputmask.ts b/src/prime-preset/tokens/components/inputmask.ts
new file mode 100644
index 00000000..8132a1ac
--- /dev/null
+++ b/src/prime-preset/tokens/components/inputmask.ts
@@ -0,0 +1,8 @@
+export const inputmaskCss = ({ dt }: { dt: (token: string) => string }): string => `
+
+/* ─── Sizes ─── */
+input-mask.input-mask-xlg .p-inputtext {
+ font-size: ${dt('inputtext.extend.extXlg.fontSize')};
+ padding: ${dt('inputtext.extend.extXlg.paddingY')} ${dt('inputtext.extend.extXlg.paddingX')};
+}
+`;
diff --git a/src/stories/components/inputmask/examples/inputmask-disabled.component.ts b/src/stories/components/inputmask/examples/inputmask-disabled.component.ts
new file mode 100644
index 00000000..9667d0ca
--- /dev/null
+++ b/src/stories/components/inputmask/examples/inputmask-disabled.component.ts
@@ -0,0 +1,47 @@
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { StoryObj } from '@storybook/angular';
+import { InputMaskComponent } from '../../../../lib/components/inputmask/inputmask.component';
+
+export const Disabled: StoryObj = {
+ name: 'Disabled',
+ render: (args) => {
+ const control = new FormControl({ value: '12-34-56', disabled: true });
+ return {
+ props: { ...args, control },
+ template: ``,
+ };
+ },
+ decorators: [
+ (story: any) => ({
+ ...story(),
+ moduleMetadata: {
+ imports: [InputMaskComponent, 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 { InputMaskComponent } from '@cdek-it/angular-ui-kit';
+
+@Component({
+ standalone: true,
+ imports: [InputMaskComponent, ReactiveFormsModule],
+ template: \`\`,
+})
+export class DisabledExample {
+ control = new FormControl({ value: '12-34-56', disabled: true });
+}
+ `,
+ },
+ },
+ },
+};
diff --git a/src/stories/components/inputmask/examples/inputmask-float-label.component.ts b/src/stories/components/inputmask/examples/inputmask-float-label.component.ts
new file mode 100644
index 00000000..8cd181b8
--- /dev/null
+++ b/src/stories/components/inputmask/examples/inputmask-float-label.component.ts
@@ -0,0 +1,67 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { StoryObj } from '@storybook/angular';
+import { InputMask } from 'primeng/inputmask';
+import { FloatLabel } from 'primeng/floatlabel';
+
+export const template = `
+
+`;
+const styles = '';
+
+@Component({
+ selector: 'app-inputmask-float-label',
+ standalone: true,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [InputMask, FloatLabel, ReactiveFormsModule],
+ template,
+ styles,
+})
+export class InputMaskFloatLabelComponent {
+ readonly control = new FormControl('');
+}
+
+export const FloatLabelStory: StoryObj = {
+ name: 'FloatLabel',
+ render: () => ({
+ template: ``,
+ }),
+ parameters: {
+ controls: { disable: true },
+ docs: {
+ description: {
+ story:
+ 'Интеграция с `p-floatlabel` — плавающая метка внутри поля. Кликните на поле чтобы увидеть анимацию. Требует нативный `` как прямой дочерний элемент `p-floatlabel`.',
+ },
+ source: {
+ language: 'ts',
+ code: `
+import { Component } from '@angular/core';
+import { InputMask } from 'primeng/inputmask';
+import { FloatLabel } from 'primeng/floatlabel';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+
+@Component({
+ selector: 'app-inputmask-float-label',
+ standalone: true,
+ imports: [InputMask, FloatLabel, ReactiveFormsModule],
+ template: \`
+
+
+
+
+ \`,
+})
+export class InputMaskFloatLabelComponent {
+ readonly control = new FormControl('');
+}
+ `,
+ },
+ },
+ },
+};
diff --git a/src/stories/components/inputmask/examples/inputmask-invalid.component.ts b/src/stories/components/inputmask/examples/inputmask-invalid.component.ts
new file mode 100644
index 00000000..699a89bb
--- /dev/null
+++ b/src/stories/components/inputmask/examples/inputmask-invalid.component.ts
@@ -0,0 +1,47 @@
+import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
+import { StoryObj } from '@storybook/angular';
+import { InputMaskComponent } from '../../../../lib/components/inputmask/inputmask.component';
+
+export const Invalid: StoryObj = {
+ name: 'Invalid',
+ render: (args) => {
+ const control = new FormControl('', Validators.required);
+ return {
+ props: { ...args, control },
+ template: ``,
+ };
+ },
+ decorators: [
+ (story: any) => ({
+ ...story(),
+ moduleMetadata: {
+ imports: [InputMaskComponent, ReactiveFormsModule],
+ },
+ }),
+ ],
+ parameters: {
+ controls: { disable: true },
+ docs: {
+ description: {
+ story: 'Невалидное состояние — определяется через валидаторы `FormControl`.',
+ },
+ source: {
+ language: 'ts',
+ code: `
+import { Component } from '@angular/core';
+import { FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
+import { InputMaskComponent } from '@cdek-it/angular-ui-kit';
+
+@Component({
+ standalone: true,
+ imports: [InputMaskComponent, ReactiveFormsModule],
+ template: \`\`,
+})
+export class InvalidExample {
+ control = new FormControl('', Validators.required);
+}
+ `,
+ },
+ },
+ },
+};
diff --git a/src/stories/components/inputmask/examples/inputmask-readonly.component.ts b/src/stories/components/inputmask/examples/inputmask-readonly.component.ts
new file mode 100644
index 00000000..b800f435
--- /dev/null
+++ b/src/stories/components/inputmask/examples/inputmask-readonly.component.ts
@@ -0,0 +1,47 @@
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { StoryObj } from '@storybook/angular';
+import { InputMaskComponent } from '../../../../lib/components/inputmask/inputmask.component';
+
+export const Readonly: StoryObj = {
+ name: 'Readonly',
+ render: (args) => {
+ const control = new FormControl('12-34-56');
+ return {
+ props: { ...args, control },
+ template: ``,
+ };
+ },
+ decorators: [
+ (story: any) => ({
+ ...story(),
+ moduleMetadata: {
+ imports: [InputMaskComponent, ReactiveFormsModule],
+ },
+ }),
+ ],
+ parameters: {
+ controls: { disable: true },
+ docs: {
+ description: {
+ story: 'Режим только для чтения — поле отображает значение, но недоступно для редактирования.',
+ },
+ source: {
+ language: 'ts',
+ code: `
+import { Component } from '@angular/core';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { InputMaskComponent } from '@cdek-it/angular-ui-kit';
+
+@Component({
+ standalone: true,
+ imports: [InputMaskComponent, ReactiveFormsModule],
+ template: \`\`,
+})
+export class ReadonlyExample {
+ control = new FormControl('12-34-56');
+}
+ `,
+ },
+ },
+ },
+};
diff --git a/src/stories/components/inputmask/examples/inputmask-sizes.component.ts b/src/stories/components/inputmask/examples/inputmask-sizes.component.ts
new file mode 100644
index 00000000..021d25e5
--- /dev/null
+++ b/src/stories/components/inputmask/examples/inputmask-sizes.component.ts
@@ -0,0 +1,55 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { StoryObj } from '@storybook/angular';
+import { InputMaskComponent } from '../../../../lib/components/inputmask/inputmask.component';
+
+type Story = StoryObj;
+
+export const Sizes: Story = {
+ name: 'Sizes',
+ render: (args) => ({
+ props: { ...args, control: new FormControl('') },
+ template: `
+
+ `,
+ }),
+ args: {
+ mask: '99-99-99',
+ size: 'small',
+ placeholder: '99-99-99',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Размеры поля: small, base, large, xlarge. Переключайте через Controls.',
+ },
+ source: {
+ language: 'ts',
+ code: `
+import { Component } from '@angular/core';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { InputMaskComponent } from '@cdek-it/angular-ui-kit';
+
+@Component({
+ standalone: true,
+ imports: [InputMaskComponent, ReactiveFormsModule],
+ template: \`\`,
+})
+export class SizesExample {
+ control = new FormControl('');
+}
+ `,
+ },
+ },
+ },
+};
diff --git a/src/stories/components/inputmask/inputmask.stories.ts b/src/stories/components/inputmask/inputmask.stories.ts
new file mode 100644
index 00000000..924ccb50
--- /dev/null
+++ b/src/stories/components/inputmask/inputmask.stories.ts
@@ -0,0 +1,231 @@
+import { Meta, StoryObj, moduleMetadata } from '@storybook/angular';
+import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { InputMaskComponent } from '../../../lib/components/inputmask/inputmask.component';
+import { InputMaskFloatLabelComponent, FloatLabelStory } from './examples/inputmask-float-label.component';
+import { Sizes } from './examples/inputmask-sizes.component';
+import { Disabled } from './examples/inputmask-disabled.component';
+import { Readonly } from './examples/inputmask-readonly.component';
+import { Invalid } from './examples/inputmask-invalid.component';
+
+type InputMaskArgs = InputMaskComponent;
+
+const meta: Meta = {
+ title: 'Components/Form/InputMask',
+ component: InputMaskComponent,
+ tags: ['autodocs'],
+ decorators: [
+ moduleMetadata({
+ imports: [
+ InputMaskComponent,
+ FormsModule,
+ ReactiveFormsModule,
+ InputMaskFloatLabelComponent,
+ ],
+ }),
+ ],
+ parameters: {
+ designTokens: { prefix: '--p-inputmask' },
+ docs: {
+ description: {
+ component: `Компонент текстового ввода по маске. Используется для ввода данных в определённом формате: дата, телефон, серийный номер и т.д.
+
+\`\`\`typescript
+import { InputMaskComponent } from '@cdek-it/angular-ui-kit';
+\`\`\``,
+ },
+ },
+ },
+ argTypes: {
+ mask: {
+ control: 'text',
+ description: 'Маска ввода (9 — цифра, a — буква, * — любой символ)',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: "''" },
+ type: { summary: 'string' },
+ },
+ },
+ slotChar: {
+ control: 'text',
+ description: 'Символ-заполнитель для пустых позиций маски',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: "'_'" },
+ type: { summary: 'string' },
+ },
+ },
+ unmask: {
+ control: 'boolean',
+ description: 'Возвращать чистое значение без символов маски',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: 'false' },
+ type: { summary: 'boolean' },
+ },
+ },
+ autoClear: {
+ control: 'boolean',
+ description: 'Очищать незавершённое значение при потере фокуса',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: 'true' },
+ type: { summary: 'boolean' },
+ },
+ },
+ showClear: {
+ control: 'boolean',
+ description: 'Показывает иконку очистки при наличии значения',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: 'false' },
+ type: { summary: 'boolean' },
+ },
+ },
+ placeholder: {
+ control: 'text',
+ description: 'Подсказка при пустом поле',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: "''" },
+ type: { summary: 'string' },
+ },
+ },
+ size: {
+ control: 'select',
+ options: ['small', 'base', 'large', 'xlarge'] as const,
+ description: 'Размер поля',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: "'base'" },
+ type: { summary: "'small' | 'base' | 'large' | 'xlarge'" },
+ },
+ },
+ 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' },
+ },
+ },
+ characterPattern: {
+ control: 'text',
+ description: 'Регулярное выражение для символов типа a в маске',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: "'[A-Za-z]'" },
+ type: { summary: 'string' },
+ },
+ },
+ keepBuffer: {
+ control: 'boolean',
+ description: 'Сохранять введённые символы при очистке маски',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: 'false' },
+ type: { summary: 'boolean' },
+ },
+ },
+ autocomplete: {
+ control: 'text',
+ description: 'Значение атрибута autocomplete для input',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: "''" },
+ type: { summary: 'string' },
+ },
+ },
+ control: { table: { disable: true } },
+ invalid: { table: { disable: true } },
+ primeSize: { table: { disable: true } },
+ writeValue: { table: { disable: true } },
+ registerOnChange: { table: { disable: true } },
+ registerOnTouched: { table: { disable: true } },
+ setDisabledState: { table: { disable: true } },
+ onComplete: {
+ control: false,
+ description: 'Событие завершения ввода маски',
+ table: { category: 'Events', type: { summary: 'EventEmitter' } },
+ },
+ onFocusEvent: {
+ control: false,
+ description: 'Событие фокуса',
+ table: { category: 'Events', type: { summary: 'EventEmitter' } },
+ },
+ onBlurEvent: {
+ control: false,
+ description: 'Событие потери фокуса',
+ table: { category: 'Events', type: { summary: 'EventEmitter' } },
+ },
+ onInputEvent: {
+ control: false,
+ description: 'Событие ввода',
+ table: { category: 'Events', type: { summary: 'EventEmitter' } },
+ },
+ onKeydownEvent: {
+ control: false,
+ description: 'Событие нажатия клавиши',
+ table: { category: 'Events', type: { summary: 'EventEmitter' } },
+ },
+ onClearEvent: {
+ control: false,
+ description: 'Событие очистки поля',
+ table: { category: 'Events', type: { summary: 'EventEmitter' } },
+ },
+ },
+ args: {
+ mask: '99-99-99',
+ slotChar: '_',
+ unmask: false,
+ autoClear: true,
+ showClear: false,
+ placeholder: '99-99-99',
+ size: 'base',
+ readonly: false,
+ fluid: false,
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ name: 'Default',
+ render: (args) => {
+ const parts: string[] = [];
+
+ if (args.mask) parts.push(`mask="${args.mask}"`);
+ if (args.slotChar && args.slotChar !== '_') parts.push(`slotChar="${args.slotChar}"`);
+ if (args.unmask) parts.push(`[unmask]="true"`);
+ if (!args.autoClear) parts.push(`[autoClear]="false"`);
+ if (args.showClear) parts.push(`[showClear]="true"`);
+ if (args.placeholder) parts.push(`placeholder="${args.placeholder}"`);
+ if (args.size && args.size !== 'base') parts.push(`size="${args.size}"`);
+ if (args.readonly) parts.push(`[readonly]="true"`);
+ if (args.fluid) parts.push(`[fluid]="true"`);
+ parts.push(`[formControl]="control"`);
+
+ const template = ``;
+
+ return { props: { ...args, control: new FormControl('') }, template };
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Базовый пример компонента. Используйте Controls для интерактивного изменения пропсов.',
+ },
+ },
+ },
+};
+
+export { Sizes, FloatLabelStory as FloatLabel, Disabled, Readonly, Invalid };