Skip to content

Commit 59b8b40

Browse files
committed
fix: fix cursor jumps with input component of Quasar/Element Plus
closes #338, #343
1 parent 97f9b12 commit 59b8b40

File tree

6 files changed

+105
-119
lines changed

6 files changed

+105
-119
lines changed

rollup.config.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ export default [
3737
}),
3838
cleanup({ extensions: ['js', 'ts'] }),
3939
filesize()
40-
]
40+
],
41+
external: ['vue']
4142
},
4243
{
4344
input: './dist/types/src/index.d.ts',

src/currencyInput.ts

+55-70
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@ export const DEFAULT_OPTIONS = {
1919

2020
export class CurrencyInput {
2121
private readonly el: HTMLInputElement
22+
private readonly onInput: (value: CurrencyInputValue) => void
23+
private readonly onChange: (value: CurrencyInputValue) => void
24+
private numberValue!: number | null
2225
private options!: CurrencyInputOptions
23-
private numberValue: number | null
2426
private currencyFormat!: CurrencyFormat
2527
private decimalSymbolInsertedAt?: number
2628
private numberMask!: InputMask
@@ -31,12 +33,17 @@ export class CurrencyInput {
3133
private valueScaling: number | undefined
3234
private valueScalingFractionDigits!: number
3335

34-
constructor(el: HTMLInputElement, options: CurrencyInputOptions) {
35-
this.el = el
36-
this.numberValue = null
36+
constructor(args: {
37+
el: HTMLInputElement
38+
options: CurrencyInputOptions
39+
onInput: (value: CurrencyInputValue) => void
40+
onChange: (value: CurrencyInputValue) => void
41+
}) {
42+
this.el = args.el
43+
this.onInput = args.onInput
44+
this.onChange = args.onChange
3745
this.addEventListener()
38-
this.init(options)
39-
this.setValue(this.currencyFormat.parse(this.el.value))
46+
this.init(args.options)
4047
}
4148

4249
setOptions(options: CurrencyInputOptions): void {
@@ -56,10 +63,6 @@ export class CurrencyInput {
5663
}
5764
}
5865

59-
private dispatchEvent(eventName: string) {
60-
this.el.dispatchEvent(new CustomEvent(eventName, { detail: this.getValue() }))
61-
}
62-
6366
private init(options: CurrencyInputOptions) {
6467
this.options = {
6568
...DEFAULT_OPTIONS,
@@ -124,7 +127,7 @@ export class CurrencyInput {
124127
private applyFixedFractionFormat(number: number | null, forcedChange = false) {
125128
this.format(this.currencyFormat.format(this.validateValueRange(number)))
126129
if (number !== this.numberValue || forcedChange) {
127-
this.dispatchEvent('change')
130+
this.onChange(this.getValue())
128131
}
129132
}
130133

@@ -178,65 +181,53 @@ export class CurrencyInput {
178181
this.numberValue = null
179182
}
180183
this.formattedValue = this.el.value
181-
this.dispatchEvent('input')
184+
this.onInput(this.getValue())
182185
}
183186

184187
private addEventListener(): void {
185-
this.el.addEventListener(
186-
'input',
187-
(e: Event) => {
188-
if (!(e as CustomEvent).detail) {
189-
const { value, selectionStart } = this.el
190-
const inputEvent = e as InputEvent
191-
if (selectionStart && inputEvent.data && DECIMAL_SEPARATORS.includes(inputEvent.data)) {
192-
this.decimalSymbolInsertedAt = selectionStart - 1
188+
this.el.addEventListener('input', (e: Event) => {
189+
const { value, selectionStart } = this.el
190+
const inputEvent = e as InputEvent
191+
if (selectionStart && inputEvent.data && DECIMAL_SEPARATORS.includes(inputEvent.data)) {
192+
this.decimalSymbolInsertedAt = selectionStart - 1
193+
}
194+
this.format(value)
195+
if (this.focus && selectionStart != null) {
196+
const getCaretPositionAfterFormat = () => {
197+
const { prefix, suffix, decimalSymbol, maximumFractionDigits, groupingSymbol } = this.currencyFormat
198+
let caretPositionFromLeft = value.length - selectionStart
199+
const newValueLength = this.formattedValue.length
200+
if (this.currencyFormat.minusSign === undefined && (value.startsWith('(') || value.startsWith('-')) && !value.endsWith(')')) {
201+
return newValueLength - this.currencyFormat.negativeSuffix.length > 1 ? this.formattedValue.substring(selectionStart).length : 1
193202
}
194-
this.format(value)
195-
if (this.focus && selectionStart != null) {
196-
const getCaretPositionAfterFormat = () => {
197-
const { prefix, suffix, decimalSymbol, maximumFractionDigits, groupingSymbol } = this.currencyFormat
198-
199-
let caretPositionFromLeft = value.length - selectionStart
200-
const newValueLength = this.formattedValue.length
201-
202-
if (this.currencyFormat.minusSign === undefined && (value.startsWith('(') || value.startsWith('-')) && !value.endsWith(')')) {
203-
return newValueLength - this.currencyFormat.negativeSuffix.length > 1 ? this.formattedValue.substring(selectionStart).length : 1
204-
}
205-
206-
if (
207-
this.formattedValue.substr(selectionStart, 1) === groupingSymbol &&
208-
count(this.formattedValue, groupingSymbol) === count(value, groupingSymbol) + 1
209-
) {
210-
return newValueLength - caretPositionFromLeft - 1
211-
}
212-
213-
if (newValueLength < caretPositionFromLeft) {
214-
return selectionStart
215-
}
216-
217-
if (decimalSymbol !== undefined && value.indexOf(decimalSymbol) !== -1) {
218-
const decimalSymbolPosition = value.indexOf(decimalSymbol) + 1
219-
if (Math.abs(newValueLength - value.length) > 1 && selectionStart <= decimalSymbolPosition) {
220-
return this.formattedValue.indexOf(decimalSymbol) + 1
221-
} else {
222-
if (!this.options.autoDecimalDigits && selectionStart > decimalSymbolPosition) {
223-
if (this.currencyFormat.onlyDigits(value.substr(decimalSymbolPosition)).length - 1 === maximumFractionDigits) {
224-
caretPositionFromLeft -= 1
225-
}
226-
}
203+
if (
204+
this.formattedValue.substr(selectionStart, 1) === groupingSymbol &&
205+
count(this.formattedValue, groupingSymbol) === count(value, groupingSymbol) + 1
206+
) {
207+
return newValueLength - caretPositionFromLeft - 1
208+
}
209+
if (newValueLength < caretPositionFromLeft) {
210+
return selectionStart
211+
}
212+
if (decimalSymbol !== undefined && value.indexOf(decimalSymbol) !== -1) {
213+
const decimalSymbolPosition = value.indexOf(decimalSymbol) + 1
214+
if (Math.abs(newValueLength - value.length) > 1 && selectionStart <= decimalSymbolPosition) {
215+
return this.formattedValue.indexOf(decimalSymbol) + 1
216+
} else {
217+
if (!this.options.autoDecimalDigits && selectionStart > decimalSymbolPosition) {
218+
if (this.currencyFormat.onlyDigits(value.substr(decimalSymbolPosition)).length - 1 === maximumFractionDigits) {
219+
caretPositionFromLeft -= 1
227220
}
228221
}
229-
230-
return this.options.hideCurrencySymbolOnFocus || this.options.currencyDisplay === CurrencyDisplay.hidden
231-
? newValueLength - caretPositionFromLeft
232-
: Math.max(newValueLength - Math.max(caretPositionFromLeft, suffix.length), prefix.length)
233222
}
234-
this.setCaretPosition(getCaretPositionAfterFormat())
235223
}
224+
return this.options.hideCurrencySymbolOnFocus || this.options.currencyDisplay === CurrencyDisplay.hidden
225+
? newValueLength - caretPositionFromLeft
226+
: Math.max(newValueLength - Math.max(caretPositionFromLeft, suffix.length), prefix.length)
236227
}
237-
},
238-
{ capture: true }
239-
)
228+
this.setCaretPosition(getCaretPositionAfterFormat())
229+
}
230+
})
240231

241232
this.el.addEventListener('focus', () => {
242233
this.focus = true
@@ -257,15 +248,9 @@ export class CurrencyInput {
257248
this.applyFixedFractionFormat(this.numberValue)
258249
})
259250

260-
this.el.addEventListener(
261-
'change',
262-
(e: Event) => {
263-
if (!(e as CustomEvent).detail) {
264-
this.dispatchEvent('change')
265-
}
266-
},
267-
{ capture: true }
268-
)
251+
this.el.addEventListener('change', () => {
252+
this.onChange(this.getValue())
253+
})
269254
}
270255

271256
private getCaretPositionOnFocus(value: string, selectionStart: number) {

src/index.ts

+1-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,2 @@
1-
import useCurrencyInput from './useCurrencyInput'
2-
31
export * from './api'
4-
export { useCurrencyInput }
2+
export { useCurrencyInput } from './useCurrencyInput'

src/useCurrencyInput.ts

+23-34
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import { CurrencyInput } from './currencyInput'
2-
import { ComponentPublicInstance, computed, ComputedRef, getCurrentInstance, onUnmounted, Ref, ref, watch, version } from 'vue'
2+
import { ComponentPublicInstance, computed, ComputedRef, getCurrentInstance, Ref, ref, version, watch } from 'vue'
33
import { CurrencyInputOptions, CurrencyInputValue, UseCurrencyInput } from './api'
44

55
const findInput = (el: HTMLElement | null) => (el?.matches('input') ? el : el?.querySelector('input')) as HTMLInputElement
66

7-
export default (options: CurrencyInputOptions, autoEmit?: boolean): UseCurrencyInput => {
8-
let numberInput: CurrencyInput | null
9-
let input: HTMLInputElement | null
7+
export function useCurrencyInput(options: CurrencyInputOptions, autoEmit?: boolean): UseCurrencyInput {
8+
let currencyInput: CurrencyInput | null
109
const inputRef: Ref<HTMLInputElement | ComponentPublicInstance | null> = ref(null)
1110
const formattedValue = ref<string | null>(null)
1211
const numberValue = ref<number | null>(null)
@@ -20,48 +19,38 @@ export default (options: CurrencyInputOptions, autoEmit?: boolean): UseCurrencyI
2019
const inputEvent = isVue3 ? 'update:modelValue' : 'input'
2120
const changeEvent = lazyModel ? 'update:modelValue' : 'change'
2221

23-
const onInput = (e: CustomEvent<CurrencyInputValue>) => {
24-
if (e.detail) {
25-
if (!lazyModel && autoEmit !== false && modelValue.value !== e.detail.number) {
26-
emit?.(inputEvent, e.detail.number)
27-
}
28-
numberValue.value = e.detail.number
29-
formattedValue.value = e.detail.formatted
30-
}
31-
}
32-
33-
const onChange = (e: CustomEvent<CurrencyInputValue>) => {
34-
if (e.detail) {
35-
emit?.(changeEvent, e.detail.number)
36-
}
37-
}
38-
3922
watch(inputRef, (value) => {
4023
if (value) {
41-
input = findInput((value as ComponentPublicInstance)?.$el ?? value)
42-
if (input) {
43-
input.addEventListener('input', onInput as EventListener)
44-
input.addEventListener('change', onChange as EventListener)
45-
numberInput = new CurrencyInput(input, options)
46-
numberInput.setValue(modelValue.value)
24+
const el = findInput((value as ComponentPublicInstance)?.$el ?? value)
25+
if (el) {
26+
currencyInput = new CurrencyInput({
27+
el,
28+
options,
29+
onInput: (value: CurrencyInputValue) => {
30+
if (!lazyModel && autoEmit !== false && modelValue.value !== value.number) {
31+
emit?.(inputEvent, value.number)
32+
}
33+
numberValue.value = value.number
34+
formattedValue.value = value.formatted
35+
},
36+
onChange: (value: CurrencyInputValue) => {
37+
emit?.(changeEvent, value.number)
38+
}
39+
})
40+
currencyInput.setValue(modelValue.value)
4741
} else {
4842
console.error('No input element found. Please make sure that the "inputRef" template ref is properly assigned.')
4943
}
5044
} else {
51-
numberInput = null
45+
currencyInput = null
5246
}
5347
})
5448

55-
onUnmounted(() => {
56-
input?.removeEventListener('input', onInput as EventListener)
57-
input?.removeEventListener('change', onChange as EventListener)
58-
})
59-
6049
return {
6150
inputRef,
6251
numberValue,
6352
formattedValue,
64-
setValue: (value: number | null) => numberInput?.setValue(value),
65-
setOptions: (options: CurrencyInputOptions) => numberInput?.setOptions(options)
53+
setValue: (value: number | null) => currencyInput?.setValue(value),
54+
setOptions: (options: CurrencyInputOptions) => currencyInput?.setOptions(options)
6655
}
6756
}

tests/unit/currencyInput.spec.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ describe('Currency Input', () => {
1515
locale: 'en',
1616
currency: 'EUR'
1717
}
18-
currencyInput = new CurrencyInput(el, options)
18+
currencyInput = new CurrencyInput({
19+
el,
20+
options,
21+
onInput: vi.fn(),
22+
onChange: vi.fn()
23+
})
1924
})
2025

2126
describe('setValue', () => {

tests/unit/useCurrencyInput.spec.ts

+18-10
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { defineComponent, h, ref, VNode } from 'vue'
44
import { useCurrencyInput } from '../../src'
55
import { mount, shallowMount } from '@vue/test-utils'
66
import { CurrencyInput } from '../../src/currencyInput'
7-
import { describe, expect, it, vi } from 'vitest'
7+
import { beforeEach, describe, expect, it, vi } from 'vitest'
88

99
vi.mock('../../src/currencyInput')
1010

@@ -31,30 +31,39 @@ const mountComponent = (
3131
)
3232

3333
describe('useCurrencyInput', () => {
34+
beforeEach(() => {
35+
vi.clearAllMocks()
36+
})
37+
3438
it('should emit the new value on input', async () => {
3539
const wrapper = mountComponent()
3640
await wrapper.vm.$nextTick()
3741

38-
wrapper.find('input').element.dispatchEvent(new CustomEvent('input', { detail: { number: 10 } }))
42+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
43+
// @ts-ignore
44+
vi.mocked(CurrencyInput).mock.calls[0][0].onInput({ number: 10, formatted: 'EUR 10' })
3945

4046
expect(wrapper.emitted('update:modelValue')).toEqual([[10]])
41-
wrapper.unmount()
4247
})
4348

4449
it('should not emit new values on input if autoEmit is false', async () => {
4550
const wrapper = mountComponent({ type: 'input', autoEmit: false })
46-
4751
await wrapper.vm.$nextTick()
48-
wrapper.find('input').element.dispatchEvent(new CustomEvent('input', { detail: { number: 10 } }))
52+
53+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
54+
// @ts-ignore
55+
vi.mocked(CurrencyInput).mock.calls[0][0].onInput({ number: 10, formatted: 'EUR 10' })
4956

5057
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
5158
})
5259

5360
it('should emit the new value on change', async () => {
5461
const wrapper = mountComponent()
55-
5662
await wrapper.vm.$nextTick()
57-
wrapper.find('input').element.dispatchEvent(new CustomEvent('change', { detail: { number: 10 } }))
63+
64+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
65+
// @ts-ignore
66+
vi.mocked(CurrencyInput).mock.calls[0][0].onChange({ number: 10, formatted: 'EUR 10' })
5867

5968
expect(wrapper.emitted('change')).toEqual([[10]])
6069
})
@@ -77,8 +86,7 @@ describe('useCurrencyInput', () => {
7786
})
7887
)
7988
await wrapper.vm.$nextTick()
80-
81-
expect(CurrencyInput).toHaveBeenCalledWith(wrapper.find('input').element, { currency: 'EUR' })
89+
expect(CurrencyInput).toHaveBeenCalledWith(expect.objectContaining({ el: wrapper.find('input').element }))
8290
})
8391

8492
it('should accept custom input components as template ref', async () => {
@@ -93,7 +101,7 @@ describe('useCurrencyInput', () => {
93101
)
94102
await currencyInput.vm.$nextTick()
95103

96-
expect(CurrencyInput).toHaveBeenCalledWith(currencyInput.find('input').element, { currency: 'EUR' })
104+
expect(CurrencyInput).toHaveBeenCalledWith(expect.objectContaining({ el: currencyInput.find('input').element }))
97105
})
98106

99107
it('should allow to update the value', async () => {

0 commit comments

Comments
 (0)