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
8 changes: 4 additions & 4 deletions .bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,19 @@
},
{
"path": "./dist/js/bootstrap.bundle.js",
"maxSize": "83.5 kB"
"maxSize": "85.25 kB"
},
{
"path": "./dist/js/bootstrap.bundle.min.js",
"maxSize": "53.25 kB"
"maxSize": "53.75 kB"
},
{
"path": "./dist/js/bootstrap.js",
"maxSize": "55.0 kB"
"maxSize": "56.5 kB"
},
{
"path": "./dist/js/bootstrap.min.js",
"maxSize": "31.25 kB"
"maxSize": "31.75 kB"
}
],
"ci": {
Expand Down
155 changes: 146 additions & 9 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,11 @@ 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
// Slot index from the most recent tap, applied once focus settles
this._pointerIndex = 0

this._setupInput()
this._renderSlots()
Expand Down Expand Up @@ -116,15 +121,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._input, '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 +211,164 @@ 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 tap focused the input natively; position the caret on the clicked
// slot now that focus has settled (doing this before native focus would
// make iOS/iPadOS raise then immediately dismiss the keyboard)
this._pointerActive = false
this._selectSlot(this._pointerIndex)
this._render()
return
}

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

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

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

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

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

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

EventHandler.trigger(this._element, EVENT_INPUT, { value: this._input.value })
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 index = this._slotIndexFromPoint(event.clientX)
if (index === null) {
return
}

// Don't let the caret land past the first empty slot
const target = Math.min(index, this._firstEmptyIndex())

if (document.activeElement === this._input) {
// Already focused (keyboard is up): take over caret placement from the
// browser. Safe to preventDefault here — it won't dismiss the keyboard.
event.preventDefault()
this._selectSlot(target)
this._render()
return
}

// Not yet focused: let the browser focus the input natively so the
// on-screen keyboard is raised by the user's tap. Position the caret in the
// focus handler once focus settles.
this._pointerActive = true
this._pointerIndex = target
}

// Map a viewport x-coordinate to the slot under it, clamped to the last slot
_slotIndexFromPoint(x) {
for (const [index, slot] of this._slots.entries()) {
if (x <= slot.getBoundingClientRect().right || index === this._slots.length - 1) {
return index
}
}

return null
}

_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
91 changes: 91 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,97 @@ describe('OtpInput', () => {
})
})

describe('interaction', () => {
it('should position the active slot from the tapped coordinate', () => {
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')
// The input is already focused (keyboard up), so a tap repositions the
// caret immediately based on its x-coordinate
input.focus()
const { left, width } = slots[0].getBoundingClientRect()
input.dispatchEvent(new MouseEvent('pointerdown', {
bubbles: true, cancelable: true, clientX: left + (width / 2)
}))

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
8 changes: 6 additions & 2 deletions scss/forms/_otp-input.scss
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ $otp-sizes: defaults(
height: 100%;
padding: 0;
color: transparent;
text-align: center;
// The input sits on top and receives taps itself, so a touch focuses it
// natively (raising the on-screen keyboard via a genuine user gesture).
// The JS maps the tap's position to a slot and sets the caret.
pointer-events: auto;
cursor: default;
caret-color: transparent;
background-color: transparent;
Expand All @@ -71,7 +74,7 @@ $otp-sizes: defaults(
.otp-slots {
display: inline-flex;
gap: var(--otp-gap);
pointer-events: none; // let clicks fall through to the input overlay
pointer-events: none; // purely visual; the overlaid input handles interaction
}

.otp-slot {
Expand All @@ -84,6 +87,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