@cbm-common/auto-tab-directive
v0.0.1
Published
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.
Readme
Auto Tab Directive
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.
📦 Instalación
npm install @cbm-common/auto-tab-directive⚙️ Configuración
Importación de la Directiva
La directiva es standalone, por lo que se puede importar directamente:
import { CbmAutoTabDirective } from '@cbm-common/auto-tab-directive';
@Component({
selector: 'app-form-with-tabs',
standalone: true,
imports: [CbmAutoTabDirective],
template: `
<div class="form-group">
<input #input1 type="text" maxlength="2" placeholder="Código 1">
<input #input2 type="text" maxlength="2" placeholder="Código 2"
[cbmAutoTab]="input1">
<input #input3 type="text" maxlength="2" placeholder="Código 3"
[cbmAutoTab]="input2">
</div>
`
})
export class FormWithTabsComponent {}🎯 Propiedades de Entrada
cbmAutoTab: HTMLElement (Requerido)
Referencia al siguiente elemento HTML que debe recibir el foco cuando el input actual alcance su longitud máxima.
// Usando template reference variables
<input #phone1 type="text" maxlength="3">
<input #phone2 type="text" maxlength="3" [cbmAutoTab]="phone1">
<input #phone3 type="text" maxlength="4" [cbmAutoTab]="phone2">
// Usando ViewChild para acceso programático
@ViewChild('phoneInput2', { static: true }) phoneInput2!: ElementRef;
<input #phoneInput2 type="text" maxlength="3" [cbmAutoTab]="phoneInput1">🏗️ Arquitectura de la Directiva
Patrón de Diseño
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 bidireccionalFuncionamiento Interno
@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
}
}Navegación Bidireccional
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
Navegación Automática
- Detección automática de longitud máxima (
maxLength) - Transición suave entre campos
- Prevención de pérdida de foco inesperada
Navegación Inversa
- Retroceso automático al borrar contenido
- Mantenimiento del contexto de entrada
- Experiencia fluida de edición
Compatibilidad
- 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
Ejemplo Básico: Código de Verificación
@Component({
selector: 'app-verification-code',
standalone: true,
imports: [CbmAutoTabDirective],
template: `
<div class="verification-code">
<h3>Ingresa el código de verificación</h3>
<div class="code-inputs">
<input #digit1
type="text"
maxlength="1"
placeholder="0"
class="digit-input">
<input #digit2
type="text"
maxlength="1"
placeholder="0"
class="digit-input"
[cbmAutoTab]="digit1">
<input #digit3
type="text"
maxlength="1"
placeholder="0"
class="digit-input"
[cbmAutoTab]="digit2">
<input #digit4
type="text"
maxlength="1"
placeholder="0"
class="digit-input"
[cbmAutoTab]="digit3">
<input #digit5
type="text"
maxlength="1"
placeholder="0"
class="digit-input"
[cbmAutoTab]="digit4">
<input #digit6
type="text"
maxlength="1"
placeholder="0"
class="digit-input"
[cbmAutoTab]="digit5">
</div>
<button type="button" (click)="verifyCode()">
Verificar Código
</button>
</div>
`,
styles: [`
.code-inputs {
display: flex;
gap: 8px;
justify-content: center;
margin: 20px 0;
}
.digit-input {
width: 40px;
height: 40px;
text-align: center;
font-size: 18px;
border: 2px solid #ddd;
border-radius: 4px;
transition: border-color 0.2s;
}
.digit-input:focus {
outline: none;
border-color: #007bff;
}
`]
})
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
}
}
}Ejemplo con Número de Teléfono
@Component({
selector: 'app-phone-input',
standalone: true,
imports: [CbmAutoTabDirective],
template: `
<div class="phone-input">
<label>Número de teléfono</label>
<div class="phone-fields">
<span class="prefix">(+57)</span>
<input #areaCode
type="text"
maxlength="3"
placeholder="601"
class="phone-field">
<input #central
type="text"
maxlength="3"
placeholder="123"
class="phone-field"
[cbmAutoTab]="areaCode">
<input #line
type="text"
maxlength="4"
placeholder="4567"
class="phone-field"
[cbmAutoTab]="central">
</div>
<div class="phone-display">
Número completo: (+57) {{ getFullNumber() }}
</div>
</div>
`,
styles: [`
.phone-fields {
display: flex;
align-items: center;
gap: 4px;
margin: 8px 0;
}
.prefix {
color: #666;
font-weight: 500;
}
.phone-field {
width: 60px;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
text-align: center;
}
.phone-display {
margin-top: 12px;
padding: 8px;
background: #f5f5f5;
border-radius: 4px;
font-family: monospace;
}
`]
})
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 '';
}
}Ejemplo con Fecha (DD/MM/YYYY)
@Component({
selector: 'app-date-input',
standalone: true,
imports: [CbmAutoTabDirective],
template: `
<div class="date-input">
<label>Fecha de nacimiento</label>
<div class="date-fields">
<input #day
type="text"
maxlength="2"
placeholder="DD"
class="date-field"
(input)="validateDay($event)">
<span class="separator">/</span>
<input #month
type="text"
maxlength="2"
placeholder="MM"
class="date-field"
[cbmAutoTab]="day"
(input)="validateMonth($event)">
<span class="separator">/</span>
<input #year
type="text"
maxlength="4"
placeholder="YYYY"
class="date-field"
[cbmAutoTab]="month"
(input)="validateYear($event)">
</div>
<div class="date-display">
Fecha: {{ getFormattedDate() }}
</div>
</div>
`,
styles: [`
.date-fields {
display: flex;
align-items: center;
gap: 2px;
margin: 8px 0;
}
.date-field {
width: 50px;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
text-align: center;
}
.separator {
color: #666;
font-weight: bold;
}
.date-display {
margin-top: 12px;
padding: 8px;
background: #f0f8ff;
border-radius: 4px;
}
`]
})
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 '';
}
}Ejemplo con Formulario Reactivo
@Component({
selector: 'app-reactive-form',
standalone: true,
imports: [CbmAutoTabDirective, ReactiveFormsModule],
template: `
<form [formGroup]="verificationForm" (ngSubmit)="onSubmit()">
<div class="form-group">
<label>Código de verificación SMS</label>
<div class="code-inputs">
<input #code1
type="text"
maxlength="1"
formControlName="digit1"
class="code-input">
<input #code2
type="text"
maxlength="1"
formControlName="digit2"
class="code-input"
[cbmAutoTab]="code1">
<input #code3
type="text"
maxlength="1"
formControlName="digit3"
class="code-input"
[cbmAutoTab]="code2">
<input #code4
type="text"
maxlength="1"
formControlName="digit4"
class="code-input"
[cbmAutoTab]="code3">
</div>
<div *ngIf="verificationForm.invalid && verificationForm.touched"
class="error-message">
Por favor ingresa un código válido
</div>
</div>
<button type="submit"
[disabled]="verificationForm.invalid"
class="submit-btn">
Verificar
</button>
</form>
`
})
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);
}
}
}Ejemplo con Componente de Tarjeta de Crédito
@Component({
selector: 'app-credit-card',
standalone: true,
imports: [CbmAutoTabDirective],
template: `
<div class="credit-card-form">
<h3>Información de Tarjeta</h3>
<div class="form-row">
<label>Número de tarjeta</label>
<div class="card-number-inputs">
<input #card1 type="text" maxlength="4" placeholder="1234" class="card-input">
<input #card2 type="text" maxlength="4" placeholder="5678" class="card-input" [cbmAutoTab]="card1">
<input #card3 type="text" maxlength="4" placeholder="9012" class="card-input" [cbmAutoTab]="card2">
<input #card4 type="text" maxlength="4" placeholder="3456" class="card-input" [cbmAutoTab]="card3">
</div>
</div>
<div class="form-row">
<label>Fecha de expiración</label>
<div class="expiry-inputs">
<input #month type="text" maxlength="2" placeholder="MM" class="expiry-input">
<span>/</span>
<input #year type="text" maxlength="2" placeholder="YY" class="expiry-input" [cbmAutoTab]="month">
</div>
</div>
<div class="form-row">
<label>Código CVV</label>
<input #cvv type="text" maxlength="3" placeholder="123" class="cvv-input">
</div>
<div class="card-preview">
<div class="card-number">{{ getCardNumber() }}</div>
<div class="card-expiry">{{ getExpiryDate() }}</div>
</div>
</div>
`,
styles: [`
.card-number-inputs, .expiry-inputs {
display: flex;
align-items: center;
gap: 8px;
}
.card-input {
width: 60px;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
text-align: center;
}
.expiry-input {
width: 40px;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
text-align: center;
}
.cvv-input {
width: 60px;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.card-preview {
margin-top: 20px;
padding: 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 8px;
font-family: monospace;
}
`]
})
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
Validaciones de Entrada
La directiva no realiza validaciones por sí misma, pero se integra perfectamente con validaciones existentes:
// 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()]]
});Manejo de Eventos
// 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();
}
}Estados de Error
- 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
Personalización del Comportamiento
// 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);
}
}
}Integración con Librerías de Máscara
// Compatible con ngx-mask o text-mask
@Component({
template: `
<input #maskedInput
type="text"
mask="000-000-0000"
[cbmAutoTab]="nextInput">
`
})
export class MaskedInputComponent {
// La directiva funciona con campos enmascarados
// El foco se mueve cuando se completa la máscara
}Configuración Programática
export class AutoTabService {
private tabGroups = new Map<string, HTMLElement[]>();
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
Peer Dependencies (Requeridas)
{
"@angular/common": ">=20.1.5",
"@angular/core": ">=20.1.5"
}Dependencias Internas
{
"tslib": "^2.3.0"
}🛠️ Desarrollo
Estructura del Proyecto
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ónConstrucción
# 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 productionPruebas
# 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
1. Diseño de UX
// Agrupar inputs relacionados visualmente
<div class="input-group">
<input class="grouped-input" [cbmAutoTab]="nextInput">
<input class="grouped-input" [cbmAutoTab]="nextInput">
</div>
// 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;
}2. Accesibilidad
// 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
<input
[attr.aria-label]="'Dígito ' + (index + 1) + ' del código'"
[attr.aria-describedby]="'code-description'">3. Optimización de Rendimiento
// 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);
}
}4. Manejo de Estados Complejos
// 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
<input [cbmAutoTab]="nextInput"
[condition]="() => formValid && !loading">🤝 Contribución
- Fork el repositorio
- Crea una rama para tu feature (
git checkout -b feature/nueva-funcionalidad) - Commit tus cambios (
git commit -am 'Agrega nueva funcionalidad') - Push a la rama (
git push origin feature/nueva-funcionalidad) - Abre un PullRequest
📄 Licencia
Este proyecto está bajo la Licencia MIT - ver el archivo LICENSE para más detalles.
📞 Soporte
Para soporte técnico o reportes de bugs, contacta al equipo de desarrollo de CBM:
- 📧 Email: [email protected]
- 💬 Slack: #desarrollo-cbm
- 📋 Issues: GitHub Issues
🔄 Changelog
v0.0.1
- ✅ Directiva standalone para navegación automática entre inputs
- ✅ Navegación bidireccional (adelante/atrás)
- ✅ Detección automática de longitud máxima
- ✅ Compatibilidad con formularios reactivos
- ✅ Integración con validaciones existentes
- ✅ Soporte para accesibilidad y navegación por teclado
- ✅ Documentación completa en español
Nota: Esta directiva mejora significativamente la experiencia de usuario en formularios con campos secuenciales, especialmente útil para códigos de verificación, números de teléfono, fechas y números de tarjetas de crédito.
