fix bug of WebOTP
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { persianToEnglishDigits } from '@/utils/persianDigits'
|
||||
import {
|
||||
beginSmsOtpCapture,
|
||||
clearPendingSmsOtp,
|
||||
subscribeSmsOtp,
|
||||
} from '@/utils/webOtp'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string
|
||||
length?: number
|
||||
autofocus?: boolean
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
length: 4,
|
||||
autofocus: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
change: [value: string]
|
||||
complete: [value: string]
|
||||
}>()
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const localValue = ref('')
|
||||
let completedFor = ''
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let unsubscribeOtp: (() => void) | null = null
|
||||
|
||||
const digits = computed(() => {
|
||||
const chars = localValue.value.padEnd(props.length, ' ').slice(0, props.length).split('')
|
||||
return chars.map((ch) => (ch === ' ' ? '' : ch))
|
||||
})
|
||||
|
||||
function normalize(raw: string) {
|
||||
return persianToEnglishDigits(raw).replace(/\D/g, '').slice(0, props.length)
|
||||
}
|
||||
|
||||
function applyCode(raw: string) {
|
||||
const code = normalize(raw)
|
||||
if (!code || code === localValue.value) {
|
||||
return
|
||||
}
|
||||
|
||||
localValue.value = code
|
||||
if (inputRef.value && inputRef.value.value !== code) {
|
||||
inputRef.value.value = code
|
||||
}
|
||||
|
||||
emit('update:modelValue', code)
|
||||
emit('change', code)
|
||||
|
||||
if (code.length === props.length && completedFor !== code) {
|
||||
completedFor = code
|
||||
clearPendingSmsOtp()
|
||||
emit('complete', code)
|
||||
}
|
||||
|
||||
if (code.length < props.length) {
|
||||
completedFor = ''
|
||||
}
|
||||
}
|
||||
|
||||
function syncFromDom() {
|
||||
const direct = inputRef.value?.value ?? ''
|
||||
if (direct) {
|
||||
applyCode(direct)
|
||||
return
|
||||
}
|
||||
|
||||
// اگر Chrome جای دیگری با autocomplete پر کرده
|
||||
const nodes = document.querySelectorAll<HTMLInputElement>(
|
||||
'input[autocomplete="one-time-code"]',
|
||||
)
|
||||
for (const node of nodes) {
|
||||
const value = node.value ?? ''
|
||||
if (normalize(value).length === props.length) {
|
||||
applyCode(value)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onInput(event: Event) {
|
||||
applyCode((event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
function onChange(event: Event) {
|
||||
applyCode((event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
function onAnimationStart(event: AnimationEvent) {
|
||||
if (!String(event.animationName || '').includes('onAutoFillStart')) {
|
||||
return
|
||||
}
|
||||
syncFromDom()
|
||||
}
|
||||
|
||||
function focusInput() {
|
||||
const el = inputRef.value
|
||||
if (!el) return
|
||||
el.focus({ preventScroll: true })
|
||||
try {
|
||||
el.setSelectionRange(el.value.length, el.value.length)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function clearInput() {
|
||||
completedFor = ''
|
||||
localValue.value = ''
|
||||
clearPendingSmsOtp()
|
||||
if (inputRef.value) {
|
||||
inputRef.value.value = ''
|
||||
}
|
||||
emit('update:modelValue', '')
|
||||
emit('change', '')
|
||||
focusInput()
|
||||
}
|
||||
|
||||
function fillInput(value: string) {
|
||||
applyCode(value)
|
||||
}
|
||||
|
||||
function startWebOtp() {
|
||||
beginSmsOtpCapture(props.length)
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling()
|
||||
pollTimer = setInterval(() => {
|
||||
syncFromDom()
|
||||
}, 200)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
const next = normalize(val ?? '')
|
||||
if (next !== localValue.value) {
|
||||
localValue.value = next
|
||||
if (inputRef.value) {
|
||||
inputRef.value.value = next
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
beginSmsOtpCapture(props.length)
|
||||
unsubscribeOtp = subscribeSmsOtp((code) => {
|
||||
applyCode(code)
|
||||
})
|
||||
if (props.autofocus) {
|
||||
focusInput()
|
||||
}
|
||||
startPolling()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unsubscribeOtp?.()
|
||||
unsubscribeOtp = null
|
||||
stopPolling()
|
||||
// listener سراسری را abort نکن — دیالوگ Allow هنوز ممکن است باز باشد
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
focusInput,
|
||||
clearInput,
|
||||
fillInput,
|
||||
startWebOtp,
|
||||
inputRef,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sms-otp" dir="ltr" @click="focusInput">
|
||||
<div class="sms-otp__boxes" aria-hidden="true">
|
||||
<div
|
||||
v-for="(digit, index) in digits"
|
||||
:key="index"
|
||||
class="sms-otp__box otp-input"
|
||||
:class="{
|
||||
'sms-otp__box--filled': !!digit,
|
||||
'sms-otp__box--active': localValue.length === index,
|
||||
}"
|
||||
>
|
||||
{{ digit }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="inputRef"
|
||||
class="sms-otp__input"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autocomplete="one-time-code"
|
||||
name="one-time-code"
|
||||
:maxlength="length"
|
||||
enterkeyhint="done"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
aria-label="کد تایید پیامک"
|
||||
@input="onInput"
|
||||
@change="onChange"
|
||||
@keyup="onInput"
|
||||
@paste="onInput"
|
||||
@animationstart="onAnimationStart"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sms-otp {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin-top: 1.25rem;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.sms-otp__boxes {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sms-otp__box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
margin: 0 8px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.sms-otp__box--active {
|
||||
border-color: var(--App-primary-color, #0a1b37);
|
||||
}
|
||||
|
||||
.sms-otp__input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
/* کمتر از این را Chrome گاهی autofill نمیکند */
|
||||
opacity: 0.15;
|
||||
color: transparent;
|
||||
caret-color: var(--App-primary-color, #0a1b37);
|
||||
background: transparent;
|
||||
font-size: 16px;
|
||||
letter-spacing: 48px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
animation-name: onAutoFillStart;
|
||||
animation-duration: 0.001s;
|
||||
}
|
||||
|
||||
@keyframes onAutoFillStart {
|
||||
from {
|
||||
/**/
|
||||
}
|
||||
to {
|
||||
/**/
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user