Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 125 additions & 8 deletions js/src/otp-input.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const SELECTOR_DATA_OTP = '[data-bs-otp]'
const SELECTOR_INPUT = 'input'

// Events that should refresh the active-slot highlight as the caret moves
const SYNC_EVENTS = ['blur', 'keyup', 'click', 'select']
const SYNC_EVENTS = ['blur', 'keyup', 'select']

const CLASS_NAME_INPUT = 'otp-input'
const CLASS_NAME_RENDERED = 'otp-rendered'
Expand Down Expand Up @@ -77,6 +77,9 @@ class OtpInput extends BaseComponent {
this._type = TYPES[this._config.type] || TYPES.numeric
this._length = this._resolveLength()
this._slots = []
// Tracks whether focus was triggered by a click so we can respect the
// clicked slot instead of jumping to the first empty one
this._pointerActive = false

this._setupInput()
this._renderSlots()
Expand Down Expand Up @@ -116,15 +119,17 @@ class OtpInput extends BaseComponent {

focus() {
this._input.focus()
// Place the caret after the last entered character
const end = this._input.value.length
this._input.setSelectionRange(end, end)
// Select the first empty slot (or the last one when the value is full)
this._selectSlot(this._firstEmptyIndex())
this._render()
}

dispose() {
EventHandler.off(this._input, 'input', this._onInput)
EventHandler.off(this._input, 'beforeinput', this._onBeforeInput)
EventHandler.off(this._input, 'focus', this._onFocus)
EventHandler.off(this._slotsContainer, 'pointerdown', this._onPointerDown)
EventHandler.off(document, 'selectionchange', this._onSelectionChange)
for (const type of SYNC_EVENTS) {
EventHandler.off(this._input, type, this._onSync)
}
Expand Down Expand Up @@ -204,34 +209,146 @@ class OtpInput extends BaseComponent {

_addEventListeners() {
// Listeners are attached with bare event names (not namespaced) because
// `input` is not in EventHandler's native-events list; we keep references
// so they can be removed on dispose.
// `input`, `beforeinput`, and `selectionchange` are not in EventHandler's
// native-events list; we keep references so they can be removed on dispose.
this._onInput = () => this._handleInput()
this._onFocus = () => this.focus()
this._onBeforeInput = event => this._handleBeforeInput(event)
this._onPointerDown = event => this._handlePointerDown(event)
this._onFocus = () => {
if (this._pointerActive) {
// A click already positioned the caret; just refresh the highlight
this._pointerActive = false
this._render()
return
}

// Keyboard (Tab) focus lands on the first empty slot
this._selectSlot(this._firstEmptyIndex())
this._render()
}

this._onSync = () => this._render()
this._onSelectionChange = () => {
if (document.activeElement === this._input) {
this._render()
}
}

EventHandler.on(this._input, 'input', this._onInput)
EventHandler.on(this._input, 'beforeinput', this._onBeforeInput)
EventHandler.on(this._input, 'focus', this._onFocus)
EventHandler.on(this._slotsContainer, 'pointerdown', this._onPointerDown)
EventHandler.on(document, 'selectionchange', this._onSelectionChange)

// Keep the active-slot highlight in sync with the caret
for (const type of SYNC_EVENTS) {
EventHandler.on(this._input, type, this._onSync)
}
}

// Bulk path: paste, SMS autofill, or a programmatic value change land here as
// a single multi-character `input` event. Single keystrokes are handled by
// `_handleBeforeInput` (overwrite semantics) and never reach this method.
_handleInput() {
const sanitized = this._sanitize(this._input.value)
if (sanitized !== this._input.value) {
this._input.value = sanitized
}

// Place the caret on the first empty slot after a paste/autofill
if (document.activeElement === this._input) {
this._selectSlot(this._firstEmptyIndex())
}

this._afterValueChange()
}

// Intercept single-character typing and backspace so each slot is overwritten
// in place rather than inserting and shifting the rest of the value. Anything
// else (paste, autofill, IME composition) falls through to `_handleInput`.
_handleBeforeInput(event) {
const { inputType, data } = event

if (inputType === 'insertText' && data && data.length === 1) {
event.preventDefault()

const char = this._sanitize(data)
if (!char) {
return
}

const index = Math.min(this._input.selectionStart ?? 0, this._length - 1)
const chars = [...this._input.value]
chars[index] = char
this._input.value = chars.join('').slice(0, this._length)

this._selectSlot(index + 1)
this._afterValueChange()
return
}

if (inputType === 'deleteContentBackward') {
event.preventDefault()

const start = this._input.selectionStart ?? 0
const end = this._input.selectionEnd ?? start
const chars = [...this._input.value]

if (end > start) {
// A filled slot is selected: clear it and keep the caret in place
chars.splice(start, end - start)
this._input.value = chars.join('')
this._selectSlot(start)
} else if (start > 0) {
// Collapsed caret: remove the previous character and step back
chars.splice(start - 1, 1)
this._input.value = chars.join('')
this._selectSlot(start - 1)
}

this._afterValueChange()
}
}

_handlePointerDown(event) {
const slot = event.target.closest(`.${CLASS_NAME_SLOT}`)
if (!slot) {
return
}

const index = this._slots.indexOf(slot)
if (index === -1) {
return
}

// Take over caret placement from the browser
event.preventDefault()

this._pointerActive = true
this._input.focus()
// Don't let the caret land past the first empty slot
this._selectSlot(Math.min(index, this._firstEmptyIndex()))
this._render()
}

_afterValueChange() {
this._render()
EventHandler.trigger(this._element, EVENT_INPUT, { value: this._input.value })

this._checkComplete()
}

_firstEmptyIndex() {
return Math.min(this._input.value.length, this._length - 1)
}

// Represent the active slot as a selection: a filled slot is selected so the
// next keystroke overwrites it; an empty slot gets a collapsed caret.
_selectSlot(index) {
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) {
return value.replace(this._type.filter, '').slice(0, this._length)
}
Expand Down
85 changes: 85 additions & 0 deletions js/tests/unit/otp-input.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,91 @@ describe('OtpInput', () => {
})
})

describe('interaction', () => {
it('should position the active slot when a slot is clicked', () => {
fixtureEl.innerHTML = getOtpHtml()

const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
const input = otpEl.querySelector('input')
otp.setValue('123456')

const slots = otpEl.querySelectorAll('.otp-slot')
slots[0].dispatchEvent(new Event('pointerdown', { bubbles: true, cancelable: true }))

expect(input.selectionStart).toEqual(0)
// A filled slot is selected so the next keystroke overwrites it
expect(input.selectionEnd).toEqual(1)
expect(slots[0]).toHaveClass('otp-slot-active')
})

it('should overwrite the active slot instead of inserting', () => {
fixtureEl.innerHTML = getOtpHtml()

const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
const input = otpEl.querySelector('input')
otp.setValue('123456')

input.focus()
input.setSelectionRange(2, 3)
input.dispatchEvent(new InputEvent('beforeinput', {
inputType: 'insertText', data: '9', bubbles: true, cancelable: true
}))

expect(input.value).toEqual('129456')
// Caret advances to the next slot
expect(input.selectionStart).toEqual(3)
})

it('should delete the previous character on backspace', () => {
fixtureEl.innerHTML = getOtpHtml()

const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
const input = otpEl.querySelector('input')
otp.setValue('123')

input.focus()
input.setSelectionRange(3, 3)
input.dispatchEvent(new InputEvent('beforeinput', { inputType: 'deleteContentBackward', bubbles: true, cancelable: true }))

expect(input.value).toEqual('12')
expect(input.selectionStart).toEqual(2)
})

it('should focus the first empty slot on keyboard focus', () => {
fixtureEl.innerHTML = getOtpHtml()

const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
const input = otpEl.querySelector('input')
otp.setValue('12')

input.focus()

expect(input.selectionStart).toEqual(2)
expect(otpEl.querySelectorAll('.otp-slot')[2]).toHaveClass('otp-slot-active')
})

it('should swallow a disallowed character on beforeinput', () => {
fixtureEl.innerHTML = getOtpHtml()

const otpEl = fixtureEl.querySelector('.otp')
new OtpInput(otpEl) // eslint-disable-line no-new
const input = otpEl.querySelector('input')

input.focus()
const event = new InputEvent('beforeinput', {
inputType: 'insertText', data: 'a', bubbles: true, cancelable: true
})
input.dispatchEvent(event)

expect(input.value).toEqual('')
expect(event.defaultPrevented).toBeTrue()
})
})

describe('mask', () => {
it('should render the mask character but keep the real value', () => {
fixtureEl.innerHTML = getOtpHtml('data-bs-mask="true"')
Expand Down
4 changes: 2 additions & 2 deletions scss/forms/_otp-input.scss
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ $otp-sizes: defaults(
height: 100%;
padding: 0;
color: transparent;
text-align: center;
pointer-events: none; // clicks go to the slots; JS focuses and positions the caret
cursor: default;
caret-color: transparent;
background-color: transparent;
Expand All @@ -71,7 +71,6 @@ $otp-sizes: defaults(
.otp-slots {
display: inline-flex;
gap: var(--otp-gap);
pointer-events: none; // let clicks fall through to the input overlay
}

.otp-slot {
Expand All @@ -84,6 +83,7 @@ $otp-sizes: defaults(
font-weight: 500;
line-height: 1;
color: var(--otp-slot-fg);
user-select: none; // decorative cells; the real input handles selection
background-color: var(--otp-slot-bg);
border: var(--otp-slot-border-width) solid var(--otp-slot-border-color);
@include border-radius(var(--otp-slot-border-radius));
Expand Down
1 change: 1 addition & 0 deletions site/src/content/docs/forms/otp-input.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ OTP (One-Time Password) inputs are a common pattern for two-factor authenticatio
- **One accessible control**: a single `<input>` backs the whole field, so assistive technology announces one field, not one per digit
- **Browser autofill**: supports `autocomplete="one-time-code"` for SMS/email code autofill
- **Paste support**: paste a full code—even a formatted one like `123-456`—and the extra characters are stripped automatically
- **Click to edit**: click any slot to jump to it; typing overwrites that digit in place instead of pushing the others along
- **Keyboard navigation**: all native text editing (typing, arrows, backspace, delete, selection) works out of the box
- **Masking and types**: optionally mask the value, and restrict input to numeric, alphanumeric, or alphabetic characters

Expand Down
Loading