diff --git a/.changeset/ten-buckets-lick.md b/.changeset/ten-buckets-lick.md
new file mode 100644
index 0000000000..e6f816e275
--- /dev/null
+++ b/.changeset/ten-buckets-lick.md
@@ -0,0 +1,5 @@
+---
+"@coinbase/onchainkit": patch
+---
+
+- **feat**: Add loading state and spinner to SwapButton. By @abcrane123 #633
diff --git a/src/internal/loading/Spinner.tsx b/src/internal/loading/Spinner.tsx
new file mode 100644
index 0000000000..1058f46982
--- /dev/null
+++ b/src/internal/loading/Spinner.tsx
@@ -0,0 +1,18 @@
+import { cn } from '../../styles/theme';
+
+/* istanbul ignore next */
+export function Spinner() {
+ return (
+
+ );
+}
diff --git a/src/swap/components/Swap.test.tsx b/src/swap/components/Swap.test.tsx
index 9c75c863f6..0de4c27c80 100644
--- a/src/swap/components/Swap.test.tsx
+++ b/src/swap/components/Swap.test.tsx
@@ -62,7 +62,12 @@ const mockContextValue = {
setFromToken: jest.fn(),
setToAmount: jest.fn(),
setToToken: jest.fn(),
- swapQuoteLoadingState: { isFromQuoteLoading: false, isToQuoteLoading: false },
+ setSwapLoadingState: jest.fn(),
+ swapLoadingState: {
+ isFromQuoteLoading: false,
+ isSwapLoading: false,
+ isToQuoteLoading: false,
+ },
toAmount: '20',
toToken: mockToken,
fromToken: mockETHToken,
diff --git a/src/swap/components/Swap.tsx b/src/swap/components/Swap.tsx
index f2a1562f83..72a0303a48 100644
--- a/src/swap/components/Swap.tsx
+++ b/src/swap/components/Swap.tsx
@@ -11,7 +11,7 @@ import { useBalance, useReadContract } from 'wagmi';
import { getTokenBalances } from '../core/getTokenBalances';
import { erc20Abi } from 'viem';
import type { Address } from 'viem';
-import type { SwapError, SwapQuoteLoadingState, SwapReact } from '../types';
+import type { SwapError, SwapLoadingState, SwapReact } from '../types';
import type { Token } from '../../token';
import type { UseBalanceReturnType, UseReadContractReturnType } from 'wagmi';
import { text } from '../../styles/theme';
@@ -22,11 +22,11 @@ export function Swap({ address, children, title = 'Swap' }: SwapReact) {
const [fromToken, setFromToken] = useState();
const [toAmount, setToAmount] = useState('');
const [toToken, setToToken] = useState();
- const [swapQuoteLoadingState, setSwapQuoteLoadingState] =
- useState({
- isFromQuoteLoading: false,
- isToQuoteLoading: false,
- });
+ const [swapLoadingState, setSwapLoadingState] = useState({
+ isFromQuoteLoading: false,
+ isSwapLoading: false,
+ isToQuoteLoading: false,
+ });
// returns ETH balance
const ethBalanceResponse: UseBalanceReturnType = useBalance({
@@ -100,9 +100,9 @@ export function Swap({ address, children, title = 'Swap' }: SwapReact) {
return;
}
try {
- setSwapQuoteLoadingState({
+ setSwapLoadingState({
+ ...swapLoadingState,
isToQuoteLoading: true,
- isFromQuoteLoading: false,
});
const response = await getSwapQuote({
from: fromToken,
@@ -122,13 +122,13 @@ export function Swap({ address, children, title = 'Swap' }: SwapReact) {
} catch (err) {
setError(err as SwapError);
} finally {
- setSwapQuoteLoadingState({
+ setSwapLoadingState({
+ ...swapLoadingState,
isToQuoteLoading: false,
- isFromQuoteLoading: false,
});
}
},
- [fromToken, toToken],
+ [fromToken, swapLoadingState, toToken],
);
/* istanbul ignore next */
@@ -139,8 +139,8 @@ export function Swap({ address, children, title = 'Swap' }: SwapReact) {
return;
}
try {
- setSwapQuoteLoadingState({
- isToQuoteLoading: false,
+ setSwapLoadingState({
+ ...swapLoadingState,
isFromQuoteLoading: true,
});
const response = await getSwapQuote({
@@ -161,13 +161,13 @@ export function Swap({ address, children, title = 'Swap' }: SwapReact) {
} catch (err) {
setError(err as SwapError);
} finally {
- setSwapQuoteLoadingState({
- isToQuoteLoading: false,
+ setSwapLoadingState({
+ ...swapLoadingState,
isFromQuoteLoading: false,
});
}
},
- [fromToken, toToken],
+ [fromToken, swapLoadingState, toToken],
);
/* istanbul ignore next */
@@ -178,6 +178,7 @@ export function Swap({ address, children, title = 'Swap' }: SwapReact) {
setFromToken(toToken);
}, [fromAmount, fromToken, toAmount, toToken]);
+ /* biome-ignore lint: need setState funcs */
const value = useMemo(() => {
return {
address,
@@ -196,7 +197,8 @@ export function Swap({ address, children, title = 'Swap' }: SwapReact) {
setFromToken,
setToToken,
setToAmount,
- swapQuoteLoadingState,
+ setSwapLoadingState,
+ swapLoadingState,
toAmount,
toToken,
};
@@ -212,7 +214,13 @@ export function Swap({ address, children, title = 'Swap' }: SwapReact) {
handleToggle,
roundedFromTokenBalance,
roundedToTokenBalance,
- swapQuoteLoadingState,
+ swapLoadingState,
+ setError,
+ setFromAmount,
+ setFromToken,
+ setSwapLoadingState,
+ setToAmount,
+ setToToken,
toAmount,
toToken,
]);
diff --git a/src/swap/components/SwapAmountInput.test.tsx b/src/swap/components/SwapAmountInput.test.tsx
index 9c60a66da6..729f57962d 100644
--- a/src/swap/components/SwapAmountInput.test.tsx
+++ b/src/swap/components/SwapAmountInput.test.tsx
@@ -59,7 +59,12 @@ const mockContextValue = {
setFromToken: jest.fn(),
setToAmount: jest.fn(),
setToToken: jest.fn(),
- swapQuoteLoadingState: { isFromQuoteLoading: false, isToQuoteLoading: false },
+ setSwapLoadingState: jest.fn(),
+ swapLoadingState: {
+ isFromQuoteLoading: false,
+ isSwapLoading: false,
+ isToQuoteLoading: false,
+ },
toAmount: '20',
toToken: mockToken,
fromToken: mockETHToken,
diff --git a/src/swap/components/SwapAmountInput.tsx b/src/swap/components/SwapAmountInput.tsx
index ae95816f68..9d47a04573 100644
--- a/src/swap/components/SwapAmountInput.tsx
+++ b/src/swap/components/SwapAmountInput.tsx
@@ -28,7 +28,7 @@ export function SwapAmountInput({
setFromToken,
setToAmount,
setToToken,
- swapQuoteLoadingState,
+ swapLoadingState,
toAmount,
toToken,
} = useSwapContext();
@@ -48,7 +48,7 @@ export function SwapAmountInput({
amount: toAmount,
convertedBalance: convertedToTokenBalance,
handleAmountChange: handleToAmountChange,
- isSwapQuoteLoading: swapQuoteLoadingState.isToQuoteLoading,
+ isSwapQuoteLoading: swapLoadingState.isToQuoteLoading,
roundedBalance: roundedToTokenBalance,
selectedToken: toToken,
setAmount: setToAmount,
@@ -59,7 +59,7 @@ export function SwapAmountInput({
amount: fromAmount,
convertedBalance: convertedFromTokenBalance,
handleAmountChange: handleFromAmountChange,
- isSwapQuoteLoading: swapQuoteLoadingState.isFromQuoteLoading,
+ isSwapQuoteLoading: swapLoadingState.isFromQuoteLoading,
roundedBalance: roundedFromTokenBalance,
selectedToken: fromToken,
setAmount: setFromAmount,
@@ -78,7 +78,7 @@ export function SwapAmountInput({
setFromToken,
setToAmount,
setToToken,
- swapQuoteLoadingState,
+ swapLoadingState,
toAmount,
toToken,
type,
diff --git a/src/swap/components/SwapButton.test.tsx b/src/swap/components/SwapButton.test.tsx
index fcb7023e3e..1964345594 100644
--- a/src/swap/components/SwapButton.test.tsx
+++ b/src/swap/components/SwapButton.test.tsx
@@ -20,12 +20,21 @@ const mockedIsSwapError = isSwapError as jest.MockedFunction<
(response: unknown) => response is SwapError
>;
+const mockSwapLoadingState = {
+ isFromQuoteLoading: false,
+ isSwapLoading: false,
+ isToQuoteLoading: false,
+};
+
describe('SwapButton', () => {
beforeEach(() => {
mockedUseSwapContext.mockReturnValue({
address: '0x123',
fromAmount: 100,
fromToken: 'ETH',
+ toAmount: 5,
+ setSwapLoadingState: jest.fn(),
+ swapLoadingState: mockSwapLoadingState,
toToken: 'DAI',
setError: jest.fn(),
});
@@ -52,6 +61,9 @@ describe('SwapButton', () => {
address: '0x123',
fromAmount: 100,
fromToken: 'ETH',
+ swapLoadingState: mockSwapLoadingState,
+ setSwapLoadingState: jest.fn(),
+ toAmount: 5,
toToken: 'DAI',
setError,
});
@@ -76,6 +88,9 @@ describe('SwapButton', () => {
address: '0x123',
fromAmount: 100,
fromToken: 'ETH',
+ setSwapLoadingState: jest.fn(),
+ swapLoadingState: mockSwapLoadingState,
+ toAmount: 5,
toToken: 'DAI',
setError,
});
@@ -102,4 +117,28 @@ describe('SwapButton', () => {
expect(onSubmit).not.toHaveBeenCalled();
});
+
+ it('does renders a loading icon when swap is loading', () => {
+ const onSubmit = jest.fn();
+ mockedUseSwapContext.mockReturnValueOnce({
+ address: '0x123',
+ fromAmount: 100,
+ fromToken: 'ETH',
+ setSwapLoadingState: jest.fn(),
+ swapLoadingState: {
+ isFromQuoteLoading: false,
+ isSwapLoading: true,
+ isToQuoteLoading: false,
+ },
+ toAmount: 5,
+ toToken: 'DAI',
+ });
+ const { getByTestId } = render(
+ ,
+ );
+
+ const spinner = getByTestId('ockSpinner');
+
+ expect(spinner).toBeInTheDocument();
+ });
});
diff --git a/src/swap/components/SwapButton.tsx b/src/swap/components/SwapButton.tsx
index 85d02434dd..3087bfec4b 100644
--- a/src/swap/components/SwapButton.tsx
+++ b/src/swap/components/SwapButton.tsx
@@ -1,17 +1,27 @@
import { useCallback } from 'react';
-import type { SwapButtonReact, SwapError } from '../types';
import { useSwapContext } from '../context';
import { buildSwapTransaction } from '../core/buildSwapTransaction';
import { cn, text } from '../../styles/theme';
import { isSwapError } from '../core/isSwapError';
+import { Spinner } from '../../internal/loading/Spinner';
+import type { SwapButtonReact, SwapError } from '../types';
export function SwapButton({ disabled = false, onSubmit }: SwapButtonReact) {
- const { address, fromAmount, fromToken, toToken, setError } =
- useSwapContext();
+ const {
+ address,
+ fromAmount,
+ fromToken,
+ toAmount,
+ setError,
+ setSwapLoadingState,
+ swapLoadingState,
+ toToken,
+ } = useSwapContext();
const handleSubmit = useCallback(async () => {
if (address && fromToken && toToken && fromAmount) {
try {
+ setSwapLoadingState({ ...swapLoadingState, isSwapLoading: true });
const response = await buildSwapTransaction({
amount: fromAmount,
fromAddress: address,
@@ -25,9 +35,28 @@ export function SwapButton({ disabled = false, onSubmit }: SwapButtonReact) {
}
} catch (error) {
setError(error as SwapError);
+ } finally {
+ setSwapLoadingState({ ...swapLoadingState, isSwapLoading: false });
}
}
- }, [address, fromAmount, fromToken, setError, onSubmit, toToken]);
+ }, [
+ address,
+ fromAmount,
+ fromToken,
+ onSubmit,
+ setError,
+ setSwapLoadingState,
+ swapLoadingState,
+ toToken,
+ ]);
+
+ const isDisabled =
+ !fromAmount ||
+ !fromToken ||
+ !toAmount ||
+ !toToken ||
+ disabled ||
+ swapLoadingState?.isSwapLoading;
return (
);
}
diff --git a/src/swap/components/SwapMessage.test.tsx b/src/swap/components/SwapMessage.test.tsx
index 7868a6170d..cfddab7795 100644
--- a/src/swap/components/SwapMessage.test.tsx
+++ b/src/swap/components/SwapMessage.test.tsx
@@ -41,7 +41,12 @@ const mockContextValue = {
setToAmount: jest.fn(),
setToToken: jest.fn(),
setError: jest.fn(),
- swapQuoteLoadingState: { isFromQuoteLoading: false, isToQuoteLoading: false },
+ setSwapLoadingState: jest.fn(),
+ swapLoadingState: {
+ isFromQuoteLoading: false,
+ isSwapLoading: false,
+ isToQuoteLoading: false,
+ },
toAmount: '20',
toToken: mockToken,
fromToken: mockETHToken,
diff --git a/src/swap/types.ts b/src/swap/types.ts
index 247724f9c8..4ab2696062 100644
--- a/src/swap/types.ts
+++ b/src/swap/types.ts
@@ -134,14 +134,16 @@ export type SwapContextType = {
setFromToken: (t: Token) => void;
setToAmount: (a: string) => void;
setToToken: (t: Token) => void;
- swapQuoteLoadingState: SwapQuoteLoadingState;
+ setSwapLoadingState: (s: SwapLoadingState) => void;
+ swapLoadingState: SwapLoadingState;
toAmount: string;
toToken?: Token;
};
-export type SwapQuoteLoadingState = {
+export type SwapLoadingState = {
isFromQuoteLoading: boolean;
isToQuoteLoading: boolean;
+ isSwapLoading: boolean;
};
/**