├── .github
└── workflows
│ └── nodejs.yml
├── .gitignore
├── .npmignore
├── LICENSE.md
├── README.md
├── babel.config.json
├── dist
├── components
│ ├── CurrencyInput.js
│ ├── CurrencyInput.test.js
│ ├── CurrencyInputTestWrapper.js
│ └── FormikTestWrapper.js
└── index.js
├── example.gif
├── package-lock.json
├── package.json
├── public
├── favicon.ico
├── index.html
├── logo192.png
├── logo512.png
├── manifest.json
└── robots.txt
├── src
├── demo
│ ├── App.js
│ ├── index.css
│ └── index.js
├── index.js
├── lib
│ ├── components
│ │ ├── CurrencyInput.js
│ │ ├── CurrencyInput.test.js
│ │ ├── CurrencyInputTestWrapper.js
│ │ └── FormikTestWrapper.js
│ └── index.js
└── setupTests.js
└── types
└── index.d.ts
/.github/workflows/nodejs.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://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
3 |
4 | name: Node.js CI
5 |
6 | on:
7 | push:
8 | branches: ["main"]
9 | pull_request:
10 | branches: ["main"]
11 |
12 | jobs:
13 | build:
14 | runs-on: ubuntu-latest
15 |
16 | strategy:
17 | matrix:
18 | node-version: [14.x, 16.x]
19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
20 |
21 | steps:
22 | - uses: actions/checkout@v3
23 | - name: Use Node.js ${{ matrix.node-version }}
24 | uses: actions/setup-node@v3
25 | with:
26 | node-version: ${{ matrix.node-version }}
27 | cache: "npm"
28 | - run: npm install
29 | - run: npm test
30 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | /node_modules
3 |
4 | # testing
5 | /coverage
6 |
7 | # misc
8 | .DS_Store
9 | .env.local
10 | .env.development.local
11 | .env.test.local
12 | .env.production.local
13 |
14 | npm-debug.log*
15 | yarn-debug.log*
16 | yarn-error.log*
17 |
18 | # Development folders and files
19 | public
20 | src
21 | scripts
22 | config
23 | demo
24 | CHANGELOG.md
25 | README.md
26 | UPGRADE.md
27 | .storybook
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Pedro Resch
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Money Input
2 |
3 |  
4 |
5 | A currency text input for React that Just Works™
6 |
7 | - "ATM style" typing, matches user expectations of how a money input should work
8 | - Uses Intl API to display locale accurate currency representations
9 | - Supports custom inputs (e.g. Material UI text fields)
10 | - Returns [`currency.js`](https://github.com/scurker/currency.js/) enforced numeric float values
11 | - Works out of the box with libs like [`Formik`](https://github.com/jaredpalmer/formik)
12 |
13 | 
14 |
15 | ## Installation
16 |
17 | ```bash
18 | npm install --save @rschpdr/react-money-input currency.js
19 | ```
20 |
21 | ## Quick Start
22 |
23 | ```javascript
24 | import React, { useState } from "react";
25 | import MoneyInput from "@rschpdr/react-money-input";
26 |
27 | function Example(props) {
28 | const [amount, setAmount] = useState(0);
29 |
30 | function handleChange(e) {
31 | setAmount(e.target.value);
32 | }
33 |
34 | return ;
35 | }
36 |
37 | export default Example;
38 | ```
39 |
40 | ## Props
41 |
42 | | Props | Options | Default | Description |
43 | | -------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
44 | | className | string | '' | Regular React classname |
45 | | style | Styles object | {} | Regular React styles object |
46 | | currencyConfig | Currency configuration object |
- locale: string = "en-US"
- currencyCode: string = "USD"
- currencyDisplay: string = "symbol"
- useGrouping: boolean = true
- minimumFractionDigits: number = undefined
| Config options for Number.toLocaleString method. [See more](https://www.techonthenet.com/js/number_tolocalestring.php) |
47 | | customInput | Component Reference | undefined | Support for custom inputs e.g. Material UI TextField |
48 | | name | string | undefined | Regular `name` HTML property |
49 | | id | string | undefined | Regular `id` HTML property |
50 | | max | number | Number.MAX_SAFE_INTEGER | Maximum allowed value |
51 | | onChange | (event) => any | undefined | `onChange` event handler. `event` is a fake Synthetic Event with only `value`, `name` and `id` properties defined inside `target` |
52 | | value | number | undefined | Input value |
53 |
54 | ## Custom Inputs
55 |
56 | Simply pass the custom input component as a prop. Pass the custom input props directly to `MoneyInput`:
57 |
58 | ```javascript
59 | import React, { useState } from "react";
60 | import { TextField } from "@material-ui/core";
61 | import MoneyInput from "@rschpdr/react-money-input";
62 |
63 | function Example(props) {
64 | const [amount, setAmount] = useState(0);
65 |
66 | function handleChange(e) {
67 | setAmount(e.target.value);
68 | }
69 |
70 | return (
71 |
78 | );
79 | }
80 |
81 | export default Example;
82 | ```
83 |
84 | ## Contributing
85 |
86 | All contributions welcome! Feel free to [raise issues](https://github.com/rschpdr/react-money-input/issues) or [submit a PR](https://github.com/rschpdr/react-money-input/pulls).
87 |
88 | ## License
89 |
90 | This project is licensed under the MIT License - see [LICENSE.md](LICENSE.md) for details.
91 |
92 | ## Acknowledgments
93 |
94 | - Based on [larkintuckerllc/react-currency-input](https://github.com/larkintuckerllc/react-currency-input)
95 | - Custom input support based on [
96 | s-yadav/react-number-format](https://github.com/s-yadav/react-number-format)
97 |
98 | Go give them stars!
99 |
--------------------------------------------------------------------------------
/babel.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "@babel/env",
5 | {
6 | "targets": {
7 | "edge": "17",
8 | "firefox": "60",
9 | "chrome": "67",
10 | "safari": "11.1"
11 | },
12 | "useBuiltIns": "usage",
13 | "corejs": "3.6.5"
14 | }
15 | ],
16 | "@babel/preset-react"
17 | ]
18 | }
19 |
--------------------------------------------------------------------------------
/dist/components/CurrencyInput.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | require("core-js/modules/web.dom-collections.iterator.js");
4 |
5 | Object.defineProperty(exports, "__esModule", {
6 | value: true
7 | });
8 | exports.default = void 0;
9 |
10 | require("core-js/modules/es.regexp.exec.js");
11 |
12 | require("core-js/modules/es.regexp.test.js");
13 |
14 | require("core-js/modules/es.regexp.to-string.js");
15 |
16 | require("core-js/modules/es.number.parse-int.js");
17 |
18 | var _react = _interopRequireWildcard(require("react"));
19 |
20 | var _currency = _interopRequireDefault(require("currency.js"));
21 |
22 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
23 |
24 | function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
25 |
26 | function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
27 |
28 | function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
29 |
30 | function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
31 |
32 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
33 |
34 | const VALID_FIRST = /^[1-9]{1}$/;
35 | const VALID_NEXT = /^[0-9]{1}$/;
36 | const DELETE_KEY_CODE = 8;
37 |
38 | const CurrencyInput = props => {
39 | const {
40 | className = "",
41 | style = {},
42 | currencyConfig = {
43 | locale: "en-US",
44 | currencyCode: "USD",
45 | currencyDisplay: "symbol",
46 | useGrouping: true,
47 | minimumFractionDigits: undefined
48 | },
49 | customInput,
50 | name,
51 | id,
52 | max = Number.MAX_SAFE_INTEGER,
53 | onChange,
54 | value
55 | } = props;
56 | const fakeChangeEvent = {
57 | target: {
58 | type: "number",
59 | name,
60 | id
61 | }
62 | };
63 | const valueInCents = (0, _currency.default)(value).intValue;
64 | const valueAbsTrunc = Math.trunc(Math.abs(valueInCents));
65 |
66 | if (valueInCents !== valueAbsTrunc || !Number.isFinite(valueInCents) || Number.isNaN(valueInCents)) {
67 | throw new Error("invalid value property");
68 | }
69 |
70 | const handleKeyDown = (0, _react.useCallback)(e => {
71 | const {
72 | key,
73 | keyCode
74 | } = e;
75 |
76 | if (valueInCents === 0 && !VALID_FIRST.test(key) || valueInCents !== 0 && !VALID_NEXT.test(key) && keyCode !== DELETE_KEY_CODE) {
77 | return;
78 | }
79 |
80 | const valueString = valueInCents.toString();
81 | let nextValue;
82 |
83 | if (keyCode !== DELETE_KEY_CODE) {
84 | const nextValueString = valueInCents === 0 ? key : "".concat(valueString).concat(key);
85 | nextValue = Number.parseInt(nextValueString, 10);
86 | } else {
87 | const nextValueString = valueString.slice(0, -1);
88 | nextValue = nextValueString === "" ? 0 : Number.parseInt(nextValueString, 10);
89 | }
90 |
91 | if (nextValue > max) {
92 | return;
93 | } // Enforce our division with currency to prevent rounding errors
94 |
95 |
96 | fakeChangeEvent.target.value = (0, _currency.default)(nextValue / 100).value;
97 | onChange(fakeChangeEvent);
98 | }, [max, onChange, valueInCents, fakeChangeEvent]);
99 | const handleChange = (0, _react.useCallback)(() => {// DUMMY TO AVOID REACT WARNING
100 | }, []);
101 | const {
102 | locale,
103 | currencyCode,
104 | currencyDisplay,
105 | useGrouping,
106 | minimumFractionDigits
107 | } = currencyConfig;
108 | const valueDisplay = (0, _currency.default)(valueInCents / 100).value.toLocaleString(locale, {
109 | style: "currency",
110 | currency: currencyCode,
111 | currencyDisplay,
112 | useGrouping,
113 | minimumFractionDigits
114 | });
115 | const inputProps = {
116 | "data-testid": "currency-input",
117 | className: className,
118 | inputMode: "numeric",
119 | onChange: handleChange,
120 | onKeyDown: handleKeyDown,
121 | style: style,
122 | value: valueDisplay
123 | };
124 |
125 | if (customInput) {
126 | const customProps = _objectSpread(_objectSpread({}, props), inputProps);
127 |
128 | delete customProps.customInput;
129 | delete customProps.currencyConfig;
130 | const CustomInput = customInput;
131 | return /*#__PURE__*/_react.default.createElement(CustomInput, customProps);
132 | }
133 |
134 | return /*#__PURE__*/_react.default.createElement("input", inputProps);
135 | };
136 |
137 | var _default = CurrencyInput;
138 | exports.default = _default;
--------------------------------------------------------------------------------
/dist/components/CurrencyInput.test.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | require("core-js/modules/es.promise.js");
4 |
5 | var _react = _interopRequireDefault(require("react"));
6 |
7 | var _material = require("@mui/material");
8 |
9 | var _react2 = require("@testing-library/react");
10 |
11 | var _userEvent = _interopRequireDefault(require("@testing-library/user-event"));
12 |
13 | require("@testing-library/jest-dom/extend-expect");
14 |
15 | var _CurrencyInput = _interopRequireDefault(require("./CurrencyInput"));
16 |
17 | var _CurrencyInputTestWrapper = _interopRequireDefault(require("./CurrencyInputTestWrapper"));
18 |
19 | var _FormikTestWrapper = _interopRequireDefault(require("./FormikTestWrapper"));
20 |
21 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
22 |
23 | // I know tests are testing implementation details as they are now, but I wanna make sure that the value you see is the value you get from the change event, to avoid unsynced behavior between the presentation and data
24 | test("Displays the formatted currency value when user types a number, and returns the numeric value to onChange handler", () => {
25 | let state = 0;
26 |
27 | function handleChange(e) {
28 | state = e;
29 | }
30 |
31 | const {
32 | getByTestId
33 | } = (0, _react2.render)( /*#__PURE__*/_react.default.createElement(_CurrencyInputTestWrapper.default, {
34 | setAmount: handleChange
35 | })); // Since this is a ATM style input that writes right-to-left, we should simulate each keystroke to make sure that the result value is correct
36 |
37 | _react2.fireEvent.keyDown(getByTestId("currency-input"), {
38 | key: "1",
39 | keyCode: 49,
40 | charCode: 49
41 | });
42 |
43 | _react2.fireEvent.keyDown(getByTestId("currency-input"), {
44 | key: "2",
45 | keyCode: 50,
46 | charCode: 50
47 | });
48 |
49 | _react2.fireEvent.keyDown(getByTestId("currency-input"), {
50 | key: "3",
51 | keyCode: 51,
52 | charCode: 51
53 | });
54 |
55 | expect(getByTestId("currency-input").value).toBe("$1.23");
56 | expect(state).toBe(1.23);
57 | });
58 | test("Performs a left-to-right deletion of values when backspace key is pressed", () => {
59 | let state = 0;
60 |
61 | function handleChange(e) {
62 | state = e;
63 | }
64 |
65 | const {
66 | getByTestId
67 | } = (0, _react2.render)( /*#__PURE__*/_react.default.createElement(_CurrencyInputTestWrapper.default, {
68 | setAmount: handleChange
69 | }));
70 |
71 | _react2.fireEvent.keyDown(getByTestId("currency-input"), {
72 | key: "1",
73 | keyCode: 49
74 | });
75 |
76 | _react2.fireEvent.keyDown(getByTestId("currency-input"), {
77 | key: "2",
78 | keyCode: 50
79 | });
80 |
81 | _react2.fireEvent.keyDown(getByTestId("currency-input"), {
82 | key: "3",
83 | keyCode: 51
84 | });
85 |
86 | _react2.fireEvent.keyDown(getByTestId("currency-input"), {
87 | key: "Backspace",
88 | keyCode: 8
89 | }); // Deletion should be in "ATM style"
90 |
91 |
92 | expect(getByTestId("currency-input").value).toBe("$0.12");
93 | expect(state).toBe(0.12);
94 | });
95 | test("Does nothing if input is not a number", () => {
96 | let state = 0;
97 |
98 | function handleChange(e) {
99 | state = e;
100 | }
101 |
102 | const {
103 | getByTestId
104 | } = (0, _react2.render)( /*#__PURE__*/_react.default.createElement(_CurrencyInputTestWrapper.default, {
105 | setAmount: handleChange
106 | }));
107 |
108 | _react2.fireEvent.keyDown(getByTestId("currency-input"), {
109 | key: "a",
110 | keyCode: 65
111 | });
112 |
113 | _react2.fireEvent.keyDown(getByTestId("currency-input"), {
114 | key: "b",
115 | keyCode: 66
116 | });
117 |
118 | _react2.fireEvent.keyDown(getByTestId("currency-input"), {
119 | key: "c",
120 | keyCode: 67
121 | });
122 |
123 | expect(getByTestId("currency-input").value).toBe("$0.00");
124 | expect(state).toBe(0);
125 | });
126 | test("Renders a Material UI TextField", () => {
127 | const {
128 | getByTestId
129 | } = (0, _react2.render)( /*#__PURE__*/_react.default.createElement(_CurrencyInput.default, {
130 | customInput: _material.TextField,
131 | value: 0
132 | }));
133 | expect(getByTestId("currency-input")).toHaveClass("MuiFormControl-root");
134 | });
135 | test("Works with Formik", async () => {
136 | let state = 0;
137 |
138 | function handleSubmit(e) {
139 | state = e;
140 | } // This wrapper is a form, we have to submit it to access the input values
141 |
142 |
143 | (0, _react2.render)( /*#__PURE__*/_react.default.createElement(_FormikTestWrapper.default, {
144 | setAmount: handleSubmit
145 | }));
146 | const {
147 | getByTestId
148 | } = _react2.screen;
149 |
150 | const user = _userEvent.default.setup();
151 |
152 | await user.type(getByTestId("currency-input"), "1");
153 | await user.type(getByTestId("currency-input"), "2");
154 | await user.type(getByTestId("currency-input"), "3");
155 | await user.click(getByTestId("formik-test-wrapper-submit"));
156 | expect(state).toBe(1.23);
157 | });
158 | test("Renders different currency formats", () => {
159 | const {
160 | getByTestId
161 | } = (0, _react2.render)( /*#__PURE__*/_react.default.createElement(_CurrencyInput.default, {
162 | value: 0,
163 | currencyConfig: {
164 | locale: "pt-BR",
165 | currencyCode: "BRL"
166 | }
167 | }));
168 | expect(getByTestId("currency-input").value).toBe("R$\xa00,00");
169 | });
--------------------------------------------------------------------------------
/dist/components/CurrencyInputTestWrapper.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | Object.defineProperty(exports, "__esModule", {
4 | value: true
5 | });
6 | exports.default = void 0;
7 |
8 | require("core-js/modules/web.dom-collections.iterator.js");
9 |
10 | var _react = _interopRequireWildcard(require("react"));
11 |
12 | var _CurrencyInput = _interopRequireDefault(require("./CurrencyInput"));
13 |
14 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
15 |
16 | function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
17 |
18 | function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
19 |
20 | function TestWrapper(props) {
21 | const [amount, setAmount] = (0, _react.useState)(0);
22 |
23 | function handleChange(e) {
24 | setAmount(e.target.value);
25 | }
26 |
27 | (0, _react.useEffect)(() => {
28 | props.setAmount(amount);
29 | }, [amount]);
30 | return /*#__PURE__*/_react.default.createElement(_CurrencyInput.default, {
31 | onChange: handleChange,
32 | value: amount
33 | });
34 | }
35 |
36 | var _default = TestWrapper;
37 | exports.default = _default;
--------------------------------------------------------------------------------
/dist/components/FormikTestWrapper.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | Object.defineProperty(exports, "__esModule", {
4 | value: true
5 | });
6 | exports.default = void 0;
7 |
8 | require("core-js/modules/web.dom-collections.iterator.js");
9 |
10 | var _react = _interopRequireDefault(require("react"));
11 |
12 | var _formik = require("formik");
13 |
14 | var _CurrencyInput = _interopRequireDefault(require("./CurrencyInput"));
15 |
16 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
17 |
18 | function formikTestWrapper(props) {
19 | return /*#__PURE__*/_react.default.createElement(_formik.Formik, {
20 | initialValues: {
21 | amount: 0
22 | },
23 | validate: values => {
24 | const errors = {};
25 |
26 | if (typeof values.amount !== "number") {
27 | errors.amount = "Amount should be a number!";
28 | }
29 |
30 | return errors;
31 | },
32 | onSubmit: values => {
33 | props.setAmount(values.amount);
34 | }
35 | }, _ref => {
36 | let {
37 | values,
38 | handleChange,
39 | handleSubmit
40 | } = _ref;
41 | return /*#__PURE__*/_react.default.createElement("form", {
42 | onSubmit: handleSubmit
43 | }, /*#__PURE__*/_react.default.createElement(_CurrencyInput.default, {
44 | name: "amount",
45 | onChange: handleChange,
46 | value: values.amount
47 | }), /*#__PURE__*/_react.default.createElement("button", {
48 | "data-testid": "formik-test-wrapper-submit",
49 | type: "submit"
50 | }, "Submit"));
51 | });
52 | }
53 |
54 | var _default = formikTestWrapper;
55 | exports.default = _default;
--------------------------------------------------------------------------------
/dist/index.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | Object.defineProperty(exports, "__esModule", {
4 | value: true
5 | });
6 | exports.default = void 0;
7 |
8 | var _CurrencyInput = _interopRequireDefault(require("./components/CurrencyInput"));
9 |
10 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
11 |
12 | var _default = _CurrencyInput.default;
13 | exports.default = _default;
--------------------------------------------------------------------------------
/example.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rschpdr/react-money-input/1ce1e95f0811e7a799b8028b2fc2974af38f2cd1/example.gif
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@rschpdr/react-money-input",
3 | "version": "1.0.0",
4 | "description": "A currency text input for React that Just Works™",
5 | "author": "rschpdr",
6 | "main": "dist/index.js",
7 | "types": "types/index.d.ts",
8 | "dependencies": {
9 | "@babel/polyfill": "^7.12.1",
10 | "@testing-library/jest-dom": "^5.16.5",
11 | "@testing-library/react": "^13.3.0",
12 | "@testing-library/user-event": "^14.4.3",
13 | "currency.js": "^2.0.4",
14 | "react": "^18.2.0",
15 | "react-dom": "^18.2.0",
16 | "react-scripts": "5.0.1",
17 | "web-vitals": "^2.1.4"
18 | },
19 | "peerDependencies": {
20 | "currency.js": "^2.0.4"
21 | },
22 | "repository": {
23 | "type": "git",
24 | "url": "git+https://github.com/rschpdr/react-money-input.git"
25 | },
26 | "keywords": [
27 | "react",
28 | "currency",
29 | "input",
30 | "money"
31 | ],
32 | "bugs": {
33 | "url": "https://github.com/rschpdr/react-money-input/issues"
34 | },
35 | "license": "MIT",
36 | "scripts": {
37 | "start": "react-scripts start",
38 | "build": "rm -rf dist && NODE_ENV=production babel src/lib --out-dir dist --copy-files",
39 | "test": "react-scripts test --watchAll=false",
40 | "eject": "react-scripts eject"
41 | },
42 | "eslintConfig": {
43 | "extends": [
44 | "react-app",
45 | "react-app/jest"
46 | ]
47 | },
48 | "browserslist": {
49 | "production": [
50 | ">0.2%",
51 | "not dead",
52 | "not op_mini all"
53 | ],
54 | "development": [
55 | "last 1 chrome version",
56 | "last 1 firefox version",
57 | "last 1 safari version"
58 | ]
59 | },
60 | "devDependencies": {
61 | "@babel/cli": "^7.18.10",
62 | "@babel/core": "^7.18.13",
63 | "@babel/preset-env": "^7.18.10",
64 | "@emotion/react": "^11.10.0",
65 | "@emotion/styled": "^11.10.0",
66 | "@mui/material": "^5.10.1",
67 | "formik": "^2.2.9"
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rschpdr/react-money-input/1ce1e95f0811e7a799b8028b2fc2974af38f2cd1/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | React App
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rschpdr/react-money-input/1ce1e95f0811e7a799b8028b2fc2974af38f2cd1/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rschpdr/react-money-input/1ce1e95f0811e7a799b8028b2fc2974af38f2cd1/public/logo512.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/demo/App.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { TextField } from "@mui/material";
3 | import CurrencyInput from "../lib";
4 |
5 | const App = (props) => {
6 | const [amount, setAmount] = useState(0);
7 |
8 | function handleChange(e) {
9 | setAmount(e.target.value);
10 | }
11 |
12 | return (
13 | <>
14 |
15 |
16 |
17 |
18 |
23 |
24 | >
25 | );
26 | };
27 |
28 | export default App;
29 |
--------------------------------------------------------------------------------
/src/demo/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | code {
11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
12 | monospace;
13 | }
14 |
--------------------------------------------------------------------------------
/src/demo/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './index.css';
4 | import App from './App';
5 |
6 | ReactDOM.render(, document.getElementById('root'));
7 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | require("./demo");
2 |
--------------------------------------------------------------------------------
/src/lib/components/CurrencyInput.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from "react";
2 | import currency from "currency.js";
3 |
4 | const VALID_FIRST = /^[1-9]{1}$/;
5 | const VALID_NEXT = /^[0-9]{1}$/;
6 | const DELETE_KEY_CODE = 8;
7 |
8 | const CurrencyInput = (props) => {
9 | const {
10 | className = "",
11 | style = {},
12 | currencyConfig = {
13 | locale: "en-US",
14 | currencyCode: "USD",
15 | currencyDisplay: "symbol",
16 | useGrouping: true,
17 | minimumFractionDigits: undefined,
18 | },
19 | customInput,
20 | name,
21 | id,
22 | max = Number.MAX_SAFE_INTEGER,
23 | onChange,
24 | value,
25 | } = props;
26 |
27 | const fakeChangeEvent = {
28 | target: {
29 | type: "number",
30 | name,
31 | id,
32 | },
33 | };
34 |
35 | const valueInCents = currency(value).intValue;
36 | const valueAbsTrunc = Math.trunc(Math.abs(valueInCents));
37 | if (
38 | valueInCents !== valueAbsTrunc ||
39 | !Number.isFinite(valueInCents) ||
40 | Number.isNaN(valueInCents)
41 | ) {
42 | throw new Error(`invalid value property`);
43 | }
44 | const handleKeyDown = useCallback(
45 | (e) => {
46 | const { key, keyCode } = e;
47 | if (
48 | (valueInCents === 0 && !VALID_FIRST.test(key)) ||
49 | (valueInCents !== 0 &&
50 | !VALID_NEXT.test(key) &&
51 | keyCode !== DELETE_KEY_CODE)
52 | ) {
53 | return;
54 | }
55 | const valueString = valueInCents.toString();
56 | let nextValue;
57 | if (keyCode !== DELETE_KEY_CODE) {
58 | const nextValueString =
59 | valueInCents === 0 ? key : `${valueString}${key}`;
60 | nextValue = Number.parseInt(nextValueString, 10);
61 | } else {
62 | const nextValueString = valueString.slice(0, -1);
63 | nextValue =
64 | nextValueString === "" ? 0 : Number.parseInt(nextValueString, 10);
65 | }
66 | if (nextValue > max) {
67 | return;
68 | }
69 | // Enforce our division with currency to prevent rounding errors
70 | fakeChangeEvent.target.value = currency(nextValue / 100).value;
71 | onChange(fakeChangeEvent);
72 | },
73 | [max, onChange, valueInCents, fakeChangeEvent]
74 | );
75 | const handleChange = useCallback(() => {
76 | // DUMMY TO AVOID REACT WARNING
77 | }, []);
78 |
79 | const {
80 | locale,
81 | currencyCode,
82 | currencyDisplay,
83 | useGrouping,
84 | minimumFractionDigits,
85 | } = currencyConfig;
86 |
87 | const valueDisplay = currency(valueInCents / 100).value.toLocaleString(
88 | locale,
89 | {
90 | style: "currency",
91 | currency: currencyCode,
92 | currencyDisplay,
93 | useGrouping,
94 | minimumFractionDigits,
95 | }
96 | );
97 |
98 | const inputProps = {
99 | "data-testid": "currency-input",
100 | className: className,
101 | inputMode: "numeric",
102 | onChange: handleChange,
103 | onKeyDown: handleKeyDown,
104 | style: style,
105 | value: valueDisplay,
106 | };
107 |
108 | if (customInput) {
109 | const customProps = { ...props, ...inputProps };
110 | delete customProps.customInput;
111 | delete customProps.currencyConfig;
112 | const CustomInput = customInput;
113 | return ;
114 | }
115 |
116 | return ;
117 | };
118 |
119 | export default CurrencyInput;
120 |
--------------------------------------------------------------------------------
/src/lib/components/CurrencyInput.test.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { TextField } from "@mui/material";
3 | import { render, fireEvent, screen } from "@testing-library/react";
4 | import userEvent from "@testing-library/user-event";
5 | import "@testing-library/jest-dom/extend-expect";
6 | import CurrencyInput from "./CurrencyInput";
7 | import CurrencyInputTestWrapper from "./CurrencyInputTestWrapper";
8 | import FormikTestWrapper from "./FormikTestWrapper";
9 |
10 | // I know tests are testing implementation details as they are now, but I wanna make sure that the value you see is the value you get from the change event, to avoid unsynced behavior between the presentation and data
11 |
12 | test("Displays the formatted currency value when user types a number, and returns the numeric value to onChange handler", () => {
13 | let state = 0;
14 |
15 | function handleChange(e) {
16 | state = e;
17 | }
18 |
19 | const { getByTestId } = render(
20 |
21 | );
22 |
23 | // Since this is a ATM style input that writes right-to-left, we should simulate each keystroke to make sure that the result value is correct
24 | fireEvent.keyDown(getByTestId("currency-input"), {
25 | key: "1",
26 | keyCode: 49,
27 | charCode: 49,
28 | });
29 | fireEvent.keyDown(getByTestId("currency-input"), {
30 | key: "2",
31 | keyCode: 50,
32 | charCode: 50,
33 | });
34 | fireEvent.keyDown(getByTestId("currency-input"), {
35 | key: "3",
36 | keyCode: 51,
37 | charCode: 51,
38 | });
39 |
40 | expect(getByTestId("currency-input").value).toBe("$1.23");
41 | expect(state).toBe(1.23);
42 | });
43 |
44 | test("Performs a left-to-right deletion of values when backspace key is pressed", () => {
45 | let state = 0;
46 |
47 | function handleChange(e) {
48 | state = e;
49 | }
50 |
51 | const { getByTestId } = render(
52 |
53 | );
54 |
55 | fireEvent.keyDown(getByTestId("currency-input"), { key: "1", keyCode: 49 });
56 | fireEvent.keyDown(getByTestId("currency-input"), { key: "2", keyCode: 50 });
57 | fireEvent.keyDown(getByTestId("currency-input"), { key: "3", keyCode: 51 });
58 |
59 | fireEvent.keyDown(getByTestId("currency-input"), {
60 | key: "Backspace",
61 | keyCode: 8,
62 | });
63 |
64 | // Deletion should be in "ATM style"
65 | expect(getByTestId("currency-input").value).toBe("$0.12");
66 | expect(state).toBe(0.12);
67 | });
68 |
69 | test("Does nothing if input is not a number", () => {
70 | let state = 0;
71 |
72 | function handleChange(e) {
73 | state = e;
74 | }
75 |
76 | const { getByTestId } = render(
77 |
78 | );
79 |
80 | fireEvent.keyDown(getByTestId("currency-input"), { key: "a", keyCode: 65 });
81 | fireEvent.keyDown(getByTestId("currency-input"), { key: "b", keyCode: 66 });
82 | fireEvent.keyDown(getByTestId("currency-input"), { key: "c", keyCode: 67 });
83 |
84 | expect(getByTestId("currency-input").value).toBe("$0.00");
85 | expect(state).toBe(0);
86 | });
87 |
88 | test("Renders a Material UI TextField", () => {
89 | const { getByTestId } = render(
90 |
91 | );
92 |
93 | expect(getByTestId("currency-input")).toHaveClass("MuiFormControl-root");
94 | });
95 |
96 | test("Works with Formik", async () => {
97 | let state = 0;
98 |
99 | function handleSubmit(e) {
100 | state = e;
101 | }
102 |
103 | // This wrapper is a form, we have to submit it to access the input values
104 | render();
105 | const { getByTestId } = screen;
106 | const user = userEvent.setup();
107 |
108 | await user.type(getByTestId("currency-input"), "1");
109 | await user.type(getByTestId("currency-input"), "2");
110 | await user.type(getByTestId("currency-input"), "3");
111 | await user.click(getByTestId("formik-test-wrapper-submit"));
112 |
113 | expect(state).toBe(1.23);
114 | });
115 |
116 | test("Renders different currency formats", () => {
117 | const { getByTestId } = render(
118 |
122 | );
123 |
124 | expect(getByTestId("currency-input").value).toBe("R$\xa00,00");
125 | });
126 |
--------------------------------------------------------------------------------
/src/lib/components/CurrencyInputTestWrapper.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import CurrencyInput from './CurrencyInput';
3 |
4 | function TestWrapper(props) {
5 | const [amount, setAmount] = useState(0);
6 |
7 | function handleChange(e) {
8 | setAmount(e.target.value);
9 | }
10 |
11 | useEffect(() => {
12 | props.setAmount(amount);
13 | }, [amount]);
14 |
15 | return ;
16 | }
17 |
18 | export default TestWrapper;
19 |
--------------------------------------------------------------------------------
/src/lib/components/FormikTestWrapper.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Formik } from "formik";
3 | import CurrencyInput from "./CurrencyInput";
4 |
5 | function formikTestWrapper(props) {
6 | return (
7 | {
10 | const errors = {};
11 | if (typeof values.amount !== "number") {
12 | errors.amount = "Amount should be a number!";
13 | }
14 | return errors;
15 | }}
16 | onSubmit={values => {
17 | props.setAmount(values.amount);
18 | }}
19 | >
20 | {({ values, handleChange, handleSubmit }) => (
21 |
31 | )}
32 |
33 | );
34 | }
35 |
36 | export default formikTestWrapper;
37 |
--------------------------------------------------------------------------------
/src/lib/index.js:
--------------------------------------------------------------------------------
1 | import MoneyInput from "./components/CurrencyInput";
2 |
3 | export default MoneyInput;
4 |
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/types/index.d.ts:
--------------------------------------------------------------------------------
1 | // This will be improved
2 |
3 | interface ICurrencyConfig {
4 | locale?: string;
5 | currencyCode?: string;
6 | currencyDisplay?: string;
7 | useGrouping?: boolean;
8 | minimumFractionDigits?: boolean;
9 | }
10 |
11 | interface FakeSyntheticEvent {
12 | target: {
13 | name: string;
14 | value: string;
15 | id: string;
16 | };
17 | }
18 | interface MoneyInputProps {
19 | className?: string;
20 | style?: React.CSSProperties;
21 | currencyConfig?: ICurrencyConfig;
22 | customInput?: React.ComponentType;
23 | name?: string;
24 | id?: string;
25 | max?: number;
26 | onChange?: (event: FakeSyntheticEvent) => any;
27 | value?: number;
28 | }
29 |
30 | declare const MoneyInput: MoneyInputProps;
31 | declare module "@rschpdr/react-money-input";
32 |
--------------------------------------------------------------------------------