diff --git a/src/fund/components/FundCard.test.tsx b/src/fund/components/FundCard.test.tsx index 80dd39a8f4..2b68810528 100644 --- a/src/fund/components/FundCard.test.tsx +++ b/src/fund/components/FundCard.test.tsx @@ -20,7 +20,7 @@ import { FundCard } from './FundCard'; import { FundCardProvider, useFundContext } from './FundCardProvider'; const mockUpdateInputWidth = vi.fn(); -vi.mock('../hooks/useInputResize', () => ({ +vi.mock('../../internal/hooks/useInputResize', () => ({ useInputResize: () => mockUpdateInputWidth, })); diff --git a/src/fund/components/FundCardAmountInput.test.tsx b/src/fund/components/FundCardAmountInput.test.tsx index 6f41c59099..8d6d52b73f 100644 --- a/src/fund/components/FundCardAmountInput.test.tsx +++ b/src/fund/components/FundCardAmountInput.test.tsx @@ -125,7 +125,7 @@ describe('FundCardAmountInput', () => { ); }); - const container = screen.getByTestId('ockFundCardAmountInputContainer'); + const container = screen.getByTestId('ockAmountInputContainer'); expect(container).toHaveClass('custom-class'); }); @@ -248,7 +248,7 @@ describe('FundCardAmountInput', () => { callback([ { contentRect: { width: 300 }, - target: screen.getByTestId('ockFundCardAmountInputContainer'), + target: screen.getByTestId('ockAmountInputContainer'), }, ]); return { @@ -265,7 +265,7 @@ describe('FundCardAmountInput', () => { ); const input = screen.getByTestId('ockTextInput_Input'); - const container = screen.getByTestId('ockFundCardAmountInputContainer'); + const container = screen.getByTestId('ockAmountInputContainer'); // Mock getBoundingClientRect for container and currency label Object.defineProperty(container, 'getBoundingClientRect', { diff --git a/src/fund/components/FundCardAmountInput.tsx b/src/fund/components/FundCardAmountInput.tsx index a08876b1dd..f3afcde809 100644 --- a/src/fund/components/FundCardAmountInput.tsx +++ b/src/fund/components/FundCardAmountInput.tsx @@ -1,11 +1,5 @@ -import { TextInput } from '@/internal/components/TextInput'; -import { useAmountInput } from '@/internal/hooks/useAmountInput'; -import { isValidAmount } from '@/internal/utils/isValidAmount'; -import { useCallback, useEffect, useRef } from 'react'; -import { cn, text } from '../../styles/theme'; -import { useInputResize } from '../hooks/useInputResize'; +import { AmountInput } from '@/internal/components/amount-input/AmountInput'; import type { FundCardAmountInputPropsReact } from '../types'; -import { FundCardCurrencyLabel } from './FundCardCurrencyLabel'; import { useFundContext } from './FundCardProvider'; export const FundCardAmountInput = ({ @@ -22,113 +16,18 @@ export const FundCardAmountInput = ({ setFundAmountCrypto, } = useFundContext(); - const currencyOrAsset = selectedInputType === 'fiat' ? currency : asset; - - const containerRef = useRef(null); - const inputRef = useRef(null); - const hiddenSpanRef = useRef(null); - const currencySpanRef = useRef(null); - - const value = - selectedInputType === 'fiat' ? fundAmountFiat : fundAmountCrypto; - - const updateInputWidth = useInputResize( - containerRef, - inputRef, - hiddenSpanRef, - currencySpanRef, - ); - - const { handleChange } = useAmountInput({ - setFiatAmount: setFundAmountFiat, - setCryptoAmount: setFundAmountCrypto, - selectedInputType, - exchangeRate: String(exchangeRate), - }); - - const handleAmountChange = useCallback( - (value: string) => { - handleChange(value, () => { - if (inputRef.current) { - inputRef.current.focus(); - } - }); - }, - [handleChange], - ); - - // biome-ignore lint/correctness/useExhaustiveDependencies: When value changes, we want to update the input width - useEffect(() => { - updateInputWidth(); - }, [value, updateInputWidth]); - - const selectedInputTypeRef = useRef(selectedInputType); - - useEffect(() => { - /** - * We need to focus the input when the input type changes - * but not on the initial render. - */ - if (selectedInputTypeRef.current !== selectedInputType) { - selectedInputTypeRef.current = selectedInputType; - handleFocusInput(); - } - }, [selectedInputType]); - - const handleFocusInput = () => { - if (inputRef.current) { - inputRef.current.focus(); - } - }; - return ( -
-
- - - -
- - {/* Hidden span for measuring text width - Without this span the input field would not adjust its width based on the text width and would look like this: - [0.12--------Empty Space-------][ETH] - As you can see the currency symbol is far away from the inputed value - - With this span we can measure the width of the text in the input field and set the width of the input field to match the text width - [0.12][ETH] - Now the currency symbol is displayed next to the input field - */} - - {value ? `${value}.` : '0.'} - -
+ ); }; diff --git a/src/fund/components/FundCardAmountInputTypeSwitch.tsx b/src/fund/components/FundCardAmountInputTypeSwitch.tsx index 5f754e6568..c98caccdbf 100644 --- a/src/fund/components/FundCardAmountInputTypeSwitch.tsx +++ b/src/fund/components/FundCardAmountInputTypeSwitch.tsx @@ -1,9 +1,4 @@ -import { formatFiatAmount } from '@/internal/utils/formatFiatAmount'; -import { truncateDecimalPlaces } from '@/internal/utils/truncateDecimalPlaces'; -import { useCallback, useMemo } from 'react'; -import { Skeleton } from '../../internal/components/Skeleton'; -import { useIcon } from '../../internal/hooks/useIcon'; -import { cn, pressable, text } from '../../styles/theme'; +import { AmountInputTypeSwitch } from '@/internal/components/amount-input/AmountInputTypeSwitch'; import type { FundCardAmountInputTypeSwitchPropsReact } from '../types'; import { useFundContext } from './FundCardProvider'; @@ -21,59 +16,18 @@ export const FundCardAmountInputTypeSwitch = ({ currency, } = useFundContext(); - const iconSvg = useIcon({ icon: 'toggle' }); - - const handleToggle = useCallback(() => { - setSelectedInputType(selectedInputType === 'fiat' ? 'crypto' : 'fiat'); - }, [selectedInputType, setSelectedInputType]); - - const formatCrypto = useCallback( - (amount: string) => { - return `${truncateDecimalPlaces(amount || '0', 8)} ${asset}`; - }, - [asset], - ); - - const amountLine = useMemo(() => { - return ( - - {selectedInputType === 'fiat' - ? formatCrypto(fundAmountCrypto) - : formatFiatAmount({ - amount: fundAmountFiat, - currency: currency, - minimumFractionDigits: 0, - })} - - ); - }, [ - fundAmountCrypto, - fundAmountFiat, - selectedInputType, - formatCrypto, - currency, - ]); - - if (exchangeRateLoading || !exchangeRate) { - return ; - } - return ( -
- -
{amountLine}
-
+ ); }; diff --git a/src/fund/components/FundCardCurrencyLabel.tsx b/src/fund/components/FundCardCurrencyLabel.tsx deleted file mode 100644 index 2206b78307..0000000000 --- a/src/fund/components/FundCardCurrencyLabel.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { forwardRef } from 'react'; -import { cn, color, text } from '../../styles/theme'; -import type { FundCardCurrencyLabelPropsReact } from '../types'; - -export const FundCardCurrencyLabel = forwardRef< - HTMLSpanElement, - FundCardCurrencyLabelPropsReact ->(({ label }, ref) => { - return ( - - {label} - - ); -}); diff --git a/src/fund/components/FundCardProvider.tsx b/src/fund/components/FundCardProvider.tsx index ac62ef9cb9..aa40888875 100644 --- a/src/fund/components/FundCardProvider.tsx +++ b/src/fund/components/FundCardProvider.tsx @@ -30,9 +30,9 @@ type FundCardContextType = { setFundAmountFiat: (amount: string) => void; fundAmountCrypto: string; setFundAmountCrypto: (amount: string) => void; - exchangeRate?: number; + exchangeRate: number; setExchangeRate: (exchangeRate: number) => void; - exchangeRateLoading?: boolean; + exchangeRateLoading: boolean; setExchangeRateLoading: (loading: boolean) => void; submitButtonState: FundButtonStateReact; setSubmitButtonState: (state: FundButtonStateReact) => void; diff --git a/src/internal/components/TextInput.tsx b/src/internal/components/TextInput.tsx index 9d4d58de84..1d7d6b3bda 100644 --- a/src/internal/components/TextInput.tsx +++ b/src/internal/components/TextInput.tsx @@ -15,10 +15,15 @@ type TextInputReact = { inputMode?: InputHTMLAttributes['inputMode']; onBlur?: () => void; onChange: (s: string) => void; + onFocus?: (e: React.FocusEvent) => void; placeholder: string; setValue?: (s: string) => void; value: string; inputValidator?: (s: string) => boolean; + /** autocomplete attribute handles browser autocomplete, defaults to 'off' */ + autoComplete?: string; + /** data-1p-ignore attribute handles password manager autocomplete, defaults to true */ + 'data-1p-ignore'?: boolean; }; export const TextInput = forwardRef( @@ -30,11 +35,14 @@ export const TextInput = forwardRef( disabled = false, onBlur, onChange, + onFocus, placeholder, setValue, inputMode, value, inputValidator = () => true, + autoComplete = 'off', + 'data-1p-ignore': data1pIgnore = true, }, ref, ) => { @@ -70,7 +78,10 @@ export const TextInput = forwardRef( value={value} onBlur={onBlur} onChange={handleChange} + onFocus={onFocus} disabled={disabled} + autoComplete={autoComplete} + data-1p-ignore={data1pIgnore} /> ); }, diff --git a/src/internal/components/amount-input/AmountInput.test.tsx b/src/internal/components/amount-input/AmountInput.test.tsx new file mode 100644 index 0000000000..e4ad4efff5 --- /dev/null +++ b/src/internal/components/amount-input/AmountInput.test.tsx @@ -0,0 +1,142 @@ +import '@testing-library/jest-dom'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { act } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AmountInput } from './AmountInput'; + +// Mock ResizeObserver +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} + +describe('AmountInput', () => { + beforeEach(() => { + global.ResizeObserver = ResizeObserverMock; + vi.clearAllMocks(); + }); + + const defaultProps = { + asset: 'ETH', + currency: 'USD', + fiatAmount: '', + cryptoAmount: '', + selectedInputType: 'fiat' as const, + setFiatAmount: vi.fn(), + setCryptoAmount: vi.fn(), + exchangeRate: '1200', + }; + + it('renders correctly with fiat input type', () => { + render(); + expect(screen.getByTestId('ockTextInput_Input')).toBeInTheDocument(); + expect(screen.getByTestId('ockCurrencySpan')).toHaveTextContent('USD'); + }); + + it('renders correctly with crypto input type', () => { + render(); + expect(screen.getByTestId('ockCurrencySpan')).toHaveTextContent('ETH'); + }); + + it('handles input change in fiat mode', () => { + render(); + const input = screen.getByTestId('ockTextInput_Input'); + + fireEvent.change(input, { target: { value: '100' } }); + expect(defaultProps.setFiatAmount).toHaveBeenCalledWith('100'); + }); + + it('handles input change in crypto mode', () => { + render(); + const input = screen.getByTestId('ockTextInput_Input'); + + fireEvent.change(input, { target: { value: '1' } }); + expect(defaultProps.setCryptoAmount).toHaveBeenCalledWith('1'); + }); + + it('applies custom className', () => { + render(); + expect(screen.getByTestId('ockAmountInputContainer')).toHaveClass( + 'custom-class', + ); + }); + + it('updates width based on currency label', async () => { + const mockResizeObserver = vi.fn(); + global.ResizeObserver = vi.fn().mockImplementation((callback) => { + callback([ + { + contentRect: { width: 300 }, + target: document.createElement('div'), + }, + ]); + return { + observe: mockResizeObserver, + unobserve: vi.fn(), + disconnect: vi.fn(), + }; + }); + + render(); + const input = screen.getByTestId('ockTextInput_Input'); + const container = screen.getByTestId('ockAmountInputContainer'); + + Object.defineProperty(container, 'getBoundingClientRect', { + value: () => ({ width: 300 }), + configurable: true, + }); + + const currencyLabel = screen.getByTestId('ockCurrencySpan'); + Object.defineProperty(currencyLabel, 'getBoundingClientRect', { + value: () => ({ width: 50 }), + configurable: true, + }); + + act(() => { + fireEvent.change(input, { target: { value: '100' } }); + window.dispatchEvent(new Event('resize')); + }); + }); + + it('focuses input when input type changes', () => { + const { rerender } = render(); + const input = screen.getByTestId('ockTextInput_Input'); + const focusSpy = vi.spyOn(input, 'focus'); + + rerender(); + expect(focusSpy).toHaveBeenCalled(); + }); + + it('displays placeholder when value is empty', () => { + render(); + const input = screen.getByTestId('ockTextInput_Input'); + expect(input).toHaveAttribute('placeholder', '0'); + }); + + it('updates hidden span content correctly', () => { + const { rerender } = render(); + const hiddenSpan = screen.getByTestId('ockHiddenSpan'); + expect(hiddenSpan).toHaveTextContent('0.'); + + rerender(); + expect(hiddenSpan).toHaveTextContent('100.'); + }); + + it('prevents invalid input', () => { + render(); + const input = screen.getByTestId('ockTextInput_Input'); + + fireEvent.change(input, { target: { value: 'abc' } }); + expect(defaultProps.setFiatAmount).not.toHaveBeenCalled(); + }); + + it('maintains focus after value change', () => { + render(); + const input = screen.getByTestId('ockTextInput_Input'); + + input.focus(); + fireEvent.change(input, { target: { value: '100' } }); + expect(document.activeElement).toBe(input); + }); +}); diff --git a/src/internal/components/amount-input/AmountInput.tsx b/src/internal/components/amount-input/AmountInput.tsx new file mode 100644 index 0000000000..2f40216d39 --- /dev/null +++ b/src/internal/components/amount-input/AmountInput.tsx @@ -0,0 +1,137 @@ +import { cn, text } from '@/styles/theme'; +import { useCallback, useEffect, useRef } from 'react'; +import { useAmountInput } from '../../hooks/useAmountInput'; +import { useInputResize } from '../../hooks/useInputResize'; +import { isValidAmount } from '../../utils/isValidAmount'; +import { TextInput } from '../TextInput'; +import { CurrencyLabel } from './CurrencyLabel'; + +type AmountInputProps = { + asset: string; + currency: string; + fiatAmount: string; + cryptoAmount: string; + selectedInputType: 'fiat' | 'crypto'; + setFiatAmount: (value: string) => void; + setCryptoAmount: (value: string) => void; + exchangeRate: string; + className?: string; +}; + +export function AmountInput({ + fiatAmount, + cryptoAmount, + asset, + selectedInputType, + currency, + className, + setFiatAmount, + setCryptoAmount, + exchangeRate, +}: AmountInputProps) { + const containerRef = useRef(null); + const inputRef = useRef(null); + const hiddenSpanRef = useRef(null); + const currencySpanRef = useRef(null); + + const currencyOrAsset = selectedInputType === 'fiat' ? currency : asset; + const value = selectedInputType === 'fiat' ? fiatAmount : cryptoAmount; + + const updateInputWidth = useInputResize( + containerRef, + inputRef, + hiddenSpanRef, + currencySpanRef, + ); + + const { handleChange } = useAmountInput({ + setFiatAmount, + setCryptoAmount, + selectedInputType, + exchangeRate, + }); + + const handleAmountChange = useCallback( + (value: string) => { + handleChange(value, () => { + if (inputRef.current) { + inputRef.current.focus(); + } + }); + }, + [handleChange], + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: When value changes, we want to update the input width + useEffect(() => { + updateInputWidth(); + }, [value, updateInputWidth]); + + const selectedInputTypeRef = useRef(selectedInputType); + + useEffect(() => { + /** + * We need to focus the input when the input type changes + * but not on the initial render. + */ + if (selectedInputTypeRef.current !== selectedInputType) { + selectedInputTypeRef.current = selectedInputType; + handleFocusInput(); + } + }, [selectedInputType]); + + const handleFocusInput = () => { + if (inputRef.current) { + inputRef.current.focus(); + } + }; + + return ( +
+
+ + + +
+ + {/* Hidden span for measuring text width + Without this span the input field would not adjust its width based on the text width and would look like this: + [0.12--------Empty Space-------][ETH] - As you can see the currency symbol is far away from the inputed value + With this span we can measure the width of the text in the input field and set the width of the input field to match the text width + [0.12][ETH] - Now the currency symbol is displayed next to the input field + */} + + {value ? `${value}.` : '0.'} + +
+ ); +} diff --git a/src/internal/components/amount-input/AmountInputTypeSwitch.test.tsx b/src/internal/components/amount-input/AmountInputTypeSwitch.test.tsx new file mode 100644 index 0000000000..527e50fa0c --- /dev/null +++ b/src/internal/components/amount-input/AmountInputTypeSwitch.test.tsx @@ -0,0 +1,98 @@ +import '@testing-library/jest-dom'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { AmountInputTypeSwitch } from './AmountInputTypeSwitch'; + +describe('AmountInputTypeSwitch', () => { + const defaultProps = { + selectedInputType: 'fiat' as const, + setSelectedInputType: vi.fn(), + asset: 'ETH', + fiatAmount: '100', + cryptoAmount: '0.5', + exchangeRate: 2000, + exchangeRateLoading: false, + currency: 'USD', + }; + + it('renders fiat to crypto conversion', () => { + render(); + expect(screen.getByTestId('ockAmountLine')).toHaveTextContent('0.5 ETH'); + }); + + it('renders crypto to fiat conversion', () => { + render( + , + ); + expect(screen.getByTestId('ockAmountLine')).toHaveTextContent('$100'); + }); + + it('toggles input type when clicked', () => { + render(); + + fireEvent.click(screen.getByTestId('ockAmountTypeSwitch')); + expect(defaultProps.setSelectedInputType).toHaveBeenCalledWith('crypto'); + }); + + it('renders loading skeleton when exchange rate is loading', () => { + render( + , + ); + expect(screen.getByTestId('ockSkeleton')).toBeInTheDocument(); + }); + + it('renders loading skeleton when exchange rate is zero', () => { + render(); + expect(screen.getByTestId('ockSkeleton')).toBeInTheDocument(); + }); + + it('applies custom className', () => { + render( + , + ); + const container = screen.getByTestId('ockAmountTypeSwitch').parentElement; + expect(container).toHaveClass('custom-class'); + }); + + it('truncates crypto amount to 8 decimal places', () => { + render( + , + ); + expect(screen.getByTestId('ockAmountLine')).toHaveTextContent( + '0.12345678 ETH', + ); + }); + + it('handles empty amounts', () => { + render( + , + ); + expect(screen.getByTestId('ockAmountLine')).toHaveTextContent('0 ETH'); + }); + + it('formats fiat amount with currency symbol', () => { + render( + , + ); + expect(screen.getByTestId('ockAmountLine')).toHaveTextContent('$1,234.56'); + }); + + it('toggles from crypto to fiat when clicked', () => { + render( + , + ); + + fireEvent.click(screen.getByTestId('ockAmountTypeSwitch')); + expect(defaultProps.setSelectedInputType).toHaveBeenCalledWith('fiat'); + }); + + it('has correct aria-label for accessibility', () => { + render(); + const button = screen.getByTestId('ockAmountTypeSwitch'); + expect(button).toHaveAttribute('aria-label', 'amount type switch'); + }); +}); diff --git a/src/internal/components/amount-input/AmountInputTypeSwitch.tsx b/src/internal/components/amount-input/AmountInputTypeSwitch.tsx new file mode 100644 index 0000000000..a192e725a0 --- /dev/null +++ b/src/internal/components/amount-input/AmountInputTypeSwitch.tsx @@ -0,0 +1,79 @@ +import { cn, pressable, text } from '@/styles/theme'; +import { useCallback, useMemo } from 'react'; +import { useIcon } from '../../hooks/useIcon'; +import { formatFiatAmount } from '../../utils/formatFiatAmount'; +import { truncateDecimalPlaces } from '../../utils/truncateDecimalPlaces'; +import { Skeleton } from '../Skeleton'; + +type AmountInputTypeSwitchPropsReact = { + selectedInputType: 'fiat' | 'crypto'; + setSelectedInputType: (type: 'fiat' | 'crypto') => void; + asset: string; + fiatAmount: string; + cryptoAmount: string; + exchangeRate: number; + exchangeRateLoading: boolean; + currency: string; + className?: string; +}; + +export function AmountInputTypeSwitch({ + selectedInputType, + setSelectedInputType, + asset, + fiatAmount, + cryptoAmount, + exchangeRate, + exchangeRateLoading, + currency, + className, +}: AmountInputTypeSwitchPropsReact) { + const iconSvg = useIcon({ icon: 'toggle' }); + + const handleToggle = useCallback(() => { + setSelectedInputType(selectedInputType === 'fiat' ? 'crypto' : 'fiat'); + }, [selectedInputType, setSelectedInputType]); + + const formatCrypto = useCallback( + (amount: string) => { + return `${truncateDecimalPlaces(amount || '0', 8)} ${asset}`; + }, + [asset], + ); + + const amountLine = useMemo(() => { + return ( + + {selectedInputType === 'fiat' + ? formatCrypto(cryptoAmount) + : formatFiatAmount({ + amount: fiatAmount, + currency: currency, + minimumFractionDigits: 0, + })} + + ); + }, [cryptoAmount, fiatAmount, selectedInputType, formatCrypto, currency]); + + if (exchangeRateLoading || !exchangeRate) { + return ; + } + + return ( +
+ +
{amountLine}
+
+ ); +} diff --git a/src/fund/components/FundCardCurrencyLabel.test.tsx b/src/internal/components/amount-input/CurrencyLabel.test.tsx similarity index 70% rename from src/fund/components/FundCardCurrencyLabel.test.tsx rename to src/internal/components/amount-input/CurrencyLabel.test.tsx index 383cb68ef0..3b0be239bf 100644 --- a/src/fund/components/FundCardCurrencyLabel.test.tsx +++ b/src/internal/components/amount-input/CurrencyLabel.test.tsx @@ -1,16 +1,16 @@ import '@testing-library/jest-dom'; import { render, screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { FundCardCurrencyLabel } from './FundCardCurrencyLabel'; +import { CurrencyLabel } from './CurrencyLabel'; -describe('FundCardCurrencyLabel', () => { +describe('CurrencyLabel', () => { it('renders the currency sign', () => { - render(); + render(); expect(screen.getByText('$')).toBeInTheDocument(); }); it('applies the correct classes', () => { - render(); + render(); const spanElement = screen.getByText('$'); expect(spanElement).toHaveClass( 'flex items-center justify-center bg-transparent text-6xl leading-none outline-none', diff --git a/src/internal/components/amount-input/CurrencyLabel.tsx b/src/internal/components/amount-input/CurrencyLabel.tsx new file mode 100644 index 0000000000..fc5a228522 --- /dev/null +++ b/src/internal/components/amount-input/CurrencyLabel.tsx @@ -0,0 +1,25 @@ +import { cn, color, text } from '@/styles/theme'; +import { forwardRef } from 'react'; + +type CurrencyLabelProps = { + label: string; +}; + +export const CurrencyLabel = forwardRef( + ({ label }, ref) => { + return ( + + {label} + + ); + }, +); diff --git a/src/fund/hooks/useInputResize.test.ts b/src/internal/hooks/useInputResize.test.ts similarity index 100% rename from src/fund/hooks/useInputResize.test.ts rename to src/internal/hooks/useInputResize.test.ts diff --git a/src/fund/hooks/useInputResize.ts b/src/internal/hooks/useInputResize.ts similarity index 100% rename from src/fund/hooks/useInputResize.ts rename to src/internal/hooks/useInputResize.ts