blob: 6641eb39cd02cffae08fb75e896058ce112c3e50 [file] [log] [blame] [edit]
/**
* --------------------------------------------------------------------------
* Bootstrap otp-input.js
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import BaseComponent from './base-component.js'
import EventHandler from './dom/event-handler.js'
import SelectorEngine from './dom/selector-engine.js'
/**
* Constants
*/
const NAME = 'otpInput'
const DATA_KEY = 'bs.otp-input'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
const EVENT_COMPLETE = `complete${EVENT_KEY}`
const EVENT_INPUT = `input${EVENT_KEY}`
const SELECTOR_DATA_OTP = '[data-bs-otp]'
const SELECTOR_INPUT = 'input'
const Default = {
length: 6,
mask: false
}
const DefaultType = {
length: 'number',
mask: 'boolean'
}
/**
* Class definition
*/
class OtpInput extends BaseComponent {
constructor(element, config) {
super(element, config)
this._inputs = SelectorEngine.find(SELECTOR_INPUT, this._element)
this._setupInputs()
this._addEventListeners()
}
// Getters
static get Default() {
return Default
}
static get DefaultType() {
return DefaultType
}
static get NAME() {
return NAME
}
// Public
getValue() {
return this._inputs.map(input => input.value).join('')
}
setValue(value) {
const chars = String(value).split('')
for (const [index, input] of this._inputs.entries()) {
input.value = chars[index] || ''
}
this._checkComplete()
}
clear() {
for (const input of this._inputs) {
input.value = ''
}
this._inputs[0]?.focus()
}
focus() {
// Focus first empty input, or last input if all filled
const emptyInput = this._inputs.find(input => !input.value)
if (emptyInput) {
emptyInput.focus()
} else {
this._inputs.at(-1)?.focus()
}
}
// Private
_setupInputs() {
for (const input of this._inputs) {
// Set attributes for proper OTP handling
input.setAttribute('maxlength', '1')
input.setAttribute('inputmode', 'numeric')
input.setAttribute('pattern', '\\d*')
// First input gets autocomplete for browser OTP autofill
if (input === this._inputs[0]) {
input.setAttribute('autocomplete', 'one-time-code')
} else {
input.setAttribute('autocomplete', 'off')
}
// Mask input if configured
if (this._config.mask) {
input.setAttribute('type', 'password')
}
}
}
_addEventListeners() {
for (const [index, input] of this._inputs.entries()) {
EventHandler.on(input, 'input', event => this._handleInput(event, index))
EventHandler.on(input, 'keydown', event => this._handleKeydown(event, index))
EventHandler.on(input, 'paste', event => this._handlePaste(event))
EventHandler.on(input, 'focus', event => this._handleFocus(event))
}
}
_handleInput(event, index) {
const input = event.target
// Only allow digits
if (!/^\d*$/.test(input.value)) {
input.value = input.value.replace(/\D/g, '')
}
const { value } = input
// Handle multi-character input (some browsers/autofill)
if (value.length > 1) {
// Distribute characters across inputs
const chars = value.split('')
input.value = chars[0] || ''
for (let i = 1; i < chars.length && index + i < this._inputs.length; i++) {
this._inputs[index + i].value = chars[i]
}
// Focus appropriate input
const nextIndex = Math.min(index + chars.length, this._inputs.length - 1)
this._inputs[nextIndex].focus()
} else if (value && index < this._inputs.length - 1) {
// Auto-advance to next input
this._inputs[index + 1].focus()
}
EventHandler.trigger(this._element, EVENT_INPUT, {
value: this.getValue(),
index
})
this._checkComplete()
}
_handleKeydown(event, index) {
const { key } = event
switch (key) {
case 'Backspace': {
if (!this._inputs[index].value && index > 0) {
// Move to previous input and clear it
event.preventDefault()
this._inputs[index - 1].value = ''
this._inputs[index - 1].focus()
}
break
}
case 'Delete': {
// Clear current and shift remaining values left
event.preventDefault()
for (let i = index; i < this._inputs.length - 1; i++) {
this._inputs[i].value = this._inputs[i + 1].value
}
this._inputs.at(-1).value = ''
break
}
case 'ArrowLeft': {
if (index > 0) {
event.preventDefault()
this._inputs[index - 1].focus()
}
break
}
case 'ArrowRight': {
if (index < this._inputs.length - 1) {
event.preventDefault()
this._inputs[index + 1].focus()
}
break
}
// No default
}
}
_handlePaste(event) {
event.preventDefault()
const pastedData = (event.clipboardData || window.clipboardData).getData('text')
const digits = pastedData.replace(/\D/g, '').slice(0, this._inputs.length)
if (digits) {
this.setValue(digits)
// Focus last filled input or last input
const lastIndex = Math.min(digits.length, this._inputs.length) - 1
this._inputs[lastIndex].focus()
}
}
_handleFocus(event) {
// Select the content on focus for easy replacement
event.target.select()
}
_checkComplete() {
const value = this.getValue()
const isComplete = value.length === this._inputs.length &&
this._inputs.every(input => input.value !== '')
if (isComplete) {
EventHandler.trigger(this._element, EVENT_COMPLETE, { value })
}
}
}
/**
* Data API implementation
*/
EventHandler.on(document, `DOMContentLoaded${EVENT_KEY}${DATA_API_KEY}`, () => {
for (const element of SelectorEngine.find(SELECTOR_DATA_OTP)) {
OtpInput.getOrCreateInstance(element)
}
})
export default OtpInput