refactor: migra los campos de formulario y sliders a Signal Forms

This commit is contained in:
2026-07-28 18:35:49 +02:00
parent 83af63ae5c
commit eb08262559
34 changed files with 506 additions and 467 deletions

View File

@@ -17,18 +17,82 @@ 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` |
| 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: `
<pi-text-field [field]="f.name" placeholder="Nombre" />
<pi-number-field [field]="f.age" />
<pi-slide-toggle [field]="f.active" />
`,
})
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.
@@ -98,7 +162,7 @@ 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` |
| `featureKey`, `getFeatureState`, `initialState` | `util/device-configs` | `util/list-configs` |

View File

@@ -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<TValue> {
@HostBinding('class')
get classes(): string {
return [
@@ -33,62 +35,63 @@ export abstract class PiBaseFormFieldComponent extends PiFormControl {
].join(' ');
}
readonly formControlName = input.required<string>();
/* --- Contrato FormValueControl --------------------------------------- */
/** Único miembro obligatorio del contrato: lo enlaza la directiva `FormField`. */
readonly value = model.required<TValue>();
readonly errors = input<readonly ValidationError.WithOptionalFieldTree[]>([]);
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<void>();
/* --- Entradas propias de presentación -------------------------------- */
readonly placeholder = input<string>();
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<boolean>();
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();
}
}

View File

@@ -9,22 +9,22 @@
<ng-content select="pi-suffix"></ng-content>
</ng-container>
@if (control) {
<input
[formControl]="control"
[value]="rawValue()"
(input)="rawValue.set($any($event.target).value)"
(focus)="onFocus()"
(blur)="onBlur()"
[placeholder]="placeholder()!"
matInput
inputmode="decimal"
pi-autofocus
[isAutofocusSelected]="isAutofocus()"
currencyMask
[options]="customCurrencyMaskConfig"
/>
}
@if (enableErrorMessage() && control.errors) {
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (text of control.errors['message']; track text) {
{{ text | translate }}
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}

View File

@@ -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,
}),
});
}

View File

@@ -6,7 +6,10 @@
</ng-container>
<input
[formControl]="control"
[value]="value()"
(input)="value.set($any($event.target).value)"
(focus)="onFocus()"
(blur)="onBlur()"
[placeholder]="placeholder()!"
autocomplete="username email"
type="email"
@@ -15,10 +18,10 @@
[isAutofocusSelected]="isAutofocus()"
/>
@if (control.errors) {
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (text of control.errors['message']; track text) {
{{ text | translate }}
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}

View File

@@ -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<string> {
@HostBinding('class') className = `pi-form-field__email`;
}

View File

@@ -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;
}

View File

