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) { + + + {{ label }} + + } @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 05f227f9..099454a8 100644 --- a/src/prime-preset/map-tokens.ts +++ b/src/prime-preset/map-tokens.ts @@ -10,8 +10,9 @@ 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'; @@ -62,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/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/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 };