From: Mark Otto Date: Thu, 11 Jun 2026 16:56:38 +0000 (-0700) Subject: Rework OTP input as a single accessible input with rendered slots (#42500) X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=d5869be3284999a3838e5c9b274a20794a6ff907;p=thirdparty%2Fbootstrap.git Rework OTP input as a single accessible input with rendered slots (#42500) * Rework OTP input as a single accessible input with rendered slots Rebuild the OTP component around one real rendered as separate digit slots (the input-otp/shadcn model) so assistive tech, password managers, and SMS autofill treat it as a single field — addressing the accessibility feedback in twbs/discussions#42486. - Replace the six-input markup and per-digit aria-labels with one labelled input - Add a type option (numeric/alphanumeric/alpha) and glyph-based masking - Make length a real option (falls back to maxlength); add groups/separator - Standardize naming on bs.otpInput; events complete.bs.otpInput / input.bs.otpInput now expose event.value - Move validation styling onto the rendered .otp-slot elements - Rewrite docs and unit tests for the single-input model * Fix bundlewatch sizes and cspell dictionary for OTP input - Bump bundlewatch maxSize thresholds to account for the OTP component - Add "autofilled" to the cspell dictionary used in the OTP docs * bump bundle --- diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index ebb04f0058..c85ddbbe73 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -34,19 +34,19 @@ }, { "path": "./dist/js/bootstrap.bundle.js", - "maxSize": "79.5 kB" + "maxSize": "80.0 kB" }, { "path": "./dist/js/bootstrap.bundle.min.js", - "maxSize": "51.75 kB" + "maxSize": "52.0 kB" }, { "path": "./dist/js/bootstrap.js", - "maxSize": "50.75 kB" + "maxSize": "51.25 kB" }, { "path": "./dist/js/bootstrap.min.js", - "maxSize": "29.75 kB" + "maxSize": "30.0 kB" } ], "ci": { diff --git a/.cspell.json b/.cspell.json index aeafee927e..e534643635 100644 --- a/.cspell.json +++ b/.cspell.json @@ -4,6 +4,7 @@ "affordance", "allowfullscreen", "Analyser", + "autofilled", "autohide", "autohiding", "autoplay", diff --git a/js/src/otp-input.js b/js/src/otp-input.js index dffa1d97b3..937382d443 100644 --- a/js/src/otp-input.js +++ b/js/src/otp-input.js @@ -14,24 +14,51 @@ import SelectorEngine from './dom/selector-engine.js' */ const NAME = 'otpInput' -const DATA_KEY = 'bs.otp-input' +const DATA_KEY = 'bs.otpInput' const EVENT_KEY = `.${DATA_KEY}` const DATA_API_KEY = '.data-api' const EVENT_COMPLETE = `complete${EVENT_KEY}` const EVENT_INPUT = `input${EVENT_KEY}` +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', 'click', 'select'] + +const CLASS_NAME_INPUT = 'otp-input' +const CLASS_NAME_RENDERED = 'otp-rendered' +const CLASS_NAME_SLOTS = 'otp-slots' +const CLASS_NAME_SLOT = 'otp-slot' +const CLASS_NAME_SLOT_FILLED = 'otp-slot-filled' +const CLASS_NAME_SLOT_ACTIVE = 'otp-slot-active' +const CLASS_NAME_SEPARATOR = 'otp-separator' + +const MASK_CHARACTER = '•' + +// Per-type input mode, validation pattern, and a filter that strips disallowed characters +const TYPES = { + numeric: { inputmode: 'numeric', pattern: '[0-9]*', filter: /[^0-9]/g }, + alphanumeric: { inputmode: 'text', pattern: '[A-Za-z0-9]*', filter: /[^A-Za-z0-9]/g }, + alpha: { inputmode: 'text', pattern: '[A-Za-z]*', filter: /[^A-Za-z]/g } +} + const Default = { - length: 6, - mask: false + groups: null, + length: null, + mask: false, + separator: '·', + type: 'numeric' } const DefaultType = { - length: 'number', - mask: 'boolean' + groups: '(array|null)', + length: '(number|null)', + mask: 'boolean', + separator: 'string', + type: 'string' } /** @@ -42,9 +69,19 @@ class OtpInput extends BaseComponent { constructor(element, config) { super(element, config) - this._inputs = SelectorEngine.find(SELECTOR_INPUT, this._element) - this._setupInputs() + this._input = SelectorEngine.findOne(SELECTOR_INPUT, this._element) + if (!this._input) { + return + } + + this._type = TYPES[this._config.type] || TYPES.numeric + this._length = this._resolveLength() + this._slots = [] + + this._setupInput() + this._renderSlots() this._addEventListeners() + this._render() } // Getters @@ -62,176 +99,160 @@ class OtpInput extends BaseComponent { // Public getValue() { - return this._inputs.map(input => input.value).join('') + return this._input.value } setValue(value) { - const chars = [...String(value)] - for (const [index, input] of this._inputs.entries()) { - input.value = chars[index] || '' - } - + this._input.value = this._sanitize(String(value)) + this._render() this._checkComplete() } clear() { - for (const input of this._inputs) { - input.value = '' - } - - this._inputs[0]?.focus() + this._input.value = '' + this._render() + this._input.focus() } focus() { - // Focus first empty input, or last input if all filled - const emptyInput = this._inputs.find(input => !input.value) - if (emptyInput) { - emptyInput.focus() - } else { - this._inputs.at(-1)?.focus() - } + this._input.focus() + // Place the caret after the last entered character + const end = this._input.value.length + this._input.setSelectionRange(end, end) + this._render() } - // Private - _setupInputs() { - for (const input of this._inputs) { - // Set attributes for proper OTP handling - input.setAttribute('maxlength', '1') - input.setAttribute('inputmode', 'numeric') - input.setAttribute('pattern', '\\d*') - - // First input gets autocomplete for browser OTP autofill - if (input === this._inputs[0]) { - input.setAttribute('autocomplete', 'one-time-code') - } else { - input.setAttribute('autocomplete', 'off') - } - - // Mask input if configured - if (this._config.mask) { - input.setAttribute('type', 'password') - } + dispose() { + EventHandler.off(this._input, 'input', this._onInput) + EventHandler.off(this._input, 'focus', this._onFocus) + for (const type of SYNC_EVENTS) { + EventHandler.off(this._input, type, this._onSync) } + + this._slotsContainer?.remove() + this._element.classList.remove(CLASS_NAME_RENDERED) + super.dispose() } - _addEventListeners() { - for (const [index, input] of this._inputs.entries()) { - EventHandler.on(input, 'input', event => this._handleInput(event, index)) - EventHandler.on(input, 'keydown', event => this._handleKeydown(event, index)) - EventHandler.on(input, 'paste', event => this._handlePaste(event)) - EventHandler.on(input, 'focus', event => this._handleFocus(event)) + // Private + _resolveLength() { + if (this._config.length) { + return this._config.length } + + const maxLength = Number.parseInt(this._input.getAttribute('maxlength'), 10) + return Number.isNaN(maxLength) || maxLength < 1 ? 6 : maxLength } - _handleInput(event, index) { - const input = event.target + _setupInput() { + const input = this._input - // Only allow digits - if (!/^\d*$/.test(input.value)) { - input.value = input.value.replace(/\D/g, '') + // A single text field backs the whole control so screen readers, password + // managers, and SMS autofill treat it like any other input. + if (input.type === 'number' || input.type === 'password') { + input.type = 'text' } - const { value } = input - - // Handle multi-character input (some browsers/autofill) - if (value.length > 1) { - // Distribute characters across inputs - const chars = [...value] - input.value = chars[0] || '' + input.classList.add(CLASS_NAME_INPUT) + input.setAttribute('maxlength', String(this._length)) + input.setAttribute('inputmode', this._type.inputmode) + input.setAttribute('pattern', this._type.pattern) - for (let i = 1; i < chars.length && index + i < this._inputs.length; i++) { - this._inputs[index + i].value = chars[i] - } - - // Focus appropriate input - const nextIndex = Math.min(index + chars.length, this._inputs.length - 1) - this._inputs[nextIndex].focus() - } else if (value && index < this._inputs.length - 1) { - // Auto-advance to next input - this._inputs[index + 1].focus() + if (!input.getAttribute('autocomplete')) { + input.setAttribute('autocomplete', 'one-time-code') } - EventHandler.trigger(this._element, EVENT_INPUT, { - value: this.getValue(), - index - }) - - this._checkComplete() + // Filter any pre-filled value through the configured type + if (input.value) { + input.value = this._sanitize(input.value) + } } - _handleKeydown(event, index) { - const { key } = event - - switch (key) { - case 'Backspace': { - if (!this._inputs[index].value && index > 0) { - // Move to previous input and clear it - event.preventDefault() - this._inputs[index - 1].value = '' - this._inputs[index - 1].focus() + _renderSlots() { + const container = document.createElement('div') + container.className = CLASS_NAME_SLOTS + container.setAttribute('aria-hidden', 'true') + + const { groups } = this._config + let groupIndex = 0 + let inGroup = 0 + + for (let i = 0; i < this._length; i++) { + const slot = document.createElement('div') + slot.className = CLASS_NAME_SLOT + container.append(slot) + this._slots.push(slot) + + // Insert a visual separator between configured groups + if (Array.isArray(groups) && groups.length > 0) { + inGroup++ + if (inGroup === groups[groupIndex] && i < this._length - 1) { + const separator = document.createElement('div') + separator.className = CLASS_NAME_SEPARATOR + separator.textContent = this._config.separator + container.append(separator) + groupIndex = Math.min(groupIndex + 1, groups.length - 1) + inGroup = 0 } - - break } + } - case 'Delete': { - // Clear current and shift remaining values left - event.preventDefault() - for (let i = index; i < this._inputs.length - 1; i++) { - this._inputs[i].value = this._inputs[i + 1].value - } - - this._inputs.at(-1).value = '' - break - } + this._slotsContainer = container + this._element.append(container) + this._element.classList.add(CLASS_NAME_RENDERED) + } - case 'ArrowLeft': { - if (index > 0) { - event.preventDefault() - this._inputs[index - 1].focus() - } + _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. + this._onInput = () => this._handleInput() + this._onFocus = () => this.focus() + this._onSync = () => this._render() + + EventHandler.on(this._input, 'input', this._onInput) + EventHandler.on(this._input, 'focus', this._onFocus) + + // Keep the active-slot highlight in sync with the caret + for (const type of SYNC_EVENTS) { + EventHandler.on(this._input, type, this._onSync) + } + } - break - } + _handleInput() { + const sanitized = this._sanitize(this._input.value) + if (sanitized !== this._input.value) { + this._input.value = sanitized + } - case 'ArrowRight': { - if (index < this._inputs.length - 1) { - event.preventDefault() - this._inputs[index + 1].focus() - } + this._render() - break - } + EventHandler.trigger(this._element, EVENT_INPUT, { value: this._input.value }) - // No default - } + this._checkComplete() } - _handlePaste(event) { - event.preventDefault() - const pastedData = (event.clipboardData || window.clipboardData).getData('text') - const digits = pastedData.replace(/\D/g, '').slice(0, this._inputs.length) - - if (digits) { - this.setValue(digits) - - // Focus last filled input or last input - const lastIndex = Math.min(digits.length, this._inputs.length) - 1 - this._inputs[lastIndex].focus() - } + _sanitize(value) { + return value.replace(this._type.filter, '').slice(0, this._length) } - _handleFocus(event) { - // Select the content on focus for easy replacement - event.target.select() + _render() { + const { value } = this._input + const isFocused = document.activeElement === this._input + // The active slot follows the caret, clamped to the last slot when the value is full + const caret = Math.min(this._input.selectionStart ?? value.length, this._length - 1) + + for (const [index, slot] of this._slots.entries()) { + const char = value[index] ?? '' + slot.textContent = char && this._config.mask ? MASK_CHARACTER : char + slot.classList.toggle(CLASS_NAME_SLOT_FILLED, Boolean(char)) + slot.classList.toggle(CLASS_NAME_SLOT_ACTIVE, isFocused && index === caret) + } } _checkComplete() { - const value = this.getValue() - const isComplete = value.length === this._inputs.length && - this._inputs.every(input => input.value !== '') - - if (isComplete) { + const { value } = this._input + if (value.length === this._length) { EventHandler.trigger(this._element, EVENT_COMPLETE, { value }) } } @@ -241,7 +262,7 @@ class OtpInput extends BaseComponent { * Data API implementation */ -EventHandler.on(document, `DOMContentLoaded${EVENT_KEY}${DATA_API_KEY}`, () => { +EventHandler.on(document, EVENT_DOMCONTENT_LOADED, () => { for (const element of SelectorEngine.find(SELECTOR_DATA_OTP)) { OtpInput.getOrCreateInstance(element) } diff --git a/js/tests/unit/otp-input.spec.js b/js/tests/unit/otp-input.spec.js index 8d98408473..7aa4cd619d 100644 --- a/js/tests/unit/otp-input.spec.js +++ b/js/tests/unit/otp-input.spec.js @@ -12,11 +12,12 @@ describe('OtpInput', () => { clearFixture() }) - const getOtpHtml = (inputCount = 6, attributes = '') => { - const inputs = Array.from({ length: inputCount }) - .map(() => '') - .join('') - return `
${inputs}
` + const getOtpHtml = (attributes = '', maxlength = 6) => + `
` + + const typeInto = (input, value) => { + input.value = value + input.dispatchEvent(createEvent('input')) } describe('VERSION', () => { @@ -34,8 +35,8 @@ describe('OtpInput', () => { describe('Default', () => { it('should return default config', () => { expect(OtpInput.Default).toEqual(jasmine.any(Object)) - expect(OtpInput.Default.length).toEqual(6) - expect(OtpInput.Default.mask).toEqual(false) + expect(OtpInput.Default.mask).toBeFalse() + expect(OtpInput.Default.type).toEqual('numeric') }) }) @@ -49,433 +50,220 @@ describe('OtpInput', () => { it('should take care of element either passed as a CSS selector or DOM element', () => { fixtureEl.innerHTML = getOtpHtml() - const otpEl = fixtureEl.querySelector('.otp-input') - const otpBySelector = new OtpInput('.otp-input') + const otpEl = fixtureEl.querySelector('.otp') + const otpBySelector = new OtpInput('.otp') const otpByElement = new OtpInput(otpEl) expect(otpBySelector._element).toEqual(otpEl) expect(otpByElement._element).toEqual(otpEl) }) - it('should set up inputs with correct attributes', () => { + it('should set up the input with correct attributes', () => { fixtureEl.innerHTML = getOtpHtml() - const otpEl = fixtureEl.querySelector('.otp-input') + const otpEl = fixtureEl.querySelector('.otp') new OtpInput(otpEl) // eslint-disable-line no-new - const inputs = otpEl.querySelectorAll('input') - - for (const input of inputs) { - expect(input.getAttribute('maxlength')).toEqual('1') - expect(input.getAttribute('inputmode')).toEqual('numeric') - expect(input.getAttribute('pattern')).toEqual('\\d*') - } + const input = otpEl.querySelector('input') - // First input should have autocomplete for OTP autofill - expect(inputs[0].getAttribute('autocomplete')).toEqual('one-time-code') - - // Other inputs should have autocomplete off - for (let i = 1; i < inputs.length; i++) { - expect(inputs[i].getAttribute('autocomplete')).toEqual('off') - } + expect(input.getAttribute('maxlength')).toEqual('6') + expect(input.getAttribute('inputmode')).toEqual('numeric') + expect(input.getAttribute('pattern')).toEqual('[0-9]*') + expect(input.getAttribute('autocomplete')).toEqual('one-time-code') }) - it('should set input type to password when mask is true', () => { - fixtureEl.innerHTML = getOtpHtml(6, 'data-bs-mask="true"') + it('should render one slot per character and mark the container as rendered', () => { + fixtureEl.innerHTML = getOtpHtml() - const otpEl = fixtureEl.querySelector('.otp-input') + const otpEl = fixtureEl.querySelector('.otp') new OtpInput(otpEl) // eslint-disable-line no-new - const inputs = otpEl.querySelectorAll('input') - - for (const input of inputs) { - expect(input.getAttribute('type')).toEqual('password') - } + expect(otpEl).toHaveClass('otp-rendered') + expect(otpEl.querySelectorAll('.otp-slot').length).toEqual(6) }) - }) - describe('getValue', () => { - it('should return empty string when no values entered', () => { - fixtureEl.innerHTML = getOtpHtml() + it('should derive length from the maxlength attribute', () => { + fixtureEl.innerHTML = getOtpHtml('', 4) - const otpEl = fixtureEl.querySelector('.otp-input') - const otp = new OtpInput(otpEl) + const otpEl = fixtureEl.querySelector('.otp') + new OtpInput(otpEl) // eslint-disable-line no-new - expect(otp.getValue()).toEqual('') + expect(otpEl.querySelectorAll('.otp-slot').length).toEqual(4) }) - it('should return concatenated values from all inputs', () => { + it('should let the length option override the maxlength attribute', () => { fixtureEl.innerHTML = getOtpHtml() - const otpEl = fixtureEl.querySelector('.otp-input') - const otp = new OtpInput(otpEl) - const inputs = otpEl.querySelectorAll('input') - - inputs[0].value = '1' - inputs[1].value = '2' - inputs[2].value = '3' + const otpEl = fixtureEl.querySelector('.otp') + const otp = new OtpInput(otpEl, { length: 4 }) + expect(otp).toBeInstanceOf(OtpInput) - expect(otp.getValue()).toEqual('123') + const input = otpEl.querySelector('input') + expect(input.getAttribute('maxlength')).toEqual('4') + expect(otpEl.querySelectorAll('.otp-slot').length).toEqual(4) }) - }) - - describe('setValue', () => { - it('should set values across all inputs', () => { - fixtureEl.innerHTML = getOtpHtml() - const otpEl = fixtureEl.querySelector('.otp-input') - const otp = new OtpInput(otpEl) - const inputs = otpEl.querySelectorAll('input') + it('should set a text inputmode and pattern for the alphanumeric type', () => { + fixtureEl.innerHTML = getOtpHtml('data-bs-type="alphanumeric"') - otp.setValue('123456') + const otpEl = fixtureEl.querySelector('.otp') + new OtpInput(otpEl) // eslint-disable-line no-new - expect(inputs[0].value).toEqual('1') - expect(inputs[1].value).toEqual('2') - expect(inputs[2].value).toEqual('3') - expect(inputs[3].value).toEqual('4') - expect(inputs[4].value).toEqual('5') - expect(inputs[5].value).toEqual('6') + const input = otpEl.querySelector('input') + expect(input.getAttribute('inputmode')).toEqual('text') + expect(input.getAttribute('pattern')).toEqual('[A-Za-z0-9]*') }) + }) - it('should handle partial values', () => { + describe('value handling', () => { + it('should return the input value from getValue', () => { fixtureEl.innerHTML = getOtpHtml() - const otpEl = fixtureEl.querySelector('.otp-input') + const otpEl = fixtureEl.querySelector('.otp') const otp = new OtpInput(otpEl) - const inputs = otpEl.querySelectorAll('input') - otp.setValue('123') + expect(otp.getValue()).toEqual('') - expect(inputs[0].value).toEqual('1') - expect(inputs[1].value).toEqual('2') - expect(inputs[2].value).toEqual('3') - expect(inputs[3].value).toEqual('') - expect(inputs[4].value).toEqual('') - expect(inputs[5].value).toEqual('') + otpEl.querySelector('input').value = '123' + expect(otp.getValue()).toEqual('123') }) - it('should handle numeric values', () => { + it('should set the value and render it into the slots', () => { fixtureEl.innerHTML = getOtpHtml() - const otpEl = fixtureEl.querySelector('.otp-input') + const otpEl = fixtureEl.querySelector('.otp') const otp = new OtpInput(otpEl) - const inputs = otpEl.querySelectorAll('input') - otp.setValue(123456) + otp.setValue('123456') - expect(inputs[0].value).toEqual('1') - expect(inputs[5].value).toEqual('6') + expect(otp.getValue()).toEqual('123456') + const slots = otpEl.querySelectorAll('.otp-slot') + expect(slots[0].textContent).toEqual('1') + expect(slots[5].textContent).toEqual('6') }) - }) - describe('clear', () => { - it('should clear all input values', () => { + it('should sanitize disallowed characters and respect the length', () => { fixtureEl.innerHTML = getOtpHtml() - const otpEl = fixtureEl.querySelector('.otp-input') + const otpEl = fixtureEl.querySelector('.otp') const otp = new OtpInput(otpEl) - const inputs = otpEl.querySelectorAll('input') - otp.setValue('123456') - otp.clear() + otp.setValue('12ab34xy567890') - for (const input of inputs) { - expect(input.value).toEqual('') - } + expect(otp.getValue()).toEqual('123456') }) - }) - describe('focus', () => { - it('should focus first empty input', () => { + it('should accept numeric values', () => { fixtureEl.innerHTML = getOtpHtml() - const otpEl = fixtureEl.querySelector('.otp-input') + const otpEl = fixtureEl.querySelector('.otp') const otp = new OtpInput(otpEl) - const inputs = otpEl.querySelectorAll('input') - - inputs[0].value = '1' - inputs[1].value = '2' - otp.focus() + otp.setValue(123456) - expect(document.activeElement).toEqual(inputs[2]) + expect(otp.getValue()).toEqual('123456') }) - it('should focus last input when all filled', () => { + it('should clear the value', () => { fixtureEl.innerHTML = getOtpHtml() - const otpEl = fixtureEl.querySelector('.otp-input') + const otpEl = fixtureEl.querySelector('.otp') const otp = new OtpInput(otpEl) - const inputs = otpEl.querySelectorAll('input') otp.setValue('123456') - otp.focus() + otp.clear() - expect(document.activeElement).toEqual(inputs[5]) + expect(otp.getValue()).toEqual('') + expect(document.activeElement).toEqual(otpEl.querySelector('input')) }) }) describe('input handling', () => { - it('should auto-advance to next input on digit entry', () => { + it('should strip disallowed characters as they are typed', () => { fixtureEl.innerHTML = getOtpHtml() - const otpEl = fixtureEl.querySelector('.otp-input') + const otpEl = fixtureEl.querySelector('.otp') new OtpInput(otpEl) // eslint-disable-line no-new - const inputs = otpEl.querySelectorAll('input') + const input = otpEl.querySelector('input') - inputs[0].focus() - inputs[0].value = '1' - inputs[0].dispatchEvent(createEvent('input')) + typeInto(input, 'a1b2') - expect(document.activeElement).toEqual(inputs[1]) + expect(input.value).toEqual('12') }) - it('should strip non-digit characters', () => { + it('should distribute a pasted/autofilled value across the slots', () => { fixtureEl.innerHTML = getOtpHtml() - const otpEl = fixtureEl.querySelector('.otp-input') + const otpEl = fixtureEl.querySelector('.otp') new OtpInput(otpEl) // eslint-disable-line no-new - const inputs = otpEl.querySelectorAll('input') + const input = otpEl.querySelector('input') - inputs[0].focus() - inputs[0].value = 'a1b' - inputs[0].dispatchEvent(createEvent('input')) + // A paste or SMS autofill lands as a single multi-character input event + typeInto(input, '123-456') - expect(inputs[0].value).toEqual('1') + expect(input.value).toEqual('123456') + const slots = otpEl.querySelectorAll('.otp-slot') + expect([...slots].map(slot => slot.textContent).join('')).toEqual('123456') }) - it('should distribute multi-character input across inputs', () => { - fixtureEl.innerHTML = getOtpHtml() - - const otpEl = fixtureEl.querySelector('.otp-input') - new OtpInput(otpEl) // eslint-disable-line no-new - const inputs = otpEl.querySelectorAll('input') - - inputs[0].focus() - // Simulate autofill that puts multiple characters in first input - inputs[0].value = '1234' - inputs[0].dispatchEvent(createEvent('input')) - - expect(inputs[0].value).toEqual('1') - expect(inputs[1].value).toEqual('2') - expect(inputs[2].value).toEqual('3') - expect(inputs[3].value).toEqual('4') - expect(document.activeElement).toEqual(inputs[3]) - }) - - it('should not advance when entering digit in last input', () => { - fixtureEl.innerHTML = getOtpHtml() + it('should keep letters for the alphanumeric type', () => { + fixtureEl.innerHTML = getOtpHtml('data-bs-type="alphanumeric"') - const otpEl = fixtureEl.querySelector('.otp-input') + const otpEl = fixtureEl.querySelector('.otp') new OtpInput(otpEl) // eslint-disable-line no-new - const inputs = otpEl.querySelectorAll('input') + const input = otpEl.querySelector('input') - inputs[5].focus() - inputs[5].value = '9' - inputs[5].dispatchEvent(createEvent('input')) + typeInto(input, 'a1B2c3') - expect(inputs[5].value).toEqual('9') - expect(document.activeElement).toEqual(inputs[5]) + expect(input.value).toEqual('a1B2c3') }) }) - describe('keydown handling', () => { - it('should move focus to previous input on backspace when current is empty', () => { - fixtureEl.innerHTML = getOtpHtml() - - const otpEl = fixtureEl.querySelector('.otp-input') - new OtpInput(otpEl) // eslint-disable-line no-new - const inputs = otpEl.querySelectorAll('input') - - inputs[0].value = '1' - inputs[1].focus() - - const backspaceEvent = new KeyboardEvent('keydown', { - key: 'Backspace', - bubbles: true - }) - inputs[1].dispatchEvent(backspaceEvent) - - expect(document.activeElement).toEqual(inputs[0]) - expect(inputs[0].value).toEqual('') - }) - - it('should navigate left with ArrowLeft', () => { - fixtureEl.innerHTML = getOtpHtml() - - const otpEl = fixtureEl.querySelector('.otp-input') - new OtpInput(otpEl) // eslint-disable-line no-new - const inputs = otpEl.querySelectorAll('input') - - inputs[2].focus() - - const arrowEvent = new KeyboardEvent('keydown', { - key: 'ArrowLeft', - bubbles: true - }) - inputs[2].dispatchEvent(arrowEvent) - - expect(document.activeElement).toEqual(inputs[1]) - }) - - it('should navigate right with ArrowRight', () => { - fixtureEl.innerHTML = getOtpHtml() - - const otpEl = fixtureEl.querySelector('.otp-input') - new OtpInput(otpEl) // eslint-disable-line no-new - const inputs = otpEl.querySelectorAll('input') - - inputs[2].focus() - - const arrowEvent = new KeyboardEvent('keydown', { - key: 'ArrowRight', - bubbles: true - }) - inputs[2].dispatchEvent(arrowEvent) - - expect(document.activeElement).toEqual(inputs[3]) - }) - - it('should not navigate left when at first input', () => { - fixtureEl.innerHTML = getOtpHtml() - - const otpEl = fixtureEl.querySelector('.otp-input') - new OtpInput(otpEl) // eslint-disable-line no-new - const inputs = otpEl.querySelectorAll('input') - - inputs[0].focus() - - const arrowEvent = new KeyboardEvent('keydown', { - key: 'ArrowLeft', - bubbles: true - }) - inputs[0].dispatchEvent(arrowEvent) - - expect(document.activeElement).toEqual(inputs[0]) - }) - - it('should not navigate right when at last input', () => { - fixtureEl.innerHTML = getOtpHtml() - - const otpEl = fixtureEl.querySelector('.otp-input') - new OtpInput(otpEl) // eslint-disable-line no-new - const inputs = otpEl.querySelectorAll('input') - - inputs[5].focus() - - const arrowEvent = new KeyboardEvent('keydown', { - key: 'ArrowRight', - bubbles: true - }) - inputs[5].dispatchEvent(arrowEvent) - - expect(document.activeElement).toEqual(inputs[5]) - }) + describe('mask', () => { + it('should render the mask character but keep the real value', () => { + fixtureEl.innerHTML = getOtpHtml('data-bs-mask="true"') - it('should shift values left on Delete key', () => { - fixtureEl.innerHTML = getOtpHtml() - - const otpEl = fixtureEl.querySelector('.otp-input') + const otpEl = fixtureEl.querySelector('.otp') const otp = new OtpInput(otpEl) - const inputs = otpEl.querySelectorAll('input') otp.setValue('123456') - inputs[2].focus() - const deleteEvent = new KeyboardEvent('keydown', { - key: 'Delete', - bubbles: true - }) - inputs[2].dispatchEvent(deleteEvent) - - expect(inputs[0].value).toEqual('1') - expect(inputs[1].value).toEqual('2') - expect(inputs[2].value).toEqual('4') - expect(inputs[3].value).toEqual('5') - expect(inputs[4].value).toEqual('6') - expect(inputs[5].value).toEqual('') + expect(otp.getValue()).toEqual('123456') + const slots = otpEl.querySelectorAll('.otp-slot') + expect([...slots].every(slot => slot.textContent === '•')).toBeTrue() }) - it('should not move focus on backspace when current input has value', () => { - fixtureEl.innerHTML = getOtpHtml() + it('should not turn the input into a password field', () => { + fixtureEl.innerHTML = getOtpHtml('data-bs-mask="true"') - const otpEl = fixtureEl.querySelector('.otp-input') + const otpEl = fixtureEl.querySelector('.otp') new OtpInput(otpEl) // eslint-disable-line no-new - const inputs = otpEl.querySelectorAll('input') - - inputs[1].value = '5' - inputs[1].focus() - const backspaceEvent = new KeyboardEvent('keydown', { - key: 'Backspace', - bubbles: true - }) - inputs[1].dispatchEvent(backspaceEvent) - - // Should stay on same input (browser handles clearing the value) - expect(document.activeElement).toEqual(inputs[1]) + expect(otpEl.querySelector('input').type).toEqual('text') }) }) - describe('paste handling', () => { - it('should distribute pasted digits across inputs', () => { - fixtureEl.innerHTML = getOtpHtml() - - const otpEl = fixtureEl.querySelector('.otp-input') - new OtpInput(otpEl) // eslint-disable-line no-new - const inputs = otpEl.querySelectorAll('input') - - inputs[0].focus() - - const pasteEvent = new ClipboardEvent('paste', { - bubbles: true, - clipboardData: new DataTransfer() - }) - pasteEvent.clipboardData.setData('text', '123456') - inputs[0].dispatchEvent(pasteEvent) - - expect(inputs[0].value).toEqual('1') - expect(inputs[1].value).toEqual('2') - expect(inputs[2].value).toEqual('3') - expect(inputs[3].value).toEqual('4') - expect(inputs[4].value).toEqual('5') - expect(inputs[5].value).toEqual('6') - }) - - it('should strip non-digits from pasted content', () => { - fixtureEl.innerHTML = getOtpHtml() + describe('separators', () => { + it('should render separators between configured groups', () => { + fixtureEl.innerHTML = getOtpHtml('data-bs-groups="[3,3]"') - const otpEl = fixtureEl.querySelector('.otp-input') + const otpEl = fixtureEl.querySelector('.otp') new OtpInput(otpEl) // eslint-disable-line no-new - const inputs = otpEl.querySelectorAll('input') - - inputs[0].focus() - const pasteEvent = new ClipboardEvent('paste', { - bubbles: true, - clipboardData: new DataTransfer() - }) - pasteEvent.clipboardData.setData('text', 'abc123def456') - inputs[0].dispatchEvent(pasteEvent) - - expect(inputs[0].value).toEqual('1') - expect(inputs[1].value).toEqual('2') - expect(inputs[2].value).toEqual('3') - expect(inputs[3].value).toEqual('4') - expect(inputs[4].value).toEqual('5') - expect(inputs[5].value).toEqual('6') + expect(otpEl.querySelectorAll('.otp-separator').length).toEqual(1) + expect(otpEl.querySelectorAll('.otp-slot').length).toEqual(6) }) }) describe('events', () => { - it('should trigger complete event when all inputs filled', () => { + it('should trigger complete event when the value is full', () => { return new Promise(resolve => { fixtureEl.innerHTML = getOtpHtml() - const otpEl = fixtureEl.querySelector('.otp-input') + const otpEl = fixtureEl.querySelector('.otp') const otp = new OtpInput(otpEl) - otpEl.addEventListener('complete.bs.otp-input', event => { + otpEl.addEventListener('complete.bs.otpInput', event => { expect(event.value).toEqual('123456') resolve() }) @@ -484,31 +272,29 @@ describe('OtpInput', () => { }) }) - it('should trigger input event on each input change', () => { + it('should trigger input event with the current value on each change', () => { return new Promise(resolve => { fixtureEl.innerHTML = getOtpHtml() - const otpEl = fixtureEl.querySelector('.otp-input') + const otpEl = fixtureEl.querySelector('.otp') new OtpInput(otpEl) // eslint-disable-line no-new - const inputs = otpEl.querySelectorAll('input') + const input = otpEl.querySelector('input') - otpEl.addEventListener('input.bs.otp-input', event => { - expect(event.value).toEqual('1') - expect(event.index).toEqual(0) + otpEl.addEventListener('input.bs.otpInput', event => { + expect(event.value).toEqual('12') resolve() }) - inputs[0].value = '1' - inputs[0].dispatchEvent(createEvent('input')) + typeInto(input, '12') }) }) }) describe('dispose', () => { - it('should dispose the instance', () => { + it('should dispose the instance and remove generated markup', () => { fixtureEl.innerHTML = getOtpHtml() - const otpEl = fixtureEl.querySelector('.otp-input') + const otpEl = fixtureEl.querySelector('.otp') const otp = new OtpInput(otpEl) expect(OtpInput.getInstance(otpEl)).not.toBeNull() @@ -516,6 +302,8 @@ describe('OtpInput', () => { otp.dispose() expect(OtpInput.getInstance(otpEl)).toBeNull() + expect(fixtureEl.querySelector('.otp-slots')).toBeNull() + expect(fixtureEl.querySelector('.otp').classList.contains('otp-rendered')).toBeFalse() }) }) @@ -523,7 +311,7 @@ describe('OtpInput', () => { it('should return otp-input instance', () => { fixtureEl.innerHTML = getOtpHtml() - const otpEl = fixtureEl.querySelector('.otp-input') + const otpEl = fixtureEl.querySelector('.otp') const otp = new OtpInput(otpEl) expect(OtpInput.getInstance(otpEl)).toEqual(otp) @@ -543,7 +331,7 @@ describe('OtpInput', () => { it('should return existing instance', () => { fixtureEl.innerHTML = getOtpHtml() - const otpEl = fixtureEl.querySelector('.otp-input') + const otpEl = fixtureEl.querySelector('.otp') const otp = new OtpInput(otpEl) expect(OtpInput.getOrCreateInstance(otpEl)).toEqual(otp) @@ -553,7 +341,7 @@ describe('OtpInput', () => { it('should create new instance when none exists', () => { fixtureEl.innerHTML = getOtpHtml() - const otpEl = fixtureEl.querySelector('.otp-input') + const otpEl = fixtureEl.querySelector('.otp') expect(OtpInput.getInstance(otpEl)).toBeNull() expect(OtpInput.getOrCreateInstance(otpEl)).toBeInstanceOf(OtpInput) diff --git a/scss/forms/_otp-input.scss b/scss/forms/_otp-input.scss index 2e42960619..06e34a57be 100644 --- a/scss/forms/_otp-input.scss +++ b/scss/forms/_otp-input.scss @@ -1,19 +1,30 @@ @use "../functions" as *; +@use "../mixins/border-radius" as *; +@use "../mixins/box-shadow" as *; +@use "../mixins/focus-ring" as *; @use "../mixins/tokens" as *; +@use "../mixins/transition" as *; $otp-tokens: () !default; // scss-docs-start otp-tokens +// stylelint-disable custom-property-no-missing-var-function // stylelint-disable-next-line scss/dollar-variable-default $otp-tokens: defaults( ( --otp-size: var(--btn-input-lg-min-height), --otp-font-size: var(--btn-input-font-size), --otp-gap: .5rem, + --otp-slot-fg: var(--btn-input-fg), + --otp-slot-bg: var(--btn-input-bg), + --otp-slot-border-width: var(--border-width), + --otp-slot-border-color: var(--border-color), + --otp-slot-border-radius: var(--radius-5), ), $otp-tokens ); // scss-docs-end otp-tokens +// stylelint-enable custom-property-no-missing-var-function // scss-docs-start otp-sizes $otp-sizes: () !default; @@ -28,43 +39,94 @@ $otp-sizes: defaults( .otp { @include tokens($otp-tokens); + position: relative; + display: flex; + } + + // A single real input backs the whole control. Once the JS renders the + // visual slots (`.otp-rendered`), the input becomes a transparent overlay + // that captures all interaction while the slots display the value. + .otp-rendered .otp-input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + color: transparent; + text-align: center; + cursor: default; + caret-color: transparent; + background-color: transparent; + border: 0; + outline: 0; + box-shadow: none; + + // Never reveal the underlying characters, even on selection + &::selection { + color: transparent; + background-color: transparent; + } + } + + .otp-slots { display: inline-flex; gap: var(--otp-gap); + pointer-events: none; // let clicks fall through to the input overlay + } + + .otp-slot { + display: flex; + align-items: center; + justify-content: center; + width: var(--otp-size); + min-height: var(--otp-size); + font-size: var(--otp-font-size); + font-weight: 500; + line-height: 1; + color: var(--otp-slot-fg); + 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)); + @include box-shadow(var(--box-shadow-inset)); + @include transition(border-color .15s ease-in-out, box-shadow .15s ease-in-out); + } - .form-control { - width: var(--otp-size); - min-height: var(--otp-size); - padding: 0; - font-size: var(--otp-font-size); - font-weight: 500; - line-height: 1; - text-align: center; - - // Remove default number spinners - &::-webkit-outer-spin-button, - &::-webkit-inner-spin-button { - margin: 0; - appearance: none; - } - - &[type="number"] { - appearance: textfield; - } - - &:focus, - &:focus-visible { - z-index: 1; - } + // The slot at the caret gets the focus ring; empty active slots show a + // blinking caret so the entry point is obvious. + .otp-slot-active { + --focus-ring-offset: -1px; + z-index: 1; + @include focus-ring(true); + + &:not(.otp-slot-filled)::after { + width: 1px; + height: 50%; + content: ""; + background-color: var(--otp-slot-fg); + animation: otp-caret-blink 1s step-end infinite; } } - // When used with .input-group, disable the gap and prevent inputs from stretching - .otp.input-group { + // Disabled state mirrors disabled form controls + .otp-input:disabled ~ .otp-slots .otp-slot { + background-color: var(--bg-2); + } + + // Connected slots share borders for a single cohesive field + .otp-connected .otp-slots { gap: 0; - width: auto; // Override input-group's width: 100% + } + .otp-connected .otp-slot { + border-radius: 0; // stylelint-disable-line property-disallowed-list - .form-control { - flex: 0 0 auto; // Don't grow or shrink, use fixed width + &:not(:first-child) { + margin-inline-start: calc(var(--otp-slot-border-width) * -1); + } + &:first-child { + @include border-start-radius(var(--otp-slot-border-radius)); + } + &:last-child { + @include border-end-radius(var(--otp-slot-border-radius)); } } @@ -76,4 +138,22 @@ $otp-sizes: defaults( color: var(--fg-4); user-select: none; } + + // OTP input sizing — keep in sync with `$form-control-sizes`. + @each $size, $_ in $otp-sizes { + .otp-#{$size} { + --otp-size: var(--btn-input-#{$size}-min-height); + --otp-font-size: var(--btn-input-#{$size}-font-size); + } + } +} + +@keyframes otp-caret-blink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } } diff --git a/scss/forms/_validation.scss b/scss/forms/_validation.scss index 58660f3cc8..1337be222d 100644 --- a/scss/forms/_validation.scss +++ b/scss/forms/_validation.scss @@ -333,16 +333,15 @@ $validation-states: defaults( } } - // OTP — validation applies to the wrapper; inner .form-control inherits. + // OTP — validation applies to the wrapper; the visual slots inherit the state. .otp { @include form-validation-state-selector($state) { - .form-control { - --control-border-color: var(--#{$theme}-border); + .otp-slot { + --otp-slot-border-color: var(--#{$theme}-border); } - .form-control:focus { + .otp-slot-active { @include focus-ring(true, $color: var(--#{$theme}-focus-ring)); - --control-border-color: var(--#{$theme}-border); } } } diff --git a/site/src/content/docs/forms/otp-input.mdx b/site/src/content/docs/forms/otp-input.mdx index 9cd103758e..ce0c10ab74 100644 --- a/site/src/content/docs/forms/otp-input.mdx +++ b/site/src/content/docs/forms/otp-input.mdx @@ -1,6 +1,6 @@ --- title: OTP input -description: Create connected input fields for one-time passwords, PIN codes, and verification codes with automatic focus advancement. +description: Collect one-time passwords, PIN codes, and verification codes with a single accessible input rendered as separate digit slots. toc: true css_layer: components js: required @@ -8,130 +8,97 @@ js: required ## Overview -OTP (One-Time Password) inputs are a common pattern for two-factor authentication, verification codes, and PIN entry. Bootstrap's OTP input component provides: +OTP (One-Time Password) inputs are a common pattern for two-factor authentication, verification codes, and PIN entry. Bootstrap's OTP input is built around a **single real text field** that is visually rendered as separate digit slots. This keeps the control simple and fully accessible—screen readers, password managers, and SMS autofill all treat it like any other text input—while still giving you the familiar multi-box appearance. -- **Auto-advance**: Focus moves to the next input after entering a digit -- **Backspace navigation**: Pressing backspace in an empty field moves to the previous field -- **Paste support**: Paste a full code and it distributes across all inputs -- **Browser autofill**: Supports the `autocomplete="one-time-code"` attribute for SMS/email code autofill -- **Keyboard navigation**: Use arrow keys to move between inputs +- **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 +- **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 -OTP input is built on [form controls]([[docsref:/forms/form-control]]) and, when using connected inputs, leverages our [input group]([[docsref:/forms/input-group]]) styles. +OTP input is built on [form controls]([[docsref:/forms/form-control]]) and shares its sizing tokens. -## Basic example +## How it works -Wrap your inputs in a container with `.otp` and add `data-bs-otp` to enable the JavaScript behavior. Each input should have `.form-control` for styling and be a single-character field. +Wrap a single `` in a container with `.otp` and add `data-bs-otp` to enable the JavaScript. The plugin reads the input's `maxlength` to determine how many slots to render, hides the real input behind transparent overlay styling, and draws one `.otp-slot` per character. Give the input an accessible name with `aria-label` or an associated `