mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 05:06:55 +04:00
Fix OtpInput stale tap position, multi-slot selection overwrite and IME composition (#3229)
This commit is contained in:
1 parent
ee3843e469
commit
dee65f2f5c
3 files changed
+87
-3
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@tabler/core": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fixed `OtpInput` reusing a stale tap position, overwriting only one slot of a selection and breaking IME composition.
|
||||||
@@ -48,6 +48,9 @@ const SELECTOR_INPUT = 'input'
|
|||||||
// Keeps the active-slot highlight in sync with the caret as it moves without typing
|
// Keeps the active-slot highlight in sync with the caret as it moves without typing
|
||||||
const SYNC_EVENTS = ['blur', 'keyup', 'select']
|
const SYNC_EVENTS = ['blur', 'keyup', 'select']
|
||||||
|
|
||||||
|
// A touch focuses the input after `pointerup`, so the tap is only settled by `click`
|
||||||
|
const POINTER_END_EVENTS = ['click', 'pointercancel']
|
||||||
|
|
||||||
const MASK_CHARACTER = '•'
|
const MASK_CHARACTER = '•'
|
||||||
|
|
||||||
// Per-type input mode, validation pattern, and a filter that strips disallowed characters
|
// Per-type input mode, validation pattern, and a filter that strips disallowed characters
|
||||||
@@ -97,11 +100,31 @@ class OtpInput extends BaseComponent {
|
|||||||
// clicked slot instead of jumping to the first empty one
|
// clicked slot instead of jumping to the first empty one
|
||||||
_pointerActive = false
|
_pointerActive = false
|
||||||
_pointerIndex = 0
|
_pointerIndex = 0
|
||||||
|
_composedValue: string | null = null
|
||||||
|
|
||||||
_onInput = (): void => this._handleInput()
|
_onInput = (event: Event): void => {
|
||||||
|
if ((event as InputEvent).isComposing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const composed = this._composedValue
|
||||||
|
this._composedValue = null
|
||||||
|
if (composed !== null && composed === this._input.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this._handleInput()
|
||||||
|
}
|
||||||
|
_onCompositionEnd = (): void => {
|
||||||
|
this._handleInput()
|
||||||
|
this._composedValue = this._input.value
|
||||||
|
}
|
||||||
_onBeforeInput = (event: Event): void => this._handleBeforeInput(event as InputEvent)
|
_onBeforeInput = (event: Event): void => this._handleBeforeInput(event as InputEvent)
|
||||||
_onFocus = (): void => this._handleFocus()
|
_onFocus = (): void => this._handleFocus()
|
||||||
_onPointerDown = (event: Event): void => this._handlePointerDown(event as PointerEvent)
|
_onPointerDown = (event: Event): void => this._handlePointerDown(event as PointerEvent)
|
||||||
|
_onPointerEnd = (): void => {
|
||||||
|
this._pointerActive = false
|
||||||
|
}
|
||||||
_onSync = (): void => this._render()
|
_onSync = (): void => this._render()
|
||||||
_onSelectionChange = (): void => {
|
_onSelectionChange = (): void => {
|
||||||
if (document.activeElement === this._input) {
|
if (document.activeElement === this._input) {
|
||||||
@@ -163,6 +186,7 @@ class OtpInput extends BaseComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
focus(): void {
|
focus(): void {
|
||||||
|
this._pointerActive = false
|
||||||
this._input.focus()
|
this._input.focus()
|
||||||
this._selectSlot(this._firstEmptyIndex())
|
this._selectSlot(this._firstEmptyIndex())
|
||||||
this._render()
|
this._render()
|
||||||
@@ -178,6 +202,10 @@ class OtpInput extends BaseComponent {
|
|||||||
this._input.removeEventListener('beforeinput', this._onBeforeInput)
|
this._input.removeEventListener('beforeinput', this._onBeforeInput)
|
||||||
this._input.removeEventListener('focus', this._onFocus)
|
this._input.removeEventListener('focus', this._onFocus)
|
||||||
this._input.removeEventListener('pointerdown', this._onPointerDown)
|
this._input.removeEventListener('pointerdown', this._onPointerDown)
|
||||||
|
this._input.removeEventListener('compositionend', this._onCompositionEnd)
|
||||||
|
for (const type of POINTER_END_EVENTS) {
|
||||||
|
this._input.removeEventListener(type, this._onPointerEnd)
|
||||||
|
}
|
||||||
for (const type of SYNC_EVENTS) {
|
for (const type of SYNC_EVENTS) {
|
||||||
this._input.removeEventListener(type, this._onSync)
|
this._input.removeEventListener(type, this._onSync)
|
||||||
}
|
}
|
||||||
@@ -271,6 +299,11 @@ class OtpInput extends BaseComponent {
|
|||||||
this._input.addEventListener('beforeinput', this._onBeforeInput)
|
this._input.addEventListener('beforeinput', this._onBeforeInput)
|
||||||
this._input.addEventListener('focus', this._onFocus)
|
this._input.addEventListener('focus', this._onFocus)
|
||||||
this._input.addEventListener('pointerdown', this._onPointerDown)
|
this._input.addEventListener('pointerdown', this._onPointerDown)
|
||||||
|
this._input.addEventListener('compositionend', this._onCompositionEnd)
|
||||||
|
for (const type of POINTER_END_EVENTS) {
|
||||||
|
this._input.addEventListener(type, this._onPointerEnd)
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('selectionchange', this._onSelectionChange)
|
document.addEventListener('selectionchange', this._onSelectionChange)
|
||||||
|
|
||||||
for (const type of SYNC_EVENTS) {
|
for (const type of SYNC_EVENTS) {
|
||||||
@@ -310,7 +343,8 @@ class OtpInput extends BaseComponent {
|
|||||||
|
|
||||||
// Intercepts single-character typing, Backspace and Delete so each slot is
|
// Intercepts single-character typing, Backspace and Delete so each slot is
|
||||||
// overwritten in place rather than inserting and shifting the value.
|
// overwritten in place rather than inserting and shifting the value.
|
||||||
// Anything else (paste, autofill, IME) falls through to `_handleInput`.
|
// Anything else (paste, autofill) falls through to `_handleInput`; IME
|
||||||
|
// composition is sanitised once on `compositionend`.
|
||||||
_handleBeforeInput(event: InputEvent): void {
|
_handleBeforeInput(event: InputEvent): void {
|
||||||
const { inputType, data } = event
|
const { inputType, data } = event
|
||||||
|
|
||||||
@@ -323,8 +357,9 @@ class OtpInput extends BaseComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const index = Math.min(this._input.selectionStart ?? 0, this._length - 1)
|
const index = Math.min(this._input.selectionStart ?? 0, this._length - 1)
|
||||||
|
const end = Math.max(this._input.selectionEnd ?? index, index + 1)
|
||||||
const chars = [...this._input.value]
|
const chars = [...this._input.value]
|
||||||
chars[index] = char
|
chars.splice(index, end - index, char)
|
||||||
this._input.value = chars.join('').slice(0, this._length)
|
this._input.value = chars.join('').slice(0, this._length)
|
||||||
|
|
||||||
this._selectSlot(index + 1)
|
this._selectSlot(index + 1)
|
||||||
|
|||||||
@@ -426,6 +426,50 @@ describe('OtpInput', () => {
|
|||||||
expect(fixtureEl.querySelectorAll('.otp-slot').length).toBe(32)
|
expect(fixtureEl.querySelectorAll('.otp-slot').length).toBe(32)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('forgets a tap that never focused the input', () => {
|
||||||
|
const instance = new OtpInput(otp())
|
||||||
|
layoutSlotsHorizontally(otp())
|
||||||
|
instance.setValue('123')
|
||||||
|
pointerDownOnSlot(input(), slots()[1])
|
||||||
|
input().dispatchEvent(new PointerEvent('pointercancel', { bubbles: true }))
|
||||||
|
input().focus()
|
||||||
|
expect(input().selectionStart).toBe(3)
|
||||||
|
|
||||||
|
input().blur()
|
||||||
|
pointerDownOnSlot(input(), slots()[1])
|
||||||
|
input().dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||||
|
input().focus()
|
||||||
|
expect(input().selectionStart).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces a multi-slot selection with the typed character', () => {
|
||||||
|
const instance = new OtpInput(otp())
|
||||||
|
instance.setValue('123456')
|
||||||
|
input().setSelectionRange(1, 4)
|
||||||
|
beforeInput(input(), { inputType: 'insertText', data: '9' })
|
||||||
|
expect(input().value).toBe('1956')
|
||||||
|
expect(input().selectionStart).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves the value alone during IME composition and sanitizes on compositionend', () => {
|
||||||
|
new OtpInput(otp())
|
||||||
|
const spy = vi.fn()
|
||||||
|
otp().addEventListener('input.bs.otpInput', spy)
|
||||||
|
input().focus()
|
||||||
|
|
||||||
|
input().value = '1a'
|
||||||
|
input().dispatchEvent(new InputEvent('input', { bubbles: true, isComposing: true, inputType: 'insertCompositionText' }))
|
||||||
|
expect(input().value).toBe('1a')
|
||||||
|
expect(spy).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
input().dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }))
|
||||||
|
expect(input().value).toBe('1')
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
input().dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertCompositionText' }))
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
it('turns an email input into a text input', () => {
|
it('turns an email input into a text input', () => {
|
||||||
fixtureEl.innerHTML = '<div class="otp" data-bs-toggle="otp"><input type="email" /></div>'
|
fixtureEl.innerHTML = '<div class="otp" data-bs-toggle="otp"><input type="email" /></div>'
|
||||||
new OtpInput(otp())
|
new OtpInput(otp())
|
||||||
|
|||||||
Reference in new issue
Block a user