From c1fa103f85f16fd1ac438caffba7b2f594214922 Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Thu, 18 Jun 2026 10:07:37 -0700 Subject: [PATCH] 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