Skip to content
Merged
808 changes: 808 additions & 0 deletions docs/superpowers/plans/2026-04-16-inputnumber.md

Large diffs are not rendered by default.

163 changes: 163 additions & 0 deletions docs/superpowers/specs/2026-04-16-inputnumber-design.md
Original file line number Diff line number Diff line change
@@ -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: `<i class="ti ti-plus"></i>` via `#incrementicon` ng-template.
Decrement button: `<i class="ti ti-minus"></i>` 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 `<div class="pt-6">` wrapper
134 changes: 134 additions & 0 deletions src/lib/components/inputnumber/inputnumber.component.ts
Original file line number Diff line number Diff line change
@@ -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: `
<p-inputNumber
[ngClass]="sizeClass"
[inputStyleClass]="inputSizeClass"
[showButtons]="showButtons"
[buttonLayout]="buttonLayout"
[mode]="mode"
[currency]="currency"
Comment thread
Tenkoru marked this conversation as resolved.
[locale]="locale"
[placeholder]="placeholder"
[disabled]="disabled"
[invalid]="invalid"
[readonly]="readonly"
[fluid]="fluid"
[min]="min"
[max]="max"
[step]="step"
[prefix]="prefix"
[suffix]="suffix"
[minFractionDigits]="minFractionDigits"
Comment thread
Tenkoru marked this conversation as resolved.
[maxFractionDigits]="maxFractionDigits"
[useGrouping]="useGrouping"
[incrementButtonIcon]="incrementButtonIcon"
[decrementButtonIcon]="decrementButtonIcon"
[ngModel]="modelValue"
(ngModelChange)="onModelChange($event)"
(onBlur)="onTouched()"
>
@if (!incrementButtonIcon) {
<ng-template pTemplate="incrementbuttonicon">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
</ng-template>
}
@if (!decrementButtonIcon) {
<ng-template pTemplate="decrementbuttonicon">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/></svg>
</ng-template>
}
</p-inputNumber>
`,
})
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<string, boolean> {
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;
}
}
44 changes: 44 additions & 0 deletions src/prime-preset/tokens/components/inputnumber.ts
Original file line number Diff line number Diff line change
@@ -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')};
}
`;
2 changes: 1 addition & 1 deletion src/prime-preset/tokens/tokens.json
Original file line number Diff line number Diff line change
Expand Up @@ -3091,7 +3091,7 @@
"transitionDuration": "{form.transitionDuration}"
},
"button": {
"width": "{form.width.300}",
"width": "{form.size.600}",
"borderRadius": "{form.borderRadius.200}",
"verticalPadding": "{form.padding.300}"
}
Expand Down
Loading
Loading