FormRevealService
Paquete: @tiendana/ngx-core/forms
FormRevealService muestra los errores de validación cuando el usuario pulsa enviar, en lugar de deshabilitar el botón. Un botón apagado esconde por qué el usuario está atascado y perjudica la conversión. El servicio marca el formulario como tocado (para que se rendericen los mensajes tdnErrors), lleva el primer control inválido al centro de la pantalla, le da el foco y sacude su campo. Disponible desde @tiendana/ngx-core 0.8.8.
Características
✅ Una sola llamada como primera línea del manejador de envío
✅ Devuelve true cuando hay que detener el envío y false cuando nada lo bloquea
✅ Scroll al centro del viewport, foco con preventScroll y sacudida de 380 ms del campo
✅ Respeta prefers-reduced-motion: scroll instantáneo y sin sacudida
✅ Marca como dirty solo el control revelado, que es lo que pinta el borde de error
✅ Extensible a controles personalizados y a bloqueos que no son controles (una subida pendiente)
Importación
import {
FormRevealService,
RevealInvalidOptions,
TDN_INVALID_CONTROL_SELECTORS
} from '@tiendana/ngx-core/forms';
FormRevealService es providedIn: 'root': solo hay que inyectarlo.
Uso Básico
import { Component, ElementRef, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import {
FormRevealService,
TdnControlDirective,
TdnErrorDirective,
TdnErrorsDirective,
TdnFormElement
} from '@tiendana/ngx-core/forms';
@Component({
selector: 'app-product-form',
imports: [ReactiveFormsModule, TdnFormElement, TdnControlDirective, TdnErrorsDirective, TdnErrorDirective],
template: `
<form [formGroup]="form" (ngSubmit)="save()">
<tdn-form-element>
<label for="name">Nombre</label>
<input id="name" formControlName="name" tdnControl />
<div [tdnErrors]="'name'">
<span tdnError="required" when="touched">El nombre es obligatorio</span>
</div>
</tdn-form-element>
<!-- El botón queda siempre habilitado: el servicio explica qué falta -->
<button type="submit" class="bttn bttn--primary">Guardar</button>
</form>
`
})
export class ProductFormComponent {
private readonly reveal = inject(FormRevealService);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
readonly form = inject(FormBuilder).nonNullable.group({
name: ['', Validators.required],
price: [0, [Validators.required, Validators.min(1)]]
});
save(): void {
if (this.reveal.revealFirstInvalid(this.form, this.host.nativeElement)) return;
// El formulario es válido: enviar
}
}
Demo
El botón de la demo permanece habilitado; al enviar con el formulario inválido se revela el primer campo con error.
Ver código
Qué Hace, en Orden
revealFirstInvalid(form, host, options?):
- Si
form.validy no hay ningún bloqueador, devuelvefalsede inmediato y no toca nada. - En otro caso llama a
form.markAllAsTouched(), de modo que cada mensajetdnErrorconwhen="touched"se renderiza, y devuelvetrueen ese momento. - Después, en el siguiente macrotask (para que el estado tocado y cualquier sección que hayas expandido en el mismo tick ya estén pintados) busca dentro de
hostel primer elemento que coincida concontrolSelectorsoblockerSelectors, en orden de documento. - Hace scroll al centro del viewport del contenedor del campo (
fieldSelector, por defectotdn-form-element), enfoca el control conpreventScrolly sacude el campo con la Web Animations API. - Marca como
dirtyúnicamente ese control, resuelto por su atributoformcontrolnamesobre el formulario que pasaste.
dirtyEl sistema de diseño pinta el borde de error con tdn-form-element--invalid y tdn-form-element--dirty a la vez. Marcar todos los campos como dirty pintaría todo el formulario de rojo, que dice "todo está mal" en vez de "empieza aquí", y dirty es además lo que leen los guards de cambios sin guardar.
El servicio busca las clases ng-invalid en el DOM en lugar de recorrer el formulario, así que el primer inválido es el primero en pantalla. Y anima con la Web Animations API para funcionar a través de la encapsulación de estilos de los componentes.
API
revealFirstInvalid
revealFirstInvalid(
form: AbstractControl,
host: HTMLElement,
options: RevealInvalidOptions = {}
): boolean
| Parámetro | Tipo | Descripción |
|---|---|---|
form | AbstractControl | El formulario (o grupo) que se envía; los formcontrolname se resuelven sobre él |
host | HTMLElement | Elemento donde buscar; normalmente el nativeElement del componente o el <form> |
options | RevealInvalidOptions | Selectores e interruptores de movimiento |
Retorna true si el formulario es inválido o hay un bloqueador (detén el envío), false si nada bloquea (envía).
focusFirstInvalid
focusFirstInvalid(host: HTMLElement, options: RevealInvalidOptions = {}): HTMLElement | null
Revela de forma síncrona (scroll, foco y sacudida) el primer control inválido o bloqueador que ya esté en host, sin tocar el estado del formulario. Es para quien ya marcó el formulario como tocado y renderizado, o valida algo que no es un formulario reactivo.
Retorna el elemento enfocado, para que marques como dirty ese control, y solo ese. Devuelve null si nada coincidió o si solo coincidió fallbackSelector.
Tipos y Tokens
RevealInvalidOptions
Todos los campos son opcionales.
| Opción | Tipo | Default | Descripción |
|---|---|---|---|
controlSelectors | string | TDN_INVALID_CONTROL_SELECTORS | Lista de selectores CSS de los controles inválidos concretos (no de sus envoltorios de grupo, que Angular también marca ng-invalid). El elemento coincidente es el que recibe el foco y el que aporta el formcontrolname a marcar dirty. |
fieldSelector | string | 'tdn-form-element' | Envoltorio (con closest()) que se desplaza y se sacude para que etiqueta, input y error se muevan juntos. Si ninguno coincide, se mueve el propio control. |
fallbackSelector | string | ninguno | Elemento a revelar cuando no coincide ningún control ni bloqueador (un valor fijado con clic y sin input enfocable). Solo se desplaza y se sacude, nunca se enfoca. |
blockerSelectors | string | ninguno | Elementos que bloquean el envío sin ser controles (una subida pendiente, un acuerdo sin aceptar). Su sola presencia en host hace que revealFirstInvalid revele, incluso con un formulario válido. |
blockerFocusSelector | string | ninguno | Qué enfocar dentro de un bloqueador; si falta o no se encuentra, el propio bloqueador. |
scroll | boolean | true | Desplaza el objetivo al centro del viewport (instantáneo con prefers-reduced-motion). |
shake | boolean | true | Sacude el objetivo (380 ms, horizontal) para que un usuario distraído lo note aun sin scroll. Siempre se omite con prefers-reduced-motion. |
Interfaz RevealInvalidOptions
export interface RevealInvalidOptions {
controlSelectors?: string;
fieldSelector?: string;
fallbackSelector?: string;
blockerSelectors?: string;
blockerFocusSelector?: string;
scroll?: boolean;
shake?: boolean;
}
TDN_INVALID_CONTROL_SELECTORS
Selectores por defecto de controlSelectors: las hojas concretas que Angular marca ng-invalid, sin los envoltorios de grupo.
export const TDN_INVALID_CONTROL_SELECTORS =
'input.ng-invalid, select.ng-invalid, textarea.ng-invalid, tdn-select.ng-invalid';
Se exporta para que una aplicación que añade sus propias etiquetas de control extienda la lista en vez de reescribirla:
const selectors = TDN_INVALID_CONTROL_SELECTORS + ', my-control.ng-invalid';
Controles Personalizados y Bloqueadores
const options: RevealInvalidOptions = {
// Revelar también un PIN incompleto y enfocar su primera casilla. La casilla no lleva
// formcontrolname: el PIN se desplaza, se enfoca y se sacude, pero no se marca dirty.
controlSelectors: TDN_INVALID_CONTROL_SELECTORS + ', tdn-input-pin.ng-invalid input',
// Una subida pendiente bloquea el envío aunque el formulario sea válido.
blockerSelectors: '[data-blocks-submit]',
blockerFocusSelector: 'button',
};
if (this.reveal.revealFirstInvalid(this.form, this.host.nativeElement, options)) return;
Accesibilidad
- Con un
input,selectotextareanativo, el foco aterriza en el propio control, así que un lector de pantalla anuncia su etiqueta y, mediante elaria-describedbyque escribetdnControl, su mensaje de error. - Un
tdn-selectinválido se empareja por su host, que no es enfocable (su superficie enfocable es el trigger interno condata-tdn-aria-target). Se desplaza, se sacude y se marcadirty, pero no recibe foco: un lector de pantalla no oye nada. - Con
prefers-reduced-motion: reduceel scroll es instantáneo y no hay sacudida.
Cosas que Fallan sin Error
- Controles anidados. El control a marcar
dirtyse busca por su atributoformcontrolnamesobre el formulario raíz que pasaste. Uno dentro deformGroupNameoformArrayNameno se encuentra así: se revela y se enfoca, pero no se marcadirty, y un estilo que dependa de--dirtyno se pinta. - Foco y
dirtyvan al mismo elemento. Un control personalizado los reparte: su host llevaformcontrolnamepero casi nunca es enfocable, y su elemento interno enfocable no llevaformcontrolname. Por esotdn-select(emparejado por su host) se marcadirtysin recibir foco, y un selector apuntado a un elemento interno (tdn-input-pin.ng-invalid input) recibe foco pero nodirty, de modo que el borde de error no se pinta.tdn-input-pin,tdn-input-datey otros controles personalizados no están en los selectores por defecto: extiendeTDN_INVALID_CONTROL_SELECTORSy decide qué mitad importa más para ese control. - Solo lo que está en el DOM. Un control inválido dentro de una pestaña cerrada o de una rama
@ifsin renderizar se omite. El método igualmente devuelvetrue(solo sabe que el formulario es inválido) y nada se mueve salvo quefallbackSelectorcoincida. Abre la sección en el mismo tick, antes de llamarlo.
Buenas Prácticas
✅ Hacer
- Llama a
revealFirstInvalidcomo primera línea del manejador de envío y retorna si devuelvetrue. - Deja el botón de envío habilitado: el servicio explica qué falta.
- Coloca un
[tdnErrors]con mensajeswhen="touched"en cada campo: son los que elmarkAllAsTouched()del servicio hace aparecer. - Abre las secciones colapsadas antes de llamarlo, en el mismo tick.
❌ Evitar
- No uses
[disabled]="form.invalid"en el botón de envío junto con este servicio: el usuario nunca llegaría a pulsarlo. - No marques todos los campos como
dirtypara "mostrar los errores": pinta todo el formulario de rojo.
Ver También
- Manejo de Errores:
[tdnErrors]ytdnError tdnControl: cableado ARIA de los camposTdnFormElement: el envoltorio que se desplaza y se sacude- Vista General de Forms