@@ -9,7 +9,10 @@
<mat-form-field appearance="fill" floatLabel="always">
<mat-label><ng-content select="pi-label"></ng-content></mat-label>
<input
[formControl]="control"
[value]="value()"
(input)="value.set($any($event.target).value)"
(focus)="onFocus()"
(blur)="onBlur()"
[placeholder]="placeholder()!"
matInput
pi-autofocus
@@ -17,10 +20,10 @@
/>
</mat-form-field>
</section>
@if (control.errors && enableErrorMessage()) {
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (text of control.errors['message']; track text) {
{{ text | translate }}
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}

View File

@@ -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<string> {
@HostBinding('class') get componentClasses(): string {
return ['pi-form-field__text-icon'].join(' ');
}

View File

@@ -28,7 +28,10 @@
</pi-suffix>
<input
[formControl]="control"
[value]="rawValue()"
(input)="rawValue.set($any($event.target).value)"
(focus)="onFocus()"
(blur)="onBlur()"
[placeholder]="placeholder()!"
matInput
type="number"
@@ -36,10 +39,10 @@
[isAutofocusSelected]="isAutofocus()"
/>
@if (control.errors) {
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (text of control.errors['message']; track text) {
{{ text | translate }}
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}

View File

@@ -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<number>(1);
readonly max = input<number>();
/**
* `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<number | undefined>(undefined);
readonly min = input<number>(0);
readonly min = input<number | undefined>(0);
readonly unit = input<unit>('');
readonly showIncrementButtons = input<boolean>(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();
}
}

View File

@@ -1,4 +1,3 @@
<form [formGroup]="form">
<div>
<pi-label>
<span>{{ 'common.min' | translate }}</span>
@@ -7,7 +6,9 @@
[slot]="step()"
[unit]="unit()"
[min]="minValue()"
formControlName="from"
[value]="value().from"
(valueChange)="setFrom($event)"
[disabled]="disabled()"
[placeholder]="placeholder()!"
[showIncrementButtons]="showIncrementButtons()"
>
@@ -24,17 +25,19 @@
[slot]="step()"
[unit]="unit()"
[min]="minValue()"
formControlName="to"
[value]="value().to"
(valueChange)="setTo($event)"
[disabled]="disabled()"
[placeholder]="placeholder()!"
[showIncrementButtons]="showIncrementButtons()"
>
</pi-incremental-number-field>
</div>
@if (form.errors && showError()) {
@if (showError() && errorMessages().length) {
<mat-error>
@for (text of form.errors['message']; track text) {
{{ text | translate }}
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}
</form>

View File

@@ -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<PiRange> {
@HostBinding('class') className =
'pi-field pi-form-field__incremental pi-form-field__incremental--range';
readonly formGroupName = input.required<string>();
/* --- Contrato FormValueControl --------------------------------------- */
readonly value = model.required<PiRange>();
readonly errors = input<readonly ValidationError.WithOptionalFieldTree[]>([]);
readonly disabled = input(false);
readonly touch = output<void>();
/* --- Presentación ----------------------------------------------------- */
readonly minValue = input.required<number>();
readonly maxValue = input.required<number>();
readonly unit = input.required<unit>();
@@ -44,9 +55,14 @@ export class PiIncrementalRangeFieldComponent {
readonly showIncrementButtons = input<boolean>(true);
readonly placeholder = input<string>();
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 });
}
}

View File

@@ -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';

View File

@@ -10,7 +10,10 @@
</ng-container>
<input
[formControl]="control"
[value]="rawValue()"
(input)="rawValue.set($any($event.target).value)"
(focus)="onFocus()"
(blur)="onBlur()"
[placeholder]="placeholder()!"
matInput
type="number"
@@ -18,10 +21,10 @@
[isAutofocusSelected]="isAutofocus()"
/>
@if (control.errors) {
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (text of control.errors['message']; track text) {
{{ text | translate }}
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}

View File

@@ -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);
}

View File

