Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 38 additions & 24 deletions docs/components-api/inputnumber.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,49 @@

# ExtraInputNumber

| Свойство | Описание | Типизация |
| ---------------- | ---------------------------------------------------------------------------------------------- | -------------------------- |
| `placeholder` | текст подсказки внутри поля | `string` |
| `label` | текст названия поля | `string` |
| `labelPosition` | положение лейбла | `left \| top \| float` |
| `buttons` | отображение кнопок для увеличения/уменьшения значения (недоступно при `labelPosition: float`) | `boolean` |
| `min` | минимальное допустимое значение | `number` |
| `max` | максимальное допустимое значение | `number` |
| `step` | шаг изменения значения (кнопками `buttons`) | `number` |
| `prefix` | текст перед значением внутри поля | `string` |
| `suffix` | текст после значения внутри поля | `string` |
| `clearable` | отображение иконки для очистки поля | `boolean` |
| `caption` | текст пояснения под полем | `string` |
| `info` | текст с доп. информацией (показывается в тултипе иконки ti-info-circle) | `string` |
| `size` | размер поля | `small \| base \| large \| xlarge` |
> ✅ **Реализован**: `ExtraInputNumberComponent` (`@cdek-it/angular-ui-kit`) соответствует спецификации.

| Свойство | Описание | Типизация |
| --------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------- |
| `placeholder` | текст подсказки внутри поля | `string` |
| `label` | текст названия поля | `string` |
| `labelPosition` | положение лейбла | `top \| left` |
| `floatLabel` | плавающий лейбл внутри поля (недоступен вместе с `showButtons`) | `boolean` |
| `showButtons` | отображение кнопок для увеличения/уменьшения значения (недоступно при `floatLabel: true`) | `boolean` |
| `min` | минимальное допустимое значение | `number` |
| `max` | максимальное допустимое значение | `number` |
| `step` | шаг изменения значения (кнопками `showButtons`) | `number` |
| `prefix` | текст перед значением внутри поля | `string` |
| `suffix` | текст после значения внутри поля | `string` |
| `clearable` | отображение иконки для очистки поля | `boolean` |
| `caption` | текст пояснения под полем | `string` |
| `info` | текст с доп. информацией (показывается в тултипе иконки ti-info-circle) | `string` |
| `size` | размер поля | `small \| base \| large \| xlarge` |
| `readonly` | только для чтения | `boolean` |
| `fluid` | растягивает поле на всю ширину контейнера | `boolean` |
| `mode` | режим форматирования числа | `decimal \| currency` |
| `currency` | код валюты (ISO 4217), используется при `mode: currency` | `string` |
| `locale` | локаль форматирования числа | `string` |
| `useGrouping` | группировка разрядов (разделитель тысяч) | `boolean` |
| `minFractionDigits` | минимальное число знаков после запятой | `number` |
| `maxFractionDigits` | максимальное число знаков после запятой | `number` |
| `buttonLayout` | расположение кнопок `showButtons` | `stacked \| horizontal \| vertical` |
| `incrementButtonIcon` | CSS-класс иконки кнопки увеличения | `string` |
| `decrementButtonIcon` | CSS-класс иконки кнопки уменьшения | `string` |

# События

| Событие | Описание | Типизация |
| --------- | ---------------------------------- | ------------------------------------------ |
| `input` | срабатывает при вводе значения | `(event: ExtraInputNumberInputEvent) => void` |
| `clear` | срабатывает при очистке значения | `() => void` |
| Событие | Описание | Типизация |
| ------- | -------------------------------- | --------------------------------------------- |
| `input` | срабатывает при вводе значения | `(event: ExtraInputNumberInputEvent) => void` |
| `clear` | срабатывает при очистке значения | `() => void` |

# ExtraInputNumberInputEvent

Событие ввода значения, передаётся в `input`.

