├── .node-version ├── .eslintignore ├── .npmrc ├── app ├── test.setup.ts ├── vite-env.d.ts ├── index.tsx ├── index.html ├── index.css ├── App.test.tsx └── App.tsx ├── lib ├── index.tsx ├── format-currency.ts ├── types.ts └── IntlCurrencyInput.tsx ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── publish-lib.yml │ └── publish-app.yml ├── tsconfig.node.json ├── .npmignore ├── turbo.json ├── vite.config.ts ├── tsconfig.json ├── LICENSE ├── .eslintrc.cjs ├── package.json ├── README.md └── .gitignore /.node-version: -------------------------------------------------------------------------------- 1 | v18 -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | -------------------------------------------------------------------------------- /app/test.setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | -------------------------------------------------------------------------------- /app/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /lib/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './IntlCurrencyInput'; -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "08:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /app/index.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | 4 | import App from './App'; 5 | 6 | createRoot(document.getElementById('root') as HTMLElement).render( 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github/ 2 | .turbo/ 3 | .vscode/ 4 | .vercel/ 5 | dist/ 6 | app/ 7 | node_modules/ 8 | lib/ 9 | .eslintignore 10 | .eslintrc.cjs 11 | .gitignore 12 | .npmignore 13 | .npmrc-github 14 | .node-version 15 | tsconfig.json 16 | tsconfig.node.json 17 | turbo.json 18 | vite.config.ts 19 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | react-intl-currency-input example 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /lib/format-currency.ts: -------------------------------------------------------------------------------- 1 | import Big from 'big.js'; 2 | 3 | import { IntlFormatterConfig } from './types'; 4 | 5 | export default function formatCurrency(value: number, localeConfig: IntlFormatterConfig, currencyName: string) { 6 | const numberConfig = localeConfig.formats.number[currencyName]; 7 | const formatter = new globalThis.Intl.NumberFormat(localeConfig.locale, numberConfig); 8 | 9 | return formatter.format(Big(value).toNumber()); 10 | } 11 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "pipeline": { 4 | "//#start:app": { 5 | "dependsOn": [] 6 | }, 7 | "//#lint": { 8 | "dependsOn": [] 9 | }, 10 | "//#preview:app": { 11 | "dependsOn": ["//#build:app"] 12 | }, 13 | "//#build:lib": { 14 | "dependsOn": [] 15 | }, 16 | "//#build:app": { 17 | "dependsOn": ["//#build:lib"] 18 | }, 19 | "//#build:all": { 20 | "dependsOn": ["//#build:lib", "//#build:app"] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/index.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@100;400;500;700;800&display=swap'); 2 | 3 | html, body { 4 | margin: 0; 5 | padding: 0; 6 | } 7 | 8 | body { 9 | background-color: #2E3436; 10 | color: #D3D7CF; 11 | font-family: 'JetBrains Mono', monospace; 12 | font-size: 11pt; 13 | } 14 | 15 | main { 16 | display: flex; 17 | justify-content: center; 18 | height: 100dvh; 19 | } 20 | 21 | section { 22 | background-color: #242829; 23 | padding: 10px 50px; 24 | min-height: 33rem; 25 | } -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | import react from '@vitejs/plugin-react'; 5 | import { defineConfig } from 'vite'; 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | plugins: [react()], 10 | test: { 11 | globals: true, 12 | environment: 'jsdom', 13 | setupFiles: './app/test.setup.ts', 14 | // you might want to disable it, if you don't have tests that rely on CSS 15 | // since parsing CSS is slow 16 | css: false, 17 | }, 18 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "allowUnreachableCode": false, 11 | "strict": true, 12 | "alwaysStrict": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "module": "ESNext", 15 | "moduleResolution": "Node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "noEmit": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noImplicitReturns": true, 22 | "jsx": "react-jsx", 23 | "types": ["vitest/globals"] 24 | }, 25 | "include": ["app", "lib"], 26 | "references": [{ "path": "./tsconfig.node.json" }] 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: CI 5 | 6 | on: 7 | push: 8 | branches: [ "**" ] 9 | pull_request: 10 | branches: [ "**" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [16.x, 18.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'npm' 29 | - run: npm i -g turbo 30 | - run: npm ci 31 | - run: npm test 32 | - run: turbo run lint 33 | - run: turbo run build:all 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Thiago Zanetti 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 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | 'browser': true, 4 | 'es2021': true, 5 | 'node': true 6 | }, 7 | extends: [ 8 | 'eslint:recommended', 9 | 'plugin:react/recommended', 10 | 'plugin:react/jsx-runtime', 11 | 'plugin:@typescript-eslint/recommended', 12 | ], 13 | overrides: [ 14 | ], 15 | parser: '@typescript-eslint/parser', 16 | parserOptions: { 17 | ecmaVersion: 'latest', 18 | sourceType: 'module' 19 | }, 20 | plugins: [ 21 | 'react', 22 | '@typescript-eslint' 23 | ], 24 | settings: { 25 | react: { 26 | version: 'detect', 27 | }, 28 | }, 29 | rules: { 30 | indent: [ 31 | 'error', 32 | 2 33 | ], 34 | quotes: [ 35 | 'error', 36 | 'single' 37 | ], 38 | semi: [ 39 | 'error', 40 | 'always' 41 | ], 42 | 'linebreak-style': [ 43 | 'error', 44 | 'unix' 45 | ], 46 | 'no-shadow': 'off', 47 | '@typescript-eslint/no-shadow': 'error', 48 | 'no-empty-function': 'off', 49 | '@typescript-eslint/no-empty-function': 'off', 50 | '@typescript-eslint/no-explicit-any': 'off', 51 | }, 52 | }; 53 | -------------------------------------------------------------------------------- /.github/workflows/publish-lib.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to NPM when a release is created 2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | name: Publish to NPM 4 | 5 | on: 6 | release: 7 | types: [created] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v3 18 | 19 | - name: Setup Node 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: 18 23 | - run: npm ci 24 | - run: npx turbo run lint 25 | - run: npx turbo run build:lib 26 | 27 | - name: Save lib artifacts 28 | uses: actions/upload-artifact@v3 29 | with: 30 | name: lib 31 | path: dist/lib 32 | 33 | publish-npm: 34 | 35 | needs: build 36 | 37 | runs-on: ubuntu-latest 38 | 39 | environment: Production 40 | 41 | steps: 42 | - name: Checkout repository 43 | uses: actions/checkout@v3 44 | 45 | - name: Setup Node 46 | uses: actions/setup-node@v3 47 | with: 48 | node-version: 18 49 | registry-url: https://registry.npmjs.org/ 50 | 51 | - name: Download previously built lib artifacts 52 | uses: actions/download-artifact@v3 53 | with: 54 | name: lib 55 | 56 | - name: Publish npm package 57 | run: npm publish 58 | env: 59 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 60 | -------------------------------------------------------------------------------- /.github/workflows/publish-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish to vercel when a release is created 2 | name: Publish to Vercel 3 | 4 | on: 5 | release: 6 | types: [created] 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Check out repository 16 | uses: actions/checkout@v3 17 | 18 | - name: Setup Node 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 18 22 | - run: npm ci 23 | - run: npx turbo run lint 24 | - run: npx turbo run build:app 25 | 26 | - name: Sav app artifacts 27 | uses: actions/upload-artifact@v3 28 | with: 29 | name: app 30 | path: dist/app 31 | 32 | publish: 33 | 34 | needs: build 35 | 36 | runs-on: ubuntu-latest 37 | 38 | environment: Production 39 | 40 | env: 41 | VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} 42 | VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} 43 | 44 | steps: 45 | - name: Check out repository 46 | uses: actions/checkout@v3 47 | 48 | - name: Setup Node 49 | uses: actions/setup-node@v3 50 | with: 51 | node-version: 18 52 | 53 | - name: Download previously built app artifacts 54 | uses: actions/download-artifact@v3 55 | with: 56 | name: app 57 | 58 | - name: Pull Vercel Environment Information 59 | run: npx vercel@latest pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }} 60 | 61 | - name: Build Project Artifacts 62 | run: npx vercel@latest build --prod --token=${{ secrets.VERCEL_TOKEN }} 63 | 64 | - name: Deploy Project Artifacts to Vercel 65 | run: npx vercel@latest deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }} 66 | -------------------------------------------------------------------------------- /app/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { render } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | 5 | 6 | import App from './App'; 7 | 8 | describe('App', () => { 9 | describe('BrlInputComponent', () => { 10 | it('must display 0 (R$ 0,00) as the default values', () => { 11 | // given 12 | const { container } = render(); 13 | 14 | // when 15 | 16 | // then 17 | expect(container.querySelector('[id="currentValue"]')).toHaveTextContent('0'); 18 | expect(container.querySelector('[id="maskedValue"]')).toHaveTextContent('R$0,00'); 19 | }); 20 | 21 | it('must display 1234.56 (R$ 1.234,56) as the result value', async () => { 22 | // given 23 | const user = userEvent.setup(); 24 | const { container } = render(); 25 | 26 | const input = container.querySelector('input'); 27 | 28 | // when 29 | if (input) { 30 | await user.type(input, '123456'); 31 | } 32 | 33 | // then 34 | expect(input).not.toBeNull(); 35 | expect(container.querySelector('[id="currentValue"]')).toHaveTextContent('1234.56'); 36 | expect(container.querySelector('[id="maskedValue"]')).toHaveTextContent('R$ 1.234,56'); 37 | }); 38 | 39 | it('must display -1234.56 (-R$ 1.234,56) as the result value', async () => { 40 | // given 41 | const user = userEvent.setup(); 42 | const { container } = render(); 43 | 44 | const input = container.querySelector('input'); 45 | 46 | // when 47 | if (input) { 48 | await user.keyboard('1[Home]{-}[End]23456'); 49 | } 50 | 51 | // then 52 | expect(input).not.toBeNull(); 53 | expect(input).toHaveFocus(); 54 | expect(container.querySelector('[id="currentValue"]')).toHaveTextContent('-1234.56'); 55 | expect(container.querySelector('[id="maskedValue"]')).toHaveTextContent('-R$ 1.234,56'); 56 | }); 57 | }); 58 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-intl-currency-input", 3 | "version": "0.3.3", 4 | "description": "A React component for i18n currency input using the Intl API.", 5 | "type": "module", 6 | "main": "index.cjs", 7 | "module": "index.js", 8 | "types": "index.d.ts", 9 | "scripts": { 10 | "start:app": "vite app --port 3000 --host 0.0.0.0 --open", 11 | "lint": "tsc", 12 | "preview:app": "vite preview app --outDir $PWD/dist/app --port 3000 --host 0.0.0.0 --open", 13 | "build:all": "npm run build:lib && npm run build:app", 14 | "build:app": "rm -rf dist/app && vite build app --outDir $PWD/dist/app --minify", 15 | "build:lib": "rm -rf dist/lib && tsup lib/index.tsx --out-dir dist/lib --format cjs,esm --dts", 16 | "test": "vitest", 17 | "coverage": "vitest run --coverage" 18 | }, 19 | "dependencies": { 20 | "react": "^18.2.0", 21 | "react-dom": "^18.2.0", 22 | "react-syntax-highlighter": "^15.5.0", 23 | "tsup": "^6.7.0" 24 | }, 25 | "devDependencies": { 26 | "@testing-library/jest-dom": "^5.16.5", 27 | "@testing-library/react": "^14.0.0", 28 | "@testing-library/user-event": "^14.4.3", 29 | "@types/big.js": "^6.1.6", 30 | "@types/react": "^18.0.33", 31 | "@types/react-dom": "^18.0.11", 32 | "@types/react-syntax-highlighter": "^15.5.6", 33 | "@typescript-eslint/eslint-plugin": "^5.57.1", 34 | "@typescript-eslint/parser": "^5.57.1", 35 | "@vitejs/plugin-react": "^3.1.0", 36 | "big.js": "^6.2.1", 37 | "eslint": "^8.37.0", 38 | "eslint-config-react-app": "^7.0.1", 39 | "eslint-plugin-react": "^7.32.2", 40 | "jsdom": "^21.1.1", 41 | "typescript": "^5.0.3", 42 | "vite": "^4.2.1", 43 | "vitest": "^0.29.8" 44 | }, 45 | "repository": { 46 | "type": "git", 47 | "url": "git+https://github.com/thiagozanetti/react-intl-currency-input.git" 48 | }, 49 | "keywords": [ 50 | "react", 51 | "react 18", 52 | "react-hooks", 53 | "esnext", 54 | "intl", 55 | "currency", 56 | "component", 57 | "input", 58 | "vite" 59 | ], 60 | "author": "Thiago Zanetti ", 61 | "license": "MIT", 62 | "bugs": { 63 | "url": "https://github.com/thiagozanetti/react-intl-currency-input/issues" 64 | }, 65 | "homepage": "https://github.com/thiagozanetti/react-intl-currency-input#readme", 66 | "engines": { 67 | "node": "18", 68 | "npm": "9" 69 | }, 70 | "workspaces": [ 71 | "app/*", 72 | "lib/*" 73 | ] 74 | } 75 | -------------------------------------------------------------------------------- /app/App.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, useState } from 'react'; 2 | import { Light as SyntaxHighlighter } from 'react-syntax-highlighter'; 3 | import javascript from 'react-syntax-highlighter/dist/esm/languages/hljs/javascript'; 4 | import railcasts from 'react-syntax-highlighter/dist/esm/styles/hljs/railscasts'; 5 | 6 | import './index.css'; 7 | 8 | import ReactIntlCurrencyInput from '../lib'; 9 | import { IntlFormatterConfig } from '../lib/types'; 10 | 11 | SyntaxHighlighter.registerLanguage('javascript', javascript); 12 | 13 | const currencyConfig: IntlFormatterConfig = { 14 | locale: 'pt-BR', 15 | formats: { 16 | number: { 17 | BRL: { 18 | style: 'currency', 19 | currency: 'BRL', 20 | minimumFractionDigits: 2, 21 | maximumFractionDigits: 2, 22 | }, 23 | }, 24 | }, 25 | }; 26 | 27 | const defaultValue = 0.0; 28 | const maxValue = 999999999.99; 29 | const regex = /"([\w]+)":/g; 30 | const subst = '$1:'; 31 | 32 | function BrlCurrencyInput() { 33 | const [value, setValue] = useState(defaultValue); 34 | const [maskedValue, setMaskedValue] = useState('R$0,00'); 35 | 36 | const handleChange = (event: ChangeEvent, currentValue: number, currentMaskedValue: string) => { 37 | event.preventDefault(); 38 | 39 | console.log(currentValue); // value without mask (ex: 1234.56) 40 | console.log(currentMaskedValue); // masked value (ex: R$1234,56) 41 | 42 | setValue(currentValue); 43 | setMaskedValue(currentMaskedValue); 44 | }; 45 | 46 | return ( 47 | <> 48 | 57 |

