blob: 7989a326f492640300583cbea5b10a0a3e71cc73 [file] [edit]
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 = (attributes = '', maxlength = 6) =>
`<div class="otp" ${attributes}><input type="text" class="otp-input" maxlength="${maxlength}"></div>`
const typeInto = (input, value) => {
input.value = value
input.dispatchEvent(createEvent('input'))
}
// The compiled CSS lays the slots out horizontally, but the unit test DOM has
// no stylesheet so the generated `<div>`s stack vertically (identical x-range).
// Force an inline horizontal layout so coordinate-based hit testing is
// deterministic when exercising pointer/tap behavior.
const layoutSlotsHorizontally = (otpEl, slotWidth = 30) => {
for (const slot of otpEl.querySelectorAll('.otp-slot, .otp-separator')) {
slot.style.display = 'inline-block'
slot.style.width = `${slotWidth}px`
slot.style.margin = '0'
slot.style.padding = '0'
}
}
const pointerDownOnSlot = (input, slot) => {
const { left, width } = slot.getBoundingClientRect()
input.dispatchEvent(new MouseEvent('pointerdown', {
bubbles: true, cancelable: true, clientX: left + (width / 2)
}))
}
const beforeInput = (input, options) =>
input.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, cancelable: true, ...options }))
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.mask).toBeFalse()
expect(OtpInput.Default.type).toEqual('numeric')
})
})
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')
const otpBySelector = new OtpInput('.otp')
expect(otpBySelector._element).toEqual(otpEl)
const otpByElement = new OtpInput(otpEl)
expect(otpByElement._element).toEqual(otpEl)
})
it('should set up the input with correct attributes', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
new OtpInput(otpEl) // eslint-disable-line no-new
const input = otpEl.querySelector('input')
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 render one slot per character and mark the container as rendered', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
new OtpInput(otpEl) // eslint-disable-line no-new
expect(otpEl).toHaveClass('otp-rendered')
expect(otpEl.querySelectorAll('.otp-slot').length).toEqual(6)
})
it('should derive length from the maxlength attribute', () => {
fixtureEl.innerHTML = getOtpHtml('', 4)
const otpEl = fixtureEl.querySelector('.otp')
new OtpInput(otpEl) // eslint-disable-line no-new
expect(otpEl.querySelectorAll('.otp-slot').length).toEqual(4)
})
it('should let the length option override the maxlength attribute', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl, { length: 4 })
expect(otp).toBeInstanceOf(OtpInput)
const input = otpEl.querySelector('input')
expect(input.getAttribute('maxlength')).toEqual('4')
expect(otpEl.querySelectorAll('.otp-slot').length).toEqual(4)
})
it('should set a text inputmode and pattern for the alphanumeric type', () => {
fixtureEl.innerHTML = getOtpHtml('data-bs-type="alphanumeric"')
const otpEl = fixtureEl.querySelector('.otp')
new OtpInput(otpEl) // eslint-disable-line no-new
const input = otpEl.querySelector('input')
expect(input.getAttribute('inputmode')).toEqual('text')
expect(input.getAttribute('pattern')).toEqual('[A-Za-z0-9]*')
})
})
describe('value handling', () => {
it('should return the input value from getValue', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
expect(otp.getValue()).toEqual('')
otpEl.querySelector('input').value = '123'
expect(otp.getValue()).toEqual('123')
})
it('should set the value and render it into the slots', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
otp.setValue('123456')
expect(otp.getValue()).toEqual('123456')
const slots = otpEl.querySelectorAll('.otp-slot')
expect(slots[0].textContent).toEqual('1')
expect(slots[5].textContent).toEqual('6')
})
it('should sanitize disallowed characters and respect the length', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
otp.setValue('12ab34xy567890')
expect(otp.getValue()).toEqual('123456')
})
it('should accept numeric values', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
otp.setValue(123456)
expect(otp.getValue()).toEqual('123456')
})
it('should clear the value', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
otp.setValue('123456')
otp.clear()
expect(otp.getValue()).toEqual('')
expect(document.activeElement).toEqual(otpEl.querySelector('input'))
})
})
describe('input handling', () => {
it('should strip disallowed characters as they are typed', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
new OtpInput(otpEl) // eslint-disable-line no-new
const input = otpEl.querySelector('input')
typeInto(input, 'a1b2')
expect(input.value).toEqual('12')
})
it('should distribute a pasted/autofilled value across the slots', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
new OtpInput(otpEl) // eslint-disable-line no-new
const input = otpEl.querySelector('input')
// A paste or SMS autofill lands as a single multi-character input event
typeInto(input, '123-456')
expect(input.value).toEqual('123456')
const slots = otpEl.querySelectorAll('.otp-slot')
expect([...slots].map(slot => slot.textContent).join('')).toEqual('123456')
})
it('should keep letters for the alphanumeric type', () => {
fixtureEl.innerHTML = getOtpHtml('data-bs-type="alphanumeric"')
const otpEl = fixtureEl.querySelector('.otp')
new OtpInput(otpEl) // eslint-disable-line no-new
const input = otpEl.querySelector('input')
typeInto(input, 'a1B2c3')
expect(input.value).toEqual('a1B2c3')
})
})
describe('interaction', () => {
it('should position the active slot from the tapped coordinate', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
const input = otpEl.querySelector('input')
otp.setValue('123456')
const slots = otpEl.querySelectorAll('.otp-slot')
// The input is already focused (keyboard up), so a tap repositions the
// caret immediately based on its x-coordinate
input.focus()
const { left, width } = slots[0].getBoundingClientRect()
input.dispatchEvent(new MouseEvent('pointerdown', {
bubbles: true, cancelable: true, clientX: left + (width / 2)
}))
expect(input.selectionStart).toEqual(0)
// A filled slot is selected so the next keystroke overwrites it
expect(input.selectionEnd).toEqual(1)
expect(slots[0]).toHaveClass('otp-slot-active')
})
it('should overwrite the active slot instead of inserting', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
const input = otpEl.querySelector('input')
otp.setValue('123456')
input.focus()
input.setSelectionRange(2, 3)
input.dispatchEvent(new InputEvent('beforeinput', {
inputType: 'insertText', data: '9', bubbles: true, cancelable: true
}))
expect(input.value).toEqual('129456')
// Caret advances to the next slot
expect(input.selectionStart).toEqual(3)
})
it('should delete the previous character on backspace', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
const input = otpEl.querySelector('input')
otp.setValue('123')
input.focus()
input.setSelectionRange(3, 3)
input.dispatchEvent(new InputEvent('beforeinput', { inputType: 'deleteContentBackward', bubbles: true, cancelable: true }))
expect(input.value).toEqual('12')
expect(input.selectionStart).toEqual(2)
})
it('should focus the first empty slot on keyboard focus', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
const input = otpEl.querySelector('input')
otp.setValue('12')
input.focus()
expect(input.selectionStart).toEqual(2)
expect(otpEl.querySelectorAll('.otp-slot')[2]).toHaveClass('otp-slot-active')
})
it('should swallow a disallowed character on beforeinput', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
new OtpInput(otpEl) // eslint-disable-line no-new
const input = otpEl.querySelector('input')
input.focus()
const event = new InputEvent('beforeinput', {
inputType: 'insertText', data: 'a', bubbles: true, cancelable: true
})
input.dispatchEvent(event)
expect(input.value).toEqual('')
expect(event.defaultPrevented).toBeTrue()
})
it('should position the caret from the tap only after the input focuses natively (iPadOS keyboard fix)', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
const input = otpEl.querySelector('input')
otp.setValue('123456')
const slots = otpEl.querySelectorAll('.otp-slot')
// Input is not yet focused: a tap must NOT reposition the caret itself
// (that would let the browser raise then immediately dismiss the keyboard).
// The tapped slot is only remembered until focus settles.
pointerDownOnSlot(input, slots[0])
expect(document.activeElement).not.toEqual(input)
// Focus settles (the browser focuses the input from the same tap)
input.focus()
// Caret lands on the tapped slot (0), not the first-empty/last slot (5)
// that a plain keyboard focus would choose
expect(input.selectionStart).toEqual(0)
expect(input.selectionEnd).toEqual(1)
expect(slots[0]).toHaveClass('otp-slot-active')
})
it('should clamp a tap beyond the filled slots to the first empty slot', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
const input = otpEl.querySelector('input')
otp.setValue('12')
layoutSlotsHorizontally(otpEl)
const slots = otpEl.querySelectorAll('.otp-slot')
input.focus()
// Move the caret away from the default first-empty position first
input.setSelectionRange(0, 1)
// Tap the far right (last) slot while only two of six are filled
pointerDownOnSlot(input, slots[5])
// The caret is clamped to the first empty slot (index 2), not slot 5
expect(input.selectionStart).toEqual(2)
expect(slots[2]).toHaveClass('otp-slot-active')
})
it('should clear the selected slot and keep the caret on backspace', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
const input = otpEl.querySelector('input')
otp.setValue('123456')
input.focus()
// A filled slot is selected (as the active slot always is)
input.setSelectionRange(2, 3)
beforeInput(input, { inputType: 'deleteContentBackward' })
expect(input.value).toEqual('12456')
expect(input.selectionStart).toEqual(2)
})
it('should do nothing on backspace at the start of an empty field', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
new OtpInput(otpEl) // eslint-disable-line no-new
const input = otpEl.querySelector('input')
input.focus()
input.setSelectionRange(0, 0)
const event = new InputEvent('beforeinput', {
inputType: 'deleteContentBackward', bubbles: true, cancelable: true
})
input.dispatchEvent(event)
expect(input.value).toEqual('')
expect(event.defaultPrevented).toBeTrue()
})
it('should append into an empty slot when typing at the caret', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
const input = otpEl.querySelector('input')
otp.setValue('12')
input.focus()
input.setSelectionRange(2, 2)
beforeInput(input, { inputType: 'insertText', data: '3' })
expect(input.value).toEqual('123')
expect(input.selectionStart).toEqual(3)
})
it('should place the caret on the first empty slot after a paste/autofill', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
new OtpInput(otpEl) // eslint-disable-line no-new
const input = otpEl.querySelector('input')
input.focus()
// A paste lands as a single multi-character input event
typeInto(input, '123')
expect(input.value).toEqual('123')
expect(input.selectionStart).toEqual(3)
expect(input.selectionEnd).toEqual(3)
})
it('should keep the active slot in sync on selectionchange', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
const input = otpEl.querySelector('input')
otp.setValue('123456')
input.focus()
input.setSelectionRange(3, 4)
document.dispatchEvent(new Event('selectionchange'))
expect(otpEl.querySelectorAll('.otp-slot')[3]).toHaveClass('otp-slot-active')
})
})
describe('focus', () => {
it('should focus the input and select the first empty slot', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
const input = otpEl.querySelector('input')
otp.setValue('123')
otp.focus()
expect(document.activeElement).toEqual(input)
expect(input.selectionStart).toEqual(3)
expect(otpEl.querySelectorAll('.otp-slot')[3]).toHaveClass('otp-slot-active')
})
it('should select the last slot when the value is already full', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
const input = otpEl.querySelector('input')
otp.setValue('123456')
otp.focus()
// Clamp to the last slot and select it so the next keystroke overwrites it
expect(input.selectionStart).toEqual(5)
expect(input.selectionEnd).toEqual(6)
})
})
describe('mask', () => {
it('should render the mask character but keep the real value', () => {
fixtureEl.innerHTML = getOtpHtml('data-bs-mask="true"')
const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
otp.setValue('123456')
expect(otp.getValue()).toEqual('123456')
const slots = otpEl.querySelectorAll('.otp-slot')
expect([...slots].every(slot => slot.textContent === '•')).toBeTrue()
})
it('should not turn the input into a password field', () => {
fixtureEl.innerHTML = getOtpHtml('data-bs-mask="true"')
const otpEl = fixtureEl.querySelector('.otp')
new OtpInput(otpEl) // eslint-disable-line no-new
expect(otpEl.querySelector('input').type).toEqual('text')
})
})
describe('separators', () => {
it('should render separators between configured groups', () => {
fixtureEl.innerHTML = getOtpHtml('data-bs-groups="[3,3]"')
const otpEl = fixtureEl.querySelector('.otp')
new OtpInput(otpEl) // eslint-disable-line no-new
expect(otpEl.querySelectorAll('.otp-separator').length).toEqual(1)
expect(otpEl.querySelectorAll('.otp-slot').length).toEqual(6)
})
})
describe('events', () => {
it('should trigger complete event when the value is full', () => {
return new Promise(resolve => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
otpEl.addEventListener('complete.bs.otpInput', event => {
expect(event.value).toEqual('123456')
resolve()
})
otp.setValue('123456')
})
})
it('should trigger input event with the current value on each change', () => {
return new Promise(resolve => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
new OtpInput(otpEl) // eslint-disable-line no-new
const input = otpEl.querySelector('input')
otpEl.addEventListener('input.bs.otpInput', event => {
expect(event.value).toEqual('12')
resolve()
})
typeInto(input, '12')
})
})
})
describe('dispose', () => {
it('should dispose the instance and remove generated markup', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
expect(OtpInput.getInstance(otpEl)).not.toBeNull()
otp.dispose()
expect(OtpInput.getInstance(otpEl)).toBeNull()
expect(fixtureEl.querySelector('.otp-slots')).toBeNull()
expect(fixtureEl.querySelector('.otp').classList.contains('otp-rendered')).toBeFalse()
})
it('should stop intercepting beforeinput after dispose', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
const input = otpEl.querySelector('input')
otp.dispose()
// The beforeinput listener is gone, so the event is no longer prevented
const event = new InputEvent('beforeinput', {
inputType: 'insertText', data: '1', bubbles: true, cancelable: true
})
input.dispatchEvent(event)
expect(event.defaultPrevented).toBeFalse()
})
})
describe('getInstance', () => {
it('should return otp-input instance', () => {
fixtureEl.innerHTML = getOtpHtml()
const otpEl = fixtureEl.querySelector('.otp')
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')
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')
expect(OtpInput.getInstance(otpEl)).toBeNull()
expect(OtpInput.getOrCreateInstance(otpEl)).toBeInstanceOf(OtpInput)
})
})
})