diff --git a/src/lib/components/inputtext/inputtext.component.ts b/src/lib/components/inputtext/inputtext.component.ts new file mode 100644 index 00000000..3904b924 --- /dev/null +++ b/src/lib/components/inputtext/inputtext.component.ts @@ -0,0 +1,130 @@ +import { 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 { InputText } from 'primeng/inputtext'; +import { IconField } from 'primeng/iconfield'; +import { InputIcon } from 'primeng/inputicon'; + +export type InputTextSize = 'small' | 'base' | 'large' | 'xlarge'; + + +@Component({ + selector: 'input-text', + standalone: true, + imports: [InputText, IconField, InputIcon, NgClass], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => InputTextComponent), + multi: true, + }, + ], + template: ` + @if (showClear) { + + + + + } @else { + + } + `, +}) +export class InputTextComponent 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: InputTextSize = 'base'; + @Input() readonly = false; + @Input() showClear = false; + @Input() fluid = false; + + disabled = false; + + get invalid(): boolean { + return this._ngControl?.invalid ?? false; + } + + @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' || this.size === 'xlarge') return 'large'; + return undefined; + } + + get sizeClass(): Record { + return { 'p-inputtext-xlg': this.size === 'xlarge' }; + } + + onInput(event: Event): void { + const value = (event.target as HTMLInputElement).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 0194af83..09d9449d 100644 --- a/src/prime-preset/map-tokens.ts +++ b/src/prime-preset/map-tokens.ts @@ -6,6 +6,7 @@ import tokens from './tokens/tokens.json'; import { avatarCss } from './tokens/components/avatar'; import { buttonCss } from './tokens/components/button'; 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 { tooltipCss } from './tokens/components/tooltip'; @@ -31,6 +32,10 @@ const presetTokens: Preset = { ...(tokens.components.progressspinner as unknown as ComponentsDesignTokens['progressspinner']), css: progressspinnerCss, }, + inputtext: { + ...(tokens.components.inputtext as unknown as ComponentsDesignTokens['inputtext']), + css: inputtextCss, + }, tag: { ...(tokens.components.tag as unknown as ComponentsDesignTokens['tag']), css: tagCss, diff --git a/src/prime-preset/tokens/components/inputtext.ts b/src/prime-preset/tokens/components/inputtext.ts new file mode 100644 index 00000000..027ae6cc --- /dev/null +++ b/src/prime-preset/tokens/components/inputtext.ts @@ -0,0 +1,49 @@ +export const inputtextCss = ({ dt }: { dt: (token: string) => string }): string => ` + +/* ─── Базовые стили ─── */ +.p-inputtext { + border-width: ${dt('inputtext.extend.borderWidth')}; + line-height: ${dt('fonts.lineHeight.250')}; +} + +/* ─── Disabled ─── */ +.p-inputtext:disabled { + background: ${dt('inputtext.root.disabledBackground')}; + color: ${dt('inputtext.root.disabledColor')}; +} + +/* ─── Readonly ─── */ +.p-inputtext:enabled:read-only { + background: ${dt('inputtext.extend.readonlyBackground')}; + color: ${dt('inputtext.root.color')}; +} + +/* ─── Focus ─── */ +.p-inputtext:enabled:focus { + box-shadow: 0 0 0 ${dt('inputtext.focusRing.width')} ${dt('inputtext.focusRing.color')}; +} + +/* ─── Invalid + Focus ─── */ +.p-inputtext.p-invalid:focus { + border-color: ${dt('inputtext.root.invalidBorderColor')}; + box-shadow: 0 0 0 ${dt('inputtext.focusRing.width')} ${dt('focusRing.extend.invalid')}; +} + +/* ─── Extra Large ─── */ +.p-inputtext.p-inputtext-xlg { + font-size: ${dt('inputtext.extend.extXlg.fontSize')}; + padding: ${dt('inputtext.extend.extXlg.paddingY')} ${dt('inputtext.extend.extXlg.paddingX')}; +} + +/* ─── IconField ─── */ +.p-iconfield[data-pc-name="iconfield"] { + width: fit-content; +} + +.p-iconfield .p-inputicon { + font-size: ${dt('inputtext.extend.iconSize')}; + width: ${dt('inputtext.extend.iconSize')}; + height: ${dt('inputtext.extend.iconSize')}; + cursor: pointer; +} +`; diff --git a/src/prime-preset/tokens/tokens.json b/src/prime-preset/tokens/tokens.json index 78e7c3d9..a28b400a 100644 --- a/src/prime-preset/tokens/tokens.json +++ b/src/prime-preset/tokens/tokens.json @@ -2909,7 +2909,7 @@ "top": "{form.padding.400}" } }, - "inside": { + "in": { "input": { "paddingTop": "{form.padding.700}", "paddingBottom": "{form.padding.300}" @@ -3120,9 +3120,9 @@ "iconSize": "{form.icon.300}", "borderWidth": "{form.borderWidth}", "extXlg": { - "fontSize": "{form.fontSize}", - "paddingX": "{form.paddingX}", - "paddingY": "{form.paddingY}" + "fontSize": "{form.xlg.fontSize}", + "paddingX": "{form.padding.300}", + "paddingY": "{form.padding.600}" } }, "root": { @@ -3140,19 +3140,19 @@ "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}", "sm": { - "fontSize": "{form.fontSize}", - "paddingX": "{form.paddingX}", - "paddingY": "{form.paddingY}" + "fontSize": "{fonts.fontSize.300}", + "paddingX": "{form.padding.300}", + "paddingY": "{form.padding.200}" }, "lg": { - "fontSize": "{form.fontSize}", - "paddingX": "{form.paddingX}", - "paddingY": "{form.paddingY}" + "fontSize": "{fonts.fontSize.300}", + "paddingX": "{form.padding.300}", + "paddingY": "{form.padding.400}" }, "focusRing": { "width": "{form.focusRing.width}", diff --git a/src/stories/components/inputtext/examples/inputtext-clear.component.ts b/src/stories/components/inputtext/examples/inputtext-clear.component.ts new file mode 100644 index 00000000..d0c13671 --- /dev/null +++ b/src/stories/components/inputtext/examples/inputtext-clear.component.ts @@ -0,0 +1,40 @@ +import { StoryObj } from '@storybook/angular'; +import { InputTextComponent } from '../../../../lib/components/inputtext/inputtext.component'; + +type Story = StoryObj; + +export const ClearButton: Story = { + name: 'ClearButton', + render: (args) => ({ + props: { ...args }, + template: ` + + `, + }), + args: { + showClear: true, + placeholder: 'Введите текст...', + }, + parameters: { + docs: { + description: { + story: 'Поле с кнопкой очистки через `showClear`. Иконка × появляется при вводе первого символа.', + }, + source: { + language: 'ts', + code: ` +import { InputTextComponent } from '@cdek-it/angular-ui-kit'; + +// template: +// + `, + }, + }, + }, +}; diff --git a/src/stories/components/inputtext/examples/inputtext-disabled.component.ts b/src/stories/components/inputtext/examples/inputtext-disabled.component.ts new file mode 100644 index 00000000..4bf1948d --- /dev/null +++ b/src/stories/components/inputtext/examples/inputtext-disabled.component.ts @@ -0,0 +1,45 @@ +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { InputTextComponent } from '../../../../lib/components/inputtext/inputtext.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: [InputTextComponent, 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 { InputTextComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + standalone: true, + imports: [InputTextComponent, ReactiveFormsModule], + template: \`\`, +}) +export class DisabledExample { + control = new FormControl({ value: '', disabled: true }); +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/inputtext/examples/inputtext-float-label-invalid.component.ts b/src/stories/components/inputtext/examples/inputtext-float-label-invalid.component.ts new file mode 100644 index 00000000..35e09cc1 --- /dev/null +++ b/src/stories/components/inputtext/examples/inputtext-float-label-invalid.component.ts @@ -0,0 +1,75 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { NgIf } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { InputText } from 'primeng/inputtext'; +import { FloatLabel } from 'primeng/floatlabel'; + +@Component({ + selector: 'app-inputtext-float-label-invalid', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [InputText, FloatLabel, FormsModule, NgIf], + template: ` +
+ + + + +
+`, +}) +export class InputTextFloatLabelInvalidComponent { + value = ''; + @Input() required = false; +} + +export const FloatLabelInvalid: StoryObj = { + name: 'FloatLabel + Invalid', + render: (args) => ({ + template: ``, + props: { required: args['required'] }, + }), + args: { required: true }, + argTypes: { + required: { + control: 'boolean', + description: 'Показывает маркер обязательного поля `*` рядом с меткой', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + }, + parameters: { + docs: { + description: { + story: 'FloatLabel с невалидным состоянием — демонстрирует стилизацию ошибки в комбинации с плавающей меткой.', + }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { InputText } from 'primeng/inputtext'; +import { FloatLabel } from 'primeng/floatlabel'; +import { FormsModule } from '@angular/forms'; + +@Component({ + standalone: true, + imports: [InputText, FloatLabel, FormsModule], + template: \` + + + + + \`, +}) +export class FloatLabelInvalidExample { + value = ''; +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/inputtext/examples/inputtext-float-label.component.ts b/src/stories/components/inputtext/examples/inputtext-float-label.component.ts new file mode 100644 index 00000000..2993498b --- /dev/null +++ b/src/stories/components/inputtext/examples/inputtext-float-label.component.ts @@ -0,0 +1,75 @@ +import { Component, Input } from '@angular/core'; +import { NgIf } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { InputText } from 'primeng/inputtext'; +import { FloatLabel } from 'primeng/floatlabel'; + +@Component({ + selector: 'app-inputtext-float-label', + standalone: true, + imports: [InputText, FloatLabel, FormsModule, NgIf], + template: ` +
+ + + + +
+`, +}) +export class InputTextFloatLabelComponent { + value = ''; + @Input() required = false; +} + +export const FloatLabelStory: StoryObj = { + name: 'FloatLabel', + render: (args) => ({ + template: ``, + props: { required: args['required'] }, + }), + args: { required: true }, + argTypes: { + required: { + control: 'boolean', + description: 'Показывает маркер обязательного поля `*` рядом с меткой', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + }, + parameters: { + docs: { + description: { + story: + 'Интеграция с `p-floatlabel` — плавающая метка внутри поля. Кликните на поле чтобы увидеть анимацию. Требует нативный `` как прямой дочерний элемент `p-floatlabel`.', + }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { InputText } from 'primeng/inputtext'; +import { FloatLabel } from 'primeng/floatlabel'; +import { FormsModule } from '@angular/forms'; + +@Component({ + standalone: true, + imports: [InputText, FloatLabel, FormsModule], + template: \` + + + + + \`, +}) +export class FloatLabelExample { + value = ''; +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/inputtext/examples/inputtext-invalid.component.ts b/src/stories/components/inputtext/examples/inputtext-invalid.component.ts new file mode 100644 index 00000000..ee49015b --- /dev/null +++ b/src/stories/components/inputtext/examples/inputtext-invalid.component.ts @@ -0,0 +1,45 @@ +import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { InputTextComponent } from '../../../../lib/components/inputtext/inputtext.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: [InputTextComponent, ReactiveFormsModule], + }, + }), + ], + parameters: { + controls: { disable: true }, + docs: { + description: { story: 'Невалидное состояние — управляется через FormControl + Validators.' }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { FormControl, Validators, ReactiveFormsModule } from '@angular/forms'; +import { InputTextComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + standalone: true, + imports: [InputTextComponent, ReactiveFormsModule], + template: \`\`, +}) +export class InvalidExample { + control = new FormControl('', Validators.required); +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/inputtext/examples/inputtext-readonly.component.ts b/src/stories/components/inputtext/examples/inputtext-readonly.component.ts new file mode 100644 index 00000000..35ce1dee --- /dev/null +++ b/src/stories/components/inputtext/examples/inputtext-readonly.component.ts @@ -0,0 +1,40 @@ +import { StoryObj } from '@storybook/angular'; +import { InputTextComponent } from '../../../../lib/components/inputtext/inputtext.component'; + +type Story = StoryObj; + +export const Readonly: Story = { + name: 'Readonly', + render: (args) => ({ + props: { ...args }, + template: ` + + `, + }), + args: { + readonly: true, + placeholder: 'Введите текст...', + }, + parameters: { + docs: { + description: { + story: 'Режим только для чтения — поле отображает значение, но недоступно для редактирования.', + }, + source: { + language: 'ts', + code: ` +import { InputTextComponent } from '@cdek-it/angular-ui-kit'; + +// template: +// + `, + }, + }, + }, +}; diff --git a/src/stories/components/inputtext/inputtext.stories.ts b/src/stories/components/inputtext/inputtext.stories.ts new file mode 100644 index 00000000..57545c18 --- /dev/null +++ b/src/stories/components/inputtext/inputtext.stories.ts @@ -0,0 +1,165 @@ +import { Meta, StoryObj, moduleMetadata } from '@storybook/angular'; +import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; +import { InputTextComponent } from '../../../lib/components/inputtext/inputtext.component'; +import { ClearButton } from './examples/inputtext-clear.component'; +import { InputTextFloatLabelComponent, FloatLabelStory } from './examples/inputtext-float-label.component'; +import { InputTextFloatLabelInvalidComponent, FloatLabelInvalid } from './examples/inputtext-float-label-invalid.component'; +import { Disabled } from './examples/inputtext-disabled.component'; +import { Readonly } from './examples/inputtext-readonly.component'; +import { Invalid } from './examples/inputtext-invalid.component'; + +type InputTextArgs = InputTextComponent & { disabled: boolean; invalid: boolean }; + +const meta: Meta = { + title: 'Components/Form/InputText', + component: InputTextComponent, + tags: ['autodocs'], + decorators: [ + moduleMetadata({ + imports: [ + InputTextComponent, + ReactiveFormsModule, + InputTextFloatLabelComponent, + InputTextFloatLabelInvalidComponent, + ], + }), + ], + parameters: { + designTokens: { prefix: '--p-inputtext' }, + docs: { + description: { + component: `Текстовое поле для ввода данных. + +\`\`\`typescript +import { InputTextModule } from 'primeng/inputtext'; +\`\`\``, + }, + }, + }, + argTypes: { + // ── Props ──────────────────────────────────────────────── + placeholder: { + control: 'text', + description: 'Подсказка при пустом поле', + table: { + category: 'Props', + defaultValue: { summary: "''" }, + type: { summary: 'string' }, + }, + }, + size: { + control: 'select', + options: ['small', 'base', 'large', 'xlarge'], + description: 'Размер поля', + table: { + category: 'Props', + defaultValue: { summary: "'base'" }, + type: { summary: "'small' | 'base' | 'large' | 'xlarge'" }, + }, + }, + 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' }, + }, + }, + showClear: { + 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' }, + }, + }, + // Hidden computed props + modelValue: { table: { disable: true } }, + primeSize: { table: { disable: true } }, + sizeClass: { table: { disable: true } }, + + // ── Events ─────────────────────────────────────────────── + onClear: { + control: false, + description: 'Событие очистки поля (при showClear)', + table: { + category: 'Events', + type: { summary: 'EventEmitter' }, + }, + }, + }, + args: { + placeholder: 'Введите текст...', + size: 'base', + disabled: false, + invalid: false, + readonly: false, + showClear: false, + fluid: false, + }, +}; + +export default meta; +type Story = StoryObj; + +// ── Default ────────────────────────────────────────────────────────────────── +export const Default: Story = { + name: 'Default', + render: (args) => { + const parts: string[] = []; + + 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.showClear) parts.push(`[showClear]="true"`); + if (args.fluid) parts.push(`[fluid]="true"`); + + const validators = []; + if (args.invalid) validators.push(Validators.required); + + const control = new FormControl({ value: '', disabled: args.disabled }, validators); + + const template = ``; + + return { props: { ...args, control }, template }; + }, + parameters: { + docs: { + description: { + story: 'Базовый пример компонента. Используйте Controls для интерактивного изменения пропсов.', + }, + }, + }, +}; + +// ── Re-exports from example components ──────────────────────────────────── +export { ClearButton, FloatLabelStory as FloatLabel, FloatLabelInvalid, Disabled, Readonly, Invalid };