]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
New OTP input (#41981)
authorMark Otto <markd.otto@gmail.com>
Mon, 29 Dec 2025 22:59:21 +0000 (14:59 -0800)
committerMark Otto <markdotto@gmail.com>
Fri, 9 Jan 2026 04:14:09 +0000 (20:14 -0800)
* 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

.bundlewatch.config.json
js/index.esm.js
js/index.umd.js
js/src/otp-input.js [new file with mode: 0644]
js/tests/unit/otp-input.spec.js [new file with mode: 0644]
scss/forms/_otp-input.scss [new file with mode: 0644]
scss/forms/index.scss
site/data/sidebar.yml
site/src/content/docs/forms/otp-input.mdx [new file with mode: 0644]

index f151d30c30d51fae608d97e61c8b806d188ecb1d..236c6ffa1df026a9f8e19ffb915c63331a51a525 100644 (file)
     },
     {
       "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": {
index 1c24edfe3fdef7a51c2fa46e1153fb421eb45ec0..a4c7cdf3866c9d91adcd1932c3a4fbd94aeb51d8 100644 (file)
@@ -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'
index eb0dc224af0dfc4eb176696fd1d6861d2463de75..4da18556e30e52bc323b91aa6ea4b2ecfd286399 100644 (file)
@@ -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 (file)
index 0000000..6641eb3
--- /dev/null
@@ -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 (file)
index 0000000..406f236
--- /dev/null
@@ -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(() => '<input type="text" class="form-control">')
+      .join('')
+    return `<div class="otp-input" ${attributes}>${inputs}</div>`
+  }
+
+  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 = '<div></div>'
+
+      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 (file)
index 0000000..f23bc36
--- /dev/null
@@ -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};
+  }
+}
index 5eddb3027ac83213f82ec8886ede5baaf7044c45..54539eebb604b5408c693a5cd3bfd135582e95b1 100644 (file)
@@ -8,4 +8,5 @@
 @forward "floating-labels";
 @forward "input-group";
 @forward "strength";
+@forward "otp-input";
 @forward "validation";
