From 3d6e233555cdd2be1ab0eb01a9863499a916f34f Mon Sep 17 00:00:00 2001 From: cxalem Date: Mon, 23 Feb 2026 20:44:05 +0100 Subject: [PATCH 1/2] feat(components): add wallet UI component package and docs Introduce the workspace with themed, reusable wallet UI primitives (address, balance, network, swap, transaction, toast, and modal flows) plus Storybook stories and tests. Add docs app pages and README usage guidance so maintainers can run, review, and integrate the components quickly. --- apps/docs/content/docs/components.mdx | 914 ++++++++++++++++++ .../docs/guides/01-getting-started.mdx | 299 ++++++ .../docs/guides/02-transaction-toasts.mdx | 378 ++++++++ .../docs/content/docs/guides/03-wallet-ui.mdx | 573 +++++++++++ apps/docs/content/docs/meta.json | 2 +- packages/components/.gitignore | 27 + packages/components/.storybook/main.ts | 25 + packages/components/.storybook/preview.ts | 22 + .../components/.storybook/vitest.setup.ts | 7 + packages/components/README.md | 606 ++++++++++++ packages/components/components.json | 22 + packages/components/index.html | 14 + packages/components/package.json | 59 ++ packages/components/registry.json | 1 + packages/components/src/App.tsx | 0 packages/components/src/index.css | 135 +++ .../address-display/AddressDisplay.test.tsx | 157 +++ .../ui/address-display/AddressDisplay.tsx | 106 ++ .../ui/address-display/index.ts | 2 + .../ui/balance-card/BalanceAmount.tsx | 40 + .../ui/balance-card/BalanceCard.test.tsx | 299 ++++++ .../ui/balance-card/BalanceCard.tsx | 119 +++ .../ui/balance-card/BalanceCardSkeleton.tsx | 40 + .../ui/balance-card/ErrorState.tsx | 25 + .../ui/balance-card/TokenList.tsx | 122 +++ .../balance-card/assets/wallet-icon-dark.png | Bin 0 -> 417 bytes .../balance-card/assets/wallet-icon-light.png | Bin 0 -> 459 bytes .../kit-components/ui/balance-card/index.ts | 29 + .../kit-components/ui/balance-card/types.ts | 130 +++ .../kit-components/ui/balance-card/utils.ts | 194 ++++ .../connect-wallet-button/ButtonContent.tsx | 17 + .../ui/connect-wallet-button/ButtonIcon.tsx | 34 + .../connect-wallet-button/ButtonSpinner.tsx | 19 + .../ui/connect-wallet-button/ChevronIcon.tsx | 28 + .../ConnectWalletButton.test.tsx | 422 ++++++++ .../ConnectWalletButton.tsx | 201 ++++ .../ui/connect-wallet-button/WalletButton.tsx | 161 +++ .../connect-wallet-button/WalletDropdown.tsx | 182 ++++ .../connect-wallet-button/assets/backpack.png | Bin 0 -> 1809 bytes .../connect-wallet-button/assets/solflare.png | Bin 0 -> 4865 bytes .../ui/connect-wallet-button/index.ts | 72 ++ .../ui/connect-wallet-button/types.ts | 87 ++ .../dashboard-shell/DashboardShell.test.tsx | 187 ++++ .../ui/dashboard-shell/DashboardShell.tsx | 61 ++ .../ui/dashboard-shell/index.ts | 2 + .../components/src/kit-components/ui/index.ts | 10 + .../ui/network-switcher/NetworkDropdown.tsx | 34 + .../ui/network-switcher/NetworkHeader.tsx | 29 + .../ui/network-switcher/NetworkOption.tsx | 30 + .../network-switcher/NetworkSwitcher.test.tsx | 303 ++++++ .../ui/network-switcher/NetworkSwitcher.tsx | 123 +++ .../ui/network-switcher/NetworkTrigger.tsx | 51 + .../ui/network-switcher/StatusIndicator.tsx | 36 + .../ui/network-switcher/index.ts | 51 + .../ui/network-switcher/types.ts | 101 ++ .../ui/skeleton/Skeleton.test.tsx | 41 + .../kit-components/ui/skeleton/Skeleton.tsx | 8 + .../src/kit-components/ui/skeleton/index.ts | 2 + .../ui/swap-input/SwapInput.tsx | 104 ++ .../ui/swap-input/SwapInputSkeleton.tsx | 35 + .../ui/swap-input/TokenInput.tsx | 265 +++++ .../src/kit-components/ui/swap-input/index.ts | 18 + .../src/kit-components/ui/swap-input/types.ts | 107 ++ .../src/kit-components/ui/swap-input/utils.ts | 39 + .../ui/transaction-table/FilterDropdown.tsx | 111 +++ .../ui/transaction-table/TransactionRow.tsx | 182 ++++ .../ui/transaction-table/TransactionTable.tsx | 174 ++++ .../TransactionTableSkeleton.tsx | 95 ++ .../ui/transaction-table/assets/receive.png | Bin 0 -> 526 bytes .../ui/transaction-table/assets/sent.png | Bin 0 -> 469 bytes .../ui/transaction-table/assets/view-dark.png | Bin 0 -> 522 bytes .../transaction-table/assets/view-light.png | Bin 0 -> 527 bytes .../ui/transaction-table/index.ts | 25 + .../ui/transaction-table/types.ts | 72 ++ .../ui/transaction-table/utils.ts | 78 ++ .../TransactionToast.test.tsx | 342 +++++++ .../ui/transaction-toast/TransactionToast.tsx | 118 +++ .../TransactionToastProvider.tsx | 72 ++ .../ui/transaction-toast/index.ts | 11 + .../transaction-toast/useTransactionToast.ts | 10 + .../ui/wallet-modal/ConnectingView.tsx | 58 ++ .../ui/wallet-modal/ErrorView.tsx | 70 ++ .../ui/wallet-modal/ModalHeader.tsx | 81 ++ .../ui/wallet-modal/NoWalletLink.tsx | 29 + .../ui/wallet-modal/WalletCard.tsx | 106 ++ .../ui/wallet-modal/WalletLabel.tsx | 38 + .../ui/wallet-modal/WalletList.tsx | 66 ++ .../ui/wallet-modal/WalletModal.test.tsx | 252 +++++ .../ui/wallet-modal/WalletModal.tsx | 94 ++ .../ui/wallet-modal/assets/backpack.png | Bin 0 -> 1809 bytes .../ui/wallet-modal/assets/error-icon.svg | 6 + .../ui/wallet-modal/assets/phantom.png | Bin 0 -> 1157 bytes .../ui/wallet-modal/assets/solflare.png | Bin 0 -> 4865 bytes .../kit-components/ui/wallet-modal/index.ts | 25 + .../kit-components/ui/wallet-modal/types.ts | 17 + packages/components/src/lib/utils.ts | 53 + packages/components/src/main.tsx | 13 + .../src/stories/AddressDisplay.stories.ts | 159 +++ .../src/stories/BalanceCard.stories.tsx | 186 ++++ .../stories/ConnectWalletButton.stories.tsx | 581 +++++++++++ .../src/stories/DashboardShell.stories.tsx | 272 ++++++ .../src/stories/NetworkSwitcher.stories.tsx | 288 ++++++ .../src/stories/Skeleton.stories.tsx | 176 ++++ .../src/stories/SwapInput.stories.tsx | 180 ++++ .../src/stories/TransactionTable.stories.tsx | 199 ++++ .../src/stories/TransactionToast.stories.tsx | 369 +++++++ .../src/stories/WalletModal.stories.tsx | 275 ++++++ packages/components/tsconfig.app.json | 34 + packages/components/tsconfig.json | 10 + packages/components/tsconfig.node.json | 26 + packages/components/vite.config.ts | 14 + packages/components/vitest.shims.d.ts | 1 + vitest.config.ts | 34 +- 113 files changed, 12206 insertions(+), 3 deletions(-) create mode 100644 apps/docs/content/docs/components.mdx create mode 100644 apps/docs/content/docs/guides/01-getting-started.mdx create mode 100644 apps/docs/content/docs/guides/02-transaction-toasts.mdx create mode 100644 apps/docs/content/docs/guides/03-wallet-ui.mdx create mode 100644 packages/components/.gitignore create mode 100644 packages/components/.storybook/main.ts create mode 100644 packages/components/.storybook/preview.ts create mode 100644 packages/components/.storybook/vitest.setup.ts create mode 100644 packages/components/README.md create mode 100644 packages/components/components.json create mode 100644 packages/components/index.html create mode 100644 packages/components/package.json create mode 100644 packages/components/registry.json create mode 100644 packages/components/src/App.tsx create mode 100644 packages/components/src/index.css create mode 100644 packages/components/src/kit-components/ui/address-display/AddressDisplay.test.tsx create mode 100644 packages/components/src/kit-components/ui/address-display/AddressDisplay.tsx create mode 100644 packages/components/src/kit-components/ui/address-display/index.ts create mode 100644 packages/components/src/kit-components/ui/balance-card/BalanceAmount.tsx create mode 100644 packages/components/src/kit-components/ui/balance-card/BalanceCard.test.tsx create mode 100644 packages/components/src/kit-components/ui/balance-card/BalanceCard.tsx create mode 100644 packages/components/src/kit-components/ui/balance-card/BalanceCardSkeleton.tsx create mode 100644 packages/components/src/kit-components/ui/balance-card/ErrorState.tsx create mode 100644 packages/components/src/kit-components/ui/balance-card/TokenList.tsx create mode 100644 packages/components/src/kit-components/ui/balance-card/assets/wallet-icon-dark.png create mode 100644 packages/components/src/kit-components/ui/balance-card/assets/wallet-icon-light.png create mode 100644 packages/components/src/kit-components/ui/balance-card/index.ts create mode 100644 packages/components/src/kit-components/ui/balance-card/types.ts create mode 100644 packages/components/src/kit-components/ui/balance-card/utils.ts create mode 100644 packages/components/src/kit-components/ui/connect-wallet-button/ButtonContent.tsx create mode 100644 packages/components/src/kit-components/ui/connect-wallet-button/ButtonIcon.tsx create mode 100644 packages/components/src/kit-components/ui/connect-wallet-button/ButtonSpinner.tsx create mode 100644 packages/components/src/kit-components/ui/connect-wallet-button/ChevronIcon.tsx create mode 100644 packages/components/src/kit-components/ui/connect-wallet-button/ConnectWalletButton.test.tsx create mode 100644 packages/components/src/kit-components/ui/connect-wallet-button/ConnectWalletButton.tsx create mode 100644 packages/components/src/kit-components/ui/connect-wallet-button/WalletButton.tsx create mode 100644 packages/components/src/kit-components/ui/connect-wallet-button/WalletDropdown.tsx create mode 100644 packages/components/src/kit-components/ui/connect-wallet-button/assets/backpack.png create mode 100644 packages/components/src/kit-components/ui/connect-wallet-button/assets/solflare.png create mode 100644 packages/components/src/kit-components/ui/connect-wallet-button/index.ts create mode 100644 packages/components/src/kit-components/ui/connect-wallet-button/types.ts create mode 100644 packages/components/src/kit-components/ui/dashboard-shell/DashboardShell.test.tsx create mode 100644 packages/components/src/kit-components/ui/dashboard-shell/DashboardShell.tsx create mode 100644 packages/components/src/kit-components/ui/dashboard-shell/index.ts create mode 100644 packages/components/src/kit-components/ui/index.ts create mode 100644 packages/components/src/kit-components/ui/network-switcher/NetworkDropdown.tsx create mode 100644 packages/components/src/kit-components/ui/network-switcher/NetworkHeader.tsx create mode 100644 packages/components/src/kit-components/ui/network-switcher/NetworkOption.tsx create mode 100644 packages/components/src/kit-components/ui/network-switcher/NetworkSwitcher.test.tsx create mode 100644 packages/components/src/kit-components/ui/network-switcher/NetworkSwitcher.tsx create mode 100644 packages/components/src/kit-components/ui/network-switcher/NetworkTrigger.tsx create mode 100644 packages/components/src/kit-components/ui/network-switcher/StatusIndicator.tsx create mode 100644 packages/components/src/kit-components/ui/network-switcher/index.ts create mode 100644 packages/components/src/kit-components/ui/network-switcher/types.ts create mode 100644 packages/components/src/kit-components/ui/skeleton/Skeleton.test.tsx create mode 100644 packages/components/src/kit-components/ui/skeleton/Skeleton.tsx create mode 100644 packages/components/src/kit-components/ui/skeleton/index.ts create mode 100644 packages/components/src/kit-components/ui/swap-input/SwapInput.tsx create mode 100644 packages/components/src/kit-components/ui/swap-input/SwapInputSkeleton.tsx create mode 100644 packages/components/src/kit-components/ui/swap-input/TokenInput.tsx create mode 100644 packages/components/src/kit-components/ui/swap-input/index.ts create mode 100644 packages/components/src/kit-components/ui/swap-input/types.ts create mode 100644 packages/components/src/kit-components/ui/swap-input/utils.ts create mode 100644 packages/components/src/kit-components/ui/transaction-table/FilterDropdown.tsx create mode 100644 packages/components/src/kit-components/ui/transaction-table/TransactionRow.tsx create mode 100644 packages/components/src/kit-components/ui/transaction-table/TransactionTable.tsx create mode 100644 packages/components/src/kit-components/ui/transaction-table/TransactionTableSkeleton.tsx create mode 100644 packages/components/src/kit-components/ui/transaction-table/assets/receive.png create mode 100644 packages/components/src/kit-components/ui/transaction-table/assets/sent.png create mode 100644 packages/components/src/kit-components/ui/transaction-table/assets/view-dark.png create mode 100644 packages/components/src/kit-components/ui/transaction-table/assets/view-light.png create mode 100644 packages/components/src/kit-components/ui/transaction-table/index.ts create mode 100644 packages/components/src/kit-components/ui/transaction-table/types.ts create mode 100644 packages/components/src/kit-components/ui/transaction-table/utils.ts create mode 100644 packages/components/src/kit-components/ui/transaction-toast/TransactionToast.test.tsx create mode 100644 packages/components/src/kit-components/ui/transaction-toast/TransactionToast.tsx create mode 100644 packages/components/src/kit-components/ui/transaction-toast/TransactionToastProvider.tsx create mode 100644 packages/components/src/kit-components/ui/transaction-toast/index.ts create mode 100644 packages/components/src/kit-components/ui/transaction-toast/useTransactionToast.ts create mode 100644 packages/components/src/kit-components/ui/wallet-modal/ConnectingView.tsx create mode 100644 packages/components/src/kit-components/ui/wallet-modal/ErrorView.tsx create mode 100644 packages/components/src/kit-components/ui/wallet-modal/ModalHeader.tsx create mode 100644 packages/components/src/kit-components/ui/wallet-modal/NoWalletLink.tsx create mode 100644 packages/components/src/kit-components/ui/wallet-modal/WalletCard.tsx create mode 100644 packages/components/src/kit-components/ui/wallet-modal/WalletLabel.tsx create mode 100644 packages/components/src/kit-components/ui/wallet-modal/WalletList.tsx create mode 100644 packages/components/src/kit-components/ui/wallet-modal/WalletModal.test.tsx create mode 100644 packages/components/src/kit-components/ui/wallet-modal/WalletModal.tsx create mode 100644 packages/components/src/kit-components/ui/wallet-modal/assets/backpack.png create mode 100644 packages/components/src/kit-components/ui/wallet-modal/assets/error-icon.svg create mode 100644 packages/components/src/kit-components/ui/wallet-modal/assets/phantom.png create mode 100644 packages/components/src/kit-components/ui/wallet-modal/assets/solflare.png create mode 100644 packages/components/src/kit-components/ui/wallet-modal/index.ts create mode 100644 packages/components/src/kit-components/ui/wallet-modal/types.ts create mode 100644 packages/components/src/lib/utils.ts create mode 100644 packages/components/src/main.tsx create mode 100644 packages/components/src/stories/AddressDisplay.stories.ts create mode 100644 packages/components/src/stories/BalanceCard.stories.tsx create mode 100644 packages/components/src/stories/ConnectWalletButton.stories.tsx create mode 100644 packages/components/src/stories/DashboardShell.stories.tsx create mode 100644 packages/components/src/stories/NetworkSwitcher.stories.tsx create mode 100644 packages/components/src/stories/Skeleton.stories.tsx create mode 100644 packages/components/src/stories/SwapInput.stories.tsx create mode 100644 packages/components/src/stories/TransactionTable.stories.tsx create mode 100644 packages/components/src/stories/TransactionToast.stories.tsx create mode 100644 packages/components/src/stories/WalletModal.stories.tsx create mode 100644 packages/components/tsconfig.app.json create mode 100644 packages/components/tsconfig.json create mode 100644 packages/components/tsconfig.node.json create mode 100644 packages/components/vite.config.ts create mode 100644 packages/components/vitest.shims.d.ts diff --git a/apps/docs/content/docs/components.mdx b/apps/docs/content/docs/components.mdx new file mode 100644 index 0000000..ff5f212 --- /dev/null +++ b/apps/docs/content/docs/components.mdx @@ -0,0 +1,914 @@ +--- +title: "UI Components" +description: Pre-built React components for Solana applications +--- + +Ready-to-use React components for displaying Solana data, transaction feedback, and loading states. Built with Tailwind CSS and fully themeable. + +## Installation + + + +```bash +npm install @solana/components +``` + + +```bash +pnpm add @solana/components +``` + + +```bash +yarn add @solana/components +``` + + +```bash +bun add @solana/components +``` + + + +## Setup + +### Tailwind CSS Configuration + +The components use Tailwind CSS v4. Add the package source to your CSS file: + +```css +@import "tailwindcss"; +@source "./node_modules/@solana/components/**/*.{ts,tsx}"; +``` + +## Display Components + +### AddressDisplay + +Displays a truncated Solana address with copy-to-clipboard and Explorer link: + +```tsx +import { AddressDisplay } from "@solana/components"; + +function WalletAddress({ address }) { + return ( + console.log("Copied!")} + /> + ); +} +``` + +The address is truncated to `6DMh...1DkK` format with a tooltip showing the full address on hover. + +**Props:** + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `address` | `Address` | required | Solana public key in base58 format | +| `theme` | `'light' \| 'dark'` | `'light'` | Color theme | +| `network` | `ClusterMoniker` | `'mainnet-beta'` | Network for Explorer URL | +| `showExplorerLink` | `boolean` | `true` | Show link to Solana Explorer | +| `showTooltip` | `boolean` | `true` | Show full address tooltip on hover | +| `onCopy` | `() => void` | - | Callback after address is copied | +| `className` | `string` | - | Additional CSS classes | + +## Wallet Components + +### ConnectWalletButton + +A fully composable wallet connection button with dropdown. Handles all connection states (disconnected, connecting, and connected) with an integrated wallet dropdown that includes address display, balance toggle, network switching, and disconnect. + +```tsx +import { ConnectWalletButton } from "@solana/components"; + +function App() { + const { status, wallet, currentConnector, disconnect, isReady } = useWalletConnection(); + const { lamports, fetching } = useBalance(wallet?.address); + + return ( + console.log("Switched to:", network)} + theme="dark" + /> + ); +} +``` + +The dropdown composes `AddressDisplay` for the wallet address and `NetworkSwitcher` sub-components for network selection, using `className` overrides to adapt them to the dropdown layout, just as any consumer would. + +**Props:** + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `status` | `'disconnected' \| 'connecting' \| 'connected' \| 'error'` | required | Current connection status | +| `isReady` | `boolean` | `true` | Whether the hook has hydrated (SSR) | +| `wallet` | `{ address: Address }` | - | Connected wallet session | +| `currentConnector` | `{ id: string; name: string; icon?: string }` | - | Current wallet connector info | +| `balance` | `Lamports` | - | Wallet balance in lamports | +| `balanceLoading` | `boolean` | `false` | Whether balance is still loading | +| `onConnect` | `() => void` | - | Callback when connect button is clicked | +| `onDisconnect` | `() => Promise \| void` | - | Callback to disconnect wallet. When omitted, the disconnect row is hidden. | +| `selectedNetwork` | `ClusterMoniker` | - | Currently selected network | +| `networkStatus` | `WalletStatus['status']` | - | Network connection status | +| `onNetworkChange` | `(network: ClusterMoniker) => void` | - | Callback when network is changed | +| `theme` | `'light' \| 'dark'` | `'dark'` | Color theme | +| `labels` | `{ connect?: string; connecting?: string; disconnect?: string }` | - | Custom labels | +| `className` | `string` | - | Additional CSS classes | + +### NetworkSwitcher + +A dropdown component for switching between Solana networks. Use standalone, or compose its sub-components (`NetworkTrigger`, `NetworkDropdown`, `NetworkHeader`, `NetworkOption`) for custom layouts. + +```tsx +import { NetworkSwitcher } from "@solana/components"; + +// Standalone usage + console.log("Switched to:", network)} + theme="dark" +/> + +// Controlled open state + +``` + +**Props:** + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `selectedNetwork` | `ClusterMoniker` | required | Currently selected network | +| `status` | `WalletStatus['status']` | `'connected'` | Connection status indicator | +| `onNetworkChange` | `(network: ClusterMoniker) => void` | - | Callback when network is selected | +| `open` | `boolean` | - | Controlled open state | +| `onOpenChange` | `(open: boolean) => void` | - | Callback when open state changes | +| `networks` | `Network[]` | `DEFAULT_NETWORKS` | List of available networks | +| `disabled` | `boolean` | `false` | Disable the switcher | +| `theme` | `'light' \| 'dark'` | `'dark'` | Color theme | +| `className` | `string` | - | Additional CSS classes | + +**Default Networks:** Mainnet Beta, Testnet, Localnet, Devnet. + +### BalanceCard + +Your users connected their wallet. Now show them what's in it. BalanceCard displays a wallet's total balance with an expandable breakdown of individual tokens. + +**What you get:** + +- Wallet address with copy functionality (uses `AddressDisplay` under the hood) +- Total balance in fiat (USD, EUR, etc.) or raw crypto units +- Expandable token list showing individual holdings +- Built-in loading skeleton and error states +- Three visual variants (`default`, `dark`, `light`) and three sizes (`sm`, `md`, `lg`) + +**Basic usage:** + +```tsx +import { BalanceCard } from "@solana/components"; + +function WalletOverview({ address, balance }) { + return ( + + ); +} +``` + +That gives you a dark card with the wallet address and a formatted USD balance. + +**Adding tokens:** + +Pass a `tokens` array and users can expand the list to see individual holdings: + +```tsx + +``` + +The token list starts collapsed by default. Use `defaultExpanded` to open it, or control it yourself with `isExpanded` and `onExpandedChange`. + +**Displaying crypto instead of fiat:** + +Not everything is denominated in dollars. For raw token balances: + +```tsx + +``` + +This displays the balance in SOL (9 decimals) with 4 decimal places shown. + +**Loading and error states:** + +Both are handled for you. Pass `isLoading` and the card renders a skeleton. Pass `error` and it shows an error message with an optional retry button: + +```tsx +// Loading + + +// Error with retry + refetch()} + variant="dark" +/> +``` + +**Props:** + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `walletAddress` | `Address` | - | Wallet address to display | +| `totalBalance` | `Lamports` | required | Total balance in lamports (bigint) | +| `tokenDecimals` | `number` | `9` | Decimals for the token (9 for SOL, 6 for USDC) | +| `isFiatBalance` | `boolean` | `true` | Display as fiat with currency symbol | +| `currency` | `string` | `'USD'` | Currency code for fiat display | +| `displayDecimals` | `number` | `2` | Decimal places to show | +| `tokens` | `TokenInfo[]` | `[]` | Tokens for the expandable list | +| `isLoading` | `boolean` | `false` | Show loading skeleton | +| `error` | `string \| Error` | - | Error message to display | +| `onRetry` | `() => void` | - | Callback for retry button in error state | +| `onCopyAddress` | `(address: Address) => void` | - | Callback when address is copied | +| `defaultExpanded` | `boolean` | `false` | Whether token list starts expanded | +| `isExpanded` | `boolean` | - | Controlled expanded state | +| `onExpandedChange` | `(expanded: boolean) => void` | - | Callback when expansion toggles | +| `variant` | `'default' \| 'dark' \| 'light'` | `'default'` | Visual variant | +| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Size variant | +| `className` | `string` | - | Additional CSS classes | +| `locale` | `string` | `'en-US'` | Locale for number formatting | + +**TokenInfo shape:** + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `symbol` | `string` | Yes | Token symbol (e.g., "USDC") | +| `name` | `string` | No | Token name (e.g., "USD Coin") | +| `balance` | `number \| string` | Yes | Token balance | +| `icon` | `string \| ReactNode` | No | Token icon URL or component | +| `fiatValue` | `number \| string` | No | Fiat value of the holding | +| `mintAddress` | `Address` | No | Token mint address | + +BalanceCard also exports its internal pieces as standalone components: `BalanceAmount` for formatted balance display, `TokenList` for the expandable token breakdown, `BalanceCardSkeleton` for loading states, and `ErrorState` for error messages with retry. + +### TransactionTable + +Your users want to see where their SOL went. TransactionTable displays a list of classified transactions with built-in filtering by date and type. + +**What you get:** + +- Table with columns for type (sent/received), time, counterparty address, and amount +- Built-in date filter (All time, 7 days, 30 days, 90 days) +- Built-in type filter (All, Sent, Received) +- Token icons and fiat values alongside token amounts +- Row action button for viewing transactions on Explorer +- Loading skeleton and empty states +- Light and dark themes, three sizes (`sm`, `md`, `lg`) + +**Basic usage:** + +```tsx +import { TransactionTable } from "@solana/components"; + +function RecentActivity({ transactions, walletAddress }) { + return ( + + ); +} +``` + +Pass your classified transactions and the current wallet address. The table handles filtering, formatting, and direction labels (sent vs received) based on the wallet address you provide. + +**Opening transactions in Explorer:** + +Add `onViewTransaction` and each row gets a view icon on hover: + +```tsx + { + window.open( + `https://explorer.solana.com/tx/${tx.tx.signature}`, + "_blank" + ); + }} + theme="dark" +/> +``` + +**Controlling filters externally:** + +The filters work out of the box with internal state, but you can control them yourself if you need to sync with URL params or other UI: + +```tsx +const [dateFilter, setDateFilter] = useState("all"); +const [typeFilter, setTypeFilter] = useState("all"); + + +``` + +**Custom row actions:** + +Need something other than the default view icon? Use `renderRowAction` to render your own: + +```tsx + ( + + )} + theme="light" +/> +``` + +**Loading and empty states:** + +Same pattern as BalanceCard. Pass `isLoading` for a skeleton, or let the empty state show when there are no transactions: + +```tsx +// Loading + +``` + +The empty state message defaults to "No transactions yet" but you can customize it with the `emptyMessage` prop. + +**Props:** + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `transactions` | `ReadonlyArray` | required | Classified transactions to display | +| `walletAddress` | `Address` | - | Wallet address for determining sent vs received | +| `isLoading` | `boolean` | `false` | Show loading skeleton | +| `theme` | `'light' \| 'dark'` | `'dark'` | Visual theme | +| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Row density | +| `dateFilter` | `'all' \| '7d' \| '30d' \| '90d'` | `'all'` | Controlled date filter | +| `onDateFilterChange` | `(value) => void` | - | Callback when date filter changes | +| `dateFilterOptions` | `FilterDropdownOption[]` | All time / 7d / 30d / 90d | Custom date filter options | +| `typeFilter` | `'all' \| 'sent' \| 'received'` | `'all'` | Controlled type filter | +| `onTypeFilterChange` | `(value) => void` | - | Callback when type filter changes | +| `typeFilterOptions` | `FilterDropdownOption[]` | All / Sent / Received | Custom type filter options | +| `emptyMessage` | `string` | `'No transactions yet'` | Message when no transactions match | +| `onViewTransaction` | `(tx: ClassifiedTransaction) => void` | - | Callback when row view icon is clicked | +| `renderRowAction` | `(tx: ClassifiedTransaction) => ReactNode` | - | Custom row action renderer (overrides view icon) | +| `className` | `string` | - | Additional CSS classes | +| `locale` | `string` | `'en-US'` | Locale for date and number formatting | + +Transactions use the `ClassifiedTransaction` type from the `tx-indexer` package, which provides structured data including sender, receiver, token amounts, and fiat values. + +TransactionTable also exports its internal pieces as standalone components: `TransactionRow` for rendering individual rows, `TransactionTableSkeleton` for custom loading layouts, and `FilterDropdown` for reusable filter controls. + +### WalletModal + +When a user clicks "Connect Wallet", they need to pick which wallet to use. WalletModal handles that selection flow, including the connecting and error states. + +**What you get:** + +- A wallet selection list with icons and status labels (Recent, Detected, Installed) +- A connecting view with animated spinner while the wallet approves +- An error view with retry when connection fails +- "I don't have a wallet" link pointing to Solana's wallet explorer +- Light and dark themes + +**Note:** WalletModal renders the panel content only, not a backdrop or overlay. Wrap it in your own modal system (a ``, a portal with overlay, or whatever your app uses). + +**Basic usage:** + +```tsx +import { WalletModal } from "@solana/components"; + +function WalletSelector({ wallets, onSelect, onClose }) { + return ( + + ); +} +``` + +That shows the wallet list. When a user taps a wallet, `onSelectWallet` fires with the wallet metadata. + +**Managing the connection flow:** + +The modal has three views you control with the `view` prop. Here's a realistic example wiring up the full flow from selection to connection to error handling: + +```tsx +import { WalletModal } from "@solana/components"; +import { useState } from "react"; + +function ConnectFlow({ wallets, connect, onClose }) { + const [view, setView] = useState("list"); + const [selectedWallet, setSelectedWallet] = useState(null); + const [error, setError] = useState(null); + + const handleSelect = async (wallet) => { + setSelectedWallet(wallet); + setView("connecting"); + setError(null); + + try { + await connect(wallet); + onClose(); + } catch (err) { + setError({ + title: "Connection failed", + message: err.message || "Unable to connect. Please try again.", + }); + setView("error"); + } + }; + + return ( + setView("list")} + onRetry={() => handleSelect(selectedWallet)} + onClose={onClose} + theme="dark" + /> + ); +} +``` + +The flow goes: user picks a wallet (`list`) → modal shows spinner (`connecting`) → connection succeeds and modal closes, or fails and shows retry (`error`). The back button returns to the wallet list. + +**Hiding the "no wallet" link:** + +The "I don't have a wallet" link shows by default and opens the Solana wallet explorer. You can hide it or point it somewhere else: + +```tsx +// Hide it + + +// Custom URL + +``` + +**Props:** + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `wallets` | `WalletConnectorMetadata[]` | required | Available wallets to display | +| `view` | `'list' \| 'connecting' \| 'error'` | `'list'` | Current modal view | +| `connectingWallet` | `WalletConnectorMetadata` | - | Wallet being connected (for connecting view) | +| `error` | `{ title?: string; message?: string }` | - | Error info (for error view) | +| `theme` | `'light' \| 'dark'` | `'dark'` | Visual theme | +| `onSelectWallet` | `(wallet: WalletConnectorMetadata) => void` | - | Callback when a wallet is selected | +| `onBack` | `() => void` | - | Callback for back button | +| `onClose` | `() => void` | - | Callback for close button | +| `onRetry` | `() => void` | - | Callback for retry button (error view) | +| `showNoWalletLink` | `boolean` | `true` | Show "I don't have a wallet" link | +| `walletGuideUrl` | `string` | Solana wallet explorer | Custom URL for wallet guide link | +| `className` | `string` | - | Additional CSS classes | + +Wallets use the `WalletConnectorMetadata` type from `@solana/client`, which provides the wallet's `id`, `name`, and `icon`. + +WalletModal also exports its internal pieces as standalone components: `WalletList` for the wallet selection list, `WalletCard` for individual wallet rows, `ConnectingView` for the loading state, `ErrorView` for the error state with retry, `ModalHeader` for the header with back/close buttons, `NoWalletLink` for the wallet guide link, and `WalletLabel` for status badges. + +## Feedback Components + +### TransactionToast + +Displays transaction status notifications with Explorer link: + +```tsx +import { TransactionToast } from "@solana/components"; + +function TransactionStatus({ signature, status }) { + return ( + + ); +} +``` + +**Props:** + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `signature` | `string` | required | Transaction signature | +| `status` | `'pending' \| 'success' \| 'error'` | required | Transaction status | +| `type` | `'sent' \| 'received' \| 'swapped'` | `'sent'` | Transaction type (affects message) | +| `theme` | `'light' \| 'dark'` | `'light'` | Color theme | +| `network` | `ClusterMoniker` | `'mainnet-beta'` | Network for Explorer URL | +| `className` | `string` | - | Additional CSS classes | + +**Status Messages:** + +| Type | Pending | Success | Error | +| --- | --- | --- | --- | +| `sent` | Transaction pending... | Transaction sent successfully | Transaction failed | +| `received` | Transaction pending... | Transaction received successfully | Transaction failed | +| `swapped` | Swap pending... | Swap completed successfully | Swap failed | + +### TransactionToastProvider + +For managing multiple toasts, wrap your app with the provider: + +```tsx +import { + TransactionToastProvider, + useTransactionToast, +} from "@solana/components"; + +function App() { + return ( + + + + ); +} + +function SendButton() { + const { toast, update, dismiss } = useTransactionToast(); + + const handleSend = async () => { + // Show pending toast + const id = toast({ + signature: "5UfDuX7h...", + status: "pending", + type: "sent", + }); + + try { + // Wait for confirmation... + await confirmTransaction(); + + // Update to success + update(id, { status: "success" }); + } catch (error) { + // Update to error + update(id, { status: "error" }); + } + }; + + return ; +} +``` + +**Provider Props:** + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `children` | `ReactNode` | required | Child components | +| `theme` | `'light' \| 'dark'` | `'light'` | Theme for all toasts | + +**Hook Return:** + +| Method | Description | +| --- | --- | +| `toast(data)` | Show a toast, returns ID | +| `update(id, data)` | Update an existing toast | +| `dismiss(id)` | Remove a toast | + +### Skeleton + +Loading placeholder that mimics content shape: + +```tsx +import { Skeleton } from "@solana/components"; + +function LoadingCard() { + return ( +
+ +
+ + +
+
+ ); +} +``` + +Use `className` to define dimensions and shape (e.g., `rounded-full` for avatars). + +**Props:** + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `theme` | `'light' \| 'dark'` | `'light'` | Color theme | +| `className` | `string` | - | Size and shape classes | + +## Layout Components + +### DashboardShell + +Every Solana dApp needs a consistent layout, a place for your logo, wallet button, and main content. DashboardShell handles this structure so you can focus on building features. + +**What you get:** + +- Full-screen container with proper background colors +- Header area (your logo on the left, wallet button on the right) +- Main content area that fills the remaining space +- Subtle dot grid pattern that matches modern dApp aesthetics +- Light and dark theme support + +**Basic usage:** + +```tsx +import { DashboardShell } from "@solana/components"; + +function App() { + return ( + +

Welcome to my dApp

+
+ ); +} +``` + +That's it. You now have a full-screen layout with the dot grid background. + +**Adding a header:** + +The `header` prop accepts any React content. It renders in a row with space-between alignment: + +```tsx + + My Wallet + + + } +> +

