blob: cceac4b80f01522c2c4d2a24f7a76029cadef287 [file] [edit]
/**
* --------------------------------------------------------------------------
* Bootstrap otp-input.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import BaseComponent from './base-component.js'
import EventHandler, { type BootstrapEvent } from './dom/event-handler.js'
import SelectorEngine from './dom/selector-engine.js'
/**
* Constants
*/
const NAME = 'otpInput'
const DATA_KEY = 'bs.otpInput'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
const EVENT_COMPLETE = `complete${EVENT_KEY}`
const EVENT_INPUT = `input${EVENT_KEY}`
const EVENT_DOMCONTENT_LOADED = `DOMContentLoaded${EVENT_KEY}${DATA_API_KEY}`
const SELECTOR_DATA_OTP = '[data-bs-otp]'
const SELECTOR_INPUT = 'input'
// Events that should refresh the active-slot highlight as the caret moves
const SYNC_EVENTS = ['blur', 'keyup', 'select']
const CLASS_NAME_INPUT = 'otp-input'
const CLASS_NAME_RENDERED = 'otp-rendered'
const CLASS_NAME_SLOTS = 'otp-slots'
const CLASS_NAME_SLOT = 'otp-slot'
const CLASS_NAME_SLOT_FILLED = 'otp-slot-filled'
const CLASS_NAME_SLOT_ACTIVE = 'otp-slot-active'
const CLASS_NAME_SEPARATOR = 'otp-separator'
const MASK_CHARACTER = '•'
// Per-type input mode, validation pattern, and a filter that strips disallowed characters
const TYPES = {
numeric: { inputmode: 'numeric', pattern: '[0-9]*', filter: /[^0-9]/g },
alphanumeric: { inputmode: 'text', pattern: '[A-Za-z0-9]*', filter: /[^A-Za-z0-9]/g },
alpha: { inputmode: 'text', pattern: '[A-Za-z]*', filter: /[^A-Za-z]/g }
}
type OtpInputConfig = {
groups: number[] | null
length: number | null
mask: boolean
separator: string
type: string
}
const Default: OtpInputConfig = {
groups: null,
length: null,
mask: false,
separator: '·',
type: 'numeric'
}
const DefaultType = {
groups: '(array|null)',
length: '(number|null)',
mask: 'boolean',
separator: 'string',
type: 'string'
}
/**
* Class definition
*/
class OtpInput extends BaseComponent {
protected declare _config: OtpInputConfig
protected declare _input: HTMLInputElement
protected declare _type: { inputmode: string, pattern: string, filter: RegExp }
protected declare _length: number
protected declare _slots: HTMLElement[]
protected declare _pointerActive: boolean
protected declare _pointerIndex: number
protected declare _slotsContainer: HTMLElement
protected declare _onInput: () => void
protected declare _onBeforeInput: (event: BootstrapEvent) => void
protected declare _onFocus: () => void
protected declare _onPointerDown: (event: BootstrapEvent) => void
protected declare _onSync: () => void
protected declare _onSelectionChange: () => void
constructor(element?: string | Element | null, config?: Partial<OtpInputConfig> | null) {
super(element, config)
const input = SelectorEngine.findOne<HTMLInputElement>(SELECTOR_INPUT, this._element)
if (!input) {
return
}
this._input = input
this._type = TYPES[this._config.type as keyof typeof TYPES] || TYPES.numeric
this._length = this._resolveLength()
this._slots = []
// Tracks whether focus was triggered by a click so we can respect the
// clicked slot instead of jumping to the first empty one
this._pointerActive = false
// Slot index from the most recent tap, applied once focus settles
this._pointerIndex = 0
this._setupInput()
this._renderSlots()
this._addEventListeners()
this._render()
}
// Getters
static override get Default(): OtpInputConfig {
return Default
}
static override get DefaultType(): Record<string, string> {
return DefaultType
}
static override get NAME(): string {
return NAME
}
// Public
getValue(): string {
return this._input.value
}
setValue(value: string | number): void {
this._input.value = this._sanitize(String(value))
this._render()
this._checkComplete()
}
clear(): void {
this._input.value = ''
this._render()
this._input.focus()
}
focus(): void {
this._input.focus()
// Select the first empty slot (or the last one when the value is full)
this._selectSlot(this._firstEmptyIndex())
this._render()
}
override dispose(): void {
EventHandler.off(this._input, 'input', this._onInput)
EventHandler.off(this._input, 'beforeinput', this._onBeforeInput)
EventHandler.off(this._input, 'focus', this._onFocus)
EventHandler.off(this._input, 'pointerdown', this._onPointerDown)
EventHandler.off(document, 'selectionchange', this._onSelectionChange)
for (const type of SYNC_EVENTS) {
EventHandler.off(this._input, type, this._onSync)
}
this._slotsContainer?.remove()
this._element.classList.remove(CLASS_NAME_RENDERED)
super.dispose()
}
// Private
protected _resolveLength(): number {
if (this._config.length) {
return this._config.length
}
const maxLength = Number.parseInt(this._input.getAttribute('maxlength')!, 10)
return Number.isNaN(maxLength) || maxLength < 1 ? 6 : maxLength
}
protected _setupInput(): void {
const input = this._input
// A single text field backs the whole control so screen readers, password
// managers, and SMS autofill treat it like any other input.
if (input.type === 'number' || input.type === 'password') {
input.type = 'text'
}
input.classList.add(CLASS_NAME_INPUT)
input.setAttribute('maxlength', String(this._length))
input.setAttribute('inputmode', this._type.inputmode)
input.setAttribute('pattern', this._type.pattern)
if (!input.getAttribute('autocomplete')) {
input.setAttribute('autocomplete', 'one-time-code')
}
// Filter any pre-filled value through the configured type
if (input.value) {
input.value = this._sanitize(input.value)
}
}
protected _renderSlots(): void {
const container = document.createElement('div')
container.className = CLASS_NAME_SLOTS
container.setAttribute('aria-hidden', 'true')
const { groups } = this._config
let groupIndex = 0
let inGroup = 0
for (let i = 0; i < this._length; i++) {
const slot = document.createElement('div')
slot.className = CLASS_NAME_SLOT
container.append(slot)
this._slots.push(slot)
// Insert a visual separator between configured groups
if (Array.isArray(groups) && groups.length > 0) {
inGroup++
if (inGroup === groups[groupIndex] && i < this._length - 1) {
const separator = document.createElement('div')
separator.className = CLASS_NAME_SEPARATOR
separator.textContent = this._config.separator
container.append(separator)
groupIndex = Math.min(groupIndex + 1, groups.length - 1)
inGroup = 0
}
}
}
this._slotsContainer = container
this._element.append(container)
this._element.classList.add(CLASS_NAME_RENDERED)
}
protected _addEventListeners(): void {
// Listeners are attached with bare event names (not namespaced) because
// `input`, `beforeinput`, and `selectionchange` are not in EventHandler's
// native-events list; we keep references so they can be removed on dispose.
this._onInput = () => this._handleInput()
this._onBeforeInput = event => this._handleBeforeInput(event)
this._onPointerDown = event => this._handlePointerDown(event)
this._onFocus = () => {
if (this._pointerActive) {
// A tap focused the input natively; position the caret on the clicked
// slot now that focus has settled (doing this before native focus would
// make iOS/iPadOS raise then immediately dismiss the keyboard)
this._pointerActive = false
this._selectSlot(this._pointerIndex)
this._render()
return
}
// Keyboard (Tab) focus lands on the first empty slot
this._selectSlot(this._firstEmptyIndex())
this._render()
}
this._onSync = () => this._render()
this._onSelectionChange = () => {
if (document.activeElement === this._input) {
this._render()
}
}
EventHandler.on(this._input, 'input', this._onInput)
EventHandler.on(this._input, 'beforeinput', this._onBeforeInput)
EventHandler.on(this._input, 'focus', this._onFocus)
EventHandler.on(this._input, 'pointerdown', this._onPointerDown)
EventHandler.on(document, 'selectionchange', this._onSelectionChange)
// Keep the active-slot highlight in sync with the caret
for (const type of SYNC_EVENTS) {
EventHandler.on(this._input, type, this._onSync)
}
}
// Bulk path: paste, SMS autofill, or a programmatic value change land here as
// a single multi-character `input` event. Single keystrokes are handled by
// `_handleBeforeInput` (overwrite semantics) and never reach this method.
protected _handleInput(): void {
const sanitized = this._sanitize(this._input.value)
if (sanitized !== this._input.value) {
this._input.value = sanitized
}
// Place the caret on the first empty slot after a paste/autofill
if (document.activeElement === this._input) {
this._selectSlot(this._firstEmptyIndex())
}
this._afterValueChange()
}
// Intercept single-character typing and backspace so each slot is overwritten
// in place rather than inserting and shifting the rest of the value. Anything
// else (paste, autofill, IME composition) falls through to `_handleInput`.
protected _handleBeforeInput(event: BootstrapEvent): void {
const { inputType, data } = event
if (inputType === 'insertText' && data && data.length === 1) {
event.preventDefault()
const char = this._sanitize(data)
if (!char) {
return
}
const index = Math.min(this._input.selectionStart ?? 0, this._length - 1)
const chars = [...this._input.value]
chars[index] = char
this._input.value = chars.join('').slice(0, this._length)
this._selectSlot(index + 1)
this._afterValueChange()
return
}
if (inputType === 'deleteContentBackward') {
event.preventDefault()
const start = this._input.selectionStart ?? 0
const end = this._input.selectionEnd ?? start
const chars = [...this._input.value]
if (end > start) {
// A filled slot is selected: clear it and keep the caret in place
chars.splice(start, end - start)
this._input.value = chars.join('')
this._selectSlot(start)
} else if (start > 0) {
// Collapsed caret: remove the previous character and step back
chars.splice(start - 1, 1)
this._input.value = chars.join('')
this._selectSlot(start - 1)
}
this._afterValueChange()
}
}
protected _handlePointerDown(event: BootstrapEvent): void {
const index = this._slotIndexFromPoint(event.clientX)
if (index === null) {
return
}
// Don't let the caret land past the first empty slot
const target = Math.min(index, this._firstEmptyIndex())
if (document.activeElement === this._input) {
// Already focused (keyboard is up): take over caret placement from the
// browser. Safe to preventDefault here — it won't dismiss the keyboard.
event.preventDefault()
this._selectSlot(target)
this._render()
return
}
// Not yet focused: let the browser focus the input natively so the
// on-screen keyboard is raised by the user's tap. Position the caret in the
// focus handler once focus settles.
this._pointerActive = true
this._pointerIndex = target
}
// Map a viewport x-coordinate to the slot under it, clamped to the last slot
protected _slotIndexFromPoint(x: number): number | null {
for (const [index, slot] of this._slots.entries()) {
if (x <= slot.getBoundingClientRect().right || index === this._slots.length - 1) {
return index
}
}
return null
}
protected _afterValueChange(): void {
this._render()
EventHandler.trigger(this._element, EVENT_INPUT, { value: this._input.value })
this._checkComplete()
}
protected _firstEmptyIndex(): number {
return Math.min(this._input.value.length, this._length - 1)
}
// Represent the active slot as a selection: a filled slot is selected so the
// next keystroke overwrites it; an empty slot gets a collapsed caret.
protected _selectSlot(index: number): void {
const clamped = Math.max(0, Math.min(index, this._length - 1))
const end = clamped < this._input.value.length ? clamped + 1 : clamped
this._input.setSelectionRange(clamped, end)
}
protected _sanitize(value: string): string {
return value.replace(this._type.filter, '').slice(0, this._length)
}
protected _render(): void {
const { value } = this._input
const isFocused = document.activeElement === this._input
// The active slot follows the caret, clamped to the last slot when the value is full
const caret = Math.min(this._input.selectionStart ?? value.length, this._length - 1)
for (const [index, slot] of this._slots.entries()) {
const char = value[index] ?? ''
slot.textContent = char && this._config.mask ? MASK_CHARACTER : char
slot.classList.toggle(CLASS_NAME_SLOT_FILLED, Boolean(char))
slot.classList.toggle(CLASS_NAME_SLOT_ACTIVE, isFocused && index === caret)
}
}
protected _checkComplete(): void {
const { value } = this._input
if (value.length === this._length) {
EventHandler.trigger(this._element, EVENT_COMPLETE, { value })
}
}
}
/**
* Data API implementation
*/
EventHandler.on(document, EVENT_DOMCONTENT_LOADED, () => {
for (const element of SelectorEngine.find(SELECTOR_DATA_OTP)) {
OtpInput.getOrCreateInstance(element)
}
})
export default OtpInput
export type { OtpInputConfig }