Saltar al contenido principal

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.

Cargando demo...
Ver código

Qué Hace, en Orden​

revealFirstInvalid(form, host, options?):

  1. Si form.valid y no hay ningún bloqueador, devuelve false de inmediato y no toca nada.
  2. En otro caso llama a form.markAllAsTouched(), de modo que cada mensaje tdnError con when="touched" se renderiza, y devuelve true en ese momento.
  3. 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 host el primer elemento que coincida con controlSelectors o blockerSelectors, en orden de documento.
  4. Hace scroll al centro del viewport del contenedor del campo (fieldSelector, por defecto tdn-form-element), enfoca el control con preventScroll y sacude el campo con la Web Animations API.
  5. Marca como dirty únicamente ese control, resuelto por su atributo formcontrolname sobre el formulario que pasaste.
Por qué solo un control queda dirty

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

Lee el DOM, no el árbol del formulario

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ámetroTipoDescripción
formAbstractControlEl formulario (o grupo) que se envía; los formcontrolname se resuelven sobre él
hostHTMLElementElemento donde buscar; normalmente el nativeElement del componente o el <form>
optionsRevealInvalidOptionsSelectores 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ónTipoDefaultDescripción
controlSelectorsstringTDN_INVALID_CONTROL_SELECTORSLista 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.
fieldSelectorstring'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.
fallbackSelectorstringningunoElemento 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.
blockerSelectorsstringningunoElementos 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.
blockerFocusSelectorstringningunoQué enfocar dentro de un bloqueador; si falta o no se encuentra, el propio bloqueador.
scrollbooleantrueDesplaza el objetivo al centro del viewport (instantáneo con prefers-reduced-motion).
shakebooleantrueSacude 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, select o textarea nativo, el foco aterriza en el propio control, así que un lector de pantalla anuncia su etiqueta y, mediante el aria-describedby que escribe tdnControl, su mensaje de error.
  • Un tdn-select inválido se empareja por su host, que no es enfocable (su superficie enfocable es el trigger interno con data-tdn-aria-target). Se desplaza, se sacude y se marca dirty, pero no recibe foco: un lector de pantalla no oye nada.
  • Con prefers-reduced-motion: reduce el scroll es instantáneo y no hay sacudida.

Cosas que Fallan sin Error​

Trampas
  • Controles anidados. El control a marcar dirty se busca por su atributo formcontrolname sobre el formulario raíz que pasaste. Uno dentro de formGroupName o formArrayName no se encuentra así: se revela y se enfoca, pero no se marca dirty, y un estilo que dependa de --dirty no se pinta.
  • Foco y dirty van al mismo elemento. Un control personalizado los reparte: su host lleva formcontrolname pero casi nunca es enfocable, y su elemento interno enfocable no lleva formcontrolname. Por eso tdn-select (emparejado por su host) se marca dirty sin recibir foco, y un selector apuntado a un elemento interno (tdn-input-pin.ng-invalid input) recibe foco pero no dirty, de modo que el borde de error no se pinta. tdn-input-pin, tdn-input-date y otros controles personalizados no están en los selectores por defecto: extiende TDN_INVALID_CONTROL_SELECTORS y 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 @if sin renderizar se omite. El método igualmente devuelve true (solo sabe que el formulario es inválido) y nada se mueve salvo que fallbackSelector coincida. Abre la sección en el mismo tick, antes de llamarlo.

Buenas Prácticas​

✅ Hacer​

  1. Llama a revealFirstInvalid como primera línea del manejador de envío y retorna si devuelve true.
  2. Deja el botón de envío habilitado: el servicio explica qué falta.
  3. Coloca un [tdnErrors] con mensajes when="touched" en cada campo: son los que el markAllAsTouched() del servicio hace aparecer.
  4. Abre las secciones colapsadas antes de llamarlo, en el mismo tick.

❌ Evitar​

  1. No uses [disabled]="form.invalid" en el botón de envío junto con este servicio: el usuario nunca llegaría a pulsarlo.
  2. No marques todos los campos como dirty para "mostrar los errores": pinta todo el formulario de rojo.

Ver También​