/** * -------------------------------------------------------------------------- * Bootstrap otp-input.ts * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ import BaseComponent from './bootstrap/base-component' import EventHandler from './bootstrap/dom/event-handler' import SelectorEngine from './bootstrap/dom/selector-engine' import { initAll } from './bootstrap/util/component-functions' import type { ElementSelector } from './bootstrap/types' type OtpInputType = 'numeric' | 'alphanumeric' | 'alpha' type ComponentConfig = { groups: number[] | null length: number | null mask: boolean separator: string type: string } type ComponentConfigInput = Partial & Record /** * Constants */ const NAME = 'otpInput' const DATA_KEY = `bs.${NAME}` const EVENT_KEY = `.${DATA_KEY}` const EVENT_INPUT = `input${EVENT_KEY}` const EVENT_COMPLETE = `complete${EVENT_KEY}` 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 SELECTOR_DATA_TOGGLE = '[data-bs-toggle="otp"], [data-tblr-toggle="otp"]' const SELECTOR_INPUT = 'input' // Keeps the active-slot highlight in sync with the caret as it moves without typing const SYNC_EVENTS = ['blur', 'keyup', 'select'] // A touch focuses the input after `pointerup`, so the tap is only settled by `click` const POINTER_END_EVENTS = ['click', 'pointercancel'] const MASK_CHARACTER = '•' // Per-type input mode, validation pattern, and a filter that strips disallowed characters const TYPES: Record = { 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 }, } // Upper bound for the slot count, whatever the config asks for. const MAX_LENGTH = 32 const Default: ComponentConfig = { groups: null, length: null, mask: false, separator: '·', type: 'numeric', } const DefaultType: Record = { groups: '(array|null)', length: '(number|null)', mask: 'boolean', separator: 'string', type: 'string', } /** * Class definition * * A single real `` inside `.otp` is turned into a transparent overlay * once its value is rendered into one `.otp-slot` per character, so screen * readers, password managers and SMS autofill still see one ordinary field. */ class OtpInput extends BaseComponent { declare _element: HTMLElement declare _config: ComponentConfig _input!: HTMLInputElement _type!: (typeof TYPES)[OtpInputType] _length = 0 _wasComplete = false _slots: HTMLElement[] = [] _slotsContainer: HTMLElement | null = null // Tracks a tap so focus (fired natively by the browser) can respect the // clicked slot instead of jumping to the first empty one _pointerActive = false _pointerIndex = 0 _composedValue: string | null = null _onInput = (event: Event): void => { if ((event as InputEvent).isComposing) { return } const composed = this._composedValue this._composedValue = null if (composed !== null && composed === this._input.value) { return } this._handleInput() } _onCompositionEnd = (): void => { this._handleInput() this._composedValue = this._input.value } _onBeforeInput = (event: Event): void => this._handleBeforeInput(event as InputEvent) _onFocus = (): void => this._handleFocus() _onPointerDown = (event: Event): void => this._handlePointerDown(event as PointerEvent) _onPointerEnd = (): void => { this._pointerActive = false } _onSync = (): void => this._render() _onSelectionChange = (): void => { if (document.activeElement === this._input) { this._render() } } constructor(element: ElementSelector, config?: ComponentConfigInput) { super(element, config) if (!this._element) { return } const input = SelectorEngine.findOne(SELECTOR_INPUT, this._element) as HTMLInputElement | null if (!input) { return } this._input = input this._type = TYPES[this._config.type as OtpInputType] ?? TYPES.numeric this._length = this._resolveLength() this._setupInput() this._renderSlots() this._addEventListeners() this._render() this._wasComplete = this._input.value.length === this._length } // Getters static get Default(): ComponentConfig { return Default } static get DefaultType(): Record { return DefaultType } static 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._pointerActive = false this._input.focus() this._selectSlot(this._firstEmptyIndex()) this._render() } dispose(): void { if (!this._input) { super.dispose() return } this._input.removeEventListener('input', this._onInput) this._input.removeEventListener('beforeinput', this._onBeforeInput) this._input.removeEventListener('focus', this._onFocus) this._input.removeEventListener('pointerdown', this._onPointerDown) this._input.removeEventListener('compositionend', this._onCompositionEnd) for (const type of POINTER_END_EVENTS) { this._input.removeEventListener(type, this._onPointerEnd) } for (const type of SYNC_EVENTS) { this._input.removeEventListener(type, this._onSync) } document.removeEventListener('selectionchange', this._onSelectionChange) this._slotsContainer?.remove() this._element.classList.remove(CLASS_NAME_RENDERED) super.dispose() } // Private _resolveLength(): number { const candidates: unknown[] = [this._config.length, Number.parseInt(this._input.getAttribute('maxlength') ?? '', 10)] const { groups } = this._config if (Array.isArray(groups) && groups.length > 0) { candidates.push(groups.reduce((sum, group) => sum + Number(group), 0)) } // The first usable value wins; a negative, fractional, or huge length // would render no slots or flood the DOM. for (const candidate of candidates) { if (typeof candidate === 'number' && Number.isInteger(candidate) && candidate > 0) { return Math.min(candidate, MAX_LENGTH) } } return 6 } _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. // Only text-like types support the selection API the slots rely on. if (input.type !== 'text' && input.type !== 'tel') { 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') } if (input.value) { input.value = this._sanitize(input.value) } } _renderSlots(): void { const container = document.createElement('div') container.className = CLASS_NAME_SLOTS container.setAttribute('aria-hidden', 'true') const { groups, separator } = 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) if (groups && groups.length > 0) { inGroup++ if (inGroup === groups[groupIndex] && i < this._length - 1) { const separatorEl = document.createElement('div') separatorEl.className = CLASS_NAME_SEPARATOR separatorEl.textContent = separator container.append(separatorEl) groupIndex = Math.min(groupIndex + 1, groups.length - 1) inGroup = 0 } } } this._slotsContainer = container this._element.append(container) this._element.classList.add(CLASS_NAME_RENDERED) } _addEventListeners(): void { this._input.addEventListener('input', this._onInput) this._input.addEventListener('beforeinput', this._onBeforeInput) this._input.addEventListener('focus', this._onFocus) this._input.addEventListener('pointerdown', this._onPointerDown) this._input.addEventListener('compositionend', this._onCompositionEnd) for (const type of POINTER_END_EVENTS) { this._input.addEventListener(type, this._onPointerEnd) } document.addEventListener('selectionchange', this._onSelectionChange) for (const type of SYNC_EVENTS) { this._input.addEventListener(type, this._onSync) } } _handleFocus(): void { if (this._pointerActive) { // Wait for focus to settle before moving the caret, or iOS raises then // immediately dismisses the on-screen keyboard. this._pointerActive = false this._selectSlot(this._pointerIndex) this._render() return } this._selectSlot(this._firstEmptyIndex()) this._render() } // Bulk path: paste, SMS autofill, or a programmatic change land here as a // single multi-character `input` event. Single keystrokes are handled by // `_handleBeforeInput` (overwrite semantics) and never reach this method. _handleInput(): void { const sanitized = this._sanitize(this._input.value) if (sanitized !== this._input.value) { this._input.value = sanitized } if (document.activeElement === this._input) { this._selectSlot(this._firstEmptyIndex()) } this._afterValueChange() } // Intercepts single-character typing, Backspace and Delete so each slot is // overwritten in place rather than inserting and shifting the value. // Anything else (paste, autofill) falls through to `_handleInput`; IME // composition is sanitised once on `compositionend`. _handleBeforeInput(event: InputEvent): 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 end = Math.max(this._input.selectionEnd ?? index, index + 1) const chars = [...this._input.value] chars.splice(index, end - index, char) this._input.value = chars.join('').slice(0, this._length) this._selectSlot(index + 1) this._afterValueChange() return } if (inputType === 'deleteContentBackward') { event.preventDefault() const before = this._input.value const start = this._input.selectionStart ?? 0 const end = this._input.selectionEnd ?? start const chars = [...this._input.value] if (end > start) { chars.splice(start, end - start) this._input.value = chars.join('') this._selectSlot(start) } else if (start > 0) { chars.splice(start - 1, 1) this._input.value = chars.join('') this._selectSlot(start - 1) } this._afterValueChange(before) return } if (inputType === 'deleteContentForward') { event.preventDefault() const before = this._input.value const start = this._input.selectionStart ?? 0 const end = this._input.selectionEnd ?? start const chars = [...this._input.value] if (end > start) { chars.splice(start, end - start) this._input.value = chars.join('') } else if (start < chars.length) { chars.splice(start, 1) this._input.value = chars.join('') } this._selectSlot(start) this._afterValueChange(before) } } _handlePointerDown(event: PointerEvent): void { const index = this._slotIndexFromPoint(event.clientX) if (index === null) { return } const target = Math.min(index, this._firstEmptyIndex()) if (document.activeElement === this._input) { // Already focused: safe to take over caret placement, it won't dismiss // the on-screen keyboard. event.preventDefault() this._selectSlot(target) this._render() return } // Not yet focused: let the browser focus the input natively so a tap // raises the on-screen keyboard; the caret is placed once focus settles. this._pointerActive = true this._pointerIndex = target } // Maps a viewport x-coordinate to the slot under it, clamped to the last slot. _slotIndexFromPoint(x: number): number | null { const rtl = getComputedStyle(this._element).direction === 'rtl' for (const [index, slot] of this._slots.entries()) { const rect = slot.getBoundingClientRect() // RTL mirrors the visual slot order, so the boundary flips from the // right edge (v6, LTR-only) to the left edge. const reached = rtl ? x >= rect.left : x <= rect.right if (reached || index === this._slots.length - 1) { return index } } return null } // `before` is the value ahead of the edit: a Backspace in the first slot or // a Delete past the last one changes nothing and fires no event. _afterValueChange(before?: string): void { this._render() if (before !== undefined && before === this._input.value) { return } EventHandler.trigger(this._element, EVENT_INPUT, { value: this._input.value }) this._checkComplete() } _firstEmptyIndex(): number { return Math.min(this._input.value.length, this._length - 1) } // Represents the active slot as a selection: a filled slot is selected so // the next keystroke overwrites it; an empty slot gets a collapsed caret. _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) } _sanitize(value: string): string { return value.replace(this._type.filter, '').slice(0, this._length) } _render(): void { const { value } = this._input const isFocused = document.activeElement === this._input 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) } } // Fires once when the value becomes full, not on every edit of a full value. _checkComplete(): void { const { value } = this._input const complete = value.length === this._length if (complete && !this._wasComplete) { EventHandler.trigger(this._element, EVENT_COMPLETE, { value }) } this._wasComplete = complete } } /** * Data API implementation */ // js-docs-start otp-init initAll(SELECTOR_DATA_TOGGLE, OtpInput) // js-docs-end otp-init export default OtpInput