From cd1e9f35649b92870fc3728281edd5da660a2779 Mon Sep 17 00:00:00 2001 From: Alexander Shabunevich Date: Sat, 30 Mar 2024 12:39:22 +0300 Subject: [PATCH] feat!: rework for v3 - new directive format: pass options as value, bound as argument with modifiers - update and checkValue methods in MaskInput --- src/directive.ts | 64 ++++++------ src/mask-input.ts | 161 ++++++++++++++---------------- test/components/BindCompleted.vue | 12 +++ test/components/BindInitial.vue | 11 -- test/components/BindMasked.vue | 10 +- test/components/BindUnmasked.vue | 10 +- test/components/Callbacks.vue | 20 ++-- test/components/Completed.vue | 12 --- test/components/Config.vue | 14 +-- test/components/Hooks.vue | 7 +- test/components/Initial.vue | 11 ++ test/components/Multiple.vue | 2 +- test/components/Options.vue | 8 +- test/directive.test.ts | 21 ++-- test/mask-input.test.ts | 24 ++--- 15 files changed, 188 insertions(+), 199 deletions(-) create mode 100644 test/components/BindCompleted.vue delete mode 100644 test/components/BindInitial.vue delete mode 100644 test/components/Completed.vue create mode 100644 test/components/Initial.vue diff --git a/src/directive.ts b/src/directive.ts index 2e8bfcd..e347b48 100644 --- a/src/directive.ts +++ b/src/directive.ts @@ -1,50 +1,54 @@ -import { Directive } from 'vue' +import { Directive, DirectiveBinding } from 'vue' import { MaskaDetail, MaskInput, MaskInputOptions } from './mask-input' -type MaskaDirective = Directive +type MaskaDirective = Directive const masks = new WeakMap() -const checkValue = (input: HTMLInputElement): void => { - setTimeout(() => { - if (masks.get(input)?.needUpdateValue(input) === true) { - input.dispatchEvent(new CustomEvent('input')) - } - }) +// hacky way to update binding.arg without using defineExposed +const setArg = (binding: DirectiveBinding, value: string | boolean) => { + if (!binding.arg || !binding.instance) 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) { + inst.$.setupState[binding.arg] = value // composition api + } } export const vMaska: MaskaDirective = (el, binding) => { const input = el instanceof HTMLInputElement ? el : el.querySelector('input') - const opts = { ...(binding.arg as MaskInputOptions) } ?? {} + const opts = { ...binding.value } ?? {} if (input == null || input?.type === 'file') return - checkValue(input) - - const existed = masks.get(input) - if (existed != null) { - if (!existed.needUpdateOptions(input, opts)) { - return - } - - existed.destroy() - } - - if (binding.value != null) { - const bound = binding.value - const onMaska = (detail: MaskaDetail): void => { - bound.masked = detail.masked - bound.unmasked = detail.unmasked - bound.completed = detail.completed + if (binding.arg) { + const updateArg = (detail: MaskaDetail) => { + const value = binding.modifiers.unmasked + ? detail.unmasked + : binding.modifiers.completed + ? detail.completed + : detail.masked + setArg(binding, value) } opts.onMaska = opts.onMaska == null - ? onMaska + ? updateArg : Array.isArray(opts.onMaska) - ? [...opts.onMaska, onMaska] - : [opts.onMaska, onMaska] + ? [...opts.onMaska, updateArg] + : [opts.onMaska, updateArg] } - masks.set(input, new MaskInput(input, opts)) + let mask = masks.get(input) + if (!mask) { + mask = new MaskInput(input, opts) + masks.set(input, mask) + } else { + mask.update(opts) + } + + // delay init to wait for v-model value + setTimeout(() => mask?.checkValue(input)) } diff --git a/src/mask-input.ts b/src/mask-input.ts index d583b9f..663625b 100644 --- a/src/mask-input.ts +++ b/src/mask-input.ts @@ -2,6 +2,7 @@ import { Mask, MaskOptions } from './mask' import { parseInput } from './parser' type OnMaskaType = (detail: MaskaDetail) => void +type MaskaTarget = string | NodeListOf | HTMLInputElement export interface MaskInputOptions extends MaskOptions { onMaska?: OnMaskaType | OnMaskaType[] @@ -18,24 +19,21 @@ export interface MaskaDetail { export class MaskInput { readonly items = new Map() - constructor ( - target: string | NodeListOf | HTMLInputElement, - readonly options: MaskInputOptions = {} - ) { - if (typeof target === 'string') { - this.init( - Array.from(document.querySelectorAll(target)), - this.getMaskOpts(options) - ) - } else { - this.init( - 'length' in target ? Array.from(target) : [target], - this.getMaskOpts(options) - ) + constructor(target: MaskaTarget, readonly options: MaskInputOptions = {}) { + this.init(this.getInputs(target), this.getOptions(options)) + } + + update(options: MaskInputOptions = {}): void { + this.init(Array.from(this.items.keys()), this.getOptions(options)) + } + + checkValue(input: HTMLInputElement) { + if (input.value && input.value !== this.process(input).masked) { + this.setMaskedValue(input, input.value) } } - destroy (): void { + destroy(): void { for (const input of this.items.keys()) { input.removeEventListener('input', this.inputEvent) input.removeEventListener('beforeinput', this.beforeinputEvent) @@ -43,50 +41,40 @@ export class MaskInput { this.items.clear() } - needUpdateOptions (input: HTMLInputElement, opts: MaskInputOptions): boolean { - const mask = this.items.get(input) as Mask - const maskNew = new Mask(parseInput(input, this.getMaskOpts(opts))) + private init(inputs: HTMLInputElement[], defaults: MaskOptions): void { + for (const input of inputs) { + const inited = this.items.has(input) - return JSON.stringify(mask.opts) !== JSON.stringify(maskNew.opts) + this.items.set(input, new Mask(parseInput(input, defaults))) + + if (!inited) { + input.addEventListener('input', this.inputEvent) + input.addEventListener('beforeinput', this.beforeinputEvent) + this.checkValue(input) + } + } } - needUpdateValue (input: HTMLInputElement): boolean { - const value = input.dataset.maskaValue + private getInputs(target: MaskaTarget): HTMLInputElement[] { + if (typeof target === 'string') { + return Array.from(document.querySelectorAll(target)) + } - return ( - (value == null && input.value !== '') || - (value != null && value !== input.value) - ) + return 'length' in target ? Array.from(target) : [target] } - private getMaskOpts (options: MaskInputOptions): MaskOptions { + private getOptions(options: MaskInputOptions): MaskOptions { const { onMaska, preProcess, postProcess, ...opts } = options return opts } - private init (inputs: HTMLInputElement[], defaults: MaskOptions): void { - for (const input of inputs) { - const mask = new Mask(parseInput(input, defaults)) - this.items.set(input, mask) - - if (input.value !== '') { - this.setMaskedValue(input, input.value) - } - - input.addEventListener('input', this.inputEvent) - input.addEventListener('beforeinput', this.beforeinputEvent) - } - } - - private readonly beforeinputEvent = (e: Event | InputEvent): void => { + private readonly beforeinputEvent = (e: InputEvent): void => { const input = e.target as HTMLInputElement const mask = this.items.get(input) as Mask - // delete first character in eager mask when it's the only left if ( mask.isEager() && - 'inputType' in e && e.inputType.startsWith('delete') && mask.unmasked(input.value).length <= 1 ) { @@ -95,71 +83,50 @@ export class MaskInput { } private readonly inputEvent = (e: Event | InputEvent): void => { - if ( - e instanceof CustomEvent && - e.type === 'input' && - e.detail != null && - typeof e.detail === 'object' && - 'masked' in e.detail - ) { + if (e instanceof CustomEvent && e.type === 'input') { return } const input = e.target as HTMLInputElement const mask = this.items.get(input) as Mask - const valueOld = input.value - const ss = input.selectionStart - const se = input.selectionEnd - let value = valueOld + const selection = input.selectionStart + let value = input.value if (mask.isEager()) { - const masked = mask.masked(valueOld) - const unmasked = mask.unmasked(valueOld) + const masked = mask.masked(value) + const unmasked = mask.unmasked(value) + const unmaskedMasked = mask.unmasked(masked) if (unmasked === '' && 'data' in e && e.data != null) { // empty state and something like `space` pressed value = e.data - } else if (unmasked !== mask.unmasked(masked)) { + } else if (unmasked !== unmaskedMasked) { value = unmasked } } this.setMaskedValue(input, value) + this.updateCursor(e, selection, value) + } - // set caret position - if ('inputType' in e) { - if ( - e.inputType.startsWith('delete') || - (ss != null && ss < valueOld.length) - ) { - try { - // see https://github.com/beholdr/maska/issues/118 - input.setSelectionRange(ss, se) - } catch {} + private updateCursor(e: Event | InputEvent, s: number | null, value: string) { + if (!('inputType' in e) || s === null) return + + const input = e.target as HTMLInputElement + + if (e.inputType.startsWith('delete') || (s != null && s < value.length)) { + try { + input.setSelectionRange(s, s) + } catch { + // see https://github.com/beholdr/maska/issues/118 } } } - private setMaskedValue (input: HTMLInputElement, value: string): void { - const mask = this.items.get(input) as Mask + private setMaskedValue(input: HTMLInputElement, value: string): void { + const detail = this.process(input, value) - if (this.options.preProcess != null) { - value = this.options.preProcess(value) - } - - const masked = mask.masked(value) - const unmasked = mask.unmasked(mask.isEager() ? masked : value) - const completed = mask.completed(value) - const detail = { masked, unmasked, completed } - - value = masked - - if (this.options.postProcess != null) { - value = this.options.postProcess(value) - } - - input.value = value - input.dataset.maskaValue = value + input.value = detail.masked if (this.options.onMaska != null) { if (Array.isArray(this.options.onMaska)) { @@ -168,7 +135,27 @@ export class MaskInput { this.options.onMaska(detail) } } + input.dispatchEvent(new CustomEvent('maska', { detail })) - input.dispatchEvent(new CustomEvent('input', { detail })) + input.dispatchEvent(new CustomEvent('input', { detail: detail.masked })) + } + + private process(input: HTMLInputElement, value?: string): MaskaDetail { + const mask = this.items.get(input) as Mask + let valueNew = value ?? input.value + + if (this.options.preProcess != null) { + valueNew = this.options.preProcess(valueNew) + } + + 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 } } } diff --git a/test/components/BindCompleted.vue b/test/components/BindCompleted.vue new file mode 100644 index 0000000..46960d0 --- /dev/null +++ b/test/components/BindCompleted.vue @@ -0,0 +1,12 @@ + + + diff --git a/test/components/BindInitial.vue b/test/components/BindInitial.vue deleted file mode 100644 index 8da8ab8..0000000 --- a/test/components/BindInitial.vue +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/test/components/BindMasked.vue b/test/components/BindMasked.vue index 9817b27..7c59231 100644 --- a/test/components/BindMasked.vue +++ b/test/components/BindMasked.vue @@ -1,11 +1,11 @@ diff --git a/test/components/BindUnmasked.vue b/test/components/BindUnmasked.vue index ba887a4..2817fce 100644 --- a/test/components/BindUnmasked.vue +++ b/test/components/BindUnmasked.vue @@ -1,11 +1,11 @@ diff --git a/test/components/Callbacks.vue b/test/components/Callbacks.vue index daff148..8c2ae28 100644 --- a/test/components/Callbacks.vue +++ b/test/components/Callbacks.vue @@ -1,24 +1,20 @@ diff --git a/test/components/Completed.vue b/test/components/Completed.vue deleted file mode 100644 index 3af3567..0000000 --- a/test/components/Completed.vue +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/test/components/Config.vue b/test/components/Config.vue index f5d09bf..89cb9c5 100644 --- a/test/components/Config.vue +++ b/test/components/Config.vue @@ -1,9 +1,9 @@ diff --git a/test/components/Hooks.vue b/test/components/Hooks.vue index f2e8bc7..21de5c2 100644 --- a/test/components/Hooks.vue +++ b/test/components/Hooks.vue @@ -1,12 +1,11 @@ diff --git a/test/components/Initial.vue b/test/components/Initial.vue new file mode 100644 index 0000000..89a6281 --- /dev/null +++ b/test/components/Initial.vue @@ -0,0 +1,11 @@ + + + diff --git a/test/components/Multiple.vue b/test/components/Multiple.vue index 6c13e6c..eaee382 100644 --- a/test/components/Multiple.vue +++ b/test/components/Multiple.vue @@ -27,7 +27,7 @@ const onMaska2 = () => { diff --git a/test/directive.test.ts b/test/directive.test.ts index 7a73134..ffe209c 100644 --- a/test/directive.test.ts +++ b/test/directive.test.ts @@ -2,18 +2,18 @@ import { nextTick } from 'vue' import { expect, test } from 'vitest' import { mount } from '@vue/test-utils' -import BindInitial from './components/BindInitial.vue' +import BindCompleted from './components/BindCompleted.vue' import BindMasked from './components/BindMasked.vue' import BindUnmasked from './components/BindUnmasked.vue' import Callbacks from './components/Callbacks.vue' import ChangeValue from './components/ChangeValue.vue' -import Completed from './components/Completed.vue' import Config from './components/Config.vue' import Custom from './components/Custom.vue' import DataAttr from './components/DataAttr.vue' import Dynamic from './components/Dynamic.vue' import Events from './components/Events.vue' import Hooks from './components/Hooks.vue' +import Initial from './components/Initial.vue' import Model from './components/Model.vue' import Multiple from './components/Multiple.vue' import Options from './components/Options.vue' @@ -63,13 +63,14 @@ test('dynamic mask', async () => { }) test('initial value', async () => { - const wrapper = mount(BindInitial) - const input = wrapper.get('input') + const wrapper = mount(Initial) + const input1 = wrapper.get('#input1') + const input2 = wrapper.get('#input2') - await nextTick() + await new Promise((r) => setTimeout(r)) - expect(input.element.value).toBe('1-2') - expect(wrapper.get('div').element.textContent).toBe('1-2') + expect(input1.element.value).toBe('1-2') + expect(input2.element.value).toBe('3-4') }) test('bind masked', async () => { @@ -77,6 +78,7 @@ test('bind masked', 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') }) @@ -86,12 +88,13 @@ test('bind unmasked', async () => { const input = wrapper.get('input') await input.setValue('123') + expect(input.element.value).toBe('1-2') expect(wrapper.get('div').element.textContent).toBe('12') }) test('bind completed', async () => { - const wrapper = mount(Completed) + const wrapper = mount(BindCompleted) const input = wrapper.get('input') await input.setValue('12') @@ -179,6 +182,8 @@ test('multiple inputs', async () => { 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) diff --git a/test/mask-input.test.ts b/test/mask-input.test.ts index 4e0ffed..d2e384e 100644 --- a/test/mask-input.test.ts +++ b/test/mask-input.test.ts @@ -176,7 +176,7 @@ describe('test hooks', () => { expect(context.onMaska).toHaveBeenCalledOnce() expect(context.onMaska).toHaveBeenCalledWith({ completed: false, - masked: '1', + masked: '$1', unmasked: '1' }) }) @@ -191,7 +191,7 @@ describe('test hooks', () => { expect(context.onMaska).toHaveBeenCalledTimes(3) expect(context.onMaska).toHaveBeenLastCalledWith({ completed: false, - masked: '123', + masked: '$123', unmasked: '123' }) }) @@ -206,7 +206,7 @@ describe('test hooks', () => { expect(context.onMaska).toHaveBeenCalledTimes(4) expect(context.onMaska).toHaveBeenLastCalledWith({ completed: true, - masked: '1234', + masked: '$1,234', unmasked: '1234' }) }) @@ -221,7 +221,7 @@ describe('test hooks', () => { expect(context.onMaska).toHaveBeenCalledTimes(7) expect(context.onMaska).toHaveBeenLastCalledWith({ completed: true, - masked: '1234567', + masked: '$1,234,567', unmasked: '1234567' }) }) @@ -236,7 +236,7 @@ describe('test hooks', () => { expect(context.onMaska).toHaveBeenCalledTimes(5) expect(context.onMaska).toHaveBeenLastCalledWith({ completed: true, - masked: '123.4', + masked: '$123.4', unmasked: '1234' }) }) @@ -251,7 +251,7 @@ describe('test hooks', () => { expect(context.onMaska).toHaveBeenCalledTimes(6) expect(context.onMaska).toHaveBeenLastCalledWith({ completed: true, - masked: '123.45', + masked: '$123.45', unmasked: '12345' }) }) @@ -266,7 +266,7 @@ describe('test hooks', () => { expect(context.onMaska).toHaveBeenCalledTimes(7) expect(context.onMaska).toHaveBeenLastCalledWith({ completed: true, - masked: '123.45', + masked: '$123.45', unmasked: '12345' }) }) @@ -281,7 +281,7 @@ describe('test hooks', () => { expect(context.onMaska).toHaveBeenCalledTimes(8) expect(context.onMaska).toHaveBeenLastCalledWith({ completed: true, - masked: '1234.56', + masked: '$1,234.56', unmasked: '123456' }) }) @@ -296,7 +296,7 @@ describe('test hooks', () => { expect(context.onMaska).toHaveBeenCalledTimes(8) expect(context.onMaska).toHaveBeenLastCalledWith({ completed: true, - masked: '1234.56', + masked: '$1,234.56', unmasked: '123456' }) }) @@ -311,7 +311,7 @@ describe('test hooks', () => { expect(context.onMaska).toHaveBeenCalledTimes(8) expect(context.onMaska).toHaveBeenLastCalledWith({ completed: true, - masked: '1234.5', + masked: '$1,234.5', unmasked: '12345' }) }) @@ -326,7 +326,7 @@ describe('test hooks', () => { expect(context.onMaska).toHaveBeenCalledTimes(9) expect(context.onMaska).toHaveBeenLastCalledWith({ completed: true, - masked: '1234.', + masked: '$1,234.', unmasked: '1234' }) }) @@ -341,7 +341,7 @@ describe('test hooks', () => { expect(context.onMaska).toHaveBeenCalledTimes(10) expect(context.onMaska).toHaveBeenLastCalledWith({ completed: true, - masked: '1234', + masked: '$1,234', unmasked: '1234' }) })