diff --git a/libs/ui/README.md b/libs/ui/README.md
index 317d640..0df5fdc 100644
--- a/libs/ui/README.md
+++ b/libs/ui/README.md
@@ -16,19 +16,83 @@ Construir: `nx build ui` · Lint: `nx lint ui`
## Qué se hizo
-| | |
-|---|---|
-| Prefijo | `Dl`→`Pi`, `dl-`→`pi-` (selectores, clases CSS, nombres de fichero), `@deale-workspace/…`→rutas relativas |
-| NgModules | 41 → 4 (sólo quedan los que aportan providers: `icon-registry`, `loaders`, `router-store`, `scroll-to`) |
-| Componentes | 70, **todos** con `ChangeDetectionStrategy.OnPush` |
-| Decoradores | 0 restantes: 460 `input()`, 6 `model()`, 20 `output()`, 8 `viewChild()`, 2 `viewChildren()`, 17 `contentChild()`, 6 `contentChildren()` |
-| Control flow | `*ngIf`/`*ngFor`/`*ngSwitch` → `@if`/`@for`/`@switch` (110 `@if`, 26 `@for`, 3 `@switch`). Ya no se importa ninguna directiva de `CommonModule` |
+| | |
+| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Prefijo | `Dl`→`Pi`, `dl-`→`pi-` (selectores, clases CSS, nombres de fichero), `@deale-workspace/…`→rutas relativas |
+| NgModules | 41 → 4 (sólo quedan los que aportan providers: `icon-registry`, `loaders`, `router-store`, `scroll-to`) |
+| Componentes | 70, **todos** con `ChangeDetectionStrategy.OnPush` |
+| Decoradores | 0 restantes: 460 `input()`, 6 `model()`, 20 `output()`, 8 `viewChild()`, 2 `viewChildren()`, 17 `contentChild()`, 6 `contentChildren()` |
+| Control flow | `*ngIf`/`*ngFor`/`*ngSwitch` → `@if`/`@for`/`@switch` (110 `@if`, 26 `@for`, 3 `@switch`). Ya no se importa ninguna directiva de `CommonModule` |
+| Formularios | Los 13 campos de `ui/form-field` y los 2 de `ui/slider` pasan de `ControlValueAccessor` + `formControlName` a **Signal Forms** (`FormValueControl` / `FormCheckboxControl`) |
Se usaron las migraciones oficiales de Angular (`@angular/core:standalone`,
`signal-input-migration`, `output-migration`, `signal-queries-migration`,
`control-flow-migration`, `cleanup-unused-imports`) y se resolvió a mano lo que
no cubren.
+## Formularios: Signal Forms
+
+Los campos ya no son `ControlValueAccessor` ni reciben `formControlName`.
+Implementan los contratos de Signal Forms, así que basta con enlazar `[field]`:
+
+```ts
+import { form, required, min, FormField } from '@angular/forms/signals';
+
+@Component({
+ imports: [FormField, PiTextFieldComponent, PiNumberFieldComponent, PiSlideToggleComponent],
+ template: `
+
+
+
+ `,
+})
+export class Ejemplo {
+ private readonly model = signal({ name: '', age: null, active: false });
+
+ protected readonly f = form(this.model, (m) => {
+ required(m.name);
+ min(m.age, 18);
+ });
+}
+```
+
+La directiva `FormField` sincroniza sola `value`, `disabled`, `errors`,
+`touched`, `required`, `min`/`max`, `maxLength`… con el campo enlazado.
+
+| Componente | Contrato | Tipo del valor |
+| ---------------------------------------------------- | --------------------- | ------------------------------------- |
+| text, email, password, phone, icon, search, textarea | `FormValueControl` | `string` |
+| number, currency, incremental-number | `FormValueControl` | `number \| null` |
+| slide-toggle | `FormCheckboxControl` | `boolean` (usa `checked`, no `value`) |
+| incremental-range, range-slider | `FormValueControl` | `PiRange` (`{from, to}`) |
+| slider | `FormValueControl` | `number` |
+
+Detalles a tener en cuenta:
+
+- **`formControlName` y `formGroupName` desaparecen.** `incremental-range-field`
+ y `range-slider` ya no leen un `FormGroup` anidado: reciben el rango completo
+ por `value` (tipo `PiRange`, exportado por la librería).
+- **`isDisabled` → `disabled`**, que es el nombre del contrato y ahora es una
+ señal (`disabled()`).
+- **Los mensajes de error** venían de `control.errors['message']` (un array de
+ claves i18n). Ahora se leen del input `errors`, usando `error.message` y
+ cayendo a `error.kind` si no lo hay. La clase base expone `errorMessages()`.
+- **Los campos numéricos usan `transformedValue`**, el mecanismo de Signal Forms
+ para parsear texto→número reportando errores de parseo al campo.
+- **`ngx-currency` se ha eliminado de `currency-field`.** Su directiva
+ `currencyMask` es un ControlValueAccessor y no funciona sin reactive forms.
+ El formateo (decimal `,`, miles `.`, 2 decimales) se replica con
+ `transformedValue` + `toLocaleString('es-ES')`. **Se pierde el enmascarado en
+ vivo mientras se escribe**: ahora se formatea al salir del campo.
+- **`slide-toggle` ya no extiende `PiBaseFormFieldComponent`**: el contrato de
+ casilla exige `checked` y prohíbe declarar `value`. También desaparece el
+ campo `isChecked` que se sincronizaba por suscripción.
+- **`touch`**: los controles emiten esta salida en el `blur` para que el campo
+ se marque como tocado.
+
+Los selects (`ui/select`) siguen con `ControlValueAccessor`; quedaron fuera del
+alcance de esta migración.
+
## Decisiones que cambian comportamiento
Revisar estos puntos antes de usar la librería en producción:
@@ -57,7 +121,7 @@ durante la detección de cambios, no de forma síncrona en la asignación:
**3. `PiAccordionComponent.expandedItemIndex` pasa a `model()`.**
Tenía un two-way binding hecho a mano (`expandedItemIndex` + `expandedItemIndexChange`).
`model()` genera el evento automáticamente. **Ojo:** el código original emitía el
-valor *anterior* al plegar/desplegar (`emit(expandedItemIndex)` antes de reasignar);
+valor _anterior_ al plegar/desplegar (`emit(expandedItemIndex)` antes de reasignar);
`model()` emite el nuevo. Parece que el original tenía un bug. Su tipo pasa a
`number | null` porque `toggleState` asigna `null` al colapsar.
@@ -97,10 +161,10 @@ corrigió la aridad a `track trackByFn(i)`.
El origen tiene símbolos duplicados. En `index.ts` gana el primero y el otro se
omite (queda anotado en el fichero). Siguen siendo accesibles por ruta directa:
-| Símbolo | Se exporta desde | Se omite de |
-|---|---|---|
-| `PiSlideToggleComponent` | `ui/form-field` | `ui/slide-toggle` |
-| `PiDisabledDirective` | `util/status` | `ui/text` |
+| Símbolo | Se exporta desde | Se omite de |
+| ----------------------------------------------- | --------------------- | ------------------- |
+| `PiSlideToggleComponent` | `ui/form-field` | `ui/slide-toggle` |
+| `PiDisabledDirective` | `util/status` | `ui/text` |
| `featureKey`, `getFeatureState`, `initialState` | `util/device-configs` | `util/list-configs` |
## APIs de terceros actualizadas
diff --git a/libs/ui/src/lib/ui/form-field/base-form-field.component.ts b/libs/ui/src/lib/ui/form-field/base-form-field.component.ts
index 5f10ecb..a61e00c 100644
--- a/libs/ui/src/lib/ui/form-field/base-form-field.component.ts
+++ b/libs/ui/src/lib/ui/form-field/base-form-field.component.ts
@@ -1,28 +1,30 @@
import {
+ ChangeDetectionStrategy,
Component,
HostBinding,
- Injector,
- effect,
+ computed,
input,
+ model,
output,
- ChangeDetectionStrategy,
+ signal,
} from '@angular/core';
-import { FormControl, NG_VALUE_ACCESSOR } from '@angular/forms';
+import { FormValueControl, ValidationError } from '@angular/forms/signals';
import { ComponentFamilyClass } from '../../styles';
-import { PiFormControl } from '../../util/form-control';
+/**
+ * Base de los campos de formulario. Implementa el contrato `FormValueControl`
+ * de Signal Forms: basta con enlazar `[field]` en el consumidor y la directiva
+ * `FormField` sincroniza `value` y el resto de entradas de estado.
+ *
+ * Sustituye al antiguo `ControlValueAccessor` + `formControlName`.
+ */
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: '',
- providers: [
- {
- provide: NG_VALUE_ACCESSOR,
- useExisting: PiBaseFormFieldComponent,
- multi: true,
- },
- ],
})
-export abstract class PiBaseFormFieldComponent extends PiFormControl {
+export abstract class PiBaseFormFieldComponent<
+ TValue = string,
+> implements FormValueControl {
@HostBinding('class')
get classes(): string {
return [
@@ -33,62 +35,63 @@ export abstract class PiBaseFormFieldComponent extends PiFormControl {
].join(' ');
}
- readonly formControlName = input.required();
+ /* --- Contrato FormValueControl --------------------------------------- */
+
+ /** Único miembro obligatorio del contrato: lo enlaza la directiva `FormField`. */
+ readonly value = model.required();
+
+ readonly errors = input([]);
+ readonly disabled = input(false);
+ readonly invalid = input(false);
+ readonly touched = input(false);
+ readonly required = input(false);
+ readonly name = input('');
+
+ /** Marca el campo como tocado; se emite en el blur del control nativo. */
+ readonly touch = output();
+
+ /* --- Entradas propias de presentación -------------------------------- */
readonly placeholder = input();
-
readonly isAutofocus = input(false);
-
readonly appearance = input<'outline' | 'fill'>('outline');
-
readonly enableErrorMessage = input(true);
- readonly isControlStatusReactive = input(false);
-
- /**
- * El estado deshabilitado tiene dos fuentes: esta entrada y el
- * `setDisabledState` del ControlValueAccessor. `disabled` (heredado de
- * PiFormControl) sigue siendo el estado efectivo que se debe leer.
- */
- readonly isDisabled = input(false);
-
readonly isActive = output();
- constructor(injector: Injector) {
- super(injector);
+ /* --- Estado interno --------------------------------------------------- */
- effect(() => {
- this.disabled = this.isDisabled();
- // Update control status reactively
- if (this.isControlStatusReactive()) {
- if (this.disabled) {
- this.control.disable();
- } else {
- this.control.enable();
- }
- }
- });
- }
+ protected readonly focused = signal(false);
- get control(): FormControl {
- return this.controlContainer.control?.get(
- this.formControlName(),
- ) as FormControl;
- }
+ /**
+ * Mensajes listos para pasar por el pipe `translate`. Antes venían de
+ * `control.errors['message']`; ahora cada `ValidationError` trae su `message`
+ * (y si no, se usa el `kind` como clave).
+ */
+ protected readonly errorMessages = computed(() =>
+ this.errors().map((error) => error.message ?? error.kind),
+ );
- get hasError(): boolean {
- return !!this.control.errors?.['message'];
- }
+ protected readonly hasError = computed(() => this.errors().length > 0);
protected get focusedClass(): string | boolean {
- return this.focused ? 'is-focused' : false;
+ return this.focused() ? 'is-focused' : false;
}
protected get hasErrorClass(): string | boolean {
- return this.hasError ? 'has-error' : '';
+ return this.hasError() ? 'has-error' : '';
}
protected get isDisabledClass(): string {
- return this.disabled ? 'is-disabled' : '';
+ return this.disabled() ? 'is-disabled' : '';
+ }
+
+ onFocus(): void {
+ this.focused.set(true);
+ }
+
+ onBlur(): void {
+ this.focused.set(false);
+ this.touch.emit();
}
}
diff --git a/libs/ui/src/lib/ui/form-field/currency-field/currency-field.component.html b/libs/ui/src/lib/ui/form-field/currency-field/currency-field.component.html
index c11f603..f090426 100644
--- a/libs/ui/src/lib/ui/form-field/currency-field/currency-field.component.html
+++ b/libs/ui/src/lib/ui/form-field/currency-field/currency-field.component.html
@@ -9,22 +9,22 @@
- @if (control) {
-
- }
+
- @if (enableErrorMessage() && control.errors) {
+ @if (enableErrorMessage() && hasError()) {
- @for (text of control.errors['message']; track text) {
- {{ text | translate }}
+ @for (message of errorMessages(); track message) {
+ {{ message | translate }}
}
}
diff --git a/libs/ui/src/lib/ui/form-field/currency-field/currency-field.component.ts b/libs/ui/src/lib/ui/form-field/currency-field/currency-field.component.ts
index 06c843d..51314a6 100644
--- a/libs/ui/src/lib/ui/form-field/currency-field/currency-field.component.ts
+++ b/libs/ui/src/lib/ui/form-field/currency-field/currency-field.component.ts
@@ -1,12 +1,5 @@
-import {
- ChangeDetectionStrategy,
- Component,
- HostBinding,
- forwardRef,
-} from '@angular/core';
-import { NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
-
-import { NgxCurrencyInputMode, NgxCurrencyDirective } from 'ngx-currency';
+import { ChangeDetectionStrategy, Component, HostBinding } from '@angular/core';
+import { transformedValue } from '@angular/forms/signals';
import { PiBaseFormFieldComponent } from '../base-form-field.component';
import {
@@ -21,41 +14,57 @@ import { PiAutofocusDirective } from '../directives/autofocus.directive';
import { TranslatePipe } from '@ngx-translate/core';
+const DECIMAL_SEPARATOR = ',';
+const THOUSANDS_SEPARATOR = '.';
+const PRECISION = 2;
+
@Component({
selector: 'pi-currency-field',
templateUrl: './currency-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
- providers: [
- {
- provide: NG_VALUE_ACCESSOR,
- useExisting: PiCurrencyFieldComponent,
- multi: true,
- },
- ],
imports: [
MatFormField,
MatLabel,
MatSuffix,
MatInput,
- NgxCurrencyDirective,
- ReactiveFormsModule,
PiAutofocusDirective,
MatError,
- forwardRef(() => PiCurrencyFieldComponent),
TranslatePipe,
],
})
-export class PiCurrencyFieldComponent extends PiBaseFormFieldComponent {
+export class PiCurrencyFieldComponent extends PiBaseFormFieldComponent<
+ number | null
+> {
@HostBinding('class') className = `pi-form-field__currency`;
- customCurrencyMaskConfig = {
- align: 'left',
- allowNegative: true,
- decimal: ',',
- precision: 2,
- prefix: '',
- thousands: '.',
- inputMode: NgxCurrencyInputMode.Natural,
- nullable: true,
- };
+ /**
+ * El formateo lo hacía la directiva `currencyMask` de ngx-currency, que es un
+ * ControlValueAccessor y por tanto no funciona sin reactive forms. Se
+ * sustituye por `transformedValue`, replicando la configuración anterior
+ * (decimal ',', miles '.', 2 decimales, admite null y negativos).
+ */
+ protected readonly rawValue = transformedValue(this.value, {
+ parse: (raw: string) => {
+ const normalized = raw
+ .split(THOUSANDS_SEPARATOR)
+ .join('')
+ .replace(DECIMAL_SEPARATOR, '.')
+ .trim();
+ if (normalized === '') return { value: null };
+ const parsed = Number(normalized);
+ if (Number.isNaN(parsed)) {
+ return {
+ error: { kind: 'parse', message: 'common.validator.onlyNumbers' },
+ };
+ }
+ return { value: parsed };
+ },
+ format: (val) =>
+ val === null || val === undefined
+ ? ''
+ : val.toLocaleString('es-ES', {
+ minimumFractionDigits: PRECISION,
+ maximumFractionDigits: PRECISION,
+ }),
+ });
}
diff --git a/libs/ui/src/lib/ui/form-field/email-field/email-field.component.html b/libs/ui/src/lib/ui/form-field/email-field/email-field.component.html
index 2b5be99..893a94a 100644
--- a/libs/ui/src/lib/ui/form-field/email-field/email-field.component.html
+++ b/libs/ui/src/lib/ui/form-field/email-field/email-field.component.html
@@ -6,7 +6,10 @@
- @if (control.errors) {
+ @if (enableErrorMessage() && hasError()) {
- @for (text of control.errors['message']; track text) {
- {{ text | translate }}
+ @for (message of errorMessages(); track message) {
+ {{ message | translate }}
}
}
diff --git a/libs/ui/src/lib/ui/form-field/email-field/email-field.component.ts b/libs/ui/src/lib/ui/form-field/email-field/email-field.component.ts
index 866a34b..339a19a 100644
--- a/libs/ui/src/lib/ui/form-field/email-field/email-field.component.ts
+++ b/libs/ui/src/lib/ui/form-field/email-field/email-field.component.ts
@@ -1,5 +1,4 @@
import { ChangeDetectionStrategy, Component, HostBinding } from '@angular/core';
-import { NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
import { PiBaseFormFieldComponent } from '../base-form-field.component';
import {
MatFormField,
@@ -17,25 +16,17 @@ import { TranslatePipe } from '@ngx-translate/core';
selector: 'pi-email-field',
templateUrl: './email-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
- providers: [
- {
- provide: NG_VALUE_ACCESSOR,
- useExisting: PiEmailFieldComponent,
- multi: true,
- },
- ],
imports: [
MatFormField,
MatLabel,
MatSuffix,
PiIconComponent,
MatInput,
- ReactiveFormsModule,
PiAutofocusDirective,
MatError,
TranslatePipe,
],
})
-export class PiEmailFieldComponent extends PiBaseFormFieldComponent {
+export class PiEmailFieldComponent extends PiBaseFormFieldComponent {
@HostBinding('class') className = `pi-form-field__email`;
}
diff --git a/libs/ui/src/lib/ui/form-field/entities/range.model.ts b/libs/ui/src/lib/ui/form-field/entities/range.model.ts
new file mode 100644
index 0000000..7d2bed0
--- /dev/null
+++ b/libs/ui/src/lib/ui/form-field/entities/range.model.ts
@@ -0,0 +1,5 @@
+/** Valor de los campos de rango (`pi-incremental-range-field`, `pi-range-slider`). */
+export interface PiRange {
+ from: number | null;
+ to: number | null;
+}
diff --git a/libs/ui/src/lib/ui/form-field/icon-field/icon-field.component.html b/libs/ui/src/lib/ui/form-field/icon-field/icon-field.component.html
index 53252dc..7a250a8 100644
--- a/libs/ui/src/lib/ui/form-field/icon-field/icon-field.component.html
+++ b/libs/ui/src/lib/ui/form-field/icon-field/icon-field.component.html
@@ -9,7 +9,10 @@
-@if (control.errors && enableErrorMessage()) {
+@if (enableErrorMessage() && hasError()) {
- @for (text of control.errors['message']; track text) {
- {{ text | translate }}
+ @for (message of errorMessages(); track message) {
+ {{ message | translate }}
}
}
diff --git a/libs/ui/src/lib/ui/form-field/icon-field/icon-field.component.ts b/libs/ui/src/lib/ui/form-field/icon-field/icon-field.component.ts
index 7c48da6..4bef91a 100644
--- a/libs/ui/src/lib/ui/form-field/icon-field/icon-field.component.ts
+++ b/libs/ui/src/lib/ui/form-field/icon-field/icon-field.component.ts
@@ -4,7 +4,6 @@ import {
HostBinding,
input,
} from '@angular/core';
-import { NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
import { iconSize } from '../../../styles';
import { PiBaseFormFieldComponent } from '../base-form-field.component';
import { PiIconComponent } from '../../icon/icon/icon.component';
@@ -19,25 +18,17 @@ import { TranslatePipe } from '@ngx-translate/core';
templateUrl: './icon-field.component.html',
styleUrls: ['./icon-field.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
- providers: [
- {
- provide: NG_VALUE_ACCESSOR,
- useExisting: PiIconFieldComponent,
- multi: true,
- },
- ],
imports: [
PiIconComponent,
MatFormField,
MatLabel,
MatInput,
- ReactiveFormsModule,
PiAutofocusDirective,
MatError,
TranslatePipe,
],
})
-export class PiIconFieldComponent extends PiBaseFormFieldComponent {
+export class PiIconFieldComponent extends PiBaseFormFieldComponent {
@HostBinding('class') get componentClasses(): string {
return ['pi-form-field__text-icon'].join(' ');
}
diff --git a/libs/ui/src/lib/ui/form-field/incremental-number-field/incremental-number-field.component.html b/libs/ui/src/lib/ui/form-field/incremental-number-field/incremental-number-field.component.html
index 70f2990..a9f03f3 100644
--- a/libs/ui/src/lib/ui/form-field/incremental-number-field/incremental-number-field.component.html
+++ b/libs/ui/src/lib/ui/form-field/incremental-number-field/incremental-number-field.component.html
@@ -28,7 +28,10 @@
- @if (control.errors) {
+ @if (enableErrorMessage() && hasError()) {
- @for (text of control.errors['message']; track text) {
- {{ text | translate }}
+ @for (message of errorMessages(); track message) {
+ {{ message | translate }}
}
}
diff --git a/libs/ui/src/lib/ui/form-field/incremental-number-field/incremental-number-field.component.ts b/libs/ui/src/lib/ui/form-field/incremental-number-field/incremental-number-field.component.ts
index 147a8b9..0d674d3 100644
--- a/libs/ui/src/lib/ui/form-field/incremental-number-field/incremental-number-field.component.ts
+++ b/libs/ui/src/lib/ui/form-field/incremental-number-field/incremental-number-field.component.ts
@@ -5,7 +5,6 @@ import {
Injectable,
input,
} from '@angular/core';
-import { NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
import { unit } from '../../../util/models';
import { PiBaseFormFieldComponent } from '../base-form-field.component';
import {
@@ -23,6 +22,7 @@ import {
import { PiIconComponent } from '../../icon/icon/icon.component';
import { MatInput } from '@angular/material/input';
import { PiAutofocusDirective } from '../directives/autofocus.directive';
+import { numericValue } from '../numeric-value';
import { TranslatePipe } from '@ngx-translate/core';
@@ -30,13 +30,6 @@ import { TranslatePipe } from '@ngx-translate/core';
selector: 'pi-incremental-number-field',
templateUrl: './incremental-number-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
- providers: [
- {
- provide: NG_VALUE_ACCESSOR,
- useExisting: PiIncrementalNumberFieldComponent,
- multi: true,
- },
- ],
imports: [
MatFormField,
MatLabel,
@@ -46,62 +39,62 @@ import { TranslatePipe } from '@ngx-translate/core';
PiSuffixDirective,
MatSuffix,
MatInput,
- ReactiveFormsModule,
PiAutofocusDirective,
MatError,
TranslatePipe,
],
})
@Injectable()
-export class PiIncrementalNumberFieldComponent extends PiBaseFormFieldComponent {
+export class PiIncrementalNumberFieldComponent extends PiBaseFormFieldComponent<
+ number | null
+> {
@HostBinding('class') className = 'pi-form-field__incremental';
readonly slot = input(1);
- readonly max = input();
+ /**
+ * `min` y `max` forman parte del contrato `FormUiControl`: si el campo
+ * enlazado declara validadores `min`/`max`, la directiva `FormField` los
+ * inyecta aquí. De ahí el `| undefined`.
+ */
+ readonly max = input(undefined);
- readonly min = input(0);
+ readonly min = input(0);
readonly unit = input('');
readonly showIncrementButtons = input(true);
- incrementValue(): void {
- if (this.disabled) return;
- let currentValue = Number(this.control.value);
- // Handle decimal numbers
+ protected readonly rawValue = numericValue(this.value);
+
+ private step(direction: 1 | -1): void {
+ if (this.disabled()) return;
const slot = this.slot();
- if (Number.isInteger(slot)) {
- this.control.setValue(currentValue + slot);
- } else {
- this.control.setValue((currentValue + slot).toFixed(2));
- }
+ const next = (this.value() ?? 0) + direction * slot;
+ // Handle decimal numbers
+ this.value.set(Number.isInteger(slot) ? next : Number(next.toFixed(2)));
+ }
+
+ incrementValue(): void {
+ this.step(1);
}
decrementValue(): void {
- if (this.disabled) return;
- let currentValue = Number(this.control.value);
- // Handle decimal numbers
- const slot = this.slot();
- if (Number.isInteger(slot)) {
- this.control.setValue(currentValue - slot);
- } else {
- this.control.setValue((currentValue - slot).toFixed(2));
- }
+ this.step(-1);
}
isIncrementButtonDisabled(): boolean {
return (
- this.disabled ||
- this.control.value + this.slot() > (this.max() ?? Infinity)
+ this.disabled() ||
+ (this.value() ?? 0) + this.slot() > (this.max() ?? Infinity)
);
}
isDecrementButtonDisabled(): boolean {
const min = this.min();
if (min || min === 0) {
- return this.disabled || this.control.value - this.slot() < min;
+ return this.disabled() || (this.value() ?? 0) - this.slot() < min;
}
- return this.disabled;
+ return this.disabled();
}
}
diff --git a/libs/ui/src/lib/ui/form-field/incremental-range-field/incremental-range-field.component.html b/libs/ui/src/lib/ui/form-field/incremental-range-field/incremental-range-field.component.html
index 89820b2..2a6ddbd 100644
--- a/libs/ui/src/lib/ui/form-field/incremental-range-field/incremental-range-field.component.html
+++ b/libs/ui/src/lib/ui/form-field/incremental-range-field/incremental-range-field.component.html
@@ -1,40 +1,43 @@
-
+
+
+ {{ 'common.max' | translate }}
+
+
+
+
+
+@if (showError() && errorMessages().length) {
+
+ @for (message of errorMessages(); track message) {
+ {{ message | translate }}
+ }
+
+}
diff --git a/libs/ui/src/lib/ui/form-field/incremental-range-field/incremental-range-field.component.ts b/libs/ui/src/lib/ui/form-field/incremental-range-field/incremental-range-field.component.ts
index 3183e57..9487796 100644
--- a/libs/ui/src/lib/ui/form-field/incremental-range-field/incremental-range-field.component.ts
+++ b/libs/ui/src/lib/ui/form-field/incremental-range-field/incremental-range-field.component.ts
@@ -3,13 +3,12 @@ import {
Component,
HostBinding,
input,
+ model,
+ output,
} from '@angular/core';
-import {
- FormGroup,
- FormGroupDirective,
- ReactiveFormsModule,
-} from '@angular/forms';
+import { FormValueControl, ValidationError } from '@angular/forms/signals';
import { unit } from '../../../util/models';
+import { PiRange } from '../entities/range.model';
import { PiLabelDirective } from '../directives/label.directive';
import { PiIncrementalNumberFieldComponent } from '../incremental-number-field/incremental-number-field.component';
import { PiDividerComponent } from '../../divider/divider/divider.component';
@@ -18,12 +17,16 @@ import { MatError } from '@angular/material/form-field';
import { TranslatePipe } from '@ngx-translate/core';
+/**
+ * Antes leía un `FormGroup` anidado vía `formGroupName`. Ahora es un control de
+ * valor compuesto: recibe el rango entero por `value` y actualiza cada extremo
+ * sin romper la inmutabilidad de la señal.
+ */
@Component({
selector: 'pi-incremental-range-field',
templateUrl: './incremental-range-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
- ReactiveFormsModule,
PiLabelDirective,
PiIncrementalNumberFieldComponent,
PiDividerComponent,
@@ -31,11 +34,19 @@ import { TranslatePipe } from '@ngx-translate/core';
TranslatePipe,
],
})
-export class PiIncrementalRangeFieldComponent {
+export class PiIncrementalRangeFieldComponent implements FormValueControl {
@HostBinding('class') className =
'pi-field pi-form-field__incremental pi-form-field__incremental--range';
- readonly formGroupName = input.required();
+ /* --- Contrato FormValueControl --------------------------------------- */
+
+ readonly value = model.required();
+ readonly errors = input([]);
+ readonly disabled = input(false);
+ readonly touch = output();
+
+ /* --- Presentación ----------------------------------------------------- */
+
readonly minValue = input.required();
readonly maxValue = input.required();
readonly unit = input.required();
@@ -44,9 +55,14 @@ export class PiIncrementalRangeFieldComponent {
readonly showIncrementButtons = input(true);
readonly placeholder = input();
- constructor(private rootFormGroup: FormGroupDirective) {}
+ protected readonly errorMessages = () =>
+ this.errors().map((error) => error.message ?? error.kind);
- get form() {
- return this.rootFormGroup.control.get(this.formGroupName()) as FormGroup;
+ protected setFrom(from: number | null): void {
+ this.value.set({ ...this.value(), from });
+ }
+
+ protected setTo(to: number | null): void {
+ this.value.set({ ...this.value(), to });
}
}
diff --git a/libs/ui/src/lib/ui/form-field/index.ts b/libs/ui/src/lib/ui/form-field/index.ts
index bfd5208..53d1840 100644
--- a/libs/ui/src/lib/ui/form-field/index.ts
+++ b/libs/ui/src/lib/ui/form-field/index.ts
@@ -4,6 +4,9 @@ export * from './directives/checkbox-grid.directive';
export * from './directives/autofocus.directive';
export * from './entities/radio.model';
+export * from './entities/range.model';
+
+export * from './numeric-value';
export * from './incremental-number-field/incremental-number-field.component';
export * from './number-field/number-field.component';
diff --git a/libs/ui/src/lib/ui/form-field/number-field/number-field.component.html b/libs/ui/src/lib/ui/form-field/number-field/number-field.component.html
index 63c9a61..3b0a9b9 100644
--- a/libs/ui/src/lib/ui/form-field/number-field/number-field.component.html
+++ b/libs/ui/src/lib/ui/form-field/number-field/number-field.component.html
@@ -10,7 +10,10 @@
- @if (control.errors) {
+ @if (enableErrorMessage() && hasError()) {
- @for (text of control.errors['message']; track text) {
- {{ text | translate }}
+ @for (message of errorMessages(); track message) {
+ {{ message | translate }}
}
}
diff --git a/libs/ui/src/lib/ui/form-field/number-field/number-field.component.ts b/libs/ui/src/lib/ui/form-field/number-field/number-field.component.ts
index b43e987..86d67af 100644
--- a/libs/ui/src/lib/ui/form-field/number-field/number-field.component.ts
+++ b/libs/ui/src/lib/ui/form-field/number-field/number-field.component.ts
@@ -4,7 +4,6 @@ import {
HostBinding,
Injectable,
} from '@angular/core';
-import { NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
import { PiBaseFormFieldComponent } from '../base-form-field.component';
import {
MatFormField,
@@ -15,6 +14,7 @@ import {
} from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
import { PiAutofocusDirective } from '../directives/autofocus.directive';
+import { numericValue } from '../numeric-value';
import { TranslatePipe } from '@ngx-translate/core';
@@ -22,26 +22,22 @@ import { TranslatePipe } from '@ngx-translate/core';
selector: 'pi-number-field',
templateUrl: './number-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
- providers: [
- {
- provide: NG_VALUE_ACCESSOR,
- useExisting: PiNumberFieldComponent,
- multi: true,
- },
- ],
imports: [
MatFormField,
MatLabel,
MatPrefix,
MatSuffix,
MatInput,
- ReactiveFormsModule,
PiAutofocusDirective,
MatError,
TranslatePipe,
],
})
@Injectable()
-export class PiNumberFieldComponent extends PiBaseFormFieldComponent {
+export class PiNumberFieldComponent extends PiBaseFormFieldComponent<
+ number | null
+> {
@HostBinding('class') className = `pi-form-field__number`;
+
+ protected readonly rawValue = numericValue(this.value);
}
diff --git a/libs/ui/src/lib/ui/form-field/numeric-value.ts b/libs/ui/src/lib/ui/form-field/numeric-value.ts
new file mode 100644
index 0000000..602670a
--- /dev/null
+++ b/libs/ui/src/lib/ui/form-field/numeric-value.ts
@@ -0,0 +1,29 @@
+import { ModelSignal } from '@angular/core';
+import {
+ TransformedValueSignal,
+ transformedValue,
+} from '@angular/forms/signals';
+
+/**
+ * Puente entre el `` (que siempre da texto) y un modelo numérico.
+ *
+ * `transformedValue` es el mecanismo de Signal Forms para esto: además de
+ * formatear y parsear, reporta los errores de parseo al campo enlazado.
+ */
+export function numericValue(
+ value: ModelSignal,
+): TransformedValueSignal {
+ return transformedValue(value, {
+ parse: (raw: string) => {
+ if (raw.trim() === '') return { value: null };
+ const parsed = Number(raw);
+ if (Number.isNaN(parsed)) {
+ return {
+ error: { kind: 'parse', message: 'common.validator.onlyNumbers' },
+ };
+ }
+ return { value: parsed };
+ },
+ format: (val) => val?.toString() ?? '',
+ });
+}
diff --git a/libs/ui/src/lib/ui/form-field/password-field/password-field.component.html b/libs/ui/src/lib/ui/form-field/password-field/password-field.component.html
index da86c1d..32723b1 100644
--- a/libs/ui/src/lib/ui/form-field/password-field/password-field.component.html
+++ b/libs/ui/src/lib/ui/form-field/password-field/password-field.component.html
@@ -4,7 +4,7 @@
- @if (control.errors) {
+ @if (enableErrorMessage() && hasError()) {
- @for (text of control.errors['message']; track text) {
- {{ text | translate }}
+ @for (message of errorMessages(); track message) {
+ {{ message | translate }}
}
}
diff --git a/libs/ui/src/lib/ui/form-field/password-field/password-field.component.ts b/libs/ui/src/lib/ui/form-field/password-field/password-field.component.ts
index 36973e2..72ec23d 100644
--- a/libs/ui/src/lib/ui/form-field/password-field/password-field.component.ts
+++ b/libs/ui/src/lib/ui/form-field/password-field/password-field.component.ts
@@ -1,5 +1,4 @@
import { ChangeDetectionStrategy, Component, HostBinding } from '@angular/core';
-import { NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
import { PiBaseFormFieldComponent } from '../base-form-field.component';
import {
MatFormField,
@@ -18,13 +17,6 @@ import { TranslatePipe } from '@ngx-translate/core';
selector: 'pi-password-field',
templateUrl: './password-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
- providers: [
- {
- provide: NG_VALUE_ACCESSOR,
- useExisting: PiPasswordFieldComponent,
- multi: true,
- },
- ],
imports: [
MatFormField,
MatLabel,
@@ -32,13 +24,12 @@ import { TranslatePipe } from '@ngx-translate/core';
MatSuffix,
PiIconComponent,
MatInput,
- ReactiveFormsModule,
PiAutofocusDirective,
MatError,
TranslatePipe,
],
})
-export class PiPasswordFieldComponent extends PiBaseFormFieldComponent {
+export class PiPasswordFieldComponent extends PiBaseFormFieldComponent {
@HostBinding('class') className = `pi-form-field__password`;
hide = true;
diff --git a/libs/ui/src/lib/ui/form-field/phone-field/phone-field.component.html b/libs/ui/src/lib/ui/form-field/phone-field/phone-field.component.html
index c01ff38..d1d9892 100644
--- a/libs/ui/src/lib/ui/form-field/phone-field/phone-field.component.html
+++ b/libs/ui/src/lib/ui/form-field/phone-field/phone-field.component.html
@@ -6,7 +6,10 @@
- @if (control.errors) {
+ @if (enableErrorMessage() && hasError()) {
- @for (text of control.errors['message']; track text) {
- {{ text | translate }}
+ @for (message of errorMessages(); track message) {
+ {{ message | translate }}
}
}
diff --git a/libs/ui/src/lib/ui/form-field/phone-field/phone-field.component.ts b/libs/ui/src/lib/ui/form-field/phone-field/phone-field.component.ts
index ba1bf00..2d2db0d 100644
--- a/libs/ui/src/lib/ui/form-field/phone-field/phone-field.component.ts
+++ b/libs/ui/src/lib/ui/form-field/phone-field/phone-field.component.ts
@@ -1,5 +1,4 @@
import { ChangeDetectionStrategy, Component, HostBinding } from '@angular/core';
-import { NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
import { PiBaseFormFieldComponent } from '../base-form-field.component';
import {
MatFormField,
@@ -17,25 +16,17 @@ import { TranslatePipe } from '@ngx-translate/core';
selector: 'pi-phone-field',
templateUrl: './phone-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
- providers: [
- {
- provide: NG_VALUE_ACCESSOR,
- useExisting: PiPhoneFieldComponent,
- multi: true,
- },
- ],
imports: [
MatFormField,
MatLabel,
MatSuffix,
PiIconComponent,
MatInput,
- ReactiveFormsModule,
PiAutofocusDirective,
MatError,
TranslatePipe,
],
})
-export class PiPhoneFieldComponent extends PiBaseFormFieldComponent {
+export class PiPhoneFieldComponent extends PiBaseFormFieldComponent {
@HostBinding('class') className = `pi-form-field__phone`;
}
diff --git a/libs/ui/src/lib/ui/form-field/search-field/search-field.component.html b/libs/ui/src/lib/ui/form-field/search-field/search-field.component.html
index b3965c5..d72b91e 100644
--- a/libs/ui/src/lib/ui/form-field/search-field/search-field.component.html
+++ b/libs/ui/src/lib/ui/form-field/search-field/search-field.component.html
@@ -9,7 +9,8 @@
#input
(focus)="onFocus()"
(blur)="onBlur()"
- [formControl]="control"
+ [value]="value()"
+ (input)="value.set($any($event.target).value)"
[placeholder]="placeholder()!"
type="search"
/>
diff --git a/libs/ui/src/lib/ui/form-field/search-field/search-field.component.ts b/libs/ui/src/lib/ui/form-field/search-field/search-field.component.ts
index 3e03312..3db51ac 100644
--- a/libs/ui/src/lib/ui/form-field/search-field/search-field.component.ts
+++ b/libs/ui/src/lib/ui/form-field/search-field/search-field.component.ts
@@ -3,15 +3,13 @@ import {
Component,
ElementRef,
HostBinding,
- Injector,
effect,
input,
model,
output,
+ untracked,
viewChild,
} from '@angular/core';
-import { NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
-import { OnInit } from '@angular/core';
import { PiBaseFormFieldComponent } from '../base-form-field.component';
import { PiIconComponent } from '../../icon/icon/icon.component';
@@ -20,19 +18,9 @@ import { PiIconComponent } from '../../icon/icon/icon.component';
templateUrl: './search-field.component.html',
styleUrls: ['./search-field.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
- providers: [
- {
- provide: NG_VALUE_ACCESSOR,
- useExisting: PiSearchFieldComponent,
- multi: true,
- },
- ],
- imports: [PiIconComponent, ReactiveFormsModule],
+ imports: [PiIconComponent],
})
-export class PiSearchFieldComponent
- extends PiBaseFormFieldComponent
- implements OnInit
-{
+export class PiSearchFieldComponent extends PiBaseFormFieldComponent {
@HostBinding('class') override get classes(): string {
const isExpandedClass = this.isExpanded() ? 'is-expanded' : 'is-collapsed';
return ['pi-field pi-form-field__search', isExpandedClass].join(' ');
@@ -48,21 +36,20 @@ export class PiSearchFieldComponent
/**
* `model` porque además de recibirse desde fuera, el propio componente la
- * escribe (openField / closeField / valueChanges).
+ * escribe (openField / closeField / cuando entra un valor).
*/
readonly isExpanded = model(false);
- constructor(injector: Injector) {
- super(injector);
- // Antes lo emitía el setter en cada asignación.
- effect(() => this.isActive.emit(this.isExpanded()));
- }
+ constructor() {
+ super();
- ngOnInit(): void {
- this.isExpanded.set(!!this.control.value || !this.isClosable());
- this.control.valueChanges.subscribe((value: any) => {
- if (value) {
- this.isExpanded.set(true);
+ effect(() => this.isActive.emit(this.isExpanded()));
+
+ // Sustituye al ngOnInit + suscripción a control.valueChanges: en cuanto hay
+ // valor (o el campo no se puede cerrar) el buscador queda desplegado.
+ effect(() => {
+ if (this.value() || !this.isClosable()) {
+ untracked(() => this.isExpanded.set(true));
}
});
}
@@ -76,8 +63,8 @@ export class PiSearchFieldComponent
if (this.isClosable()) {
this.isExpanded.set(false);
}
- if (this.control.value) {
- this.control.reset();
+ if (this.value()) {
+ this.value.set('');
this.resetClick.emit();
}
}
diff --git a/libs/ui/src/lib/ui/form-field/slide-toggle/slide-toggle.component.html b/libs/ui/src/lib/ui/form-field/slide-toggle/slide-toggle.component.html
index 44c2f02..e6ce15d 100644
--- a/libs/ui/src/lib/ui/form-field/slide-toggle/slide-toggle.component.html
+++ b/libs/ui/src/lib/ui/form-field/slide-toggle/slide-toggle.component.html
@@ -1,8 +1,11 @@
-