| Свойство | Описание | Типизация |
| ---------------- | ---------------------- | ---------------- |
| `value` | введённое значение | `number \| null` |
| `formattedValue` | отформатированное значение | `string` |
| `originalEvent` | исходное событие | `Event` |
| Свойство | Описание | Типизация |
| ---------------- | -------------------------- | ---------------- |
| `value` | введённое значение | `number \| null` |
| `formattedValue` | отформатированное значение | `string` |
| `originalEvent` | исходное событие | `Event` |
244 changes: 166 additions & 78 deletions src/lib/components/inputnumber/inputnumber.component.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,41 @@
import { Component, EventEmitter, forwardRef, inject, Injector, Input, OnInit, Output } from '@angular/core';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
forwardRef,
inject,
Injector,
Input,
OnInit,
Output
} from '@angular/core';
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR, NgControl } from '@angular/forms';
import { NgClass } from '@angular/common';
import { NgClass, NgTemplateOutlet } from '@angular/common';
import { InputNumber } from 'primeng/inputnumber';
import { FloatLabel } from 'primeng/floatlabel';
import { SharedModule } from 'primeng/api';
import { ExtraTooltipDirective } from '@cdek-it/angular-ui-kit/components/tooltip';

export type ExtraInputNumberSize = 'small' | 'base' | 'large' | 'xlarge';
export type ExtraInputNumberButtonLayout = 'stacked' | 'horizontal' | 'vertical';
export type ExtraInputNumberLabelPosition = 'top' | 'left';
export type ExtraInputNumberMode = 'decimal' | 'currency';

export interface ExtraInputNumberInputEvent {
value: number | null;
formattedValue: string;
originalEvent: Event;
}

let nextInputId = 0;

