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
|
||||
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 = '•'
|
||||
|
||||
// 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
|
||||
_pointerActive = false
|
||||
_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)
|
||||
_onFocus = (): void => this._handleFocus()
|
||||
_onPointerDown = (event: Event): void => this._handlePointerDown(event as PointerEvent)
|
||||
_onPointerEnd = (): void => {
|
||||
this._pointerActive = false
|
||||
}
|
||||
_onSync = (): void => this._render()
|
||||
_onSelectionChange = (): void => {
|
||||
if (document.activeElement === this._input) {
|
||||
@@ -163,6 +186,7 @@ class OtpInput extends BaseComponent {
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this._pointerActive = false
|
||||
this._input.focus()
|
||||
this._selectSlot(this._firstEmptyIndex())
|
||||
this._render()
|
||||
@@ -178,6 +202,10 @@ class OtpInput extends BaseComponent {
|
||||
this._input.removeEventListener('beforeinput', this._onBeforeInput)
|
||||
this._input.removeEventListener('focus', this._onFocus)
|
||||
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) {
|
||||
this._input.removeEventListener(type, this._onSync)
|
||||
}
|
||||
@@ -271,6 +299,11 @@ class OtpInput extends BaseComponent {
|
||||
this._input.addEventListener('beforeinput', this._onBeforeInput)
|
||||
this._input.addEventListener('focus', this._onFocus)
|
||||
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)
|
||||
|
||||
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
|
||||
// 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 {
|
||||
const { inputType, data } = event
|
||||
|
||||
@@ -323,8 +357,9 @@ class OtpInput extends BaseComponent {
|
||||
}
|
||||
|
||||
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]
|
||||
chars[index] = char
|
||||
chars.splice(index, end - index, char)
|
||||
this._input.value = chars.join('').slice(0, this._length)
|
||||
|
||||
this._selectSlot(index + 1)
|
||||
|
||||
@@ -426,6 +426,50 @@ describe('OtpInput', () => {
|
||||
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', () => {
|
||||
fixtureEl.innerHTML = '<div class="otp" data-bs-toggle="otp"><input type="email" /></div>'
|
||||
new OtpInput(otp())
|
||||
|
||||
Reference in new issue
Block a user