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