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.min' | translate }} - - - -
+
+ + {{ 'common.min' | translate }} + + + +
- + -
- - {{ 'common.max' | translate }} - - - -
- @if (form.errors && showError()) { - - @for (text of form.errors['message']; track text) { - {{ text | translate }} - } - - } -
+
+ + {{ '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 @@ -