Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ui/otp-Input): add OTP input component #1614

Open
wants to merge 12 commits into
base: dev
Choose a base branch
from
241 changes: 241 additions & 0 deletions packages/varlet-ui/src/otp-input/OtpInput.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
<template>
<div :class="classes(n())" ref="contentRef">
<div :class="n('container')">
<var-input
v-for="(_, i) of length"
v-model="model[i]"
type="number"
:key="i"
:maxlength="1"
:ref="(el) => setRef(el as VarInputInstance, i)"
:variant="variant"
:readonly="readonly"
:disabled="disabled"
:size="size"
:text-color="textColor"
:focus-color="focusColor"
:blur-color="blurColor"
:autofocus="i === 0 && autofocus"
@input="handleInput"
@focus="handleFocus(i)"
@blur="handleBlur(i)"
@click="handleClick(i)"
@keydown="handleKeydown"
/>
</div>
<var-form-details :error-message="errorMessage" @mousedown.stop>
<template v-if="$slots['extra-message']" #extra-message>
<slot name="extra-message" />
</template>
</var-form-details>
</div>
</template>

<script lang="ts">
import VarInput from '../input'
import VarFormDetails from '../form-details'
import { defineComponent, ref, computed, nextTick, watch } from 'vue'
import { props, type OptInputValidateTrigger } from './props'
import { call, preventDefault, raf } from '@varlet/shared'
import { useValidation, createNamespace } from '../utils/components'
import { useForm } from '../form/provide'
import { type OtpInputProvider } from './provide'

const { name, n, classes } = createNamespace('otp-input')

type VarInputInstance = InstanceType<typeof VarInput>

export default defineComponent({
name,
components: {
VarInput,
VarFormDetails,
},
props,
setup(props) {
const contentRef = ref()
const inputRefs = ref<Array<VarInputInstance> | null>([])
const { bindForm } = useForm()
const {
errorMessage,
validateWithTrigger: vt,
validate: v,
// expose
resetValidation,
} = useValidation()

const model = computed({
get() {
return String(props.modelValue).split('')
},
set(value) {
call(props.onChange, value.join(''))
call(props['onUpdate:modelValue'], value.join(''))
validateWithTrigger('onChange')
},
})

const valueWhenFocus = ref('')
const focusIndex = ref(-1)
const blurIndex = ref(-1)

watch(
() => focusIndex.value,
(index) => {
if (index === -1) {
validateWithTrigger('onBlur')
call(props.onBlur, blurIndex.value)
} else {
validateWithTrigger('onFocus')
call(props.onFocus, index)
}
}
)

const otpInputProvider: OtpInputProvider = {
reset,
validate,
resetValidation,
}

call(bindForm, otpInputProvider)

function setRef(el: VarInputInstance | null, index: number) {
if (inputRefs.value && el) {
inputRefs.value[index] = el
}
}

function validateWithTrigger(trigger: OptInputValidateTrigger) {
nextTick(() => {
const { validateTrigger, rules, modelValue } = props
vt(validateTrigger, trigger, rules, modelValue)
})
}

function focusInput(target: number | 'next' | 'prev') {
const newIndex = target === 'next' ? focusIndex.value + 1 : target === 'prev' ? focusIndex.value - 1 : target
if (inputRefs.value && inputRefs.value[newIndex]) {
focusIndex.value = newIndex
inputRefs.value[newIndex].focus()
}
}

function handleFocus(index: number) {
focusIndex.value = index
valueWhenFocus.value = model.value[index]
}

function handleBlur(index: number) {
blurIndex.value = index
focusIndex.value = -1
}

function handleInput(val: string) {
const array = model.value.slice()
const value = val
array[focusIndex.value] = value
let target: any = null
const modelLength = model.value.filter(Boolean).length
if (focusIndex.value >= modelLength) {
target = modelLength
} else if (focusIndex.value + 1 !== props.length) {
target = 'next'
}
model.value = array
call(props.onInput, model.value.join(''))

if (target) {
focusInput(target)
}
validateWithTrigger('onInput')
}

async function handleKeydown(event: KeyboardEvent) {
if (props.readonly || !['ArrowLeft', 'ArrowRight', 'Backspace', 'Delete'].includes(event.key)) {
return
}

const array = model.value.slice()
let target: 'next' | 'prev' | 'first' | 'last' | number | null = null

preventDefault(event)

if (event.key === 'ArrowLeft') {
target = 'prev'
} else if (event.key === 'ArrowRight') {
target = 'next'
} else if (['Backspace', 'Delete'].includes(event.key)) {
array[focusIndex.value] = ''
model.value = array
if (focusIndex.value > 0 && event.key === 'Backspace') {
if (valueWhenFocus.value) {
valueWhenFocus.value = ''
} else {
target = 'prev'
}
}
validateWithTrigger('onInput')
}
if (!target) return

await raf()
focusInput(target)
}

function handleClick(index: number) {
focusInput(index)
call(props.onClick, index)
}

// expose
function reset() {
call(props['onUpdate:modelValue'], '')
resetValidation()
}

// expose
function validate() {
return v(props.rules, props.modelValue)
}

// expose
function focus() {
blurIndex.value > -1 && inputRefs.value?.[blurIndex.value].focus()
chouchouji marked this conversation as resolved.
Show resolved Hide resolved
}

// expose
function blur() {
if (focusIndex.value === -1) return
blurIndex.value = focusIndex.value
inputRefs.value?.[focusIndex.value].blur()
}

return {
model,
contentRef,
inputRefs,
errorMessage,
focusIndex,
n,
classes,
handleInput,
handleFocus,
handleBlur,
handleKeydown,
handleClick,
setRef,
blur,
focus,
reset,
validate,
resetValidation,
}
},
})
</script>

<style lang="less">
@import '../styles/common';
@import './otpInput';
chouchouji marked this conversation as resolved.
Show resolved Hide resolved
</style>
Loading
Loading