Files
webMontime/src/components/auth/OtpPinInput.vue
T
2026-05-31 14:12:18 +03:30

133 lines
3.2 KiB
Vue

<template>
<div class="space-y-1.5">
<label v-if="label" class="block text-sm font-medium text-muted">{{ label }}</label>
<div class="flex justify-center gap-3" dir="ltr">
<input
v-for="(_, index) in length"
:key="index"
ref="inputRefs"
:value="cells[index]"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="1"
class="h-14 w-14 rounded-xl border border-border bg-surface-2 text-center text-xl font-semibold text-text outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="disabled"
:aria-label="`رقم ${index + 1}`"
@input="onInput(index, $event)"
@keydown="onKeydown(index, $event)"
@paste="onPaste"
@focus="($event.target as HTMLInputElement).select()"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
const props = withDefaults(
defineProps<{
modelValue?: string;
label?: string;
length?: number;
disabled?: boolean;
}>(),
{
modelValue: '',
length: 4,
disabled: false,
},
);
const emit = defineEmits<{
'update:modelValue': [value: string];
complete: [value: string];
}>();
const inputRefs = ref<HTMLInputElement[]>([]);
const cells = computed(() => {
const digits = props.modelValue.replace(/\D/g, '').slice(0, props.length);
return Array.from({ length: props.length }, (_, index) => digits[index] ?? '');
});
function updateValue(next: string) {
const normalized = next.replace(/\D/g, '').slice(0, props.length);
emit('update:modelValue', normalized);
if (normalized.length === props.length) {
emit('complete', normalized);
}
}
function focusAt(index: number) {
nextTick(() => {
inputRefs.value[index]?.focus();
});
}
function onInput(index: number, event: Event) {
const input = event.target as HTMLInputElement;
const digit = input.value.replace(/\D/g, '').slice(-1);
const next = cells.value.map((cell, cellIndex) =>
cellIndex === index ? digit : cell,
);
updateValue(next.join(''));
if (digit && index < props.length - 1) {
focusAt(index + 1);
}
}
function onKeydown(index: number, event: KeyboardEvent) {
if (event.key !== 'Backspace') {
return;
}
if (cells.value[index]) {
const next = cells.value.map((cell, cellIndex) =>
cellIndex === index ? '' : cell,
);
updateValue(next.join(''));
return;
}
if (index > 0) {
event.preventDefault();
const next = cells.value.map((cell, cellIndex) =>
cellIndex === index - 1 ? '' : cell,
);
updateValue(next.join(''));
focusAt(index - 1);
}
}
function onPaste(event: ClipboardEvent) {
event.preventDefault();
const pasted = event.clipboardData?.getData('text') ?? '';
const digits = pasted.replace(/\D/g, '').slice(0, props.length);
if (!digits) {
return;
}
updateValue(digits);
focusAt(Math.min(digits.length, props.length) - 1);
}
watch(
() => props.modelValue,
(value) => {
if (!value) {
focusAt(0);
}
},
);
defineExpose({
focus: () => focusAt(0),
clear: () => {
updateValue('');
focusAt(0);
},
});
</script>