├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── config.yml
│ └── bug-report.yml
└── workflows
│ ├── ci.yml
│ ├── release.yml
│ └── docs.yml
├── docs
├── playground.md
├── public
│ └── favicon.png
├── vue-currency-input.gif
├── .vitepress
│ ├── theme
│ │ ├── index.ts
│ │ ├── style.css
│ │ └── components
│ │ │ ├── Dialog.vue
│ │ │ ├── CurrencyInput.vue
│ │ │ ├── Checkbox.vue
│ │ │ ├── OptionSection.vue
│ │ │ ├── Switch.vue
│ │ │ └── Demo.vue
│ └── config.js
├── vite.config.ts
├── windicss.config.ts
├── examples.md
├── index.md
├── components.d.ts
├── api.md
├── config.md
└── guide.md
├── .eslintignore
├── .gitignore
├── examples
└── vue2
│ ├── vue.config.js
│ ├── babel.config.js
│ ├── public
│ ├── favicon.ico
│ └── index.html
│ ├── src
│ ├── plugins
│ │ └── vuetify.js
│ ├── main.js
│ ├── components
│ │ ├── VCurrencyField.vue
│ │ └── CurrencyInput.vue
│ └── App.vue
│ ├── .gitignore
│ ├── README.md
│ └── package.json
├── src
├── index.ts
├── utils.ts
├── api.ts
├── useCurrencyInput.ts
├── inputMask.ts
├── currencyFormat.ts
└── currencyInput.ts
├── .prettierrc
├── vitest.config.ts
├── .eslintrc.js
├── tsconfig.json
├── LICENSE
├── rollup.config.js
├── README.md
├── CHANGELOG.md
├── package.json
└── tests
└── unit
├── useCurrencyInput.spec.ts
├── currencyInput.spec.ts
├── currencyFormat.spec.ts
├── inputMask.spec.ts
└── __snapshots__
└── currencyFormat.spec.ts.snap
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | ko_fi: dm4t2
2 |
--------------------------------------------------------------------------------
/docs/playground.md:
--------------------------------------------------------------------------------
1 | # Playground
2 |
3 |
4 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | !docs/.vitepress
2 | dist
3 | examples
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | .idea
4 | coverage
5 | temp
6 |
7 | yarn-error.log
8 |
--------------------------------------------------------------------------------
/examples/vue2/vue.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | transpileDependencies: ['vuetify']
3 | }
4 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './api'
2 | export { useCurrencyInput } from './useCurrencyInput'
3 |
--------------------------------------------------------------------------------
/docs/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dm4t2/vue-currency-input/HEAD/docs/public/favicon.png
--------------------------------------------------------------------------------
/examples/vue2/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: ['@vue/cli-plugin-babel/preset']
3 | }
4 |
--------------------------------------------------------------------------------
/docs/vue-currency-input.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dm4t2/vue-currency-input/HEAD/docs/vue-currency-input.gif
--------------------------------------------------------------------------------
/examples/vue2/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dm4t2/vue-currency-input/HEAD/examples/vue2/public/favicon.ico
--------------------------------------------------------------------------------
/docs/.vitepress/theme/index.ts:
--------------------------------------------------------------------------------
1 | import DefaultTheme from 'vitepress/theme'
2 | import 'virtual:windi.css'
3 | import './style.css'
4 |
5 | export default DefaultTheme
6 |
--------------------------------------------------------------------------------
/examples/vue2/src/plugins/vuetify.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import Vuetify from 'vuetify/lib/framework'
3 |
4 | Vue.use(Vuetify)
5 |
6 | export default new Vuetify({})
7 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 160,
3 | "semi": false,
4 | "singleQuote": true,
5 | "arrowParens": "always",
6 | "trailingComma": "none",
7 | "singleAttributePerLine": true
8 | }
9 |
--------------------------------------------------------------------------------
/examples/vue2/src/main.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import App from './App.vue'
3 | import vuetify from './plugins/vuetify'
4 |
5 | new Vue({
6 | vuetify,
7 | render: (h) => h(App)
8 | }).$mount('#app')
9 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config'
2 |
3 | export default defineConfig({
4 | test: {
5 | coverage: {
6 | all: true,
7 | reporter: ['lcov', 'text', 'json'],
8 | include: ['src']
9 | }
10 | }
11 | })
12 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | /* eslint-env node */
2 | require('@rushstack/eslint-patch/modern-module-resolution')
3 |
4 | module.exports = {
5 | root: true,
6 | env: {
7 | node: true
8 | },
9 | extends: ['plugin:vue/vue3-recommended', 'eslint:recommended', '@vue/eslint-config-typescript/recommended', '@vue/eslint-config-prettier']
10 | }
11 |
--------------------------------------------------------------------------------
/examples/vue2/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /dist
4 |
5 |
6 | # local env files
7 | .env.local
8 | .env.*.local
9 |
10 | # Log files
11 | npm-debug.log*
12 | yarn-debug.log*
13 | yarn-error.log*
14 | pnpm-debug.log*
15 |
16 | # Editor directories and files
17 | .idea
18 | .vscode
19 | *.suo
20 | *.ntvs*
21 | *.njsproj
22 | *.sln
23 | *.sw?
24 |
--------------------------------------------------------------------------------
/docs/.vitepress/theme/style.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: 'Roboto', sans-serif;
3 | line-height: 1.5;
4 | }
5 |
6 | h2 {
7 | @apply font-semibold mt-12 mb-4;
8 |
9 | line-height: 1.25;
10 | font-size: 1.65rem;
11 | }
12 |
13 | h3 {
14 | @apply font-semibold mt-8;
15 |
16 | font-size: 1.35rem;
17 | }
18 |
19 | .nav-bar-title {
20 | @apply inline-flex items-center;
21 | }
22 |
--------------------------------------------------------------------------------
/docs/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import Components from 'unplugin-vue-components/vite'
3 | import WindiCSS from 'vite-plugin-windicss'
4 |
5 | export default defineConfig({
6 | optimizeDeps: {
7 | exclude: ['vue-demi']
8 | },
9 | plugins: [
10 | Components({
11 | dirs: ['.vitepress/theme/components'],
12 | include: [/\.vue$/, /\.vue\?vue/, /\.md$/]
13 | }),
14 | WindiCSS()
15 | ]
16 | })
17 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 | contact_links:
3 | - name: Feature Request
4 | url: https://github.com/dm4t2/vue-currency-input/discussions/new?category=ideas
5 | about: Suggest any ideas you have using the discussion forums.
6 | - name: Help
7 | url: https://github.com/dm4t2/vue-currency-input/discussions/new?category=q-a
8 | about: If you have a question or need help, ask a question on the discussion forums.
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2018",
4 | "module": "ESNext",
5 | "lib": ["ESNext", "DOM"],
6 | "moduleResolution": "Node",
7 | "esModuleInterop": true,
8 | "strict": true,
9 | "strictNullChecks": true,
10 | "strictFunctionTypes": true,
11 | "resolveJsonModule": true,
12 | "rootDir": ".",
13 | "skipLibCheck": true,
14 | "noUnusedLocals": true,
15 | "baseUrl": "."
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/examples/vue2/README.md:
--------------------------------------------------------------------------------
1 | # vue2
2 |
3 | ## Project setup
4 |
5 | ```
6 | yarn install
7 | ```
8 |
9 | ### Compiles and hot-reloads for development
10 |
11 | ```
12 | yarn serve
13 | ```
14 |
15 | ### Compiles and minifies for production
16 |
17 | ```
18 | yarn build
19 | ```
20 |
21 | ### Lints and fixes files
22 |
23 | ```
24 | yarn lint
25 | ```
26 |
27 | ### Customize configuration
28 |
29 | See [Configuration Reference](https://cli.vuejs.org/config/).
30 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on: [ push, pull_request ]
3 | jobs:
4 | ci:
5 | runs-on: ubuntu-latest
6 | steps:
7 | - uses: actions/checkout@v4
8 | - uses: pnpm/action-setup@v4
9 | - uses: actions/setup-node@v4
10 | with:
11 | node-version: 24
12 | cache: 'pnpm'
13 | - run: pnpm install
14 | - run: pnpm lint
15 | - run: pnpm coverage
16 | - uses: codecov/codecov-action@v4
17 | with:
18 | token: ${{ secrets.CODECOV_TOKEN }}
19 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | export const escapeRegExp = (str: string): string => {
2 | return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
3 | }
4 |
5 | export const removeLeadingZeros = (str: string): string => {
6 | return str.replace(/^0+(0$|[^0])/, '$1')
7 | }
8 |
9 | export const count = (str: string, search: string): number => {
10 | return (str.match(new RegExp(escapeRegExp(search), 'g')) || []).length
11 | }
12 |
13 | export const substringBefore = (str: string, search: string): string => {
14 | return str.substring(0, str.indexOf(search))
15 | }
16 |
--------------------------------------------------------------------------------
/docs/windicss.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite-plugin-windicss'
2 |
3 | export default defineConfig({
4 | extract: {
5 | include: ['**/*.vue', '.vitepress/**/*.vue']
6 | },
7 | alias: {
8 | 'form-input': `shadow-sm rounded-md text-base transition-all disabled:(cursor-not-allowed border-gray-300 text-gray-300) focus:(border-primary ring ring-offset-0 ring-primary ring-opacity-50)`,
9 | 'form-select': 'cursor-pointer w-full py-2 px-3'
10 | },
11 | theme: {
12 | extend: {
13 | colors: {
14 | primary: '#3eaf7c'
15 | }
16 | }
17 | },
18 | plugins: [require('windicss/plugin/forms')]
19 | })
20 |
--------------------------------------------------------------------------------
/docs/.vitepress/theme/components/Dialog.vue:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
13 |
23 |
--------------------------------------------------------------------------------
/examples/vue2/src/components/VCurrencyField.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
36 |
--------------------------------------------------------------------------------
/docs/examples.md:
--------------------------------------------------------------------------------
1 | # Examples
2 |
3 | ## Vuetify 3
4 | https://stackblitz.com/edit/vue-currency-input-vuetify-3-dh7gm2
5 |
6 | ## Quasar
7 | [](https://codesandbox.io/s/vue-currency-input-integration-with-quasar-gwn46?fontsize=14&hidenavigation=1&theme=dark)
8 |
9 | ## Element Plus
10 | [](https://codesandbox.io/p/sandbox/vue-currency-input-integration-with-element-plus-devoxx)
11 |
12 | ## FormKit
13 | [](https://codesandbox.io/p/sandbox/formkit-vue-currency-input-example-mgmvyn)
14 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | home: true
3 | heroText: Vue Currency Input
4 | tagline: Easy input of currency formatted numbers for Vue.js
5 | actionText: Get Started
6 | actionLink: /guide
7 | features:
8 | - title: Built on standards
9 | details: Uses the ECMAScript Internationalization API for currency formatting.
10 | - title: Seamless migration
11 | details: Supports both Vue 3 and Vue 2.
12 | - title: Lightweight
13 | details: Tiny bundle size.
14 | - title: Format as you type
15 | details: Numbers are formatted immediately during input.
16 | - title: Distraction free
17 | details: Hides the formatting on focus for easier input.
18 | - title: Built-in validation
19 | details: Check if the input is within a valid value range.
20 | footer: MIT Licensed | Copyright © Matthias Stiller
21 | ---
22 |
--------------------------------------------------------------------------------
/examples/vue2/src/components/CurrencyInput.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
37 |
--------------------------------------------------------------------------------
/examples/vue2/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | <%= htmlWebpackPlugin.options.title %>
9 |
10 |
11 |
12 |
13 |
14 | We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/docs/components.d.ts:
--------------------------------------------------------------------------------
1 | // generated by unplugin-vue-components
2 | // We suggest you to commit this file into source control
3 | // Read more: https://github.com/vuejs/core/pull/3399
4 | import '@vue/runtime-core'
5 |
6 | export {}
7 |
8 | declare module '@vue/runtime-core' {
9 | export interface GlobalComponents {
10 | Checkbox: typeof import('./.vitepress/theme/components/Checkbox.vue')['default']
11 | CurrencyInput: typeof import('./.vitepress/theme/components/CurrencyInput.vue')['default']
12 | Demo: typeof import('./.vitepress/theme/components/Demo.vue')['default']
13 | Dialog: typeof import('./.vitepress/theme/components/Dialog.vue')['default']
14 | Embed: typeof import('./.vitepress/theme/components/Embed.vue')['default']
15 | OptionSection: typeof import('./.vitepress/theme/components/OptionSection.vue')['default']
16 | Switch: typeof import('./.vitepress/theme/components/Switch.vue')['default']
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/examples/vue2/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Simple Input
8 |
9 |
10 |
11 | Vuetify
12 |
13 |
14 |
15 |
16 |
17 | Number value: {{ value != null ? value : 'null' }}
18 |
19 |
20 |
21 |
22 |
23 |
41 |
42 |
--------------------------------------------------------------------------------
/docs/.vitepress/theme/components/CurrencyInput.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
45 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 | on:
3 | push:
4 | branches:
5 | - main
6 | permissions:
7 | id-token: write
8 | contents: read
9 | jobs:
10 | release-please:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: GoogleCloudPlatform/release-please-action@v4
14 | id: release
15 | with:
16 | token: ${{ secrets.GITHUB_TOKEN }}
17 | release-type: node
18 | include-v-in-tag: false
19 | - uses: actions/checkout@v5
20 | if: ${{ steps.release.outputs.release_created }}
21 | - uses: pnpm/action-setup@v4
22 | if: ${{ steps.release.outputs.release_created }}
23 | - uses: actions/setup-node@v6
24 | with:
25 | node-version: 24
26 | cache: 'pnpm'
27 | if: ${{ steps.release.outputs.release_created }}
28 | - run: pnpm install
29 | if: ${{ steps.release.outputs.release_created }}
30 | - run: pnpm build
31 | if: ${{ steps.release.outputs.release_created }}
32 | - run: pnpm publish
33 | if: ${{ steps.release.outputs.release_created }}
34 |
--------------------------------------------------------------------------------
/docs/.vitepress/theme/components/Checkbox.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
13 | {{ label }}
14 |
15 |
16 |
17 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) Matthias Stiller
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/docs/.vitepress/theme/components/OptionSection.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ label }}
5 |
10 |
11 |
15 | {{ description }}
16 |
17 |
18 |
19 |
20 |
21 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/docs/.vitepress/theme/components/Switch.vue:
--------------------------------------------------------------------------------
1 |
2 |
20 |
21 |
22 |
34 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import typescript from 'rollup-plugin-typescript2'
2 | import dts from 'rollup-plugin-dts'
3 | import cleanup from 'rollup-plugin-cleanup'
4 | import filesize from 'rollup-plugin-filesize'
5 | import pkg from './package.json'
6 |
7 | const banner = `/**
8 | * Vue Currency Input ${pkg.version}
9 | * (c) 2018-${new Date().getFullYear()} ${pkg.author}
10 | * @license ${pkg.license}
11 | */`
12 |
13 | export default [
14 | {
15 | input: `src/index.ts`,
16 | output: [
17 | {
18 | file: pkg.main,
19 | format: 'cjs',
20 | exports: 'named',
21 | banner
22 | },
23 | {
24 | file: pkg.module,
25 | format: 'es',
26 | exports: 'named',
27 | banner
28 | }
29 | ],
30 | plugins: [
31 | typescript({
32 | tsconfigOverride: {
33 | compilerOptions: {
34 | declaration: false
35 | }
36 | }
37 | }),
38 | cleanup({ extensions: ['js', 'ts'] }),
39 | filesize()
40 | ],
41 | external: ['vue']
42 | },
43 | {
44 | input: './temp/types/src/index.d.ts',
45 | output: [{ file: 'dist/index.d.ts', format: 'es' }],
46 | plugins: [dts()]
47 | }
48 | ]
49 |
--------------------------------------------------------------------------------
/.github/workflows/docs.yml:
--------------------------------------------------------------------------------
1 | name: Docs
2 | on:
3 | workflow_dispatch
4 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
5 | permissions:
6 | contents: read
7 | pages: write
8 | id-token: write
9 |
10 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
11 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
12 | concurrency:
13 | group: pages
14 | cancel-in-progress: false
15 |
16 | jobs:
17 | build:
18 | runs-on: ubuntu-latest
19 | steps:
20 | - uses: actions/checkout@v5
21 | - uses: pnpm/action-setup@v4
22 | - uses: actions/setup-node@v6
23 | with:
24 | node-version: 24
25 | cache: pnpm
26 | - uses: actions/configure-pages@v4
27 | - run: pnpm install
28 | - run: pnpm run docs
29 | - uses: actions/upload-pages-artifact@v3
30 | with:
31 | path: docs/.vitepress/dist
32 |
33 | deploy:
34 | environment:
35 | name: github-pages
36 | url: ${{ steps.deployment.outputs.page_url }}
37 | needs: build
38 | runs-on: ubuntu-latest
39 | steps:
40 | - id: deployment
41 | uses: actions/deploy-pages@v4
42 |
--------------------------------------------------------------------------------
/examples/vue2/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vue2",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "serve": "vue-cli-service serve",
7 | "build": "vue-cli-service build",
8 | "lint": "vue-cli-service lint"
9 | },
10 | "dependencies": {
11 | "@vue/composition-api": "^1.4.0",
12 | "core-js": "^3.6.5",
13 | "vue": "^2.6.11",
14 | "vue-currency-input": "2.2.0",
15 | "vuetify": "^2.4.0"
16 | },
17 | "devDependencies": {
18 | "@vue/cli-plugin-babel": "~4.5.0",
19 | "@vue/cli-plugin-eslint": "~4.5.0",
20 | "@vue/cli-service": "~4.5.0",
21 | "babel-eslint": "^10.1.0",
22 | "eslint": "^6.7.2",
23 | "eslint-plugin-vue": "^6.2.2",
24 | "sass": "~1.32.0",
25 | "sass-loader": "^10.0.0",
26 | "vue-cli-plugin-vuetify": "~2.4.2",
27 | "vue-template-compiler": "^2.6.11",
28 | "vuetify-loader": "^1.7.0"
29 | },
30 | "eslintConfig": {
31 | "root": true,
32 | "env": {
33 | "node": true
34 | },
35 | "extends": [
36 | "plugin:vue/essential",
37 | "eslint:recommended"
38 | ],
39 | "parserOptions": {
40 | "parser": "babel-eslint"
41 | },
42 | "rules": {}
43 | },
44 | "browserslist": [
45 | "> 1%",
46 | "last 2 versions",
47 | "not dead"
48 | ]
49 | }
50 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug-report.yml:
--------------------------------------------------------------------------------
1 | name: Bug Report
2 | description: Create a bug report for Vue Currency Input.
3 | labels: []
4 | body:
5 | - type: input
6 | attributes:
7 | label: Vue Currency Input version
8 | description: 'For example: v2.0.2'
9 | validations:
10 | required: true
11 | - type: input
12 | attributes:
13 | label: Vue version
14 | description: 'For example: v3.2.0'
15 | validations:
16 | required: true
17 | - type: input
18 | attributes:
19 | label: What browser are you using?
20 | description: 'For example: Chrome'
21 | validations:
22 | required: true
23 | - type: input
24 | attributes:
25 | label: What operating system are you using?
26 | description: 'For example: Windows'
27 | validations:
28 | required: true
29 | - type: input
30 | attributes:
31 | label: Reproduction link
32 | description: A public GitHub repo or a [CodeSandbox](https://codesandbox.io/s/vue) that includes a minimal reproduction of the bug, for example a fresh Vue CLI project without any unnecessary code.
33 | validations:
34 | required: true
35 | - type: textarea
36 | attributes:
37 | label: Describe your issue
38 | description: Describe the problem, any important steps to reproduce and what behavior is expected.
39 |
--------------------------------------------------------------------------------
/src/api.ts:
--------------------------------------------------------------------------------
1 | import { Ref } from 'vue'
2 |
3 | /**
4 | * @internal
5 | */
6 | export interface CurrencyInputValue {
7 | number: number | null
8 | formatted: string | null
9 | }
10 |
11 | export interface NumberRange {
12 | min?: number
13 | max?: number
14 | }
15 |
16 | export enum CurrencyDisplay {
17 | symbol = 'symbol',
18 | narrowSymbol = 'narrowSymbol',
19 | code = 'code',
20 | name = 'name',
21 | hidden = 'hidden'
22 | }
23 |
24 | export enum ValueScaling {
25 | precision = 'precision',
26 | thousands = 'thousands',
27 | tenThousands = 'tenThousands',
28 | millions = 'millions',
29 | billions = 'billions'
30 | }
31 |
32 | export interface CurrencyInputOptions {
33 | accountingSign?: boolean
34 | autoDecimalDigits?: boolean
35 | currency: string
36 | currencyDisplay?: CurrencyDisplay
37 | hideCurrencySymbolOnFocus?: boolean
38 | hideGroupingSeparatorOnFocus?: boolean
39 | hideNegligibleDecimalDigitsOnFocus?: boolean
40 | locale?: string
41 | precision?: NumberRange | number
42 | useGrouping?: boolean
43 | valueRange?: NumberRange
44 | valueScaling?: ValueScaling
45 | }
46 |
47 | export interface UseCurrencyInput {
48 | formattedValue: Ref
49 | inputRef: Ref
50 | numberValue: Ref
51 | setOptions: (options: CurrencyInputOptions) => void
52 | setValue: (number: number | null) => void
53 | }
54 |
--------------------------------------------------------------------------------
/docs/api.md:
--------------------------------------------------------------------------------
1 | # API
2 |
3 | ## Functions
4 |
5 | ### useCurrencyInput
6 |
7 | ```typescript
8 | declare function useCurrencyInput(options: CurrencyInputOptions, autoEmit?: boolean): UseCurrencyInput;
9 | ```
10 |
11 | ## Enums
12 |
13 | ### CurrencyDisplay
14 |
15 | ```typescript
16 | enum CurrencyDisplay {
17 | symbol = 'symbol',
18 | narrowSymbol = 'narrowSymbol',
19 | code = 'code',
20 | name = 'name',
21 | hidden = 'hidden'
22 | }
23 | ```
24 |
25 | ### ValueScaling
26 |
27 | ```typescript
28 | enum ValueScaling {
29 | precision = 'precision',
30 | thousands = 'thousands',
31 | tenThousands = 'tenThousands',
32 | millions = 'millions',
33 | billions = 'billions'
34 | }
35 | ```
36 |
37 | ## Interfaces
38 |
39 | ### NumberRange
40 |
41 | ```typescript
42 | interface NumberRange {
43 | min?: number
44 | max?: number
45 | }
46 | ```
47 |
48 | ### CurrencyInputOptions
49 |
50 | ```typescript
51 | interface CurrencyInputOptions {
52 | accountingSign?: boolean;
53 | autoDecimalDigits?: boolean;
54 | currency: string;
55 | currencyDisplay?: CurrencyDisplay;
56 | hideCurrencySymbolOnFocus?: boolean;
57 | hideGroupingSeparatorOnFocus?: boolean;
58 | hideNegligibleDecimalDigitsOnFocus?: boolean;
59 | locale?: string;
60 | precision?: NumberRange | number;
61 | useGrouping?: boolean;
62 | valueRange?: NumberRange;
63 | valueScaling?: ValueScaling;
64 | }
65 | ```
66 |
67 | ### UseCurrencyInput
68 |
69 | ```typescript
70 | interface UseCurrencyInput {
71 | formattedValue: Ref;
72 | inputRef: Ref;
73 | numberValue: Ref;
74 | setOptions: (options: CurrencyInputOptions) => void;
75 | setValue: (number: number | null) => void;
76 | }
77 | ```
78 |
--------------------------------------------------------------------------------
/docs/.vitepress/config.js:
--------------------------------------------------------------------------------
1 | import { version } from '../../package.json'
2 |
3 | export const currentVersion = `v${version}`
4 |
5 | export const versions = [
6 | { version: currentVersion },
7 | { version: 'v2.5.1', link: 'https://vue-currency-input-v2.netlify.app/' },
8 | { version: 'v1.22.6', link: 'https://vue-currency-input-v1.netlify.app/' }
9 | ]
10 |
11 | export default {
12 | title: 'Vue Currency Input',
13 | description: 'Easy input of currency formatted numbers for Vue.js',
14 | head: [['link', { rel: 'icon', href: '/vue-currency-input/favicon.png' }]],
15 | base: '/vue-currency-input/',
16 | themeConfig: {
17 | logo: '/favicon.png',
18 | nav: [
19 | {
20 | text: '❤ Sponsor',
21 | link: 'https://ko-fi.com/dm4t2'
22 | },
23 | {
24 | text: currentVersion,
25 | items: [
26 | ...versions.map((i) =>
27 | i.version === currentVersion
28 | ? {
29 | text: `${i.version} (Current)`,
30 | activeMatch: '/',
31 | link: '/'
32 | }
33 | : {
34 | text: i.version,
35 | link: i.link
36 | }
37 | )
38 | ]
39 | },
40 | {
41 | text: 'Release Notes',
42 | link: 'https://github.com/dm4t2/vue-currency-input/releases'
43 | }
44 | ],
45 | sidebar: [
46 | {
47 | text: 'Guide',
48 | link: '/guide'
49 | },
50 | {
51 | text: 'Configuration',
52 | link: '/config'
53 | },
54 | {
55 | text: 'Playground',
56 | link: '/playground'
57 | },
58 | {
59 | text: 'Examples',
60 | link: '/examples'
61 | },
62 | {
63 | text: 'API',
64 | link: '/api'
65 | }
66 | ],
67 | repo: 'dm4t2/vue-currency-input',
68 | docsDir: 'docs'
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://codecov.io/gh/dm4t2/vue-currency-input)
2 | [](https://www.npmjs.com/package/vue-currency-input)
3 | [](https://www.npmjs.com/package/vue-currency-input)
4 | [](https://bundlephobia.com/result?p=vue-currency-input)
5 | [](https://github.com/dm4t2/vue-currency-input/blob/main/LICENSE)
6 |
7 | # Vue Currency Input
8 |
9 | [](https://dm4t2.github.io/vue-currency-input)
10 |
11 | Vue Currency Input allows an easy input of currency formatted numbers based on the [ECMAScript Internationalization API (Intl.NumberFormat)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat).
12 |
13 | Built on top of the [Vue Composition API](https://v3.vuejs.org/guide/composition-api-introduction.html), it enables you to decorate _any_ input component with currency format capabilities.
14 |
15 | ## Features
16 |
17 | - Turns the input component of your favorite framework (for example [Vuetify](https://vuetifyjs.com/en/components/text-fields/), [Quasar](https://quasar.dev/vue-components/input) or [Element Plus](https://element-plus.org/en-US/component/input.html)) into a currency input field
18 | - Supports both Vue 2 _and_ Vue 3
19 | - Built on standards: Ensures the right locale dependent formatting by using [Intl.NumberFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat)
20 | - Unobtrusive input by hiding the formatting on focus
21 | - Built-in value range validation
22 |
23 | ## Getting started
24 |
25 | Please read the [guide](https://dm4t2.github.io/vue-currency-input/guide) to get started or check out the [playground](https://dm4t2.github.io/vue-currency-input/playground) to see it in action.
26 |
27 | ## Support me
28 |
29 | If you find my work helpful, or you want to support the development, star the repo or buy me a coffee:
30 |
31 | [](https://ko-fi.com/D1D6SXEA)
32 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## [3.2.1](https://github.com/dm4t2/vue-currency-input/compare/3.2.0...3.2.1) (2025-01-19)
4 |
5 | This version contains no changes and only fixes the corrupt v3.2.0 release.
6 |
7 | ## [3.2.0](https://github.com/dm4t2/vue-currency-input/compare/3.1.0...3.2.0) (2025-01-18)
8 |
9 |
10 | ### Features
11 |
12 | * Allow value scaling by 10,000 ([443321d](https://github.com/dm4t2/vue-currency-input/commit/443321d50bc8e73ed41fa3c553614012fb951745))
13 |
14 | ## [3.1.0](https://github.com/dm4t2/vue-currency-input/compare/3.0.5...3.1.0) (2024-02-07)
15 |
16 |
17 | ### Features
18 |
19 | * Support Chinese dot as decimal separator ([b4ecd1a](https://github.com/dm4t2/vue-currency-input/commit/b4ecd1a1bbd7440036157f4271e831266d656e5b))
20 |
21 | ## [3.0.5](https://github.com/dm4t2/vue-currency-input/compare/3.0.4...3.0.5) (2023-05-26)
22 |
23 |
24 | ### Bug Fixes
25 |
26 | * only emit change events if `autoEmit` is enabled (closes [#383](https://github.com/dm4t2/vue-currency-input/issues/383)) ([2cba481](https://github.com/dm4t2/vue-currency-input/commit/2cba481d247c80619f21a58f0994dee830c7e248))
27 |
28 | ## [3.0.4](https://github.com/dm4t2/vue-currency-input/compare/3.0.3...3.0.4) (2023-03-30)
29 |
30 |
31 | ### Bug Fixes
32 |
33 | * preserve an existing "inputmode" attribute on the input element (closes [#372](https://github.com/dm4t2/vue-currency-input/issues/372)) ([e9fb330](https://github.com/dm4t2/vue-currency-input/commit/e9fb330d29bbdbde11c1b0b4d8036b692dbbcbaa))
34 | * use blur event for lazy value binding (closes [#322](https://github.com/dm4t2/vue-currency-input/issues/322)) ([eaeb864](https://github.com/dm4t2/vue-currency-input/commit/eaeb8640629036f62a59f42ffbbd6ad996c491a0))
35 |
36 | ## [3.0.3](https://github.com/dm4t2/vue-currency-input/compare/3.0.2...3.0.3) (2022-12-03)
37 |
38 |
39 | ### Bug Fixes
40 |
41 | * change default value of option `useGrouping` ([#296](https://github.com/dm4t2/vue-currency-input/issues/296)) ([a37963e](https://github.com/dm4t2/vue-currency-input/commit/a37963ec4dcf42b528bf2e4aec628745f4513bb7))
42 |
43 | ## [3.0.2](https://github.com/dm4t2/vue-currency-input/compare/3.0.1...3.0.2) (2022-10-30)
44 |
45 |
46 | ### Bug Fixes
47 |
48 | * **build:** fix ESM export ([04b512c](https://github.com/dm4t2/vue-currency-input/commit/04b512c23c525704297125c99a1aa94cb553a8b1))
49 |
50 | ## [3.0.1](https://github.com/dm4t2/vue-currency-input/compare/3.0.0...3.0.1) (2022-10-02)
51 |
52 |
53 | ### Bug Fixes
54 |
55 | * fix cursor jumps with input component of Quasar/Element Plus ([59b8b40](https://github.com/dm4t2/vue-currency-input/commit/59b8b405211c0fa0f337b118ab8d46001f030da6))
56 |
--------------------------------------------------------------------------------
/src/useCurrencyInput.ts:
--------------------------------------------------------------------------------
1 | import { CurrencyInput } from './currencyInput'
2 | import { ComponentPublicInstance, computed, ComputedRef, getCurrentInstance, Ref, ref, version, watch } from 'vue'
3 | import { CurrencyInputOptions, CurrencyInputValue, UseCurrencyInput } from './api'
4 |
5 | const findInput = (el: HTMLElement | null) => (el?.matches('input') ? el : el?.querySelector('input')) as HTMLInputElement
6 |
7 | export function useCurrencyInput(options: CurrencyInputOptions, autoEmit?: boolean): UseCurrencyInput {
8 | let currencyInput: CurrencyInput | null
9 | const inputRef: Ref = ref(null)
10 | const formattedValue = ref(null)
11 | const numberValue = ref(null)
12 |
13 | const vm = getCurrentInstance()
14 | const emit = vm?.emit || vm?.proxy?.$emit?.bind(vm?.proxy)
15 | const props = (vm?.props || vm?.proxy?.$props) as Record
16 | const isVue3 = version.startsWith('3')
17 | const lazyModel = isVue3 && (vm?.attrs.modelModifiers as Record)?.lazy
18 | const modelValue: ComputedRef = computed(() => props?.[isVue3 ? 'modelValue' : 'value'] as number)
19 | const inputEvent = isVue3 ? 'update:modelValue' : 'input'
20 | const changeEvent = lazyModel ? 'update:modelValue' : 'change'
21 |
22 | watch(inputRef, (value) => {
23 | if (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 | if (autoEmit !== false) {
38 | emit?.(changeEvent, value.number)
39 | }
40 | }
41 | })
42 | currencyInput.setValue(modelValue.value)
43 | } else {
44 | console.error('No input element found. Please make sure that the "inputRef" template ref is properly assigned.')
45 | }
46 | } else {
47 | currencyInput = null
48 | }
49 | })
50 |
51 | return {
52 | inputRef,
53 | numberValue,
54 | formattedValue,
55 | setValue: (value: number | null) => currencyInput?.setValue(value),
56 | setOptions: (options: CurrencyInputOptions) => currencyInput?.setOptions(options)
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/docs/config.md:
--------------------------------------------------------------------------------
1 | # Configuration
2 |
3 | The following options can be passed as an object literal to the `useCurrencyInput` function.
4 |
5 | ### currency
6 |
7 | A [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) currency code, for example `"USD"` or `"EUR"`. This option is **required**.
8 |
9 | ### locale
10 |
11 | A [BCP 47](https://tools.ietf.org/html/bcp47) language tag (for example `"en"` or `"de-DE"`). Default is `undefined` (use the default locale of the Browser).
12 |
13 | ### currencyDisplay
14 |
15 | How to display the currency in currency formatting. Possible values are:
16 |
17 | - `"symbol"` to use a localized currency symbol such as "€" (default value)
18 | - `"narrowSymbol"` to use a narrow format symbol ("$100" rather than "US$100")
19 | - `"code"` to use the ISO currency code
20 | - `"name"` to use a localized currency name such as "dollar"
21 | - `"hidden"` to hide the currency
22 |
23 | ### accountingSign
24 |
25 | Whether to use accounting sign formatting, for example wrapping negative values with parentheses instead of prepending a minus sign.
26 |
27 | ### autoDecimalDigits
28 |
29 | Whether the decimal symbol is inserted automatically, using the last inputted digits as decimal digits. Default is `false` (the decimal symbol needs to be inserted manually).
30 |
31 | ### precision
32 |
33 | The number of displayed decimal digits. Default is `undefined` (use the currency's default, decimal digits will be hidden for integer numbers). Must be between 0 and 15 and can only be applied for currencies that support decimal digits.
34 | You can also pass an object `{min, max}` to use a precision range.
35 |
36 | ### hideCurrencySymbolOnFocus
37 |
38 | Whether to hide the currency symbol on focus. Default is `true`.
39 |
40 | ### hideGroupingSeparatorOnFocus
41 |
42 | Whether to hide the grouping separator on focus. Default is `true`.
43 |
44 | ### hideNegligibleDecimalDigitsOnFocus
45 |
46 | Whether to hide negligible decimal digits on focus. Default is `true`.
47 |
48 | ### valueScaling
49 |
50 | Applies a scaling to the exported value. Possible values are:
51 |
52 | - `"precision"` for scaling float values automatically to integers depending on the current `precision`, for example 1.23 -> 123
53 | - `"thousands"` for using a scaling factor of 1,000
54 | - `"tenThousands"` for using a scaling factor of 10,000
55 | - `"millions""` for using scaling factor of 1,000,000
56 | - `"billions"` for using a scaling factor of 1,000,000,000
57 |
58 | ### valueRange
59 |
60 | The range of accepted values as object `{min, max}`. Default is `undefined` (no value range). The validation is triggered on blur and automatically sets the respective threshold if out of range.
61 |
62 | ### useGrouping
63 |
64 | Whether to use grouping separators such as thousands/lakh/crore separators.
65 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vue-currency-input",
3 | "description": "Easy input of currency formatted numbers for Vue.js.",
4 | "version": "3.2.1",
5 | "license": "MIT",
6 | "module": "./dist/index.mjs",
7 | "main": "./dist/index.cjs",
8 | "types": "./dist/index.d.ts",
9 | "files": [
10 | "dist"
11 | ],
12 | "exports": {
13 | ".": {
14 | "import": "./dist/index.mjs",
15 | "require": "./dist/index.cjs",
16 | "types": "./dist/index.d.ts"
17 | }
18 | },
19 | "sideeffects": false,
20 | "author": "Matthias Stiller",
21 | "repository": {
22 | "type": "git",
23 | "url": "https://github.com/dm4t2/vue-currency-input.git"
24 | },
25 | "homepage": "https://dm4t2.github.io/vue-currency-input",
26 | "keywords": [
27 | "vue",
28 | "input mask",
29 | "currency input",
30 | "money input",
31 | "number format",
32 | "ECMA-402"
33 | ],
34 | "scripts": {
35 | "dev": "vitepress dev docs",
36 | "docs": "vitepress build docs",
37 | "test": "vitest",
38 | "coverage": "vitest run --coverage",
39 | "lint": "eslint --no-fix --max-warnings 0 \"{**/*,*}.{js,ts}\"",
40 | "prebuild": "rimraf dist && rimraf temp",
41 | "build": "tsc --emitDeclarationOnly --declaration --outDir temp/types && rollup -c rollup.config.js"
42 | },
43 | "peerDependencies": {
44 | "vue": "^2.7 || ^3.0.0"
45 | },
46 | "devDependencies": {
47 | "@rushstack/eslint-patch": "^1.10.3",
48 | "@testing-library/dom": "^8.20.1",
49 | "@testing-library/user-event": "^13.5.0",
50 | "@typescript-eslint/eslint-plugin": "^5.62.0",
51 | "@typescript-eslint/parser": "^5.62.0",
52 | "@vitest/coverage-v8": "^0.34.6",
53 | "@vue/eslint-config-prettier": "^7.1.0",
54 | "@vue/eslint-config-typescript": "^11.0.3",
55 | "@vue/test-utils": "^2.4.6",
56 | "eslint": "^8.57.0",
57 | "eslint-config-prettier": "^8.10.0",
58 | "eslint-plugin-vue": "^9.26.0",
59 | "jsdom": "^20.0.3",
60 | "lint-staged": "^13.3.0",
61 | "prettier": "^2.8.8",
62 | "rimraf": "^5.0.7",
63 | "rollup": "^2.79.1",
64 | "rollup-plugin-cleanup": "^3.2.1",
65 | "rollup-plugin-dts": "^4.2.3",
66 | "rollup-plugin-filesize": "^10.0.0",
67 | "rollup-plugin-typescript2": "^0.34.1",
68 | "simple-git-hooks": "^2.11.1",
69 | "typescript": "^4.9.5",
70 | "unplugin-vue-components": "^0.22.12",
71 | "vite": "^2.9.18",
72 | "vite-plugin-windicss": "^1.9.3",
73 | "vitepress": "^0.22.4",
74 | "vitest": "^0.34.6",
75 | "vue": "^3.4.27",
76 | "windicss": "^3.5.6"
77 | },
78 | "simple-git-hooks": {
79 | "pre-commit": "npx lint-staged"
80 | },
81 | "lint-staged": {
82 | "{**/*,*}.{js,ts,vue}": [
83 | "eslint --fix"
84 | ]
85 | },
86 | "packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501"
87 | }
88 |
--------------------------------------------------------------------------------
/src/inputMask.ts:
--------------------------------------------------------------------------------
1 | import { removeLeadingZeros } from './utils'
2 | import CurrencyFormat from './currencyFormat'
3 |
4 | abstract class AbstractInputMask {
5 | protected currencyFormat: CurrencyFormat
6 |
7 | constructor(currencyFormat: CurrencyFormat) {
8 | this.currencyFormat = currencyFormat
9 | }
10 | }
11 |
12 | export interface InputMask {
13 | conformToMask(str: string, previousConformedValue: string): string | { fractionDigits: string; numberValue: number }
14 | }
15 |
16 | export class DefaultInputMask extends AbstractInputMask implements InputMask {
17 | conformToMask(str: string, previousConformedValue = ''): string | { fractionDigits: string; numberValue: number } {
18 | const negative = this.currencyFormat.isNegative(str)
19 | const isEmptyNegativeValue = (str: string) =>
20 | str === '' &&
21 | negative &&
22 | !(this.currencyFormat.minusSign === undefined
23 | ? previousConformedValue === this.currencyFormat.negativePrefix + this.currencyFormat.negativeSuffix
24 | : previousConformedValue === this.currencyFormat.negativePrefix)
25 |
26 | const checkIncompleteValue = (str: string) => {
27 | if (isEmptyNegativeValue(str)) {
28 | return ''
29 | } else if (this.currencyFormat.maximumFractionDigits > 0) {
30 | if (this.currencyFormat.isFractionIncomplete(str)) {
31 | return str
32 | } else if (str.startsWith(this.currencyFormat.decimalSymbol as string)) {
33 | return this.currencyFormat.toFraction(str)
34 | }
35 | }
36 | return null
37 | }
38 |
39 | let value = str
40 | value = this.currencyFormat.stripCurrency(value, negative)
41 | value = this.currencyFormat.stripSignLiterals(value)
42 |
43 | const incompleteValue = checkIncompleteValue(value)
44 | if (incompleteValue != null) {
45 | return this.currencyFormat.insertCurrency(incompleteValue, negative)
46 | }
47 |
48 | const [integer, ...fraction] = value.split(this.currencyFormat.decimalSymbol as string)
49 | const integerDigits = removeLeadingZeros(this.currencyFormat.onlyDigits(integer))
50 | const fractionDigits = this.currencyFormat.onlyDigits(fraction.join('')).substring(0, this.currencyFormat.maximumFractionDigits)
51 | const invalidFraction = fraction.length > 0 && fractionDigits.length === 0
52 |
53 | const invalidNegativeValue =
54 | integerDigits === '' &&
55 | negative &&
56 | (this.currencyFormat.minusSign === undefined
57 | ? previousConformedValue === str.slice(0, -2) + this.currencyFormat.negativeSuffix
58 | : previousConformedValue === str.slice(0, -1))
59 |
60 | if (invalidFraction || invalidNegativeValue || isEmptyNegativeValue(integerDigits)) {
61 | return previousConformedValue
62 | } else if (integerDigits.match(/\d+/)) {
63 | return {
64 | numberValue: Number(`${negative ? '-' : ''}${integerDigits}.${fractionDigits}`),
65 | fractionDigits
66 | }
67 | } else {
68 | return ''
69 | }
70 | }
71 | }
72 |
73 | export class AutoDecimalDigitsInputMask extends AbstractInputMask implements InputMask {
74 | conformToMask(str: string, previousConformedValue = ''): string | { fractionDigits: string; numberValue: number } {
75 | if (
76 | str === '' ||
77 | (this.currencyFormat.parse(previousConformedValue) === 0 &&
78 | this.currencyFormat.stripCurrency(previousConformedValue, true).slice(0, -1) === this.currencyFormat.stripCurrency(str, true))
79 | ) {
80 | return ''
81 | }
82 | const negative = this.currencyFormat.isNegative(str)
83 | const numberValue =
84 | this.currencyFormat.stripSignLiterals(str) === ''
85 | ? -0
86 | : Number(`${negative ? '-' : ''}${removeLeadingZeros(this.currencyFormat.onlyDigits(str))}`) / Math.pow(10, this.currencyFormat.maximumFractionDigits)
87 | return {
88 | numberValue,
89 | fractionDigits: numberValue.toFixed(this.currencyFormat.maximumFractionDigits).slice(-this.currencyFormat.maximumFractionDigits)
90 | }
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/docs/guide.md:
--------------------------------------------------------------------------------
1 | # Guide
2 |
3 | ## Introduction
4 |
5 | Vue Currency Input allows an easy input of currency formatted numbers based on the [ECMAScript Internationalization API (Intl.NumberFormat)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat).
6 |
7 | Built on top of the [Vue Composition API](https://v3.vuejs.org/guide/composition-api-introduction.html), it provides the composable function `useCurrencyInput` for decorating input components with currency format capabilities.
8 |
9 | ::: warning Vue Compatibility
10 | Vue Currency Input 3.x requires either **Vue 2.7** or **Vue 3**.
11 | For Vue 2.6 or earlier, please use [Vue Currency Input 2.x](https://vue-currency-input-v2.netlify.app/).
12 |
13 | **Nuxt 2 + Vue 2.7** is not supported. See [this issue](https://github.com/dm4t2/vue-currency-input/issues/398) for more information.
14 | :::
15 |
16 | ## Installation
17 |
18 | ```bash
19 | npm install vue-currency-input
20 | ```
21 |
22 | ## Usage
23 |
24 | Vue Currency Input does not provide a ready-to-use component, instead it enables you to create your own based on your favorite input component (for example [Quasar](examples#quasar) or [Element Plus](examples#element-plus)).
25 |
26 | ::: info Code examples
27 | The following code examples are for Vue 3. Deviations for Vue 2 are noted as inline code comments.
28 | :::
29 |
30 | ### Creating a custom component
31 |
32 | The following example component `` uses a simple HTML input element.
33 |
34 | The component must provide props for the `v-model` value binding and the options (see [Configuration](config)). Make also sure, that the input element has type `text` (or omit the type since it's the default).
35 |
36 | ```vue
37 |
38 |
42 |
43 |
44 |
60 | ```
61 |
62 | ### Use the custom component
63 |
64 | Now you can use the created `` component in your app:
65 |
66 | ```vue
67 |
68 |
72 |
73 |
74 |
83 | ```
84 |
85 | [](https://codesandbox.io/s/vue-currency-input-vue-3-example-5l51f?fontsize=14&hidenavigation=1&theme=dark)
86 |
87 | ## Auto emit
88 |
89 | By default, the number value is automatically emitted on each input.
90 | This can be disabled by setting the `autoEmit` argument of `useCurrencyInput` to `false`, allowing you to implement a custom emit behavior for features such as debouncing.
91 |
92 | The following example component `` demonstrates this by using [VueUse's `watchDebounced`](https://vueuse.org/shared/watchDebounced):
93 |
94 | ```vue
95 |
96 |
100 |
101 |
102 |
121 | ```
122 |
123 | [](https://codesandbox.io/s/using-vue-currency-input-with-debouncing-vzwnss?fontsize=14&hidenavigation=1&theme=dark)
124 |
125 | ## Lazy value binding
126 |
127 | 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:
128 |
129 | ```vue
130 |
131 | ```
132 |
133 | 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:
134 |
135 | ```vue
136 |
137 | ```
138 |
139 | ## External props changes
140 |
141 | If the value of the input is changed externally (and not only by user input) you need to use the `setValue` function returned by `useCurrencyInput` within a watcher.
142 |
143 | The same applies for the options of your currency input component. Use the `setOptions` function in a watcher in order to make the options reactive for changes after the component has been mounted (like in the [Playground](playground)).
144 |
145 | ```vue
146 |
147 |
148 |
149 |
150 |
181 | ```
182 |
--------------------------------------------------------------------------------
/tests/unit/useCurrencyInput.spec.ts:
--------------------------------------------------------------------------------
1 | // @vitest-environment jsdom
2 | /* eslint-disable vue/one-component-per-file */
3 | import { defineComponent, h, ref, VNode } from 'vue'
4 | import { useCurrencyInput } from '../../src'
5 | import { mount, shallowMount } from '@vue/test-utils'
6 | import { CurrencyInput } from '../../src/currencyInput'
7 | import { beforeEach, describe, expect, it, vi } from 'vitest'
8 |
9 | vi.mock('../../src/currencyInput')
10 |
11 | const mountComponent = (
12 | { type, children, autoEmit } = <
13 | {
14 | type: string
15 | children?: VNode[]
16 | autoEmit?: boolean
17 | }
18 | >{
19 | type: 'div',
20 | children: [h('input')],
21 | autoEmit: true
22 | }
23 | ) =>
24 | shallowMount(
25 | defineComponent({
26 | setup: () => {
27 | const { inputRef } = useCurrencyInput({ currency: 'EUR' }, autoEmit)
28 | return () => h(type, { ref: inputRef }, children)
29 | }
30 | })
31 | )
32 |
33 | describe('useCurrencyInput', () => {
34 | beforeEach(() => {
35 | vi.clearAllMocks()
36 | })
37 |
38 | it('should emit the new value on input', async () => {
39 | const wrapper = mountComponent()
40 | await wrapper.vm.$nextTick()
41 |
42 | vi.mocked(CurrencyInput).mock.calls[0][0].onInput({ number: 10, formatted: 'EUR 10' })
43 |
44 | expect(wrapper.emitted('update:modelValue')).toEqual([[10]])
45 | })
46 |
47 | it('should not emit new values on input if autoEmit is false', async () => {
48 | const wrapper = mountComponent({ type: 'input', autoEmit: false })
49 | await wrapper.vm.$nextTick()
50 |
51 | vi.mocked(CurrencyInput).mock.calls[0][0].onInput({ number: 10, formatted: 'EUR 10' })
52 |
53 | expect(wrapper.emitted('update:modelValue')).toBeUndefined()
54 | })
55 |
56 | it('should emit the new value on change', async () => {
57 | const wrapper = mountComponent()
58 | await wrapper.vm.$nextTick()
59 |
60 | vi.mocked(CurrencyInput).mock.calls[0][0].onChange({ number: 10, formatted: 'EUR 10' })
61 |
62 | expect(wrapper.emitted('change')).toEqual([[10]])
63 | })
64 |
65 | it('should not emit new values on change if autoEmit is false', async () => {
66 | const wrapper = mountComponent({ type: 'input', autoEmit: false })
67 | await wrapper.vm.$nextTick()
68 |
69 | vi.mocked(CurrencyInput).mock.calls[0][0].onChange({ number: 10, formatted: 'EUR 10' })
70 |
71 | expect(wrapper.emitted('change')).toBeUndefined()
72 | })
73 |
74 | it('should skip the CurrencyInput instantiation if no input element can be found', async () => {
75 | vi.spyOn(console, 'error')
76 | vi.clearAllMocks()
77 | const wrapper = mountComponent({ type: 'div' })
78 | await wrapper.vm.$nextTick()
79 |
80 | expect(CurrencyInput).not.toHaveBeenCalled()
81 | expect(console.error).toHaveBeenCalled()
82 | })
83 |
84 | it('should accept a input element as template ref', async () => {
85 | const wrapper = shallowMount(
86 | defineComponent({
87 | setup: () => useCurrencyInput({ currency: 'EUR' }),
88 | render: () => h('input', { ref: 'inputRef' })
89 | })
90 | )
91 | await wrapper.vm.$nextTick()
92 | expect(CurrencyInput).toHaveBeenCalledWith(expect.objectContaining({ el: wrapper.find('input').element }))
93 | })
94 |
95 | it('should accept custom input components as template ref', async () => {
96 | const wrapper = defineComponent({
97 | render: () => h('div', [h('input')])
98 | })
99 | const currencyInput = mount(
100 | defineComponent({
101 | setup: () => useCurrencyInput({ currency: 'EUR' }),
102 | render: () => h(wrapper, { ref: 'inputRef' })
103 | })
104 | )
105 | await currencyInput.vm.$nextTick()
106 |
107 | expect(CurrencyInput).toHaveBeenCalledWith(expect.objectContaining({ el: currencyInput.find('input').element }))
108 | })
109 |
110 | it('should allow to update the value', async () => {
111 | const wrapper = shallowMount(
112 | defineComponent(() => {
113 | const { setValue, inputRef } = useCurrencyInput({ currency: 'EUR' })
114 | return () =>
115 | h('div', { ref: inputRef }, [
116 | h('input'),
117 | h('button', {
118 | onClick: () => {
119 | setValue(1234)
120 | }
121 | })
122 | ])
123 | })
124 | )
125 | await wrapper.vm.$nextTick()
126 |
127 | wrapper.find('button').trigger('click')
128 |
129 | expect(vi.mocked(CurrencyInput).mock.instances[0].setValue).toHaveBeenCalledWith(1234)
130 | })
131 |
132 | it('should allow to update the options', async () => {
133 | const wrapper = shallowMount(
134 | defineComponent(() => {
135 | const { setOptions, inputRef } = useCurrencyInput({ currency: 'EUR' })
136 | return () =>
137 | h('div', { ref: inputRef }, [
138 | h('input'),
139 | h('button', {
140 | onClick: () => {
141 | setOptions({ currency: 'USD' })
142 | }
143 | })
144 | ])
145 | })
146 | )
147 | await wrapper.vm.$nextTick()
148 |
149 | wrapper.find('button').trigger('click')
150 |
151 | expect(vi.mocked(CurrencyInput).mock.instances[0].setOptions).toHaveBeenCalledWith({ currency: 'USD' })
152 | })
153 |
154 | it('should support a conditionally rendered inputRef', async () => {
155 | const wrapper = shallowMount(
156 | defineComponent(() => {
157 | const { inputRef } = useCurrencyInput({ currency: 'EUR' })
158 | const visible = ref(true)
159 | return () =>
160 | h('div', [
161 | visible.value ? h('input', { ref: inputRef }) : h('div'),
162 | h('button', {
163 | onClick: () => {
164 | visible.value = !visible.value
165 | }
166 | })
167 | ])
168 | })
169 | )
170 | await wrapper.vm.$nextTick()
171 | expect(CurrencyInput).toHaveBeenCalled()
172 |
173 | vi.mocked(CurrencyInput).mockClear()
174 | await wrapper.find('button').trigger('click')
175 | expect(CurrencyInput).not.toHaveBeenCalled()
176 |
177 | await wrapper.find('button').trigger('click')
178 | expect(CurrencyInput).toHaveBeenCalled()
179 | })
180 | })
181 |
--------------------------------------------------------------------------------
/src/currencyFormat.ts:
--------------------------------------------------------------------------------
1 | import { escapeRegExp, substringBefore } from './utils'
2 | import { CurrencyDisplay, CurrencyInputOptions } from './api'
3 |
4 | export const DECIMAL_SEPARATORS = [
5 | ',', // comma
6 | '.', // dot
7 | '٫', // Persian Momayyez
8 | '。' // Chinese dot
9 | ]
10 | export const INTEGER_PATTERN = '(0|[1-9]\\d*)'
11 |
12 | export default class CurrencyFormat {
13 | options: Intl.NumberFormatOptions
14 | locale?: string
15 | currency?: string
16 | digits: string[]
17 | decimalSymbol: string | undefined
18 | groupingSymbol: string | undefined
19 | minusSign: string | undefined
20 | minimumFractionDigits: number
21 | maximumFractionDigits: number
22 | prefix: string
23 | negativePrefix: string
24 | suffix: string
25 | negativeSuffix: string
26 |
27 | constructor(options: CurrencyInputOptions) {
28 | const { currency, currencyDisplay, locale, precision, accountingSign, useGrouping } = options
29 | this.locale = locale
30 | this.options = {
31 | currency,
32 | useGrouping,
33 | style: 'currency',
34 | currencySign: accountingSign ? 'accounting' : undefined,
35 | currencyDisplay: currencyDisplay !== CurrencyDisplay.hidden ? currencyDisplay : undefined
36 | }
37 | const numberFormat = new Intl.NumberFormat(locale, this.options)
38 | const formatParts = numberFormat.formatToParts(123456)
39 |
40 | this.currency = formatParts.find(({ type }) => type === 'currency')?.value
41 | this.digits = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => i.toLocaleString(locale))
42 | this.decimalSymbol = formatParts.find(({ type }) => type === 'decimal')?.value
43 | this.groupingSymbol = formatParts.find(({ type }) => type === 'group')?.value
44 | this.minusSign = numberFormat.formatToParts(-1).find(({ type }) => type === 'minusSign')?.value
45 |
46 | if (this.decimalSymbol === undefined) {
47 | this.minimumFractionDigits = this.maximumFractionDigits = 0
48 | } else if (typeof precision === 'number') {
49 | this.minimumFractionDigits = this.maximumFractionDigits = precision
50 | } else {
51 | this.minimumFractionDigits = precision?.min ?? numberFormat.resolvedOptions().minimumFractionDigits
52 | this.maximumFractionDigits = precision?.max ?? numberFormat.resolvedOptions().maximumFractionDigits
53 | }
54 |
55 | const getPrefix = (str: string) => {
56 | return substringBefore(str, this.digits[1])
57 | }
58 | const getSuffix = (str: string) => {
59 | return str.substring(str.lastIndexOf(this.decimalSymbol ? this.digits[0] : this.digits[1]) + 1)
60 | }
61 |
62 | this.prefix = getPrefix(numberFormat.format(1))
63 | this.suffix = getSuffix(numberFormat.format(1))
64 | this.negativePrefix = getPrefix(numberFormat.format(-1))
65 | this.negativeSuffix = getSuffix(numberFormat.format(-1))
66 | }
67 |
68 | parse(str: string | null): number | null {
69 | if (str) {
70 | const negative = this.isNegative(str)
71 | str = this.normalizeDigits(str)
72 | str = this.stripCurrency(str, negative)
73 | str = this.stripSignLiterals(str)
74 | const fraction = this.decimalSymbol ? `(?:${escapeRegExp(this.decimalSymbol)}(\\d*))?` : ''
75 | const match = this.stripGroupingSeparator(str).match(new RegExp(`^${INTEGER_PATTERN}${fraction}$`))
76 | if (match && this.isValidIntegerFormat(this.decimalSymbol ? str.split(this.decimalSymbol)[0] : str, Number(match[1]))) {
77 | return Number(`${negative ? '-' : ''}${this.onlyDigits(match[1])}.${this.onlyDigits(match[2] || '')}`)
78 | }
79 | }
80 | return null
81 | }
82 |
83 | isValidIntegerFormat(formattedNumber: string, integerNumber: number): boolean {
84 | const options = { ...this.options, minimumFractionDigits: 0 }
85 | return [
86 | this.stripCurrency(this.normalizeDigits(integerNumber.toLocaleString(this.locale, { ...options, useGrouping: true })), false),
87 | this.stripCurrency(this.normalizeDigits(integerNumber.toLocaleString(this.locale, { ...options, useGrouping: false })), false)
88 | ].includes(formattedNumber)
89 | }
90 |
91 | format(
92 | value: number | null,
93 | options: Intl.NumberFormatOptions = {
94 | minimumFractionDigits: this.minimumFractionDigits,
95 | maximumFractionDigits: this.maximumFractionDigits
96 | }
97 | ): string {
98 | return value != null ? value.toLocaleString(this.locale, { ...this.options, ...options }) : ''
99 | }
100 |
101 | toFraction(str: string): string {
102 | return `${this.digits[0]}${this.decimalSymbol}${this.onlyLocaleDigits(str.substring(1)).substring(0, this.maximumFractionDigits)}`
103 | }
104 |
105 | isFractionIncomplete(str: string): boolean {
106 | return !!this.normalizeDigits(this.stripGroupingSeparator(str)).match(new RegExp(`^${INTEGER_PATTERN}${escapeRegExp(this.decimalSymbol as string)}$`))
107 | }
108 |
109 | isNegative(str: string): boolean {
110 | return (
111 | str.startsWith(this.negativePrefix) ||
112 | (this.minusSign === undefined && (str.startsWith('(') || str.startsWith('-'))) ||
113 | (this.minusSign !== undefined && str.replace('-', this.minusSign).startsWith(this.minusSign))
114 | )
115 | }
116 |
117 | insertCurrency(str: string, negative: boolean): string {
118 | return `${negative ? this.negativePrefix : this.prefix}${str}${negative ? this.negativeSuffix : this.suffix}`
119 | }
120 |
121 | stripGroupingSeparator(str: string): string {
122 | return this.groupingSymbol !== undefined ? str.replace(new RegExp(escapeRegExp(this.groupingSymbol), 'g'), '') : str
123 | }
124 |
125 | stripSignLiterals(str: string): string {
126 | if (this.minusSign !== undefined) {
127 | return str.replace('-', this.minusSign).replace(this.minusSign, '')
128 | } else {
129 | return str.replace(/[-()]/g, '')
130 | }
131 | }
132 |
133 | stripCurrency(str: string, negative: boolean): string {
134 | return str.replace(negative ? this.negativePrefix : this.prefix, '').replace(negative ? this.negativeSuffix : this.suffix, '')
135 | }
136 |
137 | normalizeDecimalSeparator(str: string, from: number): string {
138 | DECIMAL_SEPARATORS.forEach((s) => {
139 | str = str.substring(0, from) + str.substring(from).replace(s, this.decimalSymbol as string)
140 | })
141 | return str
142 | }
143 |
144 | normalizeDigits(str: string): string {
145 | if (this.digits[0] !== '0') {
146 | this.digits.forEach((digit, index) => {
147 | str = str.replace(new RegExp(digit, 'g'), String(index))
148 | })
149 | }
150 | return str
151 | }
152 |
153 | onlyDigits(str: string): string {
154 | return this.normalizeDigits(str).replace(/\D+/g, '')
155 | }
156 |
157 | onlyLocaleDigits(str: string): string {
158 | return str.replace(new RegExp(`[^${this.digits.join('')}]*`, 'g'), '')
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/tests/unit/currencyInput.spec.ts:
--------------------------------------------------------------------------------
1 | // @vitest-environment jsdom
2 | import { CurrencyInput } from '../../src/currencyInput'
3 | import { fireEvent } from '@testing-library/dom'
4 | import userEvent from '@testing-library/user-event'
5 | import { CurrencyDisplay, CurrencyInputOptions, ValueScaling } from '../../src'
6 | import { describe, expect, it, beforeEach, vi } from 'vitest'
7 |
8 | describe('Currency Input', () => {
9 | let el: HTMLInputElement, currencyInput: CurrencyInput, options: CurrencyInputOptions
10 |
11 | beforeEach(() => {
12 | document.body.innerHTML = ` `
13 | el = document.querySelector('input') as HTMLInputElement
14 | options = {
15 | locale: 'en',
16 | currency: 'EUR'
17 | }
18 | currencyInput = new CurrencyInput({
19 | el,
20 | options,
21 | onInput: vi.fn(),
22 | onChange: vi.fn()
23 | })
24 | })
25 |
26 | describe('init', () => {
27 | it('should preserve an existing "inputmode" attribute on the input element', () => {
28 | document.body.innerHTML = ` `
29 | el = document.querySelector('input') as HTMLInputElement
30 | options = {
31 | locale: 'en',
32 | currency: 'EUR',
33 | autoDecimalDigits: true
34 | }
35 | currencyInput = new CurrencyInput({
36 | el,
37 | options,
38 | onInput: vi.fn(),
39 | onChange: vi.fn()
40 | })
41 | expect(el.getAttribute('inputmode')).toBe('text')
42 | })
43 | })
44 |
45 | describe('setValue', () => {
46 | it('should update the input value', () => {
47 | currencyInput.setValue(1)
48 |
49 | expect(el.value).toBe('€1')
50 | })
51 |
52 | it('should consider the value scaling', () => {
53 | currencyInput.setOptions({ ...options, valueScaling: ValueScaling.precision })
54 | currencyInput.setValue(1)
55 | expect(el.value).toBe('€0.01')
56 |
57 | currencyInput.setOptions({ ...options, valueScaling: ValueScaling.thousands })
58 | currencyInput.setValue(1234)
59 | expect(el.value).toBe('€1.23')
60 | })
61 | })
62 |
63 | describe('on input', () => {
64 | it('should update the input value', () => {
65 | fireEvent.input(el, { target: { value: '1234' } })
66 |
67 | expect(el.value).toBe('€1,234')
68 | })
69 |
70 | describe('caret position', () => {
71 | it('should retain the caret position if the last number before whitespace thousands separator is deleted', () => {
72 | currencyInput.setValue(1500000)
73 | currencyInput.setOptions({ currency: 'SEK', locale: 'sv-SV', hideGroupingSeparatorOnFocus: false })
74 |
75 | expect(el.value).toBe('1 500 000 kr')
76 | el.setSelectionRange(0, 1)
77 |
78 | userEvent.type(el, '{del}')
79 |
80 | expect(el.value).toBe('500 000')
81 | expect(el.selectionStart).toBe(0)
82 | })
83 | })
84 | })
85 |
86 | describe('on focus', () => {
87 | beforeEach(() => {
88 | vi.useFakeTimers()
89 | })
90 |
91 | it('should update the input value', () => {
92 | currencyInput.setValue(12345.6)
93 | expect(el.value).toBe('€12,345.60')
94 | userEvent.click(el)
95 | vi.runOnlyPendingTimers()
96 | expect(el.value).toBe('12345.6')
97 | })
98 |
99 | describe('caret position', () => {
100 | const expectCaretPosition = (given: number, expected: number) => {
101 | el.setSelectionRange(given, given)
102 | userEvent.click(el)
103 | vi.runOnlyPendingTimers()
104 |
105 | expect(el.selectionStart).toBe(expected)
106 | }
107 |
108 | it('should not modify the caret position for empty values', () => {
109 | currencyInput.setValue(null)
110 | currencyInput.setOptions({ locale: 'en', currency: 'EUR' })
111 | expectCaretPosition(0, 0)
112 | })
113 |
114 | describe('hideCurrencySymbolOnFocus is true', () => {
115 | /**
116 | * -€1|,234 -> -1|234
117 | */
118 | it('should consider the sign for the new caret position', () => {
119 | currencyInput.setValue(-1234)
120 | currencyInput.setOptions({ locale: 'en', currency: 'EUR', hideCurrencySymbolOnFocus: true })
121 | expectCaretPosition(3, 2)
122 | })
123 |
124 | /**
125 | * 1|,234 -> 1|234
126 | */
127 | it('should not modify the caret position if currencyDisplay is hidden', () => {
128 | currencyInput.setValue(1234)
129 | currencyInput.setOptions({ locale: 'en', currency: 'EUR', hideCurrencySymbolOnFocus: true, currencyDisplay: CurrencyDisplay.hidden })
130 | expectCaretPosition(1, 1)
131 | })
132 |
133 | /**
134 | * |-€1 -> -|1
135 | */
136 | it('should set the caret position in front of the first digit when targeting the sign', () => {
137 | currencyInput.setValue(-1)
138 | currencyInput.setOptions({ locale: 'en', currency: 'EUR', hideCurrencySymbolOnFocus: true })
139 | expectCaretPosition(0, 1)
140 | })
141 |
142 | /**
143 | * -|€1 -> -|1
144 | */
145 | it('should set the caret position in front of the first digit when targeting the currency', () => {
146 | currencyInput.setValue(-1)
147 | currencyInput.setOptions({ locale: 'en', currency: 'EUR', hideCurrencySymbolOnFocus: true })
148 | expectCaretPosition(1, 1)
149 | })
150 |
151 | /**
152 | * (€ 5)| -> (5|)
153 | */
154 | it('should set the caret position after the last digit number when targeting the closing parentheses of the accounting sign', () => {
155 | currencyInput.setValue(-5)
156 | currencyInput.setOptions({ locale: 'nl', currency: 'EUR', hideCurrencySymbolOnFocus: true, accountingSign: true })
157 | expectCaretPosition(el.value.length, 2)
158 | })
159 | })
160 |
161 | describe('hideCurrencySymbolOnFocus is false', () => {
162 | /**
163 | * |€1,234 -> €|1234
164 | */
165 | it('should set the caret position in front of the first number when targeting the prefix', () => {
166 | currencyInput.setValue(1234)
167 | currencyInput.setOptions({ locale: 'en', currency: 'EUR', hideCurrencySymbolOnFocus: false })
168 | expectCaretPosition(0, 1)
169 | })
170 |
171 | /**
172 | * 1.234 €| -> 1234| €
173 | */
174 | it('should set the caret position after the last number when targeting the suffix', () => {
175 | currencyInput.setValue(1234)
176 | currencyInput.setOptions({ locale: 'de', currency: 'EUR', hideCurrencySymbolOnFocus: false })
177 | expectCaretPosition(el.value.length, 4)
178 | })
179 | })
180 |
181 | describe('currencyDisplay is hidden', () => {
182 | /**
183 | * (1)| -> (1|)
184 | */
185 | it('should set the caret position after the last digit number when targeting the closing parentheses of the accounting sign', () => {
186 | currencyInput.setValue(-1)
187 | currencyInput.setOptions({ locale: 'en', currency: 'EUR', currencyDisplay: CurrencyDisplay.hidden, accountingSign: true })
188 | expectCaretPosition(el.value.length, 2)
189 | })
190 |
191 | /**
192 | * 1,234| -> 1234|
193 | */
194 | it('should ignore the prefix for the new caret position', () => {
195 | currencyInput.setValue(1234)
196 | currencyInput.setOptions({ locale: 'en', currency: 'EUR', currencyDisplay: CurrencyDisplay.hidden })
197 | expectCaretPosition(el.value.length, 4)
198 | })
199 | })
200 | })
201 | })
202 | })
203 |
--------------------------------------------------------------------------------
/tests/unit/currencyFormat.spec.ts:
--------------------------------------------------------------------------------
1 | import CurrencyFormat from '../../src/currencyFormat'
2 | import { CurrencyDisplay } from '../../src'
3 | import { describe, expect, it } from 'vitest'
4 |
5 | describe('CurrencyFormat', () => {
6 | describe('constructing number formats', () => {
7 | it('should work for respective locale and currency', () => {
8 | expect(new CurrencyFormat({ locale: 'de-DE', currency: 'EUR' })).toMatchSnapshot('de-DE_EUR')
9 | expect(new CurrencyFormat({ locale: 'de-CH', currency: 'EUR' })).toMatchSnapshot('de-CH_EUR')
10 | expect(new CurrencyFormat({ locale: 'es-ES', currency: 'EUR' })).toMatchSnapshot('es-ES_EUR')
11 | expect(new CurrencyFormat({ locale: 'nl-NL', currency: 'EUR' })).toMatchSnapshot('nl-NL_EUR')
12 | expect(new CurrencyFormat({ locale: 'en-US', currency: 'USD' })).toMatchSnapshot('en-US_USD')
13 | expect(new CurrencyFormat({ locale: 'fr-CH', currency: 'CHF' })).toMatchSnapshot('fr-CH_CHF')
14 | expect(new CurrencyFormat({ locale: 'zh', currency: 'CNY' })).toMatchSnapshot('zh_CNY')
15 | expect(new CurrencyFormat({ locale: 'en-GB', currency: 'GBP' })).toMatchSnapshot('en-GB_GBP')
16 | expect(new CurrencyFormat({ locale: 'en-GB', currency: 'INR' })).toMatchSnapshot('en-IN_INR')
17 | expect(new CurrencyFormat({ locale: 'pt', currency: 'BRL' })).toMatchSnapshot('pt_BRL')
18 | expect(new CurrencyFormat({ locale: 'ja', currency: 'JPY' })).toMatchSnapshot('ja_JPY')
19 | expect(new CurrencyFormat({ locale: 'ar-SA', currency: 'SAR' })).toMatchSnapshot('ar-SA_SAR')
20 | expect(new CurrencyFormat({ locale: 'fa-IR', currency: 'IRR' })).toMatchSnapshot('fa-IR_IRR')
21 | expect(new CurrencyFormat({ locale: 'bg-BG', currency: 'BGN' })).toMatchSnapshot('bg-BG_BGN')
22 | })
23 |
24 | describe('custom precision', () => {
25 | it('should work with a custom precision', () => {
26 | expect(new CurrencyFormat({ currency: 'EUR', precision: 0 })).toEqual(expect.objectContaining({ minimumFractionDigits: 0, maximumFractionDigits: 0 }))
27 | })
28 |
29 | it('should work with a custom precision range', () => {
30 | expect(new CurrencyFormat({ currency: 'EUR', precision: { min: 0, max: 4 } })).toEqual(
31 | expect.objectContaining({ minimumFractionDigits: 0, maximumFractionDigits: 4 })
32 | )
33 | })
34 |
35 | it('should ignore the custom precision if the locale does not support decimal digits', () => {
36 | expect(new CurrencyFormat({ locale: 'ja', currency: 'JPY', precision: 2 })).toEqual(
37 | expect.objectContaining({ minimumFractionDigits: 0, maximumFractionDigits: 0 })
38 | )
39 | })
40 | })
41 | })
42 |
43 | describe('parse', () => {
44 | it('should return null if the value is empty', () => {
45 | expect(new CurrencyFormat({ locale: 'en', currency: 'EUR' }).parse(null)).toBeNull()
46 | expect(new CurrencyFormat({ locale: 'en', currency: 'EUR' }).parse('')).toBeNull()
47 | expect(new CurrencyFormat({ locale: 'en', currency: 'EUR' }).parse(' ')).toBeNull()
48 | })
49 |
50 | it('should return null if the value is invalid', () => {
51 | expect(new CurrencyFormat({ currency: 'EUR', locale: 'en' }).parse('-')).toBeNull()
52 | expect(new CurrencyFormat({ currency: 'EUR', locale: 'en' }).parse('123e-1')).toBeNull()
53 | expect(new CurrencyFormat({ currency: 'EUR', locale: 'en' }).parse('0x11')).toBeNull()
54 | expect(new CurrencyFormat({ currency: 'EUR', locale: 'en' }).parse('0b11')).toBeNull()
55 | expect(new CurrencyFormat({ currency: 'EUR', locale: 'en' }).parse('0o11')).toBeNull()
56 | expect(new CurrencyFormat({ currency: 'EUR', locale: 'en' }).parse('1.2e1')).toBeNull()
57 | expect(new CurrencyFormat({ currency: 'EUR', locale: 'en' }).parse('1.23.4')).toBeNull()
58 | })
59 |
60 | it('should return the parsed number if the value conforms to the currency format', () => {
61 | expect(new CurrencyFormat({ locale: 'en', currency: 'EUR' }).parse('1234')).toBe(1234)
62 | expect(new CurrencyFormat({ locale: 'en', currency: 'EUR' }).parse('1,234,567')).toBe(1234567)
63 | expect(new CurrencyFormat({ locale: 'en', currency: 'EUR' }).parse('-1,234,567')).toBe(-1234567)
64 | expect(new CurrencyFormat({ locale: 'de', currency: 'EUR' }).parse('-1234567,89')).toBe(-1234567.89)
65 | expect(new CurrencyFormat({ locale: 'en', currency: 'USD' }).parse('$1,234,567')).toBe(1234567)
66 | expect(new CurrencyFormat({ locale: 'de', currency: 'EUR' }).parse('1234 €')).toBe(1234)
67 | expect(new CurrencyFormat({ locale: 'en', currency: 'EUR' }).parse('-1234')).toBe(-1234)
68 | expect(new CurrencyFormat({ locale: 'en', currency: 'USD' }).parse('-$1234')).toBe(-1234)
69 | expect(new CurrencyFormat({ locale: 'de', currency: 'EUR' }).parse('-1234 €')).toBe(-1234)
70 | expect(new CurrencyFormat({ locale: 'ja', currency: 'JPY' }).parse('¥123,456')).toBe(123456)
71 | expect(new CurrencyFormat({ locale: 'en', currency: 'EUR' }).parse('0.5')).toBe(0.5)
72 | expect(new CurrencyFormat({ locale: 'en', currency: 'EUR' }).parse('1234.50')).toBe(1234.5)
73 | expect(new CurrencyFormat({ locale: 'en', currency: 'EUR' }).parse('1234.00')).toBe(1234)
74 | expect(new CurrencyFormat({ locale: 'en', currency: 'USD' }).parse('$1,234.50')).toBe(1234.5)
75 | expect(new CurrencyFormat({ locale: 'de', currency: 'EUR' }).parse('1.234,50 €')).toBe(1234.5)
76 | // TODO: fix parsing of numbers in Arabic
77 | // expect(new CurrencyFormat({ locale: 'ar', currency: 'SAR' }).parse('١٢٣٤')).toBe(1234)
78 | // expect(new CurrencyFormat({ locale: 'ar', currency: 'SAR' }).parse('١٬٢٣٤')).toBe(1234)
79 | // expect(new CurrencyFormat({ locale: 'ar', currency: 'SAR' }).parse('٠٫٩')).toBe(0.9)
80 | // eslint-disable-next-line no-irregular-whitespace
81 | // expect(new CurrencyFormat({ locale: 'ar', currency: 'SAR' }).parse('-٠٫٥٠ ر.س.')).toBe(-0.5)
82 | expect(new CurrencyFormat({ locale: 'en-IN', currency: 'INR' }).parse('₹1,23,334.00')).toBe(123334)
83 | expect(new CurrencyFormat({ locale: 'en-IN', currency: 'INR' }).parse('₹123334.00')).toBe(123334)
84 | expect(new CurrencyFormat({ locale: 'de-AT', currency: 'EUR' }).parse('€ 66.668')).toBe(66668)
85 | expect(new CurrencyFormat({ locale: 'de-DE', currency: 'USD', currencyDisplay: CurrencyDisplay.name }).parse('1.234,50 US-Dollar')).toBe(1234.5)
86 | expect(new CurrencyFormat({ locale: 'en', currency: 'USD', accountingSign: true }).parse('(1,234.50)')).toBe(-1234.5)
87 | })
88 |
89 | it('should return null if the value does not conform to the currency format', () => {
90 | expect(new CurrencyFormat({ locale: 'en', currency: 'EUR' }).parse('1234,5')).toBeNull()
91 | expect(new CurrencyFormat({ locale: 'de', currency: 'EUR' }).parse('1,234,567.89')).toBeNull()
92 | expect(new CurrencyFormat({ locale: 'de', currency: 'EUR' }).parse('$1234')).toBeNull()
93 | expect(new CurrencyFormat({ locale: 'en', currency: 'USD' }).parse('1234 €')).toBeNull()
94 | expect(new CurrencyFormat({ locale: 'ja', currency: 'JPY' }).parse('1234.56')).toBeNull()
95 | })
96 | })
97 |
98 | describe('isFractionIncomplete', () => {
99 | it('should return true if the fraction is incomplete', () => {
100 | expect(new CurrencyFormat({ locale: 'en', currency: 'EUR' }).isFractionIncomplete('1234.')).toBe(true)
101 | expect(new CurrencyFormat({ locale: 'en', currency: 'EUR' }).isFractionIncomplete('1234')).toBe(false)
102 | expect(new CurrencyFormat({ locale: 'en', currency: 'EUR' }).isFractionIncomplete('1234.3')).toBe(false)
103 | expect(new CurrencyFormat({ locale: 'en', currency: 'EUR' }).isFractionIncomplete('1234.3.')).toBe(false)
104 | expect(new CurrencyFormat({ locale: 'de', currency: 'EUR' }).isFractionIncomplete('1,3,')).toBe(false)
105 | })
106 | })
107 |
108 | describe('normalizeDecimalSeparator', () => {
109 | it('should replace the first decimal separator with the one of the current locale', () => {
110 | expect(new CurrencyFormat({ locale: 'en', currency: 'EUR' }).normalizeDecimalSeparator('1,23,4,567', 2)).toBe('1,23.4,567')
111 | expect(new CurrencyFormat({ locale: 'en', currency: 'EUR' }).normalizeDecimalSeparator('1。23', 0)).toBe('1.23')
112 | })
113 | })
114 |
115 | describe('format', () => {
116 | it('should return the formatted value for the respective options', () => {
117 | expect(new CurrencyFormat({ locale: 'en', currency: 'EUR' }).format(null)).toBe('')
118 | expect(new CurrencyFormat({ locale: 'en', currency: 'EUR' }).format(1234.5789)).toBe('€1,234.58')
119 | expect(new CurrencyFormat({ locale: 'en', currency: 'EUR' }).format(1234.5789, { minimumFractionDigits: 4 })).toBe('€1,234.5789')
120 | })
121 |
122 | it('should apply the custom percision range', () => {
123 | expect(new CurrencyFormat({ locale: 'en', currency: 'EUR', precision: { min: 0, max: 4 } }).format(1234)).toBe('€1,234')
124 | expect(new CurrencyFormat({ locale: 'en', currency: 'EUR', precision: { min: 0, max: 4 } }).format(1234.5)).toBe('€1,234.5')
125 | expect(new CurrencyFormat({ locale: 'en', currency: 'EUR', precision: { min: 0, max: 4 } }).format(1234.57)).toBe('€1,234.57')
126 | expect(new CurrencyFormat({ locale: 'en', currency: 'EUR', precision: { min: 0, max: 4 } }).format(1234.578)).toBe('€1,234.578')
127 | expect(new CurrencyFormat({ locale: 'en', currency: 'EUR', precision: { min: 0, max: 4 } }).format(1234.5789)).toBe('€1,234.5789')
128 | expect(new CurrencyFormat({ locale: 'en', currency: 'EUR', precision: { min: 0, max: 4 } }).format(1234.57891)).toBe('€1,234.5789')
129 | })
130 | })
131 |
132 | describe('isNegative', () => {
133 | it('should check if a formatted value is negative or not', () => {
134 | expect(new CurrencyFormat({ locale: 'en', currency: 'USD', accountingSign: true }).isNegative('($1)')).toBe(true)
135 | expect(new CurrencyFormat({ locale: 'en', currency: 'USD', accountingSign: true }).isNegative('(1)')).toBe(true)
136 | expect(new CurrencyFormat({ locale: 'en', currency: 'USD', accountingSign: true }).isNegative('-1')).toBe(true)
137 | expect(new CurrencyFormat({ locale: 'sv', currency: 'EUR' }).isNegative('-1.')).toBe(true)
138 | })
139 | })
140 | })
141 |
--------------------------------------------------------------------------------
/tests/unit/inputMask.spec.ts:
--------------------------------------------------------------------------------
1 | import CurrencyFormat from '../../src/currencyFormat'
2 | import { AutoDecimalDigitsInputMask, DefaultInputMask } from '../../src/inputMask'
3 | import { describe, expect, it } from 'vitest'
4 |
5 | describe('DefaultInputMask', () => {
6 | describe('when the value is invalid', () => {
7 | it('should return an empty value', () => {
8 | const currencyFormat = new CurrencyFormat({ locale: 'en', currency: 'USD' })
9 |
10 | expect(new DefaultInputMask(currencyFormat).conformToMask('', '$1')).toEqual('')
11 | expect(new DefaultInputMask(currencyFormat).conformToMask(' ', '$1')).toEqual('')
12 | expect(new DefaultInputMask(currencyFormat).conformToMask('foo', '$1')).toEqual('')
13 | expect(new DefaultInputMask(currencyFormat).conformToMask('$', '$1')).toEqual('')
14 | expect(new DefaultInputMask(currencyFormat).conformToMask('$a', '$1')).toEqual('')
15 | })
16 | })
17 |
18 | describe('when the fraction is invalid', () => {
19 | it('should return the previous conformed value', () => {
20 | const currencyFormat = new CurrencyFormat({ locale: 'en', currency: 'USD' })
21 |
22 | expect(new DefaultInputMask(currencyFormat).conformToMask('1..', '$1')).toEqual('$1')
23 | expect(new DefaultInputMask(currencyFormat).conformToMask('1.a', '$1')).toEqual('$1')
24 | })
25 | })
26 |
27 | describe('when a invalid negative value is about to being entered', () => {
28 | describe('the currency symbol is prefixed', () => {
29 | it('should return the previous conformed value', () => {
30 | const currencyFormat = new CurrencyFormat({ locale: 'en', currency: 'USD' })
31 |
32 | expect(new DefaultInputMask(currencyFormat).conformToMask('-$a', '-$')).toEqual('-$')
33 | expect(new DefaultInputMask(currencyFormat).conformToMask('-$-', '-$')).toEqual('-$')
34 | expect(new DefaultInputMask(new CurrencyFormat({ locale: 'en', currency: 'USD', accountingSign: true })).conformToMask('($a)', '($)')).toEqual('($)')
35 | })
36 | })
37 |
38 | describe('the currency symbol is suffixed', () => {
39 | it('should return the previous conformed value', () => {
40 | const currencyFormat = new CurrencyFormat({ locale: 'de', currency: 'USD' })
41 |
42 | expect(new DefaultInputMask(currencyFormat).conformToMask('-a $', '- $')).toEqual('- $')
43 | expect(new DefaultInputMask(currencyFormat).conformToMask('-- $', '- $')).toEqual('- $')
44 | })
45 | })
46 | })
47 |
48 | describe('when the value is negative and the prefixed currency symbol is deleted', () => {
49 | it('should return an empty value', () => {
50 | expect(new DefaultInputMask(new CurrencyFormat({ locale: 'en', currency: 'USD' })).conformToMask('-', '-$')).toEqual('')
51 | expect(new DefaultInputMask(new CurrencyFormat({ locale: 'en', currency: 'USD', accountingSign: true })).conformToMask('()', '($)')).toEqual('')
52 | })
53 | })
54 |
55 | describe('when the value is incomplete', () => {
56 | // TODO: fix parsing of negative numbers in Arabic
57 | // describe('the digits are locale dependent', () => {
58 | // it('should return the expected value', () => {
59 | // const currencyFormat = new CurrencyFormat({ locale: 'ar-SA', currency: 'USD' })
60 | // expect(new DefaultInputMask(currencyFormat).conformToMask('٫١١')).toEqual('...')
61 | // })
62 | // })
63 |
64 | describe('the currency symbol is prefixed', () => {
65 | it('should return the expected value', () => {
66 | const currencyFormat = new CurrencyFormat({ locale: 'en', currency: 'USD' })
67 |
68 | expect(new DefaultInputMask(currencyFormat).conformToMask('-')).toEqual('-$')
69 | expect(new DefaultInputMask(currencyFormat).conformToMask('-$', '-$5')).toEqual('-$')
70 | expect(new DefaultInputMask(currencyFormat).conformToMask('-', '-$')).toEqual('')
71 | expect(new DefaultInputMask(currencyFormat).conformToMask('1.')).toEqual('$1.')
72 | expect(new DefaultInputMask(currencyFormat).conformToMask('1234.')).toEqual('$1234.')
73 | expect(new DefaultInputMask(currencyFormat).conformToMask('1,234.')).toEqual('$1,234.')
74 | expect(new DefaultInputMask(currencyFormat).conformToMask('-1.')).toEqual('-$1.')
75 | expect(new DefaultInputMask(currencyFormat).conformToMask('-1234.')).toEqual('-$1234.')
76 | expect(new DefaultInputMask(currencyFormat).conformToMask('-1,234.')).toEqual('-$1,234.')
77 | expect(new DefaultInputMask(currencyFormat).conformToMask('.')).toEqual('$0.')
78 | expect(new DefaultInputMask(currencyFormat).conformToMask('.1')).toEqual('$0.1')
79 | expect(new DefaultInputMask(currencyFormat).conformToMask('.1.234')).toEqual('$0.12')
80 | expect(new DefaultInputMask(currencyFormat).conformToMask('-.')).toEqual('-$0.')
81 | expect(new DefaultInputMask(currencyFormat).conformToMask('-.1')).toEqual('-$0.1')
82 | expect(new DefaultInputMask(currencyFormat).conformToMask('-.1.234')).toEqual('-$0.12')
83 | })
84 | })
85 |
86 | describe('the currency symbol is suffixed', () => {
87 | it('should return the expected value', () => {
88 | const currencyFormat = new CurrencyFormat({ locale: 'de', currency: 'USD' })
89 |
90 | expect(new DefaultInputMask(currencyFormat).conformToMask('-')).toEqual('- $')
91 | expect(new DefaultInputMask(currencyFormat).conformToMask('1,')).toEqual('1, $')
92 | expect(new DefaultInputMask(currencyFormat).conformToMask('1234,')).toEqual('1234, $')
93 | expect(new DefaultInputMask(currencyFormat).conformToMask('1.234,')).toEqual('1.234, $')
94 | expect(new DefaultInputMask(currencyFormat).conformToMask('-1,')).toEqual('-1, $')
95 | expect(new DefaultInputMask(currencyFormat).conformToMask('-1234,')).toEqual('-1234, $')
96 | expect(new DefaultInputMask(currencyFormat).conformToMask('-1.234,')).toEqual('-1.234, $')
97 | expect(new DefaultInputMask(currencyFormat).conformToMask(',')).toEqual('0, $')
98 | expect(new DefaultInputMask(currencyFormat).conformToMask(',1')).toEqual('0,1 $')
99 | expect(new DefaultInputMask(currencyFormat).conformToMask(',1,234')).toEqual('0,12 $')
100 | expect(new DefaultInputMask(currencyFormat).conformToMask('-,')).toEqual('-0, $')
101 | expect(new DefaultInputMask(currencyFormat).conformToMask('-,1')).toEqual('-0,1 $')
102 | expect(new DefaultInputMask(currencyFormat).conformToMask('-,1,234')).toEqual('-0,12 $')
103 | })
104 | })
105 |
106 | describe('no decimal digits are allowed', () => {
107 | it('should return the expected value', () => {
108 | const currencyFormat = new CurrencyFormat({ locale: 'ja', currency: 'JPY' })
109 |
110 | expect(new DefaultInputMask(currencyFormat).conformToMask('1.')).toEqual({ numberValue: 1, fractionDigits: '' })
111 | expect(new DefaultInputMask(currencyFormat).conformToMask('1234.')).toEqual({ numberValue: 1234, fractionDigits: '' })
112 | expect(new DefaultInputMask(currencyFormat).conformToMask('1,234.')).toEqual({ numberValue: 1234, fractionDigits: '' })
113 | expect(new DefaultInputMask(currencyFormat).conformToMask('-1.')).toEqual({ numberValue: -1, fractionDigits: '' })
114 | expect(new DefaultInputMask(currencyFormat).conformToMask('-1234.')).toEqual({ numberValue: -1234, fractionDigits: '' })
115 | expect(new DefaultInputMask(currencyFormat).conformToMask('-1,234.')).toEqual({ numberValue: -1234, fractionDigits: '' })
116 | expect(new DefaultInputMask(currencyFormat).conformToMask('.')).toEqual('')
117 | expect(new DefaultInputMask(currencyFormat).conformToMask('.1')).toEqual({ numberValue: 1, fractionDigits: '' })
118 | expect(new DefaultInputMask(currencyFormat).conformToMask('.1.234')).toEqual({ numberValue: 1234, fractionDigits: '' })
119 | expect(new DefaultInputMask(currencyFormat).conformToMask('-.')).toEqual('')
120 | expect(new DefaultInputMask(currencyFormat).conformToMask('-.1')).toEqual({ numberValue: -1, fractionDigits: '' })
121 | expect(new DefaultInputMask(currencyFormat).conformToMask('-.1.234')).toEqual({ numberValue: -1234, fractionDigits: '' })
122 | })
123 | })
124 | })
125 |
126 | describe('when the value conforms to the mask', () => {
127 | it('should return the expected result', () => {
128 | const currencyFormat = new CurrencyFormat({ locale: 'de', currency: 'USD', precision: 4 })
129 |
130 | expect(new DefaultInputMask(currencyFormat).conformToMask('1')).toEqual({ numberValue: 1, fractionDigits: '' })
131 | expect(new DefaultInputMask(currencyFormat).conformToMask('-1')).toEqual({ numberValue: -1, fractionDigits: '' })
132 | expect(new DefaultInputMask(currencyFormat).conformToMask('1,2')).toEqual({ numberValue: 1.2, fractionDigits: '2' })
133 | expect(new DefaultInputMask(currencyFormat).conformToMask('1,232323')).toEqual({ numberValue: 1.2323, fractionDigits: '2323' })
134 | expect(new DefaultInputMask(currencyFormat).conformToMask('0')).toEqual({ numberValue: 0, fractionDigits: '' })
135 | expect(new DefaultInputMask(currencyFormat).conformToMask('-0')).toEqual({ numberValue: -0, fractionDigits: '' })
136 | expect(new DefaultInputMask(currencyFormat).conformToMask('-0,5')).toEqual({ numberValue: -0.5, fractionDigits: '5' })
137 | expect(new DefaultInputMask(currencyFormat).conformToMask('1.000')).toEqual({ numberValue: 1000, fractionDigits: '' })
138 | })
139 | })
140 |
141 | describe('when the negative/positive prefixes have different white spaces', () => {
142 | it('should return the expected result', () => {
143 | expect(new DefaultInputMask(new CurrencyFormat({ locale: 'de-CH', currency: 'USD' })).conformToMask('$-123.45')).toEqual({
144 | numberValue: -123.45,
145 | fractionDigits: '45'
146 | })
147 | expect(new DefaultInputMask(new CurrencyFormat({ locale: 'de-CH', currency: 'CHF' })).conformToMask('CHF-123.45')).toEqual({
148 | numberValue: -123.45,
149 | fractionDigits: '45'
150 | })
151 | })
152 | })
153 | })
154 |
155 | describe('AutoDecimalDigitsInputMask', () => {
156 | it('should return the expected result', () => {
157 | const currencyFormat = new CurrencyFormat({ locale: 'nl', currency: 'EUR' })
158 |
159 | expect(new AutoDecimalDigitsInputMask(currencyFormat).conformToMask('')).toEqual('')
160 | expect(new AutoDecimalDigitsInputMask(currencyFormat).conformToMask('0,0', '0,00')).toEqual('')
161 | expect(new AutoDecimalDigitsInputMask(currencyFormat).conformToMask('-')).toEqual({ numberValue: -0, fractionDigits: '00' })
162 | expect(new AutoDecimalDigitsInputMask(currencyFormat).conformToMask('1')).toEqual({ numberValue: 0.01, fractionDigits: '01' })
163 | expect(new AutoDecimalDigitsInputMask(currencyFormat).conformToMask('12345')).toEqual({ numberValue: 123.45, fractionDigits: '45' })
164 | expect(new AutoDecimalDigitsInputMask(currencyFormat).conformToMask('-12345')).toEqual({ numberValue: -123.45, fractionDigits: '45' })
165 | expect(new AutoDecimalDigitsInputMask(currencyFormat).conformToMask('€ -12345')).toEqual({ numberValue: -123.45, fractionDigits: '45' })
166 | expect(new AutoDecimalDigitsInputMask(currencyFormat).conformToMask('00012345')).toEqual({ numberValue: 123.45, fractionDigits: '45' })
167 | })
168 | })
169 |
--------------------------------------------------------------------------------
/tests/unit/__snapshots__/currencyFormat.spec.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`CurrencyFormat > constructing number formats > should work for respective locale and currency > ar-SA_SAR 1`] = `
4 | CurrencyFormat {
5 | "currency": "ر.س.",
6 | "decimalSymbol": "٫",
7 | "digits": [
8 | "٠",
9 | "١",
10 | "٢",
11 | "٣",
12 | "٤",
13 | "٥",
14 | "٦",
15 | "٧",
16 | "٨",
17 | "٩",
18 | ],
19 | "groupingSymbol": "٬",
20 | "locale": "ar-SA",
21 | "maximumFractionDigits": 2,
22 | "minimumFractionDigits": 2,
23 | "minusSign": "-",
24 | "negativePrefix": "-",
25 | "negativeSuffix": " ر.س.",
26 | "options": {
27 | "currency": "SAR",
28 | "currencyDisplay": undefined,
29 | "currencySign": undefined,
30 | "style": "currency",
31 | "useGrouping": undefined,
32 | },
33 | "prefix": "",
34 | "suffix": " ر.س.",
35 | }
36 | `;
37 |
38 | exports[`CurrencyFormat > constructing number formats > should work for respective locale and currency > bg-BG_BGN 1`] = `
39 | CurrencyFormat {
40 | "currency": "лв.",
41 | "decimalSymbol": ",",
42 | "digits": [
43 | "0",
44 | "1",
45 | "2",
46 | "3",
47 | "4",
48 | "5",
49 | "6",
50 | "7",
51 | "8",
52 | "9",
53 | ],
54 | "groupingSymbol": " ",
55 | "locale": "bg-BG",
56 | "maximumFractionDigits": 2,
57 | "minimumFractionDigits": 2,
58 | "minusSign": "-",
59 | "negativePrefix": "-",
60 | "negativeSuffix": " лв.",
61 | "options": {
62 | "currency": "BGN",
63 | "currencyDisplay": undefined,
64 | "currencySign": undefined,
65 | "style": "currency",
66 | "useGrouping": undefined,
67 | },
68 | "prefix": "",
69 | "suffix": " лв.",
70 | }
71 | `;
72 |
73 | exports[`CurrencyFormat > constructing number formats > should work for respective locale and currency > de-CH_EUR 1`] = `
74 | CurrencyFormat {
75 | "currency": "EUR",
76 | "decimalSymbol": ".",
77 | "digits": [
78 | "0",
79 | "1",
80 | "2",
81 | "3",
82 | "4",
83 | "5",
84 | "6",
85 | "7",
86 | "8",
87 | "9",
88 | ],
89 | "groupingSymbol": "’",
90 | "locale": "de-CH",
91 | "maximumFractionDigits": 2,
92 | "minimumFractionDigits": 2,
93 | "minusSign": "-",
94 | "negativePrefix": "EUR-",
95 | "negativeSuffix": "",
96 | "options": {
97 | "currency": "EUR",
98 | "currencyDisplay": undefined,
99 | "currencySign": undefined,
100 | "style": "currency",
101 | "useGrouping": undefined,
102 | },
103 | "prefix": "EUR ",
104 | "suffix": "",
105 | }
106 | `;
107 |
108 | exports[`CurrencyFormat > constructing number formats > should work for respective locale and currency > de-DE_EUR 1`] = `
109 | CurrencyFormat {
110 | "currency": "€",
111 | "decimalSymbol": ",",
112 | "digits": [
113 | "0",
114 | "1",
115 | "2",
116 | "3",
117 | "4",
118 | "5",
119 | "6",
120 | "7",
121 | "8",
122 | "9",
123 | ],
124 | "groupingSymbol": ".",
125 | "locale": "de-DE",
126 | "maximumFractionDigits": 2,
127 | "minimumFractionDigits": 2,
128 | "minusSign": "-",
129 | "negativePrefix": "-",
130 | "negativeSuffix": " €",
131 | "options": {
132 | "currency": "EUR",
133 | "currencyDisplay": undefined,
134 | "currencySign": undefined,
135 | "style": "currency",
136 | "useGrouping": undefined,
137 | },
138 | "prefix": "",
139 | "suffix": " €",
140 | }
141 | `;
142 |
143 | exports[`CurrencyFormat > constructing number formats > should work for respective locale and currency > en-GB_GBP 1`] = `
144 | CurrencyFormat {
145 | "currency": "£",
146 | "decimalSymbol": ".",
147 | "digits": [
148 | "0",
149 | "1",
150 | "2",
151 | "3",
152 | "4",
153 | "5",
154 | "6",
155 | "7",
156 | "8",
157 | "9",
158 | ],
159 | "groupingSymbol": ",",
160 | "locale": "en-GB",
161 | "maximumFractionDigits": 2,
162 | "minimumFractionDigits": 2,
163 | "minusSign": "-",
164 | "negativePrefix": "-£",
165 | "negativeSuffix": "",
166 | "options": {
167 | "currency": "GBP",
168 | "currencyDisplay": undefined,
169 | "currencySign": undefined,
170 | "style": "currency",
171 | "useGrouping": undefined,
172 | },
173 | "prefix": "£",
174 | "suffix": "",
175 | }
176 | `;
177 |
178 | exports[`CurrencyFormat > constructing number formats > should work for respective locale and currency > en-IN_INR 1`] = `
179 | CurrencyFormat {
180 | "currency": "₹",
181 | "decimalSymbol": ".",
182 | "digits": [
183 | "0",
184 | "1",
185 | "2",
186 | "3",
187 | "4",
188 | "5",
189 | "6",
190 | "7",
191 | "8",
192 | "9",
193 | ],
194 | "groupingSymbol": ",",
195 | "locale": "en-GB",
196 | "maximumFractionDigits": 2,
197 | "minimumFractionDigits": 2,
198 | "minusSign": "-",
199 | "negativePrefix": "-₹",
200 | "negativeSuffix": "",
201 | "options": {
202 | "currency": "INR",
203 | "currencyDisplay": undefined,
204 | "currencySign": undefined,
205 | "style": "currency",
206 | "useGrouping": undefined,
207 | },
208 | "prefix": "₹",
209 | "suffix": "",
210 | }
211 | `;
212 |
213 | exports[`CurrencyFormat > constructing number formats > should work for respective locale and currency > en-US_USD 1`] = `
214 | CurrencyFormat {
215 | "currency": "$",
216 | "decimalSymbol": ".",
217 | "digits": [
218 | "0",
219 | "1",
220 | "2",
221 | "3",
222 | "4",
223 | "5",
224 | "6",
225 | "7",
226 | "8",
227 | "9",
228 | ],
229 | "groupingSymbol": ",",
230 | "locale": "en-US",
231 | "maximumFractionDigits": 2,
232 | "minimumFractionDigits": 2,
233 | "minusSign": "-",
234 | "negativePrefix": "-$",
235 | "negativeSuffix": "",
236 | "options": {
237 | "currency": "USD",
238 | "currencyDisplay": undefined,
239 | "currencySign": undefined,
240 | "style": "currency",
241 | "useGrouping": undefined,
242 | },
243 | "prefix": "$",
244 | "suffix": "",
245 | }
246 | `;
247 |
248 | exports[`CurrencyFormat > constructing number formats > should work for respective locale and currency > es-ES_EUR 1`] = `
249 | CurrencyFormat {
250 | "currency": "€",
251 | "decimalSymbol": ",",
252 | "digits": [
253 | "0",
254 | "1",
255 | "2",
256 | "3",
257 | "4",
258 | "5",
259 | "6",
260 | "7",
261 | "8",
262 | "9",
263 | ],
264 | "groupingSymbol": ".",
265 | "locale": "es-ES",
266 | "maximumFractionDigits": 2,
267 | "minimumFractionDigits": 2,
268 | "minusSign": "-",
269 | "negativePrefix": "-",
270 | "negativeSuffix": " €",
271 | "options": {
272 | "currency": "EUR",
273 | "currencyDisplay": undefined,
274 | "currencySign": undefined,
275 | "style": "currency",
276 | "useGrouping": undefined,
277 | },
278 | "prefix": "",
279 | "suffix": " €",
280 | }
281 | `;
282 |
283 | exports[`CurrencyFormat > constructing number formats > should work for respective locale and currency > fa-IR_IRR 1`] = `
284 | CurrencyFormat {
285 | "currency": "ریال",
286 | "decimalSymbol": undefined,
287 | "digits": [
288 | "۰",
289 | "۱",
290 | "۲",
291 | "۳",
292 | "۴",
293 | "۵",
294 | "۶",
295 | "۷",
296 | "۸",
297 | "۹",
298 | ],
299 | "groupingSymbol": "٬",
300 | "locale": "fa-IR",
301 | "maximumFractionDigits": 0,
302 | "minimumFractionDigits": 0,
303 | "minusSign": "−",
304 | "negativePrefix": "−ریال ",
305 | "negativeSuffix": "",
306 | "options": {
307 | "currency": "IRR",
308 | "currencyDisplay": undefined,
309 | "currencySign": undefined,
310 | "style": "currency",
311 | "useGrouping": undefined,
312 | },
313 | "prefix": "ریال ",
314 | "suffix": "",
315 | }
316 | `;
317 |
318 | exports[`CurrencyFormat > constructing number formats > should work for respective locale and currency > fr-CH_CHF 1`] = `
319 | CurrencyFormat {
320 | "currency": "CHF",
321 | "decimalSymbol": ".",
322 | "digits": [
323 | "0",
324 | "1",
325 | "2",
326 | "3",
327 | "4",
328 | "5",
329 | "6",
330 | "7",
331 | "8",
332 | "9",
333 | ],
334 | "groupingSymbol": " ",
335 | "locale": "fr-CH",
336 | "maximumFractionDigits": 2,
337 | "minimumFractionDigits": 2,
338 | "minusSign": "-",
339 | "negativePrefix": "-",
340 | "negativeSuffix": " CHF",
341 | "options": {
342 | "currency": "CHF",
343 | "currencyDisplay": undefined,
344 | "currencySign": undefined,
345 | "style": "currency",
346 | "useGrouping": undefined,
347 | },
348 | "prefix": "",
349 | "suffix": " CHF",
350 | }
351 | `;
352 |
353 | exports[`CurrencyFormat > constructing number formats > should work for respective locale and currency > ja_JPY 1`] = `
354 | CurrencyFormat {
355 | "currency": "¥",
356 | "decimalSymbol": undefined,
357 | "digits": [
358 | "0",
359 | "1",
360 | "2",
361 | "3",
362 | "4",
363 | "5",
364 | "6",
365 | "7",
366 | "8",
367 | "9",
368 | ],
369 | "groupingSymbol": ",",
370 | "locale": "ja",
371 | "maximumFractionDigits": 0,
372 | "minimumFractionDigits": 0,
373 | "minusSign": "-",
374 | "negativePrefix": "-¥",
375 | "negativeSuffix": "",
376 | "options": {
377 | "currency": "JPY",
378 | "currencyDisplay": undefined,
379 | "currencySign": undefined,
380 | "style": "currency",
381 | "useGrouping": undefined,
382 | },
383 | "prefix": "¥",
384 | "suffix": "",
385 | }
386 | `;
387 |
388 | exports[`CurrencyFormat > constructing number formats > should work for respective locale and currency > nl-NL_EUR 1`] = `
389 | CurrencyFormat {
390 | "currency": "€",
391 | "decimalSymbol": ",",
392 | "digits": [
393 | "0",
394 | "1",
395 | "2",
396 | "3",
397 | "4",
398 | "5",
399 | "6",
400 | "7",
401 | "8",
402 | "9",
403 | ],
404 | "groupingSymbol": ".",
405 | "locale": "nl-NL",
406 | "maximumFractionDigits": 2,
407 | "minimumFractionDigits": 2,
408 | "minusSign": "-",
409 | "negativePrefix": "€ -",
410 | "negativeSuffix": "",
411 | "options": {
412 | "currency": "EUR",
413 | "currencyDisplay": undefined,
414 | "currencySign": undefined,
415 | "style": "currency",
416 | "useGrouping": undefined,
417 | },
418 | "prefix": "€ ",
419 | "suffix": "",
420 | }
421 | `;
422 |
423 | exports[`CurrencyFormat > constructing number formats > should work for respective locale and currency > pt_BRL 1`] = `
424 | CurrencyFormat {
425 | "currency": "R$",
426 | "decimalSymbol": ",",
427 | "digits": [
428 | "0",
429 | "1",
430 | "2",
431 | "3",
432 | "4",
433 | "5",
434 | "6",
435 | "7",
436 | "8",
437 | "9",
438 | ],
439 | "groupingSymbol": ".",
440 | "locale": "pt",
441 | "maximumFractionDigits": 2,
442 | "minimumFractionDigits": 2,
443 | "minusSign": "-",
444 | "negativePrefix": "-R$ ",
445 | "negativeSuffix": "",
446 | "options": {
447 | "currency": "BRL",
448 | "currencyDisplay": undefined,
449 | "currencySign": undefined,
450 | "style": "currency",
451 | "useGrouping": undefined,
452 | },
453 | "prefix": "R$ ",
454 | "suffix": "",
455 | }
456 | `;
457 |
458 | exports[`CurrencyFormat > constructing number formats > should work for respective locale and currency > zh_CNY 1`] = `
459 | CurrencyFormat {
460 | "currency": "¥",
461 | "decimalSymbol": ".",
462 | "digits": [
463 | "0",
464 | "1",
465 | "2",
466 | "3",
467 | "4",
468 | "5",
469 | "6",
470 | "7",
471 | "8",
472 | "9",
473 | ],
474 | "groupingSymbol": ",",
475 | "locale": "zh",
476 | "maximumFractionDigits": 2,
477 | "minimumFractionDigits": 2,
478 | "minusSign": "-",
479 | "negativePrefix": "-¥",
480 | "negativeSuffix": "",
481 | "options": {
482 | "currency": "CNY",
483 | "currencyDisplay": undefined,
484 | "currencySign": undefined,
485 | "style": "currency",
486 | "useGrouping": undefined,
487 | },
488 | "prefix": "¥",
489 | "suffix": "",
490 | }
491 | `;
492 |
--------------------------------------------------------------------------------
/docs/.vitepress/theme/components/Demo.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
15 |
16 | Number value: {{ value != null ? value : 'null' }}
17 |
18 |
19 |
20 |
24 |
25 |
26 |
Options
27 |
28 |
32 | Export
33 |
34 |
35 | {{ stringifiedOptions }}
40 |
41 |
42 |
43 |
44 |
45 |
46 |
50 |
55 |
59 | {{ locale }}
60 |
61 |
62 |
63 |
64 |
68 |
72 | {{ currency }}
73 |
74 |
75 |
76 |
80 |
84 |
89 | {{ currencyDisplay.label }}
90 |
91 |
92 |
93 |
98 |
103 |
107 |
112 |
117 |
122 |
123 |
124 |
125 |
130 |
131 |
138 | to
139 |
146 |
147 |
148 |
153 |
154 |
160 |
164 |
169 |
174 | {{ value }}
175 |
176 |
177 | to
178 |
183 |
188 | {{ value }}
189 |
190 |
191 |
192 |
198 |
203 | {{ value }}
204 |
205 |
206 |
207 |
208 |
213 |
218 |
223 | {{ option.label }}
224 |
225 |
226 |
227 |
232 |
233 |
234 |
235 |
236 |
338 |
339 |
340 |
--------------------------------------------------------------------------------
/src/currencyInput.ts:
--------------------------------------------------------------------------------
1 | import CurrencyFormat, { DECIMAL_SEPARATORS } from './currencyFormat'
2 | import { AutoDecimalDigitsInputMask, DefaultInputMask, InputMask } from './inputMask'
3 | import { count } from './utils'
4 | import { CurrencyDisplay, CurrencyInputOptions, CurrencyInputValue, ValueScaling } from './api'
5 |
6 | export const DEFAULT_OPTIONS = {
7 | locale: undefined,
8 | currency: undefined,
9 | currencyDisplay: undefined,
10 | hideGroupingSeparatorOnFocus: true,
11 | hideCurrencySymbolOnFocus: true,
12 | hideNegligibleDecimalDigitsOnFocus: true,
13 | precision: undefined,
14 | autoDecimalDigits: false,
15 | valueRange: undefined,
16 | useGrouping: undefined,
17 | valueScaling: undefined
18 | }
19 |
20 | export class CurrencyInput {
21 | private readonly el: HTMLInputElement
22 | private readonly onInput: (value: CurrencyInputValue) => void
23 | private readonly onChange: (value: CurrencyInputValue) => void
24 | private numberValue!: number | null
25 | private numberValueOnFocus!: number | null
26 | private options!: CurrencyInputOptions
27 | private currencyFormat!: CurrencyFormat
28 | private decimalSymbolInsertedAt?: number
29 | private numberMask!: InputMask
30 | private formattedValue!: string
31 | private focus!: boolean
32 | private minValue!: number
33 | private maxValue!: number
34 | private valueScaling: number | undefined
35 | private valueScalingFractionDigits!: number
36 |
37 | constructor(args: {
38 | el: HTMLInputElement
39 | options: CurrencyInputOptions
40 | onInput: (value: CurrencyInputValue) => void
41 | onChange: (value: CurrencyInputValue) => void
42 | }) {
43 | this.el = args.el
44 | this.onInput = args.onInput
45 | this.onChange = args.onChange
46 | this.addEventListener()
47 | this.init(args.options)
48 | }
49 |
50 | setOptions(options: CurrencyInputOptions): void {
51 | this.init(options)
52 | this.format(this.currencyFormat.format(this.validateValueRange(this.numberValue)))
53 | this.onChange(this.getValue())
54 | }
55 |
56 | getValue(): CurrencyInputValue {
57 | const numberValue = this.valueScaling && this.numberValue != null ? this.toInteger(this.numberValue, this.valueScaling) : this.numberValue
58 | return { number: numberValue, formatted: this.formattedValue }
59 | }
60 |
61 | setValue(value: number | null): void {
62 | const newValue = this.valueScaling !== undefined && value != null ? this.toFloat(value, this.valueScaling) : value
63 | if (newValue !== this.numberValue) {
64 | this.format(this.currencyFormat.format(this.validateValueRange(newValue)))
65 | this.onChange(this.getValue())
66 | }
67 | }
68 |
69 | private init(options: CurrencyInputOptions) {
70 | this.options = {
71 | ...DEFAULT_OPTIONS,
72 | ...options
73 | }
74 | if (this.options.autoDecimalDigits) {
75 | this.options.hideNegligibleDecimalDigitsOnFocus = false
76 | }
77 | if (!this.el.getAttribute('inputmode')) {
78 | this.el.setAttribute('inputmode', this.options.autoDecimalDigits ? 'numeric' : 'decimal')
79 | }
80 | this.currencyFormat = new CurrencyFormat(this.options)
81 | this.numberMask = this.options.autoDecimalDigits ? new AutoDecimalDigitsInputMask(this.currencyFormat) : new DefaultInputMask(this.currencyFormat)
82 | const valueScalingOptions = {
83 | [ValueScaling.precision]: this.currencyFormat.maximumFractionDigits,
84 | [ValueScaling.thousands]: 3,
85 | [ValueScaling.tenThousands]: 4,
86 | [ValueScaling.millions]: 6,
87 | [ValueScaling.billions]: 9
88 | }
89 | this.valueScaling = this.options.valueScaling ? valueScalingOptions[this.options.valueScaling] : undefined
90 | this.valueScalingFractionDigits =
91 | this.valueScaling !== undefined && this.options.valueScaling !== ValueScaling.precision
92 | ? this.valueScaling + this.currencyFormat.maximumFractionDigits
93 | : this.currencyFormat.maximumFractionDigits
94 | this.minValue = this.getMinValue()
95 | this.maxValue = this.getMaxValue()
96 | }
97 |
98 | private getMinValue(): number {
99 | let min = this.toFloat(-Number.MAX_SAFE_INTEGER)
100 | if (this.options.valueRange?.min !== undefined) {
101 | min = Math.max(this.options.valueRange?.min, this.toFloat(-Number.MAX_SAFE_INTEGER))
102 | }
103 | return min
104 | }
105 |
106 | private getMaxValue(): number {
107 | let max = this.toFloat(Number.MAX_SAFE_INTEGER)
108 | if (this.options.valueRange?.max !== undefined) {
109 | max = Math.min(this.options.valueRange?.max, this.toFloat(Number.MAX_SAFE_INTEGER))
110 | }
111 | return max
112 | }
113 |
114 | private toFloat(value: number, maxFractionDigits?: number): number {
115 | return value / Math.pow(10, maxFractionDigits ?? this.valueScalingFractionDigits)
116 | }
117 |
118 | private toInteger(value: number, maxFractionDigits?: number) {
119 | return Number(
120 | value
121 | .toFixed(maxFractionDigits ?? this.valueScalingFractionDigits)
122 | .split('.')
123 | .join('')
124 | )
125 | }
126 |
127 | private validateValueRange(value: number | null): number | null {
128 | return value != null ? Math.min(Math.max(value, this.minValue), this.maxValue) : value
129 | }
130 |
131 | private format(value: string | null, hideNegligibleDecimalDigits = false) {
132 | if (value != null) {
133 | if (this.decimalSymbolInsertedAt !== undefined) {
134 | value = this.currencyFormat.normalizeDecimalSeparator(value, this.decimalSymbolInsertedAt)
135 | this.decimalSymbolInsertedAt = undefined
136 | }
137 | const conformedValue = this.numberMask.conformToMask(value, this.formattedValue)
138 | let formattedValue
139 | if (typeof conformedValue === 'object') {
140 | const { numberValue, fractionDigits } = conformedValue
141 | let { maximumFractionDigits, minimumFractionDigits } = this.currencyFormat
142 | if (this.focus) {
143 | minimumFractionDigits = hideNegligibleDecimalDigits
144 | ? fractionDigits.replace(/0+$/, '').length
145 | : Math.min(maximumFractionDigits, fractionDigits.length)
146 | } else if (Number.isInteger(numberValue) && !this.options.autoDecimalDigits && (this.options.precision === undefined || minimumFractionDigits === 0)) {
147 | minimumFractionDigits = maximumFractionDigits = 0
148 | }
149 | formattedValue =
150 | this.toInteger(Math.abs(numberValue)) > Number.MAX_SAFE_INTEGER
151 | ? this.formattedValue
152 | : this.currencyFormat.format(numberValue, {
153 | useGrouping: this.options.useGrouping !== false && !(this.focus && this.options.hideGroupingSeparatorOnFocus),
154 | minimumFractionDigits,
155 | maximumFractionDigits
156 | })
157 | } else {
158 | formattedValue = conformedValue
159 | }
160 | if (this.maxValue <= 0 && !this.currencyFormat.isNegative(formattedValue) && this.currencyFormat.parse(formattedValue) !== 0) {
161 | formattedValue = formattedValue.replace(this.currencyFormat.prefix, this.currencyFormat.negativePrefix)
162 | }
163 | if (this.minValue >= 0) {
164 | formattedValue = formattedValue.replace(this.currencyFormat.negativePrefix, this.currencyFormat.prefix)
165 | }
166 | if (this.options.currencyDisplay === CurrencyDisplay.hidden || (this.focus && this.options.hideCurrencySymbolOnFocus)) {
167 | formattedValue = formattedValue
168 | .replace(this.currencyFormat.negativePrefix, this.currencyFormat.minusSign !== undefined ? this.currencyFormat.minusSign : '(')
169 | .replace(this.currencyFormat.negativeSuffix, this.currencyFormat.minusSign !== undefined ? '' : ')')
170 | .replace(this.currencyFormat.prefix, '')
171 | .replace(this.currencyFormat.suffix, '')
172 | }
173 |
174 | this.el.value = formattedValue
175 | this.numberValue = this.currencyFormat.parse(formattedValue)
176 | } else {
177 | this.el.value = ''
178 | this.numberValue = null
179 | }
180 | this.formattedValue = this.el.value
181 | this.onInput(this.getValue())
182 | }
183 |
184 | private addEventListener(): void {
185 | this.el.addEventListener('input', (e: Event) => {
186 | const { value, selectionStart } = this.el
187 | const inputEvent = e as InputEvent
188 | if (selectionStart && inputEvent.data && DECIMAL_SEPARATORS.includes(inputEvent.data)) {
189 | this.decimalSymbolInsertedAt = selectionStart - 1
190 | }
191 | this.format(value)
192 | if (this.focus && selectionStart != null) {
193 | const getCaretPositionAfterFormat = () => {
194 | const { prefix, suffix, decimalSymbol, maximumFractionDigits, groupingSymbol } = this.currencyFormat
195 | let caretPositionFromLeft = value.length - selectionStart
196 | const newValueLength = this.formattedValue.length
197 | if (this.currencyFormat.minusSign === undefined && (value.startsWith('(') || value.startsWith('-')) && !value.endsWith(')')) {
198 | return newValueLength - this.currencyFormat.negativeSuffix.length > 1 ? this.formattedValue.substring(selectionStart).length : 1
199 | }
200 | if (
201 | this.formattedValue.substring(selectionStart, 1) === groupingSymbol &&
202 | count(this.formattedValue, groupingSymbol) === count(value, groupingSymbol) + 1
203 | ) {
204 | return newValueLength - caretPositionFromLeft - 1
205 | }
206 | if (newValueLength < caretPositionFromLeft) {
207 | return selectionStart
208 | }
209 | if (decimalSymbol !== undefined && value.indexOf(decimalSymbol) !== -1) {
210 | const decimalSymbolPosition = value.indexOf(decimalSymbol) + 1
211 | if (Math.abs(newValueLength - value.length) > 1 && selectionStart <= decimalSymbolPosition) {
212 | return this.formattedValue.indexOf(decimalSymbol) + 1
213 | } else {
214 | if (!this.options.autoDecimalDigits && selectionStart > decimalSymbolPosition) {
215 | if (this.currencyFormat.onlyDigits(value.substring(decimalSymbolPosition)).length - 1 === maximumFractionDigits) {
216 | caretPositionFromLeft -= 1
217 | }
218 | }
219 | }
220 | }
221 | return this.options.hideCurrencySymbolOnFocus || this.options.currencyDisplay === CurrencyDisplay.hidden
222 | ? newValueLength - caretPositionFromLeft
223 | : Math.max(newValueLength - Math.max(caretPositionFromLeft, suffix.length), prefix.length)
224 | }
225 | this.setCaretPosition(getCaretPositionAfterFormat())
226 | }
227 | })
228 |
229 | this.el.addEventListener('focus', () => {
230 | this.focus = true
231 | this.numberValueOnFocus = this.numberValue
232 | setTimeout(() => {
233 | const { value, selectionStart, selectionEnd } = this.el
234 | this.format(value, this.options.hideNegligibleDecimalDigitsOnFocus)
235 | if (selectionStart != null && selectionEnd != null && Math.abs(selectionStart - selectionEnd) > 0) {
236 | this.setCaretPosition(0, this.el.value.length)
237 | } else if (selectionStart != null) {
238 | const caretPositionOnFocus = this.getCaretPositionOnFocus(value, selectionStart)
239 | this.setCaretPosition(caretPositionOnFocus)
240 | }
241 | })
242 | })
243 |
244 | this.el.addEventListener('blur', () => {
245 | this.focus = false
246 | this.format(this.currencyFormat.format(this.validateValueRange(this.numberValue)))
247 | if (this.numberValueOnFocus !== this.numberValue) {
248 | this.onChange(this.getValue())
249 | }
250 | })
251 | }
252 |
253 | private getCaretPositionOnFocus(value: string, selectionStart: number) {
254 | if (this.numberValue == null) {
255 | return selectionStart
256 | }
257 | const { prefix, negativePrefix, suffix, negativeSuffix, groupingSymbol, currency } = this.currencyFormat
258 | const isNegative = this.numberValue < 0
259 | const currentPrefix = isNegative ? negativePrefix : prefix
260 | const prefixLength = currentPrefix.length
261 | if (this.options.hideCurrencySymbolOnFocus || this.options.currencyDisplay === CurrencyDisplay.hidden) {
262 | if (isNegative) {
263 | if (selectionStart <= 1) {
264 | return 1
265 | } else if (value.endsWith(')') && selectionStart > value.indexOf(')')) {
266 | return this.formattedValue.length - 1
267 | }
268 | }
269 | } else {
270 | const suffixLength = isNegative ? negativeSuffix.length : suffix.length
271 | if (selectionStart >= value.length - suffixLength) {
272 | return this.formattedValue.length - suffixLength
273 | } else if (selectionStart < prefixLength) {
274 | return prefixLength
275 | }
276 | }
277 | let result = selectionStart
278 | if (
279 | this.options.hideCurrencySymbolOnFocus &&
280 | this.options.currencyDisplay !== CurrencyDisplay.hidden &&
281 | selectionStart >= prefixLength &&
282 | currency !== undefined &&
283 | currentPrefix.includes(currency)
284 | ) {
285 | result -= prefixLength
286 | if (isNegative) {
287 | result += 1
288 | }
289 | }
290 | if (this.options.hideGroupingSeparatorOnFocus && groupingSymbol !== undefined) {
291 | result -= count(value.substring(0, selectionStart), groupingSymbol)
292 | }
293 | return result
294 | }
295 |
296 | private setCaretPosition(start: number, end = start) {
297 | this.el.setSelectionRange(start, end)
298 | }
299 | }
300 |
--------------------------------------------------------------------------------