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 ## Qué se hizo
| | | | | |
|---|---| | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Prefijo | `Dl`→`Pi`, `dl-`→`pi-` (selectores, clases CSS, nombres de fichero), `@deale-workspace/…`→rutas relativas | | 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`) | | NgModules | 41 → 4 (sólo quedan los que aportan providers: `icon-registry`, `loaders`, `router-store`, `scroll-to`) |
| Componentes | 70, **todos** con `ChangeDetectionStrategy.OnPush` | | Componentes | 70, **todos** con `ChangeDetectionStrategy.OnPush` |
| Decoradores | 0 restantes: 460 `input()`, 6 `model()`, 20 `output()`, 8 `viewChild()`, 2 `viewChildren()`, 17 `contentChild()`, 6 `contentChildren()` | | 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` | | 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`, Se usaron las migraciones oficiales de Angular (`@angular/core:standalone`,
`signal-input-migration`, `output-migration`, `signal-queries-migration`, `signal-input-migration`, `output-migration`, `signal-queries-migration`,
`control-flow-migration`, `cleanup-unused-imports`) y se resolvió a mano lo que `control-flow-migration`, `cleanup-unused-imports`) y se resolvió a mano lo que
no cubren. 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 ## Decisiones que cambian comportamiento
Revisar estos puntos antes de usar la librería en producción: 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()`.** **3. `PiAccordionComponent.expandedItemIndex` pasa a `model()`.**
Tenía un two-way binding hecho a mano (`expandedItemIndex` + `expandedItemIndexChange`). 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 `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 `model()` emite el nuevo. Parece que el original tenía un bug. Su tipo pasa a
`number | null` porque `toggleState` asigna `null` al colapsar. `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: omite (queda anotado en el fichero). Siguen siendo accesibles por ruta directa:
| Símbolo | Se exporta desde | Se omite de | | Símbolo | Se exporta desde | Se omite de |
|---|---|---| | ----------------------------------------------- | --------------------- | ------------------- |
| `PiSlideToggleComponent` | `ui/form-field` | `ui/slide-toggle` | | `PiSlideToggleComponent` | `ui/form-field` | `ui/slide-toggle` |
| `PiDisabledDirective` | `util/status` | `ui/text` | | `PiDisabledDirective` | `util/status` | `ui/text` |
| `featureKey`, `getFeatureState`, `initialState` | `util/device-configs` | `util/list-configs` | | `featureKey`, `getFeatureState`, `initialState` | `util/device-configs` | `util/list-configs` |

View File

