From: Mark Otto Date: Mon, 29 Dec 2025 22:59:21 +0000 (-0800) Subject: New OTP input (#41981) X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=9c32c543a392ef13f5009dffa90ce5a7e1e09e59;p=thirdparty%2Fbootstrap.git New OTP input (#41981) * feat: add OTP input component - Add OtpInput JavaScript component with keyboard navigation and paste support - Add SCSS styles for OTP input fields - Add documentation page for OTP input - Add unit tests for OTP input * Bump bundlewatch * Missed file --- diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index f151d30c30..236c6ffa1d 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -26,35 +26,35 @@ }, { "path": "./dist/css/bootstrap.css", - "maxSize": "35.75 kB" + "maxSize": "36.0 kB" }, { "path": "./dist/css/bootstrap.min.css", - "maxSize": "32.25 kB" + "maxSize": "32.5 kB" }, { "path": "./dist/js/bootstrap.bundle.js", - "maxSize": "48.5 kB" + "maxSize": "49.75 kB" }, { "path": "./dist/js/bootstrap.bundle.min.js", - "maxSize": "25.25 kB" + "maxSize": "26.0 kB" }, { "path": "./dist/js/bootstrap.esm.js", - "maxSize": "34.75 kB" + "maxSize": "36.0 kB" }, { "path": "./dist/js/bootstrap.esm.min.js", - "maxSize": "21.0 kB" + "maxSize": "22.25 kB" }, { "path": "./dist/js/bootstrap.js", - "maxSize": "35.25 kB" + "maxSize": "36.5 kB" }, { "path": "./dist/js/bootstrap.min.js", - "maxSize": "19.0 kB" + "maxSize": "19.75 kB" } ], "ci": { diff --git a/js/index.esm.js b/js/index.esm.js index 1c24edfe3f..a4c7cdf386 100644 --- a/js/index.esm.js +++ b/js/index.esm.js @@ -13,6 +13,7 @@ export { default as Dialog } from './src/dialog.js' export { default as Dropdown } from './src/dropdown.js' export { default as Offcanvas } from './src/offcanvas.js' export { default as Strength } from './src/strength.js' +export { default as OtpInput } from './src/otp-input.js' export { default as Popover } from './src/popover.js' export { default as ScrollSpy } from './src/scrollspy.js' export { default as Tab } from './src/tab.js' diff --git a/js/index.umd.js b/js/index.umd.js index eb0dc224af..4da18556e3 100644 --- a/js/index.umd.js +++ b/js/index.umd.js @@ -13,6 +13,7 @@ import Dialog from './src/dialog.js' import Dropdown from './src/dropdown.js' import Offcanvas from './src/offcanvas.js' import Strength from './src/strength.js' +import OtpInput from './src/otp-input.js' import Popover from './src/popover.js' import ScrollSpy from './src/scrollspy.js' import Tab from './src/tab.js' @@ -29,6 +30,7 @@ export default { Dropdown, Offcanvas, Strength, + OtpInput, Popover, ScrollSpy, Tab, diff --git a/js/src/otp-input.js b/js/src/otp-input.js new file mode 100644 index 0000000000..6641eb39cd --- /dev/null +++ b/js/src/otp-input.js @@ -0,0 +1,250 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap otp-input.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import BaseComponent from './base-component.js' +import EventHandler from './dom/event-handler.js' +import SelectorEngine from './dom/selector-engine.js' + +/** + * Constants + */ + +const NAME = 'otpInput' +const DATA_KEY = 'bs.otp-input' +const EVENT_KEY = `.${DATA_KEY}` +const DATA_API_KEY = '.data-api' + +const EVENT_COMPLETE = `complete${EVENT_KEY}` +const EVENT_INPUT = `input${EVENT_KEY}` + +const SELECTOR_DATA_OTP = '[data-bs-otp]' +const SELECTOR_INPUT = 'input' + +const Default = { + length: 6, + mask: false +} + +const DefaultType = { + length: 'number', + mask: 'boolean' +} + +/** + * Class definition + */ + +class OtpInput extends BaseComponent { + constructor(element, config) { + super(element, config) + + this._inputs = SelectorEngine.find(SELECTOR_INPUT, this._element) + this._setupInputs() + this._addEventListeners() + } + + // Getters + static get Default() { + return Default + } + + static get DefaultType() { + return DefaultType + } + + static get NAME() { + return NAME + } + + // Public + getValue() { + return this._inputs.map(input => input.value).join('') + } + + setValue(value) { + const chars = String(value).split('') + for (const [index, input] of this._inputs.entries()) { + input.value = chars[index] || '' + } + + this._checkComplete() + } + + clear() { + for (const input of this._inputs) { + input.value = '' + } + + this._inputs[0]?.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() + } + } + + // 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') + } + } + } + + _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)) + } + } + + _handleInput(event, index) { + const input = event.target + + // Only allow digits + if (!/^\d*$/.test(input.value)) { + input.value = input.value.replace(/\D/g, '') + } + + const { value } = input + + // Handle multi-character input (some browsers/autofill) + if (value.length > 1) { + // Distribute characters across inputs + const chars = value.split('') + input.value = chars[0] || '' + + 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() + } + + EventHandler.trigger(this._element, EVENT_INPUT, { + value: this.getValue(), + index + }) + + this._checkComplete() + } + + _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() + } + + 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 + } + + case 'ArrowLeft': { + if (index > 0) { + event.preventDefault() + this._inputs[index - 1].focus() + } + + break + } + + case 'ArrowRight': { + if (index < this._inputs.length - 1) { + event.preventDefault() + this._inputs[index + 1].focus() + } + + break + } + + // No default + } + } + + _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() + } + } + + _handleFocus(event) { + // Select the content on focus for easy replacement + event.target.select() + } + + _checkComplete() { + const value = this.getValue() + const isComplete = value.length === this._inputs.length && + this._inputs.every(input => input.value !== '') + + if (isComplete) { + EventHandler.trigger(this._element, EVENT_COMPLETE, { value }) + } + } +} + +/** + * Data API implementation + */ + +EventHandler.on(document, `DOMContentLoaded${EVENT_KEY}${DATA_API_KEY}`, () => { + for (const element of SelectorEngine.find(SELECTOR_DATA_OTP)) { + OtpInput.getOrCreateInstance(element) + } +}) + +export default OtpInput diff --git a/js/tests/unit/otp-input.spec.js b/js/tests/unit/otp-input.spec.js new file mode 100644 index 0000000000..406f236c85 --- /dev/null +++ b/js/tests/unit/otp-input.spec.js @@ -0,0 +1,448 @@ +import OtpInput from '../../src/otp-input.js' +import { clearFixture, createEvent, getFixture } from '../helpers/fixture.js' + +describe('OtpInput', () => { + let fixtureEl + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + }) + + const getOtpHtml = (inputCount = 6, attributes = '') => { + const inputs = Array.from({ length: inputCount }) + .map(() => '') + .join('') + return `
${inputs}
` + } + + describe('VERSION', () => { + it('should return plugin version', () => { + expect(OtpInput.VERSION).toEqual(jasmine.any(String)) + }) + }) + + describe('DATA_KEY', () => { + it('should return plugin data key', () => { + expect(OtpInput.DATA_KEY).toEqual('bs.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) + }) + }) + + describe('DefaultType', () => { + it('should return default type config', () => { + expect(OtpInput.DefaultType).toEqual(jasmine.any(Object)) + }) + }) + + describe('constructor', () => { + 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 otpByElement = new OtpInput(otpEl) + + expect(otpBySelector._element).toEqual(otpEl) + expect(otpByElement._element).toEqual(otpEl) + }) + + it('should set up inputs with correct attributes', () => { + fixtureEl.innerHTML = getOtpHtml() + + const otpEl = fixtureEl.querySelector('.otp-input') + 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*') + } + + // 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') + } + }) + + it('should set input type to password when mask is true', () => { + fixtureEl.innerHTML = getOtpHtml(6, 'data-bs-mask="true"') + + const otpEl = fixtureEl.querySelector('.otp-input') + new OtpInput(otpEl) // eslint-disable-line no-new + + const inputs = otpEl.querySelectorAll('input') + + for (const input of inputs) { + expect(input.getAttribute('type')).toEqual('password') + } + }) + }) + + describe('getValue', () => { + it('should return empty string when no values entered', () => { + fixtureEl.innerHTML = getOtpHtml() + + const otpEl = fixtureEl.querySelector('.otp-input') + const otp = new OtpInput(otpEl) + + expect(otp.getValue()).toEqual('') + }) + + it('should return concatenated values from all inputs', () => { + 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' + + expect(otp.getValue()).toEqual('123') + }) + }) + + 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') + + otp.setValue('123456') + + 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 handle partial values', () => { + fixtureEl.innerHTML = getOtpHtml() + + const otpEl = fixtureEl.querySelector('.otp-input') + const otp = new OtpInput(otpEl) + const inputs = otpEl.querySelectorAll('input') + + otp.setValue('123') + + 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('') + }) + + it('should handle numeric values', () => { + fixtureEl.innerHTML = getOtpHtml() + + const otpEl = fixtureEl.querySelector('.otp-input') + const otp = new OtpInput(otpEl) + const inputs = otpEl.querySelectorAll('input') + + otp.setValue(123456) + + expect(inputs[0].value).toEqual('1') + expect(inputs[5].value).toEqual('6') + }) + }) + + describe('clear', () => { + it('should clear all input values', () => { + fixtureEl.innerHTML = getOtpHtml() + + const otpEl = fixtureEl.querySelector('.otp-input') + const otp = new OtpInput(otpEl) + const inputs = otpEl.querySelectorAll('input') + + otp.setValue('123456') + otp.clear() + + for (const input of inputs) { + expect(input.value).toEqual('') + } + }) + }) + + describe('focus', () => { + it('should focus first empty input', () => { + 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' + + otp.focus() + + expect(document.activeElement).toEqual(inputs[2]) + }) + + it('should focus last input when all filled', () => { + fixtureEl.innerHTML = getOtpHtml() + + const otpEl = fixtureEl.querySelector('.otp-input') + const otp = new OtpInput(otpEl) + const inputs = otpEl.querySelectorAll('input') + + otp.setValue('123456') + otp.focus() + + expect(document.activeElement).toEqual(inputs[5]) + }) + }) + + describe('input handling', () => { + it('should auto-advance to next input on digit entry', () => { + 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() + inputs[0].value = '1' + inputs[0].dispatchEvent(createEvent('input')) + + expect(document.activeElement).toEqual(inputs[1]) + }) + + it('should strip non-digit characters', () => { + 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() + inputs[0].value = 'a1b' + inputs[0].dispatchEvent(createEvent('input')) + + expect(inputs[0].value).toEqual('1') + }) + }) + + 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]) + }) + }) + + 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() + + 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', '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') + }) + }) + + describe('events', () => { + it('should trigger complete event when all inputs filled', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = getOtpHtml() + + const otpEl = fixtureEl.querySelector('.otp-input') + const otp = new OtpInput(otpEl) + + otpEl.addEventListener('complete.bs.otp-input', event => { + expect(event.value).toEqual('123456') + resolve() + }) + + otp.setValue('123456') + }) + }) + + it('should trigger input event on each input change', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = getOtpHtml() + + const otpEl = fixtureEl.querySelector('.otp-input') + new OtpInput(otpEl) // eslint-disable-line no-new + const inputs = otpEl.querySelectorAll('input') + + otpEl.addEventListener('input.bs.otp-input', event => { + expect(event.value).toEqual('1') + expect(event.index).toEqual(0) + resolve() + }) + + inputs[0].value = '1' + inputs[0].dispatchEvent(createEvent('input')) + }) + }) + }) + + describe('dispose', () => { + it('should dispose the instance', () => { + fixtureEl.innerHTML = getOtpHtml() + + const otpEl = fixtureEl.querySelector('.otp-input') + const otp = new OtpInput(otpEl) + + expect(OtpInput.getInstance(otpEl)).not.toBeNull() + + otp.dispose() + + expect(OtpInput.getInstance(otpEl)).toBeNull() + }) + }) + + describe('getInstance', () => { + it('should return otp-input instance', () => { + fixtureEl.innerHTML = getOtpHtml() + + const otpEl = fixtureEl.querySelector('.otp-input') + const otp = new OtpInput(otpEl) + + expect(OtpInput.getInstance(otpEl)).toEqual(otp) + expect(OtpInput.getInstance(otpEl)).toBeInstanceOf(OtpInput) + }) + + it('should return null when there is no instance', () => { + fixtureEl.innerHTML = '
' + + const div = fixtureEl.querySelector('div') + + expect(OtpInput.getInstance(div)).toBeNull() + }) + }) + + describe('getOrCreateInstance', () => { + it('should return existing instance', () => { + fixtureEl.innerHTML = getOtpHtml() + + const otpEl = fixtureEl.querySelector('.otp-input') + const otp = new OtpInput(otpEl) + + expect(OtpInput.getOrCreateInstance(otpEl)).toEqual(otp) + expect(OtpInput.getOrCreateInstance(otpEl)).toBeInstanceOf(OtpInput) + }) + + it('should create new instance when none exists', () => { + fixtureEl.innerHTML = getOtpHtml() + + const otpEl = fixtureEl.querySelector('.otp-input') + + 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 new file mode 100644 index 0000000000..f23bc36fec --- /dev/null +++ b/scss/forms/_otp-input.scss @@ -0,0 +1,99 @@ +@use "../config" as *; +@use "../variables" as *; +@use "form-variables" as *; + +// scss-docs-start otp-input-variables +$otp-input-size: 3rem !default; +$otp-input-size-sm: 2.25rem !default; +$otp-input-size-lg: 3.5rem !default; +$otp-input-font-size: $font-size-lg !default; +$otp-input-font-size-sm: $font-size-base !default; +$otp-input-font-size-lg: $font-size-lg * 1.25 !default; +$otp-input-gap: .5rem !default; +// scss-docs-end otp-input-variables + +@layer forms { + .otp { + --otp-size: #{$otp-input-size}; + --otp-font-size: #{$otp-input-font-size}; + --otp-gap: #{$otp-input-gap}; + + display: inline-flex; + gap: var(--otp-gap); + + .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; + } + } + + &.is-valid .form-control, + .was-validated &:valid .form-control { + border-color: var(--form-valid-border-color); + + &:focus { + border-color: var(--form-valid-border-color); + --focus-ring-color: rgba(var(--success-rgb), .25); + } + } + + &.is-invalid .form-control, + .was-validated &:invalid .form-control { + border-color: var(--form-invalid-border-color); + + &:focus { + border-color: var(--form-invalid-border-color); + --focus-ring-color: rgba(var(--danger-rgb), .25); + } + } + } + + // When used with .input-group, disable the gap and prevent inputs from stretching + .otp.input-group { + gap: 0; + width: auto; // Override input-group's width: 100% + + .form-control { + flex: 0 0 auto; // Don't grow or shrink, use fixed width + } + } + + .otp-separator { + display: flex; + align-items: center; + padding-inline: var(--otp-gap); + font-size: var(--otp-font-size); + color: var(--fg-4); + user-select: none; + } + + .otp-sm { + --otp-size: #{$otp-input-size-sm}; + --otp-font-size: #{$otp-input-font-size-sm}; + } + + .otp-lg { + --otp-size: #{$otp-input-size-lg}; + --otp-font-size: #{$otp-input-font-size-lg}; + } +} diff --git a/scss/forms/index.scss b/scss/forms/index.scss index 5eddb3027a..54539eebb6 100644 --- a/scss/forms/index.scss +++ b/scss/forms/index.scss @@ -8,4 +8,5 @@ @forward "floating-labels"; @forward "input-group"; @forward "strength"; +@forward "otp-input"; @forward "validation"; diff --git a/site/data/sidebar.yml b/site/data/sidebar.yml index 8da00f9799..f8fa3d6100 100644 --- a/site/data/sidebar.yml +++ b/site/data/sidebar.yml @@ -73,6 +73,7 @@ - title: Range - title: Input group - title: Floating labels + - title: OTP input - title: Password strength - title: Layout - title: Validation diff --git a/site/src/content/docs/forms/otp-input.mdx b/site/src/content/docs/forms/otp-input.mdx new file mode 100644 index 0000000000..74db701504 --- /dev/null +++ b/site/src/content/docs/forms/otp-input.mdx @@ -0,0 +1,256 @@ +--- +title: OTP input +description: Create connected input fields for one-time passwords, PIN codes, and verification codes with automatic focus advancement. +toc: true +--- + +## 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: + +- **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 + +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. + +## Basic example + +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. + + + + + + + + + `} /> + +## Connected inputs + +Add `.input-group` to visually connect the inputs into a single cohesive field, leveraging Bootstrap's [input group](/docs/forms/input-group/) styles. + + + + + + + + + `} /> + +## Four-digit PIN + +You can use any number of inputs you want—the plugin will automatically detect the number of inputs and adjust accordingly. For example, you can use fewer inputs for shorter codes like 4-digit PINs. + + + + + + + `} /> + +## With separator + +Add a `.otp-separator` element between inputs to create grouped codes like "123-456". The separator is purely visual—the plugin ignores non-input elements. + + + + + + – + + + + `} /> + +You can also use separators with connected inputs by wrapping each group in a nested `.input-group`: + + +
+ + + +
+ – +
+ + + +
+ `} /> + +## Sizing + +Use `.otp-sm` or `.otp-lg` for different sizes. Don't use the input group size classes on the `.otp` container as we override specific CSS variables for sizing. + + + + + + + + + + +
+ + + + + + +
+ +
+ + + + + + +
`} /> + +## Disabled + +Add the `disabled` attribute to each input to prevent interaction. + + + + + + + + + `} /> + +## Validation + +Add `.is-valid` or `.is-invalid` to the container to show validation states. + + + + + + + + + +
+ + + + + + +
`} /> + +## With form labels + +Use a form label and help text for better accessibility. + + + +
+ + + + + + +
+
Enter the 6-digit code sent to your phone.
+ `} /> + +## Usage + +### Via data attributes + +Add `data-bs-otp` to your container element to automatically initialize the OTP input behavior. + +```html +
+ + + + +
+``` + +### Via JavaScript + +Initialize manually with JavaScript: + +```js +const otpElement = document.querySelector('.otp') +const otpInput = new bootstrap.OtpInput(otpElement) +``` + +### Options + +Options can be passed via data attributes or JavaScript: + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `mask` | boolean | `false` | If `true`, inputs will use `type="password"` to hide the entered values. | + +### Methods + +| Method | Description | +| --- | --- | +| `getValue()` | Returns the complete OTP value as a string. | +| `setValue(value)` | Sets the OTP value, distributing characters across inputs. | +| `clear()` | Clears all inputs and focuses the first one. | +| `focus()` | Focuses the first empty input, or the last input if all are filled. | +| `dispose()` | Destroys the component instance. | + +```js +const otpElement = document.querySelector('.otp') +const otpInput = bootstrap.OtpInput.getOrCreateInstance(otpElement) + +// Get the current value +console.log(otpInput.getValue()) // "123456" + +// Set a value programmatically +otpInput.setValue('654321') + +// Clear all inputs +otpInput.clear() +``` + +### Events + +| Event | Description | +| --- | --- | +| `complete.bs.otp` | Fired when all inputs are filled. The event's `value` property contains the complete code. | +| `input.bs.otp` | Fired on each input change. Includes `value` (current combined value) and `index` (changed input index). | + +```js +const otpElement = document.querySelector('.otp') + +otpElement.addEventListener('complete.bs.otp', event => { + console.log('OTP complete:', event.value) + // Submit the form or validate the code +}) + +otpElement.addEventListener('input.bs.otp', event => { + console.log('Current value:', event.value, 'Changed index:', event.index) +}) +``` + +## Accessibility + +- Each input should have an `aria-label` describing its position (e.g., "Digit 1") +- Use a form label with `aria-labelledby` on the container +- Add help text with `aria-describedby` for additional context +- The component automatically sets `inputmode="numeric"` for mobile keyboards +- Arrow keys allow navigation between inputs + +## CSS + +### Sass variables + +