refactor: migra los campos de formulario y sliders a Signal Forms
This commit is contained in:
@@ -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` |
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`;
|
||||||
}
|
}
|
||||||
|
|||||||
5
libs/ui/src/lib/ui/form-field/entities/range.model.ts
Normal file
5
libs/ui/src/lib/ui/form-field/entities/range.model.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(' ');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
<form [formGroup]="form">
|
|
||||||
<div>
|
<div>
|
||||||
<pi-label>
|
<pi-label>
|
||||||
<span>{{ 'common.min' | translate }}</span>
|
<span>{{ 'common.min' | translate }}</span>
|
||||||
@@ -7,7 +6,9 @@
|
|||||||
[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()"
|
||||||
>
|
>
|
||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
29
libs/ui/src/lib/ui/form-field/numeric-value.ts
Normal file
29
libs/ui/src/lib/ui/form-field/numeric-value.ts
Normal 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() ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
[value]="value().from"
|
||||||
|
(valueChange)="setFrom($event)"
|
||||||
|
(blur)="touch.emit()"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
matSliderEndThumb
|
||||||
|
[value]="value().to"
|
||||||
|
(valueChange)="setTo($event)"
|
||||||
|
(blur)="touch.emit()"
|
||||||
|
/>
|
||||||
</mat-slider>
|
</mat-slider>
|
||||||
</form>
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user