Saltar al contenido principal

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).

Sin exportAs

TdnControlDirective 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​

SignalTipoDescripción
focusWritableSignal<boolean>true cuando el elemento host tiene el foco
dirtyWritableSignal<boolean>true cuando el valor fue modificado por el usuario o el control se marcó con markAsDirty()
touchedWritableSignal<boolean>true cuando el control fue tocado: pérdida de foco, markAsTouched() o markAllAsTouched()
validWritableSignal<boolean>true cuando pasan todos los validadores síncronos y asíncronos
invalidWritableSignal<boolean>true cuando falla algún validador
pendingWritableSignal<boolean>true mientras corren validadores asíncronos
disabledWritableSignal<boolean>true cuando el control está deshabilitado
hasValueWritableSignal<boolean>true cuando el valor no está vacío (se evalúa con hasValue de @tiendana/utils)
errorBlockSignal<TdnErrorsDirective | null>Bloque [tdnErrors] que habla por este control, o null si el campo no tiene uno
describedBySignal<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.

ControlDónde se escriben los atributos
input, textarea, selectEn el propio elemento
Componente personalizadoEn el descendiente marcado con data-tdn-aria-target (la superficie que realmente recibe el foco)
Componente personalizado sin data-tdn-aria-targetEn 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​

  1. Usa tdnControl en todo campo dentro de un tdn-form-element: sin él, el wrapper no aplica sus clases y el control no queda descrito por su bloque de errores.

  2. No uses #control="tdnControl": la directiva no tiene exportAs. Usa viewChild(TdnControlDirective).

  3. Combina signals como invalid() && touched() para mostrar feedback solo tras la interacción.

  4. Describe a mano los componentes sin data-tdn-aria-target con el bloque exportado (#errors="tdnErrors", errors.elementId()).

  5. Para casos simples, basta el CSS: las clases de tdn-form-element suelen ser suficientes sin leer los signals.

Ver También​