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
235 changes: 235 additions & 0 deletions packages/varlet-ui/src/otp-input/OtpInput.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
<template>
<div :class="classes(n())" ref="contentRef">
<div :class="n('container')">
<var-input
v-for="(_, i) of $props.length"
v-model="model[i]"
maxlength="1"
Aybrea marked this conversation as resolved.
Show resolved Hide resolved
type="number"
:key="'otp-input-' + i"
Aybrea marked this conversation as resolved.
Show resolved Hide resolved
:ref="(el) => setRef(el as VarInputInstance, i)"
:variant="variant ? 'standard' : 'outlined'"
:readonly="$props.readonly"
Aybrea marked this conversation as resolved.
Show resolved Hide resolved
:disabled="$props.disabled"
:size="$props.size"
:text-color="$props.textColor"
:focus-color="$props.focusColor"
:blur-color="$props.blurColor"
:autofocus="i === 0 && $props.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 { 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 { focusChild } from '../utils/elements'
import { type OtpInputProvider } from './provide'

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

type VarInputInstance = InstanceType<typeof VarInput>

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

const { bindForm, form } = useForm()

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 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) {
focusChild(contentRef.value!, target)
}
validateWithTrigger('onInput')
}

async function handleKeydown(event: KeyboardEvent) {
if (props.readonly) return
Aybrea marked this conversation as resolved.
Show resolved Hide resolved
const array = model.value.slice()
let target: 'next' | 'prev' | 'first' | 'last' | number | null = null

if (!['ArrowLeft', 'ArrowRight', 'Backspace', 'Delete'].includes(event.key)) return

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()
focusChild(contentRef.value!, target)
}

function handleClick(index: number) {
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,
formDisabled: form?.disabled,
formReadonly: form?.readonly,
length,
errorMessage,
focusIndex,
variant: props.variant,
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>
127 changes: 127 additions & 0 deletions packages/varlet-ui/src/otp-input/docs/en-US.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Input

### Intro

The OTP input is used to authenticate users with a one-time password.

### Standard Usage

```html
<script setup>
import { ref } from 'vue'

const value = ref('')
</script>

<template>
<var-otp-input v-model="standardValue" />
</template>
```

### Readonly

```html
<script setup>
import { ref } from 'vue'

const value = ref('')
</script>

<template>
<var-otp-input v-model="standardValue" readonly/>
Aybrea marked this conversation as resolved.
Show resolved Hide resolved
</template>
```

### Disabled

```html
<script setup>
import { ref } from 'vue'

const value = ref('')
</script>

<template>
<var-otp-input v-model="standardValue" disabled/>
</template>
```

### Validate

```html
<script setup>
import { ref } from 'vue'

const value = ref('')
</script>

<template>
<var-otp-input v-model="standardValue" rules="[(v) => v.length === 6 || '必须输入6位验证码']"/>
</template>
```

### Smaller size

```html
<script setup>
import { ref } from 'vue'

const value = ref('')
</script>

<template>
<var-otp-input v-model="standardValue" size="small"/>
</template>
```

### Variant Appearance

```html
<script setup>
import { ref } from 'vue'

const value = ref('')
</script>

<template>
<var-otp-input v-model="value" variant/>
</template>
```

## API

### Props

| Prop | Description | Type | Default |
| --- |----------------------------------------------------------------------------------------------------------------------------------------| --- | --- |
| `v-model` | The value of the binding | _string\|number_ | `-` |
| `size` | Input size, The optional value is `normal` `small` | _string_ | `normal` |
| `variant` | Whether to use variant appearance | _boolean_ | `false` |
| `text-color` | Text color | _string_ | `-` |
| `focus-color` | The primary color in focus | _string_ | `-` |
| `blur-color` | The primary color in blur | _string_ | `-` |
| `readonly` | Whether the readonly | _boolean_ | `false` |
| `disabled` | Whether the disabled | _boolean_ | `false` |
| `autofocus` | Whether the autofocus the first input component | _boolean_ | `false` |
| `validate-trigger` | Timing to trigger validation, The optional value is `onFocus` `onBlur` `onChange` `onClick` `onInput` | _ValidateTriggers[]_ | `['onInput']` |
| `rules` | The validation rules, return `true` to indicate that the validation passed,The remaining values are converted to text as user prompts | _Array<(v: string) => any>_ | `-` |

### Methods

| Method | Description | Arguments | Return |
| --- | --- | --- | --- |
| `focus` | Focus | `-` | `-` |
| `blur` | Blur | `-` | `-` |
| `validate` | Trigger validate | `-` | `valid: Promise<boolean>` |
| `resetValidation` | Clear validate messages | `-` | `-` |
| `reset` | Clear the value of the binding and validate messages | `-` | `-` |

### Events

| Event | Description | Arguments |
| --- | --- | --- |
| `focus` | Triggered while focusing | `event: Event` |
| `blur` | Triggered when out of focus | `event: Event` |
| `click` | Triggered on Click | `event: Event` |
| `input` | Triggered on input | `value: string`, `event: Event` |
| `change` | Triggered on change | `value: string`, `event: Event` |
Loading
Loading