mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 05:06:55 +04:00
Add OTP input component (#3056)
This commit is contained in:
1 parent
e63fe34e9c
commit
a744b8407f
17 files changed
+1263
-83
No files matched your search
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@tabler/core": minor
|
||||
"@tabler/preview": minor
|
||||
"@tabler/docs": minor
|
||||
---
|
||||
|
||||
Added the `OtpInput` component with grouped slots, masking, and validation states.
|
||||
@@ -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
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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')};
|
||||
}
|
||||
}
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -365,6 +365,10 @@
|
||||
{
|
||||
"title": "Validation",
|
||||
"url": "/ui/forms/validation"
|
||||
},
|
||||
{
|
||||
"title": "OTP input",
|
||||
"url": "/ui/forms/otp-input"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user