├── .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 | 
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 |
--------------------------------------------------------------------------------