210 lines
4.2 KiB
Vue
210 lines
4.2 KiB
Vue
<template>
|
|
<div :class="[cn.b(), cn.is('invalid', invalid)]">
|
|
<div :class="[cn.e('label')]">
|
|
<slot name="title">
|
|
<p v-if="!hideTitle">
|
|
{{ title }}
|
|
</p>
|
|
</slot>
|
|
</div>
|
|
<div :class="[cn.e('wrapper')]">
|
|
<input
|
|
v-for="i in 6"
|
|
:key="i"
|
|
ref="digitInputs"
|
|
type="text"
|
|
:class="[cn.e('digit')]"
|
|
:value="value.charAt(i - 1)"
|
|
inputmode="numeric"
|
|
@focus="handleFocus(i)"
|
|
@blur="onBlur"
|
|
@keydown="handleKeydown(i, $event)"
|
|
@paste.prevent="handleClipboardPaste"
|
|
>
|
|
</div>
|
|
<p
|
|
v-if="invalid"
|
|
:class="[cn.e('validation-message')]"
|
|
>
|
|
{{ realMessage }}
|
|
</p>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, nextTick, onMounted, ref, toRef } from 'vue'
|
|
import { useField } from 'vee-validate'
|
|
import useClassname from '../../composables/use-classname'
|
|
|
|
const props = defineProps({
|
|
id: {
|
|
type: String,
|
|
required: true,
|
|
},
|
|
title: {
|
|
type: String,
|
|
default: '2FA',
|
|
},
|
|
modelValue: {
|
|
type: String,
|
|
default: '',
|
|
},
|
|
invalid: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
autofocus: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
hideTitle: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
})
|
|
|
|
defineEmits(['update:modelValue'])
|
|
|
|
const { t } = useI18n({ useScope: 'global' })
|
|
|
|
const cn = useClassname('ui-code-input')
|
|
const { value, errorMessage, meta, handleChange, handleBlur } = useField(
|
|
toRef(props, 'id'),
|
|
'required|length:6',
|
|
{
|
|
validateOnValueUpdate: false,
|
|
initialValue: props.modelValue,
|
|
syncVModel: true,
|
|
},
|
|
)
|
|
|
|
const digitInputs = ref(null)
|
|
let ignoreBlurEvent = false
|
|
|
|
const invalid = computed(
|
|
() => props.invalid || (!!errorMessage.value && meta.touched),
|
|
)
|
|
|
|
const realMessage = computed(() =>
|
|
props.invalid ? t('validation.invalid_code') : errorMessage.value,
|
|
)
|
|
|
|
function handleFocus(index) {
|
|
if (value.value.length < 6 && index > value.value.length)
|
|
digitInputs.value[value.value.length].focus()
|
|
}
|
|
|
|
async function handleKeydown(index, event) {
|
|
const currentValue = value.value
|
|
|
|
let nextInput
|
|
if (/^\d+$/.test(event.key)) {
|
|
const value
|
|
= currentValue.substring(0, index - 1)
|
|
+ event.key
|
|
+ currentValue.substring(index)
|
|
|
|
handleChange(value, false)
|
|
|
|
nextInput = digitInputs.value[index]
|
|
}
|
|
else if (event.key === 'Backspace' && currentValue) {
|
|
const currentChar = currentValue.substring(index - 1, index)
|
|
|
|
handleChange(value.value.substring(0, index - 1), false)
|
|
|
|
if (!currentChar)
|
|
nextInput = digitInputs.value[Math.max(index - 2, 0)]
|
|
}
|
|
|
|
if (
|
|
!event.ctrlKey
|
|
&& !event.metaKey
|
|
&& !['Tab', 'Enter'].includes(event.key)
|
|
)
|
|
event.preventDefault()
|
|
|
|
await nextTick()
|
|
|
|
if (nextInput) {
|
|
ignoreBlurEvent = true
|
|
|
|
event.target.blur()
|
|
nextInput.focus()
|
|
|
|
ignoreBlurEvent = false
|
|
}
|
|
}
|
|
|
|
function handleClipboardPaste(event) {
|
|
const value = event.clipboardData?.getData('text')
|
|
|
|
if (/^\d{6}/.test(value)) {
|
|
handleChange(value.substring(0, 6))
|
|
event.target.blur()
|
|
}
|
|
}
|
|
|
|
function onBlur(event) {
|
|
if (ignoreBlurEvent)
|
|
return
|
|
|
|
handleBlur(event)
|
|
}
|
|
|
|
onMounted(() => {
|
|
if (props.autofocus)
|
|
digitInputs.value[0].focus()
|
|
})
|
|
</script>
|
|
|
|
<style lang="scss">
|
|
.ui-code-input {
|
|
text-align: left;
|
|
|
|
&__wrapper {
|
|
display: flex;
|
|
}
|
|
|
|
&__label {
|
|
@include txt-m-b('code-input-label');
|
|
|
|
margin-bottom: var(--code-input-label-margin, 8px);
|
|
}
|
|
|
|
&__digit {
|
|
--border-color: #{$clr-grey-300};
|
|
--border-width: 1px;
|
|
|
|
width: var(--code-input-size, 62px);
|
|
height: var(--code-input-size, 62px);
|
|
border-radius: var(--code-input-border-radius, 16px);
|
|
outline: var(--border-width) solid var(--border-color);
|
|
outline-offset: calc(var(--border-width) * -1);
|
|
border: none;
|
|
text-align: center;
|
|
padding: 0;
|
|
transition: outline-color 0.2s ease-out;
|
|
caret-color: $clr-cyan-500;
|
|
|
|
&:focus {
|
|
--border-width: 2px;
|
|
--border-color: #{$clr-cyan-500};
|
|
}
|
|
|
|
&:not(:first-child) {
|
|
margin-left: var(--code-input-gap, 25px);
|
|
}
|
|
}
|
|
|
|
&.is-invalid {
|
|
--code-input-border-color: #{$clr-red-500};
|
|
}
|
|
|
|
&__validation-message {
|
|
margin-top: 8px;
|
|
color: $clr-red-500;
|
|
}
|
|
}
|
|
</style>
|