Skip to content

Commit a79ca06

Browse files
authored
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
1 parent eebf75b commit a79ca06

File tree

9 files changed

+1066
-8
lines changed

9 files changed

+1066
-8
lines changed

.bundlewatch.config.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,35 +26,35 @@
2626
},
2727
{
2828
"path": "./dist/css/bootstrap.css",
29-
"maxSize": "35.75 kB"
29+
"maxSize": "36.0 kB"
3030
},
3131
{
3232
"path": "./dist/css/bootstrap.min.css",
33-
"maxSize": "32.25 kB"
33+
"maxSize": "32.5 kB"
3434
},
3535
{
3636
"path": "./dist/js/bootstrap.bundle.js",
37-
"maxSize": "48.5 kB"
37+
"maxSize": "49.75 kB"
3838
},
3939
{
4040
"path": "./dist/js/bootstrap.bundle.min.js",
41-
"maxSize": "25.25 kB"
41+
"maxSize": "26.0 kB"
4242
},
4343
{
4444
"path": "./dist/js/bootstrap.esm.js",
45-
"maxSize": "34.75 kB"
45+
"maxSize": "36.0 kB"
4646
},
4747
{
4848
"path": "./dist/js/bootstrap.esm.min.js",
49-
"maxSize": "21.0 kB"
49+
"maxSize": "22.25 kB"
5050
},
5151
{
5252
"path": "./dist/js/bootstrap.js",
53-
"maxSize": "35.25 kB"
53+
"maxSize": "36.5 kB"
5454
},
5555
{
5656
"path": "./dist/js/bootstrap.min.js",
57-
"maxSize": "19.0 kB"
57+
"maxSize": "19.75 kB"
5858
}
5959
],
6060
"ci": {

js/index.esm.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export { default as Dialog } from './src/dialog.js'
1313
export { default as Dropdown } from './src/dropdown.js'
1414
export { default as Offcanvas } from './src/offcanvas.js'
1515
export { default as Strength } from './src/strength.js'
16+
export { default as OtpInput } from './src/otp-input.js'
1617
export { default as Popover } from './src/popover.js'
1718
export { default as ScrollSpy } from './src/scrollspy.js'
1819
export { default as Tab } from './src/tab.js'

js/index.umd.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import Dialog from './src/dialog.js'
1313
import Dropdown from './src/dropdown.js'
1414
import Offcanvas from './src/offcanvas.js'
1515
import Strength from './src/strength.js'
16+
import OtpInput from './src/otp-input.js'
1617
import Popover from './src/popover.js'
1718
import ScrollSpy from './src/scrollspy.js'
1819
import Tab from './src/tab.js'
@@ -29,6 +30,7 @@ export default {
2930
Dropdown,
3031
Offcanvas,
3132
Strength,
33+
OtpInput,
3234
Popover,
3335
ScrollSpy,
3436
Tab,

js/src/otp-input.js

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
/**
2+
* --------------------------------------------------------------------------
3+
* Bootstrap otp-input.js
4+
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
5+
* --------------------------------------------------------------------------
6+
*/
7+
8+
import BaseComponent from './base-component.js'
9+
import EventHandler from './dom/event-handler.js'
10+
import SelectorEngine from './dom/selector-engine.js'
11+
12+
/**
13+
* Constants
14+
*/
15+
16+
const NAME = 'otpInput'
17+
const DATA_KEY = 'bs.otp-input'
18+
const EVENT_KEY = `.${DATA_KEY}`
19+
const DATA_API_KEY = '.data-api'
20+
21+
const EVENT_COMPLETE = `complete${EVENT_KEY}`
22+
const EVENT_INPUT = `input${EVENT_KEY}`
23+
24+
const SELECTOR_DATA_OTP = '[data-bs-otp]'
25+
const SELECTOR_INPUT = 'input'
26+
27+
const Default = {
28+
length: 6,
29+
mask: false
30+
}
31+
32+
const DefaultType = {
33+
length: 'number',
34+
mask: 'boolean'
35+
}
36+
37+
/**
38+
* Class definition
39+
*/
40+
41+
class OtpInput extends BaseComponent {
42+
constructor(element, config) {
43+
super(element, config)
44+
45+
this._inputs = SelectorEngine.find(SELECTOR_INPUT, this._element)
46+
this._setupInputs()
47+
this._addEventListeners()
48+
}
49+
50+
// Getters
51+
static get Default() {
52+
return Default
53+
}
54+
55+
static get DefaultType() {
56+
return DefaultType
57+
}
58+
59+
static get NAME() {
60+
return NAME
61+
}
62+
63+
// Public
64+
getValue() {
65+
return this._inputs.map(input => input.value).join('')
66+
}
67+
68+
setValue(value) {
69+
const chars = String(value).split('')
70+
for (const [index, input] of this._inputs.entries()) {
71+
input.value = chars[index] || ''
72+
}
73+
74+
this._checkComplete()
75+
}
76+
77+
clear() {
78+
for (const input of this._inputs) {
79+
input.value = ''
80+
}
81+
82+
this._inputs[0]?.focus()
83+
}
84+
85+
focus() {
86+
// Focus first empty input, or last input if all filled
87+
const emptyInput = this._inputs.find(input => !input.value)
88+
if (emptyInput) {
89+
emptyInput.focus()
90+
} else {
91+
this._inputs.at(-1)?.focus()
92+
}
93+
}
94+
95+
// Private
96+
_setupInputs() {
97+
for (const input of this._inputs) {
98+
// Set attributes for proper OTP handling
99+
input.setAttribute('maxlength', '1')
100+
input.setAttribute('inputmode', 'numeric')
101+
input.setAttribute('pattern', '\\d*')
102+
103+
// First input gets autocomplete for browser OTP autofill
104+
if (input === this._inputs[0]) {
105+
input.setAttribute('autocomplete', 'one-time-code')
106+
} else {
107+
input.setAttribute('autocomplete', 'off')
108+
}
109+
110+
// Mask input if configured
111+
if (this._config.mask) {
112+
input.setAttribute('type', 'password')
113+
}
114+
}
115+
}
116+
117+
_addEventListeners() {
118+
for (const [index, input] of this._inputs.entries()) {
119+
EventHandler.on(input, 'input', event => this._handleInput(event, index))
120+
EventHandler.on(input, 'keydown', event => this._handleKeydown(event, index))
121+
EventHandler.on(input, 'paste', event => this._handlePaste(event))
122+
EventHandler.on(input, 'focus', event => this._handleFocus(event))
123+
}
124+
}
125+
126+
_handleInput(event, index) {
127+
const input = event.target
128+
129+
// Only allow digits
130+
if (!/^\d*$/.test(input.value)) {
131+
input.value = input.value.replace(/\D/g, '')
132+
}
133+
134+
const { value } = input
135+
136+
// Handle multi-character input (some browsers/autofill)
137+
if (value.length > 1) {
138+
// Distribute characters across inputs
139+
const chars = value.split('')
140+
input.value = chars[0] || ''
141+
142+
for (let i = 1; i < chars.length && index + i < this._inputs.length; i++) {
143+
this._inputs[index + i].value = chars[i]
144+
}
145+
146+
// Focus appropriate input
147+
const nextIndex = Math.min(index + chars.length, this._inputs.length - 1)
148+
this._inputs[nextIndex].focus()
149+
} else if (value && index < this._inputs.length - 1) {
150+
// Auto-advance to next input
151+
this._inputs[index + 1].focus()
152+
}
153+
154+
EventHandler.trigger(this._element, EVENT_INPUT, {
155+
value: this.getValue(),
156+
index
157+
})
158+
159+
this._checkComplete()
160+
}
161+
162+
_handleKeydown(event, index) {
163+
const { key } = event
164+
165+
switch (key) {
166+
case 'Backspace': {
167+
if (!this._inputs[index].value && index > 0) {
168+
// Move to previous input and clear it
169+
event.preventDefault()
170+
this._inputs[index - 1].value = ''
171+
this._inputs[index - 1].focus()
172+
}
173+
174+
break
175+
}
176+
177+
case 'Delete': {
178+
// Clear current and shift remaining values left
179+
event.preventDefault()
180+
for (let i = index; i < this._inputs.length - 1; i++) {
181+
this._inputs[i].value = this._inputs[i + 1].value
182+
}
183+
184+
this._inputs.at(-1).value = ''
185+
break
186+
}
187+
188+
case 'ArrowLeft': {
189+
if (index > 0) {
190+
event.preventDefault()
191+
this._inputs[index - 1].focus()
192+
}
193+
194+
break
195+
}
196+
197+
case 'ArrowRight': {
198+
if (index < this._inputs.length - 1) {
199+
event.preventDefault()
200+
this._inputs[index + 1].focus()
201+
}
202+
203+
break
204+
}
205+
206+
// No default
207+
}
208+
}
209+
210+
_handlePaste(event) {
211+
event.preventDefault()
212+
const pastedData = (event.clipboardData || window.clipboardData).getData('text')
213+
const digits = pastedData.replace(/\D/g, '').slice(0, this._inputs.length)
214+
215+
if (digits) {
216+
this.setValue(digits)
217+
218+
// Focus last filled input or last input
219+
const lastIndex = Math.min(digits.length, this._inputs.length) - 1
220+
this._inputs[lastIndex].focus()
221+
}
222+
}
223+
224+
_handleFocus(event) {
225+
// Select the content on focus for easy replacement
226+
event.target.select()
227+
}
228+
229+
_checkComplete() {
230+
const value = this.getValue()
231+
const isComplete = value.length === this._inputs.length &&
232+
this._inputs.every(input => input.value !== '')
233+
234+
if (isComplete) {
235+
EventHandler.trigger(this._element, EVENT_COMPLETE, { value })
236+
}
237+
}
238+
}
239+
240+
/**
241+
* Data API implementation
242+
*/
243+
244+
EventHandler.on(document, `DOMContentLoaded${EVENT_KEY}${DATA_API_KEY}`, () => {
245+
for (const element of SelectorEngine.find(SELECTOR_DATA_OTP)) {
246+
OtpInput.getOrCreateInstance(element)
247+
}
248+
})
249+
250+
export default OtpInput

0 commit comments

Comments
 (0)