UNPKG

@coreui/vue-pro

Version:

UI Components Library for Vue.js

460 lines (423 loc) 13.9 kB
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 }