Your dashboard content here

+
+``` + +The header appears at the top. Your logo/title sits on the left, actions on the right. + +**Dark theme:** + +```tsx + + {/* Dark background (#18181b), lighter dot grid */} + +``` + +**Hiding the dot grid:** + +Some designs call for a solid background: + +```tsx + + {/* Clean, solid background */} + +``` + +**Props:** + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `theme` | `'light' \| 'dark'` | `'light'` | Background and dot grid colors | +| `header` | `ReactNode` | - | Content for the header area | +| `children` | `ReactNode` | required | Your main content | +| `showDotGrid` | `boolean` | `true` | Toggle the dot grid pattern | +| `className` | `string` | - | Additional CSS classes | + +**Real-world example with other components:** + +```tsx +import { DashboardShell, AddressDisplay, Skeleton } from "@solana/components"; + +function WalletDashboard({ address, isLoading }) { + return ( + + Solana Wallet + + + } + > + {isLoading ? ( +
+ + +
+ ) : ( +
+ {/* Your balance cards, transaction lists, etc. */} +
+ )} +
+ ); +} +``` + +## Theming + +All components support `light` and `dark` themes via the `theme` prop: + +```tsx +// Individual component + + +// Or via provider for toasts + + + +``` + +Components use the `zinc` color palette from Tailwind: +- Light: `bg-zinc-50`, `text-zinc-700` +- Dark: `bg-zinc-700`, `text-zinc-50` + +## Shadcn Compatibility + +Framework Kit components follow [shadcn/ui](https://ui.shadcn.com) patterns. If you're familiar with shadcn, you'll feel at home. + +### What This Means + +- **Radix UI primitives**: Components are built on [Radix](https://www.radix-ui.com/) for accessibility out of the box +- **Tailwind styling**: All styles use Tailwind utilities, no CSS files to import +- **`cn()` utility**: Class merging with [clsx](https://github.com/lukeed/clsx) + [tailwind-merge](https://github.com/dcastil/tailwind-merge) + +### The `cn()` Utility + +Components use `cn()` to merge classNames safely: + +```tsx +import { cn } from "@solana/components"; + +// Merges classes, handles conflicts automatically +
+``` + +### Customizing Components + +Override styles via `className`: + +```tsx +// Default styling + + +// Custom styling - your classes merge with defaults + +``` + +### Using with Existing Shadcn Projects + +Framework Kit components work alongside your existing shadcn setup: + +```tsx +import { Button } from "@/components/ui/button"; // your shadcn button +import { AddressDisplay } from "@solana/components"; // framework kit + +function WalletButton({ address }) { + return ( + + ); +} +``` + +No conflicts. Same patterns. They compose naturally. + +## Integration with React Hooks + +These components work seamlessly with `@solana/react-hooks`: + +```tsx +import { useWallet, useBalance } from "@solana/react-hooks"; +import { AddressDisplay, Skeleton } from "@solana/components"; + +function WalletCard() { + const { wallet, status } = useWallet(); + const { lamports, fetching } = useBalance(wallet?.account.address); + + if (status !== "connected") return null; + + return ( +
+ + {fetching ? ( + + ) : ( +

{(lamports / 1e9).toFixed(4)} SOL

+ )} +
+ ); +} +``` + +## Components Reference + +### Display Components + +| Component | Description | +| --- | --- | +| `AddressDisplay` | Truncated address with copy and Explorer link | + +### Wallet Components + +| Component | Description | +| --- | --- | +| `ConnectWalletButton` | Full wallet connection button with dropdown (address, balance, network, disconnect) | +| `NetworkSwitcher` | Network selection dropdown with composable sub-components | +| `BalanceCard` | Wallet balance display with expandable token list, loading, and error states | +| `TransactionTable` | Transaction history with date and type filtering, loading, and empty states | +| `WalletModal` | Wallet selection modal with connecting and error states | + +### Feedback Components + +| Component | Description | +| --- | --- | +| `TransactionToast` | Transaction status notification | +| `TransactionToastProvider` | Provider for managing multiple toasts | +| `Skeleton` | Loading placeholder | + +### Layout Components + +| Component | Description | +| --- | --- | +| `DashboardShell` | Full-screen layout with header, content area, and dot grid background | + +### Hooks + +| Hook | Description | +| --- | --- | +| `useTransactionToast` | Toast management within provider | diff --git a/apps/docs/content/docs/guides/01-getting-started.mdx b/apps/docs/content/docs/guides/01-getting-started.mdx new file mode 100644 index 0000000..f23f42e --- /dev/null +++ b/apps/docs/content/docs/guides/01-getting-started.mdx @@ -0,0 +1,299 @@ +--- +date: 2026-02-03T00:00:00Z +difficulty: beginner +title: "Getting Started with Framework Kit Components" +seoTitle: "Framework Kit Components for Solana - Quick Start Guide" +description: + "Add pre-built UI components to your Solana app in minutes. Loading states, + themed components, and copy-paste examples." +tags: + - react + - components + - ui + - solana +keywords: + - solana react components + - solana ui library + - framework kit tutorial + - solana loading states + - react solana components +--- + +Build a themed loading card for your Solana app in under 5 minutes. + +## What is Framework Kit? + +Framework Kit is a UI component library for Solana apps, built on the Solana Kit ecosystem. It gives you production-ready React components for common patterns — loading states, address displays, transaction notifications, so you don't build them from scratch. + +Built with React 19, TypeScript, Tailwind CSS v4, and Radix UI primitives. Shadcn-compatible, so you can copy, paste, and customize. + +**Components available:** + +| Component | Purpose | +|-----------|---------| +| Skeleton | Loading placeholders | +| AddressDisplay | Truncated addresses with copy and explorer link | +| TransactionToast | Transaction status notifications | +| ConnectWalletButton | Wallet connection with state management | +| NetworkSwitcher | Solana network selection dropdown | +| BalanceCard | Wallet balance display with token list | +| TransactionTable | Transaction history with filtering | +| WalletModal | Wallet selection modal | +| DashboardShell | Page layout with header and content slots | +| SwapInput | Token swap input with amount handling | + +## Prerequisites + +- React 19 project with TypeScript +- Tailwind CSS v4 configured +- `@solana/kit` and `@solana/client` installed (required for component types like `Address`, `Lamports`, `ClusterMoniker`) +- Basic React knowledge + +## Setup + + + + + + +Framework Kit is currently available via the monorepo. NPM package coming soon. + + +Clone the repository and install dependencies: + +```bash +git clone https://github.com/Kronos-Guild/framework-kit.git +cd framework-kit +pnpm install +``` + +If you're working within the monorepo, the components are at `packages/components`. + + + + + +Add the components path to your CSS file. Tailwind v4 uses CSS-based configuration: + +```css filename="globals.css" +@import "tailwindcss"; + +@source "./src/**/*.{ts,tsx}"; +@source "./node_modules/@solana/components/**/*.{ts,tsx}"; +``` + + + + + +## Your First Component: Skeleton + +Skeleton creates animated loading placeholders. No configuration required — just add dimensions. + +```tsx +import { Skeleton } from "@solana/components"; + +function LoadingBar() { + return ; +} +``` + +This renders an animated loading bar. The pulse animation runs automatically. + +### Sizing with Tailwind + +Use any Tailwind classes to control size and shape: + +```tsx +// Rectangular bar + + +// Circle (avatars) + + +// Full width + +``` + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `className` | `string` | — | Tailwind classes for size and shape | +| `theme` | `'light' \| 'dark'` | `'light'` | Color theme | + +## Theming + +All Framework Kit components support light and dark themes via the `theme` prop. + +```tsx +// Light theme (default) + + +// Dark theme + +``` + +Components use Tailwind's zinc palette: +- **Light:** `zinc-200` background +- **Dark:** `zinc-800` background + +## Build a Loading Card + +Compose multiple skeletons to match your content layout. Here's a loading state for a wallet card: + +```tsx filename="WalletCardSkeleton.tsx" +import { Skeleton } from "@solana/components"; + +function WalletCardSkeleton({ theme = "light" }: { theme?: "light" | "dark" }) { + return ( +
+ {/* Avatar */} + + +
+ {/* Address */} + + {/* Balance */} + +
+ + {/* Action button */} + +
+ ); +} +``` + +### Conditional Rendering + +Show the skeleton while data loads, then swap in real content: + +```tsx filename="WalletCard.tsx" +import { Skeleton } from "@solana/components"; + +interface WalletCardProps { + address: string; + balance: number; + isLoading: boolean; +} + +function WalletCard({ address, balance, isLoading }: WalletCardProps) { + if (isLoading) { + return ( +
+ +
+ + +
+
+ ); + } + + return ( +
+
+
+

+ {address.slice(0, 4)}...{address.slice(-4)} +

+

{balance} SOL

+
+
+ ); +} +``` + +## Complete Example + +A full component with theme toggle and simulated loading: + +```tsx filename="App.tsx" +import { useState, useEffect } from "react"; +import { Skeleton } from "@solana/components"; + +type Theme = "light" | "dark"; + +function App() { + const [theme, setTheme] = useState("light"); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const timer = setTimeout(() => setIsLoading(false), 2000); + return () => clearTimeout(timer); + }, []); + + const reload = () => { + setIsLoading(true); + setTimeout(() => setIsLoading(false), 2000); + }; + + return ( +
+
+ {/* Theme controls */} +
+ + + +
+ + {/* Content */} + {isLoading ? ( +
+ + + {[1, 2, 3].map((i) => ( +
+ +
+ + +
+
+ ))} +
+ ) : ( +
+

My Wallets

+

Your content loads here.

+
+ )} +
+
+ ); +} + +export default App; +``` + +Copy this into your project. Click "Reload" to see the loading states. Toggle between light and dark themes. + +## Next Steps + +You've got loading states covered. Next: + +- **[Building Transaction UIs with Framework Kit](/docs/guides/transaction-toasts)** — Display pending, success, and error states for Solana transactions using TransactionToast. + +- **[Building a Complete Wallet UI](/docs/guides/wallet-ui)** — Combine Skeleton, AddressDisplay, and TransactionToast into a production-ready wallet interface. + +For the full component API, check the source in the [Framework Kit repository](https://github.com/Kronos-Guild/framework-kit). diff --git a/apps/docs/content/docs/guides/02-transaction-toasts.mdx b/apps/docs/content/docs/guides/02-transaction-toasts.mdx new file mode 100644 index 0000000..76952fe --- /dev/null +++ b/apps/docs/content/docs/guides/02-transaction-toasts.mdx @@ -0,0 +1,378 @@ +--- +date: 2026-02-03T00:00:00Z +difficulty: beginner +title: "Building Transaction UIs with Framework Kit" +seoTitle: "Solana Transaction Feedback UI - Toast Notifications Guide" +description: + "Show users what's happening with their Solana transactions. Pending, success, + and error states with Explorer links." +tags: + - react + - components + - transactions + - solana +keywords: + - solana transaction toast + - transaction feedback ui + - solana ux + - solana transaction status +--- + +Show users what's happening with their transactions; pending, success, or failed, with one component. + +## Why Transaction Feedback Matters + +Your user clicks "Send SOL." Then... nothing. They stare at the screen. Did it work? Is it pending? They click again. Now you might have a double spend problem. + +Good transaction UX means three things: + +1. Show a pending state immediately +2. Confirm success or explain failure +3. Give users a way to verify (Explorer link) + +TransactionToast handles all of this. + +## What You Get + +- **Three states:** pending, success, error +- **Three types:** sent, received, swapped (each with appropriate messages) +- **Explorer link:** Users can click to verify on Solana Explorer +- **Auto-dismiss:** Success toasts disappear after 5 seconds; pending and error stay until dismissed +- **Theming:** Light and dark modes + +## Prerequisites + +- React 19 project with TypeScript +- Tailwind CSS v4 configured +- Framework Kit installed ([Guide 1](/docs/guides/getting-started) covers setup) + +## Setup: The Provider + +Wrap your app with `TransactionToastProvider`. This manages all toasts in your application. + + + + + +```tsx filename="App.tsx" +import { TransactionToastProvider } from "@solana/components"; + +function App() { + return ( + + + + ); +} +``` + +The `theme` prop sets the default theme for all toasts. You can use `"light"` or `"dark"`. + + + + + +## Your First Toast + +Use the `useTransactionToast` hook to trigger toasts from any component inside the provider. + +```tsx filename="SendButton.tsx" +import { useTransactionToast } from "@solana/components"; + +function SendButton() { + const { toast } = useTransactionToast(); + + const handleClick = () => { + toast({ + signature: "5xG7abc...9Kp2", + status: "success", + type: "sent", + network: "devnet", + }); + }; + + return ; +} +``` + +Click the button. A toast appears showing "Transaction sent successfully" with a link to Solana Explorer. + +### Hook Return Values + +| Method | Description | +|--------|-------------| +| `toast(data)` | Show a toast, returns an ID | +| `update(id, data)` | Update an existing toast | +| `dismiss(id)` | Remove a toast | + +## The Core Pattern: Pending → Success/Error + +Here's what you'll actually use in production. The pattern is: + +1. Show a pending toast when the transaction starts +2. Capture the toast ID +3. Update the same toast when the transaction confirms or fails + +```tsx filename="SendTransaction.tsx" +import { useTransactionToast } from "@solana/components"; + +function SendTransaction() { + const { toast, update } = useTransactionToast(); + + const handleSend = async () => { + // 1. Show pending toast, capture ID + const toastId = toast({ + signature: "5xG7abc...9Kp2", + status: "pending", + type: "sent", + network: "devnet", + }); + + try { + // 2. Wait for transaction confirmation + await simulateTransaction(); + + // 3a. Update to success + update(toastId, { status: "success" }); + } catch (error) { + // 3b. Update to error + update(toastId, { status: "error" }); + } + }; + + return ; +} + +// Simulates network delay +function simulateTransaction() { + return new Promise((resolve) => setTimeout(resolve, 2000)); +} +``` + + +The toast ID is the key. It lets you update the same toast instead of creating new ones. Your users see one toast that changes state, not multiple toasts appearing. + + +## The Explorer Link + +Every toast includes a "View" link to Solana Explorer. Users can click to verify their transaction on-chain. + +The link is generated automatically using the `signature` and `network` props: + +``` +https://explorer.solana.com/tx/{signature}?cluster={network} +``` + + +The `network` prop matters. If you're developing on devnet but pass `"mainnet-beta"`, the Explorer link will point to the wrong cluster and show "Transaction not found." + + +**Common networks:** +- `"devnet"` — for development +- `"testnet"` — for testing +- `"mainnet-beta"` — for production + +## Transaction Types + +The `type` prop changes the toast message. Choose based on what the user did: + +| Type | Pending | Success | Error | +|------|---------|---------|-------| +| `sent` | Transaction pending... | Transaction sent successfully | Transaction failed | +| `received` | Transaction pending... | Transaction received successfully | Transaction failed | +| `swapped` | Swap pending... | Swap completed successfully | Swap failed | + +```tsx +// User sent SOL +toast({ signature, status: "pending", type: "sent", network }); + +// User received SOL +toast({ signature, status: "pending", type: "received", network }); + +// User swapped tokens +toast({ signature, status: "pending", type: "swapped", network }); +``` + +## Theming + +Set the theme on the provider. All toasts inherit it. + +```tsx +// Light theme (default) + + +// Dark theme + +``` + +Toasts use the zinc color palette: +- **Light:** `zinc-50` background, `zinc-900` text +- **Dark:** `zinc-800` background, `zinc-50` text + +For more on theming, see [Guide 1](/docs/guides/getting-started#theming). + +## Where Does the Signature Come From? + +When you send a transaction on Solana, you get back a signature — a unique string identifying that transaction. That's what you pass to the toast. + +```tsx +import { useSolTransfer } from "@solana/react-hooks"; + +function SendSol() { + const { toast, update } = useTransactionToast(); + const { send } = useSolTransfer(); + + const handleSend = async () => { + // Send transaction, get signature back + const signature = await send({ + destination: "J4AJ...MAAP", + amount: 1_000_000_000, // 1 SOL in lamports + }); + + // Show pending toast with real signature + const toastId = toast({ + signature, + status: "pending", + type: "sent", + network: "devnet", + }); + + // Wait for confirmation, then update + // (in practice, you'd listen for confirmation) + update(toastId, { status: "success" }); + }; + + return ; +} +``` + +This connects the component to real Solana transactions. Guide 3 covers the full integration pattern. + +## Complete Example + +Here's a full working example with multiple transaction simulations: + +```tsx filename="App.tsx" +import { useState } from "react"; +import { + TransactionToastProvider, + useTransactionToast, +} from "@solana/components"; + +type Theme = "light" | "dark"; + +function App() { + const [theme, setTheme] = useState("light"); + + return ( + +
+
+
+ + +
+ + +
+
+
+ ); +} + +function TransactionButtons() { + const { toast, update } = useTransactionToast(); + + // Generates a fake signature for demo purposes + const fakeSignature = () => + Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15); + + const simulateSuccess = async () => { + const toastId = toast({ + signature: fakeSignature(), + status: "pending", + type: "sent", + network: "devnet", + }); + + await new Promise((r) => setTimeout(r, 2000)); + update(toastId, { status: "success" }); + }; + + const simulateError = async () => { + const toastId = toast({ + signature: fakeSignature(), + status: "pending", + type: "sent", + network: "devnet", + }); + + await new Promise((r) => setTimeout(r, 2000)); + update(toastId, { status: "error" }); + }; + + const simulateSwap = async () => { + const toastId = toast({ + signature: fakeSignature(), + status: "pending", + type: "swapped", + network: "devnet", + }); + + await new Promise((r) => setTimeout(r, 3000)); + update(toastId, { status: "success" }); + }; + + return ( +
+ + + +
+ ); +} + +export default App; +``` + +Copy this into your project. Click the buttons to see pending → success/error transitions. Toggle themes. Click "View" to open Solana Explorer. + +## Next Steps + +You've got transaction feedback covered. Next: + +- **[Building a Complete Wallet UI](/docs/guides/wallet-ui)** — Combine Skeleton, AddressDisplay, TransactionToast, and more into a production-ready wallet interface. + +For the full component API, check the [Framework Kit repository](https://github.com/Kronos-Guild/framework-kit). diff --git a/apps/docs/content/docs/guides/03-wallet-ui.mdx b/apps/docs/content/docs/guides/03-wallet-ui.mdx new file mode 100644 index 0000000..4452e6b --- /dev/null +++ b/apps/docs/content/docs/guides/03-wallet-ui.mdx @@ -0,0 +1,573 @@ +--- +date: 2026-02-06T00:00:00Z +difficulty: intermediate +title: "The First 60 Seconds: Building a Complete Wallet UI" +seoTitle: "Complete Solana Wallet UI - Connect, Display Balance, Switch Networks" +description: + "Build the complete wallet connection experience. From Connect Wallet button + to showing balance, copying addresses, and switching networks." +tags: + - react + - components + - wallet + - ui + - solana +keywords: + - solana wallet connect + - solana wallet ui + - connect wallet button react + - solana balance display + - network switcher solana +--- + +Your users judge your dApp in the first 10 seconds. + +Before they see your killer feature, before they experience your protocol, they see a button that says "Connect Wallet." What happens next determines whether they stay or leave. + +This guide builds the complete first-impression experience. + +## What You'll Build + +By the end of this guide, you'll have: + +- A "Connect Wallet" button that opens a wallet selection modal +- Wallet modal with multiple provider options (Phantom, Solflare, Backpack) +- Connected state showing truncated address with copy functionality +- Balance card displaying SOL and token balances +- Network switcher for mainnet/devnet/testnet +- Proper loading, error, and empty states throughout + +## Prerequisites + +- Completed [Guide 1: Getting Started](/docs/guides/getting-started) or equivalent setup +- React 19 + TypeScript + Tailwind CSS v4 +- Familiarity with Solana wallets (you've used one before) + +## The Components + +| Component | Purpose | Key Props | +|-----------|---------|-----------| +| `ConnectWalletButton` | Entry point, shows "Connect" or connected state | `status`, `wallet`, `onConnect`, `onDisconnect` | +| `WalletModal` | Wallet selection dialog | `wallets`, `view`, `onSelectWallet`, `onClose` | +| `BalanceCard` | Display balance + token list | `totalBalance`, `tokens`, `walletAddress` | +| `NetworkSwitcher` | Switch between networks | `selectedNetwork`, `onNetworkChange` | +| `AddressDisplay` | Truncated address + copy + explorer | `address`, `network` | + +We'll build these incrementally, starting with the connect button. + +## Step 1: The Connect Button + +The `ConnectWalletButton` is your entry point. It handles three states: disconnected, connecting, and connected. + +### Disconnected State + +```tsx filename="WalletButton.tsx" +import { ConnectWalletButton } from '@solana/components'; + +function WalletButton() { + return ( + console.log('Open wallet modal')} + /> + ); +} +``` + +This renders a button with "Connect Wallet" text. When clicked, it fires `onConnect`, you'll wire this to open the wallet modal. + +### Connecting State + +```tsx + +``` + +The button disables and shows a loading indicator. Users know something is happening. + +### Connected State + +```tsx filename="WalletButton.tsx" +import { ConnectWalletButton } from '@solana/components'; +import { address, lamports } from '@solana/kit'; + +function WalletButton() { + const wallet = { address: address('6DMh7fYHrKdCJwCFUQfMfNAdLADi9xqsRKNzmZA31DkK') }; + const connector = { id: 'phantom', name: 'Phantom', icon: '/phantom.svg' }; + const balance = lamports(2_500_000_000n); // 2.5 SOL + + return ( + console.log('Disconnect')} + /> + ); +} +``` + +When connected, the button shows the wallet icon and truncated address. Click it to open a dropdown with balance info and a disconnect option. + + +The button manages its own dropdown state. You control the connection status; it handles the rest. + + +### Full Example with State + +```tsx filename="WalletButton.tsx" +import { ConnectWalletButton } from '@solana/components'; +import { type Address } from '@solana/kit'; +import { useState } from 'react'; + +type Status = 'disconnected' | 'connecting' | 'connected' | 'error'; + +function WalletButton({ onOpenModal }: { onOpenModal: () => void }) { + const [status, setStatus] = useState('disconnected'); + const [wallet, setWallet] = useState<{ address: Address } | null>(null); + + const handleConnect = () => { + onOpenModal(); + }; + + const handleDisconnect = async () => { + setStatus('disconnected'); + setWallet(null); + }; + + return ( + + ); +} +``` + +## Step 2: The Wallet Modal + +When users click "Connect Wallet," they need to choose which wallet to use. The `WalletModal` handles this with three views: list, connecting, and error. + +### List View + +```tsx filename="WalletConnect.tsx" +import { WalletModal } from '@solana/components'; +import type { WalletConnectorMetadata } from '@solana/client'; + +const wallets: WalletConnectorMetadata[] = [ + { id: 'phantom', name: 'Phantom', icon: 'https://phantom.app/icon.png' }, + { id: 'solflare', name: 'Solflare', icon: 'https://solflare.com/icon.png' }, + { id: 'backpack', name: 'Backpack', icon: 'https://backpack.app/icon.png' }, +]; + +function WalletConnect({ onClose }: { onClose: () => void }) { + const handleSelect = (wallet: WalletConnectorMetadata) => { + console.log('Selected:', wallet.name); + // Start connection flow + }; + + return ( + + ); +} +``` + +Each wallet shows as a clickable row with icon and name. The "I don't have a wallet" link appears at the bottom by default. + +### Connecting View + +```tsx + setView('list')} + onClose={onClose} +/> +``` + +Shows which wallet you're connecting to with a loading indicator. The back button returns to the list. + +### Error View + +```tsx + setView('connecting')} + onClose={onClose} +/> +``` + + +Always handle the error state. Users will click the wrong wallet, decline the connection, or have extension issues. Make recovery obvious. + + +### Modal Props Reference + +| Prop | Type | Description | +|------|------|-------------| +| `wallets` | `WalletConnectorMetadata[]` | Available wallets to display | +| `view` | `'list' \| 'connecting' \| 'error'` | Current modal view | +| `connectingWallet` | `WalletConnectorMetadata` | Wallet being connected (for connecting view) | +| `error` | `{ title?: string; message?: string }` | Error info (for error view) | +| `onSelectWallet` | `(wallet) => void` | Fires when wallet is selected | +| `onBack` | `() => void` | Fires when back button is clicked | +| `onClose` | `() => void` | Fires when close button is clicked | +| `onRetry` | `() => void` | Fires when retry button is clicked | +| `showNoWalletLink` | `boolean` | Show "I don't have a wallet" (default: true) | +| `theme` | `'light' \| 'dark'` | Color theme | + +## Step 3: Showing the Balance + +Once connected, users want to see their balance. The `BalanceCard` handles this with support for loading states, errors, and token lists. + +### Basic Usage + +```tsx filename="WalletDashboard.tsx" +import { BalanceCard } from '@solana/components'; +import { address, lamports } from '@solana/kit'; + +function WalletDashboard() { + const walletAddress = address('6DMh7fYHrKdCJwCFUQfMfNAdLADi9xqsRKNzmZA31DkK'); + const balance = lamports(34_810_000_000n); // ~34.81 SOL + + return ( + + ); +} +``` + +This displays the wallet address (truncated, with copy button) and the balance converted from lamports. + +### Fiat Display + +```tsx + +``` + +When `isFiatBalance` is true, the balance shows with a currency symbol ($34.81 instead of 34.81 SOL). + +### With Token List + +```tsx filename="WalletDashboard.tsx" +const tokens = [ + { symbol: 'USDC', balance: 150.50, fiatValue: 150.50 }, + { symbol: 'USDT', balance: 75.25, fiatValue: 75.25 }, + { symbol: 'BONK', balance: 1_000_000, fiatValue: 12.50 }, +]; + + +``` + +The token list is collapsible. Click "View all tokens" to expand. Empty state shows "No tokens yet." + +### Loading State + +```tsx + +``` + +Shows an animated skeleton while data loads. + +### Error State + +```tsx + refetchBalance()} +/> +``` + +Shows the error message with a "Try again" button. + +### BalanceCard Props Reference + +| Prop | Type | Description | +|------|------|-------------| +| `walletAddress` | `Address` | Wallet address to display | +| `totalBalance` | `Lamports` | Balance in lamports (bigint) | +| `tokens` | `TokenInfo[]` | Token list for expandable section | +| `isFiatBalance` | `boolean` | Display as fiat with currency symbol | +| `currency` | `string` | Currency code (default: "USD") | +| `isLoading` | `boolean` | Show skeleton loading state | +| `error` | `string \| Error` | Error message to display | +| `onRetry` | `() => void` | Callback for retry button | +| `onCopyAddress` | `(address) => void` | Callback when address is copied | +| `variant` | `'default' \| 'dark' \| 'light'` | Color variant | +| `size` | `'sm' \| 'md' \| 'lg'` | Size variant | + +## Step 4: Network Switching + +Let users switch between mainnet, devnet, and testnet with `NetworkSwitcher`. + +### Basic Usage + +```tsx filename="NetworkControl.tsx" +import { NetworkSwitcher } from '@solana/components'; +import type { ClusterMoniker } from '@solana/client'; +import { useState } from 'react'; + +function NetworkControl() { + const [network, setNetwork] = useState('mainnet-beta'); + + return ( + + ); +} +``` + +Click the trigger to open a dropdown. Select a network and it closes automatically. + +### Custom Networks + +```tsx +const networks = [ + { id: 'mainnet-beta', label: 'Mainnet' }, + { id: 'devnet', label: 'Devnet' }, + // Omit testnet if you don't need it +]; + + +``` + +### Controlled Mode + +```tsx +const [open, setOpen] = useState(false); + + { + setNetwork(n); + setOpen(false); + }} +/> +``` + +Use `open` and `onOpenChange` when you need external control over the dropdown. + +## Putting It Together + +Here's a complete wallet UI that combines all components: + +```tsx filename="CompleteWalletUI.tsx" +import { + ConnectWalletButton, + WalletModal, + BalanceCard, + NetworkSwitcher, +} from '@solana/components'; +import { address, lamports, type Address } from '@solana/kit'; +import type { ClusterMoniker } from '@solana/client'; +import { useState } from 'react'; + +type ConnectionStatus = 'disconnected' | 'connecting' | 'connected'; +type ModalView = 'list' | 'connecting' | 'error'; + +const WALLETS = [ + { id: 'phantom', name: 'Phantom', icon: 'https://phantom.app/icon.png' }, + { id: 'solflare', name: 'Solflare', icon: 'https://solflare.com/icon.png' }, +]; + +export function CompleteWalletUI() { + // Connection state + const [status, setStatus] = useState('disconnected'); + const [wallet, setWallet] = useState<{ address: Address } | null>(null); + const [connector, setConnector] = useState(null); + + // Modal state + const [modalOpen, setModalOpen] = useState(false); + const [modalView, setModalView] = useState('list'); + const [connectingWallet, setConnectingWallet] = useState(null); + const [error, setError] = useState<{ title: string; message: string } | null>(null); + + // Network state + const [network, setNetwork] = useState('devnet'); + + // Mock balance (in real app, fetch from RPC) + const balance = lamports(2_500_000_000n); + + const handleSelectWallet = async (selected: typeof WALLETS[0]) => { + setConnectingWallet(selected); + setModalView('connecting'); + setStatus('connecting'); + + try { + // Simulate connection delay + await new Promise((r) => setTimeout(r, 1500)); + + // Success + setWallet({ address: address('6DMh7fYHrKdCJwCFUQfMfNAdLADi9xqsRKNzmZA31DkK') }); + setConnector(selected); + setStatus('connected'); + setModalOpen(false); + setModalView('list'); + } catch (err) { + setError({ title: 'Connection Failed', message: 'User rejected the request' }); + setModalView('error'); + setStatus('disconnected'); + } + }; + + const handleDisconnect = () => { + setWallet(null); + setConnector(null); + setStatus('disconnected'); + }; + + return ( +
+ {/* Header */} +
+

My dApp

+
+ + setModalOpen(true)} + onDisconnect={handleDisconnect} + theme="dark" + /> +
+
+ + {/* Main Content */} +
+ {status === 'connected' && wallet ? ( + + ) : ( +
+ Connect your wallet to get started +
+ )} +
+ + {/* Modal Overlay */} + {modalOpen && ( +
+ setModalView('list')} + onClose={() => { + setModalOpen(false); + setModalView('list'); + if (status === 'connecting') setStatus('disconnected'); + }} + onRetry={() => connectingWallet && handleSelectWallet(connectingWallet)} + theme="dark" + /> +
+ )} +
+ ); +} +``` + +This gives you a complete, working wallet UI. Users can: +1. Click "Connect Wallet" to open the modal +2. Select their wallet provider +3. See the connecting state while the wallet extension responds +4. View their balance and tokens once connected +5. Switch networks +6. Disconnect when done + +## Production Considerations + +### SSR and Hydration + +If you're using Next.js or another SSR framework, wallet state isn't available on the server. Use the `isReady` prop: + +```tsx + +``` + +When `isReady` is false, the button shows "Connect Wallet" and is disabled, preventing hydration mismatches. + +### Accessibility + +All components include proper ARIA attributes out of the box: +- Modal has `role="dialog"` and `aria-modal="true"` +- Buttons have `aria-expanded` and `aria-haspopup` where appropriate +- Escape key closes dropdowns and modals + +### Error Recovery + +Handle these common scenarios: +- **Wallet not installed**: Show a link to install (the modal's "I don't have a wallet" helps here) +- **User rejected**: Display the error view with a clear retry path +- **Network timeout**: Show error with retry button +- **Wrong network**: The NetworkSwitcher lets users fix this themselves + +## Next Steps + +You've built the foundation. Your users can now connect their wallet, see their balance, and switch networks, all with proper loading states and error handling. + +Everything else you build sits on top of this experience. + +**Next: [Guide 4: Sending Your First Transaction](/docs/guides/sending-transactions)** (coming soon) + +**Resources:** +- [Component API Reference](/docs/components) +- [Source code on GitHub](https://github.com/Kronos-Guild/framework-kit) diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index 56cbb5b..932c67f 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -1,4 +1,4 @@ { "title": "Documentation", - "pages": ["index", "getting-started", "client", "react-hooks", "web3-compat", "api-reference"] + "pages": ["index", "getting-started", "client", "react-hooks", "components", "web3-compat", "api-reference"] } diff --git a/packages/components/.gitignore b/packages/components/.gitignore new file mode 100644 index 0000000..f52343a --- /dev/null +++ b/packages/components/.gitignore @@ -0,0 +1,27 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +*storybook.log +storybook-static diff --git a/packages/components/.storybook/main.ts b/packages/components/.storybook/main.ts new file mode 100644 index 0000000..d6988b1 --- /dev/null +++ b/packages/components/.storybook/main.ts @@ -0,0 +1,25 @@ +import type { StorybookConfig } from '@storybook/react-vite'; + +import { dirname } from 'path'; + +import { fileURLToPath } from 'url'; + +/** + * This function is used to resolve the absolute path of a package. + * It is needed in projects that use Yarn PnP or are set up within a monorepo. + */ +function getAbsolutePath(value: string): string { + return dirname(fileURLToPath(import.meta.resolve(`${value}/package.json`))); +} +const config: StorybookConfig = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: [ + getAbsolutePath('@chromatic-com/storybook'), + getAbsolutePath('@storybook/addon-vitest'), + getAbsolutePath('@storybook/addon-a11y'), + getAbsolutePath('@storybook/addon-docs'), + getAbsolutePath('@storybook/addon-onboarding'), + ], + framework: getAbsolutePath('@storybook/react-vite'), +}; +export default config; diff --git a/packages/components/.storybook/preview.ts b/packages/components/.storybook/preview.ts new file mode 100644 index 0000000..0525519 --- /dev/null +++ b/packages/components/.storybook/preview.ts @@ -0,0 +1,22 @@ +import type { Preview } from '@storybook/react-vite'; +import '../src/index.css'; // Import your TailwindCSS file + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + + a11y: { + // 'todo' - show a11y violations in the test UI only + // 'error' - fail CI on a11y violations + // 'off' - skip a11y checks entirely + test: 'todo', + }, + }, +}; + +export default preview; diff --git a/packages/components/.storybook/vitest.setup.ts b/packages/components/.storybook/vitest.setup.ts new file mode 100644 index 0000000..ea170b0 --- /dev/null +++ b/packages/components/.storybook/vitest.setup.ts @@ -0,0 +1,7 @@ +import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview'; +import { setProjectAnnotations } from '@storybook/react-vite'; +import * as projectAnnotations from './preview'; + +// This is an important step to apply the right configuration when testing your stories. +// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations +setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]); diff --git a/packages/components/README.md b/packages/components/README.md new file mode 100644 index 0000000..e958287 --- /dev/null +++ b/packages/components/README.md @@ -0,0 +1,606 @@ +# Framework Kit Components + +Headless-friendly, theme-aware UI components for Solana dApps. Built with React 19, Tailwind CSS v4, and the Solana Web3.js v2 type system (`@solana/kit`, `@solana/client`). + +All components are purely presentational — they accept data and callbacks as props and own zero chain logic. Your app provides wallet connection, balance fetching, transaction signing, and price quoting through hooks and services; these components render the result. + +## Quick start + +```tsx +import { + DashboardShell, + ConnectWalletButton, + NetworkSwitcher, + BalanceCard, + SwapInput, + TransactionTable, + WalletModal, + TransactionToastProvider, +} from './kit-components/ui'; +``` + +Every component lives under `src/kit-components/ui//` and is re-exported from the barrel `src/kit-components/ui/index.ts`. + +## Theming + +Components use CSS custom properties mapped through Tailwind's `@theme inline` block in `src/index.css`. Override any token on an ancestor element to re-theme an entire subtree with zero code changes. + +### Core tokens + +| Token | Tailwind class | Purpose | +|---|---|---| +| `--background` | `bg-background` | Page / shell background | +| `--foreground` | `text-foreground` | Default body text | +| `--card` / `--card-foreground` | `bg-card`, `text-card-foreground` | Card surfaces | +| `--secondary` | `bg-secondary` | Subtle surfaces (skeleton, triggers) | +| `--muted` / `--muted-foreground` | `bg-muted`, `text-muted-foreground` | De-emphasized UI | +| `--accent` / `--accent-foreground` | `bg-accent` | Hover / active highlights | +| `--primary` / `--primary-foreground` | `bg-primary` | Primary buttons | +| `--destructive` | `text-destructive` | Errors | +| `--success` / `--success-foreground` | `text-success`, `bg-success` | Success states | +| `--warning` / `--warning-foreground` | `text-warning` | Warnings | +| `--border` | `border-border` | All borders | +| `--ring` | `ring-ring` | Focus rings | + +Dark mode activates via the `.dark` class on any ancestor (custom variant `&:is(.dark *)`). + +### Custom theme example + +```css +.my-theme { + --background: oklch(0.15 0.02 280); + --primary: oklch(0.65 0.25 300); + --card: oklch(0.2 0.02 280); + /* ... override any token */ +} +``` + +```tsx +
+ + {/* All children pick up the overridden tokens */} +
+``` + +--- + +## Components + +### DashboardShell + +Full-page layout wrapper with a header slot, main content area, and optional dot-grid background pattern. + +```tsx + + + +
+ } +> + + +``` + +| Prop | Type | Default | Description | +|---|---|---|---| +| `header` | `ReactNode` | — | Slot for nav, wallet buttons, etc. Renders inside `
` | +| `children` | `ReactNode` | — | Main content. Renders inside `
` | +| `showDotGrid` | `boolean` | `true` | Radial-gradient dot pattern background | +| `rounded` | `boolean` | `true` | Applies `rounded-3xl` to the shell | +| `headerClassName` | `string` | — | Extra classes on the `
` | +| `contentClassName` | `string` | — | Extra classes on `
` | + +Both `
` and `
` use `relative` positioning without `z-index`, so dropdown menus inside the header can layer above main content naturally. + +--- + +### ConnectWalletButton + +Wallet connection button with three visual states (disconnected, connecting, connected) and an integrated dropdown for the connected wallet. + +```tsx +const { status, wallet, isReady, disconnect, currentConnector } = useWalletConnection(); +const { lamports } = useBalance(wallet?.address); + + +``` + +| Prop | Type | Default | Description | +|---|---|---|---| +| `status` | `'disconnected' \| 'connecting' \| 'connected' \| 'error'` | **required** | Current connection status | +| `isReady` | `boolean` | `true` | SSR hydration guard — shows disconnected until `true` | +| `wallet` | `{ address: Address; publicKey?: ... }` | — | Connected wallet session | +| `currentConnector` | `{ id: string; name: string; icon?: string }` | — | Wallet adapter metadata | +| `balance` | `Lamports` | — | Wallet balance in lamports (bigint) | +| `balanceLoading` | `boolean` | `false` | Show loading indicator for balance | +| `onConnect` | `() => void` | — | Called when button is clicked in disconnected state | +| `onDisconnect` | `() => Promise \| void` | — | Called from the dropdown disconnect action | +| `labels` | `{ connect?, connecting?, disconnect? }` | — | Override button text | +| `selectedNetwork` | `ClusterMoniker` | — | For the embedded network trigger | +| `networkStatus` | `WalletStatus['status']` | — | Network connection status | +| `onNetworkChange` | `(network: ClusterMoniker) => void` | — | Network switch handler | + +The dropdown closes on outside click and Escape. It includes balance display (with a visibility toggle), address display, an embedded network trigger, and a disconnect button. + +--- + +### NetworkSwitcher + +Dropdown for switching between Solana clusters. Supports both controlled and uncontrolled open state. + +```tsx + { + const resolved = resolveCluster({ moniker: network }); + actions.setCluster(resolved.endpoint); + }} +/> +``` + +| Prop | Type | Default | Description | +|---|---|---|---| +| `selectedNetwork` | `ClusterMoniker` | **required** | Currently active network | +| `status` | `WalletStatus['status']` | `'connected'` | Status indicator (green dot / spinner / red dot) | +| `onNetworkChange` | `(network: ClusterMoniker) => void` | — | Fired when a network is selected | +| `open` | `boolean` | — | Controlled open state | +| `onOpenChange` | `(open: boolean) => void` | — | Open state change handler | +| `networks` | `Network[]` | `DEFAULT_NETWORKS` | Available networks | +| `disabled` | `boolean` | `false` | Disable the trigger | + +Default networks: Mainnet, Testnet, Localnet, Devnet. The trigger button displays the selected network name and a status indicator. + +**Sub-components** (all exported): `NetworkTrigger`, `NetworkDropdown`, `NetworkOption`, `NetworkHeader`, `StatusIndicator`. + +--- + +### BalanceCard + +Displays a wallet balance with optional token list, loading skeleton, and error state. + +```tsx + +``` + +| Prop | Type | Default | Description | +|---|---|---|---| +| `totalBalance` | `Lamports` | **required** | Balance as lamports bigint | +| `tokenSymbol` | `string` | — | Symbol shown after balance (e.g. `"SOL"` renders `"4.50 SOL"`) | +| `isFiatBalance` | `boolean` | `false` | When `true`, formats as fiat (e.g. `"$4.50"`) | +| `tokenDecimals` | `number` | `9` | Decimals for the balance token | +| `displayDecimals` | `number` | `2` | Number of decimal places to show | +| `currency` | `string` | `'USD'` | Currency code for fiat formatting | +| `tokens` | `TokenInfo[]` | `[]` | Expandable token list | +| `walletAddress` | `Address` | — | For the copy-address action | +| `isLoading` | `boolean` | `false` | Shows `BalanceCardSkeleton` | +| `error` | `string \| Error` | — | Shows `ErrorState` with retry button | +| `onRetry` | `() => void` | — | Retry callback for error state | +| `defaultExpanded` | `boolean` | `false` | Initial expanded state for token list | +| `isExpanded` | `boolean` | — | Controlled expanded state | +| `onExpandedChange` | `(expanded: boolean) => void` | — | Expansion change handler | +| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Size variant | +| `locale` | `string` | `'en-US'` | Number formatting locale | + +**Important**: `totalBalance` is always a `Lamports` bigint. The component converts it internally. When `isFiatBalance` is `false` (default), the balance displays as a plain number with the optional `tokenSymbol` appended. Set `isFiatBalance={true}` only if you've already converted to fiat. + +**Sub-components**: `BalanceAmount`, `BalanceCardSkeleton`, `ErrorState`, `TokenList`. + +**Exported utilities**: `formatBalance`, `formatFiatValue`, `copyToClipboard`, `stringToColor`, `formatPercentageChange`, `truncateAddress`. + +--- + +### SwapInput + +Two-card swap widget with a pay input, receive input, and swap-direction button. Handles insufficient balance validation automatically. + +```tsx +const [payAmount, setPayAmount] = useState(''); +const [receiveAmount, setReceiveAmount] = useState(''); +const [payToken, setPayToken] = useState(SOL_TOKEN); +const [receiveToken, setReceiveToken] = useState(USDC_TOKEN); + + +``` + +| Prop | Type | Default | Description | +|---|---|---|---| +| `payAmount` | `string` | **required** | Controlled pay amount | +| `onPayAmountChange` | `(value: string) => void` | — | Pay amount change handler | +| `receiveAmount` | `string` | **required** | Controlled receive amount | +| `onReceiveAmountChange` | `(value: string) => void` | — | Receive amount change handler | +| `payToken` | `SwapTokenInfo` | — | Selected pay token | +| `payTokens` | `SwapTokenInfo[]` | — | Available pay tokens (enables dropdown) | +| `onPayTokenChange` | `(token: SwapTokenInfo) => void` | — | Pay token change handler | +| `receiveToken` | `SwapTokenInfo` | — | Selected receive token | +| `receiveTokens` | `SwapTokenInfo[]` | — | Available receive tokens | +| `onReceiveTokenChange` | `(token: SwapTokenInfo) => void` | — | Receive token change handler | +| `onSwapDirection` | `() => void` | — | Swap direction button handler | +| `payBalance` | `string` | — | User's balance for pay token (display string) | +| `receiveReadOnly` | `boolean` | `true` | Lock the receive input (computed externally) | +| `isLoading` | `boolean` | `false` | Shows `SwapInputSkeleton` | +| `isSwapping` | `boolean` | `false` | Disables swap button during execution | +| `disabled` | `boolean` | `false` | Disables all interactions | +| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Size variant | + +#### Expected integration: Jupiter API + +The `SwapInput` component is display-only — it renders amounts and tokens but does **not** fetch prices or execute swaps. For a fully functional swap UI, you need to wire it to a price quoting and swap execution service. The expected integration is the **Jupiter API** (or similar exchange aggregator). + +**What the component expects from your integration layer:** + +1. **Price quotes** — When the user types a `payAmount` or changes tokens, your app should call the Jupiter Quote API to get the `receiveAmount`. Set this on the `receiveAmount` prop (the receive side is `readOnly` by default for this reason). + +2. **Token list** — Pass the available tokens as `SwapTokenInfo[]` to `payTokens` and `receiveTokens`. You can source these from Jupiter's token list API or your own registry. Each token needs at minimum: `symbol`, and optionally `name`, `logoURI`, `mintAddress`, `decimals`. + +3. **Swap execution** — When the user confirms, your app calls the Jupiter Swap API, signs the transaction, and sends it. Use `isSwapping={true}` while the transaction is in flight to disable interactions. Pair with `TransactionToast` to show progress. + +4. **Balance** — Pass the user's balance for the selected pay token as a display string to `payBalance`. The component auto-validates and shows "Insufficient balance" when `payAmount > payBalance`. + +**Example integration pattern:** + +```tsx +// Your hook or effect that calls Jupiter +useEffect(() => { + if (!payAmount || !payToken || !receiveToken) return; + const quote = await jupiterApi.getQuote({ + inputMint: payToken.mintAddress, + outputMint: receiveToken.mintAddress, + amount: parseFloat(payAmount) * 10 ** payToken.decimals, + }); + setReceiveAmount(String(quote.outAmount / 10 ** receiveToken.decimals)); +}, [payAmount, payToken, receiveToken]); +``` + +**Sub-components**: `TokenInput` (can be used standalone for send/stake flows), `SwapInputSkeleton`. + +**Exported utilities**: `sanitizeAmountInput`, `isInsufficientBalance`. + +--- + +### TransactionTable + +Filterable table of classified transactions with date and type filters. + +```tsx + window.open(`https://explorer.solana.com/tx/${tx.tx.signature}`)} +/> +``` + +| Prop | Type | Default | Description | +|---|---|---|---| +| `transactions` | `ReadonlyArray` | **required** | From `tx-indexer` | +| `walletAddress` | `Address` | — | For sent/received classification | +| `isLoading` | `boolean` | `false` | Shows `TransactionTableSkeleton` | +| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Size variant | +| `dateFilter` | `'all' \| '7d' \| '30d' \| '90d'` | — | Controlled date filter | +| `onDateFilterChange` | `(value) => void` | — | Date filter handler | +| `typeFilter` | `'all' \| 'sent' \| 'received'` | — | Controlled type filter | +| `onTypeFilterChange` | `(value) => void` | — | Type filter handler | +| `emptyMessage` | `string` | `'No transactions yet'` | Empty state text | +| `onViewTransaction` | `(tx) => void` | — | Adds a view action per row | +| `renderRowAction` | `(tx) => ReactNode` | — | Custom row action (overrides view icon) | +| `locale` | `string` | `'en-US'` | Date/number formatting locale | + +**Transaction data**: This component expects `ClassifiedTransaction` objects from the `tx-indexer` package. Each transaction includes a `classification` with `primaryType`, `primaryAmount` (token + amount), `sender`, `receiver`, and `counterparty` fields. The component derives direction (sent/received/other) from the transaction legs and wallet address. + +**Sub-components**: `TransactionRow`, `TransactionTableSkeleton`, `FilterDropdown`. + +**Exported utilities**: `getTransactionDirection`, `getCounterpartyAddress`, `getPrimaryAmount`, `formatTxDate`, `formatTokenAmount`, `formatFiatAmount`. + +--- + +### TransactionToast + +Toast notifications for transaction lifecycle (pending, success, error). Built on Radix Toast. + +```tsx +// 1. Wrap your app with the provider + + + + +// 2. Use the hook anywhere inside +const { toast, update, dismiss } = useTransactionToast(); + +// 3. Fire-and-update pattern +const id = toast({ + signature: txSignature, + status: 'pending', + type: 'sent', + network: 'mainnet-beta', +}); + +// Later, when confirmed: +update(id, { status: 'success' }); +``` + +| Toast data | Type | Default | Description | +|---|---|---|---| +| `signature` | `string` | **required** | Solana transaction signature | +| `status` | `'pending' \| 'success' \| 'error'` | **required** | Transaction state | +| `type` | `'sent' \| 'received' \| 'swapped'` | `'sent'` | Determines message text | +| `network` | `ClusterMoniker` | `'mainnet-beta'` | For explorer link | + +Auto-dismiss: `pending` = never, `success` = 5s, `error` = never. Each toast includes a link to the Solana explorer. + +**Static rendering**: `TransactionToast` can be rendered directly (without the provider) for static previews. + +--- + +### WalletModal + +Multi-view modal for wallet selection, connection progress, and error recovery. Fully controlled — the caller owns all state. + +```tsx +const [view, setView] = useState<'list' | 'connecting' | 'error'>('list'); +const [connectingWallet, setConnectingWallet] = useState(null); +const [error, setError] = useState(null); + +const handleSelect = async (wallet) => { + setConnectingWallet(wallet); + setView('connecting'); + try { + await connect(wallet.id); + closeModal(); + } catch (err) { + setView('error'); + setError({ title: 'Connection failed', message: err.message }); + } +}; + +{isOpen && ( +
+ setView('list')} + onClose={closeModal} + onRetry={() => connectingWallet && handleSelect(connectingWallet)} + /> +
+)} +``` + +| Prop | Type | Default | Description | +|---|---|---|---| +| `wallets` | `WalletConnectorMetadata[]` | **required** | Available wallets | +| `view` | `'list' \| 'connecting' \| 'error'` | `'list'` | Current modal view | +| `connectingWallet` | `WalletConnectorMetadata` | — | Wallet being connected (for connecting view) | +| `error` | `{ title?: string; message?: string }` | — | Error info (for error view) | +| `onSelectWallet` | `(wallet) => void` | — | Wallet selection handler | +| `onBack` | `() => void` | — | Back button handler | +| `onClose` | `() => void` | — | Close button handler | +| `onRetry` | `() => void` | — | Retry button handler | +| `showNoWalletLink` | `boolean` | `true` | Show "I don't have a wallet" link | +| `walletGuideUrl` | `string` | Solana ecosystem wallets page | URL for the "no wallet" link | + +**Important**: This component does **not** render its own overlay or portal. You must provide the backdrop and positioning (as shown above). This gives you full control over animation, z-index, and dismissal behavior. + +**Sub-components**: `ConnectingView`, `ErrorView`, `ModalHeader`, `WalletList`, `WalletCard`, `WalletLabel`, `NoWalletLink`. + +--- + +### AddressDisplay + +Truncated wallet address with copy-to-clipboard and Solana Explorer link. + +```tsx + console.log('Copied!')} +/> +``` + +| Prop | Type | Default | Description | +|---|---|---|---| +| `address` | `Address` | **required** | Solana address (base58) | +| `onCopy` | `() => void` | — | Called after successful clipboard copy | +| `showExplorerLink` | `boolean` | `true` | Show external link to Solana Explorer | +| `showTooltip` | `boolean` | `true` | Hover tooltip with full address | +| `network` | `ClusterMoniker` | `'mainnet-beta'` | Explorer URL cluster param | + +--- + +### Skeleton + +Low-level building block for loading states. All other skeleton components compose this. + +```tsx + {/* Text line */} + {/* Avatar */} + {/* Card */} +``` + +Renders a `
` with `animate-pulse rounded-md bg-muted`. Pass any `className` to control size and shape. + +--- + +## Customization + +Components support three levels of customization, from lightest touch to full control. + +### CSS token overrides + +Override CSS custom properties on any ancestor element to re-theme an entire subtree. See the [Theming](#theming) section above for the full token table. + +```css +.my-brand { + --primary: oklch(0.65 0.25 160); + --card: oklch(0.18 0.02 160); +} +``` + +```tsx +
+ +
+``` + +### className overrides + +Every component accepts a `className` prop. Classes are merged with [tailwind-merge](https://github.com/dcastil/tailwind-merge), so your overrides always win over defaults — no `!important` needed. + +```tsx +{/* Sharp corners */} + + +{/* Extra rounded with shadow */} + + +{/* Pill-shaped wallet button */} + + Connect + + +{/* Custom skeleton color */} + +``` + +### Sub-component composition + +Each composite component exports its building blocks so you can assemble custom layouts without the parent wrapper. + +#### BalanceCard exports + +| Export | Description | +|---|---| +| `BalanceCard` | Full card with header, balance, token list | +| `BalanceAmount` | Formatted balance display (bigint → human-readable) | +| `TokenList` | Expandable token list with icons and fiat values | +| `BalanceCardSkeleton` | Loading skeleton | +| `ErrorState` | Error with retry button | + +#### ConnectWalletButton exports + +| Export | Description | +|---|---| +| `ConnectWalletButton` | Full button with dropdown | +| `WalletButton` | Styled button (no dropdown logic) | +| `ButtonIcon` | Wallet icon renderer | +| `ButtonContent` | Label text wrapper | +| `ButtonSpinner` | Loading spinner | +| `WalletDropdown` | Connected-state dropdown | + +#### NetworkSwitcher exports + +| Export | Description | +|---|---| +| `NetworkSwitcher` | Full dropdown switcher | +| `NetworkTrigger` | Trigger button with status dot | +| `StatusIndicator` | Connection status dot (green/spinner/red) | +| `NetworkDropdown` | Dropdown panel | +| `NetworkOption` | Single network row | + +#### SwapInput exports + +| Export | Description | +|---|---| +| `SwapInput` | Two-card swap widget | +| `TokenInput` | Single token input card (usable standalone for send/stake flows) | +| `SwapInputSkeleton` | Loading skeleton | + +#### Composition example + +Build a custom portfolio card using sub-components directly: + +```tsx +import { BalanceAmount, TokenList } from './kit-components/ui/balance-card'; +import { WalletButton, ButtonIcon } from './kit-components/ui/connect-wallet-button'; +import { NetworkTrigger, StatusIndicator } from './kit-components/ui/network-switcher'; +import { TokenInput } from './kit-components/ui/swap-input'; + +{/* Custom portfolio card */} +
+ + +
+ +{/* Standalone wallet trigger */} + + Launch Wallet + + +{/* Standalone send input */} + +``` + +--- + +## Solana types reference + +These types come from the Solana packages and appear throughout the component APIs: + +| Type | Package | Description | +|---|---|---| +| `Address` | `@solana/kit` | Base58-encoded Solana address (branded string) | +| `Lamports` | `@solana/kit` | SOL balance in lamports (branded bigint, 1 SOL = 1e9) | +| `ClusterMoniker` | `@solana/client` | `'mainnet-beta' \| 'testnet' \| 'devnet' \| 'localnet'` | +| `WalletConnectorMetadata` | `@solana/client` | `{ id, name, icon?, ready }` — wallet adapter info | +| `WalletStatus` | `@solana/client` | `{ status: 'connected' \| 'connecting' \| 'error' }` | +| `ClassifiedTransaction` | `tx-indexer` | Parsed and classified transaction with legs, amounts, and counterparty | + +--- + +## Development + +```bash +pnpm dev # Vite dev server +pnpm storybook # Storybook on :6006 +pnpm build # TypeScript check + Vite build +pnpm lint # Biome linter +pnpm format # Biome formatter +``` + +Tests run with Vitest: + +```bash +npx vitest run # All tests +npx vitest run --reporter=verbose # Verbose output +``` + +Visual integration tests live in `tests/e2e-visual/` and exercise all components in a real DashboardShell layout with mock and live chain data. diff --git a/packages/components/components.json b/packages/components/components.json new file mode 100644 index 0000000..9102640 --- /dev/null +++ b/packages/components/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/kit-components", + "utils": "@/kit-components/lib/utils", + "ui": "@/kit-components/ui", + "lib": "@/kit-components/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/packages/components/index.html b/packages/components/index.html new file mode 100644 index 0000000..f513ff5 --- /dev/null +++ b/packages/components/index.html @@ -0,0 +1,14 @@ + + + + + + + + components + + +
+ + + diff --git a/packages/components/package.json b/packages/components/package.json new file mode 100644 index 0000000..1a3fdd6 --- /dev/null +++ b/packages/components/package.json @@ -0,0 +1,59 @@ +{ + "name": "components", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build", + "build:shadcn": "pnpm shadcn build", + "format": "biome check --write src", + "lint": "biome check src" + }, + "dependencies": { + "@radix-ui/react-toast": "^1.2.15", + "@solana/client": "^1.7.0", + "@solana/kit": "catalog:solana", + "@solana/react-hooks": "^1.4.1", + "@tailwindcss/vite": "^4.1.18", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.562.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.1.18", + "tx-indexer": "^1.5.0" + }, + "devDependencies": { + "@chromatic-com/storybook": "^4.1.3", + "@eslint/js": "^9.39.1", + "@storybook/addon-a11y": "^10.1.11", + "@storybook/addon-docs": "^10.1.11", + "@storybook/addon-onboarding": "^10.1.11", + "@storybook/addon-vitest": "^10.1.11", + "@storybook/react": "^10.1.11", + "@storybook/react-vite": "^10.1.11", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@types/storybook__react": "^5.2.1", + "@vitejs/plugin-react": "^5.1.1", + "@vitest/browser-playwright": "^4.0.7", + "@vitest/coverage-v8": "^4.0.7", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "eslint-plugin-storybook": "^10.1.11", + "globals": "^16.5.0", + "playwright": "^1.57.0", + "storybook": "^10.1.11", + "tw-animate-css": "^1.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4" + } +} diff --git a/packages/components/registry.json b/packages/components/registry.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/packages/components/registry.json @@ -0,0 +1 @@ +[] diff --git a/packages/components/src/App.tsx b/packages/components/src/App.tsx new file mode 100644 index 0000000..e69de29 diff --git a/packages/components/src/index.css b/packages/components/src/index.css new file mode 100644 index 0000000..ebfcb01 --- /dev/null +++ b/packages/components/src/index.css @@ -0,0 +1,135 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.21 0.006 285.885); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --success: oklch(0.59 0.2 145.023); + --success-foreground: oklch(0.985 0 0); + --warning: oklch(0.768 0.189 70.08); + --warning-foreground: oklch(0.21 0.006 285.885); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.705 0.015 286.067); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.21 0.006 285.885); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.705 0.015 286.067); +} + +.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.92 0.004 286.32); + --primary-foreground: oklch(0.21 0.006 285.885); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --success: oklch(0.696 0.17 162.48); + --success-foreground: oklch(0.985 0 0); + --warning: oklch(0.828 0.189 84.429); + --warning-foreground: oklch(0.985 0 0); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.552 0.016 285.938); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.552 0.016 285.938); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/packages/components/src/kit-components/ui/address-display/AddressDisplay.test.tsx b/packages/components/src/kit-components/ui/address-display/AddressDisplay.test.tsx new file mode 100644 index 0000000..bb04f4b --- /dev/null +++ b/packages/components/src/kit-components/ui/address-display/AddressDisplay.test.tsx @@ -0,0 +1,157 @@ +// @vitest-environment jsdom + +import { address } from '@solana/kit'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '@testing-library/jest-dom/vitest'; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +import { AddressDisplay, getExplorerUrl, truncateAddress } from './AddressDisplay'; + +/** + * Tests for truncateAddress utility function + */ +describe('truncateAddress', () => { + it('truncates a normal Solana address', () => { + const address = 'Hb6dzd4pYxmFYKkJDWuhzBEUkkaE93sFcvXYtriTkmw9'; + expect(truncateAddress(address)).toBe('Hb6d...kmw9'); + }); + + it('returns original string if 8 characters or less', () => { + expect(truncateAddress('12345678')).toBe('12345678'); + expect(truncateAddress('abc')).toBe('abc'); + }); + + it('handles empty string', () => { + expect(truncateAddress('')).toBe(''); + }); + + it('truncates string with exactly 9 characters', () => { + expect(truncateAddress('123456789')).toBe('1234...6789'); + }); +}); + +/** + * Tests for getExplorerUrl utility function + */ +describe('getExplorerUrl', () => { + const testAddress = 'Hb6dzd4pYxmFYKkJDWuhzBEUkkaE93sFcvXYtriTkmw9'; + + it('builds mainnet URL without cluster param', () => { + const url = getExplorerUrl(testAddress, 'mainnet-beta'); + expect(url).toBe(`https://explorer.solana.com/address/${testAddress}`); + expect(url).not.toContain('cluster'); + }); + + it('builds devnet URL with cluster param', () => { + const url = getExplorerUrl(testAddress, 'devnet'); + expect(url).toBe(`https://explorer.solana.com/address/${testAddress}?cluster=devnet`); + }); + + it('builds testnet URL with cluster param', () => { + const url = getExplorerUrl(testAddress, 'testnet'); + expect(url).toBe(`https://explorer.solana.com/address/${testAddress}?cluster=testnet`); + }); +}); + +/** + * Tests for AddressDisplay component + */ +describe('AddressDisplay', () => { + const testAddressString = 'Hb6dzd4pYxmFYKkJDWuhzBEUkkaE93sFcvXYtriTkmw9'; + const testAddress = address(testAddressString); + + it('renders truncated address', () => { + render(); + expect(screen.getByText('Hb6d...kmw9')).toBeInTheDocument(); + }); + + it('renders full address in tooltip by default', () => { + render(); + expect(screen.getByText(testAddressString)).toBeInTheDocument(); + }); + + it('hides tooltip when showTooltip is false', () => { + render(); + // The full address tooltip text should not be in the DOM + expect(screen.queryByText(testAddressString)).not.toBeInTheDocument(); + }); + + it('renders copy button with accessible label', () => { + render(); + expect(screen.getByRole('button', { name: /copy address/i })).toBeInTheDocument(); + }); + + it('renders explorer link when showExplorerLink is true (default)', () => { + render(); + const link = screen.getByRole('link', { name: /view on solana explorer/i }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', `https://explorer.solana.com/address/${testAddressString}`); + }); + + it('hides explorer link when showExplorerLink is false', () => { + render(); + expect(screen.queryByRole('link', { name: /view on solana explorer/i })).not.toBeInTheDocument(); + }); + + it('uses correct explorer URL for devnet', () => { + render(); + const link = screen.getByRole('link', { name: /view on solana explorer/i }); + expect(link).toHaveAttribute('href', `https://explorer.solana.com/address/${testAddressString}?cluster=devnet`); + }); + + it('applies custom className', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('custom-class'); + }); + + describe('semantic token styles', () => { + it('applies bg-card semantic token on the chip', () => { + const { container } = render(); + const chip = container.querySelector('span > span'); + expect(chip).toHaveClass('bg-card'); + }); + }); + + describe('copy functionality', () => { + it('calls onCopy callback when copy button is clicked', async () => { + // Mock clipboard API + const mockWriteText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { + ...navigator, + clipboard: { writeText: mockWriteText }, + }); + + const onCopy = vi.fn(); + render(); + + const copyButton = screen.getByRole('button', { name: /copy address/i }); + fireEvent.click(copyButton); + + await vi.waitFor(() => { + expect(onCopy).toHaveBeenCalledTimes(1); + }); + }); + + it('copies address to clipboard when copy button is clicked', async () => { + const mockWriteText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { + ...navigator, + clipboard: { writeText: mockWriteText }, + }); + + render(); + + const copyButton = screen.getByRole('button', { name: /copy address/i }); + fireEvent.click(copyButton); + + await vi.waitFor(() => { + expect(mockWriteText).toHaveBeenCalledWith(testAddressString); + }); + }); + }); +}); diff --git a/packages/components/src/kit-components/ui/address-display/AddressDisplay.tsx b/packages/components/src/kit-components/ui/address-display/AddressDisplay.tsx new file mode 100644 index 0000000..6ef3382 --- /dev/null +++ b/packages/components/src/kit-components/ui/address-display/AddressDisplay.tsx @@ -0,0 +1,106 @@ +import type { ClusterMoniker } from '@solana/client'; +import type { Address } from '@solana/kit'; +import { Check, Copy, ExternalLink } from 'lucide-react'; +import type React from 'react'; +import { useState } from 'react'; +import { cn } from '@/lib/utils'; + +export interface AddressDisplayProps extends Omit, 'onCopy'> { + /** Solana public key in base58 format */ + address: Address; + /** Callback fired after address is copied to clipboard */ + onCopy?: () => void; + /** Show link to Solana Explorer (default: true) */ + showExplorerLink?: boolean; + /** Show full address tooltip on hover (default: true) */ + showTooltip?: boolean; + /** Solana network for Explorer URL (default: "mainnet-beta") */ + network?: ClusterMoniker; + /** Additional CSS classes to apply to the container */ + className?: string; +} + +/** Truncates a Solana address to format: "6DMh...1DkK" */ +export function truncateAddress(address: string): string { + if (address.length <= 8) return address; + return `${address.slice(0, 4)}...${address.slice(-4)}`; +} + +/** Builds Solana Explorer URL for the given address and network */ +export function getExplorerUrl(address: string, network: string): string { + const base = 'https://explorer.solana.com'; + const isMainnet = network === 'mainnet-beta' || network === 'mainnet'; + const cluster = isMainnet ? '' : `?cluster=${network}`; + return `${base}/address/${address}${cluster}`; +} + +export const AddressDisplay: React.FC = ({ + address, + onCopy, + showExplorerLink = true, + showTooltip = true, + network = 'mainnet-beta', + className, + ...props +}) => { + const [copied, setCopied] = useState(false); + const truncated = truncateAddress(address); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(address); + setCopied(true); + onCopy?.(); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy address:', err); + } + }; + + return ( + + {/* Chip */} + + {/* Address text with tooltip - hover only on address */} + + {truncated} + {/* Tooltip */} + {showTooltip && ( + + {address} + + )} + + + {showExplorerLink && ( + + + + )} + + + ); +}; diff --git a/packages/components/src/kit-components/ui/address-display/index.ts b/packages/components/src/kit-components/ui/address-display/index.ts new file mode 100644 index 0000000..a8d6429 --- /dev/null +++ b/packages/components/src/kit-components/ui/address-display/index.ts @@ -0,0 +1,2 @@ +export type { AddressDisplayProps } from './AddressDisplay'; +export { AddressDisplay } from './AddressDisplay'; diff --git a/packages/components/src/kit-components/ui/balance-card/BalanceAmount.tsx b/packages/components/src/kit-components/ui/balance-card/BalanceAmount.tsx new file mode 100644 index 0000000..6506ba4 --- /dev/null +++ b/packages/components/src/kit-components/ui/balance-card/BalanceAmount.tsx @@ -0,0 +1,40 @@ +import type React from 'react'; +import { cn } from '@/lib/utils'; +import type { BalanceAmountProps } from './types'; +import { formatBalance, formatFiatValue } from './utils'; + +/** + * Displays a formatted balance amount with optional fiat formatting + */ +export const BalanceAmount: React.FC = ({ + balance, + tokenDecimals = 9, + isFiat = false, + currency = 'USD', + displayDecimals = 2, + locale = 'en-US', + isPrivate = false, + size = 'md', + className = '', + tokenSymbol, +}) => { + const sizeStyles = { + sm: 'text-xl font-semibold', + md: 'text-2xl font-bold', + lg: 'text-4xl font-bold', + }[size]; + + const formattedBalance = isPrivate + ? '••••••' + : isFiat + ? formatFiatValue(balance, currency, locale, tokenDecimals) + : tokenSymbol + ? `${formatBalance(balance, { tokenDecimals, displayDecimals, locale })} ${tokenSymbol}` + : formatBalance(balance, { tokenDecimals, displayDecimals, locale }); + + return ( +
+ {formattedBalance} +
+ ); +}; diff --git a/packages/components/src/kit-components/ui/balance-card/BalanceCard.test.tsx b/packages/components/src/kit-components/ui/balance-card/BalanceCard.test.tsx new file mode 100644 index 0000000..0b37738 --- /dev/null +++ b/packages/components/src/kit-components/ui/balance-card/BalanceCard.test.tsx @@ -0,0 +1,299 @@ +// @vitest-environment jsdom + +import { address, lamports } from '@solana/kit'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '@testing-library/jest-dom/vitest'; + +import { BalanceCard } from './BalanceCard'; + +afterEach(() => { + cleanup(); +}); + +const testAddress = address('6DMh7fYHrKdCJwCFUQfMfNAdLADi9xqsRKNzmZA31DkK'); +const testBalance = lamports(34_810_000_000n); // ~34.81 when formatted + +const sampleTokens = [ + { symbol: 'USDC', balance: 15.5, fiatValue: 15.5 }, + { symbol: 'USDT', balance: 10.18, fiatValue: 10.18 }, +]; + +describe('BalanceCard', () => { + describe('rendering', () => { + it('renders without crashing with required props', () => { + render(); + expect(screen.getByText('Total balance')).toBeInTheDocument(); + }); + + it('renders as a section element with aria-label', () => { + render(); + expect(screen.getByRole('region')).toHaveAttribute('aria-label', expect.stringContaining('Wallet balance')); + }); + + it('displays wallet address in truncated format', () => { + render(); + expect(screen.getByText('6DMh...1DkK')).toBeInTheDocument(); + }); + + it('displays total balance label', () => { + render(); + expect(screen.getByText('Total balance')).toBeInTheDocument(); + }); + }); + + describe('balance formatting', () => { + it('shows fiat format with currency symbol when isFiatBalance is true', () => { + render(); + // Balance should include $ symbol + expect(screen.getByText(/\$/)).toBeInTheDocument(); + }); + + it('shows crypto format without currency symbol when isFiatBalance is false', () => { + render(); + // Should not have currency symbol + expect(screen.queryByText(/\$/)).not.toBeInTheDocument(); + }); + + it('shows token symbol after balance when tokenSymbol is provided', () => { + render(); + expect(screen.getByText(/SOL/)).toBeInTheDocument(); + expect(screen.queryByText(/\$/)).not.toBeInTheDocument(); + }); + + it('does not show token symbol when isFiatBalance is true even if tokenSymbol is set', () => { + render(); + expect(screen.getByText(/\$/)).toBeInTheDocument(); + expect(screen.queryByText(/SOL/)).not.toBeInTheDocument(); + }); + }); + + describe('loading state', () => { + it('renders skeleton when isLoading is true', () => { + const { container } = render(); + // Skeleton should be present, not the balance label + expect(screen.queryByText('Total balance')).not.toBeInTheDocument(); + // Check for skeleton elements (animated divs) + expect(container.querySelector('.animate-pulse')).toBeInTheDocument(); + }); + }); + + describe('error state', () => { + it('displays error message when error prop is a string', () => { + render(); + expect(screen.getByText('Failed to load balance')).toBeInTheDocument(); + }); + + it('displays error message when error prop is an Error object', () => { + render(); + expect(screen.getByText('Network error')).toBeInTheDocument(); + }); + + it('calls onRetry when retry button is clicked', () => { + const onRetry = vi.fn(); + render(); + + const retryButton = screen.getByRole('button', { name: /try again/i }); + fireEvent.click(retryButton); + + expect(onRetry).toHaveBeenCalledTimes(1); + }); + }); + + describe('token list', () => { + it('renders token list section', () => { + render(); + expect(screen.getByText('View all tokens')).toBeInTheDocument(); + }); + + it('token list is collapsed by default', () => { + render(); + // Token symbols should not be visible when collapsed + const button = screen.getByRole('button', { name: /view all tokens/i }); + expect(button).toHaveAttribute('aria-expanded', 'false'); + }); + + it('token list is expanded when defaultExpanded is true', () => { + render(); + const button = screen.getByRole('button', { name: /view all tokens/i }); + expect(button).toHaveAttribute('aria-expanded', 'true'); + }); + + it('expands token list when toggle is clicked', () => { + render(); + + const button = screen.getByRole('button', { name: /view all tokens/i }); + expect(button).toHaveAttribute('aria-expanded', 'false'); + + fireEvent.click(button); + + expect(button).toHaveAttribute('aria-expanded', 'true'); + }); + + it('shows "No tokens yet" when tokens array is empty and expanded', () => { + render(); + expect(screen.getByText('No tokens yet')).toBeInTheDocument(); + }); + + it('renders all tokens when expanded', () => { + render(); + expect(screen.getByText('USDC')).toBeInTheDocument(); + expect(screen.getByText('USDT')).toBeInTheDocument(); + }); + + it('calls onExpandedChange when token list is toggled', () => { + const onExpandedChange = vi.fn(); + render( + , + ); + + const button = screen.getByRole('button', { name: /view all tokens/i }); + fireEvent.click(button); + + expect(onExpandedChange).toHaveBeenCalledWith(true); + }); + }); + + describe('semantic tokens', () => { + it('uses semantic background token', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('bg-card'); + }); + + it('uses semantic foreground token', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('text-card-foreground'); + }); + + it('uses semantic border token', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('border-border'); + }); + }); + + describe('size variants', () => { + it('applies small padding for sm size', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('p-3'); + }); + + it('applies medium padding for md size (default)', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('p-4'); + }); + + it('applies large padding for lg size', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('p-6'); + }); + }); + + describe('custom className', () => { + it('applies additional className', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('custom-class'); + }); + }); + + describe('wallet address interactions', () => { + it('calls onCopyAddress when copy button is clicked', async () => { + // Mock clipboard API + const mockWriteText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { + ...navigator, + clipboard: { writeText: mockWriteText }, + }); + + const onCopyAddress = vi.fn(); + render( + , + ); + + const copyButton = screen.getByRole('button', { name: /copy/i }); + fireEvent.click(copyButton); + + // Wait for async clipboard operation + await vi.waitFor(() => { + expect(onCopyAddress).toHaveBeenCalledWith(testAddress); + }); + }); + }); + + describe('controlled expansion', () => { + it('respects controlled isExpanded prop', () => { + render(); + const button = screen.getByRole('button', { name: /view all tokens/i }); + expect(button).toHaveAttribute('aria-expanded', 'true'); + }); + + it('does not change expansion when controlled', () => { + const onExpandedChange = vi.fn(); + render( + , + ); + + const button = screen.getByRole('button', { name: /view all tokens/i }); + fireEvent.click(button); + + // Callback should be called, but controlled prop should still control the state + expect(onExpandedChange).toHaveBeenCalledWith(true); + }); + }); + + describe('edge cases', () => { + it('handles zero balance correctly', () => { + render(); + expect(screen.getByText('$0.00')).toBeInTheDocument(); + }); + + it('renders without walletAddress prop', () => { + render(); + // Should render without crashing, no address section + expect(screen.getByText('Total balance')).toBeInTheDocument(); + }); + + it('handles missing onRetry gracefully in error state', () => { + render(); + // Should render error but retry button may or may not be present + expect(screen.getByText('Error occurred')).toBeInTheDocument(); + }); + + it('handles missing onExpandedChange gracefully', () => { + render(); + const button = screen.getByRole('button', { name: /view all tokens/i }); + // Should not crash when clicking without handler + expect(() => fireEvent.click(button)).not.toThrow(); + }); + }); + + describe('accessibility', () => { + it('has accessible section with aria-label including wallet address', () => { + render(); + const section = screen.getByRole('region'); + expect(section).toHaveAttribute('aria-label', expect.stringContaining(testAddress)); + }); + + it('has accessible section without wallet address', () => { + render(); + const section = screen.getByRole('region'); + expect(section).toHaveAttribute('aria-label', 'Wallet balance'); + }); + + it('error state has alert role for screen readers', () => { + render(); + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + it('token list toggle has aria-expanded and aria-controls', () => { + render(); + const button = screen.getByRole('button', { name: /view all tokens/i }); + expect(button).toHaveAttribute('aria-expanded'); + expect(button).toHaveAttribute('aria-controls'); + }); + }); +}); diff --git a/packages/components/src/kit-components/ui/balance-card/BalanceCard.tsx b/packages/components/src/kit-components/ui/balance-card/BalanceCard.tsx new file mode 100644 index 0000000..5e0755f --- /dev/null +++ b/packages/components/src/kit-components/ui/balance-card/BalanceCard.tsx @@ -0,0 +1,119 @@ +import type React from 'react'; +import { cn } from '@/lib/utils'; +import { AddressDisplay } from '../address-display/AddressDisplay'; +import walletIcon from './assets/wallet-icon-dark.png'; +import { BalanceAmount } from './BalanceAmount'; +import { BalanceCardSkeleton } from './BalanceCardSkeleton'; +import { ErrorState } from './ErrorState'; +import { TokenList } from './TokenList'; +import type { BalanceCardProps } from './types'; + +const EMPTY_TOKENS: BalanceCardProps['tokens'] = []; + +/** + * A comprehensive balance card component for displaying wallet balances + * with support for token lists, loading states, and error handling. + * + * @example + * ```tsx + * // Basic usage + * + * + * // With token list + * + * ``` + */ +export const BalanceCard: React.FC = ({ + walletAddress, + totalBalance, + tokenDecimals = 9, + isFiatBalance = false, + tokenSymbol, + currency = 'USD', + displayDecimals = 2, + tokens = EMPTY_TOKENS, + isLoading = false, + error, + onRetry, + onCopyAddress, + defaultExpanded = false, + isExpanded: controlledExpanded, + onExpandedChange, + size = 'md', + className = '', + locale = 'en-US', +}) => { + // Show skeleton during loading + if (isLoading) { + return ; + } + + const paddingStyles = { + sm: 'p-3', + md: 'p-4', + lg: 'p-6', + }[size]; + + const errorMessage = error ? (typeof error === 'string' ? error : error.message || 'An error occurred') : null; + + return ( +
+ {/* Wallet address */} + {walletAddress && ( +
+ + onCopyAddress(walletAddress) : undefined} + showExplorerLink={false} + className="[&>span]:bg-transparent! [&>span]:p-0! [&>span]:rounded-none!" + /> +
+ )} + + {/* Balance label */} +
Total balance
+ + {/* Balance amount */} + + + {/* Error state */} + {errorMessage && } + + {/* Token list */} + +
+ ); +}; diff --git a/packages/components/src/kit-components/ui/balance-card/BalanceCardSkeleton.tsx b/packages/components/src/kit-components/ui/balance-card/BalanceCardSkeleton.tsx new file mode 100644 index 0000000..7ef8561 --- /dev/null +++ b/packages/components/src/kit-components/ui/balance-card/BalanceCardSkeleton.tsx @@ -0,0 +1,40 @@ +import type React from 'react'; +import { cn } from '@/lib/utils'; +import type { BalanceCardSkeletonProps } from './types'; + +/** + * Skeleton loading state for the BalanceCard component + */ +export const BalanceCardSkeleton: React.FC = ({ size = 'md', className = '' }) => { + const paddingStyles = { + sm: 'p-3', + md: 'p-4', + lg: 'p-6', + }[size]; + + return ( + + {/* Header skeleton - address area */} +
+
+
+
+
+ + {/* Label skeleton */} +
+ + {/* Balance skeleton */} +
+ + {/* View all tokens skeleton */} +
+
+
+
+ + ); +}; diff --git a/packages/components/src/kit-components/ui/balance-card/ErrorState.tsx b/packages/components/src/kit-components/ui/balance-card/ErrorState.tsx new file mode 100644 index 0000000..01b76ed --- /dev/null +++ b/packages/components/src/kit-components/ui/balance-card/ErrorState.tsx @@ -0,0 +1,25 @@ +import { TriangleAlert } from 'lucide-react'; +import type React from 'react'; +import { cn } from '@/lib/utils'; +import type { ErrorStateProps } from './types'; + +/** + * Error state component for displaying error messages with optional retry + */ +export const ErrorState: React.FC = ({ message, onRetry, className = '' }) => { + return ( +
+ + {message} + {onRetry && ( + + )} +
+ ); +}; diff --git a/packages/components/src/kit-components/ui/balance-card/TokenList.tsx b/packages/components/src/kit-components/ui/balance-card/TokenList.tsx new file mode 100644 index 0000000..66271bc --- /dev/null +++ b/packages/components/src/kit-components/ui/balance-card/TokenList.tsx @@ -0,0 +1,122 @@ +import { ChevronUp } from 'lucide-react'; +import type React from 'react'; +import { useId, useState } from 'react'; +import { cn } from '@/lib/utils'; +import type { TokenInfo, TokenListProps } from './types'; + +/** + * Formats a number as currency + */ +function formatCurrency(value: number | string, currency: string, locale: string): string { + const num = typeof value === 'string' ? Number.parseFloat(value) : value; + if (Number.isNaN(num)) return String(value); + + return new Intl.NumberFormat(locale, { + style: 'currency', + currency, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(num); +} + +/** + * Token row component for displaying individual token info + */ +const TokenRow: React.FC<{ + token: TokenInfo; + locale?: string; + currency?: string; +}> = ({ token, locale = 'en-US', currency = 'USD' }) => { + const displayBalance = token.fiatValue + ? formatCurrency(token.fiatValue, currency, locale) + : typeof token.balance === 'number' + ? token.balance.toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + : token.balance; + + return ( +
+ {token.symbol} + {displayBalance} +
+ ); +}; + +/** + * Expandable token list component showing all tokens in a wallet + */ +export const TokenList: React.FC = ({ + tokens, + isExpanded: controlledExpanded, + defaultExpanded = false, + onExpandedChange, + className = '', + locale = 'en-US', + currency = 'USD', +}) => { + const [internalExpanded, setInternalExpanded] = useState(defaultExpanded); + const contentId = useId(); + + const isControlled = controlledExpanded !== undefined; + const isExpanded = isControlled ? controlledExpanded : internalExpanded; + + const handleToggle = () => { + const newExpanded = !isExpanded; + if (!isControlled) { + setInternalExpanded(newExpanded); + } + onExpandedChange?.(newExpanded); + }; + + return ( +
+ {/* Toggle header */} + + + {/* Expandable content */} +
+ {/* Table header */} +
+ Token + Balance +
+ + {/* Token rows */} + {tokens.length === 0 ? ( +
No tokens yet
+ ) : ( +
+ {tokens.map((token) => ( + + ))} +
+ )} +
+
+ ); +}; diff --git a/packages/components/src/kit-components/ui/balance-card/assets/wallet-icon-dark.png b/packages/components/src/kit-components/ui/balance-card/assets/wallet-icon-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..0a58d321ee7abb2704c02ed6a5b3608d0d24de99 GIT binary patch literal 417 zcmV;S0bc%zP)Y`K@c2`R%Kc4AZ<|; z-|#|2Q-e2Wp$Xy9%#bDncw~X$i=M|8agYijGF2(G&Cc#ew03&L!!IU%k{x6qk*Fk$ z1oAwOF=>J9cs3|GwPQ$%^^-x1F@un^>azYp)lU|LaQn3mM6(dM1xMh032*^J}0ugF1nfFUVsX9Z(1U zAf+bsTJv5*a``9N(1`FnZzZMd>O;>1+_SNP&e9UNA57HE9bN(_yYZ8U>2oC400000 LNkvXXu0mjfBmk~I literal 0 HcmV?d00001 diff --git a/packages/components/src/kit-components/ui/balance-card/assets/wallet-icon-light.png b/packages/components/src/kit-components/ui/balance-card/assets/wallet-icon-light.png new file mode 100644 index 0000000000000000000000000000000000000000..3693e8363e8335db6f2970c3aa7364867cfd8b16 GIT binary patch literal 459 zcmV;+0W|)JP)s3XB$@^6Q6_2b0JN0=urRw002ovPDHLkV1g>$ Bv`hd1 literal 0 HcmV?d00001 diff --git a/packages/components/src/kit-components/ui/balance-card/index.ts b/packages/components/src/kit-components/ui/balance-card/index.ts new file mode 100644 index 0000000..a5898e6 --- /dev/null +++ b/packages/components/src/kit-components/ui/balance-card/index.ts @@ -0,0 +1,29 @@ +// Main component + +// Sub-components +export { BalanceAmount } from './BalanceAmount'; +export { BalanceCard } from './BalanceCard'; +export { BalanceCardSkeleton } from './BalanceCardSkeleton'; +export { ErrorState } from './ErrorState'; +export { TokenList } from './TokenList'; + +// Types +export type { + BalanceAmountProps, + BalanceCardProps, + BalanceCardSkeletonProps, + ErrorStateProps, + TokenInfo, + TokenListProps, +} from './types'; +export type { FormatBalanceOptions } from './utils'; + +// Utilities +export { + copyToClipboard, + formatBalance, + formatFiatValue, + formatPercentageChange, + stringToColor, + truncateAddress, +} from './utils'; diff --git a/packages/components/src/kit-components/ui/balance-card/types.ts b/packages/components/src/kit-components/ui/balance-card/types.ts new file mode 100644 index 0000000..1299819 --- /dev/null +++ b/packages/components/src/kit-components/ui/balance-card/types.ts @@ -0,0 +1,130 @@ +import type { Address, Lamports } from '@solana/kit'; +import type React from 'react'; + +/** + * Token information for display in the token list + */ +export interface TokenInfo { + /** Token symbol (e.g., "USDC", "SOL") */ + symbol: string; + /** Token name (e.g., "USD Coin", "Solana") */ + name?: string; + /** Token balance */ + balance: number | string; + /** Token icon URL or React node */ + icon?: string | React.ReactNode; + /** Fiat value of the token balance */ + fiatValue?: number | string; + /** Token mint address */ + mintAddress?: Address; +} + +/** + * Props for the main BalanceCard component + */ +export interface BalanceCardProps { + /** Wallet address to display */ + walletAddress?: Address; + /** Total balance in Lamports */ + totalBalance: Lamports; + /** Number of decimals for the token (default: 9 for SOL) */ + tokenDecimals?: number; + /** Whether the balance is displayed as fiat (with currency symbol) */ + isFiatBalance?: boolean; + /** Token symbol to display after balance (e.g. "SOL"). Only used when isFiatBalance is false. */ + tokenSymbol?: string; + /** Currency code for fiat display (default: "USD") */ + currency?: string; + /** Number of decimal places to display (default: 2) */ + displayDecimals?: number; + /** List of tokens to display in expandable section */ + tokens?: TokenInfo[]; + /** Whether the component is in loading state */ + isLoading?: boolean; + /** Error message or Error object */ + error?: string | Error; + /** Callback when retry is clicked in error state */ + onRetry?: () => void | Promise; + /** Callback when address copy button is clicked */ + onCopyAddress?: (address: Address) => void; + /** Whether the token list is initially expanded (default: false) */ + defaultExpanded?: boolean; + /** Controlled expanded state */ + isExpanded?: boolean; + /** Callback when expanded state changes */ + onExpandedChange?: (expanded: boolean) => void; + /** Size variant */ + size?: 'sm' | 'md' | 'lg'; + /** Additional CSS classes */ + className?: string; + /** Locale for number formatting (default: "en-US") */ + locale?: string; +} + +/** + * Props for the BalanceCardSkeleton component + */ +export interface BalanceCardSkeletonProps { + /** Size variant */ + size?: 'sm' | 'md' | 'lg'; + /** Additional CSS classes */ + className?: string; +} + +/** + * Props for the BalanceAmount component + */ +export interface BalanceAmountProps { + /** Balance value in base units (bigint) */ + balance: bigint; + /** Number of decimals for the token (e.g., 9 for SOL, 6 for USDC) */ + tokenDecimals?: number; + /** Whether to display as fiat with currency symbol */ + isFiat?: boolean; + /** Token symbol to display after balance (e.g. "SOL"). Only used when isFiat is false. */ + tokenSymbol?: string; + /** Currency code for fiat display */ + currency?: string; + /** Number of decimal places to display */ + displayDecimals?: number; + /** Locale for formatting */ + locale?: string; + /** Whether to show privacy mask */ + isPrivate?: boolean; + /** Size variant */ + size?: 'sm' | 'md' | 'lg'; + /** Additional CSS classes */ + className?: string; +} + +/** + * Props for the TokenList component + */ +export interface TokenListProps { + /** List of tokens to display */ + tokens: TokenInfo[]; + /** Whether the list is expanded (controlled) */ + isExpanded?: boolean; + /** Initial expanded state for uncontrolled mode (default: false) */ + defaultExpanded?: boolean; + /** Callback when expansion state changes */ + onExpandedChange?: (expanded: boolean) => void; + /** Additional CSS classes */ + className?: string; + /** Locale for number formatting */ + locale?: string; + /** Currency code for fiat display (default: "USD") */ + currency?: string; +} + +/** + * Props for the ErrorState component + */ +export interface ErrorStateProps { + /** Error message */ + message: string; + /** Callback when retry is clicked */ + onRetry?: () => void | Promise; + /** Additional CSS classes */ + className?: string; +} diff --git a/packages/components/src/kit-components/ui/balance-card/utils.ts b/packages/components/src/kit-components/ui/balance-card/utils.ts new file mode 100644 index 0000000..6092304 --- /dev/null +++ b/packages/components/src/kit-components/ui/balance-card/utils.ts @@ -0,0 +1,194 @@ +/** + * Utility functions for balance formatting and display + */ + +export interface FormatBalanceOptions { + /** Number of decimals in the token (e.g., 9 for SOL, 6 for USDC) */ + tokenDecimals?: number; + /** Number of decimal places to display */ + displayDecimals?: number; + locale?: string; + abbreviate?: boolean; + showLessThan?: boolean; +} + +/** + * Converts a bigint balance to a decimal number + * @param balance - The balance in base units (bigint) + * @param tokenDecimals - Number of decimals for the token + * @returns The balance as a number + */ +function bigintToNumber(balance: bigint, tokenDecimals: number): number { + const divisor = 10 ** tokenDecimals; + // Convert to number, handling precision for large values + return Number(balance) / divisor; +} + +/** + * Formats a bigint balance with proper locale formatting + * @param balance - The balance in base units (bigint) + * @param options - Formatting options + * @returns Formatted balance string + */ +export function formatBalance(balance: bigint | null | undefined, options: FormatBalanceOptions = {}): string { + const { + tokenDecimals = 9, + displayDecimals = 2, + locale = 'en-US', + abbreviate = false, + showLessThan = true, + } = options; + + if (balance === null || balance === undefined) { + return '—'; + } + + const num = bigintToNumber(balance, tokenDecimals); + + if (Number.isNaN(num)) { + return '—'; + } + + if (!Number.isFinite(num)) { + return '—'; + } + + // Handle very small numbers + if (num > 0 && num < 0.01 && showLessThan) { + return '< 0.01'; + } + + // Handle abbreviation for large numbers + if (abbreviate && Math.abs(num) >= 1_000_000_000) { + return `${(num / 1_000_000_000).toFixed(2)}B`; + } + if (abbreviate && Math.abs(num) >= 1_000_000) { + return `${(num / 1_000_000).toFixed(2)}M`; + } + if (abbreviate && Math.abs(num) >= 1_000) { + return `${(num / 1_000).toFixed(2)}K`; + } + + return new Intl.NumberFormat(locale, { + minimumFractionDigits: displayDecimals, + maximumFractionDigits: displayDecimals, + }).format(num); +} + +/** + * Formats a bigint value as currency + * @param value - The value in base units (bigint) + * @param currency - Currency code (default: USD) + * @param locale - Locale for formatting (default: en-US) + * @param tokenDecimals - Number of decimals for the token (default: 9 for SOL) + * @returns Formatted currency string + */ +export function formatFiatValue( + value: bigint | null | undefined, + currency = 'USD', + locale = 'en-US', + tokenDecimals = 9, +): string { + if (value === null || value === undefined) { + return ''; + } + + const num = bigintToNumber(value, tokenDecimals); + + if (Number.isNaN(num) || !Number.isFinite(num)) { + return ''; + } + + try { + return new Intl.NumberFormat(locale, { + style: 'currency', + currency, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(num); + } catch { + // Fallback to USD if invalid currency code is provided + return new Intl.NumberFormat(locale, { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(num); + } +} + +/** + * Truncates a wallet address for display + * @param address - Full wallet address + * @param startChars - Number of characters to show at start (default: 4) + * @param endChars - Number of characters to show at end (default: 4) + * @returns Truncated address string + */ +export function truncateAddress(address: string | null | undefined, startChars = 4, endChars = 4): string { + if (!address) { + return ''; + } + + if (address.length <= startChars + endChars + 3) { + return address; + } + + return `${address.slice(0, startChars)}...${address.slice(-endChars)}`; +} + +/** + * Generates a deterministic color based on a string (for fallback token icons) + * @param str - Input string (typically token symbol) + * @returns HSL color string + */ +export function stringToColor(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + const hue = Math.abs(hash % 360); + return `hsl(${hue}, 65%, 50%)`; +} + +/** + * Formats percentage change with sign + * @param change - Percentage change value + * @param decimals - Decimal places (default: 2) + * @returns Formatted percentage string with sign + */ +export function formatPercentageChange(change: number | null | undefined, decimals = 2): string { + if (change === null || change === undefined || Number.isNaN(change)) { + return '0.00%'; + } + + const sign = change > 0 ? '+' : ''; + return `${sign}${change.toFixed(decimals)}%`; +} + +/** + * Copies text to clipboard + * @param text - Text to copy + * @returns Promise that resolves when copy is complete + */ +export async function copyToClipboard(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Fallback for older browsers + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + document.body.appendChild(textArea); + textArea.select(); + try { + document.execCommand('copy'); + document.body.removeChild(textArea); + return true; + } catch { + document.body.removeChild(textArea); + return false; + } + } +} diff --git a/packages/components/src/kit-components/ui/connect-wallet-button/ButtonContent.tsx b/packages/components/src/kit-components/ui/connect-wallet-button/ButtonContent.tsx new file mode 100644 index 0000000..279b613 --- /dev/null +++ b/packages/components/src/kit-components/ui/connect-wallet-button/ButtonContent.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { cn } from '@/lib/utils'; +import type { ButtonContentProps } from './types'; + +/** + * ButtonContent - Text content wrapper for wallet button. + * Provides consistent typography styling. + * + * @example + * ```tsx + * Connect Wallet + * ``` + */ +export function ButtonContent({ children, className }: ButtonContentProps): React.ReactElement { + return {children}; +} diff --git a/packages/components/src/kit-components/ui/connect-wallet-button/ButtonIcon.tsx b/packages/components/src/kit-components/ui/connect-wallet-button/ButtonIcon.tsx new file mode 100644 index 0000000..fc9db0b --- /dev/null +++ b/packages/components/src/kit-components/ui/connect-wallet-button/ButtonIcon.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { Wallet } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { ButtonIconProps } from './types'; + +/** + * ButtonIcon - Displays the wallet icon in the button. + * Shows the connected wallet's logo/icon. + * Falls back to Wallet icon from lucide-react if no src provided. + * + * @example + * ```tsx + * + * ``` + */ +export function ButtonIcon({ src, alt = 'Wallet icon', size = 24, className }: ButtonIconProps): React.ReactElement { + // Fallback to Wallet icon from lucide-react if no src provided + if (!src) { + return ( + + ); + } + + return ( + {alt} + ); +} diff --git a/packages/components/src/kit-components/ui/connect-wallet-button/ButtonSpinner.tsx b/packages/components/src/kit-components/ui/connect-wallet-button/ButtonSpinner.tsx new file mode 100644 index 0000000..54d6749 --- /dev/null +++ b/packages/components/src/kit-components/ui/connect-wallet-button/ButtonSpinner.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { ButtonSpinnerProps } from './types'; + +/** + * ButtonSpinner - Animated loading spinner for wallet button. + * Shows during wallet connection attempts. + * Uses Loader2 from lucide-react for consistent iconography. + * + * @example + * ```tsx + * + * ``` + */ +export function ButtonSpinner({ size = 20, className }: ButtonSpinnerProps): React.ReactElement { + return