+
);
@@ -45,6 +76,10 @@ const preview: Preview = {
},
},
parameters: {
+ docs: {
+ theme,
+ },
+ layout: 'fullscreen',
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
@@ -52,15 +87,8 @@ const preview: Preview = {
date: /Date$/,
},
},
- backgrounds: {
- default: 'dark',
- values: [
- { name: 'dark', value: '#000000' },
- { name: 'light', value: '#ffffff' },
- ],
- },
},
- decorators: [withTheme, withI18n],
+ decorators: [withTheme, withI18n, withAppKit],
};
export default preview;
diff --git a/packages/appkit-react/.storybook/public/ton.svg b/packages/appkit-react/.storybook/public/ton.svg
new file mode 100644
index 000000000..2eba092f4
--- /dev/null
+++ b/packages/appkit-react/.storybook/public/ton.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/appkit-react/.storybook/theme.ts b/packages/appkit-react/.storybook/theme.ts
new file mode 100644
index 000000000..484846ac4
--- /dev/null
+++ b/packages/appkit-react/.storybook/theme.ts
@@ -0,0 +1,51 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { create } from 'storybook/theming';
+
+export default create({
+ base: 'dark',
+
+ // Branding
+ brandTitle: 'TON AppKit',
+ brandUrl: 'https://github.com/ton-connect/kit',
+ brandImage: 'ton.svg',
+ brandTarget: '_self',
+
+ // Colors
+ colorPrimary: '#0098EA',
+ colorSecondary: '#0098EA',
+
+ // UI
+ appBg: '#121214',
+ appContentBg: '#1E1E1E',
+ appPreviewBg: '#121214',
+ appBorderColor: '#2C2C2C',
+ appBorderRadius: 8,
+
+ // Typography
+ fontBase:
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
+ fontCode: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
+
+ // Text colors
+ textColor: '#FFFFFF',
+ textInverseColor: '#121214',
+
+ // Toolbar default and active colors
+ barTextColor: '#909DAB',
+ barSelectedColor: '#0098EA',
+ barHoverColor: '#FFFFFF',
+ barBg: '#121214',
+
+ // Form colors
+ inputBg: '#1E1E1E',
+ inputBorder: '#2C2C2C',
+ inputTextColor: '#FFFFFF',
+ inputBorderRadius: 8,
+});
diff --git a/packages/appkit-react/CLAUDE.md b/packages/appkit-react/CLAUDE.md
index 3fdb1befd..1b58037ee 100644
--- a/packages/appkit-react/CLAUDE.md
+++ b/packages/appkit-react/CLAUDE.md
@@ -7,6 +7,20 @@
- For creating new UI components, use the `add-ui-component` skill.
- Every component must have a Storybook story (`.stories.tsx`).
+## Hook ordering in components and custom hooks
+
+Where possible, group hooks in this order inside component/hook bodies. The goal is top-down dataflow: inputs → data → derivations → side-effects.
+
+1. **Local state** — `useState`, `useRef`, `useReducer`, `useDebounceValue`, and state-like wrappers.
+2. **Queries and external readers** — `useQuery`-based hooks, wallet/network selectors (`useAddress`, `useNetwork`, balance hooks, etc.).
+3. **Derivations** — `useMemo` and simple computed values derived from local state or query data (e.g. validation, formatted strings, flags).
+4. **Mutations** — `useMutation`-based hooks (`useBuildSwapTransaction`, `useSendTransaction`, etc.) and simple flags derived from them.
+5. **Callbacks / functions** — `useCallback` and inline handlers.
+6. **Effects** — `useEffect`, `useLayoutEffect`.
+
+Exception: tightly coupled values may sit next to each other even if the strict order is broken — e.g. a `useMemo` that exists
+only to prepare input for a hook right below it can live next to that hook.
+
## Styling
- Always use CSS Modules (`.module.css`).
diff --git a/packages/appkit-react/README.md b/packages/appkit-react/README.md
index 756b02e93..46ad5d6ee 100644
--- a/packages/appkit-react/README.md
+++ b/packages/appkit-react/README.md
@@ -216,11 +216,11 @@ const appKit = new AppKit({
},
},
providers: [
- new OmnistonSwapProvider({
+ createOmnistonProvider({
apiUrl: 'https://api.ston.fi',
defaultSlippageBps: 100, // 1%
}),
- new DeDustSwapProvider({
+ createDeDustProvider({
defaultSlippageBps: 100,
referralAddress: 'EQ...', // Optional
}),
@@ -240,24 +240,55 @@ AppKit supports staking through various providers (e.g., Tonstakers). The stakin
### Hooks
-Use `useStakingQuote` to get a staking/unstaking quote and `useBuildStakeTransaction` or `useBuildUnstakeTransaction` to build the transaction.
+Use `useStakingQuote` to get a staking/unstaking quote and `useBuildStakeTransaction` to build the transaction.
[Read more about Staking](https://github.com/ton-connect/kit/tree/main/packages/appkit/docs/staking.md)
```tsx
-const { data: quote } = useStakingQuote({
- amount: '1000000000',
+const {
+ data: quote,
+ isLoading,
+ error,
+} = useStakingQuote({
+ amount: '10',
direction: 'stake',
});
-const { data: balance } = useStakedBalance({
- userAddress: 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c',
+if (isLoading) return
Loading quote...
;
+if (error) return
Error: {error.message}
;
+
+return
Expected Output: {quote?.amountOut}
;
+```
+
+```tsx
+const { data: quote } = useStakingQuote({
+ amount: '10',
+ direction: 'stake',
});
+const { mutateAsync: buildTx, isPending: isBuilding } = useBuildStakeTransaction();
+const { mutateAsync: sendTx, isPending: isSending } = useSendTransaction();
+
+const handleStake = async () => {
+ if (!quote) return;
+ try {
+ const transaction = await buildTx({
+ quote,
+ userAddress: 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c',
+ });
+ await sendTx(transaction);
+ } catch (e) {
+ console.error(e);
+ }
+};
+
+const isPending = isBuilding || isSending;
+
return (
-
Staking Quote: {quote?.amountOut}
-
Staked Balance: {balance?.stakedBalance}
+
+ {isPending ? 'Processing...' : 'Stake'}
+
);
```
diff --git a/packages/appkit-react/docs/components.md b/packages/appkit-react/docs/components.md
index e431508a9..d63760fbf 100644
--- a/packages/appkit-react/docs/components.md
+++ b/packages/appkit-react/docs/components.md
@@ -105,3 +105,73 @@ A button that triggers the wallet connection flow.
```tsx
return
;
```
+
+## Staking
+
+### `StakingWidget`
+
+A high-level component that provides a complete staking interface. It handles quote fetching, transaction building, and user interactions.
+
+```tsx
+// Default UI
+return
;
+```
+
+#### Custom UI
+
+You can also use a render function to build a completely custom UI while keeping the staking logic.
+
+```tsx
+return (
+
+ {({ amount, setAmount, sendTransaction, quote, isQuoteLoading, canSubmit }) => (
+
+
setAmount(e.target.value)}
+ placeholder="Amount to stake"
+ />
+
+ {isQuoteLoading ?
Fetching quote...
: quote ?
You will receive: {quote.amountOut}
: null}
+
+
sendTransaction()}>
+ Stake TON
+
+
+ )}
+
+);
+```
+
+## Swap
+
+### `SwapWidget`
+
+A high-level component that provides a complete swap interface. It handles token selection, quote fetching, and transaction building.
+
+```tsx
+return
;
+```
+
+#### Custom UI
+
+You can also use a render function to build a completely custom UI while keeping the swap logic.
+
+```tsx
+return (
+
+ {({ fromAmount, setFromAmount, toAmount, isQuoteLoading, sendSwapTransaction, canSubmit }) => (
+
+
setFromAmount(e.target.value)} placeholder="Sell" />
+
+
{isQuoteLoading ? 'Calculating...' : `Receive: ${toAmount}`}
+
+
+ Swap Now
+
+
+ )}
+
+);
+```
diff --git a/packages/appkit-react/docs/hooks.md b/packages/appkit-react/docs/hooks.md
index 067309e34..ddeec6bdc 100644
--- a/packages/appkit-react/docs/hooks.md
+++ b/packages/appkit-react/docs/hooks.md
@@ -694,6 +694,155 @@ return (
);
```
+### `useSwapProvider`
+
+Hook to read and change the currently selected swap provider. Returns a tuple `[provider, setProviderId]` — mirrors `useSelectedWallet`.
+
+```tsx
+const [provider, setProviderId] = useSwapProvider();
+return (
+
+
Result: {provider ? provider.providerId : 'null'}
+
setProviderId('stonfi')}>Use STON.fi
+
+);
+```
+
+### `useSwapProviders`
+
+Hook to get all registered swap providers. The returned array keeps a stable reference until the provider list changes, so it is safe to use with `useSyncExternalStore`.
+
+```tsx
+const providers = useSwapProviders();
+return (
+
+ {providers.map((p) => (
+ {p.getMetadata().name}
+ ))}
+
+);
+```
+
+## Staking
+
+### `useStakingProviders`
+
+Hook to get all registered staking providers. The returned array keeps a stable reference until the provider list changes.
+
+```tsx
+const providers = useStakingProviders();
+return (
+
+ {providers.map((p) => (
+ {p.providerId}
+ ))}
+
+);
+```
+
+### `useStakingProvider`
+
+Hook to get a specific staking provider by id (or the default when no id is passed).
+
+```tsx
+const provider = useStakingProvider({ id: 'tonstakers' });
+return
Result: {provider ? provider.providerId : 'null'}
;
+```
+
+### `useStakingQuote`
+
+Hook to get a quote for staking or unstaking a given amount.
+
+```tsx
+const {
+ data: quote,
+ isLoading,
+ error,
+} = useStakingQuote({
+ amount: '10',
+ direction: 'stake',
+});
+
+if (isLoading) return
Loading quote...
;
+if (error) return
Error: {error.message}
;
+
+return
Expected Output: {quote?.amountOut}
;
+```
+
+### `useStakedBalance`
+
+Hook to get the user's currently staked balance.
+
+```tsx
+const { data: balance, isLoading } = useStakedBalance({
+ userAddress: 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c',
+});
+
+if (isLoading) return
Loading balance...
;
+
+return
Staked Balance: {balance?.stakedBalance}
;
+```
+
+### `useStakingProviderInfo`
+
+Hook to get live info about a staking provider (APY, limits, etc.).
+
+```tsx
+const { data: info, isLoading } = useStakingProviderInfo({
+ providerId: 'tonstakers',
+});
+
+if (isLoading) return
Loading info...
;
+
+return
APY: {info?.apy}
;
+```
+
+### `useStakingProviderMetadata`
+
+Hook to get static metadata about a staking provider (name, receive token, etc.).
+
+```tsx
+const metadata = useStakingProviderMetadata();
+return
Receive Token: {metadata?.receiveToken?.ticker}
;
+```
+
+### `useBuildStakeTransaction`
+
+Hook to build a stake transaction from a previously fetched quote.
+
+```tsx
+const { data: quote } = useStakingQuote({
+ amount: '10',
+ direction: 'stake',
+});
+
+const { mutateAsync: buildTx, isPending: isBuilding } = useBuildStakeTransaction();
+const { mutateAsync: sendTx, isPending: isSending } = useSendTransaction();
+
+const handleStake = async () => {
+ if (!quote) return;
+ try {
+ const transaction = await buildTx({
+ quote,
+ userAddress: 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c',
+ });
+ await sendTx(transaction);
+ } catch (e) {
+ console.error(e);
+ }
+};
+
+const isPending = isBuilding || isSending;
+
+return (
+
+
+ {isPending ? 'Processing...' : 'Stake'}
+
+
+);
+```
+
## Transaction
### `useSendTransaction`
diff --git a/packages/appkit-react/src/components/block/block.stories.tsx b/packages/appkit-react/src/components/block/block.stories.tsx
deleted file mode 100644
index a14b28716..000000000
--- a/packages/appkit-react/src/components/block/block.stories.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-/**
- * Copyright (c) TonTech.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- *
- */
-
-import type { Meta, StoryObj } from '@storybook/react';
-
-import { Block } from './block';
-
-const meta: Meta
= {
- title: 'Private/Components/Block',
- component: Block,
- tags: ['autodocs'],
- argTypes: {
- direction: {
- control: 'radio',
- options: ['row', 'column'],
- },
- },
-};
-
-export default meta;
-
-type Story = StoryObj;
-
-export const Column: Story = {
- args: {
- direction: 'column',
- children: (
- <>
- Item 1
- Item 2
- Item 3
- >
- ),
- },
-};
-
-export const Row: Story = {
- args: {
- direction: 'row',
- children: (
- <>
- Item 1
- Item 2
- Item 3
- >
- ),
- },
-};
diff --git a/packages/appkit-react/src/components/button/button.module.css b/packages/appkit-react/src/components/button/button.module.css
deleted file mode 100644
index 9482cf6e8..000000000
--- a/packages/appkit-react/src/components/button/button.module.css
+++ /dev/null
@@ -1,38 +0,0 @@
-.button {
- composes: bodyMedium from "../../styles/typography.module.css";
- appearance: none;
- border: none;
- outline: none;
- cursor: pointer;
- box-sizing: border-box;
- padding: 8px 16px;
- border-radius: 20px;
-
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 8px;
-
- background-color: var(--ta-color-primary);
- color: var(--ta-color-primary-foreground);
- transition: transform 0.125s ease-in-out, background-color 0.2s ease;
- will-change: transform;
-}
-
-.button:hover {
- transform: scale(1.02);
-}
-
-.button:active {
- transform: scale(0.98);
-}
-
-.button:disabled {
- opacity: 0.5;
- cursor: not-allowed;
-}
-
-.button:disabled:hover,
-.button:disabled:active {
- transform: none;
-}
diff --git a/packages/appkit-react/src/components/button/button.stories.tsx b/packages/appkit-react/src/components/button/button.stories.tsx
deleted file mode 100644
index a268465b5..000000000
--- a/packages/appkit-react/src/components/button/button.stories.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-/**
- * Copyright (c) TonTech.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- *
- */
-
-import type { Meta, StoryObj } from '@storybook/react';
-
-import { Button } from './button';
-
-const meta: Meta = {
- title: 'Public/Components/Button',
- component: Button,
- tags: ['autodocs'],
- argTypes: {
- disabled: {
- control: 'boolean',
- },
- },
-};
-
-export default meta;
-
-type Story = StoryObj;
-
-export const Default: Story = {
- args: {
- children: 'Click me',
- },
-};
-
-export const Disabled: Story = {
- args: {
- children: 'Disabled Button',
- disabled: true,
- },
-};
-
-export const WithIcon: Story = {
- args: {
- children: (
- <>
-
-
-
- Add Item
- >
- ),
- },
-};
diff --git a/packages/appkit-react/src/components/button/button.tsx b/packages/appkit-react/src/components/button/button.tsx
deleted file mode 100644
index a9f9dce44..000000000
--- a/packages/appkit-react/src/components/button/button.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-/**
- * Copyright (c) TonTech.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- *
- */
-
-import type { FC, ComponentProps } from 'react';
-import clsx from 'clsx';
-
-import styles from './button.module.css';
-
-export const Button: FC> = ({ className, ...props }) => {
- return ;
-};
diff --git a/packages/appkit-react/src/components/circle-icon/circle-icon.tsx b/packages/appkit-react/src/components/circle-icon/circle-icon.tsx
deleted file mode 100644
index 5ed387403..000000000
--- a/packages/appkit-react/src/components/circle-icon/circle-icon.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-/**
- * Copyright (c) TonTech.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- *
- */
-
-import type { FC, ComponentProps } from 'react';
-import clsx from 'clsx';
-import { Avatar } from 'radix-ui';
-
-import styles from './circle-icon.module.css';
-
-export interface CircleIconProps extends ComponentProps<'div'> {
- size?: number;
- src?: string;
- alt?: string;
- fallback?: string;
-}
-
-export const CircleIcon: FC = ({ className, size = 30, src, alt, fallback, ...props }) => {
- return (
-
-
-
- {(fallback || alt) && (
-
- {fallback ? fallback : alt?.[0]}
-
- )}
-
- );
-};
diff --git a/packages/appkit-react/src/components/shared/amount-presets/amount-presets.module.css b/packages/appkit-react/src/components/shared/amount-presets/amount-presets.module.css
new file mode 100644
index 000000000..1e635f7b2
--- /dev/null
+++ b/packages/appkit-react/src/components/shared/amount-presets/amount-presets.module.css
@@ -0,0 +1,20 @@
+.container {
+ width: 100%;
+ display: grid;
+ gap: 8px;
+ justify-content: center;
+ grid-template-columns: 1fr 1fr 1fr 1fr;
+ margin: 0 auto;
+}
+
+.preset {
+ width: 100%;
+ padding: 8px 16px;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.preset:hover {
+ background: var(--ta-color-background-secondary);
+ border-color: var(--ta-color-text-secondary);
+}
diff --git a/packages/appkit-react/src/components/shared/amount-presets/amount-presets.stories.tsx b/packages/appkit-react/src/components/shared/amount-presets/amount-presets.stories.tsx
new file mode 100644
index 000000000..fd9ba8e76
--- /dev/null
+++ b/packages/appkit-react/src/components/shared/amount-presets/amount-presets.stories.tsx
@@ -0,0 +1,46 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { fn } from 'storybook/test';
+
+import { AmountPresets } from './amount-presets';
+
+const meta: Meta = {
+ title: 'Components/Shared/AmountPresets',
+ component: AmountPresets,
+ tags: ['autodocs'],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ presets: [
+ { label: '10%', amount: '10' },
+ { label: '50%', amount: '50' },
+ { label: '75%', amount: '75' },
+ { label: 'MAX', amount: '100' },
+ ],
+ onPresetSelect: fn(),
+ },
+};
+
+export const WithCurrencySymbol: Story = {
+ args: {
+ presets: [
+ { label: '10', amount: '10' },
+ { label: '50', amount: '50' },
+ { label: '100', amount: '100' },
+ { label: '500', amount: '500' },
+ ],
+ currencySymbol: '$',
+ onPresetSelect: fn(),
+ },
+};
diff --git a/packages/appkit-react/src/components/shared/amount-presets/amount-presets.tsx b/packages/appkit-react/src/components/shared/amount-presets/amount-presets.tsx
new file mode 100644
index 000000000..9fa85baff
--- /dev/null
+++ b/packages/appkit-react/src/components/shared/amount-presets/amount-presets.tsx
@@ -0,0 +1,50 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { ComponentProps, FC } from 'react';
+import clsx from 'clsx';
+
+import { Button } from '../../ui/button';
+import styles from './amount-presets.module.css';
+
+export interface AmountPreset {
+ label: string;
+ amount: string;
+ onSelect?: () => void;
+}
+
+export interface AmountPresetsProps extends ComponentProps<'div'> {
+ presets: AmountPreset[];
+ currencySymbol?: string;
+ onPresetSelect: (value: string) => void;
+}
+
+export const AmountPresets: FC = ({
+ presets,
+ currencySymbol,
+ onPresetSelect,
+ className,
+ ...props
+}) => {
+ return (
+
+ {presets.map((preset) => (
+ (preset.onSelect ? preset.onSelect() : onPresetSelect(preset.amount))}
+ >
+ {currencySymbol}
+ {preset.label}
+
+ ))}
+
+ );
+};
diff --git a/packages/appkit-react/src/components/shared/amount-presets/index.ts b/packages/appkit-react/src/components/shared/amount-presets/index.ts
new file mode 100644
index 000000000..640926678
--- /dev/null
+++ b/packages/appkit-react/src/components/shared/amount-presets/index.ts
@@ -0,0 +1,10 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export { AmountPresets } from './amount-presets';
+export type { AmountPreset, AmountPresetsProps } from './amount-presets';
diff --git a/packages/appkit-react/src/components/shared/button-with-connect/button-with-connect.tsx b/packages/appkit-react/src/components/shared/button-with-connect/button-with-connect.tsx
new file mode 100644
index 000000000..b4e53b9dc
--- /dev/null
+++ b/packages/appkit-react/src/components/shared/button-with-connect/button-with-connect.tsx
@@ -0,0 +1,37 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { FC } from 'react';
+
+import type { ButtonProps } from '../../ui/button';
+import { Button } from '../../ui/button';
+import { useConnectors, useConnect, useSelectedWallet } from '../../../features/wallets';
+import { useI18n } from '../../../features/settings';
+
+export const ButtonWithConnect: FC = (props) => {
+ const connectors = useConnectors();
+ const { mutate: connect, isPending: isConnecting } = useConnect();
+ const [wallet] = useSelectedWallet();
+ const isWalletConnected = wallet !== null;
+
+ const { t } = useI18n();
+
+ if (!isWalletConnected) {
+ return (
+ connectors[0] && connect({ connectorId: connectors[0].id })}
+ >
+ {t('wallet.connectWallet')}
+
+ );
+ }
+
+ return ;
+};
diff --git a/packages/appkit-react/src/components/shared/button-with-connect/index.ts b/packages/appkit-react/src/components/shared/button-with-connect/index.ts
new file mode 100644
index 000000000..da4d46a18
--- /dev/null
+++ b/packages/appkit-react/src/components/shared/button-with-connect/index.ts
@@ -0,0 +1,9 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export { ButtonWithConnect } from './button-with-connect';
diff --git a/packages/appkit-react/src/components/shared/low-balance-modal/index.ts b/packages/appkit-react/src/components/shared/low-balance-modal/index.ts
new file mode 100644
index 000000000..ce630058a
--- /dev/null
+++ b/packages/appkit-react/src/components/shared/low-balance-modal/index.ts
@@ -0,0 +1,10 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export { LowBalanceModal } from './low-balance-modal';
+export type { LowBalanceModalProps, LowBalanceMode } from './low-balance-modal';
diff --git a/packages/appkit-react/src/components/shared/low-balance-modal/low-balance-modal.module.css b/packages/appkit-react/src/components/shared/low-balance-modal/low-balance-modal.module.css
new file mode 100644
index 000000000..af645f066
--- /dev/null
+++ b/packages/appkit-react/src/components/shared/low-balance-modal/low-balance-modal.module.css
@@ -0,0 +1,21 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+.message {
+ margin: 0 0 16px;
+ padding: 0 4px;
+ color: var(--ta-color-text-secondary);
+ font-size: 14px;
+ line-height: 1.4;
+}
+
+.actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
diff --git a/packages/appkit-react/src/components/shared/low-balance-modal/low-balance-modal.stories.tsx b/packages/appkit-react/src/components/shared/low-balance-modal/low-balance-modal.stories.tsx
new file mode 100644
index 000000000..93fbdd13b
--- /dev/null
+++ b/packages/appkit-react/src/components/shared/low-balance-modal/low-balance-modal.stories.tsx
@@ -0,0 +1,39 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { LowBalanceModal } from './low-balance-modal';
+
+const meta: Meta = {
+ title: 'Components/Shared/LowBalanceModal',
+ component: LowBalanceModal,
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ open: true,
+ mode: 'reduce',
+ requiredTon: '0.423',
+ onChange: () => {},
+ onCancel: () => {},
+ },
+};
+
+export const Topup: Story = {
+ args: {
+ open: true,
+ mode: 'topup',
+ requiredTon: '0.423',
+ onChange: () => {},
+ onCancel: () => {},
+ },
+};
diff --git a/packages/appkit-react/src/components/shared/low-balance-modal/low-balance-modal.tsx b/packages/appkit-react/src/components/shared/low-balance-modal/low-balance-modal.tsx
new file mode 100644
index 000000000..68841ca9c
--- /dev/null
+++ b/packages/appkit-react/src/components/shared/low-balance-modal/low-balance-modal.tsx
@@ -0,0 +1,58 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { FC } from 'react';
+
+import { Modal } from '../../ui/modal/modal';
+import { Button } from '../../ui/button';
+import { useI18n } from '../../../features/settings/hooks/use-i18n';
+import styles from './low-balance-modal.module.css';
+
+export type LowBalanceMode = 'reduce' | 'topup';
+
+export interface LowBalanceModalProps {
+ open: boolean;
+ /**
+ * `reduce` — user can fix it by reducing the amount (shows Change/Cancel).
+ * `topup` — reducing doesn't help, user must top up TON (shows Close only).
+ */
+ mode: LowBalanceMode;
+ /** Required amount in TON, formatted as a decimal string (e.g. "0.423"). */
+ requiredTon: string;
+ onChange: () => void;
+ onCancel: () => void;
+}
+
+export const LowBalanceModal: FC = ({ open, mode, requiredTon, onChange, onCancel }) => {
+ const { t } = useI18n();
+
+ const messageKey = mode === 'reduce' ? 'lowBalance.messageReduce' : 'lowBalance.messageTopup';
+
+ return (
+ !isOpen && onCancel()} title={t('lowBalance.title')}>
+ {t(messageKey, { amount: requiredTon })}
+
+
+ {mode === 'reduce' ? (
+ <>
+
+ {t('lowBalance.cancel')}
+
+
+ {t('lowBalance.change')}
+
+ >
+ ) : (
+
+ {t('lowBalance.close')}
+
+ )}
+
+
+ );
+};
diff --git a/packages/appkit-react/src/components/button/index.ts b/packages/appkit-react/src/components/shared/option-switcher/index.ts
similarity index 82%
rename from packages/appkit-react/src/components/button/index.ts
rename to packages/appkit-react/src/components/shared/option-switcher/index.ts
index 4d25fa681..263b997be 100644
--- a/packages/appkit-react/src/components/button/index.ts
+++ b/packages/appkit-react/src/components/shared/option-switcher/index.ts
@@ -6,4 +6,4 @@
*
*/
-export { Button } from './button';
+export * from './option-switcher';
diff --git a/packages/appkit-react/src/components/shared/option-switcher/option-switcher.module.css b/packages/appkit-react/src/components/shared/option-switcher/option-switcher.module.css
new file mode 100644
index 000000000..581026f99
--- /dev/null
+++ b/packages/appkit-react/src/components/shared/option-switcher/option-switcher.module.css
@@ -0,0 +1,18 @@
+.button {
+ composes: bodyMedium from "../../../styles/typography.module.css";
+
+ display: inline-flex;
+ height: 20px;
+ align-items: center;
+ gap: 4px;
+ padding: 0;
+ background: transparent;
+ border: none;
+ color: var(--ta-color-text);
+ cursor: pointer;
+}
+
+.button:disabled {
+ cursor: default;
+ color: var(--ta-color-text-secondary);
+}
diff --git a/packages/appkit-react/src/components/shared/option-switcher/option-switcher.stories.tsx b/packages/appkit-react/src/components/shared/option-switcher/option-switcher.stories.tsx
new file mode 100644
index 000000000..d6485dfab
--- /dev/null
+++ b/packages/appkit-react/src/components/shared/option-switcher/option-switcher.stories.tsx
@@ -0,0 +1,64 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { fn } from 'storybook/test';
+
+import { OptionSwitcher } from './option-switcher';
+
+const meta: Meta = {
+ title: 'Components/Shared/OptionSwitcher',
+ component: OptionSwitcher,
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+const PROVIDER_OPTIONS = [
+ { value: 'stonfi', label: 'STON.fi' },
+ { value: 'dedust', label: 'DeDust' },
+ { value: 'omniston', label: 'Omniston' },
+];
+
+const SLIPPAGE_OPTIONS = [
+ { value: '50', label: '0.50%' },
+ { value: '100', label: '1.00%' },
+ { value: '200', label: '2.00%' },
+];
+
+export const Default: Story = {
+ render: () => {
+ const Wrapper = () => {
+ const [value, setValue] = useState('stonfi');
+ return ;
+ };
+ return ;
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ value: 'stonfi',
+ options: PROVIDER_OPTIONS,
+ onChange: fn(),
+ disabled: true,
+ },
+};
+
+export const Slippage: Story = {
+ render: () => {
+ const Wrapper = () => {
+ const [value, setValue] = useState('50');
+ return ;
+ };
+ return ;
+ },
+};
diff --git a/packages/appkit-react/src/components/shared/option-switcher/option-switcher.tsx b/packages/appkit-react/src/components/shared/option-switcher/option-switcher.tsx
new file mode 100644
index 000000000..5225213bc
--- /dev/null
+++ b/packages/appkit-react/src/components/shared/option-switcher/option-switcher.tsx
@@ -0,0 +1,55 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { FC } from 'react';
+import clsx from 'clsx';
+
+import { ChevronsIcon } from '../../ui/icons';
+import { Select } from '../../ui/select';
+import styles from './option-switcher.module.css';
+
+export interface OptionSwitcherOption {
+ value: string;
+ label: string;
+}
+
+export interface OptionSwitcherProps {
+ /** Currently selected option value. */
+ value: string | undefined;
+ /** Available options. */
+ options: OptionSwitcherOption[];
+ /** Called when the user picks an option. */
+ onChange: (value: string) => void;
+ /** When true, the trigger is non-interactive and dimmed. */
+ disabled?: boolean;
+ className?: string;
+}
+
+/**
+ * Compact selector used inside settings modals next to a label.
+ */
+export const OptionSwitcher: FC = ({ value, options, onChange, disabled, className }) => {
+ const current = options.find((option) => option.value === value);
+ const currentLabel = current?.label ?? value ?? '—';
+
+ return (
+
+
+ {currentLabel}
+
+
+
+ {options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ );
+};
diff --git a/packages/appkit-react/src/components/shared/settings-button/index.ts b/packages/appkit-react/src/components/shared/settings-button/index.ts
new file mode 100644
index 000000000..c3ddee4b5
--- /dev/null
+++ b/packages/appkit-react/src/components/shared/settings-button/index.ts
@@ -0,0 +1,9 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export * from './settings-button';
diff --git a/packages/appkit-react/src/components/shared/settings-button/settings-button.module.css b/packages/appkit-react/src/components/shared/settings-button/settings-button.module.css
new file mode 100644
index 000000000..3310aaf9c
--- /dev/null
+++ b/packages/appkit-react/src/components/shared/settings-button/settings-button.module.css
@@ -0,0 +1,11 @@
+.settingsButton {
+ flex-shrink: 0;
+ width: 50px;
+ height: 50px;
+ aspect-ratio: 1 / 1;
+}
+
+.settingsButton svg {
+ width: 24px;
+ height: 24px;
+}
diff --git a/packages/appkit-react/src/components/shared/settings-button/settings-button.stories.tsx b/packages/appkit-react/src/components/shared/settings-button/settings-button.stories.tsx
new file mode 100644
index 000000000..c6218a3d5
--- /dev/null
+++ b/packages/appkit-react/src/components/shared/settings-button/settings-button.stories.tsx
@@ -0,0 +1,25 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { SettingsButton } from './settings-button';
+
+const meta: Meta = {
+ title: 'Components/Shared/SettingsButton',
+ component: SettingsButton,
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ onClick: () => {},
+ },
+};
diff --git a/packages/appkit-react/src/components/shared/settings-button/settings-button.tsx b/packages/appkit-react/src/components/shared/settings-button/settings-button.tsx
new file mode 100644
index 000000000..b87a55949
--- /dev/null
+++ b/packages/appkit-react/src/components/shared/settings-button/settings-button.tsx
@@ -0,0 +1,33 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { ComponentProps, FC } from 'react';
+import clsx from 'clsx';
+
+import { Button } from '../../ui/button';
+import { SlidersIcon } from '../../ui/icons';
+import styles from './settings-button.module.css';
+
+export interface SettingsButtonProps extends ComponentProps {
+ onClick?: () => void;
+}
+
+export const SettingsButton: FC = ({ onClick, className, ...props }) => {
+ return (
+
+
+
+ );
+};
diff --git a/packages/appkit-react/src/components/shared/token-select-modal/index.ts b/packages/appkit-react/src/components/shared/token-select-modal/index.ts
new file mode 100644
index 000000000..8188dd7ec
--- /dev/null
+++ b/packages/appkit-react/src/components/shared/token-select-modal/index.ts
@@ -0,0 +1,10 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export { TokenSelectModal } from './token-select-modal';
+export type { TokenSelectModalProps } from './token-select-modal';
diff --git a/packages/appkit-react/src/components/shared/token-select-modal/token-select-modal.module.css b/packages/appkit-react/src/components/shared/token-select-modal/token-select-modal.module.css
new file mode 100644
index 000000000..5c051fe7a
--- /dev/null
+++ b/packages/appkit-react/src/components/shared/token-select-modal/token-select-modal.module.css
@@ -0,0 +1,27 @@
+.searchWrapper {
+ margin-bottom: 16px;
+}
+
+.list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ height: 400px;
+ max-height: 100%;
+ overflow-y: auto;
+ flex: 1;
+}
+
+.empty {
+ padding: 32px 0;
+}
+
+.emptyText {
+ composes: bodyRegular from "../../../styles/typography.module.css";
+ margin: 0;
+ color: var(--ta-color-text-secondary);
+ text-align: center;
+}
diff --git a/packages/appkit-react/src/components/shared/token-select-modal/token-select-modal.stories.tsx b/packages/appkit-react/src/components/shared/token-select-modal/token-select-modal.stories.tsx
new file mode 100644
index 000000000..1d965c17a
--- /dev/null
+++ b/packages/appkit-react/src/components/shared/token-select-modal/token-select-modal.stories.tsx
@@ -0,0 +1,68 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { STORY_TOKENS } from '../../../storybook/fixtures/tokens';
+import { Button } from '../../ui/button';
+import { TokenSelectModal } from './token-select-modal';
+import type { AppkitUIToken } from '../../../types/appkit-ui-token';
+
+const meta: Meta = {
+ title: 'Components/Shared/TokenSelectModal',
+ component: TokenSelectModal,
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => {
+ const [open, setOpen] = useState(false);
+ const [selected, setSelected] = useState(null);
+
+ return (
+ <>
+ setOpen(true)}>
+ {selected ? `Selected: ${selected.symbol}` : 'Select Token'}
+
+ setOpen(false)}
+ tokens={STORY_TOKENS}
+ onSelect={setSelected}
+ title="Select Token"
+ searchPlaceholder="Search by name or symbol"
+ />
+ >
+ );
+ },
+};
+
+export const Empty: Story = {
+ render: () => {
+ const [open, setOpen] = useState(false);
+
+ return (
+ <>
+ setOpen(true)}>Open Empty List
+ setOpen(false)}
+ tokens={[]}
+ onSelect={() => {}}
+ title="Select Token"
+ searchPlaceholder="Search by name or symbol"
+ />
+ >
+ );
+ },
+};
diff --git a/packages/appkit-react/src/components/shared/token-select-modal/token-select-modal.tsx b/packages/appkit-react/src/components/shared/token-select-modal/token-select-modal.tsx
new file mode 100644
index 000000000..0689a6902
--- /dev/null
+++ b/packages/appkit-react/src/components/shared/token-select-modal/token-select-modal.tsx
@@ -0,0 +1,103 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { useState } from 'react';
+import type { FC } from 'react';
+import { compareAddress } from '@ton/appkit';
+
+import { Input } from '../../ui/input/input';
+import { Modal } from '../../ui/modal/modal';
+import { SearchIcon } from '../../ui/icons';
+import { CurrencyItem } from '../../../features/balances';
+import { useI18n } from '../../../features/settings/hooks/use-i18n';
+import type { AppkitUIToken } from '../../../types/appkit-ui-token';
+import styles from './token-select-modal.module.css';
+
+export interface TokenSelectModalProps {
+ open: boolean;
+ onClose: () => void;
+ tokens: AppkitUIToken[];
+ onSelect: (token: AppkitUIToken) => void;
+ title: string;
+ searchPlaceholder?: string;
+}
+
+export const TokenSelectModal: FC = ({
+ open,
+ onClose,
+ tokens,
+ onSelect,
+ title,
+ searchPlaceholder,
+}) => {
+ const { t } = useI18n();
+ const [search, setSearch] = useState('');
+
+ const filtered = tokens.filter(
+ (token) =>
+ token.symbol.toLowerCase().includes(search.toLowerCase()) ||
+ token.name.toLowerCase().includes(search.toLowerCase()) ||
+ compareAddress(token.address, search),
+ );
+
+ const handleSelect = (token: AppkitUIToken) => () => {
+ onSelect(token);
+ onClose();
+ setSearch('');
+ };
+
+ const handleOpenChange = (isOpen: boolean) => {
+ if (!isOpen) {
+ onClose();
+ setSearch('');
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ setSearch(e.target.value)}
+ autoFocus
+ />
+
+
+
+
+ {tokens.length === 0 ? (
+
+
{t('tokenSelect.emptyForNetwork')}
+
+ ) : filtered.length === 0 ? (
+
+
{t('tokenSelect.emptyNoMatch')}
+
{t('tokenSelect.emptyTryAddress')}
+
+ ) : (
+
+ {filtered.map((token) => (
+
+ ))}
+
+ )}
+
+
+ );
+};
diff --git a/packages/appkit-react/src/components/ton-icon/ton-icon.stories.tsx b/packages/appkit-react/src/components/ton-icon/ton-icon.stories.tsx
deleted file mode 100644
index 068f195be..000000000
--- a/packages/appkit-react/src/components/ton-icon/ton-icon.stories.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-/**
- * Copyright (c) TonTech.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- *
- */
-
-import type { Meta, StoryObj } from '@storybook/react';
-
-import { TonIcon, TonIconCircle } from './ton-icon';
-
-const meta: Meta = {
- title: 'Private/Components/TonIcon',
- component: TonIcon,
- tags: ['autodocs'],
- argTypes: {
- size: {
- control: { type: 'range', min: 12, max: 64, step: 4 },
- },
- },
- decorators: [
- (Story) => (
-
-
-
- ),
- ],
-};
-
-export default meta;
-
-type Story = StoryObj;
-
-export const Default: Story = {
- args: {
- size: 24,
- },
-};
-
-export const Small: Story = {
- args: {
- size: 16,
- },
-};
-
-export const Large: Story = {
- args: {
- size: 48,
- },
-};
-
-export const CustomColor: Story = {
- args: {
- size: 32,
- style: { color: '#0098EB' },
- },
-};
-
-export const Circle: StoryObj = {
- render: (args) => ,
- args: {
- size: 48,
- },
-};
-
-export const CircleSmall: StoryObj = {
- render: (args) => ,
- args: {
- size: 24,
- },
-};
-
-export const CircleLarge: StoryObj = {
- render: (args) => ,
- args: {
- size: 72,
- },
-};
diff --git a/packages/appkit-react/src/components/ton-icon/ton-icon.tsx b/packages/appkit-react/src/components/ton-icon/ton-icon.tsx
deleted file mode 100644
index d4179ba27..000000000
--- a/packages/appkit-react/src/components/ton-icon/ton-icon.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-/**
- * Copyright (c) TonTech.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- *
- */
-
-import type { FC, ComponentProps } from 'react';
-
-export interface TonIconProps extends Omit, 'width' | 'height'> {
- size?: number;
-}
-
-export const TonIcon: FC = ({ size = 16, ...props }) => {
- return (
-
-
-
- );
-};
-
-export const TonIconCircle: FC = ({ size = 16, ...props }) => {
- return (
-
-
-
-
-
-
-
-
-
-
-
- );
-};
diff --git a/packages/appkit-react/src/components/ui/amount-reversed/amount-reversed.module.css b/packages/appkit-react/src/components/ui/amount-reversed/amount-reversed.module.css
new file mode 100644
index 000000000..a909d3a19
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/amount-reversed/amount-reversed.module.css
@@ -0,0 +1,34 @@
+.container {
+ composes: bodySemibold from '../../../styles/typography.module.css';
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: text;
+ width: 100%;
+ overflow: hidden;
+ color: var(--ta-color-text-secondary);
+ gap: 8px;
+}
+
+.skeleton {
+ width: 70px;
+ height: calc(var(--ta-body-regular-line-height) - 4px);
+ margin: 2px 0;
+}
+
+.changeDirection {
+ width: 14px;
+ height: 14px;
+ background-color: transparent;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+ outline: none;
+ color: var(--ta-color-text);
+ transition: opacity 0.2s ease-in-out;
+}
+
+.changeDirection:hover {
+ opacity: 0.8;
+}
diff --git a/packages/appkit-react/src/components/ui/amount-reversed/amount-reversed.tsx b/packages/appkit-react/src/components/ui/amount-reversed/amount-reversed.tsx
new file mode 100644
index 000000000..ba2911779
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/amount-reversed/amount-reversed.tsx
@@ -0,0 +1,65 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { ComponentProps, FC } from 'react';
+import { formatLargeValue } from '@ton/appkit';
+import clsx from 'clsx';
+
+import styles from './amount-reversed.module.css';
+import { Skeleton } from '../skeleton';
+import { FlipIcon } from '../icons';
+
+export interface AmountReversedProps extends ComponentProps<'div'> {
+ value: string;
+ onChangeDirection?: () => void;
+ ticker?: string;
+ symbol?: string;
+ decimals?: number;
+ errorMessage?: string;
+ isLoading?: boolean;
+}
+
+export const AmountReversed: FC = ({
+ value,
+ onChangeDirection,
+ ticker,
+ symbol,
+ decimals,
+ errorMessage,
+ className,
+ isLoading,
+ ...props
+}) => {
+ if (errorMessage) {
+ return (
+
+ {errorMessage}
+
+ );
+ }
+
+ return (
+
+ {isLoading ? (
+
+ ) : (
+
+ {symbol}
+ {value ? formatLargeValue(value, decimals) : '0'}
+ {ticker ? ` ${ticker}` : ''}
+
+ )}
+
+ {onChangeDirection && (
+
+
+
+ )}
+
+ );
+};
diff --git a/packages/appkit-react/src/components/ui/amount-reversed/index.ts b/packages/appkit-react/src/components/ui/amount-reversed/index.ts
new file mode 100644
index 000000000..665f5f879
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/amount-reversed/index.ts
@@ -0,0 +1,10 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export { AmountReversed } from './amount-reversed';
+export type { AmountReversedProps } from './amount-reversed';
diff --git a/packages/appkit-react/src/components/block/block.module.css b/packages/appkit-react/src/components/ui/block/block.module.css
similarity index 77%
rename from packages/appkit-react/src/components/block/block.module.css
rename to packages/appkit-react/src/components/ui/block/block.module.css
index 5de8b960d..102bbef8c 100644
--- a/packages/appkit-react/src/components/block/block.module.css
+++ b/packages/appkit-react/src/components/ui/block/block.module.css
@@ -4,7 +4,7 @@
box-sizing: border-box;
padding: 16px;
border-radius: var(--ta-border-radius-l);
- background-color: var(--ta-color-block);
+ background-color: var(--ta-color-background-secondary);
}
.row {
diff --git a/packages/appkit-react/src/components/ui/block/block.stories.tsx b/packages/appkit-react/src/components/ui/block/block.stories.tsx
new file mode 100644
index 000000000..c54dbba60
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/block/block.stories.tsx
@@ -0,0 +1,65 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { Block } from './block';
+
+const meta: Meta = {
+ title: 'Components/UI/Block',
+ component: Block,
+ tags: ['autodocs'],
+ argTypes: {
+ direction: {
+ control: 'radio',
+ options: ['row', 'column'],
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Column: Story = {
+ args: {
+ direction: 'column',
+ children: (
+ <>
+
+ Item 1
+
+
+ Item 2
+
+
+ Item 3
+
+ >
+ ),
+ },
+};
+
+export const Row: Story = {
+ args: {
+ direction: 'row',
+ children: (
+ <>
+
+ Item 1
+
+
+ Item 2
+
+
+ Item 3
+
+ >
+ ),
+ },
+};
diff --git a/packages/appkit-react/src/components/block/block.tsx b/packages/appkit-react/src/components/ui/block/block.tsx
similarity index 100%
rename from packages/appkit-react/src/components/block/block.tsx
rename to packages/appkit-react/src/components/ui/block/block.tsx
diff --git a/packages/appkit-react/src/components/block/index.ts b/packages/appkit-react/src/components/ui/block/index.ts
similarity index 100%
rename from packages/appkit-react/src/components/block/index.ts
rename to packages/appkit-react/src/components/ui/block/index.ts
diff --git a/packages/appkit-react/src/components/ui/button/button.module.css b/packages/appkit-react/src/components/ui/button/button.module.css
new file mode 100644
index 000000000..028fde668
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/button/button.module.css
@@ -0,0 +1,131 @@
+.button {
+ appearance: none;
+ border: none;
+ outline: none;
+ cursor: pointer;
+ box-sizing: border-box;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ transition: opacity 0.15s ease-in-out;
+ text-decoration: none;
+ width: fit-content;
+}
+
+.button:hover:not(:disabled):not(.loading) {
+ opacity: 0.85;
+}
+
+.button:active:not(:disabled):not(.loading) {
+ opacity: 0.65;
+}
+
+.button:disabled {
+ opacity: 0.35;
+ cursor: not-allowed;
+}
+
+.loading {
+ cursor: wait;
+}
+
+/* Sizes */
+.l {
+ composes: bodySemibold from "../../../styles/typography.module.css";
+ padding: 12px 16px;
+}
+
+.m {
+ composes: labelSemibold from "../../../styles/typography.module.css";
+ padding: 12px 16px;
+}
+
+.s {
+ composes: labelSemibold from "../../../styles/typography.module.css";
+ padding: 9px 12px;
+}
+
+/* Border radius */
+.radiusS {
+ border-radius: var(--ta-border-radius-s);
+}
+
+.radiusM {
+ border-radius: var(--ta-border-radius-m);
+}
+
+.radiusL {
+ border-radius: var(--ta-border-radius-l);
+}
+
+.radiusXl {
+ border-radius: var(--ta-border-radius-xl);
+}
+
+.radius2xl {
+ border-radius: var(--ta-border-radius-2xl);
+}
+
+.radiusFull {
+ border-radius: var(--ta-border-radius-full);
+}
+
+/* Variants */
+.fill {
+ background-color: var(--ta-color-primary);
+ color: var(--ta-color-primary-foreground);
+}
+
+.secondary {
+ background-color: var(--ta-color-background-secondary);
+ color: var(--ta-color-text);
+}
+
+.bezeled {
+ background-color: var(--ta-color-background-bezeled);
+ color: var(--ta-color-primary);
+}
+
+.gray {
+ background-color: var(--ta-color-background-tertiary);
+ color: var(--ta-color-text);
+}
+
+.ghost {
+ background-color: transparent;
+ color: var(--ta-color-text);
+}
+
+.ghost:hover:not(:disabled):not(.loading) {
+ background-color: var(--ta-color-background-tertiary);
+}
+
+.fullWidth {
+ width: 100%;
+}
+
+.innerIcon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.spinner {
+ width: 18px;
+ height: 18px;
+ border: 2.5px solid currentColor;
+ border-top-color: transparent;
+ border-radius: 50%;
+ animation: button-spin 0.6s linear infinite;
+}
+
+@keyframes button-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/packages/appkit-react/src/components/ui/button/button.stories.tsx b/packages/appkit-react/src/components/ui/button/button.stories.tsx
new file mode 100644
index 000000000..67539ef1a
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/button/button.stories.tsx
@@ -0,0 +1,121 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { Button } from './button';
+
+const meta: Meta = {
+ title: 'Components/UI/Button',
+ component: Button,
+ tags: ['autodocs'],
+ argTypes: {
+ size: {
+ control: 'select',
+ options: ['s', 'm', 'l', 'unset'],
+ },
+ borderRadius: {
+ control: 'select',
+ options: ['s', 'm', 'l', 'xl', '2xl', 'full'],
+ },
+ variant: {
+ control: 'select',
+ options: ['fill', 'secondary', 'bezeled', 'gray', 'ghost', 'unstyled'],
+ },
+ disabled: {
+ control: 'boolean',
+ },
+ loading: {
+ control: 'boolean',
+ },
+ fullWidth: {
+ control: 'boolean',
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Fill: Story = {
+ args: {
+ children: 'Action',
+ variant: 'fill',
+ size: 'l',
+ },
+};
+
+export const Bezeled: Story = {
+ args: {
+ children: 'Action',
+ variant: 'bezeled',
+ size: 'l',
+ },
+};
+
+export const Gray: Story = {
+ args: {
+ children: 'Action',
+ variant: 'gray',
+ size: 'l',
+ },
+};
+
+export const Sizes: Story = {
+ render: (args) => (
+
+
+ Large Button
+
+
+ Medium Button
+
+
+ Small Button
+
+
+ ),
+ args: {
+ variant: 'fill',
+ },
+};
+
+export const Variants: Story = {
+ render: (args) => (
+
+
+ Fill Button
+
+
+ Bezeled Button
+
+
+ Gray Button
+
+
+ ),
+ args: {
+ size: 'l',
+ },
+};
+
+export const Loading: Story = {
+ args: {
+ children: 'Loading Button',
+ loading: true,
+ },
+};
+
+export const Unstyled: Story = {
+ args: {
+ children: 'Bare button',
+ variant: 'unstyled',
+ size: 'unset',
+ },
+};
diff --git a/packages/appkit-react/src/components/ui/button/button.tsx b/packages/appkit-react/src/components/ui/button/button.tsx
new file mode 100644
index 000000000..5b868b76d
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/button/button.tsx
@@ -0,0 +1,99 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { forwardRef } from 'react';
+import type { ComponentProps, ReactNode } from 'react';
+import clsx from 'clsx';
+
+import styles from './button.module.css';
+
+export type ButtonSize = 's' | 'm' | 'l' | 'unset';
+export type ButtonBorderRadius = 's' | 'm' | 'l' | 'xl' | '2xl' | 'full';
+export type ButtonVariant = 'fill' | 'secondary' | 'bezeled' | 'gray' | 'ghost' | 'unstyled';
+
+export interface ButtonProps extends ComponentProps<'button'> {
+ /**
+ * Size class applied to the button. Pass `'unset'` to skip the size class
+ * entirely (no padding, no typography) — useful with `variant="unstyled"`.
+ */
+ size?: ButtonSize;
+ borderRadius?: ButtonBorderRadius;
+ /**
+ * Visual variant. Use `'unstyled'` to opt out of all built-in styling —
+ * the consumer is fully responsible for visuals via `className`. The
+ * Button still provides ref forwarding, `disabled`/`loading` plumbing,
+ * and `icon`/`children` rendering.
+ */
+ variant?: ButtonVariant;
+ loading?: boolean;
+ fullWidth?: boolean;
+ icon?: ReactNode;
+}
+
+const SIZE_DEFAULT_RADIUS: Record, ButtonBorderRadius> = {
+ s: '2xl',
+ m: 'l',
+ l: 'xl',
+};
+
+const RADIUS_CLASS: Record = {
+ s: 'radiusS',
+ m: 'radiusM',
+ l: 'radiusL',
+ xl: 'radiusXl',
+ '2xl': 'radius2xl',
+ full: 'radiusFull',
+};
+
+export const Button = forwardRef(
+ (
+ {
+ className,
+ size = 'm',
+ borderRadius,
+ variant = 'fill',
+ loading = false,
+ fullWidth = false,
+ disabled,
+ icon,
+ children,
+ ...props
+ },
+ ref,
+ ) => {
+ const radius = borderRadius ?? (size === 'unset' ? undefined : SIZE_DEFAULT_RADIUS[size]);
+
+ return (
+
+ {loading ? (
+
+ ) : (
+ <>
+ {icon && {icon} }
+ {children}
+ >
+ )}
+
+ );
+ },
+);
+
+Button.displayName = 'Button';
diff --git a/packages/appkit-react/src/components/ui/button/index.ts b/packages/appkit-react/src/components/ui/button/index.ts
new file mode 100644
index 000000000..628283402
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/button/index.ts
@@ -0,0 +1,10 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export { Button } from './button';
+export type { ButtonProps } from './button';
diff --git a/packages/appkit-react/src/components/ui/centered-amount-input/centered-amount-input.module.css b/packages/appkit-react/src/components/ui/centered-amount-input/centered-amount-input.module.css
new file mode 100644
index 000000000..f2dece67a
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/centered-amount-input/centered-amount-input.module.css
@@ -0,0 +1,69 @@
+.wrapper {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ position: relative;
+ cursor: text;
+ width: 100%;
+ overflow: hidden;
+}
+
+.row {
+ display: flex;
+ align-items: baseline;
+ max-width: 100%;
+}
+
+.input {
+ composes: inputXl from "../../../styles/typography.module.css";
+ color: var(--ta-color-text);
+ border: none;
+ outline: none;
+ background: none;
+ padding: 0;
+ text-align: right;
+ min-width: 24px;
+ max-width: 100%;
+ box-sizing: content-box;
+}
+
+.input::placeholder {
+ color: var(--ta-color-text-tertiary);
+ opacity: 1;
+}
+
+.ticker {
+ composes: inputXlSymbol from "../../../styles/typography.module.css";
+ color: var(--ta-color-text-tertiary);
+ white-space: nowrap;
+ user-select: none;
+ margin-left: 0.2em;
+}
+
+.symbol {
+ composes: inputXl from "../../../styles/typography.module.css";
+ color: var(--ta-color-text-tertiary);
+ white-space: nowrap;
+ user-select: none;
+}
+
+.mirror {
+ composes: inputXl from "../../../styles/typography.module.css";
+ position: absolute;
+ visibility: hidden;
+ white-space: nowrap;
+ pointer-events: none;
+}
+
+.measureRow {
+ display: flex;
+ align-items: baseline;
+ position: absolute;
+ visibility: hidden;
+ white-space: nowrap;
+ pointer-events: none;
+}
+
+.measureText {
+ composes: inputXl from "../../../styles/typography.module.css";
+}
diff --git a/packages/appkit-react/src/components/ui/centered-amount-input/centered-amount-input.stories.tsx b/packages/appkit-react/src/components/ui/centered-amount-input/centered-amount-input.stories.tsx
new file mode 100644
index 000000000..83ac3b280
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/centered-amount-input/centered-amount-input.stories.tsx
@@ -0,0 +1,52 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { CenteredAmountInput } from './centered-amount-input';
+
+const meta: Meta = {
+ title: 'Components/UI/CenteredAmountInput',
+ component: CenteredAmountInput,
+ tags: ['autodocs'],
+};
+
+export default meta;
+type Story = StoryObj;
+
+const Template = (props: { symbol?: string; ticker?: string; placeholder?: string; width?: number }) => {
+ const [value, setValue] = useState('');
+ return (
+
+
+
+ );
+};
+
+export const WithSymbol: Story = {
+ render: () => ,
+};
+
+export const WithTicker: Story = {
+ render: () => ,
+};
+
+export const WithSymbolAndTicker: Story = {
+ render: () => ,
+};
+
+export const NarrowContainer: Story = {
+ render: () => ,
+};
diff --git a/packages/appkit-react/src/components/ui/centered-amount-input/centered-amount-input.tsx b/packages/appkit-react/src/components/ui/centered-amount-input/centered-amount-input.tsx
new file mode 100644
index 000000000..cf526e846
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/centered-amount-input/centered-amount-input.tsx
@@ -0,0 +1,118 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { useCallback, useLayoutEffect, useRef, useState } from 'react';
+import type { FC, ComponentProps } from 'react';
+import clsx from 'clsx';
+
+import styles from './centered-amount-input.module.css';
+
+const MIN_FONT_SCALE = 0.5;
+
+export interface CenteredAmountInputProps extends ComponentProps<'div'> {
+ value: string;
+ onValueChange: (value: string) => void;
+ ticker?: string;
+ symbol?: string;
+ placeholder?: string;
+}
+
+export const CenteredAmountInput: FC = ({
+ value,
+ onValueChange,
+ ticker,
+ symbol,
+ placeholder = '0',
+ className,
+ ...props
+}) => {
+ const wrapperRef = useRef(null);
+ const measureRowRef = useRef(null);
+ const mirrorRef = useRef(null);
+ const inputRef = useRef(null);
+ const [inputWidth, setInputWidth] = useState(undefined);
+ const [fontScale, setFontScale] = useState(1);
+
+ const adjustSize = useCallback(() => {
+ const wrapper = wrapperRef.current;
+ const measureRow = measureRowRef.current;
+ const mirror = mirrorRef.current;
+
+ if (!wrapper || !measureRow || !mirror) return;
+
+ const contentWidth = measureRow.offsetWidth;
+ const availableWidth = wrapper.clientWidth - 4;
+
+ let scale = 1;
+ if (contentWidth > 0 && contentWidth > availableWidth) {
+ scale = Math.max(MIN_FONT_SCALE, availableWidth / contentWidth);
+ }
+
+ setFontScale(scale);
+ setInputWidth(mirror.offsetWidth * scale + 4);
+ }, []);
+
+ useLayoutEffect(adjustSize, [value, placeholder, symbol, ticker, adjustSize]);
+
+ useLayoutEffect(() => {
+ const wrapper = wrapperRef.current;
+ if (!wrapper) return;
+
+ const observer = new ResizeObserver(adjustSize);
+ observer.observe(wrapper);
+ return () => observer.disconnect();
+ }, [adjustSize]);
+
+ const scaledInputFontSize = fontScale < 1 ? `calc(var(--ta-input-xl-size) * ${fontScale})` : undefined;
+ const scaledTickerFontSize = fontScale < 1 ? `calc(var(--ta-input-xl-symbol-size) * ${fontScale})` : undefined;
+
+ return (
+ inputRef.current?.focus()}
+ {...props}
+ >
+
+ {symbol && {symbol} }
+ {value || placeholder}
+ {ticker && {ticker} }
+
+
+
+ {symbol && (
+
+ {symbol}
+
+ )}
+ onValueChange(e.target.value)}
+ style={{
+ width: inputWidth ? `${inputWidth}px` : undefined,
+ fontSize: scaledInputFontSize,
+ }}
+ />
+ {ticker && (
+
+ {ticker}
+
+ )}
+
+
+
+ {value || placeholder}
+
+
+ );
+};
diff --git a/packages/appkit-react/src/components/ui/centered-amount-input/index.ts b/packages/appkit-react/src/components/ui/centered-amount-input/index.ts
new file mode 100644
index 000000000..4145d5582
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/centered-amount-input/index.ts
@@ -0,0 +1,10 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export { CenteredAmountInput } from './centered-amount-input';
+export type { CenteredAmountInputProps } from './centered-amount-input';
diff --git a/packages/appkit-react/src/components/ui/collapsible/collapsible.module.css b/packages/appkit-react/src/components/ui/collapsible/collapsible.module.css
new file mode 100644
index 000000000..d0362c1ef
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/collapsible/collapsible.module.css
@@ -0,0 +1,4 @@
+.collapsible {
+ overflow: hidden;
+ transition: height 0.25s ease;
+}
diff --git a/packages/appkit-react/src/components/ui/collapsible/collapsible.stories.tsx b/packages/appkit-react/src/components/ui/collapsible/collapsible.stories.tsx
new file mode 100644
index 000000000..f71543ffa
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/collapsible/collapsible.stories.tsx
@@ -0,0 +1,40 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { Collapsible } from './collapsible';
+
+const meta: Meta = {
+ title: 'Components/UI/Collapsible',
+ component: Collapsible,
+ tags: ['autodocs'],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => {
+ const [open, setOpen] = useState(false);
+ return (
+
+
setOpen((v) => !v)}>
+ {open ? 'Collapse' : 'Expand'}
+
+
+
+
This content is collapsible.
+
It animates from zero height to its natural height.
+
+
+
+ );
+ },
+};
diff --git a/packages/appkit-react/src/components/ui/collapsible/collapsible.tsx b/packages/appkit-react/src/components/ui/collapsible/collapsible.tsx
new file mode 100644
index 000000000..bce384715
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/collapsible/collapsible.tsx
@@ -0,0 +1,54 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { useRef, useLayoutEffect, useState } from 'react';
+import type { FC, ComponentProps } from 'react';
+
+import styles from './collapsible.module.css';
+
+export interface CollapsibleProps extends ComponentProps<'div'> {
+ open: boolean;
+}
+
+export const Collapsible: FC = ({ open, children, ...props }) => {
+ const contentRef = useRef(null);
+ const [height, setHeight] = useState(open ? undefined : 0);
+
+ useLayoutEffect(() => {
+ const el = contentRef.current;
+ if (!el) return undefined;
+
+ if (open) {
+ setHeight(el.scrollHeight);
+
+ const onEnd = () => setHeight(undefined);
+ el.addEventListener('transitionend', onEnd, { once: true });
+ return () => el.removeEventListener('transitionend', onEnd);
+ }
+
+ setHeight(el.scrollHeight);
+ const id = requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ setHeight(0);
+ });
+ });
+ return () => cancelAnimationFrame(id);
+ }, [open]);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/appkit-react/src/components/ui/collapsible/index.ts b/packages/appkit-react/src/components/ui/collapsible/index.ts
new file mode 100644
index 000000000..ad22db5e2
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/collapsible/index.ts
@@ -0,0 +1,10 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export { Collapsible } from './collapsible';
+export type { CollapsibleProps } from './collapsible';
diff --git a/packages/appkit-react/src/components/ui/dialog/dialog.tsx b/packages/appkit-react/src/components/ui/dialog/dialog.tsx
new file mode 100644
index 000000000..dbf4fefdd
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/dialog/dialog.tsx
@@ -0,0 +1,105 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { forwardRef, useCallback, useEffect, useId } from 'react';
+import { createPortal } from 'react-dom';
+import type { ComponentPropsWithoutRef, ComponentRef, FC, ReactNode } from 'react';
+
+import { DialogContext, useDialogContext } from './use-dialog-context';
+
+interface DialogRootProps {
+ children: ReactNode;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+}
+
+const DialogRoot: FC = ({ children, open = false, onOpenChange }) => {
+ const titleId = useId();
+ const handleOpenChange = useCallback((value: boolean) => onOpenChange?.(value), [onOpenChange]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+interface DialogPortalProps {
+ children: ReactNode;
+ container?: Element | null;
+}
+
+const DialogPortal: FC = ({ children, container }) => {
+ const { open } = useDialogContext();
+ if (!open || typeof document === 'undefined') return null;
+ return createPortal(children, container ?? document.body);
+};
+
+const DialogOverlay = forwardRef, ComponentPropsWithoutRef<'div'>>((props, ref) => {
+ useEffect(() => {
+ document.body.style.overflow = 'hidden';
+ return () => {
+ document.body.style.overflow = '';
+ };
+ }, []);
+
+ return
;
+});
+
+DialogOverlay.displayName = 'DialogOverlay';
+
+const DialogContent = forwardRef, ComponentPropsWithoutRef<'div'>>((props, ref) => {
+ const { onOpenChange, titleId } = useDialogContext();
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onOpenChange(false);
+ };
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [onOpenChange]);
+
+ return
;
+});
+
+DialogContent.displayName = 'DialogContent';
+
+const DialogTitle = forwardRef, ComponentPropsWithoutRef<'h2'>>((props, ref) => {
+ const { titleId } = useDialogContext();
+ return ;
+});
+
+DialogTitle.displayName = 'DialogTitle';
+
+const DialogClose = forwardRef, ComponentPropsWithoutRef<'button'>>(
+ ({ onClick, ...props }, ref) => {
+ const { onOpenChange } = useDialogContext();
+ return (
+ {
+ onClick?.(e);
+ onOpenChange(false);
+ }}
+ />
+ );
+ },
+);
+
+DialogClose.displayName = 'DialogClose';
+
+export const Dialog = {
+ Root: DialogRoot,
+ Portal: DialogPortal,
+ Overlay: DialogOverlay,
+ Content: DialogContent,
+ Title: DialogTitle,
+ Close: DialogClose,
+};
diff --git a/packages/appkit-react/src/components/ui/dialog/index.ts b/packages/appkit-react/src/components/ui/dialog/index.ts
new file mode 100644
index 000000000..5be87f197
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/dialog/index.ts
@@ -0,0 +1,9 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export * from './dialog';
diff --git a/packages/appkit-react/src/components/ui/dialog/use-dialog-context.ts b/packages/appkit-react/src/components/ui/dialog/use-dialog-context.ts
new file mode 100644
index 000000000..9420afba5
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/dialog/use-dialog-context.ts
@@ -0,0 +1,23 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { createContext, useContext } from 'react';
+
+export interface DialogContextValue {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ titleId: string;
+}
+
+export const DialogContext = createContext(null);
+
+export const useDialogContext = (): DialogContextValue => {
+ const ctx = useContext(DialogContext);
+ if (!ctx) throw new Error('Dialog compound components must be used within Dialog.Root');
+ return ctx;
+};
diff --git a/packages/appkit-react/src/components/ui/icons/chevron-down-icon.tsx b/packages/appkit-react/src/components/ui/icons/chevron-down-icon.tsx
new file mode 100644
index 000000000..b29ecc8aa
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/icons/chevron-down-icon.tsx
@@ -0,0 +1,26 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { FC } from 'react';
+
+import { DEFAULT_ICON_SIZE } from './types';
+import type { IconProps } from './types';
+
+export const ChevronDownIcon: FC = ({ size = DEFAULT_ICON_SIZE, ...props }) => (
+
+
+
+);
diff --git a/packages/appkit-react/src/components/ui/icons/chevrons-icon.tsx b/packages/appkit-react/src/components/ui/icons/chevrons-icon.tsx
new file mode 100644
index 000000000..27e037621
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/icons/chevrons-icon.tsx
@@ -0,0 +1,39 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { FC } from 'react';
+
+import { DEFAULT_ICON_SIZE } from './types';
+import type { IconProps } from './types';
+
+export const ChevronsIcon: FC = ({ size = DEFAULT_ICON_SIZE, ...props }) => (
+
+
+
+
+);
diff --git a/packages/appkit-react/src/components/ui/icons/close-icon.tsx b/packages/appkit-react/src/components/ui/icons/close-icon.tsx
new file mode 100644
index 000000000..3fc3e9a68
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/icons/close-icon.tsx
@@ -0,0 +1,32 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { FC } from 'react';
+
+import { DEFAULT_ICON_SIZE } from './types';
+import type { IconProps } from './types';
+
+export const CloseIcon: FC = ({ size = DEFAULT_ICON_SIZE, ...props }) => (
+
+
+
+);
diff --git a/packages/appkit-react/src/components/ui/icons/failed-icon.tsx b/packages/appkit-react/src/components/ui/icons/failed-icon.tsx
new file mode 100644
index 000000000..0d7aeb3f2
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/icons/failed-icon.tsx
@@ -0,0 +1,32 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { FC } from 'react';
+
+import { DEFAULT_ICON_SIZE } from './types';
+import type { IconProps } from './types';
+
+export const FailedIcon: FC = ({ size = DEFAULT_ICON_SIZE, ...props }) => (
+
+
+
+
+
+);
diff --git a/packages/appkit-react/src/components/ui/icons/flip-icon.tsx b/packages/appkit-react/src/components/ui/icons/flip-icon.tsx
new file mode 100644
index 000000000..59a700752
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/icons/flip-icon.tsx
@@ -0,0 +1,39 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { FC } from 'react';
+
+import { DEFAULT_ICON_SIZE } from './types';
+import type { IconProps } from './types';
+
+export const FlipIcon: FC = ({ size = DEFAULT_ICON_SIZE, ...props }) => (
+
+
+
+
+);
diff --git a/packages/appkit-react/src/components/ui/icons/icons.stories.tsx b/packages/appkit-react/src/components/ui/icons/icons.stories.tsx
new file mode 100644
index 000000000..cf6bc056e
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/icons/icons.stories.tsx
@@ -0,0 +1,85 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import type { FC } from 'react';
+
+import { ChevronsIcon } from './chevrons-icon';
+import { ChevronDownIcon } from './chevron-down-icon';
+import { CloseIcon } from './close-icon';
+import { FailedIcon } from './failed-icon';
+import { FlipIcon } from './flip-icon';
+import { ImageIcon } from './image-icon';
+import { SearchIcon } from './search-icon';
+import { SlidersIcon } from './sliders-icon';
+import { SpinnerIcon } from './spinner-icon';
+import { SuccessIcon } from './success-icon';
+import { TonIcon, TonIconCircle } from './ton-icon';
+import { VerifiedIcon } from './verified-icon';
+import type { IconProps } from './types';
+
+const ICONS: { name: string; Component: FC }[] = [
+ { name: 'ChevronsIcon', Component: ChevronsIcon },
+ { name: 'ChevronDownIcon', Component: ChevronDownIcon },
+ { name: 'CloseIcon', Component: CloseIcon },
+ { name: 'FailedIcon', Component: FailedIcon },
+ { name: 'FlipIcon', Component: FlipIcon },
+ { name: 'ImageIcon', Component: ImageIcon },
+ { name: 'SearchIcon', Component: SearchIcon },
+ { name: 'SlidersIcon', Component: SlidersIcon },
+ { name: 'SpinnerIcon', Component: SpinnerIcon },
+ { name: 'SuccessIcon', Component: SuccessIcon },
+ { name: 'TonIcon', Component: TonIcon },
+ { name: 'TonIconCircle', Component: TonIconCircle },
+ { name: 'VerifiedIcon', Component: VerifiedIcon },
+];
+
+const Gallery: FC = () => (
+
+ {ICONS.map(({ name, Component }) => (
+
+
+
+ {name}
+
+
+ ))}
+
+);
+
+const meta: Meta = {
+ title: 'Components/UI/Icons',
+ component: Gallery,
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const All: Story = {};
diff --git a/packages/appkit-react/src/components/ui/icons/image-icon.tsx b/packages/appkit-react/src/components/ui/icons/image-icon.tsx
new file mode 100644
index 000000000..66c0e8f4d
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/icons/image-icon.tsx
@@ -0,0 +1,32 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { FC } from 'react';
+
+import { DEFAULT_ICON_SIZE } from './types';
+import type { IconProps } from './types';
+
+export const ImageIcon: FC = ({ size = DEFAULT_ICON_SIZE, ...props }) => (
+
+
+
+);
diff --git a/packages/appkit-react/src/components/ui/icons/index.ts b/packages/appkit-react/src/components/ui/icons/index.ts
new file mode 100644
index 000000000..0c96d882e
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/icons/index.ts
@@ -0,0 +1,21 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export * from './types';
+export * from './chevrons-icon';
+export * from './chevron-down-icon';
+export * from './close-icon';
+export * from './failed-icon';
+export * from './flip-icon';
+export * from './image-icon';
+export * from './search-icon';
+export * from './sliders-icon';
+export * from './spinner-icon';
+export * from './success-icon';
+export * from './ton-icon';
+export * from './verified-icon';
diff --git a/packages/appkit-react/src/components/ui/icons/search-icon.tsx b/packages/appkit-react/src/components/ui/icons/search-icon.tsx
new file mode 100644
index 000000000..e0351c4a5
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/icons/search-icon.tsx
@@ -0,0 +1,29 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { FC } from 'react';
+
+import { DEFAULT_ICON_SIZE } from './types';
+import type { IconProps } from './types';
+
+export const SearchIcon: FC = ({ size = DEFAULT_ICON_SIZE, ...props }) => (
+
+
+
+);
diff --git a/packages/appkit-react/src/components/ui/icons/sliders-icon.tsx b/packages/appkit-react/src/components/ui/icons/sliders-icon.tsx
new file mode 100644
index 000000000..b7e19310b
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/icons/sliders-icon.tsx
@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { FC } from 'react';
+
+import { DEFAULT_ICON_SIZE } from './types';
+import type { IconProps } from './types';
+
+export const SlidersIcon: FC = ({ size = DEFAULT_ICON_SIZE, ...props }) => (
+
+
+
+
+
+
+
+
+);
diff --git a/packages/appkit-react/src/components/ui/icons/spinner-icon.tsx b/packages/appkit-react/src/components/ui/icons/spinner-icon.tsx
new file mode 100644
index 000000000..cdc180b98
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/icons/spinner-icon.tsx
@@ -0,0 +1,30 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { FC } from 'react';
+
+import { DEFAULT_ICON_SIZE } from './types';
+import type { IconProps } from './types';
+
+export const SpinnerIcon: FC = ({ size = DEFAULT_ICON_SIZE, ...props }) => (
+
+
+
+);
diff --git a/packages/appkit-react/src/components/ui/icons/success-icon.tsx b/packages/appkit-react/src/components/ui/icons/success-icon.tsx
new file mode 100644
index 000000000..d09427208
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/icons/success-icon.tsx
@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { FC } from 'react';
+
+import { DEFAULT_ICON_SIZE } from './types';
+import type { IconProps } from './types';
+
+export const SuccessIcon: FC = ({ size = DEFAULT_ICON_SIZE, ...props }) => (
+
+
+
+
+);
diff --git a/packages/appkit-react/src/components/ui/icons/ton-icon.tsx b/packages/appkit-react/src/components/ui/icons/ton-icon.tsx
new file mode 100644
index 000000000..c0be8c16f
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/icons/ton-icon.tsx
@@ -0,0 +1,50 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { FC } from 'react';
+
+import { DEFAULT_ICON_SIZE } from './types';
+import type { IconProps } from './types';
+
+export const TonIcon: FC = ({ size = DEFAULT_ICON_SIZE, ...props }) => (
+
+
+
+);
+
+export const TonIconCircle: FC = ({ size = DEFAULT_ICON_SIZE, ...props }) => (
+
+
+
+
+);
diff --git a/packages/appkit-react/src/components/ui/icons/types.ts b/packages/appkit-react/src/components/ui/icons/types.ts
new file mode 100644
index 000000000..43e24564d
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/icons/types.ts
@@ -0,0 +1,22 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { ComponentProps } from 'react';
+
+/**
+ * Standard props for all icon components.
+ *
+ * Icons render an `` whose dimensions are controlled by `size`. Color is
+ * inherited from `currentColor`, so style icons by setting `color` on a parent.
+ */
+export interface IconProps extends Omit, 'width' | 'height'> {
+ size?: number;
+}
+
+/** Default size in pixels for all icons. Override via the `size` prop. */
+export const DEFAULT_ICON_SIZE = 24;
diff --git a/packages/appkit-react/src/components/ui/icons/verified-icon.tsx b/packages/appkit-react/src/components/ui/icons/verified-icon.tsx
new file mode 100644
index 000000000..a339e84f4
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/icons/verified-icon.tsx
@@ -0,0 +1,30 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { FC } from 'react';
+
+import { DEFAULT_ICON_SIZE } from './types';
+import type { IconProps } from './types';
+
+export const VerifiedIcon: FC = ({ size = DEFAULT_ICON_SIZE, ...props }) => (
+
+
+
+);
diff --git a/packages/appkit-react/src/components/ui/info-block/index.ts b/packages/appkit-react/src/components/ui/info-block/index.ts
new file mode 100644
index 000000000..d6a679e53
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/info-block/index.ts
@@ -0,0 +1,9 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export * from './info-block';
diff --git a/packages/appkit-react/src/components/ui/info-block/info-block.module.css b/packages/appkit-react/src/components/ui/info-block/info-block.module.css
new file mode 100644
index 000000000..875ca35c9
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/info-block/info-block.module.css
@@ -0,0 +1,30 @@
+.container {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ box-sizing: border-box;
+ padding: 16px 0;
+ border-radius: var(--ta-border-radius-l);
+}
+
+.row {
+ composes: bodyMedium from "../../../styles/typography.module.css";
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.label {
+ composes: bodyMedium from "../../../styles/typography.module.css";
+ color: var(--ta-color-text-secondary);
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.value {
+ composes: bodyMedium from "../../../styles/typography.module.css";
+ color: var(--ta-color-text);
+ text-align: right;
+}
diff --git a/packages/appkit-react/src/components/ui/info-block/info-block.stories.tsx b/packages/appkit-react/src/components/ui/info-block/info-block.stories.tsx
new file mode 100644
index 000000000..7a83d65c9
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/info-block/info-block.stories.tsx
@@ -0,0 +1,58 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { InfoBlock } from './info-block';
+
+const meta: Meta = {
+ title: 'Components/UI/InfoBlock',
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => (
+
+
+ Provider
+ DeDust
+
+
+ Min received
+ 1.23 USDT
+
+
+ Slippage
+ 1.00%
+
+
+ ),
+};
+
+export const Loading: Story = {
+ render: () => (
+
+
+ Provider
+
+
+
+ Min received
+
+
+
+ Slippage
+
+
+
+ ),
+};
diff --git a/packages/appkit-react/src/components/ui/info-block/info-block.tsx b/packages/appkit-react/src/components/ui/info-block/info-block.tsx
new file mode 100644
index 000000000..47b287d9e
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/info-block/info-block.tsx
@@ -0,0 +1,47 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { FC, ComponentProps } from 'react';
+import clsx from 'clsx';
+
+import styles from './info-block.module.css';
+import { Skeleton } from '../skeleton';
+import type { SkeletonProps } from '../skeleton';
+
+const Container: FC> = ({ className, ...props }) => {
+ return
;
+};
+
+const Row: FC> = ({ className, ...props }) => {
+ return
;
+};
+
+const Label: FC> = ({ className, ...props }) => {
+ return ;
+};
+
+const Value: FC> = ({ className, ...props }) => {
+ return ;
+};
+
+const LabelSkeleton: FC = ({ width = 64, height = '1lh', ...props }) => {
+ return ;
+};
+
+const ValueSkeleton: FC = ({ width = 80, height = '1lh', ...props }) => {
+ return ;
+};
+
+export const InfoBlock = {
+ Container,
+ Row,
+ Label,
+ Value,
+ LabelSkeleton,
+ ValueSkeleton,
+};
diff --git a/packages/appkit-react/src/components/ui/input/index.ts b/packages/appkit-react/src/components/ui/input/index.ts
new file mode 100644
index 000000000..240caccc5
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/input/index.ts
@@ -0,0 +1,9 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export * from './input';
diff --git a/packages/appkit-react/src/components/ui/input/input.module.css b/packages/appkit-react/src/components/ui/input/input.module.css
new file mode 100644
index 000000000..3361abdd4
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/input/input.module.css
@@ -0,0 +1,147 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ width: 100%;
+}
+
+.header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0 4px;
+}
+
+.title {
+ composes: bodyMedium from "../../../styles/typography.module.css";
+ color: var(--ta-color-text-secondary);
+}
+
+.field {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ background: var(--ta-color-background-secondary);
+ border: var(--ta-border-width-m) solid transparent;
+ border-radius: var(--ta-border-radius-l);
+ transition: border-color 0.2s;
+ overflow: hidden;
+ position: relative;
+}
+
+.field:focus-within {
+ border-color: var(--ta-color-primary);
+}
+
+/* Sizes */
+.field {
+ padding: 14px;
+}
+
+/* Variants */
+.variant-unstyled .field {
+ background: none;
+ border: none;
+ padding: 0;
+ min-height: unset;
+ border-radius: 0;
+}
+
+.variant-unstyled .field:focus-within {
+ background: none;
+ border: none;
+}
+
+/* States */
+.disabled {
+ opacity: 0.5;
+ pointer-events: none;
+}
+
+.error .field {
+ border-color: var(--ta-color-error);
+}
+
+.input_s {
+ composes: inputS from "../../../styles/typography.module.css";
+}
+
+.input_m {
+ composes: inputM from "../../../styles/typography.module.css";
+}
+
+.input_l {
+ composes: inputL from "../../../styles/typography.module.css";
+}
+
+.inputSkeleton_s {
+ height: var(--ta-input-s-line-height);
+}
+
+.input_resize_m {
+ font-size: var(--ta-input-m-size);
+ font-weight: var(--ta-input-m-weight);
+}
+
+.input_resize_s {
+ font-size: var(--ta-input-s-size);
+ font-weight: var(--ta-input-s-weight);
+}
+
+.inputSkeleton_m {
+ height: var(--ta-input-m-line-height);
+}
+
+.inputSkeleton_l {
+ height: var(--ta-input-l-line-height);
+}
+
+/* Hidden spans used to measure text width at each size (same idea as Uniswap AmountInput) */
+.inputMeasure {
+ position: absolute;
+ visibility: hidden;
+ white-space: pre;
+ pointer-events: none;
+ font-family: var(--ta-font-family);
+}
+
+.input {
+ flex: 1;
+ min-width: 0;
+ background: none;
+ border: none;
+ outline: none;
+ color: var(--ta-color-text);
+ font-family: var(--ta-font-family);
+ width: 100%;
+ padding: 0;
+}
+
+.input::placeholder {
+ color: var(--ta-color-text-secondary);
+ opacity: 0.6;
+}
+
+.inputSkeleton {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ pointer-events: none;
+}
+
+.slot {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.caption {
+ composes: footnoteRegular from "../../../styles/typography.module.css";
+ padding: 0 4px;
+ color: var(--ta-color-text-secondary);
+}
+
+.errorText {
+ color: var(--ta-color-error);
+}
diff --git a/packages/appkit-react/src/components/ui/input/input.stories.tsx b/packages/appkit-react/src/components/ui/input/input.stories.tsx
new file mode 100644
index 000000000..50561af24
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/input/input.stories.tsx
@@ -0,0 +1,163 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { Input } from './input';
+
+const meta: Meta = {
+ title: 'Components/UI/Input',
+ component: Input,
+ tags: ['autodocs'],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => (
+
+
+ Title
+
+
+
+
+ Caption text
+
+ ),
+};
+
+export const Sizes: Story = {
+ render: () => (
+
+ {(['s', 'm', 'l'] as const).map((size) => (
+
+
+ Size {size.toUpperCase()}
+
+
+
+
+
+ ))}
+
+ ),
+};
+
+export const Unstyled: Story = {
+ render: () => (
+
+ {(['s', 'm', 'l'] as const).map((size) => (
+
+
+
+
+
+ ))}
+
+ ),
+};
+
+export const States: Story = {
+ render: () => (
+
+
+
+ Error State
+
+
+
+
+ This is an error message
+
+
+
+
+ Disabled State
+
+
+
+
+
+
+
+
+ Loading State
+
+
+
+
+
+
+ ),
+};
+
+export const Advanced: Story = {
+ render: () => (
+
+
+
+ You swap
+
+
+
+
+
+ TON ▾
+
+
+
+ $ 144.74
+
+
+ ),
+};
+
+export const Resizable: Story = {
+ render: () => (
+
+
+
+ Resizable (starts at L)
+
+
+
+
+
+ TON ▾
+
+
+
+ Font shrinks L → M → S as you type
+
+
+ ),
+};
diff --git a/packages/appkit-react/src/components/ui/input/input.tsx b/packages/appkit-react/src/components/ui/input/input.tsx
new file mode 100644
index 000000000..98b99b081
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/input/input.tsx
@@ -0,0 +1,194 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { createContext, useContext, useMemo } from 'react';
+import type { FC, ReactNode, ComponentProps, ChangeEvent } from 'react';
+import clsx from 'clsx';
+
+import { Skeleton } from '../skeleton';
+import { useInputResize } from './use-input-resize';
+import type { InputSize } from './use-input-resize';
+import styles from './input.module.css';
+
+type InputVariant = 'default' | 'unstyled';
+
+interface InputContextProps {
+ size: InputSize;
+ variant: InputVariant;
+ disabled?: boolean;
+ error?: boolean;
+ loading?: boolean;
+ resizable?: boolean;
+}
+
+const InputContext = createContext(undefined);
+
+const useInputContext = () => {
+ const context = useContext(InputContext);
+ if (!context) {
+ throw new Error('Input components must be used within an Input.Container');
+ }
+ return context;
+};
+
+export interface InputContainerProps extends ComponentProps<'div'> {
+ size?: InputSize;
+ variant?: InputVariant;
+ disabled?: boolean;
+ error?: boolean;
+ loading?: boolean;
+ resizable?: boolean;
+ children: ReactNode;
+}
+
+const Container: FC = ({
+ size = 'm',
+ variant = 'default',
+ disabled,
+ error,
+ loading,
+ resizable,
+ className,
+ children,
+ ...props
+}) => {
+ const contextValue = useMemo(
+ () => ({ size, variant, disabled, error, loading, resizable }),
+ [size, variant, disabled, error, loading, resizable],
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+export interface InputHeaderProps extends ComponentProps<'div'> {
+ children: ReactNode;
+}
+
+const Header: FC = ({ className, children, ...props }) => (
+
+ {children}
+
+);
+
+const Title: FC> = ({ className, children, ...props }) => (
+
+ {children}
+
+);
+
+export interface InputFieldProps extends ComponentProps<'div'> {
+ children: ReactNode;
+}
+
+const Field: FC = ({ className, children, ...props }) => (
+
+ {children}
+
+);
+
+export interface InputSlotProps extends ComponentProps<'div'> {
+ side?: 'left' | 'right';
+}
+
+const Slot: FC = ({ side, className, children, ...props }) => (
+
+ {children}
+
+);
+
+export type InputControlProps = ComponentProps<'input'>;
+
+const InputControl: FC = ({ className, disabled: propsDisabled, onChange, ...props }) => {
+ const { size: contextSize, disabled: contextDisabled, loading, resizable } = useInputContext();
+ const disabled = propsDisabled || contextDisabled;
+
+ const { inputRef, measureMaxRef, measureMinRef, resizeStyle, adjustSize } = useInputResize({
+ resizable,
+ contextSize,
+ value: props.value,
+ });
+
+ const handleChange = (e: ChangeEvent) => {
+ onChange?.(e);
+ adjustSize();
+ };
+
+ const text = String(props.value ?? props.defaultValue ?? '');
+
+ if (loading) {
+ const skeletonClass = styles[`inputSkeleton_${contextSize}`];
+
+ return (
+
+
+
+ );
+ }
+
+ return (
+ <>
+ {resizable && (
+ <>
+ {/* Measures actual text width at max (contextSize) font — source of truth for scaling */}
+
+ {text}
+
+ {/* Empty span — only used to read minFontSize from CSS variable via computed style */}
+
+ >
+ )}
+
+ >
+ );
+};
+
+const Caption: FC> = ({ className, children, ...props }) => {
+ const { error } = useInputContext();
+ return (
+
+ {children}
+
+ );
+};
+
+export const Input = Object.assign(Container, {
+ Container,
+ Header,
+ Title,
+ Field,
+ Slot,
+ Input: InputControl,
+ Caption,
+});
diff --git a/packages/appkit-react/src/components/ui/input/use-input-resize.ts b/packages/appkit-react/src/components/ui/input/use-input-resize.ts
new file mode 100644
index 000000000..0233ab4bf
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/input/use-input-resize.ts
@@ -0,0 +1,96 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { useRef, useState, useLayoutEffect } from 'react';
+import type { CSSProperties, RefObject } from 'react';
+
+export type InputSize = 's' | 'm' | 'l';
+
+export interface ResizeMetrics {
+ maxFontSize: number;
+ minFontSize: number;
+ lineHeightRatio: number;
+ parentFontSize: number;
+}
+
+export const readResizeMetrics = (
+ maxSpan: HTMLSpanElement,
+ minSpan: HTMLSpanElement,
+ input: HTMLInputElement,
+): ResizeMetrics => {
+ const maxStyle = getComputedStyle(maxSpan);
+ const maxFontSize = parseFloat(maxStyle.fontSize);
+ const lineHeightRatio = parseFloat(maxStyle.lineHeight) / maxFontSize;
+
+ return {
+ maxFontSize,
+ minFontSize: parseFloat(getComputedStyle(minSpan).fontSize),
+ lineHeightRatio,
+ parentFontSize: parseFloat(getComputedStyle(input.parentElement!).fontSize),
+ };
+};
+
+interface UseInputResizeOptions {
+ resizable?: boolean;
+ contextSize: InputSize;
+ value?: string | number | readonly string[];
+}
+
+interface UseInputResizeResult {
+ inputRef: RefObject;
+ measureMaxRef: RefObject;
+ measureMinRef: RefObject;
+ resizeStyle: CSSProperties | undefined;
+ adjustSize: () => void;
+}
+
+export const useInputResize = ({ resizable, contextSize, value }: UseInputResizeOptions): UseInputResizeResult => {
+ const inputRef = useRef(null);
+ const measureMaxRef = useRef(null);
+ const measureMinRef = useRef(null);
+ const [fontSizeEm, setFontSizeEm] = useState(undefined);
+ const lineHeightRatioRef = useRef(1.25);
+
+ const adjustSize = () => {
+ if (!resizable || !inputRef.current || !measureMaxRef.current || !measureMinRef.current) return;
+ const availableWidth = inputRef.current.clientWidth;
+ if (availableWidth === 0) return;
+
+ const textWidth = measureMaxRef.current.offsetWidth;
+ const { maxFontSize, minFontSize, lineHeightRatio, parentFontSize } = readResizeMetrics(
+ measureMaxRef.current,
+ measureMinRef.current,
+ inputRef.current,
+ );
+
+ const ratio = textWidth > 0 ? availableWidth / textWidth : 1;
+ const scaledPx = Math.min(maxFontSize, Math.max(minFontSize, maxFontSize * ratio));
+ setFontSizeEm(scaledPx / parentFontSize);
+ lineHeightRatioRef.current = lineHeightRatio;
+ };
+
+ // Re-measure when controlled value or context size changes
+ useLayoutEffect(adjustSize, [resizable, contextSize, value]);
+
+ // Re-measure on container resize (observe parent, not the input itself,
+ // to avoid feedback loop when font-size change triggers ResizeObserver)
+ useLayoutEffect(() => {
+ const parent = inputRef.current?.parentElement;
+ if (!resizable || !parent) return;
+ const observer = new ResizeObserver(adjustSize);
+ observer.observe(parent);
+ return () => observer.disconnect();
+ }, [resizable, contextSize]);
+
+ const resizeStyle: CSSProperties | undefined =
+ resizable && fontSizeEm !== undefined
+ ? { fontSize: `${fontSizeEm}em`, lineHeight: lineHeightRatioRef.current }
+ : undefined;
+
+ return { inputRef, measureMaxRef, measureMinRef, resizeStyle, adjustSize };
+};
diff --git a/packages/appkit-react/src/components/ui/logo/index.ts b/packages/appkit-react/src/components/ui/logo/index.ts
new file mode 100644
index 000000000..7772fbf7e
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/logo/index.ts
@@ -0,0 +1,9 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export * from './logo';
diff --git a/packages/appkit-react/src/components/circle-icon/circle-icon.module.css b/packages/appkit-react/src/components/ui/logo/logo.module.css
similarity index 70%
rename from packages/appkit-react/src/components/circle-icon/circle-icon.module.css
rename to packages/appkit-react/src/components/ui/logo/logo.module.css
index d9cf85b1a..c4f487b5b 100644
--- a/packages/appkit-react/src/components/circle-icon/circle-icon.module.css
+++ b/packages/appkit-react/src/components/ui/logo/logo.module.css
@@ -1,4 +1,4 @@
-.avatarRoot {
+.logoRoot {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -8,24 +8,24 @@
width: 45px;
height: 45px;
border-radius: 100%;
- background-color: var(--ta-color-block);
+ background-color: var(--ta-color-background);
}
-.avatarImage {
+.logoImage {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
-.avatarFallback {
+.logoFallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
- background-color: white;
- color: var(--ta-color-block-foreground);
+ background-color: var(--ta-color-background-secondary);
+ color: var(--ta-color-text);
font-size: 15px;
line-height: 1;
font-weight: 500;
diff --git a/packages/appkit-react/src/components/circle-icon/circle-icon.stories.tsx b/packages/appkit-react/src/components/ui/logo/logo.stories.tsx
similarity index 81%
rename from packages/appkit-react/src/components/circle-icon/circle-icon.stories.tsx
rename to packages/appkit-react/src/components/ui/logo/logo.stories.tsx
index adccd3719..50de2d3b3 100644
--- a/packages/appkit-react/src/components/circle-icon/circle-icon.stories.tsx
+++ b/packages/appkit-react/src/components/ui/logo/logo.stories.tsx
@@ -6,13 +6,13 @@
*
*/
-import type { Meta, StoryObj } from '@storybook/react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
-import { CircleIcon } from './circle-icon';
+import { Logo } from './logo';
-const meta: Meta = {
- title: 'Private/Components/CircleIcon',
- component: CircleIcon,
+const meta: Meta = {
+ title: 'Components/UI/Logo',
+ component: Logo,
tags: ['autodocs'],
argTypes: {
size: {
@@ -23,7 +23,7 @@ const meta: Meta = {
export default meta;
-type Story = StoryObj;
+type Story = StoryObj;
export const WithImage: Story = {
args: {
diff --git a/packages/appkit-react/src/components/ui/logo/logo.tsx b/packages/appkit-react/src/components/ui/logo/logo.tsx
new file mode 100644
index 000000000..b325bf4f7
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/logo/logo.tsx
@@ -0,0 +1,106 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { createContext, forwardRef, useContext, useEffect, useLayoutEffect, useState } from 'react';
+import type { ComponentPropsWithoutRef, ComponentRef } from 'react';
+import clsx from 'clsx';
+
+import { useImageLoadingStatus } from './use-image-loading-status';
+import type { ImageLoadingStatus } from './use-image-loading-status';
+import styles from './logo.module.css';
+
+interface LogoContextValue {
+ imageLoadingStatus: ImageLoadingStatus;
+ onImageLoadingStatusChange: (status: ImageLoadingStatus) => void;
+}
+
+const LogoContext = createContext(null);
+
+const useLogoContext = (): LogoContextValue => {
+ const ctx = useContext(LogoContext);
+ if (!ctx) throw new Error('Logo compound components must be used within Logo.Root');
+ return ctx;
+};
+
+const LogoRoot = forwardRef, ComponentPropsWithoutRef<'span'>>(({ className, ...props }, ref) => {
+ const [imageLoadingStatus, setImageLoadingStatus] = useState('idle');
+
+ return (
+
+
+
+ );
+});
+
+LogoRoot.displayName = 'LogoRoot';
+
+interface LogoImageProps extends ComponentPropsWithoutRef<'img'> {
+ onLoadingStatusChange?: (status: ImageLoadingStatus) => void;
+}
+
+const LogoImage = forwardRef, LogoImageProps>(
+ ({ src, onLoadingStatusChange, className, ...props }, ref) => {
+ const context = useLogoContext();
+ const loadingStatus = useImageLoadingStatus(src);
+
+ useLayoutEffect(() => {
+ if (loadingStatus !== 'idle') {
+ onLoadingStatusChange?.(loadingStatus);
+ context.onImageLoadingStatusChange(loadingStatus);
+ }
+ }, [loadingStatus]);
+
+ return loadingStatus === 'loaded' ? (
+
+ ) : null;
+ },
+);
+
+LogoImage.displayName = 'LogoImage';
+
+interface LogoFallbackProps extends ComponentPropsWithoutRef<'span'> {
+ delayMs?: number;
+}
+
+const LogoFallback = forwardRef, LogoFallbackProps>(({ delayMs, className, ...props }, ref) => {
+ const context = useLogoContext();
+ const [canRender, setCanRender] = useState(delayMs === undefined);
+
+ useEffect(() => {
+ if (delayMs !== undefined) {
+ const id = window.setTimeout(() => setCanRender(true), delayMs);
+ return () => window.clearTimeout(id);
+ }
+ return undefined;
+ }, [delayMs]);
+
+ return canRender && context.imageLoadingStatus !== 'loaded' ? (
+
+ ) : null;
+});
+
+LogoFallback.displayName = 'LogoFallback';
+
+export interface LogoProps extends ComponentPropsWithoutRef<'span'> {
+ size?: number;
+ src?: string;
+ alt?: string;
+ fallback?: string;
+}
+
+export const Logo = forwardRef, LogoProps>(({ size = 30, src, alt, fallback, ...props }, ref) => {
+ return (
+
+
+
+ {(fallback || alt) && {fallback ? fallback : alt?.[0]} }
+
+ );
+});
+
+Logo.displayName = 'Logo';
diff --git a/packages/appkit-react/src/components/ui/logo/use-image-loading-status.ts b/packages/appkit-react/src/components/ui/logo/use-image-loading-status.ts
new file mode 100644
index 000000000..e3b6359ec
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/logo/use-image-loading-status.ts
@@ -0,0 +1,50 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { useLayoutEffect, useRef, useState } from 'react';
+
+export type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error';
+
+const resolveLoadingStatus = (image: HTMLImageElement | null, src?: string): ImageLoadingStatus => {
+ if (!image || !src) return 'idle';
+ if (image.src !== src) image.src = src;
+ return image.complete && image.naturalWidth > 0 ? 'loaded' : 'loading';
+};
+
+export const useImageLoadingStatus = (src: string | undefined): ImageLoadingStatus => {
+ const imageRef = useRef(null);
+
+ const getImage = (): HTMLImageElement | null => {
+ if (typeof window === 'undefined') return null;
+ if (!imageRef.current) imageRef.current = new window.Image();
+ return imageRef.current;
+ };
+
+ const [loadingStatus, setLoadingStatus] = useState(() => resolveLoadingStatus(getImage(), src));
+
+ useLayoutEffect(() => {
+ setLoadingStatus(resolveLoadingStatus(getImage(), src));
+ }, [src]);
+
+ useLayoutEffect(() => {
+ const image = getImage();
+ if (!image) return;
+
+ const handleLoad = () => setLoadingStatus('loaded');
+ const handleError = () => setLoadingStatus('error');
+ image.addEventListener('load', handleLoad);
+ image.addEventListener('error', handleError);
+
+ return () => {
+ image.removeEventListener('load', handleLoad);
+ image.removeEventListener('error', handleError);
+ };
+ }, [src]);
+
+ return loadingStatus;
+};
diff --git a/packages/appkit-react/src/components/ui/modal/index.ts b/packages/appkit-react/src/components/ui/modal/index.ts
new file mode 100644
index 000000000..b6f90f596
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/modal/index.ts
@@ -0,0 +1,9 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export * from './modal';
diff --git a/packages/appkit-react/src/components/ui/modal/modal.module.css b/packages/appkit-react/src/components/ui/modal/modal.module.css
new file mode 100644
index 000000000..f64707419
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/modal/modal.module.css
@@ -0,0 +1,71 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+.overlay {
+ position: fixed;
+ inset: 0;
+ background-color: rgba(0, 0, 0, 0.4);
+ backdrop-filter: blur(4px);
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+}
+
+.content {
+ background-color: var(--ta-color-background);
+ border-radius: var(--ta-border-radius-xl);
+ width: 100%;
+ max-width: 480px;
+ max-height: calc(100vh - 32px);
+ display: flex;
+ flex-direction: column;
+ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
+ overflow: hidden;
+ outline: none;
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 20px 20px 16px;
+}
+
+.title {
+ composes: title from "../../../styles/typography.module.css";
+ margin: 0;
+ color: var(--ta-color-text);
+}
+
+.close {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border-radius: var(--ta-border-radius-full);
+ border: none;
+ background-color: var(--ta-color-background-secondary);
+ color: var(--ta-color-text-secondary);
+ cursor: pointer;
+ transition: opacity 0.2s ease;
+ padding: 0;
+}
+
+.close:hover {
+ opacity: 0.8;
+}
+
+.body {
+ composes: bodyRegular from "../../../styles/typography.module.css";
+ padding: 0 20px 24px;
+ overflow-y: auto;
+ color: var(--ta-color-text);
+}
diff --git a/packages/appkit-react/src/components/ui/modal/modal.stories.tsx b/packages/appkit-react/src/components/ui/modal/modal.stories.tsx
new file mode 100644
index 000000000..bf80dad0d
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/modal/modal.stories.tsx
@@ -0,0 +1,58 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { Modal } from './modal';
+import { Button } from '../button';
+
+const meta: Meta = {
+ title: 'Components/UI/Modal',
+ component: Modal,
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+ setOpen(true)}>Open Modal
+
+ This is a simple modal window content.
+
+ >
+ );
+ },
+};
+
+export const LargeContent: Story = {
+ render: () => {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+ setOpen(true)}>Open Scrollable Modal
+
+
+ {Array.from({ length: 20 }).map((_, i) => (
+
+ This is paragraph {i + 1} of a very long text. Modals should be scrollable when the
+ content exceeds the screen height.
+
+ ))}
+
+
+ >
+ );
+ },
+};
diff --git a/packages/appkit-react/src/components/ui/modal/modal.tsx b/packages/appkit-react/src/components/ui/modal/modal.tsx
new file mode 100644
index 000000000..126c1c40f
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/modal/modal.tsx
@@ -0,0 +1,57 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { FC, ReactNode } from 'react';
+import clsx from 'clsx';
+
+import { Dialog } from '../dialog';
+import { CloseIcon } from '../icons';
+import styles from './modal.module.css';
+
+export interface ModalProps {
+ /**
+ * Controlled open state.
+ */
+ open?: boolean;
+ /**
+ * Event handler called when the open state changes.
+ */
+ onOpenChange?: (open: boolean) => void;
+ /**
+ * Modal title.
+ */
+ title?: string;
+ /**
+ * Modal content.
+ */
+ children?: ReactNode;
+ /**
+ * Additional class name for the content container.
+ */
+ className?: string;
+}
+
+export const Modal: FC = ({ open, onOpenChange, title, children, className }) => {
+ return (
+
+
+ onOpenChange?.(false)}>
+ e.stopPropagation()}>
+
+ {title && {title} }
+
+
+
+
+ {children}
+
+
+
+
+ );
+};
diff --git a/packages/appkit-react/src/components/ui/select/index.ts b/packages/appkit-react/src/components/ui/select/index.ts
new file mode 100644
index 000000000..184b41312
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/select/index.ts
@@ -0,0 +1,9 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export * from './select';
diff --git a/packages/appkit-react/src/components/ui/select/select.module.css b/packages/appkit-react/src/components/ui/select/select.module.css
new file mode 100644
index 000000000..55f93b60b
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/select/select.module.css
@@ -0,0 +1,37 @@
+.content {
+ z-index: 1100;
+ padding: 4px;
+ background-color: var(--ta-color-background-secondary);
+ border-radius: var(--ta-border-radius-l);
+ box-shadow: 0 8px 24px rgb(0 0 0 / 0.16);
+ display: flex;
+ flex-direction: column;
+}
+
+.item {
+ composes: bodyMedium from "../../../styles/typography.module.css";
+
+ display: flex;
+ align-items: center;
+ padding: 8px 12px;
+ color: var(--ta-color-text);
+ border-radius: var(--ta-border-radius-m);
+ cursor: pointer;
+ user-select: none;
+ white-space: nowrap;
+ outline: none;
+}
+
+.item:hover,
+.item:focus-visible {
+ background-color: var(--ta-color-background-tertiary);
+}
+
+.item[data-state="checked"] {
+ color: var(--ta-color-primary);
+}
+
+.item[data-disabled] {
+ opacity: 0.4;
+ cursor: default;
+}
diff --git a/packages/appkit-react/src/components/ui/select/select.stories.tsx b/packages/appkit-react/src/components/ui/select/select.stories.tsx
new file mode 100644
index 000000000..47f3fa37c
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/select/select.stories.tsx
@@ -0,0 +1,131 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { fn } from 'storybook/test';
+
+import { ChevronDownIcon } from '../icons';
+import { Select } from './select';
+
+const meta: Meta = {
+ title: 'Components/UI/Select',
+ component: Select.Root,
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+const PROVIDERS = [
+ { value: 'stonfi', label: 'STON.fi' },
+ { value: 'dedust', label: 'DeDust' },
+ { value: 'omniston', label: 'Omniston' },
+];
+
+export const Uncontrolled: Story = {
+ render: () => (
+
+
+ STON.fi
+
+
+
+ {PROVIDERS.map((p) => (
+
+ {p.label}
+
+ ))}
+
+
+ ),
+};
+
+export const Controlled: Story = {
+ render: () => {
+ const Wrapper = () => {
+ const [value, setValue] = useState('stonfi');
+ const current = PROVIDERS.find((p) => p.value === value);
+ return (
+
+
+
+ {current?.label ?? value}
+
+
+
+ {PROVIDERS.map((p) => (
+
+ {p.label}
+
+ ))}
+
+
+ Selected: {value}
+
+ );
+ };
+ return ;
+ },
+};
+
+export const AlignedEnd: Story = {
+ render: () => (
+
+
+
+ DeDust
+
+
+
+ {PROVIDERS.map((p) => (
+
+ {p.label}
+
+ ))}
+
+
+
+ ),
+};
+
+export const Disabled: Story = {
+ render: () => (
+
+
+ STON.fi
+
+
+
+ {PROVIDERS.map((p) => (
+
+ {p.label}
+
+ ))}
+
+
+ ),
+};
+
+export const GhostTrigger: Story = {
+ render: () => (
+
+
+ STON.fi
+
+
+
+ {PROVIDERS.map((p) => (
+
+ {p.label}
+
+ ))}
+
+
+ ),
+};
diff --git a/packages/appkit-react/src/components/ui/select/select.tsx b/packages/appkit-react/src/components/ui/select/select.tsx
new file mode 100644
index 000000000..46258752f
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/select/select.tsx
@@ -0,0 +1,274 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+import type { ComponentPropsWithoutRef, ComponentRef, FC, ReactNode } from 'react';
+import clsx from 'clsx';
+
+import { Button } from '../button';
+import type { ButtonProps } from '../button';
+import styles from './select.module.css';
+import { SelectContext, useSelectContext } from './use-select-context';
+
+export interface SelectRootProps {
+ /** Controlled selected value. */
+ value?: string;
+ /** Initial value when uncontrolled. */
+ defaultValue?: string;
+ /** Called whenever the selected value changes. */
+ onValueChange?: (value: string) => void;
+ /** Controlled open state. */
+ open?: boolean;
+ /** Initial open state when uncontrolled. */
+ defaultOpen?: boolean;
+ /** Called whenever the open state changes. */
+ onOpenChange?: (open: boolean) => void;
+ /** When true, the trigger is non-interactive. */
+ disabled?: boolean;
+ children: ReactNode;
+}
+
+const SelectRoot: FC = ({
+ value: controlledValue,
+ defaultValue,
+ onValueChange,
+ open: controlledOpen,
+ defaultOpen = false,
+ onOpenChange,
+ disabled,
+ children,
+}) => {
+ const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
+
+ const isValueControlled = controlledValue !== undefined;
+ const isOpenControlled = controlledOpen !== undefined;
+ const value = isValueControlled ? controlledValue : uncontrolledValue;
+ const open = isOpenControlled ? controlledOpen : uncontrolledOpen;
+
+ const triggerRef = useRef(null);
+ const contentRef = useRef(null);
+
+ const setOpen = useCallback(
+ (next: boolean) => {
+ if (!isOpenControlled) setUncontrolledOpen(next);
+ onOpenChange?.(next);
+ },
+ [isOpenControlled, onOpenChange],
+ );
+
+ const handleValueChange = useCallback(
+ (next: string) => {
+ if (!isValueControlled) setUncontrolledValue(next);
+ onValueChange?.(next);
+ },
+ [isValueControlled, onValueChange],
+ );
+
+ const ctx = useMemo(
+ () => ({
+ value,
+ onValueChange: handleValueChange,
+ open,
+ setOpen,
+ disabled,
+ triggerRef,
+ contentRef,
+ }),
+ [value, handleValueChange, open, setOpen, disabled],
+ );
+
+ return {children} ;
+};
+
+export type SelectTriggerProps = ButtonProps;
+
+const SelectTrigger = forwardRef, SelectTriggerProps>(
+ ({ children, onClick, disabled, ...props }, forwardedRef) => {
+ const ctx = useSelectContext();
+ const isDisabled = disabled || ctx.disabled;
+
+ const setRefs = useCallback(
+ (node: HTMLButtonElement | null) => {
+ ctx.triggerRef.current = node;
+ if (typeof forwardedRef === 'function') forwardedRef(node);
+ else if (forwardedRef) forwardedRef.current = node;
+ },
+ [ctx.triggerRef, forwardedRef],
+ );
+
+ return (
+ {
+ onClick?.(event);
+ if (!event.defaultPrevented) ctx.setOpen(!ctx.open);
+ }}
+ {...props}
+ >
+ {children}
+
+ );
+ },
+);
+
+SelectTrigger.displayName = 'SelectTrigger';
+
+export interface SelectContentProps extends ComponentPropsWithoutRef<'div'> {
+ /** Horizontal alignment relative to the trigger. */
+ align?: 'start' | 'end';
+ /** Gap between trigger and content in pixels. */
+ sideOffset?: number;
+}
+
+interface ContentPosition {
+ top: number;
+ left?: number;
+ right?: number;
+ minWidth: number;
+}
+
+const SelectContent: FC = ({
+ children,
+ className,
+ align = 'start',
+ sideOffset = 4,
+ style,
+ ...props
+}) => {
+ const ctx = useSelectContext();
+ const { open, setOpen, contentRef, triggerRef } = ctx;
+ const [position, setPosition] = useState(null);
+
+ const updatePosition = useCallback(() => {
+ const trigger = triggerRef.current;
+ if (!trigger) return;
+ const rect = trigger.getBoundingClientRect();
+ setPosition({
+ top: rect.bottom + sideOffset,
+ left: align === 'end' ? undefined : rect.left,
+ right: align === 'end' ? window.innerWidth - rect.right : undefined,
+ minWidth: rect.width,
+ });
+ }, [align, sideOffset, triggerRef]);
+
+ useEffect(() => {
+ if (!open) {
+ setPosition(null);
+ return;
+ }
+
+ updatePosition();
+
+ const handler = () => updatePosition();
+ // capture: true so we react to scroll events from inner scrollable containers
+ // (e.g. the Modal body) — not just the window.
+ window.addEventListener('scroll', handler, true);
+ window.addEventListener('resize', handler);
+ return () => {
+ window.removeEventListener('scroll', handler, true);
+ window.removeEventListener('resize', handler);
+ };
+ }, [open, updatePosition]);
+
+ useEffect(() => {
+ if (!open) return;
+
+ const handlePointerDown = (event: MouseEvent) => {
+ const target = event.target as Node | null;
+ if (!target) return;
+ if (contentRef.current?.contains(target)) return;
+ if (triggerRef.current?.contains(target)) return;
+ setOpen(false);
+ };
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') setOpen(false);
+ };
+
+ document.addEventListener('mousedown', handlePointerDown);
+ document.addEventListener('keydown', handleKeyDown);
+ return () => {
+ document.removeEventListener('mousedown', handlePointerDown);
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [open, setOpen, contentRef, triggerRef]);
+
+ if (!open || !position || typeof document === 'undefined') return null;
+
+ return createPortal(
+
+ {children}
+
,
+ document.body,
+ );
+};
+
+export interface SelectItemProps extends ComponentPropsWithoutRef<'div'> {
+ value: string;
+ disabled?: boolean;
+}
+
+const SelectItem = forwardRef, SelectItemProps>(
+ ({ value, disabled, children, className, onClick, ...props }, ref) => {
+ const ctx = useSelectContext();
+ const isSelected = ctx.value === value;
+
+ return (
+ {
+ onClick?.(event);
+ if (event.defaultPrevented || disabled) return;
+ ctx.onValueChange(value);
+ ctx.setOpen(false);
+ }}
+ {...props}
+ >
+ {children}
+
+ );
+ },
+);
+
+SelectItem.displayName = 'SelectItem';
+
+export const Select = {
+ Root: SelectRoot,
+ Trigger: SelectTrigger,
+ Content: SelectContent,
+ Item: SelectItem,
+};
diff --git a/packages/appkit-react/src/components/ui/select/use-select-context.ts b/packages/appkit-react/src/components/ui/select/use-select-context.ts
new file mode 100644
index 000000000..baedeb182
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/select/use-select-context.ts
@@ -0,0 +1,28 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { createContext, useContext } from 'react';
+import type { RefObject } from 'react';
+
+export interface SelectContextValue {
+ value: string | undefined;
+ onValueChange: (value: string) => void;
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ disabled: boolean | undefined;
+ triggerRef: RefObject;
+ contentRef: RefObject;
+}
+
+export const SelectContext = createContext(null);
+
+export const useSelectContext = (): SelectContextValue => {
+ const ctx = useContext(SelectContext);
+ if (!ctx) throw new Error('Select compound components must be used within Select.Root');
+ return ctx;
+};
diff --git a/packages/appkit-react/src/components/ui/skeleton/index.ts b/packages/appkit-react/src/components/ui/skeleton/index.ts
new file mode 100644
index 000000000..586f0d232
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/skeleton/index.ts
@@ -0,0 +1,10 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export { Skeleton } from './skeleton';
+export type { SkeletonProps } from './skeleton';
diff --git a/packages/appkit-react/src/components/ui/skeleton/skeleton.module.css b/packages/appkit-react/src/components/ui/skeleton/skeleton.module.css
new file mode 100644
index 000000000..ab06da3f5
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/skeleton/skeleton.module.css
@@ -0,0 +1,25 @@
+.skeleton {
+ display: inline-block;
+ background-color: var(--ta-color-background-tertiary);
+ border-radius: var(--ta-border-radius-l);
+ position: relative;
+ overflow: hidden;
+}
+
+.skeleton::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ transform: translateX(-100%);
+ background: var(--ta-color-skeleton-shimmer);
+ animation: skeleton-shimmer 1.5s infinite;
+}
+
+@keyframes skeleton-shimmer {
+ 100% {
+ transform: translateX(100%);
+ }
+}
diff --git a/packages/appkit-react/src/components/ui/skeleton/skeleton.stories.tsx b/packages/appkit-react/src/components/ui/skeleton/skeleton.stories.tsx
new file mode 100644
index 000000000..89332c34d
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/skeleton/skeleton.stories.tsx
@@ -0,0 +1,62 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { Skeleton } from './skeleton';
+
+const meta: Meta = {
+ title: 'Components/UI/Skeleton',
+ component: Skeleton,
+ tags: ['autodocs'],
+ argTypes: {
+ width: {
+ control: 'text',
+ },
+ height: {
+ control: 'text',
+ },
+ },
+ args: {
+ width: 100,
+ height: 20,
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const CustomSize: Story = {
+ args: {
+ width: '100%',
+ height: 100,
+ },
+};
+
+export const Circular: Story = {
+ args: {
+ width: 48,
+ height: 48,
+ style: { borderRadius: '50%' },
+ },
+};
+
+export const ParagraphPlaceholder: Story = {
+ render: (args) => (
+
+
+
+
+
+
+ ),
+ args: {},
+};
diff --git a/packages/appkit-react/src/components/ui/skeleton/skeleton.tsx b/packages/appkit-react/src/components/ui/skeleton/skeleton.tsx
new file mode 100644
index 000000000..7a27f5926
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/skeleton/skeleton.tsx
@@ -0,0 +1,33 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { forwardRef } from 'react';
+import type { ComponentProps } from 'react';
+import clsx from 'clsx';
+
+import styles from './skeleton.module.css';
+
+export interface SkeletonProps extends ComponentProps<'div'> {
+ width?: string | number;
+ height?: string | number;
+}
+
+export const Skeleton = forwardRef(
+ ({ className, width, height, style, ...props }, ref) => {
+ return (
+
+ );
+ },
+);
+
+Skeleton.displayName = 'Skeleton';
diff --git a/packages/appkit-react/src/components/ui/tabs/index.ts b/packages/appkit-react/src/components/ui/tabs/index.ts
new file mode 100644
index 000000000..1a3e793db
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/tabs/index.ts
@@ -0,0 +1,10 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export { Tabs, TabsList, TabsTrigger, TabsContent } from './tabs';
+export type { TabsProps, TabsListProps, TabsTriggerProps, TabsContentProps } from './tabs';
diff --git a/packages/appkit-react/src/components/ui/tabs/tabs.module.css b/packages/appkit-react/src/components/ui/tabs/tabs.module.css
new file mode 100644
index 000000000..871749865
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/tabs/tabs.module.css
@@ -0,0 +1,32 @@
+.root {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+}
+
+.list {
+ display: flex;
+ width: 100%;
+ padding: 4px;
+ gap: 4px;
+ box-sizing: border-box;
+ border-radius: var(--ta-border-radius-2xl);
+ background-color: var(--ta-color-background-secondary);
+}
+
+.trigger {
+ composes: labelSemibold from "../../../styles/typography.module.css";
+ flex: 1;
+ padding: 10px 16px;
+ border: none;
+ border-radius: calc(var(--ta-border-radius-2xl) - 4px);
+ background: transparent;
+ color: var(--ta-color-text);
+ cursor: pointer;
+ transition: background-color 0.2s, color 0.2s;
+ outline: none;
+}
+
+.trigger[data-state="active"] {
+ background-color: var(--ta-color-background);
+}
diff --git a/packages/appkit-react/src/components/ui/tabs/tabs.stories.tsx b/packages/appkit-react/src/components/ui/tabs/tabs.stories.tsx
new file mode 100644
index 000000000..f681955c0
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/tabs/tabs.stories.tsx
@@ -0,0 +1,62 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { useState } from 'react';
+
+import { Tabs, TabsList, TabsTrigger, TabsContent } from './tabs';
+
+const meta: Meta = {
+ title: 'Components/UI/Tabs',
+ component: Tabs,
+ tags: ['autodocs'],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => (
+
+
+ Stake
+ Unstake
+
+
+ Stake content
+
+
+ Unstake content
+
+
+ ),
+};
+
+export const Controlled: Story = {
+ render: () => {
+ const [value, setValue] = useState('tab1');
+ return (
+
+
+ First
+ Second
+ Third
+
+
+ First tab content
+
+
+ Second tab content
+
+
+ Third tab content
+
+
+ );
+ },
+};
diff --git a/packages/appkit-react/src/components/ui/tabs/tabs.tsx b/packages/appkit-react/src/components/ui/tabs/tabs.tsx
new file mode 100644
index 000000000..ebe89d887
--- /dev/null
+++ b/packages/appkit-react/src/components/ui/tabs/tabs.tsx
@@ -0,0 +1,116 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { createContext, useCallback, useContext, useState } from 'react';
+import type { ComponentProps, FC, ReactNode } from 'react';
+import clsx from 'clsx';
+
+import styles from './tabs.module.css';
+
+interface TabsContextValue {
+ value: string;
+ onValueChange: (value: string) => void;
+}
+
+const TabsContext = createContext({
+ value: '',
+ onValueChange: () => {},
+});
+
+export interface TabsProps extends ComponentProps<'div'> {
+ value?: string;
+ defaultValue?: string;
+ onValueChange?: (value: string) => void;
+ children: ReactNode;
+}
+
+export const Tabs: FC = ({
+ value: controlledValue,
+ defaultValue = '',
+ onValueChange,
+ children,
+ className,
+ ...props
+}) => {
+ const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
+
+ const isControlled = controlledValue !== undefined;
+ const currentValue = isControlled ? controlledValue : uncontrolledValue;
+
+ const handleValueChange = useCallback(
+ (newValue: string) => {
+ if (!isControlled) {
+ setUncontrolledValue(newValue);
+ }
+ onValueChange?.(newValue);
+ },
+ [isControlled, onValueChange],
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+export interface TabsListProps extends ComponentProps<'div'> {
+ children: ReactNode;
+}
+
+export const TabsList: FC = ({ children, className, ...props }) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export interface TabsTriggerProps extends ComponentProps<'button'> {
+ value: string;
+ children: ReactNode;
+}
+
+export const TabsTrigger: FC = ({ value, children, className, ...props }) => {
+ const ctx = useContext(TabsContext);
+ const isActive = ctx.value === value;
+
+ return (
+ ctx.onValueChange(value)}
+ {...props}
+ >
+ {children}
+
+ );
+};
+
+export interface TabsContentProps extends ComponentProps<'div'> {
+ value: string;
+ children: ReactNode;
+}
+
+export const TabsContent: FC = ({ value, children, className, ...props }) => {
+ const ctx = useContext(TabsContext);
+ const isActive = ctx.value === value;
+
+ if (!isActive) return null;
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/appkit-react/src/features/balances/components/balance-badge/balance-badge.module.css b/packages/appkit-react/src/features/balances/components/balance-badge/balance-badge.module.css
index 5bebfd093..f942d9b42 100644
--- a/packages/appkit-react/src/features/balances/components/balance-badge/balance-badge.module.css
+++ b/packages/appkit-react/src/features/balances/components/balance-badge/balance-badge.module.css
@@ -1,5 +1,5 @@
.balance {
- composes: bodyMedium from "../../../../styles/typography.module.css";
+ composes: labelMedium from "../../../../styles/typography.module.css";
width: fit-content;
gap: 12px;
@@ -15,7 +15,7 @@
}
.ticker {
- composes: bodyBold from "../../../../styles/typography.module.css";
+ composes: labelSemibold from "../../../../styles/typography.module.css";
color: var(--ta-color-text);
line-height: 1;
}
diff --git a/packages/appkit-react/src/features/balances/components/balance-badge/balance-badge.stories.tsx b/packages/appkit-react/src/features/balances/components/balance-badge/balance-badge.stories.tsx
index eb0a784dd..38f92775e 100644
--- a/packages/appkit-react/src/features/balances/components/balance-badge/balance-badge.stories.tsx
+++ b/packages/appkit-react/src/features/balances/components/balance-badge/balance-badge.stories.tsx
@@ -6,16 +6,13 @@
*
*/
-import type { Meta, StoryObj } from '@storybook/react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
import { BalanceBadge } from './balance-badge';
const meta: Meta = {
- title: 'Public/Features/Balances/BalanceBadge',
+ title: 'Features/Balances/BalanceBadge',
tags: ['autodocs'],
- parameters: {
- layout: 'centered',
- },
};
export default meta;
diff --git a/packages/appkit-react/src/features/balances/components/balance-badge/balance-badge.tsx b/packages/appkit-react/src/features/balances/components/balance-badge/balance-badge.tsx
index 662d9250d..e3b6942b3 100644
--- a/packages/appkit-react/src/features/balances/components/balance-badge/balance-badge.tsx
+++ b/packages/appkit-react/src/features/balances/components/balance-badge/balance-badge.tsx
@@ -10,9 +10,9 @@ import { formatUnits } from '@ton/appkit';
import type { FC, ComponentProps } from 'react';
import clsx from 'clsx';
-import { Block } from '../../../../components/block';
+import { Block } from '../../../../components/ui/block';
import styles from './balance-badge.module.css';
-import { CircleIcon } from '../../../../components/circle-icon';
+import { Logo } from '../../../../components/ui/logo';
const BalanceBadgeContainer: FC> = ({ className, ...props }) => {
return ;
@@ -40,7 +40,7 @@ const BalanceSymbol: FC & { symbol: string }> = ({ classN
export const BalanceBadge = {
Container: BalanceBadgeContainer,
- Icon: CircleIcon,
+ Icon: Logo,
BalanceBlock: BalanceBlock,
Symbol: BalanceSymbol,
Balance: Balance,
diff --git a/packages/appkit-react/src/features/balances/components/currency-item/currency-item.module.css b/packages/appkit-react/src/features/balances/components/currency-item/currency-item.module.css
index d8dc0d179..ac05553b9 100644
--- a/packages/appkit-react/src/features/balances/components/currency-item/currency-item.module.css
+++ b/packages/appkit-react/src/features/balances/components/currency-item/currency-item.module.css
@@ -1,23 +1,17 @@
.currencyItem {
box-sizing: border-box;
- border-radius: var(--ta-border-radius-l);
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
- padding: 12px;
- background-color: var(--ta-color-block);
- transition: border-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
+ padding: 0;
+ background-color: transparent;
cursor: pointer;
- border: 1px solid transparent;
+ border: none;
outline: none;
}
-.currencyItem:hover {
- border-color: var(--ta-color-primary);
-}
-
.icon {
width: 40px;
height: 40px;
@@ -45,7 +39,7 @@
}
.name {
- composes: bodyBold from "../../../../styles/typography.module.css";
+ composes: bodySemibold from "../../../../styles/typography.module.css";
color: var(--ta-color-text);
white-space: nowrap;
@@ -62,7 +56,7 @@
}
.ticker {
- composes: bodyMedium from "../../../../styles/typography.module.css";
+ composes: labelMedium from "../../../../styles/typography.module.css";
color: var(--ta-color-text-secondary);
margin: 0;
diff --git a/packages/appkit-react/src/features/balances/components/currency-item/currency-item.stories.tsx b/packages/appkit-react/src/features/balances/components/currency-item/currency-item.stories.tsx
index 2ac1f7c2c..03ed16e75 100644
--- a/packages/appkit-react/src/features/balances/components/currency-item/currency-item.stories.tsx
+++ b/packages/appkit-react/src/features/balances/components/currency-item/currency-item.stories.tsx
@@ -6,18 +6,15 @@
*
*/
-import type { Meta, StoryObj } from '@storybook/react';
-import { fn } from '@storybook/test';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { fn } from 'storybook/test';
import { CurrencyItem } from './currency-item';
const meta: Meta = {
- title: 'Public/Features/Balances/CurrencyItem',
+ title: 'Features/Balances/CurrencyItem',
component: CurrencyItem,
tags: ['autodocs'],
- parameters: {
- layout: 'centered',
- },
args: {
onClick: fn(),
},
diff --git a/packages/appkit-react/src/features/balances/components/currency-item/currency-item.tsx b/packages/appkit-react/src/features/balances/components/currency-item/currency-item.tsx
index 16840eefa..ee65a5471 100644
--- a/packages/appkit-react/src/features/balances/components/currency-item/currency-item.tsx
+++ b/packages/appkit-react/src/features/balances/components/currency-item/currency-item.tsx
@@ -9,7 +9,8 @@
import type { FC, ComponentProps } from 'react';
import clsx from 'clsx';
-import { CircleIcon } from '../../../../components/circle-icon';
+import { Logo } from '../../../../components/ui/logo';
+import { VerifiedIcon } from '../../../../components/ui/icons';
import styles from './currency-item.module.css';
export interface CurrencyItemProps extends ComponentProps<'button'> {
@@ -31,29 +32,25 @@ export const CurrencyItem: FC = ({
}) => {
return (
-
+
{name || ticker}
- {isVerified && (
-
-
-
- )}
+ {isVerified &&
}
-
{ticker}
+
+ {ticker} • {name}
+
-
+ {balance && (
+
+ )}
);
};
diff --git a/packages/appkit-react/src/features/balances/components/send-jetton-button/send-jetton-button.stories.tsx b/packages/appkit-react/src/features/balances/components/send-jetton-button/send-jetton-button.stories.tsx
index 5de010829..8163acee6 100644
--- a/packages/appkit-react/src/features/balances/components/send-jetton-button/send-jetton-button.stories.tsx
+++ b/packages/appkit-react/src/features/balances/components/send-jetton-button/send-jetton-button.stories.tsx
@@ -6,9 +6,9 @@
*
*/
-import type { Meta, StoryObj } from '@storybook/react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
-import { Button } from '../../../../components/button';
+import { Button } from '../../../../components/ui/button';
const SendJettonButtonPreview = ({
text = 'Send USDT',
@@ -23,12 +23,9 @@ const SendJettonButtonPreview = ({
};
const meta: Meta = {
- title: 'Public/Features/Balances/SendJettonButton',
+ title: 'Features/Balances/SendJettonButton',
component: SendJettonButtonPreview,
tags: ['autodocs'],
- parameters: {
- layout: 'centered',
- },
};
export default meta;
diff --git a/packages/appkit-react/src/features/balances/components/send-jetton-button/send-jetton-button.tsx b/packages/appkit-react/src/features/balances/components/send-jetton-button/send-jetton-button.tsx
index 6aade283e..d9d8cd399 100644
--- a/packages/appkit-react/src/features/balances/components/send-jetton-button/send-jetton-button.tsx
+++ b/packages/appkit-react/src/features/balances/components/send-jetton-button/send-jetton-button.tsx
@@ -10,8 +10,7 @@ import { useCallback, useMemo } from 'react';
import type { FC } from 'react';
import { createTransferJettonTransaction, formatUnits, parseUnits } from '@ton/appkit';
-import { useI18n } from '../../../../hooks/use-i18n';
-import { useAppKit } from '../../../../hooks/use-app-kit';
+import { useI18n, useAppKit } from '../../../settings';
import type { SendProps } from '../../../transaction';
import { Send } from '../../../transaction';
diff --git a/packages/appkit-react/src/features/balances/components/send-ton-button/send-ton-button.stories.tsx b/packages/appkit-react/src/features/balances/components/send-ton-button/send-ton-button.stories.tsx
index ea68ef1f4..3c6437e8c 100644
--- a/packages/appkit-react/src/features/balances/components/send-ton-button/send-ton-button.stories.tsx
+++ b/packages/appkit-react/src/features/balances/components/send-ton-button/send-ton-button.stories.tsx
@@ -6,9 +6,9 @@
*
*/
-import type { Meta, StoryObj } from '@storybook/react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
-import { Button } from '../../../../components/button';
+import { Button } from '../../../../components/ui/button';
const SendTonButtonPreview = ({
text = 'Send TON',
@@ -23,12 +23,9 @@ const SendTonButtonPreview = ({
};
const meta: Meta = {
- title: 'Public/Features/Balances/SendTonButton',
+ title: 'Features/Balances/SendTonButton',
component: SendTonButtonPreview,
tags: ['autodocs'],
- parameters: {
- layout: 'centered',
- },
};
export default meta;
diff --git a/packages/appkit-react/src/features/balances/components/send-ton-button/send-ton-button.tsx b/packages/appkit-react/src/features/balances/components/send-ton-button/send-ton-button.tsx
index 7005cd59f..30201ae49 100644
--- a/packages/appkit-react/src/features/balances/components/send-ton-button/send-ton-button.tsx
+++ b/packages/appkit-react/src/features/balances/components/send-ton-button/send-ton-button.tsx
@@ -10,8 +10,7 @@ import { useCallback } from 'react';
import type { FC } from 'react';
import { createTransferTonTransaction } from '@ton/appkit';
-import { useI18n } from '../../../../hooks/use-i18n';
-import { useAppKit } from '../../../../hooks/use-app-kit';
+import { useI18n, useAppKit } from '../../../settings';
import type { SendProps } from '../../../transaction';
import { Send } from '../../../transaction';
diff --git a/packages/appkit-react/src/features/balances/hooks/use-balance-by-address.ts b/packages/appkit-react/src/features/balances/hooks/use-balance-by-address.ts
index ceddb9706..e43423235 100644
--- a/packages/appkit-react/src/features/balances/hooks/use-balance-by-address.ts
+++ b/packages/appkit-react/src/features/balances/hooks/use-balance-by-address.ts
@@ -9,9 +9,10 @@
import { getBalanceByAddressQueryOptions } from '@ton/appkit/queries';
import type { GetBalanceByAddressData, GetBalanceErrorType, GetBalanceByAddressQueryConfig } from '@ton/appkit/queries';
-import { useAppKit } from '../../../hooks/use-app-kit';
+import { useAppKit } from '../../settings';
import { useQuery } from '../../../libs/query';
import type { UseQueryReturnType } from '../../../libs/query';
+import { useNetwork } from '../../network';
export type UseBalanceByAddressParameters =
GetBalanceByAddressQueryConfig;
@@ -28,6 +29,9 @@ export const useBalanceByAddress = (
parameters: UseBalanceByAddressParameters = {},
): UseBalanceByAddressReturnType => {
const appKit = useAppKit();
+ const walletNetwork = useNetwork();
- return useQuery(getBalanceByAddressQueryOptions(appKit, parameters));
+ return useQuery(
+ getBalanceByAddressQueryOptions(appKit, { ...parameters, network: parameters.network ?? walletNetwork }),
+ );
};
diff --git a/packages/appkit-react/src/features/balances/hooks/use-watch-balance-by-address.ts b/packages/appkit-react/src/features/balances/hooks/use-watch-balance-by-address.ts
index fe4f3cb89..d03d8ec42 100644
--- a/packages/appkit-react/src/features/balances/hooks/use-watch-balance-by-address.ts
+++ b/packages/appkit-react/src/features/balances/hooks/use-watch-balance-by-address.ts
@@ -12,7 +12,7 @@ import { watchBalanceByAddress, hasStreamingProvider, resolveNetwork } from '@to
import type { WatchBalanceByAddressOptions } from '@ton/appkit';
import { handleBalanceUpdate } from '@ton/appkit/queries';
-import { useAppKit } from '../../../hooks/use-app-kit';
+import { useAppKit } from '../../settings';
export type UseWatchBalanceByAddressParameters = Partial;
diff --git a/packages/appkit-react/src/features/jettons/hooks/use-jetton-balance-by-address.ts b/packages/appkit-react/src/features/jettons/hooks/use-jetton-balance-by-address.ts
index 2b60743ef..f779d472a 100644
--- a/packages/appkit-react/src/features/jettons/hooks/use-jetton-balance-by-address.ts
+++ b/packages/appkit-react/src/features/jettons/hooks/use-jetton-balance-by-address.ts
@@ -13,9 +13,10 @@ import type {
GetJettonBalanceByAddressQueryConfig,
} from '@ton/appkit/queries';
-import { useAppKit } from '../../../hooks/use-app-kit';
+import { useAppKit } from '../../settings';
import { useQuery } from '../../../libs/query';
import type { UseQueryReturnType } from '../../../libs/query';
+import { useNetwork } from '../../network';
export type UseJettonBalanceByAddressParameters =
GetJettonBalanceByAddressQueryConfig;
@@ -32,6 +33,9 @@ export const useJettonBalanceByAddress = = {},
): UseJettonBalanceByAddressReturnType => {
const appKit = useAppKit();
+ const walletNetwork = useNetwork();
- return useQuery(getJettonBalanceByAddressQueryOptions(appKit, parameters));
+ return useQuery(
+ getJettonBalanceByAddressQueryOptions(appKit, { ...parameters, network: parameters.network ?? walletNetwork }),
+ );
};
diff --git a/packages/appkit-react/src/features/jettons/hooks/use-jetton-info.ts b/packages/appkit-react/src/features/jettons/hooks/use-jetton-info.ts
index 494d0c4e6..a3fed815b 100644
--- a/packages/appkit-react/src/features/jettons/hooks/use-jetton-info.ts
+++ b/packages/appkit-react/src/features/jettons/hooks/use-jetton-info.ts
@@ -9,9 +9,10 @@
import { getJettonInfoQueryOptions } from '@ton/appkit/queries';
import type { GetJettonInfoData, GetJettonInfoErrorType, GetJettonInfoQueryConfig } from '@ton/appkit/queries';
-import { useAppKit } from '../../../hooks/use-app-kit';
+import { useAppKit } from '../../settings';
import { useQuery } from '../../../libs/query';
import type { UseQueryReturnType } from '../../../libs/query';
+import { useNetwork } from '../../network';
export type UseJettonInfoParameters = GetJettonInfoQueryConfig;
@@ -27,6 +28,7 @@ export const useJettonInfo = (
parameters: UseJettonInfoParameters = {},
): UseJettonInfoReturnType => {
const appKit = useAppKit();
+ const walletNetwork = useNetwork();
- return useQuery(getJettonInfoQueryOptions(appKit, parameters));
+ return useQuery(getJettonInfoQueryOptions(appKit, { ...parameters, network: parameters.network ?? walletNetwork }));
};
diff --git a/packages/appkit-react/src/features/jettons/hooks/use-jetton-wallet-address.ts b/packages/appkit-react/src/features/jettons/hooks/use-jetton-wallet-address.ts
index 7bf11c6c3..7734f4fb7 100644
--- a/packages/appkit-react/src/features/jettons/hooks/use-jetton-wallet-address.ts
+++ b/packages/appkit-react/src/features/jettons/hooks/use-jetton-wallet-address.ts
@@ -13,9 +13,10 @@ import type {
GetJettonWalletAddressQueryConfig,
} from '@ton/appkit/queries';
-import { useAppKit } from '../../../hooks/use-app-kit';
+import { useAppKit } from '../../settings';
import { useQuery } from '../../../libs/query';
import type { UseQueryReturnType } from '../../../libs/query';
+import { useNetwork } from '../../network';
export type UseJettonWalletAddressParameters =
GetJettonWalletAddressQueryConfig;
@@ -32,6 +33,9 @@ export const useJettonWalletAddress = (
parameters: UseJettonWalletAddressParameters = {},
) => {
const appKit = useAppKit();
+ const walletNetwork = useNetwork();
- return useQuery(getJettonWalletAddressQueryOptions(appKit, parameters));
+ return useQuery(
+ getJettonWalletAddressQueryOptions(appKit, { ...parameters, network: parameters.network ?? walletNetwork }),
+ );
};
diff --git a/packages/appkit-react/src/features/jettons/hooks/use-jettons-by-address.ts b/packages/appkit-react/src/features/jettons/hooks/use-jettons-by-address.ts
index 2dd119d92..8455e51e9 100644
--- a/packages/appkit-react/src/features/jettons/hooks/use-jettons-by-address.ts
+++ b/packages/appkit-react/src/features/jettons/hooks/use-jettons-by-address.ts
@@ -9,9 +9,10 @@
import { getJettonsByAddressQueryOptions } from '@ton/appkit/queries';
import type { GetJettonsByAddressData, GetJettonsErrorType, GetJettonsByAddressQueryConfig } from '@ton/appkit/queries';
-import { useAppKit } from '../../../hooks/use-app-kit';
+import { useAppKit } from '../../settings';
import { useQuery } from '../../../libs/query';
import type { UseQueryReturnType } from '../../../libs/query';
+import { useNetwork } from '../../network';
export type UseJettonsByAddressParameters =
GetJettonsByAddressQueryConfig;
@@ -28,6 +29,9 @@ export const useJettonsByAddress = (
parameters: UseJettonsByAddressParameters = {},
): UseJettonsByAddressReturnType => {
const appKit = useAppKit();
+ const walletNetwork = useNetwork();
- return useQuery(getJettonsByAddressQueryOptions(appKit, parameters));
+ return useQuery(
+ getJettonsByAddressQueryOptions(appKit, { ...parameters, network: parameters.network ?? walletNetwork }),
+ );
};
diff --git a/packages/appkit-react/src/features/jettons/hooks/use-transfer-jetton.ts b/packages/appkit-react/src/features/jettons/hooks/use-transfer-jetton.ts
index 26d06237a..fb797cb94 100644
--- a/packages/appkit-react/src/features/jettons/hooks/use-transfer-jetton.ts
+++ b/packages/appkit-react/src/features/jettons/hooks/use-transfer-jetton.ts
@@ -19,7 +19,7 @@ import { transferJettonMutationOptions } from '@ton/appkit/queries';
import { useMutation } from '../../../libs/query';
import type { UseMutationReturnType } from '../../../libs/query';
-import { useAppKit } from '../../../hooks/use-app-kit';
+import { useAppKit } from '../../settings';
export type UseTransferJettonParameters = TransferJettonOptions;
diff --git a/packages/appkit-react/src/features/jettons/hooks/use-watch-jettons-by-address.ts b/packages/appkit-react/src/features/jettons/hooks/use-watch-jettons-by-address.ts
index bbdc990b6..333f9bdc0 100644
--- a/packages/appkit-react/src/features/jettons/hooks/use-watch-jettons-by-address.ts
+++ b/packages/appkit-react/src/features/jettons/hooks/use-watch-jettons-by-address.ts
@@ -12,7 +12,7 @@ import { watchJettonsByAddress, hasStreamingProvider, resolveNetwork } from '@to
import type { WatchJettonsByAddressOptions, JettonUpdate } from '@ton/appkit';
import { handleJettonBalanceUpdate, handleJettonsUpdate } from '@ton/appkit/queries';
-import { useAppKit } from '../../../hooks/use-app-kit';
+import { useAppKit } from '../../settings';
export type UseWatchJettonsByAddressParameters = Partial;
diff --git a/packages/appkit-react/src/features/network/hooks/use-block-number.ts b/packages/appkit-react/src/features/network/hooks/use-block-number.ts
index 2c2d3365e..247edc451 100644
--- a/packages/appkit-react/src/features/network/hooks/use-block-number.ts
+++ b/packages/appkit-react/src/features/network/hooks/use-block-number.ts
@@ -9,9 +9,10 @@
import { getBlockNumberQueryOptions } from '@ton/appkit/queries';
import type { GetBlockNumberData, GetBlockNumberErrorType, GetBlockNumberQueryConfig } from '@ton/appkit/queries';
-import { useAppKit } from '../../../hooks/use-app-kit';
+import { useAppKit } from '../../settings';
import { useQuery } from '../../../libs/query';
import type { UseQueryReturnType } from '../../../libs/query';
+import { useNetwork } from '../hooks/use-network';
export type UseBlockNumberParameters = GetBlockNumberQueryConfig;
@@ -27,6 +28,9 @@ export const useBlockNumber = (
parameters: UseBlockNumberParameters = {},
): UseBlockNumberReturnType => {
const appKit = useAppKit();
+ const walletNetwork = useNetwork();
- return useQuery(getBlockNumberQueryOptions(appKit, parameters));
+ return useQuery(
+ getBlockNumberQueryOptions(appKit, { ...parameters, network: parameters.network ?? walletNetwork }),
+ );
};
diff --git a/packages/appkit-react/src/features/network/hooks/use-default-network.ts b/packages/appkit-react/src/features/network/hooks/use-default-network.ts
index 43f78ebc9..694b99aed 100644
--- a/packages/appkit-react/src/features/network/hooks/use-default-network.ts
+++ b/packages/appkit-react/src/features/network/hooks/use-default-network.ts
@@ -10,7 +10,7 @@ import { useSyncExternalStore, useCallback } from 'react';
import { getDefaultNetwork, setDefaultNetwork, watchDefaultNetwork } from '@ton/appkit';
import type { GetDefaultNetworkReturnType, Network } from '@ton/appkit';
-import { useAppKit } from '../../../hooks/use-app-kit';
+import { useAppKit } from '../../settings';
export type UseDefaultNetworkReturnType = [
network: GetDefaultNetworkReturnType,
diff --git a/packages/appkit-react/src/features/network/hooks/use-networks.ts b/packages/appkit-react/src/features/network/hooks/use-networks.ts
index 788c74883..775da117e 100644
--- a/packages/appkit-react/src/features/network/hooks/use-networks.ts
+++ b/packages/appkit-react/src/features/network/hooks/use-networks.ts
@@ -10,7 +10,7 @@ import { useSyncExternalStore, useCallback, useRef } from 'react';
import { getNetworks, watchNetworks } from '@ton/appkit';
import type { GetNetworksReturnType } from '@ton/appkit';
-import { useAppKit } from '../../../hooks/use-app-kit';
+import { useAppKit } from '../../settings';
export type UseNetworksReturnType = GetNetworksReturnType;
diff --git a/packages/appkit-react/src/features/nft/components/nft-item/nft-item.module.css b/packages/appkit-react/src/features/nft/components/nft-item/nft-item.module.css
index 38704049f..c43871e54 100644
--- a/packages/appkit-react/src/features/nft/components/nft-item/nft-item.module.css
+++ b/packages/appkit-react/src/features/nft/components/nft-item/nft-item.module.css
@@ -38,10 +38,9 @@
}
.name {
- composes: bodySmall from "../../../../styles/typography.module.css";
+ composes: labelSemibold from "../../../../styles/typography.module.css";
color: var(--ta-color-text);
- font-weight: var(--ta-body-medium-font-weight);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -49,7 +48,7 @@
}
.collectionName {
- composes: bodySmall from "../../../../styles/typography.module.css";
+ composes: footnoteRegular from "../../../../styles/typography.module.css";
color: var(--ta-color-text-secondary);
white-space: nowrap;
@@ -59,7 +58,7 @@
}
.saleBadge {
- composes: caption2 from "../../../../styles/typography.module.css";
+ composes: captionSemibold from "../../../../styles/typography.module.css";
position: absolute;
top: 8px;
@@ -68,7 +67,6 @@
align-items: center;
padding: 2px 6px;
border-radius: var(--ta-border-radius-s);
- font-weight: var(--ta-body-medium-font-weight);
background-color: var(--ta-color-primary);
- color: var(--ta-color-text-on-primary, #fff);
+ color: var(--ta-color-primary-foreground);
}
diff --git a/packages/appkit-react/src/features/nft/components/nft-item/nft-item.stories.tsx b/packages/appkit-react/src/features/nft/components/nft-item/nft-item.stories.tsx
index 4b4012a82..47bd71d53 100644
--- a/packages/appkit-react/src/features/nft/components/nft-item/nft-item.stories.tsx
+++ b/packages/appkit-react/src/features/nft/components/nft-item/nft-item.stories.tsx
@@ -6,23 +6,13 @@
*
*/
-import type { Meta, StoryObj } from '@storybook/react';
-import { fn } from '@storybook/test';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { fn } from 'storybook/test';
import clsx from 'clsx';
+import { ImageIcon } from '../../../../components/ui/icons';
import styles from './nft-item.module.css';
-const PlaceholderIcon = () => (
-
-
-
-);
-
const NftItemPreview = ({
name,
collectionName,
@@ -41,7 +31,11 @@ const NftItemPreview = ({
return (
- {image ?
:
}
+ {image ? (
+
+ ) : (
+
+ )}
{isOnSale &&
On Sale }
@@ -53,7 +47,7 @@ const NftItemPreview = ({
};
const meta: Meta
= {
- title: 'Public/Features/NFT/NftItem',
+ title: 'Features/NFT/NftItem',
component: NftItemPreview,
tags: ['autodocs'],
parameters: {
diff --git a/packages/appkit-react/src/features/nft/components/nft-item/nft-item.tsx b/packages/appkit-react/src/features/nft/components/nft-item/nft-item.tsx
index e47418501..7c26830b1 100644
--- a/packages/appkit-react/src/features/nft/components/nft-item/nft-item.tsx
+++ b/packages/appkit-react/src/features/nft/components/nft-item/nft-item.tsx
@@ -12,20 +12,10 @@ import type { FC, ComponentProps } from 'react';
import { getFormattedNftInfo } from '@ton/appkit';
import clsx from 'clsx';
-import { useI18n } from '../../../../hooks/use-i18n';
+import { ImageIcon } from '../../../../components/ui/icons';
+import { useI18n } from '../../../settings/hooks/use-i18n';
import styles from './nft-item.module.css';
-const PlaceholderIcon: FC = () => (
-
-
-
-);
-
export interface NftItemProps extends ComponentProps<'button'> {
nft: NFT;
}
@@ -43,7 +33,7 @@ export const NftItem: FC = ({ nft, className, ...props }) => {
{showImage ? (
setImageError(true)} />
) : (
-
+
)}
{isOnSale && {t('nft.onSale')} }
diff --git a/packages/appkit-react/src/features/nft/hooks/use-nft.ts b/packages/appkit-react/src/features/nft/hooks/use-nft.ts
index 99681de7a..8a0d4d33e 100644
--- a/packages/appkit-react/src/features/nft/hooks/use-nft.ts
+++ b/packages/appkit-react/src/features/nft/hooks/use-nft.ts
@@ -9,9 +9,10 @@
import { getNftQueryOptions } from '@ton/appkit/queries';
import type { GetNftData, GetNftErrorType, GetNftQueryConfig } from '@ton/appkit/queries';
-import { useAppKit } from '../../../hooks/use-app-kit';
+import { useAppKit } from '../../settings';
import { useQuery } from '../../../libs/query';
import type { UseQueryReturnType } from '../../../libs/query';
+import { useNetwork } from '../../network';
export type UseNftParameters = GetNftQueryConfig;
@@ -24,6 +25,7 @@ export const useNft = (
parameters: UseNftParameters = {},
): UseNftReturnType => {
const appKit = useAppKit();
+ const walletNetwork = useNetwork();
- return useQuery(getNftQueryOptions(appKit, parameters));
+ return useQuery(getNftQueryOptions(appKit, { ...parameters, network: parameters.network ?? walletNetwork }));
};
diff --git a/packages/appkit-react/src/features/nft/hooks/use-nfts-by-address.ts b/packages/appkit-react/src/features/nft/hooks/use-nfts-by-address.ts
index 18cacb6a8..a0250940e 100644
--- a/packages/appkit-react/src/features/nft/hooks/use-nfts-by-address.ts
+++ b/packages/appkit-react/src/features/nft/hooks/use-nfts-by-address.ts
@@ -9,9 +9,10 @@
import { getNFTsQueryOptions } from '@ton/appkit/queries';
import type { GetNFTsData, GetNFTsErrorType, GetNFTsQueryConfig } from '@ton/appkit/queries';
-import { useAppKit } from '../../../hooks/use-app-kit';
+import { useAppKit } from '../../settings';
import { useQuery } from '../../../libs/query';
import type { UseQueryReturnType } from '../../../libs/query';
+import { useNetwork } from '../../network';
export type UseNFTsByAddressParameters = GetNFTsQueryConfig;
@@ -24,6 +25,7 @@ export const useNftsByAddress = (
parameters: UseNFTsByAddressParameters = {},
): UseNFTsByAddressReturnType => {
const appKit = useAppKit();
+ const walletNetwork = useNetwork();
- return useQuery(getNFTsQueryOptions(appKit, parameters));
+ return useQuery(getNFTsQueryOptions(appKit, { ...parameters, network: parameters.network ?? walletNetwork }));
};
diff --git a/packages/appkit-react/src/features/nft/hooks/use-transfer-nft.ts b/packages/appkit-react/src/features/nft/hooks/use-transfer-nft.ts
index 8d2097f1b..4ebe67a98 100644
--- a/packages/appkit-react/src/features/nft/hooks/use-transfer-nft.ts
+++ b/packages/appkit-react/src/features/nft/hooks/use-transfer-nft.ts
@@ -19,7 +19,7 @@ import { transferNftMutationOptions } from '@ton/appkit/queries';
import { useMutation } from '../../../libs/query';
import type { UseMutationReturnType } from '../../../libs/query';
-import { useAppKit } from '../../../hooks/use-app-kit';
+import { useAppKit } from '../../settings';
export type UseTransferNftParameters = TransferNftOptions;
diff --git a/packages/appkit-react/src/hooks/use-app-kit-theme.ts b/packages/appkit-react/src/features/settings/hooks/use-app-kit-theme.ts
similarity index 100%
rename from packages/appkit-react/src/hooks/use-app-kit-theme.ts
rename to packages/appkit-react/src/features/settings/hooks/use-app-kit-theme.ts
diff --git a/packages/appkit-react/src/hooks/use-app-kit.ts b/packages/appkit-react/src/features/settings/hooks/use-app-kit.ts
similarity index 85%
rename from packages/appkit-react/src/hooks/use-app-kit.ts
rename to packages/appkit-react/src/features/settings/hooks/use-app-kit.ts
index 2dfa5a34b..2a56599a7 100644
--- a/packages/appkit-react/src/hooks/use-app-kit.ts
+++ b/packages/appkit-react/src/features/settings/hooks/use-app-kit.ts
@@ -8,7 +8,7 @@
import { useContext } from 'react';
-import { AppKitContext } from '../providers/app-kit-provider';
+import { AppKitContext } from '../../../providers/app-kit-provider';
export function useAppKit() {
const context = useContext(AppKitContext);
diff --git a/packages/appkit-react/src/hooks/use-i18n.ts b/packages/appkit-react/src/features/settings/hooks/use-i18n.ts
similarity index 86%
rename from packages/appkit-react/src/hooks/use-i18n.ts
rename to packages/appkit-react/src/features/settings/hooks/use-i18n.ts
index 015e18f7d..f808e3bb1 100644
--- a/packages/appkit-react/src/hooks/use-i18n.ts
+++ b/packages/appkit-react/src/features/settings/hooks/use-i18n.ts
@@ -8,7 +8,7 @@
import { useContext } from 'react';
-import { I18nContext } from '../providers/i18n-provider';
+import { I18nContext } from '../../../providers/i18n-provider';
export const useI18n = () => {
const i18n = useContext(I18nContext);
diff --git a/packages/appkit-react/src/features/settings/index.ts b/packages/appkit-react/src/features/settings/index.ts
new file mode 100644
index 000000000..91e69da26
--- /dev/null
+++ b/packages/appkit-react/src/features/settings/index.ts
@@ -0,0 +1,11 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export { useAppKit } from './hooks/use-app-kit';
+export { useAppKitTheme, type AppKitTheme } from './hooks/use-app-kit-theme';
+export { useI18n } from './hooks/use-i18n';
diff --git a/packages/appkit-react/src/features/signing/hooks/use-sign-binary.ts b/packages/appkit-react/src/features/signing/hooks/use-sign-binary.ts
index 4b6f41d32..b13a6d30c 100644
--- a/packages/appkit-react/src/features/signing/hooks/use-sign-binary.ts
+++ b/packages/appkit-react/src/features/signing/hooks/use-sign-binary.ts
@@ -10,7 +10,7 @@ import type { UseMutationResult } from '@tanstack/react-query';
import { signBinaryMutationOptions } from '@ton/appkit/queries';
import type { SignBinaryData, SignBinaryErrorType, SignBinaryOptions, SignBinaryVariables } from '@ton/appkit/queries';
-import { useAppKit } from '../../../hooks/use-app-kit';
+import { useAppKit } from '../../settings';
import { useMutation } from '../../../libs/query';
export type UseSignBinaryParameters = SignBinaryOptions;
diff --git a/packages/appkit-react/src/features/signing/hooks/use-sign-cell.ts b/packages/appkit-react/src/features/signing/hooks/use-sign-cell.ts
index 0d93b0534..265985a27 100644
--- a/packages/appkit-react/src/features/signing/hooks/use-sign-cell.ts
+++ b/packages/appkit-react/src/features/signing/hooks/use-sign-cell.ts
@@ -10,7 +10,7 @@ import type { UseMutationResult } from '@tanstack/react-query';
import { signCellMutationOptions } from '@ton/appkit/queries';
import type { SignCellData, SignCellErrorType, SignCellOptions, SignCellVariables } from '@ton/appkit/queries';
-import { useAppKit } from '../../../hooks/use-app-kit';
+import { useAppKit } from '../../settings';
import { useMutation } from '../../../libs/query';
export type UseSignCellParameters = SignCellOptions;
diff --git a/packages/appkit-react/src/features/signing/hooks/use-sign-text.ts b/packages/appkit-react/src/features/signing/hooks/use-sign-text.ts
index f88f71c53..0845e8f59 100644
--- a/packages/appkit-react/src/features/signing/hooks/use-sign-text.ts
+++ b/packages/appkit-react/src/features/signing/hooks/use-sign-text.ts
@@ -10,7 +10,7 @@ import type { UseMutationResult } from '@tanstack/react-query';
import { signTextMutationOptions } from '@ton/appkit/queries';
import type { SignTextData, SignTextErrorType, SignTextOptions, SignTextVariables } from '@ton/appkit/queries';
-import { useAppKit } from '../../../hooks/use-app-kit';
+import { useAppKit } from '../../settings';
import { useMutation } from '../../../libs/query';
export type UseSignTextParameters = SignTextOptions;
diff --git a/packages/appkit-react/src/features/staking/components/select-unstake-mode/index.ts b/packages/appkit-react/src/features/staking/components/select-unstake-mode/index.ts
new file mode 100644
index 000000000..19bd81b5f
--- /dev/null
+++ b/packages/appkit-react/src/features/staking/components/select-unstake-mode/index.ts
@@ -0,0 +1,10 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export { SelectUnstakeMode } from './select-unstake-mode';
+export type { SelectUnstakeModeProps } from './select-unstake-mode';
diff --git a/packages/appkit-react/src/features/staking/components/select-unstake-mode/select-unstake-mode.module.css b/packages/appkit-react/src/features/staking/components/select-unstake-mode/select-unstake-mode.module.css
new file mode 100644
index 000000000..f0fa338e3
--- /dev/null
+++ b/packages/appkit-react/src/features/staking/components/select-unstake-mode/select-unstake-mode.module.css
@@ -0,0 +1,115 @@
+.root {
+ padding: 0 16px;
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+ border-radius: var(--ta-border-radius-l);
+ background-color: var(--ta-color-background-secondary);
+}
+
+.header {
+ composes: bodyMedium from "../../../../styles/typography.module.css";
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+ padding: 16px 0;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ color: var(--ta-color-text);
+}
+
+.headerLabel {
+ color: var(--ta-color-text-secondary);
+}
+
+.headerValue {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--ta-color-text);
+}
+
+.chevron {
+ transition: transform 0.25s ease;
+}
+
+.chevronOpen {
+ transform: rotate(180deg);
+}
+
+.options {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding-top: 4px;
+ padding-bottom: 16px;
+}
+
+.option {
+ cursor: pointer;
+ border: var(--ta-border-width-s) solid var(--ta-color-background-tertiary);
+ transition: border-color 0.15s;
+ padding: 16px 8px;
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+ border-radius: var(--ta-border-radius-l);
+ background-color: var(--ta-color-background-secondary);
+}
+
+.optionRow {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.radio {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 20px;
+ height: 20px;
+ border-radius: var(--ta-border-radius-full);
+ border: var(--ta-border-width-m) solid var(--ta-color-background-tertiary);
+ box-sizing: border-box;
+ transition: border-color 0.15s;
+}
+
+.radioActive {
+ border-color: var(--ta-color-primary);
+}
+
+.point {
+ width: 12px;
+ height: 12px;
+ background-color: transparent;
+ border-radius: var(--ta-border-radius-full);
+ transition: background-color 0.15s;
+}
+
+.pointActive {
+ background-color: var(--ta-color-primary);
+}
+
+.optionLabel {
+ composes: bodyMedium from "../../../../styles/typography.module.css";
+ color: var(--ta-color-text);
+}
+
+.tags {
+ display: flex;
+ gap: 6px;
+ margin-left: auto;
+}
+
+.tag {
+ composes: labelMedium from "../../../../styles/typography.module.css";
+ padding: 4px 8px;
+ border-radius: var(--ta-border-radius-full);
+ white-space: nowrap;
+ background-color: var(--ta-color-background-bezeled);
+ color: var(--ta-color-primary);
+}
diff --git a/packages/appkit-react/src/features/staking/components/select-unstake-mode/select-unstake-mode.stories.tsx b/packages/appkit-react/src/features/staking/components/select-unstake-mode/select-unstake-mode.stories.tsx
new file mode 100644
index 000000000..e7171b7af
--- /dev/null
+++ b/packages/appkit-react/src/features/staking/components/select-unstake-mode/select-unstake-mode.stories.tsx
@@ -0,0 +1,58 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { UnstakeMode } from '@ton/appkit';
+
+import { SelectUnstakeMode } from './select-unstake-mode';
+
+const meta: Meta = {
+ title: 'Features/Staking/Internal/SelectUnstakeMode',
+ component: SelectUnstakeMode,
+};
+
+export default meta;
+type Story = StoryObj;
+
+const mockMetadata = {
+ name: 'Tonstakers',
+ providerId: 'tonstakers',
+ stakeToken: {
+ symbol: 'TON',
+ ticker: 'TON',
+ decimals: 9,
+ address: 'ton',
+ },
+ supportedUnstakeModes: [UnstakeMode.INSTANT, UnstakeMode.ROUND_END, UnstakeMode.WHEN_AVAILABLE],
+ supportsReversedQuote: false,
+};
+
+const mockProviderInfo = {
+ instantUnstakeAvailable: '10000000000',
+ exchangeRate: '1.05',
+ apy: 4.5,
+};
+
+export const Default: Story = {
+ args: {
+ value: UnstakeMode.INSTANT,
+ onValueChange: () => {},
+ providerInfo: mockProviderInfo,
+ providerMetadata: mockMetadata,
+ },
+};
+
+export const LimitedModes: Story = {
+ args: {
+ ...Default.args,
+ providerMetadata: {
+ ...mockMetadata,
+ supportedUnstakeModes: [UnstakeMode.INSTANT],
+ },
+ },
+};
diff --git a/packages/appkit-react/src/features/staking/components/select-unstake-mode/select-unstake-mode.tsx b/packages/appkit-react/src/features/staking/components/select-unstake-mode/select-unstake-mode.tsx
new file mode 100644
index 000000000..64804068b
--- /dev/null
+++ b/packages/appkit-react/src/features/staking/components/select-unstake-mode/select-unstake-mode.tsx
@@ -0,0 +1,128 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { useCallback, useMemo, useState } from 'react';
+import type { FC, ComponentProps } from 'react';
+import clsx from 'clsx';
+import { UnstakeMode } from '@ton/appkit';
+import type { UnstakeModes, StakingProviderInfo, StakingProviderMetadata } from '@ton/appkit';
+
+import { Collapsible } from '../../../../components/ui/collapsible';
+import { ChevronDownIcon } from '../../../../components/ui/icons';
+import { useI18n } from '../../../settings/hooks/use-i18n';
+import { formatAmount } from '../staking-info/utils';
+import styles from './select-unstake-mode.module.css';
+
+export interface SelectUnstakeModeProps extends ComponentProps<'div'> {
+ value: UnstakeModes;
+ onValueChange: (mode: UnstakeModes) => void;
+ providerInfo: StakingProviderInfo | undefined;
+ providerMetadata: StakingProviderMetadata | undefined;
+}
+
+interface ModeOption {
+ value: UnstakeModes;
+ label: string;
+ tags: string[];
+}
+
+export const SelectUnstakeMode: FC = ({
+ value,
+ onValueChange,
+ providerInfo,
+ providerMetadata,
+ className,
+ ...props
+}) => {
+ const [open, setOpen] = useState(false);
+ const { t } = useI18n();
+
+ const instantLimit = useMemo(() => {
+ if (!providerInfo?.instantUnstakeAvailable) return undefined;
+ const limit = `${formatAmount(providerInfo.instantUnstakeAvailable, providerMetadata?.stakeToken.decimals)} ${providerMetadata?.stakeToken.ticker}`;
+ return t('staking.instantLimit', { limit });
+ }, [providerInfo, providerMetadata, t]);
+
+ const modes: ModeOption[] = useMemo(
+ () =>
+ [
+ {
+ value: UnstakeMode.INSTANT,
+ label: t('staking.instant'),
+ tags: instantLimit ? [instantLimit] : [],
+ },
+ {
+ value: UnstakeMode.ROUND_END,
+ label: t('staking.maximumReward'),
+ tags: [t('staking.maximumRewardLimit')],
+ },
+ {
+ value: UnstakeMode.WHEN_AVAILABLE,
+ label: t('staking.whenAvailable'),
+ tags: [t('staking.whenAvailableLimit')],
+ },
+ ].filter((m) =>
+ providerMetadata?.supportedUnstakeModes
+ ? providerMetadata?.supportedUnstakeModes.includes(m.value)
+ : true,
+ ),
+ [t, instantLimit, providerMetadata?.supportedUnstakeModes],
+ );
+
+ const selectedLabel = modes.find((m) => m.value === value)?.label ?? '';
+
+ const handleSelect = useCallback((mode: UnstakeModes) => onValueChange(mode), [onValueChange]);
+
+ if (modes.length === 1) {
+ return null;
+ }
+
+ return (
+
+