├── .github ├── FUNDING.yml ├── release.yml └── workflows │ ├── publish.yml │ ├── bump.yml │ └── main.yml ├── bunfig.toml ├── src ├── index.ts ├── utils.ts ├── __tests__ │ ├── utils.test.ts │ └── currency-input.test.tsx └── currency-input.tsx ├── bun.lockb ├── .husky ├── pre-commit ├── commit-msg └── common.sh ├── happydom.ts ├── .commitlintrc.json ├── .editorconfig ├── tsconfig.json ├── matchers.d.ts ├── tsup.config.ts ├── renovate.json ├── biome.json ├── .gitignore ├── LICENSE.md ├── package.json └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: danestves 2 | -------------------------------------------------------------------------------- /bunfig.toml: -------------------------------------------------------------------------------- 1 | [test] 2 | preload = "./happydom.ts" 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./currency-input"; 2 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danestves/headless-currency-input/HEAD/bun.lockb -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/common.sh" 3 | 4 | bunx lint-staged 5 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/common.sh" 3 | 4 | bun commitlint --edit $1 5 | -------------------------------------------------------------------------------- /happydom.ts: -------------------------------------------------------------------------------- 1 | import { GlobalRegistrator } from "@happy-dom/global-registrator"; 2 | 3 | GlobalRegistrator.register(); 4 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"], 3 | "rules": { 4 | "body-max-length": [0, "always"], 5 | "body-max-line-length": [0, "always"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | end_of_line = lf 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true -------------------------------------------------------------------------------- /.husky/common.sh: -------------------------------------------------------------------------------- 1 | command_exists () { 2 | command -v "$1" >/dev/null 2>&1 3 | } 4 | 5 | # Workaround for Windows 10, Git Bash and Yarn 6 | if command_exists winpty && test -t 1; then 7 | exec < /dev/tty 8 | fi 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "jsx": "react-jsx", 5 | "module": "ESNext", 6 | "moduleResolution": "Bundler", 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "noEmit": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /matchers.d.ts: -------------------------------------------------------------------------------- 1 | import type { TestingLibraryMatchers } from "@testing-library/jest-dom/matchers"; 2 | 3 | declare module "bun:test" { 4 | interface Matchers extends TestingLibraryMatchers {} 5 | interface AsymmetricMatchers extends TestingLibraryMatchers {} 6 | } 7 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: ["src/index.ts"], 5 | treeshake: true, 6 | sourcemap: true, 7 | minify: true, 8 | clean: true, 9 | dts: true, 10 | splitting: false, 11 | format: ["cjs", "esm"], 12 | external: ["react"], 13 | injectStyle: false, 14 | }); 15 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "timezone": "America/Los_Angeles", 4 | "schedule": ["on the first day of the month"], 5 | "packageRules": [ 6 | { 7 | "matchPackagePatterns": ["*"], 8 | "matchUpdateTypes": ["minor", "patch"], 9 | "groupName": "all non-major dependencies", 10 | "groupSlug": "all-minor-patch" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | categories: 3 | - title: Documentation Changes 4 | labels: 5 | - documentation 6 | - title: New Strategy 7 | labels: 8 | - strategy 9 | - title: New Features 10 | labels: 11 | - enhancement 12 | - title: Bug Fixes 13 | labels: 14 | - bug 15 | - title: Other Changes 16 | labels: 17 | - "*" 18 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true, 10 | "correctness": { 11 | "noUnusedImports": "error" 12 | }, 13 | "style": { 14 | "useImportType": "error" 15 | } 16 | } 17 | }, 18 | "formatter": { 19 | "enabled": true, 20 | "lineWidth": 120 21 | }, 22 | "files": { 23 | "ignore": ["**/node_modules/**", "**/dist/**", "package.json"] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.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 | # JetBrains IDE files 9 | .idea/ 10 | 11 | # testing 12 | /coverage 13 | 14 | # production 15 | /dist 16 | 17 | # misc 18 | .DS_Store 19 | *.pem 20 | tsconfig.tsbuildinfo 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: ⬇️ Checkout repo 12 | uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 0 15 | 16 | - name: ⎔ Setup node 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: '20.x' 20 | registry-url: https://registry.npmjs.org/ 21 | 22 | - name: 🧅 Setup bun 23 | uses: oven-sh/setup-bun@v1 24 | 25 | - name: 📥 Download deps 26 | run: bun install 27 | 28 | - name: 🏗️ Build 29 | run: bun run build 30 | 31 | - run: npm publish --access public 32 | env: 33 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 34 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @see https://github.com/i18n-components/i18n-components/blob/main/packages/input-number/src/helpers.ts#L2 3 | */ 4 | 5 | /** set the caret positon in an input field */ 6 | export function setCaretPosition(el: HTMLInputElement, caretPos = 0): boolean { 7 | if (el === null) { 8 | return false; 9 | } 10 | 11 | // Deselect any selected text 12 | el.select(); 13 | el.setSelectionRange(0, 0); 14 | 15 | if (el.selectionStart || el.selectionStart === 0) { 16 | el.focus(); 17 | el.setSelectionRange(caretPos, caretPos); 18 | return true; 19 | } 20 | 21 | // fail city, fortunately this never happens (as far as I've tested) :) 22 | el.focus(); 23 | return false; 24 | } 25 | 26 | /** 27 | * @param {HTMLInputElement} el 28 | * @returns number 29 | */ 30 | export function getCurrentCaretPosition(el: HTMLInputElement): number { 31 | /* Max of selectionStart and selectionEnd is taken for the patch of pixel and other mobile device caret bug */ 32 | const start = el.selectionStart ?? 0; 33 | const end = el.selectionEnd ?? 0; 34 | return Math.max(start, end); 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 danestves LLC 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 | -------------------------------------------------------------------------------- /.github/workflows/bump.yml: -------------------------------------------------------------------------------- 1 | name: Bump version 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'Semver type of new version (major / minor / patch)' 8 | required: true 9 | type: choice 10 | options: 11 | - major 12 | - minor 13 | - patch 14 | 15 | permissions: 16 | contents: write 17 | 18 | jobs: 19 | bump-version: 20 | name: Bump version 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: ⬇️ Checkout repo 24 | uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | ssh-key: ${{ secrets.DEPLOY_KEY }} 28 | 29 | - name: ⎔ Setup node 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version: '20.x' 33 | registry-url: https://registry.npmjs.org/ 34 | 35 | - name: 🧅 Setup bun 36 | uses: oven-sh/setup-bun@v1 37 | 38 | - name: 📥 Download deps 39 | run: bun install 40 | 41 | - name: Setup Git 42 | run: | 43 | git config user.name 'Daniel Esteves' 44 | git config user.email 'me+github@danestves.com' 45 | 46 | - name: bump version 47 | run: npm version ${{ github.event.inputs.version }} 48 | 49 | - name: Push latest version 50 | run: git push origin main --follow-tags 51 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: main 6 | pull_request: {} 7 | 8 | jobs: 9 | biome: 10 | name: ◭ Biome 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: ⬇️ Checkout repo 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: ⎔ Setup node 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: '20.x' 22 | 23 | - name: 🧅 Setup bun 24 | uses: oven-sh/setup-bun@v1 25 | 26 | - name: ◭ Setup Biome 27 | uses: biomejs/setup-biome@v2 28 | 29 | - name: 📥 Download deps 30 | run: bun install 31 | 32 | - name: 🔬 Lint 33 | run: biome ci . 34 | 35 | typecheck: 36 | name: ʦ TypeScript 37 | runs-on: ubuntu-latest 38 | steps: 39 | - name: ⬇️ Checkout repo 40 | uses: actions/checkout@v4 41 | with: 42 | fetch-depth: 0 43 | 44 | - name: ⎔ Setup node 45 | uses: actions/setup-node@v4 46 | with: 47 | node-version: '20.x' 48 | 49 | - name: 🧅 Setup bun 50 | uses: oven-sh/setup-bun@v1 51 | 52 | - name: 📥 Download deps 53 | run: bun install 54 | 55 | - name: 🔎 Type check 56 | run: bun run typecheck 57 | 58 | vitest: 59 | name: ⚡ Vitest 60 | runs-on: ubuntu-latest 61 | steps: 62 | - name: ⬇️ Checkout repo 63 | uses: actions/checkout@v4 64 | with: 65 | fetch-depth: 0 66 | 67 | - name: ⎔ Setup node 68 | uses: actions/setup-node@v4 69 | with: 70 | node-version: '20.x' 71 | 72 | - name: 🧅 Setup bun 73 | uses: oven-sh/setup-bun@v1 74 | 75 | - name: 📥 Download deps 76 | run: bun install 77 | 78 | - name: ⚡ Run vitest 79 | run: bun run test:ci 80 | 81 | build: 82 | name: Build 83 | runs-on: ubuntu-latest 84 | steps: 85 | - name: ⬇️ Checkout repo 86 | uses: actions/checkout@v4 87 | with: 88 | fetch-depth: 0 89 | 90 | - name: ⎔ Setup node 91 | uses: actions/setup-node@v4 92 | with: 93 | node-version: '20.x' 94 | 95 | - name: 🧅 Setup bun 96 | uses: oven-sh/setup-bun@v1 97 | 98 | - name: 📥 Download deps 99 | run: bun install 100 | 101 | - name: 🏗️ Build package 102 | run: bun run build 103 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "headless-currency-input", 3 | "version": "1.5.0", 4 | "description": "Headless Currency Input is a component to format currency input in an elegant way.", 5 | "keywords": [ 6 | "react", 7 | "currency", 8 | "input", 9 | "mask", 10 | "format", 11 | "number", 12 | "money", 13 | "currency-input", 14 | "currency-mask", 15 | "currency-format", 16 | "currency-number", 17 | "currency-money", 18 | "react-currency-input", 19 | "react-currency-mask", 20 | "react-currency-format", 21 | "react-currency-number", 22 | "react-currency-money" 23 | ], 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/danestves/headless-currency-input" 27 | }, 28 | "license": "MIT", 29 | "author": { 30 | "name": "Daniel Esteves", 31 | "email": "danestves@gmail.com", 32 | "url": "https://danestves.com" 33 | }, 34 | "exports": { 35 | ".": { 36 | "require": "./dist/index.js", 37 | "import": "./dist/index.mjs" 38 | } 39 | }, 40 | "main": "dist/index.js", 41 | "types": "./dist/index.d.ts", 42 | "files": [ 43 | "dist" 44 | ], 45 | "scripts": { 46 | "build": "tsup", 47 | "commit": "cz", 48 | "dev": "concurrently \"bun run build --watch\" \"bun run test\" ", 49 | "lint": "biome lint .", 50 | "lint:fix": "bun run lint --write && biome format --write .", 51 | "prepare": "husky install", 52 | "test": "bun test", 53 | "test:ci": "bun test --coverage", 54 | "test:watch": "bun test", 55 | "typecheck": "tsc" 56 | }, 57 | "lint-staged": { 58 | "**.js|ts|cjs|mjs|d.cts|d.mts|jsx|tsx": [ 59 | "biome check --files-ignore-unknown=true", 60 | "biome check --apply --no-errors-on-unmatched", 61 | "biome check --apply --organize-imports-enabled=false --no-errors-on-unmatched", 62 | "biome check --apply-unsafe --no-errors-on-unmatched", 63 | "biome format --write --no-errors-on-unmatched", 64 | "biome lint --apply --no-errors-on-unmatched" 65 | ], 66 | "*": [ 67 | "biome check --no-errors-on-unmatched --files-ignore-unknown=true" 68 | ] 69 | }, 70 | "config": { 71 | "commitizen": { 72 | "path": "./node_modules/@ryansonshine/cz-conventional-changelog" 73 | } 74 | }, 75 | "resolutions": { 76 | "glob-parent": ">=5.1.2", 77 | "parse-url": ">=8.1.0", 78 | "semver": ">=7.5.2", 79 | "trim": ">=0.0.3", 80 | "trim-newlines": ">=3.0.1", 81 | "yaml": ">=2.2.2" 82 | }, 83 | "dependencies": { 84 | "@react-aria/utils": "3.28.2", 85 | "@sumup/intl": "1.6.0", 86 | "react-number-format": "5.4.4" 87 | }, 88 | "devDependencies": { 89 | "@biomejs/biome": "1.9.4", 90 | "@commitlint/cli": "19.8.0", 91 | "@commitlint/config-conventional": "19.8.0", 92 | "@happy-dom/global-registrator": "^17.4.4", 93 | "@ryansonshine/commitizen": "4.2.8", 94 | "@ryansonshine/cz-conventional-changelog": "3.3.4", 95 | "@testing-library/jest-dom": "6.6.3", 96 | "@testing-library/react": "16.3.0", 97 | "@types/bun": "1.2.11", 98 | "@types/node": "22.15.3", 99 | "@types/react": "19.1.2", 100 | "@types/react-dom": "19.1.2", 101 | "concurrently": "9.1.2", 102 | "husky": "9.1.7", 103 | "lint-staged": "15.5.1", 104 | "react": "19.1.0", 105 | "react-dom": "19.1.0", 106 | "tsup": "8.4.0", 107 | "typescript": "5.8.3", 108 | "vitest": "3.1.2" 109 | }, 110 | "peerDependencies": { 111 | "react": "^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", 112 | "react-dom": "^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" 113 | }, 114 | "engines": { 115 | "node": ">=18.0.0" 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/__tests__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "bun:test"; 2 | import { getCurrentCaretPosition, setCaretPosition } from "../utils"; 3 | 4 | describe("utils", () => { 5 | describe("setCaretPosition", () => { 6 | it("should set caret position correctly", () => { 7 | const input = document.createElement("input"); 8 | input.value = "12345"; 9 | document.body.appendChild(input); 10 | 11 | const result = setCaretPosition(input, 2); 12 | expect(result).toBe(true); 13 | expect(input.selectionStart).toBe(2); 14 | expect(input.selectionEnd).toBe(2); 15 | 16 | document.body.removeChild(input); 17 | }); 18 | 19 | it("should handle null input element", () => { 20 | const result = setCaretPosition(null as unknown as HTMLInputElement, 2); 21 | expect(result).toBe(false); 22 | }); 23 | 24 | it("should handle input without selectionStart", () => { 25 | const input = document.createElement("input"); 26 | input.value = "12345"; 27 | // Remove selectionStart property to simulate older browsers 28 | Object.defineProperty(input, "selectionStart", { 29 | get: () => undefined, 30 | }); 31 | document.body.appendChild(input); 32 | 33 | const result = setCaretPosition(input, 2); 34 | expect(result).toBe(false); 35 | 36 | document.body.removeChild(input); 37 | }); 38 | 39 | it("should handle position at the end of input", () => { 40 | const input = document.createElement("input"); 41 | input.value = "12345"; 42 | document.body.appendChild(input); 43 | 44 | const result = setCaretPosition(input, input.value.length); 45 | expect(result).toBe(true); 46 | expect(input.selectionStart).toBe(input.value.length); 47 | expect(input.selectionEnd).toBe(input.value.length); 48 | 49 | document.body.removeChild(input); 50 | }); 51 | 52 | it("should handle position at the start of input", () => { 53 | const input = document.createElement("input"); 54 | input.value = "12345"; 55 | document.body.appendChild(input); 56 | 57 | const result = setCaretPosition(input, 0); 58 | expect(result).toBe(true); 59 | expect(input.selectionStart).toBe(0); 60 | expect(input.selectionEnd).toBe(0); 61 | 62 | document.body.removeChild(input); 63 | }); 64 | }); 65 | 66 | describe("getCurrentCaretPosition", () => { 67 | it("should return the maximum of selectionStart and selectionEnd", () => { 68 | const input = document.createElement("input"); 69 | input.value = "12345"; 70 | document.body.appendChild(input); 71 | 72 | // Set selection range 73 | input.setSelectionRange(2, 4); 74 | expect(getCurrentCaretPosition(input)).toBe(4); 75 | 76 | // In browsers, if start > end in setSelectionRange, they get swapped 77 | // So we need to mock the behavior or update our expectations 78 | Object.defineProperty(input, "selectionStart", { get: () => 2 }); 79 | Object.defineProperty(input, "selectionEnd", { get: () => 4 }); 80 | expect(getCurrentCaretPosition(input)).toBe(4); 81 | 82 | document.body.removeChild(input); 83 | }); 84 | 85 | it("should handle single position selection", () => { 86 | const input = document.createElement("input"); 87 | input.value = "12345"; 88 | document.body.appendChild(input); 89 | 90 | input.setSelectionRange(3, 3); 91 | expect(getCurrentCaretPosition(input)).toBe(3); 92 | 93 | document.body.removeChild(input); 94 | }); 95 | 96 | it("should handle position at the end of input", () => { 97 | const input = document.createElement("input"); 98 | input.value = "12345"; 99 | document.body.appendChild(input); 100 | 101 | input.setSelectionRange(input.value.length, input.value.length); 102 | expect(getCurrentCaretPosition(input)).toBe(input.value.length); 103 | 104 | document.body.removeChild(input); 105 | }); 106 | 107 | it("should handle position at the start of input", () => { 108 | const input = document.createElement("input"); 109 | input.value = "12345"; 110 | document.body.appendChild(input); 111 | 112 | input.setSelectionRange(0, 0); 113 | expect(getCurrentCaretPosition(input)).toBe(0); 114 | 115 | document.body.removeChild(input); 116 | }); 117 | 118 | it("should handle null selection values", () => { 119 | const input = document.createElement("input"); 120 | input.value = "12345"; 121 | document.body.appendChild(input); 122 | 123 | // Simulate null selection values 124 | Object.defineProperty(input, "selectionStart", { 125 | get: () => null, 126 | }); 127 | Object.defineProperty(input, "selectionEnd", { 128 | get: () => null, 129 | }); 130 | 131 | expect(getCurrentCaretPosition(input)).toBe(0); 132 | 133 | document.body.removeChild(input); 134 | }); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /src/currency-input.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { mergeRefs } from "@react-aria/utils"; 4 | import { resolveCurrencyFormat } from "@sumup/intl"; 5 | import { type ForwardedRef, forwardRef, useRef } from "react"; 6 | import { 7 | type InputAttributes, 8 | NumberFormatBase, 9 | type NumberFormatBaseProps, 10 | type NumberFormatValues, 11 | type SourceInfo, 12 | } from "react-number-format"; 13 | 14 | import { getCurrentCaretPosition, setCaretPosition } from "./utils"; 15 | 16 | type CurrencyInputProps = Omit< 17 | NumberFormatBaseProps, 18 | "format" | "prefix" | "customInput" 19 | > & { 20 | locale?: string; 21 | currency?: string; 22 | withCurrencySymbol?: boolean; 23 | customInput?: React.ComponentType; 24 | onClick?: (event: React.MouseEvent) => void; 25 | }; 26 | 27 | function RenderCurrencyInput( 28 | props: CurrencyInputProps, 29 | forwadedRef: ForwardedRef, 30 | ) { 31 | const innerRef = useRef(null); 32 | const { locale = "en", currency = "USD", withCurrencySymbol = true, ...rest } = props; 33 | const currencyFormat = resolveCurrencyFormat(locale, currency); 34 | const prefix = currencyFormat?.currencyPosition === "prefix" ? `${currencyFormat.currencySymbol} ` : ""; 35 | const minimumFractionDigits = currencyFormat?.minimumFractionDigits ?? 0; 36 | const maximumFractionDigits = currencyFormat?.maximumFractionDigits ?? 0; 37 | const divideBy = 10 ** minimumFractionDigits; 38 | 39 | function format(inputValue: string) { 40 | let value = 0; 41 | if (!Number(inputValue)) { 42 | value = 0; 43 | } else { 44 | value = Number(inputValue); 45 | } 46 | 47 | if (!innerRef.current) { 48 | return inputValue; 49 | } 50 | const amount = new Intl.NumberFormat(currencyFormat?.locale, { 51 | style: "currency", 52 | currency: currencyFormat?.currency, 53 | currencyDisplay: "code", 54 | minimumFractionDigits, 55 | maximumFractionDigits, 56 | }) 57 | .format(minimumFractionDigits ? value / divideBy : value) 58 | .replace(/[a-z]{3}/i, "") 59 | .trim(); 60 | 61 | const formattedValue = withCurrencySymbol ? `${prefix}${amount}` : amount; 62 | 63 | if (!inputValue || inputValue === "0") { 64 | updateCaretPosition(formattedValue.length); 65 | return formattedValue; 66 | } 67 | 68 | return formattedValue; 69 | } 70 | 71 | function onValueChange(values: NumberFormatValues, sourceInfo: SourceInfo) { 72 | const val = minimumFractionDigits 73 | ? (Number.parseFloat(values.value) / divideBy).toFixed(minimumFractionDigits) 74 | : values.value; 75 | const floatVal = values.floatValue && minimumFractionDigits ? values.floatValue / divideBy : values.floatValue; 76 | 77 | props?.onValueChange?.( 78 | { 79 | value: val, 80 | floatValue: floatVal, 81 | formattedValue: values.formattedValue, 82 | }, 83 | sourceInfo, 84 | ); 85 | } 86 | 87 | /** 88 | * decimal separator for current locale 89 | */ 90 | const getDecimalSeparator = (): string => { 91 | const parts = new Intl.NumberFormat(locale).formatToParts(1.1); 92 | return parts.find((p) => p.type === "decimal")?.value ?? "."; 93 | }; 94 | 95 | /** 96 | * thousand separator for current locale 97 | */ 98 | const getThousandSeparator = (): string => { 99 | const parts = new Intl.NumberFormat(locale).formatToParts(1000); 100 | return parts.find((p) => p.type === "group")?.value ?? ""; 101 | }; 102 | 103 | /** 104 | * set the curser at specific position 105 | * @param {number} pos 106 | * @returns void 107 | */ 108 | const updateCaretPosition = (pos: number): void => { 109 | if (innerRef.current) { 110 | setCaretPosition(innerRef.current, pos); 111 | } 112 | }; 113 | 114 | const correctCaretPosition = (isBackspace = false): void => { 115 | if (!innerRef.current) return; 116 | 117 | const currentCaretPosition = getCurrentCaretPosition(innerRef.current); 118 | const decimalSeparator = minimumFractionDigits ? `\\${getDecimalSeparator()}` : ""; 119 | const thousandSeparator = getThousandSeparator() ? `\\${getThousandSeparator()}` : ""; 120 | const parts = [decimalSeparator, thousandSeparator].filter(Boolean).join("|"); 121 | const separatorDecimalRegex = new RegExp(`^(${parts})$`); 122 | const currentCharacter = isBackspace 123 | ? innerRef.current.value[currentCaretPosition - 1] 124 | : innerRef.current.value[currentCaretPosition]; 125 | 126 | if (currentCharacter?.match(separatorDecimalRegex)) { 127 | const deletionPos = currentCaretPosition + (isBackspace ? -1 : 1); 128 | updateCaretPosition(deletionPos); 129 | } 130 | }; 131 | 132 | function onKeyDown(event: React.KeyboardEvent) { 133 | const { key } = event; 134 | 135 | if (event.defaultPrevented) return; 136 | 137 | if (key === "Backspace") { 138 | correctCaretPosition(true); 139 | } else if (key === "Delete") { 140 | correctCaretPosition(false); 141 | } 142 | 143 | props?.onKeyDown?.(event); 144 | } 145 | 146 | function onFocus(event: React.FocusEvent) { 147 | if (innerRef.current) { 148 | setCaretPosition(innerRef.current, innerRef.current.value.length); 149 | } 150 | props?.onFocus?.(event); 151 | } 152 | 153 | function onClick(event: React.MouseEvent) { 154 | if (innerRef.current) { 155 | setCaretPosition(innerRef.current, innerRef.current.value.length); 156 | } 157 | props?.onClick?.(event); 158 | } 159 | 160 | return ( 161 | 162 | {...(rest as NumberFormatBaseProps)} 163 | format={format} 164 | onFocus={onFocus} 165 | onClick={onClick} 166 | onKeyDown={onKeyDown} 167 | onValueChange={onValueChange} 168 | getInputRef={mergeRefs(innerRef, forwadedRef)} 169 | prefix={undefined} 170 | valueIsNumericString={false} 171 | /> 172 | ); 173 | } 174 | 175 | export const CurrencyInput = forwardRef(RenderCurrencyInput) as ( 176 | props: CurrencyInputProps & { ref?: ForwardedRef }, 177 | ) => ReturnType>; 178 | -------------------------------------------------------------------------------- /src/__tests__/currency-input.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, mock, spyOn } from "bun:test"; 2 | import { act, fireEvent, render } from "@testing-library/react"; 3 | import { CurrencyInput } from "../currency-input"; 4 | 5 | describe("CurrencyInput", () => { 6 | describe("getDecimalSeparator", () => { 7 | it("should return decimal separator for en locale", () => { 8 | const { container } = render(); 9 | const input = container.querySelector("input"); 10 | if (!input) throw new Error("Input not found"); 11 | expect(input.value).toContain("."); 12 | }); 13 | 14 | it("should return decimal separator for pt-BR locale", () => { 15 | const { container } = render(); 16 | const input = container.querySelector("input"); 17 | if (!input) throw new Error("Input not found"); 18 | expect(input.value).toContain(","); 19 | }); 20 | }); 21 | 22 | describe("getThousandSeparator", () => { 23 | it("should return thousand separator for en locale", () => { 24 | const { container } = render(); 25 | const input = container.querySelector("input"); 26 | if (!input) throw new Error("Input not found"); 27 | expect(input.value).toContain(","); 28 | }); 29 | 30 | it("should return empty string for locales without thousand separator", () => { 31 | const { container } = render(); 32 | const input = container.querySelector("input"); 33 | if (!input) throw new Error("Input not found"); 34 | expect(input.value).not.toContain(","); 35 | }); 36 | }); 37 | 38 | describe("correctCaretPosition", () => { 39 | it("should move caret past decimal separator on backspace", () => { 40 | const { container } = render(); 41 | const input = container.querySelector("input"); 42 | if (!input) throw new Error("Input not found"); 43 | 44 | // Set value with decimal separator 45 | fireEvent.change(input, { target: { value: "1.23" } }); 46 | // Set caret before decimal separator 47 | input.setSelectionRange(1, 1); 48 | // Simulate backspace 49 | fireEvent.keyDown(input, { key: "Backspace" }); 50 | 51 | // Caret should move past decimal separator 52 | expect(input.selectionStart).toBe(2); 53 | }); 54 | 55 | it("should move caret past thousand separator on delete", () => { 56 | const { container } = render(); 57 | const input = container.querySelector("input"); 58 | if (!input) throw new Error("Input not found"); 59 | 60 | // Set value with thousand separator 61 | fireEvent.change(input, { target: { value: "1,234" } }); 62 | // Set caret before thousand separator 63 | input.setSelectionRange(1, 1); 64 | // Simulate delete 65 | fireEvent.keyDown(input, { key: "Delete" }); 66 | 67 | // Caret should move past thousand separator 68 | expect(input.selectionStart).toBe(2); 69 | }); 70 | }); 71 | 72 | describe("onKeyDown", () => { 73 | it("should call correctCaretPosition on backspace", () => { 74 | const { container } = render(); 75 | const input = container.querySelector("input"); 76 | if (!input) throw new Error("Input not found"); 77 | 78 | // Set up the input state 79 | fireEvent.change(input, { target: { value: "1.23" } }); 80 | input.setSelectionRange(1, 1); 81 | 82 | const spy = spyOn(input, "setSelectionRange"); 83 | fireEvent.keyDown(input, { key: "Backspace" }); 84 | expect(spy).toHaveBeenCalled(); 85 | }); 86 | 87 | it("should call correctCaretPosition on delete", () => { 88 | const { container } = render(); 89 | const input = container.querySelector("input"); 90 | if (!input) throw new Error("Input not found"); 91 | 92 | // Set up the input state 93 | fireEvent.change(input, { target: { value: "1,234" } }); 94 | input.setSelectionRange(1, 1); 95 | 96 | const spy = spyOn(input, "setSelectionRange"); 97 | fireEvent.keyDown(input, { key: "Delete" }); 98 | expect(spy).toHaveBeenCalled(); 99 | }); 100 | 101 | it("should call props.onKeyDown if provided", () => { 102 | const onKeyDown = mock(() => {}); 103 | const { container } = render(); 104 | const input = container.querySelector("input"); 105 | if (!input) throw new Error("Input not found"); 106 | 107 | fireEvent.keyDown(input, { key: "A" }); 108 | expect(onKeyDown.mock.calls.length).toBe(1); 109 | }); 110 | }); 111 | 112 | describe("onFocus", () => { 113 | it("should set caret position to end of input", async () => { 114 | const { container } = render(); 115 | const input = container.querySelector("input"); 116 | if (!input) throw new Error("Input not found"); 117 | 118 | // Set initial value 119 | await act(async () => { 120 | fireEvent.change(input, { target: { value: "123.45" } }); 121 | // Set caret at start 122 | input.setSelectionRange(0, 0); 123 | // Trigger focus 124 | fireEvent.focus(input); 125 | // Wait for state updates 126 | await new Promise((resolve) => setTimeout(resolve, 0)); 127 | }); 128 | 129 | // Caret should be at end 130 | expect(input.selectionStart).toBe(input.value.length); 131 | }); 132 | 133 | it("should call props.onFocus if provided", async () => { 134 | const onFocus = mock(() => {}); 135 | const { container } = render(); 136 | const input = container.querySelector("input"); 137 | if (!input) throw new Error("Input not found"); 138 | 139 | await act(async () => { 140 | // Trigger focus 141 | fireEvent.focus(input); 142 | // Wait for state updates 143 | await new Promise((resolve) => setTimeout(resolve, 0)); 144 | }); 145 | 146 | // Check if onFocus was called at least once 147 | expect(onFocus.mock.calls.length).toBeGreaterThan(0); 148 | }); 149 | }); 150 | 151 | describe("onClick", () => { 152 | it("should set caret position to end of input", async () => { 153 | const { container } = render(); 154 | const input = container.querySelector("input"); 155 | if (!input) throw new Error("Input not found"); 156 | 157 | // Set initial value 158 | await act(async () => { 159 | fireEvent.change(input, { target: { value: "123.45" } }); 160 | // Set caret at start 161 | input.setSelectionRange(0, 0); 162 | // Trigger click 163 | fireEvent.click(input); 164 | // Wait for state updates 165 | await new Promise((resolve) => setTimeout(resolve, 0)); 166 | }); 167 | 168 | // Caret should be at end 169 | expect(input.selectionStart).toBe(input.value.length); 170 | }); 171 | 172 | it("should call props.onClick if provided", async () => { 173 | const onClick = mock(() => {}); 174 | const { container } = render(); 175 | const input = container.querySelector("input"); 176 | if (!input) throw new Error("Input not found"); 177 | 178 | await act(async () => { 179 | fireEvent.click(input); 180 | // Wait for state updates 181 | await new Promise((resolve) => setTimeout(resolve, 0)); 182 | }); 183 | 184 | // Check if onClick was called at least once 185 | expect(onClick.mock.calls.length).toBeGreaterThan(0); 186 | }); 187 | }); 188 | }); 189 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # headless-currency-input 2 | 3 | A customizable and feature-rich React component for handling currency input. It supports multiple currencies, formatting, and provides a seamless user experience for handling currency values. 4 | 5 | [Demo](https://codesandbox.io/p/devbox/headless-currency-input-4gwsy8?layout=%257B%2522sidebarPanel%2522%253A%2522EXPLORER%2522%252C%2522rootPanelGroup%2522%253A%257B%2522direction%2522%253A%2522horizontal%2522%252C%2522contentType%2522%253A%2522UNKNOWN%2522%252C%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522id%2522%253A%2522ROOT_LAYOUT%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522UNKNOWN%2522%252C%2522direction%2522%253A%2522vertical%2522%252C%2522id%2522%253A%2522clrjd1qfr00073b6fbm6kur58%2522%252C%2522sizes%2522%253A%255B70%252C30%255D%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522EDITOR%2522%252C%2522direction%2522%253A%2522horizontal%2522%252C%2522id%2522%253A%2522EDITOR%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522EDITOR%2522%252C%2522id%2522%253A%2522clrjd1qfr00023b6fxtdpd1ub%2522%257D%255D%257D%252C%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522SHELLS%2522%252C%2522direction%2522%253A%2522horizontal%2522%252C%2522id%2522%253A%2522SHELLS%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522SHELLS%2522%252C%2522id%2522%253A%2522clrjd1qfr00043b6fw8s5c78s%2522%257D%255D%252C%2522sizes%2522%253A%255B100%255D%257D%255D%257D%252C%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522DEVTOOLS%2522%252C%2522direction%2522%253A%2522vertical%2522%252C%2522id%2522%253A%2522DEVTOOLS%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522DEVTOOLS%2522%252C%2522id%2522%253A%2522clrjd1qfr00063b6f3nqon92r%2522%257D%255D%252C%2522sizes%2522%253A%255B100%255D%257D%255D%252C%2522sizes%2522%253A%255B50%252C50%255D%257D%252C%2522tabbedPanels%2522%253A%257B%2522clrjd1qfr00023b6fxtdpd1ub%2522%253A%257B%2522id%2522%253A%2522clrjd1qfr00023b6fxtdpd1ub%2522%252C%2522tabs%2522%253A%255B%257B%2522id%2522%253A%2522clrjd1qfq00013b6fkj0m7iz2%2522%252C%2522mode%2522%253A%2522permanent%2522%252C%2522type%2522%253A%2522FILE%2522%252C%2522filepath%2522%253A%2522%252FREADME.md%2522%252C%2522state%2522%253A%2522IDLE%2522%257D%255D%252C%2522activeTabId%2522%253A%2522clrjd1qfq00013b6fkj0m7iz2%2522%257D%252C%2522clrjd1qfr00063b6f3nqon92r%2522%253A%257B%2522id%2522%253A%2522clrjd1qfr00063b6f3nqon92r%2522%252C%2522tabs%2522%253A%255B%257B%2522type%2522%253A%2522TASK_PORT%2522%252C%2522port%2522%253A5173%252C%2522taskId%2522%253A%2522dev%2522%252C%2522id%2522%253A%2522clrjexs5h004g3b6e0o88gjpk%2522%252C%2522mode%2522%253A%2522permanent%2522%252C%2522path%2522%253A%2522%252F%2522%257D%255D%252C%2522activeTabId%2522%253A%2522clrjexs5h004g3b6e0o88gjpk%2522%257D%252C%2522clrjd1qfr00043b6fw8s5c78s%2522%253A%257B%2522id%2522%253A%2522clrjd1qfr00043b6fw8s5c78s%2522%252C%2522tabs%2522%253A%255B%257B%2522id%2522%253A%2522clrjd1qfr00033b6f8r1fjnbn%2522%252C%2522mode%2522%253A%2522permanent%2522%252C%2522type%2522%253A%2522TASK_LOG%2522%252C%2522taskId%2522%253A%2522dev%2522%257D%255D%252C%2522activeTabId%2522%253A%2522clrjd1qfr00033b6f8r1fjnbn%2522%257D%257D%252C%2522showDevtools%2522%253Atrue%252C%2522showShells%2522%253Atrue%252C%2522showSidebar%2522%253Atrue%252C%2522sidebarPanelSize%2522%253A15%257D) 6 | 7 | https://github.com/danestves/headless-currency-input/assets/31737273/5f32f037-b2fd-423e-8a94-6cf22f9ff22e 8 | 9 | > 👋 Hello there! Follow me [@danestves](https://twitter.com/danestves) or visit [danestves.com](https://danestves.com) for more cool projects like this one. 10 | 11 | ## Features 12 | 13 | - 🌍 Supports multiple currencies (thanks to [@sumup/intl](https://github.com/sumup-oss/intl-js)) 14 | - 📝 Formats currency values (thanks to [react-number-format](https://github.com/s-yadav/react-number-format)) 15 | - 📱 Mobile friendly 16 | - 🎨 Customizable 17 | - 📦 Tiny bundle size 18 | 19 | ## 🏃 Getting started 20 | 21 | ### Install 22 | 23 | ```bash 24 | npm install headless-currency-input 25 | # or 26 | yarn add headless-currency-input 27 | # or 28 | pnpm add headless-currency-input 29 | # or 30 | bun add headless-currency-input 31 | ``` 32 | 33 | ### Usage 34 | 35 | ```tsx 36 | import React from 'react'; 37 | import { CurrencyInput } from 'headless-currency-input'; 38 | 39 | const App = () => { 40 | const [values, setValue] = React.useState(245698189); 41 | 42 | return ( 43 | { 46 | console.log(values); 47 | 48 | /** 49 | * Will output: 50 | * 51 | * { 52 | * formattedValue: "$ 2,456,981.89", 53 | * value: '2456981.89', 54 | * floatValue: 2456981.89, 55 | * } 56 | */ 57 | }} 58 | currency="USD" 59 | locale="en-US" 60 | /> 61 | ); 62 | }; 63 | ``` 64 | 65 | ## API Reference 66 | 67 | ### `currency` 68 | 69 | *default:* `USD` 70 | 71 | The currency to use. Must be a valid currency code based on [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217). 72 | 73 | ### `locale` 74 | 75 | *default:* `en` 76 | 77 | The locale to use. Must be a valid locale based on [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes). 78 | 79 | ### displayType `text | input` 80 | 81 | *default:* `input` 82 | 83 | If value is `input`, it renders an input element where formatting happens as you type characters. If value is text, it renders formatted `text` in a span tag. 84 | 85 | ```tsx 86 | import { CurrencyInput } from 'headless-currency-input'; 87 | 88 | ; 89 | ; 90 | ``` 91 | 92 | > [!NOTE] 93 | > More info https://s-yadav.github.io/react-number-format/docs/props#displaytype-text--input 94 | 95 | ### getInputRef `elm => void` 96 | 97 | *default:* `null` 98 | 99 | Method to get reference of input, span (based on displayType prop) or the customInput's reference. 100 | 101 | ```tsx 102 | import { CurrencyInput } from 'headless-currency-input'; 103 | import { useRef } from 'react'; 104 | 105 | export default function App() { 106 | let ref = useRef(); 107 | return ; 108 | } 109 | ``` 110 | 111 | > [!NOTE] 112 | > More info https://s-yadav.github.io/react-number-format/docs/props#getinputref-elm--void 113 | 114 | ### isAllowed `(values) => boolean` 115 | 116 | *default:* `undefined` 117 | 118 | A checker function to validate the input value. If this function returns false, the onChange method will not get triggered and the input value will not change. 119 | 120 | ```tsx 121 | import { CurrencyInput } from 'headless-currency-input'; 122 | 123 | const MAX_LIMIT = 1000; 124 | 125 | { 128 | const { floatValue } = values; 129 | return floatValue < MAX_LIMIT; 130 | }} 131 | />; 132 | ``` 133 | 134 | > [!NOTE] 135 | > More info https://s-yadav.github.io/react-number-format/docs/props#isallowed-values--boolean 136 | 137 | ### onValueChange `(values, sourceInfo) => {}` 138 | 139 | *default:* `undefined` 140 | 141 | This handler provides access to any values changes in the input field and is triggered only when a prop changes or the user input changes. It provides two arguments namely the [valueObject](https://s-yadav.github.io/react-number-format/docs/quirks#values-object) as the first and the [sourceInfo](https://s-yadav.github.io/react-number-format/docs/quirks#sourceInfo) as the second. The [valueObject](https://s-yadav.github.io/react-number-format/docs/quirks#values-object) parameter contains the `formattedValue`, `value` and the `floatValue` of the given input field. The [sourceInfo](https://s-yadav.github.io/react-number-format/docs/quirks#sourceInfo) contains the `event` Object and a `source` key which indicates whether the triggered change is due to an event or a prop change. This is particularly useful in identify whether the change is user driven or is an uncontrolled change due to any prop value being updated. 142 | 143 | ```tsx 144 | import { CurrencyInput } from 'headless-currency-input'; 145 | 146 | { 149 | console.log(values, sourceInfo); 150 | }} 151 | />; 152 | ``` 153 | 154 | > [!NOTE] 155 | > More info https://s-yadav.github.io/react-number-format/docs/props#onvaluechange-values-sourceinfo-- 156 | 157 | ### renderText `(formattedValue, customProps) => React Element` 158 | 159 | *default:* `undefined` 160 | 161 | A renderText method useful if you want to render formattedValue in different element other than span. It also returns the custom props that are added to the component which can allow passing down props to the rendered element. 162 | 163 | ```tsx 164 | import { CurrencyInput } from 'headless-currency-input'; 165 | 166 | {value}} 170 | />; 171 | ``` 172 | 173 | > [!NOTE] 174 | > More info https://s-yadav.github.io/react-number-format/docs/props#rendertext-formattedvalue-customprops--react-element 175 | 176 | --- 177 | 178 | > [!TIP] 179 | > Other than this it accepts all the props which can be given to a input or span based on displayType you selected. 180 | 181 | --------------------------------------------------------------------------------