diff --git a/.storybook/main.ts b/.storybook/main.ts index 3b113ce65..d3d91342e 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -12,8 +12,8 @@ function getAbsolutePath(value: string): any { const config: StorybookConfig = { stories: [ '../packages/components/src/**/*.stories.@(js|jsx|mjs|ts|tsx)', - '../packages/icons/**/src/stories/*.stories.@(js|jsx|mjs|ts|tsx)', - '../packages/hooks/src/**/*.stories.@(js|jsx|mjs|ts|tsx)' + '../packages/hooks/src/**/*.stories.@(js|jsx|mjs|ts|tsx)', + '../packages/icons/**/src/stories/*.stories.@(js|jsx|mjs|ts|tsx)' ], addons: [ getAbsolutePath('@storybook/addon-links'), diff --git a/packages/components/src/TokenInput/BaseTokenInput.tsx b/packages/components/src/TokenInput/BaseTokenInput.tsx index b6d4b82d9..4e01ac05f 100644 --- a/packages/components/src/TokenInput/BaseTokenInput.tsx +++ b/packages/components/src/TokenInput/BaseTokenInput.tsx @@ -1,10 +1,10 @@ import { useLabel } from '@react-aria/label'; import { mergeProps } from '@react-aria/utils'; import { forwardRef, ReactNode } from 'react'; +import { useCurrencyFormatter } from '@interlay/hooks'; import { Flex } from '../Flex'; import { NumberInput, NumberInputProps } from '../NumberInput'; -import { formatUSD } from '../utils/format'; import { TokenInputLabel } from './TokenInputLabel'; import { StyledUSDAdornment } from './TokenInput.style'; @@ -36,12 +36,13 @@ const BaseTokenInput = forwardRef( }, ref ): JSX.Element => { + const format = useCurrencyFormatter(); const { labelProps, fieldProps } = useLabel({ label, ...props }); const hasLabel = !!label || !!balance; const bottomAdornment = valueUSD !== undefined && ( - {formatUSD(valueUSD, { compact: true })} + {format(valueUSD)} ); return ( diff --git a/packages/components/src/TokenInput/TokenListItem.tsx b/packages/components/src/TokenInput/TokenListItem.tsx index ab545b6ac..33745a61f 100644 --- a/packages/components/src/TokenInput/TokenListItem.tsx +++ b/packages/components/src/TokenInput/TokenListItem.tsx @@ -1,4 +1,5 @@ -import { formatUSD } from '../utils/format'; +import { useCurrencyFormatter } from '@interlay/hooks'; + import { CoinIcon } from '../CoinIcon'; import { Flex } from '../Flex'; import { useSelectModalContext } from '../Select/SelectModalContext'; @@ -11,6 +12,7 @@ type TokenListItemProps = { isDisabled?: boolean } & TokenData; const TokenListItem = ({ balance, balanceUSD, value, tickers, isDisabled }: TokenListItemProps): JSX.Element => { const isSelected = useSelectModalContext().selectedItem?.key === value && !isDisabled; + const format = useCurrencyFormatter(); return ( <> @@ -21,7 +23,7 @@ const TokenListItem = ({ balance, balanceUSD, value, tickers, isDisabled }: Toke {balance} - {formatUSD(balanceUSD, { compact: true })} + {format(balanceUSD)} diff --git a/packages/components/src/utils/format.ts b/packages/components/src/utils/format.ts deleted file mode 100644 index e71f8dea8..000000000 --- a/packages/components/src/utils/format.ts +++ /dev/null @@ -1,18 +0,0 @@ -const getFormatUSDNotation = (amount: number) => { - const amountLength = amount.toFixed(0).length; - - return amountLength >= 6 ? 'compact' : 'standard'; -}; - -// TODO: use react-aria i18n utils and make handle 8 demicals usd -const formatUSD = (amount: number, options?: { compact?: boolean }): string => { - const numberFormat = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - notation: options?.compact ? getFormatUSDNotation(amount) : undefined - }); - - return numberFormat.format(amount); -}; - -export { formatUSD }; diff --git a/packages/hooks/package.json b/packages/hooks/package.json index 39dd7a19a..86eabd441 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -36,7 +36,10 @@ }, "dependencies": { "@interlay/theme": "workspace:*", + "@internationalized/number": "^3.2.1", + "@react-aria/i18n": "^3.8.1", "@react-aria/utils": "^3.19.0", + "decimal.js-light": "^2.5.1", "formik": "^2.4.5", "react-use": "^17.4.0" }, diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index 1518eee2f..6b40e33ff 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -3,3 +3,4 @@ export { useStyleProps } from './use-style-props'; export * from './use-form'; export type { MarginProps, StyleProps, UseStylePropsResult, StyledMarginProps } from './use-style-props'; export { useDOMRef } from './use-dom-ref'; +export * from './use-currency-formatter'; diff --git a/packages/hooks/src/use-currency-formatter/__tests__/use-currency-formatter.test.tsx b/packages/hooks/src/use-currency-formatter/__tests__/use-currency-formatter.test.tsx new file mode 100644 index 000000000..28fa6e6f8 --- /dev/null +++ b/packages/hooks/src/use-currency-formatter/__tests__/use-currency-formatter.test.tsx @@ -0,0 +1,86 @@ +import { renderHook } from '@testing-library/react'; + +import { useCurrencyFormatter } from '../use-currency-formatter'; + +describe('useCurrencyFormatter', () => { + describe('simple integer', () => { + it('should format 0 to $0.00', () => { + const { result } = renderHook(() => useCurrencyFormatter()); + + expect(result.current(0)).toBe('$0.00'); + }); + + it('should format 1 to $1.00', () => { + const { result } = renderHook(() => useCurrencyFormatter()); + + expect(result.current(1)).toBe('$1.00'); + }); + + it('should format 1.0001 to $1.00', () => { + const { result } = renderHook(() => useCurrencyFormatter()); + + expect(result.current(1.0001)).toBe('$1.00'); + }); + + it('should format 1.0001 to $1.00', () => { + const { result } = renderHook(() => useCurrencyFormatter()); + + expect(result.current(1.0001)).toBe('$1.00'); + }); + }); + + describe('decimals', () => { + it('should format 0.1 to $0.100', () => { + const { result } = renderHook(() => useCurrencyFormatter()); + + expect(result.current(0.1)).toBe('$0.100'); + }); + + it('should format 0.0001 to $0.0001', () => { + const { result } = renderHook(() => useCurrencyFormatter()); + + expect(result.current(0.0001)).toBe('$0.0001'); + }); + + it('should format 0.0000000009 to $0.0000000009', () => { + const { result } = renderHook(() => useCurrencyFormatter()); + + expect(result.current(0.0000000009)).toBe('$0.0000000009'); + }); + + it('should format 0.00000000009 to $0.00000000009', () => { + const { result } = renderHook(() => useCurrencyFormatter()); + + expect(result.current(0.00000000009)).toBe('<$0.00000001'); + }); + }); + + describe('big integer', () => { + it('should format 100000 to $100,000.00', () => { + const { result } = renderHook(() => useCurrencyFormatter()); + + expect(result.current(100000)).toBe('$100,000.00'); + }); + + it('should format 1000000 to $1M', () => { + const { result } = renderHook(() => useCurrencyFormatter()); + + expect(result.current(1000000)).toBe('$1M'); + }); + }); + + it('should not compact', () => { + const { result } = renderHook(() => useCurrencyFormatter({ compact: false })); + + expect(result.current(0)).toBe('$0.00'); + expect(result.current(0.001)).toBe('$0.00'); + expect(result.current(1)).toBe('$1.00'); + expect(result.current(1000000)).toBe('$1,000,000.00'); + }); + + it('should format EUR', () => { + const { result } = renderHook(() => useCurrencyFormatter({ currency: 'EUR' })); + + expect(result.current(0)).toBe('€0.00'); + }); +}); diff --git a/packages/hooks/src/use-currency-formatter/index.tsx b/packages/hooks/src/use-currency-formatter/index.tsx new file mode 100644 index 000000000..694b6d0dc --- /dev/null +++ b/packages/hooks/src/use-currency-formatter/index.tsx @@ -0,0 +1,2 @@ +export { useCurrencyFormatter } from './use-currency-formatter'; +export type { UseCurrencyFormatterProps } from './use-currency-formatter'; diff --git a/packages/hooks/src/use-currency-formatter/stories/use-currency-formatter.stories.tsx b/packages/hooks/src/use-currency-formatter/stories/use-currency-formatter.stories.tsx new file mode 100644 index 000000000..45d1fee15 --- /dev/null +++ b/packages/hooks/src/use-currency-formatter/stories/use-currency-formatter.stories.tsx @@ -0,0 +1,133 @@ +import { StoryObj } from '@storybook/react'; + +import { useCurrencyFormatter } from '../use-currency-formatter'; + +const Render = () => { + const format = useCurrencyFormatter(); + + return ( +
+
+

Digits

+ + 1 digit: {format(1.6801231)} + +
+ + 2 digit: {format(10.0001231)} + +
+ + 3 digit: {format(100.0001231)} + +
+ + 4 digit: {format(1000.0001231)} + +
+ + 5 digit: {format(10000.0001231)} + +
+ + 6 digit: {format(100000.0001231)} + +
+ + 7 digit: {format(1000000.0001231)} + +
+ + 8 digit: {format(10000000.0001231)} + +
+ + 9 digit: {format(100000000.0001231)} + +
+ + 10 digit: {format(1000000000.0001231)} + +
+ + 11 digit: {format(10000000000.0001231)} + +
+ + 12 digit: {format(100000000000.0001231)} + +
+ + 13 digit: {format(1000000000000.0001231)} + +
+
+

Decimals

+ + 1 decimal: {format(0.1)} + +
+ + 2 decimal: {format(0.01)} + +
+ + 3 decimal: {format(0.001)} + +
+ + 4 decimal: {format(0.0001)} + +
+ + 5 decimal: {format(0.00001)} + +
+ + 6 decimal: {format(0.000001)} + +
+ + 7 decimal: {format(0.0000001)} + +
+ + 8 decimal: {format(0.00000001)} + +
+ + 9 decimal: {format(0.000000001)} + +
+ + 10 decimal: {format(0.0000000001)} + +
+ + 11 decimal: {format(0.00000000001)} + +
+ + 12 decimal: {format(0.000000000001)} + +
+ + 13 decimal: {format(0.0000000000001)} + +
+
+ ); +}; + +export default { + title: 'Hooks', + parameters: { + layout: 'centered' + }, + args: { + style: { minWidth: 300 } + }, + render: Render +}; + +export const UseCurrencyFormatter: StoryObj = {}; diff --git a/packages/hooks/src/use-currency-formatter/use-currency-formatter.tsx b/packages/hooks/src/use-currency-formatter/use-currency-formatter.tsx new file mode 100644 index 000000000..23d207fd2 --- /dev/null +++ b/packages/hooks/src/use-currency-formatter/use-currency-formatter.tsx @@ -0,0 +1,75 @@ +import { useLocale } from '@react-aria/i18n'; +import { useMemo } from 'react'; +import { NumberFormatter } from '@internationalized/number'; +import Decimal from 'decimal.js-light'; + +const decimalLimit = 0.00000000009; +const overDecimalLimitIndicator = 0.00000001; + +type UseCurrencyFormatterProps = { + currency?: string; + compact?: boolean; +}; + +type UseCurrencyFormatterResult = NumberFormatter['format']; + +const useCurrencyFormatter = (options: UseCurrencyFormatterProps = {}): UseCurrencyFormatterResult => { + let { locale } = useLocale(); + + const { compact: compactProp = true, currency = 'USD' } = options; + + const compact = useMemo( + () => + new NumberFormatter(locale, { + style: 'currency', + currency, + notation: 'compact' + }), + [locale, options] + ); + + const decimal = useMemo(() => { + const formatter = new NumberFormatter(locale, { + style: 'currency', + currency, + minimumFractionDigits: 3, + maximumFractionDigits: 11 + }); + + return formatter; + }, [locale, options]); + + const standard = useMemo(() => { + const formatter = new NumberFormatter(locale, { + style: 'currency', + currency, + minimumFractionDigits: 2 + }); + + return formatter; + }, [locale, options]); + + return (value) => { + if (!compactProp || value === 0) { + return standard.format(value); + } + + // checks if decimal is lower than the limit + if (new Decimal(value).lte(decimalLimit)) { + return `<${decimal.format(overDecimalLimitIndicator)}`; + } + + const length = value.toFixed(0).length; + + const isOnlyDecimal = length === 1 && value < 1; + + if (isOnlyDecimal) { + return decimal.format(value); + } + + return length > 6 ? compact.format(value) : standard.format(value); + }; +}; + +export { useCurrencyFormatter }; +export type { UseCurrencyFormatterProps }; diff --git a/packages/hooks/src/use-form/stories/useForm.stories.tsx b/packages/hooks/src/use-form/stories/use-form.stories.tsx similarity index 100% rename from packages/hooks/src/use-form/stories/useForm.stories.tsx rename to packages/hooks/src/use-form/stories/use-form.stories.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1b130fc4..3ce24c2fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -362,9 +362,18 @@ importers: '@interlay/theme': specifier: workspace:* version: link:../core/theme + '@internationalized/number': + specifier: ^3.2.1 + version: 3.2.1 + '@react-aria/i18n': + specifier: ^3.8.1 + version: 3.8.2(react@18.2.0) '@react-aria/utils': specifier: ^3.19.0 version: 3.20.0(react@18.2.0) + decimal.js-light: + specifier: ^2.5.1 + version: 2.5.1 formik: specifier: ^2.4.5 version: 2.4.5(react@18.2.0) @@ -7547,6 +7556,10 @@ packages: engines: {node: '>=0.10.0'} dev: true + /decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + dev: false + /decimal.js@10.4.3: resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} dev: true