diff --git a/docs/components-api/textarea.md b/docs/components-api/textarea.md index 9c94cec2..bf6a1c01 100644 --- a/docs/components-api/textarea.md +++ b/docs/components-api/textarea.md @@ -2,12 +2,41 @@ # ExtraTextarea +> ✅ **Реализован**: `ExtraTextareaComponent` (`@cdek-it/angular-ui-kit`) соответствует спецификации. + | Свойство | Описание | Типизация | | ---------------- | ----------------------------------------------------------------------- | -------------------------- | | `placeholder` | текст подсказки внутри поля | `string` | | `label` | текст названия поля | `string` | -| `labelPosition` | положение лейбла | `left \| top \| float` | +| `labelPosition` | положение лейбла | `top \| left` | +| `floatLabel` | лейбл-плейсхолдер, всплывающий над полем при фокусе/значении | `boolean` | | `clearable` | отображение иконки для очистки поля | `boolean` | -| `resize` | разрешить изменение размера поля | `boolean` | +| `resizable` | разрешить изменение размера поля перетаскиванием (нативный resize-уголок) | `boolean` | | `caption` | текст пояснения под полем | `string` | | `info` | текст с доп. информацией (показывается в тултипе иконки ti-info-circle) | `string` | +| `size` | размер поля | `small \| base \| large \| xlarge` | +| `fluid` | растягивает поле на всю ширину контейнера | `boolean` | +| `autoResize` | автоподстройка высоты поля под содержимое при вводе | `boolean` | +| `rows` | стартовая высота поля в строках | `number` | +| `cols` | ширина поля в символах | `number \| undefined` | +| `autofocus` | автофокус при монтировании компонента | `boolean` | + +`labelPosition`/`floatLabel` — раздельные пропы (не тройной enum `left \| top \| float`, как было в +черновике спеки): `labelPosition` отвечает только за top/left, `floatLabel` — отдельный независимый +переключатель поверх любого из них (см. `docs/naming-rules.md`, раздел «Позиции»). При +`floatLabel=true` значение `labelPosition` для позиционирования самого лейбла не используется — +лейбл всегда всплывает поверх поля. + +`resizable` — исправленное по `docs/naming-rules.md` имя (булево «возможность действия» → суффикс +`*able`, в черновике спеки было `resize`). + +`size`/`fluid`/`autoResize`/`rows`/`cols`/`autofocus` — полезные пропы сверх первоначального +черновика спеки, уже реализованы и добавлены сюда по итогам доработки (см. чеклист в корневом +`CLAUDE.md`, п.3). + +## События + +| Событие | Описание | Типизация | +| -------- | ------------------------------------------------------ | -------------------------------------------- | +| `resize` | срабатывает при изменении высоты поля пользователем | `(event: ExtraTextareaResizeEvent) => void` | +| `clear` | срабатывает при нажатии на иконку очистки (`clearable`) | `() => void` | diff --git a/src/lib/components/textarea/textarea.component.ts b/src/lib/components/textarea/textarea.component.ts index b1353d39..a8304ce2 100644 --- a/src/lib/components/textarea/textarea.component.ts +++ b/src/lib/components/textarea/textarea.component.ts @@ -1,4 +1,5 @@ import { + booleanAttribute, ChangeDetectionStrategy, Component, EventEmitter, @@ -10,18 +11,25 @@ import { Output } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR, NgControl } from '@angular/forms'; -import { NgClass } from '@angular/common'; +import { NgClass, NgTemplateOutlet } from '@angular/common'; import { Textarea } from 'primeng/textarea'; import { IconField } from 'primeng/iconfield'; import { InputIcon } from 'primeng/inputicon'; +import { FloatLabel } from 'primeng/floatlabel'; +import { ExtraTooltipDirective } from '@cdek-it/angular-ui-kit/components/tooltip'; export type ExtraTextareaSize = 'small' | 'base' | 'large' | 'xlarge'; +export type ExtraTextareaLabelPosition = 'top' | 'left'; +export type ExtraTextareaResizeEvent = Event | Record; + +let nextInputId = 0; @Component({ selector: 'extra-textarea', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [Textarea, IconField, InputIcon, NgClass], + imports: [Textarea, IconField, InputIcon, FloatLabel, NgClass, NgTemplateOutlet, ExtraTooltipDirective], + host: { style: 'display: contents' }, providers: [ { provide: NG_VALUE_ACCESSOR, @@ -30,10 +38,82 @@ export type ExtraTextareaSize = 'small' | 'base' | 'large' | 'xlarge'; } ], template: ` - @if (showClear) { - + + @if (label || caption) { +
+ @if (label && labelPosition === 'left' && !floatLabel) { + + } +
+ @if (floatLabel) { + + + @if (label) { + + } + + } @else { + @if (label && labelPosition === 'top') { + + } + + } + @if (caption) { +
{{ caption }}
+ } +
+
+ } @else { + + } + + + + + + + @if (clearable) { + + + + + } @else { - -
- } @else { - - } + } + ` }) export class ExtraTextareaComponent implements ControlValueAccessor, OnInit { @@ -89,13 +143,23 @@ export class ExtraTextareaComponent implements ControlValueAccessor, OnInit { } @Input() placeholder = ''; + @Input() label = ''; + @Input() labelPosition: ExtraTextareaLabelPosition = 'top'; + @Input({ transform: booleanAttribute }) floatLabel = false; + @Input() caption = ''; + @Input() info = ''; + @Input({ transform: booleanAttribute }) clearable = false; + @Input({ transform: booleanAttribute }) resizable = true; @Input() size: ExtraTextareaSize = 'base'; - @Input() readonly = false; - @Input() showClear = false; - @Input() fluid = false; - @Input() autoResize = false; + @Input({ transform: booleanAttribute }) readonly = false; + @Input({ transform: booleanAttribute }) fluid = false; + @Input({ transform: booleanAttribute }) autoResize = false; @Input() rows = 3; @Input() cols?: number; + @Input({ transform: booleanAttribute }) autofocus = false; + + /** Уникальный id поля для связи label ↔ textarea. */ + readonly inputId = `extra-textarea-${nextInputId++}`; disabled = false; @@ -103,7 +167,7 @@ export class ExtraTextareaComponent implements ControlValueAccessor, OnInit { return this._ngControl?.invalid ?? false; } - @Output() onResize = new EventEmitter<{ height: string } | {}>(); + @Output() onResize = new EventEmitter(); @Output() onClear = new EventEmitter(); modelValue = ''; diff --git a/src/lib/components/textarea/textarea.figma.md b/src/lib/components/textarea/textarea.figma.md index 2e04610a..aeb29078 100644 --- a/src/lib/components/textarea/textarea.figma.md +++ b/src/lib/components/textarea/textarea.figma.md @@ -10,32 +10,117 @@ figma: componentKey: 'a4d238e342705216c67068f12f4b28da12094ab7' name: ' - - - } @else { - - } - - - - ` -}) -export class TextareaFloatLabelComponent { - control = new FormControl(''); - @Input() label = 'Комментарий'; - @Input() required = false; - @Input() showClear = false; -} +import { ExtraTextareaComponent } from '../../../../lib/components/textarea/textarea.component'; export const FloatLabelStory: StoryObj = { name: 'FloatLabel', - render: (args) => ({ - props: { label: args['label'], required: args['required'], showClear: args['showClear'] }, - template: ``, - }), + render: (args) => { + const control = new FormControl(''); + return { + props: { ...args, control }, + template: `` + }; + }, args: { - label: 'Комментарий', - required: false, + clearable: false }, argTypes: { - label: { - control: 'text', - description: 'Текст плавающей метки', - table: { - category: 'Props', - defaultValue: { summary: "'Комментарий'" }, - type: { summary: 'string' }, - }, - }, - required: { + clearable: { control: 'boolean', - description: 'Показывает маркер обязательного поля `*` рядом с меткой', + description: 'Показывает иконку очистки поверх float-лейбла', table: { - category: 'Props', + category: 'Свойства', defaultValue: { summary: 'false' }, - type: { summary: 'boolean' }, - }, - }, + type: { summary: 'boolean' } + } + } }, + decorators: [ + (story: any) => ({ + ...story(), + moduleMetadata: { + imports: [ExtraTextareaComponent, ReactiveFormsModule] + } + }) + ], parameters: { docs: { description: { - story: - 'Интеграция с `p-floatlabel variant="in"` — плавающая метка внутри поля. `required` добавляет красный маркер `*`. Требует нативный ` - - - \`, + imports: [ExtraTextareaComponent, ReactiveFormsModule], + template: \`\`, }) export class FloatLabelExample { control = new FormControl(''); } - `, - }, - }, - }, + ` + } + } + } }; diff --git a/src/stories/components/textarea/examples/textarea-invalid.component.ts b/src/stories/components/textarea/examples/textarea-invalid.component.ts index 31c4aece..7214f5ca 100644 --- a/src/stories/components/textarea/examples/textarea-invalid.component.ts +++ b/src/stories/components/textarea/examples/textarea-invalid.component.ts @@ -6,9 +6,11 @@ export const Invalid: StoryObj = { name: 'Invalid', render: (args) => { const control = new FormControl('', Validators.required); + // invalid — геттер без сеттера, Storybook на попытке присвоить его напрямую роняет ошибку в консоль + const { invalid, ...rest } = args as Record; return { - props: { ...args, control }, - template: `` + props: { ...rest, control }, + template: `` }; }, decorators: [ @@ -33,7 +35,7 @@ import { ExtraTextareaComponent } from '@cdek-it/angular-ui-kit'; @Component({ standalone: true, imports: [ExtraTextareaComponent, ReactiveFormsModule], - template: \`\`, + template: \`\`, }) export class InvalidExample { control = new FormControl('', Validators.required); diff --git a/src/stories/components/textarea/examples/textarea-labels.component.ts b/src/stories/components/textarea/examples/textarea-labels.component.ts new file mode 100644 index 00000000..f2f5446c --- /dev/null +++ b/src/stories/components/textarea/examples/textarea-labels.component.ts @@ -0,0 +1,69 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { ExtraTextareaComponent } from '../../../../lib/components/textarea/textarea.component'; + +const template = ` +
+ + + + + +
+`; + +@Component({ + selector: 'app-textarea-labels', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ExtraTextareaComponent, ReactiveFormsModule], + template +}) +export class TextareaLabelsComponent { + control = new FormControl(''); +} + +export const Labels: StoryObj = { + render: () => ({ + template: `` + }), + parameters: { + controls: { disable: true }, + docs: { + description: { + story: + 'Положения лейбла (labelPosition): top (по умолчанию) и left. caption — пояснение под полем, info — текст тултипа у иконки рядом с лейблом. Без label и caption поле не оборачивается.' + }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { ExtraTextareaComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-textarea-labels', + standalone: true, + imports: [ExtraTextareaComponent, ReactiveFormsModule], + template: \` + + + + + + + + + + + \`, +}) +export class TextareaLabelsComponent { + control = new FormControl(''); +} + ` + } + } + } +}; diff --git a/src/stories/components/textarea/examples/textarea-readonly.component.ts b/src/stories/components/textarea/examples/textarea-readonly.component.ts index 8073fb91..42657fcd 100644 --- a/src/stories/components/textarea/examples/textarea-readonly.component.ts +++ b/src/stories/components/textarea/examples/textarea-readonly.component.ts @@ -6,8 +6,10 @@ export const Readonly: StoryObj = { name: 'Readonly', render: (args) => { const control = new FormControl('Только для чтения — этот текст нельзя изменить.'); + // invalid — геттер без сеттера, Storybook на попытке присвоить его напрямую роняет ошибку в консоль + const { invalid, ...rest } = args as Record; return { - props: { ...args, control }, + props: { ...rest, control }, template: `` }; }, diff --git a/src/stories/components/textarea/examples/textarea-sizes.component.ts b/src/stories/components/textarea/examples/textarea-sizes.component.ts index 0341cd21..43da1262 100644 --- a/src/stories/components/textarea/examples/textarea-sizes.component.ts +++ b/src/stories/components/textarea/examples/textarea-sizes.component.ts @@ -6,9 +6,11 @@ export const Sizes: StoryObj = { name: 'Sizes', render: (args) => { const control = new FormControl(''); + // invalid — геттер без сеттера, Storybook на попытке присвоить его напрямую роняет ошибку в консоль + const { invalid, ...rest } = args as Record; return { - props: { ...args, control }, - template: `` + props: { ...rest, control }, + template: `` }; }, args: { diff --git a/src/stories/components/textarea/textarea.stories.ts b/src/stories/components/textarea/textarea.stories.ts index 4552e09e..d04ff8cc 100644 --- a/src/stories/components/textarea/textarea.stories.ts +++ b/src/stories/components/textarea/textarea.stories.ts @@ -6,7 +6,8 @@ import { Readonly } from './examples/textarea-readonly.component'; import { Invalid } from './examples/textarea-invalid.component'; import { AutoResize, TextareaAutoResizeComponent } from './examples/textarea-autoresize.component'; import { Sizes } from './examples/textarea-sizes.component'; -import { FloatLabelStory, TextareaFloatLabelComponent } from './examples/textarea-float-label.component'; +import { FloatLabelStory } from './examples/textarea-float-label.component'; +import { Labels, TextareaLabelsComponent } from './examples/textarea-labels.component'; type TextareaArgs = ExtraTextareaComponent & { disabled: boolean; invalid: boolean }; @@ -16,7 +17,7 @@ const meta: Meta = { tags: ['autodocs'], decorators: [ moduleMetadata({ - imports: [ExtraTextareaComponent, ReactiveFormsModule, TextareaAutoResizeComponent, TextareaFloatLabelComponent] + imports: [ExtraTextareaComponent, ReactiveFormsModule, TextareaAutoResizeComponent, TextareaLabelsComponent] }) ], parameters: { @@ -25,65 +26,98 @@ const meta: Meta = { description: { component: `Многострочное текстовое поле для ввода данных. +Реализовано по спецификации [textarea.md](https://github.com/cdek-it/angular-ui-kit/blob/main/docs/components-api/textarea.md). + \`\`\`typescript import { ExtraTextareaComponent } from '@cdek-it/angular-ui-kit'; -\`\`\`` +\`\`\` + +Значение подключается через \`[(ngModel)]\` или \`[formControl]\` (ControlValueAccessor). Состояния disabled и invalid управляются через FormControl.` } } }, argTypes: { - placeholder: { + // ── Свойства (docs/components-api/textarea.md) ───────────────── + label: { control: 'text', - description: 'Подсказка при пустом поле', + description: 'Текст названия поля', table: { - category: 'Props', + category: 'Свойства', defaultValue: { summary: "''" }, type: { summary: 'string' } } }, - size: { + labelPosition: { control: 'select', - options: ['small', 'base', 'large', 'xlarge'], - description: 'Размер поля', + options: ['top', 'left'], + description: 'Положение лейбла', table: { - category: 'Props', - defaultValue: { summary: "'base'" }, - type: { summary: "'small' | 'base' | 'large' | 'xlarge'" } + category: 'Свойства', + defaultValue: { summary: "'top'" }, + type: { summary: "'top' | 'left'" } } }, - disabled: { + floatLabel: { control: 'boolean', - description: 'Отключает взаимодействие — управляется через FormControl', + description: 'Лейбл-плейсхолдер, всплывающий над полем при фокусе/значении', table: { - category: 'Props', + category: 'Свойства', defaultValue: { summary: 'false' }, type: { summary: 'boolean' } } }, - invalid: { - control: 'boolean', - description: 'Невалидное состояние — управляется через FormControl', + caption: { + control: 'text', + description: 'Текст пояснения под полем', table: { - category: 'Props', - defaultValue: { summary: 'false' }, - type: { summary: 'boolean' } + category: 'Свойства', + defaultValue: { summary: "''" }, + type: { summary: 'string' } } }, - readonly: { + info: { + control: 'text', + description: 'Текст тултипа иконки рядом с лейблом', + table: { + category: 'Свойства', + defaultValue: { summary: "''" }, + type: { summary: 'string' } + } + }, + placeholder: { + control: 'text', + description: 'Подсказка при пустом поле', + table: { + category: 'Свойства', + defaultValue: { summary: "''" }, + type: { summary: 'string' } + } + }, + size: { + control: 'select', + options: ['small', 'base', 'large', 'xlarge'], + description: 'Размер поля', + table: { + category: 'Свойства', + defaultValue: { summary: "'base'" }, + type: { summary: "'small' | 'base' | 'large' | 'xlarge'" } + } + }, + clearable: { control: 'boolean', - description: 'Только для чтения', + description: 'Показывает иконку очистки при наличии значения', table: { - category: 'Props', + category: 'Свойства', defaultValue: { summary: 'false' }, type: { summary: 'boolean' } } }, - showClear: { + resizable: { control: 'boolean', - description: 'Показывает иконку очистки при наличии значения', + description: 'Разрешает нативный resize-уголок (перетаскиванием)', table: { - category: 'Props', - defaultValue: { summary: 'false' }, + category: 'Свойства', + defaultValue: { summary: 'true' }, type: { summary: 'boolean' } } }, @@ -91,7 +125,7 @@ import { ExtraTextareaComponent } from '@cdek-it/angular-ui-kit'; control: 'boolean', description: 'Растягивает поле на всю ширину контейнера', table: { - category: 'Props', + category: 'Свойства', defaultValue: { summary: 'false' }, type: { summary: 'boolean' } } @@ -100,99 +134,153 @@ import { ExtraTextareaComponent } from '@cdek-it/angular-ui-kit'; control: 'boolean', description: 'Автоматически увеличивает высоту по мере ввода', table: { - category: 'Props', + category: 'Свойства', defaultValue: { summary: 'false' }, type: { summary: 'boolean' } } }, rows: { control: 'number', - description: 'Количество видимых строк', + description: 'Стартовая высота поля в строках', table: { - category: 'Props', + category: 'Свойства', defaultValue: { summary: '3' }, type: { summary: 'number' } } }, cols: { control: 'number', - description: 'Количество видимых столбцов', + description: 'Ширина поля в символах', table: { - category: 'Props', + category: 'Свойства', defaultValue: { summary: 'undefined' }, type: { summary: 'number' } } }, + autofocus: { + control: 'boolean', + description: 'Автофокус при монтировании компонента', + table: { + category: 'Свойства', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' } + } + }, + // ── Состояния (управляются через FormControl) ─────────────────── + disabled: { + control: 'boolean', + description: 'Отключённое состояние — управляется через FormControl', + table: { + category: 'Состояния', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' } + } + }, + invalid: { + control: 'boolean', + description: 'Невалидное состояние — вычисляется из NgControl (Validators)', + table: { + category: 'Состояния', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' } + } + }, + readonly: { + control: 'boolean', + description: 'Только для чтения', + table: { + category: 'Состояния', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' } + } + }, // Hidden computed props modelValue: { table: { disable: true } }, primeSize: { table: { disable: true } }, sizeClass: { table: { disable: true } }, - // Events + inputId: { table: { disable: true } }, + // ── События ────────────────────────────────────────────────── onResize: { control: false, - description: 'Событие изменения высоты поля (при autoResize)', + description: 'Событие изменения высоты поля пользователем', table: { - category: 'Events', - type: { summary: 'EventEmitter<{ height: string }>' } + category: 'События', + type: { summary: 'EventEmitter' } } }, onClear: { control: false, - description: 'Событие очистки поля (при showClear)', + description: 'Событие очистки поля (при clearable)', table: { - category: 'Events', + category: 'События', type: { summary: 'EventEmitter' } } } }, args: { + label: 'Комментарий', + labelPosition: 'top', + floatLabel: false, + caption: '', + info: '', placeholder: 'Введите текст...', size: 'base', - disabled: false, - invalid: false, - readonly: false, - showClear: false, + clearable: false, + resizable: true, fluid: false, autoResize: false, - rows: 3 + rows: 3, + autofocus: false, + disabled: false, + invalid: false, + readonly: false } }; export default meta; type Story = StoryObj; -// ── Default ────────────────────────────────────────────────────────────────── +// ── Default (интерактивная) ──────────────────────────────────────────────── export const Default: Story = { name: 'Default', render: (args) => { const parts: string[] = []; + if (args.label) parts.push(`label="${args.label}"`); + if (args.labelPosition && args.labelPosition !== 'top') parts.push(`labelPosition="${args.labelPosition}"`); + if (args.floatLabel) parts.push(`[floatLabel]="true"`); + if (args.caption) parts.push(`caption="${args.caption}"`); + if (args.info) parts.push(`info="${args.info}"`); if (args.placeholder) parts.push(`placeholder="${args.placeholder}"`); if (args.size && args.size !== 'base') parts.push(`size="${args.size}"`); + if (args.clearable) parts.push(`[clearable]="true"`); + if (!args.resizable) parts.push(`[resizable]="false"`); if (args.readonly) parts.push(`[readonly]="true"`); - if (args.showClear) parts.push(`[showClear]="true"`); if (args.fluid) parts.push(`[fluid]="true"`); if (args.autoResize) parts.push(`[autoResize]="true"`); if (args.rows && args.rows !== 3) parts.push(`[rows]="${args.rows}"`); if (args.cols) parts.push(`[cols]="${args.cols}"`); + if (args.autofocus) parts.push(`[autofocus]="true"`); - const validators = []; - if (args.invalid) validators.push(Validators.required); - + const validators = args.invalid ? [Validators.required] : []; const control = new FormControl({ value: '', disabled: args.disabled }, validators); const template = ``; - return { props: { ...args, control }, template }; + // invalid и disabled живут во FormControl: у компонента invalid — геттер без сеттера, + // и Storybook на попытке его присвоить роняет ошибку в консоль + const { invalid, disabled, ...rest } = args; + + return { props: { ...rest, control }, template }; }, parameters: { docs: { description: { - story: 'Базовый пример компонента. Используйте Controls для интерактивного изменения пропсов.' + story: 'Интерактивное поле со всеми свойствами спецификации. Используйте Controls для изменения пропсов; disabled и invalid управляются через FormControl.' } } } }; -// ── Re-exports from example components ──────────────────────────────────── -export { Disabled, Readonly, Invalid, AutoResize, Sizes, FloatLabelStory as FloatLabel }; +// ── Комбинаторные истории ────────────────────────────────────────────────── +export { Labels, Disabled, Readonly, Invalid, AutoResize, Sizes, FloatLabelStory as FloatLabel };