From 8d36750bddd1d50fe396e417232ab01c9bdc4e77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Sim=C3=A3o?= Date: Fri, 29 Sep 2023 15:12:46 +0100 Subject: [PATCH] feat(hooks): add use-currency-formatter --- .storybook/main.ts | 4 +- .../src/TokenInput/BaseTokenInput.tsx | 5 +- packages/components/src/utils/format.ts | 18 ---- packages/hooks/package.json | 3 + packages/hooks/src/index.ts | 1 + .../__tests__/use-currency-formatter.test.tsx | 86 +++++++++++++++++++ .../src/use-currency-formatter/index.tsx | 2 + .../use-currency-formatter.stories.tsx | 81 +++++++++++++++++ .../use-currency-formatter.tsx | 75 ++++++++++++++++ ...eForm.stories.tsx => use-form.stories.tsx} | 0 pnpm-lock.yaml | 13 +++ 11 files changed, 266 insertions(+), 22 deletions(-) delete mode 100644 packages/components/src/utils/format.ts create mode 100644 packages/hooks/src/use-currency-formatter/__tests__/use-currency-formatter.test.tsx create mode 100644 packages/hooks/src/use-currency-formatter/index.tsx create mode 100644 packages/hooks/src/use-currency-formatter/stories/use-currency-formatter.stories.tsx create mode 100644 packages/hooks/src/use-currency-formatter/use-currency-formatter.tsx rename packages/hooks/src/use-form/stories/{useForm.stories.tsx => use-form.stories.tsx} (100%) 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/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 b3f9732af..3241eeaed 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..3f7af78ad --- /dev/null +++ b/packages/hooks/src/use-currency-formatter/stories/use-currency-formatter.stories.tsx @@ -0,0 +1,81 @@ +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/useCurrencyFormatter', + parameters: { + layout: 'centered' + }, + args: { + style: { minWidth: 300 } + }, + render: Render +}; + +export const Default: 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..c61508005 --- /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, compact]); + + const standard = useMemo(() => { + const formatter = new NumberFormatter(locale, { + style: 'currency', + currency, + minimumFractionDigits: 2 + }); + + return formatter; + }, [locale, options, compact]); + + 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