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