@coreui/coreui-pro
Version:
The most popular front-end framework for developing responsive, mobile-first projects on the web rewritten by the CoreUI Team
480 lines (377 loc) • 11.5 kB
JavaScript
/**
* --------------------------------------------------------------------------
* CoreUI PRO password-input.js
* License (https://coreui.io/pro/license/)
* --------------------------------------------------------------------------
*/
import BaseComponent from './base-component.js'
import EventHandler from './dom/event-handler.js'
import SelectorEngine from './dom/selector-engine.js'
import { defineJQueryPlugin, getNextActiveElement, isRTL } from './util/index.js'
/**
* Constants
*/
const NAME = 'otp-input'
const DATA_KEY = 'coreui.otp-input'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
const ARROW_RIGHT_KEY = 'ArrowRight'
const ARROW_LEFT_KEY = 'ArrowLeft'
const BACKSPACE_KEY = 'Backspace'
const EVENT_CHANGE = `change${EVENT_KEY}`
const EVENT_COMPLETE = `complete${EVENT_KEY}`
const EVENT_FOCUS = `focus${EVENT_KEY}`
const EVENT_INPUT = `input${EVENT_KEY}`
const EVENT_KEYDOWN = `keydown${EVENT_KEY}`
const EVENT_PASTE = `paste`
const EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}`
const SELECTOR_FORM_OTP_CONTROL = '.form-otp-control'
const SELECTOR_DATA_TOGGLE = '[data-coreui-toggle="otp"]'
const Default = {
ariaLabel: (index, total) => `Digit ${index + 1} of ${total}`,
autoSubmit: false,
disabled: false,
id: null,
linear: true,
masked: false,
name: null,
placeholder: null,
readonly: false,
required: false,
type: 'number',
value: null
}
const DefaultType = {
ariaLabel: 'function',
autoSubmit: 'boolean',
disabled: 'boolean',
id: '(string|null)',
linear: 'boolean',
masked: 'boolean',
name: '(string|null)',
placeholder: '(number|string|null)',
readonly: 'boolean',
required: 'boolean',
type: 'string',
value: '(number|string|null)'
}
/**
* Class definition
*/
class OTPInput extends BaseComponent {
constructor(element, config) {
super(element, config)
this._config = this._getConfig(config)
this._inputElement = null
this._createHiddenInput()
this._setRoleAttribute()
this._setInputsAttributes()
this._setInputsTabIndexes()
this._addEventListeners()
}
// Getters
static get Default() {
return Default
}
static get DefaultType() {
return DefaultType
}
static get NAME() {
return NAME
}
// Public
clear() {
const inputs = this._getInputs()
for (const input of inputs) {
input.value = ''
}
this._setHiddenInputValue(null)
this._setInputsTabIndexes()
}
reset() {
const inputs = this._getInputs()
for (const [index, input] of inputs.entries()) {
const valueString = String(this._config.value || '')
input.value = valueString && valueString[index] ? valueString[index] : ''
}
this._setHiddenInputValue(null)
this._setInputsTabIndexes()
}
update(config) {
if (typeof config !== 'object') {
return
}
this._config = { ...this._config, ...config }
this._typeCheckConfig(this._config)
this._setInputsAttributes()
this._setInputsTabIndexes()
this._inputElement.remove()
this._createHiddenInput()
}
// Private
_addEventListeners() {
EventHandler.on(this._element, EVENT_FOCUS, SELECTOR_FORM_OTP_CONTROL, event => {
const { target } = event
if (target.value) {
setTimeout(() => {
target.select()
}, 0)
return
}
if (this._config.linear) {
const inputs = this._getInputs()
const firstEmptyInput = inputs.find(input => !input.value)
if (firstEmptyInput && firstEmptyInput !== target) {
firstEmptyInput.focus()
}
}
})
EventHandler.on(this._element, EVENT_INPUT, SELECTOR_FORM_OTP_CONTROL, event => {
const { target } = event
if (target.value.length === 1 && !this._isValidInput(target.value)) {
target.value = ''
return
}
if (target.value.length === 1) {
const inputs = this._getInputs()
if (!inputs.length) {
return
}
const currentValue = inputs.map(input => input.value).join('')
this._setHiddenInputValue(currentValue)
const nextInput = getNextActiveElement(inputs, target, true)
if (nextInput) {
nextInput.focus()
}
this._setInputsTabIndexes()
this._checkAutoSubmit(inputs)
}
})
EventHandler.on(this._element, EVENT_KEYDOWN, SELECTOR_FORM_OTP_CONTROL, event => {
const { key, target } = event
if (key === BACKSPACE_KEY && target.value === '') {
const inputs = this._getInputs()
if (!inputs.length) {
return
}
getNextActiveElement(inputs, target, false).focus()
const currentValue = inputs.map(input => input.value).join('')
this._setHiddenInputValue(currentValue)
this._setInputsTabIndexes()
return
}
if (key === ARROW_RIGHT_KEY) {
if (this._config.linear && target.value === '') {
return
}
const inputs = this._getInputs()
if (!inputs.length) {
return
}
// In RTL mode, right arrow moves to previous input, in LTR mode it moves to next input
const shouldMoveNext = !isRTL()
getNextActiveElement(inputs, target, shouldMoveNext).focus()
return
}
if (key === ARROW_LEFT_KEY) {
const inputs = this._getInputs()
if (!inputs.length) {
return
}
// In RTL mode, left arrow moves to next input, in LTR mode it moves to previous input
const shouldMoveNext = isRTL()
getNextActiveElement(inputs, target, shouldMoveNext).focus()
}
})
EventHandler.on(this._element, EVENT_PASTE, SELECTOR_FORM_OTP_CONTROL, event => {
event.preventDefault()
const pastedData = event.clipboardData.getData('text')
const validChars = this._extractValidChars(pastedData)
if (!validChars) {
return
}
const inputs = this._getInputs()
const currentIndex = inputs.indexOf(event.target)
for (let i = 0; i < validChars.length && (currentIndex + i) < inputs.length; i++) {
inputs[currentIndex + i].value = validChars[i]
}
// Focus the next empty input or the last filled input
const nextEmptyIndex = currentIndex + validChars.length
if (nextEmptyIndex < inputs.length) {
inputs[nextEmptyIndex].focus()
} else {
inputs[inputs.length - 1].focus()
}
this._setHiddenInputValue(validChars)
this._setInputsTabIndexes()
this._checkAutoSubmit(inputs)
})
}
_checkAutoSubmit(inputs) {
if (!this._config.autoSubmit) {
return
}
// Check if all inputs are filled
const allFilled = inputs.every(input => input.value.length === 1)
if (allFilled) {
// Find the closest form element
const form = this._element.closest('form')
if (form && typeof form.requestSubmit === 'function') {
form.requestSubmit()
}
}
}
_getInputs() {
return SelectorEngine.find(SELECTOR_FORM_OTP_CONTROL, this._element)
}
_createHiddenInput() {
const hiddenInput = document.createElement('input')
hiddenInput.type = 'hidden'
if (this._config.disabled) {
hiddenInput.disabled = true
}
if (this._config.id) {
hiddenInput.id = this._config.id
}
if (this._config.name) {
hiddenInput.name = this._config.name
}
hiddenInput.value = this._config.value || ''
this._element.append(hiddenInput)
this._inputElement = hiddenInput
}
_extractValidChars(text) {
switch (this._config.type) {
case 'number': {
return text.replace(/\D/g, '')
}
default: {
return text // Allow all characters for unknown types
}
}
}
_isValidInput(value) {
if (value.length !== 1) {
return false
}
switch (this._config.type) {
case 'number': {
return /^\d$/.test(value)
}
default: {
return /^.$/s.test(value) // Allow any single character for unknown types
}
}
}
_setHiddenInputValue(value) {
if (this._inputElement) {
this._inputElement.value = value || ''
}
EventHandler.trigger(this._element, EVENT_CHANGE, { value })
if (value && value.length === this._getInputs().length) {
EventHandler.trigger(this._element, EVENT_COMPLETE, { value })
}
}
_setInputsAttributes() {
const inputs = SelectorEngine.find(SELECTOR_FORM_OTP_CONTROL, this._element)
for (const [index, input] of inputs.entries()) {
input.type = this._config.masked ? 'password' : 'text'
input.maxLength = 1
input.autocomplete = 'off'
if (this._config.placeholder !== null) {
const placeholder = String(this._config.placeholder)
input.placeholder = placeholder.length > 1 ? placeholder[index] || '' : placeholder
}
if (this._config.required !== null) {
input.setAttribute('required', true)
}
switch (this._config.type) {
case 'number': {
input.inputMode = 'numeric'
input.pattern = '[0-9]*'
break
}
default: {
input.inputMode = 'text'
input.pattern = '.*'
}
}
if (this._config.disabled) {
input.disabled = true
}
if (this._config.id) {
input.id = `${this._config.id}-${index}`
}
if (this._config.name) {
input.name = `${this._config.name}-${index}`
}
if (this._config.readonly) {
input.readOnly = true
}
const valueString = String(this._config.value || '')
if (valueString && valueString[index]) {
input.value = valueString[index]
}
if (typeof this._config.ariaLabel === 'function') {
const ariaLabel = this._config.ariaLabel(index, inputs.length)
input.setAttribute('aria-label', ariaLabel)
}
}
}
_setInputsTabIndexes() {
if (!this._config.linear) {
return
}
const inputs = this._getInputs()
let foundEmpty = false
for (const input of inputs) {
const hasValue = input.value !== ''
if (hasValue) {
input.removeAttribute('tabindex')
} else if (foundEmpty) {
input.tabIndex = -1
} else {
// First empty input - should be tabbable
input.removeAttribute('tabindex')
foundEmpty = true
}
}
}
_setRoleAttribute() {
this._element.setAttribute('role', 'group')
}
// Static
static otpInputInterface(element, config) {
const data = OTPInput.getOrCreateInstance(element, config)
if (typeof config === 'string') {
if (typeof data[config] === 'undefined') {
throw new TypeError(`No method named "${config}"`)
}
data[config]()
}
}
static jQueryInterface(config) {
return this.each(function () {
const data = OTPInput.getOrCreateInstance(this)
if (typeof config === 'string') {
if (typeof data[config] === 'undefined') {
throw new TypeError(`No method named "${config}"`)
}
data[config]()
}
})
}
}
/**
* Data API implementation
*/
EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
for (const otp of SelectorEngine.find(SELECTOR_DATA_TOGGLE)) {
OTPInput.otpInputInterface(otp)
}
})
/**
* jQuery
*/
defineJQueryPlugin(OTPInput)
export default OTPInput