From de70553e9fe71a581d42098d32bc204a783ea9bb Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Thu, 16 Apr 2026 22:30:41 +0700 Subject: [PATCH 01/20] =?UTF-8?q?inputnumber:=20=D1=81=D1=82=D0=B8=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F,=20=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D1=81=D1=8B,=20=D0=BE=D0=B1=D1=91=D1=80=D1=82?= =?UTF-8?q?=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-04-16-inputnumber.md | 808 ++++++++++++++++++ .../specs/2026-04-16-inputnumber-design.md | 163 ++++ .../inputnumber/inputnumber.component.ts | 108 +++ src/prime-preset/map-tokens.ts | 5 + .../tokens/components/inputnumber.ts | 23 + .../examples/inputnumber-buttons.component.ts | 37 + .../inputnumber-currency.component.ts | 42 + .../inputnumber-disabled.component.ts | 38 + .../inputnumber-float-label.component.ts | 82 ++ .../inputnumber/inputnumber.stories.ts | 252 ++++++ 10 files changed, 1558 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-16-inputnumber.md create mode 100644 docs/superpowers/specs/2026-04-16-inputnumber-design.md create mode 100644 src/lib/components/inputnumber/inputnumber.component.ts create mode 100644 src/prime-preset/tokens/components/inputnumber.ts create mode 100644 src/stories/components/inputnumber/examples/inputnumber-buttons.component.ts create mode 100644 src/stories/components/inputnumber/examples/inputnumber-currency.component.ts create mode 100644 src/stories/components/inputnumber/examples/inputnumber-disabled.component.ts create mode 100644 src/stories/components/inputnumber/examples/inputnumber-float-label.component.ts create mode 100644 src/stories/components/inputnumber/inputnumber.stories.ts diff --git a/docs/superpowers/plans/2026-04-16-inputnumber.md b/docs/superpowers/plans/2026-04-16-inputnumber.md new file mode 100644 index 00000000..ab3591e0 --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-inputnumber.md @@ -0,0 +1,808 @@ +# InputNumber Component — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Создать Angular wrapper-компонент InputNumber с CSS-переопределениями и Storybook-сториями. + +**Architecture:** Standalone CVA-компонент `InputNumberComponent`, оборачивающий PrimeNG `p-inputnumber`. CSS-оверрайды в `src/prime-preset/tokens/components/inputnumber.ts`, подключаются через `map-tokens.ts`. Четыре стории: Default (динамический template из args), FloatLabel (нативный `p-inputnumber` внутри `p-floatlabel`), Currency, MinMax. + +**Tech Stack:** Angular 20, PrimeNG 20, Storybook 8, Tailwind, `dt()` токены, Tabler Icons. + +--- + +## File Map + +| Действие | Путь | +|---|---| +| Создать | `src/lib/components/inputnumber/inputnumber.component.ts` | +| Создать | `src/prime-preset/tokens/components/inputnumber.ts` | +| Изменить | `src/prime-preset/map-tokens.ts` | +| Создать | `src/stories/components/inputnumber/inputnumber.stories.ts` | +| Создать | `src/stories/components/inputnumber/examples/inputnumber-float-label.component.ts` | +| Создать | `src/stories/components/inputnumber/examples/inputnumber-currency.component.ts` | +| Создать | `src/stories/components/inputnumber/examples/inputnumber-minmax.component.ts` | + +--- + +### Task 1: CSS-переопределения InputNumber + +**Files:** +- Create: `src/prime-preset/tokens/components/inputnumber.ts` +- Modify: `src/prime-preset/map-tokens.ts` + +- [ ] **Step 1: Создать файл CSS-токенов** + +Создать `src/prime-preset/tokens/components/inputnumber.ts`: + +```typescript +export const inputnumberCss = ({ dt }: { dt: (token: string) => string }): string => ` + +/* ─── Кнопки +/− ─── */ +.p-inputnumber-button { + border-width: ${dt('inputnumber.extend.borderWidth')}; +} + +.p-inputnumber-horizontal .p-inputnumber-button { + min-height: ${dt('inputnumber.extend.extButton.height')}; +} + +/* ─── Disabled состояние кнопок ─── */ +.p-inputnumber-horizontal:has(.p-inputnumber-input:disabled) .p-inputnumber-button { + background: ${dt('inputtext.root.disabledBackground')}; + color: ${dt('inputtext.root.disabledColor')}; +} + +/* ─── Extra Large ─── */ +.p-inputnumber.p-inputnumber-xlg .p-inputnumber-input { + font-size: ${dt('inputtext.extend.extXlg.fontSize')}; + padding: ${dt('inputtext.extend.extXlg.paddingY')} ${dt('inputtext.extend.extXlg.paddingX')}; +} +`; +``` + +- [ ] **Step 2: Зарегистрировать CSS в map-tokens.ts** + +Открыть `src/prime-preset/map-tokens.ts`. Добавить импорт после строки с `inputtextCss`: + +```typescript +import { inputnumberCss } from './tokens/components/inputnumber'; +``` + +Добавить запись в объект `components` после блока `inputtext`: + +```typescript +inputnumber: { + ...(tokens.components.inputnumber as unknown as ComponentsDesignTokens['inputnumber']), + css: inputnumberCss, +}, +``` + +- [ ] **Step 3: Проверить компиляцию** + +```bash +cd /Users/d.khaliulin/Downloads/angular-ui-kit-feature-styles-debug +npx tsc --noEmit +``` + +Ожидается: нет ошибок. + +- [ ] **Step 4: Коммит** + +```bash +git add src/prime-preset/tokens/components/inputnumber.ts src/prime-preset/map-tokens.ts +git commit -m "feat(inputnumber): добавить CSS-переопределения токенов" +``` + +--- + +### Task 2: InputNumberComponent + +**Files:** +- Create: `src/lib/components/inputnumber/inputnumber.component.ts` + +- [ ] **Step 1: Создать компонент** + +Создать `src/lib/components/inputnumber/inputnumber.component.ts`: + +```typescript +import { Component, Input, forwardRef } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms'; +import { NgClass } from '@angular/common'; +import { InputNumber } from 'primeng/inputnumber'; + +export type InputNumberSize = 'small' | 'base' | 'large' | 'xlarge'; +export type InputNumberButtonLayout = 'horizontal' | 'vertical' | 'stacked'; +export type InputNumberMode = 'decimal' | 'currency'; + +@Component({ + selector: 'input-number', + standalone: true, + imports: [InputNumber, NgClass, FormsModule], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => InputNumberComponent), + multi: true, + }, + ], + template: ` + + `, +}) +export class InputNumberComponent implements ControlValueAccessor { + @Input() size: InputNumberSize = 'base'; + @Input() placeholder = ''; + @Input() disabled = false; + @Input() readonly = false; + @Input() invalid = false; + @Input() showButtons = true; + @Input() buttonLayout: InputNumberButtonLayout = 'horizontal'; + @Input() mode: InputNumberMode = 'decimal'; + @Input() currency = 'RUB'; + @Input() locale = 'ru-RU'; + @Input() prefix: string | undefined = undefined; + @Input() suffix: string | undefined = undefined; + @Input() min: number | undefined = undefined; + @Input() max: number | undefined = undefined; + @Input() step = 1; + @Input() minFractionDigits = 0; + @Input() maxFractionDigits = 20; + @Input() fluid = false; + + modelValue: number | null = null; + + private _onChange: (value: number | null) => void = () => {}; + onTouched: () => 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-inputnumber-xlg': this.size === 'xlarge' }; + } + + onInputChange(event: { value: number | null | undefined }): void { + const value = event.value ?? null; + this.modelValue = value; + this._onChange(value); + } + + writeValue(value: number | null): void { + this.modelValue = value ?? null; + } + + registerOnChange(fn: (value: number | null) => void): void { + this._onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } +} +``` + +- [ ] **Step 2: Проверить компиляцию** + +```bash +npx tsc --noEmit +``` + +Ожидается: нет ошибок. + +- [ ] **Step 3: Коммит** + +```bash +git add src/lib/components/inputnumber/inputnumber.component.ts +git commit -m "feat(inputnumber): добавить компонент InputNumberComponent" +``` + +--- + +### Task 3: FloatLabel story + +**Files:** +- Create: `src/stories/components/inputnumber/examples/inputnumber-float-label.component.ts` + +- [ ] **Step 1: Создать файл** + +Создать `src/stories/components/inputnumber/examples/inputnumber-float-label.component.ts`: + +```typescript +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { InputNumber } from 'primeng/inputnumber'; +import { FloatLabel } from 'primeng/floatlabel'; + +const template = ` +
+ + + + +
+`; +const styles = ''; + +@Component({ + selector: 'app-inputnumber-float-label', + standalone: true, + imports: [InputNumber, FloatLabel, FormsModule], + template, + styles, +}) +export class InputNumberFloatLabelComponent { + value: number | null = null; +} + +export const FloatLabelStory: StoryObj = { + name: 'FloatLabel', + render: () => ({ + template: ``, + }), + parameters: { + controls: { disable: true }, + docs: { + description: { + story: + 'Интеграция с `p-floatlabel` — плавающая метка внутри поля. Требует нативный `p-inputnumber` как прямой дочерний элемент `p-floatlabel`.', + }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { InputNumber } from 'primeng/inputnumber'; +import { FloatLabel } from 'primeng/floatlabel'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'app-inputnumber-float-label', + standalone: true, + imports: [InputNumber, FloatLabel, FormsModule], + template: \` + + + + + \`, +}) +export class InputNumberFloatLabelComponent { + value: number | null = null; +} + `, + }, + }, + }, +}; +``` + +- [ ] **Step 2: Проверить компиляцию** + +```bash +npx tsc --noEmit +``` + +Ожидается: нет ошибок. + +--- + +### Task 4: Currency story + +**Files:** +- Create: `src/stories/components/inputnumber/examples/inputnumber-currency.component.ts` + +- [ ] **Step 1: Создать файл** + +Создать `src/stories/components/inputnumber/examples/inputnumber-currency.component.ts`: + +```typescript +import { StoryObj } from '@storybook/angular'; +import { InputNumberComponent } from '../../../../lib/components/inputnumber/inputnumber.component'; + +type Story = StoryObj; + +export const Currency: Story = { + name: 'Currency', + render: (args) => ({ + props: { ...args, value: null }, + template: ` + + `, + }), + args: { + mode: 'currency', + currency: 'RUB', + locale: 'ru-RU', + minFractionDigits: 2, + maxFractionDigits: 2, + }, + parameters: { + docs: { + description: { + story: 'Режим валюты — форматирует значение с символом валюты по заданной локали.', + }, + source: { + language: 'ts', + code: ` +import { InputNumberComponent } from '@cdek-it/angular-ui-kit'; +import { FormsModule } from '@angular/forms'; + +// template: +// + `, + }, + }, + }, +}; +``` + +--- + +### Task 5: MinMax story + +**Files:** +- Create: `src/stories/components/inputnumber/examples/inputnumber-minmax.component.ts` + +- [ ] **Step 1: Создать файл** + +Создать `src/stories/components/inputnumber/examples/inputnumber-minmax.component.ts`: + +```typescript +import { StoryObj } from '@storybook/angular'; +import { InputNumberComponent } from '../../../../lib/components/inputnumber/inputnumber.component'; + +type Story = StoryObj; + +export const MinMax: Story = { + name: 'Min / Max / Step', + render: (args) => ({ + props: { ...args, value: null }, + template: ` + + `, + }), + args: { + min: 0, + max: 100, + step: 1, + placeholder: '0–100', + }, + parameters: { + docs: { + description: { + story: 'Ограничения min/max и шаг изменения через кнопки и клавиатуру.', + }, + source: { + language: 'ts', + code: ` +import { InputNumberComponent } from '@cdek-it/angular-ui-kit'; +import { FormsModule } from '@angular/forms'; + +// template: +// + `, + }, + }, + }, +}; +``` + +--- + +### Task 6: Main stories file + +**Files:** +- Create: `src/stories/components/inputnumber/inputnumber.stories.ts` + +- [ ] **Step 1: Создать файл** + +Создать `src/stories/components/inputnumber/inputnumber.stories.ts`: + +```typescript +import { Meta, StoryObj, moduleMetadata } from '@storybook/angular'; +import { FormsModule } from '@angular/forms'; +import { InputNumberComponent } from '../../../lib/components/inputnumber/inputnumber.component'; +import { InputNumberFloatLabelComponent, FloatLabelStory } from './examples/inputnumber-float-label.component'; +import { Currency } from './examples/inputnumber-currency.component'; +import { MinMax } from './examples/inputnumber-minmax.component'; + +type InputNumberArgs = InputNumberComponent; + +const meta: Meta = { + title: 'Components/Form/InputNumber', + component: InputNumberComponent, + tags: ['autodocs'], + decorators: [ + moduleMetadata({ + imports: [ + InputNumberComponent, + FormsModule, + InputNumberFloatLabelComponent, + ], + }), + ], + parameters: { + designTokens: { prefix: '--p-inputnumber' }, + docs: { + description: { + component: `Числовое поле ввода с поддержкой форматирования и кнопок +/−. + +\`\`\`typescript +import { InputNumberComponent } from '@cdek-it/angular-ui-kit'; +\`\`\``, + }, + }, + }, + argTypes: { + // ── Props ──────────────────────────────────────────────── + size: { + control: 'select', + options: ['small', 'base', 'large', 'xlarge'], + description: 'Размер поля', + table: { + category: 'Props', + defaultValue: { summary: "'base'" }, + type: { summary: "'small' | 'base' | 'large' | 'xlarge'" }, + }, + }, + placeholder: { + control: 'text', + description: 'Подсказка при пустом поле', + table: { + category: 'Props', + defaultValue: { summary: "''" }, + type: { summary: 'string' }, + }, + }, + disabled: { + control: 'boolean', + description: 'Отключает взаимодействие', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + readonly: { + 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' }, + }, + }, + showButtons: { + control: 'boolean', + description: 'Показывать кнопки +/−', + table: { + category: 'Props', + defaultValue: { summary: 'true' }, + type: { summary: 'boolean' }, + }, + }, + buttonLayout: { + control: 'select', + options: ['horizontal', 'vertical', 'stacked'], + description: 'Расположение кнопок', + table: { + category: 'Props', + defaultValue: { summary: "'horizontal'" }, + type: { summary: "'horizontal' | 'vertical' | 'stacked'" }, + }, + }, + mode: { + control: 'select', + options: ['decimal', 'currency'], + description: 'Режим отображения значения', + table: { + category: 'Props', + defaultValue: { summary: "'decimal'" }, + type: { summary: "'decimal' | 'currency'" }, + }, + }, + currency: { + control: 'text', + description: 'Код валюты ISO 4217, используется при mode="currency"', + table: { + category: 'Props', + defaultValue: { summary: "'RUB'" }, + type: { summary: 'string' }, + }, + }, + locale: { + control: 'text', + description: 'Локаль для форматирования числа', + table: { + category: 'Props', + defaultValue: { summary: "'ru-RU'" }, + type: { summary: 'string' }, + }, + }, + prefix: { + control: 'text', + description: 'Префикс перед значением', + table: { + category: 'Props', + type: { summary: 'string' }, + }, + }, + suffix: { + control: 'text', + description: 'Суффикс после значения (например, "%")', + table: { + category: 'Props', + type: { summary: 'string' }, + }, + }, + min: { + control: 'number', + description: 'Минимально допустимое значение', + table: { + category: 'Props', + type: { summary: 'number' }, + }, + }, + max: { + control: 'number', + description: 'Максимально допустимое значение', + table: { + category: 'Props', + type: { summary: 'number' }, + }, + }, + step: { + control: 'number', + description: 'Шаг изменения значения', + table: { + category: 'Props', + defaultValue: { summary: '1' }, + type: { summary: 'number' }, + }, + }, + minFractionDigits: { + control: { type: 'number', min: 0, max: 20 }, + description: 'Минимальное количество знаков после запятой', + table: { + category: 'Props', + defaultValue: { summary: '0' }, + type: { summary: 'number' }, + }, + }, + maxFractionDigits: { + control: { type: 'number', min: 0, max: 20 }, + description: 'Максимальное количество знаков после запятой', + table: { + category: 'Props', + defaultValue: { summary: '20' }, + type: { summary: 'number' }, + }, + }, + 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 } }, + }, + args: { + size: 'base', + placeholder: '0', + disabled: false, + readonly: false, + invalid: false, + showButtons: true, + buttonLayout: 'horizontal', + mode: 'decimal', + currency: 'RUB', + locale: 'ru-RU', + step: 1, + minFractionDigits: 0, + maxFractionDigits: 20, + fluid: false, + }, +}; + +export default meta; +type Story = StoryObj; + +// ── Default ────────────────────────────────────────────────────────────────── +export const Default: Story = { + name: 'Default', + render: (args) => { + const parts: string[] = []; + + if (args.size && args.size !== 'base') parts.push(`size="${args.size}"`); + if (args.placeholder) parts.push(`placeholder="${args.placeholder}"`); + if (args.disabled) parts.push(`[disabled]="true"`); + if (args.readonly) parts.push(`[readonly]="true"`); + if (args.invalid) parts.push(`[invalid]="true"`); + if (!args.showButtons) parts.push(`[showButtons]="false"`); + if (args.buttonLayout && args.buttonLayout !== 'horizontal') parts.push(`buttonLayout="${args.buttonLayout}"`); + if (args.mode && args.mode !== 'decimal') parts.push(`mode="${args.mode}"`); + if (args.mode === 'currency' && args.currency) parts.push(`currency="${args.currency}"`); + if (args.locale && args.locale !== 'ru-RU') parts.push(`locale="${args.locale}"`); + if (args.prefix) parts.push(`prefix="${args.prefix}"`); + if (args.suffix) parts.push(`suffix="${args.suffix}"`); + if (args.min !== undefined) parts.push(`[min]="${args.min}"`); + if (args.max !== undefined) parts.push(`[max]="${args.max}"`); + if (args.step && args.step !== 1) parts.push(`[step]="${args.step}"`); + if (args.minFractionDigits) parts.push(`[minFractionDigits]="${args.minFractionDigits}"`); + if (args.maxFractionDigits && args.maxFractionDigits !== 20) parts.push(`[maxFractionDigits]="${args.maxFractionDigits}"`); + if (args.fluid) parts.push(`[fluid]="true"`); + parts.push(`[(ngModel)]="value"`); + + const template = ``; + + return { props: { ...args, value: null }, template }; + }, + parameters: { + docs: { + description: { + story: 'Базовый пример компонента. Используйте Controls для интерактивного изменения пропсов.', + }, + }, + }, +}; + +// ── Re-exports from example components ──────────────────────────────────── +export { FloatLabelStory as FloatLabel, Currency, MinMax }; +``` + +- [ ] **Step 2: Проверить компиляцию** + +```bash +npx tsc --noEmit +``` + +Ожидается: нет ошибок. + +- [ ] **Step 3: Коммит** + +```bash +git add src/stories/components/inputnumber/ +git commit -m "feat(inputnumber): добавить Storybook-стории" +``` + +--- + +### Task 7: Финальная проверка + +- [ ] **Step 1: Полная проверка TypeScript** + +```bash +npx tsc --noEmit +``` + +Ожидается: нет ошибок. + +- [ ] **Step 2: Запустить Storybook и проверить визуально** + +```bash +npm run storybook +``` + +Открыть `http://localhost:6006` и проверить: +- `Components/Form/InputNumber` → Default: Controls меняют пропсы, code-snippet обновляется +- FloatLabel: метка анимируется при фокусе/вводе +- Currency: значение форматируется с символом ₽ +- Min/Max: кнопки не выходят за диапазон 0–100 + +- [ ] **Step 3: Финальный коммит** + +```bash +git add -A +git commit -m "feat(inputnumber): компонент InputNumber готов" +``` + +--- + +## Self-Review + +**Spec coverage:** +- ✅ Компонент с CVA — Task 2 +- ✅ Все 18 пропсов — Task 2 (InputNumberComponent) +- ✅ Size mapping (primeSize + p-inputnumber-xlg) — Task 2 +- ✅ Tabler Icons через `incrementButtonIcon` / `decrementButtonIcon` — Task 2 +- ✅ CSS overrides (border, height, disabled, xlarge) — Task 1 +- ✅ map-tokens регистрация — Task 1 +- ✅ Default story с динамическим template — Task 6 +- ✅ FloatLabel story (нативный p-inputnumber) — Task 3 +- ✅ Currency story — Task 4 +- ✅ MinMax story — Task 5 +- ✅ modelValue / primeSize / sizeClass скрыты в argTypes — Task 6 + +**Placeholder scan:** нет TBD/TODO. + +**Type consistency:** `InputNumberSize`, `InputNumberButtonLayout`, `InputNumberMode` определены в Task 2 и используются в `argTypes` Task 6 через строковые литералы — согласованы. diff --git a/docs/superpowers/specs/2026-04-16-inputnumber-design.md b/docs/superpowers/specs/2026-04-16-inputnumber-design.md new file mode 100644 index 00000000..a0331966 --- /dev/null +++ b/docs/superpowers/specs/2026-04-16-inputnumber-design.md @@ -0,0 +1,163 @@ +# InputNumber Component — Design Spec + +**Date:** 2026-04-16 +**Branch:** `form.inputnumber` (to be created) +**Reference:** [Vue InputNumber](https://github.com/cdek-it/vue-ui-kit/tree/form.InputNumber/src/plugins/prime/stories/Form/InputNumber) + +--- + +## Overview + +Angular wrapper component for PrimeNG `InputNumber`, following the same patterns as `InputTextComponent`. Provides a styled numeric input with optional increment/decrement buttons, currency formatting, and min/max/step constraints. Integrates with Angular Forms via `ControlValueAccessor`. + +--- + +## File Structure + +``` +src/lib/components/inputnumber/ + inputnumber.component.ts + +src/prime-preset/tokens/components/ + inputnumber.ts ← new CSS override file + +src/prime-preset/ + map-tokens.ts ← add inputnumber CSS + +src/stories/components/inputnumber/ + inputnumber.stories.ts + examples/ + inputnumber-float-label.component.ts + inputnumber-currency.component.ts + inputnumber-minmax.component.ts +``` + +--- + +## Component API (`InputNumberComponent`) + +**Selector:** `input-number` +**Standalone:** yes +**CVA value type:** `number | null` + +### Inputs + +| Prop | Type | Default | Description | +|---|---|---|---| +| `size` | `'small' \| 'base' \| 'large' \| 'xlarge'` | `'base'` | Размер поля | +| `placeholder` | `string` | `''` | Подсказка при пустом поле | +| `disabled` | `boolean` | `false` | Отключает взаимодействие | +| `readonly` | `boolean` | `false` | Только для чтения | +| `invalid` | `boolean` | `false` | Невалидное состояние | +| `showButtons` | `boolean` | `true` | Показывать кнопки +/− | +| `buttonLayout` | `'horizontal' \| 'vertical' \| 'stacked'` | `'horizontal'` | Расположение кнопок | +| `mode` | `'decimal' \| 'currency'` | `'decimal'` | Режим отображения | +| `currency` | `string` | `'RUB'` | Код валюты (ISO 4217) при `mode="currency"` | +| `locale` | `string` | `'ru-RU'` | Локаль форматирования | +| `prefix` | `string \| undefined` | `undefined` | Префикс перед значением | +| `suffix` | `string \| undefined` | `undefined` | Суффикс после значения | +| `min` | `number \| undefined` | `undefined` | Минимальное значение | +| `max` | `number \| undefined` | `undefined` | Максимальное значение | +| `step` | `number` | `1` | Шаг изменения | +| `minFractionDigits` | `number` | `0` | Мин. знаков после запятой | +| `maxFractionDigits` | `number` | `20` | Макс. знаков после запятой | +| `fluid` | `boolean` | `false` | Растягивает на всю ширину | + +### Size mapping + +| `size` | `pSize` (PrimeNG) | CSS class | +|---|---|---| +| `'small'` | `'small'` | — | +| `'base'` | `undefined` | — | +| `'large'` | `'large'` | — | +| `'xlarge'` | `'large'` | `p-inputnumber-xlg` (on host) | + +The `p-inputnumber-xlg` class is applied via `[ngClass]` on the `p-inputnumber` element so CSS cascade can target `.p-inputnumber-xlg .p-inputnumber-input`. + +### Icons + +Increment button: `` via `#incrementicon` ng-template. +Decrement button: `` via `#decrementicon` ng-template. + +### CVA + +- `writeValue(v: number | null)` — stores to `modelValue` +- `registerOnChange` / `registerOnTouched` — standard +- `setDisabledState` — sets `disabled` +- `onValueChange(v: number | null)` — called on PrimeNG `(onInput)` event, calls `_onChange` + +--- + +## CSS Overrides (`src/prime-preset/tokens/components/inputnumber.ts`) + +```typescript +export const inputnumberCss = ({ dt }) => ` + .p-inputnumber-button { + border-width: ${dt('inputnumber.extend.borderWidth')}; + } + + .p-inputnumber-horizontal .p-inputnumber-button { + min-height: ${dt('inputnumber.extend.extButton.height')}; + } + + .p-inputnumber-horizontal:has(.p-inputnumber-input:disabled) .p-inputnumber-button { + background: ${dt('inputtext.root.disabledBackground')}; + color: ${dt('inputtext.root.disabledColor')}; + } + + .p-inputnumber.p-inputnumber-xlg .p-inputnumber-input { + font-size: ${dt('inputtext.extend.extXlg.fontSize')}; + padding: ${dt('inputtext.extend.extXlg.paddingY')} ${dt('inputtext.extend.extXlg.paddingX')}; + } +`; +``` + +--- + +## map-tokens.ts + +Add import and entry: + +```typescript +import { inputnumberCss } from './tokens/components/inputnumber'; + +// in components: +inputnumber: { + ...(tokens.components.inputnumber as unknown as ComponentsDesignTokens['inputnumber']), + css: inputnumberCss, +}, +``` + +--- + +## Stories + +### `inputnumber.stories.ts` + +- `meta`: `title: 'Components/Form/InputNumber'`, `component: InputNumberComponent`, `tags: ['autodocs']` +- `argTypes`: all props from API table above +- `args`: defaults from API table +- `Default` story: dynamic template built from args (same pattern as InputText Default) +- Re-exports: `FloatLabel`, `Currency`, `MinMax` + +### `examples/inputnumber-float-label.component.ts` + +Uses native `p-inputnumber` (not the wrapper) as direct child of `p-floatlabel variant="in"`, because PrimeNG FloatLabel CSS relies on sibling selectors that don't work through wrapper components. Shows `showButtons`, `buttonLayout="horizontal"`, Tabler icon templates. `controls: { disable: true }`. + +### `examples/inputnumber-currency.component.ts` + +Pure `StoryObj` (no `@Component`), `render: (args) => ({ props: { ...args, value: null }, template })`. Args preset: `mode: 'currency'`, `currency: 'RUB'`, `locale: 'ru-RU'`. All other props bound through Controls. + +### `examples/inputnumber-minmax.component.ts` + +Pure `StoryObj`. Args preset: `min: 0`, `max: 100`, `step: 1`. Shows constraint behaviour. + +--- + +## Constraints + +- No `styles: [...]` in Angular `@Component` decorator — use `const styles = ''` (webpack base64 path bug) +- Storybook story layout: Tailwind classes only, no inline `style="..."` +- Float label: always use native `p-inputnumber` directly — never the wrapper component — inside `p-floatlabel` +- Default story must build template dynamically from args so the code snippet updates with Controls +- `source.code` in float-label example should not include the outer `
` wrapper diff --git a/src/lib/components/inputnumber/inputnumber.component.ts b/src/lib/components/inputnumber/inputnumber.component.ts new file mode 100644 index 00000000..d9a4c435 --- /dev/null +++ b/src/lib/components/inputnumber/inputnumber.component.ts @@ -0,0 +1,108 @@ +import { Component, Input, Output, EventEmitter, forwardRef } from '@angular/core'; +import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { InputNumber } from 'primeng/inputnumber'; +import { SharedModule } from 'primeng/api'; + +export type InputNumberButtonLayout = 'stacked' | 'horizontal' | 'vertical'; + +@Component({ + selector: 'input-number', + standalone: true, + imports: [InputNumber, SharedModule, FormsModule], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => InputNumberComponent), + multi: true, + }, + ], + template: ` + + @if (!incrementButtonIcon) { + + + + } + @if (!decrementButtonIcon) { + + + + } + + `, +}) +export class InputNumberComponent implements ControlValueAccessor { + @Input() showButtons = false; + @Input() buttonLayout: InputNumberButtonLayout = 'stacked'; + @Input() mode = 'decimal'; + @Input() currency: string | undefined; + @Input() locale: string | undefined; + @Input() placeholder = ''; + @Input() disabled = false; + @Input() invalid = false; + @Input() readonly = false; + @Input() fluid = false; + @Input() min: number | undefined; + @Input() max: number | undefined; + @Input() step = 1; + @Input() prefix: string | undefined; + @Input() suffix: string | undefined; + @Input() minFractionDigits: number | undefined; + @Input() maxFractionDigits: number | undefined; + @Input() useGrouping = true; + @Input() incrementButtonIcon: string | undefined; + @Input() decrementButtonIcon: string | undefined; + + @Output() onInput = new EventEmitter<{ value: number | null }>(); + + modelValue: number | null = null; + + private _onChange: (value: number | null) => void = () => {}; + onTouched: () => void = () => {}; + + onModelChange(value: number | null): void { + this.modelValue = value; + this._onChange(value); + this.onInput.emit({ value }); + } + + writeValue(value: number | null): void { + this.modelValue = value ?? null; + } + + registerOnChange(fn: (value: number | 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 09d9449d..02eeeb27 100644 --- a/src/prime-preset/map-tokens.ts +++ b/src/prime-preset/map-tokens.ts @@ -9,6 +9,7 @@ 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 { inputnumberCss } from './tokens/components/inputnumber'; import { tooltipCss } from './tokens/components/tooltip'; const presetTokens: Preset = { @@ -32,6 +33,10 @@ const presetTokens: Preset = { ...(tokens.components.progressspinner as unknown as ComponentsDesignTokens['progressspinner']), css: progressspinnerCss, }, + inputnumber: { + ...(tokens.components.inputnumber as unknown as ComponentsDesignTokens['inputnumber']), + css: inputnumberCss, + }, inputtext: { ...(tokens.components.inputtext as unknown as ComponentsDesignTokens['inputtext']), css: inputtextCss, diff --git a/src/prime-preset/tokens/components/inputnumber.ts b/src/prime-preset/tokens/components/inputnumber.ts new file mode 100644 index 00000000..f28e4fb7 --- /dev/null +++ b/src/prime-preset/tokens/components/inputnumber.ts @@ -0,0 +1,23 @@ +export const inputnumberCss = ({ dt }: { dt: (token: string) => string }): string => ` + +/* ─── Кнопки увеличения/уменьшения ─── */ +.p-inputnumber-button { + border-width: ${dt('inputnumber.extend.borderWidth')}; +} + +.p-inputnumber-horizontal .p-inputnumber-button { + min-height: ${dt('inputnumber.extend.extButton.height')}; +} + +/* ─── Disabled состояние ─── */ +.p-inputnumber-horizontal:has(.p-inputnumber-input:disabled) .p-inputnumber-button { + background: ${dt('inputtext.root.disabledBackground')}; + color: ${dt('inputtext.root.disabledColor')}; +} + +/* ─── Extra Large ─── */ +.p-inputnumber-input[data-p~="xlarge"] { + font-size: ${dt('inputtext.extend.extXlg.fontSize')}; + padding: ${dt('inputtext.extend.extXlg.paddingY')} ${dt('inputtext.extend.extXlg.paddingX')}; +} +`; diff --git a/src/stories/components/inputnumber/examples/inputnumber-buttons.component.ts b/src/stories/components/inputnumber/examples/inputnumber-buttons.component.ts new file mode 100644 index 00000000..b366e5f8 --- /dev/null +++ b/src/stories/components/inputnumber/examples/inputnumber-buttons.component.ts @@ -0,0 +1,37 @@ +import { StoryObj } from '@storybook/angular'; +import { InputNumberComponent } from '../../../../lib/components/inputnumber/inputnumber.component'; + +type Story = StoryObj; + +export const Buttons: Story = { + name: 'Buttons', + render: (args) => ({ + props: { ...args, value: null }, + template: ` + + `, + }), + args: {}, + parameters: { + controls: { disable: true }, + docs: { + description: { + story: 'Числовое поле с кнопками увеличения/уменьшения в горизонтальной раскладке. Кастомные SVG-иконки +/− используются по умолчанию.', + }, + source: { + language: 'ts', + code: ` +import { InputNumberComponent } from '@cdek-it/angular-ui-kit'; +import { FormsModule } from '@angular/forms'; + +// template: +// + `, + }, + }, + }, +}; diff --git a/src/stories/components/inputnumber/examples/inputnumber-currency.component.ts b/src/stories/components/inputnumber/examples/inputnumber-currency.component.ts new file mode 100644 index 00000000..186e3a9b --- /dev/null +++ b/src/stories/components/inputnumber/examples/inputnumber-currency.component.ts @@ -0,0 +1,42 @@ +import { StoryObj } from '@storybook/angular'; +import { InputNumberComponent } from '../../../../lib/components/inputnumber/inputnumber.component'; + +type Story = StoryObj; + +export const Currency: Story = { + name: 'Currency', + render: (args) => ({ + props: { ...args, value: null }, + template: ` + + `, + }), + args: { + mode: 'currency', + currency: 'RUB', + locale: 'ru-RU', + }, + parameters: { + controls: { disable: true }, + docs: { + description: { + story: 'Форматирование значения как валюты (рубли). Используются `mode="currency"`, `currency="RUB"` и `locale="ru-RU"`.', + }, + source: { + language: 'ts', + code: ` +import { InputNumberComponent } from '@cdek-it/angular-ui-kit'; +import { FormsModule } from '@angular/forms'; + +// template: +// + `, + }, + }, + }, +}; diff --git a/src/stories/components/inputnumber/examples/inputnumber-disabled.component.ts b/src/stories/components/inputnumber/examples/inputnumber-disabled.component.ts new file mode 100644 index 00000000..19d14edb --- /dev/null +++ b/src/stories/components/inputnumber/examples/inputnumber-disabled.component.ts @@ -0,0 +1,38 @@ +import { StoryObj } from '@storybook/angular'; +import { InputNumberComponent } from '../../../../lib/components/inputnumber/inputnumber.component'; + +type Story = StoryObj; + +export const Disabled: Story = { + name: 'Disabled', + render: (args) => ({ + props: { ...args, value: 42 }, + template: ` + + `, + }), + args: {}, + parameters: { + controls: { disable: true }, + docs: { + description: { + story: 'Отключённое состояние — поле и кнопки недоступны для взаимодействия.', + }, + source: { + language: 'ts', + code: ` +import { InputNumberComponent } from '@cdek-it/angular-ui-kit'; +import { FormsModule } from '@angular/forms'; + +// template: +// + `, + }, + }, + }, +}; diff --git a/src/stories/components/inputnumber/examples/inputnumber-float-label.component.ts b/src/stories/components/inputnumber/examples/inputnumber-float-label.component.ts new file mode 100644 index 00000000..d85ef06b --- /dev/null +++ b/src/stories/components/inputnumber/examples/inputnumber-float-label.component.ts @@ -0,0 +1,82 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { InputNumber } from 'primeng/inputnumber'; +import { FloatLabel } from 'primeng/floatlabel'; +import { SharedModule } from 'primeng/api'; + +const template = ` +
+ + + + + + + + + + + +
+`; +const styles = ''; + +@Component({ + selector: 'app-inputnumber-float-label', + standalone: true, + imports: [InputNumber, FloatLabel, FormsModule, SharedModule], + template, + styles, +}) +export class InputNumberFloatLabelComponent { + value: number | null = null; +} + +export const FloatLabelStory: StoryObj = { + name: 'FloatLabel', + render: () => ({ + template: ``, + }), + parameters: { + controls: { disable: true }, + docs: { + description: { + story: + 'Интеграция с `p-floatlabel` — плавающая метка внутри поля. Требует нативный `p-inputNumber` как прямой дочерний элемент `p-floatlabel`.', + }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { InputNumber } from 'primeng/inputnumber'; +import { FloatLabel } from 'primeng/floatlabel'; +import { SharedModule } from 'primeng/api'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'app-inputnumber-float-label', + standalone: true, + imports: [InputNumber, FloatLabel, FormsModule, SharedModule], + template: \` + + + + + + + + + + + + \`, +}) +export class InputNumberFloatLabelComponent { + value: number | null = null; +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/inputnumber/inputnumber.stories.ts b/src/stories/components/inputnumber/inputnumber.stories.ts new file mode 100644 index 00000000..54c02273 --- /dev/null +++ b/src/stories/components/inputnumber/inputnumber.stories.ts @@ -0,0 +1,252 @@ +import { Meta, StoryObj, moduleMetadata } from '@storybook/angular'; +import { FormsModule } from '@angular/forms'; +import { InputNumberComponent } from '../../../lib/components/inputnumber/inputnumber.component'; +import { InputNumberFloatLabelComponent, FloatLabelStory } from './examples/inputnumber-float-label.component'; +import { Currency } from './examples/inputnumber-currency.component'; +import { Buttons } from './examples/inputnumber-buttons.component'; +import { Disabled } from './examples/inputnumber-disabled.component'; + +type InputNumberArgs = InputNumberComponent; + +const meta: Meta = { + title: 'Components/Form/InputNumber', + component: InputNumberComponent, + tags: ['autodocs'], + decorators: [ + moduleMetadata({ + imports: [ + InputNumberComponent, + FormsModule, + InputNumberFloatLabelComponent, + ], + }), + ], + parameters: { + designTokens: { prefix: '--p-inputnumber' }, + docs: { + description: { + component: `Числовое поле ввода с поддержкой форматирования, валюты и кнопок увеличения/уменьшения. + +\`\`\`typescript +import { InputNumberComponent } from '@cdek-it/angular-ui-kit'; +\`\`\``, + }, + }, + }, + argTypes: { + // ── Props ──────────────────────────────────────────────── + placeholder: { + control: 'text', + description: 'Подсказка при пустом поле', + table: { + category: 'Props', + defaultValue: { summary: "''" }, + type: { summary: 'string' }, + }, + }, + showButtons: { + control: 'boolean', + description: 'Отображает кнопки увеличения/уменьшения', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + buttonLayout: { + control: 'select', + options: ['stacked', 'horizontal', 'vertical'], + description: 'Расположение кнопок', + table: { + category: 'Props', + defaultValue: { summary: "'stacked'" }, + type: { summary: "'stacked' | 'horizontal' | 'vertical'" }, + }, + }, + mode: { + control: 'select', + options: ['decimal', 'currency'], + description: 'Режим форматирования', + table: { + category: 'Props', + defaultValue: { summary: "'decimal'" }, + type: { summary: "'decimal' | 'currency'" }, + }, + }, + currency: { + control: 'text', + description: 'ISO 4217 код валюты (при `mode="currency"`)', + table: { + category: 'Props', + defaultValue: { summary: 'undefined' }, + type: { summary: 'string' }, + }, + }, + locale: { + control: 'text', + description: 'Локаль для форматирования', + table: { + category: 'Props', + defaultValue: { summary: 'undefined' }, + type: { summary: 'string' }, + }, + }, + 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' }, + }, + }, + readonly: { + control: 'boolean', + description: 'Только для чтения', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + fluid: { + control: 'boolean', + description: 'Растягивает поле на всю ширину контейнера', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + min: { + control: 'number', + description: 'Минимальное значение', + table: { + category: 'Props', + defaultValue: { summary: 'undefined' }, + type: { summary: 'number' }, + }, + }, + max: { + control: 'number', + description: 'Максимальное значение', + table: { + category: 'Props', + defaultValue: { summary: 'undefined' }, + type: { summary: 'number' }, + }, + }, + step: { + control: 'number', + description: 'Шаг изменения значения', + table: { + category: 'Props', + defaultValue: { summary: '1' }, + type: { summary: 'number' }, + }, + }, + useGrouping: { + control: 'boolean', + description: 'Использовать разделитель групп разрядов', + table: { + category: 'Props', + defaultValue: { summary: 'true' }, + type: { summary: 'boolean' }, + }, + }, + prefix: { + control: 'text', + description: 'Текст перед значением', + table: { + category: 'Props', + defaultValue: { summary: 'undefined' }, + type: { summary: 'string' }, + }, + }, + suffix: { + control: 'text', + description: 'Текст после значения', + table: { + category: 'Props', + defaultValue: { summary: 'undefined' }, + type: { summary: 'string' }, + }, + }, + // Hidden computed props + modelValue: { table: { disable: true } }, + + // ── Events ─────────────────────────────────────────────── + onInput: { + control: false, + description: 'Событие при изменении значения', + table: { + category: 'Events', + type: { summary: 'EventEmitter<{ value: number | null }>' }, + }, + }, + }, + args: { + placeholder: 'Введите число...', + showButtons: false, + buttonLayout: 'stacked', + mode: 'decimal', + disabled: false, + invalid: false, + readonly: false, + fluid: false, + step: 1, + useGrouping: true, + }, +}; + +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.showButtons) parts.push(`[showButtons]="true"`); + if (args.buttonLayout && args.buttonLayout !== 'stacked') parts.push(`buttonLayout="${args.buttonLayout}"`); + if (args.mode && args.mode !== 'decimal') parts.push(`mode="${args.mode}"`); + if (args.currency) parts.push(`currency="${args.currency}"`); + if (args.locale) parts.push(`locale="${args.locale}"`); + if (args.disabled) parts.push(`[disabled]="true"`); + if (args.invalid) parts.push(`[invalid]="true"`); + if (args.readonly) parts.push(`[readonly]="true"`); + if (args.fluid) parts.push(`[fluid]="true"`); + if (args.min != null) parts.push(`[min]="${args.min}"`); + if (args.max != null) parts.push(`[max]="${args.max}"`); + if (args.step && args.step !== 1) parts.push(`[step]="${args.step}"`); + if (args.prefix) parts.push(`prefix="${args.prefix}"`); + if (args.suffix) parts.push(`suffix="${args.suffix}"`); + if (!args.useGrouping) parts.push(`[useGrouping]="false"`); + parts.push(`[(ngModel)]="value"`); + + const template = ``; + + return { props: { ...args, value: null }, template }; + }, + parameters: { + docs: { + description: { + story: 'Базовый пример компонента. Используйте Controls для интерактивного изменения пропсов.', + }, + }, + }, +}; + +// ── Re-exports from example components ──────────────────────────────────── +export { Currency, Buttons, Disabled, FloatLabelStory as FloatLabel }; From 3cdc5eaf1dd877cbae4032cdd0bf25396cb2a8be Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Fri, 17 Apr 2026 13:05:30 +0700 Subject: [PATCH 02/20] =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=BC=D0=B5=D1=80=D0=B0=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BE?= =?UTF-8?q?=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../inputnumber/inputnumber.component.ts | 17 ++++++++++++++++- .../tokens/components/inputnumber.ts | 7 ++++++- src/prime-preset/tokens/tokens.json | 2 +- .../inputnumber/inputnumber.stories.ts | 14 ++++++++++++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/lib/components/inputnumber/inputnumber.component.ts b/src/lib/components/inputnumber/inputnumber.component.ts index d9a4c435..4ca5bf39 100644 --- a/src/lib/components/inputnumber/inputnumber.component.ts +++ b/src/lib/components/inputnumber/inputnumber.component.ts @@ -1,14 +1,16 @@ import { Component, Input, Output, EventEmitter, forwardRef } from '@angular/core'; import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { NgClass } from '@angular/common'; import { InputNumber } from 'primeng/inputnumber'; import { SharedModule } from 'primeng/api'; +export type InputNumberSize = 'small' | 'base' | 'large' | 'xlarge'; export type InputNumberButtonLayout = 'stacked' | 'horizontal' | 'vertical'; @Component({ selector: 'input-number', standalone: true, - imports: [InputNumber, SharedModule, FormsModule], + imports: [InputNumber, SharedModule, FormsModule, NgClass], providers: [ { provide: NG_VALUE_ACCESSOR, @@ -18,6 +20,8 @@ export type InputNumberButtonLayout = 'stacked' | 'horizontal' | 'vertical'; ], template: ` void = () => {}; onTouched: () => 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-inputnumber-xlg': this.size === 'xlarge' }; + } + onModelChange(value: number | null): void { this.modelValue = value; this._onChange(value); diff --git a/src/prime-preset/tokens/components/inputnumber.ts b/src/prime-preset/tokens/components/inputnumber.ts index f28e4fb7..4be65a84 100644 --- a/src/prime-preset/tokens/components/inputnumber.ts +++ b/src/prime-preset/tokens/components/inputnumber.ts @@ -15,8 +15,13 @@ export const inputnumberCss = ({ dt }: { dt: (token: string) => string }): strin color: ${dt('inputtext.root.disabledColor')}; } +/* ─── FloatLabel: кнопки на полную высоту поля ─── */ +.p-floatlabel:has(.p-inputnumber-horizontal) .p-inputnumber-button { + align-self: stretch; +} + /* ─── Extra Large ─── */ -.p-inputnumber-input[data-p~="xlarge"] { +.p-inputnumber.p-inputnumber-xlg .p-inputnumber-input { font-size: ${dt('inputtext.extend.extXlg.fontSize')}; padding: ${dt('inputtext.extend.extXlg.paddingY')} ${dt('inputtext.extend.extXlg.paddingX')}; } diff --git a/src/prime-preset/tokens/tokens.json b/src/prime-preset/tokens/tokens.json index 6fd82189..d8078a4a 100644 --- a/src/prime-preset/tokens/tokens.json +++ b/src/prime-preset/tokens/tokens.json @@ -3091,7 +3091,7 @@ "transitionDuration": "{form.transitionDuration}" }, "button": { - "width": "{form.width.300}", + "width": "{form.size.600}", "borderRadius": "{form.borderRadius.200}", "verticalPadding": "{form.padding.300}" } diff --git a/src/stories/components/inputnumber/inputnumber.stories.ts b/src/stories/components/inputnumber/inputnumber.stories.ts index 54c02273..7c37fc85 100644 --- a/src/stories/components/inputnumber/inputnumber.stories.ts +++ b/src/stories/components/inputnumber/inputnumber.stories.ts @@ -35,6 +35,16 @@ import { InputNumberComponent } from '@cdek-it/angular-ui-kit'; }, argTypes: { // ── Props ──────────────────────────────────────────────── + size: { + control: 'select', + options: ['small', 'base', 'large', 'xlarge'], + description: 'Размер компонента', + table: { + category: 'Props', + defaultValue: { summary: "'base'" }, + type: { summary: "'small' | 'base' | 'large' | 'xlarge'" }, + }, + }, placeholder: { control: 'text', description: 'Подсказка при пустом поле', @@ -183,6 +193,8 @@ import { InputNumberComponent } from '@cdek-it/angular-ui-kit'; }, // Hidden computed props modelValue: { table: { disable: true } }, + primeSize: { table: { disable: true } }, + sizeClass: { table: { disable: true } }, // ── Events ─────────────────────────────────────────────── onInput: { @@ -195,6 +207,7 @@ import { InputNumberComponent } from '@cdek-it/angular-ui-kit'; }, }, args: { + size: 'base', placeholder: 'Введите число...', showButtons: false, buttonLayout: 'stacked', @@ -217,6 +230,7 @@ export const Default: Story = { render: (args) => { const parts: string[] = []; + if (args.size && args.size !== 'base') parts.push(`size="${args.size}"`); if (args.placeholder) parts.push(`placeholder="${args.placeholder}"`); if (args.showButtons) parts.push(`[showButtons]="true"`); if (args.buttonLayout && args.buttonLayout !== 'stacked') parts.push(`buttonLayout="${args.buttonLayout}"`); From f1f83f9d14813163ceaa16d15d9e2b8840e55250 Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Fri, 17 Apr 2026 15:05:28 +0700 Subject: [PATCH 03/20] =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=20=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=86=20=D0=B4=D0=BB=D1=8F=20=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D1=81=20Buttons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/inputnumber/inputnumber.component.ts | 10 +++++----- src/prime-preset/tokens/components/inputnumber.ts | 5 +++++ .../components/inputnumber/inputnumber.stories.ts | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/lib/components/inputnumber/inputnumber.component.ts b/src/lib/components/inputnumber/inputnumber.component.ts index 4ca5bf39..4dd3e4e5 100644 --- a/src/lib/components/inputnumber/inputnumber.component.ts +++ b/src/lib/components/inputnumber/inputnumber.component.ts @@ -21,7 +21,7 @@ export type InputNumberButtonLayout = 'stacked' | 'horizontal' | 'vertical'; template: ` void = () => {}; onTouched: () => void = () => {}; - get primeSize(): 'small' | 'large' | undefined { - if (this.size === 'small') return 'small'; - if (this.size === 'large' || this.size === 'xlarge') return 'large'; - return undefined; + get inputSizeClass(): string { + if (this.size === 'small') return 'p-inputtext-sm'; + if (this.size === 'large' || this.size === 'xlarge') return 'p-inputtext-lg'; + return ''; } get sizeClass(): Record { diff --git a/src/prime-preset/tokens/components/inputnumber.ts b/src/prime-preset/tokens/components/inputnumber.ts index 4be65a84..dc4f5f57 100644 --- a/src/prime-preset/tokens/components/inputnumber.ts +++ b/src/prime-preset/tokens/components/inputnumber.ts @@ -7,6 +7,11 @@ export const inputnumberCss = ({ dt }: { dt: (token: string) => string }): strin .p-inputnumber-horizontal .p-inputnumber-button { min-height: ${dt('inputnumber.extend.extButton.height')}; + border: ${dt('inputnumber.extend.borderWidth')} solid ${dt('inputnumber.button.borderColor')}; +} + +.p-inputnumber-horizontal .p-inputnumber-decrement-button { + border-right: none; } /* ─── Disabled состояние ─── */ diff --git a/src/stories/components/inputnumber/inputnumber.stories.ts b/src/stories/components/inputnumber/inputnumber.stories.ts index 7c37fc85..9a34f283 100644 --- a/src/stories/components/inputnumber/inputnumber.stories.ts +++ b/src/stories/components/inputnumber/inputnumber.stories.ts @@ -193,7 +193,7 @@ import { InputNumberComponent } from '@cdek-it/angular-ui-kit'; }, // Hidden computed props modelValue: { table: { disable: true } }, - primeSize: { table: { disable: true } }, + inputSizeClass: { table: { disable: true } }, sizeClass: { table: { disable: true } }, // ── Events ─────────────────────────────────────────────── From 4706a04da43124c8339006934cd0ea09c961c4cb Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Mon, 20 Apr 2026 19:47:04 +0700 Subject: [PATCH 04/20] =?UTF-8?q?textarea:=20=D1=81=D1=82=D0=B8=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + .../components/textarea/textarea.component.ts | 92 +++++++++ src/prime-preset/map-tokens.ts | 5 + .../tokens/components/textarea.ts | 27 +++ src/prime-preset/tokens/tokens.json | 31 +-- .../examples/textarea-autoresize.component.ts | 49 +++++ .../examples/textarea-disabled.component.ts | 45 ++++ .../examples/textarea-invalid.component.ts | 45 ++++ .../examples/textarea-readonly.component.ts | 45 ++++ .../examples/textarea-sizes.component.ts | 58 ++++++ .../components/textarea/textarea.stories.ts | 193 ++++++++++++++++++ 11 files changed, 579 insertions(+), 13 deletions(-) create mode 100644 src/lib/components/textarea/textarea.component.ts create mode 100644 src/prime-preset/tokens/components/textarea.ts create mode 100644 src/stories/components/textarea/examples/textarea-autoresize.component.ts create mode 100644 src/stories/components/textarea/examples/textarea-disabled.component.ts create mode 100644 src/stories/components/textarea/examples/textarea-invalid.component.ts create mode 100644 src/stories/components/textarea/examples/textarea-readonly.component.ts create mode 100644 src/stories/components/textarea/examples/textarea-sizes.component.ts create mode 100644 src/stories/components/textarea/textarea.stories.ts diff --git a/.gitignore b/.gitignore index 808f9bdc..ce8e3c03 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,5 @@ src/assets/components/themes /storybook-static /debug-storybook.log /documentation.json + +.claude/* \ No newline at end of file diff --git a/src/lib/components/textarea/textarea.component.ts b/src/lib/components/textarea/textarea.component.ts new file mode 100644 index 00000000..52852c63 --- /dev/null +++ b/src/lib/components/textarea/textarea.component.ts @@ -0,0 +1,92 @@ +import { ChangeDetectionStrategy, Component, Input, Output, EventEmitter, forwardRef } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { NgClass } from '@angular/common'; +import { Textarea } from 'primeng/textarea'; + +export type TextareaSize = 'small' | 'base' | 'large' | 'xlarge'; +export type TextareaVariant = 'outlined' | 'filled'; + +@Component({ + selector: 'ui-textarea', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [Textarea, NgClass], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TextareaComponent), + multi: true, + }, + ], + template: ` + + `, +}) +export class TextareaComponent implements ControlValueAccessor { + @Input() placeholder = ''; + @Input() size: TextareaSize = 'base'; + @Input() disabled = false; + @Input() readonly = false; + @Input() invalid = false; + @Input() fluid = false; + @Input() autoResize = false; + @Input() rows = 3; + @Input() cols?: number; + @Input() variant: TextareaVariant = 'outlined'; + + @Output() onResize = new EventEmitter<{ height: string }>(); + + modelValue = ''; + + private _onChange: (value: string) => void = () => {}; + + get primeSize(): 'small' | 'large' | undefined { + if (this.size === 'small') return 'small'; + if (this.size === 'large') return 'large'; + return undefined; + } + + get sizeClass(): Record { + return { 'p-textarea-xlg': this.size === 'xlarge' }; + } + + onInput(event: Event): void { + const value = (event.target as HTMLTextAreaElement).value; + this.modelValue = value; + this._onChange(value); + } + + onTouched: () => void = () => {}; + + 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 09d9449d..353be0e4 100644 --- a/src/prime-preset/map-tokens.ts +++ b/src/prime-preset/map-tokens.ts @@ -9,6 +9,7 @@ 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 { textareaCss } from './tokens/components/textarea'; import { tooltipCss } from './tokens/components/tooltip'; const presetTokens: Preset = { @@ -40,6 +41,10 @@ const presetTokens: Preset = { ...(tokens.components.tag as unknown as ComponentsDesignTokens['tag']), css: tagCss, }, + textarea: { + ...(tokens.components.textarea as unknown as ComponentsDesignTokens['textarea']), + css: textareaCss, + }, tooltip: { ...(tokens.components.tooltip as unknown as ComponentsDesignTokens['tooltip']), css: tooltipCss, diff --git a/src/prime-preset/tokens/components/textarea.ts b/src/prime-preset/tokens/components/textarea.ts new file mode 100644 index 00000000..d35154ad --- /dev/null +++ b/src/prime-preset/tokens/components/textarea.ts @@ -0,0 +1,27 @@ +export const textareaCss = ({ dt }: { dt: (token: string) => string }): string => ` + +/* --- Base --- */ +.p-textarea { + border-width: ${dt('textarea.extend.borderWidth')}; + line-height: ${dt('fonts.lineHeight.250')}; + min-height: ${dt('textarea.extend.minHeight')}; +} + +/* --- Sizes --- */ +.p-textarea.p-textarea-xlg { + font-size: ${dt('textarea.extend.extXlg.fontSize')}; + padding: ${dt('textarea.extend.extXlg.paddingY')} ${dt('textarea.extend.extXlg.paddingX')}; +} + +/* --- States --- */ +.p-textarea:enabled:read-only { + background: ${dt('textarea.extend.readonlyBackground')}; +} + +.p-textarea:is(.p-disabled, :disabled) { + background: ${dt('textarea.disabled.background')}; + color: ${dt('textarea.disabled.color')}; + opacity: 1; +} + +`; diff --git a/src/prime-preset/tokens/tokens.json b/src/prime-preset/tokens/tokens.json index 6fd82189..0afd51d1 100644 --- a/src/prime-preset/tokens/tokens.json +++ b/src/prime-preset/tokens/tokens.json @@ -4703,7 +4703,12 @@ "readonlyBackground": "{form.readonlyBackground}", "borderWidth": "{form.borderWidth}", "iconSize": "{form.icon.300}", - "minHeight": "{form.size.900}" + "minHeight": "{form.size.900}", + "extXlg": { + "fontSize": "{fonts.fontSize.300}", + "paddingX": "{form.padding.200}", + "paddingY": "{form.padding.500}" + } }, "root": { "background": "{form.background}", @@ -4720,8 +4725,8 @@ "placeholderColor": "{form.placeholderColor}", "invalidPlaceholderColor": "{form.invalidPlaceholderColor}", "shadow": "0", - "paddingX": "{form.paddingX}", - "paddingY": "{form.paddingY}", + "paddingX": "{form.padding.200}", + "paddingY": "{form.padding.300}", "borderRadius": "{form.borderRadius.200}", "transitionDuration": "{form.transitionDuration}", "focusRing": { @@ -4730,17 +4735,17 @@ "color": "{form.focusRing.color}", "offset": "{form.focusRing.offset}", "shadow": "0" + }, + "sm": { + "fontSize": "{fonts.fontSize.300}", + "paddingX": "{form.padding.200}", + "paddingY": "{form.padding.200}" + }, + "lg": { + "fontSize": "{fonts.fontSize.300}", + "paddingX": "{form.padding.200}", + "paddingY": "{form.padding.400}" } - }, - "sm": { - "fontSize": "{fonts.fontSize.300}", - "paddingX": "{form.padding.200}", - "paddingY": "{form.padding.200}" - }, - "lg": { - "fontSize": "{fonts.fontSize.300}", - "paddingX": "{form.padding.400}", - "paddingY": "{form.padding.400}" } }, "tieredmenu": { diff --git a/src/stories/components/textarea/examples/textarea-autoresize.component.ts b/src/stories/components/textarea/examples/textarea-autoresize.component.ts new file mode 100644 index 00000000..25913565 --- /dev/null +++ b/src/stories/components/textarea/examples/textarea-autoresize.component.ts @@ -0,0 +1,49 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { TextareaComponent } from '../../../../lib/components/textarea/textarea.component'; + +export const template = ` +
+ + +
+`; +const styles = ''; + +@Component({ + selector: 'app-textarea-autoresize', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TextareaComponent, FormsModule], + template, + styles, +}) +export class TextareaAutoResizeComponent { + value = ''; +} + +export const AutoResize: StoryObj = { + render: () => ({ + template: ``, + }), + parameters: { + controls: { disable: true }, + docs: { + description: { + story: 'Режим авторасширения — поле увеличивается по высоте по мере ввода текста.', + }, + source: { + language: 'ts', + code: `@Component({ + template: \`${template}\`, +})`, + }, + }, + }, +}; diff --git a/src/stories/components/textarea/examples/textarea-disabled.component.ts b/src/stories/components/textarea/examples/textarea-disabled.component.ts new file mode 100644 index 00000000..213d8f0c --- /dev/null +++ b/src/stories/components/textarea/examples/textarea-disabled.component.ts @@ -0,0 +1,45 @@ +import { StoryObj } from '@storybook/angular'; +import { TextareaComponent } from '../../../../lib/components/textarea/textarea.component'; + +type Story = StoryObj; + +export const Disabled: Story = { + name: 'Disabled', + render: (args) => ({ + props: { ...args, value: 'Текст в заблокированном поле' }, + template: ` + + `, + }), + args: { + disabled: true, + placeholder: 'Введите текст...', + }, + parameters: { + docs: { + description: { + story: 'Отключённое состояние — поле недоступно для взаимодействия.', + }, + source: { + language: 'ts', + code: ` +import { TextareaComponent } from '@cdek-it/angular-ui-kit'; +import { FormsModule } from '@angular/forms'; + +// template: +// + `, + }, + }, + }, +}; diff --git a/src/stories/components/textarea/examples/textarea-invalid.component.ts b/src/stories/components/textarea/examples/textarea-invalid.component.ts new file mode 100644 index 00000000..0ab85e97 --- /dev/null +++ b/src/stories/components/textarea/examples/textarea-invalid.component.ts @@ -0,0 +1,45 @@ +import { StoryObj } from '@storybook/angular'; +import { TextareaComponent } from '../../../../lib/components/textarea/textarea.component'; + +type Story = StoryObj; + +export const Invalid: Story = { + name: 'Invalid', + render: (args) => ({ + props: { ...args, value: '' }, + template: ` + + `, + }), + args: { + invalid: true, + placeholder: 'Обязательное поле', + }, + parameters: { + docs: { + description: { + story: 'Невалидное состояние — поле выделяется красной рамкой.', + }, + source: { + language: 'ts', + code: ` +import { TextareaComponent } from '@cdek-it/angular-ui-kit'; +import { FormsModule } from '@angular/forms'; + +// template: +// + `, + }, + }, + }, +}; diff --git a/src/stories/components/textarea/examples/textarea-readonly.component.ts b/src/stories/components/textarea/examples/textarea-readonly.component.ts new file mode 100644 index 00000000..71c1550d --- /dev/null +++ b/src/stories/components/textarea/examples/textarea-readonly.component.ts @@ -0,0 +1,45 @@ +import { StoryObj } from '@storybook/angular'; +import { TextareaComponent } from '../../../../lib/components/textarea/textarea.component'; + +type Story = StoryObj; + +export const Readonly: Story = { + name: 'Readonly', + render: (args) => ({ + props: { ...args, value: 'Только для чтения — этот текст нельзя изменить.' }, + template: ` + + `, + }), + args: { + readonly: true, + placeholder: 'Введите текст...', + }, + parameters: { + docs: { + description: { + story: 'Режим только для чтения — содержимое отображается, но не редактируется.', + }, + source: { + language: 'ts', + code: ` +import { TextareaComponent } from '@cdek-it/angular-ui-kit'; +import { FormsModule } from '@angular/forms'; + +// template: +// + `, + }, + }, + }, +}; diff --git a/src/stories/components/textarea/examples/textarea-sizes.component.ts b/src/stories/components/textarea/examples/textarea-sizes.component.ts new file mode 100644 index 00000000..4643be86 --- /dev/null +++ b/src/stories/components/textarea/examples/textarea-sizes.component.ts @@ -0,0 +1,58 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { TextareaComponent } from '../../../../lib/components/textarea/textarea.component'; + +export const template = ` +
+
+
small
+ +
+
+
base
+ +
+
+
large
+ +
+
+
xlarge
+ +
+
+`; +const styles = ''; + +@Component({ + selector: 'app-textarea-sizes', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TextareaComponent, FormsModule], + template, + styles, +}) +export class TextareaSizesComponent { + value = ''; +} + +export const Sizes: StoryObj = { + render: () => ({ + template: ``, + }), + parameters: { + controls: { disable: true }, + docs: { + description: { + story: 'Все доступные размеры компонента: small, base, large, xlarge.', + }, + source: { + language: 'ts', + code: `@Component({ + template: \`${template}\`, +})`, + }, + }, + }, +}; diff --git a/src/stories/components/textarea/textarea.stories.ts b/src/stories/components/textarea/textarea.stories.ts new file mode 100644 index 00000000..7069b1ed --- /dev/null +++ b/src/stories/components/textarea/textarea.stories.ts @@ -0,0 +1,193 @@ +import { Meta, StoryObj, moduleMetadata } from '@storybook/angular'; +import { FormsModule } from '@angular/forms'; +import { TextareaComponent } from '../../../lib/components/textarea/textarea.component'; +import { Disabled } from './examples/textarea-disabled.component'; +import { Readonly } from './examples/textarea-readonly.component'; +import { Invalid } from './examples/textarea-invalid.component'; +import { AutoResize, TextareaAutoResizeComponent } from './examples/textarea-autoresize.component'; +import { Sizes, TextareaSizesComponent } from './examples/textarea-sizes.component'; + +type TextareaArgs = TextareaComponent; + +const meta: Meta = { + title: 'Components/Form/Textarea', + component: TextareaComponent, + tags: ['autodocs'], + decorators: [ + moduleMetadata({ + imports: [ + TextareaComponent, + FormsModule, + TextareaAutoResizeComponent, + TextareaSizesComponent, + ], + }), + ], + parameters: { + designTokens: { prefix: '--p-textarea' }, + docs: { + description: { + component: `Многострочное текстовое поле для ввода данных. Поддерживает авторасширение, состояния disabled/readonly/invalid, размеры и интеграцию с формами через CVA. + +\`\`\`typescript +import { TextareaComponent } from '@cdek-it/angular-ui-kit'; +\`\`\``, + }, + }, + }, + argTypes: { + 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: 'Отключает взаимодействие', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + readonly: { + 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' }, + }, + }, + autoResize: { + control: 'boolean', + description: 'Автоматически увеличивает высоту по мере ввода', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + rows: { + control: 'number', + description: 'Количество видимых строк', + table: { + category: 'Props', + defaultValue: { summary: '3' }, + type: { summary: 'number' }, + }, + }, + cols: { + control: 'number', + description: 'Количество видимых столбцов', + table: { + category: 'Props', + defaultValue: { summary: 'undefined' }, + type: { summary: 'number' }, + }, + }, + variant: { + control: 'select', + options: ['outlined', 'filled'], + description: 'Визуальный вариант поля', + table: { + category: 'Props', + defaultValue: { summary: "'outlined'" }, + type: { summary: "'outlined' | 'filled'" }, + }, + }, + // Скрыть внутренние computed props + modelValue: { table: { disable: true } }, + primeSize: { table: { disable: true } }, + sizeClass: { table: { disable: true } }, + // Events + onResize: { + control: false, + description: 'Событие изменения высоты поля (при autoResize)', + table: { + category: 'Events', + type: { summary: 'EventEmitter<{ height: string }>' }, + }, + }, + }, + args: { + placeholder: 'Введите текст...', + size: 'base', + disabled: false, + readonly: false, + invalid: false, + fluid: false, + autoResize: false, + rows: 3, + variant: 'outlined', + }, +}; + +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.disabled) parts.push(`[disabled]="true"`); + if (args.readonly) parts.push(`[readonly]="true"`); + if (args.invalid) parts.push(`[invalid]="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.variant && args.variant !== 'outlined') parts.push(`variant="${args.variant}"`); + parts.push(`[(ngModel)]="value"`); + + const template = ``; + + return { props: { ...args, value: '' }, template }; + }, + parameters: { + docs: { + description: { + story: 'Базовый пример компонента. Используйте Controls для интерактивного изменения пропсов.', + }, + }, + }, +}; + +// ── Re-exports from example components ──────────────────────────────────── +export { Disabled, Readonly, Invalid, AutoResize, Sizes }; From 0f358a7c6df85ffb161972fe97e1156be48b5bac Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Mon, 20 Apr 2026 20:27:09 +0700 Subject: [PATCH 05/20] =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=BC=D0=B5=D1=80=D0=BE=D0=B2;=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=81=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D1=81=20float=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/prime-preset/tokens/tokens.json | 8 +- .../textarea-float-label.component.ts | 145 ++++++++++++++++++ .../examples/textarea-sizes.component.ts | 67 +++----- .../components/textarea/textarea.stories.ts | 13 +- 4 files changed, 183 insertions(+), 50 deletions(-) create mode 100644 src/stories/components/textarea/examples/textarea-float-label.component.ts diff --git a/src/prime-preset/tokens/tokens.json b/src/prime-preset/tokens/tokens.json index 0afd51d1..1121663f 100644 --- a/src/prime-preset/tokens/tokens.json +++ b/src/prime-preset/tokens/tokens.json @@ -4706,7 +4706,7 @@ "minHeight": "{form.size.900}", "extXlg": { "fontSize": "{fonts.fontSize.300}", - "paddingX": "{form.padding.200}", + "paddingX": "{form.padding.300}", "paddingY": "{form.padding.500}" } }, @@ -4725,7 +4725,7 @@ "placeholderColor": "{form.placeholderColor}", "invalidPlaceholderColor": "{form.invalidPlaceholderColor}", "shadow": "0", - "paddingX": "{form.padding.200}", + "paddingX": "{form.padding.300}", "paddingY": "{form.padding.300}", "borderRadius": "{form.borderRadius.200}", "transitionDuration": "{form.transitionDuration}", @@ -4738,12 +4738,12 @@ }, "sm": { "fontSize": "{fonts.fontSize.300}", - "paddingX": "{form.padding.200}", + "paddingX": "{form.padding.300}", "paddingY": "{form.padding.200}" }, "lg": { "fontSize": "{fonts.fontSize.300}", - "paddingX": "{form.padding.200}", + "paddingX": "{form.padding.300}", "paddingY": "{form.padding.400}" } } diff --git a/src/stories/components/textarea/examples/textarea-float-label.component.ts b/src/stories/components/textarea/examples/textarea-float-label.component.ts new file mode 100644 index 00000000..b02a6ce4 --- /dev/null +++ b/src/stories/components/textarea/examples/textarea-float-label.component.ts @@ -0,0 +1,145 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { NgClass } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { FloatLabel } from 'primeng/floatlabel'; +import { Textarea } from 'primeng/textarea'; +import { StoryObj } from '@storybook/angular'; +import { TextareaComponent } from '../../../../lib/components/textarea/textarea.component'; + +export const sourceTemplate = ` + + + + +`; +const styles = ''; + +@Component({ + selector: 'app-textarea-float-label', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [Textarea, FloatLabel, FormsModule, NgClass], + template: ` +
+ + + + +
+ `, + styles, +}) +export class TextareaFloatLabelComponent { + @Input() size: TextareaComponent['size'] = 'base'; + @Input() disabled = false; + @Input() readonly = false; + @Input() invalid = false; + @Input() fluid = false; + @Input() rows = 3; + + value = ''; + + get primeSize(): 'small' | 'large' | undefined { + if (this.size === 'small') return 'small'; + if (this.size === 'large') return 'large'; + return undefined; + } + + get sizeClass(): Record { + return { 'p-textarea-xlg': this.size === 'xlarge' }; + } +} + +export const FloatLabelStory: StoryObj = { + name: 'FloatLabel', + render: (args) => ({ + props: { ...args }, + template: ` +
+ + + + +
+ `, + }), + argTypes: { + size: { table: { disable: true } }, + }, + args: { + disabled: false, + readonly: false, + invalid: false, + fluid: false, + rows: 3, + }, + parameters: { + docs: { + description: { + story: + 'Интеграция с `p-floatlabel variant="in"` — плавающая метка внутри поля. Кликните на поле чтобы увидеть анимацию. Требует нативный ` + + + \`, +}) +export class MyComponent { + value = ''; +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/textarea/examples/textarea-sizes.component.ts b/src/stories/components/textarea/examples/textarea-sizes.component.ts index 4643be86..f74361d7 100644 --- a/src/stories/components/textarea/examples/textarea-sizes.component.ts +++ b/src/stories/components/textarea/examples/textarea-sizes.component.ts @@ -1,57 +1,38 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { FormsModule } from '@angular/forms'; import { StoryObj } from '@storybook/angular'; import { TextareaComponent } from '../../../../lib/components/textarea/textarea.component'; -export const template = ` -
-
-
small
- -
-
-
base
- -
-
-
large
- -
-
-
xlarge
- -
-
-`; -const styles = ''; +type Story = StoryObj; -@Component({ - selector: 'app-textarea-sizes', - standalone: true, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [TextareaComponent, FormsModule], - template, - styles, -}) -export class TextareaSizesComponent { - value = ''; -} - -export const Sizes: StoryObj = { - render: () => ({ - template: ``, +export const Sizes: Story = { + name: 'Sizes', + render: (args) => ({ + props: { ...args, value: '' }, + template: ` + + `, }), parameters: { - controls: { disable: true }, docs: { description: { - story: 'Все доступные размеры компонента: small, base, large, xlarge.', + story: 'Все доступные размеры компонента: small, base, large, xlarge. Выберите размер через Controls.', }, source: { language: 'ts', - code: `@Component({ - template: \`${template}\`, -})`, + code: ` + + +`, }, }, }, diff --git a/src/stories/components/textarea/textarea.stories.ts b/src/stories/components/textarea/textarea.stories.ts index 7069b1ed..c4bd17b8 100644 --- a/src/stories/components/textarea/textarea.stories.ts +++ b/src/stories/components/textarea/textarea.stories.ts @@ -1,11 +1,15 @@ import { Meta, StoryObj, moduleMetadata } from '@storybook/angular'; +import { NgClass } from '@angular/common'; import { FormsModule } from '@angular/forms'; +import { FloatLabel } from 'primeng/floatlabel'; +import { Textarea } from 'primeng/textarea'; import { TextareaComponent } from '../../../lib/components/textarea/textarea.component'; import { Disabled } from './examples/textarea-disabled.component'; import { Readonly } from './examples/textarea-readonly.component'; import { Invalid } from './examples/textarea-invalid.component'; import { AutoResize, TextareaAutoResizeComponent } from './examples/textarea-autoresize.component'; -import { Sizes, TextareaSizesComponent } from './examples/textarea-sizes.component'; +import { Sizes } from './examples/textarea-sizes.component'; +import { FloatLabelStory, TextareaFloatLabelComponent } from './examples/textarea-float-label.component'; type TextareaArgs = TextareaComponent; @@ -18,8 +22,11 @@ const meta: Meta = { imports: [ TextareaComponent, FormsModule, + NgClass, + Textarea, + FloatLabel, TextareaAutoResizeComponent, - TextareaSizesComponent, + TextareaFloatLabelComponent, ], }), ], @@ -190,4 +197,4 @@ export const Default: Story = { }; // ── Re-exports from example components ──────────────────────────────────── -export { Disabled, Readonly, Invalid, AutoResize, Sizes }; +export { Disabled, Readonly, Invalid, AutoResize, Sizes, FloatLabelStory as FloatLabel }; From b3be4c78962d31b7113b175345a2a051eadb19ba Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Tue, 21 Apr 2026 22:43:13 +0700 Subject: [PATCH 06/20] =?UTF-8?q?=D1=83=D0=B4=D0=B0=D0=BB=D1=91=D0=BD=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=BF=D1=81=20variant=20=D0=B8=D0=B7=20Texta?= =?UTF-8?q?reaComponent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/components/textarea/textarea.component.ts | 2 -- src/stories/components/textarea/textarea.stories.ts | 12 ------------ 2 files changed, 14 deletions(-) diff --git a/src/lib/components/textarea/textarea.component.ts b/src/lib/components/textarea/textarea.component.ts index 52852c63..d843be98 100644 --- a/src/lib/components/textarea/textarea.component.ts +++ b/src/lib/components/textarea/textarea.component.ts @@ -4,7 +4,6 @@ import { NgClass } from '@angular/common'; import { Textarea } from 'primeng/textarea'; export type TextareaSize = 'small' | 'base' | 'large' | 'xlarge'; -export type TextareaVariant = 'outlined' | 'filled'; @Component({ selector: 'ui-textarea', @@ -48,7 +47,6 @@ export class TextareaComponent implements ControlValueAccessor { @Input() autoResize = false; @Input() rows = 3; @Input() cols?: number; - @Input() variant: TextareaVariant = 'outlined'; @Output() onResize = new EventEmitter<{ height: string }>(); diff --git a/src/stories/components/textarea/textarea.stories.ts b/src/stories/components/textarea/textarea.stories.ts index c4bd17b8..456bc00c 100644 --- a/src/stories/components/textarea/textarea.stories.ts +++ b/src/stories/components/textarea/textarea.stories.ts @@ -125,16 +125,6 @@ import { TextareaComponent } from '@cdek-it/angular-ui-kit'; type: { summary: 'number' }, }, }, - variant: { - control: 'select', - options: ['outlined', 'filled'], - description: 'Визуальный вариант поля', - table: { - category: 'Props', - defaultValue: { summary: "'outlined'" }, - type: { summary: "'outlined' | 'filled'" }, - }, - }, // Скрыть внутренние computed props modelValue: { table: { disable: true } }, primeSize: { table: { disable: true } }, @@ -158,7 +148,6 @@ import { TextareaComponent } from '@cdek-it/angular-ui-kit'; fluid: false, autoResize: false, rows: 3, - variant: 'outlined', }, }; @@ -180,7 +169,6 @@ export const Default: Story = { 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.variant && args.variant !== 'outlined') parts.push(`variant="${args.variant}"`); parts.push(`[(ngModel)]="value"`); const template = ``; From eac3d7783de0bb5d0598fde28f8aae2da2bcb0d9 Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Tue, 21 Apr 2026 22:43:40 +0700 Subject: [PATCH 07/20] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20showClear=20+=20NgControl=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20invalid=20=D0=B2=20TextareaComponent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/textarea/textarea.component.ts | 98 ++++++++++++++----- 1 file changed, 75 insertions(+), 23 deletions(-) diff --git a/src/lib/components/textarea/textarea.component.ts b/src/lib/components/textarea/textarea.component.ts index d843be98..46dfc60f 100644 --- a/src/lib/components/textarea/textarea.component.ts +++ b/src/lib/components/textarea/textarea.component.ts @@ -1,7 +1,9 @@ -import { ChangeDetectionStrategy, Component, Input, Output, EventEmitter, forwardRef } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { ChangeDetectionStrategy, 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 { Textarea } from 'primeng/textarea'; +import { IconField } from 'primeng/iconfield'; +import { InputIcon } from 'primeng/inputicon'; export type TextareaSize = 'small' | 'base' | 'large' | 'xlarge'; @@ -9,7 +11,7 @@ export type TextareaSize = 'small' | 'base' | 'large' | 'xlarge'; selector: 'ui-textarea', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [Textarea, NgClass], + imports: [Textarea, IconField, InputIcon, NgClass], providers: [ { provide: NG_VALUE_ACCESSOR, @@ -18,37 +20,81 @@ export type TextareaSize = 'small' | 'base' | 'large' | 'xlarge'; }, ], template: ` - + @if (showClear) { + + + + + } @else { + + } `, }) -export class TextareaComponent implements ControlValueAccessor { +export class TextareaComponent 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: TextareaSize = 'base'; - @Input() disabled = false; @Input() readonly = false; - @Input() invalid = false; + @Input() showClear = false; @Input() fluid = false; @Input() autoResize = false; @Input() rows = 3; @Input() cols?: number; + disabled = false; + + get invalid(): boolean { + return this._ngControl?.invalid ?? false; + } + @Output() onResize = new EventEmitter<{ height: string }>(); + @Output() onClear = new EventEmitter(); modelValue = ''; @@ -72,6 +118,12 @@ export class TextareaComponent implements ControlValueAccessor { onTouched: () => void = () => {}; + clearValue(): void { + this.modelValue = ''; + this._onChange(''); + this.onClear.emit(); + } + writeValue(value: string): void { this.modelValue = value ?? ''; } From 91907ead4a1817d0ad3b7ef0dbcc9d065b717d41 Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Tue, 21 Apr 2026 22:44:46 +0700 Subject: [PATCH 08/20] =?UTF-8?q?stories=20Textarea=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D1=8B=20=D0=BD=D0=B0=20For?= =?UTF-8?q?mControl=20+=20NgControl=20=D0=B2=20=D0=BA=D0=BE=D0=BC=D0=BF?= =?UTF-8?q?=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../examples/textarea-disabled.component.ts | 56 +++++++++---------- .../examples/textarea-invalid.component.ts | 56 +++++++++---------- .../components/textarea/textarea.stories.ts | 53 +++++++++++------- 3 files changed, 90 insertions(+), 75 deletions(-) diff --git a/src/stories/components/textarea/examples/textarea-disabled.component.ts b/src/stories/components/textarea/examples/textarea-disabled.component.ts index 213d8f0c..225fb177 100644 --- a/src/stories/components/textarea/examples/textarea-disabled.component.ts +++ b/src/stories/components/textarea/examples/textarea-disabled.component.ts @@ -1,43 +1,43 @@ +import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { StoryObj } from '@storybook/angular'; import { TextareaComponent } from '../../../../lib/components/textarea/textarea.component'; -type Story = StoryObj; - -export const Disabled: Story = { +export const Disabled: StoryObj = { name: 'Disabled', - render: (args) => ({ - props: { ...args, value: 'Текст в заблокированном поле' }, - template: ` - - `, - }), - args: { - disabled: true, - placeholder: 'Введите текст...', + render: (args) => { + const control = new FormControl({ value: 'Текст в заблокированном поле', disabled: true }); + return { + props: { ...args, control }, + template: ``, + }; }, + decorators: [ + (story: any) => ({ + ...story(), + moduleMetadata: { + imports: [TextareaComponent, ReactiveFormsModule], + }, + }), + ], parameters: { + controls: { disable: true }, docs: { - description: { - story: 'Отключённое состояние — поле недоступно для взаимодействия.', - }, + description: { story: 'Отключённое состояние — управляется через FormControl.' }, source: { language: 'ts', code: ` +import { Component } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { TextareaComponent } from '@cdek-it/angular-ui-kit'; -import { FormsModule } from '@angular/forms'; -// template: -// +@Component({ + standalone: true, + imports: [TextareaComponent, ReactiveFormsModule], + template: \`\`, +}) +export class DisabledExample { + control = new FormControl({ value: '', disabled: true }); +} `, }, }, diff --git a/src/stories/components/textarea/examples/textarea-invalid.component.ts b/src/stories/components/textarea/examples/textarea-invalid.component.ts index 0ab85e97..021c56cc 100644 --- a/src/stories/components/textarea/examples/textarea-invalid.component.ts +++ b/src/stories/components/textarea/examples/textarea-invalid.component.ts @@ -1,43 +1,43 @@ +import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; import { StoryObj } from '@storybook/angular'; import { TextareaComponent } from '../../../../lib/components/textarea/textarea.component'; -type Story = StoryObj; - -export const Invalid: Story = { +export const Invalid: StoryObj = { name: 'Invalid', - render: (args) => ({ - props: { ...args, value: '' }, - template: ` - - `, - }), - args: { - invalid: true, - placeholder: 'Обязательное поле', + render: (args) => { + const control = new FormControl('', Validators.required); + return { + props: { ...args, control }, + template: ``, + }; }, + decorators: [ + (story: any) => ({ + ...story(), + moduleMetadata: { + imports: [TextareaComponent, ReactiveFormsModule], + }, + }), + ], parameters: { + controls: { disable: true }, docs: { - description: { - story: 'Невалидное состояние — поле выделяется красной рамкой.', - }, + description: { story: 'Невалидное состояние — управляется через FormControl + Validators.' }, source: { language: 'ts', code: ` +import { Component } from '@angular/core'; +import { FormControl, Validators, ReactiveFormsModule } from '@angular/forms'; import { TextareaComponent } from '@cdek-it/angular-ui-kit'; -import { FormsModule } from '@angular/forms'; -// template: -// +@Component({ + standalone: true, + imports: [TextareaComponent, ReactiveFormsModule], + template: \`\`, +}) +export class InvalidExample { + control = new FormControl('', Validators.required); +} `, }, }, diff --git a/src/stories/components/textarea/textarea.stories.ts b/src/stories/components/textarea/textarea.stories.ts index 456bc00c..8f8400cb 100644 --- a/src/stories/components/textarea/textarea.stories.ts +++ b/src/stories/components/textarea/textarea.stories.ts @@ -1,8 +1,5 @@ import { Meta, StoryObj, moduleMetadata } from '@storybook/angular'; -import { NgClass } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { FloatLabel } from 'primeng/floatlabel'; -import { Textarea } from 'primeng/textarea'; +import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; import { TextareaComponent } from '../../../lib/components/textarea/textarea.component'; import { Disabled } from './examples/textarea-disabled.component'; import { Readonly } from './examples/textarea-readonly.component'; @@ -11,7 +8,7 @@ import { AutoResize, TextareaAutoResizeComponent } from './examples/textarea-aut import { Sizes } from './examples/textarea-sizes.component'; import { FloatLabelStory, TextareaFloatLabelComponent } from './examples/textarea-float-label.component'; -type TextareaArgs = TextareaComponent; +type TextareaArgs = TextareaComponent & { disabled: boolean; invalid: boolean }; const meta: Meta = { title: 'Components/Form/Textarea', @@ -21,10 +18,7 @@ const meta: Meta = { moduleMetadata({ imports: [ TextareaComponent, - FormsModule, - NgClass, - Textarea, - FloatLabel, + ReactiveFormsModule, TextareaAutoResizeComponent, TextareaFloatLabelComponent, ], @@ -64,7 +58,16 @@ import { TextareaComponent } from '@cdek-it/angular-ui-kit'; }, disabled: { control: 'boolean', - description: 'Отключает взаимодействие', + description: 'Отключает взаимодействие — управляется через FormControl', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + invalid: { + control: 'boolean', + description: 'Невалидное состояние — управляется через FormControl', table: { category: 'Props', defaultValue: { summary: 'false' }, @@ -80,9 +83,9 @@ import { TextareaComponent } from '@cdek-it/angular-ui-kit'; type: { summary: 'boolean' }, }, }, - invalid: { + showClear: { control: 'boolean', - description: 'Невалидное состояние', + description: 'Показывает иконку очистки при наличии значения', table: { category: 'Props', defaultValue: { summary: 'false' }, @@ -125,7 +128,7 @@ import { TextareaComponent } from '@cdek-it/angular-ui-kit'; type: { summary: 'number' }, }, }, - // Скрыть внутренние computed props + // Hidden computed props modelValue: { table: { disable: true } }, primeSize: { table: { disable: true } }, sizeClass: { table: { disable: true } }, @@ -138,13 +141,22 @@ import { TextareaComponent } from '@cdek-it/angular-ui-kit'; type: { summary: 'EventEmitter<{ height: string }>' }, }, }, + onClear: { + control: false, + description: 'Событие очистки поля (при showClear)', + table: { + category: 'Events', + type: { summary: 'EventEmitter' }, + }, + }, }, args: { placeholder: 'Введите текст...', size: 'base', disabled: false, - readonly: false, invalid: false, + readonly: false, + showClear: false, fluid: false, autoResize: false, rows: 3, @@ -162,18 +174,21 @@ export const Default: Story = { 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.readonly) parts.push(`[readonly]="true"`); - if (args.invalid) parts.push(`[invalid]="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}"`); - parts.push(`[(ngModel)]="value"`); - const template = ``; + const validators = []; + if (args.invalid) validators.push(Validators.required); + + const control = new FormControl({ value: '', disabled: args.disabled }, validators); + + const template = ``; - return { props: { ...args, value: '' }, template }; + return { props: { ...args, control }, template }; }, parameters: { docs: { From 4a810b4e1a30f70257213539d84a6234ee528ccb Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Tue, 21 Apr 2026 22:45:03 +0700 Subject: [PATCH 09/20] =?UTF-8?q?textarea:=20box-shadow=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D1=84=D0=BE=D0=BA=D1=83=D1=81=D0=B5,=20=D0=B8=D1=81?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20disabled/readonly?= =?UTF-8?q?=20CSS,=20invalid=20>=20focus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/prime-preset/tokens/components/textarea.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/prime-preset/tokens/components/textarea.ts b/src/prime-preset/tokens/components/textarea.ts index d35154ad..b66df116 100644 --- a/src/prime-preset/tokens/components/textarea.ts +++ b/src/prime-preset/tokens/components/textarea.ts @@ -16,12 +16,24 @@ export const textareaCss = ({ dt }: { dt: (token: string) => string }): string = /* --- States --- */ .p-textarea:enabled:read-only { background: ${dt('textarea.extend.readonlyBackground')}; + color: ${dt('textarea.color')}; } -.p-textarea:is(.p-disabled, :disabled) { - background: ${dt('textarea.disabled.background')}; - color: ${dt('textarea.disabled.color')}; +.p-textarea:disabled { + background: ${dt('textarea.disabledBackground')}; + color: ${dt('textarea.disabledColor')}; opacity: 1; } +/* --- Focus --- */ +.p-textarea:enabled:focus { + box-shadow: 0 0 0 ${dt('textarea.focusRing.width')} ${dt('textarea.focusRing.color')}; +} + +/* --- Invalid + Focus --- */ +.p-textarea.p-invalid:focus { + border-color: ${dt('textarea.invalidBorderColor')}; + box-shadow: 0 0 0 ${dt('textarea.focusRing.width')} ${dt('focusRing.extend.invalid')}; +} + `; From 46640874e8a835fba04b0055b6249b47f46d64fe Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Tue, 21 Apr 2026 23:08:27 +0700 Subject: [PATCH 10/20] =?UTF-8?q?padding-right=20=D0=BA=D0=BE=D0=B3=D0=B4?= =?UTF-8?q?=D0=B0=20=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=B5=D0=BD=20showClear?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tokens/components/textarea.ts | 19 +++ .../examples/textarea-autoresize.component.ts | 1 - .../textarea-float-label.component.ts | 153 ++++++++---------- .../examples/textarea-readonly.component.ts | 56 +++---- .../examples/textarea-sizes.component.ts | 57 ++++--- 5 files changed, 145 insertions(+), 141 deletions(-) diff --git a/src/prime-preset/tokens/components/textarea.ts b/src/prime-preset/tokens/components/textarea.ts index b66df116..95854baa 100644 --- a/src/prime-preset/tokens/components/textarea.ts +++ b/src/prime-preset/tokens/components/textarea.ts @@ -36,4 +36,23 @@ export const textareaCss = ({ dt }: { dt: (token: string) => string }): string = box-shadow: 0 0 0 ${dt('textarea.focusRing.width')} ${dt('focusRing.extend.invalid')}; } +/* --- ClearButton (showClear) --- */ +.p-iconfield:has(.p-textarea) { + display: block; + width: fit-content; +} + +.p-iconfield:has(.p-textarea) .p-textarea { + padding-right: ${dt('form.padding.500')}; +} + +.p-iconfield:has(.p-textarea) .p-inputicon { + top: ${dt('form.padding.500')}; + transform: none; + font-size: ${dt('textarea.extend.iconSize')}; + width: ${dt('textarea.extend.iconSize')}; + height: ${dt('textarea.extend.iconSize')}; + cursor: pointer; +} + `; diff --git a/src/stories/components/textarea/examples/textarea-autoresize.component.ts b/src/stories/components/textarea/examples/textarea-autoresize.component.ts index 25913565..ee12d1f7 100644 --- a/src/stories/components/textarea/examples/textarea-autoresize.component.ts +++ b/src/stories/components/textarea/examples/textarea-autoresize.component.ts @@ -5,7 +5,6 @@ import { TextareaComponent } from '../../../../lib/components/textarea/textarea. export const template = `
- - - - -`; -const styles = ''; @Component({ selector: 'app-textarea-float-label', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [Textarea, FloatLabel, FormsModule, NgClass], + imports: [Textarea, FloatLabel, ReactiveFormsModule, NgIf], template: `
- +
`, - styles, }) export class TextareaFloatLabelComponent { - @Input() size: TextareaComponent['size'] = 'base'; - @Input() disabled = false; - @Input() readonly = false; - @Input() invalid = false; - @Input() fluid = false; - @Input() rows = 3; - - value = ''; - - get primeSize(): 'small' | 'large' | undefined { - if (this.size === 'small') return 'small'; - if (this.size === 'large') return 'large'; - return undefined; - } - - get sizeClass(): Record { - return { 'p-textarea-xlg': this.size === 'xlarge' }; - } + control = new FormControl(''); + @Input() label = 'Комментарий'; + @Input() required = false; } -export const FloatLabelStory: StoryObj = { +export const FloatLabelStory: StoryObj = { name: 'FloatLabel', - render: (args) => ({ - props: { ...args }, - template: ` -
- - - - -
- `, - }), - argTypes: { - size: { table: { disable: true } }, + render: (args) => { + const control = new FormControl(''); + return { + props: { ...args, control }, + template: ` +
+ + + + +
+ `, + }; }, args: { - disabled: false, - readonly: false, - invalid: false, - fluid: false, - rows: 3, + label: 'Комментарий', + required: false, + }, + argTypes: { + label: { + control: 'text', + description: 'Текст плавающей метки', + table: { + category: 'Props', + defaultValue: { summary: "'Комментарий'" }, + type: { summary: 'string' }, + }, + }, + required: { + control: 'boolean', + description: 'Показывает маркер обязательного поля `*` рядом с меткой', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, }, + decorators: [ + (story: any) => ({ + ...story(), + moduleMetadata: { + imports: [Textarea, FloatLabel, ReactiveFormsModule, NgIf], + }, + }), + ], parameters: { docs: { description: { story: - 'Интеграция с `p-floatlabel variant="in"` — плавающая метка внутри поля. Кликните на поле чтобы увидеть анимацию. Требует нативный ` - + \`, }) -export class MyComponent { - value = ''; +export class FloatLabelExample { + 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 71c1550d..184250a0 100644 --- a/src/stories/components/textarea/examples/textarea-readonly.component.ts +++ b/src/stories/components/textarea/examples/textarea-readonly.component.ts @@ -1,43 +1,43 @@ +import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { StoryObj } from '@storybook/angular'; import { TextareaComponent } from '../../../../lib/components/textarea/textarea.component'; -type Story = StoryObj; - -export const Readonly: Story = { +export const Readonly: StoryObj = { name: 'Readonly', - render: (args) => ({ - props: { ...args, value: 'Только для чтения — этот текст нельзя изменить.' }, - template: ` - - `, - }), - args: { - readonly: true, - placeholder: 'Введите текст...', + render: (args) => { + const control = new FormControl('Только для чтения — этот текст нельзя изменить.'); + return { + props: { ...args, control }, + template: ``, + }; }, + decorators: [ + (story: any) => ({ + ...story(), + moduleMetadata: { + imports: [TextareaComponent, ReactiveFormsModule], + }, + }), + ], parameters: { + controls: { disable: true }, docs: { - description: { - story: 'Режим только для чтения — содержимое отображается, но не редактируется.', - }, + description: { story: 'Режим только для чтения — содержимое отображается, но не редактируется.' }, source: { language: 'ts', code: ` +import { Component } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { TextareaComponent } from '@cdek-it/angular-ui-kit'; -import { FormsModule } from '@angular/forms'; -// template: -// +@Component({ + standalone: true, + imports: [TextareaComponent, ReactiveFormsModule], + template: \`\`, +}) +export class ReadonlyExample { + control = new FormControl('Только для чтения.'); +} `, }, }, diff --git a/src/stories/components/textarea/examples/textarea-sizes.component.ts b/src/stories/components/textarea/examples/textarea-sizes.component.ts index f74361d7..c25eca2f 100644 --- a/src/stories/components/textarea/examples/textarea-sizes.component.ts +++ b/src/stories/components/textarea/examples/textarea-sizes.component.ts @@ -1,27 +1,34 @@ +import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { StoryObj } from '@storybook/angular'; import { TextareaComponent } from '../../../../lib/components/textarea/textarea.component'; -type Story = StoryObj; - -export const Sizes: Story = { +export const Sizes: StoryObj = { name: 'Sizes', - render: (args) => ({ - props: { ...args, value: '' }, - template: ` - - `, - }), + render: (args) => { + const control = new FormControl(''); + return { + props: { ...args, control }, + template: ``, + }; + }, + args: { + size: 'base', + placeholder: 'Введите текст...', + }, + argTypes: { + size: { + control: 'select', + options: ['small', 'base', 'large', 'xlarge'], + }, + }, + decorators: [ + (story: any) => ({ + ...story(), + moduleMetadata: { + imports: [TextareaComponent, ReactiveFormsModule], + }, + }), + ], parameters: { docs: { description: { @@ -29,10 +36,12 @@ export const Sizes: Story = { }, source: { language: 'ts', - code: ` - - -`, + code: ` + + + + + `, }, }, }, From c9c90f49f303ee6a7d2c5f40036947323378d62f Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Tue, 21 Apr 2026 23:22:35 +0700 Subject: [PATCH 11/20] =?UTF-8?q?=D0=B4=D0=BE=D0=B0=D0=B1=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=BF=D1=80=D0=BE=D0=BF=D1=81=D1=8B=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20FloatLabel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tokens/components/textarea.ts | 2 +- .../textarea-float-label.component.ts | 67 +++++++++---------- .../examples/textarea-sizes.component.ts | 2 +- .../components/textarea/textarea.stories.ts | 2 +- 4 files changed, 35 insertions(+), 38 deletions(-) diff --git a/src/prime-preset/tokens/components/textarea.ts b/src/prime-preset/tokens/components/textarea.ts index 95854baa..4619309c 100644 --- a/src/prime-preset/tokens/components/textarea.ts +++ b/src/prime-preset/tokens/components/textarea.ts @@ -43,7 +43,7 @@ export const textareaCss = ({ dt }: { dt: (token: string) => string }): string = } .p-iconfield:has(.p-textarea) .p-textarea { - padding-right: ${dt('form.padding.500')}; + padding-right: ${dt('form.padding.700')}; } .p-iconfield:has(.p-textarea) .p-inputicon { diff --git a/src/stories/components/textarea/examples/textarea-float-label.component.ts b/src/stories/components/textarea/examples/textarea-float-label.component.ts index ae734464..d780a5dc 100644 --- a/src/stories/components/textarea/examples/textarea-float-label.component.ts +++ b/src/stories/components/textarea/examples/textarea-float-label.component.ts @@ -3,22 +3,41 @@ import { NgIf } from '@angular/common'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { FloatLabel } from 'primeng/floatlabel'; import { Textarea } from 'primeng/textarea'; +import { IconField } from 'primeng/iconfield'; +import { InputIcon } from 'primeng/inputicon'; import { StoryObj } from '@storybook/angular'; @Component({ selector: 'app-textarea-float-label', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [Textarea, FloatLabel, ReactiveFormsModule, NgIf], + imports: [Textarea, FloatLabel, ReactiveFormsModule, NgIf, IconField, InputIcon], template: ` -
+
- + @if (showClear) { + + + + + } @else { + + }
@@ -28,29 +47,15 @@ export class TextareaFloatLabelComponent { control = new FormControl(''); @Input() label = 'Комментарий'; @Input() required = false; + @Input() showClear = false; } export const FloatLabelStory: StoryObj = { name: 'FloatLabel', - render: (args) => { - const control = new FormControl(''); - return { - props: { ...args, control }, - template: ` -
- - - - -
- `, - }; - }, + render: (args) => ({ + props: { label: args['label'], required: args['required'], showClear: args['showClear'] }, + template: ``, + }), args: { label: 'Комментарий', required: false, @@ -75,14 +80,6 @@ export const FloatLabelStory: StoryObj = { }, }, }, - decorators: [ - (story: any) => ({ - ...story(), - moduleMetadata: { - imports: [Textarea, FloatLabel, ReactiveFormsModule, NgIf], - }, - }), - ], parameters: { docs: { description: { diff --git a/src/stories/components/textarea/examples/textarea-sizes.component.ts b/src/stories/components/textarea/examples/textarea-sizes.component.ts index c25eca2f..9aaf6a79 100644 --- a/src/stories/components/textarea/examples/textarea-sizes.component.ts +++ b/src/stories/components/textarea/examples/textarea-sizes.component.ts @@ -8,7 +8,7 @@ export const Sizes: StoryObj = { const control = new FormControl(''); return { props: { ...args, control }, - template: ``, + template: ``, }; }, args: { diff --git a/src/stories/components/textarea/textarea.stories.ts b/src/stories/components/textarea/textarea.stories.ts index 8f8400cb..7b3b33b6 100644 --- a/src/stories/components/textarea/textarea.stories.ts +++ b/src/stories/components/textarea/textarea.stories.ts @@ -28,7 +28,7 @@ const meta: Meta = { designTokens: { prefix: '--p-textarea' }, docs: { description: { - component: `Многострочное текстовое поле для ввода данных. Поддерживает авторасширение, состояния disabled/readonly/invalid, размеры и интеграцию с формами через CVA. + component: `Многострочное текстовое поле для ввода данных. \`\`\`typescript import { TextareaComponent } from '@cdek-it/angular-ui-kit'; From 48ba27a5a10ca53e6b9d233eb22a98697df571fc Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Tue, 21 Apr 2026 23:36:48 +0700 Subject: [PATCH 12/20] =?UTF-8?q?inputnumber:=20box-shadow=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D1=84=D0=BE=D0=BA=D1=83=D1=81=D0=B5,=20invalid=20bord?= =?UTF-8?q?er=20=D0=BF=D1=80=D0=B8=20focus,=20NgControl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../inputnumber/inputnumber.component.ts | 21 ++++++++++++++----- .../tokens/components/inputnumber.ts | 11 ++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/lib/components/inputnumber/inputnumber.component.ts b/src/lib/components/inputnumber/inputnumber.component.ts index 4dd3e4e5..2a1dafe0 100644 --- a/src/lib/components/inputnumber/inputnumber.component.ts +++ b/src/lib/components/inputnumber/inputnumber.component.ts @@ -1,5 +1,5 @@ -import { Component, Input, Output, EventEmitter, forwardRef } from '@angular/core'; -import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Component, Input, Output, EventEmitter, forwardRef, inject, Injector, OnInit } from '@angular/core'; +import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR, NgControl } from '@angular/forms'; import { NgClass } from '@angular/common'; import { InputNumber } from 'primeng/inputnumber'; import { SharedModule } from 'primeng/api'; @@ -59,7 +59,14 @@ export type InputNumberButtonLayout = 'stacked' | 'horizontal' | 'vertical'; `, }) -export class InputNumberComponent implements ControlValueAccessor { +export class InputNumberComponent 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() size: InputNumberSize = 'base'; @Input() showButtons = false; @Input() buttonLayout: InputNumberButtonLayout = 'stacked'; @@ -67,8 +74,6 @@ export class InputNumberComponent implements ControlValueAccessor { @Input() currency: string | undefined; @Input() locale: string | undefined; @Input() placeholder = ''; - @Input() disabled = false; - @Input() invalid = false; @Input() readonly = false; @Input() fluid = false; @Input() min: number | undefined; @@ -82,6 +87,12 @@ export class InputNumberComponent implements ControlValueAccessor { @Input() incrementButtonIcon: string | undefined; @Input() decrementButtonIcon: string | undefined; + disabled = false; + + get invalid(): boolean { + return this._ngControl?.invalid ?? false; + } + @Output() onInput = new EventEmitter<{ value: number | null }>(); modelValue: number | null = null; diff --git a/src/prime-preset/tokens/components/inputnumber.ts b/src/prime-preset/tokens/components/inputnumber.ts index dc4f5f57..ee036c31 100644 --- a/src/prime-preset/tokens/components/inputnumber.ts +++ b/src/prime-preset/tokens/components/inputnumber.ts @@ -14,6 +14,17 @@ export const inputnumberCss = ({ dt }: { dt: (token: string) => string }): strin border-right: none; } +/* ─── Focus ─── */ +.p-inputnumber .p-inputnumber-input:enabled:focus { + box-shadow: 0 0 0 ${dt('inputtext.focusRing.width')} ${dt('inputtext.focusRing.color')}; +} + +/* ─── Invalid + Focus ─── */ +.p-inputnumber.p-invalid .p-inputnumber-input:focus { + border-color: ${dt('inputtext.root.invalidBorderColor')}; + box-shadow: 0 0 0 1px ${dt('inputtext.root.invalidBorderColor')}; +} + /* ─── Disabled состояние ─── */ .p-inputnumber-horizontal:has(.p-inputnumber-input:disabled) .p-inputnumber-button { background: ${dt('inputtext.root.disabledBackground')}; From 4b749f6137818a798591e4cc625b64525c5b5160 Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Tue, 21 Apr 2026 23:36:53 +0700 Subject: [PATCH 13/20] =?UTF-8?q?inputnumber=20stories=20Buttons/Disabled/?= =?UTF-8?q?FloatLabel:=20formControl,=20source.code=20=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../examples/inputnumber-buttons.component.ts | 46 ++++++++++++------ .../inputnumber-disabled.component.ts | 47 +++++++++++++------ .../inputnumber-float-label.component.ts | 22 ++++----- 3 files changed, 75 insertions(+), 40 deletions(-) diff --git a/src/stories/components/inputnumber/examples/inputnumber-buttons.component.ts b/src/stories/components/inputnumber/examples/inputnumber-buttons.component.ts index b366e5f8..9f774228 100644 --- a/src/stories/components/inputnumber/examples/inputnumber-buttons.component.ts +++ b/src/stories/components/inputnumber/examples/inputnumber-buttons.component.ts @@ -1,3 +1,4 @@ +import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { StoryObj } from '@storybook/angular'; import { InputNumberComponent } from '../../../../lib/components/inputnumber/inputnumber.component'; @@ -5,17 +6,27 @@ type Story = StoryObj; export const Buttons: Story = { name: 'Buttons', - render: (args) => ({ - props: { ...args, value: null }, - template: ` - - `, - }), - args: {}, + render: () => { + const control = new FormControl(null); + return { + props: { control }, + template: ` + + `, + }; + }, + decorators: [ + (story: any) => ({ + ...story(), + moduleMetadata: { + imports: [InputNumberComponent, ReactiveFormsModule], + }, + }), + ], parameters: { controls: { disable: true }, docs: { @@ -25,11 +36,18 @@ export const Buttons: Story = { source: { language: 'ts', code: ` +import { Component } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { InputNumberComponent } from '@cdek-it/angular-ui-kit'; -import { FormsModule } from '@angular/forms'; -// template: -// +@Component({ + standalone: true, + imports: [InputNumberComponent, ReactiveFormsModule], + template: \`\`, +}) +export class ButtonsExample { + control = new FormControl(null); +} `, }, }, diff --git a/src/stories/components/inputnumber/examples/inputnumber-disabled.component.ts b/src/stories/components/inputnumber/examples/inputnumber-disabled.component.ts index 19d14edb..a2d3f495 100644 --- a/src/stories/components/inputnumber/examples/inputnumber-disabled.component.ts +++ b/src/stories/components/inputnumber/examples/inputnumber-disabled.component.ts @@ -1,3 +1,4 @@ +import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { StoryObj } from '@storybook/angular'; import { InputNumberComponent } from '../../../../lib/components/inputnumber/inputnumber.component'; @@ -5,18 +6,27 @@ type Story = StoryObj; export const Disabled: Story = { name: 'Disabled', - render: (args) => ({ - props: { ...args, value: 42 }, - template: ` - - `, - }), - args: {}, + render: () => { + const control = new FormControl({ value: 42, disabled: true }); + return { + props: { control }, + template: ` + + `, + }; + }, + decorators: [ + (story: any) => ({ + ...story(), + moduleMetadata: { + imports: [InputNumberComponent, ReactiveFormsModule], + }, + }), + ], parameters: { controls: { disable: true }, docs: { @@ -26,11 +36,18 @@ export const Disabled: Story = { source: { language: 'ts', code: ` +import { Component } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { InputNumberComponent } from '@cdek-it/angular-ui-kit'; -import { FormsModule } from '@angular/forms'; -// template: -// +@Component({ + standalone: true, + imports: [InputNumberComponent, ReactiveFormsModule], + template: \`\`, +}) +export class DisabledExample { + control = new FormControl({ value: 42, disabled: true }); +} `, }, }, diff --git a/src/stories/components/inputnumber/examples/inputnumber-float-label.component.ts b/src/stories/components/inputnumber/examples/inputnumber-float-label.component.ts index d85ef06b..532093fb 100644 --- a/src/stories/components/inputnumber/examples/inputnumber-float-label.component.ts +++ b/src/stories/components/inputnumber/examples/inputnumber-float-label.component.ts @@ -1,5 +1,5 @@ -import { Component } from '@angular/core'; -import { FormsModule } from '@angular/forms'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { StoryObj } from '@storybook/angular'; import { InputNumber } from 'primeng/inputnumber'; import { FloatLabel } from 'primeng/floatlabel'; @@ -8,7 +8,7 @@ import { SharedModule } from 'primeng/api'; const template = `
- + @@ -25,12 +25,13 @@ const styles = ''; @Component({ selector: 'app-inputnumber-float-label', standalone: true, - imports: [InputNumber, FloatLabel, FormsModule, SharedModule], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [InputNumber, FloatLabel, ReactiveFormsModule, SharedModule], template, styles, }) export class InputNumberFloatLabelComponent { - value: number | null = null; + control = new FormControl(null); } export const FloatLabelStory: StoryObj = { @@ -49,18 +50,17 @@ export const FloatLabelStory: StoryObj = { language: 'ts', code: ` import { Component } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { InputNumber } from 'primeng/inputnumber'; import { FloatLabel } from 'primeng/floatlabel'; import { SharedModule } from 'primeng/api'; -import { FormsModule } from '@angular/forms'; @Component({ - selector: 'app-inputnumber-float-label', standalone: true, - imports: [InputNumber, FloatLabel, FormsModule, SharedModule], + imports: [InputNumber, FloatLabel, ReactiveFormsModule, SharedModule], template: \` - + @@ -72,8 +72,8 @@ import { FormsModule } from '@angular/forms'; \`, }) -export class InputNumberFloatLabelComponent { - value: number | null = null; +export class InputNumberFloatLabelExample { + control = new FormControl(null); } `, }, From bdbe29aee9669dd2c8cb4dbb270f94befff9582e Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Tue, 21 Apr 2026 23:36:59 +0700 Subject: [PATCH 14/20] =?UTF-8?q?inputnumber=20story=20Currency:=20mode=3D?= =?UTF-8?q?currency=20=E2=86=92=20suffix=3D'=20=E2=82=BD'=20(=D0=B1=D0=B0?= =?UTF-8?q?=D0=B3=20PrimeNG=20=D1=81=20=D0=BA=D0=B0=D1=80=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=BE=D0=B9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../inputnumber-currency.component.ts | 51 ++++++++++++------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/src/stories/components/inputnumber/examples/inputnumber-currency.component.ts b/src/stories/components/inputnumber/examples/inputnumber-currency.component.ts index 186e3a9b..6bb38c49 100644 --- a/src/stories/components/inputnumber/examples/inputnumber-currency.component.ts +++ b/src/stories/components/inputnumber/examples/inputnumber-currency.component.ts @@ -1,3 +1,4 @@ +import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { StoryObj } from '@storybook/angular'; import { InputNumberComponent } from '../../../../lib/components/inputnumber/inputnumber.component'; @@ -5,36 +6,48 @@ type Story = StoryObj; export const Currency: Story = { name: 'Currency', - render: (args) => ({ - props: { ...args, value: null }, - template: ` - - `, - }), - args: { - mode: 'currency', - currency: 'RUB', - locale: 'ru-RU', + render: () => { + const control = new FormControl(null); + return { + props: { control }, + template: ` + + `, + }; }, + decorators: [ + (story: any) => ({ + ...story(), + moduleMetadata: { + imports: [InputNumberComponent, ReactiveFormsModule], + }, + }), + ], parameters: { controls: { disable: true }, docs: { description: { - story: 'Форматирование значения как валюты (рубли). Используются `mode="currency"`, `currency="RUB"` и `locale="ru-RU"`.', + story: 'Форматирование значения как валюты через `suffix`. Режим `mode="currency"` не используется из-за известного бага PrimeNG с кареткой.', }, source: { language: 'ts', code: ` +import { Component } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { InputNumberComponent } from '@cdek-it/angular-ui-kit'; -import { FormsModule } from '@angular/forms'; -// template: -// +@Component({ + standalone: true, + imports: [InputNumberComponent, ReactiveFormsModule], + template: \`\`, +}) +export class CurrencyExample { + control = new FormControl(null); +} `, }, }, From 397206cedd8bc671b8c35495fed292265b15afbb Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Tue, 21 Apr 2026 23:37:05 +0700 Subject: [PATCH 15/20] =?UTF-8?q?inputnumber=20stories:=20minFractionDigit?= =?UTF-8?q?s/maxFractionDigits=20=D0=B2=20argTypes,=20Default=20=E2=86=92?= =?UTF-8?q?=20formControl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../inputnumber/inputnumber.stories.ts | 42 ++++++++++++++----- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/src/stories/components/inputnumber/inputnumber.stories.ts b/src/stories/components/inputnumber/inputnumber.stories.ts index 9a34f283..1f2d9d20 100644 --- a/src/stories/components/inputnumber/inputnumber.stories.ts +++ b/src/stories/components/inputnumber/inputnumber.stories.ts @@ -1,12 +1,12 @@ import { Meta, StoryObj, moduleMetadata } from '@storybook/angular'; -import { FormsModule } from '@angular/forms'; +import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; import { InputNumberComponent } from '../../../lib/components/inputnumber/inputnumber.component'; import { InputNumberFloatLabelComponent, FloatLabelStory } from './examples/inputnumber-float-label.component'; import { Currency } from './examples/inputnumber-currency.component'; import { Buttons } from './examples/inputnumber-buttons.component'; import { Disabled } from './examples/inputnumber-disabled.component'; -type InputNumberArgs = InputNumberComponent; +type InputNumberArgs = InputNumberComponent & { disabled: boolean; invalid: boolean }; const meta: Meta = { title: 'Components/Form/InputNumber', @@ -16,7 +16,7 @@ const meta: Meta = { moduleMetadata({ imports: [ InputNumberComponent, - FormsModule, + ReactiveFormsModule, InputNumberFloatLabelComponent, ], }), @@ -103,7 +103,7 @@ import { InputNumberComponent } from '@cdek-it/angular-ui-kit'; }, disabled: { control: 'boolean', - description: 'Отключает взаимодействие', + description: 'Отключает взаимодействие — управляется через FormControl', table: { category: 'Props', defaultValue: { summary: 'false' }, @@ -112,7 +112,7 @@ import { InputNumberComponent } from '@cdek-it/angular-ui-kit'; }, invalid: { control: 'boolean', - description: 'Невалидное состояние', + description: 'Невалидное состояние — управляется через FormControl', table: { category: 'Props', defaultValue: { summary: 'false' }, @@ -191,6 +191,24 @@ import { InputNumberComponent } from '@cdek-it/angular-ui-kit'; type: { summary: 'string' }, }, }, + minFractionDigits: { + control: 'number', + description: 'Минимальное количество знаков после запятой', + table: { + category: 'Props', + defaultValue: { summary: 'undefined' }, + type: { summary: 'number' }, + }, + }, + maxFractionDigits: { + control: 'number', + description: 'Максимальное количество знаков после запятой', + table: { + category: 'Props', + defaultValue: { summary: 'undefined' }, + type: { summary: 'number' }, + }, + }, // Hidden computed props modelValue: { table: { disable: true } }, inputSizeClass: { table: { disable: true } }, @@ -237,8 +255,6 @@ export const Default: Story = { if (args.mode && args.mode !== 'decimal') parts.push(`mode="${args.mode}"`); if (args.currency) parts.push(`currency="${args.currency}"`); if (args.locale) parts.push(`locale="${args.locale}"`); - if (args.disabled) parts.push(`[disabled]="true"`); - if (args.invalid) parts.push(`[invalid]="true"`); if (args.readonly) parts.push(`[readonly]="true"`); if (args.fluid) parts.push(`[fluid]="true"`); if (args.min != null) parts.push(`[min]="${args.min}"`); @@ -246,12 +262,18 @@ export const Default: Story = { if (args.step && args.step !== 1) parts.push(`[step]="${args.step}"`); if (args.prefix) parts.push(`prefix="${args.prefix}"`); if (args.suffix) parts.push(`suffix="${args.suffix}"`); + if (args.minFractionDigits != null) parts.push(`[minFractionDigits]="${args.minFractionDigits}"`); + if (args.maxFractionDigits != null) parts.push(`[maxFractionDigits]="${args.maxFractionDigits}"`); if (!args.useGrouping) parts.push(`[useGrouping]="false"`); - parts.push(`[(ngModel)]="value"`); - const template = ``; + const validators = []; + if (args.invalid) validators.push(Validators.required); + + const control = new FormControl({ value: null, disabled: args.disabled }, validators); + + const template = ``; - return { props: { ...args, value: null }, template }; + return { props: { ...args, control }, template }; }, parameters: { docs: { From d59162d20d16628b376155b680f3a8ac3807a36a Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Tue, 21 Apr 2026 23:38:26 +0700 Subject: [PATCH 16/20] =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D1=91=D0=BD=20.gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 808f9bdc..ce8e3c03 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,5 @@ src/assets/components/themes /storybook-static /debug-storybook.log /documentation.json + +.claude/* \ No newline at end of file From e15fa7dcc9db945fe303a5eb34851252195de52a Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Thu, 23 Apr 2026 23:12:24 +0700 Subject: [PATCH 17/20] =?UTF-8?q?inputnumber:=20=D1=80=D0=B5=D0=B3=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20inputnumberCss=20?= =?UTF-8?q?=D0=B2=20map-tokens.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/prime-preset/map-tokens.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/prime-preset/map-tokens.ts b/src/prime-preset/map-tokens.ts index 353be0e4..2f84e02f 100644 --- a/src/prime-preset/map-tokens.ts +++ b/src/prime-preset/map-tokens.ts @@ -9,6 +9,7 @@ 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 { inputnumberCss } from './tokens/components/inputnumber'; import { textareaCss } from './tokens/components/textarea'; import { tooltipCss } from './tokens/components/tooltip'; @@ -37,6 +38,10 @@ const presetTokens: Preset = { ...(tokens.components.inputtext as unknown as ComponentsDesignTokens['inputtext']), css: inputtextCss, }, + inputnumber: { + ...(tokens.components.inputnumber as unknown as ComponentsDesignTokens['inputnumber']), + css: inputnumberCss, + }, tag: { ...(tokens.components.tag as unknown as ComponentsDesignTokens['tag']), css: tagCss, From 848ba9c09df248f8fe929d3b9a88f1f578b495e5 Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Thu, 23 Apr 2026 23:17:26 +0700 Subject: [PATCH 18/20] =?UTF-8?q?inputnumber:=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=20=D0=BB=D0=B5=D0=B2=D1=8B=D0=B9=20=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D0=B4=D0=B5=D1=80=20=D1=83=20increment-button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/prime-preset/tokens/components/inputnumber.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/prime-preset/tokens/components/inputnumber.ts b/src/prime-preset/tokens/components/inputnumber.ts index ee036c31..63604792 100644 --- a/src/prime-preset/tokens/components/inputnumber.ts +++ b/src/prime-preset/tokens/components/inputnumber.ts @@ -14,6 +14,10 @@ export const inputnumberCss = ({ dt }: { dt: (token: string) => string }): strin border-right: none; } +.p-inputnumber-horizontal .p-inputnumber-increment-button { + border-left: none; +} + /* ─── Focus ─── */ .p-inputnumber .p-inputnumber-input:enabled:focus { box-shadow: 0 0 0 ${dt('inputtext.focusRing.width')} ${dt('inputtext.focusRing.color')}; From 12bfd62abec90d05b886ff172f0e9cafafd47809 Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Thu, 23 Apr 2026 23:18:35 +0700 Subject: [PATCH 19/20] =?UTF-8?q?inputnumber:=20z-index=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20input=20=D0=BF=D1=80=D0=B8=20=D1=84=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D1=81=D0=B5=20=D1=87=D1=82=D0=BE=D0=B1=D1=8B=20box-shadow=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BE=D0=B1=D1=80=D0=B5=D0=B7=D0=B0=D0=BB=D1=81?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/prime-preset/tokens/components/inputnumber.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/prime-preset/tokens/components/inputnumber.ts b/src/prime-preset/tokens/components/inputnumber.ts index 63604792..2fb586ec 100644 --- a/src/prime-preset/tokens/components/inputnumber.ts +++ b/src/prime-preset/tokens/components/inputnumber.ts @@ -21,6 +21,8 @@ export const inputnumberCss = ({ dt }: { dt: (token: string) => string }): strin /* ─── Focus ─── */ .p-inputnumber .p-inputnumber-input:enabled:focus { box-shadow: 0 0 0 ${dt('inputtext.focusRing.width')} ${dt('inputtext.focusRing.color')}; + z-index: 1; + position: relative; } /* ─── Invalid + Focus ─── */ From 7f6a5918993936efdd5dcfab4a1a45552674c110 Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Thu, 23 Apr 2026 23:20:01 +0700 Subject: [PATCH 20/20] =?UTF-8?q?inputnumber:=20z-index=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20floatlabel=20label=20=D1=87=D1=82=D0=BE=D0=B1=D1=8B=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BA=D1=80=D1=8B=D0=B2?= =?UTF-8?q?=D0=B0=D0=BB=D1=81=D1=8F=20input=20=D0=BF=D1=80=D0=B8=20=D1=84?= =?UTF-8?q?=D0=BE=D0=BA=D1=83=D1=81=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/prime-preset/tokens/components/inputnumber.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/prime-preset/tokens/components/inputnumber.ts b/src/prime-preset/tokens/components/inputnumber.ts index 2fb586ec..98d8d4dc 100644 --- a/src/prime-preset/tokens/components/inputnumber.ts +++ b/src/prime-preset/tokens/components/inputnumber.ts @@ -42,6 +42,10 @@ export const inputnumberCss = ({ dt }: { dt: (token: string) => string }): strin align-self: stretch; } +.p-floatlabel:has(.p-inputnumber) label { + z-index: 2; +} + /* ─── Extra Large ─── */ .p-inputnumber.p-inputnumber-xlg .p-inputnumber-input { font-size: ${dt('inputtext.extend.extXlg.fontSize')};