Add OTP input component (#3056)

This commit is contained in:
Bartosz-Do authored and GitHub committed 2026-09-15 22:34:25 +02:00
1 parent e63fe34e9c
commit a744b8407f
17 files changed
+1263 -83

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
"@tabler/core": minor
"@tabler/preview": minor
"@tabler/docs": minor
---
Added the `OtpInput` component with grouped slots, masking, and validation states.
+433
View File
@@ -0,0 +1,433 @@
/**
* --------------------------------------------------------------------------
* 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 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<ComponentConfig> & Record<string, unknown>
/**
* 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']
const MASK_CHARACTER = '•'
// Per-type input mode, validation pattern, and a filter that strips disallowed characters
const TYPES: Record<OtpInputType, { inputmode: string; pattern: string; filter: RegExp }> = {
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 },
}
const Default: ComponentConfig = {
groups: null,
length: null,
mask: false,
separator: '·',
type: 'numeric',
}
const DefaultType: Record<keyof ComponentConfig, string> = {
groups: '(array|null)',
length: '(number|null)',
mask: 'boolean',
separator: 'string',
type: 'string',
}
/**
* Class definition
*
* A single real `<input>` 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
_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
_onInput = (): void => this._handleInput()
_onBeforeInput = (event: Event): void => this._handleBeforeInput(event as InputEvent)
_onFocus = (): void => this._handleFocus()
_onPointerDown = (event: Event): void => this._handlePointerDown(event as PointerEvent)
_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()
}
// Getters
static get Default(): ComponentConfig {
return Default
}
static get DefaultType(): Record<keyof ComponentConfig, string> {
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._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)
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 {
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
}
_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')
}
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)
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 and backspace so each slot is
// overwritten in place rather than inserting and shifting the value.
// Anything else (paste, autofill, IME) falls through to `_handleInput`.
_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 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) {
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()
}
}
_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
}
_afterValueChange(): void {
this._render()
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)
}
}
_checkComplete(): void {
const { value } = this._input
if (value.length === this._length) {
EventHandler.trigger(this._element, EVENT_COMPLETE, { value })
}
}
}
/**
* Data API implementation
*/
// js-docs-start otp-init
for (const element of SelectorEngine.find(SELECTOR_DATA_TOGGLE)) {
OtpInput.getOrCreateInstance(element)
}
// js-docs-end otp-init
export default OtpInput
+2
View File
@@ -10,6 +10,7 @@ import './src/switch-icon'
import './src/tab'
import './src/toast'
import './src/sortable'
import './src/otp-input'
import './src/sparkline'
import './src/strength'
@@ -21,6 +22,7 @@ export { default as Autosize } from './src/autosize'
export { default as Confetti } from './src/confetti'
export { default as CountUp } from './src/countup'
export { default as InputMask } from './src/input-mask'
export { default as OtpInput } from './src/otp-input'
export { default as Sortable } from './src/sortable'
export { default as Sparkline } from './src/sparkline'
export { default as Strength } from './src/strength'
+342
View File
@@ -0,0 +1,342 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import { clearFixture, getFixture } from '../helpers/fixture'
import OtpInput from '../../src/otp-input'
const beforeInput = (input: HTMLInputElement, options: { inputType: string; data?: string }): InputEvent => {
const event = new InputEvent('beforeinput', { cancelable: true, bubbles: true, ...options })
input.dispatchEvent(event)
return event
}
const typeInto = (input: HTMLInputElement, value: string): void => {
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
}
const pointerDownOnSlot = (input: HTMLInputElement, slot: HTMLElement): void => {
const rect = slot.getBoundingClientRect()
input.dispatchEvent(new PointerEvent('pointerdown', { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, cancelable: true, bubbles: true }))
}
// The test page loads no stylesheet, so `.otp-slot` has no real flex layout;
// force a horizontal row of fixed-width slots for coordinate-based hit tests.
const layoutSlotsHorizontally = (container: HTMLElement, slotWidth = 40): void => {
container.style.display = 'inline-flex'
for (const slot of container.querySelectorAll<HTMLElement>('.otp-slot')) {
slot.style.display = 'inline-block'
slot.style.flex = 'none'
slot.style.width = `${slotWidth}px`
}
}
describe('OtpInput', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
beforeEach(() => {
fixtureEl.innerHTML = '<div class="otp" data-bs-toggle="otp"><input type="text" /></div>'
})
afterEach(() => {
clearFixture()
})
const otp = (): HTMLElement => fixtureEl.querySelector('.otp')!
const input = (): HTMLInputElement => fixtureEl.querySelector('input')!
const slots = (): HTMLElement[] => [...fixtureEl.querySelectorAll('.otp-slot')]
describe('NAME', () => {
it('should return the plugin name', () => {
expect(OtpInput.NAME).toBe('otpInput')
})
})
describe('constructor', () => {
it('should default the length to 6 with no maxlength or option', () => {
new OtpInput(otp())
expect(slots()).toHaveLength(6)
expect(input().getAttribute('maxlength')).toBe('6')
})
it('should read the length from the native maxlength', () => {
input().setAttribute('maxlength', '4')
new OtpInput(otp())
expect(slots()).toHaveLength(4)
})
it('should let data-bs-length override maxlength', () => {
input().setAttribute('maxlength', '4')
otp().setAttribute('data-bs-length', '8')
new OtpInput(otp())
expect(slots()).toHaveLength(8)
expect(input().getAttribute('maxlength')).toBe('8')
})
it('should set inputmode and pattern from the type option', () => {
otp().setAttribute('data-bs-type', 'alphanumeric')
new OtpInput(otp())
expect(input().getAttribute('inputmode')).toBe('text')
expect(input().getAttribute('pattern')).toBe('[A-Za-z0-9]*')
})
it('should default autocomplete to one-time-code', () => {
new OtpInput(otp())
expect(input().getAttribute('autocomplete')).toBe('one-time-code')
})
it('should keep an author-supplied autocomplete', () => {
input().setAttribute('autocomplete', 'off')
new OtpInput(otp())
expect(input().getAttribute('autocomplete')).toBe('off')
})
it('should mark the wrapper as rendered', () => {
new OtpInput(otp())
expect(otp().classList.contains('otp-rendered')).toBe(true)
})
it('should sanitize a pre-filled value', () => {
input().value = '12-34'
new OtpInput(otp())
expect(input().value).toBe('1234')
})
it('should do nothing without an inner input', () => {
fixtureEl.innerHTML = '<div class="otp" data-bs-toggle="otp"></div>'
expect(() => new OtpInput(otp())).not.toThrow()
})
})
describe('value handling', () => {
it('getValue should reflect the input value', () => {
const instance = new OtpInput(otp())
typeInto(input(), '123456')
expect(instance.getValue()).toBe('123456')
})
it('setValue should render into the slots', () => {
const instance = new OtpInput(otp())
instance.setValue('123456')
expect(slots().map((slot) => slot.textContent)).toEqual(['1', '2', '3', '4', '5', '6'])
})
it('setValue should sanitize and truncate', () => {
const instance = new OtpInput(otp())
instance.setValue('1a2b3c4d5e6f7g')
expect(instance.getValue()).toBe('123456')
})
it('setValue should accept a number', () => {
const instance = new OtpInput(otp())
instance.setValue(123)
expect(instance.getValue()).toBe('123')
})
it('clear should empty the value and focus the input', () => {
const instance = new OtpInput(otp())
instance.setValue('123456')
instance.clear()
expect(instance.getValue()).toBe('')
expect(document.activeElement).toBe(input())
})
})
describe('input handling', () => {
it('should strip disallowed characters from a pasted value', () => {
new OtpInput(otp())
typeInto(input(), '123-456')
expect(input().value).toBe('123456')
})
it('should keep letters for an alphanumeric type', () => {
otp().setAttribute('data-bs-type', 'alphanumeric')
new OtpInput(otp())
typeInto(input(), 'aB3-xZ9')
expect(input().value).toBe('aB3xZ9')
})
it('should truncate a pasted value to the slot count', () => {
otp().setAttribute('data-bs-length', '4')
new OtpInput(otp())
typeInto(input(), '123456')
expect(input().value).toBe('1234')
})
})
describe('interaction', () => {
it('should overwrite the active slot when typing', () => {
new OtpInput(otp())
input().focus()
beforeInput(input(), { inputType: 'insertText', data: '1' })
beforeInput(input(), { inputType: 'insertText', data: '2' })
expect(input().value).toBe('12')
expect(input().selectionStart).toBe(2)
})
it('should swallow a disallowed character on beforeinput', () => {
new OtpInput(otp())
input().focus()
const event = beforeInput(input(), { inputType: 'insertText', data: '#' })
expect(event.defaultPrevented).toBe(true)
expect(input().value).toBe('')
})
it('should delete the previous character on backspace with a collapsed caret', () => {
const instance = new OtpInput(otp())
instance.setValue('123')
input().setSelectionRange(3, 3)
beforeInput(input(), { inputType: 'deleteContentBackward' })
expect(input().value).toBe('12')
expect(input().selectionStart).toBe(2)
})
it('should clear a selected slot on backspace', () => {
const instance = new OtpInput(otp())
instance.setValue('123')
input().setSelectionRange(1, 2)
beforeInput(input(), { inputType: 'deleteContentBackward' })
expect(input().value).toBe('13')
expect(input().selectionStart).toBe(1)
})
it('should do nothing on backspace at the start of an empty field', () => {
new OtpInput(otp())
input().setSelectionRange(0, 0)
const event = beforeInput(input(), { inputType: 'deleteContentBackward' })
expect(event.defaultPrevented).toBe(true)
expect(input().value).toBe('')
})
it('should select the first empty slot on keyboard focus', () => {
const instance = new OtpInput(otp())
instance.setValue('12')
input().focus()
expect(input().selectionStart).toBe(2)
expect(input().selectionEnd).toBe(2)
})
it('should select the last slot on focus when the value is full', () => {
const instance = new OtpInput(otp())
instance.setValue('123456')
input().focus()
expect(input().selectionStart).toBe(5)
expect(input().selectionEnd).toBe(6)
})
it('should position the caret from a tap on a slot', () => {
const instance = new OtpInput(otp())
layoutSlotsHorizontally(otp())
instance.setValue('123456')
input().focus()
pointerDownOnSlot(input(), slots()[2])
expect(input().selectionStart).toBe(2)
})
it('should clamp a tap past the filled slots to the first empty one', () => {
const instance = new OtpInput(otp())
layoutSlotsHorizontally(otp())
instance.setValue('12')
input().focus()
pointerDownOnSlot(input(), slots()[5])
expect(input().selectionStart).toBe(2)
})
it('should mirror the tap position in RTL', () => {
otp().setAttribute('dir', 'rtl')
const instance = new OtpInput(otp())
layoutSlotsHorizontally(otp())
instance.setValue('123456')
input().focus()
pointerDownOnSlot(input(), slots()[4])
expect(input().selectionStart).toBe(4)
})
})
describe('mask', () => {
it('should render slots as the mask character while keeping the real value', () => {
otp().setAttribute('data-bs-mask', 'true')
const instance = new OtpInput(otp())
instance.setValue('123456')
expect(instance.getValue()).toBe('123456')
expect(slots().every((slot) => slot.textContent === '•')).toBe(true)
})
it('should never switch the input to type=password', () => {
otp().setAttribute('data-bs-mask', 'true')
new OtpInput(otp())
expect(input().type).toBe('text')
})
})
describe('groups and separator', () => {
it('should render one separator between two groups', () => {
otp().setAttribute('data-bs-groups', '[3,3]')
new OtpInput(otp())
expect(slots()).toHaveLength(6)
expect(fixtureEl.querySelectorAll('.otp-separator')).toHaveLength(1)
})
it('should use a custom separator character', () => {
otp().setAttribute('data-bs-groups', '[3,3]')
otp().setAttribute('data-bs-separator', '-')
new OtpInput(otp())
expect(fixtureEl.querySelector('.otp-separator')!.textContent).toBe('-')
})
})
describe('events', () => {
it('should trigger complete.bs.otpInput when the value fills every slot', () => {
new OtpInput(otp())
const spy = vi.fn()
otp().addEventListener('complete.bs.otpInput', spy)
typeInto(input(), '123456')
expect(spy).toHaveBeenCalledTimes(1)
expect((spy.mock.calls[0][0] as Event & { value: string }).value).toBe('123456')
})
it('should trigger input.bs.otpInput on every change', () => {
new OtpInput(otp())
const spy = vi.fn()
otp().addEventListener('input.bs.otpInput', spy)
typeInto(input(), '12')
expect(spy).toHaveBeenCalledTimes(1)
expect((spy.mock.calls[0][0] as Event & { value: string }).value).toBe('12')
})
it('should not trigger input.bs.otpInput from setValue', () => {
const instance = new OtpInput(otp())
const spy = vi.fn()
otp().addEventListener('input.bs.otpInput', spy)
instance.setValue('123456')
expect(spy).not.toHaveBeenCalled()
})
})
describe('dispose', () => {
it('should remove the rendered slots and instance', () => {
const instance = new OtpInput(otp())
instance.dispose()
expect(OtpInput.getInstance(otp())).toBeNull()
expect(fixtureEl.querySelectorAll('.otp-slot')).toHaveLength(0)
expect(otp().classList.contains('otp-rendered')).toBe(false)
})
it('should stop reacting to input after dispose', () => {
const instance = new OtpInput(otp())
instance.dispose()
const event = beforeInput(input(), { inputType: 'insertText', data: '1' })
expect(event.defaultPrevented).toBe(false)
})
})
})
+2 -2
View File
@@ -86,7 +86,7 @@
},
{
"path": "./dist/css/tabler.min.css",
"maxSize": "78 kB"
"maxSize": "79 kB"
},
{
"path": "./dist/css/tabler.rtl.css",
@@ -94,7 +94,7 @@
},
{
"path": "./dist/css/tabler.rtl.min.css",
"maxSize": "78 kB"
"maxSize": "79 kB"
},
{
"path": "./dist/css/tabler-flags.css",
+1
View File
@@ -66,6 +66,7 @@
@forward 'ui/forms/form-selectgroup';
@forward 'ui/forms/form-custom';
@forward 'ui/forms/form-check';
@forward 'ui/forms/form-otp';
@forward 'ui/forms/validation';
@forward 'ui/forms/strength';
@forward 'ui/legend';
+14
View File
@@ -1232,6 +1232,20 @@ $input-height-sm: add($input-line-height-sm, add(calc($input-padding-y-sm * 2),
$input-height-lg: add($input-line-height-lg, add(calc($input-padding-y-lg * 2), $input-height-border, false)) !default;
// scss-docs-end input-variables
// OTP input
// scss-docs-start otp-variables
$otp-size: $input-height !default;
$otp-font-size: $input-font-size !default;
$otp-gap: 0.5rem !default;
$otp-slot-color: var(--body-color) !default;
$otp-slot-bg: $input-bg !default;
$otp-slot-border-width: $input-border-width !default;
$otp-slot-border-color: var(--border-color) !default;
$otp-slot-border-radius: $input-border-radius !default;
$otp-slot-box-shadow: var(--shadow-input) !default;
$otp-separator-color: var(--secondary) !default;
// scss-docs-end otp-variables
// Cards
// scss-docs-start card-variables
$card-spacer-y: 1.25rem !default;
+2 -1
View File
@@ -28,7 +28,8 @@
.btn-animate-icon-tada .icon,
.wave-1,
.wave-2,
.wave-3 {
.wave-3,
.otp-slot-active:not(.otp-slot-filled)::after {
animation: none !important;
}
+169
View File
@@ -0,0 +1,169 @@
@use '../../config' as *;
.otp {
--otp-size: #{$otp-size};
--otp-font-size: #{$otp-font-size};
--otp-gap: #{$otp-gap};
--otp-slot-color: #{$otp-slot-color};
--otp-slot-bg: #{$otp-slot-bg};
--otp-slot-border-width: #{$otp-slot-border-width};
--otp-slot-border-color: #{$otp-slot-border-color};
--otp-slot-border-radius: #{$otp-slot-border-radius};
--otp-slot-box-shadow: #{$otp-slot-box-shadow};
--otp-separator-color: #{$otp-separator-color};
position: relative;
display: inline-flex;
gap: var(--otp-gap);
align-items: center;
min-height: var(--otp-size);
// `.otp` is the only content on its line, so its own baseline (derived from
// whichever slot the flex baseline algorithm picks) never has to matter -
// otherwise an empty vs. active slot resolve to different baselines and the
// line box around `.otp` grows or shrinks by a few pixels between them.
vertical-align: bottom;
}
// Before the script renders the slots, the input keeps a `.form-control`-like
// look and the `.otp` wrapper's `min-height` already matches it - no layout
// shift once `.otp-rendered .otp-input` below takes over.
.otp > input {
width: 100%;
height: var(--otp-size);
padding-inline: 0.75rem;
font-size: var(--otp-font-size);
color: var(--otp-slot-color);
background-color: var(--otp-slot-bg);
border: var(--otp-slot-border-width) solid var(--otp-slot-border-color);
box-shadow: var(--otp-slot-box-shadow);
@include border-radius(var(--otp-slot-border-radius));
}
// The real input is a transparent overlay on top of the rendered slots, so
// screen readers, password managers and SMS autofill see one ordinary field.
.otp-rendered .otp-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
color: transparent;
cursor: text;
caret-color: transparent;
background-color: transparent;
border: 0;
// The base `.otp > input` rule sets this for the pre-render fallback look;
// the overlay itself must stay invisible, or its shadow spans every slot.
box-shadow: none;
// The active slot draws its own focus ring; the native outline would
// otherwise wrap the whole overlay input, spanning every slot at once.
&:focus {
outline: 0;
}
&::selection {
background-color: transparent;
}
}
.otp-slots {
display: inline-flex;
gap: var(--otp-gap);
align-items: center;
pointer-events: none;
}
.otp-slot {
position: relative;
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: var(--otp-size);
height: var(--otp-size);
font-size: var(--otp-font-size);
color: var(--otp-slot-color);
background-color: var(--otp-slot-bg);
border: var(--otp-slot-border-width) solid var(--otp-slot-border-color);
box-shadow: var(--otp-slot-box-shadow);
@include border-radius(var(--otp-slot-border-radius));
transition:
border-color 0.15s ease-in-out,
box-shadow 0.15s ease-in-out;
}
.otp-slot-active {
// Bring the focused slot to the front so its ring isn't clipped by the
// next slot in `.otp-connected`, where slots overlap by their border width.
z-index: 1;
@include focus-ring($show-border: true);
&:not(.otp-slot-filled)::after {
display: block;
width: 1px;
height: 1.2em;
content: '';
background-color: var(--otp-slot-color);
animation: otp-caret-blink 1s step-end infinite;
}
}
.otp-input:disabled ~ .otp-slots .otp-slot {
color: var(--disabled-color);
background-color: $input-disabled-bg;
}
// Zero gap with shared, merged borders; only the first and last slot keep a
// rounded corner. Not meant to be combined with `groups`/`separator`.
.otp-connected {
--otp-gap: 0;
.otp-slot {
margin-inline-start: calc(var(--otp-slot-border-width) * -1);
border-radius: 0;
&:first-child {
margin-inline-start: 0;
border-start-start-radius: var(--otp-slot-border-radius);
border-end-start-radius: var(--otp-slot-border-radius);
}
&:last-child {
border-start-end-radius: var(--otp-slot-border-radius);
border-end-end-radius: var(--otp-slot-border-radius);
}
}
}
.otp-separator {
display: flex;
flex: none;
align-items: center;
justify-content: center;
padding-inline: calc(var(--otp-gap) / 2);
color: var(--otp-separator-color);
}
.otp-sm {
--otp-size: #{$input-height-sm};
--otp-font-size: #{$input-font-size-sm};
}
.otp-lg {
--otp-size: #{$input-height-lg};
--otp-font-size: #{$input-font-size-lg};
}
@keyframes otp-caret-blink {
0%,
50% {
opacity: 1;
}
50.01%,
100% {
opacity: 0;
}
}
+5
View File
@@ -1,3 +1,4 @@
@use 'sass:map';
@use '../../config' as *;
%validation-lite {
@@ -70,4 +71,8 @@
textarea.form-control.is-#{$state} {
@extend %validation-icon-size-textarea;
}
.otp.is-#{$state} .otp-slot {
--otp-slot-border-color: #{map.get($data, 'border-color')};
}
}
+172
View File
@@ -0,0 +1,172 @@
---
title: OTP input
seoTitle: OTP input field
order: 13
summary: A single input rendered as separate character slots, so a one-time password stays one real field for autofill, password managers and screen readers.
description: Add a one-time password (OTP) input with grouped slots, masking, and keyboard, paste and SMS autofill support.
related: [/ui/forms/elements, /ui/forms/validation]
source: core/scss/ui/forms/_form-otp.scss
classnames:
component:
- class: otp
desc: Wrapper; holds the real input and the rendered slots
part:
- class: otp-slots
desc: Container for the rendered, decorative slots
- class: otp-slot
desc: One character slot
- class: otp-separator
desc: Divider shown between groups
modifier:
- class: otp-connected
desc: Merges the slots into one bar with shared borders and no gap
size:
- class: otp-sm
desc: Smaller slots
- class: otp-lg
desc: Larger slots
---
import Example from '@components/Example.astro';
import CodeDocs from '@components/CodeDocs.astro';
## Overview
An OTP input looks like several boxes, but it is one real `<input>`. JavaScript renders one `.otp-slot` per character on top of it and turns the field itself into a transparent overlay, so screen readers, password managers and SMS autofill still see a single ordinary text field.
Add `data-bs-toggle="otp"` to a wrapper with a text input inside, and Tabler renders the slots for you.
<Example column>
<div class="otp" data-bs-toggle="otp" data-bs-length="6">
<input type="text" aria-label="Verification code" />
</div>
</Example>
## Usage
### Connected slots
Add `.otp-connected` to merge the slots into one bar with shared borders and no gap between them.
<Example column>
<div class="otp otp-connected" data-bs-toggle="otp" data-bs-length="6">
<input type="text" aria-label="Verification code" />
</div>
</Example>
### Groups and a separator
Use `data-bs-groups` to split the slots into visual groups, for example `[3,3]` for a "123 456" layout. `data-bs-separator` sets the character shown between groups; it defaults to `·`.
<Example column>
<div class="otp" data-bs-toggle="otp" data-bs-length="6" data-bs-groups="[3,3]">
<input type="text" aria-label="Verification code" />
</div>
</Example>
### Sizes
Add `.otp-sm` or `.otp-lg` for a smaller or larger control.
<Example column>
<div class="otp otp-sm" data-bs-toggle="otp" data-bs-length="4">
<input type="text" aria-label="PIN" />
</div>
<div class="otp otp-lg" data-bs-toggle="otp" data-bs-length="4">
<input type="text" aria-label="PIN" />
</div>
</Example>
### Alphanumeric and masked
Set `data-bs-type` to `alphanumeric` or `alpha` to accept letters as well as digits. Add `data-bs-mask="true"` to show a mask character instead of the typed value - `getValue()` still returns the real value.
<Example column>
<div class="otp" data-bs-toggle="otp" data-bs-length="6" data-bs-type="alphanumeric" data-bs-mask="true">
<input type="text" aria-label="Recovery code" />
</div>
</Example>
### Disabled
Add `disabled` to the inner input.
<Example column>
<div class="otp" data-bs-toggle="otp" data-bs-length="6">
<input type="text" value="123456" disabled aria-label="Verification code" />
</div>
</Example>
### Validation
Add `.is-valid` or `.is-invalid` to the `.otp` wrapper, the same way you would on a `.form-control`.
<Example column>
<div class="otp is-invalid" data-bs-toggle="otp" data-bs-length="6">
<input type="text" aria-label="Verification code" />
</div>
</Example>
## Options
Options are read from `data-bs-*` attributes on the `.otp` element (the `data-tblr-*` alias also works), or passed to the constructor.
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `length` | number | `null` | Number of slots. When `null`, it is read from the input's `maxlength`, or `6`. |
| `type` | string | `'numeric'` | `numeric`, `alphanumeric`, or `alpha` - which characters each slot accepts. |
| `mask` | boolean | `false` | Shows a mask character in filled slots instead of the real value. |
| `groups` | array or null | `null` | Slot counts per visual group, for example `[3, 3]`. |
| `separator` | string | `'·'` | Character shown between groups. |
## JavaScript
Tabler creates an `OtpInput` for every element matching `[data-bs-toggle="otp"]` (or `[data-tblr-toggle="otp"]`):
<CodeDocs name="otp-init" file="core/js/src/otp-input.ts" />
Create one manually, or get an existing instance:
```javascript
const el = document.querySelector('.otp')
const otp = tabler.OtpInput.getOrCreateInstance(el, { length: 6, type: 'numeric' })
```
Listen for `complete.bs.otpInput` to know when every slot is filled:
```javascript
el.addEventListener('complete.bs.otpInput', (event) => {
console.log(event.value)
})
```
### Methods
| Method | Description |
| --- | --- |
| `getValue()` | Returns the current value. |
| `setValue(value)` | Sets the value and re-renders the slots. |
| `clear()` | Empties the value and focuses the input. |
| `focus()` | Focuses the input and selects the first empty slot. |
| `dispose()` | Removes the rendered slots and the instance. |
| `getInstance(element)` | Static. Returns the instance bound to the element, or `null`. |
| `getOrCreateInstance(element, config)` | Static. Returns the existing instance, or creates one. |
### Events
| Event | Description |
| --- | --- |
| `input.bs.otpInput` | Fires on every change from typing, backspace, paste, or autofill. `event.value` holds the current value. |
| `complete.bs.otpInput` | Fires once the value fills every slot, including from `setValue()`. `event.value` holds the full value. |
## Accessibility
- The visible slots are decoration (`aria-hidden`); a screen reader only ever sees the one real input, so label it like any other field with `aria-label` or a `<label for>`.
- The input keeps its native `autocomplete="one-time-code"` by default, so browsers and password managers can offer SMS autofill.
- `inputmode` and a `pattern` matching the `type` option are set automatically, so mobile keyboards show the right layout.
- The mask option only hides the value visually - the input is never switched to `type="password"`, so assistive tech still reads it as plain text.
- Keyboard focus lands on the first empty slot, and typing overwrites the active slot rather than inserting, so screen reader and switch-access users always land where they expect.
## SCSS variables
<CodeDocs name="otp-variables" file="core/scss/_variables.scss" />
+3 -39
View File
@@ -3,8 +3,8 @@ import FormFooter from '@ui/FormFooter.astro'
import SingleLayout from '@shared/layouts/SingleLayout.astro'
import Button from '@ui/Button.astro'
import ButtonList from '@ui/ButtonList.astro'
import CaptureScript from '@shared/components/CaptureScript.astro'
import CardTitle from '@ui/CardTitle.astro'
import OtpInput from '@ui/OtpInput.astro'
---
<SingleLayout title="2-Step Verification">
@@ -18,20 +18,8 @@ import CardTitle from '@ui/CardTitle.astro'
<fieldset class="my-5 border-0 p-0 m-0">
<legend class="visually-hidden">Verification code</legend>
<div class="row g-4">
{
Array.from({ length: 2 }).map((_, group) => (
<div class="col">
<div class="row g-2">
{Array.from({ length: 3 }).map((_, i) => (
<div class="col">
<input type="text" class="form-control form-control-lg text-center px-3 py-3" maxlength="1" inputmode="numeric" pattern="[0-9]*" data-code-input aria-label={`Digit ${group * 3 + i + 1}`} autocomplete={group === 0 && i === 0 ? 'one-time-code' : undefined} />
</div>
))}
</div>
</div>
))
}
<div class="d-flex justify-content-center">
<OtpInput length={6} groups={[3, 3]} size="lg" ariaLabel="Verification code" />
</div>
</fieldset>
@@ -51,30 +39,6 @@ import CardTitle from '@ui/CardTitle.astro'
</div>
</form>
<CaptureScript>
<!-- BEGIN CODE INPUT SCRIPT -->
<script is:inline>
const inputs = document.querySelectorAll('[data-code-input]')
inputs.forEach((input, i) => {
input.addEventListener('input', (e) => {
const target = e.target
if (target.value.length === target.maxLength && i + 1 < inputs.length) {
inputs[i + 1].focus()
}
})
input.addEventListener('keydown', (e) => {
const target = e.target
if (target.value.length === 0 && e.key === 'Backspace' && i > 0) {
inputs[i - 1].focus()
}
})
})
</script>
<!-- END CODE INPUT SCRIPT -->
</CaptureScript>
<div class="text-center text-secondary mt-3">
It may take a minute to receive your code. Haven't received it? <a href="./">Resend a new code.</a>
</div>
+19
View File
@@ -10,6 +10,7 @@ import FormElements6 from '@shared/components/forms/FormElements6.astro'
import Layout from '@shared/components/cards/form/Layout.astro'
import InputIcon from '@ui/form/InputIcon.astro'
import InputMask from '@ui/form/InputMask.astro'
import OtpInput from '@ui/OtpInput.astro'
import Button from '@ui/Button.astro'
import Avatar from '@ui/Avatar.astro'
import Card from '@ui/Card.astro'
@@ -139,6 +140,24 @@ const rows = [
</CardBody>
</Card>
</div>
<div class="col-12 col-lg-6">
<Card>
<CardHeader title="OTP input" />
<CardBody>
<FormGroup label="Basic" for="otp-basic"><OtpInput id="otp-basic" length={6} /></FormGroup>
<FormGroup label="Sizes" associate={false}>
<div class="space-y">
<OtpInput id="otp-sm" length={4} size="sm" ariaLabel="Sizes small" />
<OtpInput id="otp-lg" length={4} size="lg" ariaLabel="Sizes large" />
</div>
</FormGroup>
<FormGroup label="Groups & separator" for="otp-groups"><OtpInput id="otp-groups" length={6} groups={[3, 3]} /></FormGroup>
<FormGroup label="Alphanumeric & masked" for="otp-alphanumeric"><OtpInput id="otp-alphanumeric" length={6} type="alphanumeric" mask /></FormGroup>
<FormGroup label="Connected" for="otp-connected"><OtpInput id="otp-connected" length={6} connected /></FormGroup>
<FormGroup label="Disabled" for="otp-disabled"><OtpInput id="otp-disabled" length={6} value="123456" disabled /></FormGroup>
</CardBody>
</Card>
</div>
<div class="col-12 col-lg-6">
<Card as="form" class="h-100">
<CardHeader title="My Profile" />
+48
View File
@@ -0,0 +1,48 @@
---
// OTP input on its own, caught mid-entry: one large 6-slot field with three
// digits typed, the fourth slot active, and the fake cursor resting under it.
// `authenticate-your-account` already shows the component inside a card, so
// this capture stays on the component and its interaction state.
//
// The active slot is class-driven (`.otp-slot-active`, applied by tabler.js
// only while the overlay input holds focus), so the state has to be set up in
// the script below rather than in markup — a capture tool cannot click it.
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import OtpInput from '@ui/OtpInput.astro'
---
<ScreenshotLayout title="OTP input" zoom={1.5} class="text-center">
<OtpInput id="shot-otp" length={6} size="lg" value="142" ariaLabel="One-time password" />
<!-- The active slot lifts itself to z-index 1 so its ring isn't clipped by a
neighbour; the pointer has to clear that to stay on top of the slot it points at. -->
<style is:inline>
.otp > .cursor {
z-index: 2;
}
</style>
<script is:inline>
document.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById('shot-otp')?.closest('.otp')
if (!el || !window.tabler?.OtpInput) return
// focus() puts the caret on the first empty slot — index 3, after the
// three digits in `value` — which is what marks it active.
window.tabler.OtpInput.getOrCreateInstance(el).focus()
// The pointer goes on `.otp` (the positioned ancestor), not inside the
// slot: _render() rewrites each slot's textContent, so a child there
// would be dropped by the next re-render.
const slot = el.querySelector('.otp-slot-active')
if (!slot) return
const cursor = document.createElement('span')
cursor.className = 'cursor'
// .cursor's own `left` centers it on its ancestor; override it to sit
// under the active slot, keeping the -10px tip offset of that default.
cursor.style.left = `${slot.offsetLeft + slot.offsetWidth / 2 - 10}px`
el.append(cursor)
})
</script>
</ScreenshotLayout>
@@ -2,8 +2,8 @@
import FormFooter from '@ui/FormFooter.astro'
import Button from '@ui/Button.astro'
import ButtonList from '@ui/ButtonList.astro'
import CaptureScript from '../CaptureScript.astro'
import CardTitle from '@ui/CardTitle.astro'
import OtpInput from '@ui/OtpInput.astro'
---
<form class="card card-md" action="#" method="get" autocomplete="off" novalidate>
@@ -14,22 +14,8 @@ import CardTitle from '@ui/CardTitle.astro'
Please confirm your account by entering the authorization code sent to <strong>+1 856-672-8552</strong>.
</p>
<div class="my-5">
<div class="row g-4">
{
Array.from({ length: 2 }).map(() => (
<div class="col">
<div class="row g-2">
{Array.from({ length: 3 }).map(() => (
<div class="col">
<input type="text" class="form-control form-control-lg text-center px-3 py-3" maxlength="1" inputmode="numeric" pattern="[0-9]*" data-code-input />
</div>
))}
</div>
</div>
))
}
</div>
<div class="my-5 d-flex justify-content-center">
<OtpInput length={6} groups={[3, 3]} size="lg" ariaLabel="Verification code" />
</div>
<div class="my-4">
@@ -47,27 +33,3 @@ import CardTitle from '@ui/CardTitle.astro'
</FormFooter>
</div>
</form>
<CaptureScript>
<!-- BEGIN CODE INPUT SCRIPT -->
<script is:inline>
const inputs = document.querySelectorAll('[data-code-input]')
inputs.forEach((input, i) => {
input.addEventListener('input', (e) => {
const target = e.target
if (target.value.length === target.maxLength && i + 1 < inputs.length) {
inputs[i + 1].focus()
}
})
input.addEventListener('keydown', (e) => {
const target = e.target
if (target.value.length === 0 && e.key === 'Backspace' && i > 0) {
inputs[i - 1].focus()
}
})
})
</script>
<!-- END CODE INPUT SCRIPT -->
</CaptureScript>
+4
View File
@@ -365,6 +365,10 @@
{
"title": "Validation",
"url": "/ui/forms/validation"
},
{
"title": "OTP input",
"url": "/ui/forms/otp-input"
}
]
},
+37
View File
@@ -0,0 +1,37 @@
---
// OTP input initializes from `data-bs-toggle="otp"` and its `data-bs-*` options (tabler.js, core/js/src/otp-input.ts).
type OtpInputType = 'numeric' | 'alphanumeric' | 'alpha'
type OtpInputSize = 'sm' | 'lg'
interface Props {
/** data-bs-length: number of slots. Defaults to the input's `maxlength`, or 6. */
length?: number
/** data-bs-type: character set accepted by each slot. */
type?: OtpInputType
/** data-bs-mask: renders filled slots as a mask character instead of the real value. */
mask?: boolean
/** data-bs-groups: slot counts per visual group, e.g. [3, 3] for "123 456". */
groups?: number[]
/** data-bs-separator: character shown between groups. */
separator?: string
/** merges the slots into one connected control, no gap between them. */
connected?: boolean
size?: OtpInputSize
ariaLabel?: string
id?: string
name?: string
value?: string
disabled?: boolean
class?: string
/** remaining attributes (data-*, …) are forwarded to the `.otp` wrapper */
[key: string]: unknown
}
const { length, type, mask, groups, separator, connected, size, ariaLabel, id, name, value, disabled, class: className, ...rest } = Astro.props
const wrapperClass = ['otp', size && `otp-${size}`, connected && 'otp-connected', className]
---
<div class:list={wrapperClass} data-bs-toggle="otp" data-bs-length={length} data-bs-type={type} data-bs-mask={mask ? 'true' : undefined} data-bs-groups={groups ? JSON.stringify(groups) : undefined} data-bs-separator={separator} {...rest}>
<input type="text" id={id} name={name} value={value} maxlength={length} disabled={disabled} aria-label={ariaLabel} />
</div>