@@ -0,0 +1,29 @@
import { ModelSignal } from '@angular/core';
import {
TransformedValueSignal,
transformedValue,
} from '@angular/forms/signals';
/**
* Puente entre el `<input>` (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<number | null>,
): TransformedValueSignal<string> {
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() ?? '',
});
}

View File

@@ -4,7 +4,7 @@
<pi-suffix matSuffix>
<pi-icon
[icon]="hide ? 'eye-off' : 'eye'"
[color]="disabled ? 'grey' : 'black'"
[color]="disabled() ? 'grey' : 'black'"
size="medium"
(click)="hide = !hide"
[attr.aria-label]="'Hide password'"
@@ -13,7 +13,10 @@
</pi-suffix>
<input
[formControl]="control"
[value]="value()"
(input)="value.set($any($event.target).value)"
(focus)="onFocus()"
(blur)="onBlur()"
[placeholder]="placeholder()!"
[type]="hide ? 'password' : 'text'"
autocomplete="current-password"
@@ -22,10 +25,10 @@
[isAutofocusSelected]="isAutofocus()"
/>
@if (control.errors) {
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (text of control.errors['message']; track text) {
{{ text | translate }}
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}

View File

@@ -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<string> {
@HostBinding('class') className = `pi-form-field__password`;
hide = true;

View File

@@ -6,7 +6,10 @@
</ng-container>
<input
[formControl]="control"
[value]="value()"
(input)="value.set($any($event.target).value)"
(focus)="onFocus()"
(blur)="onBlur()"
[placeholder]="placeholder()!"
type="tel"
matInput
@@ -14,10 +17,10 @@
[isAutofocusSelected]="isAutofocus()"
/>
@if (control.errors) {
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (text of control.errors['message']; track text) {
{{ text | translate }}
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}

View File

@@ -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<string> {
@HostBinding('class') className = `pi-form-field__phone`;
}

View File

@@ -9,7 +9,8 @@
#input
(focus)="onFocus()"
(blur)="onBlur()"
[formControl]="control"
[value]="value()"
(input)="value.set($any($event.target).value)"
[placeholder]="placeholder()!"
type="search"
/>

View File

@@ -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<string> {
@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();
}
}

View File

@@ -1,8 +1,11 @@
<label [for]="formControlName() + 'checkbox'">
<label [for]="name() + 'checkbox'">
<input
[formControl]="control"
type="checkbox"
[id]="formControlName() + 'checkbox'"
[id]="name() + 'checkbox'"
[checked]="checked()"
[disabled]="disabled()"
(change)="checked.set($any($event.target).checked)"
(blur)="touch.emit()"
/>
<span></span>
</label>

View File

@@ -2,44 +2,41 @@ import {
ChangeDetectionStrategy,
Component,
HostBinding,
OnChanges,
OnInit,
SimpleChanges,
input,
model,
output,
} from '@angular/core';
import { NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
import { PiBaseFormFieldComponent } from '../base-form-field.component';
import { FormCheckboxControl } from '@angular/forms/signals';
/**
* Implementa `FormCheckboxControl` en vez de `FormValueControl`: el contrato de
* Signal Forms para casillas usa `checked` y prohíbe expresamente declarar
* `value`, así que este componente no puede extender `PiBaseFormFieldComponent`.
*
* Antes mantenía un campo `isChecked` sincronizado por suscripción a
* `control.valueChanges`; ahora el propio `checked` es la fuente de verdad.
*/
@Component({
selector: 'pi-slide-toggle',
templateUrl: './slide-toggle.component.html',
styleUrls: ['./slide-toggle.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: PiSlideToggleComponent,
multi: true,
},
],
imports: [ReactiveFormsModule],
})
export class PiSlideToggleComponent
extends PiBaseFormFieldComponent
implements OnInit
{
@HostBinding('class') override get classes(): string {
const isCheckedClass = this.isChecked ? 'is-checked' : '';
return ['pi-slide-toggle', isCheckedClass, this.isDisabledClass].join(' ');
export class PiSlideToggleComponent implements FormCheckboxControl {
@HostBinding('class') get classes(): string {
const isCheckedClass = this.checked() ? 'is-checked' : '';
const isDisabledClass = this.disabled() ? 'is-disabled' : '';
return ['pi-slide-toggle', isCheckedClass, isDisabledClass].join(' ');
}
/* --- Contrato FormCheckboxControl ------------------------------------ */
readonly checked = model(false);
readonly disabled = input(false);
readonly name = input('');
readonly touch = output<void>();
/* --- Presentación ----------------------------------------------------- */
readonly label = input<string>();
isChecked!: boolean;
ngOnInit(): void {
this.control.valueChanges.subscribe((value: boolean) => {
this.isChecked = value;
});
}
}

View File

@@ -6,17 +6,20 @@
</ng-container>
<input
[formControl]="control"
[value]="value()"
(input)="value.set($any($event.target).value)"
(focus)="onFocus()"
(blur)="onBlur()"
[placeholder]="placeholder()!"
matInput
pi-autofocus
[isAutofocusSelected]="isAutofocus()"
/>
@if (control.errors) {
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (text of control.errors['message']; track text) {
{{ text | translate }}
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}

View File

@@ -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,
@@ -16,24 +15,16 @@ import { TranslatePipe } from '@ngx-translate/core';
selector: 'pi-text-field',
templateUrl: './text-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: PiTextFieldComponent,
multi: true,
},
],
imports: [
MatFormField,
MatLabel,
MatSuffix,
MatInput,
ReactiveFormsModule,
PiAutofocusDirective,
MatError,
TranslatePipe,
],
})
export class PiTextFieldComponent extends PiBaseFormFieldComponent {
export class PiTextFieldComponent extends PiBaseFormFieldComponent<string> {
@HostBinding('class') className = `pi-form-field__text`;
}

View File

@@ -2,10 +2,13 @@
<mat-label><ng-content select="pi-label"></ng-content></mat-label>
<textarea
[formControl]="control"
[value]="value()"
(input)="value.set($any($event.target).value)"
(focus)="onFocus()"
(blur)="onBlur()"
[placeholder]="placeholder()!"
[name]="formControlName()"
[maxLength]="maxLength()"
[name]="name()"
[maxLength]="maxLength()!"
matInput
cdkTextareaAutosize
#autosize="cdkTextareaAutosize"
@@ -15,13 +18,13 @@
[isAutofocusSelected]="isAutofocus()"
></textarea>
</mat-form-field>
@if (control.errors && enableErrorMessage()) {
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (text of control.errors['message']; track text) {
{{ text | translate }}
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}
@if (maxLength() !== 1e6) {
<span>{{ control.value?.length || 0 }} / {{ maxLength() }}</span>
<span>{{ value().length }} / {{ maxLength() }}</span>
}

View File

@@ -4,7 +4,6 @@ import {
HostBinding,
input,
} from '@angular/core';
import { NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
import { PiBaseFormFieldComponent } from '../base-form-field.component';
import { MatFormField, MatLabel, MatError } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
@@ -17,26 +16,23 @@ import { TranslatePipe } from '@ngx-translate/core';
selector: 'pi-textarea',
templateUrl: './textarea.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: PiTextareaComponent,
multi: true,
},
],
imports: [
MatFormField,
MatLabel,
MatInput,
CdkTextareaAutosize,
ReactiveFormsModule,
PiAutofocusDirective,
MatError,
TranslatePipe,
],
})
export class PiTextareaComponent extends PiBaseFormFieldComponent {
export class PiTextareaComponent extends PiBaseFormFieldComponent<string> {
@HostBinding('class') className = 'pi-form-field__textarea';
readonly maxLength = input<number>(1e6);
/**
* `number | undefined` porque el contrato `FormUiControl` declara `maxLength`
* así: si el campo enlazado tiene un validador `maxLength`, la directiva
* `FormField` lo inyecta aquí automáticamente.
*/
readonly maxLength = input<number | undefined>(1e6);
}

View File

@@ -1,17 +1,20 @@
// Angular
import { Directive, HostBinding, input, viewChild } from '@angular/core';
import { FormControlDirective } from '@angular/forms';
import { Directive, HostBinding, input, output } from '@angular/core';
// Models
import { unit } from '../../util/models';
/**
* Parte común de los sliders. Los métodos de ControlValueAccessor que delegaban
* en el `FormControlDirective` de la vista han desaparecido: con Signal Forms
* cada slider expone su propio `value`/`checked` y la directiva `FormField` se
* encarga de la sincronización.
*/
@Directive()
export abstract class PiBaseSliderComponent {
readonly formControlDirective = viewChild(FormControlDirective);
@HostBinding('class')
get classes(): string {
const isDisabledClass = this.isDisabled() ? 'is-disabled' : '';
const isDisabledClass = this.disabled() ? 'is-disabled' : '';
const flexDirection = this.flexDirection();
const flexDirectionClass =
flexDirection !== 'column' &&
@@ -30,28 +33,11 @@ export abstract class PiBaseSliderComponent {
// Skin
readonly flexDirection = input<'column' | 'column-reverse'>('column');
// Behaviour
readonly isDisabled = input<boolean>();
// Contrato FormUiControl
readonly disabled = input(false);
readonly touch = output<void>();
formatLabel = (value: number) => {
return `${value} ${this.unit()}`;
};
registerOnTouched(fn: any): void {
this.formControlDirective()?.valueAccessor?.registerOnTouched(fn);
}
registerOnChange(fn: any): void {
this.formControlDirective()?.valueAccessor?.registerOnChange(fn);
}
writeValue(obj: any): void {
this.formControlDirective()?.valueAccessor?.writeValue(obj);
}
setDisabledState(isDisabled: boolean): void {
this.formControlDirective()?.valueAccessor?.setDisabledState
? isDisabled
: null;
}
}

View File

@@ -1,13 +1,21 @@
<form [formGroup]="form">
<mat-slider
[min]="minValue()"
[max]="maxValue()"
[discrete]="thumbLabel()"
[displayWith]="formatLabel"
[step]="step()"
[disabled]="isDisabled()"
[disabled]="disabled()"
>
<input formControlName="from" matSliderStartThumb />
<input formControlName="to" matSliderEndThumb />
<input
matSliderStartThumb
[value]="value().from"
(valueChange)="setFrom($event)"
(blur)="touch.emit()"
/>
<input
matSliderEndThumb
[value]="value().to"
(valueChange)="setTo($event)"
(blur)="touch.emit()"
/>
</mat-slider>
</form>

View File

@@ -3,58 +3,40 @@ import {
ChangeDetectionStrategy,
Component,
HostBinding,
OnInit,
forwardRef,
input,
model,
} from '@angular/core';
import {
FormGroup,
FormGroupDirective,
NG_VALUE_ACCESSOR,
ReactiveFormsModule,
} from '@angular/forms';
import { FormValueControl, ValidationError } from '@angular/forms/signals';
// Component
import { PiBaseSliderComponent } from '../base-slider.component';
import { PiRange } from '../../form-field/entities/range.model';
import { MatSlider, MatSliderRangeThumb } from '@angular/material/slider';
/**
* Antes leía un `FormGroup` anidado vía `formGroupName`; ahora recibe el rango
* entero por `value`, igual que `pi-incremental-range-field`.
*/
@Component({
selector: 'pi-range-slider',
templateUrl: './range-slider.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: PiRangeSliderComponent,
multi: true,
},
],
imports: [
ReactiveFormsModule,
MatSlider,
MatSliderRangeThumb,
forwardRef(() => PiRangeSliderComponent),
],
imports: [MatSlider, MatSliderRangeThumb],
})
export class PiRangeSliderComponent
extends PiBaseSliderComponent
implements OnInit
implements FormValueControl<PiRange>
{
@HostBinding('class') className = 'pi-slider__range';
readonly formGroupName = input.required<string>();
readonly value = model.required<PiRange>();
readonly errors = input<readonly ValidationError.WithOptionalFieldTree[]>([]);
constructor(private rootFormGroup: FormGroupDirective) {
super();
protected setFrom(from: number): void {
this.value.set({ ...this.value(), from });
}
get form() {
return this.rootFormGroup.control.get(this.formGroupName()) as FormGroup;
}
ngOnInit(): void {
if (this.isDisabled()) {
this.form.disable();
}
protected setTo(to: number): void {
this.value.set({ ...this.value(), to });
}
}

View File

@@ -1,16 +1,21 @@
@if (label()) {
<span>
<label>{{ label() }}</label>
<span>{{ control?.value }} {{ unit() }}</span>
<span>{{ value() }} {{ unit() }}</span>
</span>
}
<mat-slider
[min]="minValue()"
[max]="maxValue()"
[discrete]="this.isDisabled() ? null : thumbLabel()"
[discrete]="disabled() ? false : thumbLabel()"
[displayWith]="formatLabel"
[step]="step()"
[disabled]="isDisabled()"
[disabled]="disabled()"
>
<input [formControl]="control" matSliderThumb />
<input
matSliderThumb
[value]="value()"
(valueChange)="value.set($event)"
(blur)="touch.emit()"
/>
</mat-slider>

View File

@@ -2,15 +2,10 @@
import {
ChangeDetectionStrategy,
Component,
Injector,
OnInit,
input,
model,
} from '@angular/core';
import {
ControlContainer,
NG_VALUE_ACCESSOR,
ReactiveFormsModule,
} from '@angular/forms';
import { FormValueControl, ValidationError } from '@angular/forms/signals';
// Component
import { PiBaseSliderComponent } from '../base-slider.component';
@@ -22,33 +17,12 @@ import { MatSlider, MatSliderThumb } from '@angular/material/slider';
templateUrl: './slider.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./slider.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: PiSliderComponent,
multi: true,
},
],
imports: [MatSlider, MatSliderThumb, ReactiveFormsModule],
imports: [MatSlider, MatSliderThumb],
})
export class PiSliderComponent extends PiBaseSliderComponent implements OnInit {
readonly formControlName = input<string>();
constructor(private injector: Injector) {
super();
}
get control(): any {
return this.controlContainer.control?.get(this.formControlName());
}
get controlContainer(): any {
return this.injector.get(ControlContainer);
}
ngOnInit(): void {
if (this.isDisabled()) {
this.control.disable();
}
}
export class PiSliderComponent
extends PiBaseSliderComponent
implements FormValueControl<number>
{
readonly value = model.required<number>();
readonly errors = input<readonly ValidationError.WithOptionalFieldTree[]>([]);
}