Value: {value}

58 |

Masked Value: {maskedValue}

59 |

Max Value: {maxValue}

60 | 61 | ); 62 | } 63 | 64 | function App() { 65 | return ( 66 |
67 |
68 |

react-intl-currency-input example

69 |

Using this configuration:

70 | 71 | {JSON.stringify(currencyConfig, null, 2).replace(regex, subst)} 72 | 73 |

Just input some amount to see the returned values:

74 | 75 |
76 |
77 | ); 78 | } 79 | 80 | export default App; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## react-intl-currency-input 2 | 3 | A React component for i18n currency input using [Intl API](https://developer.mozilla.org/pt-BR/docs/Web/JavaScript/Reference/Global_Objects/Intl). 4 | 5 | ### Installation 6 | 7 | ```sh 8 | $ npm install react-intl-currency-input --save-dev 9 | ``` 10 | 11 | ### How to use 12 | 13 | ```js 14 | import React from "react" 15 | import IntlCurrencyInput from "react-intl-currency-input" 16 | 17 | const currencyConfig = { 18 | locale: "pt-BR", 19 | formats: { 20 | number: { 21 | BRL: { 22 | style: "currency", 23 | currency: "BRL", 24 | minimumFractionDigits: 2, 25 | maximumFractionDigits: 2, 26 | }, 27 | }, 28 | }, 29 | }; 30 | 31 | const BrlCurrencyComponent = () => { 32 | const handleChange = (event, value, maskedValue) => { 33 | event.preventDefault(); 34 | 35 | console.log(value); // value without mask (ex: 1234.56) 36 | console.log(maskedValue); // masked value (ex: R$1234,56) 37 | }; 38 | 39 | return( 40 | 42 | ); 43 | } 44 | 45 | export default BrlCurrencyComponent; 46 | 47 | ``` 48 | ### Example 49 | 50 | ![example](https://user-images.githubusercontent.com/333482/228024696-844101c3-3e4d-419e-8e69-cf649245789c.png) 51 | 52 | To run the example: 53 | 54 | ```sh 55 | $ npx turbo run start:app 56 | ``` 57 | 58 | And a new browser window will open at [http://localhost:3000](http://localhost:3000) 59 | 60 | ### Properties 61 | 62 | | Name | Type | Default | Description | 63 | | :---: | :---: | :---: | :--- | 64 | | defaultValue | number | 0 | Sets the default / initial value to be used by the component on the first load | 65 | | currency | string | USD | Sets the [currency code](http://www.xe.com/iso4217.php) | 66 | | config | object | USD related configuration | Configuration object compliant with react-intl [intlShape](https://github.com/yahoo/react-intl/wiki/API#intlshape) | 67 | | autoFocus | boolean | false | Enables auto-focus when the component gets displayed | 68 | | autoSelect | boolean | false | Enables auto-select when the component gets displayed | 69 | | autoReset | boolean| false | Resets component's internal state when loses focus | 70 | | onChange | function | undefined | `(event, value, maskedValued) => {}`

