From 5d8af1dd9b22bddbe06f3acb669a1c5d929c6fe8 Mon Sep 17 00:00:00 2001 From: Sondre Aasemoen Date: Sat, 19 Sep 2026 18:03:58 +0200 Subject: [PATCH 1/3] Add failing test when pasting into OTP input --- js/tests/unit/otp-input.spec.js | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/js/tests/unit/otp-input.spec.js b/js/tests/unit/otp-input.spec.js index 7989a326f492..0f0acaabc6f2 100644 --- a/js/tests/unit/otp-input.spec.js +++ b/js/tests/unit/otp-input.spec.js @@ -43,6 +43,19 @@ describe('OtpInput', () => { const beforeInput = (input, options) => input.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, cancelable: true, ...options })) + const pasteInto = (input, value) => { + const event = new Event('paste', { bubbles: true, cancelable: true }) + event.clipboardData = { getData: () => value } + input.dispatchEvent(event) + + // Emulate the browser's default action. It applies maxlength before the + // input event lets the component sanitize the value. + if (!event.defaultPrevented) { + input.value = value.slice(0, input.maxLength) + input.dispatchEvent(createEvent('input')) + } + } + describe('VERSION', () => { it('should return plugin version', () => { expect(OtpInput.VERSION).toEqual(jasmine.any(String)) @@ -229,6 +242,18 @@ describe('OtpInput', () => { expect([...slots].map(slot => slot.textContent).join('')).toEqual('123456') }) + it('should preserve all digits when pasted text contains a separator', () => { + fixtureEl.innerHTML = getOtpHtml() + + const otpEl = fixtureEl.querySelector('.otp') + new OtpInput(otpEl) // eslint-disable-line no-new + const input = otpEl.querySelector('input') + + pasteInto(input, '123-456') + + expect(input.value).toEqual('123456') + }) + it('should keep letters for the alphanumeric type', () => { fixtureEl.innerHTML = getOtpHtml('data-bs-type="alphanumeric"') From 46677a2806e69500bc04084eb91eb9978678fe62 Mon Sep 17 00:00:00 2001 From: Sondre Aasemoen Date: Sat, 19 Sep 2026 18:05:42 +0200 Subject: [PATCH 2/3] Fix OTP input pasting being truncated --- js/src/otp-input.ts | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/js/src/otp-input.ts b/js/src/otp-input.ts index cceac4b80f01..fcffac866c12 100644 --- a/js/src/otp-input.ts +++ b/js/src/otp-input.ts @@ -85,6 +85,7 @@ class OtpInput extends BaseComponent { protected declare _onInput: () => void protected declare _onBeforeInput: (event: BootstrapEvent) => void protected declare _onFocus: () => void + protected declare _onPaste: (event: BootstrapEvent) => void protected declare _onPointerDown: (event: BootstrapEvent) => void protected declare _onSync: () => void protected declare _onSelectionChange: () => void @@ -155,6 +156,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._input, 'paste', this._onPaste) EventHandler.off(this._input, 'pointerdown', this._onPointerDown) EventHandler.off(document, 'selectionchange', this._onSelectionChange) for (const type of SYNC_EVENTS) { @@ -236,10 +238,11 @@ class OtpInput extends BaseComponent { protected _addEventListeners(): void { // Listeners are attached with bare event names (not namespaced) because - // `input`, `beforeinput`, and `selectionchange` are not in EventHandler's - // native-events list; we keep references so they can be removed on dispose. + // `input`, `beforeinput`, `paste`, and `selectionchange` are not in + // EventHandler's native-events list. Keep references to remove on dispose. this._onInput = () => this._handleInput() this._onBeforeInput = event => this._handleBeforeInput(event) + this._onPaste = event => this._handlePaste(event) this._onPointerDown = event => this._handlePointerDown(event) this._onFocus = () => { if (this._pointerActive) { @@ -267,6 +270,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._input, 'paste', this._onPaste) EventHandler.on(this._input, 'pointerdown', this._onPointerDown) EventHandler.on(document, 'selectionchange', this._onSelectionChange) @@ -276,8 +280,8 @@ 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 + // Bulk path: SMS autofill or a programmatic value change lands here as a + // single multi-character `input` event. Single keystrokes are handled by // `_handleBeforeInput` (overwrite semantics) and never reach this method. protected _handleInput(): void { const sanitized = this._sanitize(this._input.value) @@ -293,9 +297,24 @@ class OtpInput extends BaseComponent { this._afterValueChange() } + protected _handlePaste(event: BootstrapEvent): void { + const pastedValue = event.clipboardData?.getData('text') + if (typeof pastedValue !== 'string') { + return + } + + // Sanitize before applying the length limit. The browser applies maxlength + // to raw clipboard text before the input event, which can discard valid + // characters that follow separators or whitespace. + event.preventDefault() + this._input.value = this._sanitize(pastedValue) + 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`. + // in place rather than inserting and shifting the rest of the value. Autofill + // and IME composition fall through to `_handleInput`. protected _handleBeforeInput(event: BootstrapEvent): void { const { inputType, data } = event From 26ff5fced5655bf125f57e1152507c72dd8bcdc2 Mon Sep 17 00:00:00 2001 From: Sondre Aasemoen Date: Thu, 24 Sep 2026 20:24:10 +0200 Subject: [PATCH 3/3] Reduce bundle size, test for disposing paste listener --- js/src/otp-input.ts | 52 +++++++++++++-------------------- js/tests/unit/otp-input.spec.js | 17 +++++++++++ 2 files changed, 37 insertions(+), 32 deletions(-) diff --git a/js/src/otp-input.ts b/js/src/otp-input.ts index fcffac866c12..4bd1b5765bb8 100644 --- a/js/src/otp-input.ts +++ b/js/src/otp-input.ts @@ -25,9 +25,6 @@ const EVENT_DOMCONTENT_LOADED = `DOMContentLoaded${EVENT_KEY}${DATA_API_KEY}` 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', 'select'] - const CLASS_NAME_INPUT = 'otp-input' const CLASS_NAME_RENDERED = 'otp-rendered' const CLASS_NAME_SLOTS = 'otp-slots' @@ -82,12 +79,7 @@ class OtpInput extends BaseComponent { protected declare _pointerActive: boolean protected declare _pointerIndex: number protected declare _slotsContainer: HTMLElement - protected declare _onInput: () => void - protected declare _onBeforeInput: (event: BootstrapEvent) => void - protected declare _onFocus: () => void - protected declare _onPaste: (event: BootstrapEvent) => void - protected declare _onPointerDown: (event: BootstrapEvent) => void - protected declare _onSync: () => void + protected declare _inputListeners: Record void> protected declare _onSelectionChange: () => void constructor(element?: string | Element | null, config?: Partial | null) { @@ -153,16 +145,12 @@ class OtpInput extends BaseComponent { } override dispose(): void { - 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, 'paste', this._onPaste) - 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) + for (const [type, listener] of Object.entries(this._inputListeners ?? {})) { + EventHandler.off(this._input, type, listener) } + EventHandler.off(document, 'selectionchange', this._onSelectionChange) + this._slotsContainer?.remove() this._element.classList.remove(CLASS_NAME_RENDERED) super.dispose() @@ -240,11 +228,13 @@ class OtpInput extends BaseComponent { // Listeners are attached with bare event names (not namespaced) because // `input`, `beforeinput`, `paste`, and `selectionchange` are not in // EventHandler's native-events list. Keep references to remove on dispose. - this._onInput = () => this._handleInput() - this._onBeforeInput = event => this._handleBeforeInput(event) - this._onPaste = event => this._handlePaste(event) - this._onPointerDown = event => this._handlePointerDown(event) - this._onFocus = () => { + this._inputListeners = { + input: () => this._handleInput(), + beforeinput: event => this._handleBeforeInput(event), + paste: event => this._handlePaste(event), + pointerdown: event => this._handlePointerDown(event) + } + this._inputListeners.focus = () => { 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 @@ -260,24 +250,22 @@ class OtpInput extends BaseComponent { this._render() } - this._onSync = () => this._render() + const 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, 'paste', this._onPaste) - EventHandler.on(this._input, 'pointerdown', this._onPointerDown) - EventHandler.on(document, 'selectionchange', this._onSelectionChange) + for (const type of ['blur', 'keyup', 'select']) { + this._inputListeners[type] = onSync + } - // Keep the active-slot highlight in sync with the caret - for (const type of SYNC_EVENTS) { - EventHandler.on(this._input, type, this._onSync) + for (const [type, listener] of Object.entries(this._inputListeners)) { + EventHandler.on(this._input, type, listener) } + + EventHandler.on(document, 'selectionchange', this._onSelectionChange) } // Bulk path: SMS autofill or a programmatic value change lands here as a diff --git a/js/tests/unit/otp-input.spec.js b/js/tests/unit/otp-input.spec.js index 0f0acaabc6f2..9178d5046771 100644 --- a/js/tests/unit/otp-input.spec.js +++ b/js/tests/unit/otp-input.spec.js @@ -606,6 +606,23 @@ describe('OtpInput', () => { expect(fixtureEl.querySelector('.otp').classList.contains('otp-rendered')).toBeFalse() }) + it('should stop intercepting paste after dispose', () => { + fixtureEl.innerHTML = getOtpHtml() + + const otpEl = fixtureEl.querySelector('.otp') + const otp = new OtpInput(otpEl) + const input = otpEl.querySelector('input') + + otp.dispose() + + const event = new Event('paste', { bubbles: true, cancelable: true }) + event.clipboardData = { getData: () => '123-456' } + input.dispatchEvent(event) + + expect(event.defaultPrevented).toBeFalse() + expect(input.value).toEqual('') + }) + it('should stop intercepting beforeinput after dispose', () => { fixtureEl.innerHTML = getOtpHtml()