UNPKG

@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
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 };