Exposes the `Event` itself, the `value` with no mask and `maskedValue` for displaying purposes | 71 | | onFocus | function | undefined | `(event, value, maskedValued) => {`

Called when the component gains focus | 72 | | onBlur | function | undefined| `(event, value, maskedValued) => {`

Called when the component loses focus | 73 | | onKeyPress | function| undefined | `(event, key, keyCode) => {}`

Called when a `key` is pressed | 74 | | max | number| undefined | Maximum value for the input. Input does not change if the value is greater than max | 75 | 76 | All the other undocumented properties available for any `React Component` should also be available. 77 | -------------------------------------------------------------------------------- /lib/types.ts: -------------------------------------------------------------------------------- 1 | export type LocaleTag = 'af-ZA' | 'am-ET' | 'ar-AE' | 'ar-BH' | 'ar-DZ' | 'ar-EG' | 'ar-IQ' | 'ar-JO' | 'ar-KW' | 'ar-LB' | 'ar-LY' | 'ar-MA' | 'arn-CL' | 'ar-OM' | 'ar-QA' | 'ar-SA' | 'ar-SD' | 'ar-SY' | 'ar-TN' | 'ar-YE' | 'as-IN' | 'az-az' | 'az-Cyrl-AZ' | 'az-Latn-AZ' | 'ba-RU' | 'be-BY' | 'bg-BG' | 'bn-BD' | 'bn-IN' | 'bo-CN' | 'br-FR' | 'bs-Cyrl-BA' | 'bs-Latn-BA' | 'ca-ES' | 'co-FR' | 'cs-CZ' | 'cy-GB' | 'da-DK' | 'de-AT' | 'de-CH' | 'de-DE' | 'de-LI' | 'de-LU' | 'dsb-DE' | 'dv-MV' | 'el-CY' | 'el-GR' | 'en-029' | 'en-AU' | 'en-BZ' | 'en-CA' | 'en-cb' | 'en-GB' | 'en-IE' | 'en-IN' | 'en-JM' | 'en-MT' | 'en-MY' | 'en-NZ' | 'en-PH' | 'en-SG' | 'en-TT' | 'en-US' | 'en-ZA' | 'en-ZW' | 'es-AR' | 'es-BO' | 'es-CL' | 'es-CO' | 'es-CR' | 'es-DO' | 'es-EC' | 'es-ES' | 'es-GT' | 'es-HN' | 'es-MX' | 'es-NI' | 'es-PA' | 'es-PE' | 'es-PR' | 'es-PY' | 'es-SV' | 'es-US' | 'es-UY' | 'es-VE' | 'et-EE' | 'eu-ES' | 'fa-IR' | 'fi-FI' | 'fil-PH' | 'fo-FO' | 'fr-BE' | 'fr-CA' | 'fr-CH' | 'fr-FR' | 'fr-LU' | 'fr-MC' | 'fy-NL' | 'ga-IE' | 'gd-GB' | 'gd-ie' | 'gl-ES' | 'gsw-FR' | 'gu-IN' | 'ha-Latn-NG' | 'he-IL' | 'hi-IN' | 'hr-BA' | 'hr-HR' | 'hsb-DE' | 'hu-HU' | 'hy-AM' | 'id-ID' | 'ig-NG' | 'ii-CN' | 'in-ID' | 'is-IS' | 'it-CH' | 'it-IT' | 'iu-Cans-CA' | 'iu-Latn-CA' | 'iw-IL' | 'ja-JP' | 'ka-GE' | 'kk-KZ' | 'kl-GL' | 'km-KH' | 'kn-IN' | 'kok-IN' | 'ko-KR' | 'ky-KG' | 'lb-LU' | 'lo-LA' | 'lt-LT' | 'lv-LV' | 'mi-NZ' | 'mk-MK' | 'ml-IN' | 'mn-MN' | 'mn-Mong-CN' | 'moh-CA' | 'mr-IN' | 'ms-BN' | 'ms-MY' | 'mt-MT' | 'nb-NO' | 'ne-NP' | 'nl-BE' | 'nl-NL' | 'nn-NO' | 'no-no' | 'nso-ZA' | 'oc-FR' | 'or-IN' | 'pa-IN' | 'pl-PL' | 'prs-AF' | 'ps-AF' | 'pt-BR' | 'pt-PT' | 'qut-GT' | 'quz-BO' | 'quz-EC' | 'quz-PE' | 'rm-CH' | 'ro-mo' | 'ro-RO' | 'ru-mo' | 'ru-RU' | 'rw-RW' | 'sah-RU' | 'sa-IN' | 'se-FI' | 'se-NO' | 'se-SE' | 'si-LK' | 'sk-SK' | 'sl-SI' | 'sma-NO' | 'sma-SE' | 'smj-NO' | 'smj-SE' | 'smn-FI' | 'sms-FI' | 'sq-AL' | 'sr-BA' | 'sr-CS' | 'sr-Cyrl-BA' | 'sr-Cyrl-CS' | 'sr-Cyrl-ME' | 'sr-Cyrl-RS' | 'sr-Latn-BA' | 'sr-Latn-CS' | 'sr-Latn-ME' | 'sr-Latn-RS' | 'sr-ME' | 'sr-RS' | 'sr-sp' | 'sv-FI' | 'sv-SE' | 'sw-KE' | 'syr-SY' | 'ta-IN' | 'te-IN' | 'tg-Cyrl-TJ' | 'th-TH' | 'tk-TM' | 'tlh-QS' | 'tn-ZA' | 'tr-TR' | 'tt-RU' | 'tzm-Latn-DZ' | 'ug-CN' | 'uk-UA' | 'ur-PK' | 'uz-Cyrl-UZ' | 'uz-Latn-UZ' | 'uz-uz' | 'vi-VN' | 'wo-SN' | 'xh-ZA' | 'yo-NG' | 'zh-CN' | 'zh-HK' | 'zh-MO' | 'zh-SG' | 'zh-TW' | 'zu-ZA'; 2 | 3 | export type IntlFormatterConfig = { 4 | locale: LocaleTag; 5 | formats: { 6 | number: { 7 | [key: string]: Intl.NumberFormatOptions; 8 | } 9 | } 10 | }; 11 | 12 | export type IntlCurrencyInputProps = React.PropsWithChildren & { 13 | component: React.ElementType; 14 | defaultValue: number; 15 | value: number; 16 | max: number; 17 | currency: string; 18 | config: IntlFormatterConfig; 19 | autoFocus: boolean; 20 | autoSelect: boolean; 21 | autoReset: boolean; 22 | onChange: (...args: any[]) => void; 23 | onBlur: (...args: any[]) => void; 24 | onFocus: (...args: any[]) => void; 25 | onKeyPress: (...args: any[]) => void; 26 | inputRef: null | ((...args: any[]) => any) | Record; 27 | }; 28 | 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/node,linux,react,windows,macos 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=node,linux,react,windows,macos 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### macOS ### 20 | # General 21 | .DS_Store 22 | .AppleDouble 23 | .LSOverride 24 | 25 | # Icon must end with two \r 26 | Icon 27 | 28 | 29 | # Thumbnails 30 | ._* 31 | 32 | # Files that might appear in the root of a volume 33 | .DocumentRevisions-V100 34 | .fseventsd 35 | .Spotlight-V100 36 | .TemporaryItems 37 | .Trashes 38 | .VolumeIcon.icns 39 | .com.apple.timemachine.donotpresent 40 | 41 | # Directories potentially created on remote AFP share 42 | .AppleDB 43 | .AppleDesktop 44 | Network Trash Folder 45 | Temporary Items 46 | .apdisk 47 | 48 | ### macOS Patch ### 49 | # iCloud generated files 50 | *.icloud 51 | 52 | ### Node ### 53 | 54 | # turbo repos 55 | .turbo 56 | 57 | # vercel 58 | .vercel/ 59 | 60 | # Logs 61 | logs 62 | *.log 63 | npm-debug.log* 64 | yarn-debug.log* 65 | yarn-error.log* 66 | lerna-debug.log* 67 | .pnpm-debug.log* 68 | 69 | # Diagnostic reports (https://nodejs.org/api/report.html) 70 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 71 | 72 | # Runtime data 73 | pids 74 | *.pid 75 | *.seed 76 | *.pid.lock 77 | 78 | # Directory for instrumented libs generated by jscoverage/JSCover 79 | lib-cov 80 | 81 | # Coverage directory used by tools like istanbul 82 | coverage 83 | *.lcov 84 | 85 | # nyc test coverage 86 | .nyc_output 87 | 88 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 89 | .grunt 90 | 91 | # Bower dependency directory (https://bower.io/) 92 | bower_components 93 | 94 | # node-waf configuration 95 | .lock-wscript 96 | 97 | # Compiled binary addons (https://nodejs.org/api/addons.html) 98 | build/Release 99 | 100 | # Dependency directories 101 | node_modules/ 102 | jspm_packages/ 103 | 104 | # Snowpack dependency directory (https://snowpack.dev/) 105 | web_modules/ 106 | 107 | # TypeScript cache 108 | *.tsbuildinfo 109 | 110 | # Optional npm cache directory 111 | .npm 112 | 113 | # Optional eslint cache 114 | .eslintcache 115 | 116 | # Optional stylelint cache 117 | .stylelintcache 118 | 119 | # Microbundle cache 120 | .rpt2_cache/ 121 | .rts2_cache_cjs/ 122 | .rts2_cache_es/ 123 | .rts2_cache_umd/ 124 | 125 | # Optional REPL history 126 | .node_repl_history 127 | 128 | # Output of 'npm pack' 129 | *.tgz 130 | 131 | # Yarn Integrity file 132 | .yarn-integrity 133 | 134 | # dotenv environment variable files 135 | .env 136 | .env.development.local 137 | .env.test.local 138 | .env.production.local 139 | .env.local 140 | 141 | # parcel-bundler cache (https://parceljs.org/) 142 | .cache 143 | .parcel-cache 144 | 145 | # Next.js build output 146 | .next 147 | out 148 | 149 | # Nuxt.js build / generate output 150 | .nuxt 151 | dist 152 | 153 | # Gatsby files 154 | .cache/ 155 | # Comment in the public line in if your project uses Gatsby and not Next.js 156 | # https://nextjs.org/blog/next-9-1#public-directory-support 157 | # public 158 | 159 | # vuepress build output 160 | .vuepress/dist 161 | 162 | # vuepress v2.x temp and cache directory 163 | .temp 164 | 165 | # Docusaurus cache and generated files 166 | .docusaurus 167 | 168 | # Serverless directories 169 | .serverless/ 170 | 171 | # FuseBox cache 172 | .fusebox/ 173 | 174 | # DynamoDB Local files 175 | .dynamodb/ 176 | 177 | # TernJS port file 178 | .tern-port 179 | 180 | # Stores VSCode versions used for testing VSCode extensions 181 | .vscode-test 182 | 183 | # yarn v2 184 | .yarn/cache 185 | .yarn/unplugged 186 | .yarn/build-state.yml 187 | .yarn/install-state.gz 188 | .pnp.* 189 | 190 | ### Node Patch ### 191 | # Serverless Webpack directories 192 | .webpack/ 193 | 194 | # Optional stylelint cache 195 | 196 | # SvelteKit build / generate output 197 | .svelte-kit 198 | 199 | ### react ### 200 | .DS_* 201 | **/*.backup.* 202 | **/*.back.* 203 | 204 | node_modules 205 | 206 | *.sublime* 207 | 208 | psd 209 | thumb 210 | sketch 211 | 212 | ### Windows ### 213 | # Windows thumbnail cache files 214 | Thumbs.db 215 | Thumbs.db:encryptable 216 | ehthumbs.db 217 | ehthumbs_vista.db 218 | 219 | # Dump file 220 | *.stackdump 221 | 222 | # Folder config file 223 | [Dd]esktop.ini 224 | 225 | # Recycle Bin used on file shares 226 | $RECYCLE.BIN/ 227 | 228 | # Windows Installer files 229 | *.cab 230 | *.msi 231 | *.msix 232 | *.msm 233 | *.msp 234 | 235 | # Windows shortcuts 236 | *.lnk 237 | 238 | # End of https://www.toptal.com/developers/gitignore/api/node,linux,react,windows,macos 239 | -------------------------------------------------------------------------------- /lib/IntlCurrencyInput.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useRef, useState } from 'react'; 2 | import { any, bool, func, instanceOf, node, number, oneOfType, shape, string } from 'prop-types'; 3 | 4 | import formatCurrency from './format-currency'; 5 | import { IntlCurrencyInputProps, IntlFormatterConfig } from './types'; 6 | 7 | const MINIMUM_FRACTION_DIGITS = 2; 8 | const MAXIMUM_FRACTION_DIGITS = 2; 9 | 10 | const defaultConfig: IntlFormatterConfig = { 11 | locale: 'en-US', 12 | formats: { 13 | number: { 14 | USD: { 15 | style: 'currency', 16 | currency: 'USD', 17 | minimumFractionDigits: MINIMUM_FRACTION_DIGITS, 18 | maximumFractionDigits: MAXIMUM_FRACTION_DIGITS, 19 | }, 20 | }, 21 | }, 22 | }; 23 | 24 | const IntlCurrencyInput = ({ 25 | component: InputComponent, 26 | value, 27 | defaultValue, 28 | config, 29 | currency, 30 | max, 31 | autoFocus, 32 | autoSelect, 33 | autoReset, 34 | onChange, 35 | onBlur, 36 | onFocus, 37 | onKeyPress, 38 | inputRef, 39 | ...otherProps 40 | }: IntlCurrencyInputProps) => { 41 | const localInputRef = useRef(null); 42 | 43 | const [maskedValue, setMaskedValue] = useState('0'); 44 | 45 | // to prevent a malformed config object 46 | const safeConfig = useMemo(() => () => { 47 | const { formats: { number: { [currency]: { maximumFractionDigits = MAXIMUM_FRACTION_DIGITS } } } } = config; 48 | 49 | const finalConfig = { 50 | ...defaultConfig, 51 | ...config, 52 | }; 53 | 54 | // at the moment this prevents problems when converting numbers 55 | // with zeroes in-between, otherwise 205 would convert to 25. 56 | finalConfig.formats.number[currency].minimumFractionDigits = maximumFractionDigits; 57 | 58 | return finalConfig; 59 | }, [defaultConfig, config]); 60 | 61 | const clean = (entry: string | number): number => { 62 | if (typeof entry === 'number') { 63 | return entry; 64 | } 65 | 66 | // strips everything that is not a number (positive or negative) 67 | // also turns negative zeros (-0) into unsigned/positive ones (0) 68 | return Number(entry.replace(/[^0-9-]/g, '')) || 0; 69 | }; 70 | 71 | const normalizeValue = (entry: string | number): number => { 72 | const { formats: { number: { [currency]: { maximumFractionDigits = MAXIMUM_FRACTION_DIGITS } } } } = safeConfig(); 73 | let safeValue = entry; 74 | 75 | if (typeof entry === 'string') { 76 | safeValue = clean(entry); 77 | 78 | if (safeValue % 1 !== 0) { 79 | safeValue = safeValue.toFixed(maximumFractionDigits); 80 | } 81 | } else { 82 | // all input numbers must be a float point (for the cents portion). This is a fallback in case of integer ones. 83 | safeValue = Number.isInteger(safeValue) ? Number(safeValue) * (10 ** maximumFractionDigits) : Number(safeValue).toFixed(maximumFractionDigits); 84 | } 85 | 86 | // divide it by 10 power the maximum fraction digits. 87 | return clean(safeValue) / (10 ** maximumFractionDigits); 88 | }; 89 | 90 | const calculateValues = (inputFieldValue: number): [number, string] => { 91 | const localValue = normalizeValue(inputFieldValue); 92 | const localMaskedValue = formatCurrency(localValue, safeConfig(), currency); 93 | 94 | return [localValue, localMaskedValue]; 95 | }; 96 | 97 | const updateValues = (entry: number) => { 98 | const [calculatedValue, calculatedMaskedValue] = calculateValues(entry); 99 | 100 | if (!max || calculatedValue <= max) { 101 | setMaskedValue(calculatedMaskedValue); 102 | 103 | return [calculatedValue, calculatedMaskedValue]; 104 | } else { 105 | return [normalizeValue(maskedValue), maskedValue]; 106 | } 107 | }; 108 | 109 | const handleChange = (event: { preventDefault: () => void; target: { value: number; }; }) => { 110 | event.preventDefault(); 111 | 112 | const [localValue, localMaskedValue] = updateValues(event.target.value); 113 | 114 | if (localMaskedValue) { 115 | onChange(event, localValue, localMaskedValue); 116 | } 117 | }; 118 | 119 | const handleBlur = (event: { target: { value: number; }; }) => { 120 | const [localValue, localMaskedValue] = updateValues(event.target.value); 121 | 122 | if (autoReset) { 123 | calculateValues(0); 124 | } 125 | 126 | if (localMaskedValue) { 127 | onBlur(event, localValue, localMaskedValue); 128 | } 129 | }; 130 | 131 | const handleFocus = (event: { target: { select: () => void; value: number; }; }) => { 132 | if (autoSelect) { 133 | (localInputRef.current as any).select(); 134 | } 135 | 136 | const [localValue, localMaskedValue] = updateValues(event.target.value); 137 | 138 | if (localMaskedValue) { 139 | onFocus(event, localValue, localMaskedValue); 140 | } 141 | }; 142 | 143 | const handleKeyUp = (event: { key: any; keyCode: any; }) => onKeyPress(event, event.key, event.keyCode); 144 | 145 | useEffect(() => { 146 | const currentValue = value || defaultValue || 0; 147 | const [, localMaskedValue] = calculateValues(currentValue); 148 | 149 | setMaskedValue(localMaskedValue); 150 | 151 | if (autoFocus && localInputRef.current) { 152 | (localInputRef.current as any).focus(); 153 | } 154 | }, [autoFocus, autoSelect, currency, value, defaultValue, config]); 155 | 156 | return ( 157 | 166 | ); 167 | }; 168 | 169 | const checkCurrentPropType = () => Element ? instanceOf(Element) : any; 170 | 171 | IntlCurrencyInput.propTypes = { 172 | defaultValue: number, 173 | value: number, 174 | max: number, 175 | component: node.isRequired, 176 | currency: string.isRequired, 177 | config: shape({}).isRequired, 178 | autoFocus: bool.isRequired, 179 | autoSelect: bool.isRequired, 180 | autoReset: bool.isRequired, 181 | onChange: func.isRequired, 182 | onBlur: func.isRequired, 183 | onFocus: func.isRequired, 184 | onKeyPress: func.isRequired, 185 | inputRef: oneOfType([ 186 | func, 187 | shape({ current: checkCurrentPropType() }) 188 | ]) 189 | }; 190 | 191 | IntlCurrencyInput.defaultProps = { 192 | component: 'input', 193 | currency: 'USD', 194 | value: 0, 195 | config: defaultConfig, 196 | autoFocus: false, 197 | autoSelect: false, 198 | autoReset: false, 199 | onChange: () => {}, 200 | onBlur: () => {}, 201 | onFocus: () => {}, 202 | onKeyPress: () => {}, 203 | inputRef: null, 204 | }; 205 | 206 | export default IntlCurrencyInput; 207 | --------------------------------------------------------------------------------