diff --git a/dapp/package.json b/dapp/package.json
index a3dfaea79..383bc7456 100644
--- a/dapp/package.json
+++ b/dapp/package.json
@@ -22,7 +22,8 @@
"@ledgerhq/wallet-api-client-react": "^1.1.2",
"framer-motion": "^10.16.5",
"react": "^18.2.0",
- "react-dom": "^18.2.0"
+ "react-dom": "^18.2.0",
+ "react-number-format": "^5.3.1"
},
"devDependencies": {
"@thesis-co/eslint-config": "^0.6.1",
diff --git a/dapp/src/components/shared/NumberFormatInput/index.tsx b/dapp/src/components/shared/NumberFormatInput/index.tsx
new file mode 100644
index 000000000..a7792f156
--- /dev/null
+++ b/dapp/src/components/shared/NumberFormatInput/index.tsx
@@ -0,0 +1,47 @@
+import React from "react"
+import { NumericFormat } from "react-number-format"
+import { InputProps, chakra, useMultiStyleConfig } from "@chakra-ui/react"
+
+const ChakraWrapper = chakra(NumericFormat)
+
+export type NumberFormatInputValues = {
+ formattedValue: string
+ value: string
+ floatValue: number
+}
+
+type NumberFormatInputProps = {
+ onValueChange: (values: NumberFormatInputValues) => void
+ decimalScale?: number
+} & InputProps
+
+/**
+ * Component is from the Threshold Network React Components repository.
+ * It has been used because it supports the thousandth separator
+ * and can be easily integrated with Chakra UI.
+ *
+ * More info:
+ * https://github.com/threshold-network/components/blob/main/src/components/NumberFormatInput/index.tsx
+ */
+const NumberFormatInput = React.forwardRef<
+ HTMLInputElement,
+ NumberFormatInputProps
+>((props, ref) => {
+ const { field: css } = useMultiStyleConfig("Input", props)
+
+ const { decimalScale, isDisabled, ...restProps } = props
+
+ return (
+
+ )
+})
+
+export default NumberFormatInput
diff --git a/dapp/src/components/shared/TokenBalanceInput/index.tsx b/dapp/src/components/shared/TokenBalanceInput/index.tsx
new file mode 100644
index 000000000..41de72438
--- /dev/null
+++ b/dapp/src/components/shared/TokenBalanceInput/index.tsx
@@ -0,0 +1,121 @@
+import React, { useMemo } from "react"
+import {
+ Box,
+ Button,
+ HStack,
+ InputGroup,
+ InputProps,
+ InputRightElement,
+ createStylesContext,
+ useMultiStyleConfig,
+} from "@chakra-ui/react"
+import { fixedPointNumberToString } from "../../../utils"
+import { CurrencyType } from "../../../types"
+import { CURRENCIES_BY_TYPE } from "../../../constants"
+import NumberFormatInput, {
+ NumberFormatInputValues,
+} from "../NumberFormatInput"
+import { CurrencyBalance } from "../CurrencyBalance"
+
+const VARIANT = "balance"
+const [StylesProvider, useStyles] = createStylesContext("TokenBalanceInput")
+
+type HelperErrorTextProps = {
+ errorMsgText?: string | JSX.Element
+ hasError?: boolean
+ helperText?: string | JSX.Element
+}
+
+function HelperErrorText({
+ helperText,
+ errorMsgText,
+ hasError,
+}: HelperErrorTextProps) {
+ const styles = useStyles()
+ const text = hasError ? errorMsgText : helperText
+
+ if (!text) return null
+
+ return (
+
+ {text}
+
+ )
+}
+
+type TokenBalanceInputProps = {
+ amount?: string
+ currencyType: CurrencyType
+ tokenBalance: string | number
+ placeholder?: string
+ size?: "lg" | "md"
+ setAmount: (value: string) => void
+} & InputProps &
+ HelperErrorTextProps
+
+export default function TokenBalanceInput({
+ amount,
+ currencyType,
+ tokenBalance,
+ placeholder,
+ size = "lg",
+ setAmount,
+ errorMsgText,
+ helperText,
+ hasError = false,
+ ...inputProps
+}: TokenBalanceInputProps) {
+ const styles = useMultiStyleConfig("TokenBalanceInput", { size })
+
+ const tokenBalanceAmount = useMemo(() => {
+ const currency = CURRENCIES_BY_TYPE[currencyType]
+ return fixedPointNumberToString(
+ BigInt(tokenBalance || 0),
+ currency.decimals,
+ )
+ }, [currencyType, tokenBalance])
+
+ return (
+
+
+
+ Amount
+
+
+
+ Balance
+
+
+
+
+
+
+ setAmount(values.value)
+ }
+ {...inputProps}
+ />
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/dapp/src/theme/Input.ts b/dapp/src/theme/Input.ts
new file mode 100644
index 000000000..3bc2aeb25
--- /dev/null
+++ b/dapp/src/theme/Input.ts
@@ -0,0 +1,40 @@
+import { inputAnatomy as parts } from "@chakra-ui/anatomy"
+import { createMultiStyleConfigHelpers, defineStyle } from "@chakra-ui/react"
+
+const { definePartsStyle, defineMultiStyleConfig } =
+ createMultiStyleConfigHelpers(parts.keys)
+
+const variantBalanceField = defineStyle({
+ border: "1px solid",
+ borderColor: "gold.300",
+ color: "grey.700",
+ fontWeight: "bold",
+ bg: "opacity.white.5",
+ paddingRight: 20,
+ // TODO: Set the color correctly without using the chakra variable.
+ caretColor: "var(--chakra-colors-brand-400)",
+
+ _placeholder: {
+ color: "grey.300",
+ fontWeight: "medium",
+ },
+})
+
+const variantBalanceElement = defineStyle({
+ h: "100%",
+ width: 14,
+ mr: 2,
+})
+
+const variantBalance = definePartsStyle({
+ field: variantBalanceField,
+ element: variantBalanceElement,
+})
+
+const variants = {
+ balance: variantBalance,
+}
+
+const Input = defineMultiStyleConfig({ variants })
+
+export default Input
diff --git a/dapp/src/theme/TokenBalanceInput.ts b/dapp/src/theme/TokenBalanceInput.ts
new file mode 100644
index 000000000..5e25e3c4a
--- /dev/null
+++ b/dapp/src/theme/TokenBalanceInput.ts
@@ -0,0 +1,99 @@
+import { createMultiStyleConfigHelpers, defineStyle } from "@chakra-ui/react"
+
+const PARTS = [
+ "container",
+ "labelContainer",
+ "label",
+ "balance",
+ "helperText",
+ "errorMsgText",
+]
+
+const { defineMultiStyleConfig, definePartsStyle } =
+ createMultiStyleConfigHelpers(PARTS)
+
+const baseStyleContainer = defineStyle({
+ display: "flex",
+ flexDirection: "column",
+ gap: 1,
+})
+
+const baseStyleLabelContainer = defineStyle({
+ display: "flex",
+ justifyContent: "space-between",
+})
+
+const baseStyleLabel = defineStyle({
+ fontWeight: "semibold",
+})
+
+const baseStyleBalance = defineStyle({
+ fontWeight: "normal",
+ color: "grey.500",
+})
+
+const baseStyleHelperText = defineStyle({
+ fontWeight: "normal",
+ color: "grey.500",
+})
+
+const baseStyleErrorMsgText = defineStyle({
+ fontWeight: "normal",
+ color: "red.400",
+})
+
+const baseStyle = definePartsStyle({
+ container: baseStyleContainer,
+ labelContainer: baseStyleLabelContainer,
+ label: baseStyleLabel,
+ balance: baseStyleBalance,
+ helperText: baseStyleHelperText,
+ errorMsgText: baseStyleErrorMsgText,
+})
+
+const sizeMd = definePartsStyle({
+ label: {
+ fontSize: "sm",
+ lineHeight: "sm",
+ },
+ balance: {
+ fontSize: "sm",
+ lineHeight: "sm",
+ },
+ helperText: {
+ fontSize: "sm",
+ lineHeight: "sm",
+ },
+ errorMsgText: {
+ fontSize: "sm",
+ lineHeight: "sm",
+ },
+})
+
+const sizeLg = definePartsStyle({
+ label: {
+ fontSize: "md",
+ lineHeight: "md",
+ },
+ balance: {
+ fontSize: "md",
+ lineHeight: "md",
+ },
+ helperText: {
+ fontSize: "sm",
+ lineHeight: "sm",
+ },
+ errorMsgText: {
+ fontSize: "sm",
+ lineHeight: "sm",
+ },
+})
+
+const sizes = {
+ md: sizeMd,
+ lg: sizeLg,
+}
+
+const TokenBalanceInput = defineMultiStyleConfig({ baseStyle, sizes })
+
+export default TokenBalanceInput
diff --git a/dapp/src/theme/index.ts b/dapp/src/theme/index.ts
index 024a55b1f..68afde1dd 100644
--- a/dapp/src/theme/index.ts
+++ b/dapp/src/theme/index.ts
@@ -7,6 +7,8 @@ import Tooltip from "./Tooltip"
import { colors, fonts, lineHeights } from "./utils"
import Heading from "./Heading"
import CurrencyBalance from "./CurrencyBalance"
+import TokenBalanceInput from "./TokenBalanceInput"
+import Input from "./Input"
const defaultTheme = {
colors,
@@ -28,6 +30,8 @@ const defaultTheme = {
CurrencyBalance,
Card,
Tooltip,
+ Input,
+ TokenBalanceInput,
},
}
diff --git a/dapp/src/utils/numbers.ts b/dapp/src/utils/numbers.ts
index d2de47cca..e3c9077c4 100644
--- a/dapp/src/utils/numbers.ts
+++ b/dapp/src/utils/numbers.ts
@@ -65,3 +65,42 @@ export const formatSatoshiAmount = (
amount: number | string,
desiredDecimals = 2,
) => formatTokenAmount(amount, 8, desiredDecimals)
+
+/**
+ * Converts a fixed point number with a bigint amount and a decimals field
+ * indicating the orders of magnitude in `amount` behind the decimal point into
+ * a string in US decimal format (no thousands separators, . for the decimal
+ * separator).
+ *
+ * Used in cases where precision is critical.
+ *
+ * This function is based on the solution used by the Taho extension.
+ * More info: https://github.com/tahowallet/extension/blob/main/background/lib/fixed-point.ts#L172-L214
+ */
+export function fixedPointNumberToString(
+ amount: bigint,
+ decimals: number,
+ trimTrailingZeros = true,
+): string {
+ const undecimaledAmount = amount.toString()
+ const preDecimalLength = undecimaledAmount.length - decimals
+
+ const preDecimalCharacters =
+ preDecimalLength > 0
+ ? undecimaledAmount.substring(0, preDecimalLength)
+ : "0"
+ const postDecimalCharacters =
+ "0".repeat(Math.max(-preDecimalLength, 0)) +
+ undecimaledAmount.substring(preDecimalLength)
+
+ const trimmedPostDecimalCharacters = trimTrailingZeros
+ ? postDecimalCharacters.replace(/0*$/, "")
+ : postDecimalCharacters
+
+ const decimalString =
+ trimmedPostDecimalCharacters.length > 0
+ ? `.${trimmedPostDecimalCharacters}`
+ : ""
+
+ return `${preDecimalCharacters}${decimalString}`
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2ef71f0cc..042be719e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -135,6 +135,9 @@ importers:
react-dom:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
+ react-number-format:
+ specifier: ^5.3.1
+ version: 5.3.1(react-dom@18.2.0)(react@18.2.0)
devDependencies:
'@thesis-co/eslint-config':
specifier: ^0.6.1
@@ -13099,6 +13102,17 @@ packages:
/react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
+ /react-number-format@5.3.1(react-dom@18.2.0)(react@18.2.0):
+ resolution: {integrity: sha512-qpYcQLauIeEhCZUZY9jXZnnroOtdy3jYaS1zQ3M1Sr6r/KMOBEIGNIb7eKT19g2N1wbYgFgvDzs19hw5TrB8XQ==}
+ peerDependencies:
+ react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
+ react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
+ dependencies:
+ prop-types: 15.8.1
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ dev: false
+
/react-refresh@0.14.0:
resolution: {integrity: sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==}
engines: {node: '>=0.10.0'}