Files
Kotyata/layers/ui/components/code-input/index.vue
2026-03-17 13:24:22 +03:00

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>