@coreui/vue-pro
Version:
UI Components Library for Vue.js
460 lines (423 loc) • 13.9 kB
text/typescript
import { defineComponent, h, ref, computed, nextTick, watch } from 'vue'
import { CFormControlWrapper } from '../form/CFormControlWrapper'
import { COneTimePasswordInput } from './COneTimePasswordInput'
import { isValidInput, extractValidChars } from './utils'
import { getNextActiveElement, isRTL } from '../../utils'
const COneTimePassword = defineComponent({
name: 'COneTimePassword',
inheritAttrs: false,
props: {
/**
* Function to generate aria-label for each input field. Receives current index (0-based) and total number of inputs.
*/
ariaLabel: {
type: Function,
default: (index: number, total: number) => `Digit ${index + 1} of ${total}`,
},
/**
* Automatically submit the form when all one time password fields are filled.
*/
autoSubmit: {
type: Boolean,
default: false,
},
/**
* Disable all one time password (OTP) input fields.
*/
disabled: {
type: Boolean,
default: false,
},
/**
* Initial value for uncontrolled Vue.js one time password input.
*/
defaultValue: [String, Number],
/**
* Provide valuable, actionable feedback.
*/
feedback: String,
/**
* Provide valuable, actionable feedback.
*/
feedbackInvalid: String,
/**
* Provide valuable, actionable invalid feedback when using standard HTML form validation which applied two CSS pseudo-classes, `:invalid` and `:valid`.
*/
feedbackValid: String,
/**
* A string of all className you want applied to the floating label wrapper.
*/
floatingClassName: String,
/**
* Provide valuable, actionable valid feedback when using standard HTML form validation which applied two CSS pseudo-classes, `:invalid` and `:valid`.
*/
floatingLabel: String,
/**
* ID attribute for the hidden input field.
*/
id: String,
/**
* Set component validation state to invalid.
*/
invalid: Boolean,
/**
* Add a caption for a component.
*/
label: String,
/**
* Enforce sequential input (users must fill fields in order).
*/
linear: {
type: Boolean,
default: true,
},
/**
* Show input as password (masked characters).
*/
masked: {
type: Boolean,
default: false,
},
/**
* The default name for a value passed using v-model.
*/
modelValue: [String, Number],
/**
* Name attribute for the hidden input field.
*/
name: String,
/**
* Placeholder text for input fields. Single character applies to all fields, longer strings apply character-by-character.
*/
placeholder: String,
/**
* Make Vue.js OTP input component read-only.
*/
readonly: {
type: Boolean,
default: false,
},
/**
* Makes the input field required for form validation.
*/
required: {
type: Boolean,
default: false,
},
/**
* Sets the visual size of the Vue.js one time password (OTP) input. Use 'sm' for small or 'lg' for large input fields.
*/
size: {
type: String,
validator: (value: string) => ['sm', 'lg'].includes(value),
},
/**
* Add helper text to the component.
*/
text: String,
/**
* Display validation feedback in a styled tooltip.
*/
tooltipFeedback: Boolean,
/**
* Input validation type: 'number' for digits only, or 'text' for free text.
*/
type: {
type: String,
default: 'number',
validator: (value: string) => ['number', 'text'].includes(value),
},
/**
* Set component validation state to valid.
*/
valid: Boolean,
/**
* The current value of the one time password input.
*/
value: [String, Number],
},
emits: [
/**
* Callback triggered when the Vue.js one time password (OTP) value changes.
*/
'update:modelValue',
/**
* Callback triggered when the Vue.js one time password (OTP) value changes.
*/
'change',
/**
* Callback triggered when all Vue.js one time password (OTP) fields are filled.
*/
'complete',
],
setup(props, { attrs, slots, emit }) {
const inputRefs = ref<(HTMLInputElement | null)[]>([])
const hiddenInputRef = ref<HTMLInputElement | null>(null)
const inputValues = ref<string[]>([])
// Count valid OTP input children
const inputCount = computed(() => {
return inputRefs.value.filter((ref) => ref !== null).length
})
// Initialize input values
const initializeValues = () => {
const initialValue = String(props.modelValue ?? props.value ?? props.defaultValue ?? '')
inputValues.value = Array.from({ length: inputCount.value }, (_, i) => initialValue[i] || '')
}
// Watch for changes in modelValue or value (controlled mode)
watch(
() => props.modelValue ?? props.value,
(newValue) => {
if (newValue !== undefined) {
const valueString = String(newValue)
inputValues.value = Array.from(
{ length: inputCount.value },
(_, i) => valueString[i] || ''
)
}
},
{ immediate: true }
)
// Watch for changes in inputCount
watch(inputCount, initializeValues, { immediate: true })
// Update hidden input and trigger events
const updateValue = (newValues: string[]) => {
const newValue = newValues.join('')
if (hiddenInputRef.value) {
hiddenInputRef.value.value = newValue
}
emit('update:modelValue', newValue)
emit('change', newValue)
if (newValue.length === inputCount.value) {
emit('complete', newValue)
if (props.autoSubmit) {
nextTick(() => {
const form = hiddenInputRef.value?.closest('form') as HTMLFormElement
if (form && typeof form.requestSubmit === 'function') {
form.requestSubmit()
}
})
}
}
}
const handleInputChange = (index: number, event: Event) => {
const target = event.target as HTMLInputElement
const inputValue = target.value
if (inputValue.length === 1 && !isValidInput(inputValue, props.type as 'number' | 'text')) {
return
}
const newValues = [...inputValues.value]
newValues[index] = inputValue.length === 1 ? inputValue : ''
inputValues.value = newValues
updateValue(newValues)
if (inputValue.length === 1) {
const nextInput = getNextActiveElement(
inputRefs.value.filter(Boolean) as HTMLInputElement[],
target,
true,
false
)
nextInput?.focus()
}
}
const handleInputFocus = (event: FocusEvent) => {
const target = event.target as HTMLInputElement
if (target.value) {
setTimeout(() => {
target.select()
}, 0)
return
}
if (props.linear) {
const firstEmptyInput = inputRefs.value.find((input) => !input?.value)
if (firstEmptyInput && firstEmptyInput !== target) {
firstEmptyInput.focus()
}
}
}
const handleKeyDown = (event: KeyboardEvent) => {
const { key, target } = event
if (key === 'Backspace' && (target as HTMLInputElement).value === '') {
const newValues = [...inputValues.value]
const prevInput = getNextActiveElement(
inputRefs.value.filter(Boolean) as HTMLInputElement[],
target as HTMLInputElement,
false,
false
)
if (prevInput) {
const prevIndex = inputRefs.value.indexOf(prevInput as HTMLInputElement)
if (prevIndex !== -1) {
newValues[prevIndex] = ''
inputValues.value = newValues
updateValue(newValues)
prevInput.focus()
}
}
return
}
if (key === 'ArrowRight') {
if (props.linear && (target as HTMLInputElement).value === '') {
return
}
const shouldMoveNext = !isRTL(target as HTMLInputElement)
const nextInput = getNextActiveElement(
inputRefs.value.filter(Boolean) as HTMLInputElement[],
target as HTMLInputElement,
shouldMoveNext,
false
)
nextInput?.focus()
return
}
if (key === 'ArrowLeft') {
const shouldMoveNext = isRTL(target as HTMLInputElement)
const prevInput = getNextActiveElement(
inputRefs.value.filter(Boolean) as HTMLInputElement[],
target as HTMLInputElement,
shouldMoveNext,
false
)
prevInput?.focus()
}
}
const handlePaste = (index: number, event: ClipboardEvent) => {
event.preventDefault()
const pastedData = event.clipboardData?.getData('text') || ''
const validChars = extractValidChars(pastedData, props.type as 'number' | 'text')
if (!validChars) {
return
}
const newValues = [...inputValues.value]
const startIndex = index
for (let i = 0; i < validChars.length && startIndex + i < inputCount.value; i++) {
newValues[startIndex + i] = validChars[i]
}
inputValues.value = newValues
updateValue(newValues)
// Focus the next empty input or the last filled input
const nextEmptyIndex = startIndex + validChars.length
if (nextEmptyIndex < inputCount.value) {
inputRefs.value[nextEmptyIndex]?.focus()
} else {
inputRefs.value[inputRefs.value.length - 1]?.focus()
}
}
return () => {
if (!slots.default) {
return null
}
const children = slots.default()
let inputIndex = 0
const processedChildren = children?.map((child) => {
if (child.type && (child.type as any).name === 'COneTimePasswordInput') {
const currentInputIndex = inputIndex++
return h(COneTimePasswordInput, {
...child.props,
key: `otp-input-${currentInputIndex}`,
type: props.masked ? 'password' : 'text',
class: [
{
'is-invalid': props.invalid,
'is-valid': props.valid,
},
child.props?.class,
],
id: child.props?.id || (props.id ? `${props.id}-${currentInputIndex}` : undefined),
name:
child.props?.name || (props.name ? `${props.name}-${currentInputIndex}` : undefined),
placeholder:
child.props?.placeholder ||
(props.placeholder && props.placeholder.length > 1
? props.placeholder[currentInputIndex]
: props.placeholder),
value: inputValues.value[currentInputIndex] || '',
tabindex:
currentInputIndex === 0 ? 0 : inputValues.value[currentInputIndex - 1] ? 0 : -1,
disabled: props.disabled || child.props?.disabled,
readonly: props.readonly || child.props?.readonly,
required: props.required || child.props?.required,
'aria-label':
child.props?.['aria-label'] || props.ariaLabel!(currentInputIndex, inputCount.value),
inputmode: props.type === 'number' ? 'numeric' : 'text',
pattern: props.type === 'number' ? '[0-9]*' : '.*',
onInput: (event: Event) => {
handleInputChange(currentInputIndex, event)
},
onFocus: handleInputFocus,
onKeydown: (event: KeyboardEvent) => {
handleKeyDown(event)
},
onPaste: (event: ClipboardEvent) => {
handlePaste(currentInputIndex, event)
},
ref: (el: any) => {
// Get the actual DOM element - handle both direct elements and component instances
if (el) {
// If it's a component instance, get the DOM element
const domElement = el.$el || el
// Ensure it's actually an HTMLInputElement
if (domElement && domElement.tagName === 'INPUT') {
inputRefs.value[currentInputIndex] = domElement
} else {
inputRefs.value[currentInputIndex] = el
}
} else {
inputRefs.value[currentInputIndex] = null
}
},
})
}
return child
})
return h(
CFormControlWrapper,
{
...(typeof attrs['aria-describedby'] === 'string' && {
describedby: attrs['aria-describedby'],
}),
feedback: props.feedback,
feedbackInvalid: props.feedbackInvalid,
feedbackValid: props.feedbackValid,
floatingClassName: props.floatingClassName,
floatingLabel: props.floatingLabel,
id: props.id,
invalid: props.invalid,
label: props.label,
text: props.text,
tooltipFeedback: props.tooltipFeedback,
valid: props.valid,
},
{
default: () => [
h(
'div',
{
class: [
'form-otp',
{
[`form-otp-${props.size}`]: props.size,
},
attrs.class,
],
role: 'group',
...attrs,
},
[
...processedChildren,
h('input', {
type: 'hidden',
id: props.id,
name: props.name,
value: inputValues.value.join(''),
disabled: props.disabled,
ref: hiddenInputRef,
}),
]
),
],
}
)
}
},
})
export { COneTimePassword }