index 8da00f9799518b6ade546226ac1e794fd37b8494..f8fa3d610084eab225a11f00475a880a62c093bb 100644 (file)
@@ -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 (file)
index 0000000..74db701
--- /dev/null
@@ -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.
+
+<Example code={`<div class="otp" data-bs-otp>
+    <input type="text" class="form-control" aria-label="Digit 1">
+    <input type="text" class="form-control" aria-label="Digit 2">
+    <input type="text" class="form-control" aria-label="Digit 3">
+    <input type="text" class="form-control" aria-label="Digit 4">
+    <input type="text" class="form-control" aria-label="Digit 5">
+    <input type="text" class="form-control" aria-label="Digit 6">
+  </div>`} />
+
+## 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.
+
+<Example code={`<div class="otp input-group" data-bs-otp>
+    <input type="text" class="form-control" aria-label="Digit 1">
+    <input type="text" class="form-control" aria-label="Digit 2">
+    <input type="text" class="form-control" aria-label="Digit 3">
+    <input type="text" class="form-control" aria-label="Digit 4">
+    <input type="text" class="form-control" aria-label="Digit 5">
+    <input type="text" class="form-control" aria-label="Digit 6">
+  </div>`} />
+
+## 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.
+
+<Example code={`<div class="otp input-group" data-bs-otp>
+    <input type="text" class="form-control" aria-label="Digit 1">
+    <input type="text" class="form-control" aria-label="Digit 2">
+    <input type="text" class="form-control" aria-label="Digit 3">
+    <input type="text" class="form-control" aria-label="Digit 4">
+  </div>`} />
+
+## 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.
+
+<Example code={`<div class="otp" data-bs-otp>
+    <input type="text" class="form-control" aria-label="Digit 1">
+    <input type="text" class="form-control" aria-label="Digit 2">
+    <input type="text" class="form-control" aria-label="Digit 3">
+    <span class="otp-separator">–</span>
+    <input type="text" class="form-control" aria-label="Digit 4">
+    <input type="text" class="form-control" aria-label="Digit 5">
+    <input type="text" class="form-control" aria-label="Digit 6">
+  </div>`} />
+
+You can also use separators with connected inputs by wrapping each group in a nested `.input-group`:
+
+<Example code={`<div class="otp" data-bs-otp>
+    <div class="input-group">
+      <input type="text" class="form-control" aria-label="Digit 1">
+      <input type="text" class="form-control" aria-label="Digit 2">
+      <input type="text" class="form-control" aria-label="Digit 3">
+    </div>
+    <span class="otp-separator">–</span>
+    <div class="input-group">
+      <input type="text" class="form-control" aria-label="Digit 4">
+      <input type="text" class="form-control" aria-label="Digit 5">
+      <input type="text" class="form-control" aria-label="Digit 6">
+    </div>
+  </div>`} />
+
+## 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.
+
+<Example class="vstack align-items-start gap-3" code={`<div class="otp input-group otp-sm" data-bs-otp>
+    <input type="text" class="form-control" aria-label="Digit 1">
+    <input type="text" class="form-control" aria-label="Digit 2">
+    <input type="text" class="form-control" aria-label="Digit 3">
+    <input type="text" class="form-control" aria-label="Digit 4">
+    <input type="text" class="form-control" aria-label="Digit 5">
+    <input type="text" class="form-control" aria-label="Digit 6">
+  </div>
+
+  <div class="otp input-group" data-bs-otp>
+    <input type="text" class="form-control" aria-label="Digit 1">
+    <input type="text" class="form-control" aria-label="Digit 2">
+    <input type="text" class="form-control" aria-label="Digit 3">
+    <input type="text" class="form-control" aria-label="Digit 4">
+    <input type="text" class="form-control" aria-label="Digit 5">
+    <input type="text" class="form-control" aria-label="Digit 6">
+  </div>
+
+  <div class="otp input-group otp-lg" data-bs-otp>
+    <input type="text" class="form-control" aria-label="Digit 1">
+    <input type="text" class="form-control" aria-label="Digit 2">
+    <input type="text" class="form-control" aria-label="Digit 3">
+    <input type="text" class="form-control" aria-label="Digit 4">
+    <input type="text" class="form-control" aria-label="Digit 5">
+    <input type="text" class="form-control" aria-label="Digit 6">
+  </div>`} />
+
+## Disabled
+
+Add the `disabled` attribute to each input to prevent interaction.
+
+<Example code={`<div class="otp input-group" data-bs-otp>
+    <input type="text" class="form-control" aria-label="Digit 1" disabled>
+    <input type="text" class="form-control" aria-label="Digit 2" disabled>
+    <input type="text" class="form-control" aria-label="Digit 3" disabled>
+    <input type="text" class="form-control" aria-label="Digit 4" disabled>
+    <input type="text" class="form-control" aria-label="Digit 5" disabled>
+    <input type="text" class="form-control" aria-label="Digit 6" disabled>
+  </div>`} />
+
+## Validation
+
+Add `.is-valid` or `.is-invalid` to the container to show validation states.
+
+<Example code={`<div class="otp input-group is-valid mb-3" data-bs-otp>
+    <input type="text" class="form-control" value="1" aria-label="Digit 1">
+    <input type="text" class="form-control" value="2" aria-label="Digit 2">
+    <input type="text" class="form-control" value="3" aria-label="Digit 3">
+    <input type="text" class="form-control" value="4" aria-label="Digit 4">
+    <input type="text" class="form-control" value="5" aria-label="Digit 5">
+    <input type="text" class="form-control" value="6" aria-label="Digit 6">
+  </div>
+  <div class="otp input-group is-invalid" data-bs-otp>
+    <input type="text" class="form-control" value="1" aria-label="Digit 1">
+    <input type="text" class="form-control" value="2" aria-label="Digit 2">
+    <input type="text" class="form-control" value="3" aria-label="Digit 3">
+    <input type="text" class="form-control" aria-label="Digit 4">
+    <input type="text" class="form-control" aria-label="Digit 5">
+    <input type="text" class="form-control" aria-label="Digit 6">
+  </div>`} />
+
+## With form labels
+
+Use a form label and help text for better accessibility.
+
+<Example code={`<div class="mb-3">
+    <label class="form-label" id="otpLabel">Verification code</label>
+    <div class="otp input-group" data-bs-otp aria-labelledby="otpLabel" aria-describedby="otpHelp">
+      <input type="text" class="form-control" aria-label="Digit 1">
+      <input type="text" class="form-control" aria-label="Digit 2">
+      <input type="text" class="form-control" aria-label="Digit 3">
+      <input type="text" class="form-control" aria-label="Digit 4">
+      <input type="text" class="form-control" aria-label="Digit 5">
+      <input type="text" class="form-control" aria-label="Digit 6">
+    </div>
+    <div id="otpHelp" class="form-text">Enter the 6-digit code sent to your phone.</div>
+  </div>`} />
+
+## Usage
+
+### Via data attributes
+
+Add `data-bs-otp` to your container element to automatically initialize the OTP input behavior.
+
+```html
+<div class="otp" data-bs-otp>
+  <input type="text" class="form-control">
+  <input type="text" class="form-control">
+  <input type="text" class="form-control">
+  <input type="text" class="form-control">
+</div>
+```
+
+### 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
+
+<ScssDocs name="otp-input-variables" file="scss/forms/_otp-input.scss" />