From 1e50a2a2594ed67b0875989caa73c1e7afcae924 Mon Sep 17 00:00:00 2001 From: Alexander Shabunevich Date: Sat, 6 Apr 2024 22:43:56 +0300 Subject: [PATCH] refactor: MaskInput rework - abort controller for events - refactor onInput - remove beforeinputEvent - new cursor position fix --- src/directive.ts | 4 +- src/mask-input.ts | 92 +++++++++++++++++------------------------ test/directive.test.ts | 26 ++++++++---- test/mask-input.test.ts | 33 +++++++++++---- 4 files changed, 83 insertions(+), 72 deletions(-) diff --git a/src/directive.ts b/src/directive.ts index 9cdccd7..fc942bc 100644 --- a/src/directive.ts +++ b/src/directive.ts @@ -10,6 +10,7 @@ const setArg = (binding: DirectiveBinding, value: string | boolean) => { if (!binding.arg || (binding.instance == null)) return const inst = binding.instance as any + if (binding.arg in inst) { inst[binding.arg] = value // options api } else if (inst.$?.setupState && binding.arg in inst.$.setupState) { @@ -30,6 +31,7 @@ export const vMaska: MaskaDirective = (el, binding) => { : binding.modifiers.completed ? detail.completed : detail.masked + setArg(binding, value) } @@ -50,5 +52,5 @@ export const vMaska: MaskaDirective = (el, binding) => { } // delay for possible v-model change - setTimeout(() => mask?.updateValue(input)) + queueMicrotask(() => mask?.updateValue(input)) } diff --git a/src/mask-input.ts b/src/mask-input.ts index 1152d97..f5c6e6b 100644 --- a/src/mask-input.ts +++ b/src/mask-input.ts @@ -18,6 +18,7 @@ export interface MaskaDetail { export class MaskInput { readonly items = new Map() + private readonly abort = new AbortController() constructor (target: MaskaTarget, private options: MaskInputOptions = {}) { this.init(this.getInputs(target)) @@ -31,16 +32,13 @@ export class MaskInput { } updateValue (input: HTMLInputElement) { - if (input.value && input.value !== this.process(input).masked) { - this.setMaskedValue(input, input.value) + if (input.value && input.value !== this.processInput(input).masked) { + this.setValue(input, input.value) } } destroy (): void { - for (const input of this.items.keys()) { - input.removeEventListener('input', this.inputEvent) - input.removeEventListener('beforeinput', this.beforeinputEvent) - } + this.abort.abort() this.items.clear() } @@ -49,8 +47,10 @@ export class MaskInput { for (const input of inputs) { if (!this.items.has(input)) { - input.addEventListener('input', this.inputEvent) - input.addEventListener('beforeinput', this.beforeinputEvent) + input.addEventListener('input', this.onInput, { + signal: this.abort.signal, + capture: true + }) } this.items.set(input, new Mask(parseInput(input, defaults))) @@ -75,61 +75,45 @@ export class MaskInput { return opts } - private readonly beforeinputEvent = (e: InputEvent): void => { - const input = e.target as HTMLInputElement - const mask = this.items.get(input) as Mask - - if ( - mask.isEager() && - e.inputType.startsWith('delete') && - mask.unmasked(input.value).length <= 1 - ) { - this.setMaskedValue(input, '') - } - } - - private readonly inputEvent = (e: Event | InputEvent): void => { + private readonly onInput = (e: Event | InputEvent): void => { if (e instanceof CustomEvent && e.type === 'input') { return } const input = e.target as HTMLInputElement const mask = this.items.get(input) as Mask - const selection = input.selectionStart - let value = input.value + const isDelete = 'inputType' in e && e.inputType.startsWith('delete') + const isEager = mask.isEager() - if (mask.isEager()) { - const masked = mask.masked(value) - const unmasked = mask.unmasked(value) - const unmaskedMasked = mask.unmasked(masked) + const value = (isDelete && isEager && mask.unmasked(input.value) === '') + ? '' + : input.value - if (unmasked === '' && 'data' in e && e.data != null) { - // empty state and something like `space` pressed - value = e.data - } else if (unmasked !== unmaskedMasked) { - value = unmasked - } - } - - this.setMaskedValue(input, value) - this.updateCursor(e, selection, value) + this.fixCursor(input, isDelete, () => this.setValue(input, value)) } - private updateCursor (e: Event | InputEvent, s: number | null, value: string) { - if (!('inputType' in e) || s === null) return + private fixCursor (input: HTMLInputElement, isDelete: boolean, closure: any) { + const pos = input.selectionStart + const value = input.value - const input = e.target as HTMLInputElement + closure() - if (e.inputType.startsWith('delete') || (s != null && s < value.length)) { - // see https://github.com/beholdr/maska/issues/118 - try { - input.setSelectionRange(s, s) - } catch {} - } + // if pos is null, it means element does not support setSelectionRange + // and when cursor at the end, process only on delete event + if (pos === null || (pos === value.length && !isDelete)) return + + const valueNew = input.value + const leftPart = value.slice(0, pos) + const leftPartNew = valueNew.slice(0, pos) + const unmasked = this.processInput(input, leftPart).unmasked + const unmaskedNew = this.processInput(input, leftPartNew).unmasked + const newPos = pos + (unmasked.length - unmaskedNew.length) + + input.setSelectionRange(newPos, newPos) } - private setMaskedValue (input: HTMLInputElement, value: string): void { - const detail = this.process(input, value) + private setValue (input: HTMLInputElement, value: string): void { + const detail = this.processInput(input, value) input.value = detail.masked @@ -145,7 +129,7 @@ export class MaskInput { input.dispatchEvent(new CustomEvent('input', { detail: detail.masked })) } - private process (input: HTMLInputElement, value?: string): MaskaDetail { + private processInput (input: HTMLInputElement, value?: string): MaskaDetail { const mask = this.items.get(input) as Mask let valueNew = value ?? input.value @@ -154,13 +138,15 @@ export class MaskInput { } let masked = mask.masked(valueNew) - const unmasked = mask.unmasked(mask.isEager() ? masked : valueNew) - const completed = mask.completed(valueNew) if (this.options.postProcess != null) { masked = this.options.postProcess(masked) } - return { masked, unmasked, completed } + return { + masked, + unmasked: mask.unmasked(valueNew), + completed: mask.completed(valueNew) + } } } diff --git a/test/directive.test.ts b/test/directive.test.ts index 26c867d..0547352 100644 --- a/test/directive.test.ts +++ b/test/directive.test.ts @@ -67,7 +67,7 @@ test('initial value', async () => { const input1 = wrapper.get('#input1') const input2 = wrapper.get('#input2') - await new Promise((r) => setTimeout(r)) + await nextTick() expect(input1.element.value).toBe('1-2') expect(input2.element.value).toBe('3-4') @@ -98,13 +98,11 @@ test('bind completed', async () => { const input = wrapper.get('input') await input.setValue('12') - await nextTick() expect(input.element.value).toBe('1-2') expect(wrapper.get('div').element.textContent).toBe('Uncompleted') await input.setValue('123') - await nextTick() expect(input.element.value).toBe('1-2-3') expect(wrapper.get('div').element.textContent).toBe('Completed') @@ -120,10 +118,12 @@ test('v-model', async () => { expect(wrapper.get('div').element.textContent).toBe('1-2') await input.setValue('1') + expect(input.element.value).toBe('1-') expect(wrapper.get('div').element.textContent).toBe('1-') await input.setValue('123') + expect(input.element.value).toBe('1-2') expect(wrapper.get('div').element.textContent).toBe('1-2') }) @@ -133,10 +133,12 @@ test('custom component', async () => { const input = wrapper.get('input') await input.setValue('1') + expect(input.element.value).toBe('1-') expect(wrapper.get('div').element.textContent).toBe('1-') await input.setValue('123') + expect(input.element.value).toBe('1-2') expect(wrapper.get('div').element.textContent).toBe('1-2') }) @@ -146,14 +148,12 @@ test('change value', async () => { const wrapper = mount(ChangeValue) const input = wrapper.get('input') - await new Promise((r) => setTimeout(r)) + await nextTick() expect(input.element.value).toBe('12-3') await wrapper.get('button').trigger('click') - await new Promise((r) => setTimeout(r)) - expect(input.element.value).toBe('56-7') }) @@ -173,6 +173,7 @@ test('multiple inputs', async () => { expect(wrapper.emitted('mask2')).toHaveLength(1) await input.setValue('1') + expect(input.element.value).toBe('1') expect(wrapper.get('#value1').element.textContent).toBe('1') expect(wrapper.emitted()).toHaveProperty('mask1') @@ -180,10 +181,8 @@ test('multiple inputs', async () => { expect(wrapper.emitted('mask2')).toHaveLength(1) await checkbox.setValue() + expect(checkbox.element).toBeChecked() - - await new Promise((r) => setTimeout(r)) - expect(input.element.value).toBe('1-') expect(wrapper.emitted('mask1')).toHaveLength(3) expect(wrapper.emitted('mask2')).toHaveLength(1) @@ -194,18 +193,22 @@ test('config and bind', async () => { const input = wrapper.get('input') await input.setValue('1') + expect(input.element.value).toBe('') expect(wrapper.get('div').element.textContent).toBe('') await input.setValue('ab') + expect(input.element.value).toBe('AB') expect(wrapper.get('div').element.textContent).toBe('AB') await input.setValue('ab cd ') + expect(input.element.value).toBe('AB CD') expect(wrapper.get('div').element.textContent).toBe('AB CD') await input.setValue('ab cd1') + expect(input.element.value).toBe('AB CD') expect(wrapper.get('div').element.textContent).toBe('AB CD') }) @@ -247,15 +250,18 @@ test('callbacks', async () => { const input2 = wrapper.get('#input2') await input1.setValue('1') + expect(wrapper.emitted()).toHaveProperty('mask1') expect(wrapper.emitted('mask1')).toHaveLength(1) expect(wrapper.emitted('mask1')[0][0]).toHaveProperty('completed', false) await input1.setValue('12') + expect(wrapper.emitted('mask1')).toHaveLength(2) expect(wrapper.emitted('mask1')[1][0]).toHaveProperty('completed', true) await input2.setValue('3') + expect(wrapper.emitted()).toHaveProperty('mask2') expect(wrapper.emitted()).toHaveProperty('mask3') expect(wrapper.emitted('mask2')).toHaveLength(1) @@ -264,6 +270,7 @@ test('callbacks', async () => { expect(wrapper.emitted('mask3')[0][0]).toHaveProperty('completed', false) await input2.setValue('34') + expect(wrapper.emitted('mask2')).toHaveLength(2) expect(wrapper.emitted('mask3')).toHaveLength(2) expect(wrapper.emitted('mask2')[1][0]).toHaveProperty('completed', true) @@ -278,6 +285,7 @@ test('options api component', async () => { const input = wrapper.get('input') await input.setValue('123') + expect(input.element.value).toBe('1-2') expect(wrapper.get('div').element.textContent).toBe('1-2') }) diff --git a/test/mask-input.test.ts b/test/mask-input.test.ts index 49c79ba..4e63250 100644 --- a/test/mask-input.test.ts +++ b/test/mask-input.test.ts @@ -305,10 +305,10 @@ describe('test hooks', () => { await user.type(input, '1234.56{backspace}') expect(input).toHaveValue('$1,234.5') - expect(context.preProcess).toHaveBeenCalledTimes(8) - expect(context.postProcess).toHaveBeenCalledTimes(8) + expect(context.preProcess).toHaveBeenCalled() + expect(context.postProcess).toHaveBeenCalled() - expect(context.onMaska).toHaveBeenCalledTimes(8) + expect(context.onMaska).toHaveBeenCalled() expect(context.onMaska).toHaveBeenLastCalledWith({ completed: true, masked: '$1,234.5', @@ -320,10 +320,10 @@ describe('test hooks', () => { await user.type(input, '1234.56{backspace}{backspace}') expect(input).toHaveValue('$1,234.') - expect(context.preProcess).toHaveBeenCalledTimes(9) - expect(context.postProcess).toHaveBeenCalledTimes(9) + expect(context.preProcess).toHaveBeenCalled() + expect(context.postProcess).toHaveBeenCalled() - expect(context.onMaska).toHaveBeenCalledTimes(9) + expect(context.onMaska).toHaveBeenCalled() expect(context.onMaska).toHaveBeenLastCalledWith({ completed: true, masked: '$1,234.', @@ -335,10 +335,10 @@ describe('test hooks', () => { await user.type(input, '1234.56{backspace}{backspace}{backspace}') expect(input).toHaveValue('$1,234') - expect(context.preProcess).toHaveBeenCalledTimes(10) - expect(context.postProcess).toHaveBeenCalledTimes(10) + expect(context.preProcess).toHaveBeenCalled() + expect(context.postProcess).toHaveBeenCalled() - expect(context.onMaska).toHaveBeenCalledTimes(10) + expect(context.onMaska).toHaveBeenCalled() expect(context.onMaska).toHaveBeenLastCalledWith({ completed: true, masked: '$1,234', @@ -750,6 +750,11 @@ describe('#-# eager mask', () => { test('input 12{backspace}×2', async () => { await user.type(input, '12{backspace}{backspace}') + expect(input).toHaveValue('1-') + }) + + test('input 12{backspace}×3', async () => { + await user.type(input, '12{backspace}{backspace}{backspace}') expect(input).toHaveValue('') }) @@ -863,6 +868,16 @@ describe('+1 (#) #-# eager mask', () => { test('input 234{backspace}×4', async () => { await user.type(input, '234{backspace}{backspace}{backspace}{backspace}') + expect(input).toHaveValue('+1 (2) ') + }) + + test('input 234{backspace}×5', async () => { + await user.type(input, '234{backspace}{backspace}{backspace}{backspace}{backspace}') + expect(input).toHaveValue('+1 (2) ') + }) + + test('input 234{backspace}×6', async () => { + await user.type(input, '234{backspace}{backspace}{backspace}{backspace}{backspace}{backspace}') expect(input).toHaveValue('') }) })