@Component({
selector: 'extra-input-number',
standalone: true,
imports: [InputNumber, SharedModule, FormsModule, NgClass],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [InputNumber, SharedModule, FormsModule, NgClass, NgTemplateOutlet, FloatLabel, ExtraTooltipDirective],
host: { style: 'display: contents' },
providers: [
{
provide: NG_VALUE_ACCESSOR,
Expand All @@ -19,85 +44,136 @@ export type ExtraInputNumberButtonLayout = 'stacked' | 'horizontal' | 'vertical'
}
],
template: `
<p-inputNumber
[ngClass]="sizeClass"
[inputStyleClass]="inputSizeClass"
[showButtons]="showButtons"
[buttonLayout]="buttonLayout"
[mode]="mode"
[currency]="currency"
[locale]="locale"
[placeholder]="placeholder"
[disabled]="disabled"
[invalid]="invalid"
[readonly]="readonly"
[fluid]="fluid"
[min]="min"
[max]="max"
[step]="step"
[prefix]="prefix"
[suffix]="suffix"
[minFractionDigits]="minFractionDigits"
[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>
<!-- Без label/caption поле не оборачиваем — сохраняем прежнюю DOM-структуру
(важно для p-inputgroup, где input обязан быть прямым flex-элементом). -->
@if (label || caption) {
<div class="extra-inputnumber" [class.extra-inputnumber--left]="labelPosition === 'left'">
@if (label && labelPosition === 'left' && !floatLabel) {
<ng-container [ngTemplateOutlet]="labelTpl" />
}
<div class="extra-inputnumber-body">
@if (label && labelPosition === 'top' && !floatLabel) {
<ng-container [ngTemplateOutlet]="labelTpl" />
}
@if (floatLabel) {
<p-floatlabel variant="in">
<ng-container [ngTemplateOutlet]="fieldTpl" />
@if (label) {
<ng-container [ngTemplateOutlet]="labelTpl" />
}
</p-floatlabel>
} @else {
<ng-container [ngTemplateOutlet]="fieldTpl" />
}
@if (caption) {
<div class="extra-inputnumber-caption">{{ caption }}</div>
}
</div>
</div>
} @else {
<ng-container [ngTemplateOutlet]="fieldTpl" />
}

<ng-template #labelTpl>
<label class="extra-inputnumber-label" [for]="inputId">
{{ label }}
@if (info) {
<i class="extra-inputnumber-label-icon ti ti-info-circle" [extra-tooltip]="info"></i>
}
</label>
</ng-template>

<ng-template #fieldTpl>
<p-inputNumber
[ngClass]="sizeClass"
[inputStyleClass]="inputSizeClass"
[inputId]="inputId"
[showButtons]="showButtons && !floatLabel"
[buttonLayout]="buttonLayout"
[mode]="mode"
[currency]="currency"
[locale]="locale"
[attr.placeholder]="placeholder || null"
[disabled]="disabled"
[invalid]="invalid"
[readonly]="readonly"
[fluid]="fluid"
[min]="min"
[max]="max"
[step]="step"
[prefix]="prefix"
[suffix]="suffix"
[showClear]="clearable"
[minFractionDigits]="minFractionDigits"
[maxFractionDigits]="maxFractionDigits"
[useGrouping]="useGrouping"
[incrementButtonIcon]="incrementButtonIcon"
[decrementButtonIcon]="decrementButtonIcon"
[ngModel]="modelValue"
(onInput)="handleInput($event)"
(onClear)="handleClear()"
(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>
</ng-template>
`
})
export class ExtraInputNumberComponent implements ControlValueAccessor, OnInit {
private readonly _injector = inject(Injector);
private readonly _cdr = inject(ChangeDetectorRef);
private _ngControl: NgControl | null = null;

ngOnInit(): void {
this._ngControl = this._injector.get(NgControl, null, { self: true, optional: true });
}

@Input() placeholder = '';
@Input() label = '';
@Input() labelPosition: ExtraInputNumberLabelPosition = 'top';
@Input() floatLabel = false;
@Input() clearable = false;
@Input() caption = '';
@Input() info = '';
@Input() size: ExtraInputNumberSize = 'base';
@Input() showButtons = false;
@Input() buttonLayout: ExtraInputNumberButtonLayout = 'stacked';
@Input() mode = 'decimal';
@Input() mode: ExtraInputNumberMode = 'decimal';
@Input() currency: string | undefined;
@Input() locale: string | undefined;
@Input() placeholder = '';
@Input() readonly = false;
@Input() fluid = false;
@Input() min: number | undefined;
Expand All @@ -111,19 +187,15 @@ export class ExtraInputNumberComponent implements ControlValueAccessor, OnInit {
@Input() incrementButtonIcon: string | undefined;
@Input() decrementButtonIcon: string | undefined;

/** Уникальный id поля для связи label ↔ input. */
readonly inputId = `extra-input-number-${nextInputId++}`;

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';
Expand All @@ -134,14 +206,29 @@ export class ExtraInputNumberComponent implements ControlValueAccessor, OnInit {
return { 'p-inputnumber-xlg': this.size === 'xlarge' };
}

onModelChange(value: number | null): void {
this.modelValue = value;
this._onChange(value);
this.onInput.emit({ value });
@Output() onInput = new EventEmitter<ExtraInputNumberInputEvent>();
@Output() onClear = new EventEmitter<void>();

modelValue: number | null = null;

private _onChange: (value: number | null) => void = () => {};
onTouched: () => void = () => {};

handleInput(event: ExtraInputNumberInputEvent): void {
this.modelValue = event.value;
this._onChange(event.value);
this.onInput.emit(event);
}

handleClear(): void {
this.modelValue = null;
this._onChange(null);
this.onClear.emit();
}

writeValue(value: number | null): void {
this.modelValue = value ?? null;
this._cdr.markForCheck();
}

registerOnChange(fn: (value: number | null) => void): void {
Expand All @@ -154,5 +241,6 @@ export class ExtraInputNumberComponent implements ControlValueAccessor, OnInit {

setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
this._cdr.markForCheck();
}
}
Loading
Loading