@hauzhengyi/vue3-otp-input
Version:
A fully customizable, OTP (one-time password) input component built with Vue 3.x and Vue Composition API.
427 lines (418 loc) • 13.6 kB
JavaScript
import { defineComponent, ref, watch, onMounted, openBlock, createElementBlock, withDirectives, createElementVNode, normalizeClass, vModelDynamic, createCommentVNode, resolveComponent, Fragment, renderList, createBlock } from 'vue';
var script$1 = defineComponent({
name: "SingleOtpInput",
props: {
inputType: {
type: String,
validator: value => ["number", "tel", "letter-numeric", "password"].includes(value),
default: "tel"
},
inputmode: {
type: String,
default: "numeric"
},
value: {
type: [String, Number]
},
separator: {
type: String
},
focus: {
type: Boolean
},
inputClasses: {
type: [String, Array]
},
conditionalClass: {
type: String
},
shouldAutoFocus: {
type: Boolean
},
isLastChild: {
type: Boolean
},
placeholder: {
type: String
},
isDisabled: {
type: Boolean
}
},
emits: ["on-change", "on-keydown", "on-paste", "on-focus", "on-blur"],
setup(props, {
emit
}) {
const model = ref(props.value || "");
const input = ref(null);
const handleOnChange = () => {
model.value = model.value.toString();
if (model.value.length > 1) {
// model.value = model.value.slice(0, 1);
// for iOS Chrome paste event
return emit("on-paste", model.value);
}
return emit("on-change", model.value);
};
const isCodeLetter = charCode => charCode >= 65 && charCode <= 90;
const isCodeNumeric = charCode => charCode >= 48 && charCode <= 57 || charCode >= 96 && charCode <= 105;
// numeric keys and numpad keys
const handleOnKeyDown = event => {
if (props.isDisabled) {
event.preventDefault();
}
// Only allow characters 0-9, DEL, Backspace, Enter, Right and Left Arrows, and Pasting
const keyEvent = event || window.event;
const charCode = keyEvent.which ? keyEvent.which : keyEvent.keyCode;
if (isCodeNumeric(charCode) || props.inputType === "letter-numeric" && isCodeLetter(charCode) || [8, 9, 13, 37, 39, 46, 86].includes(charCode)) {
emit("on-keydown", event);
} else {
keyEvent.preventDefault();
}
};
const handleOnPaste = event => {
event.preventDefault();
const pastedData = event.clipboardData?.getData("text/plain");
emit("on-paste", pastedData);
};
const handleOnFocus = () => {
input.value.select();
return emit("on-focus");
};
const handleOnBlur = () => emit("on-blur");
watch(() => props.value, (newValue, oldValue) => {
if (newValue !== oldValue) {
model.value = newValue;
}
});
watch(() => props.focus, (newFocusValue, oldFocusValue) => {
// Check if focusedInput changed
// Prevent calling function if input already in focus
if (oldFocusValue !== newFocusValue && input.value && props.focus) {
input.value.focus();
input.value.select();
}
});
onMounted(() => {
if (input.value && props.focus && props.shouldAutoFocus) {
input.value.focus();
input.value.select();
}
});
return {
handleOnChange,
handleOnKeyDown,
handleOnPaste,
handleOnFocus,
handleOnBlur,
input,
model,
inputTypeValue: ["letter-numeric", "number"].includes(props.inputType) ? "text" : props.inputType
};
}
});
const _hoisted_1$1 = {
style: {
"display": "flex",
"align-items": "center"
}
};
const _hoisted_2$1 = ["type", "inputmode", "placeholder", "disabled", "maxlength"];
const _hoisted_3 = {
key: 0
};
const _hoisted_4 = ["innerHTML"];
function render$1(_ctx, _cache, $props, $setup, $data, $options) {
return openBlock(), createElementBlock("div", _hoisted_1$1, [withDirectives(createElementVNode("input", {
"data-test": "single-input",
type: _ctx.inputTypeValue,
inputmode: _ctx.inputmode,
placeholder: _ctx.placeholder,
disabled: _ctx.isDisabled,
ref: "input",
min: "0",
max: "9",
maxlength: _ctx.isLastChild ? '1' : '9',
pattern: "[0-9]",
"onUpdate:modelValue": _cache[0] || (_cache[0] = $event => _ctx.model = $event),
class: normalizeClass([_ctx.inputClasses, _ctx.conditionalClass, {
'is-complete': _ctx.model
}]),
onInput: _cache[1] || (_cache[1] = (...args) => _ctx.handleOnChange && _ctx.handleOnChange(...args)),
onKeydown: _cache[2] || (_cache[2] = (...args) => _ctx.handleOnKeyDown && _ctx.handleOnKeyDown(...args)),
onPaste: _cache[3] || (_cache[3] = (...args) => _ctx.handleOnPaste && _ctx.handleOnPaste(...args)),
onFocus: _cache[4] || (_cache[4] = (...args) => _ctx.handleOnFocus && _ctx.handleOnFocus(...args)),
onBlur: _cache[5] || (_cache[5] = (...args) => _ctx.handleOnBlur && _ctx.handleOnBlur(...args))
}, null, 42, _hoisted_2$1), [[vModelDynamic, _ctx.model]]), !_ctx.isLastChild && _ctx.separator ? (openBlock(), createElementBlock("span", _hoisted_3, [createElementVNode("span", {
innerHTML: _ctx.separator
}, null, 8, _hoisted_4)])) : createCommentVNode("", true)]);
}
script$1.render = render$1;
// keyCode constants
const BACKSPACE = 8;
const LEFT_ARROW = 37;
const RIGHT_ARROW = 39;
const DELETE = 46;
var script = /* #__PURE__ */defineComponent({
name: "Vue3OtpInput",
components: {
SingleOtpInput: script$1
},
props: {
value: {
type: String,
default: "",
required: true
},
numInputs: {
default: 4
},
separator: {
type: String,
default: ""
},
inputClasses: {
type: [String, Array]
},
conditionalClass: {
type: Array,
default: []
},
inputType: {
type: String,
validator: value => ["number", "tel", "letter-numeric", "password"].includes(value)
},
inputmode: {
type: String,
validator: value => ["numeric", "text", "tel", "none"].includes(value),
default: "text"
},
shouldAutoFocus: {
type: Boolean,
default: false
},
placeholder: {
type: Array,
default: []
},
isDisabled: {
type: Boolean,
default: false
},
shouldFocusOrder: {
type: Boolean,
default: false
}
},
setup(props, {
emit
}) {
const activeInput = ref(0);
const otp = ref([]);
const oldOtp = ref([]);
watch(() => props.value, val => {
// fix issue: https://github.com/ejirocodes/vue3-otp-input/issues/34
if (val.length === props.numInputs || otp.value.length === 0) {
const fill = val.split("");
otp.value = fill;
}
}, {
immediate: true
});
const handleOnFocus = index => {
activeInput.value = index;
};
const handleOnBlur = () => {
activeInput.value = -1;
};
// Helper to return OTP from input
const checkFilledAllInputs = () => {
if (otp.value.join("").length === props.numInputs) {
emit("update:value", otp.value.join(""));
return emit("on-complete", otp.value.join(""));
}
return "Wait until the user enters the required number of characters";
};
// Focus on input by index
const focusInput = input => {
activeInput.value = Math.max(Math.min(props.numInputs - 1, input), 0);
};
// Focus on next input
const focusNextInput = () => {
focusInput(activeInput.value + 1);
};
// Focus on previous input
const focusPrevInput = () => {
focusInput(activeInput.value - 1);
};
// Change OTP value at focused input
const changeCodeAtFocus = value => {
oldOtp.value = Object.assign([], otp.value);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
otp.value[activeInput.value] = value;
if (oldOtp.value.join("") !== otp.value.join("")) {
emit("update:value", otp.value.join(""));
emit("on-change", otp.value.join(""));
checkFilledAllInputs();
}
};
// Handle pasted OTP
const handleOnPaste = data => {
const pastedData = data.slice(0, props.numInputs - activeInput.value).split("");
if (props.inputType === "number" && !pastedData.join("").match(/^\d+$/)) {
return "Invalid pasted data";
}
if (props.inputType === "letter-numeric" && !pastedData.join("").match(/^\w+$/)) {
return "Invalid pasted data";
}
// Paste data from focused input onwards
const currentCharsInOtp = otp.value.slice(0, activeInput.value);
const combinedWithPastedData = currentCharsInOtp.concat(pastedData);
combinedWithPastedData.slice(0, props.numInputs).forEach(function (value, i) {
otp.value[i] = value;
});
focusInput(combinedWithPastedData.slice(0, props.numInputs).length);
return checkFilledAllInputs();
};
const handleOnChange = value => {
changeCodeAtFocus(value);
focusNextInput();
};
const clearInput = () => {
if (otp.value.length > 0) {
emit("update:value", "");
emit("on-change", "");
}
otp.value = [];
activeInput.value = 0;
};
const fillInput = value => {
const fill = value.split("");
if (fill.length === props.numInputs) {
otp.value = fill;
emit("update:value", otp.value.join(""));
emit("on-complete", otp.value.join(""));
}
};
// Handle cases of backspace, delete, left arrow, right arrow
const handleOnKeyDown = (event, index) => {
switch (event.keyCode) {
case BACKSPACE:
event.preventDefault();
changeCodeAtFocus("");
focusPrevInput();
break;
case DELETE:
event.preventDefault();
changeCodeAtFocus("");
break;
case LEFT_ARROW:
event.preventDefault();
focusPrevInput();
break;
case RIGHT_ARROW:
event.preventDefault();
focusNextInput();
break;
default:
focusOrder(index);
break;
}
};
/**
*
* @param currentIndex - index of the input
* @description - This function is used to focus the input in the order of the input index
*
* @example
* 1. If the user is entering the OTP in the order of the input index, then the input will be focused in the order of the input index
* 2. If the user is entering the OTP in the reverse order of the input index, then the input will be focused in the reverse order of the input index
*/
const focusOrder = currentIndex => {
if (props.shouldFocusOrder) {
setTimeout(() => {
const len = otp.value.join("").length;
if (currentIndex - len >= 0) {
activeInput.value = len;
otp.value[currentIndex] = "";
}
}, 100);
}
};
return {
activeInput,
otp,
oldOtp,
clearInput,
handleOnPaste,
handleOnKeyDown,
handleOnBlur,
changeCodeAtFocus,
focusInput,
focusNextInput,
focusPrevInput,
handleOnFocus,
checkFilledAllInputs,
handleOnChange,
fillInput
};
}
});
const _hoisted_1 = {
style: {
"display": "flex"
},
class: "otp-input-container"
};
const _hoisted_2 = {
key: 0,
autocomplete: "off",
name: "hidden",
type: "text",
style: {
"display": "none"
}
};
function render(_ctx, _cache, $props, $setup, $data, $options) {
const _component_SingleOtpInput = resolveComponent("SingleOtpInput");
return openBlock(), createElementBlock("div", _hoisted_1, [_ctx.inputType === 'password' ? (openBlock(), createElementBlock("input", _hoisted_2)) : createCommentVNode("", true), (openBlock(true), createElementBlock(Fragment, null, renderList(_ctx.numInputs, (_, i) => {
return openBlock(), createBlock(_component_SingleOtpInput, {
key: i,
focus: _ctx.activeInput === i,
value: _ctx.otp[i],
separator: _ctx.separator,
"input-type": _ctx.inputType,
inputmode: _ctx.inputmode,
"input-classes": _ctx.inputClasses,
conditionalClass: _ctx.conditionalClass[i],
"is-last-child": i === _ctx.numInputs - 1,
"should-auto-focus": _ctx.shouldAutoFocus,
placeholder: _ctx.placeholder[i],
"is-disabled": _ctx.isDisabled,
onOnChange: _ctx.handleOnChange,
onOnKeydown: $event => _ctx.handleOnKeyDown($event, i),
onOnPaste: _ctx.handleOnPaste,
onOnFocus: $event => _ctx.handleOnFocus(i),
onOnBlur: _ctx.handleOnBlur
}, null, 8, ["focus", "value", "separator", "input-type", "inputmode", "input-classes", "conditionalClass", "is-last-child", "should-auto-focus", "placeholder", "is-disabled", "onOnChange", "onOnKeydown", "onOnPaste", "onOnFocus", "onOnBlur"]);
}), 128))]);
}
script.render = render;
// Import vue component
// Default export is installable instance of component.
// IIFE injects install function into component, allowing component
// to be registered via Vue.use() as well as Vue.component(),
var entry_esm = /*#__PURE__*/(() => {
// Assign InstallableComponent type
const installable = script;
// Attach install function executed by Vue.use()
installable.install = app => {
app.component("Vue3OtpInput", installable);
};
return installable;
})();
// It's possible to expose named exports when writing components that can
// also be used as directives, etc. - eg. import { RollupDemoDirective } from 'rollup-demo';
// export const RollupDemoDirective = directive;
export { entry_esm as default };