Directiva Angular standalone para navegación automática entre campos de entrada. Facilita la experiencia de usuario en formularios con campos secuenciales como códigos, números de teléfono, fechas y otros datos estructurados.
npm install @cbm-common/auto-tab-directivebash
npm install @cbm-common/auto-tab-directive
`
⚙️ Configuración
$3
La directiva es standalone, por lo que se puede importar directamente:
`typescript
import { CbmAutoTabDirective } from '@cbm-common/auto-tab-directive';
@Component({
selector: 'app-form-with-tabs',
standalone: true,
imports: [CbmAutoTabDirective],
template:
})
export class FormWithTabsComponent {}
`
🎯 Propiedades de Entrada
$3
Referencia al siguiente elemento HTML que debe recibir el foco cuando el input actual alcance su longitud máxima.
`typescript
// Usando template reference variables
// Usando ViewChild para acceso programático
@ViewChild('phoneInput2', { static: true }) phoneInput2!: ElementRef;
`
🏗️ Arquitectura de la Directiva
$3
La directiva sigue el patrón Attribute Directive de Angular:
`
CbmAutoTabDirective
├── Selector: [cbmAutoTab]
├── Input: HTMLElement (siguiente elemento)
├── HostListener: input event
├── Lógica de navegación automática
└── Navegación bidireccional
`
$3
`typescript
@Directive({
selector: '[cbmAutoTab]',
standalone: true,
})
export class CbmAutoTabDirective {
@Input('cbmAutoTab') nextInput!: HTMLElement;
@HostListener('input', ['$event'])
onInput(event: Event) {
// 1. Verificar si se alcanzó maxLength
// 2. Configurar listener para navegación inversa
// 3. Hacer foco en el siguiente input
}
}
`
$3
La directiva implementa navegación inteligente:
- Hacia adelante: Cuando se completa el campo actual
- Hacia atrás: Cuando se borra el contenido del campo siguiente
🎨 Características Principales
$3
- Detección automática de longitud máxima (maxLength)
- Transición suave entre campos
- Prevención de pérdida de foco inesperada
$3
- Retroceso automático al borrar contenido
- Mantenimiento del contexto de entrada
- Experiencia fluida de edición
$3
- Todos los tipos de input: text, number, tel, password
- Campos con máscara: Compatible con librerías de máscara
- Formularios reactivos: Funciona con FormControl
- Validaciones: No interfiere con validaciones existentes
🚀 Ejemplos de Uso
$3
`typescript
@Component({
selector: 'app-verification-code',
standalone: true,
imports: [CbmAutoTabDirective],
template:
,
styles: [
]
})
export class VerificationCodeComponent {
@ViewChild('digit1') digit1!: ElementRef;
@ViewChild('digit2') digit2!: ElementRef;
@ViewChild('digit3') digit3!: ElementRef;
@ViewChild('digit4') digit4!: ElementRef;
@ViewChild('digit5') digit5!: ElementRef;
@ViewChild('digit6') digit6!: ElementRef;
getCode(): string {
return [
this.digit1.nativeElement.value,
this.digit2.nativeElement.value,
this.digit3.nativeElement.value,
this.digit4.nativeElement.value,
this.digit5.nativeElement.value,
this.digit6.nativeElement.value
].join('');
}
verifyCode() {
const code = this.getCode();
if (code.length === 6) {
console.log('Verificando código:', code);
// Llamar a API de verificación
}
}
}
`
$3
`typescript
@Component({
selector: 'app-phone-input',
standalone: true,
imports: [CbmAutoTabDirective],
template:
,
styles: [
]
})
export class PhoneInputComponent {
@ViewChild('areaCode') areaCode!: ElementRef;
@ViewChild('central') central!: ElementRef;
@ViewChild('line') line!: ElementRef;
getFullNumber(): string {
const area = this.areaCode.nativeElement.value;
const central = this.central.nativeElement.value;
const line = this.line.nativeElement.value;
if (area && central && line) {
return ${area}-${central}-${line};
}
return '';
}
}
`
$3
`typescript
@Component({
selector: 'app-date-input',
standalone: true,
imports: [CbmAutoTabDirective],
template:
,
styles: [
]
})
export class DateInputComponent {
@ViewChild('day') dayInput!: ElementRef;
@ViewChild('month') monthInput!: ElementRef;
@ViewChild('year') yearInput!: ElementRef;
validateDay(event: Event) {
const input = event.target as HTMLInputElement;
const value = parseInt(input.value);
if (value < 1 || value > 31) {
input.value = '';
}
}
validateMonth(event: Event) {
const input = event.target as HTMLInputElement;
const value = parseInt(input.value);
if (value < 1 || value > 12) {
input.value = '';
}
}
validateYear(event: Event) {
const input = event.target as HTMLInputElement;
const value = parseInt(input.value);
const currentYear = new Date().getFullYear();
if (value < 1900 || value > currentYear) {
input.value = '';
}
}
getFormattedDate(): string {
const day = this.dayInput.nativeElement.value.padStart(2, '0');
const month = this.monthInput.nativeElement.value.padStart(2, '0');
const year = this.yearInput.nativeElement.value;
if (day && month && year) {
return ${day}/${month}/${year};
}
return '';
}
}
`
$3
`typescript
@Component({
selector: 'app-reactive-form',
standalone: true,
imports: [CbmAutoTabDirective, ReactiveFormsModule],
template:
})
export class ReactiveFormComponent implements OnInit {
verificationForm!: FormGroup;
constructor(private fb: FormBuilder) {}
ngOnInit() {
this.verificationForm = this.fb.group({
digit1: ['', [Validators.required, Validators.pattern(/^\d$/)]],
digit2: ['', [Validators.required, Validators.pattern(/^\d$/)]],
digit3: ['', [Validators.required, Validators.pattern(/^\d$/)]],
digit4: ['', [Validators.required, Validators.pattern(/^\d$/)]]
});
}
onSubmit() {
if (this.verificationForm.valid) {
const code = Object.values(this.verificationForm.value).join('');
console.log('Código verificado:', code);
}
}
}
`
$3
`typescript
@Component({
selector: 'app-credit-card',
standalone: true,
imports: [CbmAutoTabDirective],
template:
,
styles: [
]
})
export class CreditCardComponent {
@ViewChild('card1') card1!: ElementRef;
@ViewChild('card2') card2!: ElementRef;
@ViewChild('card3') card3!: ElementRef;
@ViewChild('card4') card4!: ElementRef;
@ViewChild('month') month!: ElementRef;
@ViewChild('year') year!: ElementRef;
getCardNumber(): string {
const parts = [
this.card1.nativeElement.value,
this.card2.nativeElement.value,
this.card3.nativeElement.value,
this.card4.nativeElement.value
].filter(part => part);
return parts.join(' ').padEnd(19, '•');
}
getExpiryDate(): string {
const month = this.month.nativeElement.value;
const year = this.year.nativeElement.value;
if (month && year) {
return ${month}/${year};
}
return 'MM/YY';
}
}
`
⚠️ Manejo de Errores y Validaciones
$3
La directiva no realiza validaciones por sí misma, pero se integra perfectamente con validaciones existentes:
`typescript
// Validación personalizada para números
onlyNumbersValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value;
if (value && !/^\d+$/.test(value)) {
return { onlyNumbers: true };
}
return null;
};
}
// Uso en formulario reactivo
this.verificationForm = this.fb.group({
digit1: ['', [Validators.required, this.onlyNumbersValidator()]]
});
`
$3
`typescript
// Prevenir navegación automática en ciertos casos
@HostListener('keydown', ['$event'])
onKeyDown(event: KeyboardEvent) {
// Prevenir tab automático con tecla Backspace
if (event.key === 'Backspace' && !this.currentInput.value) {
event.preventDefault();
this.previousInput.focus();
}
}
`
$3
- Campo vacío: La directiva no interviene
- Longitud incorrecta: Se maneja con validaciones de formulario
- Tipo de dato inválido: Compatible con validaciones personalizadas
🔧 Configuración Avanzada
$3
`typescript
// Extensión de la directiva para casos específicos
@Directive({
selector: '[cbmAutoTabCustom]',
standalone: true,
})
export class CbmAutoTabCustomDirective extends CbmAutoTabDirective {
@Input() autoFocusDelay = 0;
@Input() enableBackNavigation = true;
override onInput(event: Event) {
super.onInput(event);
// Lógica personalizada adicional
if (this.autoFocusDelay > 0) {
setTimeout(() => {
this.nextInput.focus();
}, this.autoFocusDelay);
}
}
}
`
$3
`typescript
// Compatible con ngx-mask o text-mask
@Component({
template:
})
export class MaskedInputComponent {
// La directiva funciona con campos enmascarados
// El foco se mueve cuando se completa la máscara
}
`
$3
`typescript
export class AutoTabService {
private tabGroups = new Map();
registerTabGroup(groupId: string, inputs: HTMLElement[]) {
this.tabGroups.set(groupId, inputs);
}
navigateToNext(groupId: string, currentInput: HTMLElement) {
const group = this.tabGroups.get(groupId);
if (group) {
const currentIndex = group.indexOf(currentInput);
if (currentIndex < group.length - 1) {
group[currentIndex + 1].focus();
}
}
}
}
`
📋 Dependencias
$3
`json
{
"@angular/common": ">=20.1.5",
"@angular/core": ">=20.1.5"
}
`
$3
`json
{
"tslib": "^2.3.0"
}
`
🛠️ Desarrollo
$3
`
auto-tab-directive/
├── src/
│ ├── lib/
│ │ ├── auto-tab.directive.ts # Directiva principal
│ │ ├── auto-tab.directive.spec.ts # Pruebas unitarias
│ │ └── index.ts # Exportaciones
│ └── public-api.ts # API pública
├── ng-package.json # Configuración empaquetado
├── package.json # Dependencias
└── README.md # Esta documentación
`
$3
`bash
Construir la librería
ng build auto-tab-directive
Construir en modo watch
ng build auto-tab-directive --watch
Construir para producción
ng build auto-tab-directive --configuration production
`
$3
`bash
Ejecutar pruebas unitarias
ng test auto-tab-directive
Ejecutar pruebas con coverage
ng test auto-tab-directive --code-coverage
Pruebas end-to-end
ng e2e auto-tab-directive
`
🎯 Mejores Prácticas
$3
`typescript
// Agrupar inputs relacionados visualmente
// Usar estilos consistentes
.grouped-input {
border-radius: 0;
border-right: none;
}
.grouped-input:first-child {
border-radius: 4px 0 0 4px;
}
.grouped-input:last-child {
border-radius: 0 4px 4px 0;
border-right: 1px solid #ddd;
}
`
$3
`typescript
// Asegurar navegación por teclado
@HostListener('keydown', ['$event'])
onKeyDown(event: KeyboardEvent) {
switch (event.key) {
case 'ArrowRight':
this.navigateNext();
break;
case 'ArrowLeft':
this.navigatePrevious();
break;
case 'Enter':
this.submitForm();
break;
}
}
// Soporte para lectores de pantalla
[attr.aria-label]="'Dígito ' + (index + 1) + ' del código'"
[attr.aria-describedby]="'code-description'">
`
$3
`typescript
// Debounce para eventos de input
private inputDebounceTimer?: number;
@HostListener('input', ['$event'])
onInput(event: Event) {
clearTimeout(this.inputDebounceTimer);
this.inputDebounceTimer = window.setTimeout(() => {
this.handleAutoTab(event);
}, 100);
}
// Cleanup en ngOnDestroy
ngOnDestroy() {
if (this.inputDebounceTimer) {
clearTimeout(this.inputDebounceTimer);
}
}
`
$3
`typescript
// Para formularios con lógica condicional
export class ConditionalAutoTabDirective extends CbmAutoTabDirective {
@Input() condition: () => boolean = () => true;
override onInput(event: Event) {
if (this.condition()) {
super.onInput(event);
}
}
}
// Uso
[condition]="() => formValid && !loading">
`
🤝 Contribución
1. Fork el repositorio
2. Crea una rama para tu feature (git checkout -b feature/nueva-funcionalidad)
3. Commit tus cambios (git commit -am 'Agrega nueva funcionalidad')
4. Push a la rama (git push origin feature/nueva-funcionalidad`)