@@ -1,28 +1,30 @@
import { import {
ChangeDetectionStrategy,
Component, Component,
HostBinding, HostBinding,
Injector, computed,
effect,
input, input,
model,
output, output,
ChangeDetectionStrategy, signal,
} from '@angular/core'; } from '@angular/core';
import { FormControl, NG_VALUE_ACCESSOR } from '@angular/forms'; import { FormValueControl, ValidationError } from '@angular/forms/signals';
import { ComponentFamilyClass } from '../../styles'; 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({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: '', 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') @HostBinding('class')
get classes(): string { get classes(): string {
return [ return [
@@ -33,62 +35,63 @@ export abstract class PiBaseFormFieldComponent extends PiFormControl {
].join(' '); ].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 placeholder = input<string>();
readonly isAutofocus = input(false); readonly isAutofocus = input(false);
readonly appearance = input<'outline' | 'fill'>('outline'); readonly appearance = input<'outline' | 'fill'>('outline');
readonly enableErrorMessage = input(true); 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>(); readonly isActive = output<boolean>();
constructor(injector: Injector) { /* --- Estado interno --------------------------------------------------- */
super(injector);
effect(() => { protected readonly focused = signal(false);
this.disabled = this.isDisabled();
// Update control status reactively
if (this.isControlStatusReactive()) {
if (this.disabled) {
this.control.disable();
} else {
this.control.enable();
}
}
});
}
get control(): FormControl { /**
return this.controlContainer.control?.get( * Mensajes listos para pasar por el pipe `translate`. Antes venían de
this.formControlName(), * `control.errors['message']`; ahora cada `ValidationError` trae su `message`
) as FormControl; * (y si no, se usa el `kind` como clave).
} */
protected readonly errorMessages = computed(() =>
this.errors().map((error) => error.message ?? error.kind),
);
get hasError(): boolean { protected readonly hasError = computed(() => this.errors().length > 0);
return !!this.control.errors?.['message'];
}
protected get focusedClass(): string | boolean { protected get focusedClass(): string | boolean {
return this.focused ? 'is-focused' : false; return this.focused() ? 'is-focused' : false;
} }
protected get hasErrorClass(): string | boolean { protected get hasErrorClass(): string | boolean {
return this.hasError ? 'has-error' : ''; return this.hasError() ? 'has-error' : '';
} }
protected get isDisabledClass(): string { 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-content select="pi-suffix"></ng-content>
</ng-container> </ng-container>
@if (control) {
<input <input
[formControl]="control" [value]="rawValue()"
(input)="rawValue.set($any($event.target).value)"
(focus)="onFocus()"
(blur)="onBlur()"
[placeholder]="placeholder()!" [placeholder]="placeholder()!"
matInput matInput
inputmode="decimal"
pi-autofocus pi-autofocus
[isAutofocusSelected]="isAutofocus()" [isAutofocusSelected]="isAutofocus()"
currencyMask
[options]="customCurrencyMaskConfig"
/> />
}
@if (enableErrorMessage() && control.errors) { @if (enableErrorMessage() && hasError()) {
<mat-error> <mat-error>
@for (text of control.errors['message']; track text) { @for (message of errorMessages(); track message) {
{{ text | translate }} {{ message | translate }}
} }
</mat-error> </mat-error>
} }

View File

@@ -1,12 +1,5 @@
import { import { ChangeDetectionStrategy, Component, HostBinding } from '@angular/core';
ChangeDetectionStrategy, import { transformedValue } from '@angular/forms/signals';
Component,
HostBinding,
forwardRef,
} from '@angular/core';
import { NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
import { NgxCurrencyInputMode, NgxCurrencyDirective } from 'ngx-currency';
import { PiBaseFormFieldComponent } from '../base-form-field.component'; import { PiBaseFormFieldComponent } from '../base-form-field.component';
import { import {
@@ -21,41 +14,57 @@ import { PiAutofocusDirective } from '../directives/autofocus.directive';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
const DECIMAL_SEPARATOR = ',';
const THOUSANDS_SEPARATOR = '.';
const PRECISION = 2;
@Component({ @Component({
selector: 'pi-currency-field', selector: 'pi-currency-field',
templateUrl: './currency-field.component.html', templateUrl: './currency-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: PiCurrencyFieldComponent,
multi: true,
},
],
imports: [ imports: [
MatFormField, MatFormField,
MatLabel, MatLabel,
MatSuffix, MatSuffix,
MatInput, MatInput,
NgxCurrencyDirective,
ReactiveFormsModule,
PiAutofocusDirective, PiAutofocusDirective,
MatError, MatError,
forwardRef(() => PiCurrencyFieldComponent),
TranslatePipe, TranslatePipe,
], ],
}) })
export class PiCurrencyFieldComponent extends PiBaseFormFieldComponent { export class PiCurrencyFieldComponent extends PiBaseFormFieldComponent<
number | null
> {
@HostBinding('class') className = `pi-form-field__currency`; @HostBinding('class') className = `pi-form-field__currency`;
customCurrencyMaskConfig = { /**
align: 'left', * El formateo lo hacía la directiva `currencyMask` de ngx-currency, que es un
allowNegative: true, * ControlValueAccessor y por tanto no funciona sin reactive forms. Se
decimal: ',', * sustituye por `transformedValue`, replicando la configuración anterior
precision: 2, * (decimal ',', miles '.', 2 decimales, admite null y negativos).
prefix: '', */
thousands: '.', protected readonly rawValue = transformedValue(this.value, {
inputMode: NgxCurrencyInputMode.Natural, parse: (raw: string) => {
nullable: true, 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> </ng-container>
<input <input
[formControl]="control" [value]="value()"
(input)="value.set($any($event.target).value)"
(focus)="onFocus()"
(blur)="onBlur()"
[placeholder]="placeholder()!" [placeholder]="placeholder()!"
autocomplete="username email" autocomplete="username email"
type="email" type="email"
@@ -15,10 +18,10 @@
[isAutofocusSelected]="isAutofocus()" [isAutofocusSelected]="isAutofocus()"
/> />
@if (control.errors) { @if (enableErrorMessage() && hasError()) {
<mat-error> <mat-error>
@for (text of control.errors['message']; track text) { @for (message of errorMessages(); track message) {
{{ text | translate }} {{ message | translate }}
} }
</mat-error> </mat-error>
} }

View File

@@ -1,5 +1,4 @@
import { ChangeDetectionStrategy, Component, HostBinding } from '@angular/core'; import { ChangeDetectionStrategy, Component, HostBinding } from '@angular/core';
import { NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
import { PiBaseFormFieldComponent } from '../base-form-field.component'; import { PiBaseFormFieldComponent } from '../base-form-field.component';
import { import {
MatFormField, MatFormField,
@@ -17,25 +16,17 @@ import { TranslatePipe } from '@ngx-translate/core';
selector: 'pi-email-field', selector: 'pi-email-field',
templateUrl: './email-field.component.html', templateUrl: './email-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: PiEmailFieldComponent,
multi: true,
},
],
imports: [ imports: [
MatFormField, MatFormField,
MatLabel, MatLabel,
MatSuffix, MatSuffix,
PiIconComponent, PiIconComponent,
MatInput, MatInput,
ReactiveFormsModule,
PiAutofocusDirective, PiAutofocusDirective,
MatError, MatError,
TranslatePipe, TranslatePipe,
], ],
}) })
export class PiEmailFieldComponent extends PiBaseFormFieldComponent { export class PiEmailFieldComponent extends PiBaseFormFieldComponent<string> {
@HostBinding('class') className = `pi-form-field__email`; @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-form-field appearance="fill" floatLabel="always">
<mat-label><ng-content select="pi-label"></ng-content></mat-label> <mat-label><ng-content select="pi-label"></ng-content></mat-label>
<input <input
[formControl]="control" [value]="value()"
(input)="value.set($any($event.target).value)"
(focus)="onFocus()"
(blur)="onBlur()"
[placeholder]="placeholder()!" [placeholder]="placeholder()!"
matInput matInput
pi-autofocus pi-autofocus
@@ -17,10 +20,10 @@
/> />
</mat-form-field> </mat-form-field>
</section> </section>
@if (control.errors && enableErrorMessage()) { @if (enableErrorMessage() && hasError()) {
<mat-error> <mat-error>
@for (text of control.errors['message']; track text) { @for (message of errorMessages(); track message) {
{{ text | translate }} {{ message | translate }}
} }
</mat-error> </mat-error>
} }

View File

@@ -4,7 +4,6 @@ import {
HostBinding, HostBinding,
input, input,
} from '@angular/core'; } from '@angular/core';
import { NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
import { iconSize } from '../../../styles'; import { iconSize } from '../../../styles';
import { PiBaseFormFieldComponent } from '../base-form-field.component'; import { PiBaseFormFieldComponent } from '../base-form-field.component';
import { PiIconComponent } from '../../icon/icon/icon.component'; import { PiIconComponent } from '../../icon/icon/icon.component';
@@ -19,25 +18,17 @@ import { TranslatePipe } from '@ngx-translate/core';
templateUrl: './icon-field.component.html', templateUrl: './icon-field.component.html',
styleUrls: ['./icon-field.component.scss'], styleUrls: ['./icon-field.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: PiIconFieldComponent,
multi: true,
},
],
imports: [ imports: [
PiIconComponent, PiIconComponent,
MatFormField, MatFormField,
MatLabel, MatLabel,
MatInput, MatInput,
ReactiveFormsModule,
PiAutofocusDirective, PiAutofocusDirective,
MatError, MatError,
TranslatePipe, TranslatePipe,
], ],
}) })
export class PiIconFieldComponent extends PiBaseFormFieldComponent { export class PiIconFieldComponent extends PiBaseFormFieldComponent<string> {
@HostBinding('class') get componentClasses(): string { @HostBinding('class') get componentClasses(): string {
return ['pi-form-field__text-icon'].join(' '); return ['pi-form-field__text-icon'].join(' ');
} }

View File

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

View File

@@ -5,7 +5,6 @@ import {
Injectable, Injectable,
input, input,
} from '@angular/core'; } from '@angular/core';
import { NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
import { unit } from '../../../util/models'; import { unit } from '../../../util/models';
import { PiBaseFormFieldComponent } from '../base-form-field.component'; import { PiBaseFormFieldComponent } from '../base-form-field.component';
import { import {
@@ -23,6 +22,7 @@ import {
import { PiIconComponent } from '../../icon/icon/icon.component'; import { PiIconComponent } from '../../icon/icon/icon.component';
import { MatInput } from '@angular/material/input'; import { MatInput } from '@angular/material/input';
import { PiAutofocusDirective } from '../directives/autofocus.directive'; import { PiAutofocusDirective } from '../directives/autofocus.directive';
import { numericValue } from '../numeric-value';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
@@ -30,13 +30,6 @@ import { TranslatePipe } from '@ngx-translate/core';
selector: 'pi-incremental-number-field', selector: 'pi-incremental-number-field',
templateUrl: './incremental-number-field.component.html', templateUrl: './incremental-number-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: PiIncrementalNumberFieldComponent,
multi: true,
},
],
imports: [ imports: [
MatFormField, MatFormField,
MatLabel, MatLabel,
@@ -46,62 +39,62 @@ import { TranslatePipe } from '@ngx-translate/core';
PiSuffixDirective, PiSuffixDirective,
MatSuffix, MatSuffix,
MatInput, MatInput,
ReactiveFormsModule,
PiAutofocusDirective, PiAutofocusDirective,
MatError, MatError,
TranslatePipe, TranslatePipe,
], ],
}) })
@Injectable() @Injectable()
export class PiIncrementalNumberFieldComponent extends PiBaseFormFieldComponent { export class PiIncrementalNumberFieldComponent extends PiBaseFormFieldComponent<
number | null
> {
@HostBinding('class') className = 'pi-form-field__incremental'; @HostBinding('class') className = 'pi-form-field__incremental';
readonly slot = input<number>(1); 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 unit = input<unit>('');
readonly showIncrementButtons = input<boolean>(true); readonly showIncrementButtons = input<boolean>(true);
incrementValue(): void { protected readonly rawValue = numericValue(this.value);
if (this.disabled) return;
let currentValue = Number(this.control.value); private step(direction: 1 | -1): void {
// Handle decimal numbers if (this.disabled()) return;
const slot = this.slot(); const slot = this.slot();
if (Number.isInteger(slot)) { const next = (this.value() ?? 0) + direction * slot;
this.control.setValue(currentValue + slot); // Handle decimal numbers
} else { this.value.set(Number.isInteger(slot) ? next : Number(next.toFixed(2)));
this.control.setValue((currentValue + slot).toFixed(2));
} }
incrementValue(): void {
this.step(1);
} }
decrementValue(): void { decrementValue(): void {
if (this.disabled) return; this.step(-1);
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));
}
} }
isIncrementButtonDisabled(): boolean { isIncrementButtonDisabled(): boolean {
return ( return (
this.disabled || this.disabled() ||
this.control.value + this.slot() > (this.max() ?? Infinity) (this.value() ?? 0) + this.slot() > (this.max() ?? Infinity)
); );
} }
isDecrementButtonDisabled(): boolean { isDecrementButtonDisabled(): boolean {
const min = this.min(); const min = this.min();
if (min || min === 0) { 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,5 +1,4 @@
<form [formGroup]="form"> <div>
<div>
<pi-label> <pi-label>
<span>{{ 'common.min' | translate }}</span> <span>{{ 'common.min' | translate }}</span>
</pi-label> </pi-label>
@@ -7,16 +6,18 @@
[slot]="step()" [slot]="step()"
[unit]="unit()" [unit]="unit()"
[min]="minValue()" [min]="minValue()"
formControlName="from" [value]="value().from"
(valueChange)="setFrom($event)"
[disabled]="disabled()"
[placeholder]="placeholder()!" [placeholder]="placeholder()!"
[showIncrementButtons]="showIncrementButtons()" [showIncrementButtons]="showIncrementButtons()"
> >
</pi-incremental-number-field> </pi-incremental-number-field>
</div> </div>
<pi-divider color="primary" width="xx-small"></pi-divider> <pi-divider color="primary" width="xx-small"></pi-divider>
<div> <div>
<pi-label> <pi-label>
<span>{{ 'common.max' | translate }}</span> <span>{{ 'common.max' | translate }}</span>
</pi-label> </pi-label>
@@ -24,17 +25,19 @@
[slot]="step()" [slot]="step()"
[unit]="unit()" [unit]="unit()"
[min]="minValue()" [min]="minValue()"
formControlName="to" [value]="value().to"
(valueChange)="setTo($event)"
[disabled]="disabled()"
[placeholder]="placeholder()!" [placeholder]="placeholder()!"
[showIncrementButtons]="showIncrementButtons()" [showIncrementButtons]="showIncrementButtons()"
> >
</pi-incremental-number-field> </pi-incremental-number-field>
</div> </div>
@if (form.errors && showError()) {
@if (showError() && errorMessages().length) {
<mat-error> <mat-error>
@for (text of form.errors['message']; track text) { @for (message of errorMessages(); track message) {
{{ text | translate }} {{ message | translate }}
} }
</mat-error> </mat-error>
} }
</form>

View File

@@ -3,13 +3,12 @@ import {
Component, Component,
HostBinding, HostBinding,
input, input,
model,
output,
} from '@angular/core'; } from '@angular/core';
import { import { FormValueControl, ValidationError } from '@angular/forms/signals';
FormGroup,
FormGroupDirective,
ReactiveFormsModule,
} from '@angular/forms';
import { unit } from '../../../util/models'; import { unit } from '../../../util/models';
import { PiRange } from '../entities/range.model';
import { PiLabelDirective } from '../directives/label.directive'; import { PiLabelDirective } from '../directives/label.directive';
import { PiIncrementalNumberFieldComponent } from '../incremental-number-field/incremental-number-field.component'; import { PiIncrementalNumberFieldComponent } from '../incremental-number-field/incremental-number-field.component';
import { PiDividerComponent } from '../../divider/divider/divider.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'; 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({ @Component({
selector: 'pi-incremental-range-field', selector: 'pi-incremental-range-field',
templateUrl: './incremental-range-field.component.html', templateUrl: './incremental-range-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
ReactiveFormsModule,
PiLabelDirective, PiLabelDirective,
PiIncrementalNumberFieldComponent, PiIncrementalNumberFieldComponent,
PiDividerComponent, PiDividerComponent,
@@ -31,11 +34,19 @@ import { TranslatePipe } from '@ngx-translate/core';
TranslatePipe, TranslatePipe,
], ],
}) })
export class PiIncrementalRangeFieldComponent { export class PiIncrementalRangeFieldComponent implements FormValueControl<PiRange> {
@HostBinding('class') className = @HostBinding('class') className =
'pi-field pi-form-field__incremental pi-form-field__incremental--range'; '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 minValue = input.required<number>();
readonly maxValue = input.required<number>(); readonly maxValue = input.required<number>();
readonly unit = input.required<unit>(); readonly unit = input.required<unit>();
@@ -44,9 +55,14 @@ export class PiIncrementalRangeFieldComponent {
readonly showIncrementButtons = input<boolean>(true); readonly showIncrementButtons = input<boolean>(true);
readonly placeholder = input<string>(); readonly placeholder = input<string>();
constructor(private rootFormGroup: FormGroupDirective) {} protected readonly errorMessages = () =>
this.errors().map((error) => error.message ?? error.kind);
get form() { protected setFrom(from: number | null): void {
return this.rootFormGroup.control.get(this.formGroupName()) as FormGroup; 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 './directives/autofocus.directive';
export * from './entities/radio.model'; 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 './incremental-number-field/incremental-number-field.component';
export * from './number-field/number-field.component'; export * from './number-field/number-field.component';

View File

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

View File

@@ -4,7 +4,6 @@ import {
HostBinding, HostBinding,
Injectable, Injectable,
} from '@angular/core'; } from '@angular/core';
import { NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
import { PiBaseFormFieldComponent } from '../base-form-field.component'; import { PiBaseFormFieldComponent } from '../base-form-field.component';
import { import {
MatFormField, MatFormField,
@@ -15,6 +14,7 @@ import {
} from '@angular/material/form-field'; } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input'; import { MatInput } from '@angular/material/input';
import { PiAutofocusDirective } from '../directives/autofocus.directive'; import { PiAutofocusDirective } from '../directives/autofocus.directive';
import { numericValue } from '../numeric-value';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
@@ -22,26 +22,22 @@ import { TranslatePipe } from '@ngx-translate/core';
selector: 'pi-number-field', selector: 'pi-number-field',
templateUrl: './number-field.component.html', templateUrl: './number-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: PiNumberFieldComponent,
multi: true,
},
],
imports: [ imports: [
MatFormField, MatFormField,
MatLabel, MatLabel,
MatPrefix, MatPrefix,
MatSuffix, MatSuffix,
MatInput, MatInput,
ReactiveFormsModule,
PiAutofocusDirective, PiAutofocusDirective,
MatError, MatError,
TranslatePipe, TranslatePipe,
], ],
}) })
@Injectable() @Injectable()
export class PiNumberFieldComponent extends PiBaseFormFieldComponent { export class PiNumberFieldComponent extends PiBaseFormFieldComponent<
number | null
> {
@HostBinding('class') className = `pi-form-field__number`; @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-suffix matSuffix>
<pi-icon <pi-icon
[icon]="hide ? 'eye-off' : 'eye'" [icon]="hide ? 'eye-off' : 'eye'"
[color]="disabled ? 'grey' : 'black'" [color]="disabled() ? 'grey' : 'black'"
size="medium" size="medium"
(click)="hide = !hide" (click)="hide = !hide"
[attr.aria-label]="'Hide password'" [attr.aria-label]="'Hide password'"
@@ -13,7 +13,10 @@
</pi-suffix> </pi-suffix>
<input <input
[formControl]="control" [value]="value()"
(input)="value.set($any($event.target).value)"
(focus)="onFocus()"
(blur)="onBlur()"
[placeholder]="placeholder()!" [placeholder]="placeholder()!"
[type]="hide ? 'password' : 'text'" [type]="hide ? 'password' : 'text'"
autocomplete="current-password" autocomplete="current-password"
@@ -22,10 +25,10 @@
[isAutofocusSelected]="isAutofocus()" [isAutofocusSelected]="isAutofocus()"
/> />
@if (control.errors) { @if (enableErrorMessage() && hasError()) {
<mat-error> <mat-error>
@for (text of control.errors['message']; track text) { @for (message of errorMessages(); track message) {
{{ text | translate }} {{ message | translate }}
} }
</mat-error> </mat-error>
} }

View File

@@ -1,5 +1,4 @@
import { ChangeDetectionStrategy, Component, HostBinding } from '@angular/core'; import { ChangeDetectionStrategy, Component, HostBinding } from '@angular/core';
import { NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
import { PiBaseFormFieldComponent } from '../base-form-field.component'; import { PiBaseFormFieldComponent } from '../base-form-field.component';
import { import {
MatFormField, MatFormField,
@@ -18,13 +17,6 @@ import { TranslatePipe } from '@ngx-translate/core';
selector: 'pi-password-field', selector: 'pi-password-field',
templateUrl: './password-field.component.html', templateUrl: './password-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: PiPasswordFieldComponent,
multi: true,
},
],
imports: [ imports: [
MatFormField, MatFormField,
MatLabel, MatLabel,
@@ -32,13 +24,12 @@ import { TranslatePipe } from '@ngx-translate/core';
MatSuffix, MatSuffix,
PiIconComponent, PiIconComponent,
MatInput, MatInput,
ReactiveFormsModule,
PiAutofocusDirective, PiAutofocusDirective,
MatError, MatError,
TranslatePipe, TranslatePipe,
], ],
}) })
export class PiPasswordFieldComponent extends PiBaseFormFieldComponent { export class PiPasswordFieldComponent extends PiBaseFormFieldComponent<string> {
@HostBinding('class') className = `pi-form-field__password`; @HostBinding('class') className = `pi-form-field__password`;
hide = true; hide = true;

View File

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

View File

@@ -1,5 +1,4 @@
import { ChangeDetectionStrategy, Component, HostBinding } from '@angular/core'; import { ChangeDetectionStrategy, Component, HostBinding } from '@angular/core';
import { NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
import { PiBaseFormFieldComponent } from '../base-form-field.component'; import { PiBaseFormFieldComponent } from '../base-form-field.component';
import { import {
MatFormField, MatFormField,
@@ -17,25 +16,17 @@ import { TranslatePipe } from '@ngx-translate/core';
selector: 'pi-phone-field', selector: 'pi-phone-field',
templateUrl: './phone-field.component.html', templateUrl: './phone-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: PiPhoneFieldComponent,
multi: true,
},
],
imports: [ imports: [
MatFormField, MatFormField,
MatLabel, MatLabel,
MatSuffix, MatSuffix,
PiIconComponent, PiIconComponent,
MatInput, MatInput,
ReactiveFormsModule,
PiAutofocusDirective, PiAutofocusDirective,
MatError, MatError,
TranslatePipe, TranslatePipe,
], ],
}) })
export class PiPhoneFieldComponent extends PiBaseFormFieldComponent { export class PiPhoneFieldComponent extends PiBaseFormFieldComponent<string> {
@HostBinding('class') className = `pi-form-field__phone`; @HostBinding('class') className = `pi-form-field__phone`;
} }

View File

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

View File

@@ -3,15 +3,13 @@ import {
Component, Component,
ElementRef, ElementRef,
HostBinding, HostBinding,
Injector,
effect, effect,
input, input,
model, model,
output, output,
untracked,
viewChild, viewChild,
} from '@angular/core'; } from '@angular/core';
import { NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
import { OnInit } from '@angular/core';
import { PiBaseFormFieldComponent } from '../base-form-field.component'; import { PiBaseFormFieldComponent } from '../base-form-field.component';
import { PiIconComponent } from '../../icon/icon/icon.component'; import { PiIconComponent } from '../../icon/icon/icon.component';
@@ -20,19 +18,9 @@ import { PiIconComponent } from '../../icon/icon/icon.component';
templateUrl: './search-field.component.html', templateUrl: './search-field.component.html',
styleUrls: ['./search-field.component.scss'], styleUrls: ['./search-field.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
providers: [ imports: [PiIconComponent],
{
provide: NG_VALUE_ACCESSOR,
useExisting: PiSearchFieldComponent,
multi: true,
},
],
imports: [PiIconComponent, ReactiveFormsModule],
}) })
export class PiSearchFieldComponent export class PiSearchFieldComponent extends PiBaseFormFieldComponent<string> {
extends PiBaseFormFieldComponent
implements OnInit
{
@HostBinding('class') override get classes(): string { @HostBinding('class') override get classes(): string {
const isExpandedClass = this.isExpanded() ? 'is-expanded' : 'is-collapsed'; const isExpandedClass = this.isExpanded() ? 'is-expanded' : 'is-collapsed';
return ['pi-field pi-form-field__search', isExpandedClass].join(' '); 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 * `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); readonly isExpanded = model(false);
constructor(injector: Injector) { constructor() {
super(injector); super();
// Antes lo emitía el setter en cada asignación.
effect(() => this.isActive.emit(this.isExpanded()));
}
ngOnInit(): void { effect(() => this.isActive.emit(this.isExpanded()));
this.isExpanded.set(!!this.control.value || !this.isClosable());
this.control.valueChanges.subscribe((value: any) => { // Sustituye al ngOnInit + suscripción a control.valueChanges: en cuanto hay
if (value) { // valor (o el campo no se puede cerrar) el buscador queda desplegado.
this.isExpanded.set(true); effect(() => {
if (this.value() || !this.isClosable()) {
untracked(() => this.isExpanded.set(true));
} }
}); });
} }
@@ -76,8 +63,8 @@ export class PiSearchFieldComponent
if (this.isClosable()) { if (this.isClosable()) {
this.isExpanded.set(false); this.isExpanded.set(false);
} }
if (this.control.value) { if (this.value()) {
this.control.reset(); this.value.set('');
this.resetClick.emit(); this.resetClick.emit();
} }
} }

View File

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

View File

@@ -2,44 +2,41 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
HostBinding, HostBinding,
OnChanges,
OnInit,
SimpleChanges,
input, input,
model,
output,
} from '@angular/core'; } from '@angular/core';
import { NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms'; import { FormCheckboxControl } from '@angular/forms/signals';
import { PiBaseFormFieldComponent } from '../base-form-field.component';
/**
* 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({ @Component({
selector: 'pi-slide-toggle', selector: 'pi-slide-toggle',
templateUrl: './slide-toggle.component.html', templateUrl: './slide-toggle.component.html',
styleUrls: ['./slide-toggle.component.scss'], styleUrls: ['./slide-toggle.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: PiSlideToggleComponent,
multi: true,
},
],
imports: [ReactiveFormsModule],
}) })
export class PiSlideToggleComponent export class PiSlideToggleComponent implements FormCheckboxControl {
extends PiBaseFormFieldComponent @HostBinding('class') get classes(): string {
implements OnInit const isCheckedClass = this.checked() ? 'is-checked' : '';
{ const isDisabledClass = this.disabled() ? 'is-disabled' : '';
@HostBinding('class') override get classes(): string { return ['pi-slide-toggle', isCheckedClass, isDisabledClass].join(' ');
const isCheckedClass = this.isChecked ? 'is-checked' : '';
return ['pi-slide-toggle', isCheckedClass, this.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>(); 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> </ng-container>
<input <input
[formControl]="control" [value]="value()"
(input)="value.set($any($event.target).value)"
(focus)="onFocus()"
(blur)="onBlur()"
[placeholder]="placeholder()!" [placeholder]="placeholder()!"
matInput matInput
pi-autofocus pi-autofocus
[isAutofocusSelected]="isAutofocus()" [isAutofocusSelected]="isAutofocus()"
/> />
@if (control.errors) { @if (enableErrorMessage() && hasError()) {
<mat-error> <mat-error>
@for (text of control.errors['message']; track text) { @for (message of errorMessages(); track message) {
{{ text | translate }} {{ message | translate }}
} }
</mat-error> </mat-error>
} }

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ import {
HostBinding, HostBinding,
input, input,
} from '@angular/core'; } from '@angular/core';
import { NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
import { PiBaseFormFieldComponent } from '../base-form-field.component'; import { PiBaseFormFieldComponent } from '../base-form-field.component';
import { MatFormField, MatLabel, MatError } from '@angular/material/form-field'; import { MatFormField, MatLabel, MatError } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input'; import { MatInput } from '@angular/material/input';
@@ -17,26 +16,23 @@ import { TranslatePipe } from '@ngx-translate/core';
selector: 'pi-textarea', selector: 'pi-textarea',
templateUrl: './textarea.component.html', templateUrl: './textarea.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: PiTextareaComponent,
multi: true,
},
],
imports: [ imports: [
MatFormField, MatFormField,
MatLabel, MatLabel,
MatInput, MatInput,
CdkTextareaAutosize, CdkTextareaAutosize,
ReactiveFormsModule,
PiAutofocusDirective, PiAutofocusDirective,
MatError, MatError,
TranslatePipe, TranslatePipe,
], ],
}) })
export class PiTextareaComponent extends PiBaseFormFieldComponent { export class PiTextareaComponent extends PiBaseFormFieldComponent<string> {
@HostBinding('class') className = 'pi-form-field__textarea'; @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 // Angular
import { Directive, HostBinding, input, viewChild } from '@angular/core'; import { Directive, HostBinding, input, output } from '@angular/core';
import { FormControlDirective } from '@angular/forms';
// Models // Models
import { unit } from '../../util/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() @Directive()
export abstract class PiBaseSliderComponent { export abstract class PiBaseSliderComponent {
readonly formControlDirective = viewChild(FormControlDirective);
@HostBinding('class') @HostBinding('class')
get classes(): string { get classes(): string {
const isDisabledClass = this.isDisabled() ? 'is-disabled' : ''; const isDisabledClass = this.disabled() ? 'is-disabled' : '';
const flexDirection = this.flexDirection(); const flexDirection = this.flexDirection();
const flexDirectionClass = const flexDirectionClass =
flexDirection !== 'column' && flexDirection !== 'column' &&
@@ -30,28 +33,11 @@ export abstract class PiBaseSliderComponent {
// Skin // Skin
readonly flexDirection = input<'column' | 'column-reverse'>('column'); readonly flexDirection = input<'column' | 'column-reverse'>('column');
// Behaviour // Contrato FormUiControl
readonly isDisabled = input<boolean>(); readonly disabled = input(false);
readonly touch = output<void>();
formatLabel = (value: number) => { formatLabel = (value: number) => {
return `${value} ${this.unit()}`; 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
<mat-slider
[min]="minValue()" [min]="minValue()"
[max]="maxValue()" [max]="maxValue()"
[discrete]="thumbLabel()" [discrete]="thumbLabel()"
[displayWith]="formatLabel" [displayWith]="formatLabel"
[step]="step()" [step]="step()"
[disabled]="isDisabled()" [disabled]="disabled()"
> >
<input formControlName="from" matSliderStartThumb /> <input
<input formControlName="to" matSliderEndThumb /> matSliderStartThumb
</mat-slider> [value]="value().from"
</form> (valueChange)="setFrom($event)"
(blur)="touch.emit()"
/>
<input
matSliderEndThumb
[value]="value().to"
(valueChange)="setTo($event)"
(blur)="touch.emit()"
/>
</mat-slider>

View File

@@ -3,58 +3,40 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
HostBinding, HostBinding,
OnInit,
forwardRef,
input, input,
model,
} from '@angular/core'; } from '@angular/core';
import { import { FormValueControl, ValidationError } from '@angular/forms/signals';
FormGroup,
FormGroupDirective,
NG_VALUE_ACCESSOR,
ReactiveFormsModule,
} from '@angular/forms';
// Component // Component
import { PiBaseSliderComponent } from '../base-slider.component'; import { PiBaseSliderComponent } from '../base-slider.component';
import { PiRange } from '../../form-field/entities/range.model';
import { MatSlider, MatSliderRangeThumb } from '@angular/material/slider'; 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({ @Component({
selector: 'pi-range-slider', selector: 'pi-range-slider',
templateUrl: './range-slider.component.html', templateUrl: './range-slider.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
providers: [ imports: [MatSlider, MatSliderRangeThumb],
{
provide: NG_VALUE_ACCESSOR,
useExisting: PiRangeSliderComponent,
multi: true,
},
],
imports: [
ReactiveFormsModule,
MatSlider,
MatSliderRangeThumb,
forwardRef(() => PiRangeSliderComponent),
],
}) })
export class PiRangeSliderComponent export class PiRangeSliderComponent
extends PiBaseSliderComponent extends PiBaseSliderComponent
implements OnInit implements FormValueControl<PiRange>
{ {
@HostBinding('class') className = 'pi-slider__range'; @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) { protected setFrom(from: number): void {
super(); this.value.set({ ...this.value(), from });
} }
get form() { protected setTo(to: number): void {
return this.rootFormGroup.control.get(this.formGroupName()) as FormGroup; this.value.set({ ...this.value(), to });
}
ngOnInit(): void {
if (this.isDisabled()) {
this.form.disable();
}
} }
} }

View File

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

View File

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