diff --git a/src/lib/components/inputotp/inputotp.component.ts b/src/lib/components/inputotp/inputotp.component.ts new file mode 100644 index 00000000..f0f745db --- /dev/null +++ b/src/lib/components/inputotp/inputotp.component.ts @@ -0,0 +1,108 @@ +import { Component, DestroyRef, forwardRef, inject, Injector, Input, OnInit, Output, EventEmitter } from '@angular/core'; +import { ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR, NgControl, ReactiveFormsModule } from '@angular/forms'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { NgClass } from '@angular/common'; +import { InputOtp, InputOtpChangeEvent } from 'primeng/inputotp'; + +export type InputOtpSize = 'small' | 'base' | 'large' | 'xlarge'; + +@Component({ + selector: 'input-otp', + standalone: true, + imports: [InputOtp, ReactiveFormsModule, NgClass], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => InputOtpComponent), + multi: true, + }, + ], + template: ` + + `, +}) +export class InputOtpComponent implements ControlValueAccessor, OnInit { + private readonly _injector = inject(Injector); + private readonly destroyRef = inject(DestroyRef); + private _ngControl: NgControl | null = null; + + readonly control = new FormControl(null); + + @Input() length = 4; + @Input() mask = false; + @Input() integerOnly = false; + @Input() readonly = false; + @Input() size: InputOtpSize = 'base'; + @Input() tabindex: number | null = null; + @Input() autofocus = false; + + disabled = false; + + @Output() onChange = new EventEmitter(); + @Output() onFocus = new EventEmitter(); + @Output() onBlur = new EventEmitter(); + + private _onChange: (value: any) => 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); + this._onTouched(); + }); + } + + 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; + } + + get sizeClass(): Record { + return { 'p-inputotp-xlg': this.size === 'xlarge' }; + } + + onChangeHandler(event: InputOtpChangeEvent): void { + this.onChange.emit(event); + } + + writeValue(value: any): void { + this.control.setValue(value ?? null, { emitEvent: false }); + } + + registerOnChange(fn: (value: any) => void): void { + this._onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this._onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + isDisabled ? this.control.disable({ emitEvent: false }) : this.control.enable({ emitEvent: false }); + } +} diff --git a/src/lib/components/password/password.component.ts b/src/lib/components/password/password.component.ts new file mode 100644 index 00000000..2b1ebe0e --- /dev/null +++ b/src/lib/components/password/password.component.ts @@ -0,0 +1,136 @@ +import { ChangeDetectionStrategy, Component, ContentChild, EventEmitter, Input, Output, TemplateRef, forwardRef } from '@angular/core'; +import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { NgTemplateOutlet } from '@angular/common'; +import { Password } from 'primeng/password'; +import { PrimeTemplate } from 'primeng/api'; +import { FloatLabel } from 'primeng/floatlabel'; + +export type PasswordSize = 'small' | 'base' | 'large' | 'xlarge'; + +@Component({ + selector: 'password', + host: { style: 'display: block' }, + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [Password, FormsModule, FloatLabel, NgTemplateOutlet, PrimeTemplate], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => PasswordComponent), + multi: true, + }, + ], + template: ` + @if (floatLabel) { + + + + + } @else { + + } + + + + @if (headerTemplate) { + + + + } + @if (footerTemplate) { + + + + } + + + `, +}) +export class PasswordComponent implements ControlValueAccessor { + @ContentChild('header') headerTemplate: TemplateRef | null = null; + @ContentChild('footer') footerTemplate: TemplateRef | null = null; + + @Input() feedback = true; + @Input() toggleMask = false; + @Input() disabled = false; + @Input() placeholder: string | undefined = undefined; + @Input() size: PasswordSize = 'base'; + @Input() variant: 'filled' | 'outlined' = 'outlined'; + @Input() fluid = false; + @Input() invalid = false; + @Input() floatLabel = false; + @Input() label = ''; + @Input() promptLabel = 'Введите пароль'; + @Input() weakLabel = 'Слабый'; + @Input() mediumLabel = 'Средний'; + @Input() strongLabel = 'Надёжный'; + @Input() inputId: string | undefined = undefined; + @Input() inputStyleClass: string | undefined = undefined; + @Input() ariaLabel: string | undefined = undefined; + @Input() ariaLabelledBy: string | undefined = undefined; + @Input() appendTo: any = 'body'; + @Input() autofocus = false; + + @Output() onFocus = new EventEmitter(); + @Output() onBlur = new EventEmitter(); + + get sizeClass(): string { + if (this.size === 'small') return 'p-inputtext-sm'; + if (this.size === 'large') return 'p-inputtext-lg'; + if (this.size === 'xlarge') return 'p-inputtext-lg p-inputtext-xlg'; + return ''; + } + + get computedInputStyleClass(): string { + return [this.sizeClass, this.inputStyleClass].filter(Boolean).join(' '); + } + + modelValue: string | null = null; + + private _onChange: (value: string | null) => void = () => {}; + private _onTouched: () => void = () => {}; + + handleChange(value: string | null): void { + this.modelValue = value; + this._onChange(value); + this._onTouched(); + } + + writeValue(value: string | null): void { + this.modelValue = value; + } + + registerOnChange(fn: (value: string | null) => void): void { + this._onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this._onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } +} diff --git a/src/prime-preset/map-tokens.ts b/src/prime-preset/map-tokens.ts index 4d14b9ba..099454a8 100644 --- a/src/prime-preset/map-tokens.ts +++ b/src/prime-preset/map-tokens.ts @@ -10,12 +10,14 @@ 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 { passwordCss } from './tokens/components/password'; import { tagCss } from './tokens/components/tag'; -import { timelineCss } from './tokens/components/timeline'; +import { textareaCss } from './tokens/components/textarea'; import { tooltipCss } from './tokens/components/tooltip'; import { megamenuCss } from './tokens/components/megamenu'; import { selectCss } from './tokens/components/select'; import { messageCss } from './tokens/components/message'; +import { inputotpCss } from './tokens/components/inputotp'; const presetTokens: Preset = { primitive: tokens.primitive as unknown as AuraBaseDesignTokens['primitive'], @@ -46,6 +48,10 @@ const presetTokens: Preset = { ...(tokens.components.progressspinner as unknown as ComponentsDesignTokens['progressspinner']), css: progressspinnerCss, }, + inputotp: { + ...(tokens.components.inputotp as unknown as ComponentsDesignTokens['inputotp']), + css: inputotpCss, + }, inputtext: { ...(tokens.components.inputtext as unknown as ComponentsDesignTokens['inputtext']), css: inputtextCss, @@ -57,9 +63,9 @@ const presetTokens: Preset = { ...(tokens.components.tag as unknown as ComponentsDesignTokens['tag']), css: tagCss, }, - timeline: { - ...(tokens.components.timeline as unknown as ComponentsDesignTokens['timeline']), - css: timelineCss, + textarea: { + ...(tokens.components.textarea as unknown as ComponentsDesignTokens['textarea']), + css: textareaCss, }, tooltip: { ...(tokens.components.tooltip as unknown as ComponentsDesignTokens['tooltip']), diff --git a/src/prime-preset/tokens/components/inputotp.ts b/src/prime-preset/tokens/components/inputotp.ts new file mode 100644 index 00000000..de80e1d9 --- /dev/null +++ b/src/prime-preset/tokens/components/inputotp.ts @@ -0,0 +1,51 @@ +export const inputotpCss = ({ dt }: { dt: (token: string) => string }): string => ` +/* Стили границы */ +.p-inputotp.p-component .p-inputtext { + border-width: ${dt('inputotp.extend.borderWidth')}; + padding-inline: 0; +} + +/* ─── Disabled ─── */ +.p-inputotp.p-component .p-inputtext:disabled { + background: ${dt('inputtext.root.disabledBackground')}; + color: ${dt('inputtext.root.disabledColor')}; +} + +/* ─── Readonly ─── */ +.p-inputotp.p-component .p-inputtext:enabled:read-only { + background: ${dt('inputtext.extend.readonlyBackground')}; + color: ${dt('inputtext.root.color')}; +} + +/* ─── Focus ─── */ +.p-inputotp.p-component .p-inputtext:enabled:focus { + box-shadow: 0 0 0 ${dt('inputtext.focusRing.width')} ${dt('inputtext.focusRing.color')}; +} + +/* ─── Invalid + Focus ─── */ +.p-inputotp.p-component .p-inputtext.p-invalid:focus { + border-color: ${dt('inputtext.root.invalidBorderColor')}; + box-shadow: 0 0 0 ${dt('inputtext.focusRing.width')} ${dt('focusRing.extend.invalid')}; +} + +/* ─── Small ─── */ +.p-inputotp.p-component .p-inputtext.p-inputtext-sm { + padding-block: ${dt('inputtext.root.sm.paddingY')}; +} + +/* ─── Base ─── */ +.p-inputotp.p-component .p-inputtext:not(.p-inputtext-sm):not(.p-inputtext-lg):not(.p-inputtext-xlg) { + padding-block: ${dt('inputtext.root.paddingY')}; +} + +/* ─── Large ─── */ +.p-inputotp.p-component .p-inputtext.p-inputtext-lg { + padding-block: ${dt('inputtext.root.lg.paddingY')}; +} + +/* ─── Extra Large ─── */ +.p-inputotp.p-component.p-inputotp-xlg .p-inputtext { + font-size: ${dt('inputtext.extend.extXlg.fontSize')}; + padding-block: ${dt('inputtext.extend.extXlg.paddingY')}; +} +`; diff --git a/src/prime-preset/tokens/components/password.ts b/src/prime-preset/tokens/components/password.ts new file mode 100644 index 00000000..b682ad02 --- /dev/null +++ b/src/prime-preset/tokens/components/password.ts @@ -0,0 +1,97 @@ +/** + * Кастомная CSS-стилизация для компонента p-password. + * Подключается в map-tokens.ts: `import { passwordCss } from './components/password'` + */ +export const passwordCss = ({ dt }: { dt: (token: string) => string }): string => ` + /* ─── Иконки управления ─── */ + .p-password-toggle-mask-icon, + .p-icon.p-password-toggle-mask-icon.p-password-unmask-icon { + cursor: pointer; + color: ${dt('password.icon.color')}; + } + + /* ─── Оверлей и индикатор ─── */ + .p-password-overlay { + border-width: ${dt('password.extend.borderWidth')}; + } + + .p-password-meter-text { + font-family: ${dt('fonts.fontFamily.base')}; + font-size: ${dt('fonts.fontSize.200')}; + font-weight: ${dt('fonts.fontWeight.regular')}; + line-height: ${dt('fonts.lineHeight.250')}; + color: ${dt('password.overlay.color')}; + } + + /* ─── Focus ─── */ + .p-password:has(.p-inputtext:enabled:focus) { + box-shadow: 0 0 0 ${dt('inputtext.focusRing.width')} ${dt('inputtext.focusRing.color')}; + border-radius: ${dt('inputtext.root.borderRadius')}; + } + + /* ─── Invalid + Focus ─── */ + .p-password:has(.p-inputtext.p-invalid:focus) { + box-shadow: 0 0 0 ${dt('inputtext.focusRing.width')} ${dt('focusRing.extend.invalid')}; + border-radius: ${dt('inputtext.root.borderRadius')}; + } + + .p-password:has(.p-inputtext.p-invalid:focus) .p-inputtext { + border-color: ${dt('inputtext.root.invalidBorderColor')}; + } + + /* ─── FloatLabel ─── */ + .p-floatlabel:has(.p-password) label { + font-family: ${dt('fonts.fontFamily.base')}; + font-weight: ${dt('floatlabel.root.fontWeight')}; + line-height: ${dt('fonts.lineHeight.250')}; + color: ${dt('floatlabel.root.color')}; + } + + .p-floatlabel:has(.p-password) .p-floatlabel-active label { + font-weight: ${dt('floatlabel.root.active.fontWeight')}; + } + + .p-floatlabel-in .p-password .p-inputtext { + font-family: ${dt('fonts.fontFamily.base')}; + padding-block-start: ${dt('floatlabel.in.input.paddingTop')}; + padding-block-end: ${dt('floatlabel.in.input.paddingBottom')}; + } + + /* ─── Кастомный контент (правила пароля) ─── */ + .p-password-rules { + display: flex; + flex-direction: column; + gap: ${dt('password.content.gap')}; + margin: 0; + padding: 0; + list-style: none; + } + + .p-password-rule { + display: flex; + align-items: center; + gap: ${dt('password.content.gap')}; + font-family: ${dt('fonts.fontFamily.base')}; + font-size: ${dt('fonts.fontSize.200')}; + font-weight: ${dt('fonts.fontWeight.regular')}; + line-height: ${dt('fonts.lineHeight.250')}; + color: ${dt('password.overlay.color')}; + } + + /* ─── Состояния иконок правил ─── */ + .p-password-rule i { + font-size: ${dt('fonts.fontSize.200')}; + } + + .p-password-rule .ti-circle { + color: ${dt('surface.400')}; + } + + .p-password-rule .ti-circle-check { + color: ${dt('password.colorScheme.light.strength.strongBackground')}; + } + + .p-password-rule .ti-circle-x { + color: ${dt('password.colorScheme.light.strength.weakBackground')}; + } +`; diff --git a/src/prime-preset/tokens/tokens.json b/src/prime-preset/tokens/tokens.json index 79fe007d..8bf3f92d 100644 --- a/src/prime-preset/tokens/tokens.json +++ b/src/prime-preset/tokens/tokens.json @@ -3968,6 +3968,16 @@ "icon": { "color": "{form.placeholderColor}" } + }, + "dark": { + "strength": { + "weakBackground": "{error.500}", + "mediumBackground": "{warn.500}", + "strongBackground": "{success.600}" + }, + "icon": { + "color": "{form.placeholderColor}" + } } }, "meter": { diff --git a/src/stories/components/inputotp/examples/inputotp-disabled.component.ts b/src/stories/components/inputotp/examples/inputotp-disabled.component.ts new file mode 100644 index 00000000..4afef35f --- /dev/null +++ b/src/stories/components/inputotp/examples/inputotp-disabled.component.ts @@ -0,0 +1,47 @@ +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { InputOtpComponent } from '../../../../lib/components/inputotp/inputotp.component'; + +export const Disabled: StoryObj = { + name: 'Disabled', + render: (args) => { + const control = new FormControl({ value: '1234', disabled: true }); + return { + props: { ...args, control }, + template: ``, + }; + }, + decorators: [ + (story: any) => ({ + ...story(), + moduleMetadata: { + imports: [InputOtpComponent, 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 { InputOtpComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + standalone: true, + imports: [InputOtpComponent, ReactiveFormsModule], + template: \`\`, +}) +export class DisabledExample { + control = new FormControl({ value: '1234', disabled: true }); +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/inputotp/examples/inputotp-integeronly.component.ts b/src/stories/components/inputotp/examples/inputotp-integeronly.component.ts new file mode 100644 index 00000000..513d5377 --- /dev/null +++ b/src/stories/components/inputotp/examples/inputotp-integeronly.component.ts @@ -0,0 +1,47 @@ +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { InputOtpComponent } from '../../../../lib/components/inputotp/inputotp.component'; + +export const IntegerOnly: StoryObj = { + name: 'IntegerOnly', + render: (args) => { + const control = new FormControl(null); + return { + props: { ...args, control }, + template: ``, + }; + }, + decorators: [ + (story: any) => ({ + ...story(), + moduleMetadata: { + imports: [InputOtpComponent, ReactiveFormsModule], + }, + }), + ], + parameters: { + controls: { disable: true }, + docs: { + description: { + story: 'Ввод только цифр.', + }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { InputOtpComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + standalone: true, + imports: [InputOtpComponent, ReactiveFormsModule], + template: \`\`, +}) +export class IntegerOnlyExample { + control = new FormControl(null); +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/inputotp/examples/inputotp-invalid.component.ts b/src/stories/components/inputotp/examples/inputotp-invalid.component.ts new file mode 100644 index 00000000..0b975f44 --- /dev/null +++ b/src/stories/components/inputotp/examples/inputotp-invalid.component.ts @@ -0,0 +1,47 @@ +import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { InputOtpComponent } from '../../../../lib/components/inputotp/inputotp.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: [InputOtpComponent, 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 { InputOtpComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + standalone: true, + imports: [InputOtpComponent, ReactiveFormsModule], + template: \`\`, +}) +export class InvalidExample { + control = new FormControl('', Validators.required); +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/inputotp/examples/inputotp-mask.component.ts b/src/stories/components/inputotp/examples/inputotp-mask.component.ts new file mode 100644 index 00000000..4e4fd330 --- /dev/null +++ b/src/stories/components/inputotp/examples/inputotp-mask.component.ts @@ -0,0 +1,47 @@ +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { InputOtpComponent } from '../../../../lib/components/inputotp/inputotp.component'; + +export const Mask: StoryObj = { + name: 'Mask', + render: (args) => { + const control = new FormControl('1234'); + return { + props: { ...args, control }, + template: ``, + }; + }, + decorators: [ + (story: any) => ({ + ...story(), + moduleMetadata: { + imports: [InputOtpComponent, ReactiveFormsModule], + }, + }), + ], + parameters: { + controls: { disable: true }, + docs: { + description: { + story: 'Маскированный ввод — символы скрыты.', + }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { InputOtpComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + standalone: true, + imports: [InputOtpComponent, ReactiveFormsModule], + template: \`\`, +}) +export class MaskExample { + control = new FormControl('1234'); +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/inputotp/examples/inputotp-readonly.component.ts b/src/stories/components/inputotp/examples/inputotp-readonly.component.ts new file mode 100644 index 00000000..385ef945 --- /dev/null +++ b/src/stories/components/inputotp/examples/inputotp-readonly.component.ts @@ -0,0 +1,47 @@ +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { InputOtpComponent } from '../../../../lib/components/inputotp/inputotp.component'; + +export const Readonly: StoryObj = { + name: 'Readonly', + render: (args) => { + const control = new FormControl('1234'); + return { + props: { ...args, control }, + template: ``, + }; + }, + decorators: [ + (story: any) => ({ + ...story(), + moduleMetadata: { + imports: [InputOtpComponent, ReactiveFormsModule], + }, + }), + ], + parameters: { + controls: { disable: true }, + docs: { + description: { + story: 'Режим только для чтения — поле отображает значение, но недоступно для редактирования.', + }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { InputOtpComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + standalone: true, + imports: [InputOtpComponent, ReactiveFormsModule], + template: \`\`, +}) +export class ReadonlyExample { + control = new FormControl('1234'); +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/inputotp/inputotp.stories.ts b/src/stories/components/inputotp/inputotp.stories.ts new file mode 100644 index 00000000..4e75a297 --- /dev/null +++ b/src/stories/components/inputotp/inputotp.stories.ts @@ -0,0 +1,181 @@ +import { Meta, StoryObj, moduleMetadata } from '@storybook/angular'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { InputOtpComponent } from '../../../lib/components/inputotp/inputotp.component'; +import { Disabled } from './examples/inputotp-disabled.component'; +import { Invalid } from './examples/inputotp-invalid.component'; +import { Mask } from './examples/inputotp-mask.component'; +import { Readonly } from './examples/inputotp-readonly.component'; +import { IntegerOnly } from './examples/inputotp-integeronly.component'; + +type InputOtpArgs = InputOtpComponent; + +const meta: Meta = { + title: 'Components/Form/InputOtp', + component: InputOtpComponent, + tags: ['autodocs'], + decorators: [ + moduleMetadata({ + imports: [ + InputOtpComponent, + ReactiveFormsModule, + ], + }), + ], + parameters: { + designTokens: { prefix: '--p-inputotp' }, + docs: { + description: { + component: `Компонент для ввода одноразовых паролей (OTP). + +\`\`\`typescript +import { InputOtpComponent } from '@cdek-it/angular-ui-kit'; +\`\`\``, + }, + }, + }, + argTypes: { + length: { + control: 'number', + description: 'Количество символов', + table: { + category: 'Props', + defaultValue: { summary: '4' }, + type: { summary: 'number' }, + }, + }, + mask: { + control: 'boolean', + description: 'Маскирует введённые символы', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + integerOnly: { + control: 'boolean', + description: 'Разрешает ввод только цифр', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + 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' }, + }, + }, + tabindex: { + control: 'number', + description: 'Значение атрибута tabindex', + table: { + category: 'Props', + defaultValue: { summary: 'null' }, + type: { summary: 'number | null' }, + }, + }, + autofocus: { + control: 'boolean', + description: 'Автоматический фокус при загрузке', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + + // Hidden props + control: { table: { disable: true } }, + disabled: { table: { disable: true } }, + invalid: { table: { disable: true } }, + primeSize: { table: { disable: true } }, + sizeClass: { table: { disable: true } }, + writeValue: { table: { disable: true } }, + registerOnChange: { table: { disable: true } }, + registerOnTouched: { table: { disable: true } }, + setDisabledState: { table: { disable: true } }, + + // Events + onChange: { + control: false, + description: 'Событие изменения значения', + table: { + category: 'Events', + type: { summary: 'EventEmitter' }, + }, + }, + onFocus: { + control: false, + description: 'Событие фокуса', + table: { + category: 'Events', + type: { summary: 'EventEmitter' }, + }, + }, + onBlur: { + control: false, + description: 'Событие потери фокуса', + table: { + category: 'Events', + type: { summary: 'EventEmitter' }, + }, + }, + }, + args: { + length: 4, + mask: false, + integerOnly: false, + readonly: false, + autofocus: false, + tabindex: null, + size: 'base' as const, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + name: 'Default', + render: (args) => { + const parts: string[] = []; + + if (args.length !== 4) parts.push(`[length]="${args.length}"`); + if (args.mask) parts.push(`[mask]="true"`); + if (args.integerOnly) parts.push(`[integerOnly]="true"`); + if (args.size && args.size !== 'base') parts.push(`size="${args.size}"`); + if (args.readonly) parts.push(`[readonly]="true"`); + if (args.autofocus) parts.push(`[autofocus]="true"`); + if (args.tabindex != null) parts.push(`[tabindex]="${args.tabindex}"`); + parts.push(`[formControl]="control"`); + + const template = ``; + + return { props: { ...args, control: new FormControl('') }, template }; + }, + parameters: { + docs: { + description: { + story: 'Базовый пример компонента. Используйте Controls для интерактивного изменения пропсов.', + }, + }, + }, +}; + +export { Disabled, Readonly, Invalid, Mask, IntegerOnly }; diff --git a/src/stories/components/password/examples/password-disabled.component.ts b/src/stories/components/password/examples/password-disabled.component.ts new file mode 100644 index 00000000..4c13c6df --- /dev/null +++ b/src/stories/components/password/examples/password-disabled.component.ts @@ -0,0 +1,51 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { PasswordComponent } from '../../../../lib/components/password/password.component'; + +const template = ` +
+ +
+`; + +@Component({ + selector: 'app-password-disabled', + standalone: true, + imports: [PasswordComponent, FormsModule], + template, +}) +export class PasswordDisabledComponent { + value: string | null = 'secret123'; +} + +export const Disabled: StoryObj = { + render: () => ({ + template: ``, + }), + parameters: { + docs: { + description: { story: 'Поле ввода пароля в отключённом состоянии.' }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { PasswordComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-password-disabled', + standalone: true, + imports: [PasswordComponent, FormsModule], + template: \` + + \`, +}) +export class PasswordDisabledComponent { + value: string | null = 'secret123'; +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/password/examples/password-feedback.component.ts b/src/stories/components/password/examples/password-feedback.component.ts new file mode 100644 index 00000000..829aacf4 --- /dev/null +++ b/src/stories/components/password/examples/password-feedback.component.ts @@ -0,0 +1,51 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { PasswordComponent } from '../../../../lib/components/password/password.component'; + +const template = ` +
+ +
+`; + +@Component({ + selector: 'app-password-feedback', + standalone: true, + imports: [PasswordComponent, FormsModule], + template, +}) +export class PasswordFeedbackComponent { + value: string | null = null; +} + +export const Feedback: StoryObj = { + render: () => ({ + template: ``, + }), + parameters: { + docs: { + description: { story: 'Индикатор надёжности пароля с визуальной шкалой (слабый / средний / сильный).' }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { PasswordComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-password-feedback', + standalone: true, + imports: [PasswordComponent, FormsModule], + template: \` + + \`, +}) +export class PasswordFeedbackComponent { + value: string | null = null; +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/password/examples/password-float-label.component.ts b/src/stories/components/password/examples/password-float-label.component.ts new file mode 100644 index 00000000..e75551fd --- /dev/null +++ b/src/stories/components/password/examples/password-float-label.component.ts @@ -0,0 +1,78 @@ +import { Component, Input } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { PasswordComponent } from '../../../../lib/components/password/password.component'; + +@Component({ + selector: 'app-password-float-label', + standalone: true, + imports: [PasswordComponent, FormsModule], + template: ` +
+ +
+ `, +}) +export class PasswordFloatLabelComponent { + @Input() feedback = true; + @Input() toggleMask = false; + @Input() disabled = false; + @Input() invalid = false; + @Input() fluid = false; + @Input() placeholder: string | undefined = undefined; + @Input() label = 'Пароль'; + value = ''; +} + +export const FloatLabel: StoryObj = { + name: 'FloatLabel', + render: (args) => { + const parts: string[] = []; + + if (!args.feedback) parts.push(`[feedback]="false"`); + if (args.toggleMask) parts.push(`[toggleMask]="true"`); + if (args.placeholder) parts.push(`placeholder="${args.placeholder}"`); + if (args.disabled) parts.push(`[disabled]="true"`); + if (args.invalid) parts.push(`[invalid]="true"`); + if (args.fluid) parts.push(`[fluid]="true"`); + + const attrs = parts.length ? `\n ${parts.join('\n ')}` : ''; + + return { + props: args, + template: ``, + }; + }, + args: { + feedback: true, + toggleMask: false, + placeholder: undefined, + disabled: false, + invalid: false, + fluid: false, + label: 'Пароль', + }, + parameters: { + docs: { + description: { + story: + 'Интеграция с `floatLabel` — плавающая метка внутри поля. Кликните на поле чтобы увидеть анимацию.', + }, + source: { + language: 'html', + code: ``, + }, + }, + }, +}; diff --git a/src/stories/components/password/examples/password-invalid.component.ts b/src/stories/components/password/examples/password-invalid.component.ts new file mode 100644 index 00000000..dace649f --- /dev/null +++ b/src/stories/components/password/examples/password-invalid.component.ts @@ -0,0 +1,51 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { PasswordComponent } from '../../../../lib/components/password/password.component'; + +const template = ` +
+ +
+`; + +@Component({ + selector: 'app-password-invalid', + standalone: true, + imports: [PasswordComponent, FormsModule], + template, +}) +export class PasswordInvalidComponent { + value: string | null = null; +} + +export const Invalid: StoryObj = { + render: () => ({ + template: ``, + }), + parameters: { + docs: { + description: { story: 'Поле ввода пароля в состоянии ошибки валидации.' }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { PasswordComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-password-invalid', + standalone: true, + imports: [PasswordComponent, FormsModule], + template: \` + + \`, +}) +export class PasswordInvalidComponent { + value: string | null = null; +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/password/examples/password-template.component.ts b/src/stories/components/password/examples/password-template.component.ts new file mode 100644 index 00000000..063b24b5 --- /dev/null +++ b/src/stories/components/password/examples/password-template.component.ts @@ -0,0 +1,146 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { PasswordComponent } from '../../../../lib/components/password/password.component'; +import { Divider } from 'primeng/divider'; + +@Component({ + selector: 'app-password-template', + standalone: true, + imports: [PasswordComponent, Divider, FormsModule], + template: ` +
+ + + +
+
+ + Минимум одна строчная буква +
+
+ + Минимум одна заглавная буква +
+
+ + Минимум одна цифра +
+
+ + Не менее 8 символов +
+
+
+
+
+ `, +}) +export class PasswordTemplateComponent { + value: string | null = null; + + get hasLowercase(): boolean { + return /[a-z]/.test(this.value ?? ''); + } + + get hasUppercase(): boolean { + return /[A-Z]/.test(this.value ?? ''); + } + + get hasDigit(): boolean { + return /\d/.test(this.value ?? ''); + } + + get hasMinLength(): boolean { + return (this.value ?? '').length >= 8; + } +} + +export const Template: StoryObj = { + name: 'Template', + render: () => ({ + template: ``, + }), + parameters: { + controls: { disable: true }, + docs: { + description: { + story: 'Кастомный контент через `ng-template`: разделитель и список требований к паролю с tabler-иконками.', + }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { PasswordComponent } from '@cdek-it/angular-ui-kit'; +import { Divider } from 'primeng/divider'; + +@Component({ + standalone: true, + imports: [PasswordComponent, Divider, FormsModule], + template: \` + + + +
+
+ + Минимум одна строчная буква +
+
+ + Минимум одна заглавная буква +
+
+ + Минимум одна цифра +
+
+ + Не менее 8 символов +
+
+
+
+ \`, +}) +export class PasswordTemplateExample { + value: string | null = null; + + get hasLowercase(): boolean { + return /[a-z]/.test(this.value ?? ''); + } + + get hasUppercase(): boolean { + return /[A-Z]/.test(this.value ?? ''); + } + + get hasDigit(): boolean { + return /\\d/.test(this.value ?? ''); + } + + get hasMinLength(): boolean { + return (this.value ?? '').length >= 8; + } +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/password/examples/password-toggle.component.ts b/src/stories/components/password/examples/password-toggle.component.ts new file mode 100644 index 00000000..0fdb915e --- /dev/null +++ b/src/stories/components/password/examples/password-toggle.component.ts @@ -0,0 +1,51 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { PasswordComponent } from '../../../../lib/components/password/password.component'; + +const template = ` +
+ +
+`; + +@Component({ + selector: 'app-password-toggle', + standalone: true, + imports: [PasswordComponent, FormsModule], + template, +}) +export class PasswordToggleComponent { + value: string | null = null; +} + +export const ToggleMask: StoryObj = { + render: () => ({ + template: ``, + }), + parameters: { + docs: { + description: { story: 'Возможность показать/скрыть введённый пароль по иконке.' }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { PasswordComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-password-toggle', + standalone: true, + imports: [PasswordComponent, FormsModule], + template: \` + + \`, +}) +export class PasswordToggleComponent { + value: string | null = null; +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/password/password.stories.ts b/src/stories/components/password/password.stories.ts new file mode 100644 index 00000000..b7367373 --- /dev/null +++ b/src/stories/components/password/password.stories.ts @@ -0,0 +1,214 @@ +import { Meta, StoryObj, moduleMetadata } from '@storybook/angular'; +import { FormsModule } from '@angular/forms'; +import { PasswordComponent } from '../../../lib/components/password/password.component'; +import { PasswordToggleComponent, ToggleMask } from './examples/password-toggle.component'; +import { PasswordFeedbackComponent, Feedback } from './examples/password-feedback.component'; +import { PasswordDisabledComponent, Disabled } from './examples/password-disabled.component'; +import { PasswordInvalidComponent, Invalid } from './examples/password-invalid.component'; +import { PasswordFloatLabelComponent, FloatLabel } from './examples/password-float-label.component'; +import { PasswordTemplateComponent, Template } from './examples/password-template.component'; + +const meta: Meta = { + title: 'Components/Form/Password', + component: PasswordComponent, + tags: ['autodocs'], + decorators: [ + moduleMetadata({ + imports: [ + PasswordComponent, + FormsModule, + PasswordToggleComponent, + PasswordFeedbackComponent, + PasswordDisabledComponent, + PasswordInvalidComponent, + PasswordFloatLabelComponent, + PasswordTemplateComponent, + ], + }), + (story) => ({ + ...story(), + template: `
${story().template}
`, + }), + ], + parameters: { + designTokens: { prefix: '--p-password' }, + docs: { + description: { + component: `Поле ввода пароля с поддержкой индикатора надёжности и переключения видимости. + +\`\`\`typescript +import { PasswordComponent } from '@cdek-it/angular-ui-kit'; +\`\`\``, + }, + story: { height: '280px' }, + }, + }, + argTypes: { + feedback: { + control: 'boolean', + description: 'Показывать индикатор надёжности пароля', + table: { + category: 'Props', + defaultValue: { summary: 'true' }, + type: { summary: 'boolean' }, + }, + }, + toggleMask: { + control: 'boolean', + description: 'Возможность показать/скрыть пароль', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + placeholder: { + control: 'text', + description: 'Текст-подсказка', + table: { + category: 'Props', + defaultValue: { summary: 'undefined' }, + 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: 'Отключает возможность взаимодействия', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + invalid: { + 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' }, + }, + }, + floatLabel: { + control: 'boolean', + description: 'Плавающая метка внутри поля', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + label: { + control: 'text', + description: 'Текст плавающей метки (используется с floatLabel)', + table: { + category: 'Props', + defaultValue: { summary: "''" }, + type: { summary: 'string' }, + }, + }, + // Hidden props + variant: { table: { disable: true } }, + promptLabel: { table: { disable: true } }, + weakLabel: { table: { disable: true } }, + mediumLabel: { table: { disable: true } }, + strongLabel: { table: { disable: true } }, + inputId: { table: { disable: true } }, + inputStyleClass: { table: { disable: true } }, + ariaLabel: { table: { disable: true } }, + ariaLabelledBy: { table: { disable: true } }, + autofocus: { table: { disable: true } }, + sizeClass: { table: { disable: true } }, + computedInputStyleClass: { table: { disable: true } }, + + // Events + onFocus: { + control: false, + description: 'Событие фокуса', + table: { + category: 'Events', + type: { summary: 'EventEmitter' }, + }, + }, + onBlur: { + control: false, + description: 'Событие потери фокуса', + table: { + category: 'Events', + type: { summary: 'EventEmitter' }, + }, + }, + }, + args: { + feedback: true, + toggleMask: false, + placeholder: 'Введите пароль', + size: 'base', + disabled: false, + invalid: false, + fluid: false, + floatLabel: false, + label: 'Пароль', + }, +}; + +export default meta; +type Story = StoryObj; + +// ── Default ────────────────────────────────────────────────────────────────── +export const Default: Story = { + name: 'Default', + render: (args) => { + const parts: string[] = []; + + if (!args.feedback) parts.push(`[feedback]="false"`); + if (args.toggleMask) parts.push(`[toggleMask]="true"`); + if (args.floatLabel) { + parts.push(`[floatLabel]="true"`); + if (args.label) parts.push(`label="${args.label}"`); + } else { + if (args.placeholder) parts.push(`placeholder="${args.placeholder}"`); + } + if (args.size && args.size !== 'base') parts.push(`size="${args.size}"`); + if (args.disabled) parts.push(`[disabled]="true"`); + if (args.invalid) parts.push(`[invalid]="true"`); + if (args.fluid) parts.push(`[fluid]="true"`); + + parts.push(`[(ngModel)]="value"`); + + const template = parts.length > 1 + ? `` + : ``; + + return { props: { ...args, value: null }, template }; + }, + parameters: { + docs: { + description: { + story: 'Базовый пример компонента. Используйте Controls для интерактивного изменения пропсов.', + }, + }, + }, +}; + +// ── Re-exports from example components ──────────────────────────────────── +export { ToggleMask, Feedback, Disabled, Invalid, FloatLabel, Template };