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 0000000..0a58d32 Binary files /dev/null and b/packages/components/src/kit-components/ui/balance-card/assets/wallet-icon-dark.png differ 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 0000000..3693e83 Binary files /dev/null and b/packages/components/src/kit-components/ui/balance-card/assets/wallet-icon-light.png differ 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