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..2a1dafe0 --- /dev/null +++ b/src/lib/components/inputnumber/inputnumber.component.ts @@ -0,0 +1,134 @@ +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'; + +export type InputNumberSize = 'small' | 'base' | 'large' | 'xlarge'; +export type InputNumberButtonLayout = 'stacked' | 'horizontal' | 'vertical'; + +@Component({ + selector: 'input-number', + standalone: true, + imports: [InputNumber, SharedModule, FormsModule, NgClass], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => InputNumberComponent), + multi: true, + }, + ], + template: ` + + @if (!incrementButtonIcon) { + + + + } + @if (!decrementButtonIcon) { + + + + } + + `, +}) +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'; + @Input() mode = 'decimal'; + @Input() currency: string | undefined; + @Input() locale: string | undefined; + @Input() placeholder = ''; + @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; + + disabled = false; + + get invalid(): boolean { + return this._ngControl?.invalid ?? false; + } + + @Output() onInput = new EventEmitter<{ value: number | null }>(); + + modelValue: number | null = null; + + private _onChange: (value: number | null) => void = () => {}; + onTouched: () => void = () => {}; + + 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 { + return { 'p-inputnumber-xlg': this.size === 'xlarge' }; + } + + 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/tokens/components/inputnumber.ts b/src/prime-preset/tokens/components/inputnumber.ts new file mode 100644 index 00000000..ee036c31 --- /dev/null +++ b/src/prime-preset/tokens/components/inputnumber.ts @@ -0,0 +1,44 @@ +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')}; + border: ${dt('inputnumber.extend.borderWidth')} solid ${dt('inputnumber.button.borderColor')}; +} + +.p-inputnumber-horizontal .p-inputnumber-decrement-button { + 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')}; + color: ${dt('inputtext.root.disabledColor')}; +} + +/* ─── FloatLabel: кнопки на полную высоту поля ─── */ +.p-floatlabel:has(.p-inputnumber-horizontal) .p-inputnumber-button { + align-self: stretch; +} + +/* ─── 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')}; +} +`; diff --git a/src/prime-preset/tokens/tokens.json b/src/prime-preset/tokens/tokens.json index 3bad67d2..a66e83c3 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/examples/inputnumber-buttons.component.ts b/src/stories/components/inputnumber/examples/inputnumber-buttons.component.ts new file mode 100644 index 00000000..9f774228 --- /dev/null +++ b/src/stories/components/inputnumber/examples/inputnumber-buttons.component.ts @@ -0,0 +1,55 @@ +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { InputNumberComponent } from '../../../../lib/components/inputnumber/inputnumber.component'; + +type Story = StoryObj; + +export const Buttons: Story = { + name: 'Buttons', + 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: 'Числовое поле с кнопками увеличения/уменьшения в горизонтальной раскладке. Кастомные SVG-иконки +/− используются по умолчанию.', + }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { InputNumberComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + standalone: true, + imports: [InputNumberComponent, ReactiveFormsModule], + template: \`\`, +}) +export class ButtonsExample { + control = new FormControl(null); +} + `, + }, + }, + }, +}; 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..6bb38c49 --- /dev/null +++ b/src/stories/components/inputnumber/examples/inputnumber-currency.component.ts @@ -0,0 +1,55 @@ +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { InputNumberComponent } from '../../../../lib/components/inputnumber/inputnumber.component'; + +type Story = StoryObj; + +export const Currency: Story = { + name: 'Currency', + 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: 'Форматирование значения как валюты через `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'; + +@Component({ + standalone: true, + imports: [InputNumberComponent, ReactiveFormsModule], + template: \`\`, +}) +export class CurrencyExample { + 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 new file mode 100644 index 00000000..a2d3f495 --- /dev/null +++ b/src/stories/components/inputnumber/examples/inputnumber-disabled.component.ts @@ -0,0 +1,55 @@ +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { InputNumberComponent } from '../../../../lib/components/inputnumber/inputnumber.component'; + +type Story = StoryObj; + +export const Disabled: Story = { + name: 'Disabled', + 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: { + description: { + story: 'Отключённое состояние — поле и кнопки недоступны для взаимодействия.', + }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { InputNumberComponent } from '@cdek-it/angular-ui-kit'; + +@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 new file mode 100644 index 00000000..532093fb --- /dev/null +++ b/src/stories/components/inputnumber/examples/inputnumber-float-label.component.ts @@ -0,0 +1,82 @@ +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'; +import { SharedModule } from 'primeng/api'; + +const template = ` +
+ + + + + + + + + + + +
+`; +const styles = ''; + +@Component({ + selector: 'app-inputnumber-float-label', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [InputNumber, FloatLabel, ReactiveFormsModule, SharedModule], + template, + styles, +}) +export class InputNumberFloatLabelComponent { + control = new FormControl(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 { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { InputNumber } from 'primeng/inputnumber'; +import { FloatLabel } from 'primeng/floatlabel'; +import { SharedModule } from 'primeng/api'; + +@Component({ + standalone: true, + imports: [InputNumber, FloatLabel, ReactiveFormsModule, SharedModule], + template: \` + + + + + + + + + + + + \`, +}) +export class InputNumberFloatLabelExample { + control = new FormControl(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..1f2d9d20 --- /dev/null +++ b/src/stories/components/inputnumber/inputnumber.stories.ts @@ -0,0 +1,288 @@ +import { Meta, StoryObj, moduleMetadata } from '@storybook/angular'; +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 & { disabled: boolean; invalid: boolean }; + +const meta: Meta = { + title: 'Components/Form/InputNumber', + component: InputNumberComponent, + tags: ['autodocs'], + decorators: [ + moduleMetadata({ + imports: [ + InputNumberComponent, + ReactiveFormsModule, + 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' }, + }, + }, + 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: 'Отключает взаимодействие — управляется через FormControl', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + invalid: { + control: 'boolean', + description: 'Невалидное состояние — управляется через FormControl', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + readonly: { + control: 'boolean', + description: 'Только для чтения', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + 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' }, + }, + }, + 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 } }, + sizeClass: { table: { disable: true } }, + + // ── Events ─────────────────────────────────────────────── + onInput: { + control: false, + description: 'Событие при изменении значения', + table: { + category: 'Events', + type: { summary: 'EventEmitter<{ value: number | null }>' }, + }, + }, + }, + args: { + size: 'base', + 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.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}"`); + 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.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.minFractionDigits != null) parts.push(`[minFractionDigits]="${args.minFractionDigits}"`); + if (args.maxFractionDigits != null) parts.push(`[maxFractionDigits]="${args.maxFractionDigits}"`); + if (!args.useGrouping) parts.push(`[useGrouping]="false"`); + + const validators = []; + if (args.invalid) validators.push(Validators.required); + + const control = new FormControl({ value: null, disabled: args.disabled }, validators); + + const template = ``; + + return { props: { ...args, control }, template }; + }, + parameters: { + docs: { + description: { + story: 'Базовый пример компонента. Используйте Controls для интерактивного изменения пропсов.', + }, + }, + }, +}; + +// ── Re-exports from example components ──────────────────────────────────── +export { Currency, Buttons, Disabled, FloatLabelStory as FloatLabel };