Skip to content

Commit

Permalink
feat: add new option autoEmit (#330)
Browse files Browse the repository at this point in the history
  • Loading branch information
dm4t2 committed Jun 13, 2022
1 parent fa7c30c commit c881bdd
Show file tree
Hide file tree
Showing 6 changed files with 137 additions and 45 deletions.
5 changes: 3 additions & 2 deletions docs/api.md
Expand Up @@ -5,13 +5,13 @@
### useCurrencyInput

```typescript
declare const useCurrencyInput: (options: CurrencyInputOptions) => UseCurrencyInput
declare const useCurrencyInput: (options: CurrencyInputOptions, autoEmit?: boolean) => UseCurrencyInput
```

### parse

```typescript
declare const parse: (formattedValue: string, options: CurrencyFormatOptions) => number | null
declare const parse: (formattedValue: string | null, options: CurrencyFormatOptions) => number | null
```

## Enums
Expand Down Expand Up @@ -86,6 +86,7 @@ interface CurrencyInputOptions extends CurrencyFormatOptions {
```typescript
interface UseCurrencyInput {
inputRef: Ref
numberValue: Ref<number | null>
formattedValue: Ref<string | null>
setValue: (number: number | null) => void
setOptions: (options: CurrencyInputOptions) => void
Expand Down
57 changes: 51 additions & 6 deletions docs/guide.md
Expand Up @@ -34,13 +34,16 @@ The following code examples are for Vue 3. Deviations for Vue 2 are noted as inl

### Creating a custom component

The following example component `<currency-input>` uses a simple HTML input element.
The following example component `<CurrencyInput>` uses a simple HTML input element.

The component must provide props for the `v-model` value binding and the options (see [Config Reference](config)). Make also sure, that the input element has type `text` (or omit the type since it's the default).

```vue
<template>
<input ref="inputRef" type="text" />
<input
ref="inputRef"
type="text"
/>
</template>
<script>
Expand All @@ -63,11 +66,14 @@ export default {

### Use the custom component

Now you can use the created `<currency-input>` component in your app:
Now you can use the created `<CurrencyInput>` component in your app:

```vue
<template>
<currency-input v-model="value" :options="{ currency: 'EUR' }" />
<CurrencyInput
v-model="value"
:options="{ currency: 'EUR' }"
/>
</template>
<script>
Expand All @@ -83,18 +89,57 @@ export default {

See the final result in the [examples](examples#simple-html-input-element).

## Auto emit
By default, the `useCurrencyInput` composable emits the number value automatically on each input.
This can be disabled If you need a custom emit behavior for features such as debouncing.

The following example component `<DebouncedCurrencyInput>` demonstrates this by using the awesome `watchDebounced` composable of [VueUse](https://vueuse.org/shared/watchDebounced):

```vue
<template>
<input ref="inputRef" type="text" />
</template>
<script>
import { useCurrencyInput } from 'vue-currency-input'
import { watchDebounced } from '@vueuse/core'
export default {
name: 'DebouncedCurrencyInput',
props: {
modelValue: Number, // Vue 2: value
options: Object
},
setup (props, { emit }) {
const { inputRef, numberValue } = useCurrencyInput(props.options, false)
watchDebounced(numberValue, (value) => emit('update:modelValue', value), { debounce: 1000 })
return { inputRef }
}
}
</script>
```

## Lazy value binding

Sometimes you might want to update the bound value only when the input loses its focus. In this case, use `v-model.lazy` for Vue 3:

```vue
<currency-input v-model.lazy="value" :options="{ currency: 'EUR' }" />
<CurrencyInput
v-model.lazy="value"
:options="{ currency: 'EUR' }"
/>
```

For Vue 2 listen to the `change` event instead of using `v-model`, since the `lazy` modifier is not supported when using `v-model` on custom components:

```vue
<currency-input :value="value" :options="{ currency: 'EUR' }" @change="value = $event" />
<CurrencyInput
:value="value"
:options="{ currency: 'EUR' }"
@change="value = $event"
/>
```

## External props changes
Expand Down
6 changes: 1 addition & 5 deletions src/api.ts
@@ -1,5 +1,4 @@
import { Ref } from 'vue-demi'
import CurrencyFormat from './currencyFormat'

export interface CurrencyInputValue {
number: number | null
Expand Down Expand Up @@ -51,11 +50,8 @@ export interface CurrencyInputOptions extends CurrencyFormatOptions {

export interface UseCurrencyInput {
inputRef: Ref
numberValue: Ref<number | null>
formattedValue: Ref<string | null>
setValue: (number: number | null) => void
setOptions: (options: CurrencyInputOptions) => void
}

export const parse = (formattedValue: string, options: CurrencyFormatOptions): number | null => {
return new CurrencyFormat(options).parse(formattedValue)
}
8 changes: 7 additions & 1 deletion src/index.ts
@@ -1,9 +1,15 @@
import useCurrencyInput from './useCurrencyInput'
import CurrencyFormat from './currencyFormat'
import { CurrencyFormatOptions } from './api'

const parse = (formattedValue: string | null, options: CurrencyFormatOptions): number | null => {
return new CurrencyFormat(options).parse(formattedValue)
}

/**
* @deprecated Use the named export `useCurrencyInput` instead.
*/
export default useCurrencyInput
export * from './api'

export { useCurrencyInput }
export { useCurrencyInput, parse }
16 changes: 11 additions & 5 deletions src/useCurrencyInput.ts
Expand Up @@ -4,33 +4,38 @@ import { CurrencyInputOptions, CurrencyInputValue, UseCurrencyInput } from './ap

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

export default (options: CurrencyInputOptions): UseCurrencyInput => {
export default (options: CurrencyInputOptions, autoEmit?: boolean): UseCurrencyInput => {
let numberInput: CurrencyInput | null
let input: HTMLInputElement | null
const inputRef: Ref<HTMLInputElement | ComponentPublicInstance | null> = ref(null)
const formattedValue = ref<string | null>(null)
const numberValue = ref<number | null>(null)

const instance = getCurrentInstance()
const emit = (event: string, value: number | null) => instance?.emit(event, value)
const lazyModel = isVue3 && (instance?.attrs.modelModifiers as Record<string, boolean>)?.lazy
const numberValue: ComputedRef<number | null> = computed(() => instance?.props[isVue3 ? 'modelValue' : 'value'] as number)
const modelValue: ComputedRef<number | null> = computed(() => instance?.props[isVue3 ? 'modelValue' : 'value'] as number)
const inputEvent = isVue3 ? 'update:modelValue' : 'input'
const changeEvent = lazyModel ? 'update:modelValue' : 'change'
const hasInputEventListener = !isVue3 || !lazyModel
const hasChangeEventListener = !isVue3 || lazyModel || !instance?.attrs.onChange

const onInput = (e: CustomEvent<CurrencyInputValue>) => {
if (e.detail) {
if (numberValue.value !== e.detail.number) {
if (autoEmit !== false && modelValue.value !== e.detail.number) {
emit(inputEvent, e.detail.number)
}
numberValue.value = e.detail.number
formattedValue.value = e.detail.formatted
}
}

const onChange = (e: CustomEvent<CurrencyInputValue>) => {
if (e.detail) {
emit(changeEvent, e.detail.number)
if (autoEmit !== false) {
emit(changeEvent, e.detail.number)
}
numberValue.value = e.detail.number
formattedValue.value = e.detail.formatted
}
}
Expand All @@ -46,7 +51,7 @@ export default (options: CurrencyInputOptions): UseCurrencyInput => {
if (hasChangeEventListener) {
input.addEventListener('change', onChange as EventListener)
}
numberInput.setValue(numberValue.value)
numberInput.setValue(modelValue.value)
} else {
console.error('No input element found. Please make sure that the "inputRef" template ref is properly assigned.')
}
Expand All @@ -66,6 +71,7 @@ export default (options: CurrencyInputOptions): UseCurrencyInput => {

return {
inputRef,
numberValue,
formattedValue,
setValue: (value: number | null) => numberInput?.setValue(value),
setOptions: (options: CurrencyInputOptions) => numberInput?.setOptions(options)
Expand Down
90 changes: 64 additions & 26 deletions tests/unit/useCurrencyInput.spec.ts
@@ -1,38 +1,56 @@
/* eslint-disable vue/one-component-per-file */
import { defineComponent, h } from 'vue'
import { defineComponent, h, ref, VNode } from 'vue'
import { useCurrencyInput } from '../../src'
import { mount, shallowMount } from '@vue/test-utils'
import { CurrencyInput } from '../../src/currencyInput'
import { mocked } from 'ts-jest/utils'

jest.mock('../../src/currencyInput')

const mountComponent = (
{ type, children, autoEmit } = <
{
type: string
children?: VNode[]
autoEmit?: boolean
}
>{
type: 'div',
children: [h('input')],
autoEmit: true
}
) =>
shallowMount(
defineComponent({
setup: () => {
const { inputRef } = useCurrencyInput({ currency: 'EUR' }, autoEmit)
return () => h(type, { ref: inputRef }, children)
}
})
)

describe('useCurrencyInput', () => {
it('should emit the new value on input', async () => {
const wrapper = shallowMount(
defineComponent({
setup: () => {
const { inputRef, formattedValue } = useCurrencyInput({ currency: 'EUR' })
return () => h('div', { ref: inputRef }, [h('input', { value: formattedValue })])
}
})
)

const wrapper = mountComponent()
await wrapper.vm.$nextTick()

wrapper.find('input').element.dispatchEvent(new CustomEvent('input', { detail: { number: 10 } }))

expect(wrapper.emitted('update:modelValue')).toEqual([[10]])
wrapper.unmount()
})

it('should not emit new values on input if autoEmit is false', async () => {
const wrapper = mountComponent({ type: 'input', autoEmit: false })

await wrapper.vm.$nextTick()
wrapper.find('input').element.dispatchEvent(new CustomEvent('input', { detail: { number: 10 } }))

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

it('should emit the new value on change', async () => {
const wrapper = shallowMount(
defineComponent({
setup: () => {
const { inputRef, formattedValue } = useCurrencyInput({ currency: 'EUR' })
return () => h('div', { ref: inputRef }, [h('input', { value: formattedValue })])
}
})
)
const wrapper = mountComponent()

await wrapper.vm.$nextTick()
wrapper.find('input').element.dispatchEvent(new CustomEvent('change', { detail: { number: 10 } }))
Expand All @@ -42,14 +60,7 @@ describe('useCurrencyInput', () => {

it('should skip the CurrencyInput instantiation if no input element can be found', async () => {
jest.spyOn(console, 'error').mockImplementation()
const wrapper = shallowMount(
defineComponent({
setup: () => {
const { inputRef } = useCurrencyInput({ currency: 'EUR' })
return () => h('div', { ref: inputRef }, [h('div')])
}
})
)
const wrapper = mountComponent({ type: 'div' })
await wrapper.vm.$nextTick()

expect(CurrencyInput).not.toHaveBeenCalled()
Expand Down Expand Up @@ -126,4 +137,31 @@ describe('useCurrencyInput', () => {

expect(mocked(CurrencyInput).mock.instances[0].setOptions).toHaveBeenCalledWith({ currency: 'USD' })
})

it('should support a conditionally rendered inputRef', async () => {
const wrapper = shallowMount(
defineComponent(() => {
const { inputRef } = useCurrencyInput({ currency: 'EUR' })
const visible = ref(true)
return () =>
h('div', [
visible.value ? h('input', { ref: inputRef }) : h('div'),
h('button', {
onClick: () => {
visible.value = !visible.value
}
})
])
})
)
await wrapper.vm.$nextTick()
expect(CurrencyInput).toHaveBeenCalled()

mocked(CurrencyInput).mockClear()
await wrapper.find('button').trigger('click')
expect(CurrencyInput).not.toHaveBeenCalled()

await wrapper.find('button').trigger('click')
expect(CurrencyInput).toHaveBeenCalled()
})
})

0 comments on commit c881bdd

Please sign in to comment.