├── .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 | 12 | 13 | 23 | -------------------------------------------------------------------------------- /examples/vue2/src/components/VCurrencyField.vue: -------------------------------------------------------------------------------- 1 | 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 | [![Edit Vue Currency Input: Integration with Quasar](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/vue-currency-input-integration-with-quasar-gwn46?fontsize=14&hidenavigation=1&theme=dark) 8 | 9 | ## Element Plus 10 | [![Vue Currency Input: Integration with Element Plus](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/p/sandbox/vue-currency-input-integration-with-element-plus-devoxx) 11 | 12 | ## FormKit 13 | [![Vue Currency Input: Integration FormKit](https://codesandbox.io/static/img/play-codesandbox.svg)](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 | 4 | 5 | 37 | -------------------------------------------------------------------------------- /examples/vue2/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 12 | 13 | 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 | 22 | 23 | 41 | 42 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/components/CurrencyInput.vue: -------------------------------------------------------------------------------- 1 | 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 | 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 | 20 | 21 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/components/Switch.vue: -------------------------------------------------------------------------------- 1 | 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 | [![codecov](https://codecov.io/gh/dm4t2/vue-currency-input/graph/badge.svg?token=CAgHbikCov)](https://codecov.io/gh/dm4t2/vue-currency-input) 2 | [![npm Version](https://badgen.net/npm/v/vue-currency-input?color=green)](https://www.npmjs.com/package/vue-currency-input) 3 | [![npm Downloads](https://badgen.net/npm/dw/vue-currency-input?color=green)](https://www.npmjs.com/package/vue-currency-input) 4 | [![Bundlephobia](https://badgen.net/bundlephobia/minzip/vue-currency-input?color=green)](https://bundlephobia.com/result?p=vue-currency-input) 5 | [![License](https://badgen.net/github/license/dm4t2/vue-currency-input?color=green)](https://github.com/dm4t2/vue-currency-input/blob/main/LICENSE) 6 | 7 | # Vue Currency Input 8 | 9 | [![](docs/vue-currency-input.gif)](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 | [![ko-fi](https://www.ko-fi.com/img/githubbutton_sm.svg)](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 | 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 | 73 | 74 | 83 | ``` 84 | 85 | [![Edit Vue Currency Input: Vue 3 Example](https://codesandbox.io/static/img/play-codesandbox.svg)](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 | 101 | 102 | 121 | ``` 122 | 123 | [![Edit Using Vue Currency Input with debouncing](https://codesandbox.io/static/img/play-codesandbox.svg)](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 | 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 | 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 | --------------------------------------------------------------------------------