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
JavaScript
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