UNPKG

otp-angular

Version:

**Otp Angular** is a lightweight, highly customizable, and dependency-free OTP (One-Time Password) input component built for Angular 20+ applications. It offers seamless integration, flexible configuration, and a polished user experience for OTP or verifi

350 lines (342 loc) 21.9 kB
import * as i0 from '@angular/core'; import { inject, HostListener, Directive, ElementRef, DOCUMENT, Input, model, signal, output, computed, linkedSignal, effect, untracked, afterNextRender, forwardRef, Component } from '@angular/core'; import * as i1 from '@angular/forms'; import { NgControl, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; class AlphaNumeric { control = inject(NgControl); onInputChange(event) { const input = event.target; const cleanValue = input.value.replace(/[^a-zA-Z0-9]/g, ''); if (input.value !== cleanValue) { input.value = cleanValue; this.control.control?.setValue(cleanValue); // Keep form state in sync } } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.3", ngImport: i0, type: AlphaNumeric, deps: [], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.0.3", type: AlphaNumeric, isStandalone: true, selector: "[AlphaNumeric]", host: { listeners: { "input": "onInputChange($event)" } }, ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.3", ngImport: i0, type: AlphaNumeric, decorators: [{ type: Directive, args: [{ selector: '[AlphaNumeric]' }] }], propDecorators: { onInputChange: [{ type: HostListener, args: ['input', ['$event']] }] } }); class DefaultOptions { length = 4; numbersOnly = false; autoFocus = false; isPassword = false; showError = false; showCaps = false; containerClass = ''; containerStyles = {}; inputClass = ''; inputStyles = {}; placeholder = ''; separator = ''; resend = 0; resendLabel = 'RESEND VERIFICATION CODE'; resendContainerClass = ''; resendLabelClass = ''; resendTimerClass = ''; theme = 'light-theme'; constructor(data) { if (!data) return; Object.keys(data).forEach((key) => { typeof (data[key] !== undefined) && typeof (data[key] !== null) && (this[key] = data[key]); }); } } class Disabled { otpDisabled = false; el = inject(ElementRef); document = inject(DOCUMENT); constructor() { this.document.addEventListener("mousemove", (e) => { this.otpDisabledInit(this.otpDisabled); }); } otpDisabledInit(otpDisabled) { if (this.el.nativeElement.nodeName !== 'INPUT') { if (otpDisabled) { this.el.nativeElement?.classList.add('disabled'); this.el.nativeElement?.setAttribute('disabled', true); this.el.nativeElement.style.pointerEvents = 'none'; this.el.nativeElement.setAttribute('disabled', true); } else { this.el.nativeElement?.classList.remove('disabled'); this.el.nativeElement.style.pointerEvents = 'auto'; this.el.nativeElement?.removeAttribute('disabled'); this.el.nativeElement.removeAttribute('disabled'); } } else { if (otpDisabled) { this.el.nativeElement.setAttribute('readonly', true); this.el.nativeElement.setAttribute('disabled', true); } else { this.el.nativeElement.removeAttribute('readonly'); this.el.nativeElement.removeAttribute('disabled'); } } } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.3", ngImport: i0, type: Disabled, deps: [], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.0.3", type: Disabled, isStandalone: true, selector: "[otpDisabled]", inputs: { otpDisabled: "otpDisabled" }, ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.3", ngImport: i0, type: Disabled, decorators: [{ type: Directive, args: [{ selector: '[otpDisabled]' }] }], ctorParameters: () => [], propDecorators: { otpDisabled: [{ type: Input, args: ['otpDisabled'] }] } }); class OtpAngular { document = inject(DOCUMENT); //START: user can call this variables config = model.required(); disabled = signal(false); onInputChange = output(); onResendAvailable = output(); //END: user can call this variables defaultConfig = computed(() => { return { ...new DefaultOptions(), ...this.config() }; }); myValue = signal([]); inputStyleArray = computed(() => { const _config = this.config(); if (Array.isArray(_config?.inputStyles)) { return _config?.inputStyles; } else { return [{ ..._config?.inputStyles }]; } }); // Resend countdown = linkedSignal(() => { return Number(this.defaultConfig()?.resend); }); isResendAllowed = signal(false); timer; // FORMS-CONTROL & REACTIVE FORMS value = ''; onChange = () => { }; onTouched = () => { }; constructor() { effect(() => { const _countdown = this.countdown(); if (_countdown <= 0) { clearInterval(this.timer); this.isResendAllowed.set(true); } }); effect(() => { const _isResendAllowed = this.isResendAllowed(); if (!_isResendAllowed) { untracked(() => { this.timer = setInterval(() => { this.countdown.update(value => value - 1); }, 1000); }); } }); afterNextRender(() => { const _config = this.defaultConfig(); if (_config.autoFocus) { const _firstID = this.document.getElementById('otp-input-1'); _firstID.focus(); } }); } //START: user can call this functions setValue(value) { this.myValue.set(value.toString().split('')); const _config = this.defaultConfig(); const _output = _config.numbersOnly ? Number(value) : value.toString().trim(); if (_output.toString().trim() === '') { this.updateValue(null); } else { this.updateValue(_output); } } reset() { const _config = this.defaultConfig(); if (_config && _config.resend) { this.countdown.set(_config.resend); this.isResendAllowed.set(false); } } //END: user can call this functions keydown(e, index) { const _config = this.defaultConfig(); const prev = this.document.getElementById(`otp-input-${index}`); const current = this.document.getElementById(`otp-input-${index + 1}`); const next = this.document.getElementById(`otp-input-${index + 2}`); if (e.key === 'Backspace') { if (current.value.trim() === '') { index !== 0 && prev.focus(), setTimeout(() => prev.select(), 0); } else { current.value = ''; } return; } if (e.key === 'ArrowLeft' && index !== 0) { prev.focus(); setTimeout(() => prev.select(), 0); return; } if (e.key === 'ArrowRight' && index !== _config.length - 1) { next.focus(); setTimeout(() => next.select(), 0); return; } const input = e.target; if (e.key.length === 1) { const isAlphanumeric = /^[a-zA-Z0-9]*$/.test(e.key); isAlphanumeric && (input.value = ''); } } onPaste(e, index) { e.preventDefault(); const _config = this.defaultConfig(); const pasted = this.sanitize((e.clipboardData ?? window.clipboardData).getData('text'), _config.numbersOnly ? 'numeric' : 'text'); if (!pasted) { return; } if (pasted.length >= _config.length) { Array.from(pasted.slice(0, _config.length)).forEach((ch, i) => { const char = _config.showCaps ? ch.toUpperCase() : ch; this.setBoxValue(i, char); }); const last = this.document.getElementById(`otp-input-${Math.min(pasted.length, _config.length)}`); last?.focus(); } else { Array.from(pasted.slice(0, _config.length)).forEach((ch, i) => { const char = _config.showCaps ? ch.toUpperCase() : ch; this.setBoxValue(index + i, char); }); const last = this.document.getElementById(`otp-input-${Math.min(pasted.length + index, _config.length)}`); last?.focus(); } this.emit(); } setBoxValue(i, ch) { const el = this.document.getElementById(`otp-input-${i + 1}`); if (el) { el.value = ch; } } sanitize(raw, type) { switch (type) { case 'numeric': return raw.replace(/[^0-9]/g, ''); case 'text': return raw.replace(/[^A-Za-z0-9]/g, ''); } } onInput(e, index) { const _config = this.defaultConfig(); const input = e.target; // keep only allowed chars, 1 char max input.value = this.sanitize(input.value, _config.numbersOnly ? 'numeric' : 'text').slice(0, 1); if (_config.showCaps) { input.value = input.value.toUpperCase(); } if (!input.value) { return; } // move focus const next = this.document.getElementById(`otp-input-${index + 2}`); if (next) { next.focus(); } else { input.blur(); } this.emit(); } blur(e, index) { const _config = this.defaultConfig(); if (_config.showError) { const input = e.target; if (input.value.trim() === '') { input.classList.add('error'); } else { input.classList.remove('error'); } } } emit() { const _config = this.defaultConfig(); let output = ''; for (let index = 0; index < _config.length; index++) { const input = this.document.getElementById(`otp-input-${index + 1}`); output += input.value; } const _output = _config.numbersOnly ? Number(output) : output.trim(); if (_output.toString().trim() === '') { this.updateValue(null); } else { this.updateValue(_output); } } // Resend resend() { this.onResendAvailable.emit(true); } // FORMS-CONTROL & REACTIVE FORMS writeValue(obj) { this.value = obj; } registerOnChange(fn) { this.onChange = fn; } registerOnTouched(fn) { this.onTouched = fn; } // Call this method whenever the input changes updateValue(newValue) { this.value = newValue; this.onChange(this.value); this.onInputChange.emit(this.value); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.3", ngImport: i0, type: OtpAngular, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.0.3", type: OtpAngular, isStandalone: true, selector: "otp-angular", inputs: { config: { classPropertyName: "config", publicName: "config", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { config: "configChange", onInputChange: "onInputChange", onResendAvailable: "onResendAvailable" }, providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => OtpAngular), multi: true } ], ngImport: i0, template: "@let _config = defaultConfig();\n@let _theme = _config.theme;\n@let _myValue = myValue();\n@let _disabled = disabled();\n\n@if(_config) {\n@let _containerClass = _config.containerClass;\n@let containerClass = typeof _containerClass === 'string' ? _containerClass.split(' ')[0] : _containerClass.join(' ');\n@let _inputClass = _config.inputClass;\n@let inputClass = typeof _inputClass === 'string' ? _inputClass.split(' ')[0] : _inputClass;\n@let _containerStyles = _config.containerStyles;\n\n@let _inputStyles = inputStyleArray();\n\n@let _resend = _config.resend;\n@let _resendLabel = _config.resendLabel;\n@let _resendContainerClass = _config.resendContainerClass;\n@let _resendLabelClass = _config.resendLabelClass;\n@let _resendTimerClass = _config.resendTimerClass; \n\n<div class=\"__otpContainer {{_theme}} {{containerClass}}\" [style]=\"_containerStyles\">\n @for (item of [].constructor(_config.length); track $index) {\n <div class=\"otp-input-wrapper\">\n <input [id]=\"`otp-input-${$index+1}`\" [max]=\"1\" AlphaNumeric autocomplete=\"off\" name=\"dummy\"\n [placeholder]=\"_config.placeholder\"\n [otpDisabled]=\"_disabled\" [class]=\"inputClass[$index]\" [style]=\"_inputStyles[$index]\"\n [attr.inputmode]=\"_config.numbersOnly ? 'numeric' : null\" [attr.pattern]=\"_config.numbersOnly ? '[0-9]*' : null\"\n [type]=\"_config.isPassword ? `password` : `text`\"\n (keydown)=\"keydown($event, $index)\" (blur)=\"blur($event, $index)\" (input)=\"onInput($event, $index)\"\n (paste)=\"onPaste($event, $index)\"\n [(ngModel)]=\"_myValue[$index]\" />\n @if (_config.separator && $index < _config.length - 1) {\n <span class=\"otp-separator\">{{_config.separator.toString().split('')[0]}}</span>\n }\n </div>\n }\n @if(_resend && _resend > 0) {\n <div class=\"otp-resend {{_resendContainerClass}}\">\n @if (isResendAllowed()) {\n <div aria-label=\"resend\" type=\"button\" class=\"resendTxt {{_resendLabelClass}}\" (click)=\"resend()\">{{_resendLabel}}</div>\n } @else {\n <div class=\"resendTxt {{_resendTimerClass}}\" >{{_resendLabel}} IN {{countdown()}}s</div>\n }\n </div>\n }\n</div>\n}", styles: [".__otpContainer{display:flex;justify-content:center;gap:10px;margin:20px 0;flex-wrap:wrap}.__otpContainer .otp-input-wrapper{display:flex;align-items:center;gap:6px}.__otpContainer .otp-input-wrapper input{width:40px;height:50px;text-align:center;font-size:20px;border:2px solid #ccc;border-radius:8px;transition:border-color .2s ease,box-shadow .2s ease;outline:none}.__otpContainer .otp-input-wrapper input.error{border-color:#e74c3c;background-color:#ffe6e6;color:#e74c3c;animation:shake .2s ease-in-out 0s 2}.__otpContainer .otp-input-wrapper input::placeholder{color:#bbb;font-size:18px}.__otpContainer .otp-input-wrapper input:focus{border-color:#f60;box-shadow:0 0 5px #ff660080}.__otpContainer .otp-input-wrapper .otp-separator{font-size:24px;font-weight:700;color:#666;margin:0 2px;display:inline-block;vertical-align:middle}.__otpContainer .otp-resend{display:flex;justify-content:center;margin-top:16px;width:100%;font-size:14px;text-align:center}.__otpContainer .otp-resend .resendTxt{text-transform:uppercase;cursor:pointer;color:#f60;font-weight:500}.__otpContainer .otp-resend .resendTxt:hover{color:#f60;text-decoration:underline}.__otpContainer .otp-resend .resendTxt:active{opacity:.7}.__otpContainer .otp-resend .resendTxt:not([aria-label]){cursor:default;color:#999;font-weight:400;text-decoration:none}.__otpContainer .otp-resend .resendTxt:not([aria-label]):hover,.__otpContainer .otp-resend .resendTxt:not([aria-label]):active{opacity:1}@media screen and (max-width: 400px){.__otpContainer .otp-input-wrapper input{width:36px;height:45px;font-size:18px}}@keyframes shake{0%{transform:translate(0)}25%{transform:translate(-5px)}50%{transform:translate(5px)}75%{transform:translate(-5px)}to{transform:translate(0)}}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: AlphaNumeric, selector: "[AlphaNumeric]" }, { kind: "directive", type: Disabled, selector: "[otpDisabled]", inputs: ["otpDisabled"] }] }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.3", ngImport: i0, type: OtpAngular, decorators: [{ type: Component, args: [{ selector: 'otp-angular', imports: [FormsModule, AlphaNumeric, Disabled], providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => OtpAngular), multi: true } ], template: "@let _config = defaultConfig();\n@let _theme = _config.theme;\n@let _myValue = myValue();\n@let _disabled = disabled();\n\n@if(_config) {\n@let _containerClass = _config.containerClass;\n@let containerClass = typeof _containerClass === 'string' ? _containerClass.split(' ')[0] : _containerClass.join(' ');\n@let _inputClass = _config.inputClass;\n@let inputClass = typeof _inputClass === 'string' ? _inputClass.split(' ')[0] : _inputClass;\n@let _containerStyles = _config.containerStyles;\n\n@let _inputStyles = inputStyleArray();\n\n@let _resend = _config.resend;\n@let _resendLabel = _config.resendLabel;\n@let _resendContainerClass = _config.resendContainerClass;\n@let _resendLabelClass = _config.resendLabelClass;\n@let _resendTimerClass = _config.resendTimerClass; \n\n<div class=\"__otpContainer {{_theme}} {{containerClass}}\" [style]=\"_containerStyles\">\n @for (item of [].constructor(_config.length); track $index) {\n <div class=\"otp-input-wrapper\">\n <input [id]=\"`otp-input-${$index+1}`\" [max]=\"1\" AlphaNumeric autocomplete=\"off\" name=\"dummy\"\n [placeholder]=\"_config.placeholder\"\n [otpDisabled]=\"_disabled\" [class]=\"inputClass[$index]\" [style]=\"_inputStyles[$index]\"\n [attr.inputmode]=\"_config.numbersOnly ? 'numeric' : null\" [attr.pattern]=\"_config.numbersOnly ? '[0-9]*' : null\"\n [type]=\"_config.isPassword ? `password` : `text`\"\n (keydown)=\"keydown($event, $index)\" (blur)=\"blur($event, $index)\" (input)=\"onInput($event, $index)\"\n (paste)=\"onPaste($event, $index)\"\n [(ngModel)]=\"_myValue[$index]\" />\n @if (_config.separator && $index < _config.length - 1) {\n <span class=\"otp-separator\">{{_config.separator.toString().split('')[0]}}</span>\n }\n </div>\n }\n @if(_resend && _resend > 0) {\n <div class=\"otp-resend {{_resendContainerClass}}\">\n @if (isResendAllowed()) {\n <div aria-label=\"resend\" type=\"button\" class=\"resendTxt {{_resendLabelClass}}\" (click)=\"resend()\">{{_resendLabel}}</div>\n } @else {\n <div class=\"resendTxt {{_resendTimerClass}}\" >{{_resendLabel}} IN {{countdown()}}s</div>\n }\n </div>\n }\n</div>\n}", styles: [".__otpContainer{display:flex;justify-content:center;gap:10px;margin:20px 0;flex-wrap:wrap}.__otpContainer .otp-input-wrapper{display:flex;align-items:center;gap:6px}.__otpContainer .otp-input-wrapper input{width:40px;height:50px;text-align:center;font-size:20px;border:2px solid #ccc;border-radius:8px;transition:border-color .2s ease,box-shadow .2s ease;outline:none}.__otpContainer .otp-input-wrapper input.error{border-color:#e74c3c;background-color:#ffe6e6;color:#e74c3c;animation:shake .2s ease-in-out 0s 2}.__otpContainer .otp-input-wrapper input::placeholder{color:#bbb;font-size:18px}.__otpContainer .otp-input-wrapper input:focus{border-color:#f60;box-shadow:0 0 5px #ff660080}.__otpContainer .otp-input-wrapper .otp-separator{font-size:24px;font-weight:700;color:#666;margin:0 2px;display:inline-block;vertical-align:middle}.__otpContainer .otp-resend{display:flex;justify-content:center;margin-top:16px;width:100%;font-size:14px;text-align:center}.__otpContainer .otp-resend .resendTxt{text-transform:uppercase;cursor:pointer;color:#f60;font-weight:500}.__otpContainer .otp-resend .resendTxt:hover{color:#f60;text-decoration:underline}.__otpContainer .otp-resend .resendTxt:active{opacity:.7}.__otpContainer .otp-resend .resendTxt:not([aria-label]){cursor:default;color:#999;font-weight:400;text-decoration:none}.__otpContainer .otp-resend .resendTxt:not([aria-label]):hover,.__otpContainer .otp-resend .resendTxt:not([aria-label]):active{opacity:1}@media screen and (max-width: 400px){.__otpContainer .otp-input-wrapper input{width:36px;height:45px;font-size:18px}}@keyframes shake{0%{transform:translate(0)}25%{transform:translate(-5px)}50%{transform:translate(5px)}75%{transform:translate(-5px)}to{transform:translate(0)}}\n"] }] }], ctorParameters: () => [] }); /* * Public API Surface of otp-angular */ /** * Generated bundle index. Do not edit. */ export { OtpAngular }; //# sourceMappingURL=otp-angular.mjs.map