UNPKG

vue-input-otp

Version:

https://github.com/wobsoriano/vue-input-otp/assets/13049130/c5080f41-f411-4d38-aa57-d04d90c832c3

528 lines (519 loc) 18.7 kB
import { Fragment, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, defineComponent, h, inject, mergeModels, mergeProps, normalizeClass, normalizeStyle, onMounted, onUnmounted, openBlock, provide, ref, renderSlot, shallowRef, unref, useModel, watch, watchEffect } from "vue"; import { defaultDocument, defaultWindow, reactiveOmit, useEventListener, usePrevious } from "@vueuse/core"; import { useForwardProps } from "reka-ui"; //#region src/NoSciptCssFallback.ts const NOSCRIPT_CSS_FALLBACK = ` [data-input-otp] { --nojs-bg: white !important; --nojs-fg: black !important; background-color: var(--nojs-bg) !important; color: var(--nojs-fg) !important; caret-color: var(--nojs-fg) !important; letter-spacing: .25em !important; text-align: center !important; border: 1px solid var(--nojs-fg) !important; border-radius: 4px !important; width: 100% !important; } @media (prefers-color-scheme: dark) { [data-input-otp] { --nojs-bg: black !important; --nojs-fg: white !important; } }`; const NoSciptCssFallback = defineComponent({ props: { fallback: { type: String, required: true } }, setup(props) { return () => h("noscript", { innerHTML: `<style>${props.fallback}</style>` }); } }); //#endregion //#region src/symbols.ts const PublicVueOTPContextKey = Symbol("vue-otp-context"); //#endregion //#region src/sync-timeouts.ts function syncTimeouts(cb) { return [ setTimeout(cb, 0), setTimeout(cb, 10), setTimeout(cb, 50) ]; } //#endregion //#region src/use-pwm-badge.ts const PWM_BADGE_MARGIN_RIGHT = 18; const PWM_BADGE_SPACE_WIDTH_PX = 40; const PWM_BADGE_SPACE_WIDTH = `${PWM_BADGE_SPACE_WIDTH_PX}px`; const PASSWORD_MANAGERS_SELECTORS = [ "[data-lastpass-icon-root]", "com-1password-button", "[data-dashlanecreated]", "[style$=\"2147483647 !important;\"]" ].join(","); function usePasswordManagerBadge({ containerRef, inputRef, pushPasswordManagerStrategy, isFocused }) { const pwmMetadata = ref({ done: false, refocused: false }); const hasPWMBadge = ref(false); const hasPWMBadgeSpace = ref(false); const done = ref(false); const willPushPWMBadge = computed(() => { if (pushPasswordManagerStrategy === "none") return false; return (pushPasswordManagerStrategy === "increase-width" || pushPasswordManagerStrategy === "experimental-no-flickering") && hasPWMBadge.value && hasPWMBadgeSpace.value; }); const trackPWMBadge = () => { const container = containerRef.value; const input = inputRef.value; if (!container || !input || done.value || pushPasswordManagerStrategy === "none") return; const elementToCompare = container; const rightCornerX = elementToCompare.getBoundingClientRect().left + elementToCompare.offsetWidth; const centereredY = elementToCompare.getBoundingClientRect().top + elementToCompare.offsetHeight / 2; const x = rightCornerX - PWM_BADGE_MARGIN_RIGHT; const y = centereredY; if (document.querySelectorAll(PASSWORD_MANAGERS_SELECTORS).length === 0) { if (document.elementFromPoint(x, y) === container) return; } hasPWMBadge.value = true; done.value = true; if (!pwmMetadata.value.refocused && document.activeElement === input) { const sel = [input.selectionStart, input.selectionEnd]; input.blur(); input.focus(); input.setSelectionRange(sel[0], sel[1]); pwmMetadata.value.refocused = true; } }; const checkHasSpace = () => { const container = containerRef.value; if (!container || pushPasswordManagerStrategy === "none") return; hasPWMBadgeSpace.value = window.innerWidth - container.getBoundingClientRect().right >= PWM_BADGE_SPACE_WIDTH_PX; }; let spaceInterval; onMounted(() => { checkHasSpace(); spaceInterval = setInterval(checkHasSpace, 1e3); }); onUnmounted(() => { clearInterval(spaceInterval); }); watch([isFocused, inputRef], (newValues, _, onInvalidate) => { const [newIsFocused, newInputRef] = newValues; const _isFocused = newIsFocused || document.activeElement === newInputRef; if (pushPasswordManagerStrategy === "none" || !_isFocused) return; const t1 = setTimeout(trackPWMBadge, 0); const t2 = setTimeout(trackPWMBadge, 2e3); const t3 = setTimeout(trackPWMBadge, 5e3); const t4 = setTimeout(() => { done.value = true; }, 6e3); onInvalidate(() => { clearTimeout(t1); clearTimeout(t2); clearTimeout(t3); clearTimeout(t4); }); }); return { hasPWMBadge, willPushPWMBadge, PWM_BADGE_SPACE_WIDTH }; } //#endregion //#region src/OTPInput.vue const _hoisted_1 = { style: { "position": "absolute", "inset": "0", "pointer-events": "none" } }; const _hoisted_2 = [ "value", "data-input-otp-placeholder-shown", "data-input-otp-mss", "data-input-otp-mse", "aria-placeholder", "pattern" ]; const _sfc_main = /* @__PURE__ */ defineComponent({ name: "OTPInput", inheritAttrs: false, __name: "OTPInput", props: /* @__PURE__ */ mergeModels({ maxlength: {}, textAlign: { default: "left" }, inputmode: { default: "numeric" }, containerClass: {}, pushPasswordManagerStrategy: { default: "increase-width" }, noScriptCssFallback: { default: NOSCRIPT_CSS_FALLBACK }, defaultValue: { default: "" }, pasteTransformer: {}, accept: {}, alt: {}, autocomplete: { default: "one-time-code" }, autofocus: { type: Boolean }, capture: { type: [Boolean, String] }, checked: { type: [ Boolean, Array, Set ] }, crossorigin: {}, disabled: { type: Boolean }, enterKeyHint: {}, form: {}, formaction: {}, formenctype: {}, formmethod: {}, formnovalidate: { type: Boolean }, formtarget: {}, height: {}, indeterminate: { type: Boolean }, list: {}, max: {}, min: {}, minlength: {}, multiple: { type: Boolean }, name: {}, pattern: {}, placeholder: {}, readonly: { type: Boolean }, required: { type: Boolean }, size: {}, src: {}, step: {}, type: {}, value: {}, width: {} }, { "modelValue": { default(props) { return props.defaultValue; } }, "modelModifiers": {} }), emits: /* @__PURE__ */ mergeModels([ "complete", "change", "select", "input", "focus", "blur", "mouseover", "mouseleave", "paste" ], ["update:modelValue"]), setup(__props, { expose: __expose, emit: __emit }) { const props = __props; const emit = __emit; const [internalValue] = useModel(__props, "modelValue"); const previousValue = usePrevious(internalValue); const regexp = computed(() => props.pattern ? typeof props.pattern === "string" ? new RegExp(props.pattern) : props.pattern : null); const isHoveringInput = shallowRef(false); const isFocused = shallowRef(false); const mirrorSelectionStart = shallowRef(null); const mirrorSelectionEnd = shallowRef(null); const inputRef = shallowRef(null); const containerRef = shallowRef(null); const isIOS = defaultWindow?.CSS?.supports?.("-webkit-touch-callout", "none"); let inputMetadataRef = { prev: [ inputRef.value?.selectionStart, inputRef.value?.selectionEnd, inputRef.value?.selectionDirection ] }; function safeInsertRule(sheet, rule) { try { sheet.insertRule(rule); } catch { console.error("input-otp could not insert CSS rule:", rule); } } onMounted(() => { const input = inputRef.value; const container = containerRef.value; if (!input || !container) return; inputMetadataRef.prev = [ input.selectionStart, input.selectionEnd, input.selectionDirection ?? "none" ]; const removeSelectionchangeListener = useEventListener(defaultDocument, "selectionchange", onDocumentSelectionChange, { capture: true }); function onDocumentSelectionChange() { if (!input) return; if (defaultDocument?.activeElement !== input) { mirrorSelectionStart.value = null; mirrorSelectionEnd.value = null; return; } const _s = input.selectionStart; const _e = input.selectionEnd; const _dir = input.selectionDirection; const _ml = input.maxLength; const _val = input.value; const _prev = inputMetadataRef.prev; let start = -1; let end = -1; let direction = void 0; if (_val.length !== 0 && _s !== null && _e !== null) { const isSingleCaret = _s === _e; const isInsertMode = _s === _val.length && _val.length < _ml; if (isSingleCaret && !isInsertMode) { const c = _s; if (c === 0) { start = 0; end = 1; direction = "forward"; } else if (c === _ml) { start = c - 1; end = c; direction = "backward"; } else if (_ml > 1 && _val.length > 1) { let offset = 0; if (_prev[0] !== null && _prev[1] !== null) { direction = c < _prev[1] ? "backward" : "forward"; const wasPreviouslyInserting = _prev[0] === _prev[1] && _prev[0] < _ml; if (direction === "backward" && !wasPreviouslyInserting) offset = -1; } start = offset + c; end = offset + c + 1; } } if (start !== -1 && end !== -1 && start !== end) input.setSelectionRange(start, end, direction); } const s = start !== -1 ? start : _s; const e = end !== -1 ? end : _e; const dir = direction ?? _dir; mirrorSelectionStart.value = s; mirrorSelectionEnd.value = e; inputMetadataRef.prev = [ s, e, dir ]; } onDocumentSelectionChange(); if (defaultDocument?.activeElement === input) isFocused.value = true; if (!defaultDocument?.getElementById("input-otp-style")) { const styleEl = defaultDocument?.createElement("style"); styleEl.id = "input-otp-style"; defaultDocument?.head.appendChild(styleEl); if (styleEl.sheet) { const autofillStyles = "background: transparent !important; color: transparent !important; border-color: transparent !important; opacity: 0 !important; box-shadow: none !important; -webkit-box-shadow: none !important; -webkit-text-fill-color: transparent !important;"; safeInsertRule(styleEl.sheet, "[data-input-otp]::selection { background: transparent !important; color: transparent !important; }"); safeInsertRule(styleEl.sheet, `[data-input-otp]:autofill { ${autofillStyles} }`); safeInsertRule(styleEl.sheet, `[data-input-otp]:-webkit-autofill { ${autofillStyles} }`); safeInsertRule(styleEl.sheet, `@supports (-webkit-touch-callout: none) { [data-input-otp] { letter-spacing: -.6em !important; font-weight: 100 !important; font-stretch: ultra-condensed; font-optical-sizing: none !important; left: -1px !important; right: 1px !important; } }`); safeInsertRule(styleEl.sheet, `[data-input-otp] + * { pointer-events: all !important; }`); } } const updateRootHeight = () => { if (container) container.style.setProperty("--root-height", `${input.clientHeight}px`); }; updateRootHeight(); const resizeObserver = new ResizeObserver(updateRootHeight); resizeObserver.observe(input); onUnmounted(() => { removeSelectionchangeListener(); resizeObserver.disconnect(); }); }); watch([internalValue], () => { syncTimeouts(() => { if (!inputRef.value) return; inputRef.value?.dispatchEvent(new Event("input")); const s = inputRef.value?.selectionStart; const e = inputRef.value?.selectionEnd; const dir = inputRef.value?.selectionDirection; if (s !== null && e !== null) { mirrorSelectionStart.value = s ?? null; mirrorSelectionEnd.value = e ?? null; inputMetadataRef.prev = [ s, e, dir ]; } }); }, { immediate: true }); watchEffect(() => { if (previousValue.value === void 0) return; if (internalValue.value !== previousValue.value && previousValue.value.length < props.maxlength && internalValue.value.length === props.maxlength) emit("complete", internalValue.value); }); const pwmb = usePasswordManagerBadge({ containerRef, inputRef, pushPasswordManagerStrategy: props.pushPasswordManagerStrategy, isFocused }); function _beforeInputListener(e) { if (e.inputType === "insertText" && e.data !== null) { const target = e.currentTarget; const start = target.selectionStart ?? 0; const end = target.selectionEnd ?? 0; const currentValue = target.value; const newValue = (start !== end ? currentValue.slice(0, start) + e.data + currentValue.slice(end) : currentValue.slice(0, start) + e.data + currentValue.slice(start)).slice(0, props.maxlength); if (newValue.length > 0 && regexp.value && !regexp.value.test(newValue)) e.preventDefault(); } } function _inputListener(e) { const newValue = e.currentTarget.value.slice(0, props.maxlength); if (newValue.length > 0 && regexp.value && !regexp.value.test(newValue)) { e.preventDefault(); return; } if (typeof previousValue.value === "string" && newValue.length < previousValue.value.length) defaultDocument?.dispatchEvent(new Event("selectionchange")); internalValue.value = newValue; emit("input", newValue); } function _focusListener() { const input = inputRef.value; if (input) { const start = Math.min(input.value.length, props.maxlength - 1); const end = input.value.length; input.setSelectionRange(start, end); mirrorSelectionStart.value = start; mirrorSelectionEnd.value = end; } isFocused.value = true; } function _pasteListener(e) { const input = inputRef.value; if (!input) return; if (!props.pasteTransformer && (!isIOS || !e.clipboardData || !input)) return; const _content = e?.clipboardData?.getData("text/plain"); const content = props?.pasteTransformer ? props.pasteTransformer(_content) : _content; e.preventDefault(); const start = inputRef.value?.selectionStart; const end = inputRef.value?.selectionEnd; const newValue = (start !== end ? internalValue.value.slice(0, start) + content + internalValue.value.slice(end) : internalValue.value.slice(0, start) + content + internalValue.value.slice(start)).slice(0, props.maxlength); if (newValue.length > 0 && regexp.value && !regexp.value.test(newValue)) return; internalValue.value = newValue; emit("input", newValue); const _start = Math.min(newValue.length, props.maxlength - 1); const _end = newValue.length; input?.setSelectionRange(_start, _end); mirrorSelectionStart.value = _start; mirrorSelectionEnd.value = _end; } const inputProps = useForwardProps(reactiveOmit(props, "containerClass", "value", "pattern", "defaultValue", "pushPasswordManagerStrategy", "noScriptCssFallback", "modelValue")); const rootStyle = computed(() => ({ position: "relative", cursor: props.disabled ? "default" : "text", userSelect: "none", WebkitUserSelect: "none", pointerEvents: "none" })); const inputStyle = computed(() => ({ position: "absolute", inset: 0, width: pwmb.willPushPWMBadge.value ? `calc(100% + ${pwmb.PWM_BADGE_SPACE_WIDTH})` : "100%", clipPath: pwmb.willPushPWMBadge.value ? `inset(0 ${pwmb.PWM_BADGE_SPACE_WIDTH} 0 0)` : void 0, height: "100%", display: "flex", textAlign: props.textAlign, opacity: "1", color: "transparent", pointerEvents: "all", background: "transparent", caretColor: "transparent", border: "0 solid transparent", outline: "0 solid transparent", boxShadow: "none", lineHeight: "1", letterSpacing: "-.5em", fontSize: "var(--root-height)", fontFamily: "monospace", fontVariantNumeric: "tabular-nums" })); const contextValue = computed(() => { return { slots: Array.from({ length: Number(props.maxlength) }).map((_, slotIdx) => { const isActive = isFocused.value && mirrorSelectionStart.value !== null && mirrorSelectionEnd.value !== null && (mirrorSelectionStart.value === mirrorSelectionEnd.value && slotIdx === mirrorSelectionStart.value || slotIdx >= mirrorSelectionStart.value && slotIdx < mirrorSelectionEnd.value); const char = internalValue.value[slotIdx] !== void 0 ? internalValue.value[slotIdx] : null; return { char, placeholderChar: char ?? props?.placeholder?.[slotIdx] ?? null, isActive, hasFakeCaret: isActive && char === null }; }), isFocused: isFocused.value, isHovering: !props.disabled && isHoveringInput.value }; }); provide(PublicVueOTPContextKey, contextValue); __expose(Object.defineProperty({}, "$el", { enumerable: true, configurable: true, get: () => inputRef })); return (_ctx, _cache) => { return openBlock(), createElementBlock(Fragment, null, [__props.noScriptCssFallback !== null ? (openBlock(), createBlock(unref(NoSciptCssFallback), { key: 0, fallback: __props.noScriptCssFallback }, null, 8, ["fallback"])) : createCommentVNode("v-if", true), createElementVNode("div", { ref_key: "containerRef", ref: containerRef, "data-input-otp-container": "", style: normalizeStyle(rootStyle.value), class: normalizeClass(__props.containerClass) }, [renderSlot(_ctx.$slots, "default", { slots: contextValue.value.slots, isFocused: isFocused.value, isHovering: !__props.disabled && isHoveringInput.value }), createElementVNode("div", _hoisted_1, [createElementVNode("input", mergeProps({ ref_key: "inputRef", ref: inputRef, value: unref(internalValue), "data-input-otp": "", "data-input-otp-placeholder-shown": unref(internalValue).length === 0 || void 0, "data-input-otp-mss": mirrorSelectionStart.value, "data-input-otp-mse": mirrorSelectionEnd.value, "aria-placeholder": __props.placeholder, style: inputStyle.value, pattern: regexp.value?.source }, { ..._ctx.$attrs, ...unref(inputProps) }, { onBeforeinput: _beforeInputListener, onMouseover: _cache[0] || (_cache[0] = (e) => { isHoveringInput.value = true; emit("mouseover", e); }), onMouseleave: _cache[1] || (_cache[1] = (e) => { isHoveringInput.value = false; emit("mouseleave", e); }), onPaste: _cache[2] || (_cache[2] = (e) => { _pasteListener(e); emit("paste", e); }), onInput: _inputListener, onFocus: _cache[3] || (_cache[3] = (e) => { _focusListener(); emit("focus", e); }), onBlur: _cache[4] || (_cache[4] = (e) => { isFocused.value = false; emit("blur", e); }) }), null, 16, _hoisted_2)])], 6)], 64); }; } }); var OTPInput_default = _sfc_main; //#endregion //#region src/regexp.ts const REGEXP_ONLY_DIGITS = "^\\d+$"; const REGEXP_ONLY_CHARS = "^[a-zA-Z]+$"; const REGEXP_ONLY_DIGITS_AND_CHARS = "^[a-zA-Z0-9]+$"; //#endregion //#region src/use-otp-context.ts function useVueOTPContext() { return inject(PublicVueOTPContextKey); } //#endregion export { OTPInput_default as OTPInput, PublicVueOTPContextKey, REGEXP_ONLY_CHARS, REGEXP_ONLY_DIGITS, REGEXP_ONLY_DIGITS_AND_CHARS, useVueOTPContext };