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'}