@coreui/vue-pro
Version:
UI Components Library for Vue.js
503 lines (458 loc) • 16.1 kB
text/typescript
import { ComponentPublicInstance, 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()
}
})
}
}
}
// Write `chars` across the slots starting at `startIndex`, then sync focus, the
// hidden form value and auto-submit. Shared by paste and by multi-character
// `input` events.
const distributeChars = (startIndex: number, chars: string) => {
const newValues = [...inputValues.value]
// A value at least as long as the field is a complete code: fill from the
// first slot, whichever slot happens to be focused.
const start = chars.length >= inputCount.value ? 0 : startIndex
for (let i = 0; i < chars.length && start + i < inputCount.value; i++) {
newValues[start + i] = chars[i]
}
inputValues.value = newValues
updateValue(newValues)
// Focus the next empty input or the last filled one
const nextEmptyIndex = start + chars.length
const nextInput =
nextEmptyIndex < inputCount.value
? inputRefs.value[nextEmptyIndex]
: inputRefs.value[inputRefs.value.length - 1]
nextInput?.focus()
}
const handleInputChange = (index: number, event: Event) => {
const target = event.target as HTMLInputElement
const inputValue = target.value
// SMS autofill, password managers, dictation and IME commits insert the
// whole code at once and fire `input`, not `paste`. Spread it across the
// slots instead of leaving it in one of them.
if (inputValue.length > 1) {
const chars = extractValidChars(inputValue, props.type as 'number' | 'text')
// The slot is bound to the state, so restore what it holds before Vue
// re-renders — an all-invalid value leaves the state untouched.
target.value = inputValues.value[index] || ''
if (chars) {
distributeChars(index, chars)
}
return
}
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
}
distributeChars(index, validChars)
}
return () => {
if (!slots.default) {
return null
}
const children = slots.default()
let inputIndex = 0
// `inputCount` is derived from the refs, so it is still 0 on the first
// render — the slot attributes need the count the markup already carries.
const slotCount = children.filter(
(child) => (child.type as { name?: string })?.name === 'COneTimePasswordInput'
).length
const processedChildren = children?.map((child) => {
if (child.type && (child.type as { name?: string }).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]*' : '.*',
// Only the first slot advertises the one-time code, so SMS autofill
// and password managers target a single field instead of every slot,
// and it has to fit a whole code while empty.
autocomplete:
child.props?.autocomplete ?? (currentInputIndex === 0 ? 'one-time-code' : 'off'),
maxlength:
child.props?.maxlength ??
(currentInputIndex === 0 && !inputValues.value[0] ? slotCount : 1),
enterkeyhint:
child.props?.enterkeyhint ?? (currentInputIndex === slotCount - 1 ? 'done' : 'next'),
onInput: (event: Event) => {
handleInputChange(currentInputIndex, event)
},
onFocus: handleInputFocus,
onKeydown: (event: KeyboardEvent) => {
handleKeyDown(event)
},
onPaste: (event: ClipboardEvent) => {
handlePaste(currentInputIndex, event)
},
ref: (el: Element | ComponentPublicInstance | null) => {
// 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 as ComponentPublicInstance).$el ?? el
// Ensure it's actually an HTMLInputElement
inputRefs.value[currentInputIndex] =
domElement && domElement.tagName === 'INPUT'
? (domElement as HTMLInputElement)
: (el as HTMLInputElement)
} 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 }