From 0586af79c292a7b86895eb31cf540019a041f10f Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Thu, 18 Jun 2026 10:07:37 -0700 Subject: [PATCH 1/4] OtpInput: fix click-to-focus and overwrite-on-retype The single-input rewrite left two interaction gaps: clicking a slot didn't position the caret there (slots had pointer-events: none and focus always jumped to the end), and retyping inserted instead of overwriting, so preceding digits shifted along. Keep the single accessible input but make its interaction faithful to the input-otp model: - Represent the active slot as a selection range so the next keystroke overwrites a filled slot or appends to an empty one - Intercept single-char typing and backspace via beforeinput for overwrite semantics; paste/autofill/IME still flow through input - Make slots clickable (pointerdown) to position the caret, clamped to the first empty slot - Land focus on the first empty slot instead of the end; track the caret with a document selectionchange listener --- js/src/otp-input.js | 133 ++++++++++++++++++++-- js/tests/unit/otp-input.spec.js | 85 ++++++++++++++ scss/forms/_otp-input.scss | 4 +- site/src/content/docs/forms/otp-input.mdx | 1 + 4 files changed, 213 insertions(+), 10 deletions(-) diff --git a/js/src/otp-input.js b/js/src/otp-input.js index 937382d44352..bde75b56f19b 100644 --- a/js/src/otp-input.js +++ b/js/src/otp-input.js @@ -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' @@ -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() @@ -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) } @@ -204,14 +209,36 @@ 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) { @@ -219,19 +246,109 @@ class OtpInput extends BaseComponent { } } + // 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) } diff --git a/js/tests/unit/otp-input.spec.js b/js/tests/unit/otp-input.spec.js index f94aedbf3d33..867f47b2ceee 100644 --- a/js/tests/unit/otp-input.spec.js +++ b/js/tests/unit/otp-input.spec.js @@ -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"') diff --git a/scss/forms/_otp-input.scss b/scss/forms/_otp-input.scss index 06e34a57be6d..12f033d93f26 100644 --- a/scss/forms/_otp-input.scss +++ b/scss/forms/_otp-input.scss @@ -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; @@ -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 { @@ -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)); diff --git a/site/src/content/docs/forms/otp-input.mdx b/site/src/content/docs/forms/otp-input.mdx index ce0c10ab74e7..129a971b7abb 100644 --- a/site/src/content/docs/forms/otp-input.mdx +++ b/site/src/content/docs/forms/otp-input.mdx @@ -13,6 +13,7 @@ OTP (One-Time Password) inputs are a common pattern for two-factor authenticatio - **One accessible control**: a single `` 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 From 8d08e1236e8cfa55b255834a63b72cc235658620 Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Tue, 23 Jun 2026 10:25:13 -0700 Subject: [PATCH 2/4] bump bundlewatch --- .bundlewatch.config.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index 2c89f7ea47a8..52e92839b9cb 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -34,19 +34,19 @@ }, { "path": "./dist/js/bootstrap.bundle.js", - "maxSize": "82.5 kB" + "maxSize": "83.75 kB" }, { "path": "./dist/js/bootstrap.bundle.min.js", - "maxSize": "53.25 kB" + "maxSize": "53.5 kB" }, { "path": "./dist/js/bootstrap.js", - "maxSize": "53.75 kB" + "maxSize": "55.0 kB" }, { "path": "./dist/js/bootstrap.min.js", - "maxSize": "31.25 kB" + "maxSize": "31.5 kB" } ], "ci": { From ace79bd3a6533171c232ceef9d0d446f8849ed83 Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Sat, 27 Jun 2026 20:08:16 -0700 Subject: [PATCH 3/4] OTP: focus the input natively on tap to fix iPadOS keyboard dismiss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slots overlaid the input with pointer-events: none and the JS called input.focus() programmatically after preventDefault()-ing the tap. On iPadOS that raises the on-screen keyboard then dismisses it instantly, because the keyboard must be raised by a genuine, un-prevented gesture on the input itself. Let the input receive taps (pointer-events: auto) so focus — and the keyboard — come from the native gesture. Map the tap's x-coordinate to a slot and set the caret in the focus handler once focus settles; when already focused, reposition immediately (preventDefault is safe then). Reported on iPadOS 26/27 by @coliff. --- js/src/otp-input.js | 48 +++++++++++++++++++++++---------- js/tests/unit/otp-input.spec.js | 10 +++++-- scss/forms/_otp-input.scss | 6 ++++- 3 files changed, 47 insertions(+), 17 deletions(-) diff --git a/js/src/otp-input.js b/js/src/otp-input.js index bde75b56f19b..978946eee9be 100644 --- a/js/src/otp-input.js +++ b/js/src/otp-input.js @@ -80,6 +80,8 @@ class OtpInput extends BaseComponent { // 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() @@ -128,7 +130,7 @@ class OtpInput extends BaseComponent { 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(this._input, 'pointerdown', this._onPointerDown) EventHandler.off(document, 'selectionchange', this._onSelectionChange) for (const type of SYNC_EVENTS) { EventHandler.off(this._input, type, this._onSync) @@ -216,8 +218,11 @@ class OtpInput extends BaseComponent { this._onPointerDown = event => this._handlePointerDown(event) this._onFocus = () => { if (this._pointerActive) { - // A click already positioned the caret; just refresh the highlight + // 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 } @@ -237,7 +242,7 @@ class OtpInput extends BaseComponent { 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(this._input, 'pointerdown', this._onPointerDown) EventHandler.on(document, 'selectionchange', this._onSelectionChange) // Keep the active-slot highlight in sync with the caret @@ -311,24 +316,39 @@ class OtpInput extends BaseComponent { } _handlePointerDown(event) { - const slot = event.target.closest(`.${CLASS_NAME_SLOT}`) - if (!slot) { + const index = this._slotIndexFromPoint(event.clientX) + if (index === null) { return } - const index = this._slots.indexOf(slot) - if (index === -1) { + // 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 } - // Take over caret placement from the browser - event.preventDefault() - + // 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._input.focus() - // Don't let the caret land past the first empty slot - this._selectSlot(Math.min(index, this._firstEmptyIndex())) - this._render() + 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() { diff --git a/js/tests/unit/otp-input.spec.js b/js/tests/unit/otp-input.spec.js index 867f47b2ceee..e05355156e49 100644 --- a/js/tests/unit/otp-input.spec.js +++ b/js/tests/unit/otp-input.spec.js @@ -220,7 +220,7 @@ describe('OtpInput', () => { }) describe('interaction', () => { - it('should position the active slot when a slot is clicked', () => { + it('should position the active slot from the tapped coordinate', () => { fixtureEl.innerHTML = getOtpHtml() const otpEl = fixtureEl.querySelector('.otp') @@ -229,7 +229,13 @@ describe('OtpInput', () => { otp.setValue('123456') const slots = otpEl.querySelectorAll('.otp-slot') - slots[0].dispatchEvent(new Event('pointerdown', { bubbles: true, cancelable: true })) + // 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 diff --git a/scss/forms/_otp-input.scss b/scss/forms/_otp-input.scss index 12f033d93f26..4cb0a6ce8fdb 100644 --- a/scss/forms/_otp-input.scss +++ b/scss/forms/_otp-input.scss @@ -53,7 +53,10 @@ $otp-sizes: defaults( height: 100%; padding: 0; color: transparent; - pointer-events: none; // clicks go to the slots; JS focuses and positions the caret + // 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; @@ -71,6 +74,7 @@ $otp-sizes: defaults( .otp-slots { display: inline-flex; gap: var(--otp-gap); + pointer-events: none; // purely visual; the overlaid input handles interaction } .otp-slot { From a0502ebd1dfdf26483648cec4c33936ecd5237b4 Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Sun, 28 Jun 2026 13:37:03 -0700 Subject: [PATCH 4/4] Build: bump bundlewatch thresholds for OTP interaction code --- .bundlewatch.config.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index 8986f6b95373..de7d99afed68 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -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": {