tdnControl
Paquete: @tiendana/ngx-core/forms
La directiva tdnControl (TdnControlDirective) es el puente entre el NgControl de un campo y su contenedor TdnFormElement. Mantiene el estado del control como signals y, dentro de un tdn-form-element, conecta el campo con su bloque de errores para exponer aria-describedby y aria-invalid.
No tiene inputs: se lee desde el wrapper, no desde el template.
Características
✅ Signals de estado: focus, dirty, touched, valid, invalid, pending, disabled y hasValue
✅ Sigue el stream events del control: markAllAsTouched() y markAsDirty() repintan el campo al instante
✅ Cableado ARIA automático con el bloque [tdnErrors] del mismo control
✅ Funciona sobre input, textarea, select y sobre componentes personalizados con data-tdn-aria-target
Selector
[tdnControl]
Requiere un NgControl en el mismo elemento (formControlName, formControl o ngModel).
exportAsTdnControlDirective no declara exportAs, así que #control="tdnControl" falla con el error NG0301 (export not found). Para leer sus signals desde un componente usa viewChild(TdnControlDirective); para reaccionar al estado desde CSS usa las clases de TdnFormElement. La única directiva del módulo con exportAs es [tdnErrors] (#errors="tdnErrors").
API
Signals
| Signal | Tipo | Descripción |
|---|---|---|
focus | WritableSignal<boolean> | true cuando el elemento host tiene el foco |
dirty | WritableSignal<boolean> | true cuando el valor fue modificado por el usuario o el control se marcó con markAsDirty() |
touched | WritableSignal<boolean> | true cuando el control fue tocado: pérdida de foco, markAsTouched() o markAllAsTouched() |
valid | WritableSignal<boolean> | true cuando pasan todos los validadores síncronos y asíncronos |
invalid | WritableSignal<boolean> | true cuando falla algún validador |
pending | WritableSignal<boolean> | true mientras corren validadores asíncronos |
disabled | WritableSignal<boolean> | true cuando el control está deshabilitado |
hasValue | WritableSignal<boolean> | true cuando el valor no está vacío (se evalúa con hasValue de @tiendana/utils) |
errorBlock | Signal<TdnErrorsDirective | null> | Bloque [tdnErrors] que habla por este control, o null si el campo no tiene uno |
describedBy | Signal<string | null> | Id del bloque de errores mientras uno de sus mensajes está visible; null en otro caso |
Los métodos públicos onFocus(), onBlur(), onChangeValue() y onChangeState() son la plomería interna que sincroniza los signals; no los llames desde tu código.
Cómo se mantiene el estado
El estado se actualiza con cada evento del stream events del control, no con valueChanges y statusChanges. Estos dos últimos no emiten con markAsTouched() ni markAsDirty(), que es justo como un formulario revela sus errores al enviar. Por eso markAllAsTouched() o markAsDirty() repintan el campo de inmediato, sin esperar a la siguiente pulsación.
Accesibilidad (ARIA)
Dentro de un tdn-form-element, tdnControl busca el bloque [tdnErrors] que sigue esta misma instancia de control (no el mismo nombre, así que varios controles en un campo, o controles con el mismo nombre en distintas ramas @if, tienen cada uno su bloque). Mientras uno de los mensajes de ese bloque está en pantalla escribe:
aria-describedby="<id del bloque>"aria-invalid="true"
Cuando ningún mensaje se muestra, quita ambos atributos: el campo se anuncia inválido exactamente cuando el usuario puede ver el motivo.
| Control | Dónde se escriben los atributos |
|---|---|
input, textarea, select | En el propio elemento |
| Componente personalizado | En el descendiente marcado con data-tdn-aria-target (la superficie que realmente recibe el foco) |
Componente personalizado sin data-tdn-aria-target | En ninguna parte: hay que describirlo a mano |
tdn-select marca su trigger con data-tdn-aria-target, por lo que recibe el cableado automático. tdn-input-pin no, así que se describe con el bloque exportado:
<tdn-form-element>
<tdn-input-pin tdnControl formControlName="code" [codeLength]="6" label="Código de verificación"
[describedBy]="errors.showing() ? errors.elementId() : ''" [invalid]="errors.showing()" />
<div [tdnErrors]="'code'" #errors="tdnErrors">
<span tdnError="required" when="touched">Escribe el código</span>
<span tdnError="rejected">El código no es válido</span>
</div>
</tdn-form-element>
Para tu propio componente, añade data-tdn-aria-target a su elemento enfocable y tdnControl hará el resto.
Uso Básico
La forma habitual es envolver el campo en un tdn-form-element y dejar que las clases CSS reaccionen al estado:
<tdn-form-element>
<label for="email">Email</label>
<input id="email" type="email" formControlName="email" tdnControl />
<div [tdnErrors]="'email'">
<span tdnError="required" when="touched">El correo es obligatorio</span>
<span tdnError="email" when="touched">El correo no es válido</span>
</div>
</tdn-form-element>
Las clases .tdn-form-element--focus, --active, --dirty, --touched, --valid, --invalid, --pending y --disabled salen de los signals de este tdnControl. El wrapper sigue el primer tdnControl que contiene.
Leer los Signals desde un Componente
Cuando necesitas el estado en la lógica del template (un botón de limpiar, un indicador de carga), obtén la directiva con viewChild:
import { Component, viewChild } from '@angular/core';
import { ReactiveFormsModule, FormControl } from '@angular/forms';
import { TdnControlDirective } from '@tiendana/ngx-core/forms';
@Component({
selector: 'app-search-input',
imports: [ReactiveFormsModule, TdnControlDirective],
template: `
<div class="input-wrapper">
<input
type="text"
[formControl]="searchControl"
tdnControl
placeholder="Buscar..."
>
@if (control()?.hasValue()) {
<button type="button" (click)="searchControl.reset()">Limpiar</button>
}
@if (control()?.pending()) {
<span>Buscando...</span>
}
</div>
`
})
export class SearchInputComponent {
readonly control = viewChild(TdnControlDirective);
readonly searchControl = new FormControl('');
}
Feedback durante una validación asíncrona
Muestra un indicador mientras corre un validador asíncrono
import { Component, inject, viewChild } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators, AsyncValidatorFn, AbstractControl } from '@angular/forms';
import { TdnControlDirective } from '@tiendana/ngx-core/forms';
import { of, delay, map } from 'rxjs';
function usernameAvailableValidator(): AsyncValidatorFn {
return (control: AbstractControl) =>
of(control.value).pipe(
delay(1000),
map(value => (value === 'admin' ? { unavailable: true } : null))
);
}
@Component({
selector: 'app-username-field',
imports: [ReactiveFormsModule, TdnControlDirective],
template: `
<form [formGroup]="form">
<input type="text" formControlName="username" tdnControl placeholder="Nombre de usuario">
@if (control()?.pending()) {
<span>Verificando...</span>
} @else if (control()?.valid() && control()?.dirty()) {
<span>Disponible</span>
} @else if (control()?.invalid() && control()?.touched()) {
<span>No disponible</span>
}
</form>
`
})
export class UsernameFieldComponent {
private readonly fb = inject(FormBuilder);
readonly control = viewChild(TdnControlDirective);
readonly form = this.fb.group({
username: ['', {
validators: [Validators.required, Validators.minLength(3)],
asyncValidators: [usernameAvailableValidator()]
}]
});
}
Buenas Prácticas
-
Usa
tdnControlen todo campo dentro de untdn-form-element: sin él, el wrapper no aplica sus clases y el control no queda descrito por su bloque de errores. -
No uses
#control="tdnControl": la directiva no tieneexportAs. UsaviewChild(TdnControlDirective). -
Combina signals como
invalid() && touched()para mostrar feedback solo tras la interacción. -
Describe a mano los componentes sin
data-tdn-aria-targetcon el bloque exportado (#errors="tdnErrors",errors.elementId()). -
Para casos simples, basta el CSS: las clases de
tdn-form-elementsuelen ser suficientes sin leer los signals.
Ver También
TdnFormElement: el contenedor que aplica las clases de estado- Manejo de Errores:
[tdnErrors]ytdnError FormRevealService: revela el primer error al enviar